ruby_reactor 0.8.5 → 0.8.7
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 +82 -0
- data/CLAUDE.md +1 -1
- data/README.md +27 -8
- 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/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
- data/lib/ruby_reactor/error/rescuable.rb +6 -1
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
- data/lib/ruby_reactor/executor/result_handler.rb +88 -52
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
- data/lib/ruby_reactor/executor/step_executor.rb +23 -1
- data/lib/ruby_reactor/executor.rb +186 -6
- data/lib/ruby_reactor/map/collector.rb +25 -93
- data/lib/ruby_reactor/map/dispatcher.rb +38 -7
- data/lib/ruby_reactor/map/element_executor.rb +14 -10
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -155
- 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 +113 -24
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +55 -37
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +13 -0
- data/lib/ruby_reactor/step/map_step.rb +117 -114
- data/lib/ruby_reactor/storage/adapter.rb +14 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +18 -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-CQbgHtd0.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 +4 -2
- data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
- 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/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
- data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
- data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
- data/specs/010-interrupt-in-composed-child/plan.md +149 -0
- data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
- data/specs/010-interrupt-in-composed-child/research.md +207 -0
- data/specs/010-interrupt-in-composed-child/spec.md +267 -0
- data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
- data/specs/future_improvements.md +9 -33
- metadata +29 -3
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
|
@@ -34,6 +34,9 @@ module RubyReactor
|
|
|
34
34
|
return RubyReactor.Success() unless composed_data && composed_data[:context]
|
|
35
35
|
|
|
36
36
|
child_context = composed_data[:context]
|
|
37
|
+
# Linked like a running child, so a construct in it finds the run that
|
|
38
|
+
# owns it: a fan-out map's rollback resumes that run (009 S-3).
|
|
39
|
+
link_contexts(child_context, context)
|
|
37
40
|
# The child's own class: it survives serialization, while a Class in a
|
|
38
41
|
# stored undo record's arguments comes back as its name (a compose
|
|
39
42
|
# inside a map element is rolled back from its stored row).
|
|
@@ -71,6 +74,8 @@ module RubyReactor
|
|
|
71
74
|
unless child_context
|
|
72
75
|
composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
|
|
73
76
|
child_context = RubyReactor::Context.new(composed_inputs, inputs.composed_reactor_class)
|
|
77
|
+
# Resumed only through this run, never on its own (010 R-07).
|
|
78
|
+
child_context.private_data[:composed] = true
|
|
74
79
|
end
|
|
75
80
|
|
|
76
81
|
link_contexts(child_context, context)
|
|
@@ -117,6 +122,14 @@ module RubyReactor
|
|
|
117
122
|
# the metadata the direct and async paths do propagate.
|
|
118
123
|
return result if result.is_a?(RubyReactor::Failure)
|
|
119
124
|
|
|
125
|
+
# The child paused at an interrupt: so does this run. Each compose
|
|
126
|
+
# level re-stamps its own id, so the top-level result names the root.
|
|
127
|
+
if result.is_a?(RubyReactor::InterruptResult)
|
|
128
|
+
return RubyReactor::InterruptResult.new(execution_id: context.context_id,
|
|
129
|
+
correlation_id: result.correlation_id,
|
|
130
|
+
intermediate_results: context.intermediate_results)
|
|
131
|
+
end
|
|
132
|
+
|
|
120
133
|
result.success? ? RubyReactor.Success(result.value) : RubyReactor.Failure(result.error)
|
|
121
134
|
end
|
|
122
135
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
class Step
|
|
5
5
|
class MapStep < RubyReactor::Step
|
|
6
|
+
include RubyReactor::Map::StepRollback
|
|
7
|
+
|
|
6
8
|
# Seconds a rollback waits for an element's liveness lock. The last
|
|
7
9
|
# element to settle triggers the collector before its own job releases
|
|
8
10
|
# that lock, so a short wait covers the gap.
|
|
@@ -15,14 +17,16 @@ module RubyReactor
|
|
|
15
17
|
input :strict_ordering, optional: true
|
|
16
18
|
input :batch_size, optional: true
|
|
17
19
|
input :collect_block, optional: true
|
|
18
|
-
input :
|
|
20
|
+
input :atomic, optional: true
|
|
19
21
|
input :fan_out, optional: true
|
|
20
22
|
|
|
21
23
|
def run
|
|
22
|
-
return RubyReactor::Failure("Map source cannot be nil") if inputs.source.nil?
|
|
23
|
-
|
|
24
24
|
# Initialize map state in context if not present
|
|
25
25
|
context.map_operations ||= {}
|
|
26
|
+
# Dispatched already: the owner run resumed (009 R-03) — adopt the
|
|
27
|
+
# settled outcome, never dispatch again (I-3).
|
|
28
|
+
return adopt_dispatched_map(context.current_step) if dispatched_map_id(context.current_step)
|
|
29
|
+
return RubyReactor::Failure("Map source cannot be nil") if inputs.source.nil?
|
|
26
30
|
|
|
27
31
|
if fan_out?
|
|
28
32
|
run_async(context.current_step)
|
|
@@ -34,27 +38,18 @@ module RubyReactor
|
|
|
34
38
|
# Compensating a failed map and undoing a completed one are the same work,
|
|
35
39
|
# as for compose: replay the undo stack of every element that COMPLETED
|
|
36
40
|
# (a failed element already rolled itself back; a halted or skipped one
|
|
37
|
-
# did nothing to undo),
|
|
41
|
+
# did nothing to undo), newest-started first. Elements are found through
|
|
38
42
|
# the index both modes write, so nothing per element lives in the parent
|
|
39
43
|
# (008 R-02). Runs only once every element has settled (R-04), from the
|
|
40
44
|
# execution that owns the map, so it is the elements' only writer.
|
|
41
45
|
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
46
|
+
# A map rolls back where it ran (009 R-01): a fan-out map one job per
|
|
47
|
+
# started element, `batch_size` per throw, handing the run off until
|
|
48
|
+
# they all report; an inline map in process, 100 elements per read.
|
|
45
49
|
def compensate
|
|
46
50
|
step_name = context.current_step
|
|
47
51
|
map_id = "#{context.context_id}:#{step_name}"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
completed_elements(map_id, failures).each do |index, element_context|
|
|
51
|
-
tag = { map_step: step_name.to_sym, element_index: index }
|
|
52
|
-
failures.concat(rollback_element(map_id, index, element_context).map { |entry| entry.merge(tag) })
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
return RubyReactor.Success() if failures.empty?
|
|
56
|
-
|
|
57
|
-
RubyReactor.Failure("map :#{step_name} rollback incomplete", rollback_failures: failures)
|
|
52
|
+
dispatched_map_id(step_name) ? distributed_rollback(map_id, step_name) : inline_rollback(map_id, step_name)
|
|
58
53
|
end
|
|
59
54
|
|
|
60
55
|
alias undo compensate
|
|
@@ -62,6 +57,10 @@ module RubyReactor
|
|
|
62
57
|
# An interrupted run is undone too: undo replays only each element's completed steps.
|
|
63
58
|
def self.undoes_partial_run? = true
|
|
64
59
|
|
|
60
|
+
# The undo record keeps no arguments: rollback reads the map's
|
|
61
|
+
# declaration and records, never the resolved source (009 R-14).
|
|
62
|
+
def self.rollback_arguments(_resolved) = {}
|
|
63
|
+
|
|
65
64
|
class << self
|
|
66
65
|
def build_mapped_inputs(mappings, context, element)
|
|
67
66
|
built = {}
|
|
@@ -116,73 +115,6 @@ module RubyReactor
|
|
|
116
115
|
context.reactor_class.steps[context.current_step].arguments[:mapped_reactor_class][:source].value
|
|
117
116
|
end
|
|
118
117
|
|
|
119
|
-
# `[[index, context], ...]` for every completed element, highest index
|
|
120
|
-
# first. An element whose row is gone (expired past `context_ttl`) is
|
|
121
|
-
# reported, never skipped silently.
|
|
122
|
-
def completed_elements(map_id, failures)
|
|
123
|
-
storage = RubyReactor.configuration.storage_adapter
|
|
124
|
-
storage_name = RubyReactor.reactor_storage_name(element_class)
|
|
125
|
-
# A parked or retried fan-out element registers its id again.
|
|
126
|
-
ids = storage.retrieve_map_element_context_ids(map_id, context.reactor_class.name).uniq
|
|
127
|
-
rows = ids.map { |id| storage.retrieve_context(id, storage_name) }
|
|
128
|
-
elements = rows.compact.map do |data|
|
|
129
|
-
element_context = RubyReactor::Context.deserialize_from_retry(data)
|
|
130
|
-
[Utils::FetchIndifferent.call(element_context.map_metadata || {}, :index).to_i, element_context]
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
report_unavailable(elements.map(&:first), rows.count(nil), failures)
|
|
134
|
-
# An `aborted` element (an inline run interrupted in it) kept the undo
|
|
135
|
-
# entries of the steps it completed.
|
|
136
|
-
elements.select { |_, element| %w[completed aborted].include?(element.status.to_s) }
|
|
137
|
-
.sort_by { |index, _| -index }
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# The parent's map reference (in its own blob, so it lives as long as
|
|
141
|
-
# the parent) counts the elements that started: set per element inline,
|
|
142
|
-
# and to the total when a fan-out map completes. With it, every started
|
|
143
|
-
# index without a row is named, even when the index list itself expired;
|
|
144
|
-
# without it (a failed fan-out map skipped some indices), one unnamed
|
|
145
|
-
# entry per indexed row that is gone.
|
|
146
|
-
def report_unavailable(found_indexes, missing_rows, failures)
|
|
147
|
-
ref = Utils::FetchIndifferent.call(context.composed_contexts, context.current_step)
|
|
148
|
-
started = ref && Utils::FetchIndifferent.call(ref, :started)
|
|
149
|
-
missing = started ? (0...started).to_a - found_indexes : [nil] * missing_rows
|
|
150
|
-
missing.each { |index| failures << rollback_entry(index, :context_unavailable, "context expired") }
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def rollback_entry(index, reason, message)
|
|
154
|
-
step_name = context.current_step.to_sym
|
|
155
|
-
{ step: step_name, kind: :undo, key: nil, reason: reason, map_step: step_name, element_index: index,
|
|
156
|
-
message: "map element #{index} #{message}" }
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# The element's own undo stack, replayed as `ComposeStep` replays its
|
|
160
|
-
# child's, under the element's liveness lock: a held lock after the map
|
|
161
|
-
# settled is a live duplicate delivery, which is left alone and reported.
|
|
162
|
-
def rollback_element(map_id, index, element_context)
|
|
163
|
-
lock = acquire_element_lock(map_id, index)
|
|
164
|
-
return [rollback_entry(index, :element_in_flight, "was still running at rollback time")] if lock == :held
|
|
165
|
-
|
|
166
|
-
executor = RubyReactor::Executor.new(element_class, {}, element_context)
|
|
167
|
-
executor.undo_all
|
|
168
|
-
executor.save_context
|
|
169
|
-
executor.compensation_manager.rollback_failures
|
|
170
|
-
ensure
|
|
171
|
-
lock.release if lock.respond_to?(:release)
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def acquire_element_lock(map_id, index)
|
|
175
|
-
return nil if RubyReactor::Map::ElementExecutor.inline_testing_mode?
|
|
176
|
-
|
|
177
|
-
config = RubyReactor.configuration
|
|
178
|
-
lock = RubyReactor::Lock.new("map_element:#{map_id}:#{index}",
|
|
179
|
-
owner: SecureRandom.uuid, ttl: config.context_lock_ttl, wait: ELEMENT_LOCK_WAIT)
|
|
180
|
-
lock.acquire
|
|
181
|
-
lock
|
|
182
|
-
rescue RubyReactor::Lock::AcquisitionError
|
|
183
|
-
:held
|
|
184
|
-
end
|
|
185
|
-
|
|
186
118
|
# Fans out anywhere except inside a map element: an element's result and
|
|
187
119
|
# the map's completion counter are tracked by its own ElementExecutor job,
|
|
188
120
|
# so a nested hand-off there would escape that tracking. A reactor worker
|
|
@@ -194,16 +126,20 @@ module RubyReactor
|
|
|
194
126
|
inputs.fan_out
|
|
195
127
|
end
|
|
196
128
|
|
|
129
|
+
def atomic?
|
|
130
|
+
inputs.atomic.nil? || inputs.atomic
|
|
131
|
+
end
|
|
132
|
+
|
|
197
133
|
def run_inline
|
|
198
134
|
results = execute_inline_map
|
|
199
135
|
return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
|
|
200
136
|
|
|
201
|
-
|
|
137
|
+
collect_results(results)
|
|
202
138
|
end
|
|
203
139
|
|
|
204
140
|
def execute_inline_map
|
|
205
141
|
results = []
|
|
206
|
-
|
|
142
|
+
atomic = atomic?
|
|
207
143
|
|
|
208
144
|
inputs.source.each_with_index do |element, index|
|
|
209
145
|
result = execute_single_element(element, index)
|
|
@@ -212,12 +148,10 @@ module RubyReactor
|
|
|
212
148
|
# rather than being collected as a (nil) value.
|
|
213
149
|
return result if result.is_a?(RubyReactor::Halt)
|
|
214
150
|
|
|
215
|
-
if
|
|
216
|
-
return result # Stop immediately on first failure
|
|
217
|
-
end
|
|
151
|
+
return result if atomic && result.failure? # Stop immediately on first failure
|
|
218
152
|
|
|
219
|
-
#
|
|
220
|
-
results << (
|
|
153
|
+
# A non-atomic map collects Result objects; an atomic one, values.
|
|
154
|
+
results << (atomic ? result.value : result)
|
|
221
155
|
end
|
|
222
156
|
|
|
223
157
|
results
|
|
@@ -260,20 +194,6 @@ module RubyReactor
|
|
|
260
194
|
child_context.inline_async_execution = parent_context.inline_async_execution
|
|
261
195
|
end
|
|
262
196
|
|
|
263
|
-
def process_results(results, collect_block, _fail_fast = true)
|
|
264
|
-
if collect_block
|
|
265
|
-
begin
|
|
266
|
-
# Collect block receives Result objects when fail_fast is false, values when true
|
|
267
|
-
return RubyReactor::Success(collect_block.call(results))
|
|
268
|
-
rescue RubyReactor::Error::Rescuable => e
|
|
269
|
-
return RubyReactor::Failure(e)
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
# Simplified: both branches returned Success(results)
|
|
274
|
-
RubyReactor::Success(results)
|
|
275
|
-
end
|
|
276
|
-
|
|
277
197
|
def run_async(step_name)
|
|
278
198
|
map_id = "#{context.context_id}:#{step_name}"
|
|
279
199
|
context.map_operations[step_name.to_s] = map_id
|
|
@@ -293,10 +213,12 @@ module RubyReactor
|
|
|
293
213
|
element_reactor_class: inputs.mapped_reactor_class.name
|
|
294
214
|
}
|
|
295
215
|
|
|
216
|
+
# The owner's id: the run the caller holds, and the one the map's
|
|
217
|
+
# completion resumes (a composed child's own id is internal).
|
|
296
218
|
RubyReactor::DispatchResult.new(
|
|
297
219
|
job_id: job_id,
|
|
298
220
|
intermediate_results: context.intermediate_results,
|
|
299
|
-
execution_id:
|
|
221
|
+
execution_id: owner_context.context_id
|
|
300
222
|
)
|
|
301
223
|
end
|
|
302
224
|
|
|
@@ -305,10 +227,83 @@ module RubyReactor
|
|
|
305
227
|
storage.initialize_map_operation(
|
|
306
228
|
map_id, inputs.source.size, context.reactor_class.name,
|
|
307
229
|
strict_ordering: inputs.strict_ordering, reactor_class_info: reactor_class_info,
|
|
230
|
+
owner_context_id: owner_context.context_id,
|
|
231
|
+
owner_reactor_class_name: RubyReactor.reactor_storage_name(owner_context.reactor_class),
|
|
232
|
+
batch_size: effective_batch_size, atomic: atomic?,
|
|
308
233
|
**map_recovery_metadata(context.current_step)
|
|
309
234
|
)
|
|
310
235
|
end
|
|
311
236
|
|
|
237
|
+
# The top-level run: the one execution that writes the context tree, and
|
|
238
|
+
# the one the map's completion resumes (009 R-03).
|
|
239
|
+
def owner_context
|
|
240
|
+
context.root_context || context
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Declared, or `Map::DEFAULT_BATCH_SIZE`: no throw enqueues more (R-10).
|
|
244
|
+
def effective_batch_size
|
|
245
|
+
inputs.batch_size || RubyReactor::Map::DEFAULT_BATCH_SIZE
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def dispatched_map_id(step_name)
|
|
249
|
+
Utils::FetchIndifferent.call(context.map_operations || {}, step_name)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Re-entry of a dispatched map (S-1). Unsettled (an early or duplicate
|
|
253
|
+
# resume): hand off again without dispatching. Settled: the failing
|
|
254
|
+
# element's Failure for an atomic map, else the collected results. The
|
|
255
|
+
# executor records either like any step result.
|
|
256
|
+
def adopt_dispatched_map(step_name)
|
|
257
|
+
map_id = dispatched_map_id(step_name)
|
|
258
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
259
|
+
metadata = storage.retrieve_map_metadata(map_id, context.reactor_class.name)
|
|
260
|
+
return RubyReactor::Failure("map :#{step_name} records expired before it settled") unless metadata
|
|
261
|
+
|
|
262
|
+
total = metadata["count"].to_i
|
|
263
|
+
if storage.count_map_results(map_id, context.reactor_class.name) < total
|
|
264
|
+
return RubyReactor::DispatchResult.new(job_id: "map:#{map_id}", execution_id: owner_context.context_id,
|
|
265
|
+
intermediate_results: context.intermediate_results)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
failed_id = storage.retrieve_map_failed_context_id(map_id, context.reactor_class.name)
|
|
269
|
+
return adopt_element_failure(step_name, failed_id) if failed_id
|
|
270
|
+
|
|
271
|
+
record_elements_started(step_name, total)
|
|
272
|
+
collect_results(RubyReactor::Map::ResultEnumerator.new(map_id, context.reactor_class.name,
|
|
273
|
+
strict_ordering: inputs.strict_ordering))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The element's own Failure, carrying its rollback failures: the executor
|
|
277
|
+
# compensates the map (its completed elements) and rolls the run back.
|
|
278
|
+
def adopt_element_failure(step_name, failed_id)
|
|
279
|
+
data = RubyReactor.configuration.storage_adapter.retrieve_context(
|
|
280
|
+
failed_id, RubyReactor.reactor_storage_name(element_class)
|
|
281
|
+
)
|
|
282
|
+
return RubyReactor::Failure("map :#{step_name} element failed; its context expired") unless data
|
|
283
|
+
|
|
284
|
+
reason = RubyReactor::Context.deserialize_from_retry(data).failure_reason
|
|
285
|
+
reason.is_a?(RubyReactor::Failure) ? reason : RubyReactor::Failure(reason)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The collect block gets Result objects for a non-atomic map, values for
|
|
289
|
+
# an atomic one; a raise is the map's failure.
|
|
290
|
+
def collect_results(results)
|
|
291
|
+
return RubyReactor::Success(results) unless inputs.collect_block
|
|
292
|
+
|
|
293
|
+
RubyReactor::Success(inputs.collect_block.call(results))
|
|
294
|
+
rescue RubyReactor::Error::Rescuable => e
|
|
295
|
+
RubyReactor.configuration.logger.error("Map collect block raised: #{e.message}")
|
|
296
|
+
RubyReactor::Failure(e)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Every index of a completed map ran: record the count on the map's
|
|
300
|
+
# reference, which outlives the element index, so a late rollback names
|
|
301
|
+
# each element whose context expired.
|
|
302
|
+
def record_elements_started(step_name, total)
|
|
303
|
+
ref = Utils::FetchIndifferent.call(context.composed_contexts, step_name)
|
|
304
|
+
ref[:started] = total if ref
|
|
305
|
+
end
|
|
306
|
+
|
|
312
307
|
# Recovery metadata for the map sweeper. When this map runs inside a map
|
|
313
308
|
# element (context.map_metadata present), it is a NESTED map: its parent
|
|
314
309
|
# holds the element's `map_element:` lock, not an `async:` lock (N1).
|
|
@@ -324,13 +319,13 @@ module RubyReactor
|
|
|
324
319
|
end
|
|
325
320
|
|
|
326
321
|
def dispatch_async_map(map_id, _reactor_class_info, step_name)
|
|
327
|
-
# Every async map runs through the per-element Dispatcher path
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
batch_size =
|
|
322
|
+
# Every async map runs through the per-element Dispatcher path: each
|
|
323
|
+
# element runs in its own worker, with the map counter/collector
|
|
324
|
+
# tracking completion. This lets elements with async steps or async
|
|
325
|
+
# retries hand off correctly instead of being forced to run
|
|
326
|
+
# synchronously in a single worker. Without a declared batch_size, at
|
|
327
|
+
# most `Map::DEFAULT_BATCH_SIZE` are enqueued per throw (009 R-10).
|
|
328
|
+
batch_size = effective_batch_size
|
|
334
329
|
|
|
335
330
|
RubyReactor::Map::Dispatcher.perform(
|
|
336
331
|
map_id: map_id,
|
|
@@ -342,18 +337,26 @@ module RubyReactor
|
|
|
342
337
|
argument_mappings: inputs.argument_mappings,
|
|
343
338
|
strict_ordering: inputs.strict_ordering,
|
|
344
339
|
mapped_reactor_class: inputs.mapped_reactor_class,
|
|
345
|
-
|
|
340
|
+
atomic: atomic?
|
|
346
341
|
)
|
|
347
342
|
queue_collector(map_id, step_name, inputs.strict_ordering)
|
|
348
343
|
"map:#{map_id}"
|
|
349
344
|
end
|
|
350
345
|
|
|
346
|
+
# Stores the context the Dispatcher reads the source from, and — for a
|
|
347
|
+
# map inside a composed child — the owner's tree, which the map's
|
|
348
|
+
# completion resumes (009 R-03): a fast resume must find it consistent,
|
|
349
|
+
# as `handle_background_handoff` ensures for a `background` hand-off.
|
|
351
350
|
def prepare_async_execution(map_id, count)
|
|
352
351
|
storage = RubyReactor.configuration.storage_adapter
|
|
353
352
|
middlewares = context.middlewares || Executor.middlewares_for(context.reactor_class)
|
|
354
353
|
middlewares.on(:before_async_enqueue, context)
|
|
355
354
|
serialized_context = ContextSerializer.serialize(context)
|
|
356
355
|
storage.store_context(context.context_id, serialized_context, context.reactor_class.name)
|
|
356
|
+
unless owner_context.equal?(context)
|
|
357
|
+
storage.store_context(owner_context.context_id, ContextSerializer.serialize(owner_context),
|
|
358
|
+
RubyReactor.reactor_storage_name(owner_context.reactor_class))
|
|
359
|
+
end
|
|
357
360
|
storage.set_map_counter(map_id, count, context.reactor_class.name)
|
|
358
361
|
end
|
|
359
362
|
|
|
@@ -107,6 +107,20 @@ module RubyReactor
|
|
|
107
107
|
def retrieve_map_element_context_ids(map_id, reactor_class_name)
|
|
108
108
|
raise NotImplementedError
|
|
109
109
|
end
|
|
110
|
+
|
|
111
|
+
def claim_map_owner_signal(map_id, reactor_class_name)
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Map rollback records (009 DM §5); see RedisMapRollback.
|
|
116
|
+
%i[count_map_element_context_ids retrieve_map_element_context_ids_from_tail start_map_rollback
|
|
117
|
+
retrieve_map_rollback_metadata claim_map_rollback_positions retrieve_map_rollback_offset
|
|
118
|
+
store_map_rollback_outcome count_map_rollback_outcomes map_rollback_outcome_stored?
|
|
119
|
+
stored_map_rollback_positions each_map_rollback_outcome map_rollback_indexes_seen
|
|
120
|
+
mark_map_rollback_handed_off map_rollback_handed_off? claim_map_rollback_signal map_rollback_summary
|
|
121
|
+
scan_map_rollbacks].each do |method|
|
|
122
|
+
define_method(method) { |*, **| raise NotImplementedError }
|
|
123
|
+
end
|
|
110
124
|
end
|
|
111
125
|
end
|
|
112
126
|
end
|
|
@@ -11,6 +11,7 @@ module RubyReactor
|
|
|
11
11
|
include RedisStepResults
|
|
12
12
|
include RedisPubSub
|
|
13
13
|
include RedisReactorScan
|
|
14
|
+
include RedisMapRollback
|
|
14
15
|
|
|
15
16
|
def initialize(redis_config)
|
|
16
17
|
super()
|
|
@@ -70,7 +71,8 @@ module RubyReactor
|
|
|
70
71
|
# rubocop:disable Metrics/ParameterLists
|
|
71
72
|
def initialize_map_operation(map_id, count, parent_reactor_class_name, reactor_class_info:, strict_ordering: true,
|
|
72
73
|
parent_context_id: nil, step_name: nil, parent_is_map_element: false,
|
|
73
|
-
outer_map_id: nil, outer_index: nil
|
|
74
|
+
outer_map_id: nil, outer_index: nil, owner_context_id: nil,
|
|
75
|
+
owner_reactor_class_name: nil, batch_size: nil, atomic: nil)
|
|
74
76
|
# Ensure counter is set
|
|
75
77
|
set_map_counter(map_id, count, parent_reactor_class_name)
|
|
76
78
|
|
|
@@ -79,7 +81,9 @@ module RubyReactor
|
|
|
79
81
|
# nested-map fields (parent_is_map_element + outer_map_id/outer_index)
|
|
80
82
|
# record which liveness lock the parent actually holds (N1): a nested
|
|
81
83
|
# map's parent is itself a map element running under a `map_element:` lock,
|
|
82
|
-
# not an `async:` lock.
|
|
84
|
+
# not an `async:` lock. The owner is the top-level run the collector
|
|
85
|
+
# resumes (009 R-03); `batch_size` and `atomic` let a re-dispatched
|
|
86
|
+
# element keep both (R-10).
|
|
83
87
|
key = "reactor:#{parent_reactor_class_name}:map:#{map_id}:metadata"
|
|
84
88
|
metadata = {
|
|
85
89
|
map_id: map_id,
|
|
@@ -92,12 +96,22 @@ module RubyReactor
|
|
|
92
96
|
parent_is_map_element: parent_is_map_element,
|
|
93
97
|
outer_map_id: outer_map_id,
|
|
94
98
|
outer_index: outer_index,
|
|
99
|
+
owner_context_id: owner_context_id,
|
|
100
|
+
owner_reactor_class_name: owner_reactor_class_name,
|
|
101
|
+
batch_size: batch_size,
|
|
102
|
+
atomic: atomic,
|
|
95
103
|
created_at: Time.now.to_i
|
|
96
104
|
}
|
|
97
105
|
@redis.set(key, metadata.to_json, ex: durability_ttl)
|
|
98
106
|
end
|
|
99
107
|
# rubocop:enable Metrics/ParameterLists
|
|
100
108
|
|
|
109
|
+
# Claimed once, by the collector that found the map settled: only that
|
|
110
|
+
# one enqueues the owner's Worker (009 DM §3).
|
|
111
|
+
def claim_map_owner_signal(map_id, reactor_class_name) # rubocop:disable Naming/PredicateMethod
|
|
112
|
+
!!@redis.set("reactor:#{reactor_class_name}:map:#{map_id}:owner_signalled", "1", nx: true, ex: durability_ttl)
|
|
113
|
+
end
|
|
114
|
+
|
|
101
115
|
# Enumerate active map operations for the map sweeper (Phase 5d). Returns
|
|
102
116
|
# the parsed metadata hash for each (includes map_id, count,
|
|
103
117
|
# parent_context_id, step_name, parent_reactor_class_name, and the nested-map
|
|
@@ -105,6 +119,8 @@ module RubyReactor
|
|
|
105
119
|
def scan_maps(count: 1000)
|
|
106
120
|
results = []
|
|
107
121
|
@redis.scan_each(match: "reactor:*:map:*:metadata", count: 100) do |key|
|
|
122
|
+
next if key.end_with?(":rollback:metadata") # a hash, listed by scan_map_rollbacks
|
|
123
|
+
|
|
108
124
|
json = @redis.get(key)
|
|
109
125
|
next unless json
|
|
110
126
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Storage
|
|
5
|
+
# The records of one fan-out map's distributed rollback (009 DM §5), under
|
|
6
|
+
# `reactor:<P>:map:<map_id>:rollback:…`. Element rollback jobs write only
|
|
7
|
+
# these and their own element context, never a reactor context (I-1).
|
|
8
|
+
#
|
|
9
|
+
# Positions count from the tail of the element-context index, anchored at
|
|
10
|
+
# the `total` the rollback started with, so an id a late duplicate appends
|
|
11
|
+
# afterwards never shifts them.
|
|
12
|
+
module RedisMapRollback
|
|
13
|
+
# Creates the metadata only if no rollback of this map exists (I-3).
|
|
14
|
+
START_ROLLBACK_SCRIPT = <<~LUA
|
|
15
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
|
|
16
|
+
redis.call('HSET', KEYS[1], unpack(ARGV, 2))
|
|
17
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
18
|
+
return 1
|
|
19
|
+
LUA
|
|
20
|
+
|
|
21
|
+
FAILED_OUTCOMES = %w[failed element_in_flight context_unavailable].freeze
|
|
22
|
+
|
|
23
|
+
def count_map_element_context_ids(map_id, reactor_class_name)
|
|
24
|
+
@redis.llen(map_element_contexts_key(map_id, reactor_class_name))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Ids at tail positions `position...position + count`, in tail order.
|
|
28
|
+
def retrieve_map_element_context_ids_from_tail(map_id, reactor_class_name, position, count, total:)
|
|
29
|
+
stop = total - 1 - position
|
|
30
|
+
return [] if stop.negative?
|
|
31
|
+
|
|
32
|
+
start = [total - position - count, 0].max
|
|
33
|
+
@redis.lrange(map_element_contexts_key(map_id, reactor_class_name), start, stop).reverse
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# `[created, metadata]`: a second start finds the first one's records.
|
|
37
|
+
def start_map_rollback(map_id, reactor_class_name, **meta)
|
|
38
|
+
fields = meta.merge(map_id: map_id, parent_reactor_class_name: reactor_class_name, started_at: Time.now.to_i)
|
|
39
|
+
argv = fields.flat_map { |k, v| [k.to_s, JSON.generate(v)] }
|
|
40
|
+
created = @redis.eval(START_ROLLBACK_SCRIPT, keys: [map_rollback_key(map_id, reactor_class_name, :metadata)],
|
|
41
|
+
argv: [durability_ttl, *argv])
|
|
42
|
+
[created == 1, retrieve_map_rollback_metadata(map_id, reactor_class_name)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def retrieve_map_rollback_metadata(map_id, reactor_class_name)
|
|
46
|
+
parse_rollback_metadata(@redis.hgetall(map_rollback_key(map_id, reactor_class_name, :metadata)))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Claims the next `count` positions (R-02), clipped to the total.
|
|
50
|
+
def claim_map_rollback_positions(map_id, reactor_class_name, count)
|
|
51
|
+
key = map_rollback_key(map_id, reactor_class_name, :offset)
|
|
52
|
+
stop = @redis.incrby(key, count)
|
|
53
|
+
@redis.expire(key, durability_ttl)
|
|
54
|
+
total = retrieve_map_rollback_metadata(map_id, reactor_class_name)&.fetch("total", 0).to_i
|
|
55
|
+
(stop - count)...[stop, total].min
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def retrieve_map_rollback_offset(map_id, reactor_class_name)
|
|
59
|
+
@redis.get(map_rollback_key(map_id, reactor_class_name, :offset)).to_i
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The first outcome for a position wins: a duplicate delivery, which
|
|
63
|
+
# finds the element already undone, must not replace a `failed` outcome
|
|
64
|
+
# with its own `undone`. Returns whether this call stored it.
|
|
65
|
+
def store_map_rollback_outcome(map_id, reactor_class_name, position, outcome) # rubocop:disable Naming/PredicateMethod
|
|
66
|
+
results = map_rollback_key(map_id, reactor_class_name, :results)
|
|
67
|
+
indexes = map_rollback_key(map_id, reactor_class_name, :indexes)
|
|
68
|
+
index = outcome["index"] || outcome[:index]
|
|
69
|
+
stored, = @redis.multi do |tx|
|
|
70
|
+
tx.hsetnx(results, position.to_s, JSON.generate(outcome))
|
|
71
|
+
tx.expire(results, durability_ttl)
|
|
72
|
+
tx.sadd(indexes, index.to_s) unless index.nil?
|
|
73
|
+
tx.expire(indexes, durability_ttl)
|
|
74
|
+
end
|
|
75
|
+
[true, 1].include?(stored)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def count_map_rollback_outcomes(map_id, reactor_class_name)
|
|
79
|
+
@redis.hlen(map_rollback_key(map_id, reactor_class_name, :results))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stored_map_rollback_positions(map_id, reactor_class_name)
|
|
83
|
+
@redis.hkeys(map_rollback_key(map_id, reactor_class_name, :results)).map(&:to_i)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def map_rollback_outcome_stored?(map_id, reactor_class_name, position)
|
|
87
|
+
@redis.hexists(map_rollback_key(map_id, reactor_class_name, :results), position.to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Yields `position, outcome` in bounded reads.
|
|
91
|
+
def each_map_rollback_outcome(map_id, reactor_class_name)
|
|
92
|
+
@redis.hscan_each(map_rollback_key(map_id, reactor_class_name, :results), count: 500) do |position, json|
|
|
93
|
+
yield position.to_i, JSON.parse(json)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# One boolean per index: whether a rollback job saw that element (R-09).
|
|
98
|
+
def map_rollback_indexes_seen(map_id, reactor_class_name, indexes)
|
|
99
|
+
key = map_rollback_key(map_id, reactor_class_name, :indexes)
|
|
100
|
+
@redis.pipelined { |pipe| indexes.each { |index| pipe.sismember(key, index.to_s) } }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mark_map_rollback_handed_off(map_id, reactor_class_name)
|
|
104
|
+
@redis.set(map_rollback_key(map_id, reactor_class_name, :handed_off), "1", ex: durability_ttl)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def map_rollback_handed_off?(map_id, reactor_class_name)
|
|
108
|
+
@redis.exists?(map_rollback_key(map_id, reactor_class_name, :handed_off))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def claim_map_rollback_signal(map_id, reactor_class_name) # rubocop:disable Naming/PredicateMethod
|
|
112
|
+
!!@redis.set(map_rollback_key(map_id, reactor_class_name, :signalled), "1", nx: true, ex: durability_ttl)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Progress for the dashboard (R-15), or nil when this map never rolled back.
|
|
116
|
+
def map_rollback_summary(map_id, reactor_class_name)
|
|
117
|
+
meta = retrieve_map_rollback_metadata(map_id, reactor_class_name)
|
|
118
|
+
return nil unless meta
|
|
119
|
+
|
|
120
|
+
failed = 0
|
|
121
|
+
each_map_rollback_outcome(map_id, reactor_class_name) do |_, outcome|
|
|
122
|
+
failed += 1 if FAILED_OUTCOMES.include?(outcome["outcome"])
|
|
123
|
+
end
|
|
124
|
+
total = meta["total"].to_i
|
|
125
|
+
settled = count_map_rollback_outcomes(map_id, reactor_class_name)
|
|
126
|
+
{ total: total, settled: settled, outstanding: total - settled, failed: failed }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Every started map rollback, for `Map::Sweeper` (S-5).
|
|
130
|
+
def scan_map_rollbacks(count: 1000)
|
|
131
|
+
results = []
|
|
132
|
+
@redis.scan_each(match: "reactor:*:map:*:rollback:metadata", count: 100) do |key|
|
|
133
|
+
meta = parse_rollback_metadata(@redis.hgetall(key))
|
|
134
|
+
results << meta if meta
|
|
135
|
+
return results if results.size >= count
|
|
136
|
+
end
|
|
137
|
+
results
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def parse_rollback_metadata(raw)
|
|
143
|
+
return nil if raw.nil? || raw.empty?
|
|
144
|
+
|
|
145
|
+
raw.transform_values { |value| JSON.parse(value) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def map_rollback_key(map_id, reactor_class_name, suffix)
|
|
149
|
+
"reactor:#{reactor_class_name}:map:#{map_id}:rollback:#{suffix}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -67,7 +67,7 @@ module RubyReactor
|
|
|
67
67
|
|
|
68
68
|
def determine_status(data)
|
|
69
69
|
status = data["status"].to_s == "skipped" ? "halted" : data["status"].to_s # "skipped" is the legacy halt name
|
|
70
|
-
return status if %w[failed paused completed running halted pending aborted].include?(status)
|
|
70
|
+
return status if %w[failed paused completed running rolling_back halted pending aborted].include?(status)
|
|
71
71
|
return "cancelled" if data["cancelled"]
|
|
72
72
|
# Heuristic
|
|
73
73
|
return "failed" if data["retry_count"]&.positive? && !data["current_step"].nil?
|
data/lib/ruby_reactor/sweeper.rb
CHANGED
|
@@ -48,7 +48,9 @@ module RubyReactor
|
|
|
48
48
|
reenqueued = 0
|
|
49
49
|
|
|
50
50
|
@storage.scan_reactors(count: limit, include_dispatched_children: true).each do |reactor|
|
|
51
|
-
|
|
51
|
+
# Non-terminal only. A `rolling_back` run whose owner resume was lost
|
|
52
|
+
# is re-enqueued too: unsettled, it simply hands off again (009 S-5).
|
|
53
|
+
next unless %w[running rolling_back].include?(reactor[:status])
|
|
52
54
|
next if @storage.lock_held?("async:#{reactor[:id]}") # worker alive -> leave alone
|
|
53
55
|
|
|
54
56
|
@async_router.perform_async(reactor[:id], reactor[:class])
|
data/lib/ruby_reactor/version.rb
CHANGED