ruby_reactor 0.8.5 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) 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 +82 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +27 -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 +39 -9
  19. data/lib/ruby_reactor/executor/result_handler.rb +88 -52
  20. data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
  21. data/lib/ruby_reactor/executor/step_executor.rb +23 -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 +113 -24
  33. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  34. data/lib/ruby_reactor/rspec/matchers.rb +55 -37
  35. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  36. data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
  37. data/lib/ruby_reactor/rspec.rb +3 -6
  38. data/lib/ruby_reactor/step/compose_step.rb +13 -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/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  64. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  65. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  66. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  67. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  68. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  69. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  70. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  71. data/specs/future_improvements.md +9 -33
  72. metadata +29 -3
  73. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ # Rolls back ONE element of a map (009 R-07): the element's own undo
6
+ # stack, replayed under its liveness lock and saved after every undone
7
+ # entry, so a redelivery resumes after the last one (only the entry that
8
+ # was cut off may run again). The inline path calls `.call` in process; a
9
+ # fan-out map's `MapElementRollbackWorker` calls `.perform`.
10
+ class ElementRollback
11
+ extend Helpers
12
+
13
+ # The element context row is gone: its index went with it.
14
+ def self.unavailable(step_name)
15
+ { "index" => nil, "outcome" => "context_unavailable",
16
+ "failures" => ContextSerializer.serialize_value([entry(step_name, nil, :context_unavailable,
17
+ "context expired")]) }
18
+ end
19
+
20
+ # `{ "index", "outcome", "failures" }` (DM §5), with `failures`
21
+ # serialized. Outcome `contended`, never stored, means the element's lock
22
+ # was still held after `wait`.
23
+ def self.call(map_id:, element_context_id:, element_class:, step_name:, wait: Step::MapStep::ELEMENT_LOCK_WAIT)
24
+ storage_name = RubyReactor.reactor_storage_name(element_class)
25
+ data = element_context_id && RubyReactor.configuration.storage_adapter.retrieve_context(element_context_id,
26
+ storage_name)
27
+ return unavailable(step_name) unless data
28
+
29
+ element = Context.deserialize_from_retry(data)
30
+ index = Utils::FetchIndifferent.call(element.map_metadata || {}, :index).to_i
31
+ lock = acquire_element_lock(map_id, index, wait)
32
+ return outcome(index, "contended") if lock == :held
33
+
34
+ begin
35
+ roll_back(element, element_class, index, step_name)
36
+ ensure
37
+ lock.release if lock.respond_to?(:release)
38
+ end
39
+ end
40
+
41
+ # A completed element, or an aborted one (an inline run interrupted in
42
+ # it, which kept the undo entries of the steps it completed), undoes;
43
+ # a failed one already rolled itself back, and a halted or superseded
44
+ # running one did nothing to undo.
45
+ def self.roll_back(element, element_class, index, step_name)
46
+ return outcome(index, "not_needed") unless %w[completed aborted].include?(element.status.to_s)
47
+
48
+ executor = Executor.new(element.reactor_class || element_class, {}, element)
49
+ executor.undo_all { executor.checkpoint! }
50
+ executor.checkpoint!
51
+ tag = { map_step: step_name.to_sym, element_index: index }
52
+ failures = executor.compensation_manager.rollback_failures.map { |failure| failure.merge(tag) }
53
+ outcome(index, failures.empty? ? "undone" : "failed", failures)
54
+ end
55
+
56
+ # The job's body. A contended lock requeues the job, storing nothing,
57
+ # up to `lock_snooze_max_attempts` times before reporting the element
58
+ # in flight: two contexts of one index in the same batch then serialize
59
+ # instead of one reporting a false failure (R-07).
60
+ def self.perform(arguments)
61
+ args = Helpers.normalize_arguments(arguments)
62
+ map_id = args[:map_id]
63
+ map_class = args[:parent_reactor_class_name]
64
+ storage = RubyReactor.configuration.storage_adapter
65
+ return if storage.map_rollback_outcome_stored?(map_id, map_class, args[:position])
66
+
67
+ attempt = args[:attempt].to_i
68
+ result = call(map_id: map_id, element_context_id: args[:element_context_id],
69
+ element_class: resolve_reactor_class(args[:reactor_class_info]), step_name: args[:step_name],
70
+ wait: attempt.zero? ? Step::MapStep::ELEMENT_LOCK_WAIT : 0)
71
+ if result["outcome"] == "contended"
72
+ return requeue(args, attempt) unless snooze_exhausted?(attempt)
73
+
74
+ result = in_flight(result["index"], args[:step_name])
75
+ end
76
+ return unless storage.store_map_rollback_outcome(map_id, map_class, args[:position], result)
77
+
78
+ log_outcome(args, result)
79
+ Dispatcher.dispatch_rollback_batch(map_id: map_id, parent_reactor_class_name: map_class) if batch_end?(args)
80
+ signal_owner_if_settled(args)
81
+ end
82
+
83
+ def self.in_flight(index, step_name)
84
+ outcome(index, "element_in_flight",
85
+ [entry(step_name, index, :element_in_flight, "was still running at rollback time")])
86
+ end
87
+
88
+ def self.snooze_exhausted?(attempt)
89
+ max = RubyReactor.configuration.lock_snooze_max_attempts
90
+ max != :infinity && attempt >= max
91
+ end
92
+
93
+ def self.requeue(args, attempt)
94
+ config = RubyReactor.configuration
95
+ config.async_router.perform_map_element_rollback_in(
96
+ Worker.snooze_delay(config, nil), **args.slice(*ROLLBACK_JOB_ARGS), attempt: attempt + 1
97
+ )
98
+ end
99
+
100
+ # The position that ends a throw claims the next one (R-02).
101
+ def self.batch_end?(args)
102
+ batch_size = args[:batch_size].to_i
103
+ batch_size.positive? && ((args[:position].to_i + 1) % batch_size).zero?
104
+ end
105
+
106
+ # The handshake's job side (R-05): only once the hand-off is saved, so
107
+ # the owner never resumes from a stale blob — and never in inline job
108
+ # mode, where the map settles before any hand-off exists.
109
+ def self.signal_owner_if_settled(args)
110
+ storage = RubyReactor.configuration.storage_adapter
111
+ map_id = args[:map_id]
112
+ map_class = args[:parent_reactor_class_name]
113
+ meta = storage.retrieve_map_rollback_metadata(map_id, map_class)
114
+ return unless meta && storage.count_map_rollback_outcomes(map_id, map_class) >= meta["total"].to_i
115
+ return unless storage.map_rollback_handed_off?(map_id, map_class)
116
+ return unless storage.claim_map_rollback_signal(map_id, map_class)
117
+
118
+ RubyReactor.configuration.async_router.perform_async(args[:owner_context_id], args[:owner_reactor_class_name])
119
+ end
120
+
121
+ def self.log_outcome(args, result)
122
+ Map.log("ruby_reactor.map.rollback.element",
123
+ reactor: args[:owner_reactor_class_name], context_id: args[:owner_context_id],
124
+ map_step: args[:step_name], index: result["index"], outcome: result["outcome"],
125
+ failures: Array(result["failures"]).size)
126
+ end
127
+
128
+ def self.outcome(index, outcome, failures = [])
129
+ { "index" => index, "outcome" => outcome, "failures" => ContextSerializer.serialize_value(failures) }
130
+ end
131
+
132
+ # A rollback failure entry for the element as a whole (008 shape).
133
+ def self.entry(step_name, index, reason, message)
134
+ step = step_name.to_sym
135
+ { step: step, kind: :undo, key: nil, reason: reason, map_step: step, element_index: index,
136
+ message: "map element #{index} #{message}" }
137
+ end
138
+
139
+ def self.acquire_element_lock(map_id, index, wait)
140
+ return nil if ElementExecutor.inline_testing_mode?
141
+
142
+ lock = RubyReactor::Lock.new("map_element:#{map_id}:#{index}", owner: SecureRandom.uuid,
143
+ ttl: RubyReactor.configuration.context_lock_ttl,
144
+ wait: wait)
145
+ lock.acquire
146
+ lock
147
+ rescue RubyReactor::Lock::AcquisitionError
148
+ :held
149
+ end
150
+
151
+ ROLLBACK_JOB_ARGS = %i[map_id position element_context_id reactor_class_info parent_reactor_class_name step_name
152
+ batch_size owner_context_id owner_reactor_class_name].freeze
153
+
154
+ private_class_method :roll_back, :in_flight, :snooze_exhausted?, :requeue, :batch_end?,
155
+ :signal_owner_if_settled, :log_outcome, :acquire_element_lock
156
+ end
157
+ end
158
+ end
@@ -4,6 +4,16 @@ module RubyReactor
4
4
  module Map
