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
@@ -7,10 +7,13 @@ require_relative "executor/retry_manager"
7
7
  require_relative "executor/compensation_manager"
8
8
  require_relative "executor/result_handler"
9
9
  require_relative "executor/step_executor"
10
+ require_relative "executor/ordered_lock_support"
10
11
 
11
12
  module RubyReactor
12
13
  # rubocop:disable Metrics/ClassLength
13
14
  class Executor
15
+ include OrderedLockSupport
16
+
14
17
  attr_reader :reactor_class, :context, :dependency_graph, :compensation_manager, :retry_manager, :result_handler,
15
18
  :step_executor, :result, :middlewares
16
19
 
@@ -35,12 +38,24 @@ module RubyReactor
35
38
  retry_manager: @retry_manager,
36
39
  result_handler: @result_handler,
37
40
  compensation_manager: @compensation_manager,
38
- middlewares: @middlewares
41
+ middlewares: @middlewares,
42
+ # Save-per-step durable checkpoint. checkpoint! resolves the ROOT
43
+ # context, so this same callback — wired into every executor including
44
+ # the nested ones ComposeStep builds — always advances the root blob
45
+ # (F8): a mid-child crash re-runs one sub-step, not the whole child.
46
+ # `throttle: true` lets checkpoint_min_interval coalesce these mid-run
47
+ # writes (default 0 = write every step); the terminal save still runs.
48
+ on_step_complete: -> { checkpoint!(throttle: true) }
39
49
  }
40
50
  )
41
51
  @result = nil
42
52
  @acquired_lock = nil
43
53
  @acquired_semaphore = nil
54
+ @acquired_context_lock = nil
55
+ @context_lock_owner = nil
56
+ @contention_snooze = false
57
+ @skip_context_persist = false
58
+ @last_checkpoint_at = nil
44
59
  end
45
60
 
46
61
  def self.resolve_middlewares(reactor_class)
@@ -71,9 +86,13 @@ module RubyReactor
71
86
  middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
72
87
  completed = false
73
88
 
74
- if (skipped = check_period_gate)
89
+ enter_ordered_lock_scope
90
+ # short_circuit_result covers both the strict ordered-lock chain skip
91
+ # and the already-marked period bucket.
92
+ short = short_circuit_result
93
+ if short
75
94
  completed = true
76
- return finalize_skipped(skipped)
95
+ return short_circuit!(short)
77
96
  end
78
97
 
79
98
  # Validate inputs BEFORE consuming a rate-limit slot or grabbing a
@@ -109,7 +128,9 @@ module RubyReactor
109
128
  rescue RubyReactor::Lock::AcquisitionError,
110
129
  RubyReactor::Semaphore::AcquisitionError,
111
130
  RubyReactor::RateLimit::ExceededError,
112
- RubyReactor::RateLimitRegistry::UnknownLimitError => e
131
+ RubyReactor::RateLimitRegistry::UnknownLimitError,
132
+ RubyReactor::OrderedLock::WaitError => e
133
+ @contention_snooze = true
113
134
  raise e
114
135
  rescue StandardError => e
115
136
  @result = @result_handler.handle_execution_error(e)
@@ -118,77 +139,107 @@ module RubyReactor
118
139
  @result
119
140
  ensure
120
141
  release_locks
121
- save_context if persist_context?
142
+ leave_ordered_lock_scope
143
+ save_context if persist_context? && !skip_context_persist?
122
144
 
145
+ emit_lifecycle_completion(completed)
146
+ end
147
+
148
+ # Contention errors (lock/semaphore/rate-limit/ordered-lock wait) are
149
+ # expected "try again later" signals, not failures — the worker snoozes
150
+ # and re-runs. Emitting `failed_reactor` for them floods dashboards with
151
+ # phantom failures (one per snooze round), so route them to a distinct
152
+ # `snooze_reactor` event instead.
153
+ def emit_lifecycle_completion(completed)
123
154
  if completed
124
155
  middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
156
+ elsif @contention_snooze
157
+ middlewares.on(:snooze_reactor, reactor_class.name, $ERROR_INFO, @context)
125
158
  else
126
159
  middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
127
160
  end
128
161
  end
129
162
 
130
- def resume_execution # rubocop:disable Metrics/MethodLength
163
+ def resume_execution # rubocop:disable Metrics/MethodLength,Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
131
164
  middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
132
165
  completed = false
166
+
133
167
  # A fresh async reactor run reaches the worker through resume_execution
