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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +59 -2
- data/CLAUDE.md +1 -1
- data/README.md +38 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +20 -1
- data/lib/ruby_reactor/error/recorded_failure.rb +18 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +21 -1
- data/lib/ruby_reactor/executor.rb +62 -10
- data/lib/ruby_reactor/interrupt_claims.rb +81 -0
- data/lib/ruby_reactor/map/step_rollback.rb +121 -0
- data/lib/ruby_reactor/reactor.rb +210 -95
- data/lib/ruby_reactor/rspec/matchers.rb +49 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +17 -6
- data/lib/ruby_reactor/step/map_step.rb +12 -0
- data/lib/ruby_reactor/storage/adapter.rb +17 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +17 -0
- data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +73 -6
- data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
- data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
- data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
- data/specs/010-rollback-follow-ups/data-model.md +139 -0
- data/specs/010-rollback-follow-ups/plan.md +273 -0
- data/specs/010-rollback-follow-ups/pr-description.md +110 -0
- data/specs/010-rollback-follow-ups/quickstart.md +157 -0
- data/specs/010-rollback-follow-ups/research.md +440 -0
- data/specs/010-rollback-follow-ups/spec.md +489 -0
- data/specs/010-rollback-follow-ups/tasks.md +1022 -0
- data/specs/future_improvements.md +17 -2
- metadata +14 -2
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
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
|
-
#
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/ruby_reactor/version.rb
CHANGED
data/lib/ruby_reactor/web/api.rb
CHANGED
|
@@ -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?
|