ruby_reactor 0.8.7 → 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 (35) 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 +59 -2
  5. data/CLAUDE.md +1 -1
  6. data/README.md +38 -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 +21 -1
  10. data/lib/ruby_reactor/executor.rb +62 -10
  11. data/lib/ruby_reactor/interrupt_claims.rb +81 -0
  12. data/lib/ruby_reactor/map/step_rollback.rb +121 -0
  13. data/lib/ruby_reactor/reactor.rb +210 -95
  14. data/lib/ruby_reactor/rspec/matchers.rb +49 -0
  15. data/lib/ruby_reactor/rspec/test_subject.rb +17 -6
  16. data/lib/ruby_reactor/step/map_step.rb +12 -0
  17. data/lib/ruby_reactor/storage/adapter.rb +17 -0
  18. data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
  19. data/lib/ruby_reactor/version.rb +1 -1
  20. data/lib/ruby_reactor/web/api.rb +17 -0
  21. data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
  22. data/lib/ruby_reactor/web/public/index.html +1 -1
  23. data/lib/ruby_reactor/worker.rb +73 -6
  24. data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
  25. data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
  26. data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
  27. data/specs/010-rollback-follow-ups/data-model.md +139 -0
  28. data/specs/010-rollback-follow-ups/plan.md +273 -0
  29. data/specs/010-rollback-follow-ups/pr-description.md +110 -0
  30. data/specs/010-rollback-follow-ups/quickstart.md +157 -0
  31. data/specs/010-rollback-follow-ups/research.md +440 -0
  32. data/specs/010-rollback-follow-ups/spec.md +489 -0
  33. data/specs/010-rollback-follow-ups/tasks.md +1022 -0
  34. data/specs/future_improvements.md +17 -2
  35. metadata +14 -2
@@ -9,6 +9,12 @@ module RubyReactor
9
9
  # Seconds a manual undo waits for the run's context lock (009 R-12).
10
10
  UNDO_LOCK_WAIT = 5
11
11
 
12
+ # Statuses a resume is accepted in: a run paused at an interrupt (008
13
+ # FR-032), or one executing (another resume, its first run, a wait on
14
+ # background work) for an interrupt that has not run yet (010 FR-017). A
15
+ # finished, `aborted` or rolling-back run is never run forward.
16
+ RESUMABLE_STATUSES = %w[paused running].freeze
17
+
12
18
  attr_reader :context, :result, :undo_trace, :execution_trace
13
19
 
14
20
  def self.find(id)
@@ -155,66 +161,36 @@ module RubyReactor
155
161
  @result
156
162
  end
157
163
 
164
+ # Accepting a resume (010 R-05, contracts/resume-protocol.md P-2–P-4):
165
+ #
166
+ # 1. checks on the loaded snapshot, then the payload is validated here, in
167
+ # the caller's process. An invalid payload stores and enqueues nothing;
168
+ # 2. the interrupt is claimed (`SET NX`): one resume per interrupt, in every
169
+ # execution mode. The claim holds the payload;
170
+ # 3. if the run's lock is free, this process owns the run: it reloads it and
171
+ # resumes inline, as before. Otherwise (another execution is live, the
172
+ # reactor's lock or semaphore is held, or a background-resume interrupt)
173
+ # the resume is handed to a Worker, which applies the claim under the
174
+ # run's lock, and a `DispatchResult` comes back.
175
+ #
176
+ # The payload is never validated again (J-7), so an accepted resume cannot
177
+ # fail validation later.
158
178
  def continue(payload:, step_name:, idempotency_key: nil)
159
179
  _ = idempotency_key
160
180
 
161
- unless @context.current_step
162
- raise Error::ValidationError, "Cannot resume: context does not have a current step (was it interrupted?)"
163
- end
164
-
165
- # A composed child pauses only inside its root, which owns the run (010 R-07).
166
- if @context.private_data[:composed] || @context.private_data["composed"]
167
- raise Error::ValidationError, "Cannot resume: #{self.class.name} is a composed child; continue its root run"
168
- end
169
-
170
- if @context.cancelled
171
- raise Error::ValidationError,
172
- "Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
173
- end
174
-
175
- # Only a reactor paused at an interrupt takes a resume (008 FR-032): one
176
- # that is executing or rolling back (`running`), finished, or `aborted`
177
- # must not be run forward from its stored state.
178
- unless @context.status.to_s == "paused"
179
- raise Error::ValidationError,
180
- "Cannot resume: the reactor is #{@context.status}, not paused at an interrupt"
181
- end
182
-
183
181
  path = Array(self.class.interrupt_key(step_name))
