ruby_reactor 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +26 -16
  5. data/.specify/templates/plan-template.md +4 -0
  6. data/.specify/templates/tasks-template.md +1 -1
  7. data/CHANGELOG.md +140 -0
  8. data/CLAUDE.md +1 -1
  9. data/README.md +125 -31
  10. data/lib/ruby_reactor/context.rb +2 -2
  11. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  12. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  13. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
  15. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  16. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  17. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  18. data/lib/ruby_reactor/executor/result_handler.rb +9 -3
  19. data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
  20. data/lib/ruby_reactor/executor/step_executor.rb +9 -2
  21. data/lib/ruby_reactor/executor.rb +3 -0
  22. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  23. data/lib/ruby_reactor/reactor.rb +9 -12
  24. data/lib/ruby_reactor/rspec/matchers.rb +3 -6
  25. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  26. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  27. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  28. data/lib/ruby_reactor/step/map_step.rb +177 -218
  29. data/lib/ruby_reactor/step.rb +116 -21
  30. data/lib/ruby_reactor/step_signals.rb +6 -2
  31. data/lib/ruby_reactor/step_worker.rb +25 -10
  32. data/lib/ruby_reactor/template/result.rb +9 -2
  33. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor.rb +5 -2
  36. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  37. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  38. data/specs/002-step-input-contracts/data-model.md +115 -0
  39. data/specs/002-step-input-contracts/plan.md +165 -0
  40. data/specs/002-step-input-contracts/quickstart.md +170 -0
  41. data/specs/002-step-input-contracts/research.md +233 -0
  42. data/specs/002-step-input-contracts/spec.md +359 -0
  43. data/specs/002-step-input-contracts/tasks.md +367 -0
  44. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  45. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  46. data/specs/004-inheritable-step-class/data-model.md +116 -0
  47. data/specs/004-inheritable-step-class/plan.md +174 -0
  48. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  49. data/specs/004-inheritable-step-class/research.md +308 -0
  50. data/specs/004-inheritable-step-class/spec.md +316 -0
  51. data/specs/004-inheritable-step-class/tasks.md +258 -0
  52. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  53. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  54. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  55. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  56. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  57. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  58. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  59. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  60. data/specs/possible_feature.md +22 -0
  61. metadata +28 -1
