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
|
@@ -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,161 +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 RubyReactor::Error::Rescuable => 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
|
-
# The map failed in its element jobs, so no executor compensated it.
|
|
87
|
-
# Fail it exactly as the inline path does: adopt the failing
|
|
88
|
-
# element's rollback failures, compensate the map (its completed
|
|
89
|
-
# elements; every index has settled, R-04), undo the completed
|
|
90
|
-
# steps, and fail with `CompensationError` if the map's rollback
|
|
91
|
-
# was incomplete.
|
|
92
|
-
manager = executor.compensation_manager
|
|
93
|
-
manager.rollback_failures.concat(final_result.rollback_failures)
|
|
94
|
-
failure_response = begin
|
|
95
|
-
manager.handle_step_failure(parent_context.reactor_class.steps[step_name_sym], final_result.error, {})
|
|
96
|
-
executor.result_handler.handle_execution_error(error)
|
|
97
|
-
rescue RubyReactor::Error::CompensationError => e
|
|
98
|
-
executor.result_handler.handle_execution_error(e)
|
|
99
|
-
end
|
|
100
|
-
# Manually update context status since we're not running executor loop
|
|
101
|
-
executor.send(:update_context_status, failure_response)
|
|
102
|
-
ensure
|
|
103
|
-
executor.middlewares.on(:failed_reactor, parent_context.reactor_class.name, failure_response,
|
|
104
|
-
parent_context)
|
|
105
|
-
end
|
|
106
|
-
# This branch runs no executor loop, so nothing else persists the
|
|
107
|
-
# failed status: store it here.
|
|
108
|
-
store_parent(parent_context, storage)
|
|
109
|
-
else
|
|
110
|
-
parent_context.set_result(step_name_sym, final_result.value)
|
|
111
|
-
# A completed fan-out map is tracked for undo like any step (R-03).
|
|
112
|
-
# The record stays empty: `MapStep#undo` finds its elements through
|
|
113
|
-
# the map's element index, so the parent does not grow per element.
|
|
114
|
-
parent_context.undo_stack << { step: parent_context.reactor_class.steps[step_name_sym], arguments: {},
|
|
115
|
-
result: RubyReactor.Success(nil) }
|
|
116
|
-
record_elements_started(parent_context, step_name, storage)
|
|
117
|
-
|
|
118
|
-
# Manually update execution trace to reflect completion
|
|
119
|
-
# This is necessary because resume_execution continues from the NEXT step
|
|
120
|
-
# and the async step (which returned DispatchResult) needs to be marked as done with actual value
|
|
121
|
-
parent_context.append_execution_trace(
|
|
122
|
-
{
|
|
123
|
-
type: :result,
|
|
124
|
-
step: step_name_sym,
|
|
125
|
-
timestamp: Time.now,
|
|
126
|
-
value: final_result.value,
|
|
127
|
-
status: :success
|
|
128
|
-
}
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
parent_context.current_step = nil
|
|
132
|
-
|
|
133
|
-
# Mark the context as a worker before resuming: resume_execution runs
|
|
134
|
-
# the collector's own worker (this call), not the reactor's normal
|
|
135
|
-
# caller. Without this, StepExecutor#handoff_at? (gated on
|
|
136
|
-
# `inline_async_execution`) re-evaluates any later `background`
|
|
137
|
-
# before:/after: cut point and enqueues a second hand-off, or runs an
|
|
138
|
-
# `after:` target here in the collector instead of the original
|
|
139
|
-
# dispatching worker.
|
|
140
|
-
parent_context.inline_async_execution = true
|
|
141
|
-
resume_parked_aware(executor, parent_context)
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# `resume_execution` persists the parent itself — under the parent's
|
|
146
|
-
# context lock, and deliberately NOT when it lost that lock to a live
|
|
147
|
-
# duplicate or replayed an already-terminal run. Storing again here, after
|
|
148
|
-
# the lock is released, would overwrite whatever the lock's holder wrote
|
|
149
|
-
# (single writer), so the resume's own save is the only one.
|
|
150
|
-
#
|
|
151
|
-
# This collector is a worker running the parent's execution, so it is
|
|
152
|
-
# also a final handler for park signals (005 R-01), like `Worker` and
|
|
153
|
-
# `ElementExecutor`: the resume has already parked the parent's holds and
|
|
154
|
-
# saved; hand the rest back to the parent's own worker.
|
|
155
|
-
#
|
|
156
|
-
# The parent is loaded from its own blob, so it has no `root_context`:
|
|
157
|
-
# a map inside a composed child resumes and requeues that child as its
|
|
158
|
-
# own execution, never its root. ponytail: the root is never resumed
|
|
159
|
-
# after such a map (already so on main); see "Fan-out map inside a
|
|
160
|
-
# composed child" in specs/future_improvements.md.
|
|
161
|
-
def resume_parked_aware(executor, parent_context)
|
|
162
|
-
executor.resume_execution
|
|
163
|
-
rescue RubyReactor::Error::ExecutionParked => e
|
|
164
|
-
config = RubyReactor.configuration
|
|
165
|
-
config.async_router.perform_in(
|
|
166
|
-
RubyReactor::Worker.snooze_delay(config, e), parent_context.context_id,
|
|
167
|
-
RubyReactor.reactor_storage_name(parent_context.reactor_class)
|
|
168
|
-
)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Every index of a completed map ran: record the count on the map's
|
|
172
|
-
# reference in the parent, which outlives the element index, so a late
|
|
173
|
-
# rollback names each element whose context expired.
|
|
174
|
-
def record_elements_started(parent_context, step_name, storage)
|
|
175
|
-
ref = Utils::FetchIndifferent.call(parent_context.composed_contexts, step_name)
|
|
176
|
-
return unless ref
|
|
177
|
-
|
|
178
|
-
map_id = Utils::FetchIndifferent.call(ref, :map_id)
|
|
179
|
-
ref[:started] = storage.retrieve_map_metadata(map_id, parent_context.reactor_class.name)&.fetch("count", 0).to_i
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def store_parent(parent_context, storage)
|
|
183
|
-
storage.store_context(
|
|
184
|
-
parent_context.context_id,
|
|
185
|
-
ContextSerializer.serialize(parent_context),
|
|
186
|
-
RubyReactor.reactor_storage_name(parent_context.reactor_class)
|
|
187
|
-
)
|
|
188
|
-
end
|
|
189
45
|
end
|
|
190
46
|
end
|
|
191
47
|
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
# How a map step rolls back (009 R-01): a fan-out map distributed, one job
|
|
6
|
+
# per started element (S-2); an inline map in process, in bounded reads
|
|
7
|
+
# (S-4). Both report the same failures (I-7). Mixed into `Step::MapStep`.
|
|
8
|
+
module StepRollback
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# S-2: start the map's rollback once (its records, then the first
|
|
12
|
+
# throw), then either aggregate a settled rollback or hand the run off
|
|
13
|
+
# until the element jobs report (R-04, R-05). Re-entered by the owner's
|
|
14
|
+
# resume, it finds the records and only checks for the settle.
|
|
15
|
+
def distributed_rollback(map_id, step_name)
|
|
16
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
17
|
+
map_class = context.reactor_class.name
|
|
18
|
+
created, rollback = storage.start_map_rollback(map_id, map_class, **rollback_metadata(map_id, step_name))
|
|
19
|
+
if created
|
|
20
|
+
log_rollback("started", step_name, total: rollback["total"], batch_size: rollback["batch_size"])
|
|
21
|
+
RubyReactor::Map::Dispatcher.dispatch_rollback_batch(map_id: map_id, parent_reactor_class_name: map_class)
|
|
22
|
+
end
|
|
23
|
+
total = rollback["total"].to_i
|
|
24
|
+
if storage.count_map_rollback_outcomes(map_id, map_class) >= total
|
|
25
|
+
return finish_rollback(step_name, total, distributed_failures(map_id, step_name))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raise RubyReactor::Error::RollbackHandedOff.new(map_id: map_id, reactor_class_name: map_class)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def rollback_metadata(map_id, step_name)
|
|
32
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
33
|
+
map_class = context.reactor_class.name
|
|
34
|
+
forward = storage.retrieve_map_metadata(map_id, map_class) || {}
|
|
35
|
+
{
|
|
36
|
+
total: storage.count_map_element_context_ids(map_id, map_class),
|
|
37
|
+
batch_size: forward["batch_size"] || RubyReactor::Map::DEFAULT_BATCH_SIZE,
|
|
38
|
+
step_name: step_name.to_s,
|
|
39
|
+
owner_context_id: owner_context.context_id,
|
|
40
|
+
owner_reactor_class_name: RubyReactor.reactor_storage_name(owner_context.reactor_class),
|
|
41
|
+
reactor_class_info: forward["reactor_class_info"] || build_reactor_class_info(element_class, step_name)
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The failures the element jobs reported, in position order (newest
|
|
46
|
+
# first, as inline), read in bounded chunks (R-09).
|
|
47
|
+
def distributed_failures(map_id, step_name)
|
|
48
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
49
|
+
map_class = context.reactor_class.name
|
|
50
|
+
reported = []
|
|
51
|
+
unnamed = 0
|
|
52
|
+
storage.each_map_rollback_outcome(map_id, map_class) do |position, outcome|
|
|
53
|
+
unnamed += 1 if outcome["outcome"] == "context_unavailable" && outcome["index"].nil?
|
|
54
|
+
next unless %w[failed element_in_flight].include?(outcome["outcome"])
|
|
55
|
+
|
|
56
|
+
reported << [position, ContextSerializer.deserialize_value(outcome["failures"])]
|
|
57
|
+
end
|
|
58
|
+
unseen = lambda do |indexes|
|
|
59
|
+
indexes.each_slice(1000).flat_map do |slice|
|
|
60
|
+
slice.zip(storage.map_rollback_indexes_seen(map_id, map_class, slice)).reject(&:last).map(&:first)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
unavailable_entries(step_name, unnamed, unseen) + reported.sort_by(&:first).flat_map(&:last)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# S-4: in process, `Map::ROLLBACK_CHUNK` elements per read from the tail
|
|
67
|
+
# of the element index, never all at once.
|
|
68
|
+
def inline_rollback(map_id, step_name)
|
|
69
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
70
|
+
map_class = context.reactor_class.name
|
|
71
|
+
total = storage.count_map_element_context_ids(map_id, map_class)
|
|
72
|
+
reported = []
|
|
73
|
+
seen = Set.new
|
|
74
|
+
unnamed = 0
|
|
75
|
+
log_rollback("started", step_name, total: total, batch_size: RubyReactor::Map::ROLLBACK_CHUNK)
|
|
76
|
+
(0...total).step(RubyReactor::Map::ROLLBACK_CHUNK) do |position|
|
|
77
|
+
ids = storage.retrieve_map_element_context_ids_from_tail(map_id, map_class, position,
|
|
78
|
+
RubyReactor::Map::ROLLBACK_CHUNK, total: total)
|
|
79
|
+
ids.each do |id|
|
|
80
|
+
outcome = inline_element_rollback(map_id, id, step_name)
|
|
81
|
+
outcome["index"] ? seen << outcome["index"] : unnamed += 1
|
|
82
|
+
reported.concat(ContextSerializer.deserialize_value(outcome["failures"])) if outcome["index"]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
unseen = ->(indexes) { indexes.reject { |index| seen.include?(index) } }
|
|
86
|
+
finish_rollback(step_name, total, unavailable_entries(step_name, unnamed, unseen) + reported)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Today's inline behavior: one wait for the element's lock, then report
|
|
90
|
+
# it in flight.
|
|
91
|
+
def inline_element_rollback(map_id, element_context_id, step_name)
|
|
92
|
+
outcome = RubyReactor::Map::ElementRollback.call(map_id: map_id, element_context_id: element_context_id,
|
|
93
|
+
element_class: element_class, step_name: step_name)
|
|
94
|
+
if outcome["outcome"] == "contended"
|
|
95
|
+
outcome = { "index" => outcome["index"], "outcome" => "element_in_flight",
|
|
96
|
+
"failures" => ContextSerializer.serialize_value(
|
|
97
|
+
[rollback_entry(outcome["index"], :element_in_flight, "was still running at rollback time")]
|
|
98
|
+
) }
|
|
99
|
+
end
|
|
100
|
+
log_rollback("element", step_name, index: outcome["index"], outcome: outcome["outcome"],
|
|
101
|
+
failures: outcome["failures"].size)
|
|
102
|
+
outcome
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Each element whose context expired is reported once (R-09, I2). The
|
|
106
|
+
# parent's map reference counts the elements that started: set per
|
|
107
|
+
# element inline, and to the total when a fan-out map completes. With
|
|
108
|
+
# it, every started index no rollback saw is named, even when the index
|
|
109
|
+
# list itself expired; without it (a failed fan-out map skipped some
|
|
110
|
+
# indices), one unnamed entry per element whose row is gone.
|
|
111
|
+
def unavailable_entries(step_name, unnamed, unseen)
|
|
112
|
+
ref = Utils::FetchIndifferent.call(context.composed_contexts, step_name)
|
|
113
|
+
started = ref && Utils::FetchIndifferent.call(ref, :started)
|
|
114
|
+
missing = started ? unseen.call((0...started.to_i).to_a) : [nil] * unnamed
|
|
115
|
+
missing.map { |index| rollback_entry(index, :context_unavailable, "context expired") }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def finish_rollback(step_name, total, failures)
|
|
119
|
+
log_rollback("completed", step_name, total: total, failed: failures.size)
|
|
120
|
+
return RubyReactor.Success() if failures.empty?
|
|
121
|
+
|
|
122
|
+
RubyReactor.Failure("map :#{step_name} rollback incomplete", rollback_failures: failures)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def log_rollback(event, step_name, **fields)
|
|
126
|
+
RubyReactor::Map.log("ruby_reactor.map.rollback.#{event}",
|
|
127
|
+
reactor: RubyReactor.reactor_storage_name(owner_context.reactor_class),
|
|
128
|
+
context_id: owner_context.context_id, map_step: step_name.to_s, **fields)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def rollback_entry(index, reason, message)
|
|
132
|
+
RubyReactor::Map::ElementRollback.entry(context.current_step, index, reason, message)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -16,6 +16,12 @@ module RubyReactor
|
|
|
16
16
|
# re-triggered (M2) — gated so it never fires while a collector or the
|
|
17
17
|
# parent is alive, or after the parent already collected.
|
|
18
18
|
#
|
|
19
|
+
# For each distributed map rollback (009 S-5), the same signal on the
|
|
20
|
+
# rollback's own records: a claimed position with no outcome and no live
|
|
21
|
+
# element lock lost its job and is re-dispatched; with every claimed
|
|
22
|
+
# position settled but positions left, the throw's trigger was lost and
|
|
23
|
+
# the next throw is claimed. A lost owner resume is `RubyReactor::Sweeper`'s.
|
|
24
|
+
#
|
|
19
25
|
# `run_once` is pure and idempotent; the host wires the cadence (same contract
|
|
20
26
|
# as RubyReactor::Sweeper).
|
|
21
27
|
class Sweeper
|
|
@@ -29,10 +35,11 @@ module RubyReactor
|
|
|
29
35
|
@logger = logger || RubyReactor.configuration.logger
|
|
30
36
|
end
|
|
31
37
|
|
|
32
|
-
# Returns { redispatched:, recollected: } counts.
|
|
38
|
+
# Returns { redispatched:, recollected:, rollback_redispatched: } counts.
|
|
33
39
|
def run_once(limit: 1000)
|
|
34
40
|
redispatched = 0
|
|
35
41
|
recollected = 0
|
|
42
|
+
rollback_redispatched = 0
|
|
36
43
|
|
|
37
44
|
@storage.scan_maps(count: limit).each do |meta|
|
|
38
45
|
missing = missing_indices(meta)
|
|
@@ -46,11 +53,53 @@ module RubyReactor
|
|
|
46
53
|
@logger.warn("RubyReactor::Map::Sweeper failed on map #{meta["map_id"]}: #{e.class}: #{e.message}")
|
|
47
54
|
end
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
@storage.scan_map_rollbacks(count: limit).each do |meta|
|
|
57
|
+
rollback_redispatched += sweep_rollback(meta)
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
@logger.warn("RubyReactor::Map::Sweeper failed on map rollback #{meta["map_id"]}: #{e.class}: #{e.message}")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
{ redispatched: redispatched, recollected: recollected, rollback_redispatched: rollback_redispatched }
|
|
50
63
|
end
|
|
51
64
|
|
|
52
65
|
private
|
|
53
66
|
|
|
67
|
+
def sweep_rollback(meta)
|
|
68
|
+
map_id = meta["map_id"]
|
|
69
|
+
map_class = meta["parent_reactor_class_name"]
|
|
70
|
+
total = meta["total"].to_i
|
|
71
|
+
claimed = [@storage.retrieve_map_rollback_offset(map_id, map_class), total].min
|
|
72
|
+
missing = (0...claimed).to_a - @storage.stored_map_rollback_positions(map_id, map_class)
|
|
73
|
+
return redispatch_rollback(meta, missing) if missing.any?
|
|
74
|
+
return 0 if claimed >= total
|
|
75
|
+
|
|
76
|
+
RubyReactor::Map::Dispatcher.dispatch_rollback_batch(map_id: map_id, parent_reactor_class_name: map_class)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def redispatch_rollback(meta, positions)
|
|
80
|
+
map_id = meta["map_id"]
|
|
81
|
+
map_class = meta["parent_reactor_class_name"]
|
|
82
|
+
positions.count do |position|
|
|
83
|
+
id = @storage.retrieve_map_element_context_ids_from_tail(map_id, map_class, position, 1,
|
|
84
|
+
total: meta["total"].to_i).first
|
|
85
|
+
index = element_index(id, meta)
|
|
86
|
+
next false if index && @storage.lock_held?("map_element:#{map_id}:#{index}") # element alive
|
|
87
|
+
|
|
88
|
+
RubyReactor::Map::Dispatcher.queue_rollback_job(meta, map_id, map_class, position, id)
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# From the element's row; nil once the row is gone (the job reports it).
|
|
94
|
+
def element_index(element_context_id, meta)
|
|
95
|
+
return nil unless element_context_id
|
|
96
|
+
|
|
97
|
+
element_class = RubyReactor::Map::Helpers.resolve_reactor_class(meta["reactor_class_info"])
|
|
98
|
+
data = @storage.retrieve_context(element_context_id, RubyReactor.reactor_storage_name(element_class))
|
|
99
|
+
metadata = data && ContextSerializer.deserialize_value(data["map_metadata"])
|
|
100
|
+
metadata && Utils::FetchIndifferent.call(metadata, :index)
|
|
101
|
+
end
|
|
102
|
+
|
|
54
103
|
def missing_indices(meta)
|
|
55
104
|
@storage.missing_map_indices(meta["map_id"], meta["count"].to_i, meta["parent_reactor_class_name"])
|
|
56
105
|
end
|
|
@@ -77,22 +126,31 @@ module RubyReactor
|
|
|
77
126
|
end
|
|
78
127
|
|
|
79
128
|
# N1: a nested map's parent is a map element running under a `map_element:`
|
|
80
|
-
# lock, not an `async:` lock. Derive the right key from metadata.
|
|
129
|
+
# lock, not an `async:` lock. Derive the right key from metadata. Any
|
|
130
|
+
# other map's completion resumes its OWNER run (009 R-03), so that run's
|
|
131
|
+
# lock is the one to check; metadata from before 009 names no owner.
|
|
81
132
|
def parent_live_lock?(meta)
|
|
82
133
|
if meta["parent_is_map_element"]
|
|
83
134
|
@storage.lock_held?("map_element:#{meta["outer_map_id"]}:#{meta["outer_index"]}")
|
|
84
135
|
else
|
|
85
|
-
@storage.lock_held?("async:#{meta["parent_context_id"]}")
|
|
136
|
+
@storage.lock_held?("async:#{meta["owner_context_id"] || meta["parent_context_id"]}")
|
|
86
137
|
end
|
|
87
138
|
end
|
|
88
139
|
|
|
140
|
+
# The map's own context already recorded the step, or the owner run is
|
|
141
|
+
# past it: finished, or rolling back (it adopted the map).
|
|
89
142
|
def parent_already_collected?(meta)
|
|
90
143
|
data = @storage.retrieve_context(meta["parent_context_id"], meta["parent_reactor_class_name"])
|
|
91
|
-
return
|
|
144
|
+
return true if data && (data["intermediate_results"] || {}).key?(meta["step_name"].to_s)
|
|
145
|
+
|
|
146
|
+
owner = owner_context_data(meta) || data
|
|
147
|
+
owner && %w[completed failed halted skipped cancelled rolling_back].include?(owner["status"].to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def owner_context_data(meta)
|
|
151
|
+
return nil unless meta["owner_context_id"]
|
|
92
152
|
|
|
93
|
-
|
|
94
|
-
status = data["status"].to_s
|
|
95
|
-
results.key?(meta["step_name"].to_s) || %w[completed failed halted skipped].include?(status)
|
|
153
|
+
@storage.retrieve_context(meta["owner_context_id"], meta["owner_reactor_class_name"])
|
|
96
154
|
end
|
|
97
155
|
|
|
98
156
|
def retrigger_collector(meta)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
# Element jobs one throw enqueues when a fan-out map declares no
|
|
6
|
+
# `batch_size` (009 R-10), forward and rollback alike.
|
|
7
|
+
DEFAULT_BATCH_SIZE = 50
|
|
8
|
+
# Element contexts an inline map's rollback loads per read (009 R-01).
|
|
9
|
+
ROLLBACK_CHUNK = 100
|
|
10
|
+
|
|
11
|
+
# One structured key=value line (009 FR-011), as `Executor#log_completion`.
|
|
12
|
+
def self.log(event, **fields)
|
|
13
|
+
line = { event: event, **fields }.map { |key, value| "#{key}=#{value.inspect}" }.join(" ")
|
|
14
|
+
RubyReactor.configuration.logger.info(line)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -7,12 +7,14 @@ module RubyReactor
|
|
|
7
7
|
# rubocop:disable Metrics/ParameterLists
|
|
8
8
|
def initialize(message, step:, attempts:, original_error: nil,
|
|
9
9
|
inputs: {}, backtrace: nil, redact_inputs: [],
|
|
10
|
-
reactor_name: nil, step_arguments: {}, validation_errors: nil, rollback_failures: nil
|
|
10
|
+
reactor_name: nil, step_arguments: {}, validation_errors: nil, rollback_failures: nil,
|
|
11
|
+
exception_class: nil)
|
|
11
12
|
# rubocop:enable Metrics/ParameterLists
|
|
12
13
|
super(message,
|
|
13
14
|
step_name: step, inputs: inputs, backtrace: backtrace,
|
|
14
15
|
redact_inputs: redact_inputs, reactor_name: reactor_name, step_arguments: step_arguments,
|
|
15
|
-
validation_errors: validation_errors, rollback_failures: rollback_failures
|
|
16
|
+
validation_errors: validation_errors, rollback_failures: rollback_failures,
|
|
17
|
+
exception_class: exception_class)
|
|
16
18
|
@attempts = attempts
|
|
17
19
|
@original_error = original_error
|
|
18
20
|
end
|