5
5
  # Shared helper methods for Map executors
6
6
  module Helpers
7
+ # Job payloads enqueued before 009 carry `fail_fast` where they now carry
8
+ # `atomic` (R-11, FR-023): the one place the old key is read. Removed at
9
+ # the next MAJOR.
10
+ def self.normalize_arguments(arguments)
11
+ normalized = arguments.to_h.transform_keys(&:to_sym)
12
+ normalized[:atomic] = normalized[:fail_fast] if !normalized.key?(:atomic) && normalized.key?(:fail_fast)
13
+ normalized.delete(:fail_fast)
14
+ normalized
15
+ end
16
+
7
17
  # Resolves the reactor class from reactor_class_info
8
18
  def resolve_reactor_class(info)
9
19
  if info["type"] == "class"
@@ -20,6 +30,7 @@ module RubyReactor
20
30
  raise "Unknown reactor class info: #{info}"
21
31
  end
22
32
  end
33
+ module_function :resolve_reactor_class
23
34
 
24
35
  # Loads parent context from storage
25
36
  def load_parent_context_from_storage(parent_context_id, reactor_class_name, storage)
@@ -31,161 +42,6 @@ module RubyReactor
31
42
  def build_element_inputs(mappings, parent_context, element)
32
43
  RubyReactor::Step::MapStep.build_mapped_inputs(mappings, parent_context, element)
