ruby_reactor 0.8.5 → 0.8.7

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +82 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +27 -8
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
  15. data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
  16. data/lib/ruby_reactor/error/rescuable.rb +6 -1
  17. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
  19. data/lib/ruby_reactor/executor/result_handler.rb +88 -52
  20. data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
  21. data/lib/ruby_reactor/executor/step_executor.rb +23 -1
  22. data/lib/ruby_reactor/executor.rb +186 -6
  23. data/lib/ruby_reactor/map/collector.rb +25 -93
  24. data/lib/ruby_reactor/map/dispatcher.rb +38 -7
  25. data/lib/ruby_reactor/map/element_executor.rb +14 -10
  26. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  27. data/lib/ruby_reactor/map/helpers.rb +11 -155
  28. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  29. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  30. data/lib/ruby_reactor/map.rb +17 -0
  31. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  32. data/lib/ruby_reactor/reactor.rb +113 -24
  33. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  34. data/lib/ruby_reactor/rspec/matchers.rb +55 -37
  35. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  36. data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
  37. data/lib/ruby_reactor/rspec.rb +3 -6
  38. data/lib/ruby_reactor/step/compose_step.rb +13 -0
  39. data/lib/ruby_reactor/step/map_step.rb +117 -114
  40. data/lib/ruby_reactor/storage/adapter.rb +14 -0
  41. data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
  42. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  43. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  44. data/lib/ruby_reactor/sweeper.rb +3 -1
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +11 -1
  47. data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
  48. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  49. data/lib/ruby_reactor/web/public/index.html +2 -2
  50. data/lib/ruby_reactor/worker.rb +4 -2
  51. data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
  53. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  54. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  55. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  56. data/specs/009-distributed-map-undo/data-model.md +151 -0
  57. data/specs/009-distributed-map-undo/plan.md +323 -0
  58. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  59. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  60. data/specs/009-distributed-map-undo/research.md +585 -0
  61. data/specs/009-distributed-map-undo/spec.md +360 -0
  62. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  63. data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  64. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  65. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  66. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  67. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  68. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  69. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  70. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  71. data/specs/future_improvements.md +9 -33
  72. metadata +29 -3
  73. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -135,6 +135,10 @@ module RubyReactor
135
135
  handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
136
136
  completed = true
137
137
  @result
138
+ rescue Error::RollbackHandedOff => e
139
+ record_rollback_handoff(e)
140
+ completed = true
141
+ @result
138
142
  rescue RubyReactor::Lock::AcquisitionError,
139
143
  RubyReactor::Semaphore::AcquisitionError,
140
144
  RubyReactor::RateLimit::ExceededError,
@@ -153,7 +157,7 @@ module RubyReactor
153
157
  @contention_snooze = true
154
158
  raise
155
159
  rescue Error::Rescuable => e
156
- @result = aborting_on_interruption { @result_handler.handle_execution_error(e) }
160
+ @result = handing_off { aborting_on_interruption { @result_handler.handle_execution_error(e) } }
157
161
  update_context_status(@result)
158
162
  completed = true
159
163
  @result
@@ -184,6 +188,9 @@ module RubyReactor
184
188
  end
185
189
 
186
190
  def resume_execution # rubocop:disable Metrics/MethodLength,Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
191
+ # A composed child re-entered while its own rollback is handed off.
192
+ return resume_rollback if @context.rolling_back?
193
+
187
194
  middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
188
195
  completed = false
189
196
 
@@ -256,6 +263,10 @@ module RubyReactor
256
263
  handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
257
264
  completed = true
258
265
  @result
266
+ rescue Error::RollbackHandedOff => e
267
+ record_rollback_handoff(e)
268
+ completed = true
269
+ @result
259
270
  rescue RubyReactor::Lock::AcquisitionError,
260
271
  RubyReactor::Semaphore::AcquisitionError,
261
272
  RubyReactor::RateLimit::ExceededError,
@@ -274,7 +285,7 @@ module RubyReactor
274
285
  @contention_snooze = true
275
286
  raise e
276
287
  rescue Error::Rescuable => e
277
- aborting_on_interruption { handle_resume_error(e) }
288
+ handing_off { aborting_on_interruption { handle_resume_error(e) } }
278
289
  update_context_status(@result)
279
290
  completed = true
280
291
  @result
