Skip to content

Commit f7170ec

Browse files
antiguruclaude
andcommitted
compute: bound a stashed peek answer across the workers that produced it
`max_result_size` is a bound on the answer a client receives, and a stashed answer escaped it. Each worker decides on its own whether its share is large enough to stash, so an oversized result arrives as several stashed responses each well under the ceiling, and the controller measured only the rows a response carries inline. The batches were invisible to it, which left the effective bound at the ceiling times the worker count. Carry the size of the stashed rows in the response, measured the way an inline answer measures its own, and accumulate that in the controller's per-peek total. The encoded size the response already carried is what the batches cost in blob storage, which is a different number and not the one this bounds. The gap was hidden by the adapter's per-chunk check on read-back, which rejects a chunk larger than the ceiling and so happened to catch this at the default read batch size. `max_result_size.slt` now covers the case with that batch size lowered, so the chunk check cannot be what rejects the result, and with a stash threshold low enough that every worker's share reaches the stash. Without this change the query returns roughly 1.1 MiB under a 1 MiB cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0a346d0 commit f7170ec

7 files changed

Lines changed: 148 additions & 10 deletions

File tree

src/compute-client/src/protocol/response.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,18 @@ impl PeekResponse {
217217
Self::Error(_) | Self::Canceled => 0,
218218
}
219219
}
220+
221+
/// The size of the rows this response answers with, stashed rows included.
222+
///
223+
/// This is what `max_result_size` bounds, so a stashed answer has to be measured the way an
224+
/// inline one is. Reading [`PeekResponse::inline_byte_len`] instead would leave a stashed
225+
/// answer bounded per worker only, since the batches a worker writes are invisible to it.
226+
pub fn answer_byte_len(&self) -> usize {
227+
match self {
228+
Self::Rows(_) | Self::Error(_) | Self::Canceled => self.inline_byte_len(),
229+
Self::Stashed(stashed) => stashed.answer_byte_len(),
230+
}
231+
}
220232
}
221233

222234
/// The error of an unsuccessful peek.
@@ -280,7 +292,15 @@ pub struct StashedPeekResponse {
280292
/// This does _NOT_ include rows in `inline_rows`.
281293
pub num_rows_batches: u64,
282294
/// The sum of the encoded sizes of all batches in this response.
295+
///
296+
/// What the batches cost in blob storage, which is not what `max_result_size` bounds. That is
297+
/// [`StashedPeekResponse::stashed_byte_len`], the size of the same rows as an answer carries
298+
/// them.
283299
pub encoded_size_bytes: usize,
300+
/// The size of the stashed rows, measured as an inline answer measures its own.
301+
///
302+
/// Does _NOT_ include `inline_rows`.
303+
pub stashed_byte_len: usize,
284304
/// [RelationDesc] for the rows in these stashed batches of results.
285305
pub relation_desc: RelationDesc,
286306
/// The [ShardId] under which result batches have been stashed.
@@ -311,6 +331,13 @@ impl StashedPeekResponse {
311331

312332
self.encoded_size_bytes + inline_size
313333
}
334+
335+
/// The size of the rows in this result, measured as an inline answer measures its own.
336+
pub fn answer_byte_len(&self) -> usize {
337+
let inline_size: usize = self.inline_rows.iter().map(|r| r.byte_len()).sum();
338+
339+
self.stashed_byte_len.saturating_add(inline_size)
340+
}
314341
}
315342

316343
/// Various responses that can be communicated after a COPY TO command.

