Skip to content

Commit 5367fa0

Browse files
committed
compute: make the peek stash a state transition of one scan
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.
1 parent 40f7a4b commit 5367fa0

24 files changed

Lines changed: 2044 additions & 776 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

doc/user/data/metrics.yml

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,10 @@ metrics:
11441144
help: Time in seek_fulfillment method including frontier checks and data collection.
11451145
source: src/compute/src/metrics.rs
11461146
visibility: internal
1147+
- name: mz_index_peek_stashed_total
1148+
help: The number of index peek walks that answered with a handle to the peek response stash, always a subset of the `offloaded` substrate of `mz_index_peek_walks_total`.
1149+
source: src/compute/src/metrics.rs
1150+
visibility: internal
11471151
- name: mz_index_peek_total_seconds_bucket
11481152
help: Time one visit to an index peek spent on the timely worker. A peek whose walk was offloaded contributes only the inline slice that offloaded it, and its time away from the worker is `mz_index_peek_offload_seconds`.
11491153
labels:
@@ -3704,25 +3708,6 @@ metrics:
37043708
- version
37053709
source: src/environmentd/src/environmentd/main.rs
37063710
visibility: internal
3707-
- name: mz_stashed_peek_seconds_bucket
3708-
help: Time spent reading a peek result and stashing it in the peek result stash (aka. persist blob).
3709-
labels:
3710-
- le
3711-
- worker_id
3712-
source: src/compute/src/metrics.rs
3713-
visibility: internal
3714-
- name: mz_stashed_peek_seconds_count
3715-
help: Time spent reading a peek result and stashing it in the peek result stash (aka. persist blob).
3716-
labels:
3717-
- worker_id
3718-
source: src/compute/src/metrics.rs
3719-
visibility: internal
3720-
- name: mz_stashed_peek_seconds_sum
3721-
help: Time spent reading a peek result and stashing it in the peek result stash (aka. persist blob).
3722-
labels:
3723-
- worker_id
3724-
source: src/compute/src/metrics.rs
3725-
visibility: internal
37263711
- name: mz_statement_logging_actual_bytes
37273712
help: The total amount of SQL text that was logged by statement logging.
37283713
source: src/adapter/src/metrics.rs