134
168
  # (it never calls execute), so the period and rate-limit gates that live
135
169
  # in execute must be applied here too. Genuine resumes (a step already ran
136
170
  # or we paused mid-flight, so current_step is set) must NOT re-gate: a
137
171
  # paused reactor must not throttle or skip itself on the way back in.
138
172
  first_run = first_execution?
139
- begin
140
- @context.status = :running
141
173
 
142
- if first_run && (skipped = check_period_gate)
143
- completed = true
144
- return finalize_skipped(skipped)
145
- end
146
- check_rate_limit if first_run
174
+ enter_ordered_lock_scope
175
+ # ordered-lock skip applies on any run; the period gate only on a fresh
176
+ # first run (a genuine resume must not skip itself when its own marker
177
+ # eventually lands).
178
+ short = ordered_lock_short_circuit
179
+ short ||= check_period_gate if first_run
180
+ if short
181
+ completed = true
182
+ return short_circuit!(short)
183
+ end
147
184
 
148
- acquire_concurrency_primitives
185
+ @context.status = :running
186
+ check_rate_limit if first_run
187
+
188
+ # Per-context liveness lock: serializes duplicate deliveries of the same
189
+ # root context (e.g. a sweeper re-enqueue racing a still-live worker) and
190
+ # doubles as the sweeper's "worker alive" signal. Only the ROOT executor
191
+ # holds it — composed/nested children resume inline under the root worker
192
+ # and must not contend on the root's own key.
193
+ acquire_context_lock
194
+
195
+ # Resumes intentionally skip check_rate_limit (a paused run must not
196
+ # block itself on resume), so acquire lock/semaphore directly rather
197
+ # than via acquire_locks.
198
+ acquire_exclusive_lock if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
199
+ acquire_semaphore if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
149
200
 
150
- # Post-lock re-check (see execute) — closes the period race for the
151
- # first run of a locked async reactor.
152
- if first_run && (skipped = check_period_gate)
153
- completed = true
154
- return finalize_skipped(skipped)
155
- end
201
+ # Post-lock re-check (see execute) — closes the period race for the
202
+ # first run of a locked async reactor.
203
+ if first_run && (skipped = check_period_gate)
204
+ completed = true
205
+ return finalize_skipped(skipped)
206
+ end
156
207
 
157
- prepare_for_resume
158
- save_context
208
+ prepare_for_resume
209
+ save_context
159
210
 
160
- @result = if @context.current_step
161
- execute_current_step_and_continue
162
- else
163
- execute_remaining_steps
164
- end
211
+ @result = if @context.current_step
212
+ execute_current_step_and_continue
213
+ else
214
+ execute_remaining_steps
215
+ end
165
216
 
166
- update_context_status(@result)
167
- mark_period_on_success(@result)
217
+ update_context_status(@result)
218
+ mark_period_on_success(@result)
168
219
 
169
- handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
170
- completed = true
171
- @result
172
- rescue RubyReactor::Lock::AcquisitionError,
173
- RubyReactor::Semaphore::AcquisitionError,
174
- RubyReactor::RateLimit::ExceededError,
175
- RubyReactor::RateLimitRegistry::UnknownLimitError
176
- raise
177
- rescue StandardError => e
178
- handle_resume_error(e)
179
- update_context_status(@result)
180
- completed = true
181
- @result
182
- ensure
183
- release_locks
184
- save_context
220
+ handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
221
+ completed = true
222
+ @result
223
+ rescue RubyReactor::Lock::AcquisitionError,
224
+ RubyReactor::Semaphore::AcquisitionError,
225
+ RubyReactor::RateLimit::ExceededError,
226
+ RubyReactor::RateLimitRegistry::UnknownLimitError,
227
+ RubyReactor::OrderedLock::WaitError => e
228
+ @contention_snooze = true
229
+ raise e
230
+ rescue StandardError => e
231
+ handle_resume_error(e)
232
+ update_context_status(@result)
233
+ completed = true
234
+ @result
235
+ ensure
236
+ release_locks
237
+ @acquired_context_lock&.release
238
+ @acquired_context_lock = nil
239
+ leave_ordered_lock_scope
240
+ save_context unless skip_context_persist?
185
241
 
186
- if completed
187
- middlewares.on(:complete_reactor, reactor_class.name, @result, @context)
188
- else
189
- middlewares.on(:failed_reactor, reactor_class.name, $ERROR_INFO, @context)
190
- end
191
- end
242
+ emit_lifecycle_completion(completed)
192
243
  end
