ruby_reactor 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +2 -2
- data/README.md +35 -2
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +31 -11
- data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +7 -1
- data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
- data/lib/ruby_reactor/step/compose_step.rb +14 -3
- data/lib/ruby_reactor/step.rb +49 -7
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +27 -5
- data/specs/future_improvements.md +250 -0
- metadata +8 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- data/specs/possible_feature.md +0 -22
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
# Feature Specification: Step-Scoped Coordination
|
|
2
|
-
|
|
3
|
-
**Feature Branch**: `step_validations`
|
|
4
|
-
|
|
5
|
-
**Created**: 2026-09-10
|
|
6
|
-
|
|
7
|
-
**Status**: Draft
|
|
8
|
-
|
|
9
|
-
**Input**: User description: "One more feature to added to steps: Locks should be able to be declared in the class steps
|
|
10
|
-
|
|
11
|
-
```ruby
|
|
12
|
-
class MyStep
|
|
13
|
-
include RubyReactor::Step
|
|
14
|
-
input :id
|
|
15
|
-
with_lock { |i| "k:#{i[:id]}" }
|
|
16
|
-
end
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
It should follow the same reentry primitives as the nested reactors do."
|
|
20
|
-
|
|
21
|
-
## User Scenarios & Testing *(mandatory)*
|
|
22
|
-
|
|
23
|
-
### User Story 1 - A step class declares the lock it needs (Priority: P1)
|
|
24
|
-
|
|
25
|
-
A workflow author writes a step that must not run concurrently with another execution of
|
|
26
|
-
itself for the same subject — charging one account, updating one inventory row, syncing one
|
|
27
|
-
external record. Today the only place to say that is the whole reactor, which locks far more
|
|
28
|
-
than the step needs and forces the key to be derived from reactor inputs rather than from the
|
|
29
|
-
step's own values.
|
|
30
|
-
|
|
31
|
-
The author declares the lock inside the step class, next to the inputs it is keyed on. The
|
|
32
|
-
lock is taken immediately before the step's work begins and released as soon as the step
|
|
33
|
-
finishes.
|
|
34
|
-
|
|
35
|
-
**Why this priority**: This is the feature. Without it, "lock this one step" is expressible
|
|
36
|
-
only by locking the entire reactor.
|
|
37
|
-
|
|
38
|
-
**Independent Test**: Define a step class with a declared lock keyed on one of its inputs,
|
|
39
|
-
run two reactors concurrently with the same key value, and confirm the step bodies never
|
|
40
|
-
overlap; run two with different key values and confirm they do overlap.
|
|
41
|
-
|
|
42
|
-
**Acceptance Scenarios**:
|
|
43
|
-
|
|
44
|
-
1. **Given** a step class declaring a lock keyed on one of its inputs, **When** two executions
|
|
45
|
-
with the same key value run concurrently, **Then** the second cannot enter the step's work
|
|
46
|
-
until the first has left it.
|
|
47
|
-
2. **Given** the same step class, **When** two executions with different key values run
|
|
48
|
-
concurrently, **Then** both enter the step's work at the same time.
|
|
49
|
-
3. **Given** a step holding a declared lock, **When** the step's work succeeds, **Then** the
|
|
50
|
-
lock is released before the next step begins.
|
|
51
|
-
4. **Given** a step holding a declared lock, **When** the step's work raises or returns a
|
|
52
|
-
failure, **Then** the lock is released rather than held until it expires.
|
|
53
|
-
5. **Given** a step whose lock key derives from an input, **When** the reactor supplies that
|
|
54
|
-
input, **Then** the key is computed from the step's own resolved values, not from the
|
|
55
|
-
reactor's inputs.
|
|
56
|
-
|
|
57
|
-
---
|
|
58
|
-
|
|
59
|
-
### User Story 2 - Only the step is locked, not the whole workflow (Priority: P1)
|
|
60
|
-
|
|
61
|
-
An author has a reactor where one step out of eight needs exclusivity. Locking the reactor
|
|
62
|
-
serializes all eight and holds the lock across slow, lock-irrelevant work. With a
|
|
63
|
-
step-scoped lock, the other seven steps of two concurrent executions run in parallel and only
|
|
64
|
-
the one contended step serializes.
|
|
65
|
-
|
|
66
|
-
**Why this priority**: This is the value the feature delivers over what exists. Without it,
|
|
67
|
-
the declaration has moved but the behavior has not improved.
|
|
68
|
-
|
|
69
|
-
**Independent Test**: Run two executions of an eight-step reactor whose third step declares a
|
|
70
|
-
lock on a shared key; confirm the first two steps of both run concurrently and only the third
|
|
71
|
-
serializes.
|
|
72
|
-
|
|
73
|
-
**Acceptance Scenarios**:
|
|
74
|
-
|
|
75
|
-
1. **Given** a reactor where one step declares a lock, **When** two executions run
|
|
76
|
-
concurrently with the same key, **Then** only that step serializes; the surrounding steps
|
|
77
|
-
overlap.
|
|
78
|
-
2. **Given** the same reactor, **When** one execution is waiting on the step's coordination,
|
|
79
|
-
**Then** the other execution's unrelated steps are not blocked by that wait.
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
### User Story 3 - Contention parks the execution instead of failing it (Priority: P1)
|
|
84
|
-
|
|
85
|
-
Two executions reach the same locked step at the same time. The loser does not fail — its
|
|
86
|
-
work has not been attempted and nothing needs compensating. When the execution is running in
|
|
87
|
-
a worker, it steps aside and is retried later, the way an out-of-turn ordered execution
|
|
88
|
-
already snoozes today. The workflow completes; it simply completes later.
|
|
89
|
-
|
|
90
|
-
Running synchronously in the calling process there is no queue to step aside into. There, the
|
|
91
|
-
step waits up to its configured wait and then fails with a contention error naming the step
|
|
92
|
-
and the key.
|
|
93
|
-
|
|
94
|
-
**Why this priority**: Contention is the normal case a lock exists to handle. Turning routine
|
|
95
|
-
contention into failure-plus-rollback would make step locks unusable for the workloads that
|
|
96
|
-
most need them.
|
|
97
|
-
|
|
98
|
-
**Independent Test**: Run two worker-backed executions against the same key and confirm both
|
|
99
|
-
eventually complete successfully, the second after the first released; then run the same pair
|
|
100
|
-
synchronously and confirm the loser fails with a contention error.
|
|
101
|
-
|
|
102
|
-
**Acceptance Scenarios**:
|
|
103
|
-
|
|
104
|
-
1. **Given** two worker-backed executions contending for one step's key, **When** the loser
|
|
105
|
-
cannot take the key, **Then** it is retried later and eventually completes successfully.
|
|
106
|
-
2. **Given** the loser is parked for a later attempt, **When** it parks, **Then** no step of
|
|
107
|
-
that execution has been compensated and no side effect of the contended step has occurred.
|
|
108
|
-
3. **Given** a parked execution holds other coordination for the same execution, **When** it
|
|
109
|
-
parks and later resumes, **Then** it keeps ownership across the gap and resumes without
|
|
110
|
-
re-competing for what it already held.
|
|
111
|
-
4. **Given** a synchronous in-process execution, **When** it cannot take the key within its
|
|
112
|
-
configured wait, **Then** the step fails with a contention error naming the reactor, step,
|
|
113
|
-
and key, and prior steps compensate as any step failure does.
|
|
114
|
-
5. **Given** repeated contention, **When** an execution is retried more times than its
|
|
115
|
-
configured ceiling, **Then** it stops being retried and reports the contention rather than
|
|
116
|
-
snoozing forever.
|
|
117
|
-
|
|
118
|
-
---
|
|
119
|
-
|
|
120
|
-
### User Story 4 - Re-entrancy behaves exactly as nested workflows already do (Priority: P1)
|
|
121
|
-
|
|
122
|
-
An execution never blocks on coordination it already holds. A step keyed the same as its own
|
|
123
|
-
reactor proceeds; nested work started inside a locked step proceeds; a key is released for
|
|
124
|
-
other executions only once the outermost holder within the execution is done with it.
|
|
125
|
-
|
|
126
|
-
Where ownership genuinely cannot be shared — work handed to another process, which runs
|
|
127
|
-
concurrently rather than within the holder — the system refuses at hand-off time with an
|
|
128
|
-
actionable message instead of letting the two sides wait on each other forever.
|
|
129
|
-
|
|
130
|
-
**Why this priority**: Coordination that deadlocks against itself is worse than no
|
|
131
|
-
coordination. The existing rules for nested workflows are the rules; step locks must not
|
|
132
|
-
introduce a second, different set.
|
|
133
|
-
|
|
134
|
-
**Independent Test**: Build a reactor that locks a key, containing a step that locks the same
|
|
135
|
-
key, containing nested work that locks it again; confirm it completes. Then hand the nested
|
|
136
|
-
work to another process and confirm the hand-off is refused with a message naming the key.
|
|
137
|
-
|
|
138
|
-
**Acceptance Scenarios**:
|
|
139
|
-
|
|
140
|
-
1. **Given** a reactor holding a key, **When** a step inside it declares the same key,
|
|
141
|
-
**Then** the step proceeds without waiting.
|
|
142
|
-
2. **Given** a step holding a key, **When** its work starts nested work declaring the same
|
|
143
|
-
key, **Then** the nested work proceeds without waiting.
|
|
144
|
-
3. **Given** nested holds on one key within one execution, **When** the inner holds are
|
|
145
|
-
released, **Then** the key stays unavailable to other executions until the outermost hold
|
|
146
|
-
is released.
|
|
147
|
-
4. **Given** an execution holding a key, **When** it tries to hand work declaring that key to
|
|
148
|
-
another process, **Then** the hand-off is refused before dispatch with a message naming
|
|
149
|
-
the key, the holder, and how to restructure.
|
|
150
|
-
5. **Given** a step whose work is handed to a worker, **When** the work runs there, **Then**
|
|
151
|
-
the key is taken by that worker — never taken in the dispatching process and carried
|
|
152
|
-
across.
|
|
153
|
-
6. **Given** an execution that parks mid-flight while holding coordination, **When** it
|
|
154
|
-
resumes, **Then** it re-adopts what it held without a duplicate acquisition being recorded,
|
|
155
|
-
and falls back to competing normally if the hold lapsed while parked.
|
|
156
|
-
|
|
157
|
-
---
|
|
158
|
-
|
|
159
|
-
### User Story 5 - The whole coordination family is available per step (Priority: P2)
|
|
160
|
-
|
|
161
|
-
Everything a reactor can declare about coordination, a step can declare about itself:
|
|
162
|
-
exclusivity, a concurrency ceiling, a rate ceiling, once-per-window deduplication, and strict
|
|
163
|
-
ordering. Each keeps the meaning it has at reactor level, narrowed to the step.
|
|
164
|
-
|
|
165
|
-
**Why this priority**: Parity is what makes the step a real unit of work rather than a
|
|
166
|
-
partial one; but exclusivity alone already delivers the primary value.
|
|
167
|
-
|
|
168
|
-
**Independent Test**: Declare each primitive on a step in turn and confirm the step-scoped
|
|
169
|
-
behavior matches the reactor-scoped behavior narrowed to that step.
|
|
170
|
-
|
|
171
|
-
**Acceptance Scenarios**:
|
|
172
|
-
|
|
173
|
-
1. **Given** a step declaring a concurrency ceiling of N for a key, **When** more than N
|
|
174
|
-
executions reach it, **Then** at most N are inside the step's work at once and the rest
|
|
175
|
-
contend as US3 describes.
|
|
176
|
-
2. **Given** a step declaring a rate ceiling, **When** the ceiling is reached, **Then**
|
|
177
|
-
further executions of that step contend as US3 describes rather than exceeding the rate.
|
|
178
|
-
3. **Given** a step declaring once-per-window deduplication, **When** a second execution
|
|
179
|
-
reaches it in the same window with the same key, **Then** that **step** is skipped and the
|
|
180
|
-
rest of the workflow continues — the reactor is not halted.
|
|
181
|
-
4. **Given** a step declaring strict ordering, **When** executions reach it out of order,
|
|
182
|
-
**Then** each waits until its turn, and the surrounding steps are unaffected.
|
|
183
|
-
5. **Given** a step declaring strict ordering with stop-the-line behavior, **When** an earlier
|
|
184
|
-
position in the sequence ends in failure, **Then** later positions short-circuit at that
|
|
185
|
-
step rather than executing it.
|
|
186
|
-
|
|
187
|
-
---
|
|
188
|
-
|
|
189
|
-
### User Story 6 - Coordination is re-taken to undo the work it protected (Priority: P2)
|
|
190
|
-
|
|
191
|
-
A locked step succeeded; a later step failed; rollback reaches the locked step. The
|
|
192
|
-
compensating work touches the same resource the forward work did, so it runs under the same
|
|
193
|
-
exclusivity — a refund never races another execution's charge on the same key.
|
|
194
|
-
|
|
195
|
-
**Why this priority**: Without it, the lock protects the forward path and abandons the
|
|
196
|
-
rollback path, which is where correctness problems are hardest to see.
|
|
197
|
-
|
|
198
|
-
**Independent Test**: Fail a reactor after a locked step succeeded; confirm the compensation
|
|
199
|
-
of that step holds the same key, and that a concurrent execution cannot enter the step's
|
|
200
|
-
forward work while the compensation runs.
|
|
201
|
-
|
|
202
|
-
**Acceptance Scenarios**:
|
|
203
|
-
|
|
204
|
-
1. **Given** a step that declared exclusivity and succeeded, **When** rollback compensates it,
|
|
205
|
-
**Then** the compensation runs holding the same key, computed from the same values.
|
|
206
|
-
2. **Given** that compensation is running, **When** another execution reaches the same step
|
|
207
|
-
with the same key, **Then** it cannot enter until the compensation has released the key.
|
|
208
|
-
3. **Given** a step declaring a rate ceiling or a deduplication window, **When** it is
|
|
209
|
-
compensated, **Then** the compensation is not gated by those — cleanup is never suppressed
|
|
210
|
-
by a forward-work quota.
|
|
211
|
-
4. **Given** compensation cannot take the key, **When** the wait expires, **Then** the
|
|
212
|
-
rollback reports it rather than silently skipping the compensation.
|
|
213
|
-
|
|
214
|
-
---
|
|
215
|
-
|
|
216
|
-
### User Story 7 - Operators can see step coordination (Priority: P2)
|
|
217
|
-
|
|
218
|
-
An operator debugging a stalled workflow needs to know which step is waiting on which key,
|
|
219
|
-
and which execution holds it. Step coordination appears in the same surfaces reactor-level
|
|
220
|
-
coordination already does — logs, failure records, and the dashboard's coordination view.
|
|
221
|
-
|
|
222
|
-
**Why this priority**: Required by the project's observability commitments; the feature
|
|
223
|
-
functions without it.
|
|
224
|
-
|
|
225
|
-
**Independent Test**: Start a long-held step lock, inspect the dashboard's coordination view
|
|
226
|
-
and the logs for the waiting execution, and confirm the step, key, and holder are
|
|
227
|
-
identifiable.
|
|
228
|
-
|
|
229
|
-
**Acceptance Scenarios**:
|
|
230
|
-
|
|
231
|
-
1. **Given** a step holding coordination, **When** an operator inspects the running execution,
|
|
232
|
-
**Then** the key, the owning step, and the holder are visible.
|
|
233
|
-
2. **Given** a step that could not take its key, **When** the outcome is inspected, **Then**
|
|
234
|
-
it names the reactor, the step, and the key.
|
|
235
|
-
3. **Given** coordination is taken and released, **When** instrumentation is enabled, **Then**
|
|
236
|
-
acquisition, release, and failure are observable as distinct events attributed to the step.
|
|
237
|
-
4. **Given** an execution parked by contention, **When** an operator inspects it, **Then** it
|
|
238
|
-
is distinguishable from a failed execution and shows what it is waiting on.
|
|
239
|
-
|
|
240
|
-
---
|
|
241
|
-
|
|
242
|
-
### User Story 8 - Inline steps can declare coordination too (Priority: P3)
|
|
243
|
-
|
|
244
|
-
An author writing a short inline step declares its coordination in the step block, using the
|
|
245
|
-
same words a step class uses, so moving the step into a class later is a copy rather than a
|
|
246
|
-
rewrite.
|
|
247
|
-
|
|
248
|
-
**Why this priority**: Consistency; class steps are the project's preferred style, so this is
|
|
249
|
-
a completeness item.
|
|
250
|
-
|
|
251
|
-
**Independent Test**: Declare a lock on an inline step, confirm the same behavior as the class
|
|
252
|
-
form, then move it into a class unchanged.
|
|
253
|
-
|
|
254
|
-
**Acceptance Scenarios**:
|
|
255
|
-
|
|
256
|
-
1. **Given** an inline step declaring a lock, **When** two executions with the same key run,
|
|
257
|
-
**Then** the behavior matches the class form exactly.
|
|
258
|
-
|
|
259
|
-
---
|
|
260
|
-
|
|
261
|
-
### Edge Cases
|
|
262
|
-
|
|
263
|
-
- The key expression raises, or returns an unusable value (nil, empty): the step fails before
|
|
264
|
-
the work runs, naming the step and the cause. Work is never run unprotected because its key
|
|
265
|
-
could not be computed.
|
|
266
|
-
- The step's work outlives the coordination's expiry: the hold is kept alive while the work
|
|
267
|
-
runs, so a slow step does not silently lose exclusivity mid-flight.
|
|
268
|
-
- The holding process crashes while the step is running: the hold expires on its own so the
|
|
269
|
-
key does not stay locked forever, and the next execution proceeds.
|
|
270
|
-
- The step is skipped by a condition or guard: nothing is taken for work that never runs.
|
|
271
|
-
- Two steps in one reactor declare the same key: the second takes it after the first released
|
|
272
|
-
it; they do not deadlock, because both holds belong to the same execution.
|
|
273
|
-
- A step declares coordination and its work is handed to another process: the hold is taken in
|
|
274
|
-
that process, and the hand-off is refused up front if the dispatching execution already
|
|
275
|
-
holds the key (US4 scenario 4).
|
|
276
|
-
- An execution parks at an interrupt while a step's hold is live: the hold is kept through the
|
|
277
|
-
gap bounded by its expiry and re-adopted on resume, without recording a second acquisition.
|
|
278
|
-
- Repeated contention: retries are bounded, and an execution that never wins reports the
|
|
279
|
-
contention rather than snoozing indefinitely.
|
|
280
|
-
- The coordination backing store is unreachable: the step fails with a clear cause rather than
|
|
281
|
-
proceeding unprotected.
|
|
282
|
-
- A step declares more than one primitive: they are taken in a fixed, documented order and
|
|
283
|
-
released in reverse, so two steps declaring the same pair can never deadlock against each
|
|
284
|
-
other.
|
|
285
|
-
|
|
286
|
-
## Requirements *(mandatory)*
|
|
287
|
-
|
|
288
|
-
### Functional Requirements
|
|
289
|
-
|
|
290
|
-
#### Declaration
|
|
291
|
-
|
|
292
|
-
- **FR-001**: A step class MUST be able to declare coordination for itself, with keys derived
|
|
293
|
-
from the step's own resolved argument values.
|
|
294
|
-
- **FR-002**: The full coordination family MUST be declarable at step level — exclusivity, a
|
|
295
|
-
concurrency ceiling, a rate ceiling, once-per-window deduplication, and strict ordering —
|
|
296
|
-
each keeping its reactor-level meaning narrowed to the step.
|
|
297
|
-
- **FR-003**: Once-per-window deduplication at step level MUST skip the **step** and let the
|
|
298
|
-
workflow continue, rather than halting the reactor as the reactor-level form does.
|
|
299
|
-
- **FR-004**: Strict ordering at step level MUST sequence executions at that step only, and
|
|
300
|
-
its stop-the-line behavior MUST short-circuit that step for later positions rather than the
|
|
301
|
-
whole workflow.
|
|
302
|
-
- **FR-005**: An inline step MUST be able to declare coordination with the same vocabulary a
|
|
303
|
-
step class uses, with identical behavior.
|
|
304
|
-
- **FR-006**: Declarations MUST be introspectable, so tooling and operational views can report
|
|
305
|
-
which steps coordinate and on what keys.
|
|
306
|
-
- **FR-007**: A key that cannot be computed MUST fail the step before its work runs, naming
|
|
307
|
-
the step and the cause.
|
|
308
|
-
- **FR-008**: A step declaring multiple primitives MUST take them in a fixed, documented order
|
|
309
|
-
and release them in reverse.
|
|
310
|
-
|
|
311
|
-
#### Scope and lifecycle
|
|
312
|
-
|
|
313
|
-
- **FR-009**: Coordination MUST be taken immediately before the step's work begins and
|
|
314
|
-
released when the step finishes — on success, failure, or unexpected error.
|
|
315
|
-
- **FR-010**: Coordination MUST be taken in whichever process performs the step's work,
|
|
316
|
-
including background workers, retried attempts, and runs resumed after an interrupt. It MUST
|
|
317
|
-
NOT be held in a process that is only dispatching work elsewhere.
|
|
318
|
-
- **FR-011**: A step-scoped hold MUST NOT serialize the steps around it — concurrent
|
|
319
|
-
executions MUST continue to overlap on every step that does not share the key.
|
|
320
|
-
- **FR-012**: Nothing MUST be taken for a step that a condition or guard prevents from running.
|
|
321
|
-
- **FR-013**: A hold MUST be kept alive while its step's work is still running, and MUST expire
|
|
322
|
-
on its own if the holding process dies.
|
|
323
|
-
- **FR-014**: Declaring coordination MUST NOT change which steps run or in what order; it
|
|
324
|
-
changes only when a step may begin.
|
|
325
|
-
|
|
326
|
-
#### Contention
|
|
327
|
-
|
|
328
|
-
- **FR-015**: When an execution running in a worker cannot take a step's coordination within
|
|
329
|
-
its configured wait, the execution MUST be parked and retried later rather than failed. No
|
|
330
|
-
step MUST be compensated and no side effect of the contended step MUST have occurred.
|
|
331
|
-
- **FR-016**: When an execution running synchronously in the calling process cannot take a
|
|
332
|
-
step's coordination within its configured wait, the step MUST fail with a contention error
|
|
333
|
-
naming the reactor, step, and key, and rollback MUST proceed as for any step failure.
|
|
334
|
-
- **FR-017**: Retries caused by contention MUST be bounded; an execution exceeding the ceiling
|
|
335
|
-
MUST report the contention rather than being retried indefinitely.
|
|
336
|
-
- **FR-018**: A parked execution MUST keep ownership of coordination it already holds across
|
|
337
|
-
the gap and re-adopt it on resume without recording a duplicate acquisition, falling back to
|
|
338
|
-
competing normally if the hold lapsed while parked.
|
|
339
|
-
|
|
340
|
-
#### Re-entrancy
|
|
341
|
-
|
|
342
|
-
- **FR-019**: Holds MUST be owned by the execution, not by the individual step or reactor, so
|
|
343
|
-
that any work within one execution proceeds on a key that execution already holds.
|
|
344
|
-
- **FR-020**: Nested holds on one key within one execution MUST be counted, and the key MUST
|
|
345
|
-
remain unavailable to other executions until the outermost hold is released.
|
|
346
|
-
- **FR-021**: The keys an execution currently holds MUST be tracked for the execution as a
|
|
347
|
-
whole, so that hand-off decisions and operational views can see them.
|
|
348
|
-
- **FR-022**: Ownership MUST NOT be shared across a hand-off to another process. Handing off
|
|
349
|
-
work that declares a key the dispatching execution currently holds MUST be refused before
|
|
350
|
-
dispatch, with a message naming the key, the holder, and how to restructure.
|
|
351
|
-
- **FR-023**: A step's declared coordination MUST be honored when the step class is invoked
|
|
352
|
-
directly, not only when a reactor executes it.
|
|
353
|
-
|
|
354
|
-
#### Rollback
|
|
355
|
-
|
|
356
|
-
- **FR-024**: Exclusivity and concurrency ceilings declared by a step MUST be re-taken for that
|
|
357
|
-
step's compensation and undo, using the same key computed from the same values.
|
|
358
|
-
- **FR-025**: Rate ceilings and deduplication windows MUST NOT gate compensation or undo —
|
|
359
|
-
cleanup MUST never be suppressed by a forward-work quota.
|
|
360
|
-
- **FR-026**: Compensation that cannot take its key within the configured wait MUST be reported
|
|
361
|
-
rather than silently skipped.
|
|
362
|
-
|
|
363
|
-
#### Compatibility and delivery
|
|
364
|
-
|
|
365
|
-
- **FR-027**: Reactor-level coordination declarations MUST continue to work unchanged; step
|
|
366
|
-
level is additive and independent.
|
|
367
|
-
- **FR-028**: Acquisition, release, and acquisition failure MUST be observable as distinct
|
|
368
|
-
events attributed to the step, carrying the key.
|
|
369
|
-
- **FR-029**: Step-level coordination MUST appear in the operational views that already show
|
|
370
|
-
reactor-level coordination state, identified by its step, and a contention-parked execution
|
|
371
|
-
MUST be distinguishable from a failed one.
|
|
372
|
-
- **FR-030**: The feature MUST ship a runnable demo reactor, a demo task, and a spec using only
|
|
373
|
-
the shipped test surface, demonstrating the serialized path, the contention path, and the
|
|
374
|
-
compensation path.
|
|
375
|
-
- **FR-031**: Documentation MUST show the step-scoped forms, state when to prefer them over
|
|
376
|
-
reactor-level declarations, and describe the contention outcome on both execution paths.
|
|
377
|
-
|
|
378
|
-
### Key Entities *(include if data involved)*
|
|
379
|
-
|
|
380
|
-
- **Step Coordination Declaration**: what a unit of work declares about when it may run.
|
|
381
|
-
Attributes: primitive kind, key expression (evaluated against the step's resolved
|
|
382
|
-
arguments), limits, expiry, wait tolerance, keep-alive. Owned by exactly one step.
|
|
383
|
-
- **Hold**: the runtime fact that one execution holds one key. Attributes: key, owning
|
|
384
|
-
execution, owning step, nesting count, acquired-at, expiry. Ends when the outermost hold is
|
|
385
|
-
released or the expiry lapses.
|
|
386
|
-
- **Held-Key Registry**: the set of keys an execution currently holds, tracked for the
|
|
387
|
-
execution as a whole and consulted when work is handed to another process.
|
|
388
|
-
- **Contention Outcome**: what an execution gets when it cannot take a key in time — a parked
|
|
389
|
-
execution scheduled for a later attempt, or a contention failure, depending on whether it is
|
|
390
|
-
running in a worker or synchronously.
|
|
391
|
-
|
|
392
|
-
## Success Criteria *(mandatory)*
|
|
393
|
-
|
|
394
|
-
### Measurable Outcomes
|
|
395
|
-
|
|
396
|
-
- **SC-001**: Two concurrent executions of a reactor whose step declares the same key never
|
|
397
|
-
overlap inside that step's work — 0 overlapping entries across a sustained concurrent run.
|
|
398
|
-
- **SC-002**: In the same run, every step that does not share the key overlaps freely; total
|
|
399
|
-
wall-clock time is bounded by the contended step alone, not by the whole workflow.
|
|
400
|
-
- **SC-003**: Coordination is released within one step boundary of the step finishing, in 100%
|
|
401
|
-
of outcomes including failures and unexpected errors.
|
|
402
|
-
- **SC-004**: 100% of worker-backed executions that lose contention still complete
|
|
403
|
-
successfully on a later attempt, with zero compensations triggered by the contention itself.
|
|
404
|
-
- **SC-005**: A step whose work runs in a background worker is protected identically to one
|
|
405
|
-
that runs in the calling process — the same concurrency test passes on both paths.
|
|
406
|
-
- **SC-006**: A nested arrangement holding one key at reactor, step, and nested-work level
|
|
407
|
-
completes without waiting on itself, and the key becomes available to other executions only
|
|
408
|
-
after the outermost release.
|
|
409
|
-
- **SC-007**: 100% of hand-offs that would deadlock on a held key are refused at dispatch with
|
|
410
|
-
a message naming the key — none are allowed to wait indefinitely.
|
|
411
|
-
- **SC-008**: A killed process holding step coordination leaves the key available again
|
|
412
|
-
without operator action.
|
|
413
|
-
- **SC-009**: A compensation of a step that declared exclusivity runs under that exclusivity in
|
|
414
|
-
100% of rollbacks, verified by a concurrent execution being unable to enter the step's
|
|
415
|
-
forward work during the compensation.
|
|
416
|
-
- **SC-010**: An operator can identify the step, key, and holder of any live step-level hold,
|
|
417
|
-
and can tell a contention-parked execution from a failed one, without reading application
|
|
418
|
-
code.
|
|
419
|
-
- **SC-011**: A step whose key cannot be computed never executes its work.
|
|
420
|
-
- **SC-012**: Every existing reactor-level coordination test passes unchanged.
|
|
421
|
-
- **SC-013**: The demo runs end to end in the project's container setup, showing the
|
|
422
|
-
serialized, contended, and compensated paths.
|
|
423
|
-
|
|
424
|
-
## Assumptions
|
|
425
|
-
|
|
426
|
-
- The audience is developers authoring reactors and steps with this library.
|
|
427
|
-
- Step-scoped coordination reuses the existing coordination guarantees, expiry semantics,
|
|
428
|
-
keep-alive behavior, and backing store; this feature changes the scope of a hold, not the
|
|
429
|
-
mechanism.
|
|
430
|
-
- Re-entrancy reuses the rules nested workflows already follow, unchanged: holds owned by the
|
|
431
|
-
execution, counted nesting, an execution-wide registry of held keys, refusal at hand-off
|
|
432
|
-
when ownership cannot be shared, and keep-ownership-across-parks with re-adoption on resume.
|
|
433
|
-
- The key expression receives the step's resolved arguments — the same values the step's work
|
|
434
|
-
receives.
|
|
435
|
-
- Contention behavior deliberately differs by execution path: parked-and-retried in a worker,
|
|
436
|
-
wait-then-fail synchronously. This is a consequence of there being no queue to step aside
|
|
437
|
-
into in a synchronous run; it is called out in the documentation so authors know which they
|
|
438
|
-
will get. A synchronous author who wants the parked behavior can run the workflow in the
|
|
439
|
-
background.
|
|
440
|
-
- Compensation re-takes only the mutual-exclusion primitives. Rate ceilings and deduplication
|
|
441
|
-
windows gate whether forward work happens, not whether cleanup happens.
|
|
442
|
-
- Declaring coordination is opt-in per step; steps that declare none behave exactly as today.
|
|
443
|
-
- Reactor-level declarations remain the right tool for "this whole workflow is exclusive"; step
|
|
444
|
-
level is for "this one operation is exclusive". Documentation must say which to reach for.
|
|
445
|
-
- This feature composes with steps declaring their own inputs (see
|
|
446
|
-
`specs/002-step-input-contracts/`), since a key expression reads the step's arguments, but
|
|
447
|
-
does not require it — a step wired only with reactor-side arguments can declare coordination.
|