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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +58 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +23 -8
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
  15. data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
  16. data/lib/ruby_reactor/error/rescuable.rb +6 -1
  17. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +33 -9
  19. data/lib/ruby_reactor/executor/result_handler.rb +72 -52
  20. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  21. data/lib/ruby_reactor/executor/step_executor.rb +3 -1
  22. data/lib/ruby_reactor/executor.rb +186 -6
  23. data/lib/ruby_reactor/map/collector.rb +25 -93
  24. data/lib/ruby_reactor/map/dispatcher.rb +38 -7
  25. data/lib/ruby_reactor/map/element_executor.rb +14 -10
  26. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  27. data/lib/ruby_reactor/map/helpers.rb +11 -155
  28. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  29. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  30. data/lib/ruby_reactor/map.rb +17 -0
  31. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  32. data/lib/ruby_reactor/reactor.rb +41 -2
  33. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  34. data/lib/ruby_reactor/rspec/matchers.rb +40 -21
  35. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  36. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  37. data/lib/ruby_reactor/rspec.rb +3 -6
  38. data/lib/ruby_reactor/step/compose_step.rb +3 -0
  39. data/lib/ruby_reactor/step/map_step.rb +117 -114
  40. data/lib/ruby_reactor/storage/adapter.rb +14 -0
  41. data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
  42. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  43. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  44. data/lib/ruby_reactor/sweeper.rb +3 -1
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +11 -1
  47. data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
  48. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  49. data/lib/ruby_reactor/web/public/index.html +2 -2
  50. data/lib/ruby_reactor/worker.rb +4 -2
  51. data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
  53. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  54. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  55. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  56. data/specs/009-distributed-map-undo/data-model.md +151 -0
  57. data/specs/009-distributed-map-undo/plan.md +323 -0
  58. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  59. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  60. data/specs/009-distributed-map-undo/research.md +585 -0
  61. data/specs/009-distributed-map-undo/spec.md +360 -0
  62. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  63. data/specs/future_improvements.md +18 -30
  64. metadata +21 -3
  65. 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
- { redispatched: redispatched, recollected: recollected }
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 false unless data
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
- results = data["intermediate_results"] || {}
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
@@ -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
- executor.undo_all
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[:args]).perform_now
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[:args]).perform_now
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
- ::RSpec::Matchers.define :be_success do
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
- ::RSpec::Matchers.define :be_failure do
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
- ::RSpec::Matchers.define :have_run_step do |step_name|
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
- ::RSpec::Matchers.define :have_retried_step do |step_name|
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
- ::RSpec::Matchers.define :have_validation_error do |field|
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
- ::RSpec::Matchers.define :be_paused do
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
- ::RSpec::Matchers.define :be_paused_at do |*step_names|
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
- ::RSpec::Matchers.define :have_ready_interrupts do |*expected_steps|
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
- ::RSpec::Matchers.define :be_halted do
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
- ::RSpec::Matchers.define :be_skipped do
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
- ::RSpec::Matchers.define :be_locked do
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
- ::RSpec::Matchers.define :have_available_tokens do |expected|
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
- ::RSpec::Matchers.define :have_held_tokens do |expected|
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
- ::RSpec::Matchers.define :have_rate_limit_count do |expected|
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
- ::RSpec::Matchers.define :be_period_marked do
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
- ::RSpec::Matchers.define :have_ordered_lock_next do |expected|
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
- ::RSpec::Matchers.define :have_ordered_lock_last_completed do |expected|
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
- ::RSpec::Matchers.define :have_ordered_lock_in_flight do |*expected|
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
- ::RSpec::Matchers.define :be_ordered_lock_drained do
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
- ::RSpec::Matchers.define :have_contended_at do |step_name|
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
- ::RSpec::Matchers.define :have_rollback_failure do |step_name|
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
- SidekiqHelpers.drain_async_jobs(max_iterations: max_iterations)
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 Sidekiq internals.
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
- SidekiqHelpers.pending_async_jobs
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