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,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # The body of one dispatched `async_step`, shared by every queueing backend
5
+ # (`Adapters::Sidekiq::StepWorker`, `Adapters::ActiveJob::StepWorker`) exactly
6
+ # as `Map::ElementExecutor` is shared by the map element workers.
7
+ #
8
+ # It is deliberately NOT a reactor run: it loads the parent context, resolves
9
+ # just this one step's arguments against it, runs the step body, writes the
10
+ # durable Step Result Record, and publishes the completion signal. Ordering is
11
+ # load-bearing — the record is written BEFORE the signal, so a reader that
12
+ # misses the (at-most-once) signal still finds the answer on its next
13
+ # fallback re-check.
14
+ class StepWorker
15
+ class << self
16
+ def perform(arguments)
17
+ arguments = arguments.transform_keys(&:to_sym)
18
+ new(**slice_arguments(arguments)).perform
19
+ end
20
+
21
+ private
22
+
23
+ def slice_arguments(arguments)
24
+ {
25
+ root_context_id: arguments[:root_context_id],
26
+ reactor_class_name: arguments[:reactor_class_name],
27
+ step_context_id: arguments[:step_context_id],
28
+ step_name: arguments[:step_name].to_sym
29
+ }
30
+ end
31
+ end
32
+
33
+ def initialize(root_context_id:, reactor_class_name:, step_context_id:, step_name:)
34
+ @root_context_id = root_context_id
35
+ @reactor_class_name = reactor_class_name
36
+ @step_context_id = step_context_id || root_context_id
37
+ @step_name = step_name
38
+ end
39
+
40
+ # The lock is what makes a lost unit recoverable: the record alone cannot say
41
+ # whether a `dispatched` unit is mid-flight or gone, so StepSweeper reads this
42
+ # lock as the liveness signal. It also drops a duplicate delivery rather than
43
+ # running the body a second time.
44
+ def perform
45
+ lock = acquire_liveness_lock
46
+ return if lock == :contended
47
+
48
+ perform_unit
49
+ ensure
50
+ lock.release if lock.respond_to?(:release)
51
+ end
52
+
53
+ private
54
+
55
+ def perform_unit
56
+ context = load_step_context
57
+ return record_missing_parent unless context
58
+
59
+ step_config = context.reactor_class&.steps&.[](@step_name)
60
+ return record_missing_step unless step_config
61
+
62
+ complete(run_step(context, step_config), context)
63
+ rescue StandardError => e
64
+ # The unit's failure belongs in its record, where a reader can see it.
65
+ # Raising instead would hand the job to the backend's retry machinery to
66
+ # fail identically N more times while every reader waits out its timeout.
67
+ log(:error, "failed", error: "#{e.class}: #{e.message}")
68
+ complete(RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name), nil)
69
+ end
70
+
71
+ def acquire_liveness_lock
72
+ # Inline testing re-enters this frame synchronously, so the lock would
73
+ # self-contend; it only guards cross-process delivery, impossible inline.
74
+ return :inline if inline_testing_mode?
75
+
76
+ lock = RubyReactor::Lock.new(
77
+ RubyReactor.async_step_lock_key(@step_context_id, @step_name),
78
+ owner: SecureRandom.uuid, ttl: RubyReactor.configuration.context_lock_ttl,
79
+ wait: 0, auto_extend: true
80
+ )
81
+ lock.acquire
82
+ lock
83
+ rescue RubyReactor::Lock::AcquisitionError
84
+ log(:info, "duplicate_dropped")
85
+ :contended
86
+ end
87
+
88
+ def inline_testing_mode?
89
+ defined?(Sidekiq::Testing) && Sidekiq::Testing.respond_to?(:inline?) && Sidekiq::Testing.inline?
90
+ end
91
+
92
+ def run_step(context, step_config)
93
+ arguments = resolve_arguments(step_config, context)
94
+ log(:info, "running")
95
+
96
+ attempt = 0
97
+ result = nil
98
+
99
+ loop do
100
+ attempt += 1
101
+ result = execute_step_body(step_config, arguments, context)
102
+ break unless retry?(step_config, result, attempt)
103
+
104
+ delay = backoff_delay(step_config, attempt)
105
+ log(:warn, "retrying", attempt: attempt, delay: delay)
106
+ sleep(delay)
107
+ end
108
+
109
+ result
110
+ end
111
+
112
+ def execute_step_body(step_config, arguments, context)
113
+ result =
114
+ if step_config.has_run_block?
115
+ args = arguments.empty? ? context.inputs : arguments
116
+ step_config.run_block.call(args, context)
117
+ elsif step_config.has_impl?
118
+ step_config.impl.run(arguments, context)
119
+ else
120
+ RubyReactor.Failure("Step '#{@step_name}' has no implementation")
121
+ end
122
+
123
+ normalize(result)
124
+ rescue StandardError => e
125
+ RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name)
126
+ end
127
+
128
+ # Mirrors `Executor::RetryManager#can_retry_step?` for the one path that
129
+ # never reaches it: an `async_step`'s body runs entirely inside this
130
+ # worker, so retries here must be attempted synchronously in-process
131
+ # rather than requeued as a new job.
132
+ def retry?(step_config, result, attempt)
133
+ return false unless result.is_a?(RubyReactor::Failure) && result.retryable?
134
+
135
+ step_config.retryable? && attempt < step_config.retry_config[:max_attempts]
136
+ end
137
+
138
+ def backoff_delay(step_config, attempt)
139
+ RetryContext.calculate_backoff_delay(
140
+ attempt, step_config.retry_config[:backoff], step_config.retry_config[:base_delay]
141
+ )
142
+ end
143
+
144
+ def normalize(result)
145
+ return result if result.is_a?(RubyReactor::Success) || result.is_a?(RubyReactor::Failure)
146
+
147
+ RubyReactor.Success(result)
148
+ end
149
+
150
+ def resolve_arguments(step_config, context)
151
+ step_config.arguments.to_h do |arg_name, arg_config|
152
+ value = arg_config[:source].resolve(context)
153
+ value = arg_config[:transform].call(value) if arg_config[:transform]
154
+ [arg_name, value]
155
+ end
156
+ end
157
+
158
+ # Write first, publish second. The record is the answer; the signal only
159
+ # saves the reader a fallback interval.
160
+ def complete(result, context)
161
+ storage.store_step_result(
162
+ @step_context_id, @step_name,
163
+ {
164
+ "status" => "completed",
165
+ "success" => result.success?,
166
+ "result" => ContextSerializer.serialize_value(result.success? ? result.value : result.to_h),
167
+ "completed_at" => Time.now.iso8601
168
+ },
169
+ @reactor_class_name
170
+ )
171
+ log(result.success? ? :info : :warn, result.success? ? "completed" : "completed_with_failure")
172
+ storage.publish(RubyReactor.async_step_channel(@step_context_id, @step_name), "done")
173
+ result
174
+ ensure
175
+ # A step body may have mutated the sub-context; nothing else will persist
176
+ # it, and the dashboard reads the parent's blob.
177
+ save_root(context) if context
178
+ end
179
+
180
+ def record_missing_parent
181
+ # The parent was swept or outlived its retention window, so this
182
+ # unit's arguments can never be resolved. A record saying so beats a
183
+ # reader waiting out the full timeout for an answer that will never come.
184
+ log(:error, "parent_context_missing")
185
+ complete(
186
+ RubyReactor.Failure(
187
+ "Parent context #{@step_context_id} for async_step :#{@step_name} is no longer in storage " \
188
+ "(swept, or dispatched longer ago than `context_ttl`). The step's arguments cannot be resolved."
189
+ ),
190
+ nil
191
+ )
192
+ end
193
+
194
+ def record_missing_step
195
+ log(:error, "step_not_found")
196
+ complete(
197
+ RubyReactor.Failure("async_step :#{@step_name} is not defined on #{@reactor_class_name}"),
198
+ nil
199
+ )
200
+ end
201
+
202
+ def load_step_context
203
+ data = storage.retrieve_context(@root_context_id, @reactor_class_name)
204
+ return nil unless data
205
+
206
+ root = ContextSerializer.deserialize_hash(data)
207
+ @root_context = root
208
+ found = find_context(root, @step_context_id)
209
+ # The step runs in its own job; nothing it reaches should hand off again.
210
+ found&.inline_async_execution = true
211
+ found
212
+ rescue RubyReactor::Error::DeserializationError, RubyReactor::Error::SchemaVersionError => e
213
+ log(:error, "parent_context_unreadable", error: "#{e.class}: #{e.message}")
214
+ nil
215
+ end
216
+
217
+ def find_context(context, target_id)
218
+ return context if context.context_id == target_id
219
+
220
+ context.composed_contexts.each_value do |entry|
221
+ next unless entry.is_a?(Hash) && entry[:context].is_a?(RubyReactor::Context)
222
+
223
+ found = find_context(entry[:context], target_id)
224
+ return found if found
225
+ end
226
+ nil
227
+ end
228
+
229
+ def save_root(_context)
230
+ return unless @root_context
231
+
232
+ storage.store_context(@root_context.context_id, ContextSerializer.serialize(@root_context),
233
+ @reactor_class_name)
234
+ rescue StandardError => e
235
+ RubyReactor.configuration.logger.warn(
236
+ "RubyReactor: async_step :#{@step_name} could not persist its parent context: #{e.message}"
237
+ )
238
+ end
239
+
240
+ def storage
241
+ RubyReactor.configuration.storage_adapter
242
+ end
243
+
244
+ # Machine-parseable, and carrying enough identity to correlate a
245
+ # worker-side outcome with the parent execution — which matters more here
246
+ # than elsewhere, because a fire-and-forget failure may have no other surface.
247
+ def log(level, event, **extra)
248
+ fields = {
249
+ event: "ruby_reactor.async_step.#{event}",
250
+ reactor: @reactor_class_name,
251
+ step: @step_name,
252
+ execution_id: @step_context_id
253
+ }.merge(extra)
254
+
255
+ RubyReactor.configuration.logger.public_send(
256
+ level, fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
257
+ )
258
+ end
259
+ end
260
+ end
@@ -15,6 +15,27 @@ module RubyReactor
15
15
  raise NotImplementedError