184
- target_context, step_config = resolve_interrupt_target!(path)
182
+ _, step_config = ensure_resumable!(path)
183
+
184
+ # A claimed resume is accepted: a later payload, valid or not, must not
185
+ # count against `max_attempts` and undo the run it is about to complete.
186
+ raise_already_resumed(path) if InterruptClaims.claimed?(@context, path)
185
187
 
186
188
  failure = validate_continue_payload(payload, step_config, path)
187
189
  return failure if failure
188
190
 
189
- target_context.set_result(path.last, payload) # the context that paused (010 R-06)
190
-
191
- # `interrupt :x, resume: :background` — payload is validated and stored
192
- # (above, in this process); the remaining work goes to a worker instead
193
- # of running inline in the delivering process.
194
- if step_config.respond_to?(:background_resume?) && step_config.background_resume?
195
- return @result = enqueue_background_resume
196
- end
197
-
198
- # Claim the resume before running it, so a `continue` that arrives while
199
- # this one executes or rolls back reads `running` and fails (FR-032).
200
- @context.status = :running
201
- save_context
202
-
203
- # Resume execution
204
- executor = Executor.new(self.class, {}, @context)
205
- @result = executor.resume_execution
206
-
207
- @context = executor.context
208
- @undo_trace = executor.undo_trace
209
- @execution_trace = executor.execution_trace
191
+ raise_already_resumed(path) unless InterruptClaims.claim!(@context, path, payload)
210
192
 
211
- @result
212
- rescue Lock::AcquisitionError, Semaphore::AcquisitionError => e
213
- # Contended at the resume's own gates: nothing ran, so the run is still
214
- # paused and the caller may retry, as with `Reactor.run`. A context-lock
215
- # contention means a live resume holds the run: leave it alone.
216
- reopen_paused unless executor&.past_gates? || e.is_a?(Lock::ContextLockContention)
217
- raise
193
+ resume_claimed(path, step_config, payload)
218
194
  rescue Error::InputValidationError => e
219
195
  # This might catch other validations, but here we specifically want payload validation.
220
196
  # The block above handles payload validation explicitly.
@@ -225,12 +201,31 @@ module RubyReactor
225
201
  # resumes it meanwhile (009 R-12). Returns `:handed_off` when a fan-out
226
202
  # map's rollback continues in its element jobs: the run is then
227
203
  # `rolling_back`, and a worker finishes it as `cancelled`.
228
- def undo
204
+ #
205
+ # Lock, then load (010 R-09): the run is reloaded once the lock is held, so
206
+ # an undo built from an older snapshot never undoes stale state. With
207
+ # `failure:` the run ends `failed` with that reason (an interrupt's payload
208
+ # attempts ran out, R-08) instead of being left for `cancel`.
209
+ def undo(failure: nil)
229
210
  raise Error::ValidationError, "rollback already in progress" if @context.rolling_back?
230
211
 
231
212
  lock = acquire_undo_lock
213
+ @context = self.class.find(@context.context_id).context
214
+ raise Error::ValidationError, "rollback already in progress" if @context.rolling_back?
215
+
232
216
  executor = Executor.new(self.class, {}, @context)
217
+ # An aborted run may record a `compensate` the interruption cut off:
218
+ # keep it, so `undo_all` runs it first (010 R-10).
219
+ cut_off = if @context.status.to_s == "aborted" && @context.rollback.is_a?(Hash) &&
220
+ @context.rollback.key?("arguments")
221
+ @context.rollback
222
+ else
223
+ {}
224
+ end
233
225
  @context.rollback = { "trigger" => "undo", "compensated" => true, "failures" => [] }