33
44
  end
34
-
35
- # Applies collect block to results
36
- def apply_collect_block(results, step_config)
37
- collect_block = step_config.arguments[:collect_block][:source].value
38
-
39
- if collect_block
40
- # Pass all results (Success and Failure) to collect block
41
- begin
42
- collected = collect_block.call(results)
43
- RubyReactor::Success(collected)
44
- rescue RubyReactor::Error::Rescuable => e
45
- RubyReactor::Failure(e)
46
- end
47
- else
48
- # Default behavior: fail if any failure
49
- first_failure = results.find(&:failure?)
50
- first_failure || RubyReactor::Success(results.map(&:value))
51
- end
52
- end
53
-
54
- # Resumes parent reactor execution after map completion
55
- def resume_parent_execution(parent_context, step_name, final_result, storage) # rubocop:disable Metrics/MethodLength
56
- executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
57
- step_name_sym = step_name.to_sym
58
-
59
- if final_result.failure?
60
- parent_context.current_step = step_name_sym
61
-
62
- error = RubyReactor::Error::StepFailureError.new(
63
- final_result.error,
64
- step: step_name_sym,
65
- context: parent_context,
66
- original_error: final_result.error.is_a?(Exception) ? final_result.error : nil,
67
- exception_class: final_result.respond_to?(:exception_class) ? final_result.exception_class : nil
68
- )
69
-
70
- # Pass backtrace if available
71
- if final_result.respond_to?(:backtrace) && final_result.backtrace
72
- error.set_backtrace(final_result.backtrace)
73
- elsif final_result.error.respond_to?(:backtrace)
74
- error.set_backtrace(final_result.error.backtrace)
75
- end
76
-
77
- # Bracket the rollback with reactor lifecycle events so that
78
- # compensation/undo spans nest under a reactor span (and stay attached
79
- # to the originating trace), mirroring the success path's
80
- # resume_execution. Without this, rollback runs in the collector worker
81
- # with no active reactor span and the undo/compensation spans orphan.
82
- executor.middlewares.on(:start_reactor, parent_context.reactor_class.name, parent_context.inputs,
83
- parent_context)
84
- failure_response = nil
85
- begin
86
- # The map failed in its element jobs, so no executor compensated it.
87
- # Fail it exactly as the inline path does: adopt the failing
88
- # element's rollback failures, compensate the map (its completed
89
- # elements; every index has settled, R-04), undo the completed
90
- # steps, and fail with `CompensationError` if the map's rollback
91
- # was incomplete.
92
- manager = executor.compensation_manager
93
- manager.rollback_failures.concat(final_result.rollback_failures)
94
- failure_response = begin
95
- manager.handle_step_failure(parent_context.reactor_class.steps[step_name_sym], final_result.error, {})
96
- executor.result_handler.handle_execution_error(error)
97
- rescue RubyReactor::Error::CompensationError => e
98
- executor.result_handler.handle_execution_error(e)
99
- end
100
- # Manually update context status since we're not running executor loop
101
- executor.send(:update_context_status, failure_response)
102
- ensure
103
- executor.middlewares.on(:failed_reactor, parent_context.reactor_class.name, failure_response,
104
- parent_context)
105
- end
106
- # This branch runs no executor loop, so nothing else persists the
107
- # failed status: store it here.
108
- store_parent(parent_context, storage)
109
- else
110
- parent_context.set_result(step_name_sym, final_result.value)
111
- # A completed fan-out map is tracked for undo like any step (R-03).
112
- # The record stays empty: `MapStep#undo` finds its elements through
113
- # the map's element index, so the parent does not grow per element.
114
- parent_context.undo_stack << { step: parent_context.reactor_class.steps[step_name_sym], arguments: {},
115
- result: RubyReactor.Success(nil) }
116
- record_elements_started(parent_context, step_name, storage)
117
-
118
- # Manually update execution trace to reflect completion
119
- # This is necessary because resume_execution continues from the NEXT step
120
- # and the async step (which returned DispatchResult) needs to be marked as done with actual value
121
- parent_context.append_execution_trace(
122
- {
123
- type: :result,
124
- step: step_name_sym,
125
- timestamp: Time.now,
126
- value: final_result.value,
127
- status: :success
128
- }
129
- )
130
-
131
- parent_context.current_step = nil
132
-
133
- # Mark the context as a worker before resuming: resume_execution runs
134
- # the collector's own worker (this call), not the reactor's normal
135
- # caller. Without this, StepExecutor#handoff_at? (gated on
136
- # `inline_async_execution`) re-evaluates any later `background`
137
- # before:/after: cut point and enqueues a second hand-off, or runs an
138
- # `after:` target here in the collector instead of the original
139
- # dispatching worker.
140
- parent_context.inline_async_execution = true
141
- resume_parked_aware(executor, parent_context)
142
- end
143
- end
144
-
145
- # `resume_execution` persists the parent itself — under the parent's
146
- # context lock, and deliberately NOT when it lost that lock to a live
147
- # duplicate or replayed an already-terminal run. Storing again here, after
148
- # the lock is released, would overwrite whatever the lock's holder wrote
149
- # (single writer), so the resume's own save is the only one.
150
- #
151
- # This collector is a worker running the parent's execution, so it is
152
- # also a final handler for park signals (005 R-01), like `Worker` and
153
- # `ElementExecutor`: the resume has already parked the parent's holds and
154
- # saved; hand the rest back to the parent's own worker.
155
- #
156
- # The parent is loaded from its own blob, so it has no `root_context`:
157
- # a map inside a composed child resumes and requeues that child as its
158
- # own execution, never its root. ponytail: the root is never resumed
159
- # after such a map (already so on main); see "Fan-out map inside a
160
- # composed child" in specs/future_improvements.md.
161
- def resume_parked_aware(executor, parent_context)
162
- executor.resume_execution
163
- rescue RubyReactor::Error::ExecutionParked => e
164
- config = RubyReactor.configuration
165
- config.async_router.perform_in(
166
- RubyReactor::Worker.snooze_delay(config, e), parent_context.context_id,
167
- RubyReactor.reactor_storage_name(parent_context.reactor_class)
168
- )
169
- end
170
-
171
- # Every index of a completed map ran: record the count on the map's
172
- # reference in the parent, which outlives the element index, so a late
173
- # rollback names each element whose context expired.
174
- def record_elements_started(parent_context, step_name, storage)
175
- ref = Utils::FetchIndifferent.call(parent_context.composed_contexts, step_name)
176
- return unless ref
177
-
178
- map_id = Utils::FetchIndifferent.call(ref, :map_id)
179
- ref[:started] = storage.retrieve_map_metadata(map_id, parent_context.reactor_class.name)&.fetch("count", 0).to_i
180
- end
181
-
182
- def store_parent(parent_context, storage)
183
- storage.store_context(
184
- parent_context.context_id,
185
- ContextSerializer.serialize(parent_context),
186
- RubyReactor.reactor_storage_name(parent_context.reactor_class)
187
- )
188
- end
189
45
  end
