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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +17 -14
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +1 -1
- data/README.md +27 -8
- data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
- data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
- data/lib/ruby_reactor/context.rb +14 -1
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
- data/lib/ruby_reactor/error/rescuable.rb +6 -1
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
- data/lib/ruby_reactor/executor/result_handler.rb +88 -52
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
- data/lib/ruby_reactor/executor/step_executor.rb +23 -1
- data/lib/ruby_reactor/executor.rb +186 -6
- data/lib/ruby_reactor/map/collector.rb +25 -93
- data/lib/ruby_reactor/map/dispatcher.rb +38 -7
- data/lib/ruby_reactor/map/element_executor.rb +14 -10
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -155
- data/lib/ruby_reactor/map/step_rollback.rb +136 -0
- data/lib/ruby_reactor/map/sweeper.rb +66 -8
- data/lib/ruby_reactor/map.rb +17 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
- data/lib/ruby_reactor/reactor.rb +113 -24
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +55 -37
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +13 -0
- data/lib/ruby_reactor/step/map_step.rb +117 -114
- data/lib/ruby_reactor/storage/adapter.rb +14 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
- data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/sweeper.rb +3 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +11 -1
- data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
- data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +4 -2
- data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
- data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
- data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
- data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
- data/specs/009-distributed-map-undo/data-model.md +151 -0
- data/specs/009-distributed-map-undo/plan.md +323 -0
- data/specs/009-distributed-map-undo/pr-description.md +78 -0
- data/specs/009-distributed-map-undo/quickstart.md +154 -0
- data/specs/009-distributed-map-undo/research.md +585 -0
- data/specs/009-distributed-map-undo/spec.md +360 -0
- data/specs/009-distributed-map-undo/tasks.md +1060 -0
- data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
- data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
- data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
- data/specs/010-interrupt-in-composed-child/plan.md +149 -0
- data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
- data/specs/010-interrupt-in-composed-child/research.md +207 -0
- data/specs/010-interrupt-in-composed-child/spec.md +267 -0
- data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
- data/specs/future_improvements.md +9 -33
- metadata +29 -3
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
|
@@ -135,6 +135,10 @@ module RubyReactor
|
|
|
135
135
|
handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
|
|
136
136
|
completed = true
|
|
137
137
|
@result
|
|
138
|
+
rescue Error::RollbackHandedOff => e
|
|
139
|
+
record_rollback_handoff(e)
|
|
140
|
+
completed = true
|
|
141
|
+
@result
|
|
138
142
|
rescue RubyReactor::Lock::AcquisitionError,
|
|
139
143
|
RubyReactor::Semaphore::AcquisitionError,
|
|
140
144
|
RubyReactor::RateLimit::ExceededError,
|
|
@@ -153,7 +157,7 @@ module RubyReactor
|
|
|
153
157
|
@contention_snooze = true
|
|
154
158
|
raise
|
|
155
159
|
rescue Error::Rescuable => e
|
|
156
|
-
@result = aborting_on_interruption { @result_handler.handle_execution_error(e) }
|
|
160
|
+
@result = handing_off { aborting_on_interruption { @result_handler.handle_execution_error(e) } }
|
|
157
161
|
update_context_status(@result)
|
|
158
162
|
completed = true
|
|
159
163
|
@result
|
|
@@ -184,6 +188,9 @@ module RubyReactor
|
|
|
184
188
|
end
|
|
185
189
|
|
|
186
190
|
def resume_execution # rubocop:disable Metrics/MethodLength,Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
|
|
191
|
+
# A composed child re-entered while its own rollback is handed off.
|
|
192
|
+
return resume_rollback if @context.rolling_back?
|
|
193
|
+
|
|
187
194
|
middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
|
|
188
195
|
completed = false
|
|
189
196
|
|
|
@@ -256,6 +263,10 @@ module RubyReactor
|
|
|
256
263
|
handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
|
|
257
264
|
completed = true
|
|
258
265
|
@result
|
|
266
|
+
rescue Error::RollbackHandedOff => e
|
|
267
|
+
record_rollback_handoff(e)
|
|
268
|
+
completed = true
|
|
269
|
+
@result
|
|
259
270
|
rescue RubyReactor::Lock::AcquisitionError,
|
|
260
271
|
RubyReactor::Semaphore::AcquisitionError,
|
|
261
272
|
RubyReactor::RateLimit::ExceededError,
|
|
@@ -274,7 +285,7 @@ module RubyReactor
|
|
|
274
285
|
@contention_snooze = true
|
|
275
286
|
raise e
|
|
276
287
|
rescue Error::Rescuable => e
|
|
277
|
-
aborting_on_interruption { handle_resume_error(e) }
|
|
288
|
+
handing_off { aborting_on_interruption { handle_resume_error(e) } }
|
|
278
289
|
update_context_status(@result)
|
|
279
290
|
completed = true
|
|
280
291
|
@result
|
|
@@ -283,16 +294,100 @@ module RubyReactor
|
|
|
283
294
|
raise
|
|
284
295
|
ensure
|
|
285
296
|
release_locks unless @parked
|
|
297
|
+
# Saved while the context lock is still held (009 R-13): released first,
|
|
298
|
+
# a worker resuming this run could load the pre-save blob, and this save
|
|
299
|
+
# would then overwrite its progress.
|
|
300
|
+
save_context unless skip_context_persist?
|
|
286
301
|
@acquired_context_lock&.release
|
|
287
302
|
@acquired_context_lock = nil
|
|
288
303
|
leave_ordered_lock_scope
|
|
304
|
+
|
|
305
|
+
emit_lifecycle_completion(completed)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Undoes every completed step (manual undo, a composed child's undo, a
|
|
309
|
+
# map element's rollback). A rollback that hands off here (009 R-04)
|
|
310
|
+
# leaves this level's rollback failures on its context, and the next
|
|
311
|
+
# `undo_all` of the same context picks them up, so none is lost though
|
|
312
|
+
# the executor is rebuilt on resume (G2).
|
|
313
|
+
def undo_all(&after_pop)
|
|
314
|
+
saved = @context.rollback && @context.rollback["failures"]
|
|
315
|
+
@compensation_manager.restore_rollback_failures(saved) if saved
|
|
316
|
+
@compensation_manager.rollback_completed_steps(&after_pop)
|
|
317
|
+
clear_saved_rollback_failures
|
|
318
|
+
rescue Error::RollbackHandedOff
|
|
319
|
+
(@context.rollback ||= {})["failures"] =
|
|
320
|
+
ContextSerializer.serialize_value(@compensation_manager.rollback_failures)
|
|
321
|
+
raise
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Finishes a rollback that handed off at a fan-out map (009 R-04), once
|
|
325
|
+
# the map's element rollbacks have all reported: the Worker of a
|
|
326
|
+
# `rolling_back` run, or a composed child re-entered by its root. Under
|
|
327
|
+
# the run's context lock, as `resume_execution`.
|
|
328
|
+
def resume_rollback
|
|
329
|
+
state = @context.rollback || {}
|
|
330
|
+
# The hand-off came out of a running step (a composed child rolling
|
|
331
|
+
# back inside `ComposeStep#run`): resume forward, and that step
|
|
332
|
+
# finishes its child's rollback.
|
|
333
|
+
if state["trigger"] == "step" || state.empty?
|
|
334
|
+
@context.rollback = nil
|
|
335
|
+
@context.status = :running
|
|
336
|
+
return resume_execution
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
middlewares.on(:start_reactor, reactor_class.name, context.inputs, @context)
|
|
340
|
+
completed = false
|
|
341
|
+
enter_ordered_lock_scope
|
|
342
|
+
acquire_context_lock
|
|
343
|
+
@result = finish_rollback(state)
|
|
344
|
+
@context.rollback = nil
|
|
345
|
+
update_context_status(@result)
|
|
346
|
+
completed = true
|
|
347
|
+
@result
|
|
348
|
+
rescue Error::RollbackHandedOff => e
|
|
349
|
+
record_rollback_handoff(e)
|
|
350
|
+
completed = true
|
|
351
|
+
@result
|
|
352
|
+
rescue RubyReactor::Lock::AcquisitionError => e
|
|
353
|
+
@contention_snooze = true
|
|
354
|
+
raise composed_contention_park(e) || e
|
|
355
|
+
rescue Error::Rescuable => e
|
|
356
|
+
handing_off { aborting_on_interruption { handle_resume_error(e) } }
|
|
357
|
+
update_context_status(@result)
|
|
358
|
+
completed = true
|
|
359
|
+
@result
|
|
360
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
361
|
+
mark_aborted
|
|
362
|
+
raise
|
|
363
|
+
ensure
|
|
289
364
|
save_context unless skip_context_persist?
|
|
365
|
+
@acquired_context_lock&.release
|
|
366
|
+
@acquired_context_lock = nil
|
|
367
|
+
leave_ordered_lock_scope
|
|
290
368
|
|
|
291
369
|
emit_lifecycle_completion(completed)
|
|
292
370
|
end
|
|
293
371
|
|
|
294
|
-
|
|
295
|
-
|
|
372
|
+
# The rollback's other half (009 R-05): marks the hand-off saved, then
|
|
373
|
+
# enqueues the owner's Worker if the map rollback already settled and this
|
|
374
|
+
# side claims the signal. Whichever of this and the last element rollback
|
|
375
|
+
# job sees both second enqueues the owner, once. Called right after the
|
|
376
|
+
# `rolling_back` save, while the context lock is still held.
|
|
377
|
+
def hand_off_rollback!(handed_off)
|
|
378
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
379
|
+
map_id = handed_off.map_id
|
|
380
|
+
map_class = handed_off.reactor_class_name
|
|
381
|
+
storage.mark_map_rollback_handed_off(map_id, map_class)
|
|
382
|
+
RubyReactor.configuration.logger.info(
|
|
383
|
+
"event=ruby_reactor.rollback.handed_off context_id=#{@context.context_id.inspect} map_id=#{map_id.inspect}"
|
|
384
|
+
)
|
|
385
|
+
meta = storage.retrieve_map_rollback_metadata(map_id, map_class)
|
|
386
|
+
return unless meta && storage.count_map_rollback_outcomes(map_id, map_class) >= meta["total"].to_i
|
|
387
|
+
return unless storage.claim_map_rollback_signal(map_id, map_class)
|
|
388
|
+
|
|
389
|
+
RubyReactor.configuration.async_router.perform_async(@context.context_id,
|
|
390
|
+
RubyReactor.reactor_storage_name(@reactor_class))
|
|
296
391
|
end
|
|
297
392
|
|
|
298
393
|
# True once `resume_execution` is past its reactor-level gates (context
|
|
@@ -318,10 +413,18 @@ module RubyReactor
|
|
|
318
413
|
def aborting_on_interruption
|
|
319
414
|
yield
|
|
320
415
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
321
|
-
mark_aborted unless Error::Rescuable === e # rubocop:disable Style/CaseEquality
|
|
416
|
+
mark_aborted unless Error::Rescuable === e || e.is_a?(Error::RollbackHandedOff) # rubocop:disable Style/CaseEquality
|
|
322
417
|
raise
|
|
323
418
|
end
|
|
324
419
|
|
|
420
|
+
# A rollback run from a `rescue Error::Rescuable` body (the error's own
|
|
421
|
+
# rollback) is outside the sibling `rescue Error::RollbackHandedOff`.
|
|
422
|
+
def handing_off
|
|
423
|
+
yield
|
|
424
|
+
rescue Error::RollbackHandedOff => e
|
|
425
|
+
record_rollback_handoff(e)
|
|
426
|
+
end
|
|
427
|
+
|
|
325
428
|
def undo_stack
|
|
326
429
|
@compensation_manager.undo_stack
|
|
327
430
|
end
|
|
@@ -768,7 +871,8 @@ module RubyReactor
|
|
|
768
871
|
|
|
769
872
|
case result
|
|
770
873
|
when RubyReactor::DispatchResult
|
|
771
|
-
|
|
874
|
+
# A rollback hand-off returns one too, and stays `rolling_back`.
|
|
875
|
+
@context.status = :running unless @context.rolling_back?
|
|
772
876
|
when RubyReactor::Halt
|
|
773
877
|
@context.status = :halted
|
|
774
878
|
when RubyReactor::Success
|
|
@@ -833,6 +937,82 @@ module RubyReactor
|
|
|
833
937
|
@result
|
|
834
938
|
end
|
|
835
939
|
|
|
940
|
+
# Where this level's rollback stands when it hands off (009 DM §1). A
|
|
941
|
+
# composed child keeps it on its own context, inside the root's blob, and
|
|
942
|
+
# re-raises; the top-level run saves it as `rolling_back` while it still
|
|
943
|
+
# holds the context lock (I-6), runs the handshake, and returns a
|
|
944
|
+
# `DispatchResult`, as a forward hand-off does.
|
|
945
|
+
def record_rollback_handoff(handed_off)
|
|
946
|
+
failure = handed_off.failure
|
|
947
|
+
handed_off.failure = nil # this level's Failure; the level above builds its own
|
|
948
|
+
@context.rollback = rollback_state(failure)
|
|
949
|
+
@context.status = :rolling_back
|
|
950
|
+
raise handed_off if @context.root_context
|
|
951
|
+
|
|
952
|
+
save_context
|
|
953
|
+
hand_off_rollback!(handed_off)
|
|
954
|
+
@result = RubyReactor::DispatchResult.new(job_id: "map_rollback:#{handed_off.map_id}",
|
|
955
|
+
intermediate_results: @context.intermediate_results,
|
|
956
|
+
execution_id: @context.context_id)
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# `trigger`: `failure` (a step failure is rolling back), `undo` (set by
|
|
960
|
+
# `Reactor#undo`), or `step` (a running step's child handed off, and no
|
|
961
|
+
# rollback started at this level). A later hand-off of the same rollback
|
|
962
|
+
# keeps what an earlier one recorded.
|
|
963
|
+
def rollback_state(failure)
|
|
964
|
+
state = (@context.rollback || {}).dup
|
|
965
|
+
pending = @compensation_manager.pending
|
|
966
|
+
state["trigger"] ||= failure || pending ? "failure" : "step"
|
|
967
|
+
if pending
|
|
968
|
+
state["step"] = pending[:step].to_s
|
|
969
|
+
state["compensated"] = pending[:compensated]
|
|
970
|
+
state["compensation_error"] = pending[:compensation_error]
|
|
971
|
+
end
|
|
972
|
+
state["failure"] = ContextSerializer.serialize_value(failure) if failure
|
|
973
|
+
state["failures"] = ContextSerializer.serialize_value(@compensation_manager.rollback_failures)
|
|
974
|
+
state
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# The rest of a handed-off rollback: the failing step's own compensate if
|
|
978
|
+
# it had not finished (a map or compose adopts its settled state), the
|
|
979
|
+
# steps still on the undo stack, then the outcome the inline rollback
|
|
980
|
+
# gives (I-7).
|
|
981
|
+
def finish_rollback(state)
|
|
982
|
+
@compensation_manager.restore_rollback_failures(state["failures"])
|
|
983
|
+
return finish_undo if state["trigger"] == "undo"
|
|
984
|
+
|
|
985
|
+
failure = ContextSerializer.deserialize_value(state["failure"])
|
|
986
|
+
step_config = state["step"] && @reactor_class.steps[state["step"].to_sym]
|
|
987
|
+
if step_config && !state["compensated"]
|
|
988
|
+
@compensation_manager.handle_step_failure(step_config, failure.error, {})
|
|
989
|
+
else
|
|
990
|
+
@compensation_manager.rollback_completed_steps
|
|
991
|
+
if state["compensation_error"]
|
|
992
|
+
raise Error::CompensationError.new(state["compensation_error"], step: step_config&.name, context: @context)
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
@context.current_step = failure.step_name&.to_sym
|
|
996
|
+
@result_handler.handed_off_failure(failure)
|
|
997
|
+
rescue Error::CompensationError => e
|
|
998
|
+
@result_handler.handle_execution_error(e)
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
def finish_undo
|
|
1002
|
+
@compensation_manager.rollback_completed_steps
|
|
1003
|
+
@context.cancelled = true
|
|
1004
|
+
@context.cancellation_reason = "Undo triggered"
|
|
1005
|
+
@context.status = "cancelled"
|
|
1006
|
+
nil
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
def clear_saved_rollback_failures
|
|
1010
|
+
return unless @context.rollback
|
|
1011
|
+
|
|
1012
|
+
@context.rollback.delete("failures")
|
|
1013
|
+
@context.rollback = nil if @context.rollback.empty?
|
|
1014
|
+
end
|
|
1015
|
+
|
|
836
1016
|
def handle_interrupt(interrupt_result)
|
|
837
1017
|
save_context
|
|
838
1018
|
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module Map
|
|
5
5
|
class Collector
|
|
6
|
-
extend Helpers
|
|
7
|
-
|
|
8
6
|
# Seconds a collector waits for another collector of the same map. One
|
|
9
7
|
# that found the map unsettled releases within milliseconds, and may be
|
|
10
8
|
# the only trigger left for a failure it just deferred (R-04); a holder
|
|
@@ -12,7 +10,7 @@ module RubyReactor
|
|
|
12
10
|
COLLECT_LOCK_WAIT = 2
|
|
13
11
|
|
|
14
12
|
def self.perform(arguments)
|
|
15
|
-
arguments =
|
|
13
|
+
arguments = Helpers.normalize_arguments(arguments)
|
|
16
14
|
map_id = arguments[:map_id]
|
|
17
15
|
|
|
18
16
|
# Serialize concurrent collector deliveries for the SAME map (eager queue +
|
|
@@ -48,105 +46,39 @@ module RubyReactor
|
|
|
48
46
|
defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
|
|
49
47
|
end
|
|
50
48
|
|
|
49
|
+
# Once every index has a result slot (a value, `_error`, `_halt` or
|
|
50
|
+
# `_skipped`), signal the map's OWNER run — the top-level context —
|
|
51
|
+
# once, and write nothing else (009 R-03, I-1). The owner's Worker
|
|
52
|
+
# resumes, and `MapStep#run` adopts the settled outcome at any
|
|
53
|
+
# composition depth. An atomic map's failure is applied the same way
|
|
54
|
+
# only once every index has settled, so its compensate sees every
|
|
55
|
+
# element that completed, including ones still in flight when it
|
|
56
|
+
# failed. Until then the last element to settle, or the map sweeper,
|
|
57
|
+
# re-triggers this collector.
|
|
51
58
|
def self.perform_collection(arguments)
|
|
52
59
|
map_id = arguments[:map_id]
|
|
53
|
-
parent_context_id = arguments[:parent_context_id]
|
|
54
60
|
parent_reactor_class_name = arguments[:parent_reactor_class_name]
|
|
55
|
-
step_name = arguments[:step_name]
|
|
56
|
-
strict_ordering = arguments[:strict_ordering]
|
|
57
|
-
# timeout = arguments[:timeout]
|
|
58
|
-
|
|
59
61
|
storage = RubyReactor.configuration.storage_adapter
|
|
60
|
-
parent_context_data = storage.retrieve_context(parent_context_id, parent_reactor_class_name)
|
|
61
|
-
parent_context = RubyReactor::Context.deserialize_from_retry(parent_context_data)
|
|
62
|
-
|
|
63
|
-
# Idempotency: if the parent already recorded this map step's result, a
|
|
64
|
-
# prior collector already resumed it. Re-resuming would double-execute the
|
|
65
|
-
# steps after the map. Skip. A parent already finished (a prior
|
|
66
|
-
# collector applied this map's failure) must not be rolled back twice.
|
|
67
|
-
return if parent_context.intermediate_results.key?(step_name.to_sym) || parent_context.finished?
|
|
68
62
|
|
|
69
|
-
# Check if all tasks are completed
|
|
70
63
|
metadata = storage.retrieve_map_metadata(map_id, parent_reactor_class_name)
|
|
71
|
-
|
|
64
|
+
return unless metadata
|
|
65
|
+
# Judged against the element count, not map_offset (which batching
|
|
66
|
+
# reservation can push past it).
|
|
67
|
+
return if storage.count_map_results(map_id, parent_reactor_class_name) < metadata["count"].to_i
|
|
68
|
+
return unless storage.claim_map_owner_signal(map_id, parent_reactor_class_name)
|
|
72
69
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# Completion is judged against the total count of elements, not
|
|
76
|
-
# map_offset (which batching reservation can push past it).
|
|
77
|
-
#
|
|
78
|
-
# A fail-fast failure is applied only once every index has settled
|
|
79
|
-
# (a result, `_error`, `_halt` or `_skipped` slot), so the map's
|
|
80
|
-
# compensate sees every element that completed — including ones still
|
|
81
|
-
# in flight when the failure happened (R-04). Until then the last
|
|
82
|
-
# element to settle, or the map sweeper, re-triggers this collector.
|
|
83
|
-
return if results_count < total_count
|
|
84
|
-
|
|
85
|
-
if (failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name))
|
|
86
|
-
handle_failure(failed_context_id, metadata, storage, parent_context, step_name)
|
|
87
|
-
return
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# Retrieve results lazily
|
|
91
|
-
results = RubyReactor::Map::ResultEnumerator.new(
|
|
92
|
-
map_id,
|
|
93
|
-
parent_reactor_class_name,
|
|
94
|
-
strict_ordering: strict_ordering
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
# Apply collect block (or default collection)
|
|
98
|
-
step_config = parent_context.reactor_class.steps[step_name.to_sym]
|
|
99
|
-
|
|
100
|
-
begin
|
|
101
|
-
final_result = apply_collect_block(results, step_config)
|
|
102
|
-
|
|
103
|
-
if final_result.failure?
|
|
104
|
-
# Optionally log failure internally or just rely on context status update
|
|
105
|
-
end
|
|
106
|
-
rescue StandardError => e
|
|
107
|
-
final_result = RubyReactor::Failure(e)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Resume parent execution
|
|
111
|
-
resume_parent_execution(parent_context, step_name, final_result, storage)
|
|
112
|
-
rescue StandardError => e
|
|
113
|
-
RubyReactor.configuration.logger.error("Map collector crashed: #{e.message}")
|
|
114
|
-
RubyReactor.configuration.logger.error(e.backtrace.join("\n")) if e.backtrace
|
|
115
|
-
raise e
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def self.apply_collect_block(results, step_config)
|
|
119
|
-
collect_block = step_config.arguments[:collect_block][:source].value if step_config.arguments[:collect_block]
|
|
120
|
-
# TODO: Check allow_partial_failure option
|
|
121
|
-
|
|
122
|
-
if collect_block
|
|
123
|
-
begin
|
|
124
|
-
# Pass Enumerator to collect block
|
|
125
|
-
collected = collect_block.call(results)
|
|
126
|
-
RubyReactor::Success(collected)
|
|
127
|
-
rescue RubyReactor::Error::Rescuable => e
|
|
128
|
-
RubyReactor.configuration.logger.error("Map collect block raised: #{e.message}")
|
|
129
|
-
RubyReactor.configuration.logger.error(e.backtrace.join("\n")) if e.backtrace
|
|
130
|
-
RubyReactor::Failure(e)
|
|
131
|
-
end
|
|
132
|
-
else
|
|
133
|
-
# Default behavior: Return Success(Enumerator).
|
|
134
|
-
# Logic for checking failures is deferred to the consumer of the enumerator.
|
|
135
|
-
RubyReactor::Success(results)
|
|
136
|
-
end
|
|
70
|
+
signal_owner(metadata, arguments)
|
|
137
71
|
end
|
|
138
72
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
result = reason.is_a?(RubyReactor::Failure) ? reason : RubyReactor::Failure(reason)
|
|
149
|
-
resume_parent_execution(parent_context, step_name, result, storage)
|
|
73
|
+
# Metadata written before the upgrade names no owner (S-6): the parent is
|
|
74
|
+
# the owner of a root-level map.
|
|
75
|
+
def self.signal_owner(metadata, arguments)
|
|
76
|
+
owner_id = metadata["owner_context_id"] || metadata["parent_context_id"] || arguments[:parent_context_id]
|
|
77
|
+
owner_class = metadata["owner_reactor_class_name"] || metadata["parent_reactor_class_name"]
|
|
78
|
+
RubyReactor.configuration.logger.info(
|
|
79
|
+
"event=ruby_reactor.map.settled map_id=#{arguments[:map_id].inspect} owner=#{owner_id.inspect}"
|
|
80
|
+
)
|
|
81
|
+
RubyReactor.configuration.async_router.perform_async(owner_id, owner_class)
|
|
150
82
|
end
|
|
151
83
|
end
|
|
152
84
|
end
|
|
@@ -6,7 +6,7 @@ module RubyReactor
|
|
|
6
6
|
extend Helpers
|
|
7
7
|
|
|
8
8
|
def self.perform(arguments)
|
|
9
|
-
arguments =
|
|
9
|
+
arguments = Helpers.normalize_arguments(arguments)
|
|
10
10
|
parent_reactor_class_name = arguments[:parent_reactor_class_name]
|
|
11
11
|
|
|
12
12
|
storage = RubyReactor.configuration.storage_adapter
|
|
@@ -64,12 +64,12 @@ module RubyReactor
|
|
|
64
64
|
map_id = arguments[:map_id]
|
|
65
65
|
reactor_class_name = arguments[:parent_reactor_class_name]
|
|
66
66
|
|
|
67
|
-
#
|
|
68
|
-
if arguments[:
|
|
67
|
+
# An atomic map stops dispatching at its first failure.
|
|
68
|
+
if arguments[:atomic] && storage.retrieve_map_failed_context_id(map_id, reactor_class_name)
|
|
69
69
|
return settle_undispatched(arguments, storage)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
batch_size = arguments[:batch_size] ||
|
|
72
|
+
batch_size = arguments[:batch_size] || RubyReactor::Map::DEFAULT_BATCH_SIZE
|
|
73
73
|
|
|
74
74
|
# Atomically reserve a batch
|
|
75
75
|
new_offset = storage.increment_map_offset(map_id, batch_size, reactor_class_name)
|
|
@@ -103,7 +103,7 @@ module RubyReactor
|
|
|
103
103
|
end
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
#
|
|
106
|
+
# An atomic map stopped dispatching: claim every index not dispatched
|
|
107
107
|
# yet (one offset bump, so a later dispatcher claims none), settle each
|
|
108
108
|
# with a `_skipped` slot, count them down, and trigger the collector if
|
|
109
109
|
# that settled the map. Without this those indices never settle, so the
|
|
@@ -146,7 +146,7 @@ module RubyReactor
|
|
|
146
146
|
strict_ordering: map_meta["strict_ordering"],
|
|
147
147
|
parent_context_id: map_meta["parent_context_id"],
|
|
148
148
|
parent_reactor_class_name: parent_class_name,
|
|
149
|
-
|
|
149
|
+
atomic: map_meta["atomic"],
|
|
150
150
|
batch_size: map_meta["batch_size"]
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -211,10 +211,41 @@ module RubyReactor
|
|
|
211
211
|
parent_reactor_class_name: context.reactor_class.name,
|
|
212
212
|
step_name: options[:step_name].to_s,
|
|
213
213
|
batch_size: arguments[:batch_size], # Passed to worker so it knows to trigger next batch?
|
|
214
|
-
|
|
214
|
+
atomic: arguments[:atomic]
|
|
215
215
|
)
|
|
216
216
|
end
|
|
217
217
|
|
|
218
|
+
# One throw of a map's distributed rollback (009 R-02, R-06): claim the
|
|
219
|
+
# next `batch_size` positions of the element-context index, counted from
|
|
220
|
+
# its tail, and enqueue one rollback job per position. The job at the
|
|
221
|
+
# last position of a throw claims the next one, as forward.
|
|
222
|
+
def self.dispatch_rollback_batch(map_id:, parent_reactor_class_name:)
|
|
223
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
224
|
+
meta = storage.retrieve_map_rollback_metadata(map_id, parent_reactor_class_name)
|
|
225
|
+
return 0 unless meta
|
|
226
|
+
|
|
227
|
+
positions = storage.claim_map_rollback_positions(map_id, parent_reactor_class_name, meta["batch_size"].to_i)
|
|
228
|
+
return 0 if positions.none?
|
|
229
|
+
|
|
230
|
+
ids = storage.retrieve_map_element_context_ids_from_tail(map_id, parent_reactor_class_name, positions.first,
|
|
231
|
+
positions.size, total: meta["total"].to_i)
|
|
232
|
+
positions.each_with_index do |position, i|
|
|
233
|
+
queue_rollback_job(meta, map_id, parent_reactor_class_name, position, ids[i])
|
|
234
|
+
end
|
|
235
|
+
positions.size
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# rubocop:disable Metrics/ParameterLists
|
|
239
|
+
def self.queue_rollback_job(meta, map_id, parent_reactor_class_name, position, element_context_id, attempt: 0)
|
|
240
|
+
RubyReactor.configuration.async_router.perform_map_element_rollback_async(
|
|
241
|
+
map_id: map_id, position: position, element_context_id: element_context_id,
|
|
242
|
+
reactor_class_info: meta["reactor_class_info"], parent_reactor_class_name: parent_reactor_class_name,
|
|
243
|
+
step_name: meta["step_name"], batch_size: meta["batch_size"], owner_context_id: meta["owner_context_id"],
|
|
244
|
+
owner_reactor_class_name: meta["owner_reactor_class_name"], attempt: attempt
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
# rubocop:enable Metrics/ParameterLists
|
|
248
|
+
|
|
218
249
|
def self.resolve_reactor_class_info(arguments, context)
|
|
219
250
|
mapped_reactor_class = arguments[:mapped_reactor_class]
|
|
220
251
|
step_name = arguments[:step_name]
|
|
@@ -6,7 +6,7 @@ module RubyReactor
|
|
|
6
6
|
extend Helpers
|
|
7
7
|
|
|
8
8
|
def self.perform(arguments)
|
|
9
|
-
arguments =
|
|
9
|
+
arguments = Helpers.normalize_arguments(arguments)
|
|
10
10
|
|
|
11
11
|
# Per-element liveness lock (Phase 5b): its presence is the map sweeper's
|
|
12
12
|
# "element alive" signal, and it serializes duplicate deliveries so a
|
|
@@ -55,12 +55,16 @@ module RubyReactor
|
|
|
55
55
|
context.inline_async_execution = true
|
|
56
56
|
|
|
57
57
|
storage = RubyReactor.configuration.storage_adapter
|
|
58
|
-
return if
|
|
58
|
+
return if check_atomic?(arguments, storage)
|
|
59
59
|
|
|
60
60
|
# Indexed only once it runs: a skipped element saves no context, and
|
|
61
61
|
# the map's rollback reports an indexed element without one as expired.
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
# Only a fresh context: a parked or retried element re-enters with its
|
|
63
|
+
# own, already indexed (009 R-06).
|
|
64
|
+
unless arguments[:serialized_context]
|
|
65
|
+
storage.store_map_element_context_id(arguments[:map_id], context.context_id,
|
|
66
|
+
arguments[:parent_reactor_class_name])
|
|
67
|
+
end
|
|
64
68
|
|
|
65
69
|
executor = Executor.new(context.reactor_class, {}, context)
|
|
66
70
|
begin
|
|
@@ -95,7 +99,7 @@ module RubyReactor
|
|
|
95
99
|
strict_ordering: arguments[:strict_ordering], parent_context_id: arguments[:parent_context_id],
|
|
96
100
|
parent_reactor_class_name: arguments[:parent_reactor_class_name], step_name: arguments[:step_name],
|
|
97
101
|
batch_size: arguments[:batch_size], serialized_context: ContextSerializer.serialize(context),
|
|
98
|
-
|
|
102
|
+
atomic: arguments[:atomic]
|
|
99
103
|
)
|
|
100
104
|
end
|
|
101
105
|
|
|
@@ -144,8 +148,8 @@ module RubyReactor
|
|
|
144
148
|
end
|
|
145
149
|
# rubocop:enable Style/IdenticalConditionalBranches
|
|
146
150
|
|
|
147
|
-
def self.
|
|
148
|
-
return false unless arguments[:
|
|
151
|
+
def self.check_atomic?(arguments, storage)
|
|
152
|
+
return false unless arguments[:atomic]
|
|
149
153
|
|
|
150
154
|
map_id = arguments[:map_id]
|
|
151
155
|
parent_reactor_class_name = arguments[:parent_reactor_class_name]
|
|
@@ -154,7 +158,7 @@ module RubyReactor
|
|
|
154
158
|
return false unless failed_context_id
|
|
155
159
|
|
|
156
160
|
# Skip execution, but settle the index: the collector applies a
|
|
157
|
-
#
|
|
161
|
+
# atomic map's failure only once every index has a result slot (R-04), and
|
|
158
162
|
# the map sweeper would otherwise keep re-dispatching this one.
|
|
159
163
|
storage.store_map_result(map_id, arguments[:index], { "_skipped" => true }, parent_reactor_class_name,
|
|
160
164
|
strict_ordering: arguments[:strict_ordering])
|
|
@@ -183,12 +187,12 @@ module RubyReactor
|
|
|
183
187
|
# Store the whole serialized Failure, not just its message: step_name,
|
|
184
188
|
# backtrace, file_path and code_snippet are the only record of why this
|
|
185
189
|
# element failed once its context row expires, and the dashboard has
|
|
186
|
-
# nothing else to show for a non-
|
|
190
|
+
# nothing else to show for a non-atomic map.
|
|
187
191
|
storage.store_map_result(map_id, index,
|
|
188
192
|
{ _error: ContextSerializer.serialize_value(result) }, parent_class,
|
|
189
193
|
strict_ordering: arguments[:strict_ordering])
|
|
190
194
|
|
|
191
|
-
if arguments[:
|
|
195
|
+
if arguments[:atomic]
|
|
192
196
|
storage.store_map_failed_context_id(map_id, context.context_id, parent_class)
|
|
193
197
|
# FAST FAIL: Trigger Collector immediately to cancel/fail the map execution
|
|
194
198
|
RubyReactor.configuration.async_router.perform_map_collection_async(
|