226
+ .merge(cut_off.slice("step", "compensated", "arguments", "error"))
227
+ # A hand-off finishes in a worker (`Executor#finish_undo`), which applies it.
228
+ @context.rollback["failure_reason"] = failure if failure
234
229
  begin
235
230
  executor.undo_all
236
231
  rescue Error::RollbackHandedOff => e
@@ -240,6 +235,10 @@ module RubyReactor
240
235
  return :handed_off
241
236
  end
242
237
  @context.rollback = nil
238
+ if failure
239
+ @context.status = "failed"
240
+ @context.failure_reason = failure
241
+ end
243
242
  executor.save_context
244
243
  ensure
245
244
  lock&.release
@@ -259,7 +258,7 @@ module RubyReactor
259
258
  # for one of this reactor's own, and the step path from here (the compose
260
259
  # step names, then the interrupt) for one inside a composed child.
261
260
  def ready_interrupt_steps
262
- return [] unless @context.status.to_s == "paused"
261
+ return [] unless RESUMABLE_STATUSES.include?(@context.status.to_s)
263
262
 
264
263
  pending_interrupts(self.class, @context, [])
265
264
  end
@@ -277,15 +276,154 @@ module RubyReactor
277
276
  RubyReactor::Configuration.instance
278
277
  end
279
278
 
280
- def reopen_paused
281
- @context.status = :paused
279
+ # Returns the context that paused at the interrupt `path` and its config.
280
+ def ensure_resumable!(path)
281
+ if @context.status.to_s == "paused" && !@context.current_step
282
+ raise Error::ValidationError, "Cannot resume: context does not have a current step (was it interrupted?)"
283
+ end
284
+
285
+ # A composed child pauses only inside its root, which owns the run (010 R-07).
286
+ if @context.private_data[:composed] || @context.private_data["composed"]
287
+ raise Error::ValidationError, "Cannot resume: #{self.class.name} is a composed child; continue its root run"
288
+ end
289
+
290
+ if @context.cancelled
291
+ raise Error::ValidationError,
292
+ "Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
293
+ end
294
+
295
+ unless RESUMABLE_STATUSES.include?(@context.status.to_s)
296
+ raise Error::ValidationError,
297
+ "Cannot resume: the reactor is #{@context.status}, not paused or running at an interrupt"
298
+ end
299
+
300
+ target_context, step_config = resolve_interrupt_target!(path)
301
+ raise_already_resumed(path) if target_context.has_result?(path.last)
302
+
303
+ [target_context, step_config]
304
+ end
305
+
306
+ def raise_already_resumed(path)
307
+ raise Error::ValidationError, "Cannot resume: interrupt :#{path.join(".")} was already resumed"
308
+ end
309
+
310
+ # Owns a paused run if its lock is free (lock, then load), else hands off.
311
+ # A running run is another execution's: the resume joins it through a
312
+ # Worker and never runs it here. The lock is released before any hand-off
313
+ # enqueues, so the Worker never waits on it; a crash in between leaves a
314
+ # `running` run the sweeper recovers.
315
+ def resume_claimed(path, step_config, payload)
316
+ lock = @context.status.to_s == "paused" ? try_run_lock : :contended
317
+ return hand_off_resume(path, reason: :run_busy) if lock == :contended
318
+
319
+ begin
320
+ reload_for_resume!
321
+ # This process owns the run: apply the caller's payload as given, as
322
+ # an inline resume always did (the claim's copy went through
323
+ # serialization). Other claims are applied from storage at resume.
324
+ if @context.status.to_s == "paused"
325
+ path[0...-1].reduce(@context) { |context, name| paused_child(context, name) }
326
+ &.set_result(path.last, payload)
327
+ end
328
+ if @context.status.to_s != "paused"
329
+ reason = :run_busy
330
+ elsif background_resume?(step_config)
331
+ prepare_background_resume
332
+ reason = :background
333
+ else
334
+ return resume_inline(lock)
335
+ end
336
+ rescue Lock::AcquisitionError, Semaphore::AcquisitionError => e
337
+ # The run's own lock is held here, so this is the reactor's `with_lock`
338
+ # or `with_semaphore`: the resume, already saved `running` with its
339
+ # payload applied, waits in a worker instead (FR-009).
340
+ raise if e.is_a?(Lock::ContextLockContention)
341
+
342
+ reason = e.is_a?(Semaphore::AcquisitionError) ? :semaphore : :lock
343
+ ensure
344
+ lock&.release
345
+ end
346
+ hand_off_resume(path, reason: reason, key: contended_key(reason))
347
+ end
348
+
349
+ # nil in inline job-testing mode (no run lock there, as in the executor);
350
+ # :contended while another execution holds it.
351
+ def try_run_lock
352
+ return nil if inline_testing_mode?
353
+
354
+ lock = RubyReactor::Lock.new("async:#{@context.context_id}", owner: SecureRandom.uuid,
355
+ ttl: configuration.context_lock_ttl,
356
+ wait: 0, auto_extend: true)
357
+ lock.acquire
358
+ lock
359
+ rescue Lock::AcquisitionError
360
+ :contended
361
+ end
362
+
363
+ def reload_for_resume!
364
+ @context = self.class.find(@context.context_id).context
365
+ return unless @context.cancelled || @context.finished? || @context.rolling_back? ||
366
+ @context.status.to_s == "aborted"
367
+
368
+ raise Error::ValidationError,
369
+ "Cannot resume: the reactor is #{@context.status}, not paused at an interrupt"
370
+ end
371
+
372
+ def resume_inline(lock)
373
+ executor = Executor.new(self.class, {}, @context)
374
+ executor.context_lock_owner = lock&.owner
375
+ @result = executor.resume_execution
376
+ @context = executor.context
377
+ @undo_trace = executor.undo_trace
378
+ @execution_trace = executor.execution_trace
379
+ @result
380
+ end
381
+
382
+ def background_resume?(step_config)
383
+ step_config.respond_to?(:background_resume?) && step_config.background_resume?
384
+ end
385
+
386
+ # `interrupt :x, resume: :background`: the payload is applied and the run
387
+ # saved `running` under its lock; the remaining work goes to a worker.
388
+ def prepare_background_resume
389
+ InterruptClaims.apply!(@context)
390
+ @context.status = :running
391
+ Executor.middlewares_for(self.class).on(:before_async_enqueue, @context)
282
392
  save_context
