ruby_reactor 0.5.4 → 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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
  3. data/.claude/skills/speckit-analyze/SKILL.md +262 -0
  4. data/.claude/skills/speckit-checklist/SKILL.md +374 -0
  5. data/.claude/skills/speckit-clarify/SKILL.md +286 -0
  6. data/.claude/skills/speckit-constitution/SKILL.md +157 -0
  7. data/.claude/skills/speckit-converge/SKILL.md +277 -0
  8. data/.claude/skills/speckit-implement/SKILL.md +224 -0
  9. data/.claude/skills/speckit-plan/SKILL.md +171 -0
  10. data/.claude/skills/speckit-specify/SKILL.md +346 -0
  11. data/.claude/skills/speckit-tasks/SKILL.md +215 -0
  12. data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
  13. data/.release-please-manifest.json +1 -1
  14. data/.specify/extensions/.registry +19 -0
  15. data/.specify/extensions/agent-context/README.md +66 -0
  16. data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
  17. data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
  18. data/.specify/extensions/agent-context/extension.yml +34 -0
  19. data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
  20. data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
  21. data/.specify/extensions.yml +23 -0
  22. data/.specify/feature.json +3 -0
  23. data/.specify/init-options.json +9 -0
  24. data/.specify/integration.json +15 -0
  25. data/.specify/integrations/claude.manifest.json +17 -0
  26. data/.specify/integrations/speckit.manifest.json +17 -0
  27. data/.specify/memory/constitution.md +134 -0
  28. data/.specify/scripts/bash/check-prerequisites.sh +189 -0
  29. data/.specify/scripts/bash/common.sh +619 -0
  30. data/.specify/scripts/bash/create-new-feature.sh +299 -0
  31. data/.specify/scripts/bash/setup-plan.sh +84 -0
  32. data/.specify/scripts/bash/setup-tasks.sh +91 -0
  33. data/.specify/templates/checklist-template.md +40 -0
  34. data/.specify/templates/constitution-template.md +50 -0
  35. data/.specify/templates/plan-template.md +113 -0
  36. data/.specify/templates/spec-template.md +131 -0
  37. data/.specify/templates/tasks-template.md +252 -0
  38. data/.specify/workflows/speckit/workflow.yml +77 -0
  39. data/.specify/workflows/workflow-registry.json +13 -0
  40. data/CHANGELOG.md +121 -1
  41. data/CLAUDE.md +5 -0
  42. data/README.md +181 -39
  43. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  46. data/lib/ruby_reactor/adapters/active_job/router.rb +106 -0
  47. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  48. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  49. data/lib/ruby_reactor/adapters/active_job/worker.rb +29 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/router.rb +106 -0
  53. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  54. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  55. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +28 -0
  56. data/lib/ruby_reactor/async_waiter.rb +113 -0
  57. data/lib/ruby_reactor/configuration.rb +54 -5
  58. data/lib/ruby_reactor/context.rb +21 -0
  59. data/lib/ruby_reactor/context_serializer.rb +2 -0
  60. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  61. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  62. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  63. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  64. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  65. data/lib/ruby_reactor/dsl/lockable.rb +1 -1
  66. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  67. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  68. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  69. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  70. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  71. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  72. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  73. data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
  74. data/lib/ruby_reactor/executor/result_handler.rb +12 -1
  75. data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
  76. data/lib/ruby_reactor/executor/step_executor.rb +58 -11
  77. data/lib/ruby_reactor/executor.rb +162 -8
  78. data/lib/ruby_reactor/lock.rb +19 -0
  79. data/lib/ruby_reactor/map/element_executor.rb +7 -2
  80. data/lib/ruby_reactor/map/helpers.rb +1 -1
  81. data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
  82. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  83. data/lib/ruby_reactor/open_telemetry.rb +1 -1
  84. data/lib/ruby_reactor/reactor.rb +25 -2
  85. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  86. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  87. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +4 -3
  88. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  89. data/lib/ruby_reactor/rspec/test_subject.rb +77 -18
  90. data/lib/ruby_reactor/rspec.rb +4 -0
  91. data/lib/ruby_reactor/semaphore.rb +10 -0
  92. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  93. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  94. data/lib/ruby_reactor/step/map_step.rb +1 -1
  95. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  96. data/lib/ruby_reactor/step_worker.rb +260 -0
  97. data/lib/ruby_reactor/storage/adapter.rb +22 -1
  98. data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
  99. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  100. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  101. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  102. data/lib/ruby_reactor/sweeper.rb +7 -1
  103. data/lib/ruby_reactor/sweeper_job.rb +71 -0
  104. data/lib/ruby_reactor/template/result.rb +151 -5
  105. data/lib/ruby_reactor/version.rb +1 -1
  106. data/lib/ruby_reactor/web/api.rb +94 -16
  107. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
  108. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
  109. data/lib/ruby_reactor/web/public/index.html +2 -2
  110. data/lib/ruby_reactor/worker.rb +276 -0
  111. data/lib/ruby_reactor.rb +64 -4
  112. data/specs/001-background-async-steps/checklists/requirements.md +39 -0
  113. data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
  114. data/specs/001-background-async-steps/data-model.md +117 -0
  115. data/specs/001-background-async-steps/plan.md +168 -0
  116. data/specs/001-background-async-steps/quickstart.md +102 -0
  117. data/specs/001-background-async-steps/research.md +150 -0
  118. data/specs/001-background-async-steps/spec.md +146 -0
  119. data/specs/001-background-async-steps/tasks.md +271 -0
  120. data/specs/active_job.md +259 -0
  121. metadata +80 -8
  122. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  123. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  124. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  125. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  126. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
  127. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  128. data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module Sidekiq
