ruby_reactor 0.8.3 → 0.8.5

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +208 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +140 -102
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
  10. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  11. data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
  12. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  13. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  14. data/lib/ruby_reactor/dsl/step_builder.rb +131 -33
  15. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  16. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  17. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  19. data/lib/ruby_reactor/executor/result_handler.rb +21 -18
  20. data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
  21. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  22. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  23. data/lib/ruby_reactor/executor.rb +38 -4
  24. data/lib/ruby_reactor/map/collector.rb +21 -11
  25. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  26. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  27. data/lib/ruby_reactor/map/helpers.rb +32 -2
  28. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  29. data/lib/ruby_reactor/reactor.rb +24 -0
  30. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  31. data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
  32. data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
  33. data/lib/ruby_reactor/step/compose_step.rb +14 -4
  34. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  35. data/lib/ruby_reactor/step/inputs.rb +57 -0
  36. data/lib/ruby_reactor/step/map_step.rb +140 -25
  37. data/lib/ruby_reactor/step.rb +20 -7
  38. data/lib/ruby_reactor/step_worker.rb +46 -22
  39. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  40. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  41. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  42. data/lib/ruby_reactor/version.rb +1 -1
  43. data/lib/ruby_reactor/web/api.rb +1 -1
  44. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  45. data/lib/ruby_reactor/web/public/index.html +1 -1
  46. data/lib/ruby_reactor/worker.rb +3 -1
  47. data/lib/ruby_reactor.rb +20 -7
  48. data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
  49. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  50. data/specs/006-step-retry-declarations/data-model.md +58 -0
  51. data/specs/006-step-retry-declarations/plan.md +187 -0
  52. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  53. data/specs/006-step-retry-declarations/research.md +194 -0
  54. data/specs/006-step-retry-declarations/spec.md +453 -0
  55. data/specs/006-step-retry-declarations/tasks.md +382 -0
  56. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  57. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  58. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  59. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  60. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  61. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  62. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  63. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  64. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  65. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  66. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  67. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  68. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  69. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  72. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  73. data/specs/007-execution-flow-analysis/plan.md +127 -0
  74. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  75. data/specs/007-execution-flow-analysis/research.md +202 -0
  76. data/specs/007-execution-flow-analysis/spec.md +270 -0
  77. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  78. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  79. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  80. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  81. data/specs/008-rollback-reliability/data-model.md +139 -0
  82. data/specs/008-rollback-reliability/plan.md +233 -0
  83. data/specs/008-rollback-reliability/quickstart.md +105 -0
  84. data/specs/008-rollback-reliability/research.md +653 -0
  85. data/specs/008-rollback-reliability/spec.md +561 -0
  86. data/specs/008-rollback-reliability/tasks.md +1110 -0
  87. data/specs/future_improvements.md +48 -0
  88. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  89. metadata +47 -2
@@ -41,7 +41,7 @@ module RubyReactor
41
41
  begin
42
42
  collected = collect_block.call(results)
43
43
  RubyReactor::Success(collected)
44
- rescue StandardError => e
44
+ rescue RubyReactor::Error::Rescuable => e
45
45
  RubyReactor::Failure(e)
46
46
  end
47
47
  else
@@ -83,7 +83,20 @@ module RubyReactor
83
83
  parent_context)
84
84
  failure_response = nil
85
85
  begin
86
- failure_response = executor.result_handler.handle_execution_error(error)
86
+ # The map failed in its element jobs, so no executor compensated it.
87
+ # Fail it exactly as the inline path does: adopt the failing
88
+ # element's rollback failures, compensate the map (its completed
89
+ # elements; every index has settled, R-04), undo the completed
90
+ # steps, and fail with `CompensationError` if the map's rollback
91
+ # was incomplete.
92
+ manager = executor.compensation_manager
93
+ manager.rollback_failures.concat(final_result.rollback_failures)
94
+ failure_response = begin
95
+ manager.handle_step_failure(parent_context.reactor_class.steps[step_name_sym], final_result.error, {})
96
+ executor.result_handler.handle_execution_error(error)
97
+ rescue RubyReactor::Error::CompensationError => e
98
+ executor.result_handler.handle_execution_error(e)
99
+ end
87
100
  # Manually update context status since we're not running executor loop