193
244
 
194
245
  def undo_all
@@ -209,13 +260,40 @@ module RubyReactor
209
260
 
210
261
  def save_context
211
262
  storage = RubyReactor::Configuration.instance.storage_adapter
212
- reactor_class_name = @reactor_class.name || "AnonymousReactor-#{@reactor_class.object_id}"
263
+ reactor_class_name = RubyReactor.reactor_storage_name(@reactor_class)
213
264
 
214
265
  # Serialize context
215
266
  serialized_context = ContextSerializer.serialize(@context)
216
267
  storage.store_context(@context.context_id, serialized_context, reactor_class_name)
217
268
  end
218
269
 
270
+ # Durable per-step checkpoint. Unlike save_context (which serializes THIS
271
+ # executor's @context — the observability path, F1), checkpoint! always
272
+ # serializes and stores the ROOT context under the root's key — the unit the
273
+ # async worker rehydrates by id. For a top-level reactor root == @context; for
274
+ # a composed/nested child it stores the root with the child's live state
275
+ # embedded via composed_contexts. TTL is re-stamped on every write (Phase 4).
276
+ def checkpoint!(throttle: false)
277
+ return if throttle && !checkpoint_due?
278
+
279
+ root = @context.root_context || @context
280
+ storage = RubyReactor::Configuration.instance.storage_adapter
281
+ reactor_class_name = RubyReactor.reactor_storage_name(root.reactor_class)
282
+ storage.store_context(root.context_id, ContextSerializer.serialize(root), reactor_class_name)
283
+ @last_checkpoint_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
284
+ end
285
+
286
+ # Whether a throttled (per-step) checkpoint is due. With checkpoint_min_interval
287
+ # <= 0 (default) every step checkpoints; otherwise mid-run checkpoints are
288
+ # coalesced to at most one per interval. The first step of a run always writes
289
+ # (@last_checkpoint_at is nil), and the run's terminal save is never throttled.
290
+ def checkpoint_due?
291
+ interval = RubyReactor.configuration.checkpoint_min_interval.to_f
292
+ return true if interval <= 0 || @last_checkpoint_at.nil?
293
+
294
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_checkpoint_at) >= interval
295
+ end
296
+
219
297
  def persist_context?
220
298
  @context.status.to_s != "pending" ||
221
299
  @context.execution_trace.any? ||
@@ -308,6 +386,42 @@ module RubyReactor
308
386
  RubyReactor::Period.key(base, config[:every])
309
387
  end
310
388
 
389
+ # Per-execution liveness lock on the root context id. Owner is a fresh UUID
390
+ # per execution (NOT the context_id): a duplicate delivery of the *same*
391
+ # context from a different worker must be blocked, so reentrancy by id would
392
+ # defeat the guard. Only the root executor acquires — a composed/nested child
393
+ # resumes inline under the root worker and shares the root's lock, so it must
394
+ # not try to re-acquire the same key with a different owner (self-deadlock).
395
+ def acquire_context_lock
396
+ root = @context.root_context || @context
397
+ return unless root.equal?(@context) # only the root executor holds it
398
+ # In Sidekiq::Testing.inline! the retry/snooze `perform_in` re-enters the
399
+ # worker synchronously, nested inside this still-running frame that holds
400
+ # the lock — it would self-contend forever. The lock guards concurrent
401
+ # cross-process delivery, which cannot happen under inline testing, so skip.
402
+ return if inline_testing_mode?
403
+
404
+ lock = RubyReactor::Lock.new(
405
+ "async:#{root.context_id}",
406
+ owner: @context_lock_owner ||= SecureRandom.uuid,
407
+ ttl: RubyReactor.configuration.context_lock_ttl,
408
+ wait: 0, # fail fast -> snooze; never block the worker thread
409
+ auto_extend: true # keep the liveness signal fresh while we run
410
+ )
411
+ lock.acquire
412
+ @acquired_context_lock = lock
413
+ rescue RubyReactor::Lock::AcquisitionError => e
414
+ # We lost the race to a live original holding this context's lock. We did
415
+ # no work, so we must NOT persist on the way out — saving our (older)
416
+ # rehydrated snapshot would clobber the original's newer checkpoint.
417
+ @skip_context_persist = true
418
+ raise RubyReactor::Lock::ContextLockContention.new(e.message, context_lock_key: "async:#{root.context_id}")
419
+ end
420
+
421
+ def inline_testing_mode?
422
+ defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
423
+ end
424
+
311
425
  def acquire_exclusive_lock
