ruby_reactor 0.8.5 → 0.8.7

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 (73) 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 +82 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +27 -8
  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/definition_warnings.rb +30 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
  15. data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
  16. data/lib/ruby_reactor/error/rescuable.rb +6 -1
  17. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
  19. data/lib/ruby_reactor/executor/result_handler.rb +88 -52
  20. data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
  21. data/lib/ruby_reactor/executor/step_executor.rb +23 -1
  22. data/lib/ruby_reactor/executor.rb +186 -6
  23. data/lib/ruby_reactor/map/collector.rb +25 -93
  24. data/lib/ruby_reactor/map/dispatcher.rb +38 -7
  25. data/lib/ruby_reactor/map/element_executor.rb +14 -10
  26. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  27. data/lib/ruby_reactor/map/helpers.rb +11 -155
  28. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  29. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  30. data/lib/ruby_reactor/map.rb +17 -0
  31. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  32. data/lib/ruby_reactor/reactor.rb +113 -24
  33. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  34. data/lib/ruby_reactor/rspec/matchers.rb +55 -37
  35. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  36. data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
  37. data/lib/ruby_reactor/rspec.rb +3 -6
  38. data/lib/ruby_reactor/step/compose_step.rb +13 -0
  39. data/lib/ruby_reactor/step/map_step.rb +117 -114
  40. data/lib/ruby_reactor/storage/adapter.rb +14 -0
  41. data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
  42. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  43. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  44. data/lib/ruby_reactor/sweeper.rb +3 -1
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +11 -1
  47. data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
  48. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  49. data/lib/ruby_reactor/web/public/index.html +2 -2
  50. data/lib/ruby_reactor/worker.rb +4 -2
  51. data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
  53. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  54. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  55. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  56. data/specs/009-distributed-map-undo/data-model.md +151 -0
  57. data/specs/009-distributed-map-undo/plan.md +323 -0
  58. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  59. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  60. data/specs/009-distributed-map-undo/research.md +585 -0
  61. data/specs/009-distributed-map-undo/spec.md +360 -0
  62. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  63. data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  64. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  65. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  66. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  67. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  68. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  69. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  70. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  71. data/specs/future_improvements.md +9 -33
  72. metadata +29 -3
  73. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -34,6 +34,9 @@ module RubyReactor
34
34
  return RubyReactor.Success() unless composed_data && composed_data[:context]
35
35
 
36
36
  child_context = composed_data[:context]
37
+ # Linked like a running child, so a construct in it finds the run that
38
+ # owns it: a fan-out map's rollback resumes that run (009 S-3).
39
+ link_contexts(child_context, context)
37
40
  # The child's own class: it survives serialization, while a Class in a
38
41
  # stored undo record's arguments comes back as its name (a compose
39
42
  # inside a map element is rolled back from its stored row).
@@ -71,6 +74,8 @@ module RubyReactor
71
74
  unless child_context
72
75
  composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
73
76
  child_context = RubyReactor::Context.new(composed_inputs, inputs.composed_reactor_class)
77
+ # Resumed only through this run, never on its own (010 R-07).
78
+ child_context.private_data[:composed] = true
74
79
  end
75
80
 
76
81
  link_contexts(child_context, context)
@@ -117,6 +122,14 @@ module RubyReactor
117
122
  # the metadata the direct and async paths do propagate.
118
123
  return result if result.is_a?(RubyReactor::Failure)
119
124
 
125
+ # The child paused at an interrupt: so does this run. Each compose
126
+ # level re-stamps its own id, so the top-level result names the root.
127
+ if result.is_a?(RubyReactor::InterruptResult)
128
+ return RubyReactor::InterruptResult.new(execution_id: context.context_id,
129
+ correlation_id: result.correlation_id,
130
+ intermediate_results: context.intermediate_results)
131
+ end
132
+
120
133
  result.success? ? RubyReactor.Success(result.value) : RubyReactor.Failure(result.error)
121
134
  end
122
135
  end
@@ -3,6 +3,8 @@
3
3
  module RubyReactor
4
4
  class Step
5
5
  class MapStep < RubyReactor::Step
6
+ include RubyReactor::Map::StepRollback
7
+
6
8
  # Seconds a rollback waits for an element's liveness lock. The last
7
9
  # element to settle triggers the collector before its own job releases
8
10
  # that lock, so a short wait covers the gap.
@@ -15,14 +17,16 @@ module RubyReactor
15
17
  input :strict_ordering, optional: true
