Skip to content

Commit 3f5e531

Browse files
antiguruclaude
andcommitted
compute: bound a stashed peek answer by the result size ceiling
max_result_size was compared against the rows a scan happened to be holding. Once a peek diverts to the stash that prefix is handed away and reset on every batch, so the comparison never reached the ceiling and a stashed answer could grow without bound. The ceiling appeared to hold only because the whole answer used to reach the adapter as one chunk, and RowSetFinishingIncremental checks the chunk it is given rather than the running total: splitting the answer into inline rows plus threshold-sized batches left every chunk under the ceiling and stopped it applying at all. Track what the batches already handed off contained and measure the sum, so the bound covers the answer the client receives rather than the prefix the scan retains. Failing in the walk also spares the replica writing an answer to blob storage that nothing may read. The test that pinned the old reading asserted a stash-bound prefix escapes the ceiling. It now asserts the opposite, alongside one that keeps a stashed answer under the ceiling walking the whole trace, so the bound does not cost the stash the results it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f926d65 commit 3f5e531

1 file changed

Lines changed: 67 additions & 13 deletions

File tree

src/compute/src/compute_state/peek_scan.rs

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,10 @@ where
156156
results: RowBatch,
157157
/// The byte size of `results`, as an answer built from them would store them.
158158
total_size: usize,
159-
/// The ceiling on what the scan may hold, above which the peek fails.
159+
/// The byte size of the batches already handed to a driver, which `total_size` no longer
160+
/// counts. Together the two are the size of the answer the peek is building.
161+
handed_off_size: usize,
162+
/// The ceiling on the whole answer, above which the peek fails.
160163
max_result_size: usize,
161164
/// Whether this peek may divert its rows to the peek stash.
162165
peek_stash_eligible: bool,
@@ -235,6 +238,7 @@ where
235238
ok_walk_end: None,
236239
results: Vec::new(),
237240
total_size: 0,
241+
handed_off_size: 0,
238242
max_result_size: usize::cast_from(max_result_size),
239243
peek_stash_eligible,
240244
peek_stash_threshold_bytes,
@@ -338,6 +342,9 @@ where
338342
/// and the stash threshold are read against, and rows that have left the scan are bounded by
339343
/// neither.
340344
fn take_results(&mut self) -> RowBatch {
345+
// Carried rather than dropped, because `max_result_size` bounds the answer the peek
346+
// returns and not the prefix the scan happens to be holding.
347+
self.handed_off_size = self.handed_off_size.saturating_add(self.total_size);
341348
self.total_size = 0;
342349
mem::take(&mut self.results)
343350
}
@@ -430,10 +437,14 @@ where
430437
.saturating_add(COUNT_BYTE_SIZE);
431438
let batch_ready = self.batch_ready();
432439

433-
// Rows bound for the stash are answered by a handle rather than by themselves, so the
434-
// ceiling on an inline answer does not apply to a prefix that has grown past the
435-
// stash threshold.
436-
if !batch_ready && self.total_size > self.max_result_size {
440+
// Measured against everything the answer will contain, the batches already handed off
441+
// included, because a peek bound for the stash returns its rows to the client like any
442+
// other. Checking only the retained prefix would stop bounding a stashed answer at
443+
// all, since a prefix is handed away and reset well below the ceiling. Failing here
444+
// rather than on the way out also spares the replica writing an answer to blob storage
445+
// that nothing may read.
446+
let answer_size = self.handed_off_size.saturating_add(self.total_size);
447+
if answer_size > self.max_result_size {
437448
break ScanOutcome::Failed(PeekError::unstructured(format!(
438449
"result exceeds max size of {}",
439450
ByteSize::b(u64::cast_from(self.max_result_size))
@@ -616,6 +627,7 @@ mod tests {
616627
ok_walk_end: None,
617628
results: Vec::new(),
618629
total_size: 0,
630+
handed_off_size: 0,
619631
max_result_size: usize::MAX,
620632
peek_stash_eligible: false,
621633
peek_stash_threshold_bytes: usize::MAX,
@@ -948,24 +960,66 @@ mod tests {
948960
assert_eq!(subject.results, expected(0..3));
949961
}
950962

951-
/// The result-size ceiling bounds an inline answer, which rows bound for the stash are not.
952-
/// A scan whose batches are taken therefore walks the whole trace with a ceiling below what it
953-
/// produces, rather than failing the peek.
963+
/// The result-size ceiling bounds the whole answer, the batches already handed off included,
964+
/// so a peek bound for the stash fails on it like any other.
954965
///
955-
/// Driven with unbounded fuel, so every suspension is a full batch, and a scan that grew its
956-
/// prefix past the threshold instead of stopping would fail on the ceiling.
966+
/// The ceiling here sits above the stash threshold, so the scan hands off batches and only
967+
/// their sum reaches it. A ceiling compared against the retained prefix alone would never be
968+
/// reached, because a prefix is handed away and reset well below it, and the peek would return
969+
/// an answer of any size at all.
970+
///
971+
/// Driven with unbounded fuel, so every suspension is a full batch.
957972
#[mz_ore::test]
958-
fn a_prefix_bound_for_the_stash_is_not_bound_by_the_result_size_ceiling() {
973+
fn a_stashed_answer_is_bound_by_the_result_size_ceiling_across_its_batches() {
959974
let keys = rows(0..8);
960975
let mut subject = scan(ErrorPhase::Clean, &keys);
961-
subject.max_result_size = 3 * row_size();
976+
subject.max_result_size = 5 * row_size();
962977
subject.peek_stash_eligible = true;
963978
subject.peek_stash_threshold_bytes = 2 * row_size();
964979

965980
let mut collected = RowBatch::new();
966-
let mut completed = false;
981+
let mut failure = None;
967982
// Bounded so that a regression which restarts the ok walk on every resumption fails here
968983
// rather than spinning.
984+
for _ in 0..RESUMPTION_BOUND {
985+
let mut fuel = usize::MAX;
986+
match subject.step(None, &mut fuel) {
987+
ScanOutcome::Suspended => {
988+
collected.extend(subject.take_batch().expect("a full batch"));
989+
}
990+
ScanOutcome::Complete(rest) => {
991+
collected.extend(rest);
992+
break;
993+
}
994+
ScanOutcome::Failed(error) => {
995+
failure = Some(error);
996+
break;
997+
}
998+
}
999+
}
1000+
1001+
assert_eq!(
1002+
failure,
1003+
Some(PeekError::unstructured(format!(
1004+
"result exceeds max size of {}",
1005+
ByteSize::b(u64::cast_from(5 * row_size()))
1006+
))),
1007+
"a stashed answer past the ceiling must fail rather than answer, having collected {collected:?}"
1008+
);
1009+
}
1010+
1011+
/// A peek whose whole answer stays under the ceiling still reaches the stash, so the bound
1012+
/// across batches does not cost the stash the results it exists for.
1013+
#[mz_ore::test]
1014+
fn a_stashed_answer_under_the_ceiling_still_walks_the_whole_trace() {
1015+
let keys = rows(0..8);
1016+
let mut subject = scan(ErrorPhase::Clean, &keys);
1017+
subject.max_result_size = 100 * row_size();
1018+
subject.peek_stash_eligible = true;
1019+
subject.peek_stash_threshold_bytes = 2 * row_size();
1020+
1021+
let mut collected = RowBatch::new();
1022+
let mut completed = false;
9691023
for _ in 0..RESUMPTION_BOUND {
9701024
let mut fuel = usize::MAX;
9711025
match subject.step(None, &mut fuel) {

0 commit comments

Comments
 (0)