ruby_reactor 0.8.2 → 0.8.3
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 +82 -0
- data/CLAUDE.md +2 -2
- data/README.md +35 -2
- 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/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
- 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/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 +31 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
- 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 +7 -1
- data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
- data/lib/ruby_reactor/step/compose_step.rb +14 -3
- data/lib/ruby_reactor/step.rb +49 -7
- 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 +27 -5
- data/specs/future_improvements.md +250 -0
- metadata +8 -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/checklists/requirements.md +0 -40
- 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
|
|
@@ -767,7 +767,13 @@ module RubyReactor
|
|
|
767
767
|
if step_config_orig.has_run_block?
|
|
768
768
|
step_config_orig.run_block
|
|
769
769
|
elsif step_config_orig.has_impl?
|
|
770
|
-
|
|
770
|
+
impl = step_config_orig.impl
|
|
771
|
+
|
|
772
|
+
if impl.respond_to?(:run_without_coordination)
|
|
773
|
+
->(args, ctx) { impl.run_without_coordination(args, ctx) }
|
|
774
|
+
else
|
|
775
|
+
->(args, ctx) { impl.run(args, ctx) }
|
|
776
|
+
end
|
|
771
777
|
else
|
|
772
778
|
->(_, _) { raise "No implementation found for #{target_step}" }
|
|
773
779
|
end
|
|
@@ -15,6 +15,44 @@ 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
|
+
class << self
|
|
19
|
+
# Exclusive keys this EXECUTION currently holds, read from the root
|
|
20
|
+
# context's registry. Shared by the `async_reactor` dispatch check
|
|
21
|
+
# below (via `detect_lock_deadlock`) and the `async_step` dispatch-time
|
|
22
|
+
# guard (`Executor::AsyncStepDispatch`, T034) — both hand off work to
|
|
23
|
+
# a process that will never share this execution's owner, so a key
|
|
24
|
+
# the execution currently holds would deadlock the dispatched unit.
|
|
25
|
+
def held_lock_keys(context)
|
|
26
|
+
root = context.root_context || context
|
|
27
|
+
Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# `kind:` names what is being dispatched ("async_reactor" / "async_step")
|
|
31
|
+
# so the two guards share one message shape instead of drifting apart.
|
|
32
|
+
def deadlock_message(key, dispatched_name, context, kind: "async_reactor")
|
|
33
|
+
parent = context.reactor_class&.name || "the dispatching reactor"
|
|
34
|
+
<<~MSG.strip
|
|
35
|
+
#{kind} dispatch of #{dispatched_name || "<anonymous>"} would deadlock: it declares the lock key \
|
|
36
|
+
'#{key}', which #{parent} currently holds and will not release until it finishes.
|
|
37
|
+
The child would snooze forever, and if #{parent} later reads this child's result it would wait \
|
|
38
|
+
for work that can never start. Lock ownership is never shared across the async boundary — the \
|
|
39
|
+
two run concurrently, so sharing it would break mutual exclusion outright.
|
|
40
|
+
Fix, in order of preference:
|
|
41
|
+
1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
|
|
42
|
+
section and its result is needed — waiting for it means the work is sequential anyway.
|
|
43
|
+
2. Narrow the lock keys, if parent and child actually protect different resources.
|
|
44
|
+
3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
|
|
45
|
+
verify in the child itself or in a successor reactor outside the lock window.#{async_step_remedy(kind)}
|
|
46
|
+
MSG
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def async_step_remedy(kind)
|
|
50
|
+
return "" unless kind == "async_step"
|
|
51
|
+
|
|
52
|
+
"\n 4. Run the step inline (drop `async_step`) if it belongs inside the critical section."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
18
56
|
def run
|
|
19
57
|
child_class = inputs[:async_reactor_class]
|
|
20
58
|
child_inputs = build_child_inputs(inputs[:argument_mappings] || {})
|
|
@@ -60,13 +98,13 @@ module RubyReactor
|
|
|
60
98
|
# loudly, at dispatch. Ordinary cross-execution contention on the same
|
|
61
99
|
# key is unaffected and still snoozes normally.
|
|
62
100
|
def detect_lock_deadlock(child_class, child_inputs)
|
|
63
|
-
held = held_lock_keys
|
|
101
|
+
held = self.class.held_lock_keys(context)
|
|
64
102
|
return nil if held.empty?
|
|
65
103
|
|
|
66
104
|
collision = child_lock_keys(child_class, child_inputs).find { |key| held.include?(key) }
|
|
67
105
|
return nil unless collision
|
|
68
106
|
|
|
69
|
-
RubyReactor.Failure(deadlock_message(collision, child_class))
|
|
107
|
+
RubyReactor.Failure(self.class.deadlock_message(collision, child_class.name, context))
|
|
70
108
|
end
|
|
71
109
|
|
|
72
110
|
def child_lock_keys(child_class, child_inputs)
|
|
@@ -83,28 +121,6 @@ module RubyReactor
|
|
|
83
121
|
keys.compact
|
|
84
122
|
end
|
|
85
123
|
|
|
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
124
|
# `RSpec::TestSubject`'s `async: false` clears the dispatch marker to run
|
|
109
125
|
# the whole reactor in one process.
|
|
110
126
|
def run_inline?
|
|
@@ -21,7 +21,9 @@ module RubyReactor
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Compensating a failed compose and undoing a completed one are the same
|
|
24
|
-
# work: roll back whatever the child reactor completed.
|
|
24
|
+
# work: roll back whatever the child reactor completed. Any child undo
|
|
25
|
+
# that did not complete comes back on the Failure, which the parent's
|
|
26
|
+
# `CompensationManager` flattens into its own `rollback_failures`.
|
|
25
27
|
def compensate
|
|
26
28
|
step_name = context.current_step
|
|
27
29
|
composed_data = context.composed_contexts[step_name]
|
|
@@ -32,7 +34,10 @@ module RubyReactor
|
|
|
32
34
|
executor.undo_all
|
|
33
35
|
executor.save_context
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
failures = executor.compensation_manager.rollback_failures
|
|
38
|
+
return RubyReactor.Success() if failures.empty?
|
|
39
|
+
|
|
40
|
+
RubyReactor.Failure("composed :#{step_name} rollback incomplete", rollback_failures: failures)
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
alias undo compensate
|
|
@@ -79,7 +84,13 @@ module RubyReactor
|
|
|
79
84
|
def execute_child_reactor(composed_reactor, child_context, composed_data)
|
|
80
85
|
executor = RubyReactor::Executor.new(composed_reactor, {}, child_context)
|
|
81
86
|
|
|
82
|
-
|
|
87
|
+
# Resume once the child has been admitted, not by its `current_step`:
|
|
88
|
+
# a park two levels down unwinds the child's `with_step` and clears
|
|
89
|
+
# it, and `execute` would then re-charge the child's rate limit and
|
|
90
|
+
# fresh-acquire its lock instead of re-adopting the parked hold
|
|
91
|
+
# (005 R-02). `current_step` stays as the fallback for a child saved
|
|
92
|
+
# before `admitted` existed.
|
|
93
|
+
if composed_data && (child_context.admitted? || child_context.current_step)
|
|
83
94
|
executor.resume_execution
|
|
84
95
|
else
|
|
85
96
|
executor.execute
|
data/lib/ruby_reactor/step.rb
CHANGED
|
@@ -13,9 +13,15 @@ module RubyReactor
|
|
|
13
13
|
# 1. Resolve `inputs`: the given arguments with the contract's defaults
|
|
14
14
|
# applied. `run`, `undo`, and `compensate` all see the same values.
|
|
15
15
|
# 2. `.run` ONLY: enforce the declared input contract, raising
|
|
16
|
-
# `Error::InputValidationError` before any instance exists.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
16
|
+
# `Error::InputValidationError` before any instance exists.
|
|
17
|
+
# 2b. `.run` ONLY: step-scoped coordination (`with_lock` etc, if declared)
|
|
18
|
+
# is acquired around the rest of `.run`, keyed on the just-validated
|
|
19
|
+
# inputs — after contract enforcement, so a step that will fail
|
|
20
|
+
# validation never takes a hold (research Finding 7 / Finding 8). A
|
|
21
|
+
# reactor never goes through here: it calls `.run_without_coordination`
|
|
22
|
+
# and coordinates the step itself (research D2).
|
|
23
|
+
# `.undo` and `.compensate` NEVER enforce the input contract: rollback
|
|
24
|
+
# must not fail on the very inputs that may have caused the failure.
|
|
19
25
|
# 3. Build a FRESH instance, never reused across actions. An ivar set in
|
|
20
26
|
# `run` is gone by the time `undo` runs on its own instance, so async
|
|
21
27
|
# execution running `run` and `undo` in different processes behaves
|
|
@@ -23,10 +29,13 @@ module RubyReactor
|
|
|
23
29
|
# 4. Invoke the matching instance method, translating any `StepSignals`
|
|
24
30
|
# throw (`success!`/`skip!`/`fail!`/`halt!`) into its result wrapper.
|
|
25
31
|
#
|
|
26
|
-
# No `prepend`/`
|
|
27
|
-
#
|
|
32
|
+
# No `prepend`/`define_method`/`method_missing` — every step in the class
|
|
33
|
+
# reads top to bottom as ordinary method calls. The one `extend` is
|
|
34
|
+
# `Dsl::Lockable::ClassMethods` (the five coordination macros), a
|
|
35
|
+
# self-contained module with no hooks of its own (Finding 9).
|
|
28
36
|
class Step
|
|
29
37
|
include RubyReactor::StepSignals
|
|
38
|
+
extend RubyReactor::Dsl::Lockable::ClassMethods
|
|
30
39
|
|
|
31
40
|
attr_reader :inputs, :context, :result, :reason
|
|
32
41
|
|
|
@@ -68,12 +77,28 @@ module RubyReactor
|
|
|
68
77
|
# rubocop:enable Naming/MethodName
|
|
69
78
|
|
|
70
79
|
class << self
|
|
71
|
-
|
|
80
|
+
# A DIRECT invocation — application code, or another step's body. It is
|
|
81
|
+
# its own unit of work: it takes this class's coordination itself and,
|
|
82
|
+
# having no queue to park into, waits then fails (FR-016/FR-023).
|
|
83
|
+
# `context` is optional: a stand-alone `ChargeStep.run(args)` is its own
|
|
84
|
+
# execution, with no reactor context to inherit ownership from.
|
|
85
|
+
def run(arguments, context = nil)
|
|
72
86
|
validated = enforce_contract!(arguments)
|
|
73
|
-
|
|
87
|
+
coordinate(validated, context) { run_without_coordination(validated, context) }
|
|
74
88
|
end
|
|
75
89
|
alias call run
|
|
76
90
|
|
|
91
|
+
# The reactor's entry (`StepConfig#call_body`): the executor or
|
|
92
|
+
# `StepWorker` has already taken this step's EFFECTIVE coordination —
|
|
93
|
+
# the class's declarations and any inline ones, in one fixed order — so
|
|
94
|
+
# taking the class's here again would split acquisition across two
|
|
95
|
+
# layers. Still enforces the contract (idempotent: it only applies
|
|
96
|
+
# defaults to already-valid arguments).
|
|
97
|
+
def run_without_coordination(arguments, context)
|
|
98
|
+
validated = enforce_contract!(arguments)
|
|
99
|
+
catch(StepSignals::TAG) { new(validated, context).run }
|
|
100
|
+
end
|
|
101
|
+
|
|
77
102
|
# Same `inputs` as `.run` (defaults applied), but NEVER enforces the contract.
|
|
78
103
|
def undo(result, arguments, context)
|
|
79
104
|
catch(StepSignals::TAG) { new(with_defaults(arguments), context, result: result).undo }
|
|
@@ -117,6 +142,23 @@ module RubyReactor
|
|
|
117
142
|
|
|
118
143
|
private
|
|
119
144
|
|
|
145
|
+
# Step-scoped coordination for a DIRECT call (`direct: true`): never
|
|
146
|
+
# parks, keeps no state on `context`, and so can never be mistaken for
|
|
147
|
+
# the coordination of the reactor step whose body made the call.
|
|
148
|
+
# After `enforce_contract!`, so a step that will fail validation never
|
|
149
|
+
# takes a hold (Finding 8). A bare step pays one check. `context` may be
|
|
150
|
+
# nil (a stand-alone `MyStep.run(args)`) — `StepCoordination#owner`
|
|
151
|
+
# handles that case.
|
|
152
|
+
def coordinate(validated, context, &block)
|
|
153
|
+
return block.call if Executor::StepCoordination.none?(self)
|
|
154
|
+
|
|
155
|
+
ctx = context if context.is_a?(RubyReactor::Context)
|
|
156
|
+
Executor::StepCoordination.new(
|
|
157
|
+
step_config: self, arguments: validated, context: ctx, reactor_class: ctx&.reactor_class,
|
|
158
|
+
middlewares: ctx&.middlewares || Executor.middlewares_for(ctx&.reactor_class), direct: true
|
|
159
|
+
).around_run(&block)
|
|
160
|
+
end
|
|
161
|
+
|
|
120
162
|
def own_input_contract
|
|
121
163
|
@own_input_contract ||= Step::InputContract.new(owner: self)
|
|
122
164
|
end
|
|
@@ -42,9 +42,10 @@ module RubyReactor
|
|
|
42
42
|
|
|
43
43
|
arguments = dispatch_arguments(record)
|
|
44
44
|
next unless arguments
|
|
45
|
+
next if parked?(record)
|
|
45
46
|
next if live?(arguments)
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
redispatch(record, arguments)
|
|
48
49
|
redispatched += 1
|
|
49
50
|
rescue StandardError => e
|
|
50
51
|
# One bad record must not abort the whole sweep.
|
|
@@ -56,6 +57,17 @@ module RubyReactor
|
|
|
56
57
|
|
|
57
58
|
private
|
|
58
59
|
|
|
60
|
+
# A swept park must not restart its contention counter: rebuilt from the
|
|
61
|
+
# record alone, a zeroed payload would let `lock_snooze_max_attempts`
|
|
62
|
+
# never bite. `perform_step_in(0, ...)` is the only re-dispatch that
|
|
63
|
+
# carries the count.
|
|
64
|
+
def redispatch(record, arguments)
|
|
65
|
+
attempts = record["contention_attempts"].to_i
|
|
66
|
+
return @async_router.perform_step_async(**arguments) unless attempts.positive?
|
|
67
|
+
|
|
68
|
+
@async_router.perform_step_in(0, **arguments, contention_attempts: attempts)
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
def dispatch_arguments(record)
|
|
60
72
|
values = record.values_at(*DISPATCH_KEYS)
|
|
61
73
|
return nil if values.any?(&:nil?)
|
|
@@ -63,6 +75,22 @@ module RubyReactor
|
|
|
63
75
|
DISPATCH_KEYS.map(&:to_sym).zip(values).to_h
|
|
64
76
|
end
|
|
65
77
|
|
|
78
|
+
# A unit parked on step-level contention (StepWorker#handle_contention)
|
|
79
|
+
# released its liveness lock on purpose and has a redelivery already
|
|
80
|
+
# scheduled. It looks exactly like a lost unit, so without this the sweep
|
|
81
|
+
# would dispatch a duplicate that runs the body a second time once the
|
|
82
|
+
# redelivery fires. Past the stamped window it is fair game again — a
|
|
83
|
+
# redelivery that eventually lands on a finished unit is dropped by
|
|
84
|
+
# `StepWorker#already_completed?`.
|
|
85
|
+
def parked?(record)
|
|
86
|
+
parked_until = record["parked_until"]
|
|
87
|
+
return false unless parked_until
|
|
88
|
+
|
|
89
|
+
Time.iso8601(parked_until) > Time.now
|
|
90
|
+
rescue ArgumentError, TypeError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
66
94
|
def live?(arguments)
|
|
67
95
|
@storage.lock_held?(
|
|
68
96
|
RubyReactor.async_step_lock_key(arguments[:step_context_id], arguments[:step_name])
|