@@ -283,16 +294,100 @@ module RubyReactor
283
294
  raise
284
295
  ensure
285
296
  release_locks unless @parked
297
+ # Saved while the context lock is still held (009 R-13): released first,
298
+ # a worker resuming this run could load the pre-save blob, and this save
299
+ # would then overwrite its progress.
300
+ save_context unless skip_context_persist?
286
301
  @acquired_context_lock&.release
287
302
  @acquired_context_lock = nil
288
303
  leave_ordered_lock_scope
304
+
305
+ emit_lifecycle_completion(completed)
306
+ end
307
+
308
+ # Undoes every completed step (manual undo, a composed child's undo, a
309
+ # map element's rollback). A rollback that hands off here (009 R-04)
310
+ # leaves this level's rollback failures on its context, and the next
311
+ # `undo_all` of the same context picks them up, so none is lost though
312
+ # the executor is rebuilt on resume (G2).
313
+ def undo_all(&after_pop)
314
+ saved = @context.rollback && @context.rollback["failures"]
315
+ @compensation_manager.restore_rollback_failures(saved) if saved
316
+ @compensation_manager.rollback_completed_steps(&after_pop)
317
+ clear_saved_rollback_failures
318
+ rescue Error::RollbackHandedOff
319
+ (@context.rollback ||= {})["failures"] =
320
+ ContextSerializer.serialize_value(@compensation_manager.rollback_failures)
321
+ raise
322
+ end
323
+
324
+ # Finishes a rollback that handed off at a fan-out map (009 R-04), once
325
+ # the map's element rollbacks have all reported: the Worker of a
326
+ # `rolling_back` run, or a composed child re-entered by its root. Under
327
+ # the run's context lock, as `resume_execution`.
328
+ def resume_rollback
329
+ state = @context.rollback || {}
330
+ # The hand-off came out of a running step (a composed child rolling
331
+ # back inside `ComposeStep#run`): resume forward, and that step
332
+ # finishes its child's rollback.
333
+ if state["trigger"] == "step" || state.empty?
334
+ @context.rollback = nil
335
+ @context.status = :running
336
+ return resume_execution
337
+ end
338
+
339
+ middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
340
+ completed = false
341
+ enter_ordered_lock_scope
342
+ acquire_context_lock
343
+ @result = finish_rollback(state)
344
+ @context.rollback = nil
345
+ update_context_status(@result)
346
+ completed = true
347
+ @result
348
+ rescue Error::RollbackHandedOff => e
349
+ record_rollback_handoff(e)
350
+ completed = true
351
+ @result
352
+ rescue RubyReactor::Lock::AcquisitionError => e
353
+ @contention_snooze = true
354
+ raise composed_contention_park(e) || e
355
+ rescue Error::Rescuable => e
356
+ handing_off { aborting_on_interruption { handle_resume_error(e) } }
357
+ update_context_status(@result)
358
+ completed = true
359
+ @result
360
+ rescue Exception # rubocop:disable Lint/RescueException
361
+ mark_aborted
362
+ raise
363
+ ensure
289
364
  save_context unless skip_context_persist?
365
+ @acquired_context_lock&.release
366
+ @acquired_context_lock = nil
367
+ leave_ordered_lock_scope
290
368
 
291
369
  emit_lifecycle_completion(completed)
292
370
  end
293
371
 
294
- def undo_all
295
- @compensation_manager.rollback_completed_steps
372
+ # The rollback's other half (009 R-05): marks the hand-off saved, then
373
+ # enqueues the owner's Worker if the map rollback already settled and this
374
+ # side claims the signal. Whichever of this and the last element rollback
375
+ # job sees both second enqueues the owner, once. Called right after the
376
+ # `rolling_back` save, while the context lock is still held.
377
+ def hand_off_rollback!(handed_off)
378
+ storage = RubyReactor.configuration.storage_adapter
379
+ map_id = handed_off.map_id
380
+ map_class = handed_off.reactor_class_name
381
+ storage.mark_map_rollback_handed_off(map_id, map_class)
382
+ RubyReactor.configuration.logger.info(
383
+ "event=ruby_reactor.rollback.handed_off context_id=#{@context.context_id.inspect} map_id=#{map_id.inspect}"
384
+ )
385
+ meta = storage.retrieve_map_rollback_metadata(map_id, map_class)
386
+ return unless meta && storage.count_map_rollback_outcomes(map_id, map_class) >= meta["total"].to_i
387
+ return unless storage.claim_map_rollback_signal(map_id, map_class)
388
+
389
+ RubyReactor.configuration.async_router.perform_async(@context.context_id,
390
+ RubyReactor.reactor_storage_name(@reactor_class))
296
391
  end
