compute: walk an expensive index peek off the serving worker - #38509
Conversation
08d2504 to
b942886
Compare
b942886 to
51b8da6
Compare
51b8da6 to
9f485fc
Compare
9f485fc to
9f54d66
Compare
|
Review findings, posted by Claude Code on behalf of @antiguru. Line numbers are at 1. The offloaded diversion drops a soundness guard the inline driver keeps
ScanOutcome::Suspended if scan.batch_ready() => {
metrics.observe_error_phase(&scan.phases());
return Some(OffloadOutcome::NeedsStash);
}The inline driver, at the same decision point ( if !scan.error_trace_clean() {
soft_panic_or_log!("peek on {} suspended before its error trace was read out", ...);
return PeekStatus::Ready(PeekResponse::Error(...));
}Both drivers make the same decision, handing a peek to a stash walk that reads the ok trace only. Its own comment says "the guard states that rather than assuming it", and the offload path assumes it. If anything ever lets the error phase accumulate rows, or Worth noting for whoever fixes it: 2. The permit does not bound the memory the module doc says it bounds
False for a queued walk. The permit is acquired inside the task at Concretely: 500 expensive peeks promote on a two-worker process, two run, 498 sit in the queue, each holding pinned batch handles and up to the stash threshold of rows. Retained memory scales with promoted-and-queued walks, which nothing bounds. 3. The permit count equals the tokio runtime's worker-thread count
The PR already knows this: 4. The semaphore's reach is right in one place and wrong in four
An operator sizing the flag from its description under-counts admissions by 2x on a two-runtime process. 5. Test coverage: permit accounting is pinned only on the cancellation path, and the tokio cancellation mechanism nowhere
6. A fixture disagrees with its own schema
Minor
Checked and cleanPermit lifetime and drop order in the running task: The resume ring: no starvation found. The kill switch restores placement byte-for-byte: Conventions clean: no |
9f54d66 to
bf11206
Compare
|
Findings addressed, posted by Claude Code on behalf of @antiguru. Your inline comments are answered on their own threads. The diversion guard is no longer asymmetric. The inline driver refuses to divert a peek to the stash unless its error walk is over, because the stash answers from the ok trace alone and a peek diverted with its error trace half-read would return rows where it owes an error. The promoted driver made the same decision without the guard, so it now mirrors it. It is load-bearing for this layer only: #38510 keeps one scan across the stash boundary, and both guards go with the diversion they guarded. Two properties the tests did not pin. A walk that answers has to return its permit, or the bound shrinks by one per promoted peek until the replica admits nothing. And dropping a promoted peek has to abort the walk: every cancellation test also dropped the receiving end of the answer channel, so the walk stopped on the closed-channel backstop and deleting What the permit bounds, and how far it reaches. The module doc said the permit ties the concurrency bound to the memory those walks retain. It does not: a promoted walk that has not been admitted sits in the queue holding its scan, which retains its accumulated rows and pins the batches its cursors were opened over, so retained memory grows with promoted walks rather than running ones. And the semaphore covers the workers of one Smaller. The |
|
Second round addressed, posted by Claude Code on behalf of @antiguru. Each of your eight comments is answered on its own thread; this is what changed beyond them. The permit bound is a plain fraction. Three clones became moves. The sweep matched the pending peek by reference, so the arms that hand a peek to another driver cloned it and its trace bundle instead of taking them. The dyncfg defaults test is gone. Its scope and value assertions restated the literals a few lines above them, and its two ordering assertions constrain only the defaults, which an operator may override without either check running. A verbosity pass over the stack, not only the lines you marked. |
721eaa7 to
c8d65e7
Compare
c8d65e7 to
236bd69
Compare
QA LLM Review1. MEDIUM -- parallel-workload still varies the permit bound in nano-fractionsmisc/python/materialize/parallel_workload/action.py:3109 Details
The list is: # One permit per worker (the default), a bound that serializes every
# promoted walk, and one that never queues. Expressed in
# nano-fractions of the process's worker count.
self.flags_with_values["compute_index_peek_permit_fraction"] = [
"1000000000",
"1",
"1000000000000",
]Against
# One permit per worker (the default), a bound that serializes every
# promoted walk, and one that never queues.
self.flags_with_values["compute_index_peek_permit_fraction"] = [
"1.0",
"0.0001",
"1000.0",
]2. LOW -- the new .slt never runs its queries with the offload offtest/sqllogictest/index_peek_offload.slt:15 DetailsThe file states that every query "has to answer exactly as it does with the offload off, and each is run both ways", but sqllogictest runs with The assertions are still correct answers, so nothing passes that should fail; what is lost is the A/B the file says it performs. The flag-off placement is covered by |
236bd69 to
7466a3f
Compare
|
Both findings are right and both are fixed in 1. The permit bound in nano-fractions. Confirmed: the values were left behind when the parameter became a plain # One permit per worker (the default), a bound that serializes every
# promoted walk, and one that never queues. A fraction of the process's
# worker count, floored at one permit, so any tiny fraction serializes.
self.flags_with_values["compute_index_peek_permit_fraction"] = [
"1.0",
"0.0001",
"1000.0",
]The serializing value relies on the floor in 2. The .slt never runs with the offload off. Also confirmed: this PR adds the flag to Posted by Claude Code on behalf of @antiguru. |
QA LLM Review1. MEDIUM -- the offloaded walk builds its answer unsliced on a tokio runtime worker threadsrc/compute/src/compute_state/peek_offload.rs:288 DetailsEvery slice of an offloaded walk is stepped on the blocking pool, but the terminal step that turns the accumulated rows into the answer runs inline in the async task:
How large the row set gets: This is the one step the module doc's "neither the timely worker nor an async one carries the walk" ( Fix: give the terminal answer the same treatment as a slice. Either build it inside the |
853867f to
00926d8
Compare
|
Confirmed and fixed in The finding is right on every step. Fixed with the second of the two suggested shapes, wrapping the single call: return Some(match upload {
// Onto the blocking pool for the same reason a slice goes there:
// building the answer sorts and copies the whole row set, and a
// finishing that carries an order never reaches the stash, so it
// accumulates the whole result before it can.
None => {
let answer_metrics = metrics.clone();
let order_by = order_by.to_vec();
mz_ore::task::spawn_blocking(
|| "peek_offload::answer",
move || answer_metrics.rows_response(rows, &order_by),
)
.await
}
Some(upload) => stashed_answer(peek_uuid, upload, rows).await,
});Building it inside the slice closure would have to move the metrics and the order into every slice to serve the one that turns out to be terminal, so the wrapper keeps the slice loop as it is. The stashed arm is left as it is. Posted by Claude Code on behalf of @moritz*.* |
00926d8 to
40f7a4b
Compare
40f7a4b to
316c9fe
Compare
|
Two fixes here, both found while reading rather than reported.
The offloaded-walk counter comment said the opposite of what the code does. It read as though counting past the walk was what caught a cancelled walk. It is what excludes one: Posted by Claude Code on behalf of @antiguru. |
316c9fe to
5ed961e
Compare
|
Three things from the same read.
Refilling at Posted by Claude Code on behalf of @antiguru. |
5ed961e to
f49c9fe
Compare
f49c9fe to
a2c45cd
Compare
|
|
||
| // The aggregate does not refill within an activation, so the first peek the budget cannot | ||
| // serve is also the last: every peek behind it would be passed over for the same reason. | ||
| let mut queued_peeks = std::mem::take(&mut self.compute_state.queued_peeks); |
There was a problem hiding this comment.
We should leave a note here about why we take it out of self. I assume it's to be able to call walk_index_peek below which is a &mut Self receiver method
There was a problem hiding this comment.
Right, that is the reason. Noted in 2be9565e11:
// Both queues are taken out of the state for the sweep, because serving a peek borrows
// `self` mutably. A peek the sweep returns for another turn lands in the emptied queue.The second sentence is the part worth knowing: walk_index_peek pushes a not-yet-ready peek back into self.compute_state.queued_peeks while the sweep holds the taken copy, which is why the tail of the function merges the two rather than assigning.
🤖 Posted by Claude Code on behalf of @antiguru
| // accumulate, so it travels to the offloaded walk with its positions and their cost, | ||
| // which is what makes the offload cost one hand-off rather than a second walk. | ||
| ScanOutcome::Suspended if !scan.batch_ready() => return PeekStatus::Offload(scan), | ||
| // Diversion is sound only for a scan whose error walk is over. The stash answers the |
There was a problem hiding this comment.
What ensures that we never suspend mid-error walk. From my understanding we fuel the error walk so it should be possible to stop while not having read everything.
What would happen if we unconditionally returned PeekStatus::Offload(_) in both cases?
There was a problem hiding this comment.
Nothing prevents suspending mid-error walk, and that case is fine: it takes the arm above this one. A suspension during the error walk has no batch, because only the ok walk accumulates rows, so !scan.batch_ready() holds and the scan goes to PeekStatus::Offload, which resumes it where it stopped. The guard here is on the other arm only, the one that hands the peek to the stash, and it can fail only if a scan holds a full batch without having finished the error walk, which the scan's structure rules out. It states the invariant rather than assuming it.
Unconditional Offload in both cases would wedge here: at this layer the offloaded driver writes nothing, and a scan holding a full batch makes no progress until the batch is taken, so the walk would spin on a suspension it cannot resolve. #38510 is where that becomes the right answer. There the offloaded walk owns the stash upload, so it does exactly what you describe: both suspensions go to Offload, and UsePeekStash and this guard disappear with the second walk.
🤖 Posted by Claude Code on behalf of @antiguru
a2c45cd to
2be9565
Compare
2be9565 to
45f8a74
Compare
|
That scenario is 100 filter peeks over a million-row index on one worker, each rejecting every row. Every peek offloads, and the walk was cut into slices of The walk now stays on its blocking thread for as long as it has nothing to await. It checks for cancellation and re-reads its configuration every
🤖 Posted by Claude Code on behalf of @antiguru |
45f8a74 to
41a3350
Compare
41a3350 to
2e236f7
Compare
QA LLM Review1. MEDIUM -- lowering
|
Moves an expensive fast-path index peek's walk off the timely worker that
received it, so a long scan no longer delays the peeks queued behind it. This is
the layer the ones beneath it were built for.
A peek runs its first slice inline under a small budget, sized so point lookups
finish there and nothing else does. If it completes, the peek never leaves the
worker. If it outruns the budget it is offloaded to a task that walks the rest
on the blocking pool, holding a permit from a process-wide semaphore. The walk
stays on its blocking thread, checking for cancellation every few thousand
positions, and returns to the runtime only to answer: a round trip per slice
costs two thread wakes, which is more than the slice itself on a small machine.
Cost is measured rather than predicted, so a skewed point lookup over a hot key
needs no special case: it enters as a point lookup, overruns, and offloads.
The offload is for a scan that suspended with its budget spent, never for one
holding a full batch. An offloaded task has nowhere to write a batch until the
stash becomes a state transition of the same scan, and stepping a batch-ready
scan returns without spending fuel or advancing, so offloading one would spin. A
scan that fills a batch while offloaded hands back and the worker starts the
existing stash walk.
The permit is owned by the running task rather than by the pending peek, so
releasing it coincides with releasing the batches it accounts for. Permits
default to this runtime's worker count, which preserves the ceiling that exists
today: a peek blocking its worker already costs one core per worker. Excess scans
queue in the semaphore, so a peek storm costs queue entries rather than threads.
Cancellation needs no new mechanism at any stage. Removing the pending peek drops
the result channel's receiver, and the walk observes a closed sender at its next
cancellation check; a scan still waiting for a permit leaves the queue the same
way.
Permits release on drop, including on panic.
The peeks awaiting a turn on the worker and the peeks a driver has taken over sit
in separate queues, because they need opposite treatment. The former all draw on
one per-activation aggregate that does not refill within an activation, so the
sweep serves them from the front and stops at the first peek the budget cannot
serve. The latter draw no budget, since their work is not on the worker, so the
sweep polls all of them. A peek that gets no turn keeps its place and is served
first on the next activation.
Off by default in production and on in the test configuration. With the switch
off nothing is offloaded and placement is unchanged.
Both substrate counters are pre-resolved, so
`mz_index_peek_walks_total{substrate="offloaded"}` reads zero rather than being
absent, which keeps "the offload changed nothing" distinguishable from "the
offload never engaged". Reading `mz_index_peek_total_seconds` across the flag is
misleading: an offloaded peek contributes only its inline slice, which the inline
budget bounds, so the expensive peeks leave a bounded sample where they used to
leave the whole walk. `mz_index_peek_offload_seconds` and the per-phase
histograms are the honest pairing.
2e236f7 to
a0ec303
Compare
|
Confirmed and fixed in
Pinned by 🤖 Posted by Claude Code on behalf of @antiguru |
Moves an expensive fast-path index peek's walk off the timely worker that received it, so a long scan no longer delays the peeks queued behind it. This is the layer the ones beneath it were built for.
A peek runs its first slice inline under a small budget, sized so point lookups finish there and nothing else does. If it completes, the peek never leaves the worker. If it outruns the budget it is offloaded to a task that walks the rest on the blocking pool, holding a permit from a process-wide semaphore. The walk stays on its blocking thread, checking for cancellation every few thousand positions, and returns to the runtime only to answer: a round trip per slice costs two thread wakes, which is more than the slice itself on a small machine. Cost is measured rather than predicted, so a skewed point lookup over a hot key needs no special case: it enters as a point lookup, overruns, and offloads.
The offload is for a scan that suspended with its budget spent, never for one holding a full batch. An offloaded task has nowhere to write a batch until the stash becomes a state transition of the same scan, and stepping a batch-ready scan returns without spending fuel or advancing, so offloading one would spin. A scan that fills a batch while offloaded hands back and the worker starts the existing stash walk.
The permit travels with the scan rather than with the pending peek, so releasing it coincides with releasing the batches it accounts for. Permits default to this runtime's worker count, which preserves the ceiling that exists today: a peek blocking its worker already costs one core per worker. Excess scans queue in the semaphore, so a peek storm costs queue entries rather than threads.
Cancellation needs no new mechanism at any stage. Removing the pending peek drops the result channel's receiver, and the walk observes a closed sender at its next cancellation check; a scan still waiting for a permit leaves the queue the same way. Permits release on drop, including on panic.
The peeks awaiting a turn on the worker and the peeks a driver has taken over sit in separate queues, because they need opposite treatment. The former all draw on one per-activation aggregate that does not refill within an activation, so the sweep serves them from the front and stops at the first peek the budget cannot serve: every peek behind it would be passed over for the same reason. The latter draw no budget, since their work is not on the worker, so the sweep polls all of them. A peek that gets no turn keeps its place and is served first on the next activation.
Off by default in production and on in the test configuration. With the switch off nothing is offloaded and placement is unchanged.
Both substrate counters are pre-resolved, so
mz_index_peek_walks_total{substrate="offloaded"}reads zero rather than being absent, which keeps "the offload changed nothing" distinguishable from "the offload never engaged".Reading
mz_index_peek_total_secondsacross the flag is misleading: an offloaded peek contributes only its inline slice, which the inline budget bounds, so the expensive peeks leave a bounded sample where they used to leave the whole walk.mz_index_peek_offload_secondsand the per-phase histograms are the honest pairing.mz_index_peek_offload_secondsis wall clock away from the worker, so it counts the wait for a permit, not the walk's own time.🤖 Opened by Claude Code on behalf of @antiguru
Replaces #38478, 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.