6
+ class Router
7
+ # Identity-only payload: the worker rehydrates the live context from storage
8
+ # by (context_id, reactor_class_name). The caller already holds context_id, so
9
+ # there is no blob to deserialize here.
10
+ def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
11
+ job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_async(context_id, reactor_class_name)
12
+ RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
13
+ execution_id: context_id)
14
+ end
15
+
16
+ def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
17
+ job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_in(delay, context_id, reactor_class_name)
18
+ RubyReactor::DispatchResult.new(job_id: job_id, intermediate_results: intermediate_results,
19
+ execution_id: context_id)
20
+ end
21
+
22
+ # One `async_step`'s work, dispatched as its own independent unit. The
23
+ # payload is identity-only for the same reason every other dispatch here
24
+ # is: the worker rehydrates the parent context from storage.
25
+ def self.perform_step_async(root_context_id:, reactor_class_name:, step_context_id:, step_name:)
26
+ job_id = RubyReactor::Adapters::Sidekiq::StepWorker.perform_async(
27
+ {
28
+ "root_context_id" => root_context_id,
29
+ "reactor_class_name" => reactor_class_name,
30
+ "step_context_id" => step_context_id,
31
+ "step_name" => step_name.to_s
32
+ }
33
+ )
34
+ RubyReactor::DispatchResult.new(job_id: job_id)
35
+ end
36
+
37
+ # rubocop:disable Metrics/ParameterLists
38
+ def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
39
+ strict_ordering:, parent_context_id:, parent_reactor_class_name:,
40
+ step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
41
+ job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_async(
42
+ {
43
+ "map_id" => map_id,
44
+ "element_id" => element_id,
45
+ "index" => index,
46
+ "serialized_inputs" => serialized_inputs,
47
+ "reactor_class_info" => reactor_class_info,
48
+ "strict_ordering" => strict_ordering,
49
+ "parent_context_id" => parent_context_id,
50
+ "parent_reactor_class_name" => parent_reactor_class_name,
51
+ "step_name" => step_name,
52
+ "batch_size" => batch_size,
53
+ "serialized_context" => serialized_context,
54
+ "fail_fast" => fail_fast
55
+ }
56
+ )
57
+ RubyReactor::DispatchResult.new(job_id: job_id)
58
+ end
59
+
60
+ def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
61
+ reactor_class_info:, strict_ordering:, parent_context_id:,
62
+ parent_reactor_class_name:, step_name:, batch_size: nil,
63
+ serialized_context: nil, fail_fast: nil)
64
+ job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_in(
65
+ delay,
66
+ {
67
+ "map_id" => map_id,
68
+ "element_id" => element_id,
69
+ "index" => index,
70
+ "serialized_inputs" => serialized_inputs,
71
+ "reactor_class_info" => reactor_class_info,
72
+ "strict_ordering" => strict_ordering,
73
+ "parent_context_id" => parent_context_id,
74
+ "parent_reactor_class_name" => parent_reactor_class_name,
75
+ "step_name" => step_name,
76
+ "batch_size" => batch_size,
77
+ "serialized_context" => serialized_context,
78
+ "fail_fast" => fail_fast
79
+ }
80
+ )
81
+ # Return an DispatchResult so RetryManager#handle_async_retry recognises the
82
+ # element was successfully requeued and yields a RetryQueuedResult.
83
+ RubyReactor::DispatchResult.new(job_id: job_id)
84
+ end
85
+ # rubocop:enable Metrics/ParameterLists
86
+
87
+ # rubocop:disable Metrics/ParameterLists
88
+ def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
89
+ strict_ordering:, timeout:)
90
+ job_id = RubyReactor::Adapters::Sidekiq::MapCollectorWorker.perform_async(
91
+ {
92
+ "parent_context_id" => parent_context_id,
93
+ "map_id" => map_id,
94
+ "parent_reactor_class_name" => parent_reactor_class_name,
95
+ "step_name" => step_name,
96
+ "strict_ordering" => strict_ordering,
97
+ "timeout" => timeout
98
+ }
99
+ )
100
+ RubyReactor::DispatchResult.new(job_id: job_id)
101
+ end
102
+ # rubocop:enable Metrics/ParameterLists
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Adapters
5
+ module Sidekiq
6
+ # One dispatched `async_step`, mirroring MapElementWorker: the backend
7
+ # binding only, all behavior in the shared `RubyReactor::StepWorker`.
8
+ class StepWorker
9
+ include ::Sidekiq::Worker
10
+
11
+ def perform(arguments)
12
+ RubyReactor::StepWorker.perform(arguments)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module Sidekiq
8
+ class SweeperWorker
9
+ include ::Sidekiq::Worker
10
+ include RubyReactor::SweeperJob
11
+
12
+ # retry: false — the sweep is idempotent and self-rescheduling, so a failed
13
+ # tick must not pile up Sidekiq retries; the next tick (or a super_fetch
14
+ # recovery) re-runs it anyway.
15
+ sidekiq_options retry: false, queue: RubyReactor.configuration.queue_name
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+
5
+ module RubyReactor
6
+ module Adapters
7
+ module Sidekiq
8
+ # Sidekiq worker for executing RubyReactor reactors asynchronously
9
+ # with non-blocking retry capabilities. All resume/snooze/escalate logic
10
+ # lives in `RubyReactor::Worker` — this class only wires it to Sidekiq.
11
+ class Worker
12
+ include ::Sidekiq::Worker
13
+ include RubyReactor::Worker
14
+
15
+ # Enable Sidekiq retries for infrastructure failures only
16
+ sidekiq_options retry: RubyReactor.configuration.job_retry_count, dead: false,
17
+ queue: RubyReactor.configuration.queue_name
18
+
19
+ sidekiq_retries_exhausted do |msg, exception|
20
+ # With `dead: false` this job is discarded after its last retry, so
21
+ # this hook is the only place left to surface the failure: mark the
22
+ # context failed and signal any reader waiting on it.
23
+ RubyReactor::Worker.record_retries_exhausted(msg["args"], exception)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # The shared wait core behind `result(:name)` for an `async_step` or an
5
+ # `async_reactor` that has not finished yet. Both callers hand it a pub/sub
6
+ # channel and a callable that returns the terminal value (or nil while the work
7
+ # is still in flight), so there is exactly one implementation of the wait.
8
+ #
9
+ # The contract is "durable record answers, signal only hurries":
10
+ #
11
+ # * the completing side writes its durable outcome FIRST, then publishes;
12
+ # * this side checks the durable target, then blocks until either the signal
13
+ # arrives or a coarse fallback interval elapses, then re-checks.
14
+ #
15
+ # Redis pub/sub is at-most-once and unpersisted, so a dropped signal must never
16
+ # cost correctness — only fallback latency. Every exit path here goes through
17
+ # the durable check, and the whole thing is bounded: it raises rather than
18
+ # hanging (SC-005).
19
+ class AsyncWaiter
20
+ # Latency backstop for a lost signal, not a tuning surface — derived from the
21
+ # timeout rather than configured (Principle V). The clamp guarantees ~10
22
+ # re-checks inside any bound, so a dropped notification costs at most ~10% of
23
+ # the wait, and never re-checks hotter than once a second.
24
+ FALLBACK_BOUNDS = (1.0..5.0)
25
+
26
+ attr_reader :channel, :timeout
27
+
28
+ # @param channel [String] completion-signal channel to listen on
29
+ # @param timeout [Numeric, nil] seconds; defaults to `async_wait_timeout`
30
+ # @yieldreturn [Object, nil] the terminal value, or nil while still pending
31
+ def initialize(channel:, timeout: nil, &terminal_check)
32
+ @channel = channel
33
+ @timeout = timeout || RubyReactor.configuration.async_wait_timeout
34
+ @terminal_check = terminal_check
35
+ @mutex = Mutex.new
36
+ @condition = ConditionVariable.new
37
+ @signalled = false
38
+ end
39
+
40
+ def wait
41
+ deadline = monotonic + @timeout
42
+ subscriber = start_subscriber
43
+
44
+ loop do
45
+ value = @terminal_check.call
46
+ return value unless value.nil?
47
+
48
+ remaining = deadline - monotonic
49
+ raise timeout_error if remaining <= 0
50
+
51
+ block_until_signalled_or([fallback_interval, remaining].min)
52
+ end
53
+ ensure
54
+ # ponytail: killing the thread is enough — `subscribe`'s own ensure closes
55
+ # the dedicated connection on the way out.
56
+ subscriber&.kill
57
+ end
58
+
59
+ private
60
+
61
+ def fallback_interval
62
+ (@timeout / 10.0).clamp(FALLBACK_BOUNDS)
63
+ end
64
+
65
+ # Subscribing happens before the first durable check so a completion landing
66
+ # mid-check still wakes us. The subscription is established asynchronously,
67
+ # so this narrows the race rather than closing it outright — which is fine
68
+ # precisely because the fallback re-check, not the signal, is what makes the
69
+ # wait correct.
70
+ def start_subscriber
71
+ Thread.new do
72
+ RubyReactor.configuration.storage_adapter.subscribe(@channel) do |_message|
73
+ signal!
74
+ true # stop subscribing — completion is one-shot
75
+ end
76
+ rescue StandardError => e
77
+ # A waiter that loses its notification channel degrades to the fallback
78
+ # re-check; it must not take the waiting step down with it.
79
+ RubyReactor.configuration.logger.warn(
80
+ "RubyReactor: async completion subscription to #{@channel} failed (#{e.class}: #{e.message}); " \
81
+ "falling back to periodic re-checks"
82
+ )
83
+ end
84
+ end
85
+
86
+ def signal!
87
+ @mutex.synchronize do
88
+ @signalled = true
89
+ @condition.broadcast
90
+ end
91
+ end
92
+
93
+ def block_until_signalled_or(seconds)
94
+ @mutex.synchronize do
95
+ @condition.wait(@mutex, seconds) unless @signalled
96
+ @signalled = false
97
+ end
98
+ end
99
+
100
+ def timeout_error
101
+ Error::AsyncWaitTimeoutError.new(
102
+ "Timed out after #{@timeout}s waiting for async completion on '#{@channel}'. " \
103
+ "The dispatched work never reached a terminal state within " \
104
+ "`RubyReactor.configuration.async_wait_timeout` — check that a worker is running and " \
105
+ "consuming the queue, or raise the timeout if this unit is legitimately slower."
106
+ )
107
+ end
108
+
109
+ def monotonic
110
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+ end
112
+ end
113
+ end
@@ -7,13 +7,24 @@ module RubyReactor
7
7
  class Configuration
