mz_compute/sink/materialized_view_v2.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Sync Timely operator implementation of the MV sink.
11//!
12//! This module provides an alternative implementation of `persist_sink` that uses sync Timely
13//! operators communicating with Tokio tasks via channels, instead of async Timely operators.
14//! Gated behind the `ENABLE_SYNC_MV_SINK` dyncfg.
15//!
16//! See the [main module](super::materialized_view) for the operator graph and design docs.
17//!
18//! ### Channel ordering requirements
19//!
20//! Each operator splits state across a Timely thread (which observes inputs and frontiers) and a
21//! Tokio task (which owns persist I/O state). They communicate via `mpsc` command channels, which
22//! preserve send order on a single sender. Each operator instance constructs its own
23//! `(tx, rx)` pair inside `render` and never clones the sender, so there is exactly one producer
24//! per channel — sends are totally ordered. Different worker instances of the same operator
25//! never share a channel, so cross-worker ordering is not a concern. The correctness of the
26//! operators relies on a few ordering invariants between the messages sent within a single Timely
27//! activation:
28//!
29//! * **`mint`**: the `persist_watch` Tokio task is the sole producer of persist-frontier updates
30//! and emits them in monotonically increasing order, terminated by the empty frontier. The
31//! Timely closure drains the receiver each activation; processing in receive order is therefore
32//! sufficient. No cross-channel ordering is needed because `mint` only has the one channel.
33//!
34//! * **`write`**: per-activation, the Timely closure first appends all observed input data into
35//! a single `WriteCommand::Batch` and only then sends a `WriteCommand::WriteBatch` (issued from
36//! `maybe_start_batch` after frontier checks). The Tokio task processes commands FIFO, so a
37//! `WriteBatch` is guaranteed to see every `Batch` from the same activation already applied to
38//! the corrections buffer. Reversing this order would let the task write a batch that is
39//! missing updates the Timely closure already observed.
40//!
41//! * **`append`**: per-activation, the Timely closure forwards messages in the order
42//! `Description` → `Batch` → `BatchesFrontier`. The first two carry the data the task needs
43//! to absorb; `BatchesFrontier` is the trigger that allows `maybe_append_batches` to fire.
44//! Sending `BatchesFrontier` *after* its corresponding `Batch` messages ensures the task does
45//! not append a batch description before all batches contributing to it have been absorbed.
46//! If the order were reversed, `maybe_append_batches` could fire on an incomplete `batches`
47//! set and miss writes.
48
49use std::any::Any;
50use std::cell::RefCell;
51use std::rc::Rc;
52use std::sync::Arc;
53
54use differential_dataflow::{Hashable, VecCollection};
55use mz_compute_types::dyncfgs::{
56 MV_SINK_ADVANCE_PERSIST_FRONTIERS, MV_SINK_SHARED_BATCH_MAX_BYTES,
57};
58use mz_dyncfg::{ConfigSet, ConfigValHandle};
59use mz_ore::cast::CastFrom;
60use mz_persist_client::batch::{Batch, ProtoBatch};
61use mz_persist_client::write::WriteHandle;
62use mz_repr::{Diff, GlobalId, Row, Timestamp};
63use mz_storage_types::StorageDiff;
64use mz_storage_types::sources::SourceData;
65use timely::PartialOrder;
66use timely::dataflow::channels::pact::{Exchange, Pipeline};
67use timely::dataflow::operators::generic::OutputBuilder;
68use timely::dataflow::operators::generic::builder_rc::OperatorBuilder as OperatorBuilderRc;
69use timely::dataflow::operators::vec::Broadcast;
70use timely::dataflow::operators::{Capability, CapabilitySet};
71use timely::progress::Antichain;
72use timely::progress::frontier::AntichainRef;
73use tokio::sync::{mpsc, watch};
74use tracing::trace;
75
76use crate::compute_state::ComputeState;
77use crate::render::StartSignal;
78use crate::render::errors::DataflowErrorSer;
79use crate::sink::correction::{ChannelLogging, Correction, CorrectionLogger};
80use crate::sink::materialized_view::{
81 BatchDescription, BatchesStream, DescsStream, DesiredStreams, OkErr, PersistApi,
82 PersistStreams, SharedSinkFrontier, advance, operator_name, persist_source,
83};
84use crate::sink::shared_batch::SharedBatchWriter;
85
86/// Renders an MV sink writing the given desired collection into the `target` persist collection.
87///
88/// This is the sync Timely operator implementation, using Tokio tasks for I/O.
89pub(super) fn persist_sink<'s>(
90 sink_id: GlobalId,
91 target: &mz_storage_types::controller::CollectionMetadata,
92 ok_collection: VecCollection<'s, Timestamp, Row, Diff>,
93 err_collection: VecCollection<'s, Timestamp, DataflowErrorSer, Diff>,
94 as_of: Antichain<Timestamp>,
95 compute_state: &mut ComputeState,
96 start_signal: StartSignal,
97 read_only_rx: watch::Receiver<bool>,
98) -> Rc<dyn Any> {
99 let scope = ok_collection.scope();
100 let desired = OkErr::new(ok_collection.inner, err_collection.inner);
101
102 // Read back the persist shard.
103 let (persist, persist_token) =
104 persist_source(scope, sink_id, target.clone(), compute_state, start_signal);
105
106 let persist_api = PersistApi {
107 persist_clients: Arc::clone(&compute_state.persist_clients),
108 persist_batches: compute_state.persist_batches.clone(),
109 collection: target.clone(),
110 shard_name: sink_id.to_string(),
111 purpose: format!("MV sink {sink_id}"),
112 };
113
114 let (desired, descs, sink_frontier) = mint::render(
115 sink_id,
116 persist_api.clone(),
117 as_of.clone(),
118 read_only_rx.clone(),
119 desired,
120 );
121
122 // Broadcast batch descriptions to all workers, regardless of whether or not they are
123 // responsible for the append, to give them a chance to clean up any outdated state they
124 // might still hold.
125 let descs = descs.broadcast();
126
127 let batches = write::render(
128 sink_id,
129 persist_api.clone(),
130 as_of,
131 desired,
132 persist,
133 descs.clone(),
134 read_only_rx,
135 Rc::clone(&compute_state.worker_config),
136 );
137
138 append::render(sink_id, persist_api, descs, batches);
139
140 // Report sink frontier updates to the `ComputeState`.
141 let collection = compute_state.expect_collection_mut(sink_id);
142 collection.sink_write_frontier = Some(sink_frontier);
143
144 Rc::new(persist_token)
145}
146
147/// Implementation of the `mint` operator.
148mod mint {
149 use super::*;
150 use timely::progress::frontier::AntichainRef;
151
152 /// Render the `mint` operator.
153 ///
154 /// The parameters passed in are:
155 /// * `sink_id`: The `GlobalId` of the sink export.
156 /// * `persist_api`: An object providing access to the output persist shard.
157 /// * `as_of`: The first time for which the sink may produce output.
158 /// * `read_only_rx`: A receiver that reports the sink is in read-only mode.
159 /// * `desired`: The ok/err streams that should be sinked to persist.
160 pub fn render<'s>(
161 sink_id: GlobalId,
162 persist_api: PersistApi,
163 as_of: Antichain<Timestamp>,
164 mut read_only_rx: watch::Receiver<bool>,
165 desired: DesiredStreams<'s>,
166 ) -> (DesiredStreams<'s>, DescsStream<'s>, SharedSinkFrontier) {
167 let scope = desired.ok.scope();
168 let worker_id = scope.index();
169 let worker_count = scope.peers();
170
171 // Determine the active worker for the mint operator.
172 let active_worker_id = usize::cast_from(sink_id.hashed()) % scope.peers();
173
174 let sink_frontier = Rc::new(RefCell::new(Antichain::from_elem(Timestamp::MIN)));
175 let shared_frontier = Rc::clone(&sink_frontier);
176
177 let name = operator_name(sink_id, "mint");
178 let mut builder = OperatorBuilderRc::new(name, scope.clone());
179 let info = builder.operator_info();
180
181 // Create outputs (before inputs, so no input connections yet).
182 let (ok_output, ok_stream) = builder.new_output();
183 let (err_output, err_stream) = builder.new_output();
184 let (desc_output, desc_stream) = builder.new_output();
185
186 let mut ok_output = OutputBuilder::from(ok_output);
187 let mut err_output = OutputBuilder::from(err_output);
188 let mut desc_output = OutputBuilder::from(desc_output);
189
190 // desired_ok -> output 0 (ok passthrough)
191 let mut desired_ok_input = builder.new_input_connection(
192 desired.ok,
193 Pipeline,
194 [(0, Antichain::from_elem(Default::default()))],
195 );
196 // desired_err -> output 1 (err passthrough)
197 let mut desired_err_input = builder.new_input_connection(
198 desired.err,
199 Pipeline,
200 [(1, Antichain::from_elem(Default::default()))],
201 );
202
203 // Set up background tasks and state for the active worker only.
204 let mut task_handles = Vec::new();
205 let read_only = *read_only_rx.borrow_and_update();
206 let mut state = None;
207 if worker_id == active_worker_id {
208 // Spawn a Tokio task to watch the persist shard's upper frontier.
209 //
210 // We collect the persist frontier from a write handle directly, rather than
211 // inspecting the `persist` stream, because the latter has two annoying glitches:
212 // (a) It starts at the shard's read frontier, not its write frontier.
213 // (b) It can lag behind if there are spikes in ingested data.
214 //
215 // The decoupling from the `persist` stream is load-bearing: that stream can fall
216 // arbitrarily behind the shard upper during snapshot replay or write spikes. Using
217 // it would delay both (1) the controller-visible sink frontier (`shared_frontier`),
218 // which previously caused a CrossJoin feature-bench regression where the controller
219 // held a finished MV dataflow open waiting for the empty frontier, and (2) the
220 // `state.persist_frontier` that gates batch-description minting, stalling mint until
221 // the read-back stream catches up. The goal isn't tick-granular descriptions per
222 // se — it's avoiding the stream-induced stall. See 5eab5ff896 for the original
223 // regression and rationale.
224 //
225 // The task sends the empty frontier as its final message before exiting. The
226 // operator drops `persist_rx` once it receives the empty frontier.
227 let (persist_tx, persist_rx) = mpsc::unbounded_channel();
228 let sync_activator = scope.worker().sync_activator_for(info.address.to_vec());
229 let handle = mz_ore::task::spawn(
230 || operator_name(sink_id, "mint::persist_watch"),
231 async move {
232 let mut writer = persist_api.open_writer().await;
233 let mut frontier = Antichain::from_elem(Timestamp::MIN);
234 loop {
235 writer.wait_for_upper_past(&frontier).await;
236 frontier = writer.upper().clone();
237 if persist_tx.send(frontier.clone()).is_err() {
238 return;
239 }
240 if sync_activator.activate().is_err() {
241 return;
242 }
243 if frontier.is_empty() {
244 return;
245 }
246 }
247 },
248 );
249 task_handles.push(handle.abort_on_drop());
250
251 // Spawn a Tokio task to wake the operator when read-only mode changes.
252 if read_only {
253 let sync_activator = scope.worker().sync_activator_for(info.address.to_vec());
254 let mut rx = read_only_rx.clone();
255 let handle = mz_ore::task::spawn(
256 || format!("mv_sink({sink_id})::mint::read_only_watch"),
257 async move {
258 let _ = rx.changed().await;
259 let _ = sync_activator.activate();
260 },
261 );
262 task_handles.push(handle.abort_on_drop());
263 }
264
265 state = Some(State::new(
266 sink_id,
267 worker_count,
268 as_of,
269 read_only,
270 persist_rx,
271 ));
272 }
273
274 builder.build(move |capabilities| {
275 // Passing through the `desired` streams only requires data capabilities, so we can
276 // immediately drop their initial capabilities here.
277 let [_, _, desc_cap]: [_; 3] =
278 capabilities.try_into().expect("one capability per output");
279
280 let mut cap_set = if state.is_some() {
281 Some(CapabilitySet::from_elem(desc_cap))
282 } else {
283 drop(desc_cap);
284 shared_frontier.borrow_mut().clear();
285 None
286 };
287
288 move |frontiers| {
289 // Keep task handles alive so they are aborted when the operator is dropped.
290 let _ = &task_handles;
291
292 // Pass through desired data.
293 let mut ok_out = ok_output.activate();
294 desired_ok_input.for_each(|cap, data| {
295 ok_out.session(&cap).give_container(data);
296 });
297 let mut err_out = err_output.activate();
298 desired_err_input.for_each(|cap, data| {
299 err_out.session(&cap).give_container(data);
300 });
301
302 let Some(state) = &mut state else {
303 // Non-active worker: just pass through data.
304 return;
305 };
306 let cap_set = cap_set.as_mut().unwrap();
307
308 // Track desired frontiers.
309 state.advance_desired_ok_frontier(frontiers[0].frontier());
310 state.advance_desired_err_frontier(frontiers[1].frontier());
311
312 state.drain_persist_rx(&shared_frontier);
313
314 // Check read-only mode.
315 if state.read_only && read_only_rx.has_changed().unwrap_or(false) {
316 if !*read_only_rx.borrow_and_update() {
317 state.allow_writes();
318 }
319 }
320
321 // Try to mint a batch description.
322 let mut desc_out = desc_output.activate();
323 if let Some(desc) = state.maybe_mint_batch_description() {
324 let lower_ts = *desc.lower.as_option().expect("not empty");
325 let cap = cap_set.delayed(&lower_ts);
326 desc_out.session(&cap).give(desc);
327
328 // We only emit strictly increasing `lower`s, so we can let our output frontier
329 // advance beyond the current `lower`.
330 cap_set.downgrade([lower_ts.step_forward()]);
331 } else {
332 // The next emitted `lower` will be at least the `persist` frontier, so we can
333 // advance our output frontier as far.
334 let _ = cap_set.try_downgrade(state.persist_frontier.iter());
335 }
336 }
337 });
338
339 let desired_output_streams = OkErr::new(ok_stream, err_stream);
340 (desired_output_streams, desc_stream, sink_frontier)
341 }
342
343 /// State maintained by the `mint` operator.
344 struct State {
345 sink_id: GlobalId,
346 /// The number of workers in the Timely cluster.
347 worker_count: usize,
348 /// The frontiers of the `desired` inputs.
349 desired_frontiers: OkErr<Antichain<Timestamp>, Antichain<Timestamp>>,
350 /// The frontier of the target persist shard.
351 persist_frontier: Antichain<Timestamp>,
352 /// Receiver for persist frontier updates from the Tokio persist_watch task.
353 ///
354 /// Dropped once the empty frontier is received (the task's shutdown signal).
355 persist_rx: Option<mpsc::UnboundedReceiver<Antichain<Timestamp>>>,
356 /// The append worker for the next batch description, chosen in round-robin fashion.
357 next_append_worker: usize,
358 /// The last `lower` we have emitted in a batch description, if any. Whenever the
359 /// `persist_frontier` moves beyond this frontier, we need to mint a new description.
360 last_lower: Option<Antichain<Timestamp>>,
361 /// Whether we are operating in read-only mode.
362 ///
363 /// In read-only mode, minting of batch descriptions is disabled.
364 read_only: bool,
365 }
366
367 impl State {
368 fn new(
369 sink_id: GlobalId,
370 worker_count: usize,
371 as_of: Antichain<Timestamp>,
372 read_only: bool,
373 persist_rx: mpsc::UnboundedReceiver<Antichain<Timestamp>>,
374 ) -> Self {
375 // Initializing `persist_frontier` to the `as_of` ensures that the first minted batch
376 // description will have a `lower` of `as_of` or beyond, and thus that we don't spend
377 // effort writing out snapshots of data that is already in the shard.
378 let persist_frontier = as_of;
379
380 Self {
381 sink_id,
382 worker_count,
383 desired_frontiers: OkErr::new_frontiers(),
384 persist_frontier,
385 persist_rx: Some(persist_rx),
386 next_append_worker: 0,
387 last_lower: None,
388 read_only,
389 }
390 }
391
392 fn trace<S: AsRef<str>>(&self, message: S) {
393 let message = message.as_ref();
394 trace!(
395 sink_id = %self.sink_id,
396 desired_frontier = ?self.desired_frontiers.frontier().elements(),
397 persist_frontier = ?self.persist_frontier.elements(),
398 last_lower = ?self.last_lower,
399 message,
400 );
401 }
402
403 fn advance_desired_ok_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
404 if advance(&mut self.desired_frontiers.ok, frontier) {
405 self.trace("advanced `desired` ok frontier");
406 }
407 }
408
409 fn advance_desired_err_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
410 if advance(&mut self.desired_frontiers.err, frontier) {
411 self.trace("advanced `desired` err frontier");
412 }
413 }
414
415 fn advance_persist_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
416 if advance(&mut self.persist_frontier, frontier) {
417 self.trace("advanced `persist` frontier");
418 }
419 }
420
421 /// Drain persist frontier updates from the Tokio task.
422 ///
423 /// Frontiers from the `persist_watch` task are monotonically increasing, so only the
424 /// most recent one matters. We drain all queued messages and apply just the latest,
425 /// avoiding redundant `advance_persist_frontier`/`trace!` calls when several updates
426 /// arrived between activations.
427 ///
428 /// The task sends the empty frontier as its final message before exiting. Once
429 /// received, we drop the receiver.
430 fn drain_persist_rx(&mut self, shared_frontier: &RefCell<Antichain<Timestamp>>) {
431 let Some(mut rx) = self.persist_rx.take() else {
432 return;
433 };
434 let mut latest: Option<Antichain<Timestamp>> = None;
435 let mut closed = false;
436 loop {
437 match rx.try_recv() {
438 Ok(frontier) => {
439 let done = frontier.is_empty();
440 latest = Some(frontier);
441 if done {
442 closed = true;
443 break;
444 }
445 }
446 Err(mpsc::error::TryRecvError::Empty) => break,
447 Err(mpsc::error::TryRecvError::Disconnected) => {
448 panic!("mint persist_watch task unexpectedly gone");
449 }
450 }
451 }
452 if let Some(frontier) = latest {
453 shared_frontier.borrow_mut().clone_from(&frontier);
454 self.advance_persist_frontier(frontier.borrow());
455 }
456 if !closed {
457 self.persist_rx = Some(rx);
458 }
459 }
460
461 fn allow_writes(&mut self) {
462 if self.read_only {
463 self.read_only = false;
464 self.trace("switched to write mode");
465 }
466 }
467
468 fn maybe_mint_batch_description(&mut self) -> Option<BatchDescription> {
469 let desired_frontier = self.desired_frontiers.frontier();
470 let persist_frontier = &self.persist_frontier;
471
472 // We only mint new batch descriptions when:
473 // 1. We are _not_ in read-only mode.
474 // 2. The `desired` frontier is ahead of the `persist` frontier.
475 // 3. The `persist` frontier advanced since we last emitted a batch description.
476 let desired_ahead = PartialOrder::less_than(persist_frontier, desired_frontier);
477 let persist_advanced = self.last_lower.as_ref().map_or(true, |lower| {
478 PartialOrder::less_than(lower, persist_frontier)
479 });
480
481 if self.read_only || !desired_ahead || !persist_advanced {
482 return None;
483 }
484
485 let lower = persist_frontier.clone();
486 let upper = desired_frontier.clone();
487 let append_worker = self.next_append_worker;
488 let desc = BatchDescription::new(lower, upper, append_worker);
489
490 self.next_append_worker = (append_worker + 1) % self.worker_count;
491 self.last_lower = Some(desc.lower.clone());
492
493 self.trace(format!("minted batch description: {desc:?}"));
494 Some(desc)
495 }
496 }
497}
498
499/// Implementation of the `write` operator.
500mod write {
501 use super::*;
502
503 use mz_timely_util::activator::ArcActivator;
504
505 /// Commands sent from the Timely operator to the Tokio write task.
506 enum WriteCommand {
507 /// A coalesced batch of work gathered during a single operator activation.
508 ///
509 /// The Timely closure accumulates all updates observed across the four data inputs plus
510 /// any frontier advancement and forced-consolidation flag, and sends a single
511 /// `WriteCommand::Batch` per activation. This keeps the channel overhead independent of
512 /// the number of Timely chunks processed per activation.
513 Batch(BatchUpdates),
514 /// Write a batch with the given description. The task drains corrections and writes
515 /// them to persist.
516 WriteBatch(BatchDescription),
517 }
518
519 /// The payload of a coalesced [`WriteCommand::Batch`].
520 struct BatchUpdates {
521 /// Positive contributions from the `desired` ok input.
522 desired_ok: Vec<(Row, Timestamp, Diff)>,
523 /// Positive contributions from the `desired` err input.
524 desired_err: Vec<(DataflowErrorSer, Timestamp, Diff)>,
525 /// Negative contributions from the `persist` ok input.
526 persist_ok: Vec<(Row, Timestamp, Diff)>,
527 /// Negative contributions from the `persist` err input.
528 persist_err: Vec<(DataflowErrorSer, Timestamp, Diff)>,
529 /// The new persist frontier, if it advanced this activation.
530 persist_frontier: Option<Antichain<Timestamp>>,
531 /// Whether a consolidation of the corrections buffer should be forced.
532 force_consolidation: bool,
533 }
534
535 impl BatchUpdates {
536 fn new() -> Self {
537 Self {
538 desired_ok: Vec::new(),
539 desired_err: Vec::new(),
540 persist_ok: Vec::new(),
541 persist_err: Vec::new(),
542 persist_frontier: None,
543 force_consolidation: false,
544 }
545 }
546
547 /// Returns true if there is no work in this batch.
548 fn is_empty(&self) -> bool {
549 self.desired_ok.is_empty()
550 && self.desired_err.is_empty()
551 && self.persist_ok.is_empty()
552 && self.persist_err.is_empty()
553 && self.persist_frontier.is_none()
554 && !self.force_consolidation
555 }
556 }
557
558 /// The correction buffers owned by the Tokio write task.
559 type Corrections = OkErr<Correction<Row>, Correction<DataflowErrorSer>>;
560
561 /// A response from the Tokio write task back to the Timely operator.
562 struct WriteResponse {
563 /// The written batches, empty if this worker has none to deliver for the interval.
564 batches: Vec<ProtoBatch>,
565 }
566
567 /// Render the `write` operator.
568 ///
569 /// The parameters passed in are:
570 /// * `sink_id`: The `GlobalId` of the sink export.
571 /// * `persist_api`: An object providing access to the output persist shard.
572 /// * `as_of`: The first time for which the sink may produce output.
573 /// * `desired`: The ok/err streams that should be sinked to persist.
574 /// * `persist`: The ok/err streams read back from the output persist shard.
575 /// * `descs`: The stream of batch descriptions produced by the `mint` operator.
576 pub fn render<'s>(
577 sink_id: GlobalId,
578 persist_api: PersistApi,
579 as_of: Antichain<Timestamp>,
580 desired: DesiredStreams<'s>,
581 persist: PersistStreams<'s>,
582 descs: DescsStream<'s>,
583 mut read_only_rx: watch::Receiver<bool>,
584 worker_config: Rc<ConfigSet>,
585 ) -> BatchesStream<'s> {
586 let scope = desired.ok.scope();
587 let worker_id = scope.index();
588
589 let name = operator_name(sink_id, "write");
590 let mut builder = OperatorBuilderRc::new(name, scope.clone());
591 let info = builder.operator_info();
592
593 // Set up correction buffer logging. CorrectionLogger is not Send (uses timely
594 // loggers), so the Tokio task uses ChannelLogging to send events back to the
595 // Timely thread for application by the CorrectionLogger.
596 let mut channel_logging = None;
597 let mut correction_logger = None;
598 if let (Some(compute_logger), Some(differential_logger)) = (
599 scope.worker().logger_for("materialize/compute"),
600 scope.worker().logger_for("differential/arrange"),
601 ) {
602 let operator_info = builder.operator_info();
603 let (tx, rx) = mpsc::unbounded_channel();
604 channel_logging = Some(ChannelLogging::new(tx));
605 correction_logger = Some(CorrectionLogger::new(
606 compute_logger,
607 differential_logger.into(),
608 operator_info.global_id,
609 operator_info.address.to_vec(),
610 rx,
611 ));
612 }
613
614 // It is important that we exchange the `desired` and `persist` data the same way, so
615 // updates that cancel each other out end up on the same worker.
616 let exchange_ok = |(d, _, _): &(Row, Timestamp, Diff)| d.hashed();
617 let exchange_err = |(d, _, _): &(DataflowErrorSer, Timestamp, Diff)| d.hashed();
618
619 // Data inputs are created before the output, so they are not connected to it.
620 let mut desired_ok_input = builder.new_input(desired.ok, Exchange::new(exchange_ok));
621 let mut desired_err_input = builder.new_input(desired.err, Exchange::new(exchange_err));
622 let mut persist_ok_input = builder.new_input(persist.ok, Exchange::new(exchange_ok));
623 let mut persist_err_input = builder.new_input(persist.err, Exchange::new(exchange_err));
624 let mut descs_input = builder.new_input(descs, Pipeline);
625
626 // Only descs (input 4) is connected to the batches output.
627 let (batches_output, batches_output_stream) =
628 builder.new_output_connection([(4, Antichain::from_elem(Default::default()))]);
629 let mut batches_output = OutputBuilder::from(batches_output);
630
631 // Obtain SinkMetrics synchronously from the persist client cache, rather than through
632 // a WriteHandle, to avoid async I/O on the Timely thread.
633 let sink_metrics = persist_api.persist_clients.metrics().sink.clone();
634
635 // Construct corrections on the Timely thread (reads ConfigSet), then move to the
636 // Tokio task. The ChannelLogging sends events back to the Timely thread.
637 let worker_metrics = sink_metrics.for_worker(worker_id);
638 let mut corrections: Corrections = OkErr::new(
639 Correction::new(
640 sink_metrics.clone(),
641 worker_metrics.clone(),
642 channel_logging.clone(),
643 &worker_config,
644 ),
645 Correction::new(
646 sink_metrics.clone(),
647 worker_metrics,
648 channel_logging,
649 &worker_config,
650 ),
651 );
652
653 // Read `MV_SINK_ADVANCE_PERSIST_FRONTIERS` exactly once and reuse the captured value for
654 // both the Tokio-side `corrections.since` initialization below and the Timely-side
655 // `persist_frontiers` initialization in `State::new`. Re-reading the dyncfg per init site
656 // would let the value flip between reads and produce the very inconsistency this fix
657 // addresses: `persist_frontiers = as_of` (gate open) with `corrections.since = MIN`
658 // (snapshot updates not advanced) reproduces the original `UpdateNotBeyondLower` panic.
659 let advance_persist_frontiers_at_startup =
660 MV_SINK_ADVANCE_PERSIST_FRONTIERS.get(&worker_config);
661
662 let shared_batch_max_bytes = MV_SINK_SHARED_BATCH_MAX_BYTES.handle(&worker_config);
663
664 // Mirror the persist-frontier initialization performed by `State::new` below. With the
665 // flag enabled, `State` advances its Timely-side `persist_frontiers` to `as_of`, opening
666 // the `maybe_start_batch` write gate (`desc.lower <= persist_frontiers.frontier()`)
667 // immediately for the first description minted with `lower = as_of`. The corrections
668 // buffer lives on the Tokio task and only learns of frontier advancements through
669 // `WriteCommand::Batch { persist_frontier, .. }`, which the Timely closure populates only
670 // when an input frontier actually moves. On startup, the input frontiers begin at
671 // `Timestamp::MIN`, so no `Batch` carries `persist_frontier` until the persist input
672 // catches up — yet a `WriteBatch(desc)` with `desc.lower = as_of` can already be sent.
673 // Snapshot-replay updates inserted in the meantime stay at their original timestamps
674 // (`Correction::insert` rounds to `max(t, since)` and `since == MIN`), and slip into the
675 // batch, tripping persist's `UpdateNotBeyondLower` invariant. Advancing
676 // `corrections.since` here keeps the Tokio side in lockstep with the Timely side, the
677 // same invariant `materialized_view::write::State::new` upholds via
678 // `apply_persist_frontier_advancement`.
679 if advance_persist_frontiers_at_startup {
680 corrections.ok.advance_since(as_of.clone());
681 corrections.err.advance_since(as_of.clone());
682 }
683
684 // Channels for commands and responses.
685 let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<WriteCommand>();
686 let (resp_tx, mut resp_rx) = mpsc::unbounded_channel::<WriteResponse>();
687
688 // Spawn Tokio task that owns the batch-writing context and corrections buffer.
689 let (activator, activation_ack) = ArcActivator::new(scope, &info);
690 let write_task_handle = {
691 mz_ore::task::spawn(
692 || operator_name(sink_id, "write::batch_writer"),
693 async move {
694 let batch_writer = BatchWriter::new(&persist_api, shared_batch_max_bytes).await;
695
696 while let Some(cmd) = cmd_rx.recv().await {
697 corrections =
698 apply_command(sink_id, corrections, &batch_writer, cmd, &resp_tx).await;
699 // Activate the operator to drain logging events and process batch responses.
700 // ArcActivator suppresses redundant activations, so this is cheap.
701 activator.activate();
702 }
703 },
704 )
705 .abort_on_drop()
706 };
707
708 // In read-only mode, wake the operator when writes are allowed so the forced
709 // consolidation stops re-arming and the `WriteBatch` path takes over promptly. Without
710 // this the operator only learns of the transition on its next input activation, which an
711 // idle sink may not get for a while.
712 let read_only_watch_handle = (*read_only_rx.borrow()).then(|| {
713 let sync_activator = scope.worker().sync_activator_for(info.address.to_vec());
714 let mut rx = read_only_rx.clone();
715 mz_ore::task::spawn(
716 || operator_name(sink_id, "write::read_only_watch"),
717 async move {
718 let _ = rx.changed().await;
719 let _ = sync_activator.activate();
720 },
721 )
722 .abort_on_drop()
723 });
724
725 builder.build(move |capabilities| {
726 // We will use the data capabilities from the `descs` input to produce output, so no
727 // need to hold onto the initial capabilities.
728 drop(capabilities);
729
730 let read_only = *read_only_rx.borrow_and_update();
731 let mut state = State::new(
732 sink_id,
733 worker_id,
734 as_of,
735 advance_persist_frontiers_at_startup,
736 read_only,
737 );
738
739 // Whether a batch write is currently in flight in the Tokio task.
740 let mut batch_in_flight: Option<(BatchDescription, Capability<Timestamp>)> = None;
741
742 // CorrectionLogger lives on the Timely thread and drains events from
743 // the channel each activation. On drop, it drains remaining events and
744 // retracts all logged state.
745 let mut correction_logger = correction_logger;
746
747 move |frontiers| {
748 // Keep task handles alive so they are aborted when the operator is dropped.
749 let _ = &write_task_handle;
750 let _ = &read_only_watch_handle;
751
752 // Acknowledge activation so the Tokio task can activate us again.
753 activation_ack.ack();
754
755 // Drain logging events from the Tokio task's ChannelLogging.
756 if let Some(logger) = &mut correction_logger {
757 logger.apply_events();
758 }
759 // Coalesce all work from this activation into a single command. This keeps
760 // per-chunk channel overhead low, which matters for hydration when many small
761 // Timely chunks arrive in a single activation sweep.
762 let mut batch = BatchUpdates::new();
763 desired_ok_input.for_each(|_cap, data| {
764 batch.desired_ok.append(data);
765 });
766 desired_err_input.for_each(|_cap, data| {
767 batch.desired_err.append(data);
768 });
769 persist_ok_input.for_each(|_cap, data| {
770 batch.persist_ok.append(data);
771 });
772 persist_err_input.for_each(|_cap, data| {
773 batch.persist_err.append(data);
774 });
775
776 // Accept batch descriptions.
777 descs_input.for_each(|cap, data| {
778 let cap = cap.retain(0);
779 for desc in data.drain(..) {
780 state.absorb_batch_description(desc, cap.clone());
781 }
782 });
783
784 // Track frontiers. Include the new persist frontier in the coalesced batch for
785 // `advance_since`.
786 state.advance_desired_ok_frontier(frontiers[0].frontier());
787 state.advance_desired_err_frontier(frontiers[1].frontier());
788 if state.advance_persist_ok_frontier(frontiers[2].frontier())
789 | state.advance_persist_err_frontier(frontiers[3].frontier())
790 {
791 batch.persist_frontier = Some(state.persist_frontiers.frontier().to_owned());
792 }
793 // Track read-only mode so the forced consolidation stops re-arming once writes
794 // are allowed and the `WriteBatch` path takes over driving consolidation.
795 if state.read_only && read_only_rx.has_changed().unwrap_or(false) {
796 if !*read_only_rx.borrow_and_update() {
797 state.set_read_only(false);
798 }
799 }
800
801 if state.should_force_consolidation() {
802 batch.force_consolidation = true;
803 }
804
805 if !batch.is_empty() {
806 cmd_tx
807 .send(WriteCommand::Batch(batch))
808 .expect("write task unexpectedly gone");
809 }
810
811 // Try to receive batch results from the Tokio task.
812 loop {
813 match resp_rx.try_recv() {
814 Ok(resp) => {
815 if let Some((desc, cap)) = batch_in_flight.take() {
816 if resp.batches.is_empty() {
817 state.trace("skipping empty batch");
818 } else {
819 state.trace(format!("wrote {} batches", resp.batches.len()));
820 let mut out = batches_output.activate();
821 let mut session = out.session(&cap);
822 for batch in resp.batches {
823 session.give((desc.clone(), batch));
824 }
825 }
826 }
827 }
828 Err(mpsc::error::TryRecvError::Empty) => break,
829 Err(mpsc::error::TryRecvError::Disconnected) => {
830 panic!("write task unexpectedly gone");
831 }
832 }
833 }
834
835 // If no batch in flight, try to write a new batch.
836 if batch_in_flight.is_none() {
837 if let Some((desc, cap)) = state.maybe_start_batch(&cmd_tx) {
838 batch_in_flight = Some((desc, cap));
839 }
840 }
841 }
842 });
843
844 batches_output_stream
845 }
846
847 /// How many updates the batch read-back hands over per chunk.
848 const READ_BACK_CHUNK: usize = 1024;
849 /// How many chunks may sit between the blocking read-back and the async task feeding the
850 /// batch builder. Together with [`READ_BACK_CHUNK`] this bounds the handoff to a few thousand
851 /// buffered updates.
852 const READ_BACK_CHUNKS_IN_FLIGHT: usize = 4;
853
854 /// The batch-writing context owned by the Tokio write task.
855 ///
856 /// The `writer` cleans up a finished batch when the response channel has already gone away.
857 struct BatchWriter {
858 writer: WriteHandle<SourceData, (), Timestamp, StorageDiff>,
859 shared: SharedBatchWriter,
860 }
861
862 impl BatchWriter {
863 async fn new(
864 persist_api: &PersistApi,
865 shared_batch_max_bytes: ConfigValHandle<usize>,
866 ) -> Self {
867 let writer = persist_api.open_writer().await;
868 let shared = SharedBatchWriter::new(persist_api, &writer, shared_batch_max_bytes).await;
869 Self { writer, shared }
870 }
871 }
872
873 /// Apply a single command to the task state, returning the correction buffers.
874 ///
875 /// `desired` updates enter `corrections` as positive contributions and `persist` updates as
876 /// negative contributions, so the buffer contains `desired - persist`, i.e. the updates that
877 /// need to be written to bring the shard in line with `desired`.
878 ///
879 /// Correction maintenance is CPU-bound and unbounded in duration: an insert can merge chains
880 /// spanning the whole buffer and a consolidation sorts it, neither with an await point in
881 /// between. Running that inline occupies a Tokio worker thread for the entire time, which
882 /// stops it from polling every other task scheduled on it. It therefore runs on a blocking
883 /// thread, which the OS can preempt. The buffers move into the blocking closure and back out
884 /// again because this task owns them exclusively.
885 async fn apply_command(
886 sink_id: GlobalId,
887 mut corrections: Corrections,
888 batch_writer: &BatchWriter,
889 cmd: WriteCommand,
890 resp_tx: &mpsc::UnboundedSender<WriteResponse>,
891 ) -> Corrections {
892 match cmd {
893 WriteCommand::Batch(batch) => apply_batch(sink_id, corrections, batch).await,
894 WriteCommand::WriteBatch(desc) => {
895 // Reading the updates back clones a row per update and, with the pager enabled,
896 // pages chunks back in, so it stalls the same way the consolidation does and runs
897 // on the same blocking thread: the closure keeps the buffers, consolidates, and
898 // streams the consolidated updates back in chunks that this task feeds to the
899 // persist batch builder.
900 //
901 // Not `block_in_place`: that parks a worker's core on a blocking-pool thread for
902 // the whole write, while the batch's part uploads are spawned tasks that need a
903 // live core. With one write per sink and worker, enough concurrent sinks exhaust
904 // the pool's thread cap and the runtime deadlocks. `spawn_blocking` queues
905 // instead of stranding a core, and this task stays cancellable at every chunk
906 // boundary.
907 let upper = desc.upper.clone();
908 let (updates_tx, mut updates_rx) = mpsc::channel(READ_BACK_CHUNKS_IN_FLIGHT);
909 let read_back =
910 mz_ore::task::spawn_blocking(
911 || operator_name(sink_id, "write::consolidate"),
912 move || {
913 corrections.ok.consolidate_before(&upper);
914 corrections.err.consolidate_before(&upper);
915
916 let oks = corrections
917 .ok
918 .consolidated_updates_before(&upper)
919 .map(|(d, t, r)| ((SourceData(Ok(d)), ()), t, r.into_inner()));
920 let errs = corrections.err.consolidated_updates_before(&upper).map(
921 |(d, t, r)| {
922 ((SourceData(Err(d.deserialize())), ()), t, r.into_inner())
923 },
924 );
925
926 let mut updates = oks.chain(errs).peekable();
927 while updates.peek().is_some() {
928 let mut chunk = Vec::with_capacity(READ_BACK_CHUNK);
929 chunk.extend(updates.by_ref().take(READ_BACK_CHUNK));
930 // A closed channel means the write task is gone, so the batch it
931 // asked for is moot and there is no point in pulling the rest of
932 // the buffer.
933 if updates_tx.blocking_send(chunk).is_err() {
934 break;
935 }
936 }
937
938 // The iterators borrow the correction buffers, so they must end before
939 // the buffers move back out.
940 drop(updates);
941 corrections
942 },
943 );
944
945 let batches = write_batches(&batch_writer.shared, &desc, &mut updates_rx).await;
946 let corrections = read_back.await;
947
948 if let Err(err) = resp_tx.send(WriteResponse { batches }) {
949 for proto_batch in err.0.batches {
950 let batch = batch_writer
951 .writer
952 .batch_from_transmittable_batch(proto_batch);
953 batch.delete().await;
954 }
955 }
956
957 corrections
958 }
959 }
960 }
961
962 /// Write the updates for `desc` through `writer`, returning the batches this worker delivers.
963 async fn write_batches(
964 writer: &SharedBatchWriter,
965 desc: &BatchDescription,
966 updates_rx: &mut mpsc::Receiver<Vec<((SourceData, ()), Timestamp, StorageDiff)>>,
967 ) -> Vec<ProtoBatch> {
968 // Start the write only once there are updates, since `maybe_start_batch` requests a write
969 // for every ready interval, including idle ones. A worker that never joins a shared batch
970 // cannot be the last holder responsible for delivering it.
971 let mut write = None;
972 while let Some(chunk) = updates_rx.recv().await {
973 let write = write.get_or_insert_with(|| writer.start(desc));
974 for (kv, t, d) in &chunk {
975 write.push(kv, *t, *d).await;
976 }
977 }
978 match write {
979 Some(write) => write.finish().await,
980 None => Vec::new(),
981 }
982 }
983
984 /// Apply a coalesced batch of updates to the correction buffers, on a blocking thread.
985 ///
986 /// See [`apply_command`] for why this work must not run on a Tokio worker thread.
987 async fn apply_batch(
988 sink_id: GlobalId,
989 mut corrections: Corrections,
990 mut batch: BatchUpdates,
991 ) -> Corrections {
992 mz_ore::task::spawn_blocking(
993 || operator_name(sink_id, "write::apply_batch"),
994 move || {
995 // Stands in for the page-in storm this code produces when the process is under
996 // memory pressure: the buffer's chunks come back one blocking read at a time, from
997 // inside the sorts and merges below, where no await point can be placed. The
998 // failpoint's `sleep` action blocks its thread the same way, so it has to sit on
999 // the same side of the `spawn_blocking` boundary as the work it stands for.
1000 //
1001 // NOTE: activate it with the `FAILPOINTS` env var on the clusterd process, e.g.
1002 // `FAILPOINTS=mv_sink_correction=sleep(30000)`. The `failpoints` session variable
1003 // only reaches environmentd, never a cluster.
1004 fail::fail_point!("mv_sink_correction");
1005
1006 // Apply the same logical sequence of operations that the per-chunk commands
1007 // used to: positive desired inserts, negated persist inserts, then optional
1008 // frontier advancement and forced consolidation.
1009 if !batch.desired_ok.is_empty() {
1010 corrections.ok.insert(&mut batch.desired_ok);
1011 }
1012 if !batch.desired_err.is_empty() {
1013 corrections.err.insert(&mut batch.desired_err);
1014 }
1015 if !batch.persist_ok.is_empty() {
1016 corrections.ok.insert_negated(&mut batch.persist_ok);
1017 }
1018 if !batch.persist_err.is_empty() {
1019 corrections.err.insert_negated(&mut batch.persist_err);
1020 }
1021 if let Some(frontier) = batch.persist_frontier {
1022 // We will only emit times at or after the `persist` frontier, so now is a good
1023 // time to advance the times of stashed updates.
1024 corrections.ok.advance_since(frontier.clone());
1025 corrections.err.advance_since(frontier);
1026 }
1027 if batch.force_consolidation {
1028 corrections.ok.consolidate_at_since();
1029 corrections.err.consolidate_at_since();
1030 }
1031 corrections
1032 },
1033 )
1034 .await
1035 }
1036
1037 /// State maintained by the `write` operator on the Timely thread.
1038 struct State {
1039 sink_id: GlobalId,
1040 worker_id: usize,
1041 /// The frontiers of the `desired` inputs.
1042 desired_frontiers: OkErr<Antichain<Timestamp>, Antichain<Timestamp>>,
1043 /// The frontiers of the `persist` inputs.
1044 ///
1045 /// Note that this is _not_ the same as the write frontier of the output persist shard! It
1046 /// usually is, but during snapshot processing, these frontiers will start at the shard's
1047 /// read frontier, so they can be beyond its write frontier. This is important as it means
1048 /// we must not discard batch descriptions based on these persist frontiers: A batch
1049 /// description might still be valid even if its `lower` is before the persist frontiers we
1050 /// observe.
1051 persist_frontiers: OkErr<Antichain<Timestamp>, Antichain<Timestamp>>,
1052 /// The current valid batch description and associated output capability, if any.
1053 batch_description: Option<(BatchDescription, Capability<Timestamp>)>,
1054 /// A request to force a consolidation of corrections once both `desired_frontiers` and
1055 /// `persist_frontiers` become greater than the given frontier.
1056 ///
1057 /// Normally we force a consolidation whenever we write a batch, but there are periods
1058 /// (like read-only mode) when that doesn't happen, and we need to manually force
1059 /// consolidation instead. In read-only mode this is re-armed after each firing so it
1060 /// keeps sweeping forward as the frontiers advance (see `should_force_consolidation`).
1061 force_consolidation_after: Option<Antichain<Timestamp>>,
1062 /// Whether the sink is in read-only mode. While read-only the `write` operator mints no
1063 /// batches, so the `WriteBatch` path never sweeps `consolidate_before(upper)` forward;
1064 /// the forced consolidation stands in for it and is re-armed as long as this holds.
1065 read_only: bool,
1066 }
1067
1068 impl State {
1069 fn new(
1070 sink_id: GlobalId,
1071 worker_id: usize,
1072 as_of: Antichain<Timestamp>,
1073 advance_persist_frontiers_at_startup: bool,
1074 read_only: bool,
1075 ) -> Self {
1076 // Force a consolidation of corrections after the snapshot updates have been fully
1077 // processed, to ensure we get rid of those as quickly as possible.
1078 let force_consolidation_after = Some(as_of.clone());
1079
1080 let mut state = Self {
1081 sink_id,
1082 worker_id,
1083 desired_frontiers: OkErr::new_frontiers(),
1084 persist_frontiers: OkErr::new_frontiers(),
1085 batch_description: None,
1086 force_consolidation_after,
1087 read_only,
1088 };
1089
1090 // Immediately advance the persist frontier tracking to the `as_of`.
1091 // This is important to ensure the persist sink doesn't get stuck if the output shard's
1092 // initial frontier is less than the `as_of`. The `mint` operator first emits a batch
1093 // description with `lower = as_of`, and the `write` operator only emits a batch when
1094 // its observed persist frontier is >= the batch description's `lower`, which (assuming
1095 // no other writers) would be never if we didn't advance the observed persist frontier
1096 // to the `as_of`.
1097 //
1098 // The `must_use` bool returned by the advance helpers signals that a corresponding
1099 // `corrections.advance_since` must be queued to the Tokio task. We drop it here
1100 // because the Tokio-side `corrections.since` is initialized to the same `as_of` in
1101 // `write::render` before the task is spawned (using the same captured flag value),
1102 // keeping both sides in lockstep without an additional channel send.
1103 if advance_persist_frontiers_at_startup {
1104 let _ = state.advance_persist_ok_frontier(as_of.borrow());
1105 let _ = state.advance_persist_err_frontier(as_of.borrow());
1106 }
1107
1108 state
1109 }
1110
1111 fn trace<S: AsRef<str>>(&self, message: S) {
1112 let message = message.as_ref();
1113 trace!(
1114 sink_id = %self.sink_id,
1115 worker = %self.worker_id,
1116 desired_frontier = ?self.desired_frontiers.frontier().elements(),
1117 persist_frontier = ?self.persist_frontiers.frontier().elements(),
1118 batch_description = ?self.batch_description.as_ref().map(|(d, _)| d),
1119 message,
1120 );
1121 }
1122
1123 fn advance_desired_ok_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
1124 if advance(&mut self.desired_frontiers.ok, frontier) {
1125 self.trace("advanced `desired` ok frontier");
1126 }
1127 }
1128
1129 fn advance_desired_err_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
1130 if advance(&mut self.desired_frontiers.err, frontier) {
1131 self.trace("advanced `desired` err frontier");
1132 }
1133 }
1134
1135 /// Returns true if the persist frontier advanced.
1136 ///
1137 /// The caller must propagate a `true` return value into the next `WriteCommand::Batch`'s
1138 /// `persist_frontier` field so the Tokio task advances `corrections.since` accordingly.
1139 /// Dropping the bool leaves the Tokio-side `since` lagging behind `persist_frontiers`,
1140 /// which can let updates with timestamps below `desc.lower` slip into a written batch.
1141 #[must_use = "advance_persist_ok_frontier's return value gates a `corrections.advance_since` send to the Tokio task"]
1142 fn advance_persist_ok_frontier(&mut self, frontier: AntichainRef<Timestamp>) -> bool {
1143 if advance(&mut self.persist_frontiers.ok, frontier) {
1144 self.trace("advanced `persist` ok frontier");
1145 true
1146 } else {
1147 false
1148 }
1149 }
1150
1151 /// Returns true if the persist frontier advanced.
1152 ///
1153 /// The caller must propagate a `true` return value into the next `WriteCommand::Batch`'s
1154 /// `persist_frontier` field so the Tokio task advances `corrections.since` accordingly.
1155 /// Dropping the bool leaves the Tokio-side `since` lagging behind `persist_frontiers`,
1156 /// which can let updates with timestamps below `desc.lower` slip into a written batch.
1157 #[must_use = "advance_persist_err_frontier's return value gates a `corrections.advance_since` send to the Tokio task"]
1158 fn advance_persist_err_frontier(&mut self, frontier: AntichainRef<Timestamp>) -> bool {
1159 if advance(&mut self.persist_frontiers.err, frontier) {
1160 self.trace("advanced `persist` err frontier");
1161 true
1162 } else {
1163 false
1164 }
1165 }
1166
1167 /// Check if a forced consolidation should be triggered.
1168 fn should_force_consolidation(&mut self) -> bool {
1169 let Some(request) = &self.force_consolidation_after else {
1170 return false;
1171 };
1172
1173 let desired_frontier = self.desired_frontiers.frontier();
1174 let persist_frontier = self.persist_frontiers.frontier();
1175 if PartialOrder::less_than(request, desired_frontier)
1176 && PartialOrder::less_than(request, persist_frontier)
1177 {
1178 self.trace("requesting correction consolidation");
1179 // In read-only mode the sink mints no batches, so the `WriteBatch` path never
1180 // sweeps `consolidate_before(upper)` forward and a single forced consolidation
1181 // only covers the band below the `since` at the moment it fires, leaving the
1182 // forward region uncancelled for the whole read-only window (CLU-131). Re-arm at
1183 // the current persist frontier so the forced consolidation keeps sweeping forward
1184 // as `desired`/`persist` advance, mirroring the read-write path. Once writes are
1185 // allowed, the `WriteBatch` path takes over and we disarm.
1186 self.force_consolidation_after = if self.read_only {
1187 Some(persist_frontier.to_owned())
1188 } else {
1189 None
1190 };
1191 true
1192 } else {
1193 false
1194 }
1195 }
1196
1197 /// Update read-only mode. While read-only the forced consolidation re-arms itself; once
1198 /// writes are allowed the still-armed request fires one final time and then disarms, after
1199 /// which the `WriteBatch` path drives consolidation.
1200 fn set_read_only(&mut self, read_only: bool) {
1201 self.read_only = read_only;
1202 }
1203
1204 fn absorb_batch_description(&mut self, desc: BatchDescription, cap: Capability<Timestamp>) {
1205 // Enforce monotonicity: drop descriptions whose `lower` regresses below the one we
1206 // already hold. The `mint` operator only emits strictly increasing `lower`s
1207 // (invariant 1), so a regression means this description is outdated. We cannot use
1208 // `persist_frontiers` for the same check, because during snapshot processing those
1209 // frontiers can be ahead of the shard's write frontier and a still-valid description
1210 // may have a `lower` below them.
1211 if let Some((prev, _)) = &self.batch_description {
1212 if PartialOrder::less_than(&desc.lower, &prev.lower) {
1213 self.trace(format!("skipping outdated batch description: {desc:?}"));
1214 return;
1215 }
1216 }
1217
1218 self.batch_description = Some((desc, cap));
1219 self.trace("set batch description");
1220 }
1221
1222 /// Check if a batch can be written and send a write command to the Tokio task if so.
1223 fn maybe_start_batch(
1224 &mut self,
1225 cmd_tx: &mpsc::UnboundedSender<WriteCommand>,
1226 ) -> Option<(BatchDescription, Capability<Timestamp>)> {
1227 let (desc, _cap) = self.batch_description.as_ref()?;
1228
1229 // We can write a new batch if we have seen all `persist` updates before `lower` and
1230 // all `desired` updates before `upper`.
1231 let persist_ready =
1232 PartialOrder::less_equal(&desc.lower, self.persist_frontiers.frontier());
1233 let desired_ready =
1234 PartialOrder::less_equal(&desc.upper, self.desired_frontiers.frontier());
1235 if !persist_ready || !desired_ready {
1236 return None;
1237 }
1238
1239 self.trace("write batch description");
1240 let (desc, cap) = self.batch_description.take()?;
1241 cmd_tx
1242 .send(WriteCommand::WriteBatch(desc.clone()))
1243 .expect("write task unexpectedly gone");
1244 Some((desc, cap))
1245 }
1246 }
1247
1248 #[cfg(test)]
1249 mod tests {
1250 use std::sync::Arc;
1251 use std::sync::atomic::{AtomicU64, Ordering};
1252 use std::time::{Duration, Instant};
1253
1254 use mz_ore::metrics::MetricsRegistry;
1255 use mz_persist_client::cfg::PersistConfig;
1256 use mz_persist_client::metrics::Metrics;
1257
1258 use super::*;
1259
1260 /// One stall per worker thread would leave scheduling to chance, so oversubscribe: with
1261 /// more stalls than workers every worker is guaranteed to pick one up.
1262 const WORKER_THREADS: usize = 2;
1263 const STALLS: usize = WORKER_THREADS * 2;
1264 /// How long a stalled `apply_batch` blocks its thread.
1265 const STALL: Duration = Duration::from_millis(1_000);
1266 /// The largest tick gap the canary may observe. Generous enough to absorb scheduling noise
1267 /// on a loaded CI host, while far below the `STALL` an inline correction pass produces.
1268 const MAX_GAP: Duration = Duration::from_millis(500);
1269 const TICK: Duration = Duration::from_millis(10);
1270
1271 fn corrections() -> Corrections {
1272 let registry = MetricsRegistry::new();
1273 let metrics = Metrics::new(&PersistConfig::new_for_tests(), ®istry);
1274 let sink_metrics = metrics.sink.clone();
1275 let worker_metrics = sink_metrics.for_worker(0);
1276 let config = mz_dyncfgs::all_dyncfgs();
1277 OkErr::new(
1278 Correction::new(sink_metrics.clone(), worker_metrics.clone(), None, &config),
1279 Correction::new(sink_metrics, worker_metrics, None, &config),
1280 )
1281 }
1282
1283 /// Tick every [`TICK`], recording the largest gap between consecutive ticks in millis.
1284 ///
1285 /// A gap only opens up if no worker thread was available to poll this task.
1286 async fn canary(max_gap_millis: Arc<AtomicU64>) {
1287 let mut last = Instant::now();
1288 loop {
1289 tokio::time::sleep(TICK).await;
1290 let now = Instant::now();
1291 let gap = u64::try_from(now.duration_since(last).as_millis()).unwrap_or(u64::MAX);
1292 max_gap_millis.fetch_max(gap, Ordering::Relaxed);
1293 last = now;
1294 }
1295 }
1296
1297 /// Correction maintenance must not occupy a Tokio worker thread.
1298 ///
1299 /// Run inline, a pass over a large buffer pins a worker for its whole duration. Under
1300 /// memory pressure it is blocked in the kernel paging chunks back in rather than
1301 /// computing, which is why yielding cannot fix it.
1302 ///
1303 /// Stalls every worker thread inside `apply_batch` and asserts an unrelated task keeps
1304 /// getting polled throughout.
1305 #[mz_ore::test(tokio::test(flavor = "multi_thread", worker_threads = 2))]
1306 #[cfg_attr(miri, ignore)] // depends on real thread scheduling
1307 async fn correction_work_does_not_stall_the_runtime() {
1308 let max_gap_millis = Arc::new(AtomicU64::new(0));
1309 let _canary = mz_ore::task::spawn(|| "canary", canary(Arc::clone(&max_gap_millis)))
1310 .abort_on_drop();
1311
1312 // Let the canary settle, then discard the gaps observed while it did.
1313 tokio::time::sleep(TICK * 5).await;
1314 max_gap_millis.store(0, Ordering::Relaxed);
1315
1316 let action = format!("sleep({})", STALL.as_millis());
1317 fail::cfg("mv_sink_correction", &action).expect("valid failpoint action");
1318
1319 let stalls: Vec<_> = (0..STALLS)
1320 .map(|i| {
1321 // A forced consolidation is the operation that hurt: it sweeps the whole
1322 // buffer, so its cost is unbounded by the size of the incoming batch.
1323 let batch = BatchUpdates {
1324 force_consolidation: true,
1325 ..BatchUpdates::new()
1326 };
1327 let sink_id = GlobalId::User(u64::cast_from(i));
1328 mz_ore::task::spawn(|| "stall", apply_batch(sink_id, corrections(), batch))
1329 })
1330 .collect();
1331 for stall in stalls {
1332 let _ = stall.await;
1333 }
1334
1335 fail::remove("mv_sink_correction");
1336
1337 let max_gap = Duration::from_millis(max_gap_millis.load(Ordering::Relaxed));
1338 assert!(
1339 max_gap < MAX_GAP,
1340 "runtime went unpolled for {max_gap:?}, so correction work is occupying a \
1341 worker thread",
1342 );
1343 }
1344 }
1345}
1346
1347/// Implementation of the `append` operator.
1348mod append {
1349 use super::*;
1350
1351 /// Commands sent from the Timely operator to the Tokio append task.
1352 enum AppendCommand {
1353 /// A new batch description has been received.
1354 Description(BatchDescription),
1355 /// A written batch has been received.
1356 Batch(ProtoBatch),
1357 /// The batches frontier has advanced.
1358 BatchesFrontier(Antichain<Timestamp>),
1359 }
1360
1361 /// Render the `append` operator.
1362 ///
1363 /// The parameters passed in are:
1364 /// * `sink_id`: The `GlobalId` of the sink export.
1365 /// * `persist_api`: An object providing access to the output persist shard.
1366 /// * `descs`: The stream of batch descriptions produced by the `mint` operator.
1367 /// * `batches`: The stream of written batches produced by the `write` operator.
1368 pub fn render<'s>(
1369 sink_id: GlobalId,
1370 persist_api: PersistApi,
1371 descs: DescsStream<'s>,
1372 batches: BatchesStream<'s>,
1373 ) {
1374 let scope = descs.scope();
1375 let worker_id = scope.index();
1376
1377 let name = operator_name(sink_id, "append");
1378 let mut builder = OperatorBuilderRc::new(name, scope.clone());
1379 let mut descs_input = builder.new_input(descs, Pipeline);
1380 let batch_exchange =
1381 Exchange::new(|(desc, _): &(BatchDescription, _)| u64::cast_from(desc.append_worker));
1382 let mut batches_input = builder.new_input(batches, batch_exchange);
1383
1384 // Channel for commands to the Tokio append task.
1385 let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<AppendCommand>();
1386
1387 // Spawn Tokio task that owns the append state machine.
1388 let append_task_handle =
1389 mz_ore::task::spawn(|| operator_name(sink_id, "append"), async move {
1390 let writer = persist_api.open_writer().await;
1391 let mut state = State::new(sink_id, worker_id, writer);
1392
1393 while let Some(cmd) = cmd_rx.recv().await {
1394 match cmd {
1395 AppendCommand::Description(desc) => {
1396 state.absorb_batch_description(desc).await;
1397 state.maybe_append_batches().await;
1398 }
1399 AppendCommand::Batch(batch) => {
1400 state.absorb_batch(batch).await;
1401 }
1402 AppendCommand::BatchesFrontier(frontier) => {
1403 state.advance_batches_frontier(frontier.borrow());
1404 state.maybe_append_batches().await;
1405 }
1406 }
1407 }
1408 })
1409 .abort_on_drop();
1410
1411 builder.build(move |_capabilities| {
1412 let mut prev_batches_frontier = Antichain::from_elem(Timestamp::MIN);
1413
1414 move |frontiers| {
1415 // Keep task handle alive so it is aborted when the operator is dropped.
1416 let _ = &append_task_handle;
1417
1418 // Forward batch descriptions to the Tokio task.
1419 descs_input.for_each(|_cap, data| {
1420 for desc in data.drain(..) {
1421 cmd_tx
1422 .send(AppendCommand::Description(desc))
1423 .expect("append task unexpectedly gone");
1424 }
1425 });
1426
1427 // Forward batches to the Tokio task.
1428 batches_input.for_each(|_cap, data| {
1429 for (_desc, batch) in data.drain(..) {
1430 // The batch description is only used for routing and we ignore it
1431 // here since we already get one from `descs_input`.
1432 cmd_tx
1433 .send(AppendCommand::Batch(batch))
1434 .expect("append task unexpectedly gone");
1435 }
1436 });
1437
1438 // Forward batches frontier advancements *after* the per-activation
1439 // `Description`/`Batch` sends above. The Tokio task drains commands FIFO and only
1440 // calls `maybe_append_batches` on `Description`/`BatchesFrontier`; if a frontier
1441 // advance arrived before its batches, the task could append an incomplete set.
1442 // See module-level docs for the full ordering invariant.
1443 let new_batches_frontier = frontiers[1].frontier();
1444 if PartialOrder::less_than(&prev_batches_frontier.borrow(), &new_batches_frontier) {
1445 prev_batches_frontier.clear();
1446 prev_batches_frontier.extend(new_batches_frontier.iter().cloned());
1447 cmd_tx
1448 .send(AppendCommand::BatchesFrontier(
1449 new_batches_frontier.to_owned(),
1450 ))
1451 .expect("append task unexpectedly gone");
1452 }
1453 }
1454 });
1455 }
1456
1457 /// State maintained by the `append` Tokio task.
1458 struct State {
1459 sink_id: GlobalId,
1460 worker_id: usize,
1461 persist_writer: WriteHandle<SourceData, (), Timestamp, StorageDiff>,
1462 /// The current input frontier of `batches`.
1463 batches_frontier: Antichain<Timestamp>,
1464 /// The greatest observed `lower` from both `descs` and `batches`.
1465 lower: Antichain<Timestamp>,
1466 /// The batch description for `lower`, if any.
1467 batch_description: Option<BatchDescription>,
1468 /// Batches received for `lower`.
1469 batches: Vec<Batch<SourceData, (), Timestamp, StorageDiff>>,
1470 }
1471
1472 impl State {
1473 fn new(
1474 sink_id: GlobalId,
1475 worker_id: usize,
1476 persist_writer: WriteHandle<SourceData, (), Timestamp, StorageDiff>,
1477 ) -> Self {
1478 Self {
1479 sink_id,
1480 worker_id,
1481 persist_writer,
1482 batches_frontier: Antichain::from_elem(Timestamp::MIN),
1483 lower: Antichain::from_elem(Timestamp::MIN),
1484 batch_description: None,
1485 batches: Default::default(),
1486 }
1487 }
1488
1489 fn trace<S: AsRef<str>>(&self, message: S) {
1490 let message = message.as_ref();
1491 trace!(
1492 sink_id = %self.sink_id,
1493 worker = %self.worker_id,
1494 batches_frontier = ?self.batches_frontier.elements(),
1495 lower = ?self.lower.elements(),
1496 batch_description = ?self.batch_description,
1497 message,
1498 );
1499 }
1500
1501 fn advance_batches_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
1502 if advance(&mut self.batches_frontier, frontier) {
1503 self.trace("advanced `batches` frontier");
1504 }
1505 }
1506
1507 /// Advance the current `lower`.
1508 ///
1509 /// Discards all currently stashed batches and batch descriptions, assuming that they are
1510 /// now invalid.
1511 async fn advance_lower(&mut self, frontier: Antichain<Timestamp>) {
1512 assert!(PartialOrder::less_than(&self.lower, &frontier));
1513
1514 self.lower = frontier;
1515 self.batch_description = None;
1516
1517 // Remove stashed batches, cleaning up those we didn't append.
1518 for batch in self.batches.drain(..) {
1519 batch.delete().await;
1520 }
1521
1522 self.trace("advanced `lower`");
1523 }
1524
1525 /// Absorb the given batch description into the state, provided it is not outdated.
1526 async fn absorb_batch_description(&mut self, desc: BatchDescription) {
1527 if PartialOrder::less_than(&self.lower, &desc.lower) {
1528 self.advance_lower(desc.lower.clone()).await;
1529 } else if &self.lower != &desc.lower {
1530 self.trace(format!("skipping outdated batch description: {desc:?}"));
1531 return;
1532 }
1533
1534 if desc.append_worker == self.worker_id {
1535 self.batch_description = Some(desc);
1536 self.trace("set batch description");
1537 }
1538 }
1539
1540 /// Absorb the given batch into the state, provided it is not outdated.
1541 async fn absorb_batch(&mut self, batch: ProtoBatch) {
1542 let batch = self.persist_writer.batch_from_transmittable_batch(batch);
1543 if PartialOrder::less_than(&self.lower, batch.lower()) {
1544 self.advance_lower(batch.lower().clone()).await;
1545 } else if &self.lower != batch.lower() {
1546 self.trace(format!(
1547 "skipping outdated batch: ({:?}, {:?})",
1548 batch.lower().elements(),
1549 batch.upper().elements(),
1550 ));
1551
1552 // Ensure the batch's data gets properly cleaned up before dropping it.
1553 batch.delete().await;
1554 return;
1555 }
1556
1557 self.batches.push(batch);
1558 self.trace("absorbed a batch");
1559 }
1560
1561 async fn maybe_append_batches(&mut self) {
1562 let batches_complete = PartialOrder::less_than(&self.lower, &self.batches_frontier);
1563 if !batches_complete {
1564 return;
1565 }
1566
1567 let Some(desc) = self.batch_description.take() else {
1568 return;
1569 };
1570
1571 let new_lower = match self.append_batches(desc).await {
1572 Ok(shard_upper) => {
1573 self.trace("appended a batch");
1574 shard_upper
1575 }
1576 Err(shard_upper) => {
1577 // Failing the append is expected in the presence of concurrent replicas. There
1578 // is nothing special to do here: The self-correcting feedback mechanism
1579 // ensures that we observe the concurrent changes, compute their consequences,
1580 // and append them at a future time.
1581 self.trace(format!(
1582 "append failed due to `lower` mismatch: {:?}",
1583 shard_upper.elements(),
1584 ));
1585 shard_upper
1586 }
1587 };
1588
1589 self.advance_lower(new_lower).await;
1590 }
1591
1592 /// Append the current `batches` to the output shard.
1593 ///
1594 /// Returns whether the append was successful or not, and the current shard upper in either
1595 /// case.
1596 ///
1597 /// This method advances the shard upper to the batch `lower` if necessary. This is the
1598 /// mechanism that brings the shard upper to the sink as-of when appending the initial
1599 /// batch.
1600 ///
1601 /// An alternative mechanism for bringing the shard upper to the sink as-of would be making
1602 /// a single append at operator startup. The reason we are doing it here instead is that it
1603 /// simplifies the implementation of read-only mode. In read-only mode we have to defer any
1604 /// persist writes, including the initial upper bump. Having only a single place that
1605 /// performs writes makes it easy to ensure we are doing that correctly.
1606 async fn append_batches(
1607 &mut self,
1608 desc: BatchDescription,
1609 ) -> Result<Antichain<Timestamp>, Antichain<Timestamp>> {
1610 let (lower, upper) = (desc.lower, desc.upper);
1611 let mut to_append: Vec<_> = self.batches.iter_mut().collect();
1612
1613 loop {
1614 let result = self
1615 .persist_writer
1616 .compare_and_append_batch(&mut to_append, lower.clone(), upper.clone(), true)
1617 .await
1618 .expect("valid usage");
1619
1620 match result {
1621 Ok(()) => return Ok(upper),
1622 Err(mismatch) if PartialOrder::less_than(&mismatch.current, &lower) => {
1623 advance_shard_upper(&mut self.persist_writer, lower.clone()).await;
1624
1625 // At this point the shard's since and upper are likely the same, a state
1626 // that is likely to hit edge-cases in logic reasoning about frontiers.
1627 fail::fail_point!("mv_advanced_upper");
1628 }
1629 Err(mismatch) => return Err(mismatch.current),
1630 }
1631 }
1632 }
1633 }
1634
1635 /// Advance the frontier of the given writer's shard to at least the given `upper`.
1636 async fn advance_shard_upper(
1637 persist_writer: &mut WriteHandle<SourceData, (), Timestamp, StorageDiff>,
1638 upper: Antichain<Timestamp>,
1639 ) {
1640 let empty_updates: &[((SourceData, ()), Timestamp, StorageDiff)] = &[];
1641 let lower = Antichain::from_elem(Timestamp::MIN);
1642 persist_writer
1643 .append(empty_updates, lower, upper)
1644 .await
1645 .expect("valid usage")
1646 .expect("should always succeed");
1647 }
1648}