ruby_reactor 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +179 -26
- data/lib/ruby_reactor/configuration.rb +66 -2
- data/lib/ruby_reactor/context_serializer.rb +9 -4
- data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
- data/lib/ruby_reactor/dsl/lockable.rb +41 -1
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +7 -2
- data/lib/ruby_reactor/executor/step_executor.rb +25 -5
- data/lib/ruby_reactor/executor.rb +166 -52
- data/lib/ruby_reactor/lock.rb +13 -0
- data/lib/ruby_reactor/map/collector.rb +41 -0
- data/lib/ruby_reactor/map/dispatcher.rb +42 -0
- data/lib/ruby_reactor/map/element_executor.rb +39 -0
- data/lib/ruby_reactor/map/helpers.rb +10 -3
- data/lib/ruby_reactor/map/sweeper.rb +110 -0
- data/lib/ruby_reactor/ordered_lock.rb +158 -0
- data/lib/ruby_reactor/reactor.rb +48 -5
- data/lib/ruby_reactor/rspec/helpers.rb +6 -0
- data/lib/ruby_reactor/rspec/matchers.rb +66 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
- data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
- data/lib/ruby_reactor/rspec.rb +37 -0
- data/lib/ruby_reactor/sidekiq_adapter.rb +9 -8
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +73 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +82 -36
- data/lib/ruby_reactor/step/map_step.rb +18 -2
- data/lib/ruby_reactor/storage/redis_adapter.rb +84 -60
- data/lib/ruby_reactor/storage/redis_locking.rb +8 -0
- data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
- data/lib/ruby_reactor/sweeper.rb +58 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +43 -0
- metadata +9 -1
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Executor
|
|
5
|
+
# Gate check and terminal-advance logic for `with_ordered_lock`. Mixed
|
|
6
|
+
# into Executor to keep that class under the length limit. All methods
|
|
7
|
+
# read from `@context.private_data[:ordered_lock]`, which Reactor#run
|
|
8
|
+
# populates at enqueue time.
|
|
9
|
+
module OrderedLockSupport
|
|
10
|
+
# Thread-local stack of ordered-lock keys whose steps are currently
|
|
11
|
+
# running in this thread. Used to detect a synchronous `Reactor.run`
|
|
12
|
+
# nested under another ordered-lock reactor on the same key — which
|
|
13
|
+
# would deadlock since the outer holds the slot and the inner can never
|
|
14
|
+
# advance.
|
|
15
|
+
THREAD_LOCAL_ACTIVE_KEYS = :ruby_reactor_active_ordered_locks
|
|
16
|
+
|
|
17
|
+
# Minimum interval between liveness heartbeats; protects very small
|
|
18
|
+
# poison_pill_timeouts (mirrors Lock::MIN_EXTEND_INTERVAL).
|
|
19
|
+
HEARTBEAT_MIN_INTERVAL = 1.0
|
|
20
|
+
|
|
21
|
+
def self.active_keys
|
|
22
|
+
Thread.current[THREAD_LOCAL_ACTIVE_KEYS] ||= []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse the ordered-lock stash from a context's private_data, surviving
|
|
26
|
+
# the JSON round-trip (symbol or string keys). Module-level so the
|
|
27
|
+
# Sidekiq worker can advance the nonce on escalation paths that never
|
|
28
|
+
# construct an Executor.
|
|
29
|
+
def self.info_from(context)
|
|
30
|
+
data = context.private_data[:ordered_lock] || context.private_data["ordered_lock"]
|
|
31
|
+
return nil unless data
|
|
32
|
+
|
|
33
|
+
strict_raw = data[:strict]
|
|
34
|
+
strict_raw = data["strict"] if strict_raw.nil?
|
|
35
|
+
strict_raw = true if strict_raw.nil?
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
key: data[:key] || data["key"],
|
|
39
|
+
nonce: (data[:nonce] || data["nonce"]).to_i,
|
|
40
|
+
epoch: (data[:epoch] || data["epoch"]).to_i,
|
|
41
|
+
poison_pill_timeout: (data[:poison_pill_timeout] || data["poison_pill_timeout"] ||
|
|
42
|
+
OrderedLock::DEFAULT_POISON_PILL_TIMEOUT).to_i,
|
|
43
|
+
ttl: (data[:ttl] || data["ttl"] || OrderedLock::DEFAULT_TTL).to_i,
|
|
44
|
+
strict: [true, "true"].include?(strict_raw)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Strict-ordering gate. Runs BEFORE rate-limit / lock / semaphore so a
|
|
49
|
+
# waiting nonce never holds any other primitive — preventing
|
|
50
|
+
# hold-and-wait deadlocks when `with_lock` and `with_ordered_lock`
|
|
51
|
+
# share inputs. Raises {OrderedLock::WaitError}; the Sidekiq worker
|
|
52
|
+
# rescues and snoozes.
|
|
53
|
+
def check_ordered_lock_gate
|
|
54
|
+
info = ordered_lock_info
|
|
55
|
+
return :go unless info
|
|
56
|
+
|
|
57
|
+
OrderedLock.new(
|
|
58
|
+
info.fetch(:key),
|
|
59
|
+
nonce: info.fetch(:nonce),
|
|
60
|
+
epoch: info.fetch(:epoch),
|
|
61
|
+
poison_pill_timeout: info.fetch(:poison_pill_timeout),
|
|
62
|
+
strict: info.fetch(:strict)
|
|
63
|
+
).check!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Combined gate-check + thread-local push. Call at the top of
|
|
67
|
+
# `execute` / `resume_execution`. Pair with `leave_ordered_lock_scope`
|
|
68
|
+
# in `ensure`.
|
|
69
|
+
#
|
|
70
|
+
# The strict-mode chain-skip only fires on a *fresh* start (no step
|
|
71
|
+
# has run yet on this context). This lets an in-flight run that paused
|
|
72
|
+
# (Interrupt / AsyncResult) complete on resume regardless of chain
|
|
73
|
+
# failures that landed while it was parked, while still applying
|
|
74
|
+
# strict to a fresh Sidekiq job (which enters via `resume_execution`
|
|
75
|
+
# but has no prior step state).
|
|
76
|
+
def enter_ordered_lock_scope
|
|
77
|
+
gate = check_ordered_lock_gate
|
|
78
|
+
# A stale-batch run never participates regardless of fresh/resume state —
|
|
79
|
+
# its numbering belongs to a drained generation. Chain-skip stays gated
|
|
80
|
+
# on a fresh start so an in-flight paused run still completes on resume.
|
|
81
|
+
@ordered_lock_stale_batch = gate == :stale_batch
|
|
82
|
+
@ordered_lock_chain_skip = fresh_ordered_lock_start? && gate == :skip_chain_failed
|
|
83
|
+
|
|
84
|
+
# Drained-batch gate: the batch GC'd while this caller slept. A genuine
|
|
85
|
+
# late straggler runs (poison semantics); a Sidekiq redelivery of an
|
|
86
|
+
# ALREADY-terminal context must not re-execute its steps. Only the
|
|
87
|
+
# latter — confirmed by a terminal stored status — is short-circuited.
|
|
88
|
+
@ordered_lock_drained_replay = gate == :drained_go && stored_status_terminal?
|
|
89
|
+
|
|
90
|
+
info = ordered_lock_info
|
|
91
|
+
return unless info
|
|
92
|
+
|
|
93
|
+
OrderedLockSupport.active_keys << info[:key]
|
|
94
|
+
|
|
95
|
+
# Only a run that will actually execute steps needs a heartbeat. A
|
|
96
|
+
# short-circuiting run (stale batch / strict chain skip / drained
|
|
97
|
+
# redelivery) does no work and terminally advances immediately, so
|
|
98
|
+
# starting a thread for it is pointless churn.
|
|
99
|
+
return if @ordered_lock_stale_batch || @ordered_lock_chain_skip || @ordered_lock_drained_replay
|
|
100
|
+
|
|
101
|
+
start_ordered_lock_heartbeat(info)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fresh_ordered_lock_start?
|
|
105
|
+
@context.intermediate_results.empty? && @context.current_step.nil?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ordered_lock_chain_skip?
|
|
109
|
+
@ordered_lock_chain_skip == true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ordered_lock_stale_batch?
|
|
113
|
+
@ordered_lock_stale_batch == true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ordered_lock_drained_replay?
|
|
117
|
+
@ordered_lock_drained_replay == true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Terminal Skipped result when the ordered-lock gate short-circuits this
|
|
121
|
+
# run (stale batch, strict chain failure, or a drained-batch redelivery of
|
|
122
|
+
# an already-terminal context), or nil to continue. Shared by `execute`
|
|
123
|
+
# and `resume_execution`.
|
|
124
|
+
def ordered_lock_short_circuit
|
|
125
|
+
return RubyReactor::Skipped.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
|
|
126
|
+
return RubyReactor::Skipped.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
|
|
127
|
+
return RubyReactor::Skipped.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
|
|
128
|
+
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Pre-step short-circuit: ordered-lock gate skip or already-marked
|
|
133
|
+
# period bucket. Returns a terminal result or nil.
|
|
134
|
+
def short_circuit_result
|
|
135
|
+
ordered_lock_short_circuit || check_period_gate
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def short_circuit!(result)
|
|
139
|
+
@result = result
|
|
140
|
+
|
|
141
|
+
# A stale-batch or drained-batch-redelivery skip means this run's epoch
|
|
142
|
+
# belongs to a drained generation — typically a slow straggler or a
|
|
143
|
+
# Sidekiq at-least-once redelivery. If the redelivery is of a job that
|
|
144
|
+
# ALREADY reached a terminal status, its stored context is the source of
|
|
145
|
+
# truth; writing :skipped over a :completed/:failed record would silently
|
|
146
|
+
# corrupt the outcome. Return the skip to the worker (so it stops)
|
|
147
|
+
# without saving. The `@skip_context_persist` flag also suppresses the
|
|
148
|
+
# ensure-block save in execute / resume_execution, which would otherwise
|
|
149
|
+
# clobber the stored terminal record with this run's stale in-memory
|
|
150
|
+
# status.
|
|
151
|
+
if redelivery_of_terminal?(result)
|
|
152
|
+
@skip_context_persist = true
|
|
153
|
+
return @result
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
update_context_status(@result)
|
|
157
|
+
save_context
|
|
158
|
+
@result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def skip_context_persist?
|
|
162
|
+
@skip_context_persist == true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# True when the skip is one of the drained-generation reasons (stale batch
|
|
166
|
+
# or drained-batch replay) AND the stored context already reached a
|
|
167
|
+
# terminal status — i.e. this is a redelivery of an already-finished run
|
|
168
|
+
# whose record must not be overwritten. The drained-replay flag is only
|
|
169
|
+
# set when the status was terminal, but re-checking keeps both paths
|
|
170
|
+
# uniform and self-guarding.
|
|
171
|
+
def redelivery_of_terminal?(result)
|
|
172
|
+
return false unless result.is_a?(RubyReactor::Skipped)
|
|
173
|
+
return false unless %i[ordered_lock_stale_batch ordered_lock_drained_replay].include?(result.reason)
|
|
174
|
+
|
|
175
|
+
stored_status_terminal?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def stored_status_terminal?
|
|
179
|
+
%w[completed failed skipped].include?(stored_context_status)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def stored_context_status
|
|
183
|
+
reactor_class_name = RubyReactor.reactor_storage_name(@reactor_class)
|
|
184
|
+
data = RubyReactor.configuration.storage_adapter.retrieve_context(@context.context_id, reactor_class_name)
|
|
185
|
+
return nil unless data
|
|
186
|
+
|
|
187
|
+
(data["status"] || data[:status]).to_s
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Combined terminal-advance + thread-local pop. Idempotent: safe to call
|
|
193
|
+
# in `ensure` even if `enter_ordered_lock_scope` never pushed (gate
|
|
194
|
+
# raised, or no ordered_lock configured).
|
|
195
|
+
def leave_ordered_lock_scope
|
|
196
|
+
# Stop (and join) the heartbeat BEFORE advancing: the advance HDELs this
|
|
197
|
+
# nonce's assigned_at, and a heartbeat racing that HDEL could restamp it.
|
|
198
|
+
# The HEARTBEAT_SCRIPT's hexists guard makes a late restamp a harmless
|
|
199
|
+
# no-op, but joining first keeps the ordering deterministic.
|
|
200
|
+
stop_ordered_lock_heartbeat
|
|
201
|
+
advance_ordered_lock_if_terminal
|
|
202
|
+
info = ordered_lock_info
|
|
203
|
+
return unless info
|
|
204
|
+
|
|
205
|
+
stack = OrderedLockSupport.active_keys
|
|
206
|
+
idx = stack.rindex(info[:key])
|
|
207
|
+
stack.delete_at(idx) if idx
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Background thread that restamps this nonce's assigned_at every pp/3
|
|
211
|
+
# seconds (floored at HEARTBEAT_MIN_INTERVAL) while its steps run, so a
|
|
212
|
+
# legitimately slow blocker is not poison-passed by a successor. Mirrors
|
|
213
|
+
# the Lock auto-extend thread. The thread sleeps FIRST, so a job that
|
|
214
|
+
# finishes faster than one interval never touches Redis.
|
|
215
|
+
def start_ordered_lock_heartbeat(info)
|
|
216
|
+
return if @ordered_lock_heartbeat_running
|
|
217
|
+
|
|
218
|
+
pp = info[:poison_pill_timeout].to_f
|
|
219
|
+
interval = [pp / 3.0, HEARTBEAT_MIN_INTERVAL].max
|
|
220
|
+
@ordered_lock_heartbeat_running = true
|
|
221
|
+
lock = OrderedLock.new(
|
|
222
|
+
info.fetch(:key), nonce: info.fetch(:nonce), epoch: info.fetch(:epoch)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@ordered_lock_heartbeat = Thread.new do
|
|
226
|
+
while @ordered_lock_heartbeat_running
|
|
227
|
+
sleep interval
|
|
228
|
+
break unless @ordered_lock_heartbeat_running
|
|
229
|
+
|
|
230
|
+
begin
|
|
231
|
+
lock.heartbeat!
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
RubyReactor.configuration.logger.warn(
|
|
234
|
+
"RubyReactor ordered_lock heartbeat failed for '#{info[:key]}' " \
|
|
235
|
+
"nonce #{info[:nonce]}: #{e.message}"
|
|
236
|
+
)
|
|
237
|
+
break
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def stop_ordered_lock_heartbeat
|
|
244
|
+
return unless @ordered_lock_heartbeat_running
|
|
245
|
+
|
|
246
|
+
@ordered_lock_heartbeat_running = false
|
|
247
|
+
thread = @ordered_lock_heartbeat
|
|
248
|
+
@ordered_lock_heartbeat = nil
|
|
249
|
+
return unless thread
|
|
250
|
+
|
|
251
|
+
thread.wakeup if thread.alive?
|
|
252
|
+
thread.join(0.1)
|
|
253
|
+
rescue StandardError
|
|
254
|
+
# Best-effort shutdown; never let heartbeat teardown break the ensure chain.
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Advance the cursor when this run reached a *terminal* status.
|
|
258
|
+
# Retry-queued, interrupted, or async-handed-off results keep the same
|
|
259
|
+
# nonce owning the slot — a Sidekiq retry must not double-advance. A
|
|
260
|
+
# terminal `Failure` is also recorded as the chain's poison marker
|
|
261
|
+
# (only the FIRST such failure sticks).
|
|
262
|
+
def advance_ordered_lock_if_terminal
|
|
263
|
+
info = ordered_lock_info
|
|
264
|
+
return unless info
|
|
265
|
+
return unless terminal_for_ordered_lock?(@result)
|
|
266
|
+
|
|
267
|
+
OrderedLockSupport.advance_with_retry(info, failed: @result.is_a?(RubyReactor::Failure))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A missed advance on a terminal result stalls every successor for up to
|
|
271
|
+
# poison_pill_timeout with only a warn line as evidence, so one transient
|
|
272
|
+
# Redis blip is worth absorbing here before giving up.
|
|
273
|
+
def self.advance_with_retry(info, failed:)
|
|
274
|
+
attempts = 0
|
|
275
|
+
begin
|
|
276
|
+
attempts += 1
|
|
277
|
+
OrderedLock.new(
|
|
278
|
+
info.fetch(:key), nonce: info.fetch(:nonce), epoch: info.fetch(:epoch), ttl: info.fetch(:ttl)
|
|
279
|
+
).advance!(failed: failed)
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
retry if attempts < 2
|
|
282
|
+
|
|
283
|
+
RubyReactor.configuration.logger.warn(
|
|
284
|
+
"RubyReactor failed to advance ordered_lock '#{info[:key]}' nonce #{info[:nonce]} " \
|
|
285
|
+
"after #{attempts} attempts: #{e.message} — successors will stall until " \
|
|
286
|
+
"poison_pill_timeout (#{info[:poison_pill_timeout]}s) expires"
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private
|
|
292
|
+
|
|
293
|
+
def ordered_lock_info
|
|
294
|
+
OrderedLockSupport.info_from(@context)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def terminal_for_ordered_lock?(result)
|
|
298
|
+
case result
|
|
299
|
+
when RubyReactor::AsyncResult, RubyReactor::InterruptResult, RetryQueuedResult
|
|
300
|
+
false
|
|
301
|
+
when RubyReactor::Success, RubyReactor::Failure
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -48,7 +48,7 @@ module RubyReactor
|
|
|
48
48
|
@context.root_context || @context
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
reactor_class_name = context_to_serialize.reactor_class
|
|
51
|
+
reactor_class_name = RubyReactor.reactor_storage_name(context_to_serialize.reactor_class)
|
|
52
52
|
|
|
53
53
|
@middlewares.on(:before_async_enqueue, context_to_serialize)
|
|
54
54
|
|
|
@@ -72,7 +72,12 @@ module RubyReactor
|
|
|
72
72
|
fail_fast: map_args[:fail_fast]
|
|
73
73
|
)
|
|
74
74
|
else
|
|
75
|
-
|
|
75
|
+
# Persist BEFORE enqueue — the job payload is identity-only (F2). The
|
|
76
|
+
# rescheduled job rehydrates the root by id from storage.
|
|
77
|
+
configuration.storage_adapter.store_context(
|
|
78
|
+
context_to_serialize.context_id, serialized_context, reactor_class_name
|
|
79
|
+
)
|
|
80
|
+
configuration.async_router.perform_in(delay, context_to_serialize.context_id, reactor_class_name)
|
|
76
81
|
end
|
|
77
82
|
end
|
|
78
83
|
|
|
@@ -11,6 +11,7 @@ module RubyReactor
|
|
|
11
11
|
@result_handler = managers[:result_handler]
|
|
12
12
|
@compensation_manager = managers[:compensation_manager]
|
|
13
13
|
@middlewares = managers[:middlewares] || context.middlewares || Executor.middlewares_for(reactor_class)
|
|
14
|
+
@on_step_complete = managers[:on_step_complete]
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def execute_all_steps
|
|
@@ -45,8 +46,14 @@ module RubyReactor
|
|
|
45
46
|
# If a step returns InterruptResult, we need to stop execution and return it
|
|
46
47
|
return result if result.is_a?(RubyReactor::InterruptResult)
|
|
47
48
|
|
|
48
|
-
#
|
|
49
|
-
|
|
49
|
+
# Only a continue-Success reaches here (Async/Retry/Skipped/Failure/
|
|
50
|
+
# Interrupt all returned above; nil is inline-async test mode). It is
|
|
51
|
+
# the one outcome where the loop proceeds to more steps with no other
|
|
52
|
+
# save in between — every terminal/handoff result persists via its own
|
|
53
|
+
# path. Write a durable checkpoint so a crash re-runs at most this one
|
|
54
|
+
# step. Ordering: side-effect -> record result (inside execute_step) ->
|
|
55
|
+
# checkpoint here.
|
|
56
|
+
@on_step_complete&.call if result.is_a?(RubyReactor::Success)
|
|
50
57
|
end
|
|
51
58
|
end
|
|
52
59
|
|
|
@@ -198,20 +205,33 @@ module RubyReactor
|
|
|
198
205
|
|
|
199
206
|
# Use root context if available to ensure we serialize the full tree
|
|
200
207
|
context_to_serialize = @context.root_context || @context
|
|
201
|
-
reactor_class_name = context_to_serialize.reactor_class
|
|
208
|
+
reactor_class_name = RubyReactor.reactor_storage_name(context_to_serialize.reactor_class)
|
|
202
209
|
|
|
203
210
|
# Inject OTel context before serialization
|
|
204
211
|
@middlewares.on(:before_async_enqueue, context_to_serialize)
|
|
205
212
|
|
|
206
|
-
|
|
213
|
+
# Storage is load-bearing: the job payload is identity-only, so the root
|
|
214
|
+
# context MUST be persisted BEFORE the job is enqueued (F2). The reactor
|
|
215
|
+
# class name used for the storage key must match the one handed to the
|
|
216
|
+
# worker, so compute it once and reuse it for both.
|
|
217
|
+
checkpoint_root!(context_to_serialize, reactor_class_name)
|
|
207
218
|
|
|
208
219
|
configuration.async_router.perform_async(
|
|
209
|
-
|
|
220
|
+
context_to_serialize.context_id,
|
|
210
221
|
reactor_class_name,
|
|
211
222
|
intermediate_results: @context.intermediate_results
|
|
212
223
|
)
|
|
213
224
|
end
|
|
214
225
|
|
|
226
|
+
# Persist the root context under its storage key. Mirrors Executor#checkpoint!
|
|
227
|
+
# but lives here because handle_async_step runs inside the StepExecutor and
|
|
228
|
+
# must serialize AFTER the before_async_enqueue middleware has injected its
|
|
229
|
+
# OTel context.
|
|
230
|
+
def checkpoint_root!(root, reactor_class_name)
|
|
231
|
+
storage = RubyReactor::Configuration.instance.storage_adapter
|
|
232
|
+
storage.store_context(root.context_id, ContextSerializer.serialize(root), reactor_class_name)
|
|
233
|
+
end
|
|
234
|
+
|
|
215
235
|
def handle_interrupt_step(step_config)
|
|
216
236
|
# Check if we have a result for this step (resuming)
|
|
217
237
|
if @context.intermediate_results.key?(step_config.name)
|