88
101
  executor.send(:update_context_status, failure_response)
89
102
  ensure
@@ -95,6 +108,12 @@ module RubyReactor
95
108
  store_parent(parent_context, storage)
96
109
  else
97
110
  parent_context.set_result(step_name_sym, final_result.value)
111
+ # A completed fan-out map is tracked for undo like any step (R-03).
112
+ # The record stays empty: `MapStep#undo` finds its elements through
113
+ # the map's element index, so the parent does not grow per element.
114
+ parent_context.undo_stack << { step: parent_context.reactor_class.steps[step_name_sym], arguments: {},
115
+ result: RubyReactor.Success(nil) }
116
+ record_elements_started(parent_context, step_name, storage)
98
117
 
99
118
  # Manually update execution trace to reflect completion
100
119
  # This is necessary because resume_execution continues from the NEXT step
@@ -149,6 +168,17 @@ module RubyReactor
149
168
  )
150
169
  end
151
170
 
171
+ # Every index of a completed map ran: record the count on the map's
172
+ # reference in the parent, which outlives the element index, so a late
173
+ # rollback names each element whose context expired.
174
+ def record_elements_started(parent_context, step_name, storage)
175
+ ref = Utils::FetchIndifferent.call(parent_context.composed_contexts, step_name)
176
+ return unless ref
177
+
178
+ map_id = Utils::FetchIndifferent.call(ref, :map_id)
179
+ ref[:started] = storage.retrieve_map_metadata(map_id, parent_context.reactor_class.name)&.fetch("count", 0).to_i
180
+ end
181
+
152
182
  def store_parent(parent_context, storage)
