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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +81 -0
- data/CLAUDE.md +1 -1
- data/README.md +42 -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 +27 -1
- data/lib/ruby_reactor/executor/result_handler.rb +16 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -2
- data/lib/ruby_reactor/executor/step_executor.rb +20 -0
- 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 +270 -105
- data/lib/ruby_reactor/rspec/matchers.rb +64 -16
- data/lib/ruby_reactor/rspec/test_subject.rb +25 -24
- data/lib/ruby_reactor/step/compose_step.rb +10 -0
- 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-interrupt-in-composed-child/checklists/requirements.md +43 -0
- data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
- data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
- data/specs/010-interrupt-in-composed-child/plan.md +149 -0
- data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
- data/specs/010-interrupt-in-composed-child/research.md +207 -0
- data/specs/010-interrupt-in-composed-child/spec.md +267 -0
- data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
- 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 -14
- 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
|
|
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
|
-
|
|
224
|
-
|
|
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 #{
|
|
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 #{
|
|
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
|
-
|
|
249
|
-
|
|
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 #{
|
|
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 #{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
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?
|