190
46
  end
191
47
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ # How a map step rolls back (009 R-01): a fan-out map distributed, one job
6
+ # per started element (S-2); an inline map in process, in bounded reads
7
+ # (S-4). Both report the same failures (I-7). Mixed into `Step::MapStep`.
8
+ module StepRollback
9
+ private
10
+
11
+ # S-2: start the map's rollback once (its records, then the first
12
+ # throw), then either aggregate a settled rollback or hand the run off
13
+ # until the element jobs report (R-04, R-05). Re-entered by the owner's
14
+ # resume, it finds the records and only checks for the settle.
15
+ def distributed_rollback(map_id, step_name)
16
+ storage = RubyReactor.configuration.storage_adapter
17
+ map_class = context.reactor_class.name
18
+ created, rollback = storage.start_map_rollback(map_id, map_class, **rollback_metadata(map_id, step_name))
19
+ if created
20
+ log_rollback("started", step_name, total: rollback["total"], batch_size: rollback["batch_size"])
21
+ RubyReactor::Map::Dispatcher.dispatch_rollback_batch(map_id: map_id, parent_reactor_class_name: map_class)
22
+ end
23
+ total = rollback["total"].to_i
24
+ if storage.count_map_rollback_outcomes(map_id, map_class) >= total
25
+ return finish_rollback(step_name, total, distributed_failures(map_id, step_name))
26
+ end
27
+
28
+ raise RubyReactor::Error::RollbackHandedOff.new(map_id: map_id, reactor_class_name: map_class)
29
+ end
30
+
31
+ def rollback_metadata(map_id, step_name)
32
+ storage = RubyReactor.configuration.storage_adapter
33
+ map_class = context.reactor_class.name
34
+ forward = storage.retrieve_map_metadata(map_id, map_class) || {}
35
+ {
36
+ total: storage.count_map_element_context_ids(map_id, map_class),
37
+ batch_size: forward["batch_size"] || RubyReactor::Map::DEFAULT_BATCH_SIZE,
38
+ step_name: step_name.to_s,
39
+ owner_context_id: owner_context.context_id,
40
+ owner_reactor_class_name: RubyReactor.reactor_storage_name(owner_context.reactor_class),
41
+ reactor_class_info: forward["reactor_class_info"] || build_reactor_class_info(element_class, step_name)
42
+ }
43
+ end
44
+
45
+ # The failures the element jobs reported, in position order (newest
46
+ # first, as inline), read in bounded chunks (R-09).
47
+ def distributed_failures(map_id, step_name)
48
+ storage = RubyReactor.configuration.storage_adapter
49
+ map_class = context.reactor_class.name
50
+ reported = []
51
+ unnamed = 0
52
+ storage.each_map_rollback_outcome(map_id, map_class) do |position, outcome|
53
+ unnamed += 1 if outcome["outcome"] == "context_unavailable" && outcome["index"].nil?
54
+ next unless %w[failed element_in_flight].include?(outcome["outcome"])
55
+
56
+ reported << [position, ContextSerializer.deserialize_value(outcome["failures"])]
57
+ end
58
+ unseen = lambda do |indexes|
59
+ indexes.each_slice(1000).flat_map do |slice|
60
+ slice.zip(storage.map_rollback_indexes_seen(map_id, map_class, slice)).reject(&:last).map(&:first)
61
+ end
62
+ end
63
+ unavailable_entries(step_name, unnamed, unseen) + reported.sort_by(&:first).flat_map(&:last)
64
+ end
65
+
66
+ # S-4: in process, `Map::ROLLBACK_CHUNK` elements per read from the tail
67
+ # of the element index, never all at once.
68
+ def inline_rollback(map_id, step_name)
69
+ storage = RubyReactor.configuration.storage_adapter
70
+ map_class = context.reactor_class.name
71
+ total = storage.count_map_element_context_ids(map_id, map_class)
72
+ reported = []
73
+ seen = Set.new
74
+ unnamed = 0
75
+ log_rollback("started", step_name, total: total, batch_size: RubyReactor::Map::ROLLBACK_CHUNK)
76
+ (0...total).step(RubyReactor::Map::ROLLBACK_CHUNK) do |position|
77
+ ids = storage.retrieve_map_element_context_ids_from_tail(map_id, map_class, position,
78
+ RubyReactor::Map::ROLLBACK_CHUNK, total: total)
79
+ ids.each do |id|
80
+ outcome = inline_element_rollback(map_id, id, step_name)
81
+ outcome["index"] ? seen << outcome["index"] : unnamed += 1
82
+ reported.concat(ContextSerializer.deserialize_value(outcome["failures"])) if outcome["index"]
83
+ end
84
+ end
85
+ unseen = ->(indexes) { indexes.reject { |index| seen.include?(index) } }
86
+ finish_rollback(step_name, total, unavailable_entries(step_name, unnamed, unseen) + reported)
87
+ end
88
+
89
+ # Today's inline behavior: one wait for the element's lock, then report
90
+ # it in flight.
91
+ def inline_element_rollback(map_id, element_context_id, step_name)
92
+ outcome = RubyReactor::Map::ElementRollback.call(map_id: map_id, element_context_id: element_context_id,
93
+ element_class: element_class, step_name: step_name)
94
+ if outcome["outcome"] == "contended"
95
+ outcome = { "index" => outcome["index"], "outcome" => "element_in_flight",
96
+ "failures" => ContextSerializer.serialize_value(
97
+ [rollback_entry(outcome["index"], :element_in_flight, "was still running at rollback time")]
98
+ ) }
99
+ end
100
+ log_rollback("element", step_name, index: outcome["index"], outcome: outcome["outcome"],
101
+ failures: outcome["failures"].size)
102
+ outcome
103
+ end
104
+
105
+ # Each element whose context expired is reported once (R-09, I2). The
106
+ # parent's map reference counts the elements that started: set per
107
+ # element inline, and to the total when a fan-out map completes. With
108
+ # it, every started index no rollback saw is named, even when the index
109
+ # list itself expired; without it (a failed fan-out map skipped some
110
+ # indices), one unnamed entry per element whose row is gone.
111
+ def unavailable_entries(step_name, unnamed, unseen)
112
+ ref = Utils::FetchIndifferent.call(context.composed_contexts, step_name)
113
+ started = ref && Utils::FetchIndifferent.call(ref, :started)
114
+ missing = started ? unseen.call((0...started.to_i).to_a) : [nil] * unnamed
115
+ missing.map { |index| rollback_entry(index, :context_unavailable, "context expired") }
116
+ end
117
+
118
+ def finish_rollback(step_name, total, failures)
119
+ log_rollback("completed", step_name, total: total, failed: failures.size)
120
+ return RubyReactor.Success() if failures.empty?
121
+
122
+ RubyReactor.Failure("map :#{step_name} rollback incomplete", rollback_failures: failures)
123
+ end
124
+
125
+ def log_rollback(event, step_name, **fields)
126
+ RubyReactor::Map.log("ruby_reactor.map.rollback.#{event}",
127
+ reactor: RubyReactor.reactor_storage_name(owner_context.reactor_class),
128
+ context_id: owner_context.context_id, map_step: step_name.to_s, **fields)
129
+ end
130
+
131
+ def rollback_entry(index, reason, message)
132
+ RubyReactor::Map::ElementRollback.entry(context.current_step, index, reason, message)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -16,6 +16,12 @@ module RubyReactor
16
16
  # re-triggered (M2) — gated so it never fires while a collector or the