src/compute-client/src/service.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -566,12 +566,12 @@ impl PendingSubscribe {
566566
struct PendingPeek {
567567
/// The responses merged so far.
568568
response: PeekResponse,
569-
/// Inline result bytes seen so far, across all shards.
569+
/// Answer bytes seen so far, across all shards, stashed rows included.
570570
///
571571
/// Tracked separately from `response` because a worker's rows are dropped as soon as any
572572
/// worker reports an error. Without this the aggregate size check would depend on the order
573573
/// the responses happen to arrive in.
574-
inline_byte_len: usize,
574+
answer_byte_len: usize,
575575
/// The shards that have provided responses.
576576
ready_shards: BTreeSet<usize>,
577577
}
@@ -580,7 +580,7 @@ impl PendingPeek {
580580
fn new() -> Self {
581581
Self {
582582
response: PeekResponse::Rows(vec![RowCollection::default()]),
583-
inline_byte_len: 0,
583+
answer_byte_len: 0,
584584
ready_shards: BTreeSet::new(),
585585
}
586586
}
@@ -589,15 +589,17 @@ impl PendingPeek {
589589
let first = self.ready_shards.insert(shard_id);
590590
assert!(first, "duplicate peek response");
591591

592-
self.inline_byte_len = self
593-
.inline_byte_len
594-
.saturating_add(response.inline_byte_len());
592+
// Stashed rows count here as inline ones do, so a peek whose workers each stash a share
593+
// under the ceiling is still bounded by what the client receives in total.
594+
self.answer_byte_len = self
595+
.answer_byte_len
596+
.saturating_add(response.answer_byte_len());
595597
let current = mem::replace(&mut self.response, PeekResponse::Canceled);
596598
self.response = merge_peek_responses(current, response);
597599

598600
// Merging eagerly is what keeps the controller's memory bounded, so the size check has to
599601
// happen on every response rather than once at the end.
600-
if self.inline_byte_len > max_result_size.cast_into() {
602+
if self.answer_byte_len > max_result_size.cast_into() {
601603
// NOTE: Tests match on this exact message, so nothing else may produce it.
602604
let error = PeekError::unstructured(format!(
603605
"total result exceeds max size of {}",
@@ -636,6 +638,7 @@ fn merge_peek_responses(resp1: PeekResponse, resp2: PeekResponse) -> PeekRespons
636638
let StashedPeekResponse {
637639
num_rows_batches: num_rows_batches1,
638640
encoded_size_bytes: encoded_size_bytes1,
641+
stashed_byte_len: stashed_byte_len1,
639642
relation_desc: relation_desc1,
640643
shard_id: shard_id1,
641644
batches: mut batches1,
@@ -644,6 +647,7 @@ fn merge_peek_responses(resp1: PeekResponse, resp2: PeekResponse) -> PeekRespons
644647
let StashedPeekResponse {
645648
num_rows_batches: num_rows_batches2,
646649
encoded_size_bytes: encoded_size_bytes2,
650+
stashed_byte_len: stashed_byte_len2,
647651
relation_desc: relation_desc2,
648652
shard_id: shard_id2,
649653
batches: mut batches2,
@@ -671,6 +675,7 @@ fn merge_peek_responses(resp1: PeekResponse, resp2: PeekResponse) -> PeekRespons
671675
Stashed(Box::new(StashedPeekResponse {
672676
num_rows_batches: num_rows_batches1 + num_rows_batches2,
673677
encoded_size_bytes: encoded_size_bytes1 + encoded_size_bytes2,
678+
stashed_byte_len: stashed_byte_len1.saturating_add(stashed_byte_len2),
674679
relation_desc: relation_desc1,
675680
shard_id: shard_id1,
676681
batches: batches1,

src/compute-client/src/service/tests.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
1212
use super::*;
1313
use std::num::NonZeroUsize;
14+
use std::str::FromStr;
1415

15-
use mz_repr::Row;
16+
use mz_persist_types::ShardId;
17+
use mz_repr::{RelationDesc, Row};
1618

1719
#[mz_ore::test]
1820
fn pending_peek_response_precedence() {
@@ -74,3 +76,44 @@ fn peek_max_size_wins_over_row_iteration_limit_in_every_order() {
7476
assert_eq!(pending.response, expected, "{permutation:?}");
7577
}
7678
}
79+
80+
/// A stashed answer is bounded by `max_result_size` across the workers that produced it, not
81+
/// per worker.
82+
///
83+
/// Each worker decides on its own whether its share is large enough to stash, so a peek whose
84+
/// answer exceeds the ceiling can arrive as several stashed responses each well under it. Reading
85+
/// only the rows a response carries inline would leave such an answer bounded at the ceiling times
86+
/// the worker count.
87+
#[mz_ore::test]
88+
fn a_stashed_answer_is_bounded_across_workers() {
89+
let stashed = |stashed_byte_len| {
90+
PeekResponse::Stashed(Box::new(StashedPeekResponse {
91+
num_rows_batches: 1,
92+
encoded_size_bytes: 0,
93+
stashed_byte_len,
94+
relation_desc: RelationDesc::empty(),
95+
shard_id: ShardId::from_str("s00000000-0000-0000-0000-000000000000").expect("valid"),
96+
batches: Vec::new(),
97+
inline_rows: Vec::new(),
98+
}))
99+
};
100+
let max_result_size = 100;
101+
102+
// One worker's share, under the ceiling on its own.
103+
let mut pending = PendingPeek::new();
104+
pending.absorb(0, stashed(60), max_result_size);
105+
assert!(
106+
matches!(pending.response, PeekResponse::Stashed(_)),
107+
"a share under the ceiling answers with its batches"
108+
);
109+
110+
// A second share of the same size puts the answer over it.
111+
pending.absorb(1, stashed(60), max_result_size);
112+
assert_eq!(
113+
pending.response,
114+
PeekResponse::Error(PeekError::unstructured(format!(
115+
"total result exceeds max size of {}",
116+
ByteSize::b(max_result_size)
117+
))),
118+
);
119+
}

src/compute/src/compute_state/peek_scan.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const _: () = {
5151
pub(super) type RowBatch = Vec<(Row, NonZeroI64)>;
5252

5353
/// The byte size of a row's count, as an answer built from a [`RowBatch`] stores it.
54-
const COUNT_BYTE_SIZE: usize = size_of::<NonZeroUsize>();
54+
pub(super) const COUNT_BYTE_SIZE: usize = size_of::<NonZeroUsize>();
5555

5656
/// What a walk has spent, in the phases the peek metrics report.
5757
///

src/compute/src/compute_state/peek_stash.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use tokio::sync::oneshot;
2828
use tracing::warn;
2929
use uuid::Uuid;
3030

31-
use crate::compute_state::peek_scan::RowBatch;
31+
use crate::compute_state::peek_scan::{COUNT_BYTE_SIZE, RowBatch};
3232

3333
/// Whether a [`StashUpload`] has room for more rows.
3434
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -95,6 +95,9 @@ pub(super) struct StashUpload {
9595
/// Rows written so far, counting a row with a diff of `n` as `n` rows, which is how the
9696
/// finishing counts them.
9797
num_rows: u64,
98+
/// The size of those rows, measured as an inline answer measures its own, so that
99+
/// `max_result_size` bounds a stashed answer and an inline one alike.
100+
stashed_byte_len: usize,
98101
/// The runtime an abandoned upload's deletion is spawned on, held rather than taken from the
99102
/// ambient context because [`StashUpload::abandon`] runs where there may be none.
100103
runtime: Handle,
@@ -155,6 +158,7 @@ impl StashUpload {
155158
upper,
156159
max_rows,
157160
num_rows: 0,
161+
stashed_byte_len: 0,
158162
runtime: Handle::current(),
159163
})
160164
}
@@ -175,6 +179,10 @@ impl StashUpload {
175179
}
176180

177181
self.num_rows += u64::from(NonZeroU64::try_from(diff).expect("diff fits into u64"));
182+
self.stashed_byte_len = self
183+
.stashed_byte_len
184+
.saturating_add(row.byte_len())
185+
.saturating_add(COUNT_BYTE_SIZE);
178186
let diff: i64 = diff.into();
179187

180188
self.batch_builder
@@ -226,6 +234,7 @@ impl StashUpload {
226234
let stashed_response = StashedPeekResponse {
227235
num_rows_batches: self.num_rows,
228236
encoded_size_bytes: batch.encoded_size_bytes(),
237+
stashed_byte_len: self.stashed_byte_len,
229238
relation_desc: self.relation_desc.clone(),
230239
shard_id: self.shard_id,
231240
batches: vec![batch.into_transmittable_batch()],

src/compute/src/compute_state/peek_stash/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ async fn upload_with_bounds(
459459
upper: Antichain::from_elem(upper),
460460
max_rows: None,
461461
num_rows: 0,
462+
stashed_byte_len: 0,
462463
runtime: Handle::current(),
463464
}
464465
}

test/sqllogictest/max_result_size.slt

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,59 @@ SELECT * FROM t1;
141141
query error total result exceeds max size of 1\.0 MiB
142142
SUBSCRIBE (SELECT * FROM t1 GROUP BY a, b)
143143

144+
# A peek whose answer goes to the peek stash is bounded by `max_result_size` the same way an
145+
# inline one is. Each worker decides on its own whether its share is large enough to stash, so a
146+
# result over the cap arrives as several stashed responses each well under it, and a controller
147+
# that measured only the rows a response carries inline would bound such an answer at the cap
148+
# times the worker count.
149+
simple conn=mz_system,user=mz_system
150+
ALTER SYSTEM SET max_result_size TO '1MB';
151+
----
152+
COMPLETE 0
153+
154+
simple conn=mz_system,user=mz_system
155+
ALTER SYSTEM SET enable_compute_peek_response_stash TO 'true';
156+
----
157+
COMPLETE 0
158+
159+
# Low enough that every worker's share of the result below reaches the stash.
160+
simple conn=mz_system,user=mz_system
161+
ALTER SYSTEM SET compute_peek_response_stash_threshold_bytes TO 1024;
162+
----
163+
COMPLETE 0
164+
165+
# Small enough that the per-chunk check the adapter applies on read-back cannot be what
166+
# rejects the result, so what this pins is the bound across the workers that produced it.
167+
simple conn=mz_system,user=mz_system
168+
ALTER SYSTEM SET compute_peek_response_stash_read_batch_size_bytes TO 65536;
169+
----
170+
COMPLETE 0
171+
172+
# The index is what makes this a fast-path index peek, which is the path that writes to the stash.
173+
statement ok
174+
CREATE INDEX t1_idx ON t1 (a);
175+
176+
query error total result exceeds max size of 1\.0 MiB
177+
SELECT * FROM t1;
178+
179+
statement ok
180+
DROP INDEX t1_idx;
181+
182+
simple conn=mz_system,user=mz_system
183+
ALTER SYSTEM RESET compute_peek_response_stash_threshold_bytes;
184+
----
185+
COMPLETE 0
186+
187+
simple conn=mz_system,user=mz_system
188+
ALTER SYSTEM RESET compute_peek_response_stash_read_batch_size_bytes;
189+
----
190+
COMPLETE 0
191+
192+
simple conn=mz_system,user=mz_system
193+
ALTER SYSTEM SET enable_compute_peek_response_stash TO 'false';
194+
----
195+
COMPLETE 0
196+
144197
# Regression: `RowSetFinishing` must not reject a result that fits within
145198
# `max_result_size`. This folds to a fast-path Constant of 10000 rows with a
146199
# `byte_len()` of 1100000, which fits under the 1140000 cap. The finishing path

0 commit comments

Comments
 (0)