Skip to main content

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                    // The `mint` operator above emits one `cap_set.delayed(..)` per message.
779                    let cap = cap
780                        .retain_least(0)
781                        .expect("message stamped with no capabilities");
782                    for desc in data.drain(..) {
783                        state.absorb_batch_description(desc, cap.clone());
784                    }
785                });
786
787                // Track frontiers. Include the new persist frontier in the coalesced batch for
788                // `advance_since`.
789                state.advance_desired_ok_frontier(frontiers[0].frontier());
790                state.advance_desired_err_frontier(frontiers[1].frontier());
791                if state.advance_persist_ok_frontier(frontiers[2].frontier())
792                    | state.advance_persist_err_frontier(frontiers[3].frontier())
793                {
794                    batch.persist_frontier = Some(state.persist_frontiers.frontier().to_owned());
795                }
796                // Track read-only mode so the forced consolidation stops re-arming once writes
797                // are allowed and the `WriteBatch` path takes over driving consolidation.
798                if state.read_only && read_only_rx.has_changed().unwrap_or(false) {
799                    if !*read_only_rx.borrow_and_update() {
800                        state.set_read_only(false);
801                    }
802                }
803
804                if state.should_force_consolidation() {
805                    batch.force_consolidation = true;
806                }
807
808                if !batch.is_empty() {
809                    cmd_tx
810                        .send(WriteCommand::Batch(batch))
811                        .expect("write task unexpectedly gone");
812                }
813
814                // Try to receive batch results from the Tokio task.
815                loop {
816                    match resp_rx.try_recv() {
817                        Ok(resp) => {
818                            if let Some((desc, cap)) = batch_in_flight.take() {
819                                if resp.batches.is_empty() {
820                                    state.trace("skipping empty batch");
821                                } else {
822                                    state.trace(format!("wrote {} batches", resp.batches.len()));
823                                    let mut out = batches_output.activate();
824                                    let mut session = out.session(&cap);
825                                    for batch in resp.batches {
826                                        session.give((desc.clone(), batch));
827                                    }
828                                }
829                            }
830                        }
831                        Err(mpsc::error::TryRecvError::Empty) => break,
832                        Err(mpsc::error::TryRecvError::Disconnected) => {
833                            panic!("write task unexpectedly gone");
834                        }
835                    }
836                }
837
838                // If no batch in flight, try to write a new batch.
839                if batch_in_flight.is_none() {
840                    if let Some((desc, cap)) = state.maybe_start_batch(&cmd_tx) {
841                        batch_in_flight = Some((desc, cap));
842                    }
843                }
844            }
845        });
846
847        batches_output_stream
848    }
849
850    /// How many updates the batch read-back hands over per chunk.
851    const READ_BACK_CHUNK: usize = 1024;
852    /// How many chunks may sit between the blocking read-back and the async task feeding the
853    /// batch builder. Together with [`READ_BACK_CHUNK`] this bounds the handoff to a few thousand
854    /// buffered updates.
855    const READ_BACK_CHUNKS_IN_FLIGHT: usize = 4;
856
857    /// The batch-writing context owned by the Tokio write task.
858    ///
859    /// The `writer` cleans up a finished batch when the response channel has already gone away.
860    struct BatchWriter {
861        writer: WriteHandle<SourceData, (), Timestamp, StorageDiff>,
862        shared: SharedBatchWriter,
863    }
864
865    impl BatchWriter {
866        async fn new(
867            persist_api: &PersistApi,
868            shared_batch_max_bytes: ConfigValHandle<usize>,
869        ) -> Self {
870            let writer = persist_api.open_writer().await;
871            let shared = SharedBatchWriter::new(persist_api, &writer, shared_batch_max_bytes).await;
872            Self { writer, shared }
873        }
874    }
875
876    /// Apply a single command to the task state, returning the correction buffers.
877    ///
878    /// `desired` updates enter `corrections` as positive contributions and `persist` updates as
879    /// negative contributions, so the buffer contains `desired - persist`, i.e. the updates that
880    /// need to be written to bring the shard in line with `desired`.
881    ///
882    /// Correction maintenance is CPU-bound and unbounded in duration: an insert can merge chains
883    /// spanning the whole buffer and a consolidation sorts it, neither with an await point in
884    /// between. Running that inline occupies a Tokio worker thread for the entire time, which
885    /// stops it from polling every other task scheduled on it. It therefore runs on a blocking
886    /// thread, which the OS can preempt. The buffers move into the blocking closure and back out
887    /// again because this task owns them exclusively.
888    async fn apply_command(
889        sink_id: GlobalId,
890        mut corrections: Corrections,
891        batch_writer: &BatchWriter,
892        cmd: WriteCommand,
893        resp_tx: &mpsc::UnboundedSender<WriteResponse>,
894    ) -> Corrections {
895        match cmd {
896            WriteCommand::Batch(batch) => apply_batch(sink_id, corrections, batch).await,
897            WriteCommand::WriteBatch(desc) => {
898                // Reading the updates back clones a row per update and, with the pager enabled,
899                // pages chunks back in, so it stalls the same way the consolidation does and runs
900                // on the same blocking thread: the closure keeps the buffers, consolidates, and
901                // streams the consolidated updates back in chunks that this task feeds to the
902                // persist batch builder.
903                //
904                // Not `block_in_place`: that parks a worker's core on a blocking-pool thread for
905                // the whole write, while the batch's part uploads are spawned tasks that need a
906                // live core. With one write per sink and worker, enough concurrent sinks exhaust
907                // the pool's thread cap and the runtime deadlocks. `spawn_blocking` queues
908                // instead of stranding a core, and this task stays cancellable at every chunk
909                // boundary.
910                let upper = desc.upper.clone();
911                let (updates_tx, mut updates_rx) = mpsc::channel(READ_BACK_CHUNKS_IN_FLIGHT);
912                let read_back =
913                    mz_ore::task::spawn_blocking(
914                        || operator_name(sink_id, "write::consolidate"),
915                        move || {
916                            corrections.ok.consolidate_before(&upper);
917                            corrections.err.consolidate_before(&upper);
918
919                            let oks = corrections
920                                .ok
921                                .consolidated_updates_before(&upper)
922                                .map(|(d, t, r)| ((SourceData(Ok(d)), ()), t, r.into_inner()));
923                            let errs = corrections.err.consolidated_updates_before(&upper).map(
924                                |(d, t, r)| {
925                                    ((SourceData(Err(d.deserialize())), ()), t, r.into_inner())
926                                },
927                            );
928
929                            let mut updates = oks.chain(errs).peekable();
930                            while updates.peek().is_some() {
931                                let mut chunk = Vec::with_capacity(READ_BACK_CHUNK);
932                                chunk.extend(updates.by_ref().take(READ_BACK_CHUNK));
933                                // A closed channel means the write task is gone, so the batch it
934                                // asked for is moot and there is no point in pulling the rest of
935                                // the buffer.
936                                if updates_tx.blocking_send(chunk).is_err() {
937                                    break;
938                                }
939                            }
940
941                            // The iterators borrow the correction buffers, so they must end before
942                            // the buffers move back out.
943                            drop(updates);
944                            corrections
945                        },
946                    );
947
948                let batches = write_batches(&batch_writer.shared, &desc, &mut updates_rx).await;
949                let corrections = read_back.await;
950
951                if let Err(err) = resp_tx.send(WriteResponse { batches }) {
952                    for proto_batch in err.0.batches {
953                        let batch = batch_writer
954                            .writer
955                            .batch_from_transmittable_batch(proto_batch);
956                        batch.delete().await;
957                    }
958                }
959
960                corrections
961            }
962        }
963    }
964
965    /// Write the updates for `desc` through `writer`, returning the batches this worker delivers.
966    async fn write_batches(
967        writer: &SharedBatchWriter,
968        desc: &BatchDescription,
969        updates_rx: &mut mpsc::Receiver<Vec<((SourceData, ()), Timestamp, StorageDiff)>>,
970    ) -> Vec<ProtoBatch> {
971        // Start the write only once there are updates, since `maybe_start_batch` requests a write
972        // for every ready interval, including idle ones. A worker that never joins a shared batch
973        // cannot be the last holder responsible for delivering it.
974        let mut write = None;
975        while let Some(chunk) = updates_rx.recv().await {
976            let write = write.get_or_insert_with(|| writer.start(desc));
977            for (kv, t, d) in &chunk {
978                write.push(kv, *t, *d).await;
979            }
980        }
981        match write {
982            Some(write) => write.finish().await,
983            None => Vec::new(),
984        }
985    }
986
987    /// Apply a coalesced batch of updates to the correction buffers, on a blocking thread.
988    ///
989    /// See [`apply_command`] for why this work must not run on a Tokio worker thread.
990    async fn apply_batch(
991        sink_id: GlobalId,
992        mut corrections: Corrections,
993        mut batch: BatchUpdates,
994    ) -> Corrections {
995        mz_ore::task::spawn_blocking(
996            || operator_name(sink_id, "write::apply_batch"),
997            move || {
998                // Stands in for the page-in storm this code produces when the process is under
999                // memory pressure: the buffer's chunks come back one blocking read at a time, from
1000                // inside the sorts and merges below, where no await point can be placed. The
1001                // failpoint's `sleep` action blocks its thread the same way, so it has to sit on
1002                // the same side of the `spawn_blocking` boundary as the work it stands for.
1003                //
1004                // NOTE: activate it with the `FAILPOINTS` env var on the clusterd process, e.g.
1005                // `FAILPOINTS=mv_sink_correction=sleep(30000)`. The `unsafe_failpoints` session
1006                // variable only reaches environmentd, never a cluster.
1007                fail::fail_point!("mv_sink_correction");
1008
1009                // Apply the same logical sequence of operations that the per-chunk commands
1010                // used to: positive desired inserts, negated persist inserts, then optional
1011                // frontier advancement and forced consolidation.
1012                if !batch.desired_ok.is_empty() {
1013                    corrections.ok.insert(&mut batch.desired_ok);
1014                }
1015                if !batch.desired_err.is_empty() {
1016                    corrections.err.insert(&mut batch.desired_err);
1017                }
1018                if !batch.persist_ok.is_empty() {
1019                    corrections.ok.insert_negated(&mut batch.persist_ok);
1020                }
1021                if !batch.persist_err.is_empty() {
1022                    corrections.err.insert_negated(&mut batch.persist_err);
1023                }
1024                if let Some(frontier) = batch.persist_frontier {
1025                    // We will only emit times at or after the `persist` frontier, so now is a good
1026                    // time to advance the times of stashed updates.
1027                    corrections.ok.advance_since(frontier.clone());
1028                    corrections.err.advance_since(frontier);
1029                }
1030                if batch.force_consolidation {
1031                    corrections.ok.consolidate_at_since();
1032                    corrections.err.consolidate_at_since();
1033                }
1034                corrections
1035            },
1036        )
1037        .await
1038    }
1039
1040    /// State maintained by the `write` operator on the Timely thread.
1041    struct State {
1042        sink_id: GlobalId,
1043        worker_id: usize,
1044        /// The frontiers of the `desired` inputs.
1045        desired_frontiers: OkErr<Antichain<Timestamp>, Antichain<Timestamp>>,
1046        /// The frontiers of the `persist` inputs.
1047        ///
1048        /// Note that this is _not_ the same as the write frontier of the output persist shard! It
1049        /// usually is, but during snapshot processing, these frontiers will start at the shard's
1050        /// read frontier, so they can be beyond its write frontier. This is important as it means
1051        /// we must not discard batch descriptions based on these persist frontiers: A batch
1052        /// description might still be valid even if its `lower` is before the persist frontiers we
1053        /// observe.
1054        persist_frontiers: OkErr<Antichain<Timestamp>, Antichain<Timestamp>>,
1055        /// The current valid batch description and associated output capability, if any.
1056        batch_description: Option<(BatchDescription, Capability<Timestamp>)>,
1057        /// A request to force a consolidation of corrections once both `desired_frontiers` and
1058        /// `persist_frontiers` become greater than the given frontier.
1059        ///
1060        /// Normally we force a consolidation whenever we write a batch, but there are periods
1061        /// (like read-only mode) when that doesn't happen, and we need to manually force
1062        /// consolidation instead. In read-only mode this is re-armed after each firing so it
1063        /// keeps sweeping forward as the frontiers advance (see `should_force_consolidation`).
1064        force_consolidation_after: Option<Antichain<Timestamp>>,
1065        /// Whether the sink is in read-only mode. While read-only the `write` operator mints no
1066        /// batches, so the `WriteBatch` path never sweeps `consolidate_before(upper)` forward;
1067        /// the forced consolidation stands in for it and is re-armed as long as this holds.
1068        read_only: bool,
1069    }
1070
1071    impl State {
1072        fn new(
1073            sink_id: GlobalId,
1074            worker_id: usize,
1075            as_of: Antichain<Timestamp>,
1076            advance_persist_frontiers_at_startup: bool,
1077            read_only: bool,
1078        ) -> Self {
1079            // Force a consolidation of corrections after the snapshot updates have been fully
1080            // processed, to ensure we get rid of those as quickly as possible.
1081            let force_consolidation_after = Some(as_of.clone());
1082
1083            let mut state = Self {
1084                sink_id,
1085                worker_id,
1086                desired_frontiers: OkErr::new_frontiers(),
1087                persist_frontiers: OkErr::new_frontiers(),
1088                batch_description: None,
1089                force_consolidation_after,
1090                read_only,
1091            };
1092
1093            // Immediately advance the persist frontier tracking to the `as_of`.
1094            // This is important to ensure the persist sink doesn't get stuck if the output shard's
1095            // initial frontier is less than the `as_of`. The `mint` operator first emits a batch
1096            // description with `lower = as_of`, and the `write` operator only emits a batch when
1097            // its observed persist frontier is >= the batch description's `lower`, which (assuming
1098            // no other writers) would be never if we didn't advance the observed persist frontier
1099            // to the `as_of`.
1100            //
1101            // The `must_use` bool returned by the advance helpers signals that a corresponding
1102            // `corrections.advance_since` must be queued to the Tokio task. We drop it here
1103            // because the Tokio-side `corrections.since` is initialized to the same `as_of` in
1104            // `write::render` before the task is spawned (using the same captured flag value),
1105            // keeping both sides in lockstep without an additional channel send.
1106            if advance_persist_frontiers_at_startup {
1107                let _ = state.advance_persist_ok_frontier(as_of.borrow());
1108                let _ = state.advance_persist_err_frontier(as_of.borrow());
1109            }
1110
1111            state
1112        }
1113
1114        fn trace<S: AsRef<str>>(&self, message: S) {
1115            let message = message.as_ref();
1116            trace!(
1117                sink_id = %self.sink_id,
1118                worker = %self.worker_id,
1119                desired_frontier = ?self.desired_frontiers.frontier().elements(),
1120                persist_frontier = ?self.persist_frontiers.frontier().elements(),
1121                batch_description = ?self.batch_description.as_ref().map(|(d, _)| d),
1122                message,
1123            );
1124        }
1125
1126        fn advance_desired_ok_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
1127            if advance(&mut self.desired_frontiers.ok, frontier) {
1128                self.trace("advanced `desired` ok frontier");
1129            }
1130        }
1131
1132        fn advance_desired_err_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
1133            if advance(&mut self.desired_frontiers.err, frontier) {
1134                self.trace("advanced `desired` err frontier");
1135            }
1136        }
1137
1138        /// Returns true if the persist frontier advanced.
1139        ///
1140        /// The caller must propagate a `true` return value into the next `WriteCommand::Batch`'s
1141        /// `persist_frontier` field so the Tokio task advances `corrections.since` accordingly.
1142        /// Dropping the bool leaves the Tokio-side `since` lagging behind `persist_frontiers`,
1143        /// which can let updates with timestamps below `desc.lower` slip into a written batch.
1144        #[must_use = "advance_persist_ok_frontier's return value gates a `corrections.advance_since` send to the Tokio task"]
1145        fn advance_persist_ok_frontier(&mut self, frontier: AntichainRef<Timestamp>) -> bool {
1146            if advance(&mut self.persist_frontiers.ok, frontier) {
1147                self.trace("advanced `persist` ok frontier");
1148                true
1149            } else {
1150                false
1151            }
1152        }
1153
1154        /// Returns true if the persist frontier advanced.
1155        ///
1156        /// The caller must propagate a `true` return value into the next `WriteCommand::Batch`'s
1157        /// `persist_frontier` field so the Tokio task advances `corrections.since` accordingly.
1158        /// Dropping the bool leaves the Tokio-side `since` lagging behind `persist_frontiers`,
1159        /// which can let updates with timestamps below `desc.lower` slip into a written batch.
1160        #[must_use = "advance_persist_err_frontier's return value gates a `corrections.advance_since` send to the Tokio task"]
1161        fn advance_persist_err_frontier(&mut self, frontier: AntichainRef<Timestamp>) -> bool {
1162            if advance(&mut self.persist_frontiers.err, frontier) {
1163                self.trace("advanced `persist` err frontier");
1164                true
1165            } else {
1166                false
1167            }
1168        }
1169
1170        /// Check if a forced consolidation should be triggered.
1171        fn should_force_consolidation(&mut self) -> bool {
1172            let Some(request) = &self.force_consolidation_after else {
1173                return false;
1174            };
1175
1176            let desired_frontier = self.desired_frontiers.frontier();
1177            let persist_frontier = self.persist_frontiers.frontier();
1178            if PartialOrder::less_than(request, desired_frontier)
1179                && PartialOrder::less_than(request, persist_frontier)
1180            {
1181                self.trace("requesting correction consolidation");
1182                // In read-only mode the sink mints no batches, so the `WriteBatch` path never
1183                // sweeps `consolidate_before(upper)` forward and a single forced consolidation
1184                // only covers the band below the `since` at the moment it fires, leaving the
1185                // forward region uncancelled for the whole read-only window (CLU-131). Re-arm at
1186                // the current persist frontier so the forced consolidation keeps sweeping forward
1187                // as `desired`/`persist` advance, mirroring the read-write path. Once writes are
1188                // allowed, the `WriteBatch` path takes over and we disarm.
1189                self.force_consolidation_after = if self.read_only {
1190                    Some(persist_frontier.to_owned())
1191                } else {
1192                    None
1193                };
1194                true
1195            } else {
1196                false
1197            }
1198        }
1199
1200        /// Update read-only mode. While read-only the forced consolidation re-arms itself; once
1201        /// writes are allowed the still-armed request fires one final time and then disarms, after
1202        /// which the `WriteBatch` path drives consolidation.
1203        fn set_read_only(&mut self, read_only: bool) {
1204            self.read_only = read_only;
1205        }
1206
1207        fn absorb_batch_description(&mut self, desc: BatchDescription, cap: Capability<Timestamp>) {
1208            // Enforce monotonicity: drop descriptions whose `lower` regresses below the one we
1209            // already hold. The `mint` operator only emits strictly increasing `lower`s
1210            // (invariant 1), so a regression means this description is outdated. We cannot use
1211            // `persist_frontiers` for the same check, because during snapshot processing those
1212            // frontiers can be ahead of the shard's write frontier and a still-valid description
1213            // may have a `lower` below them.
1214            if let Some((prev, _)) = &self.batch_description {
1215                if PartialOrder::less_than(&desc.lower, &prev.lower) {
1216                    self.trace(format!("skipping outdated batch description: {desc:?}"));
1217                    return;
1218                }
1219            }
1220
1221            self.batch_description = Some((desc, cap));
1222            self.trace("set batch description");
1223        }
1224
1225        /// Check if a batch can be written and send a write command to the Tokio task if so.
1226        fn maybe_start_batch(
1227            &mut self,
1228            cmd_tx: &mpsc::UnboundedSender<WriteCommand>,
1229        ) -> Option<(BatchDescription, Capability<Timestamp>)> {
1230            let (desc, _cap) = self.batch_description.as_ref()?;
1231
1232            // We can write a new batch if we have seen all `persist` updates before `lower` and
1233            // all `desired` updates before `upper`.
1234            let persist_ready =
1235                PartialOrder::less_equal(&desc.lower, self.persist_frontiers.frontier());
1236            let desired_ready =
1237                PartialOrder::less_equal(&desc.upper, self.desired_frontiers.frontier());
1238            if !persist_ready || !desired_ready {
1239                return None;
1240            }
1241
1242            self.trace("write batch description");
1243            let (desc, cap) = self.batch_description.take()?;
1244            cmd_tx
1245                .send(WriteCommand::WriteBatch(desc.clone()))
1246                .expect("write task unexpectedly gone");
1247            Some((desc, cap))
1248        }
1249    }
1250
1251    #[cfg(test)]
1252    mod tests {
1253        use std::sync::Arc;
1254        use std::sync::atomic::{AtomicU64, Ordering};
1255        use std::time::{Duration, Instant};
1256
1257        use mz_ore::metrics::MetricsRegistry;
1258        use mz_persist_client::cfg::PersistConfig;
1259        use mz_persist_client::metrics::Metrics;
1260
1261        use super::*;
1262
1263        /// One stall per worker thread would leave scheduling to chance, so oversubscribe: with
1264        /// more stalls than workers every worker is guaranteed to pick one up.
1265        const WORKER_THREADS: usize = 2;
1266        const STALLS: usize = WORKER_THREADS * 2;
1267        /// How long a stalled `apply_batch` blocks its thread.
1268        const STALL: Duration = Duration::from_millis(1_000);
1269        /// The largest tick gap the canary may observe. Generous enough to absorb scheduling noise
1270        /// on a loaded CI host, while far below the `STALL` an inline correction pass produces.
1271        const MAX_GAP: Duration = Duration::from_millis(500);
1272        const TICK: Duration = Duration::from_millis(10);
1273
1274        fn corrections() -> Corrections {
1275            let registry = MetricsRegistry::new();
1276            let metrics = Metrics::new(&PersistConfig::new_for_tests(), &registry);
1277            let sink_metrics = metrics.sink.clone();
1278            let worker_metrics = sink_metrics.for_worker(0);
1279            let config = mz_dyncfgs::all_dyncfgs();
1280            OkErr::new(
1281                Correction::new(sink_metrics.clone(), worker_metrics.clone(), None, &config),
1282                Correction::new(sink_metrics, worker_metrics, None, &config),
1283            )
1284        }
1285
1286        /// Tick every [`TICK`], recording the largest gap between consecutive ticks in millis.
1287        ///
1288        /// A gap only opens up if no worker thread was available to poll this task.
1289        async fn canary(max_gap_millis: Arc<AtomicU64>) {
1290            let mut last = Instant::now();
1291            loop {
1292                tokio::time::sleep(TICK).await;
1293                let now = Instant::now();
1294                let gap = u64::try_from(now.duration_since(last).as_millis()).unwrap_or(u64::MAX);
1295                max_gap_millis.fetch_max(gap, Ordering::Relaxed);
1296                last = now;
1297            }
1298        }
1299
1300        /// Correction maintenance must not occupy a Tokio worker thread.
1301        ///
1302        /// Run inline, a pass over a large buffer pins a worker for its whole duration. Under
1303        /// memory pressure it is blocked in the kernel paging chunks back in rather than
1304        /// computing, which is why yielding cannot fix it.
1305        ///
1306        /// Stalls every worker thread inside `apply_batch` and asserts an unrelated task keeps
1307        /// getting polled throughout.
1308        #[mz_ore::test(tokio::test(flavor = "multi_thread", worker_threads = 2))]
1309        #[cfg_attr(miri, ignore)] // depends on real thread scheduling
1310        async fn correction_work_does_not_stall_the_runtime() {
1311            let max_gap_millis = Arc::new(AtomicU64::new(0));
1312            let _canary = mz_ore::task::spawn(|| "canary", canary(Arc::clone(&max_gap_millis)))
1313                .abort_on_drop();
1314
1315            // Let the canary settle, then discard the gaps observed while it did.
1316            tokio::time::sleep(TICK * 5).await;
1317            max_gap_millis.store(0, Ordering::Relaxed);
1318
1319            let action = format!("sleep({})", STALL.as_millis());
1320            fail::cfg("mv_sink_correction", &action).expect("valid failpoint action");
1321
1322            let stalls: Vec<_> = (0..STALLS)
1323                .map(|i| {
1324                    // A forced consolidation is the operation that hurt: it sweeps the whole
1325                    // buffer, so its cost is unbounded by the size of the incoming batch.
1326                    let batch = BatchUpdates {
1327                        force_consolidation: true,
1328                        ..BatchUpdates::new()
1329                    };
1330                    let sink_id = GlobalId::User(u64::cast_from(i));
1331                    mz_ore::task::spawn(|| "stall", apply_batch(sink_id, corrections(), batch))
1332                })
1333                .collect();
1334            for stall in stalls {
1335                let _ = stall.await;
1336            }
1337
1338            fail::remove("mv_sink_correction");
1339
1340            let max_gap = Duration::from_millis(max_gap_millis.load(Ordering::Relaxed));
1341            assert!(
1342                max_gap < MAX_GAP,
1343                "runtime went unpolled for {max_gap:?}, so correction work is occupying a \
1344                 worker thread",
1345            );
1346        }
1347    }
1348}
1349
1350/// Implementation of the `append` operator.
1351mod append {
1352    use super::*;
1353
1354    /// Commands sent from the Timely operator to the Tokio append task.
1355    enum AppendCommand {
1356        /// A new batch description has been received.
1357        Description(BatchDescription),
1358        /// A written batch has been received.
1359        Batch(ProtoBatch),
1360        /// The batches frontier has advanced.
1361        BatchesFrontier(Antichain<Timestamp>),
1362    }
1363
1364    /// Render the `append` operator.
1365    ///
1366    /// The parameters passed in are:
1367    ///  * `sink_id`: The `GlobalId` of the sink export.
1368    ///  * `persist_api`: An object providing access to the output persist shard.
1369    ///  * `descs`: The stream of batch descriptions produced by the `mint` operator.
1370    ///  * `batches`: The stream of written batches produced by the `write` operator.
1371    pub fn render<'s>(
1372        sink_id: GlobalId,
1373        persist_api: PersistApi,
1374        descs: DescsStream<'s>,
1375        batches: BatchesStream<'s>,
1376    ) {
1377        let scope = descs.scope();
1378        let worker_id = scope.index();
1379
1380        let name = operator_name(sink_id, "append");
1381        let mut builder = OperatorBuilderRc::new(name, scope.clone());
1382        let mut descs_input = builder.new_input(descs, Pipeline);
1383        let batch_exchange =
1384            Exchange::new(|(desc, _): &(BatchDescription, _)| u64::cast_from(desc.append_worker));
1385        let mut batches_input = builder.new_input(batches, batch_exchange);
1386
1387        // Channel for commands to the Tokio append task.
1388        let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<AppendCommand>();
1389
1390        // Spawn Tokio task that owns the append state machine.
1391        let append_task_handle =
1392            mz_ore::task::spawn(|| operator_name(sink_id, "append"), async move {
1393                let writer = persist_api.open_writer().await;
1394                let mut state = State::new(sink_id, worker_id, writer);
1395
1396                while let Some(cmd) = cmd_rx.recv().await {
1397                    match cmd {
1398                        AppendCommand::Description(desc) => {
1399                            state.absorb_batch_description(desc).await;
1400                            state.maybe_append_batches().await;
1401                        }
1402                        AppendCommand::Batch(batch) => {
1403                            state.absorb_batch(batch).await;
1404                        }
1405                        AppendCommand::BatchesFrontier(frontier) => {
1406                            state.advance_batches_frontier(frontier.borrow());
1407                            state.maybe_append_batches().await;
1408                        }
1409                    }
1410                }
1411            })
1412            .abort_on_drop();
1413
1414        builder.build(move |_capabilities| {
1415            let mut prev_batches_frontier = Antichain::from_elem(Timestamp::MIN);
1416
1417            move |frontiers| {
1418                // Keep task handle alive so it is aborted when the operator is dropped.
1419                let _ = &append_task_handle;
1420
1421                // Forward batch descriptions to the Tokio task.
1422                descs_input.for_each(|_cap, data| {
1423                    for desc in data.drain(..) {
1424                        cmd_tx
1425                            .send(AppendCommand::Description(desc))
1426                            .expect("append task unexpectedly gone");
1427                    }
1428                });
1429
1430                // Forward batches to the Tokio task.
1431                batches_input.for_each(|_cap, data| {
1432                    for (_desc, batch) in data.drain(..) {
1433                        // The batch description is only used for routing and we ignore it
1434                        // here since we already get one from `descs_input`.
1435                        cmd_tx
1436                            .send(AppendCommand::Batch(batch))
1437                            .expect("append task unexpectedly gone");
1438                    }
1439                });
1440
1441                // Forward batches frontier advancements *after* the per-activation
1442                // `Description`/`Batch` sends above. The Tokio task drains commands FIFO and only
1443                // calls `maybe_append_batches` on `Description`/`BatchesFrontier`; if a frontier
1444                // advance arrived before its batches, the task could append an incomplete set.
1445                // See module-level docs for the full ordering invariant.
1446                let new_batches_frontier = frontiers[1].frontier();
1447                if PartialOrder::less_than(&prev_batches_frontier.borrow(), &new_batches_frontier) {
1448                    prev_batches_frontier.clear();
1449                    prev_batches_frontier.extend(new_batches_frontier.iter().cloned());
1450                    cmd_tx
1451                        .send(AppendCommand::BatchesFrontier(
1452                            new_batches_frontier.to_owned(),
1453                        ))
1454                        .expect("append task unexpectedly gone");
1455                }
1456            }
1457        });
1458    }
1459
1460    /// State maintained by the `append` Tokio task.
1461    struct State {
1462        sink_id: GlobalId,
1463        worker_id: usize,
1464        persist_writer: WriteHandle<SourceData, (), Timestamp, StorageDiff>,
1465        /// The current input frontier of `batches`.
1466        batches_frontier: Antichain<Timestamp>,
1467        /// The greatest observed `lower` from both `descs` and `batches`.
1468        lower: Antichain<Timestamp>,
1469        /// The batch description for `lower`, if any.
1470        batch_description: Option<BatchDescription>,
1471        /// Batches received for `lower`.
1472        batches: Vec<Batch<SourceData, (), Timestamp, StorageDiff>>,
1473    }
1474
1475    impl State {
1476        fn new(
1477            sink_id: GlobalId,
1478            worker_id: usize,
1479            persist_writer: WriteHandle<SourceData, (), Timestamp, StorageDiff>,
1480        ) -> Self {
1481            Self {
1482                sink_id,
1483                worker_id,
1484                persist_writer,
1485                batches_frontier: Antichain::from_elem(Timestamp::MIN),
1486                lower: Antichain::from_elem(Timestamp::MIN),
1487                batch_description: None,
1488                batches: Default::default(),
1489            }
1490        }
1491
1492        fn trace<S: AsRef<str>>(&self, message: S) {
1493            let message = message.as_ref();
1494            trace!(
1495                sink_id = %self.sink_id,
1496                worker = %self.worker_id,
1497                batches_frontier = ?self.batches_frontier.elements(),
1498                lower = ?self.lower.elements(),
1499                batch_description = ?self.batch_description,
1500                message,
1501            );
1502        }
1503
1504        fn advance_batches_frontier(&mut self, frontier: AntichainRef<Timestamp>) {
1505            if advance(&mut self.batches_frontier, frontier) {
1506                self.trace("advanced `batches` frontier");
1507            }
1508        }
1509
1510        /// Advance the current `lower`.
1511        ///
1512        /// Discards all currently stashed batches and batch descriptions, assuming that they are
1513        /// now invalid.
1514        async fn advance_lower(&mut self, frontier: Antichain<Timestamp>) {
1515            assert!(PartialOrder::less_than(&self.lower, &frontier));
1516
1517            self.lower = frontier;
1518            self.batch_description = None;
1519
1520            // Remove stashed batches, cleaning up those we didn't append.
1521            for batch in self.batches.drain(..) {
1522                batch.delete().await;
1523            }
1524
1525            self.trace("advanced `lower`");
1526        }
1527
1528        /// Absorb the given batch description into the state, provided it is not outdated.
1529        async fn absorb_batch_description(&mut self, desc: BatchDescription) {
1530            if PartialOrder::less_than(&self.lower, &desc.lower) {
1531                self.advance_lower(desc.lower.clone()).await;
1532            } else if &self.lower != &desc.lower {
1533                self.trace(format!("skipping outdated batch description: {desc:?}"));
1534                return;
1535            }
1536
1537            if desc.append_worker == self.worker_id {
1538                self.batch_description = Some(desc);
1539                self.trace("set batch description");
1540            }
1541        }
1542
1543        /// Absorb the given batch into the state, provided it is not outdated.
1544        async fn absorb_batch(&mut self, batch: ProtoBatch) {
1545            let batch = self.persist_writer.batch_from_transmittable_batch(batch);
1546            if PartialOrder::less_than(&self.lower, batch.lower()) {
1547                self.advance_lower(batch.lower().clone()).await;
1548            } else if &self.lower != batch.lower() {
1549                self.trace(format!(
1550                    "skipping outdated batch: ({:?}, {:?})",
1551                    batch.lower().elements(),
1552                    batch.upper().elements(),
1553                ));
1554
1555                // Ensure the batch's data gets properly cleaned up before dropping it.
1556                batch.delete().await;
1557                return;
1558            }
1559
1560            self.batches.push(batch);
1561            self.trace("absorbed a batch");
1562        }
1563
1564        async fn maybe_append_batches(&mut self) {
1565            let batches_complete = PartialOrder::less_than(&self.lower, &self.batches_frontier);
1566            if !batches_complete {
1567                return;
1568            }
1569
1570            let Some(desc) = self.batch_description.take() else {
1571                return;
1572            };
1573
1574            let new_lower = match self.append_batches(desc).await {
1575                Ok(shard_upper) => {
1576                    self.trace("appended a batch");
1577                    shard_upper
1578                }
1579                Err(shard_upper) => {
1580                    // Failing the append is expected in the presence of concurrent replicas. There
1581                    // is nothing special to do here: The self-correcting feedback mechanism
1582                    // ensures that we observe the concurrent changes, compute their consequences,
1583                    // and append them at a future time.
1584                    self.trace(format!(
1585                        "append failed due to `lower` mismatch: {:?}",
1586                        shard_upper.elements(),
1587                    ));
1588                    shard_upper
1589                }
1590            };
1591
1592            self.advance_lower(new_lower).await;
1593        }
1594
1595        /// Append the current `batches` to the output shard.
1596        ///
1597        /// Returns whether the append was successful or not, and the current shard upper in either
1598        /// case.
1599        ///
1600        /// This method advances the shard upper to the batch `lower` if necessary. This is the
1601        /// mechanism that brings the shard upper to the sink as-of when appending the initial
1602        /// batch.
1603        ///
1604        /// An alternative mechanism for bringing the shard upper to the sink as-of would be making
1605        /// a single append at operator startup. The reason we are doing it here instead is that it
1606        /// simplifies the implementation of read-only mode. In read-only mode we have to defer any
1607        /// persist writes, including the initial upper bump. Having only a single place that
1608        /// performs writes makes it easy to ensure we are doing that correctly.
1609        async fn append_batches(
1610            &mut self,
1611            desc: BatchDescription,
1612        ) -> Result<Antichain<Timestamp>, Antichain<Timestamp>> {
1613            let (lower, upper) = (desc.lower, desc.upper);
1614            let mut to_append: Vec<_> = self.batches.iter_mut().collect();
1615
1616            loop {
1617                let result = self
1618                    .persist_writer
1619                    .compare_and_append_batch(&mut to_append, lower.clone(), upper.clone(), true)
1620                    .await
1621                    .expect("valid usage");
1622
1623                match result {
1624                    Ok(()) => return Ok(upper),
1625                    Err(mismatch) if PartialOrder::less_than(&mismatch.current, &lower) => {
1626                        advance_shard_upper(&mut self.persist_writer, lower.clone()).await;
1627
1628                        // At this point the shard's since and upper are likely the same, a state
1629                        // that is likely to hit edge-cases in logic reasoning about frontiers.
1630                        fail::fail_point!("mv_advanced_upper");
1631                    }
1632                    Err(mismatch) => return Err(mismatch.current),
1633                }
1634            }
1635        }
1636    }
1637
1638    /// Advance the frontier of the given writer's shard to at least the given `upper`.
1639    async fn advance_shard_upper(
1640        persist_writer: &mut WriteHandle<SourceData, (), Timestamp, StorageDiff>,
1641        upper: Antichain<Timestamp>,
1642    ) {
1643        let empty_updates: &[((SourceData, ()), Timestamp, StorageDiff)] = &[];
1644        let lower = Antichain::from_elem(Timestamp::MIN);
1645        persist_writer
1646            .append(empty_updates, lower, upper)
1647            .await
1648            .expect("valid usage")
1649            .expect("should always succeed");
1650    }
1651}