153
183
  storage.store_context(
154
184
  parent_context.context_id,
@@ -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
@@ -153,6 +153,14 @@ module RubyReactor
153
153
  "Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
154
154
  end
155
155
 
156
+ # Only a reactor paused at an interrupt takes a resume (008 FR-032): one
157
+ # that is executing or rolling back (`running`), finished, or `aborted`
158
+ # must not be run forward from its stored state.
159
+ unless @context.status.to_s == "paused"
160
+ raise Error::ValidationError,
161
+ "Cannot resume: the reactor is #{@context.status}, not paused at an interrupt"
162
+ end
163
+
156
164
  validate_continue_step!(step_name)
157
165
 
158
166
  if (failure = validate_continue_payload(payload, step_name))
@@ -170,6 +178,11 @@ module RubyReactor
170
178
  return @result = enqueue_background_resume
171
179
  end
172
180
 
181
+ # Claim the resume before running it, so a `continue` that arrives while
182
+ # this one executes or rolls back reads `running` and fails (FR-032).
183
+ @context.status = :running
184
+ save_context
185
+
173
186
  # Resume execution
174
187
  executor = Executor.new(self.class, {}, @context)
175
188
  @result = executor.resume_execution
@@ -179,6 +192,12 @@ module RubyReactor
179
192
  @execution_trace = executor.execution_trace
180
193
 
181
194
  @result
195
+ rescue Lock::AcquisitionError, Semaphore::AcquisitionError => e
196
+ # Contended at the resume's own gates: nothing ran, so the run is still
197
+ # paused and the caller may retry, as with `Reactor.run`. A context-lock
198
+ # contention means a live resume holds the run: leave it alone.
199
+ reopen_paused unless executor&.past_gates? || e.is_a?(Lock::ContextLockContention)
200
+ raise
182
201
  rescue Error::InputValidationError => e
183
202
  # This might catch other validations, but here we specifically want payload validation.
184
203
  # The block above handles payload validation explicitly.
@@ -211,6 +230,11 @@ module RubyReactor
211
230
  RubyReactor::Configuration.instance
212
231
  end
213
232
 
233
+ def reopen_paused
234
+ @context.status = :paused
235
+ save_context
236
+ end
237
+
214
238
  def validate_steps!
215
239
  return unless self.class.steps.empty?
216
240
 
@@ -261,6 +261,15 @@ module RubyReactor
261
261
  RubyReactor.configuration.storage_adapter
262
262
  end
263
263
 
264
+ def self.rate_limit_total(key_base, period, since)
265
+ return coordination_adapter.rate_limit_count(key_base, period) unless since
266
+
267
+ seconds = RubyReactor::Period.period_seconds(period)
268
+ ((since.to_i / seconds)..(Time.now.to_i / seconds)).sum do |bucket|
269
+ coordination_adapter.rate_limit_count(key_base, period, now: bucket * seconds)
270
+ end
271
+ end
272
+
264
273
  # Distinguishes `RubyReactor::Halt` (a clean halt) from a plain
265
274
  # `Success`. Works on any object with a `halted?` predicate.
266
275
  #
@@ -419,22 +428,29 @@ module RubyReactor
419
428
  end
420
429
 
421
430
  # Asserts the current rate-limit counter for a (key_base, period) pair.
422
- # Use `.for(period_unit)` to specify which window.
431
+ # Use `.for(period_unit)` to specify which window. Add `.since(time)` to
432
+ # sum every bucket from `time` to now instead — buckets are fixed
433
+ # windows, so a run that straddles a boundary splits its count.
423
434
  #
424
435
  # expect("stripe:42").to have_rate_limit_count(3).for(:second)
436
+ # expect("stripe:42").to have_rate_limit_count(1).for(:minute).since(started_at)
425
437
  ::RSpec::Matchers.define :have_rate_limit_count do |expected|
426
438
  match do |key_base|
427
439
  raise ArgumentError, "have_rate_limit_count requires .for(period)" unless @period
428
440
 
429
- Matchers.coordination_adapter.rate_limit_count(key_base, @period) == expected
441
+ Matchers.rate_limit_total(key_base, @period, @since) == expected
430
442
  end
431
443
 
432
444
  chain :for do |period|
433
445
  @period = period
434
446
  end
435
447
 
448
+ chain :since do |time|
449
+ @since = time
450
+ end
451
+
436
452
  failure_message do |key_base|
437
- actual = Matchers.coordination_adapter.rate_limit_count(key_base, @period)
453
+ actual = Matchers.rate_limit_total(key_base, @period, @since)
438
454
  "expected rate-limit '#{key_base}' (#{@period}) count to be #{expected}, got #{actual}"
439
455
  end
440
456
  end
@@ -556,7 +556,6 @@ module RubyReactor
556
556
  @middlewares = superclass.middlewares.dup
557
557
  @return_step = superclass.return_step
558
558
  @background_handoff = superclass.background_handoff
559
- @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
560
559
 
561
560
  # 2. Add Name Handling with Unique Registry Entry
562
561
  # We must register a unique name so that if this reactor is reloaded (e.g. after async child completion),
@@ -648,7 +647,6 @@ module RubyReactor
648
647
  @middlewares = superclass.middlewares.dup
649
648
  @return_step = superclass.return_step
650
649
  @background_handoff = superclass.background_handoff
651
- @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
652
650
  end
653
651
 
654
652
  strip_background_and_async!(child_class)
@@ -736,7 +734,6 @@ module RubyReactor
736
734
  @middlewares = superclass.middlewares.dup
737
735
  @return_step = superclass.return_step
738
736
  @background_handoff = superclass.background_handoff
739
- @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
740
737
  end
741
738
 
742
739
  # Recursively apply interceptors to the child reactor
@@ -765,14 +762,15 @@ module RubyReactor
765
762
  # callable a mock block can invoke.
766
763
  def original_impl_for(step_config_orig, target_step)
767
764
  if step_config_orig.has_run_block?
768
- step_config_orig.run_block
765
+ # A mock may hand `original` a plain Hash (`args.to_h.merge(...)`).
766
+ ->(args, ctx) { step_config_orig.run_block.call(step_config_orig.wrap_inputs(args), ctx) }
769
767
  elsif step_config_orig.has_impl?
770
768
  impl = step_config_orig.impl
771
769
 
772
770
  if impl.respond_to?(:run_without_coordination)
773
771
  ->(args, ctx) { impl.run_without_coordination(args, ctx) }
774
772
  else
775
- ->(args, ctx) { impl.run(args, ctx) }
773
+ ->(args, ctx) { impl.run(args.to_h, ctx) }
776
774
  end
777
775
  else
778
776
  ->(_, _) { raise "No implementation found for #{target_step}" }
@@ -15,6 +15,10 @@ module RubyReactor
15
15
  # ordered-lock nonce at ENQUEUE time (so ordering matches caller order), and
16
16
  # persist before enqueueing (F2).
17
17
  class AsyncReactorStep < RubyReactor::Step
18
+ # Untyped, so no validation: declared only so `inputs.x` can read them.
19
+ input :async_reactor_class
20
+ input :argument_mappings, optional: true
21
+
18
22
  class << self
19
23
  # Exclusive keys this EXECUTION currently holds, read from the root
20
24
  # context's registry. Shared by the `async_reactor` dispatch check
@@ -54,8 +58,8 @@ module RubyReactor
54
58
  end
55
59
 
56
60
  def run
57
- child_class = inputs[:async_reactor_class]
58
- child_inputs = build_child_inputs(inputs[:argument_mappings] || {})
61
+ child_class = inputs.async_reactor_class
62
+ child_inputs = build_child_inputs(inputs.argument_mappings || {})
59
63
 
60
64
  # A dispatch-time failure fails the DISPATCHING step, i.e. normal saga
61
65
  # handling in the parent. That is deliberately outside the
@@ -3,6 +3,10 @@
3
3
  module RubyReactor
4
4
  class Step
5
5
  class ComposeStep < RubyReactor::Step
6
+ # Untyped, so no validation: declared only so `inputs.x` can read them.
7
+ input :composed_reactor_class
8
+ input :argument_mappings, optional: true
9
+
6
10
  def run
7
11
  step_name = context.current_step
8
12
  composed_data = context.composed_contexts[step_name]
@@ -12,7 +16,7 @@ module RubyReactor
12
16
  store_child_context(step_name, child_context)
13
17
 
14
18
  # Execute the composed reactor
15
- result = execute_child_reactor(inputs[:composed_reactor_class], child_context, composed_data)
19
+ result = execute_child_reactor(inputs.composed_reactor_class, child_context, composed_data)
16
20
 
17
21
  # Update the stored context
18
22
  store_child_context(step_name, child_context)
@@ -30,7 +34,10 @@ module RubyReactor
30
34
  return RubyReactor.Success() unless composed_data && composed_data[:context]
31
35
 
32
36
  child_context = composed_data[:context]
33
- executor = RubyReactor::Executor.new(inputs[:composed_reactor_class], {}, child_context)
37
+ # The child's own class: it survives serialization, while a Class in a
38
+ # stored undo record's arguments comes back as its name (a compose
39
+ # inside a map element is rolled back from its stored row).
40
+ executor = RubyReactor::Executor.new(child_context.reactor_class, {}, child_context)
34
41
  executor.undo_all
35
42
  executor.save_context
36
43
 
@@ -42,6 +49,9 @@ module RubyReactor
42
49
 
43
50
  alias undo compensate
44
51
 
52
+ # An interrupted run is undone too: undo replays only the child's completed steps.
53
+ def self.undoes_partial_run? = true
54
+
45
55
  private
46
56
 
47
57
  def build_composed_inputs(mappings)
@@ -59,8 +69,8 @@ module RubyReactor
59
69
  child_context = composed_data ? composed_data[:context] : nil
60
70
 
61
71
  unless child_context
62
- composed_inputs = build_composed_inputs(inputs[:argument_mappings] || {})
63
- child_context = RubyReactor::Context.new(composed_inputs, inputs[:composed_reactor_class])
72
+ composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
73
+ child_context = RubyReactor::Context.new(composed_inputs, inputs.composed_reactor_class)
64
74
  end
65
75
 
66
76
  link_contexts(child_context, context)
@@ -25,6 +25,11 @@ module RubyReactor
25
25
  def input(name, type = nil, optional: false, default: nil, redact: false, validate: nil, **predicates, &block)
26
26
  # rubocop:enable Metrics/ParameterLists
27
27
  check_dry_validation_available!
28
+ if Step::Inputs.public_method_defined?(name)
29
+ raise Error::ValidationError,
30
+ "input :#{name} is reserved: step code reads inputs as `inputs.#{name}`, and `#{name}` is already " \
31
+ "a method there. Rename the input."
32
+ end
28
33
  unless default.nil? || optional
29
34
  raise Error::ValidationError,
30
35
  "input :#{name} declares `default:` but is required; a default only applies to an " \
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Step
5
+ # What step code (`run`, `undo`, `compensate`) receives as its inputs: a
6
+ # frozen, read-only view of the argument Hash with one reader per name the
7
+ # step may read. Any other name raises `Error::UndeclaredInputError` on the
8
+ # line that reads it, instead of returning nil and failing somewhere else.
9
+ #
10
+ # inputs.order_guid # the value, or nil for an absent optional input
11
+ # inputs.order_id # raises UndeclaredInputError
12
+ # inputs.to_h # supplied values, readable names only
13
+ # Service.call(**inputs) # via `to_hash`
14
+ #
15
+ # Readable names are the contract's declarations, or the keys present when
16
+ # the step has none. Built only where inputs reach step code; everything
17
+ # else in the gem keeps the Hash.
18
+ class Inputs
19
+ def initialize(values, contract: nil, owner: nil)
20
+ @values = values.to_h
21
+ @owner = owner
22
+ declared = contract&.declarations&.keys || []
23
+ @names = declared.empty? ? @values.keys.map(&:to_sym).uniq : declared
24
+ @redacted = contract&.redacted_names || []
25
+ freeze
26
+ end
27
+
28
+ def to_h
29
+ @names.each_with_object({}) do |name, hash|
30
+ hash[name] = Utils::FetchIndifferent.call(@values, name) if supplied?(name)
31
+ end
32
+ end
33
+ alias to_hash to_h
34
+
35
+ def inspect
36
+ to_h.to_h { |name, value| [name, @redacted.include?(name) ? InputContract::REDACTED : value] }.inspect
37
+ end
38
+
39
+ private
40
+
41
+ def method_missing(name, *args)
42
+ return Utils::FetchIndifferent.call(@values, name) if args.empty? && @names.include?(name)
43
+
44
+ declared = @names.empty? ? "none" : @names.map(&:inspect).join(", ")
45
+ raise Error::UndeclaredInputError.new("#{@owner} has no input :#{name}. Declared inputs: #{declared}.", name)
46
+ end
47
+
48
+ def respond_to_missing?(name, include_private = false)
49
+ @names.include?(name) || super
50
+ end
51
+
52
+ def supplied?(name)
53
+ @values.key?(name) || @values.key?(name.to_s)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -3,8 +3,23 @@
3
3
  module RubyReactor
4
4
  class Step
5
5
  class MapStep < RubyReactor::Step
6
+ # Seconds a rollback waits for an element's liveness lock. The last
7
+ # element to settle triggers the collector before its own job releases
8
+ # that lock, so a short wait covers the gap.
9
+ ELEMENT_LOCK_WAIT = 2
10
+
11
+ # Untyped, so no validation: declared only so `inputs.x` can read them.
12
+ input :source
13
+ input :mapped_reactor_class
14
+ input :argument_mappings, optional: true
15
+ input :strict_ordering, optional: true
16
+ input :batch_size, optional: true
17
+ input :collect_block, optional: true
18
+ input :fail_fast, optional: true
19
+ input :fan_out, optional: true
20
+
6
21
  def run
7
- return RubyReactor::Failure("Map source cannot be nil") if inputs[:source].nil?
22
+ return RubyReactor::Failure("Map source cannot be nil") if inputs.source.nil?
8
23
 
9
24
  # Initialize map state in context if not present
10
25
  context.map_operations ||= {}
@@ -16,11 +31,37 @@ module RubyReactor
16
31
  end
17
32
  end
18
33
 
34
+ # Compensating a failed map and undoing a completed one are the same work,
35
+ # as for compose: replay the undo stack of every element that COMPLETED
36
+ # (a failed element already rolled itself back; a halted or skipped one
37
+ # did nothing to undo), highest index first. Elements are found through
38
+ # the index both modes write, so nothing per element lives in the parent
39
+ # (008 R-02). Runs only once every element has settled (R-04), from the
40
+ # execution that owns the map, so it is the elements' only writer.
41
+ #
42
+ # ponytail: serial, in the process that detected the failure, so rollback
43
+ # time is linear in the number of completed elements. Fan the rollback
44
+ # out per element if that ever outgrows one job.
19
45
  def compensate
20
- # TODO: Implement compensation for map steps
21
- RubyReactor.Success()
46
+ step_name = context.current_step
47
+ map_id = "#{context.context_id}:#{step_name}"
48
+ failures = []
49
+
50
+ completed_elements(map_id, failures).each do |index, element_context|
51
+ tag = { map_step: step_name.to_sym, element_index: index }
52
+ failures.concat(rollback_element(map_id, index, element_context).map { |entry| entry.merge(tag) })
53
+ end
54
+
55
+ return RubyReactor.Success() if failures.empty?
56
+
57
+ RubyReactor.Failure("map :#{step_name} rollback incomplete", rollback_failures: failures)
22
58
  end
23
59
 
60
+ alias undo compensate
61
+
62
+ # An interrupted run is undone too: undo replays only each element's completed steps.
63
+ def self.undoes_partial_run? = true
64
+
24
65
  class << self
25
66
  def build_mapped_inputs(mappings, context, element)
26
67
  built = {}
@@ -69,6 +110,79 @@ module RubyReactor
69
110
 
70
111
  private
71
112
 
113
+ # Read from the map step's static declaration, not from the undo record,
114
+ # which a fan-out map leaves empty (R-03).
115
+ def element_class
116
+ context.reactor_class.steps[context.current_step].arguments[:mapped_reactor_class][:source].value
117
+ end
118
+
119
+ # `[[index, context], ...]` for every completed element, highest index
120
+ # first. An element whose row is gone (expired past `context_ttl`) is
121
+ # reported, never skipped silently.
122
+ def completed_elements(map_id, failures)
123
+ storage = RubyReactor.configuration.storage_adapter
124
+ storage_name = RubyReactor.reactor_storage_name(element_class)
125
+ # A parked or retried fan-out element registers its id again.
126
+ ids = storage.retrieve_map_element_context_ids(map_id, context.reactor_class.name).uniq
127
+ rows = ids.map { |id| storage.retrieve_context(id, storage_name) }
128
+ elements = rows.compact.map do |data|
129
+ element_context = RubyReactor::Context.deserialize_from_retry(data)
130
+ [Utils::FetchIndifferent.call(element_context.map_metadata || {}, :index).to_i, element_context]
131
+ end
132
+
133
+ report_unavailable(elements.map(&:first), rows.count(nil), failures)
134
+ # An `aborted` element (an inline run interrupted in it) kept the undo
135
+ # entries of the steps it completed.
136
+ elements.select { |_, element| %w[completed aborted].include?(element.status.to_s) }
137
+ .sort_by { |index, _| -index }
138
+ end
139
+
140
+ # The parent's map reference (in its own blob, so it lives as long as
141
+ # the parent) counts the elements that started: set per element inline,
142
+ # and to the total when a fan-out map completes. With it, every started
143
+ # index without a row is named, even when the index list itself expired;
144
+ # without it (a failed fan-out map skipped some indices), one unnamed
145
+ # entry per indexed row that is gone.
146
+ def report_unavailable(found_indexes, missing_rows, failures)
147
+ ref = Utils::FetchIndifferent.call(context.composed_contexts, context.current_step)
148
+ started = ref && Utils::FetchIndifferent.call(ref, :started)
149
+ missing = started ? (0...started).to_a - found_indexes : [nil] * missing_rows
150
+ missing.each { |index| failures << rollback_entry(index, :context_unavailable, "context expired") }
151
+ end
152
+
153
+ def rollback_entry(index, reason, message)
154
+ step_name = context.current_step.to_sym
155
+ { step: step_name, kind: :undo, key: nil, reason: reason, map_step: step_name, element_index: index,
156
+ message: "map element #{index} #{message}" }
157
+ end
158
+
159
+ # The element's own undo stack, replayed as `ComposeStep` replays its
160
+ # child's, under the element's liveness lock: a held lock after the map
161
+ # settled is a live duplicate delivery, which is left alone and reported.
162
+ def rollback_element(map_id, index, element_context)
163
+ lock = acquire_element_lock(map_id, index)
164
+ return [rollback_entry(index, :element_in_flight, "was still running at rollback time")] if lock == :held
165
+
166
+ executor = RubyReactor::Executor.new(element_class, {}, element_context)
167
+ executor.undo_all
168
+ executor.save_context
169
+ executor.compensation_manager.rollback_failures
170
+ ensure
171
+ lock.release if lock.respond_to?(:release)
172
+ end
173
+
174
+ def acquire_element_lock(map_id, index)
175
+ return nil if RubyReactor::Map::ElementExecutor.inline_testing_mode?
176
+
177
+ config = RubyReactor.configuration
178
+ lock = RubyReactor::Lock.new("map_element:#{map_id}:#{index}",
179
+ owner: SecureRandom.uuid, ttl: config.context_lock_ttl, wait: ELEMENT_LOCK_WAIT)
180
+ lock.acquire
181
+ lock
182
+ rescue RubyReactor::Lock::AcquisitionError
183
+ :held
184
+ end
185
+
72
186
  # Fans out anywhere except inside a map element: an element's result and
73
187
  # the map's completion counter are tracked by its own ElementExecutor job,
74
188
  # so a nested hand-off there would escape that tracking. A reactor worker
@@ -77,21 +191,21 @@ module RubyReactor
77
191
  def fan_out?
78
192
  return false if context.map_metadata || context.root_context&.map_metadata
79
193
 
80
- inputs[:fan_out]
194
+ inputs.fan_out
81
195
  end
82
196
 
83
197
  def run_inline
84
198
  results = execute_inline_map
85
199
  return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
86
200
 
87
- process_results(results, inputs[:collect_block], inputs[:fail_fast])
201
+ process_results(results, inputs.collect_block, inputs.fail_fast)
88
202
  end
89
203
 
90
204
  def execute_inline_map
91
205
  results = []
92
- fail_fast = inputs[:fail_fast].nil? || inputs[:fail_fast]
206
+ fail_fast = inputs.fail_fast.nil? || inputs.fail_fast
93
207
 
94
- inputs[:source].each_with_index do |element, index|
208
+ inputs.source.each_with_index do |element, index|
95
209
  result = execute_single_element(element, index)
96
210
 
97
211
  # An element-level Halt propagates as a run halt: stop immediately
@@ -110,8 +224,8 @@ module RubyReactor
110
224
  end
111
225
 
112
226
  def execute_single_element(element, index)
113
- mapped_inputs = self.class.build_mapped_inputs(inputs[:argument_mappings] || {}, context, element)
114
- child_context = RubyReactor::Context.new(mapped_inputs, inputs[:mapped_reactor_class])
227
+ mapped_inputs = self.class.build_mapped_inputs(inputs.argument_mappings || {}, context, element)
228
+ child_context = RubyReactor::Context.new(mapped_inputs, inputs.mapped_reactor_class)
115
229
 
116
230
  link_contexts(child_context, context)
117
231
 
@@ -131,10 +245,11 @@ module RubyReactor
131
245
  name: context.current_step,
132
246
  type: :map_ref,
133
247
  map_id: map_id,
134
- element_reactor_class: inputs[:mapped_reactor_class].name
248
+ element_reactor_class: inputs.mapped_reactor_class.name,
249
+ started: index + 1
135
250
  }
