Skip to content

feat(platform): hold/postpone — a fourth delivery outcome for backing off - #487

Draft
behinddwalls wants to merge 1 commit into
preetam/consumer-backofffrom
preetam/consumer-hold-impl
Draft

feat(platform): hold/postpone — a fourth delivery outcome for backing off#487
behinddwalls wants to merge 1 commit into
preetam/consumer-backofffrom
preetam/consumer-hold-impl

Conversation

@behinddwalls

@behinddwalls behinddwalls commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Why?

Queue controllers have no way to say "this message is fine, but it must wait." The outcome model is ack/nack/reject, so waiting stages ack and republish fresh copies of their own messages via PublishAfter, with message-id minting to dodge the publish dedup, retry accounting reset every cycle, and loop liveness hanging on a publish succeeding. Designed in doc/rfc/consumer-hold.md (previous commit).

What?

The messagequeue extension Delivery gains Postpone(delayMs): the delivery finalizes, the message becomes invisible for the delay, and it acts as a partition barrier — the mysql poll loop stops scanning the partition at a postponed row instead of skipping past it (nacked rows keep skip-and-continue, so failures never halt a partition). A new postponed flag on queue_delivery_state makes the post-postpone redelivery exempt from the retry_count increment and resets the count, so deliberate waits never burn the DLQ budget while real failures still dead-letter.

The consumer framework's Delivery view gains Hold(delayMs): an intent-recording call with no I/O. On a nil return from Process the framework postpones instead of acking (metric op postpone); an error return wins over a recorded hold (hold_ignored counter). A failed postpone write is abandoned like a failed ack — the visibility timeout lapses into a normal redelivery, so hold-loop liveness is framework-owned.

Controller unit tests across submitqueue/stovepipe/runway previously passed the extension mock as consumer.Delivery, which only worked structurally; they now use the consumer-facing mock (which has Hold).

Test Plan

make test (83 targets) — includes new consumer hold outcome tests, mysql MarkPostponed/GetDeliveryState store tests, and poll-loop barrier tests. ✅ bazel test //test/integration/extension/messagequeue/... — new end-to-end tests: postpone blocks the partition until due then redelivers in order as attempt 1; a postpone resets the budget but subsequent real failures still dead-letter. ✅ make fmt, make gazelle, make mocks.

Issues

Stack

  1. docs(rfc): consumer hold — postpone a delivery as a partition barrier #486
  2. @ feat(platform): hold/postpone — a fourth delivery outcome for backing off #487
  3. feat(stovepipe): migrate process and buildsignal waits to the hold primitive #488
  4. feat(orchestrator): migrate the buildsignal poll loop to the hold primitive #489
  5. feat(messagequeue)!: remove PublishAfter and the visible_after column #490
  6. feat(messagequeue)!: drop the requeue delay parameter from Nack #491
  7. feat(consumergate)!: rebuild the gate on the postpone primitive #492

… off

## Summary

### Why?

Queue controllers have no way to say "this message is fine, but it must wait." The outcome model is ack/nack/reject, so waiting stages ack and republish fresh copies of their own messages via PublishAfter, with message-id minting to dodge the publish dedup, retry accounting reset every cycle, and loop liveness hanging on a publish succeeding. Designed in doc/rfc/consumer-hold.md (previous commit).

### What?

The messagequeue extension Delivery gains Postpone(delayMs): the delivery finalizes, the message becomes invisible for the delay, and it acts as a partition barrier — the mysql poll loop stops scanning the partition at a postponed row instead of skipping past it (nacked rows keep skip-and-continue, so failures never halt a partition). A new `postponed` flag on queue_delivery_state makes the post-postpone redelivery exempt from the retry_count increment and resets the count, so deliberate waits never burn the DLQ budget while real failures still dead-letter.

The consumer framework's Delivery view gains Hold(delayMs): an intent-recording call with no I/O. On a nil return from Process the framework postpones instead of acking (metric op `postpone`); an error return wins over a recorded hold (`hold_ignored` counter). A failed postpone write is abandoned like a failed ack — the visibility timeout lapses into a normal redelivery, so hold-loop liveness is framework-owned.

Controller unit tests across submitqueue/stovepipe/runway previously passed the extension mock as consumer.Delivery, which only worked structurally; they now use the consumer-facing mock (which has Hold).

## Test Plan

✅ `make test` (83 targets) — includes new consumer hold outcome tests, mysql MarkPostponed/GetDeliveryState store tests, and poll-loop barrier tests. ✅ `bazel test //test/integration/extension/messagequeue/...` — new end-to-end tests: postpone blocks the partition until due then redelivers in order as attempt 1; a postpone resets the budget but subsequent real failures still dead-letter. ✅ `make fmt`, `make gazelle`, `make mocks`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant