ruby_reactor 0.8.6 → 0.8.8

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 (47) 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 +81 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +42 -3
  7. data/lib/ruby_reactor/dsl/map_builder.rb +20 -1
  8. data/lib/ruby_reactor/error/recorded_failure.rb +18 -0
  9. data/lib/ruby_reactor/executor/compensation_manager.rb +27 -1
  10. data/lib/ruby_reactor/executor/result_handler.rb +16 -0
  11. data/lib/ruby_reactor/executor/retry_manager.rb +2 -2
  12. data/lib/ruby_reactor/executor/step_executor.rb +20 -0
  13. data/lib/ruby_reactor/executor.rb +62 -10
  14. data/lib/ruby_reactor/interrupt_claims.rb +81 -0
  15. data/lib/ruby_reactor/map/step_rollback.rb +121 -0
  16. data/lib/ruby_reactor/reactor.rb +270 -105
  17. data/lib/ruby_reactor/rspec/matchers.rb +64 -16
  18. data/lib/ruby_reactor/rspec/test_subject.rb +25 -24
  19. data/lib/ruby_reactor/step/compose_step.rb +10 -0
  20. data/lib/ruby_reactor/step/map_step.rb +12 -0
  21. data/lib/ruby_reactor/storage/adapter.rb +17 -0
  22. data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor/web/api.rb +17 -0
  25. data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
  26. data/lib/ruby_reactor/web/public/index.html +1 -1
  27. data/lib/ruby_reactor/worker.rb +73 -6
  28. data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  29. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  30. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  31. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  32. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  33. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  34. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  35. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  36. data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
  37. data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
  38. data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
  39. data/specs/010-rollback-follow-ups/data-model.md +139 -0
  40. data/specs/010-rollback-follow-ups/plan.md +273 -0
  41. data/specs/010-rollback-follow-ups/pr-description.md +110 -0
  42. data/specs/010-rollback-follow-ups/quickstart.md +157 -0
  43. data/specs/010-rollback-follow-ups/research.md +440 -0
  44. data/specs/010-rollback-follow-ups/spec.md +489 -0
  45. data/specs/010-rollback-follow-ups/tasks.md +1022 -0
  46. data/specs/future_improvements.md +17 -14
  47. metadata +22 -2
@@ -207,53 +207,101 @@ module RubyReactor
207
207
  end
208
208
  end
209
209
 
210
+ # The subject's last `resume` was accepted and handed to a worker (010
211
+ # FR-009, FR-019): it returned a DispatchResult and the run is `running`.
212
+ # Use with `resume(..., process_jobs: false)`, before draining.
213
+ ::RSpec::Matchers.define :be_resume_deferred do
214
+ match do |subject|
215
+ subject.ensure_executed!
216
+ subject.last_resume_result.is_a?(RubyReactor::DispatchResult) &&
217
+ subject.reactor_instance.context.status.to_s == "running"
218
+ end
219
+
220
+ failure_message do |subject|
221
+ "expected the last resume to be deferred to a worker (DispatchResult, status running), but it " \
222
+ "returned #{subject.last_resume_result.class} and the status is " \
223
+ "#{subject.reactor_instance.context.status}"
224
+ end
225
+
226
+ failure_message_when_negated do |_subject|
227
+ "expected the last resume not to be deferred, but it was handed to a worker"
228
+ end
229
+ end
230
+
231
+ # The map rolled its completed elements back with one `undo_all` call
232
+ # (010 US7); `.with_elements(n)` checks how many results it was given.
233
+ ::RSpec::Matchers.define :have_run_undo_all do |step_name|
234
+ chain(:with_elements) { |count| @count = count }
235
+
236
+ match do |subject|
237
+ subject.ensure_executed!
238
+ @entries = subject.reactor_instance.context.execution_trace.select do |entry|
239
+ fetch = ->(key) { entry[key] || entry[key.to_s] }
240
+ fetch.call(:type).to_s == "undo_all" && fetch.call(:step).to_s == step_name.to_s
241
+ end
242
+ @entries.any? && (@count.nil? || @entries.any? { |e| (e[:count] || e["count"]).to_i == @count })
243
+ end
244
+
245
+ failure_message do |_subject|
246
+ counts = @entries.map { |e| e[:count] || e["count"] }
247
+ if @entries.empty?
248
+ "expected map :#{step_name} to have run undo_all, but it did not"
249
+ else
250
+ "expected map :#{step_name}'s undo_all to get #{@count} elements, but it got #{counts.inspect}"
251
+ end
252
+ end
253
+
254
+ failure_message_when_negated do |_subject|
255
+ "expected map :#{step_name} not to have run undo_all, but it did"
256
+ end
257
+ end
258
+
210
259
  # Matcher to check if reactor is paused at a specific interrupt step
