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
|
@@ -6,7 +6,7 @@ module RubyReactor
|
|
|
6
6
|
extend Helpers
|
|
7
7
|
|
|
8
8
|
def self.perform(arguments)
|
|
9
|
-
arguments =
|
|
9
|
+
arguments = Helpers.normalize_arguments(arguments)
|
|
10
10
|
parent_reactor_class_name = arguments[:parent_reactor_class_name]
|
|
11
11
|
|
|
12
12
|
storage = RubyReactor.configuration.storage_adapter
|
|
@@ -64,13 +64,12 @@ module RubyReactor
|
|
|
64
64
|
map_id = arguments[:map_id]
|
|
65
65
|
reactor_class_name = arguments[:parent_reactor_class_name]
|
|
66
66
|
|
|
67
|
-
#
|
|
68
|
-
if arguments[:
|
|
69
|
-
|
|
70
|
-
return if failed_context_id
|
|
67
|
+
# An atomic map stops dispatching at its first failure.
|
|
68
|
+
if arguments[:atomic] && storage.retrieve_map_failed_context_id(map_id, reactor_class_name)
|
|
69
|
+
return settle_undispatched(arguments, storage)
|
|
71
70
|
end
|
|
72
71
|
|
|
73
|
-
batch_size = arguments[:batch_size] ||
|
|
72
|
+
batch_size = arguments[:batch_size] || RubyReactor::Map::DEFAULT_BATCH_SIZE
|
|
74
73
|
|
|
75
74
|
# Atomically reserve a batch
|
|
76
75
|
new_offset = storage.increment_map_offset(map_id, batch_size, reactor_class_name)
|
|
@@ -104,6 +103,33 @@ module RubyReactor
|
|
|
104
103
|
end
|
|
105
104
|
end
|
|
106
105
|
|
|
106
|
+
# An atomic map stopped dispatching: claim every index not dispatched
|
|
107
|
+
# yet (one offset bump, so a later dispatcher claims none), settle each
|
|
108
|
+
# with a `_skipped` slot, count them down, and trigger the collector if
|
|
109
|
+
# that settled the map. Without this those indices never settle, so the
|
|
110
|
+
# failure would never be applied (R-04) and the map sweeper would keep
|
|
111
|
+
# re-dispatching them.
|
|
112
|
+
def self.settle_undispatched(arguments, storage)
|
|
113
|
+
map_id = arguments[:map_id]
|
|
114
|
+
reactor_class_name = arguments[:parent_reactor_class_name]
|
|
115
|
+
total = storage.retrieve_map_metadata(map_id, reactor_class_name)&.fetch("count", 0).to_i
|
|
116
|
+
new_offset = storage.increment_map_offset(map_id, total, reactor_class_name)
|
|
117
|
+
claimed = (new_offset - total)...[new_offset, total].min
|
|
118
|
+
return if claimed.none?
|
|
119
|
+
|
|
120
|
+
claimed.each do |index|
|
|
121
|
+
storage.store_map_result(map_id, index, { "_skipped" => true }, reactor_class_name,
|
|
122
|
+
strict_ordering: arguments[:strict_ordering])
|
|
123
|
+
end
|
|
124
|
+
return unless storage.decrement_map_counter_by(map_id, claimed.size, reactor_class_name) <= 0
|
|
125
|
+
|
|
126
|
+
RubyReactor.configuration.async_router.perform_map_collection_async(
|
|
127
|
+
parent_context_id: arguments[:parent_context_id], map_id: map_id,
|
|
128
|
+
parent_reactor_class_name: reactor_class_name, step_name: arguments[:step_name].to_s,
|
|
129
|
+
strict_ordering: arguments[:strict_ordering], timeout: 3600
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
107
133
|
# Re-dispatch a SPECIFIC index whose result slot is missing (Phase 5c, used
|
|
108
134
|
# by the map sweeper). Index-driven rather than offset-driven: resolve the
|
|
109
135
|
# source from the stored parent context and pick source[index]. Idempotent
|
|
@@ -120,7 +146,7 @@ module RubyReactor
|
|
|
120
146
|
strict_ordering: map_meta["strict_ordering"],
|
|
121
147
|
parent_context_id: map_meta["parent_context_id"],
|
|
122
148
|
parent_reactor_class_name: parent_class_name,
|
|
123
|
-
|
|
149
|
+
atomic: map_meta["atomic"],
|
|
124
150
|
batch_size: map_meta["batch_size"]
|
|
125
151
|
}
|
|
126
152
|
|
|
@@ -185,9 +211,40 @@ module RubyReactor
|
|
|
185
211
|
parent_reactor_class_name: context.reactor_class.name,
|
|
186
212
|
step_name: options[:step_name].to_s,
|
|
187
213
|
batch_size: arguments[:batch_size], # Passed to worker so it knows to trigger next batch?
|
|
188
|
-
|
|
214
|
+
atomic: arguments[:atomic]
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# One throw of a map's distributed rollback (009 R-02, R-06): claim the
|
|
219
|
+
# next `batch_size` positions of the element-context index, counted from
|
|
220
|
+
# its tail, and enqueue one rollback job per position. The job at the
|
|
221
|
+
# last position of a throw claims the next one, as forward.
|
|
222
|
+
def self.dispatch_rollback_batch(map_id:, parent_reactor_class_name:)
|
|
223
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
224
|
+
meta = storage.retrieve_map_rollback_metadata(map_id, parent_reactor_class_name)
|
|
225
|
+
return 0 unless meta
|
|
226
|
+
|
|
227
|
+
positions = storage.claim_map_rollback_positions(map_id, parent_reactor_class_name, meta["batch_size"].to_i)
|
|
228
|
+
return 0 if positions.none?
|
|
229
|
+
|
|
230
|
+
ids = storage.retrieve_map_element_context_ids_from_tail(map_id, parent_reactor_class_name, positions.first,
|
|
231
|
+
positions.size, total: meta["total"].to_i)
|
|
232
|
+
positions.each_with_index do |position, i|
|
|
233
|
+
queue_rollback_job(meta, map_id, parent_reactor_class_name, position, ids[i])
|
|
234
|
+
end
|
|
235
|
+
positions.size
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# rubocop:disable Metrics/ParameterLists
|
|
239
|
+
def self.queue_rollback_job(meta, map_id, parent_reactor_class_name, position, element_context_id, attempt: 0)
|
|
240
|
+
RubyReactor.configuration.async_router.perform_map_element_rollback_async(
|
|
241
|
+
map_id: map_id, position: position, element_context_id: element_context_id,
|
|
242
|
+
reactor_class_info: meta["reactor_class_info"], parent_reactor_class_name: parent_reactor_class_name,
|
|
243
|
+
step_name: meta["step_name"], batch_size: meta["batch_size"], owner_context_id: meta["owner_context_id"],
|
|
244
|
+
owner_reactor_class_name: meta["owner_reactor_class_name"], attempt: attempt
|
|
189
245
|
)
|
|
190
246
|
end
|
|
247
|
+
# rubocop:enable Metrics/ParameterLists
|
|
191
248
|
|
|
192
249
|
def self.resolve_reactor_class_info(arguments, context)
|
|
193
250
|
mapped_reactor_class = arguments[:mapped_reactor_class]
|
|
@@ -6,7 +6,7 @@ module RubyReactor
|
|
|
6
6
|
extend Helpers
|
|
7
7
|
|
|
8
8
|
def self.perform(arguments)
|
|
9
|
-
arguments =
|
|
9
|
+
arguments = Helpers.normalize_arguments(arguments)
|
|
10
10
|
|
|
11
11
|
# Per-element liveness lock (Phase 5b): its presence is the map sweeper's
|
|
12
12
|
# "element alive" signal, and it serializes duplicate deliveries so a
|
|
@@ -55,10 +55,16 @@ module RubyReactor
|
|
|
55
55
|
context.inline_async_execution = true
|
|
56
56
|
|
|
57
57
|
storage = RubyReactor.configuration.storage_adapter
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
return if check_atomic?(arguments, storage)
|
|
59
|
+
|
|
60
|
+
# Indexed only once it runs: a skipped element saves no context, and
|
|
61
|
+
# the map's rollback reports an indexed element without one as expired.
|
|
62
|
+
# Only a fresh context: a parked or retried element re-enters with its
|
|
63
|
+
# own, already indexed (009 R-06).
|
|
64
|
+
unless arguments[:serialized_context]
|
|
65
|
+
storage.store_map_element_context_id(arguments[:map_id], context.context_id,
|
|
66
|
+
arguments[:parent_reactor_class_name])
|
|
67
|
+
end
|
|
62
68
|
|
|
63
69
|
executor = Executor.new(context.reactor_class, {}, context)
|
|
64
70
|
begin
|
|
@@ -93,7 +99,7 @@ module RubyReactor
|
|
|
93
99
|
strict_ordering: arguments[:strict_ordering], parent_context_id: arguments[:parent_context_id],
|
|
94
100
|
parent_reactor_class_name: arguments[:parent_reactor_class_name], step_name: arguments[:step_name],
|
|
95
101
|
batch_size: arguments[:batch_size], serialized_context: ContextSerializer.serialize(context),
|
|
96
|
-
|
|
102
|
+
atomic: arguments[:atomic]
|
|
97
103
|
)
|
|
98
104
|
end
|
|
99
105
|
|
|
@@ -142,8 +148,8 @@ module RubyReactor
|
|
|
142
148
|
end
|
|
143
149
|
# rubocop:enable Style/IdenticalConditionalBranches
|
|
144
150
|
|
|
145
|
-
def self.
|
|
146
|
-
return false unless arguments[:
|
|
151
|
+
def self.check_atomic?(arguments, storage)
|
|
152
|
+
return false unless arguments[:atomic]
|
|
147
153
|
|
|
148
154
|
map_id = arguments[:map_id]
|
|
149
155
|
parent_reactor_class_name = arguments[:parent_reactor_class_name]
|
|
@@ -151,7 +157,11 @@ module RubyReactor
|
|
|
151
157
|
failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name)
|
|
152
158
|
return false unless failed_context_id
|
|
153
159
|
|
|
154
|
-
# Skip execution
|
|
160
|
+
# Skip execution, but settle the index: the collector applies a
|
|
161
|
+
# atomic map's failure only once every index has a result slot (R-04), and
|
|
162
|
+
# the map sweeper would otherwise keep re-dispatching this one.
|
|
163
|
+
storage.store_map_result(map_id, arguments[:index], { "_skipped" => true }, parent_reactor_class_name,
|
|
164
|
+
strict_ordering: arguments[:strict_ordering])
|
|
155
165
|
finalize_execution(arguments, storage)
|
|
156
166
|
true
|
|
157
167
|
end
|
|
@@ -177,12 +187,12 @@ module RubyReactor
|
|
|
177
187
|
# Store the whole serialized Failure, not just its message: step_name,
|
|
178
188
|
# backtrace, file_path and code_snippet are the only record of why this
|
|
179
189
|
# element failed once its context row expires, and the dashboard has
|
|
180
|
-
# nothing else to show for a non-
|
|
190
|
+
# nothing else to show for a non-atomic map.
|
|
181
191
|
storage.store_map_result(map_id, index,
|
|
182
192
|
{ _error: ContextSerializer.serialize_value(result) }, parent_class,
|
|
183
193
|
strict_ordering: arguments[:strict_ordering])
|
|
184
194
|
|
|
185
|
-
if arguments[:
|
|
195
|
+
if arguments[:atomic]
|
|
186
196
|
storage.store_map_failed_context_id(map_id, context.context_id, parent_class)
|
|
187
197
|
# FAST FAIL: Trigger Collector immediately to cancel/fail the map execution
|
|
188
198
|
RubyReactor.configuration.async_router.perform_map_collection_async(
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
# Rolls back ONE element of a map (009 R-07): the element's own undo
|
|
6
|
+
# stack, replayed under its liveness lock and saved after every undone
|
|
7
|
+
# entry, so a redelivery resumes after the last one (only the entry that
|
|
8
|
+
# was cut off may run again). The inline path calls `.call` in process; a
|
|
9
|
+
# fan-out map's `MapElementRollbackWorker` calls `.perform`.
|
|
10
|
+
class ElementRollback
|
|
11
|
+
extend Helpers
|
|
12
|
+
|
|
13
|
+
# The element context row is gone: its index went with it.
|
|
14
|
+
def self.unavailable(step_name)
|
|
15
|
+
{ "index" => nil, "outcome" => "context_unavailable",
|
|
16
|
+
"failures" => ContextSerializer.serialize_value([entry(step_name, nil, :context_unavailable,
|
|
17
|
+
"context expired")]) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# `{ "index", "outcome", "failures" }` (DM §5), with `failures`
|
|
21
|
+
# serialized. Outcome `contended`, never stored, means the element's lock
|
|
22
|
+
# was still held after `wait`.
|
|
23
|
+
def self.call(map_id:, element_context_id:, element_class:, step_name:, wait: Step::MapStep::ELEMENT_LOCK_WAIT)
|
|
24
|
+
storage_name = RubyReactor.reactor_storage_name(element_class)
|
|
25
|
+
data = element_context_id && RubyReactor.configuration.storage_adapter.retrieve_context(element_context_id,
|
|
26
|
+
storage_name)
|
|
27
|
+
return unavailable(step_name) unless data
|
|
28
|
+
|
|
29
|
+
element = Context.deserialize_from_retry(data)
|
|
30
|
+
index = Utils::FetchIndifferent.call(element.map_metadata || {}, :index).to_i
|
|
31
|
+
lock = acquire_element_lock(map_id, index, wait)
|
|
32
|
+
return outcome(index, "contended") if lock == :held
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
roll_back(element, element_class, index, step_name)
|
|
36
|
+
ensure
|
|
37
|
+
lock.release if lock.respond_to?(:release)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A completed element, or an aborted one (an inline run interrupted in
|
|
42
|
+
# it, which kept the undo entries of the steps it completed), undoes;
|
|
43
|
+
# a failed one already rolled itself back, and a halted or superseded
|
|
44
|
+
# running one did nothing to undo.
|
|
45
|
+
def self.roll_back(element, element_class, index, step_name)
|
|
46
|
+
return outcome(index, "not_needed") unless %w[completed aborted].include?(element.status.to_s)
|
|
47
|
+
|
|
48
|
+
executor = Executor.new(element.reactor_class || element_class, {}, element)
|
|
49
|
+
executor.undo_all { executor.checkpoint! }
|
|
50
|
+
executor.checkpoint!
|
|
51
|
+
tag = { map_step: step_name.to_sym, element_index: index }
|
|
52
|
+
failures = executor.compensation_manager.rollback_failures.map { |failure| failure.merge(tag) }
|
|
53
|
+
outcome(index, failures.empty? ? "undone" : "failed", failures)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The job's body. A contended lock requeues the job, storing nothing,
|
|
57
|
+
# up to `lock_snooze_max_attempts` times before reporting the element
|
|
58
|
+
# in flight: two contexts of one index in the same batch then serialize
|
|
59
|
+
# instead of one reporting a false failure (R-07).
|
|
60
|
+
def self.perform(arguments)
|
|
61
|
+
args = Helpers.normalize_arguments(arguments)
|
|
62
|
+
map_id = args[:map_id]
|
|
63
|
+
map_class = args[:parent_reactor_class_name]
|
|
64
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
65
|
+
return if storage.map_rollback_outcome_stored?(map_id, map_class, args[:position])
|
|
66
|
+
|
|
67
|
+
attempt = args[:attempt].to_i
|
|
68
|
+
result = call(map_id: map_id, element_context_id: args[:element_context_id],
|
|
69
|
+
element_class: resolve_reactor_class(args[:reactor_class_info]), step_name: args[:step_name],
|
|
70
|
+
wait: attempt.zero? ? Step::MapStep::ELEMENT_LOCK_WAIT : 0)
|
|
71
|
+
if result["outcome"] == "contended"
|
|
72
|
+
return requeue(args, attempt) unless snooze_exhausted?(attempt)
|
|
73
|
+
|
|
74
|
+
result = in_flight(result["index"], args[:step_name])
|
|
75
|
+
end
|
|
76
|
+
return unless storage.store_map_rollback_outcome(map_id, map_class, args[:position], result)
|
|
77
|
+
|
|
78
|
+
log_outcome(args, result)
|
|
79
|
+
Dispatcher.dispatch_rollback_batch(map_id: map_id, parent_reactor_class_name: map_class) if batch_end?(args)
|
|
80
|
+
signal_owner_if_settled(args)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.in_flight(index, step_name)
|
|
84
|
+
outcome(index, "element_in_flight",
|
|
85
|
+
[entry(step_name, index, :element_in_flight, "was still running at rollback time")])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.snooze_exhausted?(attempt)
|
|
89
|
+
max = RubyReactor.configuration.lock_snooze_max_attempts
|
|
90
|
+
max != :infinity && attempt >= max
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.requeue(args, attempt)
|
|
94
|
+
config = RubyReactor.configuration
|
|
95
|
+
config.async_router.perform_map_element_rollback_in(
|
|
96
|
+
Worker.snooze_delay(config, nil), **args.slice(*ROLLBACK_JOB_ARGS), attempt: attempt + 1
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The position that ends a throw claims the next one (R-02).
|
|
101
|
+
def self.batch_end?(args)
|
|
102
|
+
batch_size = args[:batch_size].to_i
|
|
103
|
+
batch_size.positive? && ((args[:position].to_i + 1) % batch_size).zero?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The handshake's job side (R-05): only once the hand-off is saved, so
|
|
107
|
+
# the owner never resumes from a stale blob — and never in inline job
|
|
108
|
+
# mode, where the map settles before any hand-off exists.
|
|
109
|
+
def self.signal_owner_if_settled(args)
|
|
110
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
111
|
+
map_id = args[:map_id]
|
|
112
|
+
map_class = args[:parent_reactor_class_name]
|
|
113
|
+
meta = storage.retrieve_map_rollback_metadata(map_id, map_class)
|
|
114
|
+
return unless meta && storage.count_map_rollback_outcomes(map_id, map_class) >= meta["total"].to_i
|
|
115
|
+
return unless storage.map_rollback_handed_off?(map_id, map_class)
|
|
116
|
+
return unless storage.claim_map_rollback_signal(map_id, map_class)
|
|
117
|
+
|
|
118
|
+
RubyReactor.configuration.async_router.perform_async(args[:owner_context_id], args[:owner_reactor_class_name])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.log_outcome(args, result)
|
|
122
|
+
Map.log("ruby_reactor.map.rollback.element",
|
|
123
|
+
reactor: args[:owner_reactor_class_name], context_id: args[:owner_context_id],
|
|
124
|
+
map_step: args[:step_name], index: result["index"], outcome: result["outcome"],
|
|
125
|
+
failures: Array(result["failures"]).size)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.outcome(index, outcome, failures = [])
|
|
129
|
+
{ "index" => index, "outcome" => outcome, "failures" => ContextSerializer.serialize_value(failures) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A rollback failure entry for the element as a whole (008 shape).
|
|
133
|
+
def self.entry(step_name, index, reason, message)
|
|
134
|
+
step = step_name.to_sym
|
|
135
|
+
{ step: step, kind: :undo, key: nil, reason: reason, map_step: step, element_index: index,
|
|
136
|
+
message: "map element #{index} #{message}" }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.acquire_element_lock(map_id, index, wait)
|
|
140
|
+
return nil if ElementExecutor.inline_testing_mode?
|
|
141
|
+
|
|
142
|
+
lock = RubyReactor::Lock.new("map_element:#{map_id}:#{index}", owner: SecureRandom.uuid,
|
|
143
|
+
ttl: RubyReactor.configuration.context_lock_ttl,
|
|
144
|
+
wait: wait)
|
|
145
|
+
lock.acquire
|
|
146
|
+
lock
|
|
147
|
+
rescue RubyReactor::Lock::AcquisitionError
|
|
148
|
+
:held
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
ROLLBACK_JOB_ARGS = %i[map_id position element_context_id reactor_class_info parent_reactor_class_name step_name
|
|
152
|
+
batch_size owner_context_id owner_reactor_class_name].freeze
|
|
153
|
+
|
|
154
|
+
private_class_method :roll_back, :in_flight, :snooze_exhausted?, :requeue, :batch_end?,
|
|
155
|
+
:signal_owner_if_settled, :log_outcome, :acquire_element_lock
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -4,6 +4,16 @@ module RubyReactor
|
|
|
4
4
|
module Map
|
|
5
5
|
# Shared helper methods for Map executors
|
|
6
6
|
module Helpers
|
|
7
|
+
# Job payloads enqueued before 009 carry `fail_fast` where they now carry
|
|
8
|
+
# `atomic` (R-11, FR-023): the one place the old key is read. Removed at
|
|
9
|
+
# the next MAJOR.
|
|
10
|
+
def self.normalize_arguments(arguments)
|
|
11
|
+
normalized = arguments.to_h.transform_keys(&:to_sym)
|
|
12
|
+
normalized[:atomic] = normalized[:fail_fast] if !normalized.key?(:atomic) && normalized.key?(:fail_fast)
|
|
13
|
+
normalized.delete(:fail_fast)
|
|
14
|
+
normalized
|
|
15
|
+
end
|
|
16
|
+
|
|
7
17
|
# Resolves the reactor class from reactor_class_info
|
|
8
18
|
def resolve_reactor_class(info)
|
|
9
19
|
if info["type"] == "class"
|
|
@@ -20,6 +30,7 @@ module RubyReactor
|
|
|
20
30
|
raise "Unknown reactor class info: #{info}"
|
|
21
31
|
end
|
|
22
32
|
end
|
|
33
|
+
module_function :resolve_reactor_class
|
|
23
34
|
|
|
24
35
|
# Loads parent context from storage
|
|
25
36
|
def load_parent_context_from_storage(parent_context_id, reactor_class_name, storage)
|
|
@@ -31,131 +42,6 @@ module RubyReactor
|
|
|
31
42
|
def build_element_inputs(mappings, parent_context, element)
|
|
32
43
|
RubyReactor::Step::MapStep.build_mapped_inputs(mappings, parent_context, element)
|
|
33
44
|
end
|
|
34
|
-
|
|
35
|
-
# Applies collect block to results
|
|
36
|
-
def apply_collect_block(results, step_config)
|
|
37
|
-
collect_block = step_config.arguments[:collect_block][:source].value
|
|
38
|
-
|
|
39
|
-
if collect_block
|
|
40
|
-
# Pass all results (Success and Failure) to collect block
|
|
41
|
-
begin
|
|
42
|
-
collected = collect_block.call(results)
|
|
43
|
-
RubyReactor::Success(collected)
|
|
44
|
-
rescue StandardError => e
|
|
45
|
-
RubyReactor::Failure(e)
|
|
46
|
-
end
|
|
47
|
-
else
|
|
48
|
-
# Default behavior: fail if any failure
|
|
49
|
-
first_failure = results.find(&:failure?)
|
|
50
|
-
first_failure || RubyReactor::Success(results.map(&:value))
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Resumes parent reactor execution after map completion
|
|
55
|
-
def resume_parent_execution(parent_context, step_name, final_result, storage) # rubocop:disable Metrics/MethodLength
|
|
56
|
-
executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
|
|
57
|
-
step_name_sym = step_name.to_sym
|
|
58
|
-
|
|
59
|
-
if final_result.failure?
|
|
60
|
-
parent_context.current_step = step_name_sym
|
|
61
|
-
|
|
62
|
-
error = RubyReactor::Error::StepFailureError.new(
|
|
63
|
-
final_result.error,
|
|
64
|
-
step: step_name_sym,
|
|
65
|
-
context: parent_context,
|
|
66
|
-
original_error: final_result.error.is_a?(Exception) ? final_result.error : nil,
|
|
67
|
-
exception_class: final_result.respond_to?(:exception_class) ? final_result.exception_class : nil
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
# Pass backtrace if available
|
|
71
|
-
if final_result.respond_to?(:backtrace) && final_result.backtrace
|
|
72
|
-
error.set_backtrace(final_result.backtrace)
|
|
73
|
-
elsif final_result.error.respond_to?(:backtrace)
|
|
74
|
-
error.set_backtrace(final_result.error.backtrace)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Bracket the rollback with reactor lifecycle events so that
|
|
78
|
-
# compensation/undo spans nest under a reactor span (and stay attached
|
|
79
|
-
# to the originating trace), mirroring the success path's
|
|
80
|
-
# resume_execution. Without this, rollback runs in the collector worker
|
|
81
|
-
# with no active reactor span and the undo/compensation spans orphan.
|
|
82
|
-
executor.middlewares.on(:start_reactor, parent_context.reactor_class.name, parent_context.inputs,
|
|
83
|
-
parent_context)
|
|
84
|
-
failure_response = nil
|
|
85
|
-
begin
|
|
86
|
-
failure_response = executor.result_handler.handle_execution_error(error)
|
|
87
|
-
# Manually update context status since we're not running executor loop
|
|
88
|
-
executor.send(:update_context_status, failure_response)
|
|
89
|
-
ensure
|
|
90
|
-
executor.middlewares.on(:failed_reactor, parent_context.reactor_class.name, failure_response,
|
|
91
|
-
parent_context)
|
|
92
|
-
end
|
|
93
|
-
# This branch runs no executor loop, so nothing else persists the
|
|
94
|
-
# failed status: store it here.
|
|
95
|
-
store_parent(parent_context, storage)
|
|
96
|
-
else
|
|
97
|
-
parent_context.set_result(step_name_sym, final_result.value)
|
|
98
|
-
|
|
99
|
-
# Manually update execution trace to reflect completion
|
|
100
|
-
# This is necessary because resume_execution continues from the NEXT step
|
|
101
|
-
# and the async step (which returned DispatchResult) needs to be marked as done with actual value
|
|
102
|
-
parent_context.append_execution_trace(
|
|
103
|
-
{
|
|
104
|
-
type: :result,
|
|
105
|
-
step: step_name_sym,
|
|
106
|
-
timestamp: Time.now,
|
|
107
|
-
value: final_result.value,
|
|
108
|
-
status: :success
|
|
109
|
-
}
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
parent_context.current_step = nil
|
|
113
|
-
|
|
114
|
-
# Mark the context as a worker before resuming: resume_execution runs
|
|
115
|
-
# the collector's own worker (this call), not the reactor's normal
|
|
116
|
-
# caller. Without this, StepExecutor#handoff_at? (gated on
|
|
117
|
-
# `inline_async_execution`) re-evaluates any later `background`
|
|
118
|
-
# before:/after: cut point and enqueues a second hand-off, or runs an
|
|
119
|
-
# `after:` target here in the collector instead of the original
|
|
120
|
-
# dispatching worker.
|
|
121
|
-
parent_context.inline_async_execution = true
|
|
122
|
-
resume_parked_aware(executor, parent_context)
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# `resume_execution` persists the parent itself — under the parent's
|
|
127
|
-
# context lock, and deliberately NOT when it lost that lock to a live
|
|
128
|
-
# duplicate or replayed an already-terminal run. Storing again here, after
|
|
129
|
-
# the lock is released, would overwrite whatever the lock's holder wrote
|
|
130
|
-
# (single writer), so the resume's own save is the only one.
|
|
131
|
-
#
|
|
132
|
-
# This collector is a worker running the parent's execution, so it is
|
|
133
|
-
# also a final handler for park signals (005 R-01), like `Worker` and
|
|
134
|
-
# `ElementExecutor`: the resume has already parked the parent's holds and
|
|
135
|
-
# saved; hand the rest back to the parent's own worker.
|
|
136
|
-
#
|
|
137
|
-
# The parent is loaded from its own blob, so it has no `root_context`:
|
|
138
|
-
# a map inside a composed child resumes and requeues that child as its
|
|
139
|
-
# own execution, never its root. ponytail: the root is never resumed
|
|
140
|
-
# after such a map (already so on main); see "Fan-out map inside a
|
|
141
|
-
# composed child" in specs/future_improvements.md.
|
|
142
|
-
def resume_parked_aware(executor, parent_context)
|
|
143
|
-
executor.resume_execution
|
|
144
|
-
rescue RubyReactor::Error::ExecutionParked => e
|
|
145
|
-
config = RubyReactor.configuration
|
|
146
|
-
config.async_router.perform_in(
|
|
147
|
-
RubyReactor::Worker.snooze_delay(config, e), parent_context.context_id,
|
|
148
|
-
RubyReactor.reactor_storage_name(parent_context.reactor_class)
|
|
149
|
-
)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def store_parent(parent_context, storage)
|
|
153
|
-
storage.store_context(
|
|
154
|
-
parent_context.context_id,
|
|
155
|
-
ContextSerializer.serialize(parent_context),
|
|
156
|
-
RubyReactor.reactor_storage_name(parent_context.reactor_class)
|
|
157
|
-
)
|
|
158
|
-
end
|
|
159
45
|
end
|
|
160
46
|
end
|
|
161
47
|
end
|
|
@@ -22,7 +22,8 @@ module RubyReactor
|
|
|
22
22
|
|
|
23
23
|
if @strict_ordering
|
|
24
24
|
count.times do |i|
|
|
25
|
-
|
|
25
|
+
raw = raw_at(i)
|
|
26
|
+
yield raw && wrap_result(raw) unless skipped?(raw)
|
|
26
27
|
end
|
|
27
28
|
else
|
|
28
29
|
offset = 0
|
|
@@ -37,7 +38,7 @@ module RubyReactor
|
|
|
37
38
|
|
|
38
39
|
break if results.empty?
|
|
39
40
|
|
|
40
|
-
results.each { |result| yield wrap_result(result) }
|
|
41
|
+
results.each { |result| yield wrap_result(result) unless skipped?(result) }
|
|
41
42
|
|
|
42
43
|
offset += results.size
|
|
43
44
|
break if results.size < @batch_size
|
|
@@ -59,21 +60,15 @@ module RubyReactor
|
|
|
59
60
|
!empty?
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
# nil for an index a fail-fast map skipped (never started).
|
|
62
64
|
def [](index)
|
|
63
65
|
index += count if index.negative?
|
|
64
66
|
return nil if index.negative? || index >= count
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@reactor_class_name,
|
|
69
|
-
offset: index,
|
|
70
|
-
limit: 1,
|
|
71
|
-
strict_ordering: @strict_ordering
|
|
72
|
-
)
|
|
68
|
+
raw = raw_at(index)
|
|
69
|
+
return nil if raw.nil? || skipped?(raw)
|
|
73
70
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
wrap_result(results.first)
|
|
71
|
+
wrap_result(raw)
|
|
77
72
|
end
|
|
78
73
|
|
|
79
74
|
def first
|
|
@@ -94,6 +89,17 @@ module RubyReactor
|
|
|
94
89
|
|
|
95
90
|
private
|
|
96
91
|
|
|
92
|
+
def raw_at(index)
|
|
93
|
+
@storage.retrieve_map_results_batch(@map_id, @reactor_class_name, offset: index, limit: 1,
|
|
94
|
+
strict_ordering: @strict_ordering).first
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A `_skipped` slot settles an index a fail-fast map never ran (R-04).
|
|
98
|
+
# Such a map is never collected as a success, so no consumer sees one.
|
|
99
|
+
def skipped?(result)
|
|
100
|
+
result.is_a?(Hash) && result.key?("_skipped")
|
|
101
|
+
end
|
|
102
|
+
|
|
97
103
|
def wrap_result(result)
|
|
98
104
|
if result.is_a?(Hash) && result.key?("_error")
|
|
99
105
|
# `backtrace: []` is load-bearing: without it Failure falls back to
|