16
18
  input :batch_size, optional: true
17
19
  input :collect_block, optional: true
18
- input :fail_fast, optional: true
20
+ input :atomic, optional: true
19
21
  input :fan_out, optional: true
20
22
 
21
23
  def run
22
- return RubyReactor::Failure("Map source cannot be nil") if inputs.source.nil?
23
-
24
24
  # Initialize map state in context if not present
25
25
  context.map_operations ||= {}
26
+ # Dispatched already: the owner run resumed (009 R-03) — adopt the
27
+ # settled outcome, never dispatch again (I-3).
28
+ return adopt_dispatched_map(context.current_step) if dispatched_map_id(context.current_step)
29
+ return RubyReactor::Failure("Map source cannot be nil") if inputs.source.nil?
26
30
 
27
31
  if fan_out?
28
32
  run_async(context.current_step)
@@ -34,27 +38,18 @@ module RubyReactor
34
38
  # Compensating a failed map and undoing a completed one are the same work,
35
39
  # as for compose: replay the undo stack of every element that COMPLETED
36
40
  # (a failed element already rolled itself back; a halted or skipped one
37
- # did nothing to undo), highest index first. Elements are found through
41
+ # did nothing to undo), newest-started first. Elements are found through
38
42
  # the index both modes write, so nothing per element lives in the parent
39
43
  # (008 R-02). Runs only once every element has settled (R-04), from the
40
44
  # execution that owns the map, so it is the elements' only writer.
41
45
  #
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.
46
+ # A map rolls back where it ran (009 R-01): a fan-out map one job per
47
+ # started element, `batch_size` per throw, handing the run off until
48
+ # they all report; an inline map in process, 100 elements per read.
45
49
  def compensate
46
50
  step_name = context.current_step
47
51
  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)
52
+ dispatched_map_id(step_name) ? distributed_rollback(map_id, step_name) : inline_rollback(map_id, step_name)
58
53
  end
59
54
 
60
55
  alias undo compensate
@@ -62,6 +57,10 @@ module RubyReactor
62
57
  # An interrupted run is undone too: undo replays only each element's completed steps.
63
58
  def self.undoes_partial_run? = true
64
59
 
60
+ # The undo record keeps no arguments: rollback reads the map's
61
+ # declaration and records, never the resolved source (009 R-14).
62
+ def self.rollback_arguments(_resolved) = {}
63
+
65
64
  class << self
66
65
  def build_mapped_inputs(mappings, context, element)
67
66
  built = {}
@@ -116,73 +115,6 @@ module RubyReactor
116
115
  context.reactor_class.steps[context.current_step].arguments[:mapped_reactor_class][:source].value
117
116
  end
118
117
 
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
-
186
118
  # Fans out anywhere except inside a map element: an element's result and
187
119
  # the map's completion counter are tracked by its own ElementExecutor job,
188
120
  # so a nested hand-off there would escape that tracking. A reactor worker
@@ -194,16 +126,20 @@ module RubyReactor
194
126
  inputs.fan_out
195
127
  end
196
128
 
129
+ def atomic?
130
+ inputs.atomic.nil? || inputs.atomic
131
+ end
132
+
197
133
  def run_inline
198
134
  results = execute_inline_map
199
135
  return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
200
136
 
201
- process_results(results, inputs.collect_block, inputs.fail_fast)
137
+ collect_results(results)
202
138
  end
203
139
 
204
140
  def execute_inline_map
205
141
  results = []
206
- fail_fast = inputs.fail_fast.nil? || inputs.fail_fast
142
+ atomic = atomic?
207
143
 
208
144
  inputs.source.each_with_index do |element, index|
209
145
  result = execute_single_element(element, index)
@@ -212,12 +148,10 @@ module RubyReactor
212
148
  # rather than being collected as a (nil) value.
213
149
  return result if result.is_a?(RubyReactor::Halt)
214
150
 
215
- if fail_fast && result.failure?
216
- return result # Stop immediately on first failure
217
- end
151
+ return result if atomic && result.failure? # Stop immediately on first failure
218
152
 
219
- # When fail_fast is false, store Result objects; when true, store values
220
- results << (fail_fast ? result.value : result)
153
+ # A non-atomic map collects Result objects; an atomic one, values.
154
+ results << (atomic ? result.value : result)
221
155
  end
222
156
 
223
157
  results
@@ -260,20 +194,6 @@ module RubyReactor
260
194
  child_context.inline_async_execution = parent_context.inline_async_execution