136
251
 
137
- executor = RubyReactor::Executor.new(inputs[:mapped_reactor_class], {}, child_context)
252
+ executor = RubyReactor::Executor.new(inputs.mapped_reactor_class, {}, child_context)
138
253
  executor.execute
139
254
  executor.result
140
255
  end
@@ -150,7 +265,7 @@ module RubyReactor
150
265
  begin
151
266
  # Collect block receives Result objects when fail_fast is false, values when true
152
267
  return RubyReactor::Success(collect_block.call(results))
153
- rescue StandardError => e
268
+ rescue RubyReactor::Error::Rescuable => e
154
269
  return RubyReactor::Failure(e)
155
270
  end
156
271
  end
@@ -162,9 +277,9 @@ module RubyReactor
162
277
  def run_async(step_name)
163
278
  map_id = "#{context.context_id}:#{step_name}"
164
279
  context.map_operations[step_name.to_s] = map_id
165
- prepare_async_execution(map_id, inputs[:source].size)
280
+ prepare_async_execution(map_id, inputs.source.size)
166
281
 
167
- reactor_class_info = build_reactor_class_info(inputs[:mapped_reactor_class], step_name)
282
+ reactor_class_info = build_reactor_class_info(inputs.mapped_reactor_class, step_name)
168
283
 
