ruby_reactor 0.6.0 → 0.7.0
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/CHANGELOG.md +114 -1
- data/CLAUDE.md +5 -0
- data/README.md +131 -16
- data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
- data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
- data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
- data/lib/ruby_reactor/async_waiter.rb +113 -0
- data/lib/ruby_reactor/configuration.rb +30 -1
- data/lib/ruby_reactor/context.rb +21 -0
- data/lib/ruby_reactor/context_serializer.rb +2 -0
- data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
- data/lib/ruby_reactor/dsl/lockable.rb +1 -1
- data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
- data/lib/ruby_reactor/dsl/reactor.rb +29 -10
- data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
- data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
- data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
- data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
- data/lib/ruby_reactor/executor/result_handler.rb +12 -1
- data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
- data/lib/ruby_reactor/executor/step_executor.rb +58 -11
- data/lib/ruby_reactor/executor.rb +162 -8
- data/lib/ruby_reactor/lock.rb +19 -0
- data/lib/ruby_reactor/map/element_executor.rb +6 -1
- data/lib/ruby_reactor/map/helpers.rb +1 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
- data/lib/ruby_reactor/map/result_summary.rb +63 -0
- data/lib/ruby_reactor/open_telemetry.rb +1 -1
- data/lib/ruby_reactor/reactor.rb +25 -2
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
- data/lib/ruby_reactor/rspec/test_subject.rb +66 -11
- data/lib/ruby_reactor/semaphore.rb +10 -0
- data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
- data/lib/ruby_reactor/step/compose_step.rb +1 -1
- data/lib/ruby_reactor/step/map_step.rb +1 -1
- data/lib/ruby_reactor/step_sweeper.rb +72 -0
- data/lib/ruby_reactor/step_worker.rb +260 -0
- data/lib/ruby_reactor/storage/adapter.rb +22 -1
- data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
- data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
- data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
- data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
- data/lib/ruby_reactor/sweeper.rb +7 -1
- data/lib/ruby_reactor/sweeper_job.rb +1 -0
- data/lib/ruby_reactor/template/result.rb +151 -5
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +94 -16
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +53 -3
- data/lib/ruby_reactor.rb +24 -3
- data/specs/001-background-async-steps/checklists/requirements.md +39 -0
- data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
- data/specs/001-background-async-steps/data-model.md +117 -0
- data/specs/001-background-async-steps/plan.md +168 -0
- data/specs/001-background-async-steps/quickstart.md +102 -0
- data/specs/001-background-async-steps/research.md +150 -0
- data/specs/001-background-async-steps/spec.md +146 -0
- data/specs/001-background-async-steps/tasks.md +271 -0
- data/specs/active_job.md +1 -1
- metadata +27 -3
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
|
@@ -6,6 +6,7 @@ require_relative "executor/graph_manager"
|
|
|
6
6
|
require_relative "executor/retry_manager"
|
|
7
7
|
require_relative "executor/compensation_manager"
|
|
8
8
|
require_relative "executor/result_handler"
|
|
9
|
+
require_relative "executor/async_step_dispatch"
|
|
9
10
|
require_relative "executor/step_executor"
|
|
10
11
|
require_relative "executor/ordered_lock_support"
|
|
11
12
|
|
|
@@ -53,6 +54,7 @@ module RubyReactor
|
|
|
53
54
|
@acquired_semaphore = nil
|
|
54
55
|
@acquired_context_lock = nil
|
|
55
56
|
@context_lock_owner = nil
|
|
57
|
+
@parked = false
|
|
56
58
|
@contention_snooze = false
|
|
57
59
|
@skip_context_persist = false
|
|
58
60
|
@last_checkpoint_at = nil
|
|
@@ -101,6 +103,7 @@ module RubyReactor
|
|
|
101
103
|
input_validator = InputValidator.new(@reactor_class, @context)
|
|
102
104
|
input_validator.validate!
|
|
103
105
|
|
|
106
|
+
reset_held_lock_keys!
|
|
104
107
|
acquire_locks_with_telemetry
|
|
105
108
|
|
|
106
109
|
# Re-check the period gate now that we hold the lock. The pre-lock check
|
|
@@ -132,6 +135,13 @@ module RubyReactor
|
|
|
132
135
|
RubyReactor::OrderedLock::WaitError => e
|
|
133
136
|
@contention_snooze = true
|
|
134
137
|
raise e
|
|
138
|
+
rescue Error::AsyncResultPending
|
|
139
|
+
# Only reachable when this executor runs nested inside a worker (a
|
|
140
|
+
# composed child; sync callers never park). Propagate to the ROOT
|
|
141
|
+
# resume, which owns the park. This child's own lock/semaphore (if any)
|
|
142
|
+
# ARE released below and re-competed for on redelivery.
|
|
143
|
+
@contention_snooze = true
|
|
144
|
+
raise
|
|
135
145
|
rescue StandardError => e
|
|
136
146
|
@result = @result_handler.handle_execution_error(e)
|
|
137
147
|
update_context_status(@result)
|
|
@@ -192,11 +202,19 @@ module RubyReactor
|
|
|
192
202
|
# and must not contend on the root's own key.
|
|
193
203
|
acquire_context_lock
|
|
194
204
|
|
|
205
|
+
reset_held_lock_keys!
|
|
206
|
+
|
|
195
207
|
# Resumes intentionally skip check_rate_limit (a paused run must not
|
|
196
208
|
# block itself on resume), so acquire lock/semaphore directly rather
|
|
197
|
-
# than via acquire_locks.
|
|
198
|
-
|
|
199
|
-
|
|
209
|
+
# than via acquire_locks. A context parked on an async result kept its
|
|
210
|
+
# primitives held across the gap — re-adopt them instead of re-competing.
|
|
211
|
+
parked = consume_parked_primitives!
|
|
212
|
+
if @reactor_class.respond_to?(:lock_config) && @reactor_class.lock_config
|
|
213
|
+
acquire_exclusive_lock(reattach: parked[:lock])
|
|
214
|
+
end
|
|
215
|
+
if @reactor_class.respond_to?(:semaphore_config) && @reactor_class.semaphore_config
|
|
216
|
+
acquire_semaphore(reattach_token: parked[:semaphore_token])
|
|
217
|
+
end
|
|
200
218
|
|
|
201
219
|
# Post-lock re-check (see execute) — closes the period race for the
|
|
202
220
|
# first run of a locked async reactor.
|
|
@@ -227,13 +245,21 @@ module RubyReactor
|
|
|
227
245
|
RubyReactor::OrderedLock::WaitError => e
|
|
228
246
|
@contention_snooze = true
|
|
229
247
|
raise e
|
|
248
|
+
rescue Error::AsyncResultPending => e
|
|
249
|
+
# An awaited async unit is not terminal yet: park. Exclusive lock and
|
|
250
|
+
# semaphore stay HELD (recorded on the context for the resuming job to
|
|
251
|
+
# re-adopt); the worker snoozes the job. The context lock is still
|
|
252
|
+
# released below — the redelivered job must be able to take it.
|
|
253
|
+
park_held_primitives!
|
|
254
|
+
@contention_snooze = true
|
|
255
|
+
raise e
|
|
230
256
|
rescue StandardError => e
|
|
231
257
|
handle_resume_error(e)
|
|
232
258
|
update_context_status(@result)
|
|
233
259
|
completed = true
|
|
234
260
|
@result
|
|
235
261
|
ensure
|
|
236
|
-
release_locks
|
|
262
|
+
release_locks unless @parked
|
|
237
263
|
@acquired_context_lock&.release
|
|
238
264
|
@acquired_context_lock = nil
|
|
239
265
|
leave_ordered_lock_scope
|
|
@@ -265,6 +291,25 @@ module RubyReactor
|
|
|
265
291
|
# Serialize context
|
|
266
292
|
serialized_context = ContextSerializer.serialize(@context)
|
|
267
293
|
storage.store_context(@context.context_id, serialized_context, reactor_class_name)
|
|
294
|
+
publish_completion_signal(storage)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Wake any parent blocked in the notified wait on this execution. Published
|
|
298
|
+
# AFTER the durable save, never before: the context row is the answer and
|
|
299
|
+
# the signal only saves the waiter a fallback interval. Unconditional —
|
|
300
|
+
# publishing to a channel with no subscribers is near-free, so there is no
|
|
301
|
+
# need for an "am I awaited?" marker.
|
|
302
|
+
def publish_completion_signal(storage)
|
|
303
|
+
return unless @context.finished?
|
|
304
|
+
|
|
305
|
+
log_completion
|
|
306
|
+
storage.publish(RubyReactor.async_reactor_channel(@context.context_id), @context.status.to_s)
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
# The signal is an optimisation; losing it costs the waiter one fallback
|
|
309
|
+
# interval and must never fail the run that just completed.
|
|
310
|
+
RubyReactor.configuration.logger.warn(
|
|
311
|
+
"RubyReactor: could not publish completion signal for #{@context.context_id}: #{e.message}"
|
|
312
|
+
)
|
|
268
313
|
end
|
|
269
314
|
|
|
270
315
|
# Durable per-step checkpoint. Unlike save_context (which serializes THIS
|
|
@@ -386,6 +431,33 @@ module RubyReactor
|
|
|
386
431
|
RubyReactor::Period.key(base, config[:every])
|
|
387
432
|
end
|
|
388
433
|
|
|
434
|
+
# One machine-parseable line whenever an execution reaches a terminal
|
|
435
|
+
# state, carrying the parent link. A child dispatched fire-and-forget may
|
|
436
|
+
# have no other surface in its parent at all, so a failure entry also names
|
|
437
|
+
# the reason.
|
|
438
|
+
def log_completion
|
|
439
|
+
return unless @context.parent_context_id
|
|
440
|
+
|
|
441
|
+
fields = {
|
|
442
|
+
event: "ruby_reactor.async_reactor.completed",
|
|
443
|
+
reactor: @reactor_class&.name,
|
|
444
|
+
execution_id: @context.context_id,
|
|
445
|
+
parent_execution_id: @context.parent_context_id,
|
|
446
|
+
status: @context.status.to_s
|
|
447
|
+
}
|
|
448
|
+
fields[:failure] = failure_summary if @context.failed?
|
|
449
|
+
|
|
450
|
+
RubyReactor.configuration.logger.public_send(
|
|
451
|
+
@context.failed? ? :warn : :info,
|
|
452
|
+
fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
|
|
453
|
+
)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def failure_summary
|
|
457
|
+
reason = @context.failure_reason
|
|
458
|
+
reason.respond_to?(:error) ? reason.error.to_s : reason.to_s
|
|
459
|
+
end
|
|
460
|
+
|
|
389
461
|
# Per-execution liveness lock on the root context id. Owner is a fresh UUID
|
|
390
462
|
# per execution (NOT the context_id): a duplicate delivery of the *same*
|
|
391
463
|
# context from a different worker must be blocked, so reentrancy by id would
|
|
@@ -422,7 +494,7 @@ module RubyReactor
|
|
|
422
494
|
defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
|
|
423
495
|
end
|
|
424
496
|
|
|
425
|
-
def acquire_exclusive_lock
|
|
497
|
+
def acquire_exclusive_lock(reattach: false)
|
|
426
498
|
config = @reactor_class.lock_config
|
|
427
499
|
key = config[:key_proc].call(@context.inputs)
|
|
428
500
|
|
|
@@ -436,9 +508,21 @@ module RubyReactor
|
|
|
436
508
|
wait: contention_wait(config[:wait]),
|
|
437
509
|
auto_extend: config.fetch(:auto_extend, true)
|
|
438
510
|
)
|
|
511
|
+
|
|
512
|
+
# Re-adopting a lock held across a parked gap: no :lock_acquired event —
|
|
513
|
+
# the original acquisition already emitted it, and the eventual release
|
|
514
|
+
# emits exactly one :lock_released. A lapsed TTL falls through to a
|
|
515
|
+
# fresh acquire.
|
|
516
|
+
if reattach && lock.reattach
|
|
517
|
+
@acquired_lock = lock
|
|
518
|
+
held_lock_keys << key
|
|
519
|
+
return
|
|
520
|
+
end
|
|
521
|
+
|
|
439
522
|
begin
|
|
440
523
|
lock.acquire
|
|
441
524
|
@acquired_lock = lock
|
|
525
|
+
held_lock_keys << key
|
|
442
526
|
middlewares.on(:lock_acquired, key, @context)
|
|
443
527
|
rescue RubyReactor::Lock::AcquisitionError => e
|
|
444
528
|
middlewares.on(:lock_failed, key, e, @context)
|
|
@@ -446,15 +530,29 @@ module RubyReactor
|
|
|
446
530
|
end
|
|
447
531
|
end
|
|
448
532
|
|
|
449
|
-
def acquire_semaphore
|
|
533
|
+
def acquire_semaphore(reattach_token: nil)
|
|
450
534
|
config = @reactor_class.semaphore_config
|
|
451
535
|
key = config[:key_proc].call(@context.inputs)
|
|
452
536
|
limit = config[:limit]
|
|
453
537
|
|
|
454
538
|
semaphore = RubyReactor::Semaphore.new(key, limit: limit, wait: contention_wait(config[:wait]))
|
|
539
|
+
|
|
540
|
+
# Same shape as the lock reattach above: keep the slot held across the
|
|
541
|
+
# parked gap, no duplicate :semaphore_acquired event, fall through to a
|
|
542
|
+
# fresh acquire when the token was lost in between.
|
|
543
|
+
if reattach_token && semaphore.reattach(reattach_token)
|
|
544
|
+
@acquired_semaphore = semaphore
|
|
545
|
+
held_lock_keys << key if limit == 1
|
|
546
|
+
return
|
|
547
|
+
end
|
|
548
|
+
|
|
455
549
|
begin
|
|
456
550
|
semaphore.acquire
|
|
457
551
|
@acquired_semaphore = semaphore
|
|
552
|
+
# Only a single-slot semaphore has the circular-wait shape the
|
|
553
|
+
# async_reactor deadlock guard can act on; higher limits are ordinary
|
|
554
|
+
# contention and must keep snoozing.
|
|
555
|
+
held_lock_keys << key if limit == 1
|
|
458
556
|
middlewares.on(:semaphore_acquired, key, limit, @context)
|
|
459
557
|
rescue RubyReactor::Semaphore::AcquisitionError => e
|
|
460
558
|
middlewares.on(:semaphore_failed, key, limit, e, @context)
|
|
@@ -471,10 +569,47 @@ module RubyReactor
|
|
|
471
569
|
configured_wait
|
|
472
570
|
end
|
|
473
571
|
|
|
572
|
+
# Park on a pending async result: keep exclusive lock / semaphore checked
|
|
573
|
+
# out through the gap, recording just enough on the (about-to-be-saved)
|
|
574
|
+
# context for the resuming job to re-adopt them. The lock's auto-extender
|
|
575
|
+
# dies with this process, so the parked gap is bounded by the lock TTL —
|
|
576
|
+
# the snooze redelivery (seconds) sits comfortably inside the default 60s.
|
|
577
|
+
def park_held_primitives!
|
|
578
|
+
@parked = true
|
|
579
|
+
parked = {}
|
|
580
|
+
|
|
581
|
+
if @acquired_lock
|
|
582
|
+
@acquired_lock.detach
|
|
583
|
+
parked[:lock] = true
|
|
584
|
+
@acquired_lock = nil
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
if @acquired_semaphore
|
|
588
|
+
parked[:semaphore_token] = @acquired_semaphore.token
|
|
589
|
+
@acquired_semaphore = nil
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
@context.private_data[:parked_primitives] = parked if parked.any?
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# One-shot: the marker is deleted on read so a crash after this point
|
|
596
|
+
# degrades to a fresh acquire (reentrant by owner for the lock) rather
|
|
597
|
+
# than a stale reattach on some later, unrelated resume.
|
|
598
|
+
def consume_parked_primitives!
|
|
599
|
+
raw = @context.private_data.delete(:parked_primitives) ||
|
|
600
|
+
@context.private_data.delete("parked_primitives") || {}
|
|
601
|
+
|
|
602
|
+
{
|
|
603
|
+
lock: raw[:lock] || raw["lock"],
|
|
604
|
+
semaphore_token: raw[:semaphore_token] || raw["semaphore_token"]
|
|
605
|
+
}
|
|
606
|
+
end
|
|
607
|
+
|
|
474
608
|
def release_locks
|
|
475
609
|
if @acquired_semaphore
|
|
476
610
|
key = @acquired_semaphore.key
|
|
477
611
|
release_one("semaphore", @acquired_semaphore)
|
|
612
|
+
held_lock_keys.delete(key)
|
|
478
613
|
middlewares.on(:semaphore_released, key, @context)
|
|
479
614
|
end
|
|
480
615
|
@acquired_semaphore = nil
|
|
@@ -483,10 +618,29 @@ module RubyReactor
|
|
|
483
618
|
|
|
484
619
|
key = @acquired_lock.key
|
|
485
620
|
release_one("lock", @acquired_lock)
|
|
621
|
+
held_lock_keys.delete(key)
|
|
486
622
|
@acquired_lock = nil
|
|
487
623
|
middlewares.on(:lock_released, key, @context)
|
|
488
624
|
end
|
|
489
625
|
|
|
626
|
+
# Exclusive keys this EXECUTION currently holds, recorded on the root
|
|
627
|
+
# context so a dispatching step anywhere in the tree can see the whole
|
|
628
|
+
# chain. Read by the async_reactor deadlock guard; nothing else
|
|
629
|
+
# depends on it, so a stale entry can only cost a false positive — hence
|
|
630
|
+
# the reset on the way in.
|
|
631
|
+
def held_lock_keys
|
|
632
|
+
root = @context.root_context || @context
|
|
633
|
+
root.private_data[:held_lock_keys] ||= []
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# A rehydrated context can carry keys from the process that died holding
|
|
637
|
+
# them. Only the root executor resets, and only on the way in.
|
|
638
|
+
def reset_held_lock_keys!
|
|
639
|
+
return unless (@context.root_context || @context).equal?(@context)
|
|
640
|
+
|
|
641
|
+
(@context.root_context || @context).private_data[:held_lock_keys] = []
|
|
642
|
+
end
|
|
643
|
+
|
|
490
644
|
def release_one(kind, primitive)
|
|
491
645
|
released = primitive.release
|
|
492
646
|
return if released
|
|
@@ -506,7 +660,7 @@ module RubyReactor
|
|
|
506
660
|
return unless result
|
|
507
661
|
|
|
508
662
|
case result
|
|
509
|
-
when RubyReactor::
|
|
663
|
+
when RubyReactor::DispatchResult
|
|
510
664
|
@context.status = :running
|
|
511
665
|
when RubyReactor::Skipped
|
|
512
666
|
@context.status = :skipped
|
|
@@ -547,7 +701,7 @@ module RubyReactor
|
|
|
547
701
|
when RubyReactor::Skipped,
|
|
548
702
|
RetryQueuedResult,
|
|
549
703
|
RubyReactor::Failure,
|
|
550
|
-
RubyReactor::
|
|
704
|
+
RubyReactor::DispatchResult,
|
|
551
705
|
RubyReactor::InterruptResult
|
|
552
706
|
# Terminal: step was skipped, requeued, failed, paused, or handed
|
|
553
707
|
# off to async. Return the result as-is.
|
data/lib/ruby_reactor/lock.rb
CHANGED
|
@@ -54,6 +54,25 @@ module RubyReactor
|
|
|
54
54
|
adapter.lock_release(@key, @owner)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Park support: stop refreshing the TTL but LEAVE the key held — the
|
|
58
|
+
# parked execution keeps ownership through the gap, bounded by the TTL.
|
|
59
|
+
def detach
|
|
60
|
+
stop_extender
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Resume ownership after a parked gap: verify we are still the owner and
|
|
64
|
+
# refresh the TTL — WITHOUT incrementing the reentrancy count. The count
|
|
65
|
+
# was never decremented at park, so a plain `acquire` here would bump it
|
|
66
|
+
# to 2 and the final release would leave the key behind until TTL.
|
|
67
|
+
# Returns false when ownership lapsed (TTL expired mid-park); the caller
|
|
68
|
+
# falls back to a fresh acquire.
|
|
69
|
+
def reattach # rubocop:disable Naming/PredicateMethod
|
|
70
|
+
return false unless adapter.lock_extend(@key, @owner, @ttl)
|
|
71
|
+
|
|
72
|
+
start_extender if @auto_extend
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
57
76
|
def synchronize
|
|
58
77
|
acquire
|
|
59
78
|
yield
|
|
@@ -146,7 +146,12 @@ module RubyReactor
|
|
|
146
146
|
parent_class, strict_ordering: arguments[:strict_ordering])
|
|
147
147
|
else
|
|
148
148
|
executor.undo_all
|
|
149
|
-
|
|
149
|
+
# Store the whole serialized Failure, not just its message: step_name,
|
|
150
|
+
# backtrace, file_path and code_snippet are the only record of why this
|
|
151
|
+
# element failed once its context row expires, and the dashboard has
|
|
152
|
+
# nothing else to show for a non-fail_fast map.
|
|
153
|
+
storage.store_map_result(map_id, index,
|
|
154
|
+
{ _error: ContextSerializer.serialize_value(result) }, parent_class,
|
|
150
155
|
strict_ordering: arguments[:strict_ordering])
|
|
151
156
|
|
|
152
157
|
if arguments[:fail_fast]
|
|
@@ -95,7 +95,7 @@ module RubyReactor
|
|
|
95
95
|
|
|
96
96
|
# Manually update execution trace to reflect completion
|
|
97
97
|
# This is necessary because resume_execution continues from the NEXT step
|
|
98
|
-
# and the async step (which returned
|
|
98
|
+
# and the async step (which returned DispatchResult) needs to be marked as done with actual value
|
|
99
99
|
parent_context.execution_trace << {
|
|
100
100
|
type: :result,
|
|
101
101
|
step: step_name_sym,
|
|
@@ -96,7 +96,11 @@ module RubyReactor
|
|
|
96
96
|
|
|
97
97
|
def wrap_result(result)
|
|
98
98
|
if result.is_a?(Hash) && result.key?("_error")
|
|
99
|
-
|
|
99
|
+
# `backtrace: []` is load-bearing: without it Failure falls back to
|
|
100
|
+
# `caller`, so a bare-message element failure would carry the stack of
|
|
101
|
+
# whoever happened to materialize the enumerator (the JSON encoder, in
|
|
102
|
+
# the dashboard's case) instead of the stack of the step that failed.
|
|
103
|
+
RubyReactor::Failure.new(result["_error"], backtrace: [])
|
|
100
104
|
else
|
|
101
105
|
RubyReactor::Success.new(ContextSerializer.deserialize_value(result))
|
|
102
106
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
# A map's result set is unbounded, so the API describes it instead of
|
|
6
|
+
# inlining it. Inlining meant the JSON encoder materialized the lazy
|
|
7
|
+
# ResultEnumerator itself — one storage read per element — and rendered every
|
|
8
|
+
# element failure as an opaque blob with no count of how many there were.
|
|
9
|
+
class ResultSummary
|
|
10
|
+
# How many failed elements are described in full. The rest are only
|
|
11
|
+
# counted — a map failing 50k elements must not produce a 50k-entry
|
|
12
|
+
# response.
|
|
13
|
+
SAMPLE_LIMIT = 25
|
|
14
|
+
|
|
15
|
+
# Sampled failures are a list, not a detail view, and the same summary is
|
|
16
|
+
# echoed in intermediate_results, the execution trace and the undo stack —
|
|
17
|
+
# full traces on each would triple a large payload.
|
|
18
|
+
BACKTRACE_FRAMES = 10
|
|
19
|
+
|
|
20
|
+
# ponytail: one windowed read of the whole map; paginate if maps grow past
|
|
21
|
+
# what fits in a single response.
|
|
22
|
+
def self.build(enumerator)
|
|
23
|
+
total = enumerator.count
|
|
24
|
+
results = RubyReactor.configuration.storage_adapter.retrieve_map_results_batch(
|
|
25
|
+
enumerator.map_id, enumerator.reactor_class_name, offset: 0, limit: total
|
|
26
|
+
)
|
|
27
|
+
# Slots are index-keyed and the window starts at 0, so a full read means
|
|
28
|
+
# position == element index. A short read means the map is still filling
|
|
29
|
+
# in and gaps would shift positions — then indices are omitted.
|
|
30
|
+
failures = collect_failures(results, indexed: results.size == total)
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
"_type" => "map_results",
|
|
34
|
+
"total" => total,
|
|
35
|
+
"succeeded" => total - failures[:count],
|
|
36
|
+
"failed" => failures[:count],
|
|
37
|
+
"failures" => failures[:sample],
|
|
38
|
+
"failures_truncated" => failures[:count] > failures[:sample].size
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.collect_failures(results, indexed:)
|
|
43
|
+
count = 0
|
|
44
|
+
sample = []
|
|
45
|
+
|
|
46
|
+
results.each_with_index do |raw, position|
|
|
47
|
+
next unless raw.is_a?(Hash) && raw.key?("_error")
|
|
48
|
+
|
|
49
|
+
count += 1
|
|
50
|
+
next if sample.size >= SAMPLE_LIMIT
|
|
51
|
+
|
|
52
|
+
entry = ContextSerializer.simplify_for_api(Failure.new(raw["_error"], backtrace: []))
|
|
53
|
+
entry["backtrace"] = Array(entry["backtrace"]).first(BACKTRACE_FRAMES)
|
|
54
|
+
entry["index"] = position if indexed
|
|
55
|
+
sample << entry
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
{ count: count, sample: sample }
|
|
59
|
+
end
|
|
60
|
+
private_class_method :collect_failures
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -147,7 +147,7 @@ module RubyReactor
|
|
|
147
147
|
span = @step_spans.delete(step_name)
|
|
148
148
|
return unless span
|
|
149
149
|
|
|
150
|
-
if result.is_a?(RubyReactor::
|
|
150
|
+
if result.is_a?(RubyReactor::DispatchResult)
|
|
151
151
|
# The step was handed off to a background worker; the run block did not
|
|
152
152
|
# execute here. Rename the span so it is not confused with the real
|
|
153
153
|
# execution span emitted later under the resumed reactor span.
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -118,7 +118,7 @@ module RubyReactor
|
|
|
118
118
|
RubyReactor.reactor_storage_name(self.class),
|
|
119
119
|
intermediate_results: @context.intermediate_results)
|
|
120
120
|
|
|
121
|
-
# Even if it's an
|
|
121
|
+
# Even if it's an DispatchResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
|
|
122
122
|
# Check storage to see if it's already finished or paused (interrupted).
|
|
123
123
|
begin
|
|
124
124
|
reloaded = self.class.find(@context.context_id)
|
|
@@ -167,6 +167,14 @@ module RubyReactor
|
|
|
167
167
|
target_step = step_name
|
|
168
168
|
@context.set_result(target_step, payload)
|
|
169
169
|
|
|
170
|
+
# `interrupt :x, resume: :background` — payload is validated and stored
|
|
171
|
+
# (above, in this process); the remaining work goes to a worker instead
|
|
172
|
+
# of running inline in the delivering process.
|
|
173
|
+
step_config = self.class.steps[step_name.to_sym]
|
|
174
|
+
if step_config.respond_to?(:background_resume?) && step_config.background_resume?
|
|
175
|
+
return @result = enqueue_background_resume
|
|
176
|
+
end
|
|
177
|
+
|
|
170
178
|
# Resume execution
|
|
171
179
|
executor = Executor.new(self.class, {}, @context)
|
|
172
180
|
@result = executor.resume_execution
|
|
@@ -311,6 +319,21 @@ module RubyReactor
|
|
|
311
319
|
save_context
|
|
312
320
|
end
|
|
313
321
|
|
|
322
|
+
# Mirror of perform_async_run for the interrupt-resume path: persist the
|
|
323
|
+
# context (now carrying the validated payload) BEFORE enqueue — the job
|
|
324
|
+
# payload is identity-only (F2) — then hand the remainder to a worker.
|
|
325
|
+
def enqueue_background_resume
|
|
326
|
+
@context.status = :running
|
|
327
|
+
Executor.middlewares_for(self.class).on(:before_async_enqueue, @context)
|
|
328
|
+
save_context
|
|
329
|
+
|
|
330
|
+
@result = configuration.async_router.perform_async(@context.context_id,
|
|
331
|
+
RubyReactor.reactor_storage_name(self.class),
|
|
332
|
+
intermediate_results: @context.intermediate_results)
|
|
333
|
+
|
|
334
|
+
check_for_inline_completion || @result
|
|
335
|
+
end
|
|
336
|
+
|
|
314
337
|
def perform_async_run
|
|
315
338
|
@context.status = :running
|
|
316
339
|
# Persist BEFORE enqueue — the job payload is identity-only (F2).
|
|
@@ -324,7 +347,7 @@ module RubyReactor
|
|
|
324
347
|
end
|
|
325
348
|
|
|
326
349
|
def check_for_inline_completion
|
|
327
|
-
# Even if it's an
|
|
350
|
+
# Even if it's an DispatchResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
|
|
328
351
|
# Check storage to see if it's already finished or paused (interrupted).
|
|
329
352
|
reloaded = self.class.find(@context.context_id)
|
|
330
353
|
if reloaded.finished? || reloaded.context.status.to_s == "paused"
|
|
@@ -38,7 +38,8 @@ module RubyReactor
|
|
|
38
38
|
@worker_classes ||= [
|
|
39
39
|
RubyReactor::Adapters::Sidekiq::Worker,
|
|
40
40
|
RubyReactor::Adapters::Sidekiq::MapElementWorker,
|
|
41
|
-
RubyReactor::Adapters::Sidekiq::MapCollectorWorker
|
|
41
|
+
RubyReactor::Adapters::Sidekiq::MapCollectorWorker,
|
|
42
|
+
RubyReactor::Adapters::Sidekiq::StepWorker
|
|
42
43
|
]
|
|
43
44
|
end
|
|
44
45
|
|
|
@@ -14,7 +14,7 @@ module RubyReactor
|
|
|
14
14
|
result = super
|
|
15
15
|
|
|
16
16
|
# 2. Add test-specific logic for inline async execution
|
|
17
|
-
# Only interfere if we got an
|
|
17
|
+
# Only interfere if we got an DispatchResult and we are in a testing environment that supports inline execution
|
|
18
18
|
if should_check_inline_completion?(result)
|
|
19
19
|
# Check if it finished or paused inline (e.g. Sidekiq::Testing.inline!)
|
|
20
20
|
refresh_context_from_storage
|
|
@@ -75,7 +75,7 @@ module RubyReactor
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def should_check_inline_completion?(result)
|
|
78
|
-
return false unless result.is_a?(RubyReactor::
|
|
78
|
+
return false unless result.is_a?(RubyReactor::DispatchResult) || result.is_a?(RubyReactor::RetryQueuedResult)
|
|
79
79
|
return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
|
|
80
80
|
|
|
81
81
|
false
|
|
@@ -130,6 +130,56 @@ module RubyReactor
|
|
|
130
130
|
elements[index]
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
# The durable record for a dispatched `async_step`, or nil if the step was
|
|
134
|
+
# never dispatched (e.g. under `async: false`, where it ran inline and its
|
|
135
|
+
# value is an ordinary `step_result`). Mirrors `#composed` / `#map` in
|
|
136
|
+
# reading the reference off `composed_contexts`.
|
|
137
|
+
#
|
|
138
|
+
# subject.async_step(:send_email) # => the raw record hash
|
|
139
|
+
# subject.async_step(:send_email, :status) # => "dispatched" / "completed"
|
|
140
|
+
def async_step(step_name, key = nil)
|
|
141
|
+
ensure_executed!
|
|
142
|
+
|
|
143
|
+
entry = composed_entry(step_name)
|
|
144
|
+
return nil unless entry && entry[:type] == :async_step_ref
|
|
145
|
+
|
|
146
|
+
record = RubyReactor.configuration.storage_adapter.retrieve_step_result(
|
|
147
|
+
entry[:context_id] || @reactor_instance.context.context_id,
|
|
148
|
+
step_name,
|
|
149
|
+
RubyReactor.reactor_storage_name(@reactor_instance.class)
|
|
150
|
+
)
|
|
151
|
+
key && record ? record[key.to_s] : record
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The child execution a dispatched `async_reactor` created, as its own
|
|
155
|
+
# TestSubject — the same drill-down `#composed` gives for an inline child.
|
|
156
|
+
def async_reactor(step_name)
|
|
157
|
+
ensure_executed!
|
|
158
|
+
|
|
159
|
+
entry = composed_entry(step_name)
|
|
160
|
+
return nil unless entry && entry[:type] == :async_reactor_ref
|
|
161
|
+
|
|
162
|
+
child_class = RubyReactor::Context.resolve_reactor_class(entry[:reactor_class_name])
|
|
163
|
+
return nil unless child_class
|
|
164
|
+
|
|
165
|
+
child_instance = child_class.find(entry[:execution_id])
|
|
166
|
+
self.class.new(
|
|
167
|
+
reactor_class: child_instance.class,
|
|
168
|
+
inputs: child_instance.context.inputs,
|
|
169
|
+
context: child_instance.context,
|
|
170
|
+
async: @async,
|
|
171
|
+
process_jobs: @process_jobs
|
|
172
|
+
).tap do |s|
|
|
173
|
+
s.instance_variable_set(:@executed, true)
|
|
174
|
+
s.instance_variable_set(:@reactor_instance, child_instance)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def composed_entry(step_name)
|
|
179
|
+
contexts = @reactor_instance.context.composed_contexts
|
|
180
|
+
contexts[step_name] || contexts[step_name.to_s] || contexts[step_name.to_sym]
|
|
181
|
+
end
|
|
182
|
+
|
|
133
183
|
private
|
|
134
184
|
|
|
135
185
|
def traverse_composed(step_name)
|
|
@@ -441,7 +491,8 @@ module RubyReactor
|
|
|
441
491
|
end
|
|
442
492
|
|
|
443
493
|
def prepare_execution_class
|
|
444
|
-
# Even if no interceptors, we might need to subclass to
|
|
494
|
+
# Even if no interceptors, we might need to subclass to force the whole
|
|
495
|
+
# reactor to run in-process.
|
|
445
496
|
return @reactor_class if @interceptors.empty? && @async != false
|
|
446
497
|
|
|
447
498
|
interceptors = @interceptors
|
|
@@ -454,7 +505,7 @@ module RubyReactor
|
|
|
454
505
|
@input_validations = superclass.input_validations.dup
|
|
455
506
|
@middlewares = superclass.middlewares.dup
|
|
456
507
|
@return_step = superclass.return_step
|
|
457
|
-
@
|
|
508
|
+
@background_handoff = superclass.background_handoff
|
|
458
509
|
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
459
510
|
|
|
460
511
|
# 2. Add Name Handling with Unique Registry Entry
|
|
@@ -464,15 +515,16 @@ module RubyReactor
|
|
|
464
515
|
define_singleton_method(:name) { unique_name }
|
|
465
516
|
RubyReactor::Registry.register(unique_name, self)
|
|
466
517
|
|
|
467
|
-
# 3.
|
|
518
|
+
# 3. `async: false` / `run_async(false)` means "run this reactor's full
|
|
519
|
+
# logic here, in one process". Under the new DSL that is three things:
|
|
520
|
+
# suppress the `background` hand-off, and run `async_step` /
|
|
521
|
+
# `async_reactor` units inline instead of dispatching them.
|
|
468
522
|
if force_sync
|
|
523
|
+
@background_handoff = nil
|
|
469
524
|
@steps.each do |name, config|
|
|
470
|
-
next unless config.
|
|
525
|
+
next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
|
|
471
526
|
|
|
472
|
-
|
|
473
|
-
new_config = config.clone
|
|
474
|
-
new_config.instance_variable_set(:@async, false)
|
|
475
|
-
@steps[name] = new_config
|
|
527
|
+
@steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
|
|
476
528
|
end
|
|
477
529
|
end
|
|
478
530
|
end
|
|
@@ -503,7 +555,8 @@ module RubyReactor
|
|
|
503
555
|
|
|
504
556
|
if nested_interceptors.any?
|
|
505
557
|
apply_nested_interceptors(step_config, nested_interceptors)
|
|
506
|
-
|
|
558
|
+
# A mocked inner step only takes effect if the child runs here.
|
|
559
|
+
step_config.instance_variable_set(:@async_dispatch, nil)
|
|
507
560
|
end
|
|
508
561
|
|
|
509
562
|
# Apply direct interceptors (mocks/failures on this step)
|
|
@@ -551,7 +604,7 @@ module RubyReactor
|
|
|
551
604
|
@input_validations = superclass.input_validations.dup
|
|
552
605
|
@middlewares = superclass.middlewares.dup
|
|
553
606
|
@return_step = superclass.return_step
|
|
554
|
-
@
|
|
607
|
+
@background_handoff = superclass.background_handoff
|
|
555
608
|
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
556
609
|
end
|
|
557
610
|
|
|
@@ -606,7 +659,9 @@ module RubyReactor
|
|
|
606
659
|
end
|
|
607
660
|
|
|
608
661
|
step_config.instance_variable_set(:@run_block, wrapper_impl)
|
|
609
|
-
|
|
662
|
+
# The mock replaces the step's body, so it must run where the spec can
|
|
663
|
+
# observe it rather than being dispatched to a worker.
|
|
664
|
+
step_config.instance_variable_set(:@async_dispatch, nil)
|
|
610
665
|
end
|
|
611
666
|
end
|
|
612
667
|
# rubocop:enable Metrics/ClassLength
|
|
@@ -40,6 +40,16 @@ module RubyReactor
|
|
|
40
40
|
release
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Park support: re-adopt a token that stayed checked out across a parked
|
|
44
|
+
# gap. Verifies the token is still in the held set — a reset/expiry in
|
|
45
|
+
# between means the slot was lost and the caller must acquire fresh.
|
|
46
|
+
def reattach(token) # rubocop:disable Naming/PredicateMethod
|
|
47
|
+
return false unless token && adapter.semaphore_held?(@key, token)
|
|
48
|
+
|
|
49
|
+
@token = token
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
private
|
|
44
54
|
|
|
45
55
|
def ensure_initialized
|