283
393
  end
284
394
 
395
+ def hand_off_resume(path, reason:, key: nil)
396
+ log_resume_deferred(path, reason, key)
397
+ @result = configuration.async_router.perform_async(@context.context_id,
398
+ RubyReactor.reactor_storage_name(self.class),
399
+ intermediate_results: @context.intermediate_results)
400
+ check_for_inline_completion || @result
401
+ end
402
+
403
+ def log_resume_deferred(path, reason, key)
404
+ fields = { event: "ruby_reactor.resume.deferred", reactor: RubyReactor.reactor_storage_name(self.class),
405
+ context_id: @context.context_id, step: path.join("."), reason: reason.to_s, key: key }
406
+ configuration.logger.info(fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" "))
407
+ end
408
+
409
+ def contended_key(reason)
410
+ config = case reason
411
+ when :lock then self.class.respond_to?(:lock_config) && self.class.lock_config
412
+ when :semaphore then self.class.respond_to?(:semaphore_config) && self.class.semaphore_config
413
+ end
414
+ config ? config[:key_proc].call(@context.inputs).to_s : nil
415
+ rescue StandardError
416
+ nil
417
+ end
418
+
419
+ def inline_testing_mode?
420
+ defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
421
+ end
422
+
285
423
  # Raises `Lock::AcquisitionError` while a live run or rollback holds it.
286
424
  # Skipped in inline job-testing mode, as `Executor#acquire_context_lock`.
287
425
  def acquire_undo_lock
288
- return if defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
426
+ return if inline_testing_mode?
289
427
 