@@ -0,0 +1,154 @@
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.
@@ -0,0 +1,131 @@
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.
@@ -0,0 +1,166 @@
1
+ # Implementation Plan: Step-Scoped Coordination
2
+
3
+ **Branch**: `step_validations` | **Date**: 2026-09-10 | **Spec**: [spec.md](./spec.md)
4
+
5
+ **Input**: Feature specification from `specs/003-step-lock-declarations/spec.md`
6
+
7
+ ## Summary
8
+
9
+ Let a step declare its own coordination — exclusivity, concurrency ceiling, rate ceiling,
10
+ dedup window, strict ordering — keyed on the step's own resolved arguments, so one step of a
11
+ workflow can be serialized without serializing the workflow.
12
+
13
+ The declaration surface already exists: `Dsl::Lockable`'s five macros are a self-contained
14
+ module whose only contract is "a key proc that receives a hash". Steps host it unchanged and
15
+ pass their arguments where the reactor passes its inputs. Enforcement is a `StepCoordination`
16
+ object the executor wraps around the step body, in the same fixed order the reactor uses.
17
+
18
+ Contention parks the execution rather than failing it, reusing `requeue_job_for_step_retry` —
19
+ which already persists the context with `current_step` set and re-enqueues — with contention
20
+ attempts counted separately from failure retries. Synchronously there is no queue to park
21
+ into, so that path waits then fails.
22
+
23
+ Re-entrancy reuses every existing nested-workflow primitive unchanged: holds owned by the root
24
+ context id, the adapter's nesting count, the `held_lock_keys` registry, and the dispatch-time
25
+ deadlock guard — which already covers step holds, since it reads that registry.
26
+
27
+ Design decisions and evidence: [research.md](./research.md).
28
+
29
+ ## Technical Context
30
+
31
+ **Language/Version**: Ruby >= 3.0.0
32
+
33
+ **Primary Dependencies**: redis ~> 5.0 (every primitive is Redis-backed), sidekiq ~> 7.0
34
+ (the park-and-retry path), zeitwerk ~> 2.6. No new dependency.
35
+
36
+ **Storage**: Redis. Step holds use the same key spaces, TTLs, and Lua primitives as reactor
37
+ holds (`storage/redis_locking.rb`, `storage/redis_ordered_locking.rb`). New per-step state is
38
+ confined to `context.private_data` (contention counter, per-step ordered-lock nonce), which
39
+ already round-trips through `ContextSerializer`.
40
+
41
+ **Testing**: RSpec against real Redis (constitution III). Concurrency claims need genuine
42
+ parallelism — overlap detection across processes/threads, not mocked timing. The park-and-
43
+ retry path must be exercised with a real Sidekiq worker, not `Sidekiq::Testing.inline!`.
44
+
45
+ **Target Platform**: Ruby library, sync and Sidekiq-backed async execution
46
+
47
+ **Project Type**: Library / DSL
48
+
49
+ **Performance Goals**: A step declaring nothing pays one nil check per step. A step declaring
50
+ coordination pays the same Redis round-trips the reactor-level equivalent pays today, moved
51
+ from once-per-run to once-per-step-execution. The point of the feature is that the critical
52
+ section shrinks, so end-to-end throughput under contention should improve, not regress.
53
+
54
+ **Constraints**: Additive and SemVer-MINOR — no existing reactor changes behavior. Coordination
55
+ must never be held across a process hand-off. The critical section must stay minimal:
56
+ acquisition happens after guards and after argument validation.
57
+
58
+ **Scale/Scope**: ~10 library files touched, 2-3 new, plus demo-app artifacts and docs. The
59
+ ordered-lock phase is roughly the weight of the other four primitives combined.
60
+
61
+ ## Constitution Check
62
+
63
+ *GATE: passed before Phase 0. Re-checked after Phase 1 design — see below.*
64
+
65
+ | Principle | Assessment |
66
+ |---|---|
67
+ | **I. Gem-First Design** | ✅ Entirely inside `lib/`. Redis and Sidekiq usage stays behind the existing adapter and router boundaries; callers still provide their own connections. |
68
+ | **II. Saga Pattern Integrity** | ✅ The strongest alignment in this feature. Coordination is re-taken for compensate/undo (FR-024), so rollback of a protected operation is protected too — closing a race the reactor-level lock leaves open whenever rollback outlives the reactor's own hold. Contention parks rather than fails, so routine contention never triggers spurious compensation. Nothing changes which steps run or in what order (FR-014). |
69
+ | **III. Test-First with Real Infrastructure** | ✅ Non-negotiable here: every claim is a concurrency claim. Real Redis, real Sidekiq for the park path. `Sidekiq::Testing.inline!` is explicitly wrong for this feature — it re-enters the worker synchronously inside the holding frame (the reason `acquire_context_lock` skips itself under it, `executor.rb:470`). |
70
+ | **IV. Observability by Default** | ✅ FR-028/FR-029. Events carry the step name; the dashboard's coordination view learns step-level state; a contention-parked execution is distinguishable from a failed one, reusing the `:snooze_reactor` precedent that already keeps snooze rounds from reading as phantom failures. |
71
+ | **V. Simplicity and SemVer** | ⚠️ Justified. MINOR and fully additive — a step declaring nothing is unaffected. But the scope is five primitives where the request was one, and YAGNI applies to four of them; the step-level ordered lock in particular invents a guarantee (order-of-arrival) weaker than the one its name implies. Recorded in Complexity Tracking, sequenced last, and flagged as the first thing to cut. |
72
+ | **VI. Demo-App Proof of Feature** | ✅ Blocking work: example reactor + `demo:` rake task + spec using only shipped matchers + `docker compose run`. `be_locked`, `have_available_tokens`, `have_held_tokens`, `have_rate_limit_count`, `be_period_marked`, and the ordered-lock matchers already exist; step-scoped assertions are expected to need at least one addition (a step-attributed hold), which goes into `lib/ruby_reactor/rspec/` in the same change rather than being worked around. |
73
+
74
+ **Post-design re-check**: no new violations. No new dependency, no new storage primitive, no
75
+ new failure shape — the design routes a second declaration site into mechanisms that already
76
+ exist. The two carried items are scope (five primitives) and the ordered-lock guarantee gap.
77
+
78
+ ## Project Structure
79
+
80
+ ### Documentation (this feature)
81
+
82
+ ```text
83
+ specs/003-step-lock-declarations/
84
+ ├── plan.md # This file
85
+ ├── spec.md # Feature specification
86
+ ├── research.md # Phase 0 — current-state findings and design decisions
87
+ ├── data-model.md # Phase 1 — declaration/hold entities and lifecycle
88
+ ├── quickstart.md # Phase 1 — how to run and verify
89
+ ├── contracts/
90
+ │ └── dsl-surface.md # Phase 1 — public DSL, semantics per primitive, errors
91
+ ├── checklists/
92
+ │ └── requirements.md # Spec quality checklist (complete)
93
+ └── tasks.md # Phase 2 — /speckit-tasks output, NOT created here
94
+ ```
95
+
96
+ ### Source Code (repository root)
97
+
98
+ ```text
99
+ lib/ruby_reactor/
100
+ ├── dsl/
101
+ │ ├── lockable.rb # unchanged module, now also hosted by steps
102
+ │ └── step_builder.rb # + the five macros for inline steps; refuse on
103
+ │ # interrupt steps (D6); config onto StepConfig
104
+ ├── step.rb # + host Lockable macros; introspection (D1)
105
+ ├── executor/
106
+ │ ├── step_coordination.rb # NEW — acquire/release in fixed order, contention
107
+ │ │ # handling, park decision (D2, D3, D4)
108
+ │ ├── step_executor.rb # wrap the step body in StepCoordination
109
+ │ ├── retry_manager.rb # contention requeue + separate contention counter (D4)
110
+ │ └── compensation_manager.rb # re-take exclusion primitives for compensate/undo (FR-024)
111
+ ├── step/
112
+ │ └── async_reactor_step.rb # deadlock guard also covers async_step dispatch (D5)
113
+ ├── step_worker.rb # coordination around the worker-side step body
114
+ ├── retry_context.rb # + contention attempt counter
115
+ ├── web/coordination_serializer.rb # + step-level coordination state (D9)
116
+ └── rspec/matchers.rb # + step-attributed hold assertions as needed
117
+
118
+ spec/ruby_reactor/
119
+ ├── step_coordination/lock_spec.rb # NEW — US1, US2 (real concurrency)
120
+ ├── step_coordination/contention_spec.rb # NEW — US3 both paths, bounded retries
121
+ ├── step_coordination/reentrancy_spec.rb # NEW — US4 incl. dispatch refusal
122
+ ├── step_coordination/primitives_spec.rb # NEW — US5, one per primitive
123
+ ├── step_coordination/rollback_spec.rb # NEW — US6
124
+ └── step_coordination/inline_spec.rb # NEW — US8 equivalence
125
+
126
+ demo_app/
127
+ ├── app/reactors/step_lock_demo_reactor.rb # NEW — serialized, contended, compensated
128
+ ├── lib/tasks/demo_reactors.rake # + demo:step_lock
129
+ └── spec/reactors/step_lock_demo_reactor_spec.rb # NEW — shipped matchers only
130
+
131
+ documentation/locks_and_semaphores.md # step-scoped forms, when to prefer which
132
+ README.md, CHANGELOG.md
133
+ ```
134
+
135
+ **Structure Decision**: Existing layout kept. One new library file carries the feature
136
+ (`executor/step_coordination.rb`); everything else is an edit to the file that already owns
137
+ the concern. Specs get a `step_coordination/` directory because they are concurrency tests
138
+ with shared harness needs, not unit tests scattered across existing files.
139
+
140
+ ## Phase 2 outline (for `/speckit-tasks`)
141
+
142
+ Dependency-ordered. Phases 1-6 deliver US1-US4 and US6-US8 in full.
143
+
144
+ 1. **Declaration surface** — host `Lockable` on `Step` and `StepBuilder`, introspection,
145
+ refuse on interrupt steps. No enforcement yet.
146
+ 2. **Exclusive lock enforcement (US1, US2)** — `StepCoordination` around the step body,
147
+ acquire/release, keep-alive, guard skip, key-computation failure. Real-concurrency specs.
148
+ 3. **Re-entrancy (US4)** — root-context owner, registry push/pop, `async_step` dispatch guard.
149
+ 4. **Contention (US3)** — requeue park, contention counter and ceiling, sync wait-then-fail.
150
+ 5. **Rollback (US6)** — re-take exclusion primitives for compensate/undo; verify rate/dedup are
151
+ not applied.
152
+ 6. **Remaining narrowing primitives (US5 partial)** — semaphore, rate limit, period-skips-step.
153
+ 7. **Observability + inline steps (US7, US8)** — events, dashboard, matcher additions, inline
154
+ equivalence.
155
+ 8. **Step-level ordered lock (US5 remainder)** — per-step nonce, heartbeat, advance-on-terminal,
156
+ strict chain skip. Last, and separable: see Complexity Tracking.
157
+ 9. **Docs + demo** — `documentation/locks_and_semaphores.md`, README, CHANGELOG, demo reactor +
158
+ rake + spec, docker acceptance run.
159
+
160
+ ## Complexity Tracking
161
+
162
+ | Violation | Why Needed | Simpler Alternative Rejected Because |
163
+ |-----------|------------|--------------------------------------|
164
+ | Five primitives at step level where the request named one (Principle V / YAGNI) | Explicit user decision after being shown the narrower option. Parity means an author never has to ask which primitives "work" on a step. | Shipping `with_lock` alone covers the stated use case and every acceptance scenario in US1-US4. It was offered and declined. The four extra primitives are sequenced after the core so the schedule can still absorb them being cut. |
165
+ | Step-level ordered lock provides a weaker guarantee than its reactor-level namesake | Included in the user's "all five" decision. Sequencing at step arrival is still useful for a step that sits first in its reactor, where arrival order equals enqueue order. | The reactor-level guarantee cannot be reproduced: the nonce would have to be assigned at enqueue, but the key expression reads arguments that do not exist until the step is reached (research Finding 5). Mitigation is documentation on the macro plus a demo that shows arrival ordering explicitly — not a silent redefinition of the word "ordered". |
166
+ | Contention behaves differently in a worker (park) than synchronously (wait, then fail) | Direct consequence of the chosen park-and-retry behavior; a synchronous run has no queue to park into. | Failing on both paths is simpler and was the recommended option; it was declined. `contention_wait` already encodes this exact split for reactor-level holds, so the divergence is inherited rather than invented, and it is one branch in one method. |
@@ -0,0 +1,169 @@
1
+ # Quickstart: Step-Scoped Coordination
2
+
3
+ **Feature**: `specs/003-step-lock-declarations/` | **Date**: 2026-09-10
4
+
5
+ How to run and verify this feature. DSL details: [contracts/dsl-surface.md](./contracts/dsl-surface.md).
6
+ Design rationale: [research.md](./research.md).
7
+
8
+ ## Prerequisites
9
+
10
+ Every claim here is a concurrency claim, so real infrastructure is mandatory — mocked Redis or
11
+ `Sidekiq::Testing.inline!` cannot prove any of it. Inline testing mode is actively wrong for
12
+ this feature: it re-enters the worker synchronously inside the frame that already holds the
13
+ lock.
14
+
15
+ ```bash
16
+ docker compose up -d redis-test # gem suite (port 6780)
17
+ docker compose up -d demo-redis sidekiq # demo app + a real worker
18
+ ```
19
+
20
+ ## Scenario 1 — one step serializes, the workflow does not (US1, US2)
21
+
22
+ ```ruby
23
+ class ChargeStep < RubyReactor::Step
24
+ input :account_id
25
+ input :amount
26
+
27
+ with_lock { |args| "acct:#{args[:account_id]}" }
28
+
29
+ def run
30
+ Success(charge!(inputs))
31
+ end
32
+ end
33
+
34
+ class PaymentReactor < RubyReactor::Reactor
35
+ input :account_id
36
+ input :amount
37
+
38
+ step :audit, AuditStep # unlocked
39
+ step :charge, ChargeStep # locked on the account
40
+ step :notify, NotifyStep # unlocked
41
+ end
42
+ ```
43
+
44
+ **Expected**: two concurrent runs with the same `account_id` never overlap inside `:charge`;
45
+ `:audit` and `:notify` of both runs overlap freely. Two runs with different `account_id`
46
+ overlap everywhere.
47
+
48
+ ```bash
49
+ bundle exec rspec spec/ruby_reactor/step_coordination/lock_spec.rb
50
+ ```
51
+
52
+ ## Scenario 2 — contention parks instead of failing (US3)
53
+
54
+ ```ruby
55
+ # Two worker-backed executions, same key:
56
+ PaymentReactor.run(account_id: 1, amount: 10) # via background dispatch
57
+ PaymentReactor.run(account_id: 1, amount: 20)
58
+
59
+ # => both complete successfully; the second after the first released.
60
+ # No compensation ran. The second was requeued, not failed.
61
+ ```
62
+
63
+ Synchronously there is no queue to park into:
64
+
65
+ ```ruby
66
+ PaymentReactor.run(account_id: 1, amount: 20) # in-process, key held elsewhere
67
+ # => Failure(Lock::AcquisitionError, reactor:, step: :charge, key: "acct:1")
68
+ # prior steps compensated, as with any step failure
69
+ ```
70
+
71
+ ```bash
72
+ bundle exec rspec spec/ruby_reactor/step_coordination/contention_spec.rb
73
+ ```
74
+
75
+ Also asserted there: contention attempts are bounded, counted separately from failure retries,
76
+ and an execution over the ceiling reports contention rather than snoozing forever.
77
+
78
+ ## Scenario 3 — re-entrancy matches nested reactors (US4)
79
+
80
+ ```ruby
81
+ class OuterReactor < RubyReactor::Reactor
82
+ input :id
83
+ with_lock { |i| "k:#{i[:id]}" } # reactor holds it
84
+ step :work, LockingStep # step declares the same key
85
+ end
86
+ ```
87
+
88
+ **Expected**: completes without waiting on itself; the key becomes available to other
89
+ executions only after the outermost release.
90
+
91
+ Where ownership cannot cross a process boundary, the hand-off is refused up front:
92
+
93
+ ```ruby
94
+ # execution holds "k:1", then dispatches work that declares "k:1"
95
+ # => Failure at dispatch naming the key, the holder, and how to restructure.
96
+ # Never a silent wait.
97
+ ```
98
+
99
+ ```bash
100
+ bundle exec rspec spec/ruby_reactor/step_coordination/reentrancy_spec.rb
101
+ ```
102
+
103
+ ## Scenario 4 — rollback runs under the same exclusivity (US6)
104
+
105
+ ```ruby
106
+ # :charge succeeds holding "acct:1", a later step fails, rollback reaches :charge
107
+ # => the compensation runs holding "acct:1"
108
+ # => a concurrent execution cannot enter :charge's forward work while it runs
109
+ ```
110
+
111
+ Rate ceilings and dedup windows are deliberately *not* applied to compensation — cleanup is
112
+ never suppressed by a forward-work quota.
113
+
114
+ ```bash
115
+ bundle exec rspec spec/ruby_reactor/step_coordination/rollback_spec.rb
116
+ ```
117
+
118
+ ## Scenario 5 — the other primitives (US5)
119
+
120
+ ```bash
121
+ bundle exec rspec spec/ruby_reactor/step_coordination/primitives_spec.rb
122
+ ```
123
+
124
+ Asserts, one per primitive:
125
+
126
+ - semaphore: at most N inside the step's work per key
127
+ - rate limit: further executions of the step contend rather than exceed the rate
128
+ - dedup window: the **step** is skipped and the workflow continues — the reactor is not halted
129
+ - ordered lock: executions pass through the step in sequence; stop-the-line short-circuits that
130
+ step for later positions
131
+
132
+ ## Scenario 6 — end to end against real infrastructure
133
+
134
+ ```bash
135
+ docker compose run --rm demo-app bin/rails demo:step_lock
136
+ ```
137
+
138
+ **Expected output**: the serialized path (two executions, non-overlapping step bodies), the
139
+ contended path (one parked and retried, both completing), and the compensated path (rollback
140
+ holding the same key).
141
+
142
+ ## Full suite
143
+
144
+ ```bash
145
+ docker compose up -d redis-test
146
+ bundle exec rspec
147
+ bundle exec rubocop
148
+
149
+ docker compose run --rm demo-app bundle exec rspec spec/reactors/step_lock_demo_reactor_spec.rb
150
+ docker compose run --rm demo-app bin/rails demo:step_lock
151
+ ```
152
+
153
+ ## Acceptance checklist
154
+
155
+ | # | Claim | Verified by |
156
+ |---|---|---|
157
+ | SC-001 | Same-key step bodies never overlap | Scenario 1, sustained concurrent run |
158
+ | SC-002 | Unrelated steps still overlap | Scenario 1 |
159
+ | SC-003 | Released within one step boundary, all outcomes | Scenario 1 + failure/raise cases |
160
+ | SC-004 | Contention costs zero compensations | Scenario 2 |
161
+ | SC-005 | Worker path protected identically to in-process | Scenarios 1 and 6 |
162
+ | SC-006 | Nested holds on one key complete without self-waiting | Scenario 3 |
163
+ | SC-007 | Deadlocking hand-offs refused at dispatch | Scenario 3 |
164
+ | SC-008 | Killed holder frees the key without operator action | kill-process test in lock_spec |
165
+ | SC-009 | Compensation runs under the same exclusivity | Scenario 4 |
166
+ | SC-010 | Operator can see step, key, holder; park ≠ failure | dashboard + log assertions |
167
+ | SC-011 | Uncomputable key never runs the work | lock_spec |
168
+ | SC-012 | Existing reactor-level coordination tests unchanged | full suite |
169
+ | SC-013 | Demo shows serialized, contended, compensated | Scenario 6 |