312
426
  config = @reactor_class.lock_config
313
427
  key = config[:key_proc].call(@context.inputs)
@@ -4,6 +4,19 @@ module RubyReactor
4
4
  class Lock
5
5
  class AcquisitionError < StandardError; end
6
6
 
7
+ # Raised specifically for the per-context liveness lock (`async:<id>`).
8
+ # Carries the bare key so the worker can exempt it from the snooze cap:
9
+ # a duplicate of the *same* execution may legitimately wait arbitrarily
10
+ # long for the live original to finish.
11
+ class ContextLockContention < AcquisitionError
12
+ attr_reader :context_lock_key
13
+
14
+ def initialize(message, context_lock_key:)
15
+ super(message)
16
+ @context_lock_key = context_lock_key
17
+ end
18
+ end
19
+
7
20
  # Minimum interval between auto-extend pings; protects very small TTLs.
8
21
  MIN_EXTEND_INTERVAL = 1.0
9
22
 
@@ -8,6 +8,42 @@ module RubyReactor
8
8
  def self.perform(arguments)
9
9
  arguments = arguments.transform_keys(&:to_sym)
10
10
  map_id = arguments[:map_id]
11
+
12
+ # Serialize concurrent collector deliveries for the SAME map (eager queue +
13
+ # counter-zero trigger + sweeper re-trigger could otherwise all resume the
14
+ # parent at once and both write its context). A dedicated map_collect lock
15
+ # is used rather than the parent's own lock so it never conflicts with the
16
+ # context lock the parent's resume_execution acquires for itself.
17
+ lock = acquire_collect_lock(map_id)
18
+ return if lock == :contended
19
+
20
+ begin
21
+ perform_collection(arguments)
22
+ ensure
23
+ lock.release if lock.respond_to?(:release)
24
+ end
25
+ end
26
+
27
+ def self.acquire_collect_lock(map_id)
28
+ return :inline if inline_testing_mode?
29
+
30
+ lock = RubyReactor::Lock.new(
31
+ "map_collect:#{map_id}",
32
+ owner: SecureRandom.uuid, ttl: RubyReactor.configuration.context_lock_ttl,
33
+ wait: 0, auto_extend: true
34
+ )
35
+ lock.acquire
36
+ lock
37
+ rescue RubyReactor::Lock::AcquisitionError
38
+ :contended
39
+ end
40
+
41
+ def self.inline_testing_mode?
42
+ defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
43
+ end
44
+
45
+ def self.perform_collection(arguments)
46
+ map_id = arguments[:map_id]
11
47
  parent_context_id = arguments[:parent_context_id]
12
48
  parent_reactor_class_name = arguments[:parent_reactor_class_name]
13
49
  step_name = arguments[:step_name]
@@ -18,6 +54,11 @@ module RubyReactor
18
54
  parent_context_data = storage.retrieve_context(parent_context_id, parent_reactor_class_name)
19
55
  parent_context = RubyReactor::Context.deserialize_from_retry(parent_context_data)
20
56
 
57
+ # Idempotency: if the parent already recorded this map step's result, a
58
+ # prior collector already resumed it. Re-resuming would double-execute the
59
+ # steps after the map. Skip.
60
+ return if parent_context.intermediate_results.key?(step_name.to_sym)
61
+
21
62
  # Check if all tasks are completed
22
63
  metadata = storage.retrieve_map_metadata(map_id, parent_reactor_class_name)
23
64
  total_count = metadata ? metadata["count"].to_i : 0
@@ -104,6 +104,48 @@ module RubyReactor
104
104
  end
105
105
  end
106
106
 