211
260
  # Works with both single and multiple concurrent interrupts
212
261
  matcher :be_paused_at do |*step_names|
262
+ # A step inside a composed child is named by its path, e.g. `[:fulfil, :approve]`.
263
+ expected = -> { step_names.map { |name| RubyReactor::Reactor.interrupt_key(name).inspect }.join(", ") }
264
+
213
265
  match do |subject|
214
266
  subject.ensure_executed!
215
267
  return false unless subject.paused?
216
268
 
217
269
  ready_steps = subject.ready_interrupt_steps
218
- step_names.all? { |name| ready_steps.include?(name.to_sym) }
270
+ step_names.all? { |name| ready_steps.include?(RubyReactor::Reactor.interrupt_key(name)) }
219
271
  end
220
272
 
221
273
  failure_message do |subject|
222
274
  if subject.paused?
223
- ready_steps = subject.ready_interrupt_steps
224
- if step_names.size == 1
225
- "expected reactor to be paused at :#{step_names.first}, " \
226
- "but ready interrupt steps are: #{ready_steps.inspect}"
227
- else
228
- "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
229
- "but ready interrupt steps are: #{ready_steps.inspect}"
230
- end
275
+ "expected reactor to be paused at #{expected.call}, " \
276
+ "but ready interrupt steps are: #{subject.ready_interrupt_steps.inspect}"
231
277
  else
232
- "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
278
+ "expected reactor to be paused at #{expected.call}, " \
233
279
  "but status was #{subject.reactor_instance.context.status}"
234
280
  end
235
281
  end
236
282
 
237
283
  failure_message_when_negated do |_subject|
238
- "expected reactor not to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, but it is"
284
+ "expected reactor not to be paused at #{expected.call}, but it is"
239
285
  end
240
286
  end
241
287
 
242
288
  # Matcher to check the exact set of ready interrupt steps
243
289
  matcher :have_ready_interrupts do |*expected_steps|
290
+ # A step inside a composed child is named by its path, e.g. `[:fulfil, :approve]`.
291
+ expected = -> { expected_steps.map { |name| RubyReactor::Reactor.interrupt_key(name) } }
292
+
244
293
  match do |subject|
245
294
  subject.ensure_executed!
246
295
  return false unless subject.paused?
247
296
 
248
- actual_steps = subject.ready_interrupt_steps.sort
249
- expected = expected_steps.map(&:to_sym).sort
250
- actual_steps == expected
297
+ # Symbols and paths (Arrays) do not compare: sort by their inspect.
298
+ subject.ready_interrupt_steps.sort_by(&:inspect) == expected.call.sort_by(&:inspect)
251
299
  end
252
300
 
253
301
  failure_message do |subject|
254
302
  if subject.paused?
255
303
  actual_steps = subject.ready_interrupt_steps
256
- "expected ready interrupt steps to be #{expected_steps.map { |s| ":#{s}" }}, " \
304
+ "expected ready interrupt steps to be #{expected.call}, " \
257
305
  "but got #{actual_steps.inspect}"
258
306
  else
259
307
  "expected reactor to be paused with ready interrupt steps, " \
@@ -263,7 +311,7 @@ module RubyReactor
263
311
 
264
312
  failure_message_when_negated do |subject|
265
313
  actual_steps = subject.ready_interrupt_steps
266
- "expected ready interrupt steps not to be #{expected_steps.map { |s| ":#{s}" }}, " \
314
+ "expected ready interrupt steps not to be #{expected.call}, " \
267
315
  "but it was #{actual_steps.inspect}"
268
316
  end
269
317
  end
@@ -6,7 +6,7 @@ module RubyReactor
6
6
  class TestSubject
7
7
  include ::RSpec::Mocks::ExampleMethods
8
8
 
9
- attr_reader :reactor_instance, :run_result
9
+ attr_reader :reactor_instance, :run_result, :last_resume_result
10
10
 