8
8
  include Singleton
9
9
 
10
- attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router,
10
+ attr_writer :queue_name, :job_retry_count, :logger, :async_router,
11
11
  :lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts,
12
12
  :middlewares, :context_ttl, :context_lock_ttl, :checkpoint_min_interval,
13
- :sweeper_enabled, :sweeper_interval, :sweeper_limit
13
+ :sweeper_enabled, :sweeper_interval, :sweeper_limit, :async_wait_timeout,
14
+ :async_park_timeout
14
15
 
16
+ def queue_name
17
+ @queue_name ||= :default
18
+ end
19
+
20
+ # Deprecated alias for `queue_name` — kept so existing Sidekiq-only configs
21
+ # don't break.
15
22
  def sidekiq_queue
16
- @sidekiq_queue ||= :default
23
+ queue_name
24
+ end
25
+
26
+ def sidekiq_queue=(value)
27
+ self.queue_name = value
17
28
  end
18
29
 
19
30
  # Retention TTL (seconds) for a stored reactor context. Storage is
@@ -79,8 +90,46 @@ module RubyReactor
79
90
  @context_lock_ttl ||= 60
80
91
  end
81
92
 
93
+ # Seconds a step blocks in the notified wait when it reads
94
+ # `result(:name)` for an `async_step` / `async_reactor` that has not finished
95
+ # yet. Never unbounded: on expiry the referencing step fails with an
96
+ # `Error::AsyncWaitTimeoutError`.
97
+ #
98
+ # 30s must comfortably exceed dispatch -> worker pickup -> completion for a
99
+ # small unit under a healthy queue, while staying under the request/job
100
+ # timeouts of typical hosts (Sidekiq's 25s shutdown grace, Puma's 60s) so a
101
+ # stuck wait fails loudly on our terms instead of being killed from outside.
102
+ #
103
+ # The wait's fallback re-check interval is DERIVED from this, not configured:
104
+ # `async_wait_timeout / 10` clamped to 1..5s (see AsyncWaiter).
105
+ def async_wait_timeout
106
+ @async_wait_timeout ||= 30
107
+ end
108
+
109
+ # Total seconds a WORKER-side `result(:name)` wait may stay parked before
110
+ # failing with `Error::AsyncWaitTimeoutError`, measured from the unit's
111
+ # `dispatched_at`. Inside a worker a pending result does not block the
112
+ # thread for `async_wait_timeout` — after a short in-thread grace the job
113
+ # parks (re-enqueues itself, locks kept held) and re-checks on redelivery,
114
+ # so this bound can be generous where the blocking one must stay tight.
115
+ # `:infinity` removes the bound (the context TTL then remains the only
116
+ # backstop), mirroring `lock_snooze_max_attempts`.
117
+ def async_park_timeout
118
+ @async_park_timeout ||= 3600
119
+ end
120
+
121
+ def job_retry_count
122
+ @job_retry_count ||= 3
123
+ end
124
+
125
+ # Deprecated alias for `job_retry_count` — kept so existing Sidekiq-only
126
+ # configs don't break.
82
127
  def sidekiq_retry_count