107
+ # Re-dispatch a SPECIFIC index whose result slot is missing (Phase 5c, used
108
+ # by the map sweeper). Index-driven rather than offset-driven: resolve the
109
+ # source from the stored parent context and pick source[index]. Idempotent
110
+ # because store_map_result HSETs by index — a re-run overwrites slot `index`,
111
+ # never duplicates.
112
+ def self.requeue_index(map_meta, index)
113
+ storage = RubyReactor.configuration.storage_adapter
114
+ parent_class_name = map_meta["parent_reactor_class_name"]
115
+ parent_context = load_parent_context_from_storage(map_meta["parent_context_id"], parent_class_name, storage)
116
+
117
+ arguments = {
118
+ map_id: map_meta["map_id"],
119
+ step_name: map_meta["step_name"],
120
+ strict_ordering: map_meta["strict_ordering"],
121
+ parent_context_id: map_meta["parent_context_id"],
122
+ parent_reactor_class_name: parent_class_name,
123
+ fail_fast: map_meta["fail_fast"],
124
+ batch_size: map_meta["batch_size"]
125
+ }
126
+
127
+ source = resolve_source(arguments, parent_context)
128
+ element = element_at(source, index)
129
+
130
+ queue_element_job(element, index, {
131
+ map_id: map_meta["map_id"],
132
+ arguments: arguments,
133
+ context: parent_context,
134
+ reactor_class_info: map_meta["reactor_class_info"],
135
+ step_name: map_meta["step_name"]
136
+ })
137
+ end
138
+
139
+ def self.element_at(source, index)
140
+ if source.is_a?(Array)
141
+ source[index]
142
+ elsif source.respond_to?(:offset) && source.respond_to?(:limit)
143
+ source.offset(index).limit(1).to_a.first
144
+ else
145
+ source.drop(index).first
146
+ end
147
+ end
148
+
107
149
  def self.queue_element_job(element, index, options)
108
150
  arguments = options[:arguments]
109
151
  context = options[:context]
@@ -8,6 +8,45 @@ module RubyReactor
8
8
  def self.perform(arguments)
9
9
  arguments = arguments.transform_keys(&:to_sym)
10
10
 
11
+ # Per-element liveness lock (Phase 5b): its presence is the map sweeper's
12
+ # "element alive" signal, and it serializes duplicate deliveries so a
13
+ # re-run can't double-decrement the counter (M3). A duplicate of a live
14
+ # element is dropped — the live original stores the result and finalizes.
15
+ lock = acquire_element_lock(arguments)
16
+ return if lock == :contended
17
+
18
+ begin
19
+ perform_element(arguments)
20
+ ensure
21
+ lock.release if lock.respond_to?(:release)
22
+ end
23
+ end
24
+
25
+ def self.acquire_element_lock(arguments)
26
+ # In Sidekiq::Testing.inline! an element's async-retry perform_map_element_in
27
+ # re-enters synchronously inside this frame; the lock would self-contend.
28
+ # It only guards concurrent cross-process delivery, impossible inline.
29
+ return :inline if inline_testing_mode?
30
+
31
+ lock = RubyReactor::Lock.new(
32
+ "map_element:#{arguments[:map_id]}:#{arguments[:index]}",
33
+ owner: SecureRandom.uuid, ttl: RubyReactor.configuration.context_lock_ttl,
34
+ wait: 0, auto_extend: true
35
+ )
36
+ lock.acquire
37
+ lock
38
+ rescue RubyReactor::Lock::AcquisitionError
39
+ RubyReactor.configuration.logger.info(
40
+ "RubyReactor map element #{arguments[:map_id]}:#{arguments[:index]} already in flight; dropping duplicate"
41
+ )
42
+ :contended
43
+ end
44
+
45
+ def self.inline_testing_mode?
46
+ defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
47
+ end
48
+
49
+ def self.perform_element(arguments)
11
50
  context = hydrate_or_create_context(arguments)
12
51
  # The element already runs inside its own background worker, so any async
13
52
  # steps (and async retries) must execute inline here rather than handing
@@ -108,10 +108,17 @@ module RubyReactor
108
108
  executor.resume_execution
109
109
  end
110
110
 
111
+ # Checkpoint the ROOT, not the sub (F9/C2). When the map is embedded in a
112
+ # composed sub-reactor, parent_context is the *sub*; storing only the sub
113
+ # would leave the root blob stale and a rehydrate-by-root-id resume would
114
+ # lose the map's completion. Resolve the root (which embeds the sub's
115
+ # post-map state via composed_contexts) and store that. For a top-level
116
+ # map parent_context IS the root, so this is unchanged.
117
+ root = parent_context.root_context || parent_context
111
118
  storage.store_context(
112
- parent_context.context_id,
113
- ContextSerializer.serialize(parent_context),
114
- parent_context.reactor_class.name
119
+ root.context_id,
120
+ ContextSerializer.serialize(root),
121
+ RubyReactor.reactor_storage_name(root.reactor_class)
115
122
  )
116
123
  end