169
284
  initialize_map_metadata(map_id, reactor_class_info)
170
285
 
@@ -175,7 +290,7 @@ module RubyReactor
175
290
  name: step_name.to_s,
176
291
  type: :map_ref,
177
292
  map_id: map_id,
178
- element_reactor_class: inputs[:mapped_reactor_class].name
293
+ element_reactor_class: inputs.mapped_reactor_class.name
179
294
  }
180
295
 
181
296
  RubyReactor::DispatchResult.new(
@@ -188,9 +303,9 @@ module RubyReactor
188
303
  def initialize_map_metadata(map_id, reactor_class_info)
189
304
  storage = RubyReactor.configuration.storage_adapter
190
305
  storage.initialize_map_operation(
191
- map_id, inputs[:source].size, context.reactor_class.name,
192
- strict_ordering: inputs[:strict_ordering], reactor_class_info: reactor_class_info,
193
- **map_recovery_metadata(inputs[:step_name] || context.current_step)
306
+ map_id, inputs.source.size, context.reactor_class.name,
307
+ strict_ordering: inputs.strict_ordering, reactor_class_info: reactor_class_info,
308
+ **map_recovery_metadata(context.current_step)
194
309
  )
195
310
  end
196
311
 
@@ -215,21 +330,21 @@ module RubyReactor
215
330
  # own worker, with the map counter/collector tracking completion. This
216
331
  # lets elements with async steps or async retries hand off correctly
217
332
  # instead of being forced to run synchronously in a single worker.
218
- batch_size = inputs[:batch_size] || inputs[:source].size
333
+ batch_size = inputs.batch_size || inputs.source.size
219
334
 
220
335
  RubyReactor::Map::Dispatcher.perform(
221
336
  map_id: map_id,
222
337
  parent_context_id: context.context_id,
223
338
  parent_reactor_class_name: context.reactor_class.name,
224
- source: inputs[:source],
339
+ source: inputs.source,
225
340
  batch_size: batch_size,
226
341
  step_name: step_name,
227
- argument_mappings: inputs[:argument_mappings],
228
- strict_ordering: inputs[:strict_ordering],
229
- mapped_reactor_class: inputs[:mapped_reactor_class],
230
- fail_fast: inputs[:fail_fast].nil? || inputs[:fail_fast]
342
+ argument_mappings: inputs.argument_mappings,
343
+ strict_ordering: inputs.strict_ordering,
344
+ mapped_reactor_class: inputs.mapped_reactor_class,
345
+ fail_fast: inputs.fail_fast.nil? || inputs.fail_fast
231
346
  )
232
- queue_collector(map_id, step_name, inputs[:strict_ordering])
347
+ queue_collector(map_id, step_name, inputs.strict_ordering)
233
348
  "map:#{map_id}"
234
349
  end
235
350