297
392
 
298
393
  # True once `resume_execution` is past its reactor-level gates (context
@@ -318,10 +413,18 @@ module RubyReactor
318
413
  def aborting_on_interruption
319
414
  yield
320
415
  rescue Exception => e # rubocop:disable Lint/RescueException
321
- mark_aborted unless Error::Rescuable === e # rubocop:disable Style/CaseEquality
416
+ mark_aborted unless Error::Rescuable === e || e.is_a?(Error::RollbackHandedOff) # rubocop:disable Style/CaseEquality
322
417
  raise
323
418
  end
324
419
 
420
+ # A rollback run from a `rescue Error::Rescuable` body (the error's own
421
+ # rollback) is outside the sibling `rescue Error::RollbackHandedOff`.
422
+ def handing_off
423
+ yield
424
+ rescue Error::RollbackHandedOff => e
425
+ record_rollback_handoff(e)
426
+ end
427
+
325
428
  def undo_stack
326
429
  @compensation_manager.undo_stack
327
430
  end
@@ -768,7 +871,8 @@ module RubyReactor
768
871
 
769
872
  case result
770
873
  when RubyReactor::DispatchResult
771
- @context.status = :running
874
+ # A rollback hand-off returns one too, and stays `rolling_back`.
875
+ @context.status = :running unless @context.rolling_back?
772
876
  when RubyReactor::Halt
773
877
  @context.status = :halted
774
878
  when RubyReactor::Success
@@ -833,6 +937,82 @@ module RubyReactor
833
937
  @result
834
938
  end
835
939
 
940
+ # Where this level's rollback stands when it hands off (009 DM §1). A
941
+ # composed child keeps it on its own context, inside the root's blob, and
942
+ # re-raises; the top-level run saves it as `rolling_back` while it still
943
+ # holds the context lock (I-6), runs the handshake, and returns a
944
+ # `DispatchResult`, as a forward hand-off does.
945
+ def record_rollback_handoff(handed_off)
946
+ failure = handed_off.failure
947
+ handed_off.failure = nil # this level's Failure; the level above builds its own
948
+ @context.rollback = rollback_state(failure)
949
+ @context.status = :rolling_back
950
+ raise handed_off if @context.root_context
951
+
952
+ save_context
953
+ hand_off_rollback!(handed_off)
954
+ @result = RubyReactor::DispatchResult.new(job_id: "map_rollback:#{handed_off.map_id}",
955
+ intermediate_results: @context.intermediate_results,
956
+ execution_id: @context.context_id)
957
+ end
958
+
959
+ # `trigger`: `failure` (a step failure is rolling back), `undo` (set by
960
+ # `Reactor#undo`), or `step` (a running step's child handed off, and no
961
+ # rollback started at this level). A later hand-off of the same rollback
962
+ # keeps what an earlier one recorded.
963
+ def rollback_state(failure)
964
+ state = (@context.rollback || {}).dup
965
+ pending = @compensation_manager.pending
966
+ state["trigger"] ||= failure || pending ? "failure" : "step"
967
+ if pending
968
+ state["step"] = pending[:step].to_s
969
+ state["compensated"] = pending[:compensated]
970
+ state["compensation_error"] = pending[:compensation_error]
971
+ end
972
+ state["failure"] = ContextSerializer.serialize_value(failure) if failure
973
+ state["failures"] = ContextSerializer.serialize_value(@compensation_manager.rollback_failures)
974
+ state
975
+ end
976
+
977
+ # The rest of a handed-off rollback: the failing step's own compensate if
978
+ # it had not finished (a map or compose adopts its settled state), the
979
+ # steps still on the undo stack, then the outcome the inline rollback
980
+ # gives (I-7).
981
+ def finish_rollback(state)
982
+ @compensation_manager.restore_rollback_failures(state["failures"])
983
+ return finish_undo if state["trigger"] == "undo"
984
+
985
+ failure = ContextSerializer.deserialize_value(state["failure"])
986
+ step_config = state["step"] && @reactor_class.steps[state["step"].to_sym]
987
+ if step_config && !state["compensated"]
988
+ @compensation_manager.handle_step_failure(step_config, failure.error, {})
989
+ else
990
+ @compensation_manager.rollback_completed_steps
991
+ if state["compensation_error"]
992
+ raise Error::CompensationError.new(state["compensation_error"], step: step_config&.name, context: @context)
993
+ end
994
+ end
995
+ @context.current_step = failure.step_name&.to_sym
996
+ @result_handler.handed_off_failure(failure)
997
+ rescue Error::CompensationError => e
998
+ @result_handler.handle_execution_error(e)
999
+ end
1000
+
1001
+ def finish_undo
1002
+ @compensation_manager.rollback_completed_steps
1003
+ @context.cancelled = true
1004
+ @context.cancellation_reason = "Undo triggered"
1005
+ @context.status = "cancelled"
1006
+ nil
1007
+ end
1008
+
1009
+ def clear_saved_rollback_failures
1010
+ return unless @context.rollback
1011
+
1012
+ @context.rollback.delete("failures")
1013
+ @context.rollback = nil if @context.rollback.empty?
1014
+ end
1015
+
836
1016
  def handle_interrupt(interrupt_result)