261
195
  end
262
196
 
263
- def process_results(results, collect_block, _fail_fast = true)
264
- if collect_block
265
- begin
266
- # Collect block receives Result objects when fail_fast is false, values when true
267
- return RubyReactor::Success(collect_block.call(results))
268
- rescue RubyReactor::Error::Rescuable => e
269
- return RubyReactor::Failure(e)
270
- end
271
- end
272
-
273
- # Simplified: both branches returned Success(results)
274
- RubyReactor::Success(results)
275
- end
276
-
277
197
  def run_async(step_name)
278
198
  map_id = "#{context.context_id}:#{step_name}"
279
199
  context.map_operations[step_name.to_s] = map_id
@@ -293,10 +213,12 @@ module RubyReactor
293
213
  element_reactor_class: inputs.mapped_reactor_class.name
294
214
  }
295
215
 
216
+ # The owner's id: the run the caller holds, and the one the map's
217
+ # completion resumes (a composed child's own id is internal).
296
218
  RubyReactor::DispatchResult.new(
297
219
  job_id: job_id,
298
220
  intermediate_results: context.intermediate_results,
299
- execution_id: context.context_id
221
+ execution_id: owner_context.context_id
300
222
  )
301
223
  end
302
224
 
@@ -305,10 +227,83 @@ module RubyReactor
305
227
  storage.initialize_map_operation(
306
228
  map_id, inputs.source.size, context.reactor_class.name,
307
229
  strict_ordering: inputs.strict_ordering, reactor_class_info: reactor_class_info,
230
+ owner_context_id: owner_context.context_id,
231
+ owner_reactor_class_name: RubyReactor.reactor_storage_name(owner_context.reactor_class),
232
+ batch_size: effective_batch_size, atomic: atomic?,
308
233
  **map_recovery_metadata(context.current_step)
309
234
  )
310
235
  end
311
236
 
237
+ # The top-level run: the one execution that writes the context tree, and
238
+ # the one the map's completion resumes (009 R-03).
239
+ def owner_context
240
+ context.root_context || context
241
+ end
242
+
243
+ # Declared, or `Map::DEFAULT_BATCH_SIZE`: no throw enqueues more (R-10).
244
+ def effective_batch_size
245
+ inputs.batch_size || RubyReactor::Map::DEFAULT_BATCH_SIZE
246
+ end
247
+
248
+ def dispatched_map_id(step_name)
249
+ Utils::FetchIndifferent.call(context.map_operations || {}, step_name)
250
+ end
251
+
252
+ # Re-entry of a dispatched map (S-1). Unsettled (an early or duplicate
253
+ # resume): hand off again without dispatching. Settled: the failing
254
+ # element's Failure for an atomic map, else the collected results. The
255
+ # executor records either like any step result.
256
+ def adopt_dispatched_map(step_name)
257
+ map_id = dispatched_map_id(step_name)
258
+ storage = RubyReactor.configuration.storage_adapter
259
+ metadata = storage.retrieve_map_metadata(map_id, context.reactor_class.name)
260
+ return RubyReactor::Failure("map :#{step_name} records expired before it settled") unless metadata
261
+
262
+ total = metadata["count"].to_i
263
+ if storage.count_map_results(map_id, context.reactor_class.name) < total
264
+ return RubyReactor::DispatchResult.new(job_id: "map:#{map_id}", execution_id: owner_context.context_id,
265
+ intermediate_results: context.intermediate_results)
266
+ end
267
+
268
+ failed_id = storage.retrieve_map_failed_context_id(map_id, context.reactor_class.name)
269
+ return adopt_element_failure(step_name, failed_id) if failed_id
270
+
271
+ record_elements_started(step_name, total)
272
+ collect_results(RubyReactor::Map::ResultEnumerator.new(map_id, context.reactor_class.name,
273
+ strict_ordering: inputs.strict_ordering))
274
+ end
275
+
276
+ # The element's own Failure, carrying its rollback failures: the executor
277
+ # compensates the map (its completed elements) and rolls the run back.
278
+ def adopt_element_failure(step_name, failed_id)
279
+ data = RubyReactor.configuration.storage_adapter.retrieve_context(
280
+ failed_id, RubyReactor.reactor_storage_name(element_class)
281
+ )
282
+ return RubyReactor::Failure("map :#{step_name} element failed; its context expired") unless data
283
+
284
+ reason = RubyReactor::Context.deserialize_from_retry(data).failure_reason
285
+ reason.is_a?(RubyReactor::Failure) ? reason : RubyReactor::Failure(reason)
286
+ end
287
+
288
+ # The collect block gets Result objects for a non-atomic map, values for
289
+ # an atomic one; a raise is the map's failure.
290
+ def collect_results(results)
291
+ return RubyReactor::Success(results) unless inputs.collect_block
292
+
293
+ RubyReactor::Success(inputs.collect_block.call(results))
294
+ rescue RubyReactor::Error::Rescuable => e
295
+ RubyReactor.configuration.logger.error("Map collect block raised: #{e.message}")
296
+ RubyReactor::Failure(e)
297
+ end
298
+
299
+ # Every index of a completed map ran: record the count on the map's
300
+ # reference, which outlives the element index, so a late rollback names
301
+ # each element whose context expired.
302
+ def record_elements_started(step_name, total)
303
+ ref = Utils::FetchIndifferent.call(context.composed_contexts, step_name)
304
+ ref[:started] = total if ref
305
+ end
306
+
312
307
  # Recovery metadata for the map sweeper. When this map runs inside a map
