ruby_reactor 0.8.4 → 0.8.6

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 (104) 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 +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  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/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -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,13 +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]
69
- failed_context_id = storage.retrieve_map_failed_context_id(map_id, reactor_class_name)
70
- return if failed_context_id
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
+ return settle_undispatched(arguments, storage)
71
70
  end
72
71
 
73
- 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
74
73
 
75
74
  # Atomically reserve a batch
76
75
  new_offset = storage.increment_map_offset(map_id, batch_size, reactor_class_name)
@@ -104,6 +103,33 @@ module RubyReactor
104
103
  end
105
104
  end
106
105
 
106
+ # An atomic map stopped dispatching: claim every index not dispatched
107
+ # yet (one offset bump, so a later dispatcher claims none), settle each
108
+ # with a `_skipped` slot, count them down, and trigger the collector if
109
+ # that settled the map. Without this those indices never settle, so the
110
+ # failure would never be applied (R-04) and the map sweeper would keep
111
+ # re-dispatching them.
112
+ def self.settle_undispatched(arguments, storage)
113
+ map_id = arguments[:map_id]
114
+ reactor_class_name = arguments[:parent_reactor_class_name]
115
+ total = storage.retrieve_map_metadata(map_id, reactor_class_name)&.fetch("count", 0).to_i
116
+ new_offset = storage.increment_map_offset(map_id, total, reactor_class_name)
117
+ claimed = (new_offset - total)...[new_offset, total].min
118
+ return if claimed.none?
119
+
120
+ claimed.each do |index|
121
+ storage.store_map_result(map_id, index, { "_skipped" => true }, reactor_class_name,
122
+ strict_ordering: arguments[:strict_ordering])
123
+ end
124
+ return unless storage.decrement_map_counter_by(map_id, claimed.size, reactor_class_name) <= 0
125
+
126
+ RubyReactor.configuration.async_router.perform_map_collection_async(
127
+ parent_context_id: arguments[:parent_context_id], map_id: map_id,
128
+ parent_reactor_class_name: reactor_class_name, step_name: arguments[:step_name].to_s,
129
+ strict_ordering: arguments[:strict_ordering], timeout: 3600
130
+ )
131
+ end
132
+
107
133
  # Re-dispatch a SPECIFIC index whose result slot is missing (Phase 5c, used
108
134
  # by the map sweeper). Index-driven rather than offset-driven: resolve the
109
135
  # source from the stored parent context and pick source[index]. Idempotent
@@ -120,7 +146,7 @@ module RubyReactor
120
146
  strict_ordering: map_meta["strict_ordering"],
121
147
  parent_context_id: map_meta["parent_context_id"],
122
148
  parent_reactor_class_name: parent_class_name,
123
- fail_fast: map_meta["fail_fast"],
149
+ atomic: map_meta["atomic"],
124
150
  batch_size: map_meta["batch_size"]
125
151
  }
126
152
 
@@ -185,9 +211,40 @@ module RubyReactor
185
211
  parent_reactor_class_name: context.reactor_class.name,
186
212
  step_name: options[:step_name].to_s,
187
213
  batch_size: arguments[:batch_size], # Passed to worker so it knows to trigger next batch?
188
- fail_fast: arguments[:fail_fast]
214
+ atomic: arguments[:atomic]
215
+ )
216
+ end
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
189
245
  )
190
246
  end
247
+ # rubocop:enable Metrics/ParameterLists
191
248
 
192
249
  def self.resolve_reactor_class_info(arguments, context)
193
250
  mapped_reactor_class = arguments[:mapped_reactor_class]
@@ -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,10 +55,16 @@ module RubyReactor
55
55
  context.inline_async_execution = true
56
56
 
57
57
  storage = RubyReactor.configuration.storage_adapter
58
- storage.store_map_element_context_id(arguments[:map_id], context.context_id,
59
- arguments[:parent_reactor_class_name])
60
-
61
- return if check_fail_fast?(arguments, storage)
58
+ return if check_atomic?(arguments, storage)
59
+
60
+ # Indexed only once it runs: a skipped element saves no context, and
61
+ # the map's rollback reports an indexed element without one as expired.
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
62
68
 