837
1017
  save_context
838
1018
 
@@ -3,8 +3,6 @@
3
3
  module RubyReactor
4
4
  module Map
5
5
  class Collector
6
- extend Helpers
7
-
8
6
  # Seconds a collector waits for another collector of the same map. One
9
7
  # that found the map unsettled releases within milliseconds, and may be
10
8
  # the only trigger left for a failure it just deferred (R-04); a holder
@@ -12,7 +10,7 @@ module RubyReactor
12
10
  COLLECT_LOCK_WAIT = 2
13
11
 
14
12
  def self.perform(arguments)
15
- arguments = arguments.transform_keys(&:to_sym)
13
+ arguments = Helpers.normalize_arguments(arguments)
16
14
  map_id = arguments[:map_id]
17
15
 
18
16
  # Serialize concurrent collector deliveries for the SAME map (eager queue +
@@ -48,105 +46,39 @@ module RubyReactor
48
46
  defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
49
47
  end
50
48
 
49
+ # Once every index has a result slot (a value, `_error`, `_halt` or
50
+ # `_skipped`), signal the map's OWNER run — the top-level context —
51
+ # once, and write nothing else (009 R-03, I-1). The owner's Worker
52
+ # resumes, and `MapStep#run` adopts the settled outcome at any
53
+ # composition depth. An atomic map's failure is applied the same way
54
+ # only once every index has settled, so its compensate sees every
55
+ # element that completed, including ones still in flight when it
56
+ # failed. Until then the last element to settle, or the map sweeper,
57
+ # re-triggers this collector.
51
58
  def self.perform_collection(arguments)
52
59
  map_id = arguments[:map_id]
53
- parent_context_id = arguments[:parent_context_id]
54
60
  parent_reactor_class_name = arguments[:parent_reactor_class_name]
55
- step_name = arguments[:step_name]
56
- strict_ordering = arguments[:strict_ordering]
57
- # timeout = arguments[:timeout]
58
-
59
61
  storage = RubyReactor.configuration.storage_adapter
60
- parent_context_data = storage.retrieve_context(parent_context_id, parent_reactor_class_name)
61
- parent_context = RubyReactor::Context.deserialize_from_retry(parent_context_data)
62
-
63
- # Idempotency: if the parent already recorded this map step's result, a
64
- # prior collector already resumed it. Re-resuming would double-execute the
65
- # steps after the map. Skip. A parent already finished (a prior
66
- # collector applied this map's failure) must not be rolled back twice.
67
- return if parent_context.intermediate_results.key?(step_name.to_sym) || parent_context.finished?
68
62
 
69
- # Check if all tasks are completed
70
63
  metadata = storage.retrieve_map_metadata(map_id, parent_reactor_class_name)
71
- total_count = metadata ? metadata["count"].to_i : 0
64
+ return unless metadata
65
+ # Judged against the element count, not map_offset (which batching
66
+ # reservation can push past it).
67
+ return if storage.count_map_results(map_id, parent_reactor_class_name) < metadata["count"].to_i
68
+ return unless storage.claim_map_owner_signal(map_id, parent_reactor_class_name)
72
69
 
73
- results_count = storage.count_map_results(map_id, parent_reactor_class_name)
74
-
75
- # Completion is judged against the total count of elements, not
76
- # map_offset (which batching reservation can push past it).
77
- #
78
- # A fail-fast failure is applied only once every index has settled
79
- # (a result, `_error`, `_halt` or `_skipped` slot), so the map's
80
- # compensate sees every element that completed — including ones still
81
- # in flight when the failure happened (R-04). Until then the last
82
- # element to settle, or the map sweeper, re-triggers this collector.
83
- return if results_count < total_count
84
-
85
- if (failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name))
86
- handle_failure(failed_context_id, metadata, storage, parent_context, step_name)
87
- return
88
- end
89
-
90
- # Retrieve results lazily
91
- results = RubyReactor::Map::ResultEnumerator.new(
92
- map_id,
93
- parent_reactor_class_name,
94
- strict_ordering: strict_ordering
95
- )
96
-
97
- # Apply collect block (or default collection)
98
- step_config = parent_context.reactor_class.steps[step_name.to_sym]
99
-
100
- begin
101
- final_result = apply_collect_block(results, step_config)
102
-
103
- if final_result.failure?
104
- # Optionally log failure internally or just rely on context status update
105
- end
106
- rescue StandardError => e
107
- final_result = RubyReactor::Failure(e)
108
- end
109
-
110
- # Resume parent execution
111
- resume_parent_execution(parent_context, step_name, final_result, storage)
112
- rescue StandardError => e
113
- RubyReactor.configuration.logger.error("Map collector crashed: #{e.message}")
114
- RubyReactor.configuration.logger.error(e.backtrace.join("\n")) if e.backtrace
115
- raise e
116
- end
117
-
118
- def self.apply_collect_block(results, step_config)
119
- collect_block = step_config.arguments[:collect_block][:source].value if step_config.arguments[:collect_block]
120
- # TODO: Check allow_partial_failure option
121
-
122
- if collect_block
123
- begin
124
- # Pass Enumerator to collect block
125
- collected = collect_block.call(results)
126
- RubyReactor::Success(collected)
127
- rescue RubyReactor::Error::Rescuable => e
128
- RubyReactor.configuration.logger.error("Map collect block raised: #{e.message}")
129
- RubyReactor.configuration.logger.error(e.backtrace.join("\n")) if e.backtrace
130
- RubyReactor::Failure(e)
131
- end
132
- else
133
- # Default behavior: Return Success(Enumerator).
134
- # Logic for checking failures is deferred to the consumer of the enumerator.
135
- RubyReactor::Success(results)
136
- end
70
+ signal_owner(metadata, arguments)
137
71
  end
138
72
 
139
- def self.handle_failure(failed_context_id, metadata, storage, parent_context, step_name)
140
- # Resolve the class of the mapped reactor to retrieve its context
141
- reactor_class = resolve_reactor_class(metadata["reactor_class_info"])
142
- failed_context_data = storage.retrieve_context(failed_context_id, reactor_class.name)
143
-
144
- return unless failed_context_data
145
-
146
- failed_context = RubyReactor::Context.deserialize_from_retry(failed_context_data)
147
- reason = failed_context.failure_reason
148
- result = reason.is_a?(RubyReactor::Failure) ? reason : RubyReactor::Failure(reason)
149
- resume_parent_execution(parent_context, step_name, result, storage)
73
+ # Metadata written before the upgrade names no owner (S-6): the parent is
74
+ # the owner of a root-level map.
75
+ def self.signal_owner(metadata, arguments)
76
+ owner_id = metadata["owner_context_id"] || metadata["parent_context_id"] || arguments[:parent_context_id]
77
+ owner_class = metadata["owner_reactor_class_name"] || metadata["parent_reactor_class_name"]
78
+ RubyReactor.configuration.logger.info(
79
+ "event=ruby_reactor.map.settled map_id=#{arguments[:map_id].inspect} owner=#{owner_id.inspect}"
80
+ )
81
+ RubyReactor.configuration.async_router.perform_async(owner_id, owner_class)
150
82
  end
151
83
  end
152
84
  end
@@ -6,7 +6,7 @@ module RubyReactor
6
6
  extend Helpers
7
7
 
8
8
  def self.perform(arguments)
9
- arguments = arguments.transform_keys(&:to_sym)
9
+ arguments = Helpers.normalize_arguments(arguments)
10
10
  parent_reactor_class_name = arguments[:parent_reactor_class_name]
11
11
 
12
12
  storage = RubyReactor.configuration.storage_adapter
@@ -64,12 +64,12 @@ module RubyReactor
64
64
  map_id = arguments[:map_id]
65
65
  reactor_class_name = arguments[:parent_reactor_class_name]
66
66
 
67
- # Fail Fast Check
68
- if arguments[:fail_fast] && storage.retrieve_map_failed_context_id(map_id, reactor_class_name)
67
+ # An atomic map stops dispatching at its first failure.
68
+ if arguments[:atomic] && storage.retrieve_map_failed_context_id(map_id, reactor_class_name)
69
69
  return settle_undispatched(arguments, storage)
70
70
  end
71
71
 
72
- batch_size = arguments[:batch_size] || source.size # Default to all if no batch_size (async=true only)
72
+ batch_size = arguments[:batch_size] || RubyReactor::Map::DEFAULT_BATCH_SIZE
73
73
 
74
74
  # Atomically reserve a batch
75
75
  new_offset = storage.increment_map_offset(map_id, batch_size, reactor_class_name)
@@ -103,7 +103,7 @@ module RubyReactor
103
103
  end
104
104
  end
105
105
 
106
- # A fail-fast map stopped dispatching: claim every index not dispatched
106
+ # An atomic map stopped dispatching: claim every index not dispatched
107
107
  # yet (one offset bump, so a later dispatcher claims none), settle each
108
108
  # with a `_skipped` slot, count them down, and trigger the collector if
109
109
  # that settled the map. Without this those indices never settle, so the
@@ -146,7 +146,7 @@ module RubyReactor
146
146
  strict_ordering: map_meta["strict_ordering"],
147
147
  parent_context_id: map_meta["parent_context_id"],
148
148
  parent_reactor_class_name: parent_class_name,
149
- fail_fast: map_meta["fail_fast"],
149
+ atomic: map_meta["atomic"],
150
150
  batch_size: map_meta["batch_size"]
151
151
  }
152
152
 
@@ -211,10 +211,41 @@ module RubyReactor
211
211
  parent_reactor_class_name: context.reactor_class.name,
212
212
  step_name: options[:step_name].to_s,
213
213
  batch_size: arguments[:batch_size], # Passed to worker so it knows to trigger next batch?
214
- fail_fast: arguments[:fail_fast]
214
+ atomic: arguments[:atomic]
215
215
  )
216
216
  end
217
217
 
218
+ # One throw of a map's distributed rollback (009 R-02, R-06): claim the
219
+ # next `batch_size` positions of the element-context index, counted from
220
+ # its tail, and enqueue one rollback job per position. The job at the
221
+ # last position of a throw claims the next one, as forward.
222
+ def self.dispatch_rollback_batch(map_id:, parent_reactor_class_name:)
223
+ storage = RubyReactor.configuration.storage_adapter
224
+ meta = storage.retrieve_map_rollback_metadata(map_id, parent_reactor_class_name)
225
+ return 0 unless meta
226
+
227
+ positions = storage.claim_map_rollback_positions(map_id, parent_reactor_class_name, meta["batch_size"].to_i)
228
+ return 0 if positions.none?
229
+
230
+ ids = storage.retrieve_map_element_context_ids_from_tail(map_id, parent_reactor_class_name, positions.first,
231
+ positions.size, total: meta["total"].to_i)
232
+ positions.each_with_index do |position, i|
233
+ queue_rollback_job(meta, map_id, parent_reactor_class_name, position, ids[i])
234
+ end
235
+ positions.size
236
+ end
237
+
238
+ # rubocop:disable Metrics/ParameterLists
239
+ def self.queue_rollback_job(meta, map_id, parent_reactor_class_name, position, element_context_id, attempt: 0)
240
+ RubyReactor.configuration.async_router.perform_map_element_rollback_async(
241
+ map_id: map_id, position: position, element_context_id: element_context_id,
242
+ reactor_class_info: meta["reactor_class_info"], parent_reactor_class_name: parent_reactor_class_name,
243
+ step_name: meta["step_name"], batch_size: meta["batch_size"], owner_context_id: meta["owner_context_id"],
244
+ owner_reactor_class_name: meta["owner_reactor_class_name"], attempt: attempt
245
+ )
246
+ end
247
+ # rubocop:enable Metrics/ParameterLists
248
+
218
249
  def self.resolve_reactor_class_info(arguments, context)
