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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +179 -26
  5. data/lib/ruby_reactor/configuration.rb +66 -2
  6. data/lib/ruby_reactor/context_serializer.rb +9 -4
  7. data/lib/ruby_reactor/dsl/compose_builder.rb +20 -0
  8. data/lib/ruby_reactor/dsl/lockable.rb +41 -1
  9. data/lib/ruby_reactor/executor/ordered_lock_support.rb +307 -0
  10. data/lib/ruby_reactor/executor/retry_manager.rb +7 -2
  11. data/lib/ruby_reactor/executor/step_executor.rb +25 -5
  12. data/lib/ruby_reactor/executor.rb +166 -52
  13. data/lib/ruby_reactor/lock.rb +13 -0
  14. data/lib/ruby_reactor/map/collector.rb +41 -0
  15. data/lib/ruby_reactor/map/dispatcher.rb +42 -0
  16. data/lib/ruby_reactor/map/element_executor.rb +39 -0
  17. data/lib/ruby_reactor/map/helpers.rb +10 -3
  18. data/lib/ruby_reactor/map/sweeper.rb +110 -0
  19. data/lib/ruby_reactor/ordered_lock.rb +158 -0
  20. data/lib/ruby_reactor/reactor.rb +48 -5
  21. data/lib/ruby_reactor/rspec/helpers.rb +6 -0
  22. data/lib/ruby_reactor/rspec/matchers.rb +66 -0
  23. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +70 -0
  24. data/lib/ruby_reactor/rspec/storage_reset.rb +23 -0
  25. data/lib/ruby_reactor/rspec/test_subject.rb +14 -28
  26. data/lib/ruby_reactor/rspec.rb +37 -0
  27. data/lib/ruby_reactor/sidekiq_adapter.rb +9 -8
  28. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +73 -0
  29. data/lib/ruby_reactor/sidekiq_workers/worker.rb +82 -36
  30. data/lib/ruby_reactor/step/map_step.rb +18 -2
  31. data/lib/ruby_reactor/storage/redis_adapter.rb +84 -60
  32. data/lib/ruby_reactor/storage/redis_locking.rb +8 -0
  33. data/lib/ruby_reactor/storage/redis_ordered_locking.rb +382 -0
  34. data/lib/ruby_reactor/sweeper.rb +58 -0
  35. data/lib/ruby_reactor/version.rb +1 -1
  36. data/lib/ruby_reactor.rb +43 -0
  37. 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.name
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
- configuration.async_router.perform_in(delay, serialized_context, reactor_class_name)
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
- # If result is nil, it means async was executed inline (test mode), continue
49
- next if result.nil?
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.name
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
- serialized_context = ContextSerializer.serialize(context_to_serialize)
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
- serialized_context,
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)