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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-review/SKILL.md +324 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.specify/extensions.yml +10 -0
  5. data/.specify/feature.json +1 -1
  6. data/.specify/workflows/speckit/workflow.yml +13 -1
  7. data/.specify/workflows/workflow-registry.json +2 -2
  8. data/CHANGELOG.md +82 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +35 -2
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
  13. data/lib/ruby_reactor/context.rb +26 -0
  14. data/lib/ruby_reactor/context_serializer.rb +4 -2
  15. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  16. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  17. data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
  18. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  19. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  20. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  21. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  22. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  24. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  25. data/lib/ruby_reactor/executor/result_handler.rb +31 -11
  26. data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
  27. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  28. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  29. data/lib/ruby_reactor/executor.rb +90 -20
  30. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  31. data/lib/ruby_reactor/map/helpers.rb +35 -11
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  34. data/lib/ruby_reactor/retry_context.rb +31 -2
  35. data/lib/ruby_reactor/rspec/helpers.rb +15 -0
  36. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  37. data/lib/ruby_reactor/rspec/test_subject.rb +7 -1
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
  39. data/lib/ruby_reactor/step/compose_step.rb +14 -3
  40. data/lib/ruby_reactor/step.rb +49 -7
  41. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  42. data/lib/ruby_reactor/step_worker.rb +260 -37
  43. data/lib/ruby_reactor/version.rb +1 -1
  44. data/lib/ruby_reactor/web/api.rb +72 -7
  45. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  46. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  47. data/lib/ruby_reactor/web/public/index.html +1 -1
  48. data/lib/ruby_reactor/worker.rb +56 -30
  49. data/lib/ruby_reactor.rb +27 -5
  50. data/specs/future_improvements.md +250 -0
  51. metadata +8 -28
  52. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  53. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  54. data/specs/002-step-input-contracts/data-model.md +0 -115
  55. data/specs/002-step-input-contracts/plan.md +0 -165
  56. data/specs/002-step-input-contracts/quickstart.md +0 -170
  57. data/specs/002-step-input-contracts/research.md +0 -233
  58. data/specs/002-step-input-contracts/spec.md +0 -359
  59. data/specs/002-step-input-contracts/tasks.md +0 -367
  60. data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
  61. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  62. data/specs/004-inheritable-step-class/data-model.md +0 -116
  63. data/specs/004-inheritable-step-class/plan.md +0 -174
  64. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  65. data/specs/004-inheritable-step-class/research.md +0 -308
  66. data/specs/004-inheritable-step-class/spec.md +0 -316
  67. data/specs/004-inheritable-step-class/tasks.md +0 -258
  68. data/specs/active_job.md +0 -259
  69. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  70. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  71. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  72. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  73. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  74. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  75. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  76. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  77. 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.