feat(messagequeue)!: carry a structured failure across the dead-letter boundary - #562
Merged
Merged
Conversation
## Summary ### Why? Speculation died for an entire queue whenever any batch depended on one that had reached `merging`: ``` speculator failed for queue demo-queue: score dependency "demo-queue/batch/1": failed to resolve storage for queue "": queue name must not be empty ``` The root cause is a caller contract violation, not a generator bug. `speculator.Speculate` documents `batches` as "every in-flight batch of the queue, plus any finalized batch still referenced as a dependency by an in-flight one". `ask` was passing `snap.speculating` — the speculating heads alone. `read` already assembles the right set in `snap.batches`; it simply was not the slice handed over. A merging dependency was therefore absent from the generator's index, `batchByID[id]` returned a zero `entity.Batch`, and that zero batch reached the scorer with an empty `Queue`. The blast radius was the whole queue rather than one batch, because `Generate` scores every unresolved dependency up front to seed its heap and returned on the first error. `standard.Speculate` then short-circuited before the allocator pulled a single candidate: no path failed, none was ever produced, and the fall-back-to-the-next-path machinery sits downstream of a stage that had already died. A measured run of 20 requests left 9 in `error` and 11 wedged in `speculating` with nothing recorded against them. ### What? **Hand the Speculator the whole queue.** `ask` now flattens `snap.batches` — every batch the run read — and passes that. No ordering is imposed: the generator's heap comparator is a strict total order, so its candidate sequence is identical whatever order the input arrives in (checked over 200 shuffles), and a Speculator that read meaning into input order would be relying on something the contract never offered. `ask`'s doc comment argued for the narrow slice and is rewritten. Widening is safe because of the commit below this one. A head this run has just decided still reads as `Speculating` in the snapshot — `finalize` records only terminal outcomes — and both the Speculator and `check` read head eligibility off that same map, so a stale entry would fool both filters at once. What stops it mattering is that a head now merges only once *every* dependency has settled: the generator pins them all, can therefore construct nothing but the path that already passed, and the allocator skips that as finished. Verified by driving the real `bestfirst`/`sticky` pair with a merged head and settled dependencies — zero actions proposed for it. That ordering is load-bearing, which is why the merge gate is the parent rather than a follow-up. With the old gate a head could merge past a dependency that was still live; the generator would then see that dependency as an open question, offer a path ID the set had never held, and the allocator would fund a fresh build for a batch already handed to Runway. **Make one unpriceable dependency cost only its own estimate.** `score` now substitutes `defaultProbability` when the scorer returns an error, and never calls the scorer at all for a dependency the snapshot did not carry — that batch is zero in every field, so scoring it would price some other batch entirely or fail on its empty queue name. Context cancellation is still fatal, including when it surfaces *as* the scorer's error: the loop checks `ctx` before each call, so a context that dies during the last one would otherwise be absorbed as an unpriceable dependency and hand back an iterator to a caller that has already gone. Scorer failures stay observable through the scorer's own metrics span, which already reports them via `op.Complete(retErr)`. That is the whole containment fix. An earlier revision of this branch also made scoring lazy — heads seeded at an optimistic bound and priced on first pull — and it has been dropped. The only admissible bound for an unpriced head is `log 1`, identical for every head, so the first pull priced the entire queue anyway; the laziness bought one narrow case (a run that pulls nothing because the budget is saturated) in exchange for a placeholder, an admissibility argument, priced and unpriced items sharing a heap, and five reworked tests. Defaulting on failure fixes the bug on its own. **`Merging` is left as an open question in the generator.** Tempting to pin it to *succeeds* — the batch looks committed to landing — but a merge can fail, so nothing is settled, and it would put a state-specific policy inside the search when whether a path betting against a merging batch is worth funding is a question of price that belongs to the scorer. The allocator already draws exactly this line — "no batch state enters this decision — `merging` and the rest are states of a batch, never of a path" — and the generator holds it too. ## Test Plan - ✅ `make test` — 96/96 pass - ✅ `make lint`, `make check-gazelle`, `make check-tidy` New and reworked coverage, per defect: - `TestRun_PassesSnapshotToSpeculator` — the Speculator receives every batch the run read, in a stable order - `TestBestFirst_AbsorbsScorerError` — a scorer error costs that dependency its estimate and nothing else - `TestBestFirst_NeverScoresAnAbsentDependency` — an absent dependency never reaches the scorer, even when the caller hands over a malformed snapshot - `TestBestFirst_MergingDependencyStaysOpen` — a merging dependency is priced like any other and keeps both sides - `TestBestFirst_HonorsCancelledContext/a scorer that fails on a dead context ends the run` The last was added for a defect a review of this branch turned up, and was confirmed to fail against the code as it stood before the fix. Not verified end to end: `make demo-pr` lives on the `sq/demo-pr` branch, so reproducing the original 20-request run needs that target ported across worktrees plus a Docker stack. ## Issue Fixes https://linear.app/uber/issue/CODEM-424 That issue proposed lazy scoring as its primary fix; this lands the containment it was after without the algorithm change, for the reasons above. Follow-up filed as https://linear.app/uber/issue/CODEM-428 — this stops queues wedging this way, but a queue already wedged still has no event that will wake it, because speculation is edge-triggered only. The merge-gate defect found while investigating this one — a head could merge on a *fails* assumption that had not come true — is the parent commit, since the widening here relies on the invariant it restores.
…r boundary
## Summary
### Why?
Today the only thing that crosses the dead-letter boundary is a string, and on the most common path not even that.
`Nack(ctx)` takes no reason at all, so when a retryable error exhausts its budget the poll loop dead-letters the message with the hardcoded literal `"exceeded retry limit"`. The error that actually caused it exists only in a log line. `Reject` does better — it passes `err.Error()` — but that is a flattened `fmt.Errorf` chain, so a consumer wanting to know *what* failed has nothing to read but prose.
That is a problem for any stage whose work is wider than the message that triggers it. Such a stage can fail because of an entity the message does not name, or because of nothing in particular, and a dead-letter reconciler has no way to tell — so it acts on the entity it does have and can terminate the wrong one.
### What?
A failure now travels as data. `platform/base/failure` defines `Failure{Message, Subjects, Detail}`, where a `Subject{Type, ID}` names an entity the failure is about. Types are domain-chosen, so the platform stays domain-agnostic. A failure is always about *something*: when no single record is at fault the subject is the wider thing that is, which leaves an empty subject list to mean "unattributed" rather than "nothing was to blame".
The pieces:
- `errs.Attribute` / `errs.Detail` attach subjects and context to an error; `errs.Attribution` reads them back, merging across layers. The wrapper implements `Unwrap` and is neither a `userError` nor an `infraError`, so classification walks straight through it and retryability is unchanged.
- `Nack` and `Reject` take a `Failure`, and `Delivery.Failure()` returns the one recorded against a dead-lettered message.
- The consumer builds the failure from the error the controller returned, defaulting the message to `err.Error()`. A controller that attributes nothing produces exactly what callers sent before, which is what makes this change behaviour-neutral.
- `Nack` also dead-letters directly once the retry budget is spent, so the attempt still holding the reason is the one that records it. The poll-time check stays as a backstop for a delivery that never reaches `Nack` — a crash, or a visibility timeout — and that path keeps the generic literal. Message count is unchanged: at attempt N the next poll would have dead-lettered iff `N >= MaxAttempts`, which is the condition `Nack` now applies.
Storage splits the failure across two columns of `queue_messages`. `last_error` keeps the human-readable message, unchanged in meaning, so nothing has to decode it and `SELECT last_error` stays useful. A new `failure_detail JSON` column holds subjects and detail. The split is what makes the round trip unambiguous — one column would force a decoder to guess whether the text is an envelope or a message, and any error whose text happened to be valid JSON would decode as a malformed envelope and lose the message. An absent `failure_detail` means unattributed, with no heuristic involved, which is exactly the state of rows written before this column existed and of the retry-limit backstop.
`failure_detail` is nullable rather than taking an empty sentinel like its neighbours: a JSON column rejects `''`. `MoveToDLQ` binds SQL NULL explicitly instead of a nil `[]byte`, so the value does not depend on driver conversion.
No behaviour changes for any existing caller. This is the mechanism a follow-up uses to attribute speculation failures and stop them stranding a queue.
## Test Plan
✅ `bazel test //platform/... //submitqueue/... //runway/... //stovepipe/...`
✅ `bazel test //test/integration/extension/messagequeue/...`
✅ `make lint check-tidy check-gazelle check-mocks`
New coverage:
- `platform/base/failure` — codec round trip including nested detail, and the `float64` number-decoding behaviour pinned so it cannot surprise a caller later.
- `platform/errs` — the regression that matters: `errors.As` still reaches a wrapped driver error *through* the envelope, and a classifier still marks it retryable. Had the wrapper broken the chain walk, every storage error would have silently stopped being retryable.
- `platform/extension/messagequeue/mysql` — the nack-time dead-letter boundary as a table (budget remaining, one attempt left, final attempt, single-attempt budget, unset budget), because dead-lettering one attempt early would silently cost every message a retry.
- `platform/consumer` — an unattributed controller error yields a message-only failure, proving behaviour-neutrality.
- Integration — the DLQ test previously asserted `dlq.last_error == "exceeded retry limit"`; it now asserts the real reason from the final nack, and that the subject survives the round trip.
## Issue
Part of CODEM-428
behinddwalls
force-pushed
the
preetam/codem-428-failure-envelope
branch
from
August 11, 2026 18:36
a995972 to
aa7ef40
Compare
behinddwalls
marked this pull request as ready for review
August 11, 2026 18:36
behinddwalls
force-pushed
the
preetam/codem-428-failure-envelope
branch
from
August 11, 2026 21:06
aa7ef40 to
a995972
Compare
mnoah1
approved these changes
Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Why?
Today the only thing that crosses the dead-letter boundary is a string, and on the most common path not even that.
Nack(ctx)takes no reason at all, so when a retryable error exhausts its budget the poll loop dead-letters the message with the hardcoded literal"exceeded retry limit". The error that actually caused it exists only in a log line.Rejectdoes better — it passeserr.Error()— but that is a flattenedfmt.Errorfchain, so a consumer wanting to know what failed has nothing to read but prose.That is a problem for any stage whose work is wider than the message that triggers it. Such a stage can fail because of an entity the message does not name, or because of nothing in particular, and a dead-letter reconciler has no way to tell — so it acts on the entity it does have and can terminate the wrong one.
What?
A failure now travels as data.
platform/base/failuredefinesFailure{Message, Subjects, Detail}, where aSubject{Type, ID}names an entity the failure is about. Types are domain-chosen, so the platform stays domain-agnostic. A failure is always about something: when no single record is at fault the subject is the wider thing that is, which leaves an empty subject list to mean "unattributed" rather than "nothing was to blame".The pieces:
errs.Attribute/errs.Detailattach subjects and context to an error;errs.Attributionreads them back, merging across layers. The wrapper implementsUnwrapand is neither auserErrornor aninfraError, so classification walks straight through it and retryability is unchanged.NackandRejecttake aFailure, andDelivery.Failure()returns the one recorded against a dead-lettered message.err.Error(). A controller that attributes nothing produces exactly what callers sent before, which is what makes this change behaviour-neutral.Nackalso dead-letters directly once the retry budget is spent, so the attempt still holding the reason is the one that records it. The poll-time check stays as a backstop for a delivery that never reachesNack— a crash, or a visibility timeout — and that path keeps the generic literal. Message count is unchanged: at attempt N the next poll would have dead-lettered iffN >= MaxAttempts, which is the conditionNacknow applies.Storage splits the failure across two columns of
queue_messages.last_errorkeeps the human-readable message, unchanged in meaning, so nothing has to decode it andSELECT last_errorstays useful. A newfailure_detail JSONcolumn holds subjects and detail. The split is what makes the round trip unambiguous — one column would force a decoder to guess whether the text is an envelope or a message, and any error whose text happened to be valid JSON would decode as a malformed envelope and lose the message. An absentfailure_detailmeans unattributed, with no heuristic involved, which is exactly the state of rows written before this column existed and of the retry-limit backstop.failure_detailis nullable rather than taking an empty sentinel like its neighbours: a JSON column rejects''.MoveToDLQbinds SQL NULL explicitly instead of a nil[]byte, so the value does not depend on driver conversion.No behaviour changes for any existing caller. This is the mechanism a follow-up uses to attribute speculation failures and stop them stranding a queue.
Test Plan
✅
bazel test //platform/... //submitqueue/... //runway/... //stovepipe/...✅
bazel test //test/integration/extension/messagequeue/...✅
make lint check-tidy check-gazelle check-mocksNew coverage:
platform/base/failure— codec round trip including nested detail, and thefloat64number-decoding behaviour pinned so it cannot surprise a caller later.platform/errs— the regression that matters:errors.Asstill reaches a wrapped driver error through the envelope, and a classifier still marks it retryable. Had the wrapper broken the chain walk, every storage error would have silently stopped being retryable.platform/extension/messagequeue/mysql— the nack-time dead-letter boundary as a table (budget remaining, one attempt left, final attempt, single-attempt budget, unset budget), because dead-lettering one attempt early would silently cost every message a retry.platform/consumer— an unattributed controller error yields a message-only failure, proving behaviour-neutrality.dlq.last_error == "exceeded retry limit"; it now asserts the real reason from the final nack, and that the subject survives the round trip.Issue
Part of CODEM-428
Issues