117
124
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ # Recovers map fan-out from a hard kill (Phase 5d). Maps are the path most
6
+ # exposed to a lost job: one missing element result hangs the whole map and
7
+ # its parent forever. The unifying signal is the results hash — index-keyed
8
+ # and idempotent (HSET) — so completion is authoritative on `missing`, not on
9
+ # the fragile counter:
10
+ #
11
+ # missing = (0...count) - HKEYS(results)
12
+ #
13
+ # For each active map:
14
+ # * missing indices with NO live element lock are re-dispatched (M1/M4/M5).
15
+ # * if nothing is missing but the parent never resumed, the collector is
16
+ # re-triggered (M2) — gated so it never fires while a collector or the
17
+ # parent is alive, or after the parent already collected.
18
+ #
19
+ # `run_once` is pure and idempotent; the host wires the cadence (same contract
20
+ # as RubyReactor::Sweeper).
21
+ class Sweeper
22
+ def self.run_once(limit: 1000)
23
+ new.run_once(limit: limit)
24
+ end
25
+
26
+ def initialize(storage: nil, async_router: nil, logger: nil)
27
+ @storage = storage || RubyReactor.configuration.storage_adapter
28
+ @async_router = async_router || RubyReactor.configuration.async_router
29
+ @logger = logger || RubyReactor.configuration.logger
30
+ end
31
+
32
+ # Returns { redispatched:, recollected: } counts.
33
+ def run_once(limit: 1000)
34
+ redispatched = 0
35
+ recollected = 0
36
+
37
+ @storage.scan_maps(count: limit).each do |meta|
38
+ missing = missing_indices(meta)
39
+ if missing.any?
40
+ redispatched += redispatch_missing(meta, missing)
41
+ elsif recollect?(meta)
42
+ retrigger_collector(meta)
43
+ recollected += 1
44
+ end
45
+ rescue StandardError => e
46
+ @logger.warn("RubyReactor::Map::Sweeper failed on map #{meta["map_id"]}: #{e.class}: #{e.message}")
47
+ end
48
+
49
+ { redispatched: redispatched, recollected: recollected }
50
+ end
51
+
52
+ private
53
+
54
+ def missing_indices(meta)
55
+ @storage.missing_map_indices(meta["map_id"], meta["count"].to_i, meta["parent_reactor_class_name"])
56
+ end
57
+
58
+ def redispatch_missing(meta, missing)
59
+ count = 0
60
+ missing.each do |index|
61
+ next if @storage.lock_held?("map_element:#{meta["map_id"]}:#{index}") # element alive
62
+
63
+ RubyReactor::Map::Dispatcher.requeue_index(meta, index)
64
+ count += 1
65
+ end
66
+ count
67
+ end
68
+
69
+ # All results are in. Re-trigger the collector only if no collector/parent is
70
+ # alive and the parent has not already collected this step.
71
+ def recollect?(meta)
72
+ return false if @storage.lock_held?("map_collect:#{meta["map_id"]}") # a collector is running
73
+ return false if parent_live_lock?(meta) # parent execution alive
74
+ return false if parent_already_collected?(meta)
75
+
76
+ true
77
+ end
78
+
79
+ # N1: a nested map's parent is a map element running under a `map_element:`
80
+ # lock, not an `async:` lock. Derive the right key from metadata.
81
+ def parent_live_lock?(meta)
82
+ if meta["parent_is_map_element"]
83
+ @storage.lock_held?("map_element:#{meta["outer_map_id"]}:#{meta["outer_index"]}")
84
+ else
85
+ @storage.lock_held?("async:#{meta["parent_context_id"]}")
86
+ end
87
+ end
88
+
89
+ def parent_already_collected?(meta)
90
+ data = @storage.retrieve_context(meta["parent_context_id"], meta["parent_reactor_class_name"])
91
+ return false unless data
92
+
93
+ results = data["intermediate_results"] || {}
94
+ status = data["status"].to_s
95
+ results.key?(meta["step_name"].to_s) || %w[completed failed skipped].include?(status)
96
+ end
97
+
98
+ def retrigger_collector(meta)
99
+ @async_router.perform_map_collection_async(
100
+ parent_context_id: meta["parent_context_id"],
101
+ map_id: meta["map_id"],
102
+ parent_reactor_class_name: meta["parent_reactor_class_name"],
103
+ step_name: meta["step_name"],
104
+ strict_ordering: meta["strict_ordering"],
105
+ timeout: 3600
106
+ )
107
+ end
108
+ end
109
+ end
110
+ end