16
16
  end
17
17
 
18
+ # The durable outcome of one `async_step`, keyed by (parent context, step
19
+ # name). A separate worker writes it concurrently with the still-running
20
+ # parent, so it deliberately lives OUTSIDE the parent's context blob —
21
+ # writing into that blob from two processes would race.
22
+ #
23
+ # `record` is a plain hash: at minimum `status` ("dispatched" or
24
+ # "completed"); a completed record also carries the serialized outcome.
25
+ # The `dispatched` record is written before the job is enqueued, so it
26
+ # doubles as the re-attach marker on recovery.
27
+ def store_step_result(context_id, step_name, record, reactor_class_name)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ def retrieve_step_result(context_id, step_name, reactor_class_name)
32
+ raise NotImplementedError
33
+ end
34
+
35
+ def scan_step_results(count: 1000)
36
+ raise NotImplementedError
37
+ end
38
+
18
39
  def retrieve_map_results(map_id, reactor_class_name, strict_ordering: true)
19
40
  raise NotImplementedError
20
41
  end
@@ -63,7 +84,7 @@ module RubyReactor
63
84
  raise NotImplementedError
64
85
  end
65
86
 
66
- def scan_reactors(pattern: "*", count: 50)
87
+ def scan_reactors(pattern: "*", count: 50, include_dispatched_children: false)
67
88
  raise NotImplementedError
