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