mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
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.
99 lines
2.4 KiB
Ruby
Vendored
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
|