219
250
  mapped_reactor_class = arguments[:mapped_reactor_class]
220
251
  step_name = arguments[:step_name]
@@ -6,7 +6,7 @@ module RubyReactor
6
6
  extend Helpers
7
7
 
8
8
  def self.perform(arguments)
9
- arguments = arguments.transform_keys(&:to_sym)
9
+ arguments = Helpers.normalize_arguments(arguments)
10
10
 
11
11
  # Per-element liveness lock (Phase 5b): its presence is the map sweeper's
12
12
  # "element alive" signal, and it serializes duplicate deliveries so a
@@ -55,12 +55,16 @@ module RubyReactor
55
55
  context.inline_async_execution = true
56
56
 
57
57
  storage = RubyReactor.configuration.storage_adapter
58
- return if check_fail_fast?(arguments, storage)
58
+ return if check_atomic?(arguments, storage)
59
59
 
60
60
  # Indexed only once it runs: a skipped element saves no context, and
61
61
  # the map's rollback reports an indexed element without one as expired.
62
- storage.store_map_element_context_id(arguments[:map_id], context.context_id,
63
- arguments[:parent_reactor_class_name])
62
+ # Only a fresh context: a parked or retried element re-enters with its
63
+ # own, already indexed (009 R-06).
64
+ unless arguments[:serialized_context]
65
+ storage.store_map_element_context_id(arguments[:map_id], context.context_id,
66
+ arguments[:parent_reactor_class_name])
67
+ end
64
68
 
65
69
  executor = Executor.new(context.reactor_class, {}, context)
66
70
  begin
@@ -95,7 +99,7 @@ module RubyReactor
95
99
  strict_ordering: arguments[:strict_ordering], parent_context_id: arguments[:parent_context_id],
96
100
  parent_reactor_class_name: arguments[:parent_reactor_class_name], step_name: arguments[:step_name],
97
101
  batch_size: arguments[:batch_size], serialized_context: ContextSerializer.serialize(context),
98
- fail_fast: arguments[:fail_fast]
102
+ atomic: arguments[:atomic]
99
103
  )
100
104
  end
101
105
 
@@ -144,8 +148,8 @@ module RubyReactor
144
148
  end
145
149
  # rubocop:enable Style/IdenticalConditionalBranches
146
150
 
147
- def self.check_fail_fast?(arguments, storage)
148
- return false unless arguments[:fail_fast]
151
+ def self.check_atomic?(arguments, storage)
152
+ return false unless arguments[:atomic]
149
153
 
150
154
  map_id = arguments[:map_id]
151
155
  parent_reactor_class_name = arguments[:parent_reactor_class_name]
@@ -154,7 +158,7 @@ module RubyReactor
154
158
  return false unless failed_context_id
155
159
 
156
160
  # Skip execution, but settle the index: the collector applies a
157
- # fail-fast failure only once every index has a result slot (R-04), and
161
+ # atomic map's failure only once every index has a result slot (R-04), and
158
162
  # the map sweeper would otherwise keep re-dispatching this one.
159
163
  storage.store_map_result(map_id, arguments[:index], { "_skipped" => true }, parent_reactor_class_name,
160
164
  strict_ordering: arguments[:strict_ordering])
@@ -183,12 +187,12 @@ module RubyReactor
183
187
  # Store the whole serialized Failure, not just its message: step_name,
184
188
  # backtrace, file_path and code_snippet are the only record of why this
185
189
  # element failed once its context row expires, and the dashboard has
186
- # nothing else to show for a non-fail_fast map.
190
+ # nothing else to show for a non-atomic map.
187
191
  storage.store_map_result(map_id, index,
188
192
  { _error: ContextSerializer.serialize_value(result) }, parent_class,
189
193
  strict_ordering: arguments[:strict_ordering])
190
194
 
191
- if arguments[:fail_fast]
195
+ if arguments[:atomic]
192
196
  storage.store_map_failed_context_id(map_id, context.context_id, parent_class)
193
197
  # FAST FAIL: Trigger Collector immediately to cancel/fail the map execution
194
198
  RubyReactor.configuration.async_router.perform_map_collection_async(