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.
Files changed (72) 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 +196 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +47 -11
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  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/step_builder.rb +91 -19
  13. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  14. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  16. data/lib/ruby_reactor/executor/result_handler.rb +18 -16
  17. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  18. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  19. data/lib/ruby_reactor/executor.rb +38 -4
  20. data/lib/ruby_reactor/map/collector.rb +21 -11
  21. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  22. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  23. data/lib/ruby_reactor/map/helpers.rb +32 -2
  24. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  25. data/lib/ruby_reactor/reactor.rb +24 -0
  26. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  27. data/lib/ruby_reactor/step/compose_step.rb +7 -1
  28. data/lib/ruby_reactor/step/map_step.rb +109 -4
  29. data/lib/ruby_reactor/step.rb +7 -0
  30. data/lib/ruby_reactor/step_worker.rb +46 -22
  31. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  32. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  33. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor/web/api.rb +1 -1
  36. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  37. data/lib/ruby_reactor/web/public/index.html +1 -1
  38. data/lib/ruby_reactor/worker.rb +3 -1
  39. data/lib/ruby_reactor.rb +17 -6
  40. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  41. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  42. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  43. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  44. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  45. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  46. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  47. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  48. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  49. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  50. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  51. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  53. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  54. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  55. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  56. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  57. data/specs/007-execution-flow-analysis/plan.md +127 -0
  58. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  59. data/specs/007-execution-flow-analysis/research.md +202 -0
  60. data/specs/007-execution-flow-analysis/spec.md +270 -0
  61. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  62. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  63. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  64. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  65. data/specs/008-rollback-reliability/data-model.md +139 -0
  66. data/specs/008-rollback-reliability/plan.md +233 -0
  67. data/specs/008-rollback-reliability/quickstart.md +105 -0
  68. data/specs/008-rollback-reliability/research.md +653 -0
  69. data/specs/008-rollback-reliability/spec.md +561 -0
  70. data/specs/008-rollback-reliability/tasks.md +1110 -0
  71. data/specs/future_improvements.md +48 -0
  72. 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
- # TODO: Implement compensation for map steps
31
- 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)
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 StandardError => e
268
+ rescue RubyReactor::Error::Rescuable => e
164
269
  return RubyReactor::Failure(e)
165
270
  end
166
271
  end
@@ -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
- 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.4"
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)