ruby_reactor 0.8.4 → 0.8.6

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -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
@@ -103,6 +107,20 @@ module RubyReactor
103
107
  def retrieve_map_element_context_ids(map_id, reactor_class_name)
104
108
  raise NotImplementedError
105
109
  end
110
+
111
+ def claim_map_owner_signal(map_id, reactor_class_name)
112
+ raise NotImplementedError
113
+ end
114
+
115
+ # Map rollback records (009 DM §5); see RedisMapRollback.
116
+ %i[count_map_element_context_ids retrieve_map_element_context_ids_from_tail start_map_rollback
117
+ retrieve_map_rollback_metadata claim_map_rollback_positions retrieve_map_rollback_offset
118
+ store_map_rollback_outcome count_map_rollback_outcomes map_rollback_outcome_stored?
119
+ stored_map_rollback_positions each_map_rollback_outcome map_rollback_indexes_seen
120
+ mark_map_rollback_handed_off map_rollback_handed_off? claim_map_rollback_signal map_rollback_summary
121
+ scan_map_rollbacks].each do |method|
122
+ define_method(method) { |*, **| raise NotImplementedError }
123
+ end
106
124
  end
107
125
  end
108
126
  end
@@ -11,6 +11,7 @@ module RubyReactor
11
11
  include RedisStepResults
12
12
  include RedisPubSub
13
13
  include RedisReactorScan
14
+ include RedisMapRollback
14
15
 
15
16
  def initialize(redis_config)
16
17
  super()
@@ -70,7 +71,8 @@ module RubyReactor
70
71
  # rubocop:disable Metrics/ParameterLists
71
72
  def initialize_map_operation(map_id, count, parent_reactor_class_name, reactor_class_info:, strict_ordering: true,
72
73
  parent_context_id: nil, step_name: nil, parent_is_map_element: false,
73
- outer_map_id: nil, outer_index: nil)
74
+ outer_map_id: nil, outer_index: nil, owner_context_id: nil,
75
+ owner_reactor_class_name: nil, batch_size: nil, atomic: nil)
74
76
  # Ensure counter is set
75
77
  set_map_counter(map_id, count, parent_reactor_class_name)
76
78
 
@@ -79,7 +81,9 @@ module RubyReactor
79
81
  # nested-map fields (parent_is_map_element + outer_map_id/outer_index)
80
82
  # record which liveness lock the parent actually holds (N1): a nested
81
83
  # map's parent is itself a map element running under a `map_element:` lock,
82
- # not an `async:` lock.
84
+ # not an `async:` lock. The owner is the top-level run the collector
85
+ # resumes (009 R-03); `batch_size` and `atomic` let a re-dispatched
86
+ # element keep both (R-10).
83
87
  key = "reactor:#{parent_reactor_class_name}:map:#{map_id}:metadata"
84
88
  metadata = {
85
89
  map_id: map_id,
@@ -92,12 +96,22 @@ module RubyReactor
92
96
  parent_is_map_element: parent_is_map_element,
93
97
  outer_map_id: outer_map_id,
94
98
  outer_index: outer_index,
99
+ owner_context_id: owner_context_id,
100
+ owner_reactor_class_name: owner_reactor_class_name,
101
+ batch_size: batch_size,
102
+ atomic: atomic,
95
103
  created_at: Time.now.to_i
96
104
  }
97
105
  @redis.set(key, metadata.to_json, ex: durability_ttl)
98
106
  end
99
107
  # rubocop:enable Metrics/ParameterLists
100
108
 
109
+ # Claimed once, by the collector that found the map settled: only that
110
+ # one enqueues the owner's Worker (009 DM §3).
111
+ def claim_map_owner_signal(map_id, reactor_class_name) # rubocop:disable Naming/PredicateMethod
112
+ !!@redis.set("reactor:#{reactor_class_name}:map:#{map_id}:owner_signalled", "1", nx: true, ex: durability_ttl)
113
+ end
114
+
101
115
  # Enumerate active map operations for the map sweeper (Phase 5d). Returns
102
116
  # the parsed metadata hash for each (includes map_id, count,
103
117
  # parent_context_id, step_name, parent_reactor_class_name, and the nested-map
@@ -105,6 +119,8 @@ module RubyReactor
105
119
  def scan_maps(count: 1000)
106
120
  results = []
107
121
  @redis.scan_each(match: "reactor:*:map:*:metadata", count: 100) do |key|
122
+ next if key.end_with?(":rollback:metadata") # a hash, listed by scan_map_rollbacks
123
+
108
124
  json = @redis.get(key)
109
125
  next unless json
110
126
 
@@ -133,6 +149,15 @@ module RubyReactor
133
149
  @redis.decr(key)
134
150
  end
135
151
 
152
+ # Settles `amount` indices at once (a fail-fast dispatcher claiming the
153
+ # ones it never dispatched). Returns the count left.
154
+ def decrement_map_counter_by(map_id, amount, reactor_class_name)
155
+ key = map_counter_key(map_id, reactor_class_name)
156
+ left = @redis.decrby(key, amount)
157
+ @redis.expire(key, durability_ttl)
158
+ left
159
+ end
160
+
136
161
  def set_last_queued_index(map_id, index, reactor_class_name)
