ruby_reactor 0.8.5 → 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 +58 -0
- data/CLAUDE.md +1 -1
- data/README.md +23 -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 +33 -9
- data/lib/ruby_reactor/executor/result_handler.rb +72 -52
- data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
- data/lib/ruby_reactor/executor/step_executor.rb +3 -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 +41 -2
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +40 -21
- 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 +3 -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/future_improvements.md +18 -30
- metadata +21 -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
|
|
|
@@ -204,13 +210,34 @@ module RubyReactor
|
|
|
204
210
|
RubyReactor::Failure(e.message, invalid_payload: true)
|
|
205
211
|
end
|
|
206
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`.
|
|
207
217
|
def undo
|
|
218
|
+
raise Error::ValidationError, "rollback already in progress" if @context.rolling_back?
|
|
219
|
+
|
|
220
|
+
lock = acquire_undo_lock
|
|
208
221
|
executor = Executor.new(self.class, {}, @context)
|
|
209
|
-
|
|
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
|
|
210
232
|
executor.save_context
|
|
233
|
+
ensure
|
|
234
|
+
lock&.release
|
|
211
235
|
end
|
|
212
236
|
|
|
213
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
|
+
|
|
214
241
|
@context.cancelled = true
|
|
215
242
|
@context.cancellation_reason = reason
|
|
216
243
|
@context.status = "cancelled"
|
|
@@ -235,6 +262,18 @@ module RubyReactor
|
|
|
235
262
|
save_context
|
|
236
263
|
end
|
|
237
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
|
+
|
|
238
277
|
def validate_steps!
|
|
239
278
|
return unless self.class.steps.empty?
|
|
240
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
|
|
|
@@ -4,8 +4,10 @@ module RubyReactor
|
|
|
4
4
|
module RSpec
|
|
5
5
|
# rubocop:disable Metrics/ModuleLength
|
|
6
6
|
module Matchers
|
|
7
|
+
extend ::RSpec::Matchers::DSL
|
|
8
|
+
|
|
7
9
|
# rubocop:disable Metrics/BlockLength
|
|
8
|
-
|
|
10
|
+
matcher :be_success do
|
|
9
11
|
match do |subject|
|
|
10
12
|
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
11
13
|
subject.success?
|
|
@@ -63,7 +65,7 @@ module RubyReactor
|
|
|
63
65
|
end
|
|
64
66
|
end
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
matcher :be_failure do
|
|
67
69
|
match do |subject|
|
|
68
70
|
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
69
71
|
subject.failure?
|
|
@@ -74,7 +76,7 @@ module RubyReactor
|
|
|
74
76
|
end
|
|
75
77
|
end
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
matcher :have_run_step do |step_name|
|
|
78
80
|
match do |subject|
|
|
79
81
|
subject.ensure_executed!
|
|
80
82
|
@trace = subject.reactor_instance.context.execution_trace
|
|
@@ -127,7 +129,7 @@ module RubyReactor
|
|
|
127
129
|
end
|
|
128
130
|
end
|
|
129
131
|
|
|
130
|
-
|
|
132
|
+
matcher :have_retried_step do |step_name|
|
|
131
133
|
match do |subject|
|
|
132
134
|
subject.ensure_executed!
|
|
133
135
|
attempts = subject.reactor_instance.context.retry_context.attempts_for_step(step_name)
|
|
@@ -151,7 +153,7 @@ module RubyReactor
|
|
|
151
153
|
end
|
|
152
154
|
end
|
|
153
155
|
|
|
154
|
-
|
|
156
|
+
matcher :have_validation_error do |field|
|
|
155
157
|
match do |subject|
|
|
156
158
|
subject.ensure_executed!
|
|
157
159
|
return false unless subject.failure?
|
|
@@ -173,7 +175,7 @@ module RubyReactor
|
|
|
173
175
|
end
|
|
174
176
|
|
|
175
177
|
# Matcher to check if reactor is paused at an interrupt
|
|
176
|
-
|
|
178
|
+
matcher :be_paused do
|
|
177
179
|
match do |subject|
|
|
178
180
|
subject.ensure_executed!
|
|
179
181
|
subject.paused?
|
|
@@ -188,9 +190,26 @@ module RubyReactor
|
|
|
188
190
|
end
|
|
189
191
|
end
|
|
190
192
|
|
|
193
|
+
# A run whose rollback handed off at a fan-out map and has not finished
|
|
194
|
+
# (009): its element rollback jobs are still pending.
|
|
195
|
+
matcher :be_rolling_back do
|
|
196
|
+
match do |subject|
|
|
197
|
+
subject.ensure_executed!
|
|
198
|
+
subject.rolling_back?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
failure_message do |subject|
|
|
202
|
+
"expected reactor to be rolling back, but status was #{subject.reactor_instance.context.status}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
failure_message_when_negated do |_subject|
|
|
206
|
+
"expected reactor not to be rolling back, but it is"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
191
210
|
# Matcher to check if reactor is paused at a specific interrupt step
|
|
192
211
|
# Works with both single and multiple concurrent interrupts
|
|
193
|
-
|
|
212
|
+
matcher :be_paused_at do |*step_names|
|
|
194
213
|
match do |subject|
|
|
195
214
|
subject.ensure_executed!
|
|
196
215
|
return false unless subject.paused?
|
|
@@ -221,7 +240,7 @@ module RubyReactor
|
|
|
221
240
|
end
|
|
222
241
|
|
|
223
242
|
# Matcher to check the exact set of ready interrupt steps
|
|
224
|
-
|
|
243
|
+
matcher :have_ready_interrupts do |*expected_steps|
|
|
225
244
|
match do |subject|
|
|
226
245
|
subject.ensure_executed!
|
|
227
246
|
return false unless subject.paused?
|
|
@@ -277,7 +296,7 @@ module RubyReactor
|
|
|
277
296
|
# expect(result).to be_halted
|
|
278
297
|
# expect(result).to be_halted.because(:period)
|
|
279
298
|
# expect(result).to be_halted.at_step(:second)
|
|
280
|
-
|
|
299
|
+
matcher :be_halted do
|
|
281
300
|
match do |subject|
|
|
282
301
|
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
283
302
|
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
@@ -320,7 +339,7 @@ module RubyReactor
|
|
|
320
339
|
# Examples:
|
|
321
340
|
# expect(result).to be_skipped
|
|
322
341
|
# expect(subject).to be_skipped.at_step(:maybe_sync)
|
|
323
|
-
|
|
342
|
+
matcher :be_skipped do
|
|
324
343
|
match do |subject|
|
|
325
344
|
subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
|
|
326
345
|
actual = subject.respond_to?(:result) ? subject.result : subject
|
|
@@ -367,7 +386,7 @@ module RubyReactor
|
|
|
367
386
|
#
|
|
368
387
|
# expect("order:42").to be_locked
|
|
369
388
|
# expect("order:42").to be_locked.by("ctx-abc")
|
|
370
|
-
|
|
389
|
+
matcher :be_locked do
|
|
371
390
|
match do |key|
|
|
372
391
|
info = Matchers.coordination_adapter.lock_info("lock:#{key}")
|
|
373
392
|
next false unless info
|
|
@@ -400,7 +419,7 @@ module RubyReactor
|
|
|
400
419
|
# user-provided semaphore name (without the "semaphore:" prefix).
|
|
401
420
|
#
|
|
402
421
|
# expect("api_limit").to have_available_tokens(3)
|
|
403
|
-
|
|
422
|
+
matcher :have_available_tokens do |expected|
|
|
404
423
|
match do |name|
|
|
405
424
|
Matchers.coordination_adapter.semaphore_state(name)[:available] == expected
|
|
406
425
|
end
|
|
@@ -415,7 +434,7 @@ module RubyReactor
|
|
|
415
434
|
# Asserts the number of currently-checked-out semaphore tokens.
|
|
416
435
|
#
|
|
417
436
|
# expect("api_limit").to have_held_tokens(2)
|
|
418
|
-
|
|
437
|
+
matcher :have_held_tokens do |expected|
|
|
419
438
|
match do |name|
|
|
420
439
|
Matchers.coordination_adapter.semaphore_state(name)[:held] == expected
|
|
421
440
|
end
|
|
@@ -434,7 +453,7 @@ module RubyReactor
|
|
|
434
453
|
#
|
|
435
454
|
# expect("stripe:42").to have_rate_limit_count(3).for(:second)
|
|
436
455
|
# expect("stripe:42").to have_rate_limit_count(1).for(:minute).since(started_at)
|
|
437
|
-
|
|
456
|
+
matcher :have_rate_limit_count do |expected|
|
|
438
457
|
match do |key_base|
|
|
439
458
|
raise ArgumentError, "have_rate_limit_count requires .for(period)" unless @period
|
|
440
459
|
|
|
@@ -458,7 +477,7 @@ module RubyReactor
|
|
|
458
477
|
# Asserts that a `with_period` bucket has been marked. Use `.for(period)`.
|
|
459
478
|
#
|
|
460
479
|
# expect("daily_report:7").to be_period_marked.for(:day)
|
|
461
|
-
|
|
480
|
+
matcher :be_period_marked do
|
|
462
481
|
match do |key_base|
|
|
463
482
|
raise ArgumentError, "be_period_marked requires .for(period)" unless @period
|
|
464
483
|
|
|
@@ -482,7 +501,7 @@ module RubyReactor
|
|
|
482
501
|
# the user-provided ordered_lock key (without the `ordered_lock:` prefix).
|
|
483
502
|
#
|
|
484
503
|
# expect("orders:42").to have_ordered_lock_next(3)
|
|
485
|
-
|
|
504
|
+
matcher :have_ordered_lock_next do |expected|
|
|
486
505
|
match { |key| Matchers.coordination_adapter.ordered_lock_peek(key)[:next] == expected }
|
|
487
506
|
|
|
488
507
|
failure_message do |key|
|
|
@@ -495,7 +514,7 @@ module RubyReactor
|
|
|
495
514
|
# Asserts the last-advanced cursor for an ordered_lock key.
|
|
496
515
|
#
|
|
497
516
|
# expect("orders:42").to have_ordered_lock_last_completed(2)
|
|
498
|
-
|
|
517
|
+
matcher :have_ordered_lock_last_completed do |expected|
|
|
499
518
|
match do |key|
|
|
500
519
|
Matchers.coordination_adapter.ordered_lock_peek(key)[:last_completed] == expected
|
|
501
520
|
end
|
|
@@ -511,7 +530,7 @@ module RubyReactor
|
|
|
511
530
|
# Order-insensitive — the matcher sorts both sides.
|
|
512
531
|
#
|
|
513
532
|
# expect("orders:42").to have_ordered_lock_in_flight(2, 3)
|
|
514
|
-
|
|
533
|
+
matcher :have_ordered_lock_in_flight do |*expected|
|
|
515
534
|
match do |key|
|
|
516
535
|
actual = Matchers.coordination_adapter.ordered_lock_peek(key)[:in_flight].sort
|
|
517
536
|
actual == expected.flatten.map(&:to_i).sort
|
|
@@ -528,7 +547,7 @@ module RubyReactor
|
|
|
528
547
|
# in-flight nonces. After a clean drain `peek` returns all zeros.
|
|
529
548
|
#
|
|
530
549
|
# expect("orders:42").to be_ordered_lock_drained
|
|
531
|
-
|
|
550
|
+
matcher :be_ordered_lock_drained do
|
|
532
551
|
match do |key|
|
|
533
552
|
state = Matchers.coordination_adapter.ordered_lock_peek(key)
|
|
534
553
|
state[:next].zero? && state[:last_completed].zero? && state[:in_flight].empty?
|
|
@@ -551,7 +570,7 @@ module RubyReactor
|
|
|
551
570
|
#
|
|
552
571
|
# expect(result).to have_contended_at(:charge)
|
|
553
572
|
# expect(result).to have_contended_at(:charge).on("acct:1")
|
|
554
|
-
|
|
573
|
+
matcher :have_contended_at do |step_name|
|
|
555
574
|
match do |subject|
|
|
556
575
|
trace = execution_trace_for(subject)
|
|
557
576
|
@entry = trace.find do |t|
|
|
@@ -596,7 +615,7 @@ module RubyReactor
|
|
|
596
615
|
# expect(result).to have_rollback_failure(:charge)
|
|
597
616
|
# expect(subject).to have_rollback_failure(:charge).for_key("acct:1").because(:coordination_unavailable)
|
|
598
617
|
# expect(subject).not_to have_rollback_failure(:charge)
|
|
599
|
-
|
|
618
|
+
matcher :have_rollback_failure do |step_name|
|
|
600
619
|
match do |subject|
|
|
601
620
|
@entries = rollback_failures_for(subject)
|
|
602
621
|
@entries.any? do |e|
|
|
@@ -12,15 +12,18 @@ module RubyReactor
|
|
|
12
12
|
# until the queues are empty. Recursive — handles jobs that re-enqueue
|
|
13
13
|
# themselves (e.g. ordered_lock snoozes) and worker chains that queue
|
|
14
14
|
# additional jobs.
|
|
15
|
+
# Whichever test backend is active (Sidekiq fake mode or the ActiveJob
|
|
16
|
+
# `:test` adapter), via `AsyncTestHelpers`.
|
|
15
17
|
def drain_async_jobs(max_iterations: 100)
|
|
16
|
-
|
|
18
|
+
AsyncTestHelpers.drain_async_jobs(max_iterations: max_iterations)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
# All currently-pending async jobs, wrapped in `PendingJob` so callers
|
|
20
22
|
# can perform individual jobs out-of-order (e.g. to assert
|
|
21
|
-
# ordered_lock's snooze behavior) without touching
|
|
23
|
+
# ordered_lock's snooze behavior) without touching backend internals.
|
|
24
|
+
# `worker_class` names the job's class on either backend.
|
|
22
25
|
def pending_async_jobs
|
|
23
|
-
|
|
26
|
+
AsyncTestHelpers.pending_async_jobs
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
PendingJob = Struct.new(:worker_class, :raw) do
|
|
@@ -39,6 +42,7 @@ module RubyReactor
|
|
|
39
42
|
RubyReactor::Adapters::Sidekiq::Worker,
|
|
40
43
|
RubyReactor::Adapters::Sidekiq::MapElementWorker,
|
|
41
44
|
RubyReactor::Adapters::Sidekiq::MapCollectorWorker,
|
|
45
|
+
RubyReactor::Adapters::Sidekiq::MapElementRollbackWorker,
|
|
42
46
|
RubyReactor::Adapters::Sidekiq::StepWorker
|
|
43
47
|
]
|
|
44
48
|
end
|