ruby_reactor 0.8.2 → 0.8.3
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-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +2 -2
- data/README.md +35 -2
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +31 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +7 -1
- data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
- data/lib/ruby_reactor/step/compose_step.rb +14 -3
- data/lib/ruby_reactor/step.rb +49 -7
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +27 -5
- data/specs/future_improvements.md +250 -0
- metadata +8 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- data/specs/possible_feature.md +0 -22
data/specs/active_job.md
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
# Pluggable Background Adapters (Sidekiq → ActiveJob)
|
|
2
|
-
|
|
3
|
-
## Goal
|
|
4
|
-
|
|
5
|
-
Today RubyReactor is hardwired to Sidekiq for everything async: enqueuing,
|
|
6
|
-
resuming, map-element fan-out/collection, and the recovery sweeper. We want
|
|
7
|
-
to support other background processors — starting with ActiveJob — without
|
|
8
|
-
duplicating the reactor-resume/snooze/escalate logic per adapter.
|
|
9
|
-
|
|
10
|
-
## What's already abstracted (good news)
|
|
11
|
-
|
|
12
|
-
The **enqueue side** is already behind a seam:
|
|
13
|
-
|
|
14
|
-
- `RubyReactor.configuration.async_router` (default `RubyReactor::Adapters::Sidekiq::Router`,
|
|
15
|
-
[configuration.rb:126-128](../lib/ruby_reactor/configuration.rb#L126-L128)) is the
|
|
16
|
-
only thing the core engine calls to go async. Call sites:
|
|
17
|
-
[reactor.rb:117](../lib/ruby_reactor/reactor.rb#L117),
|
|
18
|
-
[reactor.rb:319](../lib/ruby_reactor/reactor.rb#L319),
|
|
19
|
-
[step/map_step.rb:274](../lib/ruby_reactor/step/map_step.rb#L274),
|
|
20
|
-
[step/map_step.rb:285](../lib/ruby_reactor/step/map_step.rb#L285),
|
|
21
|
-
[map/dispatcher.rb:177](../lib/ruby_reactor/map/dispatcher.rb#L177),
|
|
22
|
-
[map/element_executor.rb:155](../lib/ruby_reactor/map/element_executor.rb#L155),
|
|
23
|
-
[map/element_executor.rb:176](../lib/ruby_reactor/map/element_executor.rb#L176),
|
|
24
|
-
[executor/retry_manager.rb:59,80](../lib/ruby_reactor/executor/retry_manager.rb#L59),
|
|
25
|
-
[executor/step_executor.rb:219](../lib/ruby_reactor/executor/step_executor.rb#L219),
|
|
26
|
-
[sweeper.rb:48](../lib/ruby_reactor/sweeper.rb#L48),
|
|
27
|
-
[map/sweeper.rb:99](../lib/ruby_reactor/map/sweeper.rb#L99).
|
|
28
|
-
- The router contract is 5 class methods on `SidekiqAdapter`
|
|
29
|
-
([sidekiq_adapter.rb](../lib/ruby_reactor/sidekiq_adapter.rb)):
|
|
30
|
-
`perform_async`, `perform_in`, `perform_map_element_async`,
|
|
31
|
-
`perform_map_element_in`, `perform_map_collection_async`. All return
|
|
32
|
-
`RubyReactor::DispatchResult`.
|
|
33
|
-
- An adapter for any other queueing backend just needs to implement that
|
|
34
|
-
same 5-method contract and assign it to `config.async_router`. **This part
|
|
35
|
-
needs no rework.**
|
|
36
|
-
|
|
37
|
-
## What's NOT abstracted (the actual gap)
|
|
38
|
-
|
|
39
|
-
The **worker/job side** — the classes the queue invokes — bakes Sidekiq in
|
|
40
|
-
directly. **Decision: these move into `RubyReactor::Adapters::Sidekiq::*`**
|
|
41
|
-
(renamed from `RubyReactor::SidekiqWorkers::*`), with a sibling
|
|
42
|
-
`RubyReactor::Adapters::ActiveJob::*` for the new adapter — see
|
|
43
|
-
[Namespace](#namespace) below.
|
|
44
|
-
|
|
45
|
-
| Class (today) | File | Sidekiq coupling |
|
|
46
|
-
|---|---|---|
|
|
47
|
-
| `SidekiqWorkers::Worker` | [worker.rb](../lib/ruby_reactor/sidekiq_workers/worker.rb) | `include ::Sidekiq::Worker`, `sidekiq_options`, `sidekiq_retries_exhausted`, and internally calls `self.class.perform_in(...)` to reschedule snoozes (lines 116, 91-117) |
|
|
48
|
-
| `SidekiqWorkers::MapElementWorker` | [map_element_worker.rb](../lib/ruby_reactor/sidekiq_workers/map_element_worker.rb) | `include ::Sidekiq::Worker`; body is a 1-line delegate to `Map::ElementExecutor.perform` |
|
|
49
|
-
| `SidekiqWorkers::MapCollectorWorker` | [map_collector_worker.rb](../lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb) | same — 1-line delegate to `Map::Collector.perform` |
|
|
50
|
-
| `SidekiqWorkers::SweeperWorker` | [sweeper_worker.rb](../lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb) | `include ::Sidekiq::Worker`, `sidekiq_options retry: false`, self-reschedules via `perform_in` / class-level `perform_in` in `schedule_next` |
|
|
51
|
-
|
|
52
|
-
Plus test-side coupling:
|
|
53
|
-
|
|
54
|
-
- [rspec/sidekiq_helpers.rb](../lib/ruby_reactor/rspec/sidekiq_helpers.rb) hardcodes
|
|
55
|
-
the 3 Sidekiq worker classes and `Sidekiq::Testing` fake-mode draining.
|
|
56
|
-
- [rspec/test_subject.rb:194-203,433](../lib/ruby_reactor/rspec/test_subject.rb#L194-L203)
|
|
57
|
-
gates job-processing on `defined?(Sidekiq::Testing)` and forces
|
|
58
|
-
`async_router` back to `SidekiqAdapter`.
|
|
59
|
-
- [ruby_reactor.rb:22-27](../lib/ruby_reactor.rb#L22-L27) optionally requires
|
|
60
|
-
`sidekiq`; [ruby_reactor.rb:359](../lib/ruby_reactor.rb#L359) calls
|
|
61
|
-
`SidekiqWorkers::SweeperWorker.schedule_next` directly from
|
|
62
|
-
`RubyReactor.start_sweeper!`.
|
|
63
|
-
|
|
64
|
-
The real logic worth extracting lives almost entirely in
|
|
65
|
-
`SidekiqWorkers::Worker#perform` (~65 lines): rehydrate context from
|
|
66
|
-
storage, deserialize, resolve `reactor_class`, mark
|
|
67
|
-
`inline_async_execution`, run `Executor#resume_execution`, and on
|
|
68
|
-
lock/semaphore/rate-limit/ordered-lock contention either snooze (re-enqueue
|
|
69
|
-
with a computed delay) or escalate to `failed`. None of that is
|
|
70
|
-
Sidekiq-specific — it only *touches* Sidekiq via `self.class.perform_in` to
|
|
71
|
-
reschedule.
|
|
72
|
-
|
|
73
|
-
## Proposal
|
|
74
|
-
|
|
75
|
-
### 1. Normalize the enqueue API at the job-class boundary, not in the shared logic
|
|
76
|
-
|
|
77
|
-
`Sidekiq::Worker` gives every job class `.perform_async` / `.perform_in` for
|
|
78
|
-
free. ActiveJob doesn't — it has `.perform_later` and
|
|
79
|
-
`.set(wait: delay).perform_later`. Rather than teach the shared logic two
|
|
80
|
-
different reschedule calls, give every framework-specific job class the
|
|
81
|
-
same two class methods, so the shared mixin can keep calling
|
|
82
|
-
`self.class.perform_in(...)` unchanged:
|
|
83
|
-
|
|
84
|
-
```ruby
|
|
85
|
-
module RubyReactor
|
|
86
|
-
module Adapters
|
|
87
|
-
module ActiveJob
|
|
88
|
-
module Compat
|
|
89
|
-
def perform_async(*args) = perform_later(*args)
|
|
90
|
-
def perform_in(delay, *args) = set(wait: delay).perform_later(*args)
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
### 2. Extract `RubyReactor::Worker` — the framework-agnostic mixin
|
|
98
|
-
|
|
99
|
-
Move the body of `Adapters::Sidekiq::Worker#perform` (and its private snooze/
|
|
100
|
-
escalate/deserialization-failure helpers) into a plain module with no
|
|
101
|
-
`Sidekiq` reference:
|
|
102
|
-
|
|
103
|
-
```ruby
|
|
104
|
-
module RubyReactor
|
|
105
|
-
module Worker
|
|
106
|
-
def perform(context_id, reactor_class_name = nil, snooze_count = 0)
|
|
107
|
-
# ...exact same logic as today's SidekiqWorkers::Worker#perform...
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
private
|
|
111
|
-
# handle_snooze, compute_snooze_delay, hinted_retry?, escalate_snooze,
|
|
112
|
-
# log_infrastructure_failure, handle_deserialization_failure,
|
|
113
|
-
# build_failed_context_payload — unchanged, moved verbatim.
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
`Adapters::Sidekiq::Worker` then becomes:
|
|
119
|
-
|
|
120
|
-
```ruby
|
|
121
|
-
module RubyReactor
|
|
122
|
-
module Adapters
|
|
123
|
-
module Sidekiq
|
|
124
|
-
class Worker
|
|
125
|
-
include ::Sidekiq::Worker
|
|
126
|
-
include RubyReactor::Worker
|
|
127
|
-
|
|
128
|
-
sidekiq_options retry: RubyReactor.configuration.sidekiq_retry_count,
|
|
129
|
-
dead: false, queue: RubyReactor.configuration.sidekiq_queue
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
And a new `Adapters::ActiveJob::Worker`:
|
|
137
|
-
|
|
138
|
-
```ruby
|
|
139
|
-
module RubyReactor
|
|
140
|
-
module Adapters
|
|
141
|
-
module ActiveJob
|
|
142
|
-
class Worker < ::ActiveJob::Base
|
|
143
|
-
extend Compat
|
|
144
|
-
include RubyReactor::Worker
|
|
145
|
-
|
|
146
|
-
queue_as { RubyReactor.configuration.sidekiq_queue } # or a renamed generic config
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Same pattern applies to `MapElementWorker` / `MapCollectorWorker` — they're
|
|
154
|
-
already a 1-line delegate, so genericizing is just swapping the include; no
|
|
155
|
-
logic to extract.
|
|
156
|
-
|
|
157
|
-
### 3. Sweeper
|
|
158
|
-
|
|
159
|
-
`SweeperWorker`'s window-claim-lock + self-reschedule logic
|
|
160
|
-
([sweeper_worker.rb:30-70](../lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb#L30-L70))
|
|
161
|
-
is also framework-agnostic except for the `perform_in` call in
|
|
162
|
-
`schedule_next`. Extract the same way into `RubyReactor::SweeperJob`,
|
|
163
|
-
included into both `Adapters::Sidekiq::SweeperWorker` (`sidekiq_options retry: false`)
|
|
164
|
-
and an `Adapters::ActiveJob::SweeperWorker`. `RubyReactor.start_sweeper!`
|
|
165
|
-
([ruby_reactor.rb:356-360](../lib/ruby_reactor.rb#L356-L360)) needs to call
|
|
166
|
-
through whichever sweeper job class matches the configured adapter instead
|
|
167
|
-
of hardcoding `Adapters::Sidekiq::SweeperWorker`.
|
|
168
|
-
|
|
169
|
-
### 4. New `RubyReactor::Adapters::ActiveJob::Router`
|
|
170
|
-
|
|
171
|
-
Mirrors today's `RubyReactor::Adapters::Sidekiq::Router` (renamed from
|
|
172
|
-
`SidekiqAdapter`) exactly — same 5 methods, just pointing at the
|
|
173
|
-
`Adapters::ActiveJob::*` job classes instead of `Adapters::Sidekiq::*`. No
|
|
174
|
-
changes needed to any core call site; swap is purely
|
|
175
|
-
`config.async_router = RubyReactor::Adapters::ActiveJob::Router`.
|
|
176
|
-
|
|
177
|
-
### 5. Optional config sugar
|
|
178
|
-
|
|
179
|
-
`configuration.rb` already does this pattern for storage
|
|
180
|
-
([configuration.rb:114-121](../lib/ruby_reactor/configuration.rb#L114-L121)):
|
|
181
|
-
a single `storage.adapter` symbol resolves to a concrete adapter instance.
|
|
182
|
-
Could mirror it — `config.queue_adapter = :sidekiq | :active_job` resolving
|
|
183
|
-
both `async_router` and the sweeper job class — but this is sugar, not
|
|
184
|
-
required for the feature to work. Confirm whether you want it.
|
|
185
|
-
|
|
186
|
-
### 6. Test helpers
|
|
187
|
-
|
|
188
|
-
`rspec/sidekiq_helpers.rb` and the `Sidekiq::Testing` branch in
|
|
189
|
-
`rspec/test_subject.rb` only fire for Sidekiq today. For ActiveJob, Rails
|
|
190
|
-
already ships `ActiveJob::TestHelper` (`perform_enqueued_jobs`,
|
|
191
|
-
`have_enqueued_job`) which covers most of this generically. We'd still want
|
|
192
|
-
something equivalent to `drain_async_jobs` (loops until self-rescheduling
|
|
193
|
-
jobs — e.g. ordered-lock snoozes — stop producing new ones), so
|
|
194
|
-
`test_subject.rb`'s job-processing gate needs to branch on which
|
|
195
|
-
testing framework is active (or be driven by `configuration.async_router`)
|
|
196
|
-
rather than hardcoding `defined?(Sidekiq::Testing)`.
|
|
197
|
-
|
|
198
|
-
### 7. Loading
|
|
199
|
-
|
|
200
|
-
`ruby_reactor.rb:22-27` optionally `require "sidekiq"`. Add the same
|
|
201
|
-
optional-require pattern for `active_job`, so neither dependency is forced
|
|
202
|
-
on users who only need one.
|
|
203
|
-
|
|
204
|
-
## Namespace
|
|
205
|
-
|
|
206
|
-
**Decided: `RubyReactor::Adapters::Sidekiq::*` / `RubyReactor::Adapters::ActiveJob::*`.**
|
|
207
|
-
|
|
208
|
-
Renames/moves required (mechanical, but touches every reference):
|
|
209
|
-
|
|
210
|
-
| Today | Becomes |
|
|
211
|
-
|---|---|
|
|
212
|
-
| `RubyReactor::SidekiqAdapter` | `RubyReactor::Adapters::Sidekiq::Router` |
|
|
213
|
-
| `RubyReactor::SidekiqWorkers::Worker` | `RubyReactor::Adapters::Sidekiq::Worker` |
|
|
214
|
-
| `RubyReactor::SidekiqWorkers::MapElementWorker` | `RubyReactor::Adapters::Sidekiq::MapElementWorker` |
|
|
215
|
-
| `RubyReactor::SidekiqWorkers::MapCollectorWorker` | `RubyReactor::Adapters::Sidekiq::MapCollectorWorker` |
|
|
216
|
-
| `RubyReactor::SidekiqWorkers::SweeperWorker` | `RubyReactor::Adapters::Sidekiq::SweeperWorker` |
|
|
217
|
-
| (new) | `RubyReactor::Adapters::ActiveJob::Router` |
|
|
218
|
-
| (new) | `RubyReactor::Adapters::ActiveJob::{Worker,MapElementWorker,MapCollectorWorker,SweeperWorker}` |
|
|
219
|
-
|
|
220
|
-
Files move from `lib/ruby_reactor/sidekiq_workers/*.rb` +
|
|
221
|
-
`lib/ruby_reactor/sidekiq_adapter.rb` to
|
|
222
|
-
`lib/ruby_reactor/adapters/sidekiq/*.rb` (Zeitwerk-driven, so the directory
|
|
223
|
-
move IS the rename — no manual `module` boilerplate beyond nesting). New
|
|
224
|
-
ActiveJob side lives in `lib/ruby_reactor/adapters/active_job/*.rb`.
|
|
225
|
-
|
|
226
|
-
References that need updating for the rename:
|
|
227
|
-
- [configuration.rb:107](../lib/ruby_reactor/configuration.rb#L107) — `@async_router ||= RubyReactor::SidekiqAdapter`
|
|
228
|
-
- [ruby_reactor.rb:359](../lib/ruby_reactor.rb#L359) — `SidekiqWorkers::SweeperWorker.schedule_next`
|
|
229
|
-
- [rspec/sidekiq_helpers.rb](../lib/ruby_reactor/rspec/sidekiq_helpers.rb) — `worker_classes` list
|
|
230
|
-
- [rspec/test_subject.rb:196](../lib/ruby_reactor/rspec/test_subject.rb#L196) — stub target
|
|
231
|
-
- [spec/ruby_reactor/sidekiq_workers/worker_spec.rb](../spec/ruby_reactor/sidekiq_workers/worker_spec.rb),
|
|
232
|
-
[spec/ruby_reactor/sidekiq_workers/sweeper_worker_spec.rb](../spec/ruby_reactor/sidekiq_workers/sweeper_worker_spec.rb) —
|
|
233
|
-
`described_class` references, move to `spec/ruby_reactor/adapters/sidekiq/`
|
|
234
|
-
|
|
235
|
-
## Retry config — decided: generic
|
|
236
|
-
|
|
237
|
-
`sidekiq_retry_count` / `sidekiq_queue` rename to `config.job_retry_count` /
|
|
238
|
-
`config.queue_name`, used by both adapters:
|
|
239
|
-
|
|
240
|
-
- `Adapters::Sidekiq::Worker` → `sidekiq_options retry: config.job_retry_count, dead: false, queue: config.queue_name`
|
|
241
|
-
- `Adapters::ActiveJob::Worker` → maps to `retry_on StandardError, attempts: config.job_retry_count` (infra
|
|
242
|
-
failures only — reactor-specific errors are already caught by the shared
|
|
243
|
-
snooze/escalate logic in `RubyReactor::Worker` before they'd ever reach
|
|
244
|
-
the framework's retry layer) and `queue_as { config.queue_name }`.
|
|
245
|
-
- `sidekiq_retries_exhausted` (currently an empty hook) → ActiveJob
|
|
246
|
-
equivalent is `retry_on ... do |job, error| ... end` / `discard_on`.
|
|
247
|
-
|
|
248
|
-
`configuration.rb` changes: add `job_retry_count`/`queue_name` as the
|
|
249
|
-
canonical attrs; keep `sidekiq_retry_count`/`sidekiq_queue` as deprecated
|
|
250
|
-
aliases delegating to the new names so existing configs don't break.
|
|
251
|
-
|
|
252
|
-
None of this requires touching the enqueue-side call sites in
|
|
253
|
-
`reactor.rb`, `executor/*`, `map/*` — that seam already works (it just calls
|
|
254
|
-
through `config.async_router`, whose value changes, not its call sites).
|
|
255
|
-
The work is isolated to: `lib/ruby_reactor/worker.rb` (new),
|
|
256
|
-
`lib/ruby_reactor/sweeper_job.rb` (new), the `sidekiq_workers/` →
|
|
257
|
-
`adapters/sidekiq/` move + rename, `adapters/active_job/*.rb` (new, 5
|
|
258
|
-
classes: `Router` + 4 job classes), and the test-helper /
|
|
259
|
-
`start_sweeper!` branching described above.
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# Specification Quality Checklist: Step-Scoped Coordination
|
|
2
|
-
|
|
3
|
-
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
-
**Created**: 2026-09-10
|
|
5
|
-
**Feature**: [spec.md](../spec.md)
|
|
6
|
-
|
|
7
|
-
## Content Quality
|
|
8
|
-
|
|
9
|
-
- [x] No implementation details (languages, frameworks, APIs)
|
|
10
|
-
- [x] Focused on user value and business needs
|
|
11
|
-
- [x] Written for non-technical stakeholders
|
|
12
|
-
- [x] All mandatory sections completed
|
|
13
|
-
|
|
14
|
-
## Requirement Completeness
|
|
15
|
-
|
|
16
|
-
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
17
|
-
- [x] Requirements are testable and unambiguous
|
|
18
|
-
- [x] Success criteria are measurable
|
|
19
|
-
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
20
|
-
- [x] All acceptance scenarios are defined
|
|
21
|
-
- [x] Edge cases are identified
|
|
22
|
-
- [x] Scope is clearly bounded
|
|
23
|
-
- [x] Dependencies and assumptions identified
|
|
24
|
-
|
|
25
|
-
## Feature Readiness
|
|
26
|
-
|
|
27
|
-
- [x] All functional requirements have clear acceptance criteria
|
|
28
|
-
- [x] User scenarios cover primary flows
|
|
29
|
-
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
30
|
-
- [x] No implementation details leak into specification
|
|
31
|
-
|
|
32
|
-
## Notes
|
|
33
|
-
|
|
34
|
-
- All items pass. 0 [NEEDS CLARIFICATION] markers remain.
|
|
35
|
-
- Resolved with the user on 2026-09-10:
|
|
36
|
-
- **Contention**: park the execution and retry it later rather than failing (FR-015). No
|
|
37
|
-
queue exists in a synchronous run, so that path waits then fails (FR-016) — the split is
|
|
38
|
-
documented rather than hidden.
|
|
39
|
-
- **Scope**: all five primitives at step level (FR-002), with the two whose meaning does not
|
|
40
|
-
narrow trivially pinned down explicitly — deduplication skips the step rather than halting
|
|
41
|
-
the reactor (FR-003), strict ordering sequences that step only (FR-004).
|
|
42
|
-
- **Rollback**: exclusivity and concurrency ceilings are re-taken for compensate/undo
|
|
43
|
-
(FR-024); rate ceilings and dedup windows are not (FR-025).
|
|
44
|
-
- **Re-entrancy**: reuses the nested-workflow rules unchanged — execution-owned holds,
|
|
45
|
-
counted nesting, an execution-wide held-key registry, refusal at hand-off when ownership
|
|
46
|
-
cannot cross a process boundary, and keep-ownership-across-parks (FR-019 to FR-022,
|
|
47
|
-
FR-018).
|
|
48
|
-
- "Non-technical stakeholders" is read as *developers who are not this library's
|
|
49
|
-
maintainers*: the spec names no Ruby constructs, gems, or file paths outside the verbatim
|
|
50
|
-
user input.
|
|
51
|
-
- Ready for `/speckit-plan`.
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
# Public DSL Contract: Step-Scoped Coordination
|
|
2
|
-
|
|
3
|
-
**Feature**: `specs/003-step-lock-declarations/` | **Date**: 2026-09-10
|
|
4
|
-
|
|
5
|
-
The gem's external interface is its DSL. This document is what the specs assert against and
|
|
6
|
-
what `documentation/locks_and_semaphores.md` must match.
|
|
7
|
-
|
|
8
|
-
## 1. The five macros, now available on steps
|
|
9
|
-
|
|
10
|
-
Identical signatures to the reactor-level forms (`Dsl::Lockable`). The only difference is what
|
|
11
|
-
the key proc receives: **the step's resolved arguments**, where the reactor form receives the
|
|
12
|
-
reactor's inputs.
|
|
13
|
-
|
|
14
|
-
```ruby
|
|
15
|
-
class ChargeStep < RubyReactor::Step
|
|
16
|
-
input :account_id
|
|
17
|
-
input :amount
|
|
18
|
-
|
|
19
|
-
with_lock(ttl: 60, wait: 0, auto_extend: true) { |args| "acct:#{args[:account_id]}" }
|
|
20
|
-
|
|
21
|
-
def run
|
|
22
|
-
Success(charge!(inputs))
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
| Macro | Step-scoped meaning |
|
|
28
|
-
|---|---|
|
|
29
|
-
| `with_lock { \|args\| key }` | At most one execution inside this step's work per key |
|
|
30
|
-
| `with_semaphore(limit: N) { \|args\| key }` | At most N executions inside this step's work per key |
|
|
31
|
-
| `with_rate_limit(limit:, period:) { \|args\| key }` | At most X executions of this step per window per key. `with_rate_limit(:name)` still references a registered global limit |
|
|
32
|
-
| `with_period(every:) { \|args\| key }` | This step runs at most once per bucket per key. **The step is skipped**; the workflow continues |
|
|
33
|
-
| `with_ordered_lock { \|args\| key }` | Executions pass through this step in sequence per key |
|
|
34
|
-
|
|
35
|
-
### `with_period` differs from the reactor form
|
|
36
|
-
|
|
37
|
-
Reactor-level `with_period` halts the whole reactor when the bucket is marked. At step level
|
|
38
|
-
that would kill a workflow over one deduplicated step, so the **step** is skipped and the
|
|
39
|
-
following steps run. A step returning `Skipped` behaves as it does anywhere else.
|
|
40
|
-
|
|
41
|
-
### `with_ordered_lock` provides a weaker guarantee than its reactor namesake
|
|
42
|
-
|
|
43
|
-
The reactor form assigns its position at enqueue time, so it orders executions by enqueue. A
|
|
44
|
-
step's key is computed from arguments that do not exist until the step is reached, so the step
|
|
45
|
-
form orders executions **by arrival at that step**. For a step that sits first in its reactor
|
|
46
|
-
the two coincide; the deeper the step, the weaker the promise. This is documented on the macro
|
|
47
|
-
itself, not only here.
|
|
48
|
-
|
|
49
|
-
## 2. Inline steps
|
|
50
|
-
|
|
51
|
-
```ruby
|
|
52
|
-
step :charge do
|
|
53
|
-
with_lock { |args| "acct:#{args[:account_id]}" }
|
|
54
|
-
|
|
55
|
-
argument :account_id, input(:account_id)
|
|
56
|
-
run { |args, _| charge!(args) }
|
|
57
|
-
end
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
Same macros, same behavior as the class form.
|
|
61
|
-
|
|
62
|
-
## 3. Where it is enforced
|
|
63
|
-
|
|
64
|
-
Acquisition happens after guards and after argument validation, so a step that will be skipped
|
|
65
|
-
or will fail validation never takes a hold.
|
|
66
|
-
|
|
67
|
-
| Order | Taken | Released |
|
|
68
|
-
|---|---|---|
|
|
69
|
-
| 1 | Ordered-lock gate (nothing else held while waiting for a turn) | last |
|
|
70
|
-
| 2 | Dedup window, fast check | — |
|
|
71
|
-
| 3 | Rate limit | — |
|
|
72
|
-
| 4 | Exclusive lock | 3rd |
|
|
73
|
-
| 5 | Semaphore | 2nd |
|
|
74
|
-
| 6 | Dedup window, re-check under the lock | marked on success |
|
|
75
|
-
|
|
76
|
-
Released in reverse in an `ensure`, on success, failure, or unexpected error.
|
|
77
|
-
|
|
78
|
-
| Entry point | Coordinated |
|
|
79
|
-
|---|---|
|
|
80
|
-
| Reactor step execution | ✅ |
|
|
81
|
-
| Retried attempt | ✅ each attempt takes and releases |
|
|
82
|
-
| `async_step` worker | ✅ taken in the worker, never in the dispatcher |
|
|
83
|
-
| `background` hand-off worker | ✅ |
|
|
84
|
-
| Resume after interrupt | ✅ |
|
|
85
|
-
| Each `map` iteration | ✅ |
|
|
86
|
-
| `ChargeStep.run(args, ctx)` directly | ✅ wait-then-fail; no execution to park |
|
|
87
|
-
| `compensate` / `undo` | ✅ exclusion primitives only — see §6 |
|
|
88
|
-
| Step suppressed by `where`/guard | ❌ by design |
|
|
89
|
-
| Interrupt step | ❌ declaring coordination on one raises |
|
|
90
|
-
|
|
91
|
-
## 4. Contention
|
|
92
|
-
|
|
93
|
-
| Execution path | Behavior |
|
|
94
|
-
|---|---|
|
|
95
|
-
| Running in a worker | The execution is **parked** and retried later. No step compensates; the contended step's work has not been attempted. |
|
|
96
|
-
| Running synchronously | Waits up to the configured `wait:`, then fails with a contention error naming reactor, step, and key. Rollback proceeds as for any step failure. |
|
|
97
|
-
|
|
98
|
-
Contention attempts are counted separately from failure retries and bounded by a configurable
|
|
99
|
-
ceiling; exceeding it turns the park into a contention failure. A busy key can therefore never
|
|
100
|
-
exhaust the retry budget meant for genuine failures, nor snooze forever.
|
|
101
|
-
|
|
102
|
-
## 5. Re-entrancy
|
|
103
|
-
|
|
104
|
-
Identical to nested reactors — same primitives, no second rule set:
|
|
105
|
-
|
|
106
|
-
- Holds are owned by the **execution** (its root context), so a step keyed the same as its own
|
|
107
|
-
reactor, or nested work inside a locked step, proceeds without waiting.
|
|
108
|
-
- Nested holds on one key are counted; the key frees for other executions only when the
|
|
109
|
-
outermost hold is released.
|
|
110
|
-
- The keys an execution holds are tracked for the execution as a whole.
|
|
111
|
-
- **Ownership never crosses a process hand-off.** Dispatching work that declares a key the
|
|
112
|
-
execution currently holds is refused before dispatch, with a message naming the key, the
|
|
113
|
-
holder, and how to restructure. This now covers `async_step` dispatch as well as
|
|
114
|
-
`async_reactor`.
|
|
115
|
-
- An execution that parks while holding coordination re-adopts it on resume without recording a
|
|
116
|
-
second acquisition, falling back to competing normally if the hold lapsed.
|
|
117
|
-
|
|
118
|
-
## 6. Rollback
|
|
119
|
-
|
|
120
|
-
| Primitive | Re-taken for compensate/undo |
|
|
121
|
-
|---|---|
|
|
122
|
-
| `with_lock`, `with_semaphore` | ✅ same key, computed from the same arguments |
|
|
123
|
-
| `with_rate_limit`, `with_period`, `with_ordered_lock` | ❌ a forward-work quota must never suppress cleanup |
|
|
124
|
-
|
|
125
|
-
Compensation that cannot acquire within its wait is reported, never silently skipped. It does
|
|
126
|
-
not park — the execution is already mid-failure.
|
|
127
|
-
|
|
128
|
-
## 7. Errors
|
|
129
|
-
|
|
130
|
-
| Situation | Outcome |
|
|
131
|
-
|---|---|
|
|
132
|
-
| Key proc raises, or returns nil/empty | Step fails before its work runs, naming step and cause |
|
|
133
|
-
| Contention, synchronous | `Lock::AcquisitionError` / `Semaphore::AcquisitionError` / `RateLimit::ExceededError`, naming reactor, step, key |
|
|
134
|
-
| Contention ceiling exceeded | Contention failure with the attempt count |
|
|
135
|
-
| Coordination declared on an interrupt step | Raises at declaration, pointing at reactor-level coordination |
|
|
136
|
-
| Hand-off would deadlock | Failure at dispatch naming key, holder, and remedies |
|
|
137
|
-
| Backing store unreachable | Step fails with the cause; work never runs unprotected |
|
|
138
|
-
|
|
139
|
-
## 8. Observability
|
|
140
|
-
|
|
141
|
-
- Acquisition, release, and acquisition failure are distinct events carrying the key and the
|
|
142
|
-
owning step.
|
|
143
|
-
- A contention-parked execution is reported distinctly from a failure — a snooze round must not
|
|
144
|
-
read as a phantom failure.
|
|
145
|
-
- The dashboard's coordination view shows step-level holds alongside reactor-level ones,
|
|
146
|
-
identified by step.
|
|
147
|
-
|
|
148
|
-
## 9. Compatibility
|
|
149
|
-
|
|
150
|
-
- Additive. A step declaring nothing behaves exactly as today.
|
|
151
|
-
- Reactor-level declarations are unchanged in syntax and behavior.
|
|
152
|
-
- Guidance: reactor level for "this whole workflow is exclusive", step level for "this one
|
|
153
|
-
operation is exclusive". Step level keeps the critical section small, so prefer it when only
|
|
154
|
-
part of the workflow needs protection.
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
# Phase 1 Data Model: Step-Scoped Coordination
|
|
2
|
-
|
|
3
|
-
**Feature**: `specs/003-step-lock-declarations/` | **Date**: 2026-09-10
|
|
4
|
-
|
|
5
|
-
Definition-time state lives on Ruby classes. Runtime state lives in Redis (the holds
|
|
6
|
-
themselves, unchanged key spaces) and in `context.private_data` (per-execution bookkeeping,
|
|
7
|
-
which already round-trips through `ContextSerializer`).
|
|
8
|
-
|
|
9
|
-
## StepCoordinationDeclaration
|
|
10
|
-
|
|
11
|
-
What a unit of work declares about when it may run. One per primitive per step; a step may
|
|
12
|
-
declare several.
|
|
13
|
-
|
|
14
|
-
| Field | Type | Notes |
|
|
15
|
-
|---|---|---|
|
|
16
|
-
| `primitive` | `:lock` \| `:semaphore` \| `:rate_limit` \| `:period` \| `:ordered_lock` | |
|
|
17
|
-
| `key_proc` | Proc | Receives the step's **resolved arguments**, returns the key. Where the reactor form receives reactor inputs. |
|
|
18
|
-
| `ttl` | Integer | `:lock`, `:ordered_lock`. Default as today. |
|
|
19
|
-
| `wait` | Integer | `:lock`, `:semaphore`. Tolerance before contention handling. |
|
|
20
|
-
| `auto_extend` | Boolean | `:lock`. Keeps the hold alive while the step's work runs. |
|
|
21
|
-
| `limit` | Integer | `:semaphore` — concurrent holders per key. |
|
|
22
|
-
| `limits` | Hash | `:rate_limit` — window → ceiling, or a registered name. |
|
|
23
|
-
| `every` | Symbol \| Integer | `:period` — bucket size. |
|
|
24
|
-
| `poison_pill_timeout`, `strict` | Integer, Boolean | `:ordered_lock`. |
|
|
25
|
-
|
|
26
|
-
**Validation rules**:
|
|
27
|
-
|
|
28
|
-
- Declaring any primitive on an interrupt step raises at declaration time (research D6).
|
|
29
|
-
- The existing per-macro argument validation is unchanged — e.g. `with_rate_limit(:name)`
|
|
30
|
-
still refuses to also take `limit:`/`period:`/a block; `with_period` still validates `every:`
|
|
31
|
-
eagerly at class load.
|
|
32
|
-
- A key proc returning nil or empty fails the step before its work runs (FR-007).
|
|
33
|
-
|
|
34
|
-
**Ownership**: a step class, or an inline step's `StepConfig`. Propagates to subclasses via
|
|
35
|
-
the existing `inherited` hook; a subclass redeclaring a primitive replaces the parent's.
|
|
36
|
-
|
|
37
|
-
**Introspection** (FR-006): `declares_coordination?`, `coordination_declarations`, and the
|
|
38
|
-
existing per-primitive readers (`lock_config`, `semaphore_config`, …) available on the step.
|
|
39
|
-
|
|
40
|
-
## Hold
|
|
41
|
-
|
|
42
|
-
The runtime fact that one execution holds one key. Redis-side representation is unchanged —
|
|
43
|
-
this is the model of what already exists, now also created by steps.
|
|
44
|
-
|
|
45
|
-
| Field | Source | Notes |
|
|
46
|
-
|---|---|---|
|
|
47
|
-
| `key` | key proc output, namespaced per primitive | |
|
|
48
|
-
| `owner` | root context id | The basis of re-entrancy: every reactor and step in one execution tree shares it. A direct step invocation uses a per-call UUID instead. |
|
|
49
|
-
| `owning_step` | step name | New. Nil for a reactor-level hold. |
|
|
50
|
-
| `nesting_count` | adapter-maintained | Increments on re-acquire by the same owner; the key frees at zero. |
|
|
51
|
-
| `acquired_at`, `ttl` | as today | Refreshed by the auto-extend thread while the step's work runs. |
|
|
52
|
-
|
|
53
|
-
**Lifecycle**: `acquired` → (`extended`…) → `released`, or `expired` if the holder dies.
|
|
54
|
-
Release is in `ensure` around the step body, in reverse acquisition order.
|
|
55
|
-
|
|
56
|
-
## HeldKeyRegistry
|
|
57
|
-
|
|
58
|
-
`root.private_data[:held_lock_keys]` — the set of keys this execution currently holds.
|
|
59
|
-
Unchanged structure; step holds push and pop the same way reactor holds do.
|
|
60
|
-
|
|
61
|
-
Read by the dispatch-time deadlock guard: handing off work that declares a key present in the
|
|
62
|
-
registry is refused before dispatch (FR-022). Extended in this feature to consult a dispatched
|
|
63
|
-
**step class's** declarations, not only a child reactor's.
|
|
64
|
-
|
|
65
|
-
## ContentionState
|
|
66
|
-
|
|
67
|
-
Per-execution bookkeeping for the park-and-retry path, in `context.private_data`.
|
|
68
|
-
|
|
69
|
-
| Field | Type | Notes |
|
|
70
|
-
|---|---|---|
|
|
71
|
-
| `attempts_by_step` | Hash{step → Integer} | Counted separately from failure retries, so a busy key cannot exhaust the budget meant for genuine failures. |
|
|
72
|
-
| `ceiling` | Integer | Configurable. Exceeding it converts the park into a contention failure (FR-017). |
|
|
73
|
-
| `next_attempt_at` | Time | Set from the primitive's own hint (`retry_after_seconds` for rate limits) or the configured contention backoff. |
|
|
74
|
-
|
|
75
|
-
**Transitions**:
|
|
76
|
-
|
|
77
|
-
```text
|
|
78
|
-
reached step ──cannot acquire──> in a worker?
|
|
79
|
-
│
|
|
80
|
-
yes ────────┴──────── no
|
|
81
|
-
│ │
|
|
82
|
-
attempts < ceiling? waited `wait` already
|
|
83
|
-
│ │ │
|
|
84
|
-
yes no │
|
|
85
|
-
│ │ │
|
|
86
|
-
park + requeue contention contention
|
|
87
|
-
(RetryQueued) failure failure
|
|
88
|
-
│
|
|
89
|
-
redelivered ──> retry acquisition
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
A parked execution has run no part of the contended step and compensated nothing (FR-015).
|
|
93
|
-
|
|
94
|
-
## StepOrderedLockState
|
|
95
|
-
|
|
96
|
-
Per-step sequencing state, in `context.private_data`, keyed by step name. Mirrors the
|
|
97
|
-
reactor-level `private_data[:ordered_lock]` stash.
|
|
98
|
-
|
|
99
|
-
| Field | Notes |
|
|
100
|
-
|---|---|
|
|
101
|
-
| `key`, `nonce`, `epoch` | Assigned when the execution first reaches the step; reused across contention redeliveries. |
|
|
102
|
-
| `poison_pill_timeout`, `ttl`, `strict` | From the declaration. |
|
|
103
|
-
|
|
104
|
-
**Caveat carried from research D8**: the nonce is assigned on arrival at the step, not at
|
|
105
|
-
enqueue, so the guarantee is arrival-ordered rather than enqueue-ordered.
|
|
106
|
-
|
|
107
|
-
## CoordinationOutcome
|
|
108
|
-
|
|
109
|
-
What the executor produces at a coordinated step boundary.
|
|
110
|
-
|
|
111
|
-
| Outcome | When | Result |
|
|
112
|
-
|---|---|---|
|
|
113
|
-
| Proceed | All declared primitives taken | Step body runs, holds released after |
|
|
114
|
-
| Skip | Dedup window already marked for this bucket and key | `Skipped` for the step; workflow continues (FR-003) |
|
|
115
|
-
| Skip (chain) | Strict ordering and an earlier position failed | `Skipped` for the step (FR-004) |
|
|
116
|
-
| Park | Contention, in a worker, under the ceiling | `RetryQueuedResult`; execution resumes at this step later |
|
|
117
|
-
| Fail | Contention synchronously, or over the ceiling, or key computation failed | Step failure; rollback proceeds as for any step failure |
|
|
118
|
-
| Refuse | Hand-off would deadlock on a held key | Failure at dispatch, naming key, holder, and remedies (FR-022) |
|
|
119
|
-
|
|
120
|
-
## Rollback interaction
|
|
121
|
-
|
|
122
|
-
| Primitive | Re-taken for compensate/undo? |
|
|
123
|
-
|---|---|
|
|
124
|
-
| `:lock` | ✅ same key, same values (FR-024) |
|
|
125
|
-
| `:semaphore` | ✅ |
|
|
126
|
-
| `:rate_limit` | ❌ a forward-work quota must not suppress cleanup (FR-025) |
|
|
127
|
-
| `:period` | ❌ same reason |
|
|
128
|
-
| `:ordered_lock` | ❌ sequencing governs forward work |
|
|
129
|
-
|
|
130
|
-
Compensation that cannot acquire within its wait is reported, never silently skipped
|
|
131
|
-
(FR-026), and never parks — the execution is already mid-failure.
|