11
11
  def initialize(reactor_class:, inputs:, context: {}, async: nil, process_jobs: true)
12
12
  @reactor_class = reactor_class
@@ -405,6 +405,12 @@ module RubyReactor
405
405
  @reactor_instance.context.status.to_s == "paused"
406
406
  end
407
407
 
408
+ # Paused at an interrupt, or running (a resume or background work in
409
+ # progress): either takes a resume for a ready interrupt (010 FR-017).
410
+ def resumable?
411
+ %w[paused running].include?(@reactor_instance.context.status.to_s)
412
+ end
413
+
408
414
  # Whether the run's rollback handed off at a fan-out map and has not
409
415
  # finished (009). Reloads, so it reflects jobs performed since.
410
416
  def rolling_back?
@@ -426,48 +432,43 @@ module RubyReactor
426
432
  # Get all ready interrupt steps (steps that can be resumed)
427
433
  # This is useful when multiple interrupts are waiting concurrently.
428
434
  #
429
- # @return [Array<Symbol>] list of ready interrupt step names
435
+ # @return [Array<Symbol, Array<Symbol>>] the reactor's own interrupts by
436
+ # name, and one inside a composed child by its step path
437
+ # (e.g. `[:fulfil, :approve]`)
430
438
  def ready_interrupt_steps
431
439
  ensure_executed!
432
- return [] unless paused?
433
-
434
- # Build the dependency graph and get ready steps
435
- graph = RubyReactor::DependencyGraph.new
436
- graph_manager = RubyReactor::Executor::GraphManager.new(
437
- @reactor_class, graph, @reactor_instance.context
438
- )
439
- graph_manager.build_and_validate!
440
- graph_manager.mark_completed_steps_from_context
441
-
442
- # Filter to only interrupt steps (using interrupt? predicate method)
443
- ready = graph_manager.dependency_graph.ready_steps
444
- ready.select { |step_config| step_config.respond_to?(:interrupt?) && step_config.interrupt? }
445
- .map { |step_config| step_config.name.to_sym }
440
+ @reactor_instance.ready_interrupt_steps
446
441
  end
447
442
 
448
443
  # Resume a paused reactor with the given payload
449
444
  #
450
445
  # @param payload [Hash] The data to provide to the interrupt step
451
- # @param step [Symbol, String, nil] The specific interrupt step to resume.
446
+ # @param step [Symbol, String, Array, nil] The specific interrupt step to resume,
447
+ # or the step path of one inside a composed child (e.g. `[:fulfil, :approve]`).
452
448
  # Required when multiple interrupts are ready. If not provided and only
453
449
  # one interrupt is ready, that step will be used.
450
+ # @param process_jobs [Boolean, nil] false leaves a hand-off pending (to
451
+ # assert `be_resume_deferred`); nil keeps the subject's setting
454
452
  # @return [TestSubject] self for chaining and introspection
455
453
  # @raise [Error::ValidationError] if the reactor is not paused, step is ambiguous, or payload is invalid
456
- def resume(payload: {}, step: nil)
454
+ def resume(payload: {}, step: nil, process_jobs: nil)
457
455
  ensure_executed!
458
456
 
459
- unless paused?
457
+ # A paused run, or a running one with a ready interrupt, mirroring
458
+ # `continue` (010 FR-017).
459
+ unless resumable?
460
460
  raise RubyReactor::Error::ValidationError,
461
- "Cannot resume: reactor is not paused (status: #{@reactor_instance.context.status})"
461
+ "Cannot resume: reactor is not paused or running (status: #{@reactor_instance.context.status})"
462
462
  end
463
463
 
464
464
  step_name = determine_resume_step(step)
465
465
 
466
466
  # Use the reactor's continue method
467
- @reactor_instance.continue(payload: payload, step_name: step_name)
467
+ @last_resume_result = @reactor_instance.continue(payload: payload, step_name: step_name)
468
468
 
469
469
  # Process any pending async jobs
470
- process_pending_jobs if @process_jobs && AsyncTestHelpers.active?
470
+ drain = process_jobs.nil? ? @process_jobs : process_jobs
471
+ process_pending_jobs if drain && AsyncTestHelpers.active?
471
472
 
472
473
  # Reload the reactor instance to get updated state
473
474
  @reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
@@ -483,10 +484,10 @@ module RubyReactor
483
484
 