68
89
  end
69
90
 
@@ -8,9 +8,12 @@ module RubyReactor
8
8
  class RedisAdapter < Adapter
9
9
  include RedisLocking
10
10
  include RedisOrderedLocking
11
+ include RedisStepResults
12
+ include RedisPubSub
11
13
 
12
14
  def initialize(redis_config)
13
15
  super()
16
+ @redis_config = redis_config
14
17
  @redis = Redis.new(redis_config)
15
18
  end
16
19
 
@@ -174,20 +177,12 @@ module RubyReactor
174
177
  @redis.del(key)
175
178
  end
176
179
 
177
- def subscribe(channel, &block)
178
- @redis.subscribe(channel, &block)
179
- end
180
-
181
- def publish(channel, message)
182
- @redis.publish(channel, message)
183
- end
184
-
185
180
  def expire(key, seconds)
186
181
  @redis.expire(key, seconds)
187
182
  end
188
183
 
189
184
  # New methods for API
190
- def scan_reactors(pattern: "reactor:*:context:*", count: 50)
185
+ def scan_reactors(pattern: "reactor:*:context:*", count: 50, include_dispatched_children: false)
191
186
  # Use SCAN to find keys matching the pattern
192
187
  results = []
193
188
  batch_keys = []
@@ -199,7 +194,7 @@ module RubyReactor
199
194
 
