0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/lib/service/base/context.rb
Loïc Guitaut f92a035936
DEV: Fix isolation bugs in service framework's each step (#40311)
Three independent issues in `each`'s isolation, each surfacing as soon
as the step is used outside the simplest shapes:

1. Nested `each` blocks crashed inside `with_isolation`'s `ensure`. The
isolation kept its snapshot in a single slot that the inner call would
overwrite then null, so the outer's cleanup ran against `nil`. The
snapshot now lives on a stack, making isolation re-entrant.

2. ActiveRecord models in the context lost their primary key inside an
`each` block. The snapshot used `deep_dup`, which recurses into AR
objects via `dup` and AR's `dup` returns an unpersisted copy with `id ==
nil`. Basic shapes like `model :user; each :things do ... end` silently
swapped the real user for a useless ghost. The deep copy was originally
an attempt at mutation isolation for collections, but that contract was
never documented, its supporting spec was removed before the original PR
merged, and the rest of the framework already lets steps mutate what
they receive. A shallow dup matches the documented "variables set inside
the loop don't leak" guarantee and stops corrupting models.

3. Non-persisted state leaked between iterations. The whole loop ran
inside one isolation, so iteration N could read scratch values iteration
N-1 had left around. Each iteration now gets its own isolation. Steps
inside the same iteration still share state freely (step 2 can act on
what step 1 produced); only cross-iteration carry-over now requires
`persist:`, which makes that dependency visible at the `each`
declaration.
2026-05-27 09:47:34 +02:00

99 lines
2.4 KiB
Ruby
Vendored

# frozen_string_literal: true
module Service
module Base
# Simple structure to hold the context of the service during its whole lifecycle.
class Context
delegate :slice, :dig, :merge!, to: :store
def self.build(context = {})
self === context ? context : new(context)
end
def initialize(context = {})
@store = context.symbolize_keys
end
def [](key)
store[key.to_sym]
end
def []=(key, value)
store[key.to_sym] = value
end
def to_h
store.dup
end
def with_isolation(persist_keys: [])
isolated_stores << to_h
yield
ensure
isolated_stores.pop.then do |isolated_store|
store.merge!(isolated_store.slice(*persist_keys, *step_result_keys(isolated_store)))
end
end
# @return [Boolean] returns +true+ if the context is set as successful (default)
def success?
!failure?
end
# @return [Boolean] returns +true+ if the context is set as failed
# @see #fail!
# @see #fail
def failure?
@failure || false
end
# Marks the context as failed.
# @param context [Hash, Context] the context to merge into the current one
# @example
# context.fail!("failure": "something went wrong")
# @return [Context]
def fail!(context = {})
self.fail(context)
raise Failure, self
end
# Marks the context as failed without raising an exception.
# @param context [Hash, Context] the context to merge into the current one
# @example
# context.fail("failure": "something went wrong")
# @return [Context]
def fail(context = {})
store.merge!(context.symbolize_keys)
@failure = true
self
end
def inspect_steps
Service::StepsInspector.new(self).inspect
end
private
def store
isolated_stores.last || @store
end
def isolated_stores
@isolated_stores ||= []
end
def step_result_keys(source = store)
source.keys.select { it.start_with?("result.") }
end
def method_missing(method_name, *args, &block)
return super if args.present?
store[method_name]
end
def respond_to_missing?(name, include_all)
store.key?(name) || super
end
end
end
end