137
162
  key = map_last_queued_index_key(map_id, reactor_class_name)
138
163
  @redis.set(key, index, ex: durability_ttl)
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Storage
5
+ # The records of one fan-out map's distributed rollback (009 DM §5), under
6
+ # `reactor:<P>:map:<map_id>:rollback:…`. Element rollback jobs write only
7
+ # these and their own element context, never a reactor context (I-1).
8
+ #
9
+ # Positions count from the tail of the element-context index, anchored at
10
+ # the `total` the rollback started with, so an id a late duplicate appends
11
+ # afterwards never shifts them.
12
+ module RedisMapRollback
13
+ # Creates the metadata only if no rollback of this map exists (I-3).
14
+ START_ROLLBACK_SCRIPT = <<~LUA
15
+ if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
16
+ redis.call('HSET', KEYS[1], unpack(ARGV, 2))
17
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
18
+ return 1
19
+ LUA
20
+
21
+ FAILED_OUTCOMES = %w[failed element_in_flight context_unavailable].freeze
22
+
23
+ def count_map_element_context_ids(map_id, reactor_class_name)
24
+ @redis.llen(map_element_contexts_key(map_id, reactor_class_name))
25
+ end
26
+
27
+ # Ids at tail positions `position...position + count`, in tail order.
28
+ def retrieve_map_element_context_ids_from_tail(map_id, reactor_class_name, position, count, total:)
29
+ stop = total - 1 - position
30
+ return [] if stop.negative?
31
+
32
+ start = [total - position - count, 0].max
33
+ @redis.lrange(map_element_contexts_key(map_id, reactor_class_name), start, stop).reverse
34
+ end
35
+
36
+ # `[created, metadata]`: a second start finds the first one's records.
37
+ def start_map_rollback(map_id, reactor_class_name, **meta)
38
+ fields = meta.merge(map_id: map_id, parent_reactor_class_name: reactor_class_name, started_at: Time.now.to_i)
39
+ argv = fields.flat_map { |k, v| [k.to_s, JSON.generate(v)] }
40
+ created = @redis.eval(START_ROLLBACK_SCRIPT, keys: [map_rollback_key(map_id, reactor_class_name, :metadata)],
41
+ argv: [durability_ttl, *argv])
42
+ [created == 1, retrieve_map_rollback_metadata(map_id, reactor_class_name)]
43
+ end
44
+
45
+ def retrieve_map_rollback_metadata(map_id, reactor_class_name)
46
+ parse_rollback_metadata(@redis.hgetall(map_rollback_key(map_id, reactor_class_name, :metadata)))
47
+ end
48
+
49
+ # Claims the next `count` positions (R-02), clipped to the total.
50
+ def claim_map_rollback_positions(map_id, reactor_class_name, count)
51
+ key = map_rollback_key(map_id, reactor_class_name, :offset)
52
+ stop = @redis.incrby(key, count)
53
+ @redis.expire(key, durability_ttl)
54
+ total = retrieve_map_rollback_metadata(map_id, reactor_class_name)&.fetch("total", 0).to_i
55
+ (stop - count)...[stop, total].min
56
+ end
57
+
58
+ def retrieve_map_rollback_offset(map_id, reactor_class_name)
59
+ @redis.get(map_rollback_key(map_id, reactor_class_name, :offset)).to_i
60
+ end
61
+
62
+ # The first outcome for a position wins: a duplicate delivery, which
63
+ # finds the element already undone, must not replace a `failed` outcome
64
+ # with its own `undone`. Returns whether this call stored it.
65
+ def store_map_rollback_outcome(map_id, reactor_class_name, position, outcome) # rubocop:disable Naming/PredicateMethod
66
+ results = map_rollback_key(map_id, reactor_class_name, :results)
67
+ indexes = map_rollback_key(map_id, reactor_class_name, :indexes)
68
+ index = outcome["index"] || outcome[:index]
69
+ stored, = @redis.multi do |tx|
70
+ tx.hsetnx(results, position.to_s, JSON.generate(outcome))
71
+ tx.expire(results, durability_ttl)
72
+ tx.sadd(indexes, index.to_s) unless index.nil?
73
+ tx.expire(indexes, durability_ttl)
74
+ end
75
+ [true, 1].include?(stored)
76
+ end
77
+
78
+ def count_map_rollback_outcomes(map_id, reactor_class_name)
79
+ @redis.hlen(map_rollback_key(map_id, reactor_class_name, :results))
80
+ end
81
+
82
+ def stored_map_rollback_positions(map_id, reactor_class_name)
83
+ @redis.hkeys(map_rollback_key(map_id, reactor_class_name, :results)).map(&:to_i)
84
+ end
85
+
86
+ def map_rollback_outcome_stored?(map_id, reactor_class_name, position)
87
+ @redis.hexists(map_rollback_key(map_id, reactor_class_name, :results), position.to_s)
88
+ end
89
+
90
+ # Yields `position, outcome` in bounded reads.
91
+ def each_map_rollback_outcome(map_id, reactor_class_name)
92
+ @redis.hscan_each(map_rollback_key(map_id, reactor_class_name, :results), count: 500) do |position, json|
93
+ yield position.to_i, JSON.parse(json)
94
+ end
95
+ end
96
+
97
+ # One boolean per index: whether a rollback job saw that element (R-09).
98
+ def map_rollback_indexes_seen(map_id, reactor_class_name, indexes)
99
+ key = map_rollback_key(map_id, reactor_class_name, :indexes)
100
+ @redis.pipelined { |pipe| indexes.each { |index| pipe.sismember(key, index.to_s) } }
101
+ end
102
+
103
+ def mark_map_rollback_handed_off(map_id, reactor_class_name)
104
+ @redis.set(map_rollback_key(map_id, reactor_class_name, :handed_off), "1", ex: durability_ttl)
105
+ end
106
+
107
+ def map_rollback_handed_off?(map_id, reactor_class_name)
108
+ @redis.exists?(map_rollback_key(map_id, reactor_class_name, :handed_off))
109
+ end
110
+
111
+ def claim_map_rollback_signal(map_id, reactor_class_name) # rubocop:disable Naming/PredicateMethod
112
+ !!@redis.set(map_rollback_key(map_id, reactor_class_name, :signalled), "1", nx: true, ex: durability_ttl)
113
+ end
114
+
115
+ # Progress for the dashboard (R-15), or nil when this map never rolled back.
116
+ def map_rollback_summary(map_id, reactor_class_name)
117
+ meta = retrieve_map_rollback_metadata(map_id, reactor_class_name)
118
+ return nil unless meta
119
+
120
+ failed = 0
121
+ each_map_rollback_outcome(map_id, reactor_class_name) do |_, outcome|
122
+ failed += 1 if FAILED_OUTCOMES.include?(outcome["outcome"])
123
+ end
124
+ total = meta["total"].to_i
125
+ settled = count_map_rollback_outcomes(map_id, reactor_class_name)
126
+ { total: total, settled: settled, outstanding: total - settled, failed: failed }
127
+ end
128
+
129
+ # Every started map rollback, for `Map::Sweeper` (S-5).
130
+ def scan_map_rollbacks(count: 1000)
131
+ results = []
132
+ @redis.scan_each(match: "reactor:*:map:*:rollback:metadata", count: 100) do |key|
133
+ meta = parse_rollback_metadata(@redis.hgetall(key))
134
+ results << meta if meta
135
+ return results if results.size >= count
136
+ end
137
+ results
138
+ end
139
+
140
+ private
141
+
142
+ def parse_rollback_metadata(raw)
143
+ return nil if raw.nil? || raw.empty?
144
+
145
+ raw.transform_values { |value| JSON.parse(value) }
146
+ end
147
+
148
+ def map_rollback_key(map_id, reactor_class_name, suffix)
149
+ "reactor:#{reactor_class_name}:map:#{map_id}:rollback:#{suffix}"
150
+ end
151
+ end
152
+ end
153
+ end
@@ -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 rolling_back 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?
@@ -48,7 +48,9 @@ module RubyReactor
48
48
  reenqueued = 0