83
- @sidekiq_retry_count ||= 3
128
+ job_retry_count
129
+ end
130
+
131
+ def sidekiq_retry_count=(value)
132
+ self.job_retry_count = value
84
133
  end
85
134
 
86
135
  # Base seconds the Sidekiq worker waits before re-checking a contended lock.
@@ -104,7 +153,7 @@ module RubyReactor
104
153
  end
105
154
 
106
155
  def async_router
107
- @async_router ||= RubyReactor::SidekiqAdapter
156
+ @async_router ||= RubyReactor::Adapters::Sidekiq::Router
108
157
  end
109
158
 
110
159
  def storage
@@ -2,6 +2,27 @@
2
2
 
3
3
  module RubyReactor
4
4
  class Context
5
+ # `composed_contexts[step_name]` is the one channel for "children of this
6
+ # context, referenced for later drill-down" — the dashboard's
7
+ # `hydrate_composed_contexts` and `RSpec::TestSubject`'s traversal both
8
+ # switch on the `type:` tag. Four tags, all plain data (no serialization
9
+ # change between them):
10
+ #
11
+ # :composed => { name:, type:, context: } — an inline `compose` child
12
+ # :map_ref => { name:, type:, map_id:, element_reactor_class: }
13
+ # :async_step_ref => { name:, type:, dispatched_at: }
14
+ # The reference only. The `async_step`'s actual outcome lives in the Step
15
+ # Result Record bucket, keyed by (context_id, step_name), because a
16
+ # separate worker writes it concurrently with this still-running context.
17
+ # :async_reactor_ref => { name:, type:, execution_id:, reactor_class_name:, dispatched_at: }
18
+ # The child is an ordinary addressable reactor, so its outcome is simply
19
+ # its own context row — no extra storage primitive.
20
+ #
21
+ # Both async refs are written SYNCHRONOUSLY by the dispatching step, before
22
+ # it returns, so there is no cross-process write race on the reference
23
+ # itself (unlike the result).
24
+ COMPOSED_CONTEXT_TYPES = %i[composed map_ref async_step_ref async_reactor_ref].freeze
25
+
5
26
  attr_accessor :inputs, :intermediate_results, :private_data, :current_step, :retry_count, :concurrency_key,
