ruby_reactor 0.8.4 → 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 +196 -0
- data/CLAUDE.md +1 -1
- data/README.md +47 -11
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- 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/step_builder.rb +91 -19
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +28 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
- data/lib/ruby_reactor/executor/result_handler.rb +18 -16
- 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/step/compose_step.rb +7 -1
- data/lib/ruby_reactor/step/map_step.rb +109 -4
- data/lib/ruby_reactor/step.rb +7 -0
- 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 +17 -6
- 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
- metadata +35 -2
|
@@ -3,6 +3,11 @@
|
|
|
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
|
+
|
|
6
11
|
# Untyped, so no validation: declared only so `inputs.x` can read them.
|
|
7
12
|
input :source
|
|
8
13
|
input :mapped_reactor_class
|
|
@@ -26,11 +31,37 @@ module RubyReactor
|
|
|
26
31
|
end
|
|
27
32
|
end
|
|
28
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.
|
|
29
45
|
def compensate
|
|
30
|
-
|
|
31
|
-
|
|
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)
|
|
32
58
|
end
|
|
33
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
|
+
|
|
34
65
|
class << self
|
|
35
66
|
def build_mapped_inputs(mappings, context, element)
|
|
36
67
|
built = {}
|
|
@@ -79,6 +110,79 @@ module RubyReactor
|
|
|
79
110
|
|
|
80
111
|
private
|
|
81
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
|
+
|
|
82
186
|
# Fans out anywhere except inside a map element: an element's result and
|
|
83
187
|
# the map's completion counter are tracked by its own ElementExecutor job,
|
|
84
188
|
# so a nested hand-off there would escape that tracking. A reactor worker
|
|
@@ -141,7 +245,8 @@ module RubyReactor
|
|
|
141
245
|
name: context.current_step,
|
|
142
246
|
type: :map_ref,
|
|
143
247
|
map_id: map_id,
|
|
144
|
-
element_reactor_class: inputs.mapped_reactor_class.name
|
|
248
|
+
element_reactor_class: inputs.mapped_reactor_class.name,
|
|
249
|
+
started: index + 1
|
|
145
250
|
}
|
|
146
251
|
|
|
147
252
|
executor = RubyReactor::Executor.new(inputs.mapped_reactor_class, {}, child_context)
|
|
@@ -160,7 +265,7 @@ module RubyReactor
|
|
|
160
265
|
begin
|
|
161
266
|
# Collect block receives Result objects when fail_fast is false, values when true
|
|
162
267
|
return RubyReactor::Success(collect_block.call(results))
|
|
163
|
-
rescue
|
|
268
|
+
rescue RubyReactor::Error::Rescuable => e
|
|
164
269
|
return RubyReactor::Failure(e)
|
|
165
270
|
end
|
|
166
271
|
end
|
data/lib/ruby_reactor/step.rb
CHANGED
|
@@ -113,6 +113,13 @@ module RubyReactor
|
|
|
113
113
|
catch(StepSignals::TAG) { new(with_defaults(arguments), context, reason: reason).compensate }
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
+
# Whether `undo` also reverts a run cut short by an interruption. A
|
|
117
|
+
# plain step's body stopped mid-way, so no: only a construct whose undo
|
|
118
|
+
# replays the child work it completed (compose, map) says yes (008 R-16).
|
|
119
|
+
def undoes_partial_run?
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
116
123
|
def input(...)
|
|
117
124
|
own_input_contract.input(...)
|
|
118
125
|
@input_contract = nil
|
|
@@ -88,7 +88,13 @@ module RubyReactor
|
|
|
88
88
|
log(:error, "failed", error: "#{e.class}: #{e.message}")
|
|
89
89
|
complete(RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name, retryable: false),
|
|
90
90
|
context)
|
|
91
|
-
|
|
91
|
+
# Arguments raised: the body never started, so it is neither retried nor
|
|
92
|
+
# compensated (008 R-06).
|
|
93
|
+
rescue Error::ArgumentResolutionError => e
|
|
94
|
+
log(:error, "failed", error: "#{e.class}: #{e.message}")
|
|
95
|
+
complete(RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name, retryable: false,
|
|
96
|
+
exception_class: e.exception_class), context)
|
|
97
|
+
rescue Error::Rescuable => e
|
|
92
98
|
# The unit's failure belongs in its record, where a reader can see it.
|
|
93
99
|
# Raising instead would hand the job to the backend's retry machinery to
|
|
94
100
|
# fail identically N more times while every reader waits out its timeout.
|
|
@@ -238,17 +244,7 @@ module RubyReactor
|
|
|
238
244
|
end
|
|
239
245
|
|
|
240
246
|
def run_step(context, step_config)
|
|
241
|
-
|
|
242
|
-
# arguments are validated or any hold is taken, exactly as
|
|
243
|
-
# `StepExecutor#execute_step_sync` orders it. `complete` persists the
|
|
244
|
-
# nil result, so the reader sees the same skipped unit a same-process
|
|
245
|
-
# step would produce.
|
|
246
|
-
unless step_config.should_run?(context)
|
|
247
|
-
log(:info, "skipped")
|
|
248
|
-
return RubyReactor.Success(nil)
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
arguments = resolve_arguments(step_config, context)
|
|
247
|
+
arguments = step_config.resolve_arguments(context)
|
|
252
248
|
# Reactor-side `argument`/`validate_args` rules gate the step BEFORE its
|
|
253
249
|
# coordination is acquired, exactly as `StepExecutor#execute_step_sync`
|
|
254
250
|
# orders them — an async_step must not take a lock (or spend a rate-limit
|
|
@@ -289,9 +285,43 @@ module RubyReactor
|
|
|
289
285
|
end
|
|
290
286
|
end
|
|
291
287
|
|
|
288
|
+
compensate_unit(context, step_config, result, arguments)
|
|
292
289
|
result
|
|
293
290
|
end
|
|
294
291
|
|
|
292
|
+
# 008 R-09: the unit compensates ITSELF, once, here in its own job, after
|
|
293
|
+
# its final attempt failed — whether or not any step ever reads it, the
|
|
294
|
+
# way an `async_reactor` child rolls itself back. Never for a success,
|
|
295
|
+
# skip or halt, and never for a body that never started (invalid
|
|
296
|
+
# arguments), exactly the executor's rule. Same coordination re-take,
|
|
297
|
+
# middleware events and failure recording as an in-process compensate.
|
|
298
|
+
# The outcome goes on this unit's record (`complete`); the parent's
|
|
299
|
+
# context is never written here (single writer), so the in-memory trace
|
|
300
|
+
# entry the compensate appends stays local to this job.
|
|
301
|
+
def compensate_unit(context, step_config, result, arguments)
|
|
302
|
+
return unless result.is_a?(RubyReactor::Failure) && body_started?(result.error)
|
|
303
|
+
|
|
304
|
+
manager = Executor::CompensationManager.new(context)
|
|
305
|
+
outcome = manager.compensate(step_config, result.error, arguments)
|
|
306
|
+
@compensation = {
|
|
307
|
+
"status" => compensation_status(outcome),
|
|
308
|
+
"rollback_failures" => ContextSerializer.serialize_value(manager.rollback_failures),
|
|
309
|
+
"completed_at" => Time.now.iso8601
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def body_started?(error)
|
|
314
|
+
return false if error.is_a?(Error::InputValidationError)
|
|
315
|
+
|
|
316
|
+
Executor::CompensationManager::NEVER_STARTED_ERROR_CLASSES.none? { |klass| error.is_a?(klass) }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def compensation_status(outcome)
|
|
320
|
+
return "failed" if outcome.is_a?(RubyReactor::Failure)
|
|
321
|
+
|
|
322
|
+
outcome.respond_to?(:skipped?) && outcome.skipped? ? "skipped" : "completed"
|
|
323
|
+
end
|
|
324
|
+
|
|
295
325
|
# Same check and same structured, non-retryable shape `StepExecutor`
|
|
296
326
|
# produces — the same arguments fail the same rules on every attempt.
|
|
297
327
|
def validate_arguments(step_config, arguments)
|
|
@@ -330,7 +360,7 @@ module RubyReactor
|
|
|
330
360
|
retryable: false)
|
|
331
361
|
rescue Executor::StepCoordination::Contended, Executor::StepCoordination::KeyError
|
|
332
362
|
raise
|
|
333
|
-
rescue
|
|
363
|
+
rescue Error::Rescuable => e
|
|
334
364
|
RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name)
|
|
335
365
|
end
|
|
336
366
|
|
|
@@ -356,14 +386,6 @@ module RubyReactor
|
|
|
356
386
|
RubyReactor.Success(result)
|
|
357
387
|
end
|
|
358
388
|
|
|
359
|
-
def resolve_arguments(step_config, context)
|
|
360
|
-
step_config.arguments.to_h do |arg_name, arg_config|
|
|
361
|
-
value = arg_config[:source].resolve(context)
|
|
362
|
-
value = arg_config[:transform].call(value) if arg_config[:transform]
|
|
363
|
-
[arg_name, value]
|
|
364
|
-
end
|
|
365
|
-
end
|
|
366
|
-
|
|
367
389
|
# Write first, publish second. The record is the answer; the signal only
|
|
368
390
|
# saves the reader a fallback interval.
|
|
369
391
|
def complete(result, context)
|
|
@@ -394,8 +416,10 @@ module RubyReactor
|
|
|
394
416
|
|
|
395
417
|
# This delivery's run, once the body was reached: `started_at`,
|
|
396
418
|
# `arguments` (redacted, serialized) and `attempts`. Empty before that.
|
|
419
|
+
# Plus `compensation` when the unit compensated itself.
|
|
397
420
|
def run_fields
|
|
398
|
-
@run || {}
|
|
421
|
+
fields = @run || {}
|
|
422
|
+
@compensation ? fields.merge("compensation" => @compensation) : fields
|
|
399
423
|
end
|
|
400
424
|
|
|
401
425
|
def record_missing_parent
|
|
@@ -133,6 +133,15 @@ module RubyReactor
|
|
|
133
133
|
@redis.decr(key)
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# Settles `amount` indices at once (a fail-fast dispatcher claiming the
|
|
137
|
+
# ones it never dispatched). Returns the count left.
|
|
138
|
+
def decrement_map_counter_by(map_id, amount, reactor_class_name)
|
|
139
|
+
key = map_counter_key(map_id, reactor_class_name)
|
|
140
|
+
left = @redis.decrby(key, amount)
|
|
141
|
+
@redis.expire(key, durability_ttl)
|
|
142
|
+
left
|
|
143
|
+
end
|
|
144
|
+
|
|
136
145
|
def set_last_queued_index(map_id, index, reactor_class_name)
|
|
137
146
|
key = map_last_queued_index_key(map_id, reactor_class_name)
|
|
138
147
|
@redis.set(key, index, ex: durability_ttl)
|
|
@@ -67,7 +67,7 @@ module RubyReactor
|
|
|
67
67
|
|
|
68
68
|
def determine_status(data)
|
|
69
69
|
status = data["status"].to_s == "skipped" ? "halted" : data["status"].to_s # "skipped" is the legacy halt name
|
|
70
|
-
return status if %w[failed paused completed running halted pending].include?(status)
|
|
70
|
+
return status if %w[failed paused completed running halted pending aborted].include?(status)
|
|
71
71
|
return "cancelled" if data["cancelled"]
|
|
72
72
|
# Heuristic
|
|
73
73
|
return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
|
data/lib/ruby_reactor/version.rb
CHANGED
data/lib/ruby_reactor/web/api.rb
CHANGED
|
@@ -173,7 +173,7 @@ module RubyReactor
|
|
|
173
173
|
|
|
174
174
|
def self.reactor_status(data)
|
|
175
175
|
status = data[:status].to_s == "skipped" ? "halted" : data[:status].to_s
|
|
176
|
-
return status if %w[failed paused completed running halted pending].include?(status)
|
|
176
|
+
return status if %w[failed paused completed running halted pending aborted].include?(status)
|
|
177
177
|
return "cancelled" if data[:cancelled]
|
|
178
178
|
return "running" if data[:current_step]
|
|
179
179
|
return "completed" if execution_evidence?(data)
|