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
@@ -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[:amount])
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 one `extend` is
34
- # `Dsl::Lockable::ClassMethods` (the five coordination macros), a
35
- # self-contained module with no hooks of its own (Finding 9).
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
- @inputs = inputs
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
- rescue StandardError => e
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
- # A suppressed step never coordinates (FR-012) — decided before the
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 StandardError => e
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
@@ -56,6 +56,10 @@ module RubyReactor
56
56
  raise NotImplementedError
57
57
  end
58
58
 
59
+ def decrement_map_counter_by(map_id, amount, reactor_class_name)
60
+ raise NotImplementedError
61
+ end
62
+
59
63
  def subscribe(channel, &block)
60
64
  raise NotImplementedError
61
65
  end
@@ -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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.8.3"
4
+ VERSION = "0.8.5"
5
5
  end
@@ -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)