You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: doc/rfc/stovepipe/steps/process.md
+80-16Lines changed: 80 additions & 16 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -26,7 +26,7 @@ For a delivery carrying request id `R`:
26
26
5. Coalesce: if R.Sequence < Q.latest_request_seq:
27
27
- a newer head exists -> mark R superseded, ack, return. (No slot consumed.)
28
28
6. R is the latest head. Gate: if Q.in_flight_count >= Q.max_concurrent:
29
-
- park the delivery (extend visibility, no ack/nack, no state change) -> re-check until the slot frees (admit) or a newer head supersedes it. See [Waiting for a slot](#waiting-for-a-slot).
29
+
- defer (Option 1 or Option 2 below) -> re-check until the slot frees (admit) or a newer head supersedes it. See [Waiting for a slot](#waiting-for-a-slot).
30
30
7. Admit R:
31
31
a. Derive build strategy + baseline (see "Build-strategy decision").
32
32
b. CAS the Queue row: in_flight_count += 1.
@@ -100,7 +100,7 @@ Why not `SourceControl.History`: a history walk is expensive, and after a rewrit
100
100
101
101
Ordering caveat: `counter.Next` doesn't guarantee assignment order, so under concurrent same-Queue ingest (rare — one serial poller) "highest sequence" may not equal "most recently reported". They agree in practice, and a rare inversion self-corrects next poll. Acceptable for MVP.
102
102
103
-
**The pointer prevents deadlock.** Under `BatchSize = 1` a parked delivery blocks its partition, so `process` can't learn of newer heads *from the stream*. But ingest writes `latest_request_seq` independently of the partition, so a parked head's re-check (or an intermediate delivery) sees the newer head at step 5 and supersedes the stale one. Without it, `process` could wait on a stale head forever behind the blocked partition.
103
+
**The pointer prevents deadlock.** Under `BatchSize = 1`, Option 1 blocks its partition while waiting, so `process` can't learn of newer heads *from the stream* during that wait. Option 2 unblocks the partition, so newer ingest deliveries can arrive immediately. Both options read `latest_request_seq` from the Queue row on every wake-up (step 5), so a stale waiter — blocked or delayed — still supersedes correctly. Ingest stamps the pointer independently of the partition (see [Backlog coalescing](#backlog-coalescing)).
104
104
105
105
**Progress (no starvation).** Superseding is always forward motion toward the newest head, and the newest head is never superseded (nothing is newer). So as long as `process` supersedes faster than ingest adds heads — it does, since superseding is a CAS + ack with no build, far cheaper than the poll cadence — a build always starts; a high commit rate just coalesces more intermediates away.
106
106
@@ -113,19 +113,19 @@ The gate is **not** tied to `process` returning; a slot taken at admit is held u
113
113
**Rules**
114
114
115
115
1.**One slot per in-flight validation** (MVP: one per Queue). `process` increments `in_flight_count` on admit; `record` decrements on terminal.
116
-
2.**No skip-ahead while in-flight.** The latest head parks until the running validation completes; it never preempts.
116
+
2.**No skip-ahead while in-flight.** The latest head waits for a slot until the running validation completes; it never preempts.
117
117
3.**Intermediates are superseded on sight**, gate open or closed — no slot consumed (step 5).
118
-
4.**Coalesce-to-latest on gate open.** When a slot frees, the parked latest head is admitted.
118
+
4.**Coalesce-to-latest on gate open.** When a slot frees, the waiting latest head is admitted.
119
119
5.**The cycle repeats** for whatever accumulated during the previous validation.
1.**A** admitted (`in_flight_count = 1`), published to `build`.
124
124
2. While A runs, **B**, **C**, **D** are ingested (`latest_request_seq = D.seq`).
125
-
3. B: `B.seq < D.seq` → **superseded** (acked), though A is still in flight. Same for **C**. D is latest but the gate is closed → **D parked**.
125
+
3. B: `B.seq < D.seq` → **superseded** (acked), though A is still in flight. Same for **C**. D is latest but the gate is closed → **waits for slot** (Option 1 or 2).
5. D's re-check → gate open, D still latest → **D admitted**, published to `build`.
128
-
6. While D runs, **E**, **F** ingested (`latest_request_seq = F.seq`). E superseded on sight; F parked.
128
+
6. While D runs, **E**, **F** ingested (`latest_request_seq = F.seq`). E superseded on sight; F waits for slot.
129
129
7. D completes → slot frees → **F admitted**.
130
130
131
131
A, D, F each get a full cycle; B, C, E end `superseded`. No intermediate is validated individually — intentional for MVP.
@@ -134,7 +134,7 @@ A, D, F each get a full cycle; B, C, E end `superseded`. No intermediate is vali
134
134
135
135
-`process` returning does **not** free a slot — only `record` (or DLQ reconciliation) does.
136
136
- A newer head does **not** preempt an in-flight validation.
137
-
-Parked messages are **not** failed or dead-lettered — they wait for the gate (see [Waiting for a slot](#waiting-for-a-slot)).
137
+
-Deferred messages are **not** failed or dead-lettered — they wait for the gate (see [Waiting for a slot](#waiting-for-a-slot)).
138
138
139
139
## Idempotency and at-least-once delivery
140
140
@@ -143,7 +143,7 @@ Every branch is safe under redelivery:
143
143
-**accepted, no strategy** → full admit path. On a crash after incrementing `in_flight_count` but before persisting `processing`, redelivery re-reads `accepted` and re-runs; the increment re-applies only if the count CAS hasn't already moved (see integrity below).
144
144
-**processing** → re-publish to `build` and ack. The `build` consumer is keyed on the request id and idempotent, so a duplicate publish is harmless.
-**parked** → no state or count change; pure deferral (re-parks on redelivery).
146
+
-**deferred (waiting for slot)** → no state or count change; pure deferral (re-enters on renewal or reschedule).
147
147
148
148
The window to handle is "count incremented, state not yet `processing`". Admit does the increment and the state transition as two ordered CAS writes, and the decrement is tied to the state transition, not a side counter (see integrity below).
149
149
@@ -159,7 +159,7 @@ On a crash between admit and `record`, the Request stays non-terminal; visibilit
159
159
## Edge cases
160
160
161
161
-**Re-ingest of a superseded URI.** Ingest dedups on `(Queue, URI)` and returns the existing (now terminal `superseded`) id; `process` acks it as a no-op (step 2). Correct: a URI is only superseded for a *strictly newer* head, so re-validating it is never wanted.
162
-
-**Gate closed, no newer head.** The single latest head parks until the in-flight validation completes — the steady state, not an error.
162
+
-**Gate closed, no newer head.** The single latest head waits for a slot until the in-flight validation completes — the steady state, not an error.
163
163
-**Head equals last-green.**`IsAncestor(lastGreen, R.URI)` with `R.URI == lastGreen` is degenerate; treat as already-green, or (simpler) run an incremental build with an empty delta. Left to `build`.
164
164
-**Queue row missing.** First head for a Queue: ingest get-or-creates the row with defaults (`in_flight_count = 0`, empty `last_green_uri`, `max_concurrent` from config). `process` treats a missing row as retryable (ingest write not yet visible).
165
165
@@ -207,19 +207,83 @@ No "list requests by queue/state" query is introduced; coalescing uses the singl
207
207
208
208
## Waiting for a slot
209
209
210
-
When the gate is closed, `process`defers the latest head by **parking the delivery** — no new platform primitive required. It keeps the in-flight message alive with periodic `ExtendVisibilityTimeout` calls (already exposed to controllers for long-running work) and re-checks the Queue row on an interval, re-running the algorithm's**coalesce-then-gate**order (steps 5 → 6) in this priority:
210
+
When the gate is closed, `process`must defer the latest head without admitting it (no `in_flight_count` increment, no publish to `build`). Two options fit; pick one at implementation time. Both re-run the same**coalesce-then-gate**checks on every wake-up (steps 5 → 6):
211
211
212
-
1.**Stale? (checked first.)** If a newer head arrived (`R.Sequence < latest_request_seq`), `R` is no longer latest → supersede it (ack); the newer head is admitted by its own delivery. Checking this first guarantees a freed slot never admits a stale parked head.
213
-
2.**Slot free?**Otherwise, if `in_flight_count < max_concurrent`, `R` is still latest → admit it.
212
+
1.**Stale? (checked first.)** If `R.Sequence < latest_request_seq`, `R` is no longer latest → supersede it (ack). A newer head is admitted by its own delivery when its slot attempt runs.
213
+
2.**Slot free?**If `in_flight_count < max_concurrent` and `R` is still latest → admit (step 7).
214
214
215
-
Otherwise keep waiting. The delivery is never acked or nacked while parked, and `ExtendVisibilityTimeout` renews the lease **without** incrementing `retry_count`, so a head can wait through an arbitrarily long build without being dead-lettered. The loop must honor context cancellation — on shutdown it returns promptly and the head resumes on redelivery.
215
+
Neither option admits to `build` until the gate opens.
216
216
217
-
**Why a missed renewal is safe.** If the worker stalls and the visibility window lapses, another worker may pick up the same head and run concurrently. Harmless: admission is CAS-guarded (`accepted → processing` plus the count increment), so only one admit wins and publishes; the loser sees `processing` (step 3) and no-ops. A missed renewal costs redundant work, never correctness.
217
+
### Option 1: park and extend visibility
218
218
219
-
**Alternative (high level): Hold.** At high Queue counts, one parked goroutine per waiting head is a real cost. A `Hold` primitive would instead *release* the message during the wait and lean on the queue's timer to redeliver it — the controller returns a `consumer.ErrHold` flow signal, the consumer skips ack/nack, and re-evaluation happens on redelivery rather than in a loop. It trades a small `platform/consumer` addition (plus care that a held redelivery not count toward `MaxAttempts`) for not holding a goroutine per waiting head. Deferred until parked-goroutine cost is shown to matter.
219
+
**Mechanism.** Keep the in-flight delivery alive. Loop: call `ExtendVisibilityTimeout` on an interval (renews the lease **without** incrementing `retry_count`), reload the Queue row, run coalesce-then-gate. Never ack or nack while waiting. Honor context cancellation — on shutdown return promptly; the head resumes on redelivery.
220
+
221
+
**Partition behavior.** Under `BatchSize = 1`, the delivery stays in-flight and **blocks the partition** until it admits or supersedes. Newer ingest messages queue behind it in the log; coalescing for the waiting head relies on `latest_request_seq` from the Queue row, not on newer deliveries arriving while blocked.
4. While D waits, **E**, **F** ingested (`latest_request_seq = F.seq`). Their process messages sit in the log behind D's blocked delivery — they do not run yet.
7. A completes at `record` → `in_flight_count → 0`. F's renew loop sees gate open → **admit F**, publish to `build`, ack.
232
+
233
+
**Supersede reasoning.** Simple while blocked: the waiter periodically re-checks `latest_request_seq` and supersedes itself when a newer head appears. Intermediates (B, C, E) only run once the partition unblocks enough to reach their offsets.
234
+
235
+
**Tradeoffs.**
236
+
237
+
| Pros | Cons |
238
+
|---|---|
239
+
| Delivery API only — no publisher in `process`| Goroutine blocked in renew loop per waiting head |
240
+
| One delivery, minimal log churn | Partition blocked — newer heads wait in the log |
241
+
|`ExtendVisibility` does not increment `retry_count`| Lease lapses (missed renewal) increment `retry_count` → `MaxAttempts` risk |
**Safety.** If renewal lapses, another worker may redeliver the same head concurrently. Harmless: admission is CAS-guarded; one admit wins, the other sees `processing` (step 3) and no-ops.
245
+
246
+
### Option 2: ack and `PublishAfter`
247
+
248
+
**Mechanism.** On gate closed and still latest: **ack** the current delivery, then **`PublishAfter`** the same `ProcessRequest` to the process topic (same partition key = queue name) after a short delay. Each wake-up is a **fresh** message (`retry_count` starts at 0). Run coalesce-then-gate at the top of every wake-up; if still latest and gate still closed, ack and `PublishAfter` again. Only reschedule when both conditions hold — otherwise supersede or admit immediately.
249
+
250
+
**Partition behavior.** Acks free the partition. Newer ingest deliveries (E, F, …) are processed and superseded while the latest head waits on a timer. Deferred rows use `visible_after` and are skipped until due — the same non-blocking property as a nacked message — so a reschedule at offset 11 can run *after* a newer ingest message at offset 12. Delivery order reshuffles relative to strict log order; coalescing keys off `latest_request_seq`, not offset.
251
+
252
+
**Walkthrough** — same scenario, Option 2 chosen:
253
+
254
+
1.**A** admitted, published to `build` (`in_flight_count = 1`).
255
+
2.**B**, **C** ingested → delivered and **superseded** on sight.
6. A completes at `record` → `in_flight_count → 0`. F's timer fires → gate open, still latest → **admit F**, publish to `build`, ack.
260
+
261
+
**Supersede reasoning.** Straightforward: every wake-up (immediate or delayed) runs step 5 first. Stale delayed messages (D) supersede on sight. Only the current latest (F) should schedule the next wait. Older delayed rows are expected no-ops, not errors.
262
+
263
+
**Tradeoffs.**
264
+
265
+
| Pros | Cons |
266
+
|---|---|
267
+
| No `MaxAttempts` burn while waiting (each cycle acks; reschedule is fresh) | Ack + new log row per poll cycle while gate is closed |
| Worker returns between waits — no blocked goroutine | Delivery order ≠ ingest order (correctness unaffected) |
270
+
| Stale delayed waiters self-clean via step 5 | Redundant delayed rows if multiple heads reschedule before timers fire |
271
+
272
+
### Comparison
273
+
274
+
||**Option 1: park + extend**|**Option 2: ack + `PublishAfter`**|
275
+
|---|---|---|
276
+
|**`MaxAttempts`**| Safe when renewals keep up; lease lapses increment `retry_count`| Safe — waiting never increments `retry_count`|
277
+
|**Partition (`BatchSize = 1`)**| Blocks until admit or supersede | Unblocks; newer heads process immediately |
278
+
|**Supersede**| Waiter polls `latest_request_seq` in loop | Immediate deliveries + stale timers supersede on wake-up |
279
+
|**Churn**| One delivery, periodic extends | One new log row per wait cycle |
280
+
|**Wiring**|`ExtendVisibilityTimeout` on `Delivery`| Publisher + topic registry in controller |
281
+
|**Worker**| Goroutine in renew loop | Returns; timer brings work back |
282
+
283
+
Implementation picks one option and wires step 6 to it. A future `consumer.ErrHold` primitive would resemble Option 2 (release + redeliver) without self-republish; neither option is chosen here yet.
220
284
221
285
## Batch consume
222
286
223
-
The MVP coalesces one delivery at a time via the sequence pointer. The only churn is intermediate heads, each delivered once and superseded; the parked latest head adds none (it waits in place).
287
+
Coalescing uses the sequence pointer one delivery at a time. Intermediates are each delivered once and superseded. The latest head adds no extra rows under Option 1 (it waits in place); under Option 2 it adds one reschedule row per wait cycle while the gate is closed.
224
288
225
289
If that churn matters at scale, an optional `BatchController` (receiving `[]Delivery` per poll) would let `process` supersede all intermediates in a single tick — an optimization over the single-delivery path that can land later without changing the state machine or storage contract.
0 commit comments