ruby_reactor 0.6.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 +125 -1
- data/CLAUDE.md +5 -0
- data/README.md +155 -25
- data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
- data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
- data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
- data/lib/ruby_reactor/async_waiter.rb +113 -0
- data/lib/ruby_reactor/configuration.rb +30 -1
- data/lib/ruby_reactor/context.rb +28 -0
- data/lib/ruby_reactor/context_serializer.rb +15 -0
- data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
- data/lib/ruby_reactor/dsl/lockable.rb +3 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
- data/lib/ruby_reactor/dsl/reactor.rb +29 -10
- data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
- data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
- data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
- data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
- data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
- data/lib/ruby_reactor/executor/result_handler.rb +47 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
- data/lib/ruby_reactor/executor/step_executor.rb +77 -24
- data/lib/ruby_reactor/executor.rb +179 -23
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +13 -2
- data/lib/ruby_reactor/map/helpers.rb +10 -8
- data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/open_telemetry.rb +8 -5
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +25 -2
- data/lib/ruby_reactor/rspec/matchers.rb +61 -11
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
- data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
- data/lib/ruby_reactor/semaphore.rb +10 -0
- data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
- data/lib/ruby_reactor/step/compose_step.rb +1 -1
- data/lib/ruby_reactor/step/map_step.rb +6 -2
- data/lib/ruby_reactor/step.rb +10 -4
- data/lib/ruby_reactor/step_signals.rb +33 -0
- data/lib/ruby_reactor/step_sweeper.rb +72 -0
- data/lib/ruby_reactor/step_worker.rb +260 -0
- data/lib/ruby_reactor/storage/adapter.rb +26 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
- data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
- data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
- data/lib/ruby_reactor/sweeper.rb +7 -1
- data/lib/ruby_reactor/sweeper_job.rb +1 -0
- data/lib/ruby_reactor/template/result.rb +151 -5
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +117 -22
- 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/worker.rb +53 -3
- data/lib/ruby_reactor.rb +80 -10
- data/specs/active_job.md +1 -1
- metadata +23 -3
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
|
@@ -10,13 +10,14 @@ module RubyReactor
|
|
|
10
10
|
base.instance_variable_set(:@return_step, nil)
|
|
11
11
|
base.instance_variable_set(:@middlewares, [])
|
|
12
12
|
base.instance_variable_set(:@input_validations, {})
|
|
13
|
-
base.instance_variable_set(:@
|
|
13
|
+
base.instance_variable_set(:@background_handoff, nil)
|
|
14
14
|
base.instance_variable_set(:@retry_defaults, { max_attempts: 3, backoff: :exponential, base_delay: 1 })
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
module ClassMethods
|
|
18
18
|
include RubyReactor::Dsl::TemplateHelpers
|
|
19
19
|
include RubyReactor::Dsl::ValidationHelpers
|
|
20
|
+
include RubyReactor::Dsl::AsyncMacros
|
|
20
21
|
|
|
21
22
|
require_relative "interrupt_builder"
|
|
22
23
|
require_relative "interrupt_step_config"
|
|
@@ -41,12 +42,17 @@ module RubyReactor
|
|
|
41
42
|
@input_validations ||= {}
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
# Whole-reactor `async true` is gone: it named the same idea as
|
|
46
|
+
# `background`'s cut point with a different word, right next to the
|
|
47
|
+
# new `async_step`/`async_reactor` macros whose names mean something
|
|
48
|
+
# else entirely. `async?` (the reader) lives in `AsyncMacros`, driven
|
|
49
|
+
# off `background_handoff`.
|
|
50
|
+
def async(*)
|
|
51
|
+
raise RubyReactor::Error::DeprecatedDslError,
|
|
52
|
+
"`async true` on a reactor has been removed: it named the same idea as `background`'s cut " \
|
|
53
|
+
"point with a different word, and read confusingly next to the `async_step`/`async_reactor` " \
|
|
54
|
+
"step macros. Use `background all: true` instead — identical behavior, including validating " \
|
|
55
|
+
"inputs inside the worker."
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
def retry_defaults(**kwargs)
|
|
@@ -138,8 +144,18 @@ module RubyReactor
|
|
|
138
144
|
step_config
|
|
139
145
|
end
|
|
140
146
|
|
|
141
|
-
|
|
142
|
-
|
|
147
|
+
# `resume: :background` — after `continue` validates and stores the
|
|
148
|
+
# payload, the remaining work is enqueued to a worker instead of
|
|
149
|
+
# running inline in the delivering process (webhook, admin UI).
|
|
150
|
+
def interrupt(name, resume: :inline, &block)
|
|
151
|
+
unless %i[inline background].include?(resume)
|
|
152
|
+
raise RubyReactor::Error::ValidationError,
|
|
153
|
+
"interrupt :#{name} has invalid `resume: #{resume.inspect}` — " \
|
|
154
|
+
"use `:inline` (default, resume runs in the calling process) or " \
|
|
155
|
+
"`:background` (resume is enqueued to a worker)."
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
builder = RubyReactor::Dsl::InterruptBuilder.new(name, self, resume: resume)
|
|
143
159
|
builder.instance_eval(&block) if block_given?
|
|
144
160
|
|
|
145
161
|
step_config = builder.build
|
|
@@ -148,7 +164,10 @@ module RubyReactor
|
|
|
148
164
|
end
|
|
149
165
|
|
|
150
166
|
def returns(step_name = nil)
|
|
151
|
-
|
|
167
|
+
if step_name
|
|
168
|
+
reject_async_return_step!(step_name)
|
|
169
|
+
@return_step = step_name
|
|
170
|
+
end
|
|
152
171
|
@return_step
|
|
153
172
|
end
|
|
154
173
|
|
|
@@ -24,7 +24,6 @@ module RubyReactor
|
|
|
24
24
|
@validate_args_input = nil
|
|
25
25
|
@args_validator = nil
|
|
26
26
|
@output_validator = nil
|
|
27
|
-
@async = false
|
|
28
27
|
@retry_config = {}
|
|
29
28
|
end
|
|
30
29
|
|
|
@@ -85,8 +84,22 @@ module RubyReactor
|
|
|
85
84
|
end
|
|
86
85
|
end
|
|
87
86
|
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
# The per-step hand-off flag is gone. Only the FIRST flagged step
|
|
88
|
+
# in a reactor ever took effect — every later one was silently ignored —
|
|
89
|
+
# so this must fail at class-definition time rather than surprise someone
|
|
90
|
+
# at run time. Kept as a stub purely to say what to use instead.
|
|
91
|
+
def async(*)
|
|
92
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
93
|
+
"`async` inside a `step` block has been removed: it was ambiguous (only the first " \
|
|
94
|
+
"flagged step in a reactor ever took effect). Replacements:\n " \
|
|
95
|
+
"* `background after: :#{@name}` — hand every REMAINING step to a worker once " \
|
|
96
|
+
":#{@name} finishes in the calling process (declared on the reactor, not the step);\n " \
|
|
97
|
+
"* `background before: :#{@name}` — hand off starting WITH :#{@name};\n " \
|
|
98
|
+
"* `async_step :#{@name}` — dispatch just this step's work to its own job while the " \
|
|
99
|
+
"reactor keeps running;\n " \
|
|
100
|
+
"* `async_reactor :name, ChildReactor` — dispatch a whole nested reactor independently.",
|
|
101
|
+
step: @name
|
|
102
|
+
)
|
|
90
103
|
end
|
|
91
104
|
|
|
92
105
|
def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
|
|
@@ -97,8 +110,12 @@ module RubyReactor
|
|
|
97
110
|
}
|
|
98
111
|
end
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
# `async_dispatch` marks a step whose work is dispatched as an independent
|
|
114
|
+
# unit rather than run inline — `:step` for `async_step`, `:reactor` for
|
|
115
|
+
# `async_reactor`. Nil for an ordinary step.
|
|
116
|
+
def build(async_dispatch: nil)
|
|
101
117
|
step_config = {
|
|
118
|
+
async_dispatch: async_dispatch,
|
|
102
119
|
name: @name,
|
|
103
120
|
impl: @impl,
|
|
104
121
|
arguments: @arguments,
|
|
@@ -110,7 +127,6 @@ module RubyReactor
|
|
|
110
127
|
dependencies: @dependencies,
|
|
111
128
|
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
112
129
|
output_validator: @output_validator,
|
|
113
|
-
async: @async,
|
|
114
130
|
retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
|
|
115
131
|
}
|
|
116
132
|
|
|
@@ -120,9 +136,10 @@ module RubyReactor
|
|
|
120
136
|
|
|
121
137
|
class StepConfig
|
|
122
138
|
attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
|
|
123
|
-
:dependencies, :args_validator, :output_validator, :
|
|
139
|
+
:dependencies, :args_validator, :output_validator, :retry_config, :async_dispatch
|
|
124
140
|
|
|
125
141
|
def initialize(config)
|
|
142
|
+
@async_dispatch = config[:async_dispatch]
|
|
126
143
|
@name = config[:name]
|
|
127
144
|
@impl = config[:impl]
|
|
128
145
|
@arguments = config[:arguments] || {}
|
|
@@ -134,10 +151,16 @@ module RubyReactor
|
|
|
134
151
|
@dependencies = config[:dependencies] || []
|
|
135
152
|
@args_validator = config[:args_validator]
|
|
136
153
|
@output_validator = config[:output_validator]
|
|
137
|
-
@async = config[:async] || false
|
|
138
154
|
@retry_config = { max_attempts: 1 }.merge(config[:retry_config] || {})
|
|
139
155
|
end
|
|
140
156
|
|
|
157
|
+
# True for `async_step` / `async_reactor` — the step's work leaves this
|
|
158
|
+
# process instead of running inline. `RSpec::TestSubject`'s `async: false`
|
|
159
|
+
# clears the marker to run the whole reactor in one process.
|
|
160
|
+
def async_dispatch?
|
|
161
|
+
!@async_dispatch.nil?
|
|
162
|
+
end
|
|
163
|
+
|
|
141
164
|
def has_impl?
|
|
142
165
|
!@impl.nil?
|
|
143
166
|
end
|
|
@@ -146,10 +169,6 @@ module RubyReactor
|
|
|
146
169
|
!@run_block.nil?
|
|
147
170
|
end
|
|
148
171
|
|
|
149
|
-
def async?
|
|
150
|
-
@async
|
|
151
|
-
end
|
|
152
|
-
|
|
153
172
|
def retryable?
|
|
154
173
|
(retry_config[:max_attempts] || 0) > 1
|
|
155
174
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# Raised inside a WORKER when `result(:name)` references an `async_step` /
|
|
6
|
+
# `async_reactor` that is not yet terminal. Instead of blocking the worker
|
|
7
|
+
# thread for the whole wait (the sync-caller behavior), the executor parks:
|
|
8
|
+
# exclusive lock / semaphore stay HELD (recorded on the context), the job
|
|
9
|
+
# re-enqueues itself via the snooze path, and the wait resumes on
|
|
10
|
+
# redelivery. Bounded by `Configuration#async_park_timeout`, enforced at
|
|
11
|
+
# the wait site before this is raised.
|
|
12
|
+
class AsyncResultPending < Base
|
|
13
|
+
attr_reader :channel
|
|
14
|
+
|
|
15
|
+
def initialize(message, channel: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@channel = channel
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# Raised when a notified wait on an `async_step` / `async_reactor`
|
|
6
|
+
# result exceeds `Configuration#async_wait_timeout`. Never an unbounded wait.
|
|
7
|
+
class AsyncWaitTimeoutError < Base
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# Definition-time error for DSL that has been removed. Subclasses
|
|
6
|
+
# ValidationError so existing `rescue Error::ValidationError` sites keep
|
|
7
|
+
# catching it.
|
|
8
|
+
class DeprecatedDslError < ValidationError
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Executor
|
|
5
|
+
# The dispatching half of `async_step`, mixed into StepExecutor. Split out
|
|
6
|
+
# because it is a self-contained concern — write the durable record and the
|
|
7
|
+
# context reference, enqueue, then mark the node graph-complete — and because
|
|
8
|
+
# it is where the structured logging for every hand-off lives.
|
|
9
|
+
module AsyncStepDispatch
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Send one step's work off as its own job and KEEP GOING.
|
|
13
|
+
#
|
|
14
|
+
# The ordering below is load-bearing (F2): the durable record and the
|
|
15
|
+
# context reference are written BEFORE the enqueue, so a crash in between
|
|
16
|
+
# can never leave a job with no record — and that same record is what
|
|
17
|
+
# tells a recovery pass the work is already out there, so it
|
|
18
|
+
# re-attaches instead of dispatching a duplicate side effect.
|
|
19
|
+
#
|
|
20
|
+
# Deliberately NOT gated on `inline_async_execution`: that flag stops the
|
|
21
|
+
# `background` hand-off from re-triggering inside a worker, but an
|
|
22
|
+
# `async_step` reached during a worker resume must still get its own job,
|
|
23
|
+
# or the feature silently degrades to inline execution exactly where the
|
|
24
|
+
# spec says it must not.
|
|
25
|
+
def dispatch_async_step(step_config)
|
|
26
|
+
if already_dispatched?(step_config)
|
|
27
|
+
@dependency_graph.complete_step(step_config.name)
|
|
28
|
+
return RubyReactor.Success(nil)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
record_async_step_dispatch(step_config)
|
|
32
|
+
enqueue_async_step(step_config)
|
|
33
|
+
|
|
34
|
+
# Mark complete for SCHEDULING only — no result is recorded, so
|
|
35
|
+
# `result(:name)` still routes through the notified wait. This is what
|
|
36
|
+
# lets unrelated siblings become ready and run while the unit is in
|
|
37
|
+
# flight, instead of the loop returning early on an DispatchResult.
|
|
38
|
+
@dependency_graph.complete_step(step_config.name)
|
|
39
|
+
RubyReactor.Success(nil)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def already_dispatched?(step_config)
|
|
43
|
+
!storage.retrieve_step_result(@context.context_id, step_config.name, async_step_class_name).nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_async_step_dispatch(step_config)
|
|
47
|
+
root = @context.root_context || @context
|
|
48
|
+
@context.composed_contexts[step_config.name] = {
|
|
49
|
+
name: step_config.name,
|
|
50
|
+
type: :async_step_ref,
|
|
51
|
+
# Carried on the ref so the dashboard can find the Step Result Record
|
|
52
|
+
# from the reference alone, without re-deriving which context owns it.
|
|
53
|
+
context_id: @context.context_id,
|
|
54
|
+
dispatched_at: Time.now
|
|
55
|
+
}
|
|
56
|
+
storage.store_step_result(
|
|
57
|
+
@context.context_id, step_config.name,
|
|
58
|
+
{
|
|
59
|
+
"status" => "dispatched", "dispatched_at" => Time.now.iso8601,
|
|
60
|
+
# The re-dispatch arguments, verbatim. The record's own key names the
|
|
61
|
+
# reactor that OWNS the step, which for a composed child is not the
|
|
62
|
+
# root the worker must load, so recovery cannot re-derive them.
|
|
63
|
+
"root_context_id" => root.context_id,
|
|
64
|
+
"reactor_class_name" => RubyReactor.reactor_storage_name(root.reactor_class),
|
|
65
|
+
"step_context_id" => @context.context_id,
|
|
66
|
+
"step_name" => step_config.name.to_s
|
|
67
|
+
},
|
|
68
|
+
async_step_class_name
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# The worker loads the parent by id, so the parent must be durable
|
|
72
|
+
# before the job exists AND must outlive the dispatched unit — including
|
|
73
|
+
# the fire-and-forget case where this reactor finishes immediately and
|
|
74
|
+
# nothing ever waits.
|
|
75
|
+
checkpoint_root!(root, RubyReactor.reactor_storage_name(root.reactor_class))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def enqueue_async_step(step_config)
|
|
79
|
+
root = @context.root_context || @context
|
|
80
|
+
log_async_event("async_step.dispatched", step_config.name)
|
|
81
|
+
configuration.async_router.perform_step_async(
|
|
82
|
+
root_context_id: root.context_id,
|
|
83
|
+
reactor_class_name: RubyReactor.reactor_storage_name(root.reactor_class),
|
|
84
|
+
step_context_id: @context.context_id,
|
|
85
|
+
step_name: step_config.name
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Step Result Records are namespaced by the reactor that OWNS the step,
|
|
90
|
+
# which for a composed child is the child — the same name the reader's
|
|
91
|
+
# `Template::Result` will look under.
|
|
92
|
+
def async_step_class_name
|
|
93
|
+
RubyReactor.reactor_storage_name(@context.reactor_class || @reactor_class)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def storage
|
|
97
|
+
RubyReactor::Configuration.instance.storage_adapter
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One machine-parseable line per hand-off / dispatch, carrying the
|
|
101
|
+
# three identifiers needed to correlate it with everything else.
|
|
102
|
+
def log_async_event(event, step_name)
|
|
103
|
+
configuration.logger.info(
|
|
104
|
+
"event=\"ruby_reactor.#{event}\" reactor=#{@reactor_class&.name.inspect} " \
|
|
105
|
+
"step=#{step_name.inspect} execution_id=#{@context.context_id.inspect}"
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
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
|
|
@@ -69,7 +69,7 @@ module RubyReactor
|
|
|
69
69
|
#
|
|
70
70
|
# The strict-mode chain-skip only fires on a *fresh* start (no step
|
|
71
71
|
# has run yet on this context). This lets an in-flight run that paused
|
|
72
|
-
# (Interrupt /
|
|
72
|
+
# (Interrupt / DispatchResult) complete on resume regardless of chain
|
|
73
73
|
# failures that landed while it was parked, while still applying
|
|
74
74
|
# strict to a fresh Sidekiq job (which enters via `resume_execution`
|
|
75
75
|
# but has no prior step state).
|
|
@@ -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
|
|
@@ -296,7 +296,7 @@ module RubyReactor
|
|
|
296
296
|
|
|
297
297
|
def terminal_for_ordered_lock?(result)
|
|
298
298
|
case result
|
|
299
|
-
when RubyReactor::
|
|
299
|
+
when RubyReactor::DispatchResult, RubyReactor::InterruptResult, RetryQueuedResult
|
|
300
300
|
false
|
|
301
301
|
when RubyReactor::Success, RubyReactor::Failure
|
|
302
302
|
true
|
|
@@ -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,30 +82,62 @@ 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
|
|
|
97
122
|
def handle_success(step_config, result, resolved_arguments)
|
|
98
123
|
validate_step_output(step_config, result.value, resolved_arguments)
|
|
99
124
|
@step_results[step_config.name] = result
|
|
100
|
-
|
|
125
|
+
# `async_step` / `async_reactor` dispatches are independent units of
|
|
126
|
+
# work with their own compensation flows — the parent rolling back must
|
|
127
|
+
# not "undo" a dispatch whose unit runs (and may still succeed)
|
|
128
|
+
# elsewhere. They never enter the parent's undo stack.
|
|
129
|
+
unless async_unit?(step_config)
|
|
130
|
+
@compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
|
|
131
|
+
result: result })
|
|
132
|
+
end
|
|
101
133
|
@context.set_result(step_config.name, result.value)
|
|
102
134
|
@dependency_graph.complete_step(step_config.name)
|
|
103
135
|
end
|
|
104
136
|
|
|
137
|
+
def async_unit?(step_config)
|
|
138
|
+
step_config.respond_to?(:async_dispatch?) && step_config.async_dispatch?
|
|
139
|
+
end
|
|
140
|
+
|
|
105
141
|
def handle_retries_exhausted(step_config, result, resolved_arguments)
|
|
106
142
|
@compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
|
|
107
143
|
orig_err = result.original_error.is_a?(Exception) ? result.original_error : nil
|