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.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
- data/.claude/skills/speckit-analyze/SKILL.md +262 -0
- data/.claude/skills/speckit-checklist/SKILL.md +374 -0
- data/.claude/skills/speckit-clarify/SKILL.md +286 -0
- data/.claude/skills/speckit-constitution/SKILL.md +157 -0
- data/.claude/skills/speckit-converge/SKILL.md +277 -0
- data/.claude/skills/speckit-implement/SKILL.md +224 -0
- data/.claude/skills/speckit-plan/SKILL.md +171 -0
- data/.claude/skills/speckit-specify/SKILL.md +346 -0
- data/.claude/skills/speckit-tasks/SKILL.md +215 -0
- data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions/.registry +19 -0
- data/.specify/extensions/agent-context/README.md +66 -0
- data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
- data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
- data/.specify/extensions/agent-context/extension.yml +34 -0
- data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
- data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
- data/.specify/extensions.yml +23 -0
- data/.specify/feature.json +3 -0
- data/.specify/init-options.json +9 -0
- data/.specify/integration.json +15 -0
- data/.specify/integrations/claude.manifest.json +17 -0
- data/.specify/integrations/speckit.manifest.json +17 -0
- data/.specify/memory/constitution.md +134 -0
- data/.specify/scripts/bash/check-prerequisites.sh +189 -0
- data/.specify/scripts/bash/common.sh +619 -0
- data/.specify/scripts/bash/create-new-feature.sh +299 -0
- data/.specify/scripts/bash/setup-plan.sh +84 -0
- data/.specify/scripts/bash/setup-tasks.sh +91 -0
- data/.specify/templates/checklist-template.md +40 -0
- data/.specify/templates/constitution-template.md +50 -0
- data/.specify/templates/plan-template.md +113 -0
- data/.specify/templates/spec-template.md +131 -0
- data/.specify/templates/tasks-template.md +252 -0
- data/.specify/workflows/speckit/workflow.yml +77 -0
- data/.specify/workflows/workflow-registry.json +13 -0
- data/CHANGELOG.md +121 -1
- data/CLAUDE.md +5 -0
- data/README.md +181 -39
- data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
- data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +106 -0
- data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +29 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +106 -0
- data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
- data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +28 -0
- data/lib/ruby_reactor/async_waiter.rb +113 -0
- data/lib/ruby_reactor/configuration.rb +54 -5
- 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 +7 -2
- 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/active_job_helpers.rb +52 -0
- data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +4 -3
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
- data/lib/ruby_reactor/rspec/test_subject.rb +77 -18
- data/lib/ruby_reactor/rspec.rb +4 -0
- 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 +71 -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 +276 -0
- data/lib/ruby_reactor.rb +64 -4
- 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 +259 -0
- metadata +80 -8
- data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
- data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
- 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"]
|
|
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
|
data/lib/ruby_reactor/sweeper.rb
CHANGED
|
@@ -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
|