misc/python/materialize/mzcompose/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -832,8 +832,6 @@ def get_default_system_parameters(
832832
"compute_index_peek_permit_fraction",
833833
"compute_peek_response_stash_read_batch_size_bytes",
834834
"compute_peek_response_stash_read_memory_budget_bytes",
835-
"compute_peek_stash_num_batches",
836-
"compute_peek_stash_batch_size",
837835
"storage_statistics_retention_duration",
838836
"enable_paused_cluster_readhold_downgrade",
839837
"kafka_retry_backoff",

misc/python/materialize/parallel_workload/action.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3437,8 +3437,6 @@ def __init__(
34373437
"mz_metrics_lgalloc_refresh_interval",
34383438
"mz_metrics_rusage_refresh_interval",
34393439
"mz_metrics_usage_refresh_interval",
3440-
"compute_peek_stash_num_batches",
3441-
"compute_peek_stash_batch_size",
34423440
"compute_peek_response_stash_batch_max_runs",
34433441
"compute_peek_response_stash_read_batch_size_bytes",
34443442
"compute_peek_response_stash_read_memory_budget_bytes",

src/adapter/src/coord/peek.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1111,7 +1111,9 @@ impl crate::coord::Coordinator {
11111111
// - ProtoBatch is lost in flight
11121112
// - ProtoBatch is lost because when combining PeekResponse
11131113
// from workers a cancellation or error "overrides" other
1114-
// results, meaning we drop them
1114+
// results, meaning we drop them, which includes the
1115+
// `max_result_size` rejection the controller raises once it
1116+
// has summed the workers' stashed rows
11151117
// - This task here is not run to completion before it can
11161118
// delete all batches
11171119
//

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

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

223235
/// The error of an unsuccessful peek.
@@ -291,7 +303,15 @@ pub struct StashedPeekResponse {
291303
/// This does _NOT_ include rows in `inline_rows`.
292304
pub num_rows_batches: u64,
293305
/// The sum of the encoded sizes of all batches in this response.
306+
///
307+
/// What the batches cost in blob storage, which is not what `max_result_size` bounds. That is
308+
/// [`StashedPeekResponse::stashed_byte_len`], the size of the same rows as an answer carries
309+
/// them.
294310
pub encoded_size_bytes: usize,
311+
/// The size of the stashed rows, measured as an inline answer measures its own.
312+
///
313+
/// Does _NOT_ include `inline_rows`.
314+
pub stashed_byte_len: usize,
295315
/// [RelationDesc] for the rows in these stashed batches of results.
296316
pub relation_desc: RelationDesc,
297317
/// The [ShardId] under which result batches have been stashed.
@@ -322,6 +342,13 @@ impl StashedPeekResponse {
322342

323343
self.encoded_size_bytes + inline_size
324344
}
345+
346+
/// The size of the rows in this result, measured as an inline answer measures its own.
347+
pub fn answer_byte_len(&self) -> usize {
348+
let inline_size: usize = self.inline_rows.iter().map(|r| r.byte_len()).sum();
349+
350+
self.stashed_byte_len.saturating_add(inline_size)
351+
}
325352
}
326353

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

src/compute-client/src/service.rs

Lines changed: 20 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,25 @@ 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() {
603+
// NOTE: an error merged over a stashed response drops the batches that response
604+
// names, and nothing else reaches them: only the reader deletes a stashed answer's
605+
// parts, and it never sees this response. Counting stashed rows here makes that
606+
// reachable for a peek over the ceiling, where before the check could not fire on a
607+
// stashed answer at all. It is the leak DB-50 already reports for a cancelled peek,
608+
// which drops the same batches at the same merge, and reclaiming either needs the
609+
// batches to survive the merge rather than a bound that avoids producing them.
610+
//
601611
// NOTE: Tests match on this exact message, so nothing else may produce it.
602612
let error = PeekError::unstructured(format!(
603613
"total result exceeds max size of {}",
@@ -636,6 +646,7 @@ fn merge_peek_responses(resp1: PeekResponse, resp2: PeekResponse) -> PeekRespons
636646
let StashedPeekResponse {
637647
num_rows_batches: num_rows_batches1,
638648
encoded_size_bytes: encoded_size_bytes1,
649+
stashed_byte_len: stashed_byte_len1,
639650
relation_desc: relation_desc1,
640651
shard_id: shard_id1,
641652
batches: mut batches1,
@@ -644,6 +655,7 @@ fn merge_peek_responses(resp1: PeekResponse, resp2: PeekResponse) -> PeekRespons
644655
let StashedPeekResponse {
645656
num_rows_batches: num_rows_batches2,
646657
encoded_size_bytes: encoded_size_bytes2,
658+
stashed_byte_len: stashed_byte_len2,
647659
relation_desc: relation_desc2,
648660
shard_id: shard_id2,
649661
batches: mut batches2,
@@ -671,6 +683,7 @@ fn merge_peek_responses(resp1: PeekResponse, resp2: PeekResponse) -> PeekRespons
671683
Stashed(Box::new(StashedPeekResponse {
672684
num_rows_batches: num_rows_batches1 + num_rows_batches2,
673685
encoded_size_bytes: encoded_size_bytes1 + encoded_size_bytes2,
686+
stashed_byte_len: stashed_byte_len1.saturating_add(stashed_byte_len2),
674687
relation_desc: relation_desc1,
675688
shard_id: shard_id1,
676689
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-types/src/dyncfgs.rs

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -626,23 +626,6 @@ pub const PEEK_RESPONSE_STASH_READ_MEMORY_BUDGET_BYTES: Config<usize> = Config::
626626
ParameterScope::Environment,
627627
);
628628

629-
/// The number of batches to pump from the peek result iterator when stashing peek responses.
630-
pub const PEEK_STASH_NUM_BATCHES: Config<usize> = Config::new(
631-
"compute_peek_stash_num_batches",
632-
100,
633-
"The number of batches to pump from the peek result iterator (in one iteration through the worker loop) when stashing peek responses.",
634-
ParameterScope::Environment,
635-
);
636-
637-
/// The size of each batch, as number of rows, pumped from the peek result
638-
/// iterator when stashing peek responses.
639-
pub const PEEK_STASH_BATCH_SIZE: Config<usize> = Config::new(
640-
"compute_peek_stash_batch_size",
641-
100000,
642-
"The size, as number of rows, of each batch pumped from the peek result iterator (in one iteration through the worker loop) when stashing peek responses.",
643-
ParameterScope::Environment,
644-
);
645-
646629
/// Whether compute should stop peeks that iterate over too many rows.
647630
pub const ENABLE_PEEK_ROW_ITERATION_LIMIT: Config<bool> = Config::new(
648631
"enable_compute_peek_row_iteration_limit",
@@ -652,19 +635,26 @@ pub const ENABLE_PEEK_ROW_ITERATION_LIMIT: Config<bool> = Config::new(
652635
);
653636

654637
/// The maximum number of rows a peek may iterate over on each worker.
638+
///
639+
/// The count spans a peek's whole walk of its arrangement, rows written to the peek stash
640+
/// included, because a peek walks its arrangement once and the count travels with that walk.
655641
pub const PEEK_ROW_ITERATION_LIMIT: Config<usize> = Config::new(
656642
"compute_peek_row_iteration_limit",
657643
1000,
658-
"The maximum number of rows a peek may iterate over on each worker when enable_compute_peek_row_iteration_limit is enabled. Does not apply once a peek's results move to the peek stash.",
644+
"The maximum number of rows a peek may iterate over on each worker when enable_compute_peek_row_iteration_limit is enabled. The count spans the peek's whole walk, rows written to the peek stash included.",
659645
ParameterScope::Environment,
660646
);
661647

662-
/// Whether a fast-path index peek may move its walk off the timely worker.
648+
/// Whether a fast-path index peek may move its walk off the timely worker for latency.
663649
///
664-
/// Off, a peek walks to completion on the worker that owns it, delaying every other message that
665-
/// worker serves. On, a peek that outruns [`INDEX_PEEK_INLINE_BUDGET`] finishes away from it.
650+
/// Off, a peek walks on the worker that owns it until it answers, delaying every other message
651+
/// that worker serves. On, a peek that outruns [`INDEX_PEEK_INLINE_BUDGET`] finishes away from it.
666652
///
667-
/// The kill switch for the whole mechanism, off by default until the path earns trust.
653+
/// This gates latency offload only. A peek whose rows outgrow an inline answer is offloaded either
654+
/// way, because the driver that writes to the peek stash is the offloaded one. Off means an ordinary
655+
/// peek runs where it used to, not that none leaves the worker.
656+
///
657+
/// Off by default until the path earns trust.
668658
pub const ENABLE_INDEX_PEEK_OFFLOAD: Config<bool> = Config::new(
669659
"enable_compute_index_peek_offload",
670660
false,
@@ -713,8 +703,12 @@ pub const INDEX_PEEK_ACTIVATION_BUDGET: Config<usize> = Config::new(
713703
///
714704
/// At a plausible 100ns to 1us per position this bounds cancellation latency to single-digit
715705
/// milliseconds. Larger than [`INDEX_PEEK_INLINE_BUDGET`] because an offloaded scan is off the
716-
/// worker's critical path, so its slices answer to cancellation latency rather than to the
717-
/// worker's availability.
706+
/// worker's critical path, so its slices answer to cancellation latency, not to the worker's
707+
/// availability.
708+
///
709+
/// An upper bound, not a period: a walk bound for the peek stash suspends once its accumulation
710+
/// crosses `peek_response_stash_threshold_bytes`, by far the smaller trigger at that threshold's
711+
/// default, and unspent fuel is not carried over.
718712
pub const INDEX_PEEK_YIELD_GRANULARITY: Config<usize> = Config::new(
719713
"compute_index_peek_yield_granularity",
720714
10000,
@@ -827,8 +821,6 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
827821
.add(&PEEK_RESPONSE_STASH_BATCH_MAX_RUNS)
828822
.add(&PEEK_RESPONSE_STASH_READ_BATCH_SIZE_BYTES)
829823
.add(&PEEK_RESPONSE_STASH_READ_MEMORY_BUDGET_BYTES)
830-
.add(&PEEK_STASH_NUM_BATCHES)
831-
.add(&PEEK_STASH_BATCH_SIZE)
832824
.add(&ENABLE_PEEK_ROW_ITERATION_LIMIT)
833825
.add(&PEEK_ROW_ITERATION_LIMIT)
834826
.add(&ENABLE_INDEX_PEEK_OFFLOAD)

src/compute/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ mz-expr = { path = "../expr" }
3333
mz-metrics = { path = "../metrics" }
3434
mz-ore = { path = "../ore", features = ["async", "process", "tracing", "columnar", "differential-dataflow", "pager", "region"] }
3535
mz-proto = { path = "../proto" }
36+
mz-persist = { path = "../persist" }
3637
mz-persist-client = { path = "../persist-client" }
3738
mz-persist-types = { path = "../persist-types" }
3839
mz-repr = { path = "../repr" }
@@ -47,6 +48,7 @@ prost.workspace = true
4748
scopeguard.workspace = true
4849
serde.workspace = true
4950
smallvec = { workspace = true, features = ["serde", "union"] }
51+
thiserror.workspace = true
5052
timely.workspace = true
5153
tokio.workspace = true
5254
tracing.workspace = true

0 commit comments

Comments
 (0)