ruby_reactor 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +17 -14
- data/CHANGELOG.md +254 -0
- data/CLAUDE.md +1 -1
- data/README.md +66 -15
- data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
- data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
- data/lib/ruby_reactor/context.rb +14 -1
- data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
- data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
- data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
- data/lib/ruby_reactor/error/rescuable.rb +33 -0
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
- data/lib/ruby_reactor/executor/result_handler.rb +85 -63
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
- data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
- data/lib/ruby_reactor/executor/step_executor.rb +61 -49
- data/lib/ruby_reactor/executor.rb +221 -7
- data/lib/ruby_reactor/map/collector.rb +31 -89
- data/lib/ruby_reactor/map/dispatcher.rb +65 -8
- data/lib/ruby_reactor/map/element_executor.rb +21 -11
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -125
- data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
- data/lib/ruby_reactor/map/step_rollback.rb +136 -0
- data/lib/ruby_reactor/map/sweeper.rb +66 -8
- data/lib/ruby_reactor/map.rb +17 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
- data/lib/ruby_reactor/reactor.rb +65 -2
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +59 -24
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +10 -1
- data/lib/ruby_reactor/step/map_step.rb +144 -36
- data/lib/ruby_reactor/step.rb +7 -0
- data/lib/ruby_reactor/step_worker.rb +46 -22
- data/lib/ruby_reactor/storage/adapter.rb +18 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
- data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/sweeper.rb +3 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +11 -1
- data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
- data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +7 -3
- data/lib/ruby_reactor.rb +17 -6
- data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
- data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
- data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
- data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
- data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
- data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
- data/specs/007-execution-flow-analysis/data-model.md +83 -0
- data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
- data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
- data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
- data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
- data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
- data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
- data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
- data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
- data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
- data/specs/007-execution-flow-analysis/plan.md +127 -0
- data/specs/007-execution-flow-analysis/quickstart.md +51 -0
- data/specs/007-execution-flow-analysis/research.md +202 -0
- data/specs/007-execution-flow-analysis/spec.md +270 -0
- data/specs/007-execution-flow-analysis/tasks.md +257 -0
- data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
- data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
- data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
- data/specs/008-rollback-reliability/data-model.md +139 -0
- data/specs/008-rollback-reliability/plan.md +233 -0
- data/specs/008-rollback-reliability/quickstart.md +105 -0
- data/specs/008-rollback-reliability/research.md +653 -0
- data/specs/008-rollback-reliability/spec.md +561 -0
- data/specs/008-rollback-reliability/tasks.md +1110 -0
- data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
- data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
- data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
- data/specs/009-distributed-map-undo/data-model.md +151 -0
- data/specs/009-distributed-map-undo/plan.md +323 -0
- data/specs/009-distributed-map-undo/pr-description.md +78 -0
- data/specs/009-distributed-map-undo/quickstart.md +154 -0
- data/specs/009-distributed-map-undo/research.md +585 -0
- data/specs/009-distributed-map-undo/spec.md +360 -0
- data/specs/009-distributed-map-undo/tasks.md +1060 -0
- data/specs/future_improvements.md +64 -28
- metadata +54 -3
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
|
@@ -0,0 +1,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
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -6,6 +6,9 @@ module RubyReactor
|
|
|
6
6
|
include RubyReactor::Dsl::Reactor
|
|
7
7
|
include RubyReactor::Dsl::Lockable
|
|
8
8
|
|
|
9
|
+
# Seconds a manual undo waits for the run's context lock (009 R-12).
|
|
10
|
+
UNDO_LOCK_WAIT = 5
|
|
11
|
+
|
|
9
12
|
attr_reader :context, :result, :undo_trace, :execution_trace
|
|
10
13
|
|
|
11
14
|
def self.find(id)
|
|
@@ -60,9 +63,12 @@ module RubyReactor
|
|
|
60
63
|
reactor.cancel(reason)
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
# A rollback that hands off at a fan-out map finishes in a worker, which
|
|
67
|
+
# applies `cancelled` itself (009 R-12).
|
|
63
68
|
def self.undo(id)
|
|
64
69
|
reactor = find(id)
|
|
65
|
-
reactor.undo
|
|
70
|
+
return if reactor.undo == :handed_off
|
|
71
|
+
|
|
66
72
|
cancel(id: id, reason: "Undo triggered")
|
|
67
73
|
end
|
|
68
74
|
|
|
@@ -153,6 +159,14 @@ module RubyReactor
|
|
|
153
159
|
"Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
|
|
154
160
|
end
|
|
155
161
|
|
|
162
|
+
# Only a reactor paused at an interrupt takes a resume (008 FR-032): one
|
|
163
|
+
# that is executing or rolling back (`running`), finished, or `aborted`
|
|
164
|
+
# must not be run forward from its stored state.
|
|
165
|
+
unless @context.status.to_s == "paused"
|
|
166
|
+
raise Error::ValidationError,
|
|
167
|
+
"Cannot resume: the reactor is #{@context.status}, not paused at an interrupt"
|
|
168
|
+
end
|
|
169
|
+
|
|
156
170
|
validate_continue_step!(step_name)
|
|
157
171
|
|
|
158
172
|
if (failure = validate_continue_payload(payload, step_name))
|
|
@@ -170,6 +184,11 @@ module RubyReactor
|
|
|
170
184
|
return @result = enqueue_background_resume
|
|
171
185
|
end
|
|
172
186
|
|
|
187
|
+
# Claim the resume before running it, so a `continue` that arrives while
|
|
188
|
+
# this one executes or rolls back reads `running` and fails (FR-032).
|
|
189
|
+
@context.status = :running
|
|
190
|
+
save_context
|
|
191
|
+
|
|
173
192
|
# Resume execution
|
|
174
193
|
executor = Executor.new(self.class, {}, @context)
|
|
175
194
|
@result = executor.resume_execution
|
|
@@ -179,19 +198,46 @@ module RubyReactor
|
|
|
179
198
|
@execution_trace = executor.execution_trace
|
|
180
199
|
|
|
181
200
|
@result
|
|
201
|
+
rescue Lock::AcquisitionError, Semaphore::AcquisitionError => e
|
|
202
|
+
# Contended at the resume's own gates: nothing ran, so the run is still
|
|
203
|
+
# paused and the caller may retry, as with `Reactor.run`. A context-lock
|
|
204
|
+
# contention means a live resume holds the run: leave it alone.
|
|
205
|
+
reopen_paused unless executor&.past_gates? || e.is_a?(Lock::ContextLockContention)
|
|
206
|
+
raise
|
|
182
207
|
rescue Error::InputValidationError => e
|
|
183
208
|
# This might catch other validations, but here we specifically want payload validation.
|
|
184
209
|
# The block above handles payload validation explicitly.
|
|
185
210
|
RubyReactor::Failure(e.message, invalid_payload: true)
|
|
186
211
|
end
|
|
187
212
|
|
|
213
|
+
# Undoes every completed step, under the run's context lock so no worker
|
|
214
|
+
# resumes it meanwhile (009 R-12). Returns `:handed_off` when a fan-out
|
|
215
|
+
# map's rollback continues in its element jobs: the run is then
|
|
216
|
+
# `rolling_back`, and a worker finishes it as `cancelled`.
|
|
188
217
|
def undo
|
|
218
|
+
raise Error::ValidationError, "rollback already in progress" if @context.rolling_back?
|
|
219
|
+
|
|
220
|
+
lock = acquire_undo_lock
|
|
189
221
|
executor = Executor.new(self.class, {}, @context)
|
|
190
|
-
|
|
222
|
+
@context.rollback = { "trigger" => "undo", "compensated" => true, "failures" => [] }
|
|
223
|
+
begin
|
|
224
|
+
executor.undo_all
|
|
225
|
+
rescue Error::RollbackHandedOff => e
|
|
226
|
+
@context.status = :rolling_back
|
|
227
|
+
executor.save_context
|
|
228
|
+
executor.hand_off_rollback!(e)
|
|
229
|
+
return :handed_off
|
|
230
|
+
end
|
|
231
|
+
@context.rollback = nil
|
|
191
232
|
executor.save_context
|
|
233
|
+
ensure
|
|
234
|
+
lock&.release
|
|
192
235
|
end
|
|
193
236
|
|
|
194
237
|
def cancel(reason)
|
|
238
|
+
# `cancelled` is terminal: the steps before the map would never be undone.
|
|
239
|
+
raise Error::ValidationError, "rollback in progress; cannot cancel" if @context.rolling_back?
|
|
240
|
+
|
|
195
241
|
@context.cancelled = true
|
|
196
242
|
@context.cancellation_reason = reason
|
|
197
243
|
@context.status = "cancelled"
|
|
@@ -211,6 +257,23 @@ module RubyReactor
|
|
|
211
257
|
RubyReactor::Configuration.instance
|
|
212
258
|
end
|
|
213
259
|
|
|
260
|
+
def reopen_paused
|
|
261
|
+
@context.status = :paused
|
|
262
|
+
save_context
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Raises `Lock::AcquisitionError` while a live run or rollback holds it.
|
|
266
|
+
# Skipped in inline job-testing mode, as `Executor#acquire_context_lock`.
|
|
267
|
+
def acquire_undo_lock
|
|
268
|
+
return if defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
|
|
269
|
+
|
|
270
|
+
lock = RubyReactor::Lock.new("async:#{@context.context_id}", owner: SecureRandom.uuid,
|
|
271
|
+
ttl: configuration.context_lock_ttl,
|
|
272
|
+
wait: UNDO_LOCK_WAIT)
|
|
273
|
+
lock.acquire
|
|
274
|
+
lock
|
|
275
|
+
end
|
|
276
|
+
|
|
214
277
|
def validate_steps!
|
|
215
278
|
return unless self.class.steps.empty?
|
|
216
279
|
|
|
@@ -11,12 +11,16 @@ module RubyReactor
|
|
|
11
11
|
PendingJob = Struct.new(:job_class, :raw) do
|
|
12
12
|
def perform!
|
|
13
13
|
::ActiveJob::Base.queue_adapter.enqueued_jobs.delete(raw)
|
|
14
|
-
job_class.new(*raw
|
|
14
|
+
job_class.new(*ActiveJobHelpers.arguments(raw)).perform_now
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def args
|
|
18
18
|
raw[:args]
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# The Sidekiq `PendingJob`'s name for it, so step-wise drains read the
|
|
22
|
+
# same on both backends (009 U4).
|
|
23
|
+
alias_method :worker_class, :job_class
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def self.test_adapter?
|
|
@@ -37,11 +41,18 @@ module RubyReactor
|
|
|
37
41
|
|
|
38
42
|
queue.dup.each do |job|
|
|
39
43
|
queue.delete(job)
|
|
40
|
-
job[:job].new(*job
|
|
44
|
+
job[:job].new(*arguments(job)).perform_now
|
|
41
45
|
end
|
|
42
46
|
end
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
# The test adapter keeps a job's arguments serialized; a real backend
|
|
50
|
+
# hands `perform` the deserialized ones (a job requeueing its own
|
|
51
|
+
# arguments would otherwise re-serialize ActiveJob's markers).
|
|
52
|
+
def self.arguments(raw)
|
|
53
|
+
::ActiveJob::Arguments.deserialize(raw[:args])
|
|
54
|
+
end
|
|
55
|
+
|
|
45
56
|
def self.pending_async_jobs
|
|
46
57
|
return [] unless test_adapter?
|
|
47
58
|
|