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
data/lib/ruby_reactor/step.rb
CHANGED
|
@@ -5,13 +5,14 @@ module RubyReactor
|
|
|
5
5
|
#
|
|
6
6
|
# class MyStep < RubyReactor::Step
|
|
7
7
|
# input :amount, :integer
|
|
8
|
-
# def run = Success(charged: inputs
|
|
8
|
+
# def run = Success(charged: inputs.amount)
|
|
9
9
|
# end
|
|
10
10
|
#
|
|
11
11
|
# Lifecycle of every class-level call (`.run`/`.call`, `.undo`, `.compensate`):
|
|
12
12
|
#
|
|
13
13
|
# 1. Resolve `inputs`: the given arguments with the contract's defaults
|
|
14
|
-
# applied. `run`, `undo`, and `compensate` all see the same values
|
|
14
|
+
# applied. `run`, `undo`, and `compensate` all see the same values,
|
|
15
|
+
# through a read-only `Step::Inputs` (`inputs.amount`).
|
|
15
16
|
# 2. `.run` ONLY: enforce the declared input contract, raising
|
|
16
17
|
# `Error::InputValidationError` before any instance exists.
|
|
17
18
|
# 2b. `.run` ONLY: step-scoped coordination (`with_lock` etc, if declared)
|
|
@@ -30,17 +31,20 @@ module RubyReactor
|
|
|
30
31
|
# throw (`success!`/`skip!`/`fail!`/`halt!`) into its result wrapper.
|
|
31
32
|
#
|
|
32
33
|
# No `prepend`/`define_method`/`method_missing` — every step in the class
|
|
33
|
-
# reads top to bottom as ordinary method calls. The
|
|
34
|
-
# `Dsl::Lockable::ClassMethods` (the five coordination macros)
|
|
35
|
-
# self-contained
|
|
34
|
+
# reads top to bottom as ordinary method calls. The two `extend`s are
|
|
35
|
+
# `Dsl::Lockable::ClassMethods` (the five coordination macros) and
|
|
36
|
+
# `Dsl::Retryable` (`retries`), self-contained modules whose only hook is
|
|
37
|
+
# an `inherited` that copies their declarations down (Finding 9).
|
|
36
38
|
class Step
|
|
37
39
|
include RubyReactor::StepSignals
|
|
38
40
|
extend RubyReactor::Dsl::Lockable::ClassMethods
|
|
41
|
+
extend RubyReactor::Dsl::Retryable
|
|
39
42
|
|
|
40
43
|
attr_reader :inputs, :context, :result, :reason
|
|
41
44
|
|
|
42
45
|
def initialize(inputs, context, result: nil, reason: nil)
|
|
43
|
-
|
|
46
|
+
contract = self.class.input_contract if self.class.declares_inputs?
|
|
47
|
+
@inputs = Inputs.new(inputs, contract: contract, owner: self.class.name || self.class.inspect)
|
|
44
48
|
@context = context
|
|
45
49
|
@result = result
|
|
46
50
|
@reason = reason
|
|
@@ -109,6 +113,13 @@ module RubyReactor
|
|
|
109
113
|
catch(StepSignals::TAG) { new(with_defaults(arguments), context, reason: reason).compensate }
|
|
110
114
|
end
|
|
111
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
|
+
|
|
112
123
|
def input(...)
|
|
113
124
|
own_input_contract.input(...)
|
|
114
125
|
@input_contract = nil
|
|
@@ -163,11 +174,13 @@ module RubyReactor
|
|
|
163
174
|
@own_input_contract ||= Step::InputContract.new(owner: self)
|
|
164
175
|
end
|
|
165
176
|
|
|
177
|
+
# `to_h`: a step body may hand its own `Inputs` on (`OtherStep.run(inputs, context)`).
|
|
166
178
|
def with_defaults(arguments)
|
|
167
|
-
input_contract.apply_defaults(arguments)
|
|
179
|
+
input_contract.apply_defaults(arguments.to_h)
|
|
168
180
|
end
|
|
169
181
|
|
|
170
182
|
def enforce_contract!(arguments)
|
|
183
|
+
arguments = arguments.to_h
|
|
171
184
|
return arguments unless declares_inputs?
|
|
172
185
|
|
|
173
186
|
input_contract.enforce!(arguments)
|
|
@@ -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)
|