200
195
  # specific batch size for MGET processing
201
196
  if batch_keys.size >= 50
202
- results.concat(fetch_and_filter_reactors(batch_keys))
197
+ results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children))
203
198
  batch_keys = []
204
199
 
205
200
  # Stop if we have enough results
@@ -208,7 +203,7 @@ module RubyReactor
208
203
  end
209
204
 
210
205
  # Process remaining keys
211
- results.concat(fetch_and_filter_reactors(batch_keys)) if batch_keys.any?
206
+ results.concat(fetch_and_filter_reactors(batch_keys, include_dispatched_children)) if batch_keys.any?
212
207
 
213
208
  results.take(count)
214
209
  end
@@ -320,14 +315,18 @@ module RubyReactor
320
315
  RubyReactor.configuration.context_ttl
321
316
  end
322
317
 
323
- def fetch_and_filter_reactors(keys)
318
+ def fetch_and_filter_reactors(keys, include_dispatched_children = false)
324
319
  return [] if keys.empty?
325
320
 
326
321
  json_results = @redis.mget(*keys)
327
322
 
328
323
  json_results.compact.map do |json|
329
324
  data = JSON.parse(json)
330
- next if data["parent_context_id"] # Skip nested reactors
325
+ next if data["parent_context_id"] && !(include_dispatched_children && dispatched_child?(data))
326
+ # Skip non-context records (e.g. async_step Step Result Records) whose
327
+ # keys are a "reactor:*:context:*" substring match on the SCAN glob
328
+ # (context:#{id}:step_result:#{name}) but aren't a reactor context.
329
+ next unless data["reactor_class"]
331
330
 