17
17
  # parent is alive, or after the parent already collected.
18
18
  #
19
+ # For each distributed map rollback (009 S-5), the same signal on the
20
+ # rollback's own records: a claimed position with no outcome and no live
21
+ # element lock lost its job and is re-dispatched; with every claimed
22
+ # position settled but positions left, the throw's trigger was lost and
23
+ # the next throw is claimed. A lost owner resume is `RubyReactor::Sweeper`'s.
24
+ #
19
25
  # `run_once` is pure and idempotent; the host wires the cadence (same contract
20
26
  # as RubyReactor::Sweeper).
21
27
  class Sweeper
@@ -29,10 +35,11 @@ module RubyReactor
29
35
  @logger = logger || RubyReactor.configuration.logger
30
36
  end
31
37
 
32
- # Returns { redispatched:, recollected: } counts.
38
+ # Returns { redispatched:, recollected:, rollback_redispatched: } counts.
33
39
  def run_once(limit: 1000)
34
40
  redispatched = 0
35
41
  recollected = 0
42
+ rollback_redispatched = 0
36
43
 
37
44
  @storage.scan_maps(count: limit).each do |meta|
38
45
  missing = missing_indices(meta)
@@ -46,11 +53,53 @@ module RubyReactor
46
53
  @logger.warn("RubyReactor::Map::Sweeper failed on map #{meta["map_id"]}: #{e.class}: #{e.message}")
47
54
  end
48
55
 
49
- { 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