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.
Files changed (104) 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 +254 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +66 -15
  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/async_macros.rb +30 -1
  14. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  15. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  16. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  18. data/lib/ruby_reactor/dsl/map_builder.rb +27 -9
  19. data/lib/ruby_reactor/dsl/step_builder.rb +93 -34
  20. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  21. data/lib/ruby_reactor/error/rescuable.rb +33 -0
  22. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +63 -35
  24. data/lib/ruby_reactor/executor/result_handler.rb +85 -63
  25. data/lib/ruby_reactor/executor/retry_manager.rb +4 -3
  26. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  27. data/lib/ruby_reactor/executor/step_executor.rb +61 -49
  28. data/lib/ruby_reactor/executor.rb +221 -7
  29. data/lib/ruby_reactor/map/collector.rb +31 -89
  30. data/lib/ruby_reactor/map/dispatcher.rb +65 -8
  31. data/lib/ruby_reactor/map/element_executor.rb +21 -11
  32. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  33. data/lib/ruby_reactor/map/helpers.rb +11 -125
  34. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  35. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  36. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  37. data/lib/ruby_reactor/map.rb +17 -0
  38. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  39. data/lib/ruby_reactor/reactor.rb +65 -2
  40. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  41. data/lib/ruby_reactor/rspec/matchers.rb +59 -24
  42. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  43. data/lib/ruby_reactor/rspec/test_subject.rb +13 -3
  44. data/lib/ruby_reactor/rspec.rb +3 -6
  45. data/lib/ruby_reactor/step/compose_step.rb +10 -1
  46. data/lib/ruby_reactor/step/map_step.rb +144 -36
  47. data/lib/ruby_reactor/step.rb +7 -0
  48. data/lib/ruby_reactor/step_worker.rb +46 -22
  49. data/lib/ruby_reactor/storage/adapter.rb +18 -0
  50. data/lib/ruby_reactor/storage/redis_adapter.rb +27 -2
  51. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  52. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  53. data/lib/ruby_reactor/sweeper.rb +3 -1
  54. data/lib/ruby_reactor/version.rb +1 -1
  55. data/lib/ruby_reactor/web/api.rb +11 -1
  56. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-C6ejszCQ.js} +12 -12
  57. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  58. data/lib/ruby_reactor/web/public/index.html +2 -2
  59. data/lib/ruby_reactor/worker.rb +7 -3
  60. data/lib/ruby_reactor.rb +17 -6
  61. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  62. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  63. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  64. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  65. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  66. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  67. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  68. data/specs/007-execution-flow-analysis/evidence/harness.rb +231 -0
  69. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  72. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +235 -0
  73. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  74. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  75. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  76. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  77. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  78. data/specs/007-execution-flow-analysis/plan.md +127 -0
  79. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  80. data/specs/007-execution-flow-analysis/research.md +202 -0
  81. data/specs/007-execution-flow-analysis/spec.md +270 -0
  82. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  83. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  84. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  85. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  86. data/specs/008-rollback-reliability/data-model.md +139 -0
  87. data/specs/008-rollback-reliability/plan.md +233 -0
  88. data/specs/008-rollback-reliability/quickstart.md +105 -0
  89. data/specs/008-rollback-reliability/research.md +653 -0
  90. data/specs/008-rollback-reliability/spec.md +561 -0
  91. data/specs/008-rollback-reliability/tasks.md +1110 -0
  92. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  93. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  94. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  95. data/specs/009-distributed-map-undo/data-model.md +151 -0
  96. data/specs/009-distributed-map-undo/plan.md +323 -0
  97. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  98. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  99. data/specs/009-distributed-map-undo/research.md +585 -0
  100. data/specs/009-distributed-map-undo/spec.md +360 -0
  101. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  102. data/specs/future_improvements.md +64 -28
  103. metadata +54 -3
  104. 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
 
@@ -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
- 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
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[: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