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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +208 -0
- data/CLAUDE.md +1 -1
- data/README.md +140 -102
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
- data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +131 -33
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +21 -18
- data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +59 -49
- data/lib/ruby_reactor/executor.rb +38 -4
- data/lib/ruby_reactor/map/collector.rb +21 -11
- data/lib/ruby_reactor/map/dispatcher.rb +29 -3
- data/lib/ruby_reactor/map/element_executor.rb +9 -3
- data/lib/ruby_reactor/map/helpers.rb +32 -2
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/reactor.rb +24 -0
- data/lib/ruby_reactor/rspec/matchers.rb +19 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
- data/lib/ruby_reactor/step/compose_step.rb +14 -4
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +140 -25
- data/lib/ruby_reactor/step.rb +20 -7
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +1 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +3 -1
- data/lib/ruby_reactor.rb +20 -7
- data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/future_improvements.md +48 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
58
|
-
child_inputs = build_child_inputs(inputs
|
|
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
|
|
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
|
-
|
|
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
|
|
63
|
-
child_context = RubyReactor::Context.new(composed_inputs, inputs
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
|
206
|
+
fail_fast = inputs.fail_fast.nil? || inputs.fail_fast
|
|
93
207
|
|
|
94
|
-
inputs
|
|
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
|
|
114
|
-
child_context = RubyReactor::Context.new(mapped_inputs, inputs
|
|
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
|
|
248
|
+
element_reactor_class: inputs.mapped_reactor_class.name,
|
|
249
|
+
started: index + 1
|
|
135
250
|
}
|
|
136
251
|
|
|
137
|
-
executor = RubyReactor::Executor.new(inputs
|
|
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
|
|
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
|
|
280
|
+
prepare_async_execution(map_id, inputs.source.size)
|
|
166
281
|
|
|
167
|
-
reactor_class_info = build_reactor_class_info(inputs
|
|
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
|
|
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
|
|
192
|
-
strict_ordering: inputs
|
|
193
|
-
**map_recovery_metadata(
|
|
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
|
|
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
|
|
339
|
+
source: inputs.source,
|
|
225
340
|
batch_size: batch_size,
|
|
226
341
|
step_name: step_name,
|
|
227
|
-
argument_mappings: inputs
|
|
228
|
-
strict_ordering: inputs
|
|
229
|
-
mapped_reactor_class: inputs
|
|
230
|
-
fail_fast: inputs
|
|
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
|
|
347
|
+
queue_collector(map_id, step_name, inputs.strict_ordering)
|
|
233
348
|
"map:#{map_id}"
|
|
234
349
|
end
|
|
235
350
|
|