49
49
 
50
50
  @storage.scan_reactors(count: limit, include_dispatched_children: true).each do |reactor|
51
- next unless reactor[:status] == "running" # non-terminal only
51
+ # Non-terminal only. A `rolling_back` run whose owner resume was lost
52
+ # is re-enqueued too: unsettled, it simply hands off again (009 S-5).
53
+ next unless %w[running rolling_back].include?(reactor[:status])
52
54
  next if @storage.lock_held?("async:#{reactor[:id]}") # worker alive -> leave alone
53
55
 
54
56
  @async_router.perform_async(reactor[:id], reactor[:class])
@@ -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.6"
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 rolling_back 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)
@@ -393,7 +393,17 @@ module RubyReactor
393
393
  ref_data.merge("context" => child)
394
394
  end
395
395
 
396
+ # A map that rolled back distributed also carries its progress, `{
397
+ # total, settled, outstanding, failed }` (009 R-15). The records outlive
398
+ # the run, so a client shows it only while the run is `rolling_back`.
396
399
  def self.hydrate_map_ref(ref_data, reactor_class_name)
400
+ map_id = ref_data[:map_id] || ref_data["map_id"]
401
+ hydrated = hydrate_map_element(ref_data, reactor_class_name)
402
+ rollback = map_id && RubyReactor.configuration.storage_adapter.map_rollback_summary(map_id, reactor_class_name)
403
+ rollback ? hydrated.merge("rollback" => rollback.transform_keys(&:to_s)) : hydrated
404
+ end
405
+
406
+ def self.hydrate_map_element(ref_data, reactor_class_name)
397
407
  storage = RubyReactor.configuration.storage_adapter
398
408
  map_id = ref_data[:map_id] || ref_data["map_id"]
399
409