ruby_reactor 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +17 -14
- data/CHANGELOG.md +254 -0
- data/CLAUDE.md +1 -1
- data/README.md +66 -15
- data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
- data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
- data/lib/ruby_reactor/context.rb +14 -1
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
- data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +33 -0
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
- data/lib/ruby_reactor/executor/result_handler.rb +85 -63
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +61 -49
- data/lib/ruby_reactor/executor.rb +221 -7
- data/lib/ruby_reactor/map/collector.rb +31 -89
- data/lib/ruby_reactor/map/dispatcher.rb +65 -8
- data/lib/ruby_reactor/map/element_executor.rb +21 -11
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -125
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/map/step_rollback.rb +136 -0
- data/lib/ruby_reactor/map/sweeper.rb +66 -8
- data/lib/ruby_reactor/map.rb +17 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
- data/lib/ruby_reactor/reactor.rb +65 -2
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +59 -24
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +10 -1
- data/lib/ruby_reactor/step/map_step.rb +144 -36
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +18 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
- data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/sweeper.rb +3 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +11 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
- data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +7 -3
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
- data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
- data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
- data/specs/009-distributed-map-undo/data-model.md +151 -0
- data/specs/009-distributed-map-undo/plan.md +323 -0
- data/specs/009-distributed-map-undo/pr-description.md +78 -0
- data/specs/009-distributed-map-undo/quickstart.md +154 -0
- data/specs/009-distributed-map-undo/research.md +585 -0
- data/specs/009-distributed-map-undo/spec.md +360 -0
- data/specs/009-distributed-map-undo/tasks.md +1060 -0
- data/specs/future_improvements.md +64 -28
- metadata +54 -3
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sidekiq
|
|
6
|
+
# Rolls back one element of a fan-out map (009 R-07).
|
|
7
|
+
class MapElementRollbackWorker
|
|
8
|
+
include ::Sidekiq::Worker
|
|
9
|
+
|
|
10
|
+
def perform(arguments)
|
|
11
|
+
RubyReactor::Map::ElementRollback.perform(arguments)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -58,7 +58,7 @@ module RubyReactor
|
|
|
58
58
|
# rubocop:disable Metrics/ParameterLists
|
|
59
59
|
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
60
60
|
strict_ordering:, parent_context_id:, parent_reactor_class_name:,
|
|
61
|
-
step_name:, batch_size: nil, serialized_context: nil,
|
|
61
|
+
step_name:, batch_size: nil, serialized_context: nil, atomic: nil)
|
|
62
62
|
job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_async(
|
|
63
63
|
{
|
|
64
64
|
"map_id" => map_id,
|
|
@@ -72,7 +72,7 @@ module RubyReactor
|
|
|
72
72
|
"step_name" => step_name,
|
|
73
73
|
"batch_size" => batch_size,
|
|
74
74
|
"serialized_context" => serialized_context,
|
|
75
|
-
"
|
|
75
|
+
"atomic" => atomic
|
|
76
76
|
}
|
|
77
77
|
)
|
|
78
78
|
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
@@ -81,7 +81,7 @@ module RubyReactor
|
|
|
81
81
|
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
|
|
82
82
|
reactor_class_info:, strict_ordering:, parent_context_id:,
|
|
83
83
|
parent_reactor_class_name:, step_name:, batch_size: nil,
|
|
84
|
-
serialized_context: nil,
|
|
84
|
+
serialized_context: nil, atomic: nil)
|
|
85
85
|
job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_in(
|
|
86
86
|
delay,
|
|
87
87
|
{
|
|
@@ -96,7 +96,7 @@ module RubyReactor
|
|
|
96
96
|
"step_name" => step_name,
|
|
97
97
|
"batch_size" => batch_size,
|
|
98
98
|
"serialized_context" => serialized_context,
|
|
99
|
-
"
|
|
99
|
+
"atomic" => atomic
|
|
100
100
|
}
|
|
101
101
|
)
|
|
102
102
|
# Return an DispatchResult so RetryManager#handle_async_retry recognises the
|
|
@@ -105,6 +105,34 @@ module RubyReactor
|
|
|
105
105
|
end
|
|
106
106
|
# rubocop:enable Metrics/ParameterLists
|
|
107
107
|
|
|
108
|
+
# One element's rollback in a fan-out map's distributed rollback (009
|
|
109
|
+
# R-07); the `_in` form is its requeue on a contended element lock.
|
|
110
|
+
def self.perform_map_element_rollback_async(**args)
|
|
111
|
+
job_id = RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker.perform_async(map_element_rollback_payload(**args))
|
|
112
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.perform_map_element_rollback_in(delay, **args)
|
|
116
|
+
job_id = RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker.perform_in(
|
|
117
|
+
delay, map_element_rollback_payload(**args)
|
|
118
|
+
)
|
|
119
|
+
RubyReactor::DispatchResult.new(job_id: job_id)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# rubocop:disable Metrics/ParameterLists
|
|
123
|
+
def self.map_element_rollback_payload(map_id:, position:, element_context_id:, reactor_class_info:,
|
|
124
|
+
parent_reactor_class_name:, step_name:, batch_size:, owner_context_id:,
|
|
125
|
+
owner_reactor_class_name:, attempt: 0)
|
|
126
|
+
{
|
|
127
|
+
"map_id" => map_id, "position" => position, "element_context_id" => element_context_id,
|
|
128
|
+
"reactor_class_info" => reactor_class_info, "parent_reactor_class_name" => parent_reactor_class_name,
|
|
129
|
+
"step_name" => step_name, "batch_size" => batch_size, "owner_context_id" => owner_context_id,
|
|
130
|
+
"owner_reactor_class_name" => owner_reactor_class_name, "attempt" => attempt
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
# rubocop:enable Metrics/ParameterLists
|
|
134
|
+
private_class_method :map_element_rollback_payload
|
|
135
|
+
|
|
108
136
|
# rubocop:disable Metrics/ParameterLists
|
|
109
137
|
def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
|
|
110
138
|
strict_ordering:, timeout:)
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -29,6 +29,12 @@ module RubyReactor
|
|
|
29
29
|
:cancelled, :cancellation_reason, :parent_context_id, :retried_from_id, :status, :failure_reason,
|
|
30
30
|
:middlewares
|
|
31
31
|
|
|
32
|
+
# Where this execution level's rollback stands while it is `rolling_back`
|
|
33
|
+
# (009 DM §1): `trigger`, `step`, `compensated`, `failure`, `failures`.
|
|
34
|
+
# Kept JSON-safe (string keys; `failure` / `failures` already serialized),
|
|
35
|
+
# so it round-trips unchanged. Nil otherwise.
|
|
36
|
+
attr_accessor :rollback
|
|
37
|
+
|
|
32
38
|
# Transient, NOT serialized (absent from `to_h`, `serialize_for_retry`,
|
|
33
39
|
# and `deserialize_from_retry` below — confirmed by
|
|
34
40
|
# reentrancy_spec.rb US4-16). Overrides `StepCoordination#owner` for
|
|
@@ -76,6 +82,10 @@ module RubyReactor
|
|
|
76
82
|
@status.to_s == "failed"
|
|
77
83
|
end
|
|
78
84
|
|
|
85
|
+
def rolling_back?
|
|
86
|
+
@status.to_s == "rolling_back"
|
|
87
|
+
end
|
|
88
|
+
|
|
79
89
|
def get_input(name, path = nil)
|
|
80
90
|
value = Utils::FetchIndifferent.call(@inputs, name)
|
|
81
91
|
return nil if value.nil?
|
|
@@ -149,7 +159,8 @@ module RubyReactor
|
|
|
149
159
|
reactor_class: @reactor_class,
|
|
150
160
|
execution_trace: @execution_trace,
|
|
151
161
|
status: @status,
|
|
152
|
-
failure_reason: @failure_reason
|
|
162
|
+
failure_reason: @failure_reason,
|
|
163
|
+
rollback: @rollback
|
|
153
164
|
}
|
|
154
165
|
end
|
|
155
166
|
|
|
@@ -175,6 +186,7 @@ module RubyReactor
|
|
|
175
186
|
cancellation_reason: @cancellation_reason,
|
|
176
187
|
status: @status,
|
|
177
188
|
failure_reason: ContextSerializer.serialize_value(@failure_reason),
|
|
189
|
+
rollback: @rollback,
|
|
178
190
|
parent_context_id: @parent_context&.context_id || @parent_context_id,
|
|
179
191
|
retried_from_id: @retried_from_id
|
|
180
192
|
}
|
|
@@ -200,6 +212,7 @@ module RubyReactor
|
|
|
200
212
|
context.cancellation_reason = data["cancellation_reason"]
|
|
201
213
|
context.status = data["status"] || "pending"
|
|
202
214
|
context.failure_reason = ContextSerializer.deserialize_value(data["failure_reason"])
|
|
215
|
+
context.rollback = data["rollback"]
|
|
203
216
|
context.parent_context_id = data["parent_context_id"]
|
|
204
217
|
context.retried_from_id = data["retried_from_id"]
|
|
205
218
|
|
|
@@ -137,9 +137,38 @@ module RubyReactor
|
|
|
137
137
|
builder = RubyReactor::Dsl::StepBuilder.new(name, impl, self)
|
|
138
138
|
builder.instance_eval(&block) if block_given?
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
config = builder.build(async_dispatch: :step)
|
|
141
|
+
check_async_step_undo!(builder, config, caller_locations(1, 1).first)
|
|
142
|
+
steps[name] = config
|
|
141
143
|
end
|
|
142
144
|
|
|
145
|
+
# An async_step is never undone: its parent does not track it for undo
|
|
146
|
+
# (008 R-09/R-10), so an `undo` on it would be dead code. An inline one
|
|
147
|
+
# is rejected; a step class's is only warned about, because the same
|
|
148
|
+
# class is legitimately reused by ordinary steps, where it does run.
|
|
149
|
+
def check_async_step_undo!(builder, config, site)
|
|
150
|
+
if config.undo_block
|
|
151
|
+
raise RubyReactor::Error::ValidationError,
|
|
152
|
+
"`undo` on async_step :#{config.name} would never run: the parent never undoes an " \
|
|
153
|
+
"independent async unit. Put failure cleanup in the unit's `compensate` (it runs in the " \
|
|
154
|
+
"unit's job when its last attempt fails) or in the reading step's `compensate`; for cleanup " \
|
|
155
|
+
"that must run when the parent rolls back, use a `step`/`compose`/`map` (tracked for undo) " \
|
|
156
|
+
"or an `async_reactor` child whose steps declare `undo`."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
return unless defines_undo?(config.impl)
|
|
160
|
+
|
|
161
|
+
builder.send(:warn_definition, site, nil,
|
|
162
|
+
"async_step :#{config.name} uses #{config.impl}; its `undo` will not run for this async " \
|
|
163
|
+
"use (async units are never undone).")
|
|
164
|
+
end
|
|
165
|
+
private :check_async_step_undo!
|
|
166
|
+
|
|
167
|
+
def defines_undo?(impl)
|
|
168
|
+
impl.is_a?(Class) && impl < RubyReactor::Step && impl.instance_method(:undo).owner != RubyReactor::Step
|
|
169
|
+
end
|
|
170
|
+
private :defines_undo?
|
|
171
|
+
|
|
143
172
|
# Dispatch a whole nested reactor to run INDEPENDENTLY — linked
|
|
144
173
|
# to this one by execution id for traceability, but excluded from its
|
|
145
174
|
# compensation graph. Fire-and-forget unless a later step reads
|
|
@@ -10,7 +10,6 @@ module RubyReactor
|
|
|
10
10
|
# ordering nonce.
|
|
11
11
|
class AsyncReactorBuilder
|
|
12
12
|
include RubyReactor::Dsl::TemplateHelpers
|
|
13
|
-
include RubyReactor::Dsl::Retryable
|
|
14
13
|
|
|
15
14
|
attr_accessor :name, :child_reactor_class, :argument_mappings
|
|
16
15
|
|
|
@@ -19,13 +18,23 @@ module RubyReactor
|
|
|
19
18
|
@child_reactor_class = child_reactor_class
|
|
20
19
|
@reactor = reactor
|
|
21
20
|
@argument_mappings = {}
|
|
22
|
-
@retry_config = nil
|
|
23
21
|
end
|
|
24
22
|
|
|
25
23
|
def argument(child_input_name, source)
|
|
26
24
|
@argument_mappings[child_input_name] = source
|
|
27
25
|
end
|
|
28
26
|
|
|
27
|
+
# A parent never retries a nested reactor as a whole (008 R-14): the
|
|
28
|
+
# child owns its steps' retries. Kept as a stub to name the replacement.
|
|
29
|
+
def retries(*)
|
|
30
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
31
|
+
"`retries` on an `async_reactor` has been removed: a parent never retries a nested " \
|
|
32
|
+
"reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
|
|
33
|
+
"(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
|
|
34
|
+
step: @name
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
def build
|
|
30
39
|
RubyReactor::Dsl::StepConfig.new(
|
|
31
40
|
async_dispatch: :reactor,
|
|
@@ -41,12 +50,9 @@ module RubyReactor
|
|
|
41
50
|
# that reads `result(:name)` and decides to fail.
|
|
42
51
|
compensate_block: nil,
|
|
43
52
|
undo_block: nil,
|
|
44
|
-
conditions: [],
|
|
45
|
-
guards: [],
|
|
46
53
|
dependencies: dependencies_from_mappings,
|
|
47
54
|
args_validator: nil,
|
|
48
|
-
output_validator: nil
|
|
49
|
-
retry_config: @retry_config
|
|
55
|
+
output_validator: nil
|
|
50
56
|
)
|
|
51
57
|
end
|
|
52
58
|
|
|
@@ -4,7 +4,6 @@ module RubyReactor
|
|
|
4
4
|
module Dsl
|
|
5
5
|
class ComposeBuilder
|
|
6
6
|
include RubyReactor::Dsl::TemplateHelpers
|
|
7
|
-
include RubyReactor::Dsl::Retryable
|
|
8
7
|
|
|
9
8
|
attr_accessor :name, :composed_reactor_class, :argument_mappings
|
|
10
9
|
|
|
@@ -21,7 +20,6 @@ module RubyReactor
|
|
|
21
20
|
end
|
|
22
21
|
@reactor = reactor
|
|
23
22
|
@argument_mappings = {}
|
|
24
|
-
@retry_config = nil
|
|
25
23
|
end
|
|
26
24
|
|
|
27
25
|
def argument(composed_input_name, source)
|
|
@@ -45,6 +43,17 @@ module RubyReactor
|
|
|
45
43
|
)
|
|
46
44
|
end
|
|
47
45
|
|
|
46
|
+
# A parent never retries a nested reactor as a whole (008 R-14): the
|
|
47
|
+
# child owns its steps' retries. Kept as a stub to name the replacement.
|
|
48
|
+
def retries(*)
|
|
49
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
50
|
+
"`retries` on a `compose` has been removed: a parent never retries a nested " \
|
|
51
|
+
"reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
|
|
52
|
+
"(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
|
|
53
|
+
step: @name
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
48
57
|
def build
|
|
49
58
|
warn_if_child_has_ordered_lock!
|
|
50
59
|
dependencies = extract_dependencies_from_mappings
|
|
@@ -59,12 +68,9 @@ module RubyReactor
|
|
|
59
68
|
run_block: nil,
|
|
60
69
|
compensate_block: nil,
|
|
61
70
|
undo_block: nil,
|
|
62
|
-
conditions: [],
|
|
63
|
-
guards: [],
|
|
64
71
|
dependencies: dependencies,
|
|
65
72
|
args_validator: nil,
|
|
66
|
-
output_validator: nil
|
|
67
|
-
retry_config: @retry_config
|
|
73
|
+
output_validator: nil
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
RubyReactor::Dsl::StepConfig.new(step_config)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Dsl
|
|
5
|
+
# Definition-time warnings for the DSL builders, printed once per
|
|
6
|
+
# declaration site for the process. The including builder sets `@reactor`.
|
|
7
|
+
module DefinitionWarnings
|
|
8
|
+
def self.deprecation_sites
|
|
9
|
+
@deprecation_sites ||= Set.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def warn_deprecation(site, message)
|
|
15
|
+
warn_definition(site, "DEPRECATION:", "#{message} Removal no earlier than the next MAJOR.")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def warn_definition(site, prefix, message)
|
|
19
|
+
location = "#{site.path}:#{site.lineno}"
|
|
20
|
+
return unless DefinitionWarnings.deprecation_sites.add?(location)
|
|
21
|
+
|
|
22
|
+
warn ["[RubyReactor]", prefix, location, reactor_label, message].compact.join(" ")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reactor_label
|
|
26
|
+
@reactor&.name || @reactor.inspect
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -73,9 +73,7 @@ module RubyReactor
|
|
|
73
73
|
validation_schema: @validation_schema,
|
|
74
74
|
max_attempts: @max_attempts,
|
|
75
75
|
resume_mode: @resume_mode,
|
|
76
|
-
dependencies: @dependencies
|
|
77
|
-
conditions: @conditions,
|
|
78
|
-
guards: @guards
|
|
76
|
+
dependencies: @dependencies
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
RubyReactor::Dsl::InterruptStepConfig.new(step_config)
|
|
@@ -4,6 +4,7 @@ module RubyReactor
|
|
|
4
4
|
module Dsl
|
|
5
5
|
class MapBuilder
|
|
6
6
|
include RubyReactor::Dsl::TemplateHelpers
|
|
7
|
+
include RubyReactor::Dsl::DefinitionWarnings
|
|
7
8
|
|
|
8
9
|
attr_accessor :name, :mapped_reactor_class, :argument_mappings, :source_enumerable
|
|
9
10
|
|
|
@@ -25,7 +26,7 @@ module RubyReactor
|
|
|
25
26
|
@batch_size = nil
|
|
26
27
|
@source_enumerable = nil
|
|
27
28
|
@collect_block = nil
|
|
28
|
-
@
|
|
29
|
+
@atomic = true # Default: every element succeeds, or none is kept
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def argument(mapped_input_name, source)
|
|
@@ -41,10 +42,10 @@ module RubyReactor
|
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
# Run every element as its own background job. `batch_size` caps how many
|
|
44
|
-
# element jobs
|
|
45
|
-
#
|
|
46
|
-
# stops at the map and resumes in a worker
|
|
47
|
-
# element's outcome.
|
|
45
|
+
# element jobs one throw enqueues (back pressure), forward and rollback;
|
|
46
|
+
# without it, `Map::DEFAULT_BATCH_SIZE` (50) does. A fan-out map is a
|
|
47
|
+
# hand-off point: the reactor stops at the map and resumes in a worker
|
|
48
|
+
# once the collector has every element's outcome.
|
|
48
49
|
def fan_out(enabled = true, batch_size: nil)
|
|
49
50
|
@fan_out = enabled
|
|
50
51
|
self.batch_size(batch_size) unless batch_size.nil?
|
|
@@ -83,11 +84,30 @@ module RubyReactor
|
|
|
83
84
|
@collect_block = block
|
|
84
85
|
end
|
|
85
86
|
|
|
87
|
+
# On (the default), the map succeeds only if every element does: the
|
|
88
|
+
# first element failure fails it, no new element starts, and every
|
|
89
|
+
# completed element is rolled back. Off, it completes with every
|
|
90
|
+
# element's outcome; a failed element rolls itself back, the others are
|
|
91
|
+
# kept.
|
|
92
|
+
def atomic(enabled = true)
|
|
93
|
+
@atomic_declared = true
|
|
94
|
+
@atomic = enabled
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The old name for `atomic` (009 R-11), which read as "stop early".
|
|
86
98
|
def fail_fast(enabled = true)
|
|
87
|
-
@
|
|
99
|
+
@fail_fast_site = caller_locations(1, 1).first
|
|
100
|
+
@atomic = enabled
|
|
101
|
+
warn_deprecation(@fail_fast_site, "map :#{@name} declares fail_fast. Use atomic (same meaning).")
|
|
88
102
|
end
|
|
89
103
|
|
|
90
104
|
def build
|
|
105
|
+
if @atomic_declared && @fail_fast_site
|
|
106
|
+
raise RubyReactor::Error::ValidationError.new(
|
|
107
|
+
"map :#{@name} declares both fail_fast and atomic; declare only atomic", step: @name
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
91
111
|
dependencies = extract_dependencies_from_mappings
|
|
92
112
|
dependencies << @source_enumerable.step_name if @source_enumerable.is_a?(RubyReactor::Template::Result)
|
|
93
113
|
|
|
@@ -123,14 +143,12 @@ module RubyReactor
|
|
|
123
143
|
strict_ordering: { source: RubyReactor::Template::Value.new(@strict_ordering) },
|
|
124
144
|
batch_size: { source: RubyReactor::Template::Value.new(@batch_size) },
|
|
125
145
|
collect_block: { source: RubyReactor::Template::Value.new(@collect_block) },
|
|
126
|
-
|
|
146
|
+
atomic: { source: RubyReactor::Template::Value.new(@atomic) },
|
|
127
147
|
fan_out: { source: RubyReactor::Template::Value.new(@fan_out) }
|
|
128
148
|
},
|
|
129
149
|
run_block: nil,
|
|
130
150
|
compensate_block: nil,
|
|
131
151
|
undo_block: nil,
|
|
132
|
-
conditions: [],
|
|
133
|
-
guards: [],
|
|
134
152
|
dependencies: dependencies.uniq,
|
|
135
153
|
args_validator: nil,
|
|
136
154
|
output_validator: nil
|
|
@@ -7,13 +7,14 @@ module RubyReactor
|
|
|
7
7
|
include RubyReactor::Dsl::ValidationHelpers
|
|
8
8
|
include RubyReactor::Dsl::Lockable::ClassMethods
|
|
9
9
|
include RubyReactor::Dsl::Retryable
|
|
10
|
+
include RubyReactor::Dsl::DefinitionWarnings
|
|
10
11
|
|
|
11
12
|
COORDINATION_MACROS = {
|
|
12
13
|
lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
|
|
13
14
|
period_config: "with_period", ordered_lock_config: "with_ordered_lock"
|
|
14
15
|
}.freeze
|
|
15
16
|
|
|
16
|
-
attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
|
|
17
|
+
attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
|
|
17
18
|
:dependencies, :args_validator, :output_validator
|
|
18
19
|
|
|
19
20
|
def initialize(name, impl = nil, reactor = nil)
|
|
@@ -24,8 +25,6 @@ module RubyReactor
|
|
|
24
25
|
@run_block = nil
|
|
25
26
|
@compensate_block = nil
|
|
26
27
|
@undo_block = nil
|
|
27
|
-
@conditions = []
|
|
28
|
-
@guards = []
|
|
29
28
|
@dependencies = []
|
|
30
29
|
@arg_validations = []
|
|
31
30
|
@validate_args_input = nil
|
|
@@ -36,11 +35,6 @@ module RubyReactor
|
|
|
36
35
|
@rule_sites = []
|
|
37
36
|
end
|
|
38
37
|
|
|
39
|
-
# Deprecation notices print once per declaration site for the process.
|
|
40
|
-
def self.deprecation_sites
|
|
41
|
-
@deprecation_sites ||= Set.new
|
|
42
|
-
end
|
|
43
|
-
|
|
44
38
|
def argument(name, source, type = nil, transform: nil, **predicates)
|
|
45
39
|
@arguments[name] = {
|
|
46
40
|
source: source,
|
|
@@ -78,12 +72,15 @@ module RubyReactor
|
|
|
78
72
|
@undo_block = block
|
|
79
73
|
end
|
|
80
74
|
|
|
81
|
-
|
|
82
|
-
|
|
75
|
+
# `where`/`guard` were a second, pre-body skip mechanism with failure
|
|
76
|
+
# rules of their own (008 R-15). A step decides to skip itself from its
|
|
77
|
+
# body instead, so both stay only to name the replacement.
|
|
78
|
+
def where(*)
|
|
79
|
+
raise_removed_condition!(:where)
|
|
83
80
|
end
|
|
84
81
|
|
|
85
|
-
def guard(
|
|
86
|
-
|
|
82
|
+
def guard(*)
|
|
83
|
+
raise_removed_condition!(:guard)
|
|
87
84
|
end
|
|
88
85
|
|
|
89
86
|
def wait_for(*step_names)
|
|
@@ -148,8 +145,6 @@ module RubyReactor
|
|
|
148
145
|
run_block: @run_block,
|
|
149
146
|
compensate_block: @compensate_block,
|
|
150
147
|
undo_block: @undo_block,
|
|
151
|
-
conditions: @conditions,
|
|
152
|
-
guards: @guards,
|
|
153
148
|
dependencies: @dependencies,
|
|
154
149
|
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
155
150
|
output_validator: @output_validator,
|
|
@@ -167,6 +162,15 @@ module RubyReactor
|
|
|
167
162
|
|
|
168
163
|
private
|
|
169
164
|
|
|
165
|
+
def raise_removed_condition!(keyword)
|
|
166
|
+
raise RubyReactor::Error::DeprecatedDslError.new(
|
|
167
|
+
"`#{keyword}` inside a step block has been removed (`where`/`guard` are gone). To skip " \
|
|
168
|
+
":#{@name}, return `Skipped(value)` (or call `skip!(value)`) from its `run` body; the " \
|
|
169
|
+
"reactor continues with that value, exactly as for a Success.",
|
|
170
|
+
step: @name
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
170
174
|
# The same primitive declared BOTH inline and on the step class is
|
|
171
175
|
# ambiguous — two keys for one slot of the fixed acquisition order, and
|
|
172
176
|
# `StepConfig`'s readers would silently let the inline one win. Refuse it
|
|
@@ -236,27 +240,15 @@ module RubyReactor
|
|
|
236
240
|
"the step instead (`validate_inputs` in #{target}).")
|
|
237
241
|
end
|
|
238
242
|
|
|
239
|
-
def warn_deprecation(site, message)
|
|
240
|
-
location = "#{site.path}:#{site.lineno}"
|
|
241
|
-
return unless StepBuilder.deprecation_sites.add?(location)
|
|
242
|
-
|
|
243
|
-
warn "[RubyReactor] DEPRECATION: #{location} #{reactor_label} #{message} " \
|
|
244
|
-
"Removal no earlier than the next MAJOR."
|
|
245
|
-
end
|
|
246
|
-
|
|
247
243
|
def owned_contract
|
|
248
244
|
@inline_contract || (@impl.input_contract if @impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?)
|
|
249
245
|
end
|
|
250
|
-
|
|
251
|
-
def reactor_label
|
|
252
|
-
@reactor&.name || @reactor.inspect
|
|
253
|
-
end
|
|
254
246
|
end
|
|
255
247
|
|
|
256
248
|
class StepConfig
|
|
257
249
|
NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze
|
|
258
250
|
|
|
259
|
-
attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
|
|
251
|
+
attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
|
|
260
252
|
:dependencies, :args_validator, :output_validator, :async_dispatch,
|
|
261
253
|
:inline_contract
|
|
262
254
|
|
|
@@ -268,8 +260,6 @@ module RubyReactor
|
|
|
268
260
|
@run_block = config[:run_block]
|
|
269
261
|
@compensate_block = config[:compensate_block]
|
|
270
262
|
@undo_block = config[:undo_block]
|
|
271
|
-
@conditions = config[:conditions] || []
|
|
272
|
-
@guards = config[:guards] || []
|
|
273
263
|
@dependencies = config[:dependencies] || []
|
|
274
264
|
@args_validator = config[:args_validator]
|
|
275
265
|
@output_validator = config[:output_validator]
|
|
@@ -358,6 +348,51 @@ module RubyReactor
|
|
|
358
348
|
input_contract ? input_contract.enforce!(args) : args
|
|
359
349
|
end
|
|
360
350
|
|
|
351
|
+
# The step's `argument` wiring resolved against `context`: each source,
|
|
352
|
+
# then its `transform`. The one copy every process uses (StepExecutor,
|
|
353
|
+
# StepWorker). Any raise becomes an `ArgumentResolutionError`, a
|
|
354
|
+
# never-started failure attributed to this step (008 R-06) — except a
|
|
355
|
+
# park signal (a worker waiting on an async result), which is not a failure.
|
|
356
|
+
def resolve_arguments(context)
|
|
357
|
+
arguments.to_h do |arg_name, arg_config|
|
|
358
|
+
value = arg_config[:source].resolve(context)
|
|
359
|
+
value = arg_config[:transform].call(value) if arg_config[:transform]
|
|
360
|
+
[arg_name, value]
|
|
361
|
+
end
|
|
362
|
+
rescue Error::ExecutionParked
|
|
363
|
+
raise
|
|
364
|
+
rescue Error::Rescuable => e
|
|
365
|
+
raise never_started(Error::ArgumentResolutionError, "could not resolve its arguments", e)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Rollback dispatch, the same wherever the step rolls back
|
|
369
|
+
# (CompensationManager, StepWorker): the inline block, else the class
|
|
370
|
+
# step, else Skipped — nothing defined, rollback continues. Coordination,
|
|
371
|
+
# trace and middleware stay with the caller.
|
|
372
|
+
def call_compensate(error, arguments, context)
|
|
373
|
+
catch(StepSignals::TAG) do
|
|
374
|
+
if compensate_block
|
|
375
|
+
compensate_block.call(error, wrap_inputs(arguments), context)
|
|
376
|
+
elsif has_impl?
|
|
377
|
+
impl.compensate(error, arguments, context)
|
|
378
|
+
else
|
|
379
|
+
RubyReactor.Skipped()
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def call_undo(result_value, arguments, context)
|
|
385
|
+
catch(StepSignals::TAG) do
|
|
386
|
+
if undo_block
|
|
387
|
+
undo_block.call(result_value, wrap_inputs(arguments), context)
|
|
388
|
+
elsif has_impl?
|
|
389
|
+
impl.undo(result_value, arguments, context)
|
|
390
|
+
else
|
|
391
|
+
RubyReactor.Skipped()
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
361
396
|
# The step's work as a reactor runs it. Coordination is NOT taken here:
|
|
362
397
|
# the caller (StepExecutor / StepWorker) takes this config's effective
|
|
363
398
|
# declarations — inline and class alike — in one fixed order around it.
|
|
@@ -381,6 +416,27 @@ module RubyReactor
|
|
|
381
416
|
!@async_dispatch.nil?
|
|
382
417
|
end
|
|
383
418
|
|
|
419
|
+
# Whether a success of this step is recorded for undo. `async_step` /
|
|
420
|
+
# `async_reactor` dispatches are independent units of work with their
|
|
421
|
+
# own compensation flows: the parent rolling back must not "undo" a
|
|
422
|
+
# dispatch whose unit runs (and may still succeed) elsewhere (008 R-10).
|
|
423
|
+
def rollback_tracked?
|
|
424
|
+
!async_dispatch?
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Whether a run of this step cut short by an interruption is still
|
|
428
|
+
# tracked for undo: the construct's own definition decides (008 R-16).
|
|
429
|
+
def undoes_partial_run?
|
|
430
|
+
rollback_tracked? && has_impl? && impl.respond_to?(:undoes_partial_run?) && impl.undoes_partial_run?
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# What the undo record keeps of the step's resolved arguments: all of
|
|
434
|
+
# them, unless the step reads its rollback state elsewhere (a map reads
|
|
435
|
+
# its declaration and the map records, 009 R-14).
|
|
436
|
+
def rollback_arguments(resolved)
|
|
437
|
+
has_impl? && impl.respond_to?(:rollback_arguments) ? impl.rollback_arguments(resolved) : resolved
|
|
438
|
+
end
|
|
439
|
+
|
|
384
440
|
def has_impl?
|
|
385
441
|
!@impl.nil?
|
|
386
442
|
end
|
|
@@ -403,14 +459,17 @@ module RubyReactor
|
|
|
403
459
|
(retry_config[:max_attempts] || 0) > 1
|
|
404
460
|
end
|
|
405
461
|
|
|
406
|
-
def should_run?(context)
|
|
407
|
-
@conditions.all? { |condition| condition.call(context) } &&
|
|
408
|
-
@guards.all? { |guard| guard.call(context) }
|
|
409
|
-
end
|
|
410
|
-
|
|
411
462
|
def interrupt?
|
|
412
463
|
false
|
|
413
464
|
end
|
|
465
|
+
|
|
466
|
+
private
|
|
467
|
+
|
|
468
|
+
def never_started(error_class, what, cause)
|
|
469
|
+
error = error_class.new("Step '#{name}' #{what}: #{cause.message}", step: name, original_error: cause)
|
|
470
|
+
error.set_backtrace(cause.backtrace)
|
|
471
|
+
error
|
|
472
|
+
end
|
|
414
473
|
end
|
|
415
474
|
end
|
|
416
475
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# A step's `argument` source, `transform` or result path raised. The step's
|
|
6
|
+
# body never started, so it is never compensated; completed steps are
|
|
7
|
+
# undone (008 R-06). The same inputs fail the same way, so never retried.
|
|
8
|
+
class ArgumentResolutionError < Base
|
|
9
|
+
attr_reader :exception_class
|
|
10
|
+
|
|
11
|
+
def initialize(message, step:, original_error:, context: nil)
|
|
12
|
+
super(message, step: step, context: context, original_error: original_error)
|
|
13
|
+
@exception_class = original_error.class.name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def retryable? = false
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Error
|
|
5
|
+
# `rescue Error::Rescuable` catches every exception reactor code can raise —
|
|
6
|
+
# standard or not (`NotImplementedError`, `SystemStackError`, a custom
|
|
7
|
+
# `Exception` subclass) — so it fails its step and rolls back (008 R-16).
|
|
8
|
+
#
|
|
9
|
+
# It lets through only interruptions: exceptions raised INTO running code
|
|
10
|
+
# from outside it. A signal (incl. `Interrupt`, `Sidekiq::Shutdown`), an
|
|
11
|
+
# exit, running out of memory, and the one an enclosing `Timeout.timeout`
|
|
12
|
+
# raises — rescuing that would stop the caller's timeout from ever firing.
|
|
13
|
+
# Running rollback code during any of them is unsafe; the executor marks
|
|
14
|
+
# the run `aborted` for a manual undo instead.
|
|
15
|
+
#
|
|
16
|
+
# Nor does it match `RollbackHandedOff`, a control signal (009 R-04): it
|
|
17
|
+
# must pass the `rescue Error::Rescuable` sites between `MapStep#undo` and
|
|
18
|
+
# the executor (`CompensationManager#undo_step`, `StepExecutor`), which
|
|
19
|
+
# would otherwise record it as an undo failure.
|
|
20
|
+
module Rescuable
|
|
21
|
+
INTERRUPTIONS = [SignalException, SystemExit, NoMemoryError].freeze
|
|
22
|
+
|
|
23
|
+
def self.===(exception)
|
|
24
|
+
exception.is_a?(Exception) && !interruption?(exception) && !exception.is_a?(RollbackHandedOff)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.interruption?(exception)
|
|
28
|
+
INTERRUPTIONS.any? { |klass| exception.is_a?(klass) } ||
|
|
29
|
+
(defined?(::Timeout::ExitException) && exception.is_a?(::Timeout::ExitException))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|