484
485
  if step
485
486
  # User explicitly specified a step
486
- step_sym = step.to_sym
487
+ step_sym = RubyReactor::Reactor.interrupt_key(step)
487
488
  unless ready_steps.include?(step_sym)
488
489
  raise RubyReactor::Error::ValidationError,
489
- "Cannot resume: step :#{step} is not ready. Ready steps: #{ready_steps.inspect}"
490
+ "Cannot resume: step #{step_sym.inspect} is not ready. Ready steps: #{ready_steps.inspect}"
490
491
  end
491
492
  step_sym
492
493
  elsif ready_steps.size == 1
@@ -74,6 +74,8 @@ module RubyReactor
74
74
  unless child_context
75
75
  composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
76
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
77
79
  end
78
80
 
79
81
  link_contexts(child_context, context)
@@ -120,6 +122,14 @@ module RubyReactor
120
122
  # the metadata the direct and async paths do propagate.
121
123
  return result if result.is_a?(RubyReactor::Failure)
122
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
+
123
133
  result.success? ? RubyReactor.Success(result.value) : RubyReactor.Failure(result.error)
124
134
  end
125
135
  end
@@ -19,6 +19,7 @@ module RubyReactor
19
19
  input :collect_block, optional: true
20
20
  input :atomic, optional: true
21
21
  input :fan_out, optional: true
22
+ input :undo_all_block, optional: true
22
23
 
23
24
  def run
24
25
  # Initialize map state in context if not present
@@ -46,9 +47,14 @@ module RubyReactor
46
47
  # A map rolls back where it ran (009 R-01): a fan-out map one job per
47
48
  # started element, `batch_size` per throw, handing the run off until
48
49
  # they all report; an inline map in process, 100 elements per read.
50
+ #
51
+ # A map declaring `undo_all` rolls its completed elements back with one
52
+ # call to that block instead (010 R-13).
49
53
  def compensate
50
54
  step_name = context.current_step
51
55
  map_id = "#{context.context_id}:#{step_name}"
56
+ return bulk_rollback(map_id, step_name, undo_all_block) if undo_all_block
57
+
52
58
  dispatched_map_id(step_name) ? distributed_rollback(map_id, step_name) : inline_rollback(map_id, step_name)
53
59
  end
54
60
 
@@ -115,6 +121,12 @@ module RubyReactor
115
121
  context.reactor_class.steps[context.current_step].arguments[:mapped_reactor_class][:source].value
116
122
  end
117
123
 
124
+ # The `undo_all` block, from the same static declaration (009 R-14): the
125
+ # undo record carries no arguments.
126
+ def undo_all_block
127
+ context.reactor_class.steps[context.current_step].arguments[:undo_all_block]&.dig(:source)&.value
128
+ end
129
+
118
130
  # Fans out anywhere except inside a map element: an element's result and
119
131
  # the map's completion counter are tracked by its own ElementExecutor job,
120
132
  # so a nested hand-off there would escape that tracking. A reactor worker
@@ -112,6 +112,23 @@ module RubyReactor
112
112
  raise NotImplementedError
113
113
  end
114
114
 
115
+ def retrieve_map_result_slots(map_id, reactor_class_name, indexes)
116
+ raise NotImplementedError
117
+ end
118
+
119
+ # Interrupt resume claims and attempt counts (010 DM §2, §3).
120
+ def claim_interrupt_resume(context_id, reactor_class_name, step_name, serialized_payload)
121
+ raise NotImplementedError
122
+ end
123
+
124
+ def retrieve_interrupt_resumes(context_id, reactor_class_name, step_names)
125
+ raise NotImplementedError
126
+ end
127
+
128
+ def increment_interrupt_attempts(context_id, reactor_class_name, step_name)
129
+ raise NotImplementedError
130
+ end
131
+
115
132
  # Map rollback records (009 DM §5); see RedisMapRollback.
