ruby_reactor 0.7.0 → 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.
- checksums.yaml +4 -4
- data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +92 -15
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +8 -1
- data/CHANGELOG.md +151 -0
- data/CLAUDE.md +2 -2
- data/README.md +149 -40
- data/lib/ruby_reactor/context.rb +9 -2
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +44 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
- data/lib/ruby_reactor/executor/step_executor.rb +27 -14
- data/lib/ruby_reactor/executor.rb +20 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +64 -17
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +178 -215
- data/lib/ruby_reactor/step.rb +119 -18
- data/lib/ruby_reactor/step_signals.rb +37 -0
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +61 -9
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +34 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- data/specs/001-background-async-steps/tasks.md +0 -271
|
@@ -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 |
|