@@ -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