ruby_reactor 0.8.2 → 0.8.4
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/.claude/skills/speckit-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +94 -0
- data/CLAUDE.md +2 -2
- data/README.md +128 -93
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +34 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
- data/lib/ruby_reactor/step/compose_step.rb +22 -7
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +32 -22
- data/lib/ruby_reactor/step.rb +59 -11
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +30 -6
- data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/future_improvements.md +250 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +20 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- data/specs/possible_feature.md +0 -22
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
# Tracks retry attempts and state for steps in async execution
|
|
5
5
|
class RetryContext
|
|
6
|
-
attr_accessor :step_attempts, :current_step, :failure_reason, :next_retry_at
|
|
6
|
+
attr_accessor :step_attempts, :current_step, :failure_reason, :next_retry_at, :contention_attempts
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@step_attempts = {}
|
|
10
10
|
@current_step = nil
|
|
11
11
|
@failure_reason = nil
|
|
12
12
|
@next_retry_at = nil
|
|
13
|
+
@contention_attempts = {}
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def increment_attempt_for_step(step_name)
|
|
@@ -18,6 +19,16 @@ module RubyReactor
|
|
|
18
19
|
@step_attempts[step_name] += 1
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
# Undoes one `increment_attempt_for_step` — called at the start of a
|
|
23
|
+
# contention park (Finding 2) so a busy key never eats the retry budget
|
|
24
|
+
# meant for genuine failures. Floored at 0.
|
|
25
|
+
def decrement_attempt_for_step(step_name)
|
|
26
|
+
step_name = step_name.to_s
|
|
27
|
+
return unless @step_attempts[step_name]
|
|
28
|
+
|
|
29
|
+
@step_attempts[step_name] = [@step_attempts[step_name] - 1, 0].max
|
|
30
|
+
end
|
|
31
|
+
|
|
21
32
|
def attempts_for_step(step_name)
|
|
22
33
|
step_name = step_name.to_s
|
|
23
34
|
@step_attempts[step_name] || 0
|
|
@@ -28,11 +39,27 @@ module RubyReactor
|
|
|
28
39
|
attempts < max_attempts
|
|
29
40
|
end
|
|
30
41
|
|
|
42
|
+
def increment_contention_for_step(step_name)
|
|
43
|
+
step_name = step_name.to_s
|
|
44
|
+
@contention_attempts[step_name] ||= 0
|
|
45
|
+
@contention_attempts[step_name] += 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def contention_attempts_for_step(step_name)
|
|
49
|
+
step_name = step_name.to_s
|
|
50
|
+
@contention_attempts[step_name] || 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clear_contention_for_step(step_name)
|
|
54
|
+
@contention_attempts.delete(step_name.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
31
57
|
def reset
|
|
32
58
|
@step_attempts = {}
|
|
33
59
|
@current_step = nil
|
|
34
60
|
@failure_reason = nil
|
|
35
61
|
@next_retry_at = nil
|
|
62
|
+
@contention_attempts = {}
|
|
36
63
|
end
|
|
37
64
|
|
|
38
65
|
def serialize_for_retry
|
|
@@ -40,7 +67,8 @@ module RubyReactor
|
|
|
40
67
|
step_attempts: @step_attempts,
|
|
41
68
|
current_step: @current_step,
|
|
42
69
|
failure_reason: serialize_error(@failure_reason),
|
|
43
|
-
next_retry_at: @next_retry_at&.iso8601
|
|
70
|
+
next_retry_at: @next_retry_at&.iso8601,
|
|
71
|
+
contention_attempts: @contention_attempts
|
|
44
72
|
}
|
|
45
73
|
end
|
|
46
74
|
|
|
@@ -50,6 +78,7 @@ module RubyReactor
|
|
|
50
78
|
context.current_step = data["current_step"]
|
|
51
79
|
context.failure_reason = deserialize_error(data["failure_reason"])
|
|
52
80
|
context.next_retry_at = data["next_retry_at"] ? Time.iso8601(data["next_retry_at"]) : nil
|
|
81
|
+
context.contention_attempts = data["contention_attempts"] || {}
|
|
53
82
|
context
|
|
54
83
|
end
|
|
55
84
|
|
|
@@ -19,6 +19,21 @@ module RubyReactor
|
|
|
19
19
|
process_jobs: process_jobs
|
|
20
20
|
)
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# Holds `key` as an EXTERNAL owner for the duration of the block, so a
|
|
24
|
+
# spec can assert against genuine lock contention (a step or reactor
|
|
25
|
+
# failing/parking because something else already holds its key)
|
|
26
|
+
# without reaching into `RubyReactor::Lock` or raw Redis calls itself.
|
|
27
|
+
# Released even if the block raises.
|
|
28
|
+
#
|
|
29
|
+
# hold_lock("acct:1") { expect(ChargeStep.run({ account_id: 1 }, nil)).to be_a(RubyReactor::Failure) }
|
|
30
|
+
def hold_lock(key, owner: "spec", ttl: 30)
|
|
31
|
+
lock = RubyReactor::Lock.new(key, owner: owner, ttl: ttl, wait: 0, auto_extend: true)
|
|
32
|
+
lock.acquire
|
|
33
|
+
yield
|
|
34
|
+
ensure
|
|
35
|
+
lock&.release
|
|
36
|
+
end
|
|
22
37
|
end
|
|
23
38
|
end
|
|
24
39
|
end
|
|
@@ -528,6 +528,98 @@ module RubyReactor
|
|
|
528
528
|
end
|
|
529
529
|
end
|
|
530
530
|
|
|
531
|
+
# Asserts a `:contention_park` trace entry exists for a step (US7-4,
|
|
532
|
+
# T054). Subject can be a `test_reactor` wrapper, a dispatch/find result
|
|
533
|
+
# (`.context`), or a raw `Context` (`.execution_trace`) — matching
|
|
534
|
+
# `have_run_step`'s subject flexibility. Use `.on(key)` to pin the key.
|
|
535
|
+
#
|
|
536
|
+
# expect(result).to have_contended_at(:charge)
|
|
537
|
+
# expect(result).to have_contended_at(:charge).on("acct:1")
|
|
538
|
+
::RSpec::Matchers.define :have_contended_at do |step_name|
|
|
539
|
+
match do |subject|
|
|
540
|
+
trace = execution_trace_for(subject)
|
|
541
|
+
@entry = trace.find do |t|
|
|
542
|
+
t[:type].to_s == "contention_park" && t[:step].to_s == step_name.to_s &&
|
|
543
|
+
(@expected_key.nil? || t[:key].to_s == @expected_key.to_s)
|
|
544
|
+
end
|
|
545
|
+
!@entry.nil?
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
chain :on do |key|
|
|
549
|
+
@expected_key = key
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def execution_trace_for(subject)
|
|
553
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
554
|
+
if subject.respond_to?(:reactor_instance)
|
|
555
|
+
subject.reactor_instance.context.execution_trace
|
|
556
|
+
elsif subject.respond_to?(:context)
|
|
557
|
+
subject.context.execution_trace
|
|
558
|
+
else
|
|
559
|
+
subject.execution_trace
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
failure_message do |_subject|
|
|
564
|
+
msg = "expected execution trace to contain a contention_park entry for step :#{step_name}"
|
|
565
|
+
msg += " on key #{@expected_key.inspect}" if @expected_key
|
|
566
|
+
msg
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
failure_message_when_negated do |_subject|
|
|
570
|
+
msg = "expected execution trace not to contain a contention_park entry for step :#{step_name}"
|
|
571
|
+
msg += " on key #{@expected_key.inspect}" if @expected_key
|
|
572
|
+
msg
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Asserts that a failed run reports a rollback (undo or compensation)
|
|
577
|
+
# of `step_name` that did not complete, on `Failure#rollback_failures`.
|
|
578
|
+
# Subject is a `Failure` or a `test_reactor` wrapper (its `result`).
|
|
579
|
+
#
|
|
580
|
+
# expect(result).to have_rollback_failure(:charge)
|
|
581
|
+
# expect(subject).to have_rollback_failure(:charge).for_key("acct:1").because(:coordination_unavailable)
|
|
582
|
+
# expect(subject).not_to have_rollback_failure(:charge)
|
|
583
|
+
::RSpec::Matchers.define :have_rollback_failure do |step_name|
|
|
584
|
+
match do |subject|
|
|
585
|
+
@entries = rollback_failures_for(subject)
|
|
586
|
+
@entries.any? do |e|
|
|
587
|
+
e[:step].to_s == step_name.to_s &&
|
|
588
|
+
(@expected_key.nil? || e[:key].to_s == @expected_key.to_s) &&
|
|
589
|
+
(@expected_reason.nil? || e[:reason].to_s == @expected_reason.to_s)
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
chain :for_key do |key|
|
|
594
|
+
@expected_key = key
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
chain :because do |reason|
|
|
598
|
+
@expected_reason = reason
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def rollback_failures_for(subject)
|
|
602
|
+
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
603
|
+
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
604
|
+
actual.respond_to?(:rollback_failures) ? actual.rollback_failures : []
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def expectation_text(step_name)
|
|
608
|
+
text = "a rollback failure for :#{step_name}"
|
|
609
|
+
text += " on key #{@expected_key.inspect}" if @expected_key
|
|
610
|
+
text += " because #{@expected_reason.inspect}" if @expected_reason
|
|
611
|
+
text
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
failure_message do |_subject|
|
|
615
|
+
"expected #{expectation_text(step_name)}, got rollback_failures #{@entries.inspect}"
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
failure_message_when_negated do |_subject|
|
|
619
|
+
"expected no #{expectation_text(step_name)}, got rollback_failures #{@entries.inspect}"
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
531
623
|
# Add more matchers as per plan
|
|
532
624
|
# rubocop:enable Metrics/BlockLength
|
|
533
625
|
end
|
|
@@ -556,7 +556,6 @@ module RubyReactor
|
|
|
556
556
|
@middlewares = superclass.middlewares.dup
|
|
557
557
|
@return_step = superclass.return_step
|
|
558
558
|
@background_handoff = superclass.background_handoff
|
|
559
|
-
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
560
559
|
|
|
561
560
|
# 2. Add Name Handling with Unique Registry Entry
|
|
562
561
|
# We must register a unique name so that if this reactor is reloaded (e.g. after async child completion),
|
|
@@ -648,7 +647,6 @@ module RubyReactor
|
|
|
648
647
|
@middlewares = superclass.middlewares.dup
|
|
649
648
|
@return_step = superclass.return_step
|
|
650
649
|
@background_handoff = superclass.background_handoff
|
|
651
|
-
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
652
650
|
end
|
|
653
651
|
|
|
654
652
|
strip_background_and_async!(child_class)
|
|
@@ -736,7 +734,6 @@ module RubyReactor
|
|
|
736
734
|
@middlewares = superclass.middlewares.dup
|
|
737
735
|
@return_step = superclass.return_step
|
|
738
736
|
@background_handoff = superclass.background_handoff
|
|
739
|
-
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
740
737
|
end
|
|
741
738
|
|
|
742
739
|
# Recursively apply interceptors to the child reactor
|
|
@@ -765,9 +762,16 @@ module RubyReactor
|
|
|
765
762
|
# callable a mock block can invoke.
|
|
766
763
|
def original_impl_for(step_config_orig, target_step)
|
|
767
764
|
if step_config_orig.has_run_block?
|
|
768
|
-
|
|
765
|
+
# A mock may hand `original` a plain Hash (`args.to_h.merge(...)`).
|
|
766
|
+
->(args, ctx) { step_config_orig.run_block.call(step_config_orig.wrap_inputs(args), ctx) }
|
|
769
767
|
elsif step_config_orig.has_impl?
|
|
770
|
-
|
|
768
|
+
impl = step_config_orig.impl
|
|
769
|
+
|
|
770
|
+
if impl.respond_to?(:run_without_coordination)
|
|
771
|
+
->(args, ctx) { impl.run_without_coordination(args, ctx) }
|
|
772
|
+
else
|
|
773
|
+
->(args, ctx) { impl.run(args.to_h, ctx) }
|
|
774
|
+
end
|
|
771
775
|
else
|
|
772
776
|
->(_, _) { raise "No implementation found for #{target_step}" }
|
|
773
777
|
end
|
|
@@ -15,9 +15,51 @@ module RubyReactor
|
|
|
15
15
|
# ordered-lock nonce at ENQUEUE time (so ordering matches caller order), and
|
|
16
16
|
# persist before enqueueing (F2).
|
|
17
17
|
class AsyncReactorStep < RubyReactor::Step
|
|
18
|
+
# Untyped, so no validation: declared only so `inputs.x` can read them.
|
|
19
|
+
input :async_reactor_class
|
|
20
|
+
input :argument_mappings, optional: true
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Exclusive keys this EXECUTION currently holds, read from the root
|
|
24
|
+
# context's registry. Shared by the `async_reactor` dispatch check
|
|
25
|
+
# below (via `detect_lock_deadlock`) and the `async_step` dispatch-time
|
|
26
|
+
# guard (`Executor::AsyncStepDispatch`, T034) — both hand off work to
|
|
27
|
+
# a process that will never share this execution's owner, so a key
|
|
28
|
+
# the execution currently holds would deadlock the dispatched unit.
|
|
29
|
+
def held_lock_keys(context)
|
|
30
|
+
root = context.root_context || context
|
|
31
|
+
Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `kind:` names what is being dispatched ("async_reactor" / "async_step")
|
|
35
|
+
# so the two guards share one message shape instead of drifting apart.
|
|
36
|
+
def deadlock_message(key, dispatched_name, context, kind: "async_reactor")
|
|
37
|
+
parent = context.reactor_class&.name || "the dispatching reactor"
|
|
38
|
+
<<~MSG.strip
|
|
39
|
+
#{kind} dispatch of #{dispatched_name || "<anonymous>"} would deadlock: it declares the lock key \
|
|
40
|
+
'#{key}', which #{parent} currently holds and will not release until it finishes.
|
|
41
|
+
The child would snooze forever, and if #{parent} later reads this child's result it would wait \
|
|
42
|
+
for work that can never start. Lock ownership is never shared across the async boundary — the \
|
|
43
|
+
two run concurrently, so sharing it would break mutual exclusion outright.
|
|
44
|
+
Fix, in order of preference:
|
|
45
|
+
1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
|
|
46
|
+
section and its result is needed — waiting for it means the work is sequential anyway.
|
|
47
|
+
2. Narrow the lock keys, if parent and child actually protect different resources.
|
|
48
|
+
3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
|
|
49
|
+
verify in the child itself or in a successor reactor outside the lock window.#{async_step_remedy(kind)}
|
|
50
|
+
MSG
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def async_step_remedy(kind)
|
|
54
|
+
return "" unless kind == "async_step"
|
|
55
|
+
|
|
56
|
+
"\n 4. Run the step inline (drop `async_step`) if it belongs inside the critical section."
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
18
60
|
def run
|
|
19
|
-
child_class = inputs
|
|
20
|
-
child_inputs = build_child_inputs(inputs
|
|
61
|
+
child_class = inputs.async_reactor_class
|
|
62
|
+
child_inputs = build_child_inputs(inputs.argument_mappings || {})
|
|
21
63
|
|
|
22
64
|
# A dispatch-time failure fails the DISPATCHING step, i.e. normal saga
|
|
23
65
|
# handling in the parent. That is deliberately outside the
|
|
@@ -60,13 +102,13 @@ module RubyReactor
|
|
|
60
102
|
# loudly, at dispatch. Ordinary cross-execution contention on the same
|
|
61
103
|
# key is unaffected and still snoozes normally.
|
|
62
104
|
def detect_lock_deadlock(child_class, child_inputs)
|
|
63
|
-
held = held_lock_keys
|
|
105
|
+
held = self.class.held_lock_keys(context)
|
|
64
106
|
return nil if held.empty?
|
|
65
107
|
|
|
66
108
|
collision = child_lock_keys(child_class, child_inputs).find { |key| held.include?(key) }
|
|
67
109
|
return nil unless collision
|
|
68
110
|
|
|
69
|
-
RubyReactor.Failure(deadlock_message(collision, child_class))
|
|
111
|
+
RubyReactor.Failure(self.class.deadlock_message(collision, child_class.name, context))
|
|
70
112
|
end
|
|
71
113
|
|
|
72
114
|
def child_lock_keys(child_class, child_inputs)
|
|
@@ -83,28 +125,6 @@ module RubyReactor
|
|
|
83
125
|
keys.compact
|
|
84
126
|
end
|
|
85
127
|
|
|
86
|
-
def held_lock_keys
|
|
87
|
-
root = context.root_context || context
|
|
88
|
-
Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def deadlock_message(key, child_class)
|
|
92
|
-
parent = context.reactor_class&.name || "the dispatching reactor"
|
|
93
|
-
<<~MSG.strip
|
|
94
|
-
async_reactor dispatch of #{child_class.name || "<anonymous>"} would deadlock: it declares the \
|
|
95
|
-
lock key '#{key}', which #{parent} currently holds and will not release until it finishes.
|
|
96
|
-
The child would snooze forever, and if #{parent} later reads this child's result it would wait \
|
|
97
|
-
for work that can never start. Lock ownership is never shared across the async boundary — the \
|
|
98
|
-
two run concurrently, so sharing it would break mutual exclusion outright.
|
|
99
|
-
Fix, in order of preference:
|
|
100
|
-
1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
|
|
101
|
-
section and its result is needed — waiting for it means the work is sequential anyway.
|
|
102
|
-
2. Narrow the lock keys, if parent and child actually protect different resources.
|
|
103
|
-
3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
|
|
104
|
-
verify in the child itself or in a successor reactor outside the lock window.
|
|
105
|
-
MSG
|
|
106
|
-
end
|
|
107
|
-
|
|
108
128
|
# `RSpec::TestSubject`'s `async: false` clears the dispatch marker to run
|
|
109
129
|
# the whole reactor in one process.
|
|
110
130
|
def run_inline?
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
class Step
|
|
5
5
|
class ComposeStep < RubyReactor::Step
|
|
6
|
+
# Untyped, so no validation: declared only so `inputs.x` can read them.
|
|
7
|
+
input :composed_reactor_class
|
|
8
|
+
input :argument_mappings, optional: true
|
|
9
|
+
|
|
6
10
|
def run
|
|
7
11
|
step_name = context.current_step
|
|
8
12
|
composed_data = context.composed_contexts[step_name]
|
|
@@ -12,7 +16,7 @@ module RubyReactor
|
|
|
12
16
|
store_child_context(step_name, child_context)
|
|
13
17
|
|
|
14
18
|
# Execute the composed reactor
|
|
15
|
-
result = execute_child_reactor(inputs
|
|
19
|
+
result = execute_child_reactor(inputs.composed_reactor_class, child_context, composed_data)
|
|
16
20
|
|
|
17
21
|
# Update the stored context
|
|
18
22
|
store_child_context(step_name, child_context)
|
|
@@ -21,18 +25,23 @@ module RubyReactor
|
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
# Compensating a failed compose and undoing a completed one are the same
|
|
24
|
-
# work: roll back whatever the child reactor completed.
|
|
28
|
+
# work: roll back whatever the child reactor completed. Any child undo
|
|
29
|
+
# that did not complete comes back on the Failure, which the parent's
|
|
30
|
+
# `CompensationManager` flattens into its own `rollback_failures`.
|
|
25
31
|
def compensate
|
|
26
32
|
step_name = context.current_step
|
|
27
33
|
composed_data = context.composed_contexts[step_name]
|
|
28
34
|
return RubyReactor.Success() unless composed_data && composed_data[:context]
|
|
29
35
|
|
|
30
36
|
child_context = composed_data[:context]
|
|
31
|
-
executor = RubyReactor::Executor.new(inputs
|
|
37
|
+
executor = RubyReactor::Executor.new(inputs.composed_reactor_class, {}, child_context)
|
|
32
38
|
executor.undo_all
|
|
33
39
|
executor.save_context
|
|
34
40
|
|
|
35
|
-
|
|
41
|
+
failures = executor.compensation_manager.rollback_failures
|
|
42
|
+
return RubyReactor.Success() if failures.empty?
|
|
43
|
+
|
|
44
|
+
RubyReactor.Failure("composed :#{step_name} rollback incomplete", rollback_failures: failures)
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
alias undo compensate
|
|
@@ -54,8 +63,8 @@ module RubyReactor
|
|
|
54
63
|
child_context = composed_data ? composed_data[:context] : nil
|
|
55
64
|
|
|
56
65
|
unless child_context
|
|
57
|
-
composed_inputs = build_composed_inputs(inputs
|
|
58
|
-
child_context = RubyReactor::Context.new(composed_inputs, inputs
|
|
66
|
+
composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
|
|
67
|
+
child_context = RubyReactor::Context.new(composed_inputs, inputs.composed_reactor_class)
|
|
59
68
|
end
|
|
60
69
|
|
|
61
70
|
link_contexts(child_context, context)
|
|
@@ -79,7 +88,13 @@ module RubyReactor
|
|
|
79
88
|
def execute_child_reactor(composed_reactor, child_context, composed_data)
|
|
80
89
|
executor = RubyReactor::Executor.new(composed_reactor, {}, child_context)
|
|
81
90
|
|
|
82
|
-
|
|
91
|
+
# Resume once the child has been admitted, not by its `current_step`:
|
|
92
|
+
# a park two levels down unwinds the child's `with_step` and clears
|
|
93
|
+
# it, and `execute` would then re-charge the child's rate limit and
|
|
94
|
+
# fresh-acquire its lock instead of re-adopting the parked hold
|
|
95
|
+
# (005 R-02). `current_step` stays as the fallback for a child saved
|
|
96
|
+
# before `admitted` existed.
|
|
97
|
+
if composed_data && (child_context.admitted? || child_context.current_step)
|
|
83
98
|
executor.resume_execution
|
|
84
99
|
else
|
|
85
100
|
executor.execute
|
|
@@ -25,6 +25,11 @@ module RubyReactor
|
|
|
25
25
|
def input(name, type = nil, optional: false, default: nil, redact: false, validate: nil, **predicates, &block)
|
|
26
26
|
# rubocop:enable Metrics/ParameterLists
|
|
27
27
|
check_dry_validation_available!
|
|
28
|
+
if Step::Inputs.public_method_defined?(name)
|
|
29
|
+
raise Error::ValidationError,
|
|
30
|
+
"input :#{name} is reserved: step code reads inputs as `inputs.#{name}`, and `#{name}` is already " \
|
|
31
|
+
"a method there. Rename the input."
|
|
32
|
+
end
|
|
28
33
|
unless default.nil? || optional
|
|
29
34
|
raise Error::ValidationError,
|
|
30
35
|
"input :#{name} declares `default:` but is required; a default only applies to an " \
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Step
|
|
5
|
+
# What step code (`run`, `undo`, `compensate`) receives as its inputs: a
|
|
6
|
+
# frozen, read-only view of the argument Hash with one reader per name the
|
|
7
|
+
# step may read. Any other name raises `Error::UndeclaredInputError` on the
|
|
8
|
+
# line that reads it, instead of returning nil and failing somewhere else.
|
|
9
|
+
#
|
|
10
|
+
# inputs.order_guid # the value, or nil for an absent optional input
|
|
11
|
+
# inputs.order_id # raises UndeclaredInputError
|
|
12
|
+
# inputs.to_h # supplied values, readable names only
|
|
13
|
+
# Service.call(**inputs) # via `to_hash`
|
|
14
|
+
#
|
|
15
|
+
# Readable names are the contract's declarations, or the keys present when
|
|
16
|
+
# the step has none. Built only where inputs reach step code; everything
|
|
17
|
+
# else in the gem keeps the Hash.
|
|
18
|
+
class Inputs
|
|
19
|
+
def initialize(values, contract: nil, owner: nil)
|
|
20
|
+
@values = values.to_h
|
|
21
|
+
@owner = owner
|
|
22
|
+
declared = contract&.declarations&.keys || []
|
|
23
|
+
@names = declared.empty? ? @values.keys.map(&:to_sym).uniq : declared
|
|
24
|
+
@redacted = contract&.redacted_names || []
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
@names.each_with_object({}) do |name, hash|
|
|
30
|
+
hash[name] = Utils::FetchIndifferent.call(@values, name) if supplied?(name)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
alias to_hash to_h
|
|
34
|
+
|
|
35
|
+
def inspect
|
|
36
|
+
to_h.to_h { |name, value| [name, @redacted.include?(name) ? InputContract::REDACTED : value] }.inspect
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def method_missing(name, *args)
|
|
42
|
+
return Utils::FetchIndifferent.call(@values, name) if args.empty? && @names.include?(name)
|
|
43
|
+
|
|
44
|
+
declared = @names.empty? ? "none" : @names.map(&:inspect).join(", ")
|
|
45
|
+
raise Error::UndeclaredInputError.new("#{@owner} has no input :#{name}. Declared inputs: #{declared}.", name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def respond_to_missing?(name, include_private = false)
|
|
49
|
+
@names.include?(name) || super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def supplied?(name)
|
|
53
|
+
@values.key?(name) || @values.key?(name.to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -3,8 +3,18 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
class Step
|
|
5
5
|
class MapStep < RubyReactor::Step
|
|
6
|
+
# Untyped, so no validation: declared only so `inputs.x` can read them.
|
|
7
|
+
input :source
|
|
8
|
+
input :mapped_reactor_class
|
|
9
|
+
input :argument_mappings, optional: true
|
|
10
|
+
input :strict_ordering, optional: true
|
|
11
|
+
input :batch_size, optional: true
|
|
12
|
+
input :collect_block, optional: true
|
|
13
|
+
input :fail_fast, optional: true
|
|
14
|
+
input :fan_out, optional: true
|
|
15
|
+
|
|
6
16
|
def run
|
|
7
|
-
return RubyReactor::Failure("Map source cannot be nil") if inputs
|
|
17
|
+
return RubyReactor::Failure("Map source cannot be nil") if inputs.source.nil?
|
|
8
18
|
|
|
9
19
|
# Initialize map state in context if not present
|
|
10
20
|
context.map_operations ||= {}
|
|
@@ -77,21 +87,21 @@ module RubyReactor
|
|
|
77
87
|
def fan_out?
|
|
78
88
|
return false if context.map_metadata || context.root_context&.map_metadata
|
|
79
89
|
|
|
80
|
-
inputs
|
|
90
|
+
inputs.fan_out
|
|
81
91
|
end
|
|
82
92
|
|
|
83
93
|
def run_inline
|
|
84
94
|
results = execute_inline_map
|
|
85
95
|
return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
|
|
86
96
|
|
|
87
|
-
process_results(results, inputs
|
|
97
|
+
process_results(results, inputs.collect_block, inputs.fail_fast)
|
|
88
98
|
end
|
|
89
99
|
|
|
90
100
|
def execute_inline_map
|
|
91
101
|
results = []
|
|
92
|
-
fail_fast = inputs
|
|
102
|
+
fail_fast = inputs.fail_fast.nil? || inputs.fail_fast
|
|
93
103
|
|
|
94
|
-
inputs
|
|
104
|
+
inputs.source.each_with_index do |element, index|
|
|
95
105
|
result = execute_single_element(element, index)
|
|
96
106
|
|
|
97
107
|
# An element-level Halt propagates as a run halt: stop immediately
|
|
@@ -110,8 +120,8 @@ module RubyReactor
|
|
|
110
120
|
end
|
|
111
121
|
|
|
112
122
|
def execute_single_element(element, index)
|
|
113
|
-
mapped_inputs = self.class.build_mapped_inputs(inputs
|
|
114
|
-
child_context = RubyReactor::Context.new(mapped_inputs, inputs
|
|
123
|
+
mapped_inputs = self.class.build_mapped_inputs(inputs.argument_mappings || {}, context, element)
|
|
124
|
+
child_context = RubyReactor::Context.new(mapped_inputs, inputs.mapped_reactor_class)
|
|
115
125
|
|
|
116
126
|
link_contexts(child_context, context)
|
|
117
127
|
|
|
@@ -131,10 +141,10 @@ module RubyReactor
|
|
|
131
141
|
name: context.current_step,
|
|
132
142
|
type: :map_ref,
|
|
133
143
|
map_id: map_id,
|
|
134
|
-
element_reactor_class: inputs
|
|
144
|
+
element_reactor_class: inputs.mapped_reactor_class.name
|
|
135
145
|
}
|
|
136
146
|
|
|
137
|
-
executor = RubyReactor::Executor.new(inputs
|
|
147
|
+
executor = RubyReactor::Executor.new(inputs.mapped_reactor_class, {}, child_context)
|
|
138
148
|
executor.execute
|
|
139
149
|
executor.result
|
|
140
150
|
end
|
|
@@ -162,9 +172,9 @@ module RubyReactor
|
|
|
162
172
|
def run_async(step_name)
|
|
163
173
|
map_id = "#{context.context_id}:#{step_name}"
|
|
164
174
|
context.map_operations[step_name.to_s] = map_id
|
|
165
|
-
prepare_async_execution(map_id, inputs
|
|
175
|
+
prepare_async_execution(map_id, inputs.source.size)
|
|
166
176
|
|
|
167
|
-
reactor_class_info = build_reactor_class_info(inputs
|
|
177
|
+
reactor_class_info = build_reactor_class_info(inputs.mapped_reactor_class, step_name)
|
|
168
178
|
|
|
169
179
|
initialize_map_metadata(map_id, reactor_class_info)
|
|
170
180
|
|
|
@@ -175,7 +185,7 @@ module RubyReactor
|
|
|
175
185
|
name: step_name.to_s,
|
|
176
186
|
type: :map_ref,
|
|
177
187
|
map_id: map_id,
|
|
178
|
-
element_reactor_class: inputs
|
|
188
|
+
element_reactor_class: inputs.mapped_reactor_class.name
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
RubyReactor::DispatchResult.new(
|
|
@@ -188,9 +198,9 @@ module RubyReactor
|
|
|
188
198
|
def initialize_map_metadata(map_id, reactor_class_info)
|
|
189
199
|
storage = RubyReactor.configuration.storage_adapter
|
|
190
200
|
storage.initialize_map_operation(
|
|
191
|
-
map_id, inputs
|
|
192
|
-
strict_ordering: inputs
|
|
193
|
-
**map_recovery_metadata(
|
|
201
|
+
map_id, inputs.source.size, context.reactor_class.name,
|
|
202
|
+
strict_ordering: inputs.strict_ordering, reactor_class_info: reactor_class_info,
|
|
203
|
+
**map_recovery_metadata(context.current_step)
|
|
194
204
|
)
|
|
195
205
|
end
|
|
196
206
|
|
|
@@ -215,21 +225,21 @@ module RubyReactor
|
|
|
215
225
|
# own worker, with the map counter/collector tracking completion. This
|
|
216
226
|
# lets elements with async steps or async retries hand off correctly
|
|
217
227
|
# instead of being forced to run synchronously in a single worker.
|
|
218
|
-
batch_size = inputs
|
|
228
|
+
batch_size = inputs.batch_size || inputs.source.size
|
|
219
229
|
|
|
220
230
|
RubyReactor::Map::Dispatcher.perform(
|
|
221
231
|
map_id: map_id,
|
|
222
232
|
parent_context_id: context.context_id,
|
|
223
233
|
parent_reactor_class_name: context.reactor_class.name,
|
|
224
|
-
source: inputs
|
|
234
|
+
source: inputs.source,
|
|
225
235
|
batch_size: batch_size,
|
|
226
236
|
step_name: step_name,
|
|
227
|
-
argument_mappings: inputs
|
|
228
|
-
strict_ordering: inputs
|
|
229
|
-
mapped_reactor_class: inputs
|
|
230
|
-
fail_fast: inputs
|
|
237
|
+
argument_mappings: inputs.argument_mappings,
|
|
238
|
+
strict_ordering: inputs.strict_ordering,
|
|
239
|
+
mapped_reactor_class: inputs.mapped_reactor_class,
|
|
240
|
+
fail_fast: inputs.fail_fast.nil? || inputs.fail_fast
|
|
231
241
|
)
|
|
232
|
-
queue_collector(map_id, step_name, inputs
|
|
242
|
+
queue_collector(map_id, step_name, inputs.strict_ordering)
|
|
233
243
|
"map:#{map_id}"
|
|
234
244
|
end
|
|
235
245
|
|