compute: make the peek stash a state transition of one scan - #38510
compute: make the peek stash a state transition of one scan#38510antiguru wants to merge 1 commit into
Conversation
3f5e531 to
3a98ea5
Compare
QA LLM Review1. MEDIUM -- Every peek over the stash threshold now needs a promotion permit, and holds it across the blob write
Routing the stash through the promoted driver puts every streamable peek whose result exceeds Details
Worth deciding explicitly rather than inheriting: either release the permit once the walk stops producing rows and let 2. LOW --
|
|
(Sorry, I don't have capacity to review today, because I need to urgently finish something else, and I'll be on PTO tomorrow. I can review next week, or you could ask Aljoscha, who originally authored the peek stash.) |
3a98ea5 to
3cd9c99
Compare
|
Review findings, posted by Claude Code on behalf of @antiguru. Line numbers are at 1. The size ceiling this layer added is per-worker, so a stashed answer still escapes itThis corrects the claim in
The fixture in the tree demonstrates it. What this layer did fix is real: a single worker's stashed answer was previously unbounded, because the prefix is handed away and reset on every batch. It does not restore the cross-worker bound. 2. The rewrite ships on by default and the kill switch does not cover it
Turning 3. The panic catch this layer is built around is never exercised
The test harness sets The primitive is the right one: Smaller, same area: the catch wraps only 4. No
|
3cd9c99 to
f7170ec
Compare
|
Findings addressed, posted by Claude Code on behalf of @antiguru. The result-size ceiling now bounds a stashed answer across workers. Each worker decides on its own whether its share is large enough to stash, so an oversized result arrives as several stashed responses each under the ceiling, and Corrections to my earlier report on this PR. I cited The defect is real, and here is what actually demonstrates it. With an index on the table so the peek takes the fast-path index route, the stash threshold low enough that every worker's share reaches the stash, and The panic catch is still untested, and now says so. The abort tests raise persist's outstanding-part bound to keep a stalled write out of the way, and that stall is the condition the catch exists for. Against in-memory persist a part write completes before an abort can land inside it, so the The stash location is off the sweep's hot path. It was cloned on every visit to every pending index peek, two Open for you. This layer ships default-on with no revert: |
2d7ec7f to
b5853d4
Compare
b5853d4 to
0cd261a
Compare
0cd261a to
5bd7d0b
Compare
5bd7d0b to
476c739
Compare
56d3833 to
c481f19
Compare
c481f19 to
ff9fe76
Compare
QA LLM Review1. MEDIUM -- A stashed row is charged 16 bytes more than the same row answered inline, so
|
ff9fe76 to
30a500d
Compare
30a500d to
5d3f7b9
Compare
5d3f7b9 to
634c05b
Compare
634c05b to
84b6258
Compare
84b6258 to
33e31fe
Compare
9bf89cf to
5367fa0
Compare
5367fa0 to
2b885a4
Compare
|
Confirmed and fixed. The mechanism is right on every step, and the fix goes wider than the one call site, because the site was not the only place using that ruler. What the two rulers are. One ruler now, in Three test fixtures pinned thresholds as The That drops the word "total" from the peek message, which The rest of the Posted by Claude Code on behalf of @antiguru. |
2b885a4 to
0bb8b7c
Compare
|
One correction to the reasoning: This shape only works from this commit up. In Posted by Claude Code on behalf of @antiguru. |
Deletes the second walk a stashed peek used to cost. A peek whose accumulated rows crossed the stash threshold abandoned its walk, and a separate path re-read the trace bundle from the beginning to stream rows into persist. The offloaded driver now writes each full batch as the walk produces it, so a peek walks its arrangement exactly once whatever its answer turns out to be, and never returns to the worker to reach the stash. The upload becomes a handle the driver feeds. `StashUpload` keeps everything about the persist interaction, the shard derived from the peek uuid, the schemas, the batch builder and the `max_rows` early exit, and takes rows through `push` instead of through a channel the worker pumps. `Complete` carries whatever the scan still holds, and the driver assembles the answer around it: a driver that never opened an upload answers with a row collection, and one that did finishes and answers with the stashed handle, whose `inline_rows` carry the rows that never reached the stash. The hand-back the layer below needed goes away, because the task now has somewhere to write. `PendingPeek` ends with fewer index-peek states than it had before this work started. `PendingPeek::Stash`, `StashingPeek` and its worker-driven pump, `start_stash_upload`, `PeekStatus::UsePeekStash` and `PEEK_STASH_NUM_BATCHES` are all gone. `PEEK_STASH_BATCH_SIZE` goes too, against the plan: it counted rows, the upload cuts a batch on the byte threshold the scan already tracks, and nothing read it. A live tunable that does nothing is worse than an absent one, because an operator who reaches for it gets silence rather than an error. Two consequences worth stating plainly rather than discovering later. The kill switch no longer means no peek ever leaves the worker. With `UsePeekStash` gone, a large streamable peek would otherwise have no route to the stash at all, which is a functional regression rather than a placement change. The switch therefore gates the budget-based offload only: a scan that suspends because its prefix is batch-ready is handed to a task whichever way the switch is set, because that offload is for stashing rather than for latency. With the switch off, ordinary peeks behave exactly as they do today. The row iteration limit now follows a peek into the peek stash. #38158 stopped at the stash because a stashed peek restarted its scan and the restart charged the same rows twice. Deleting the restart is what this change does, so the count simply continues because the scan does. Cancellation deletes what it wrote. A cancelled peek used to leave the parts already written in blob storage, since `impl Drop for Batch` only logs the dangling keys and the reader-side delete runs after a successful read that a cancelled peek never reaches. Cancellation aborts the offloaded task rather than signalling it, so an await placed after the cancellation check would never run, and the obligation lives on the upload itself: dropping one spawns a `Batch::delete()` onto a runtime handle captured when it opened. A guard covers the window inside `finish`, where persist has taken the builder and no upload holds the parts any more. Reclaiming those blobs must not cost more than it saves. A builder whose part write was in flight when its walk was aborted holds a write persist has already marked as waited on, and finishing it panics rather than returning; this replica aborts the process on any uncaught panic, so reclaiming one query's blob storage could take the replica with it. The panic is caught and the shard logged. Failing to reclaim is an outcome this path already tolerates for a replica that dies mid-upload, which makes a leak the right answer there and an abort the wrong one. `mz_index_peek_stashed_total` counts the walks the stash answered. It is incremented beside the offloaded substrate counter, so it is a strict subset by construction, and every inline-driver test asserts it stays at zero. `mz_stashed_peek_seconds` goes: its only observer was the deleted walk, and a registered histogram that never observes reports a flat zero, which reads worse in a graph than an absent series. The duration is deliberately not reconstructed, because one loop now walks and writes, so the write is not separable from the walk.
0bb8b7c to
120ff0e
Compare
Deletes the second walk a stashed peek used to cost. A peek whose accumulated rows crossed the stash threshold abandoned its walk, and a separate path re-read the trace bundle from the beginning to stream rows into persist. The offloaded driver now writes each full batch as the walk produces it, so a peek walks its arrangement exactly once whatever its answer turns out to be, and never returns to the worker to reach the stash. This is the layer that makes the one-path claim in the design document true.
The upload becomes a handle the driver feeds.
StashUploadkeeps everything about the persist interaction, the shard derived from the peek uuid, the schemas, the batch builder, themax_rowsearly exit, and takes rows throughpushinstead of through a channel the worker pumps.Completecarries whatever the scan still holds, and the driver assembles the answer around it: a driver that never opened an upload answers with a row collection, and one that did finishes and answers with the stashed handle, whoseinline_rowscarry the rows that never reached the stash. The hand-back the layer below needed goes away, because the task now has somewhere to write.PendingPeekends with fewer index-peek states than it had before this work started, which is the trade the design says the effort is making.PendingPeek::Stash,StashingPeekand its worker-driven pump,start_stash_upload,PeekStatus::UsePeekStashandPEEK_STASH_NUM_BATCHESare all gone.PEEK_STASH_BATCH_SIZEgoes too, against the plan: it counted rows, the upload cuts a batch on the byte threshold the scan already tracks, and nothing read it. A live tunable that does nothing is worse than an absent one, because an operator who reaches for it gets silence rather than an error.Two consequences worth stating plainly rather than discovering later.
The kill switch no longer means no peek ever leaves the worker. With
UsePeekStashgone, a large streamable peek would otherwise have no route to the stash at all, which is a functional regression rather than a placement change. The switch therefore gates the budget-based offload only: a scan that suspends because its prefix is batch-ready is handed to a task whichever way the switch is set, because that offload is for stashing rather than for latency. With the switch off, ordinary peeks behave exactly as they do today.The row iteration limit now follows a peek into the peek stash. #38158 stopped at the stash because a stashed peek restarted its scan and the restart charged the same rows twice. Deleting the restart is what this change does, so the count simply continues because the scan does. The dyncfg description said otherwise and no longer does.
Cancellation deletes what it wrote. A cancelled peek used to leave the parts already written in blob storage, since
impl Drop for Batchonly logs the dangling keys and the reader-side delete runs after a successful read that a cancelled peek never reaches. Cancellation aborts the offloaded task rather than signalling it, so an await placed after the cancellation check would never run, and the obligation lives on the upload itself: dropping one spawns aBatch::delete()onto a runtime handle captured when it opened. A guard covers the window insidefinish, where persist has taken the builder and no upload holds the parts any more.Reclaiming those blobs must not cost more than it saves. A builder whose part write was in flight when its walk was aborted holds a write persist has already marked as waited on, and finishing it panics rather than returning; this replica aborts the process on any uncaught panic, so reclaiming one query's blob storage could take the replica with it. The panic is caught and the shard logged. Failing to reclaim is an outcome this path already tolerates for a replica that dies mid-upload, which makes a leak the right answer there and an abort the wrong one. A Linear issue tracks the persist-side fix that would remove the need for the catch.
mz_index_peek_stashed_totalcounts the walks the stash answered. It is incremented beside the offloaded substrate counter, so it is a strict subset by construction, and every inline-driver test asserts it stays at zero.mz_stashed_peek_secondsgoes: its only observer was the deleted walk, and a registered histogram that never observes reports a flat zero, which reads worse in a graph than an absent series. The duration is deliberately not reconstructed, because one loop now walks and writes, so the write is not separable from the walk.What an upload can delete is not always everything it wrote. Persist merges runs once a builder holds more than
peek_response_stash_batch_max_runsof them, and a merge writes a fresh output and drops the inputs it read without entering them into shard state. Parts flush atpersist_blob_target_size, so an upload small enough never to merge deletes all of what it wrote and a large one deletes the output of its last merge. A reader deleting a response it has finished with reaches exactly the same parts, so this is a property of the builder rather than of abandonment, and it is unchanged by this PR.Nothing bounds the total size of a stashed answer, which is also true today.
max_result_sizebounds the prefix a single scan retains between batches and never the sum across them. The only ceiling is the reader's own budget.Known gaps, none of which this PR closes:
inline_rowswas always empty from a worker on the old path and is now non-empty up to the threshold, and the adapter merges it across workers, so that merge has no coverage in CI.persist_blob_target_size, and the abandon task outlives the walk's permit.cargo test.🤖 Opened by Claude Code on behalf of @antiguru
Replaces #38482, which GitHub closed when the design document moved from the bottom of the stack to the top and its branch was force-pushed past these commits. Same content, new base.