63
69
  executor = Executor.new(context.reactor_class, {}, context)
64
70
  begin
@@ -93,7 +99,7 @@ module RubyReactor
93
99
  strict_ordering: arguments[:strict_ordering], parent_context_id: arguments[:parent_context_id],
94
100
  parent_reactor_class_name: arguments[:parent_reactor_class_name], step_name: arguments[:step_name],
95
101
  batch_size: arguments[:batch_size], serialized_context: ContextSerializer.serialize(context),
96
- fail_fast: arguments[:fail_fast]
102
+ atomic: arguments[:atomic]
97
103
  )
98
104
  end
99
105
 
@@ -142,8 +148,8 @@ module RubyReactor
142
148
  end
143
149
  # rubocop:enable Style/IdenticalConditionalBranches
144
150
 
145
- def self.check_fail_fast?(arguments, storage)
146
- return false unless arguments[:fail_fast]
151
+ def self.check_atomic?(arguments, storage)
152
+ return false unless arguments[:atomic]
147
153
 
148
154
  map_id = arguments[:map_id]
149
155
  parent_reactor_class_name = arguments[:parent_reactor_class_name]
@@ -151,7 +157,11 @@ module RubyReactor
151
157
  failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name)
152
158
  return false unless failed_context_id
153
159
 
154
- # Skip execution
160
+ # Skip execution, but settle the index: the collector applies a
161
+ # atomic map's failure only once every index has a result slot (R-04), and
162
+ # the map sweeper would otherwise keep re-dispatching this one.
163
+ storage.store_map_result(map_id, arguments[:index], { "_skipped" => true }, parent_reactor_class_name,
164
+ strict_ordering: arguments[:strict_ordering])
155
165
  finalize_execution(arguments, storage)
156
166
  true
157
167
  end
@@ -177,12 +187,12 @@ module RubyReactor
177
187
  # Store the whole serialized Failure, not just its message: step_name,
178
188
  # backtrace, file_path and code_snippet are the only record of why this
179
189
  # element failed once its context row expires, and the dashboard has
180
- # nothing else to show for a non-fail_fast map.
190
+ # nothing else to show for a non-atomic map.
181
191
  storage.store_map_result(map_id, index,
182
192
  { _error: ContextSerializer.serialize_value(result) }, parent_class,
183
193
  strict_ordering: arguments[:strict_ordering])
184
194
 
185
- if arguments[:fail_fast]
195
+ if arguments[:atomic]
186
196
  storage.store_map_failed_context_id(map_id, context.context_id, parent_class)
187
197
  # FAST FAIL: Trigger Collector immediately to cancel/fail the map execution
188
198
  RubyReactor.configuration.async_router.perform_map_collection_async(
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ # Rolls back ONE element of a map (009 R-07): the element's own undo
6
+ # stack, replayed under its liveness lock and saved after every undone
7
+ # entry, so a redelivery resumes after the last one (only the entry that
8
+ # was cut off may run again). The inline path calls `.call` in process; a
9
+ # fan-out map's `MapElementRollbackWorker` calls `.perform`.
10
+ class ElementRollback
11
+ extend Helpers
12
+
13
+ # The element context row is gone: its index went with it.
14
+ def self.unavailable(step_name)
15
+ { "index" => nil, "outcome" => "context_unavailable",
16
+ "failures" => ContextSerializer.serialize_value([entry(step_name, nil, :context_unavailable,
17
+ "context expired")]) }
18
+ end
19
+
20
+ # `{ "index", "outcome", "failures" }` (DM §5), with `failures`
21
+ # serialized. Outcome `contended`, never stored, means the element's lock
22
+ # was still held after `wait`.
23
+ def self.call(map_id:, element_context_id:, element_class:, step_name:, wait: Step::MapStep::ELEMENT_LOCK_WAIT)
24
+ storage_name = RubyReactor.reactor_storage_name(element_class)
25
+ data = element_context_id && RubyReactor.configuration.storage_adapter.retrieve_context(element_context_id,
26
+ storage_name)
27
+ return unavailable(step_name) unless data
28
+
29
+ element = Context.deserialize_from_retry(data)
30
+ index = Utils::FetchIndifferent.call(element.map_metadata || {}, :index).to_i
31
+ lock = acquire_element_lock(map_id, index, wait)
32
+ return outcome(index, "contended") if lock == :held
33
+
34
+ begin
35
+ roll_back(element, element_class, index, step_name)
36
+ ensure
37
+ lock.release if lock.respond_to?(:release)
38
+ end
39
+ end
40
+
41
+ # A completed element, or an aborted one (an inline run interrupted in
42
+ # it, which kept the undo entries of the steps it completed), undoes;
43
+ # a failed one already rolled itself back, and a halted or superseded
44
+ # running one did nothing to undo.
45
+ def self.roll_back(element, element_class, index, step_name)
46
+ return outcome(index, "not_needed") unless %w[completed aborted].include?(element.status.to_s)
47
+
48
+ executor = Executor.new(element.reactor_class || element_class, {}, element)
49
+ executor.undo_all { executor.checkpoint! }
50
+ executor.checkpoint!
51
+ tag = { map_step: step_name.to_sym, element_index: index }
52
+ failures = executor.compensation_manager.rollback_failures.map { |failure| failure.merge(tag) }
53
+ outcome(index, failures.empty? ? "undone" : "failed", failures)
54
+ end
55
+
56
+ # The job's body. A contended lock requeues the job, storing nothing,
57
+ # up to `lock_snooze_max_attempts` times before reporting the element
58
+ # in flight: two contexts of one index in the same batch then serialize
59
+ # instead of one reporting a false failure (R-07).
60
+ def self.perform(arguments)
61
+ args = Helpers.normalize_arguments(arguments)
62
+ map_id = args[:map_id]
63
+ map_class = args[:parent_reactor_class_name]
64
+ storage = RubyReactor.configuration.storage_adapter
65
+ return if storage.map_rollback_outcome_stored?(map_id, map_class, args[:position])
66
+
67
+ attempt = args[:attempt].to_i
68
+ result = call(map_id: map_id, element_context_id: args[:element_context_id],
69
+ element_class: resolve_reactor_class(args[:reactor_class_info]), step_name: args[:step_name],
70
+ wait: attempt.zero? ? Step::MapStep::ELEMENT_LOCK_WAIT : 0)
71
+ if result["outcome"] == "contended"
72
+ return requeue(args, attempt) unless snooze_exhausted?(attempt)
73
+
74
+ result = in_flight(result["index"], args[:step_name])
75
+ end
76
+ return unless storage.store_map_rollback_outcome(map_id, map_class, args[:position], result)
77
+
78
+ log_outcome(args, result)
79
+ Dispatcher.dispatch_rollback_batch(map_id: map_id, parent_reactor_class_name: map_class) if batch_end?(args)
80
+ signal_owner_if_settled(args)
81
+ end
82
+
83
+ def self.in_flight(index, step_name)
84
+ outcome(index, "element_in_flight",
85
+ [entry(step_name, index, :element_in_flight, "was still running at rollback time")])
86
+ end
87
+
88
+ def self.snooze_exhausted?(attempt)
89
+ max = RubyReactor.configuration.lock_snooze_max_attempts
90
+ max != :infinity && attempt >= max
91
+ end
92
+
93
+ def self.requeue(args, attempt)
94
+ config = RubyReactor.configuration
95
+ config.async_router.perform_map_element_rollback_in(
96
+ Worker.snooze_delay(config, nil), **args.slice(*ROLLBACK_JOB_ARGS), attempt: attempt + 1
97
+ )
98
+ end
99
+
100
+ # The position that ends a throw claims the next one (R-02).
101
+ def self.batch_end?(args)
102
+ batch_size = args[:batch_size].to_i
103
+ batch_size.positive? && ((args[:position].to_i + 1) % batch_size).zero?
104
+ end
105
+
106
+ # The handshake's job side (R-05): only once the hand-off is saved, so
107
+ # the owner never resumes from a stale blob — and never in inline job
108
+ # mode, where the map settles before any hand-off exists.
109
+ def self.signal_owner_if_settled(args)
110
+ storage = RubyReactor.configuration.storage_adapter
111
+ map_id = args[:map_id]
112
+ map_class = args[:parent_reactor_class_name]
113
+ meta = storage.retrieve_map_rollback_metadata(map_id, map_class)
114
+ return unless meta && storage.count_map_rollback_outcomes(map_id, map_class) >= meta["total"].to_i
115
+ return unless storage.map_rollback_handed_off?(map_id, map_class)
116
+ return unless storage.claim_map_rollback_signal(map_id, map_class)
117
+
118
+ RubyReactor.configuration.async_router.perform_async(args[:owner_context_id], args[:owner_reactor_class_name])
119
+ end
120
+
121
+ def self.log_outcome(args, result)
122
+ Map.log("ruby_reactor.map.rollback.element",
123
+ reactor: args[:owner_reactor_class_name], context_id: args[:owner_context_id],
124
+ map_step: args[:step_name], index: result["index"], outcome: result["outcome"],
125
+ failures: Array(result["failures"]).size)
126
+ end
127
+
128
+ def self.outcome(index, outcome, failures = [])
129
+ { "index" => index, "outcome" => outcome, "failures" => ContextSerializer.serialize_value(failures) }
130
+ end
131
+
132
+ # A rollback failure entry for the element as a whole (008 shape).
133
+ def self.entry(step_name, index, reason, message)
134
+ step = step_name.to_sym
135
+ { step: step, kind: :undo, key: nil, reason: reason, map_step: step, element_index: index,
136
+ message: "map element #{index} #{message}" }
137
+ end
138
+
139
+ def self.acquire_element_lock(map_id, index, wait)
140
+ return nil if ElementExecutor.inline_testing_mode?
141
+
142
+ lock = RubyReactor::Lock.new("map_element:#{map_id}:#{index}", owner: SecureRandom.uuid,
143
+ ttl: RubyReactor.configuration.context_lock_ttl,
144
+ wait: wait)
145
+ lock.acquire
146
+ lock
147
+ rescue RubyReactor::Lock::AcquisitionError
148
+ :held
149
+ end
150
+
151
+ ROLLBACK_JOB_ARGS = %i[map_id position element_context_id reactor_class_info parent_reactor_class_name step_name
152
+ batch_size owner_context_id owner_reactor_class_name].freeze
153
+
154
+ private_class_method :roll_back, :in_flight, :snooze_exhausted?, :requeue, :batch_end?,
155
+ :signal_owner_if_settled, :log_outcome, :acquire_element_lock
156
+ end
157
+ end
158
+ end
@@ -4,6 +4,16 @@ module RubyReactor
4
4
  module Map
5
5
  # Shared helper methods for Map executors
6
6
  module Helpers
7
+ # Job payloads enqueued before 009 carry `fail_fast` where they now carry
8
+ # `atomic` (R-11, FR-023): the one place the old key is read. Removed at
9
+ # the next MAJOR.
10
+ def self.normalize_arguments(arguments)
11
+ normalized = arguments.to_h.transform_keys(&:to_sym)
12
+ normalized[:atomic] = normalized[:fail_fast] if !normalized.key?(:atomic) && normalized.key?(:fail_fast)
13
+ normalized.delete(:fail_fast)
14
+ normalized
15
+ end
16
+
7
17
  # Resolves the reactor class from reactor_class_info
8
18
  def resolve_reactor_class(info)
9
19
  if info["type"] == "class"
@@ -20,6 +30,7 @@ module RubyReactor
20
30
  raise "Unknown reactor class info: #{info}"
21
31
  end
22
32
  end
33
+ module_function :resolve_reactor_class
23
34
 
24
35
  # Loads parent context from storage
25
36
  def load_parent_context_from_storage(parent_context_id, reactor_class_name, storage)
@@ -31,131 +42,6 @@ module RubyReactor
31
42
  def build_element_inputs(mappings, parent_context, element)
32
43
  RubyReactor::Step::MapStep.build_mapped_inputs(mappings, parent_context, element)
33
44
  end
34
-
35
- # Applies collect block to results
36
- def apply_collect_block(results, step_config)
37
- collect_block = step_config.arguments[:collect_block][:source].value
38
-
39
- if collect_block
40
- # Pass all results (Success and Failure) to collect block
41
- begin
42
- collected = collect_block.call(results)
43
- RubyReactor::Success(collected)
44
- rescue StandardError => e
45
- RubyReactor::Failure(e)
46
- end
47
- else
48
- # Default behavior: fail if any failure
49
- first_failure = results.find(&:failure?)
50
- first_failure || RubyReactor::Success(results.map(&:value))
51
- end
52
- end
53
-
54
- # Resumes parent reactor execution after map completion
55
- def resume_parent_execution(parent_context, step_name, final_result, storage) # rubocop:disable Metrics/MethodLength
56
- executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
57
- step_name_sym = step_name.to_sym
58
-
59
- if final_result.failure?
60
- parent_context.current_step = step_name_sym
61
-
62
- error = RubyReactor::Error::StepFailureError.new(
63
- final_result.error,
64
- step: step_name_sym,
65
- context: parent_context,
66
- original_error: final_result.error.is_a?(Exception) ? final_result.error : nil,
67
- exception_class: final_result.respond_to?(:exception_class) ? final_result.exception_class : nil
68
- )
69
-
70
- # Pass backtrace if available
71
- if final_result.respond_to?(:backtrace) && final_result.backtrace
72
- error.set_backtrace(final_result.backtrace)
73
- elsif final_result.error.respond_to?(:backtrace)
74
- error.set_backtrace(final_result.error.backtrace)
75
- end
76
-
77
- # Bracket the rollback with reactor lifecycle events so that
78
- # compensation/undo spans nest under a reactor span (and stay attached
79
- # to the originating trace), mirroring the success path's
80
- # resume_execution. Without this, rollback runs in the collector worker
81
- # with no active reactor span and the undo/compensation spans orphan.
82
- executor.middlewares.on(:start_reactor, parent_context.reactor_class.name, parent_context.inputs,
83
- parent_context)
84
- failure_response = nil
85
- begin
86
- failure_response = executor.result_handler.handle_execution_error(error)
87
- # Manually update context status since we're not running executor loop
88
- executor.send(:update_context_status, failure_response)
89
- ensure
90
- executor.middlewares.on(:failed_reactor, parent_context.reactor_class.name, failure_response,
91
- parent_context)
92
- end
93
- # This branch runs no executor loop, so nothing else persists the
94
- # failed status: store it here.
95
- store_parent(parent_context, storage)
96
- else
97
- parent_context.set_result(step_name_sym, final_result.value)
98
-
99
- # Manually update execution trace to reflect completion
100
- # This is necessary because resume_execution continues from the NEXT step
101
- # and the async step (which returned DispatchResult) needs to be marked as done with actual value
102
- parent_context.append_execution_trace(
103
- {
104
- type: :result,
105
- step: step_name_sym,
106
- timestamp: Time.now,
107
- value: final_result.value,
108
- status: :success
109
- }
110
- )
111
-
112
- parent_context.current_step = nil
113
-
114
- # Mark the context as a worker before resuming: resume_execution runs
115
- # the collector's own worker (this call), not the reactor's normal
116
- # caller. Without this, StepExecutor#handoff_at? (gated on
117
- # `inline_async_execution`) re-evaluates any later `background`
118
- # before:/after: cut point and enqueues a second hand-off, or runs an
119
- # `after:` target here in the collector instead of the original
120
- # dispatching worker.
121
- parent_context.inline_async_execution = true
122
- resume_parked_aware(executor, parent_context)
123
- end
124
- end
125
-
126
- # `resume_execution` persists the parent itself — under the parent's
127
- # context lock, and deliberately NOT when it lost that lock to a live
128
- # duplicate or replayed an already-terminal run. Storing again here, after
129
- # the lock is released, would overwrite whatever the lock's holder wrote
130
- # (single writer), so the resume's own save is the only one.
131
- #
132
- # This collector is a worker running the parent's execution, so it is
133
- # also a final handler for park signals (005 R-01), like `Worker` and
134
- # `ElementExecutor`: the resume has already parked the parent's holds and
135
- # saved; hand the rest back to the parent's own worker.
136
- #
137
- # The parent is loaded from its own blob, so it has no `root_context`:
138
- # a map inside a composed child resumes and requeues that child as its
139
- # own execution, never its root. ponytail: the root is never resumed
140
- # after such a map (already so on main); see "Fan-out map inside a
141
- # composed child" in specs/future_improvements.md.
142
- def resume_parked_aware(executor, parent_context)
143
- executor.resume_execution
144
- rescue RubyReactor::Error::ExecutionParked => e
145
- config = RubyReactor.configuration
146
- config.async_router.perform_in(
147
- RubyReactor::Worker.snooze_delay(config, e), parent_context.context_id,
148
- RubyReactor.reactor_storage_name(parent_context.reactor_class)
149
- )
150
- end
151
-
152
- def store_parent(parent_context, storage)
153
- storage.store_context(
154
- parent_context.context_id,
155
- ContextSerializer.serialize(parent_context),
156
- RubyReactor.reactor_storage_name(parent_context.reactor_class)
157
- )
158
- end
159
45
  end
160
46
  end
161
47
  end
@@ -22,7 +22,8 @@ module RubyReactor
22
22
 
23
23
  if @strict_ordering
24
24
  count.times do |i|
25
- yield self[i]
25
+ raw = raw_at(i)
26
+ yield raw && wrap_result(raw) unless skipped?(raw)
26
27
  end
27
28
  else
28
29
  offset = 0
@@ -37,7 +38,7 @@ module RubyReactor
37
38
 
38
39
  break if results.empty?
39
40
 
40
- results.each { |result| yield wrap_result(result) }
41
+ results.each { |result| yield wrap_result(result) unless skipped?(result) }
41
42
 
42
43
  offset += results.size
43
44
  break if results.size < @batch_size
@@ -59,21 +60,15 @@ module RubyReactor
59
60
  !empty?
60
61
  end
61
62
 
63
+ # nil for an index a fail-fast map skipped (never started).
62
64
  def [](index)
63
65
  index += count if index.negative?
64
66
  return nil if index.negative? || index >= count
65
67
 
66
- results = @storage.retrieve_map_results_batch(
67
- @map_id,
68
- @reactor_class_name,
69
- offset: index,
70
- limit: 1,
71
- strict_ordering: @strict_ordering
72
- )
68
+ raw = raw_at(index)
69
+ return nil if raw.nil? || skipped?(raw)
73
70
 
74
- return nil if results.empty?
75
-
76
- wrap_result(results.first)
71
+ wrap_result(raw)
77
72
  end
78
73
 
79
74
  def first
@@ -94,6 +89,17 @@ module RubyReactor
94
89
 
95
90
  private
96
91
 
92
+ def raw_at(index)
93
+ @storage.retrieve_map_results_batch(@map_id, @reactor_class_name, offset: index, limit: 1,
94
+ strict_ordering: @strict_ordering).first
95
+ end
96
+
97
+ # A `_skipped` slot settles an index a fail-fast map never ran (R-04).
98
+ # Such a map is never collected as a success, so no consumer sees one.
99
+ def skipped?(result)
100
+ result.is_a?(Hash) && result.key?("_skipped")
101
+ end
102
+
97
103
  def wrap_result(result)
98
104
  if result.is_a?(Hash) && result.key?("_error")
99
105
  # `backtrace: []` is load-bearing: without it Failure falls back to