290
428
  lock = RubyReactor::Lock.new("async:#{@context.context_id}", owner: SecureRandom.uuid,
291
429
  ttl: configuration.context_lock_ttl,
@@ -399,21 +537,6 @@ module RubyReactor
399
537
  save_context
400
538
  end
401
539
 
402
- # Mirror of perform_async_run for the interrupt-resume path: persist the
403
- # context (now carrying the validated payload) BEFORE enqueue — the job
404
- # payload is identity-only (F2) — then hand the remainder to a worker.
405
- def enqueue_background_resume
406
- @context.status = :running
407
- Executor.middlewares_for(self.class).on(:before_async_enqueue, @context)
408
- save_context
409
-
410
- @result = configuration.async_router.perform_async(@context.context_id,
411
- RubyReactor.reactor_storage_name(self.class),
412
- intermediate_results: @context.intermediate_results)
413
-
414
- check_for_inline_completion || @result
415
- end
416
-
417
540
  def perform_async_run
418
541
  @context.status = :running
419
542
  # Persist BEFORE enqueue — the job payload is identity-only (F2).
@@ -509,33 +632,25 @@ module RubyReactor
509
632
 
510
633
  return unless validation.failure?
511
634
 
512
- # Track attempts
513
- @context.private_data[:interrupt_attempts] ||= {}
514
- @context.private_data[:interrupt_attempts][step_name] ||= 0
515
- @context.private_data[:interrupt_attempts][step_name] += 1
516
-
517
- save_context # Persist the attempt count
518
-
519
- current_attempts = @context.private_data[:interrupt_attempts][step_name]
635
+ # Counted in its own record (010 R-08): writing the snapshot here could
636
+ # overwrite a resume that is executing the run right now.
637
+ current_attempts = configuration.storage_adapter.increment_interrupt_attempts(
638
+ @context.context_id, RubyReactor.reactor_storage_name(self.class), step_name
639
+ )
520
640
  max_attempts = step_config.max_attempts
521
641
 
522
642
  if max_attempts != :infinity && current_attempts >= max_attempts
523
- # Max attempts reached - Fail and Compensate
524
- undo
525
-
526
- # Instead of cancelling, we mark as failed so it shows up as failed in UI
527
- @context.status = "failed"
528
- @context.failure_reason = {
529
- message: "Validation failed after #{max_attempts} attempts",
530
- step_name: step_name,
531
- errors: validation.errors.to_h,
532
- payload: payload,
533
- step_arguments: payload,
534
- attempts: current_attempts,
535
- validation_errors: validation.errors.to_h
536
- }
537
-
538
- save_context
643
+ # Max attempts reached: undo, and mark the run failed (not cancelled)
644
+ # so it shows up as failed in the UI. Both under the run's lock (R-09).
645
+ undo(failure: {
646
+ message: "Validation failed after #{max_attempts} attempts",
647
+ step_name: step_name,
648
+ errors: validation.errors.to_h,
649
+ payload: payload,
650
+ step_arguments: payload,
651
+ attempts: current_attempts,
652
+ validation_errors: validation.errors.to_h
653
+ })
539
654
 
540
655
  return RubyReactor::Failure(
541
656
  "Validation failed after #{max_attempts} attempts",
@@ -207,6 +207,55 @@ 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|
@@ -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?
@@ -441,23 +447,28 @@ module RubyReactor
441
447
  # or the step path of one inside a composed child (e.g. `[:fulfil, :approve]`).
442
448
  # Required when multiple interrupts are ready. If not provided and only
443
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
444
452
  # @return [TestSubject] self for chaining and introspection
445
453
  # @raise [Error::ValidationError] if the reactor is not paused, step is ambiguous, or payload is invalid
446
- def resume(payload: {}, step: nil)
454
+ def resume(payload: {}, step: nil, process_jobs: nil)
447
455
  ensure_executed!
448
456
 
449
- unless paused?
457
+ # A paused run, or a running one with a ready interrupt, mirroring
458
+ # `continue` (010 FR-017).
459
+ unless resumable?
450
460
  raise RubyReactor::Error::ValidationError,
451
- "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})"
452
462
  end
453
463
 
454
464
  step_name = determine_resume_step(step)
455
465
 
456
466
  # Use the reactor's continue method
457
- @reactor_instance.continue(payload: payload, step_name: step_name)
467
+ @last_resume_result = @reactor_instance.continue(payload: payload, step_name: step_name)
458
468
 
459
469
  # Process any pending async jobs
460
- 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?
461
472
 
462
473
  # Reload the reactor instance to get updated state
463
474
  @reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
@@ -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.7"
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?