ruby_reactor 0.7.0 → 0.7.1
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/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +79 -12
- data/.specify/templates/tasks-template.md +7 -0
- data/CHANGELOG.md +11 -0
- data/CLAUDE.md +2 -2
- data/README.md +24 -9
- data/lib/ruby_reactor/context.rb +7 -0
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +35 -10
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -1
- data/lib/ruby_reactor/executor/step_executor.rb +19 -13
- data/lib/ruby_reactor/executor.rb +17 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/rspec/matchers.rb +61 -11
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- data/lib/ruby_reactor/step/map_step.rb +5 -1
- data/lib/ruby_reactor/step.rb +10 -4
- data/lib/ruby_reactor/step_signals.rb +33 -0
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +56 -7
- metadata +7 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- data/specs/001-background-async-steps/tasks.md +0 -271
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -103,6 +103,13 @@ module RubyReactor
|
|
|
103
103
|
@current_step = old_step
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
# Appends an execution-trace event stamped with whether this process is the
|
|
107
|
+
# worker (`inline_async_execution`). Traces written before this field
|
|
108
|
+
# existed stay unlabeled on deserialize.
|
|
109
|
+
def append_execution_trace(entry)
|
|
110
|
+
@execution_trace << entry.merge(background: !!@inline_async_execution)
|
|
111
|
+
end
|
|
112
|
+
|
|
106
113
|
def to_h
|
|
107
114
|
{
|
|
108
115
|
inputs: @inputs,
|
|
@@ -36,6 +36,11 @@ module RubyReactor
|
|
|
36
36
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
37
37
|
def serialize_value(value)
|
|
38
38
|
case value
|
|
39
|
+
when RubyReactor::Halt
|
|
40
|
+
{ "_type" => "Halt", "reason" => value.reason, "period_key" => value.period_key,
|
|
41
|
+
"step_name" => value.step_name }
|
|
42
|
+
when RubyReactor::Skipped
|
|
43
|
+
{ "_type" => "Skipped", "value" => serialize_value(value.value), "reason" => value.reason }
|
|
39
44
|
when RubyReactor::Success
|
|
40
45
|
{ "_type" => "Success", "value" => serialize_value(value.value) }
|
|
41
46
|
when RubyReactor::Failure
|
|
@@ -112,6 +117,14 @@ module RubyReactor
|
|
|
112
117
|
if value.key?("_type")
|
|
113
118
|
# Special serialized types (Time, BigDecimal, etc.)
|
|
114
119
|
case value["_type"]
|
|
120
|
+
when "Halt"
|
|
121
|
+
RubyReactor::Halt.new(
|
|
122
|
+
reason: value["reason"],
|
|
123
|
+
period_key: value["period_key"],
|
|
124
|
+
step_name: value["step_name"]
|
|
125
|
+
)
|
|
126
|
+
when "Skipped"
|
|
127
|
+
RubyReactor::Skipped.new(deserialize_value(value["value"]), reason: value["reason"])
|
|
115
128
|
when "Success"
|
|
116
129
|
RubyReactor::Success(deserialize_value(value["value"]))
|
|
117
130
|
when "Failure"
|
|
@@ -53,7 +53,7 @@ module RubyReactor
|
|
|
53
53
|
|
|
54
54
|
# Configure a calendar-aligned dedup window for this reactor. The
|
|
55
55
|
# reactor will run at most once per bucket per key; subsequent calls
|
|
56
|
-
# in the same bucket return `RubyReactor::
|
|
56
|
+
# in the same bucket return `RubyReactor::Halt` without executing
|
|
57
57
|
# any steps.
|
|
58
58
|
#
|
|
59
59
|
# Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
|
|
@@ -93,7 +93,7 @@ module RubyReactor
|
|
|
93
93
|
# assign. Only fully-drained sequences GC themselves.
|
|
94
94
|
# @param strict [Boolean] When true (default), if any nonce in the
|
|
95
95
|
# sequence terminates with a `Failure`, all subsequent nonces are
|
|
96
|
-
# short-circuited with `
|
|
96
|
+
# short-circuited with `Halt(reason: :ordered_lock_chain_failed)`
|
|
97
97
|
# instead of executing. This models "stop the line on the first
|
|
98
98
|
# problem" pipelines (e.g. ledger transactions). When false, the
|
|
99
99
|
# sequence keeps executing every nonce in order regardless of prior
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module Dsl
|
|
5
5
|
module TemplateHelpers
|
|
6
|
+
include RubyReactor::StepSignals
|
|
7
|
+
|
|
6
8
|
def input(name, path = nil)
|
|
7
9
|
RubyReactor::Template::Input.new(name, path)
|
|
8
10
|
end
|
|
@@ -19,7 +21,7 @@ module RubyReactor
|
|
|
19
21
|
RubyReactor::Template::Element.new(map_name, path)
|
|
20
22
|
end
|
|
21
23
|
|
|
22
|
-
# Make Success, Failure, and Skipped available in DSL contexts
|
|
24
|
+
# Make Success, Failure, Halt, and Skipped available in DSL contexts
|
|
23
25
|
# rubocop:disable Naming/MethodName
|
|
24
26
|
def Success(value = nil)
|
|
25
27
|
# rubocop:enable Naming/MethodName
|
|
@@ -33,9 +35,15 @@ module RubyReactor
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# rubocop:disable Naming/MethodName
|
|
36
|
-
def
|
|
38
|
+
def Halt(reason: nil, **kwargs)
|
|
39
|
+
# rubocop:enable Naming/MethodName
|
|
40
|
+
RubyReactor.Halt(reason: reason, **kwargs)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# rubocop:disable Naming/MethodName
|
|
44
|
+
def Skipped(...)
|
|
37
45
|
# rubocop:enable Naming/MethodName
|
|
38
|
-
RubyReactor.Skipped(
|
|
46
|
+
RubyReactor.Skipped(...)
|
|
39
47
|
end
|
|
40
48
|
end
|
|
41
49
|
end
|
|
@@ -55,33 +55,44 @@ module RubyReactor
|
|
|
55
55
|
@context.middlewares || RubyReactor::MiddlewareRunner.new([])
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
|
|
59
|
+
def loggable_value(result)
|
|
60
|
+
if result.respond_to?(:value)
|
|
61
|
+
result.value
|
|
62
|
+
elsif result.respond_to?(:error)
|
|
63
|
+
result.error
|
|
64
|
+
else
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def skipped_result?(result)
|
|
70
|
+
result.respond_to?(:skipped?) && result.skipped?
|
|
71
|
+
end
|
|
72
|
+
|
|
58
73
|
def compensate_step(step_config, error, arguments)
|
|
59
74
|
middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
|
|
60
75
|
begin
|
|
61
|
-
compensate_result =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
timestamp: Time.now,
|
|
82
|
-
result: logged_result,
|
|
83
|
-
arguments: arguments
|
|
84
|
-
}
|
|
76
|
+
compensate_result = catch(StepSignals::TAG) do
|
|
77
|
+
if step_config.compensate_block
|
|
78
|
+
step_config.compensate_block.call(error, arguments, @context)
|
|
79
|
+
elsif step_config.has_impl?
|
|
80
|
+
step_config.impl.compensate(error, arguments, @context)
|
|
81
|
+
else
|
|
82
|
+
RubyReactor.Skipped() # Default: nothing defined, rollback continues
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@context.append_execution_trace(
|
|
87
|
+
{
|
|
88
|
+
type: :compensate,
|
|
89
|
+
step: step_config.name,
|
|
90
|
+
timestamp: Time.now,
|
|
91
|
+
result: loggable_value(compensate_result),
|
|
92
|
+
arguments: arguments,
|
|
93
|
+
skipped: skipped_result?(compensate_result)
|
|
94
|
+
}
|
|
95
|
+
)
|
|
85
96
|
@undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
|
|
86
97
|
|
|
87
98
|
if compensate_result.is_a?(RubyReactor::Failure)
|
|
@@ -100,25 +111,26 @@ module RubyReactor
|
|
|
100
111
|
def undo_step(step_config, result, arguments)
|
|
101
112
|
middlewares.on(:start_undo, step_config.name, result, arguments, @context)
|
|
102
113
|
begin
|
|
103
|
-
undo_result =
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
undo_result = catch(StepSignals::TAG) do
|
|
115
|
+
if step_config.undo_block
|
|
116
|
+
step_config.undo_block.call(result.value, arguments, @context)
|
|
117
|
+
elsif step_config.has_impl?
|
|
118
|
+
step_config.impl.undo(result.value, arguments, @context)
|
|
119
|
+
else
|
|
120
|
+
RubyReactor.Skipped() # Default: nothing defined, rollback continues
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@context.append_execution_trace(
|
|
125
|
+
{
|
|
126
|
+
type: :undo,
|
|
127
|
+
step: step_config.name,
|
|
128
|
+
timestamp: Time.now,
|
|
129
|
+
result: loggable_value(undo_result),
|
|
130
|
+
arguments: arguments,
|
|
131
|
+
skipped: skipped_result?(undo_result)
|
|
132
|
+
}
|
|
133
|
+
)
|
|
122
134
|
|
|
123
135
|
if undo_result.is_a?(RubyReactor::Failure)
|
|
124
136
|
middlewares.on(:failed_undo, step_config.name, undo_result, @context)
|
|
@@ -130,8 +142,9 @@ module RubyReactor
|
|
|
130
142
|
rescue StandardError => e
|
|
131
143
|
middlewares.on(:failed_undo, step_config.name, e, @context)
|
|
132
144
|
# Log undo failure but don't halt the rollback process
|
|
133
|
-
@context.
|
|
134
|
-
|
|
145
|
+
@context.append_execution_trace(
|
|
146
|
+
{ type: :undo_failure, step: step_config.name, timestamp: Time.now, error: e.message }
|
|
147
|
+
)
|
|
135
148
|
RubyReactor.Failure(e)
|
|
136
149
|
end
|
|
137
150
|
end
|
|
@@ -117,14 +117,14 @@ module RubyReactor
|
|
|
117
117
|
@ordered_lock_drained_replay == true
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
# Terminal
|
|
120
|
+
# Terminal Halt result when the ordered-lock gate short-circuits this
|
|
121
121
|
# run (stale batch, strict chain failure, or a drained-batch redelivery of
|
|
122
122
|
# an already-terminal context), or nil to continue. Shared by `execute`
|
|
123
123
|
# and `resume_execution`.
|
|
124
124
|
def ordered_lock_short_circuit
|
|
125
|
-
return RubyReactor::
|
|
126
|
-
return RubyReactor::
|
|
127
|
-
return RubyReactor::
|
|
125
|
+
return RubyReactor::Halt.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
|
|
126
|
+
return RubyReactor::Halt.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
|
|
127
|
+
return RubyReactor::Halt.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
|
|
128
128
|
|
|
129
129
|
nil
|
|
130
130
|
end
|
|
@@ -138,12 +138,12 @@ module RubyReactor
|
|
|
138
138
|
def short_circuit!(result)
|
|
139
139
|
@result = result
|
|
140
140
|
|
|
141
|
-
# A stale-batch or drained-batch-redelivery
|
|
141
|
+
# A stale-batch or drained-batch-redelivery halt means this run's epoch
|
|
142
142
|
# belongs to a drained generation — typically a slow straggler or a
|
|
143
143
|
# Sidekiq at-least-once redelivery. If the redelivery is of a job that
|
|
144
144
|
# ALREADY reached a terminal status, its stored context is the source of
|
|
145
|
-
# truth; writing :
|
|
146
|
-
# corrupt the outcome. Return the
|
|
145
|
+
# truth; writing :halted over a :completed/:failed record would silently
|
|
146
|
+
# corrupt the outcome. Return the halt to the worker (so it stops)
|
|
147
147
|
# without saving. The `@skip_context_persist` flag also suppresses the
|
|
148
148
|
# ensure-block save in execute / resume_execution, which would otherwise
|
|
149
149
|
# clobber the stored terminal record with this run's stale in-memory
|
|
@@ -169,14 +169,14 @@ module RubyReactor
|
|
|
169
169
|
# set when the status was terminal, but re-checking keeps both paths
|
|
170
170
|
# uniform and self-guarding.
|
|
171
171
|
def redelivery_of_terminal?(result)
|
|
172
|
-
return false unless result.is_a?(RubyReactor::
|
|
172
|
+
return false unless result.is_a?(RubyReactor::Halt)
|
|
173
173
|
return false unless %i[ordered_lock_stale_batch ordered_lock_drained_replay].include?(result.reason)
|
|
174
174
|
|
|
175
175
|
stored_status_terminal?
|
|
176
176
|
end
|
|
177
177
|
|
|
178
178
|
def stored_status_terminal?
|
|
179
|
-
%w[completed failed skipped].include?(stored_context_status)
|
|
179
|
+
%w[completed failed halted skipped].include?(stored_context_status)
|
|
180
180
|
end
|
|
181
181
|
|
|
182
182
|
def stored_context_status
|
|
@@ -14,9 +14,13 @@ module RubyReactor
|
|
|
14
14
|
|
|
15
15
|
def handle_step_result(step_config, result, resolved_arguments)
|
|
16
16
|
case result
|
|
17
|
+
when RubyReactor::Halt
|
|
18
|
+
# Important: must come before Skipped and Success — both are Halt's
|
|
19
|
+
# siblings under Success, and Halt takes precedence over either.
|
|
20
|
+
handle_halt(step_config, result)
|
|
17
21
|
when RubyReactor::Skipped
|
|
18
|
-
#
|
|
19
|
-
handle_skipped(step_config, result)
|
|
22
|
+
# Must come before the Success branch — Skipped < Success.
|
|
23
|
+
handle_skipped(step_config, result, resolved_arguments)
|
|
20
24
|
when RubyReactor::Success
|
|
21
25
|
handle_success(step_config, result, resolved_arguments)
|
|
22
26
|
when RubyReactor::MaxRetriesExhaustedFailure
|
|
@@ -78,19 +82,40 @@ module RubyReactor
|
|
|
78
82
|
)
|
|
79
83
|
end
|
|
80
84
|
|
|
81
|
-
# A step returned `RubyReactor.
|
|
85
|
+
# A step returned `RubyReactor.Halt(...)`. Halt cleanly: record the
|
|
82
86
|
# event in the trace, do NOT push to the undo stack (so existing
|
|
83
87
|
# completed steps stay as-is — no compensation), and stamp the step
|
|
84
88
|
# name on the result so the caller can see who halted.
|
|
85
|
-
def
|
|
89
|
+
def handle_halt(step_config, result)
|
|
86
90
|
@step_results[step_config.name] = result
|
|
87
91
|
result.instance_variable_set(:@step_name, step_config.name) if result.step_name.nil?
|
|
88
|
-
@context.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
@context.append_execution_trace(
|
|
93
|
+
{
|
|
94
|
+
type: :halt,
|
|
95
|
+
step: step_config.name,
|
|
96
|
+
timestamp: Time.now,
|
|
97
|
+
reason: result.reason
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A step returned `RubyReactor.Skipped(...)`. The reactor continues:
|
|
104
|
+
# validate and record the value exactly like a Success, but do NOT push
|
|
105
|
+
# to the undo stack (nothing happened, so there is nothing to undo).
|
|
106
|
+
def handle_skipped(step_config, result, resolved_arguments)
|
|
107
|
+
validate_step_output(step_config, result.value, resolved_arguments)
|
|
108
|
+
@step_results[step_config.name] = result
|
|
109
|
+
@context.set_result(step_config.name, result.value)
|
|
110
|
+
@dependency_graph.complete_step(step_config.name)
|
|
111
|
+
@context.append_execution_trace(
|
|
112
|
+
{
|
|
113
|
+
type: :skipped,
|
|
114
|
+
step: step_config.name,
|
|
115
|
+
timestamp: Time.now,
|
|
116
|
+
reason: result.reason
|
|
117
|
+
}
|
|
118
|
+
)
|
|
94
119
|
result
|
|
95
120
|
end
|
|
96
121
|
|
|
@@ -94,7 +94,10 @@ module RubyReactor
|
|
|
94
94
|
|
|
95
95
|
def handle_retry_result(step_config, reactor_class, result)
|
|
96
96
|
case result
|
|
97
|
-
when RubyReactor::Success
|
|
97
|
+
when RubyReactor::Halt, RubyReactor::Skipped, RubyReactor::Success
|
|
98
|
+
# Halt and Skipped are Success subclasses, so they already take this
|
|
99
|
+
# path via inheritance; the explicit arms are readability plus a
|
|
100
|
+
# guard against a future hierarchy change (R5).
|
|
98
101
|
clear_retry_state
|
|
99
102
|
result
|
|
100
103
|
when RubyReactor::Failure
|
|
@@ -37,10 +37,10 @@ module RubyReactor
|
|
|
37
37
|
# If a step returns RetryQueuedResult, we need to stop and return it
|
|
38
38
|
return result if result.is_a?(RetryQueuedResult)
|
|
39
39
|
|
|
40
|
-
# If a step returns
|
|
40
|
+
# If a step returns Halt, stop the reactor cleanly (no
|
|
41
41
|
# compensation). Must be checked BEFORE Failure / Success because
|
|
42
|
-
#
|
|
43
|
-
return result if result.is_a?(RubyReactor::
|
|
42
|
+
# Halt is a Success subclass.
|
|
43
|
+
return result if result.is_a?(RubyReactor::Halt)
|
|
44
44
|
|
|
45
45
|
# If a step returns Failure, we need to stop execution and return it
|
|
46
46
|
return result if result.is_a?(RubyReactor::Failure)
|
|
@@ -48,13 +48,17 @@ module RubyReactor
|
|
|
48
48
|
# If a step returns InterruptResult, we need to stop execution and return it
|
|
49
49
|
return result if result.is_a?(RubyReactor::InterruptResult)
|
|
50
50
|
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
51
|
+
# A Skipped step (or a plain Success) continues the loop — Skipped
|
|
52
|
+
# is a Success subclass, so this also fires the durable checkpoint
|
|
53
|
+
# for it, same as a plain success.
|
|
54
|
+
#
|
|
55
|
+
# Only a continue-Success/Skipped reaches here (Async/Retry/Halt/
|
|
56
|
+
# Failure/Interrupt all returned above; nil is inline-async test
|
|
57
|
+
# mode). It is the one outcome where the loop proceeds to more
|
|
58
|
+
# steps with no other save in between — every terminal/handoff
|
|
59
|
+
# result persists via its own path. Write a durable checkpoint so
|
|
60
|
+
# a crash re-runs at most this one step. Ordering: side-effect ->
|
|
61
|
+
# record result (inside execute_step) -> checkpoint here.
|
|
58
62
|
@on_step_complete&.call if result.is_a?(RubyReactor::Success)
|
|
59
63
|
end
|
|
60
64
|
end
|
|
@@ -336,15 +340,17 @@ module RubyReactor
|
|
|
336
340
|
end
|
|
337
341
|
|
|
338
342
|
def run_step_implementation(step_config, arguments)
|
|
339
|
-
@context.
|
|
343
|
+
@context.append_execution_trace(
|
|
344
|
+
{ type: :run, step: step_config.name, timestamp: Time.now, arguments: arguments }
|
|
345
|
+
)
|
|
340
346
|
if step_config.has_run_block?
|
|
341
347
|
# Execute inline block
|
|
342
348
|
# If no arguments are defined for the step, pass the reactor inputs as arguments
|
|
343
349
|
args_to_pass = arguments.empty? ? @context.inputs : arguments
|
|
344
|
-
step_config.run_block.call(args_to_pass, @context)
|
|
350
|
+
catch(StepSignals::TAG) { step_config.run_block.call(args_to_pass, @context) }
|
|
345
351
|
elsif step_config.has_impl?
|
|
346
352
|
# Execute step class
|
|
347
|
-
step_config.impl.run(arguments, @context)
|
|
353
|
+
catch(StepSignals::TAG) { step_config.impl.run(arguments, @context) }
|
|
348
354
|
else
|
|
349
355
|
raise Error::ValidationError.new(
|
|
350
356
|
"Step '#{step_config.name}' has no implementation",
|
|
@@ -110,9 +110,9 @@ module RubyReactor
|
|
|
110
110
|
# is a fast path; this one closes the race where two callers both passed
|
|
111
111
|
# it and then serialized on the lock — without it the second caller would
|
|
112
112
|
# re-run work the first already marked. (No-op when no lock is configured.)
|
|
113
|
-
if (
|
|
113
|
+
if (halted = check_period_gate)
|
|
114
114
|
completed = true
|
|
115
|
-
return
|
|
115
|
+
return finalize_halt(halted)
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
@context.status = :running
|
|
@@ -218,9 +218,9 @@ module RubyReactor
|
|
|
218
218
|
|
|
219
219
|
# Post-lock re-check (see execute) — closes the period race for the
|
|
220
220
|
# first run of a locked async reactor.
|
|
221
|
-
if first_run && (
|
|
221
|
+
if first_run && (halted = check_period_gate)
|
|
222
222
|
completed = true
|
|
223
|
-
return
|
|
223
|
+
return finalize_halt(halted)
|
|
224
224
|
end
|
|
225
225
|
|
|
226
226
|
prepare_for_resume
|
|
@@ -393,16 +393,16 @@ module RubyReactor
|
|
|
393
393
|
@context.current_step.nil? && @context.intermediate_results.empty?
|
|
394
394
|
end
|
|
395
395
|
|
|
396
|
-
# Record and persist a
|
|
396
|
+
# Record and persist a Halt result, then return it. Shared by the
|
|
397
397
|
# pre-lock and post-lock period gates in both execute and resume.
|
|
398
|
-
def
|
|
399
|
-
@result =
|
|
398
|
+
def finalize_halt(halted)
|
|
399
|
+
@result = halted
|
|
400
400
|
update_context_status(@result)
|
|
401
401
|
save_context
|
|
402
402
|
@result
|
|
403
403
|
end
|
|
404
404
|
|
|
405
|
-
# Returns a
|
|
405
|
+
# Returns a Halt result if the period bucket is already marked, else nil.
|
|
406
406
|
# Consulted before AND after lock acquisition on a first execution; genuine
|
|
407
407
|
# resumes never re-check (a paused run must not skip itself when its own
|
|
408
408
|
# marker eventually appears).
|
|
@@ -413,13 +413,13 @@ module RubyReactor
|
|
|
413
413
|
key = period_key(config)
|
|
414
414
|
return nil unless RubyReactor.configuration.storage_adapter.period_seen?(key)
|
|
415
415
|
|
|
416
|
-
RubyReactor::
|
|
416
|
+
RubyReactor::Halt.new(reason: :period, period_key: key)
|
|
417
417
|
end
|
|
418
418
|
|
|
419
419
|
def mark_period_on_success(result)
|
|
420
420
|
return unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
|
|
421
421
|
return unless result.is_a?(RubyReactor::Success)
|
|
422
|
-
return if result.is_a?(RubyReactor::
|
|
422
|
+
return if result.is_a?(RubyReactor::Halt)
|
|
423
423
|
|
|
424
424
|
config = @reactor_class.period_config
|
|
425
425
|
ttl = RubyReactor::Period.ttl_seconds(config[:every])
|
|
@@ -662,8 +662,8 @@ module RubyReactor
|
|
|
662
662
|
case result
|
|
663
663
|
when RubyReactor::DispatchResult
|
|
664
664
|
@context.status = :running
|
|
665
|
-
when RubyReactor::
|
|
666
|
-
@context.status = :
|
|
665
|
+
when RubyReactor::Halt
|
|
666
|
+
@context.status = :halted
|
|
667
667
|
when RubyReactor::Success
|
|
668
668
|
@context.status = :completed
|
|
669
669
|
when RubyReactor::Failure
|
|
@@ -696,14 +696,16 @@ module RubyReactor
|
|
|
696
696
|
@result = @step_executor.execute_all_steps
|
|
697
697
|
else
|
|
698
698
|
case result
|
|
699
|
-
#
|
|
699
|
+
# Halt must be listed before Success (Halt < Success) so the
|
|
700
700
|
# halt path wins over the "continue with remaining steps" path.
|
|
701
|
-
|
|
701
|
+
# Skipped is NOT listed here — it is a Success subclass and must
|
|
702
|
+
# continue with the remaining steps, same as a plain Success.
|
|
703
|
+
when RubyReactor::Halt,
|
|
702
704
|
RetryQueuedResult,
|
|
703
705
|
RubyReactor::Failure,
|
|
704
706
|
RubyReactor::DispatchResult,
|
|
705
707
|
RubyReactor::InterruptResult
|
|
706
|
-
# Terminal: step
|
|
708
|
+
# Terminal: step halted, requeued, failed, paused, or handed
|
|
707
709
|
# off to async. Return the result as-is.
|
|
708
710
|
@result = result
|
|
709
711
|
when RubyReactor::Success
|
|
@@ -141,7 +141,13 @@ module RubyReactor
|
|
|
141
141
|
index = arguments[:index]
|
|
142
142
|
parent_class = arguments[:parent_reactor_class_name] # Using short name for variable
|
|
143
143
|
|
|
144
|
-
if result.
|
|
144
|
+
if result.halted?
|
|
145
|
+
# A Halt must not be collected as a (nil) value indistinguishable
|
|
146
|
+
# from an ordinary success — mark it so the enumerator reconstructs
|
|
147
|
+
# a real Halt for the consumer.
|
|
148
|
+
storage.store_map_result(map_id, index, { _halt: true, reason: result.reason },
|
|
149
|
+
parent_class, strict_ordering: arguments[:strict_ordering])
|
|
150
|
+
elsif result.success?
|
|
145
151
|
storage.store_map_result(map_id, index, ContextSerializer.serialize_value(result.value),
|
|
146
152
|
parent_class, strict_ordering: arguments[:strict_ordering])
|
|
147
153
|
else
|
|
@@ -96,13 +96,15 @@ module RubyReactor
|
|
|
96
96
|
# Manually update execution trace to reflect completion
|
|
97
97
|
# This is necessary because resume_execution continues from the NEXT step
|
|
98
98
|
# and the async step (which returned DispatchResult) needs to be marked as done with actual value
|
|
99
|
-
parent_context.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
parent_context.append_execution_trace(
|
|
100
|
+
{
|
|
101
|
+
type: :result,
|
|
102
|
+
step: step_name_sym,
|
|
103
|
+
timestamp: Time.now,
|
|
104
|
+
value: final_result.value,
|
|
105
|
+
status: :success
|
|
106
|
+
}
|
|
107
|
+
)
|
|
106
108
|
|
|
107
109
|
parent_context.current_step = nil
|
|
108
110
|
executor.resume_execution
|
|
@@ -101,6 +101,8 @@ module RubyReactor
|
|
|
101
101
|
# whoever happened to materialize the enumerator (the JSON encoder, in
|
|
102
102
|
# the dashboard's case) instead of the stack of the step that failed.
|
|
103
103
|
RubyReactor::Failure.new(result["_error"], backtrace: [])
|
|
104
|
+
elsif result.is_a?(Hash) && result.key?("_halt")
|
|
105
|
+
RubyReactor::Halt.new(reason: result["reason"])
|
|
104
106
|
else
|
|
105
107
|
RubyReactor::Success.new(ContextSerializer.deserialize_value(result))
|
|
106
108
|
end
|
|
@@ -92,7 +92,7 @@ module RubyReactor
|
|
|
92
92
|
|
|
93
93
|
results = data["intermediate_results"] || {}
|
|
94
94
|
status = data["status"].to_s
|
|
95
|
-
results.key?(meta["step_name"].to_s) || %w[completed failed skipped].include?(status)
|
|
95
|
+
results.key?(meta["step_name"].to_s) || %w[completed failed halted skipped].include?(status)
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def retrigger_collector(meta)
|
|
@@ -502,9 +502,9 @@ module RubyReactor
|
|
|
502
502
|
|
|
503
503
|
case result
|
|
504
504
|
when RubyReactor::Success
|
|
505
|
-
if result.
|
|
506
|
-
span.set_attribute("reactor.status", "
|
|
507
|
-
span.set_attribute("reactor.
|
|
505
|
+
if result.halted?
|
|
506
|
+
span.set_attribute("reactor.status", "halted")
|
|
507
|
+
span.set_attribute("reactor.halt_reason", result.reason.to_s)
|
|
508
508
|
span.status = ::OpenTelemetry::Trace::Status.ok
|
|
509
509
|
else
|
|
510
510
|
span.set_attribute("reactor.status", "completed")
|
|
@@ -578,7 +578,10 @@ module RubyReactor
|
|
|
578
578
|
|
|
579
579
|
case result
|
|
580
580
|
when RubyReactor::Success
|
|
581
|
-
if result.
|
|
581
|
+
if result.halted?
|
|
582
|
+
span.set_attribute("step.status", "halted")
|
|
583
|
+
span.set_attribute("step.halt_reason", result.reason.to_s)
|
|
584
|
+
elsif result.skipped?
|
|
582
585
|
span.set_attribute("step.status", "skipped")
|
|
583
586
|
span.set_attribute("step.skipped_reason", result.reason.to_s)
|
|
584
587
|
else
|
|
@@ -62,11 +62,11 @@ module RubyReactor
|
|
|
62
62
|
# via the stored context status.
|
|
63
63
|
# - `:skip_chain_failed` — only in strict mode: an earlier nonce in this
|
|
64
64
|
# sequence terminated with a Failure, so this run is short-circuited
|
|
65
|
-
# with `
|
|
65
|
+
# with `Halt(reason: :ordered_lock_chain_failed)` without executing.
|
|
66
66
|
# - `:stale_batch` — this run's epoch no longer matches the key's current
|
|
67
67
|
# generation: its batch fully drained and the numbering was reused by a
|
|
68
68
|
# newer batch. The run is short-circuited with
|
|
69
|
-
# `
|
|
69
|
+
# `Halt(reason: :ordered_lock_stale_batch)` and must not participate.
|
|
70
70
|
# - `:poison_advance` is collapsed to `:go` from the caller's perspective.
|
|
71
71
|
def check!
|
|
72
72
|
raise ArgumentError, "OrderedLock#check! requires a nonce" unless @nonce
|
|
@@ -111,7 +111,7 @@ module RubyReactor
|
|
|
111
111
|
#
|
|
112
112
|
# `failed:` records this nonce as the chain-failure marker (only the FIRST
|
|
113
113
|
# failure sticks). In strict mode the marker causes subsequent nonces to
|
|
114
|
-
# short-circuit with
|
|
114
|
+
# short-circuit with Halt.
|
|
115
115
|
def advance!(failed: false)
|
|
116
116
|
raise ArgumentError, "OrderedLock#advance! requires a nonce" unless @nonce
|
|
117
117
|
|