6
27
  :retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack,
7
28
  :parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata,
@@ -185,6 +185,8 @@ module RubyReactor
185
185
  # Simplifies data for public API usage (removes wrappers, flattens types)
186
186
  def simplify_for_api(value)
187
187
  case value
188
+ when RubyReactor::Map::ResultEnumerator
189
+ RubyReactor::Map::ResultSummary.build(value)
188
190
  when Hash
189
191
  simplified = value.each_with_object({}) do |(k, v), hash|
190
192
  hash[k.to_s] = simplify_for_api(v)
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ # The three ways a reactor sends work out of the calling process, kept
6
+ # together because they are one design decision seen from three distances:
7
+ #
8
+ # background — the rest of THIS reactor moves to a worker
9
+ # (`all: true` — EVERYTHING, incl. input validation)
10
+ # async_step — ONE step's work becomes its own job
11
+ # async_reactor — a whole nested reactor runs independently
12
+ #
13
+ # Mixed into `Dsl::Reactor::ClassMethods`.
14
+ module AsyncMacros
15
+ # The single, unambiguous cut point between what runs in
16
+ # the calling process and what is handed to a worker. Replaces the
17
+ # per-step `async` flag, where only the first flagged step ever took
18
+ # effect and the rest were silently ignored, and the whole-reactor
19
+ # `async true` flag, which named the same idea with a different word.
20
+ #
21
+ # background after: :second # :second is the LAST step to run here
22
+ # background before: :third # :third is the FIRST step in the worker
23
+ # background all: true # the ENTIRE reactor runs in the worker,
24
+ # # including input validation
25
+ #
26
+ # `after:`/`before:` name one cut point from opposite sides — identical
27
+ # in a linear chain, different in a DAG, where each pins the step it
28
+ # names. `all:` names no step: there is nothing left to pin, everything
29
+ # moves. Triggering is keyed to REACHING the named step (or, for `all:`,
30
+ # to the run starting at all), not to where this declaration sits in
31
+ # the class body.
32
+ def background(after: nil, before: nil, all: false)
33
+ point = validate_background_declaration!(after, before, all)
34
+
35
+ @background_handoff = point
36
+ end
37
+
38
+ # The normalized `{ mode:, step: }` pair — one reader, never a one-sided
39
+ # `background_after`, so no consumer can be accidentally implemented for
40
+ # `after:` only. `step` is `nil` when `mode` is `:all`.
41
+ def background_handoff
42
+ @background_handoff
43
+ end
44
+
45
+ # True only for the whole-reactor hand-off (`background all: true`) —
46
+ # the entire run, including input validation, happens in a worker.
47
+ def async?
48
+ background_handoff&.fetch(:mode, nil) == :all
49
+ end
50
+
51
+ def validate_background_declaration!(after, before, all)
52
+ given = [after, before, all].count { |v| v }
53
+ if given > 1
54
+ raise RubyReactor::Error::ValidationError,
55
+ "`background` takes exactly one of `after:`, `before:`, or `all:`, got more than one " \
56
+ "(after: #{after.inspect}, before: #{before.inspect}, all: #{all.inspect}). Each names a " \
57
+ "different hand-off shape: `after: :x` / `before: :x` pin a cut point around step :x; " \
58
+ "`all: true` sends the whole reactor, including input validation."
59
+ end
60
+
61
+ if given.zero?
62
+ raise RubyReactor::Error::ValidationError,
63
+ "`background` requires one of `after: :step_name` (that step is the last to run in the " \
64
+ "calling process), `before: :step_name` (that step is the first to run in the worker), or " \
65
+ "`all: true` (the entire reactor, including input validation, runs in the worker)."
66
+ end
67
+
68
+ point = if all
69
+ { mode: :all, step: nil }
70
+ else
71
+ { mode: after ? :after : :before, step: (after || before).to_sym }
72
+ end
73
+
74
+ # Re-declaring the SAME point is a no-op — a class body can be
75
+ # evaluated twice (Rails reloading, a spec reopening a fixture class)
76
+ # and that must not be an error. A DIFFERENT second point is the real
77
+ # footgun `background` exists to remove.
78
+ if background_handoff && background_handoff != point
79
+ raise RubyReactor::Error::ValidationError,
80
+ "#{name || "This reactor"} already declares `background " \
81
+ "#{describe_handoff_point(background_handoff)}` and cannot also declare `background " \
82
+ "#{describe_handoff_point(point)}`. A reactor has exactly one hand-off point — a second " \
83
+ "would reintroduce the ambiguity `background` exists to remove."
84
+ end
85
+
86
+ validate_step_handoff_point!(point) unless point[:mode] == :all
87
+
88
+ point
89
+ end
90
+ private :validate_background_declaration!
91
+
92
+ def validate_step_handoff_point!(point)
93
+ step_name = point[:step]
94
+ unless steps.key?(step_name)
95
+ raise RubyReactor::Error::ValidationError,
96
+ "`background` names unknown step :#{step_name}. Known steps: " \
97
+ "#{steps.keys.map { |k| ":#{k}" }.join(", ")}. The step must be defined before the " \
98
+ "`background` declaration."
99
+ end
100
+
101
+ reject_interrupt_handoff_point!(point)
102
+ end
103
+ private :validate_step_handoff_point!
104
+
105
+ def describe_handoff_point(point)
106
+ point[:mode] == :all ? "all: true" : "#{point[:mode]}: :#{point[:step]}"
107
+ end
108
+ private :describe_handoff_point
109
+
110
+ # An interrupt re-enters the reactor from a foreground process, so an
111
+ # edge-triggered hand-off keyed to it either never fires (`after:` — the
112
+ # resume path skips the already-resulted step) or enqueues a worker that
113
+ # instantly pauses and swallows the InterruptResult (`before:`). Both are
114
+ # the silent-failure class `background` exists to remove.
115
+ def reject_interrupt_handoff_point!(point)
116
+ config = steps[point[:step]]
117
+ return unless config.respond_to?(:interrupt?) && config.interrupt?
118
+
119
+ raise RubyReactor::Error::ValidationError,
120
+ "`background #{point[:mode]}: :#{point[:step]}` names an interrupt step, which cannot be a " \
121
+ "hand-off point. To resume :#{point[:step]} in a worker, declare " \
122
+ "`interrupt :#{point[:step]}, resume: :background` instead; to hand off around it, name an " \
123
+ "ordinary step on the side you need."
124
+ end
125
+ private :reject_interrupt_handoff_point!
126
+ # A step whose work is dispatched to its own independent worker
127
+ # job while this reactor keeps executing every other ready step. Same
128
+ # call shape and same block DSL as `step` — `argument`, `run`,
129
+ # `compensate`, `undo`, `retries`, validators all behave identically;
130
+ # only WHERE the body runs changes.
131
+ #
132
+ # Any step reading `result(:name)` blocks (bounded) until the unit
133
+ # finishes. A failure with no reader does NOT compensate this reactor —
134
+ # compensation is opt-in, via a reader that inspects the result and
135
+ # returns `Failure` itself.
136
+ def async_step(name, impl = nil, &block)
137
+ builder = RubyReactor::Dsl::StepBuilder.new(name, impl, self)
138
+ builder.instance_eval(&block) if block_given?
139
+
140
+ steps[name] = builder.build(async_dispatch: :step)
141
+ end
142
+
143
+ # Dispatch a whole nested reactor to run INDEPENDENTLY — linked
144
+ # to this one by execution id for traceability, but excluded from its
145
+ # compensation graph. Fire-and-forget unless a later step reads
146
+ # `result(:name)`, which blocks until the child is terminal and hands
147
+ # over the child's real Success/Failure to inspect.
148
+ #
149
+ # Contrast with `compose`, which runs the child inline, synchronously,
150
+ # and fully wired into the parent's rollback path.
151
+ def async_reactor(name, child_reactor_class, &block)
152
+ builder = RubyReactor::Dsl::AsyncReactorBuilder.new(name, child_reactor_class, self)
153
+ builder.instance_eval(&block) if block_given?
154
+
155
+ steps[name] = builder.build
156
+ end
157
+
158
+ # A reactor's return value must come from a step that ran in the calling
159
+ # process. An `async_step` / `async_reactor` may still be in flight when
160
+ # this reactor finishes — that is the whole point of dispatching it — so
161
+ # returning it would either mean returning nothing or silently turning
162
+ # the fire-and-forget contract into a blocking wait.
163
+ def reject_async_return_step!(step_name)
164
+ config = steps[step_name]
165
+ return unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
166
+
167
+ kind = config.async_dispatch == :reactor ? "async_reactor" : "async_step"
168
+ raise RubyReactor::Error::ValidationError,
169
+ "`returns :#{step_name}` is invalid: :#{step_name} is an `#{kind}`, which may still be " \
170
+ "running when this reactor finishes. Return a same-process step instead — if you need the " \
171
+ "dispatched outcome, add a step that reads `result(:#{step_name})` and return that."
172
+ end
173
+ private :reject_async_return_step!
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ # Builds the `async_reactor` dispatch step. Same `argument` mapping shape as
6
+ # `ComposeBuilder`, but deliberately NOT a subclass of it: `compose`'s
7
+ # builder warns that a child's `with_ordered_lock` is ignored (true for an
8
+ # inline child, which bypasses `Reactor#run`) whereas an `async_reactor`
9
+ # child is dispatched through the full pre-enqueue sequence and DOES get its
10
+ # ordering nonce.
11
+ class AsyncReactorBuilder
12
+ include RubyReactor::Dsl::TemplateHelpers
13
+
14
+ attr_accessor :name, :child_reactor_class, :argument_mappings
15
+
16
+ def initialize(name, child_reactor_class, reactor = nil)
17
+ @name = name
18
+ @child_reactor_class = child_reactor_class
19
+ @reactor = reactor
20
+ @argument_mappings = {}
21
+ @retry_config = {}
22
+ end
23
+
24
+ def argument(child_input_name, source)
25
+ @argument_mappings[child_input_name] = source
26
+ end
27
+
28
+ def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
29
+ @retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
30
+ end
31
+
32
+ def build
33
+ RubyReactor::Dsl::StepConfig.new(
34
+ async_dispatch: :reactor,
35
+ name: @name,
36
+ impl: RubyReactor::Step::AsyncReactorStep,
37
+ arguments: {
38
+ async_reactor_class: { source: RubyReactor::Template::Value.new(@child_reactor_class) },
39
+ argument_mappings: { source: RubyReactor::Template::Value.new(@argument_mappings) }
40
+ },
41
+ run_block: nil,
42
+ # No compensate/undo: the child is deliberately outside the parent's
43
+ # compensation graph. Compensation is opt-in, via a later step
44
+ # that reads `result(:name)` and decides to fail.
45
+ compensate_block: nil,
46
+ undo_block: nil,
47
+ conditions: [],
48
+ guards: [],
49
+ dependencies: dependencies_from_mappings,
50
+ args_validator: nil,
51
+ output_validator: nil,
52
+ retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def dependencies_from_mappings
59
+ @argument_mappings.each_value
60
+ .select { |source| source.is_a?(RubyReactor::Template::Result) }
61
+ .map(&:step_name)
62
+ .uniq
63
+ end
64
+ end
65
+ end
66
+ end