332
331
  {
333
332
  id: data["context_id"],
@@ -339,6 +338,11 @@ module RubyReactor
339
338
  end.compact
340
339
  end
341
340
 
341
+ # An `async_reactor` child owns its own job, so a lost job strands it like
342
+ # a top-level reactor. Compose children (inline) and map elements
343
+ # (Map::Sweeper's) carry no marker, so neither is swept.
344
+ def dispatched_child?(data) = data.dig("private_data", "async_dispatched")
345
+
342
346
  def context_key(context_id, reactor_class_name)
343
347
  "reactor:#{reactor_class_name}:context:#{context_id}"
344
348
  end
@@ -123,6 +123,13 @@ module RubyReactor
123
123
  @redis.del("#{key}:init")
124
124
  end
125
125
 
126
+ # Whether a token is still checked out — used to re-adopt a slot held
127
+ # across a parked async wait.
128
+ def semaphore_held(key, token)
129
+ @redis.sismember("#{key}:held", token)
130
+ end
131
+ alias semaphore_held? semaphore_held
132
+
126
133
  def semaphore_acquire(key, timeout: 0)
127
134
  held_key = "#{key}:held"
128
135
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Storage
5
+ # The completion-signal channel behind the notified wait. Pure latency
6
+ # optimisation: at-most-once, unpersisted, never load-bearing — every waiting
7
+ # path ends at a durable record, so a lost signal costs a fallback interval
8
+ # and never correctness.
9
+ module RedisPubSub
10
+ # SUBSCRIBE puts a connection into subscriber mode — every other command on
11
+ # it then fails — so this MUST NOT use the shared client, or one waiter
12
+ # would poison storage for the whole process. A dedicated connection is
13
+ # opened per subscription and closed on the way out.
14
+ #
15
+ # Blocks the calling thread until the block returns truthy for a message
16
+ # (completion signals are one-shot) or the thread is killed.
17
+ def subscribe(channel, &block)
18
+ connection = Redis.new(@redis_config)
19
+ connection.subscribe(channel) do |on|
20
+ on.message { |_channel, message| connection.unsubscribe if block.call(message) }
21
+ end
22
+ ensure
23
+ connection&.close
24
+ end
25
+
26
+ def publish(channel, message)
27
+ @redis.publish(channel, message)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Storage
5
+ # The durable outcome of one `async_step`, keyed by (parent context, step
6
+ # name). It lives OUTSIDE the parent's serialized context blob on purpose: a
7
+ # worker writes it concurrently with the still-running parent, and two
8
+ # writers on one blob race.
9
+ #
10
+ # The `dispatched` record is written before the job is enqueued, so it also
11
+ # serves as the re-attach marker on recovery — "a record exists" is
12
+ # exactly the question "was this already dispatched?".
13
+ module RedisStepResults
14
+ def store_step_result(context_id, step_name, record, reactor_class_name)
15
+ key = step_result_key(context_id, step_name, reactor_class_name)
16
+ # Shares context_ttl with the parent: the record must not outlive what it
17
+ # belongs to, and must not expire before it either.
18
+ @redis.set(key, JSON.generate(record), ex: durability_ttl)
19
+ end
20
+
21
+ def retrieve_step_result(context_id, step_name, reactor_class_name)
22
+ json = @redis.get(step_result_key(context_id, step_name, reactor_class_name))
23
+ return nil unless json
24
+
25
+ JSON.parse(json)
26
+ end
27
+
28
+ # Every record, for StepSweeper — a unit whose job was lost leaves nothing
29
+ # else behind to find it by, since the parent only parks on the read side.
30
+ def scan_step_results(count: 1000)
31
+ records = []
32
+
33
+ @redis.scan_each(match: "reactor:*:context:*:step_result:*", count: 100) do |key|
34
+ json = @redis.get(key)
35
+ records << JSON.parse(json) if json
36
+ return records if records.size >= count
37
+ end
38
+
39
+ records
40
+ end
41
+
42
+ private
43
+
44
+ def step_result_key(context_id, step_name, reactor_class_name)
45
+ "reactor:#{reactor_class_name}:context:#{context_id}:step_result:#{step_name}"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -17,6 +17,12 @@ module RubyReactor
17
17
  # race) and re-enqueued while its worker is actually alive, the duplicate hits
18
18
  # the live lock -> ContextLockContention -> uncapped snooze -> no double run.
19
19
  #
20
+ # `async_reactor` children ARE covered: each runs as its own job, so a lost
21
+ # job strands one exactly like a top-level reactor, and a fire-and-forget
22
+ # child has no parent left waiting to notice. They are identified by the
23
+ # `async_dispatched` marker rather than by absence of a parent, so compose
24
+ # children — same parent link, but run inline — are never re-enqueued.
25
+ #
20
26
  # Map fan-out (element/collector jobs) is NOT covered here — those contexts
21
27
  # carry parent_context_id and scan_reactors filters them out (F6). The map
22
28
  # sweeper (Phase 5) owns them.
@@ -41,7 +47,7 @@ module RubyReactor
41
47
  def run_once(limit: DEFAULT_LIMIT)
42
48
  reenqueued = 0
43
49
 
44
- @storage.scan_reactors(count: limit).each do |reactor|
50
+ @storage.scan_reactors(count: limit, include_dispatched_children: true).each do |reactor|
45
51
  next unless reactor[:status] == "running" # non-terminal only
46
52
  next if @storage.lock_held?("async:#{reactor[:id]}") # worker alive -> leave alone
47
53
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RubyReactor
6
+ # Self-rescheduling recovery tick, shared by every queueing backend's
7
+ # sweeper job class. Each run sweeps both the top-level reactor sweeper and
8
+ # the map sweeper, then schedules the next tick — a perpetual chain the
9
+ # host kicks once via `RubyReactor.start_sweeper!`.
10
+ #
11
+ # super_fetch safety. Sidekiq Enterprise `super_fetch` reliably re-runs a job
12
+ # whose worker died mid-execution. For a self-rescheduling chain that is a
13
+ # hazard: a tick can crash AFTER enqueuing its successor but BEFORE acking, so
14
+ # super_fetch recovers the crashed tick *alongside* the successor it already
15
+ # scheduled — the chain forks and then doubles every interval. We therefore do
16
+ # NOT rely on "exactly one job exists". The next tick is claimed by a
17
+ # per-time-window lock: every duplicate computes the SAME target window and
18
+ # only one wins the claim, so recovered/duplicated ticks collapse back to a
19
+ # single chain. The claim lock is never released — it simply expires — so no
20
+ # delete can race two duplicates into both winning.
21
+ module SweeperJob
22
+ def self.included(base)
23
+ base.extend(ClassMethods)
24
+ end
25
+
26
+ def perform
27
+ config = RubyReactor.configuration
28
+ return unless config.sweeper_enabled
29
+
30
+ run_sweeps(config)
31
+ ensure
32
+ # Always chain forward (unless disabled), even after an error above, so a
33
+ # single bad sweep can't kill recovery. The window lock keeps this from
34
+ # forking under super_fetch.
35
+ self.class.schedule_next if RubyReactor.configuration.sweeper_enabled
36
+ end
37
+
38
+ def run_sweeps(config)
39
+ RubyReactor::Sweeper.run_once(limit: config.sweeper_limit)
40
+ RubyReactor::Map::Sweeper.run_once(limit: config.sweeper_limit)
41
+ RubyReactor::StepSweeper.run_once(limit: config.sweeper_limit)
42
+ rescue StandardError => e
43
+ config.logger.error("RubyReactor sweeper sweep failed: #{e.class}: #{e.message}")
44
+ end
45
+
46
+ module ClassMethods
47
+ # Enqueue the next tick for the upcoming time window, claiming that window
48
+ # so concurrent/duplicate/recovered ticks produce exactly one successor.
49
+ # Idempotent: also safe to call from `start_sweeper!` on every process boot.
50
+ def schedule_next
51
+ interval = RubyReactor.configuration.sweeper_interval
52
+ window = (Time.now.to_i / interval) + 1
53
+
54
+ lock = RubyReactor::Lock.new(
55
+ "sweeper:window:#{window}",
56
+ owner: SecureRandom.uuid,
57
+ ttl: interval * 2, # outlive the window; expires on its own (never released)
58
+ wait: 0,
59
+ auto_extend: false
60
+ )
61
+ lock.acquire # raises AcquisitionError if this window is already claimed
62
+
63
+ delay = (window * interval) - Time.now.to_i
64
+ perform_in([delay, 1].max)
65
+ rescue RubyReactor::Lock::AcquisitionError
66
+ # Another tick already scheduled this window — collapse the duplicate.
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end