313
308
  # element (context.map_metadata present), it is a NESTED map: its parent
314
309
  # holds the element's `map_element:` lock, not an `async:` lock (N1).
@@ -324,13 +319,13 @@ module RubyReactor
324
319
  end
325
320
 
326
321
  def dispatch_async_map(map_id, _reactor_class_info, step_name)
327
- # Every async map runs through the per-element Dispatcher path. When no
328
- # batch_size is given we default to the full source size (one fan-out
329
- # batch), so there is a single execution path: each element runs in its
330
- # own worker, with the map counter/collector tracking completion. This
331
- # lets elements with async steps or async retries hand off correctly
332
- # instead of being forced to run synchronously in a single worker.
333
- batch_size = inputs.batch_size || inputs.source.size
322
+ # Every async map runs through the per-element Dispatcher path: each
323
+ # element runs in its own worker, with the map counter/collector
324
+ # tracking completion. This lets elements with async steps or async
325
+ # retries hand off correctly instead of being forced to run
326
+ # synchronously in a single worker. Without a declared batch_size, at
327
+ # most `Map::DEFAULT_BATCH_SIZE` are enqueued per throw (009 R-10).
328
+ batch_size = effective_batch_size
334
329
 
335
330
  RubyReactor::Map::Dispatcher.perform(
336
331
  map_id: map_id,
@@ -342,18 +337,26 @@ module RubyReactor
342
337
  argument_mappings: inputs.argument_mappings,
343
338
  strict_ordering: inputs.strict_ordering,
344
339
  mapped_reactor_class: inputs.mapped_reactor_class,
345
- fail_fast: inputs.fail_fast.nil? || inputs.fail_fast
340
+ atomic: atomic?
346
341
  )
347
342
  queue_collector(map_id, step_name, inputs.strict_ordering)
348
343
  "map:#{map_id}"
349
344
  end
350
345
 
346
+ # Stores the context the Dispatcher reads the source from, and — for a
347
+ # map inside a composed child — the owner's tree, which the map's
348
+ # completion resumes (009 R-03): a fast resume must find it consistent,
349
+ # as `handle_background_handoff` ensures for a `background` hand-off.
351
350
  def prepare_async_execution(map_id, count)
352
351
  storage = RubyReactor.configuration.storage_adapter
353
352
  middlewares = context.middlewares || Executor.middlewares_for(context.reactor_class)
354
353
  middlewares.on(:before_async_enqueue, context)
355
354
  serialized_context = ContextSerializer.serialize(context)
356
355
  storage.store_context(context.context_id, serialized_context, context.reactor_class.name)
356
+ unless owner_context.equal?(context)
357
+ storage.store_context(owner_context.context_id, ContextSerializer.serialize(owner_context),
358
+ RubyReactor.reactor_storage_name(owner_context.reactor_class))
359
+ end
357
360
  storage.set_map_counter(map_id, count, context.reactor_class.name)
358
361
  end
359
362
 
@@ -107,6 +107,20 @@ module RubyReactor
107
107
  def retrieve_map_element_context_ids(map_id, reactor_class_name)
108
108
  raise NotImplementedError
109
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
110
124
  end
111
125
  end
112
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
 
@@ -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 aborted].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.5"
4
+ VERSION = "0.8.7"
5
5
  end