116
133
  %i[count_map_element_context_ids retrieve_map_element_context_ids_from_tail start_map_rollback
117
134
  retrieve_map_rollback_metadata claim_map_rollback_positions retrieve_map_rollback_offset
@@ -286,6 +286,44 @@ module RubyReactor
286
286
  @redis.hlen(key)
287
287
  end
288
288
 
289
+ # Result slots at `indexes`, aligned with them: nil where a slot is
290
+ # missing (expired), else the stored value, JSON-parsed (010 R-13).
291
+ def retrieve_map_result_slots(map_id, reactor_class_name, indexes)
292
+ return [] if indexes.empty?
293
+
294
+ @redis.hmget(map_results_key(map_id, reactor_class_name), *indexes.map(&:to_s)).map do |raw|
295
+ raw && JSON.parse(raw)
296
+ end
297
+ end
298
+
299
+ # A resume's claim on one interrupt (010 DM §2): the first `SET NX` wins,
300
+ # and the value holds the payload until the run's lock owner applies it.
301
+ # Never deleted; it expires with the run and stays the dedupe marker.
302
+ def claim_interrupt_resume(context_id, reactor_class_name, step_name, serialized_payload) # rubocop:disable Naming/PredicateMethod
303
+ !!@redis.set(interrupt_resume_key(context_id, reactor_class_name, step_name), serialized_payload,
304
+ nx: true, ex: durability_ttl)
305
+ end
306
+
307
+ # `{ "step" => serialized_payload }` for the claimed ones among `step_names`.
308
+ def retrieve_interrupt_resumes(context_id, reactor_class_name, step_names)
309
+ names = Array(step_names).map(&:to_s)
310
+ return {} if names.empty?
311
+
312
+ values = @redis.mget(*names.map { |name| interrupt_resume_key(context_id, reactor_class_name, name) })
313
+ names.zip(values).to_h.compact
314
+ end
315
+
316
+ # Invalid resume payloads counted per interrupt (010 DM §3), kept out of
317
+ # the context blob so counting never writes the run.
318
+ def increment_interrupt_attempts(context_id, reactor_class_name, step_name)
319
+ key = "#{context_key(context_id, reactor_class_name)}:resume_attempts:#{step_name}"
320
+ count, = @redis.multi do |tx|
321
+ tx.incr(key)
322
+ tx.expire(key, durability_ttl)
323
+ end
324
+ count
325
+ end
326
+
289
327
  private
290
328
 
291
329
  # Single source of truth for the retention window of all durability-bearing
@@ -297,6 +335,10 @@ module RubyReactor
297
335
  RubyReactor.configuration.context_ttl
298
336
  end
299
337
 
338
+ def interrupt_resume_key(context_id, reactor_class_name, step_name)
339
+ "#{context_key(context_id, reactor_class_name)}:resume:#{step_name}"
340
+ end
341
+
300
342
  def context_key(context_id, reactor_class_name)
301
343
  "reactor:#{reactor_class_name}:context:#{context_id}"
302
344
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.8.6"
4
+ VERSION = "0.8.8"
5
5
  end
@@ -81,6 +81,8 @@ module RubyReactor
81
81
  ),
82
82
  error: data[:failure_reason]
83
83
  }
84
+ pending = self.class.pending_compensation(api_status, data[:rollback])
85
+ response_data[:pending_compensation] = pending if pending
84
86
 
85
87
  ContextSerializer.simplify_for_api(response_data)
86
88
  end
@@ -152,6 +154,9 @@ module RubyReactor
152
154
  if result.is_a?(RubyReactor::Failure)
153
155
  response.status = 422
154
156
  { error: result.error }
157
+ elsif result.is_a?(RubyReactor::DispatchResult)
158
+ # Accepted and handed to a worker (010 FR-009, FR-019).
159
+ { success: true, message: "Resume accepted" }
155
160
  else
156
161
  { success: true, message: "Reactor resumed" }
157
162
  end
@@ -164,6 +169,18 @@ module RubyReactor
164
169
  end
165
170
  end
166
171
 
172
+ # An aborted run whose failing step's `compensate` an interruption cut
173
+ # off (010 R-11): a manual undo runs it again first.
174
+ def self.pending_compensation(status, rollback)
175
+ return unless status == "aborted" && rollback.is_a?(Hash)
176
+
177
+ fetch = ->(key) { Utils::FetchIndifferent.call(rollback, key) }
178
+ return unless rollback.key?("arguments") || rollback.key?(:arguments)
179
+ return unless fetch.call(:compensated) == false
180
+
181
+ { step: fetch.call(:step).to_s }
182
+ end
183
+
167
184
  def self.scan_limit(raw)
168
185
  limit = raw.to_i
169
186
  return 50 unless limit.positive?