Skip to main content

mz_compute_client/
as_of_selection.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//! Support for selecting as-ofs of compute dataflows during system initialization.
11//!
12//! The functionality implemented here is invoked by the coordinator during its bootstrap process.
13//! Ideally, it would be part of the controller and transparent to the coordinator, but that's
14//! difficult to reconcile with the current controller API. For now, we still make the coordinator
15//! worry about as-of selection but keep the implementation in a compute crate because it really is
16//! a compute implementation concern.
17//!
18//! The as-of selection process takes a list of `DataflowDescription`s, determines compatible
19//! as-ofs for the compute collections they export, and augments the `DataflowDescription`s with
20//! these as-ofs.
21//!
22//! For each compute collection, the as-of selection process keeps an `AsOfBounds` instance that
23//! tracks a lower and an upper bound for the as-of the collection may get assigned. Throughout the
24//! process, a collection's `AsOfBounds` get repeatedly refined, by increasing the lower bound and
25//! decreasing the upper bound. The final upper bound is then used for the collection as-of. Using
26//! the upper bound maximizes the chances of compute reconciliation being effective, and minimizes
27//! the amount of historical data that must be read from the dataflow sources.
28//!
29//! Refinement of `AsOfBounds` is performed by applying `Constraint`s to collections. A
30//! `Constraint` specifies which bound should be refined to which frontier. A `Constraint` may be
31//! "hard" or "soft", which determines how failure to apply it is handled. Failing to apply a hard
32//! constraint is treated as an error, failing to apply a soft constraint is not. If a constraint
33//! fails to apply, the respective `AsOfBounds` are refined as much as possible (to a single
34//! frontier) and marked as "sealed". Subsequent constraint applications against the sealed bounds
35//! are no-ops. This is done to avoid log noise from repeated constraint application failures.
36//!
37//! Note that failing to apply a hard constraint does not abort the as-of selection process for the
38//! affected collection. Instead the failure is handled gracefully by logging an error and
39//! assigning the collection a best-effort as-of. This is done, rather than panicking or returning
40//! an error and letting the coordinator panic, to ensure the availability of the system. Ideally,
41//! we would instead mark the affected dataflow as failed/poisoned, but such a mechanism doesn't
42//! currently exist.
43//!
44//! The as-of selection process applies constraints in order of importance, because once a
45//! constraint application fails, the respective `AsOfBounds` are sealed and later applications
46//! won't have any effect. This means hard constraints must be applied before soft constraints, and
47//! more desirable soft constraints should be applied before less desirable ones.
48//!
49//! # `AsOfBounds` Invariants
50//!
51//! Correctness requires two invariants of `AsOfBounds` of dependent collections:
52//!
53//!  (1) The lower bound of a collection is >= the lower bound of each of its inputs.
54//!  (2) The upper bound of a collection is >= the upper bound of each of its inputs.
55//!
56//! Each step of the as-of selection process needs to ensure that these invariants are upheld once
57//! it completes. The expectation is that each step (a) performs local changes to either the
58//! `lower` _or_ the `upper` bounds of some collections and (b) invokes the appropriate
59//! `propagate_bounds_*` method to restore the invariant broken by (a).
60//!
61//! For steps that behave as described in (a), we can prove that (b) will always succeed in
62//! applying the bounds propagation constraints:
63//!
64//! | Let `A` and `B` be any pair of collections where `A` is an input of `B`.
65//! | Before (a), both invariants are upheld, i.e. `A.lower <= B.lower` and `A.upper <= B.upper`.
66//! |
67//! | Case 1: (a) increases `A.lower` and/or `B.lower` to `A.lower'` and `B.lower'`
68//! |     Invariant (1) might be broken, need to prove that it can be restored.
69//! |     Case 1.a: `A.lower' <= B.lower'`
70//! |         Invariant (1) is still upheld without propagation.
71//! |     Case 1.b: `A.lower' > B.lower'`
72//! |         A collection's lower bound can only be increased up to its upper bound.
73//! |         Therefore, and from invariant (2): `A.lower' <= A.upper <= B.upper`
74//! |         Therefore, propagation can set `B.lower' = A.lower'`, restoring invariant (1).
75//! | Case 2: (a) decreases `A.upper` and/or `B.upper`
76//! |     Invariant (2) might be broken, need to prove that it can be restored.
77//! |     The proof is equivalent to Case 1.
78
79use std::cell::RefCell;
80use std::collections::{BTreeMap, BTreeSet};
81use std::fmt;
82use std::rc::Rc;
83
84use mz_compute_types::dataflows::LirDataflowDescription;
85use mz_ore::collections::CollectionExt;
86use mz_ore::soft_panic_or_log;
87use mz_repr::{GlobalId, Timestamp};
88use mz_storage_client::storage_collections::StorageCollections;
89use mz_storage_types::read_holds::ReadHold;
90use mz_storage_types::read_policy::ReadPolicy;
91use timely::PartialOrder;
92use timely::progress::Antichain;
93use tracing::{info, warn};
94
95/// Runs as-of selection for the given dataflows.
96///
97/// Assigns the selected as-of to the provided dataflow descriptions and returns a set of
98/// `ReadHold`s that must not be dropped nor downgraded until the dataflows have been installed
99/// with the compute controller.
100pub fn run(
101    dataflows: &mut [LirDataflowDescription],
102    read_policies: &BTreeMap<GlobalId, ReadPolicy>,
103    storage_collections: &dyn StorageCollections,
104    current_time: Timestamp,
105    read_only_mode: bool,
106) -> BTreeMap<GlobalId, ReadHold> {
107    // Get read holds for the storage inputs of the dataflows.
108    // This ensures that storage frontiers don't advance past the selected as-ofs.
109    let mut storage_read_holds = BTreeMap::new();
110    for dataflow in &*dataflows {
111        for id in dataflow.source_imports.keys() {
112            if !storage_read_holds.contains_key(id) {
113                let read_hold = storage_collections
114                    .acquire_read_holds(vec![*id])
115                    .expect("storage collection exists")
116                    .into_element();
117                storage_read_holds.insert(*id, read_hold);
118            }
119        }
120    }
121
122    let mut ctx = Context::new(dataflows, storage_collections, read_policies, current_time);
123
124    // Dataflows that sink into a storage collection that has advanced to the empty frontier don't
125    // need to be installed at all. So we can apply an optimization where we prune them here and
126    // assign them an empty as-of at the end.
127    ctx.prune_sealed_persist_sinks();
128
129    // During 0dt upgrades, it can happen that the leader environment drops storage collections,
130    // making the read-only environment observe inconsistent read frontiers (database-issues#8836).
131    // To avoid hard-constraint failures, we prune all collections depending on these dropped
132    // storage collections.
133    if read_only_mode {
134        ctx.prune_dropped_collections();
135    }
136
137    // Apply hard constraints from upstream and downstream storage collections.
138    ctx.apply_upstream_storage_constraints(&storage_read_holds);
139    ctx.apply_downstream_storage_constraints();
140
141    // At this point all collections have as-of bounds that reflect what is required for
142    // correctness. The current state isn't very usable though. In particular, most of the upper
143    // bounds are likely to be the empty frontier, so if we'd select as-ofs on this basis, the
144    // resulting dataflows would never hydrate. Instead we'll apply a number of soft constraints to
145    // end up in a better place.
146
147    // Constrain collection as-ofs to times that are currently available in the inputs. This
148    // ensures that dataflows can immediately start hydrating. It also ensures that dataflows don't
149    // get an empty as-of, except when they exclusively depend on constant collections.
150    //
151    // Allowing dataflows to hydrate immediately is desirable. It allows them to complete the most
152    // resource-intensive phase of their lifecycle as early as possible. Ideally we want this phase
153    // to occur during a 0dt upgrade, where we still have the option to roll back if a cluster
154    // doesn't come up successfully. For dataflows with a refresh schedule, hydrating early also
155    // ensures that there isn't a large output delay when the refresh time is reached.
156    ctx.apply_warmup_constraints();
157
158    // Constrain as-ofs of indexes according to their read policies.
159    ctx.apply_index_read_policy_constraints();
160
161    // Constrain as-ofs of indexes to the current time. This ensures that indexes are immediately
162    // readable.
163    ctx.apply_index_current_time_constraints();
164
165    // Apply the derived as-of bounds to the dataflows.
166    for dataflow in dataflows {
167        // `AsOfBounds` are shared between the exports of a dataflow, so looking at just the first
168        // export is sufficient.
169        let first_export = dataflow.export_ids().next();
170        let as_of = first_export.map_or_else(Antichain::new, |id| ctx.best_as_of(id));
171        dataflow.as_of = Some(as_of);
172    }
173
174    storage_read_holds
175}
176
177/// Bounds for possible as-of values of a dataflow.
178#[derive(Debug)]
179struct AsOfBounds {
180    lower: Antichain<Timestamp>,
181    upper: Antichain<Timestamp>,
182    /// Whether these bounds can still change.
183    sealed: bool,
184}
185
186impl AsOfBounds {
187    /// Creates an `AsOfBounds` that only allows the given `frontier`.
188    fn single(frontier: Antichain<Timestamp>) -> Self {
189        Self {
190            lower: frontier.clone(),
191            upper: frontier,
192            sealed: false,
193        }
194    }
195
196    /// Get the bound of the given type.
197    fn get(&self, type_: BoundType) -> &Antichain<Timestamp> {
198        match type_ {
199            BoundType::Lower => &self.lower,
200            BoundType::Upper => &self.upper,
201        }
202    }
203}
204
205impl Default for AsOfBounds {
206    fn default() -> Self {
207        Self {
208            lower: Antichain::from_elem(Timestamp::MIN),
209            upper: Antichain::new(),
210            sealed: false,
211        }
212    }
213}
214
215impl fmt::Display for AsOfBounds {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(
218            f,
219            "[{:?} .. {:?}]",
220            self.lower.elements(),
221            self.upper.elements()
222        )
223    }
224}
225
226/// Types of bounds.
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228enum BoundType {
229    Lower,
230    Upper,
231}
232
233impl fmt::Display for BoundType {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        match self {
236            Self::Lower => f.write_str("lower"),
237            Self::Upper => f.write_str("upper"),
238        }
239    }
240}
241
242/// Types of constraints.
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
244enum ConstraintType {
245    /// Hard constraints are applied to enforce correctness properties, and failing to apply them is
246    /// an error.
247    Hard,
248    /// Soft constraints are applied to improve performance or UX, and failing to apply them is
249    /// undesirable but not an error.
250    Soft,
251}
252
253/// A constraint that can be applied to the `AsOfBounds` of a collection.
254#[derive(Debug)]
255struct Constraint<'a> {
256    type_: ConstraintType,
257    /// Which bound this constraint applies to.
258    bound_type: BoundType,
259    /// The frontier by which the bound should be constrained.
260    frontier: &'a Antichain<Timestamp>,
261    /// A short description of the reason for applying this constraint.
262    ///
263    /// Used only for logging.
264    reason: &'a str,
265}
266
267impl Constraint<'_> {
268    /// Applies this constraint to the given bounds.
269    ///
270    /// Returns a bool indicating whether the given bounds were changed as a result.
271    ///
272    /// Applying a constraint can fail, if the constraint frontier is incompatible with the
273    /// existing bounds. In this case, the constraint still gets partially applied by moving one of
274    /// the bounds up/down to the other, depending on the `bound_type`.
275    ///
276    /// Applying a constraint to sealed bounds is a no-op.
277    fn apply(&self, bounds: &mut AsOfBounds) -> Result<bool, bool> {
278        if bounds.sealed {
279            return Ok(false);
280        }
281
282        match self.bound_type {
283            BoundType::Lower => {
284                if PartialOrder::less_than(&bounds.upper, self.frontier) {
285                    bounds.sealed = true;
286                    if PartialOrder::less_than(&bounds.lower, &bounds.upper) {
287                        bounds.lower.clone_from(&bounds.upper);
288                        Err(true)
289                    } else {
290                        Err(false)
291                    }
292                } else if PartialOrder::less_equal(self.frontier, &bounds.lower) {
293                    Ok(false)
294                } else {
295                    bounds.lower.clone_from(self.frontier);
296                    Ok(true)
297                }
298            }
299            BoundType::Upper => {
300                if PartialOrder::less_than(self.frontier, &bounds.lower) {
301                    bounds.sealed = true;
302                    if PartialOrder::less_than(&bounds.lower, &bounds.upper) {
303                        bounds.upper.clone_from(&bounds.lower);
304                        Err(true)
305                    } else {
306                        Err(false)
307                    }
308                } else if PartialOrder::less_equal(&bounds.upper, self.frontier) {
309                    Ok(false)
310                } else {
311                    bounds.upper.clone_from(self.frontier);
312                    Ok(true)
313                }
314            }
315        }
316    }
317}
318
319/// State tracked for a compute collection during as-of selection.
320struct Collection<'a> {
321    storage_inputs: Vec<GlobalId>,
322    compute_inputs: Vec<GlobalId>,
323    read_policy: Option<&'a ReadPolicy>,
324    /// The currently known as-of bounds.
325    ///
326    /// Shared between collections exported by the same dataflow.
327    bounds: Rc<RefCell<AsOfBounds>>,
328    /// Whether this collection is an index.
329    is_index: bool,
330}
331
332/// The as-of selection context.
333struct Context<'a> {
334    collections: BTreeMap<GlobalId, Collection<'a>>,
335    storage_collections: &'a dyn StorageCollections,
336    current_time: Timestamp,
337}
338
339impl<'a> Context<'a> {
340    /// Initializes an as-of selection context for the given `dataflows`.
341    fn new(
342        dataflows: &[LirDataflowDescription],
343        storage_collections: &'a dyn StorageCollections,
344        read_policies: &'a BTreeMap<GlobalId, ReadPolicy>,
345        current_time: Timestamp,
346    ) -> Self {
347        // Construct initial collection state for each dataflow export. Dataflows might have their
348        // as-ofs already fixed, which we need to take into account when constructing `AsOfBounds`.
349        let mut collections = BTreeMap::new();
350        for dataflow in dataflows {
351            let storage_inputs: Vec<_> = dataflow.source_imports.keys().copied().collect();
352            let compute_inputs: Vec<_> = dataflow.index_imports.keys().copied().collect();
353
354            let bounds = match dataflow.as_of.clone() {
355                Some(frontier) => AsOfBounds::single(frontier),
356                None => AsOfBounds::default(),
357            };
358            let bounds = Rc::new(RefCell::new(bounds));
359
360            for id in dataflow.export_ids() {
361                let collection = Collection {
362                    storage_inputs: storage_inputs.clone(),
363                    compute_inputs: compute_inputs.clone(),
364                    read_policy: read_policies.get(&id),
365                    bounds: Rc::clone(&bounds),
366                    is_index: dataflow.index_exports.contains_key(&id),
367                };
368                collections.insert(id, collection);
369            }
370        }
371
372        Self {
373            collections,
374            storage_collections,
375            current_time,
376        }
377    }
378
379    /// Returns the state of the identified collection.
380    ///
381    /// # Panics
382    ///
383    /// Panics if the identified collection doesn't exist.
384    fn expect_collection(&self, id: GlobalId) -> &Collection<'_> {
385        self.collections
386            .get(&id)
387            .unwrap_or_else(|| panic!("collection missing: {id}"))
388    }
389
390    /// Applies the given as-of constraint to the identified collection.
391    ///
392    /// Returns whether the collection's as-of bounds where changed as a result.
393    fn apply_constraint(&self, id: GlobalId, constraint: Constraint) -> bool {
394        let collection = self.expect_collection(id);
395        let mut bounds = collection.bounds.borrow_mut();
396        match constraint.apply(&mut bounds) {
397            Ok(changed) => {
398                if changed {
399                    info!(%id, %bounds, reason = %constraint.reason, "applied as-of constraint");
400                }
401                changed
402            }
403            Err(changed) => {
404                match constraint.type_ {
405                    ConstraintType::Hard => {
406                        soft_panic_or_log!(
407                            "failed to apply hard as-of constraint \
408                             (id={id}, bounds={bounds}, constraint={constraint:?})"
409                        );
410                    }
411                    ConstraintType::Soft => {
412                        info!(%id, %bounds, ?constraint, "failed to apply soft as-of constraint");
413                    }
414                }
415                changed
416            }
417        }
418    }
419
420    /// Apply as-of constraints imposed by the frontiers of upstream storage collections.
421    ///
422    /// A collection's as-of _must_ be >= the read frontier of each of its (transitive) storage
423    /// inputs.
424    ///
425    /// Failing to apply this constraint to a collection is an error. The affected dataflow will
426    /// not be able to hydrate successfully.
427    fn apply_upstream_storage_constraints(
428        &self,
429        storage_read_holds: &BTreeMap<GlobalId, ReadHold>,
430    ) {
431        // Apply direct constraints from storage inputs.
432        for (id, collection) in &self.collections {
433            for input_id in &collection.storage_inputs {
434                let read_hold = &storage_read_holds[input_id];
435                let constraint = Constraint {
436                    type_: ConstraintType::Hard,
437                    bound_type: BoundType::Lower,
438                    frontier: read_hold.since(),
439                    reason: &format!("storage input {input_id} read frontier"),
440                };
441                self.apply_constraint(*id, constraint);
442            }
443        }
444
445        // Propagate constraints downstream, restoring `AsOfBounds` invariant (1).
446        self.propagate_bounds_downstream(BoundType::Lower);
447    }
448
449    /// Apply as-of constraints imposed by the frontiers of downstream storage collections.
450    ///
451    /// A collection's as-of _must_ be < the write frontier of the storage collection it exports to
452    /// (if any) if it is non-empty, and <= the storage collection's read frontier otherwise.
453    ///
454    /// Rationale:
455    ///
456    /// * A collection's as-of must be <= the write frontier of its dependent storage collection,
457    ///   because we need to pick up computing the contents of storage collections where we left
458    ///   off previously, to avoid skipped times observable in the durable output.
459    /// * Some dataflows feeding into storage collections (specifically: continual tasks) need to
460    ///   be able to observe input changes at times they write to the output. If we selected the
461    ///   as-of to be equal to the write frontier of the output storage collection, we wouldn't be
462    ///   able to produce the correct output at that frontier. Thus the selected as-of must be
463    ///   strictly less than the write frontier.
464    /// * As an exception to the above, if the output storage collection is empty (i.e. its write
465    ///   frontier is <= its read frontier), we need to allow the as-of to be equal to the read
466    ///   frontier. This is correct in the sense that it mirrors the timestamp selection behavior
467    ///   of the sequencer when it created the collection. Chances are that the sequencer chose the
468    ///   initial as-of (and therefore the initial read frontier of the storage collection) as the
469    ///   smallest possible time that can still be read from the collection inputs, so forcing the
470    ///   upper bound any lower than that read frontier would produce a hard constraint violation.
471    ///
472    /// Failing to apply this constraint to a collection is an error. The storage collection it
473    /// exports to may have times visible to readers skipped in its output, violating correctness.
474    fn apply_downstream_storage_constraints(&self) {
475        // Apply direct constraints from storage exports.
476        for id in self.collections.keys() {
477            let Ok(frontiers) = self.storage_collections.collection_frontiers(*id) else {
478                continue;
479            };
480
481            let collection_empty =
482                PartialOrder::less_equal(&frontiers.write_frontier, &frontiers.read_capabilities);
483            let upper = if collection_empty {
484                frontiers.read_capabilities
485            } else {
486                step_back_frontier(&frontiers.write_frontier)
487            };
488
489            let constraint = Constraint {
490                type_: ConstraintType::Hard,
491                bound_type: BoundType::Upper,
492                frontier: &upper,
493                reason: &format!("storage export {id} write frontier"),
494            };
495            self.apply_constraint(*id, constraint);
496        }
497
498        // Propagate constraints upstream, restoring `AsOfBounds` invariant (2).
499        self.propagate_bounds_upstream(BoundType::Upper);
500    }
501
502    /// Apply as-of constraints to ensure collections can hydrate immediately.
503    ///
504    /// A collection's as-of _should_ be < the write frontier of each of its (transitive) storage
505    /// inputs.
506    ///
507    /// Failing to apply this constraint is not an error. The affected dataflow will not be able to
508    /// hydrate immediately, but it will be able to hydrate once its inputs have sufficiently
509    /// advanced.
510    fn apply_warmup_constraints(&self) {
511        // Collect write frontiers from storage inputs.
512        let mut write_frontiers = BTreeMap::new();
513        for (id, collection) in &self.collections {
514            let storage_frontiers = self
515                .storage_collections
516                .collections_frontiers(collection.storage_inputs.clone())
517                .expect("storage collections exist");
518
519            let mut write_frontier = Antichain::new();
520            for frontiers in storage_frontiers {
521                write_frontier.extend(frontiers.write_frontier);
522            }
523
524            write_frontiers.insert(*id, write_frontier);
525        }
526
527        // Propagate write frontiers through compute inputs.
528        fixpoint(|changed| {
529            for (id, collection) in &self.collections {
530                let mut write_frontier = write_frontiers.remove(id).expect("inserted above");
531                for input_id in &collection.compute_inputs {
532                    let frontier = &write_frontiers[input_id];
533                    *changed |= write_frontier.extend(frontier.iter().cloned());
534                }
535                write_frontiers.insert(*id, write_frontier);
536            }
537        });
538
539        // Apply the warmup constraint.
540        for (id, write_frontier) in write_frontiers {
541            let upper = step_back_frontier(&write_frontier);
542            let constraint = Constraint {
543                type_: ConstraintType::Soft,
544                bound_type: BoundType::Upper,
545                frontier: &upper,
546                reason: &format!(
547                    "warmup frontier derived from storage write frontier {:?}",
548                    write_frontier.elements()
549                ),
550            };
551            self.apply_constraint(id, constraint);
552        }
553
554        // Restore `AsOfBounds` invariant (2).
555        self.propagate_bounds_upstream(BoundType::Upper);
556    }
557
558    /// Apply as-of constraints to ensure indexes contain historical data as requested by their
559    /// associated read policies.
560    ///
561    /// An index's as-of _should_ be <= the frontier determined by its read policy applied to its
562    /// write frontier.
563    ///
564    /// Failing to apply this constraint is not an error. The affected index will not contain
565    /// historical times for its entire compaction window initially, but will do so once sufficient
566    /// time has passed.
567    fn apply_index_read_policy_constraints(&self) {
568        // For the write frontier of an index, we'll use the least write frontier of its
569        // (transitive) storage inputs. This is an upper bound for the write frontier the index
570        // could have had before the restart. For indexes without storage inputs we use the current
571        // time.
572
573        // Collect write frontiers from storage inputs.
574        let mut write_frontiers = BTreeMap::new();
575        for (id, collection) in &self.collections {
576            let storage_frontiers = self
577                .storage_collections
578                .collections_frontiers(collection.storage_inputs.clone())
579                .expect("storage collections exist");
580
581            let mut write_frontier = Antichain::new();
582            for frontiers in storage_frontiers {
583                write_frontier.extend(frontiers.write_frontier);
584            }
585
586            write_frontiers.insert(*id, write_frontier);
587        }
588
589        // Propagate write frontiers through compute inputs.
590        fixpoint(|changed| {
591            for (id, collection) in &self.collections {
592                let mut write_frontier = write_frontiers.remove(id).expect("inserted above");
593                for input_id in &collection.compute_inputs {
594                    let frontier = &write_frontiers[input_id];
595                    *changed |= write_frontier.extend(frontier.iter().cloned());
596                }
597                write_frontiers.insert(*id, write_frontier);
598            }
599        });
600
601        // Apply the read policy constraint to indexes.
602        for (id, collection) in &self.collections {
603            if let (true, Some(read_policy)) = (collection.is_index, &collection.read_policy) {
604                let mut write_frontier = write_frontiers.remove(id).expect("inserted above");
605                if write_frontier.is_empty() {
606                    write_frontier = Antichain::from_elem(self.current_time.clone());
607                }
608                let upper = read_policy.frontier(write_frontier.borrow());
609                let constraint = Constraint {
610                    type_: ConstraintType::Soft,
611                    bound_type: BoundType::Upper,
612                    frontier: &upper,
613                    reason: &format!(
614                        "read policy applied to write frontier {:?}",
615                        write_frontier.elements()
616                    ),
617                };
618                self.apply_constraint(*id, constraint);
619            }
620        }
621
622        // Restore `AsOfBounds` invariant (2).
623        self.propagate_bounds_upstream(BoundType::Upper);
624    }
625
626    /// Apply as-of constraints to ensure indexes are immediately readable.
627    ///
628    /// An index's as-of _should_ be <= the current time.
629    ///
630    /// Failing to apply this constraint is not an error. The affected index will not be readable
631    /// immediately, but will be readable once sufficient time has passed.
632    fn apply_index_current_time_constraints(&self) {
633        // Apply the current time constraint to indexes.
634        let upper = Antichain::from_elem(self.current_time.clone());
635        for (id, collection) in &self.collections {
636            if collection.is_index {
637                let constraint = Constraint {
638                    type_: ConstraintType::Soft,
639                    bound_type: BoundType::Upper,
640                    frontier: &upper,
641                    reason: "index current time",
642                };
643                self.apply_constraint(*id, constraint);
644            }
645        }
646
647        // Restore `AsOfBounds` invariant (2).
648        self.propagate_bounds_upstream(BoundType::Upper);
649    }
650
651    /// Propagate as-of bounds through the dependency graph, in downstream direction.
652    fn propagate_bounds_downstream(&self, bound_type: BoundType) {
653        // Propagating `lower` bounds downstream restores `AsOfBounds` invariant (1) and must
654        // therefore always succeed.
655        let constraint_type = match bound_type {
656            BoundType::Lower => ConstraintType::Hard,
657            BoundType::Upper => ConstraintType::Soft,
658        };
659
660        // We don't want to rely on a correspondence between `GlobalId` order and dependency order,
661        // so we use a fixpoint loop here.
662        fixpoint(|changed| {
663            self.propagate_bounds_downstream_inner(bound_type, constraint_type, changed);
664
665            // Propagating `upper` bounds downstream might break `AsOfBounds` invariant (2), so we
666            // need to restore it.
667            if bound_type == BoundType::Upper {
668                self.propagate_bounds_upstream_inner(
669                    BoundType::Upper,
670                    ConstraintType::Hard,
671                    changed,
672                );
673            }
674        });
675    }
676
677    fn propagate_bounds_downstream_inner(
678        &self,
679        bound_type: BoundType,
680        constraint_type: ConstraintType,
681        changed: &mut bool,
682    ) {
683        for (id, collection) in &self.collections {
684            for input_id in &collection.compute_inputs {
685                let input_collection = self.expect_collection(*input_id);
686                let bounds = input_collection.bounds.borrow();
687                let constraint = Constraint {
688                    type_: constraint_type,
689                    bound_type,
690                    frontier: bounds.get(bound_type),
691                    reason: &format!("upstream {input_id} {bound_type} as-of bound"),
692                };
693                *changed |= self.apply_constraint(*id, constraint);
694            }
695        }
696    }
697
698    /// Propagate as-of bounds through the dependency graph, in upstream direction.
699    fn propagate_bounds_upstream(&self, bound_type: BoundType) {
700        // Propagating `upper` bounds upstream restores `AsOfBounds` invariant (2) and must
701        // therefore always succeed.
702        let constraint_type = match bound_type {
703            BoundType::Lower => ConstraintType::Soft,
704            BoundType::Upper => ConstraintType::Hard,
705        };
706
707        // We don't want to rely on a correspondence between `GlobalId` order and dependency order,
708        // so we use a fixpoint loop here.
709        fixpoint(|changed| {
710            self.propagate_bounds_upstream_inner(bound_type, constraint_type, changed);
711
712            // Propagating `lower` bounds upstream might break `AsOfBounds` invariant (1), so we
713            // need to restore it.
714            if bound_type == BoundType::Lower {
715                self.propagate_bounds_downstream_inner(
716                    BoundType::Lower,
717                    ConstraintType::Hard,
718                    changed,
719                );
720            }
721        });
722    }
723
724    fn propagate_bounds_upstream_inner(
725        &self,
726        bound_type: BoundType,
727        constraint_type: ConstraintType,
728        changed: &mut bool,
729    ) {
730        for (id, collection) in self.collections.iter().rev() {
731            let bounds = collection.bounds.borrow();
732            for input_id in &collection.compute_inputs {
733                let constraint = Constraint {
734                    type_: constraint_type,
735                    bound_type,
736                    frontier: bounds.get(bound_type),
737                    reason: &format!("downstream {id} {bound_type} as-of bound"),
738                };
739                *changed |= self.apply_constraint(*input_id, constraint);
740            }
741        }
742    }
743
744    /// Selects the "best" as-of for the identified collection, based on its currently known
745    /// bounds.
746    ///
747    /// We simply use the upper bound here, to maximize the chances of compute reconciliation
748    /// succeeding. Choosing the latest possible as-of also minimizes the amount of work the
749    /// dataflow has to spend processing historical data from its sources.
750    fn best_as_of(&self, id: GlobalId) -> Antichain<Timestamp> {
751        if let Some(collection) = self.collections.get(&id) {
752            let bounds = collection.bounds.borrow();
753            bounds.upper.clone()
754        } else {
755            Antichain::new()
756        }
757    }
758
759    /// Removes collections that sink into sealed persist shards from the context.
760    ///
761    /// The dataflows of these collections will get an empty default as-of assigned at the end of
762    /// the as-of selection process, ensuring that they won't get installed unnecessarily.
763    ///
764    /// Note that it is valid to remove these collections from consideration because they don't
765    /// impose as-of constraints on other compute collections.
766    fn prune_sealed_persist_sinks(&mut self) {
767        self.collections.retain(|id, _| {
768            self.storage_collections
769                .collection_frontiers(*id)
770                .map_or(true, |f| !f.write_frontier.is_empty())
771        });
772    }
773
774    /// Removes collections depending on storage collections with empty read frontiers.
775    ///
776    /// The dataflows of these collections will get an empty default as-of assigned at the end of
777    /// the as-of selection process, ensuring that they won't get installed.
778    ///
779    /// This exists only to work around database-issues#8836.
780    fn prune_dropped_collections(&mut self) {
781        // Remove collections with dropped storage inputs.
782        let mut pruned = BTreeSet::new();
783        self.collections.retain(|id, c| {
784            let input_dropped = c.storage_inputs.iter().any(|id| {
785                let frontiers = self
786                    .storage_collections
787                    .collection_frontiers(*id)
788                    .expect("storage collection exists");
789                frontiers.read_capabilities.is_empty()
790            });
791
792            if input_dropped {
793                pruned.insert(*id);
794                false
795            } else {
796                true
797            }
798        });
799
800        warn!(?pruned, "pruned dependants of dropped storage collections");
801
802        // Remove (transitive) dependants of pruned collections.
803        while !pruned.is_empty() {
804            let pruned_inputs = std::mem::take(&mut pruned);
805
806            self.collections.retain(|id, c| {
807                if c.compute_inputs.iter().any(|id| pruned_inputs.contains(id)) {
808                    pruned.insert(*id);
809                    false
810                } else {
811                    true
812                }
813            });
814
815            warn!(?pruned, "pruned collections with pruned inputs");
816        }
817    }
818}
819
820/// Runs `step` in a loop until it stops reporting changes.
821fn fixpoint(mut step: impl FnMut(&mut bool)) {
822    loop {
823        let mut changed = false;
824        step(&mut changed);
825        if !changed {
826            break;
827        }
828    }
829}
830
831/// Step back the given frontier.
832///
833/// This method is saturating: If the frontier contains `T::minimum()` times, these are kept
834/// unchanged.
835fn step_back_frontier(frontier: &Antichain<Timestamp>) -> Antichain<Timestamp> {
836    frontier
837        .iter()
838        .map(|t| t.step_back().unwrap_or(Timestamp::MIN))
839        .collect()
840}
841
842#[cfg(test)]
843mod tests {
844    use std::collections::BTreeSet;
845
846    use async_trait::async_trait;
847    use futures::future::BoxFuture;
848    use futures::stream::BoxStream;
849    use mz_compute_types::dataflows::{DataflowDescription, IndexDesc, IndexImport};
850    use mz_compute_types::sinks::ComputeSinkConnection;
851    use mz_compute_types::sinks::ComputeSinkDesc;
852    use mz_compute_types::sinks::MaterializedViewSinkConnection;
853    use mz_compute_types::sources::SourceInstanceArguments;
854    use mz_compute_types::sources::SourceInstanceDesc;
855    use mz_persist_client::stats::{SnapshotPartsStats, SnapshotStats};
856    use mz_persist_types::ShardId;
857    use mz_repr::{RelationDesc, RelationVersion, Row, SqlRelationType};
858    use mz_repr::{ReprRelationType, Timestamp};
859    use mz_storage_client::client::TimestamplessUpdateBuilder;
860    use mz_storage_client::controller::{CollectionDescription, StorageMetadata, StorageTxn};
861    use mz_storage_client::storage_collections::{CollectionFrontiers, SnapshotCursor};
862    use mz_storage_types::StorageDiff;
863    use mz_storage_types::controller::{CollectionMetadata, StorageError};
864    use mz_storage_types::errors::CollectionMissing;
865    use mz_storage_types::parameters::StorageParameters;
866    use mz_storage_types::sources::SourceData;
867    use mz_storage_types::time_dependence::{TimeDependence, TimeDependenceError};
868
869    use super::*;
870
871    const SEALED: u64 = 0x5ea1ed;
872
873    fn ts_to_frontier(ts: u64) -> Antichain<Timestamp> {
874        if ts == SEALED {
875            Antichain::new()
876        } else {
877            Antichain::from_elem(ts.into())
878        }
879    }
880
881    #[derive(Debug)]
882    struct StorageFrontiers(BTreeMap<GlobalId, (Antichain<Timestamp>, Antichain<Timestamp>)>);
883
884    #[async_trait]
885    impl StorageCollections for StorageFrontiers {
886        async fn initialize_state(
887            &self,
888            _txn: &mut (dyn StorageTxn + Send),
889            _init_ids: BTreeSet<GlobalId>,
890        ) -> Result<(), StorageError> {
891            unimplemented!()
892        }
893
894        fn update_parameters(&self, _config_params: StorageParameters) {
895            unimplemented!()
896        }
897
898        fn collection_metadata(
899            &self,
900            _id: GlobalId,
901        ) -> Result<CollectionMetadata, CollectionMissing> {
902            unimplemented!()
903        }
904
905        fn active_collection_metadatas(&self) -> Vec<(GlobalId, CollectionMetadata)> {
906            unimplemented!()
907        }
908
909        fn collections_frontiers(
910            &self,
911            ids: Vec<GlobalId>,
912        ) -> Result<Vec<CollectionFrontiers>, CollectionMissing> {
913            let mut frontiers = Vec::with_capacity(ids.len());
914            for id in ids {
915                let (read, write) = self.0.get(&id).ok_or(CollectionMissing(id))?;
916                frontiers.push(CollectionFrontiers {
917                    id,
918                    write_frontier: write.clone(),
919                    implied_capability: read.clone(),
920                    read_capabilities: read.clone(),
921                })
922            }
923            Ok(frontiers)
924        }
925
926        fn active_collection_frontiers(&self) -> Vec<CollectionFrontiers> {
927            unimplemented!()
928        }
929
930        fn check_exists(&self, _id: GlobalId) -> Result<(), StorageError> {
931            unimplemented!()
932        }
933
934        async fn snapshot_stats(
935            &self,
936            _id: GlobalId,
937            _as_of: Antichain<Timestamp>,
938        ) -> Result<SnapshotStats, StorageError> {
939            unimplemented!()
940        }
941
942        async fn snapshot_parts_stats(
943            &self,
944            _id: GlobalId,
945            _as_of: Antichain<Timestamp>,
946        ) -> BoxFuture<'static, Result<SnapshotPartsStats, StorageError>> {
947            unimplemented!()
948        }
949
950        fn snapshot(
951            &self,
952            _id: GlobalId,
953            _as_of: Timestamp,
954        ) -> BoxFuture<'static, Result<Vec<(Row, StorageDiff)>, StorageError>> {
955            unimplemented!()
956        }
957
958        async fn snapshot_latest(&self, _id: GlobalId) -> Result<Vec<Row>, StorageError> {
959            unimplemented!()
960        }
961
962        fn snapshot_cursor(
963            &self,
964            _id: GlobalId,
965            _as_of: Timestamp,
966        ) -> BoxFuture<'static, Result<SnapshotCursor, StorageError>> {
967            unimplemented!()
968        }
969
970        fn snapshot_and_stream(
971            &self,
972            _id: GlobalId,
973            _as_of: Timestamp,
974        ) -> BoxFuture<
975            'static,
976            Result<BoxStream<'static, (SourceData, Timestamp, StorageDiff)>, StorageError>,
977        > {
978            unimplemented!()
979        }
980
981        fn create_update_builder(
982            &self,
983            _id: GlobalId,
984        ) -> BoxFuture<
985            'static,
986            Result<TimestamplessUpdateBuilder<SourceData, (), StorageDiff>, StorageError>,
987        > {
988            unimplemented!()
989        }
990
991        async fn prepare_state(
992            &self,
993            _txn: &mut (dyn StorageTxn + Send),
994            _ids_to_add: BTreeSet<GlobalId>,
995            _ids_to_drop: BTreeSet<GlobalId>,
996            _ids_to_register: BTreeMap<GlobalId, ShardId>,
997        ) -> Result<(), StorageError> {
998            unimplemented!()
999        }
1000
1001        async fn create_collections_for_bootstrap(
1002            &self,
1003            _storage_metadata: &StorageMetadata,
1004            _register_ts: Option<Timestamp>,
1005            _collections: Vec<(GlobalId, CollectionDescription)>,
1006            _migrated_storage_collections: &BTreeSet<GlobalId>,
1007        ) -> Result<(), StorageError> {
1008            unimplemented!()
1009        }
1010
1011        async fn alter_table_desc(
1012            &self,
1013            _existing_collection: GlobalId,
1014            _new_collection: GlobalId,
1015            _new_desc: RelationDesc,
1016            _expected_version: RelationVersion,
1017        ) -> Result<(), StorageError> {
1018            unimplemented!()
1019        }
1020
1021        fn drop_collections_unvalidated(
1022            &self,
1023            _storage_metadata: &StorageMetadata,
1024            _identifiers: Vec<GlobalId>,
1025        ) {
1026            unimplemented!()
1027        }
1028
1029        fn set_read_policies(&self, _policies: Vec<(GlobalId, ReadPolicy)>) {
1030            unimplemented!()
1031        }
1032
1033        fn acquire_read_holds(
1034            &self,
1035            desired_holds: Vec<GlobalId>,
1036        ) -> Result<Vec<ReadHold>, CollectionMissing> {
1037            let mut holds = Vec::with_capacity(desired_holds.len());
1038            for id in desired_holds {
1039                let (read, _write) = self.0.get(&id).ok_or(CollectionMissing(id))?;
1040                let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1041                holds.push(ReadHold::with_channel(id, read.clone(), tx));
1042            }
1043            Ok(holds)
1044        }
1045
1046        fn determine_time_dependence(
1047            &self,
1048            _id: GlobalId,
1049        ) -> Result<Option<TimeDependence>, TimeDependenceError> {
1050            unimplemented!()
1051        }
1052
1053        fn dump(&self) -> Result<serde_json::Value, anyhow::Error> {
1054            unimplemented!()
1055        }
1056    }
1057
1058    fn dataflow(
1059        export_id: &str,
1060        input_ids: &[&str],
1061        storage_ids: &BTreeSet<&str>,
1062    ) -> LirDataflowDescription {
1063        let source_imports = input_ids
1064            .iter()
1065            .filter(|s| storage_ids.contains(*s))
1066            .map(|s| {
1067                let id = s.parse().unwrap();
1068                let desc = SourceInstanceDesc {
1069                    arguments: SourceInstanceArguments {
1070                        operators: Default::default(),
1071                    },
1072                    storage_metadata: Default::default(),
1073                    typ: SqlRelationType::empty(),
1074                };
1075                (
1076                    id,
1077                    mz_compute_types::dataflows::SourceImport {
1078                        desc,
1079                        monotonic: Default::default(),
1080                        with_snapshot: true,
1081                        upper: Default::default(),
1082                    },
1083                )
1084            })
1085            .collect();
1086        let index_imports = input_ids
1087            .iter()
1088            .filter(|s| !storage_ids.contains(*s))
1089            .map(|s| {
1090                let id = s.parse().unwrap();
1091                let import = IndexImport {
1092                    desc: IndexDesc {
1093                        on_id: GlobalId::Transient(0),
1094                        key: Default::default(),
1095                    },
1096                    typ: ReprRelationType::empty(),
1097                    monotonic: Default::default(),
1098                    with_snapshot: true,
1099                };
1100                (id, import)
1101            })
1102            .collect();
1103        let index_exports = std::iter::once(export_id)
1104            .filter(|s| !storage_ids.contains(*s))
1105            .map(|sid| {
1106                let id = sid.parse().unwrap();
1107                let desc = IndexDesc {
1108                    on_id: GlobalId::Transient(0),
1109                    key: Default::default(),
1110                };
1111                let typ = ReprRelationType::empty();
1112                (id, (desc, typ))
1113            })
1114            .collect();
1115        let sink_exports = std::iter::once(export_id)
1116            .filter(|s| storage_ids.contains(*s))
1117            .map(|sid| {
1118                let id = sid.parse().unwrap();
1119                let desc = ComputeSinkDesc {
1120                    from: GlobalId::Transient(0),
1121                    from_desc: RelationDesc::empty(),
1122                    connection: ComputeSinkConnection::MaterializedView(
1123                        MaterializedViewSinkConnection {
1124                            value_desc: RelationDesc::empty(),
1125                            storage_metadata: Default::default(),
1126                        },
1127                    ),
1128                    with_snapshot: Default::default(),
1129                    up_to: Default::default(),
1130                    non_null_assertions: Default::default(),
1131                    refresh_schedule: Default::default(),
1132                    from_arrangement: None,
1133                };
1134                (id, desc)
1135            })
1136            .collect();
1137
1138        DataflowDescription {
1139            source_imports,
1140            index_imports,
1141            objects_to_build: Default::default(),
1142            index_exports,
1143            sink_exports,
1144            as_of: None,
1145            until: Default::default(),
1146            initial_storage_as_of: Default::default(),
1147            refresh_schedule: Default::default(),
1148            debug_name: Default::default(),
1149            time_dependence: None,
1150        }
1151    }
1152
1153    macro_rules! testcase {
1154        ($name:ident, {
1155            storage: { $( $storage_id:literal: ($read:expr, $write:expr), )* },
1156            dataflows: [ $( $export_id:literal <- $inputs:expr => $as_of:expr, )* ],
1157            current_time: $current_time:literal,
1158            $( read_policies: { $( $policy_id:literal: $policy:expr, )* }, )?
1159            $( read_only: $read_only:expr, )?
1160        }) => {
1161            #[mz_ore::test]
1162            fn $name() {
1163                let storage_ids = [$( $storage_id, )*].into();
1164
1165                let storage_frontiers = StorageFrontiers(BTreeMap::from([
1166                    $(
1167                        (
1168                            $storage_id.parse().unwrap(),
1169                            (ts_to_frontier($read), ts_to_frontier($write)),
1170                        ),
1171                    )*
1172                ]));
1173
1174                let mut dataflows = [
1175                    $(
1176                        dataflow($export_id, &$inputs, &storage_ids),
1177                    )*
1178                ];
1179
1180                let read_policies = BTreeMap::from([
1181                    $($( ($policy_id.parse().unwrap(), $policy), )*)?
1182                ]);
1183
1184                #[allow(unused_variables)]
1185                let read_only = false;
1186                $( let read_only = $read_only; )?
1187
1188                super::run(
1189                    &mut dataflows,
1190                    &read_policies,
1191                    &storage_frontiers,
1192                    $current_time.into(),
1193                    read_only,
1194                );
1195
1196                let actual_as_ofs: Vec<_> = dataflows
1197                    .into_iter()
1198                    .map(|d| d.as_of.unwrap())
1199                    .collect();
1200                let expected_as_ofs = [ $( ts_to_frontier($as_of), )* ];
1201
1202                assert_eq!(actual_as_ofs, expected_as_ofs);
1203            }
1204        };
1205    }
1206
1207    testcase!(upstream_storage_constraints, {
1208        storage: {
1209            "s1": (10, 20),
1210            "s2": (20, 30),
1211        },
1212        dataflows: [
1213            "u1" <- ["s1"]       => 10,
1214            "u2" <- ["s2"]       => 20,
1215            "u3" <- ["s1", "s2"] => 20,
1216            "u4" <- ["u1", "u2"] => 20,
1217        ],
1218        current_time: 0,
1219    });
1220
1221    testcase!(downstream_storage_constraints, {
1222        storage: {
1223            "s1": (10, 20),
1224            "u3": (10, 15),
1225            "u4": (10, 13),
1226        },
1227        dataflows: [
1228            "u1" <- ["s1"] => 19,
1229            "u2" <- ["s1"] => 12,
1230            "u3" <- ["u2"] => 14,
1231            "u4" <- ["u2"] => 12,
1232        ],
1233        current_time: 100,
1234    });
1235
1236    testcase!(warmup_constraints, {
1237        storage: {
1238            "s1": (10, 20),
1239            "s2": (10, 30),
1240            "s3": (10, 40),
1241            "s4": (10, 50),
1242        },
1243        dataflows: [
1244            "u1" <- ["s1"]       => 19,
1245            "u2" <- ["s2"]       => 19,
1246            "u3" <- ["s3"]       => 39,
1247            "u4" <- ["s4"]       => 39,
1248            "u5" <- ["u1", "u2"] => 19,
1249            "u6" <- ["u3", "u4"] => 39,
1250        ],
1251        current_time: 100,
1252    });
1253
1254    testcase!(index_read_policy_constraints, {
1255        storage: {
1256            "s1": (10, 20),
1257            "u6": (10, 18),
1258        },
1259        dataflows: [
1260            "u1" <- ["s1"] => 15,
1261            "u2" <- ["s1"] => 10,
1262            "u3" <- ["s1"] => 13,
1263            "u4" <- ["s1"] => 10,
1264            "u5" <- []     => 95,
1265            "u6" <- ["s1"] => 17,
1266        ],
1267        current_time: 100,
1268        read_policies: {
1269            "u1": ReadPolicy::lag_writes_by(5.into(), 1.into()),
1270            "u2": ReadPolicy::lag_writes_by(15.into(), 1.into()),
1271            "u3": ReadPolicy::ValidFrom(Antichain::from_elem(13.into())),
1272            "u4": ReadPolicy::ValidFrom(Antichain::from_elem(5.into())),
1273            "u5": ReadPolicy::lag_writes_by(5.into(), 1.into()),
1274            "u6": ReadPolicy::ValidFrom(Antichain::from_elem(13.into())),
1275        },
1276    });
1277
1278    testcase!(index_current_time_constraints, {
1279        storage: {
1280            "s1": (10, 20),
1281            "s2": (20, 30),
1282            "u4": (10, 12),
1283            "u5": (10, 18),
1284        },
1285        dataflows: [
1286            "u1" <- ["s1"] => 15,
1287            "u2" <- ["s2"] => 20,
1288            "u3" <- ["s1"] => 11,
1289            "u4" <- ["u3"] => 11,
1290            "u5" <- ["s1"] => 17,
1291            "u6" <- []     => 15,
1292        ],
1293        current_time: 15,
1294    });
1295
1296    testcase!(sealed_storage_sink, {
1297        storage: {
1298            "s1": (10, 20),
1299            "u1": (10, SEALED),
1300        },
1301        dataflows: [
1302            "u1" <- ["s1"] => SEALED,
1303        ],
1304        current_time: 100,
1305    });
1306
1307    testcase!(read_only_dropped_storage_inputs, {
1308        storage: {
1309            "s1": (10, 20),
1310            "s2": (SEALED, SEALED),
1311            "u4": (10, 20),
1312        },
1313        dataflows: [
1314            "u1" <- ["s1"] => 15,
1315            "u2" <- ["s2"] => SEALED,
1316            "u3" <- ["s1", "s2"] => SEALED,
1317            "u4" <- ["u2"] => SEALED,
1318        ],
1319        current_time: 15,
1320        read_only: true,
1321    });
1322
1323    // Regression test for database-issues#9273.
1324    testcase!(github_9273, {
1325        storage: {
1326            "s1": (10, 20),
1327            "u3": (14, 15),
1328        },
1329        dataflows: [
1330            "u1" <- ["s1"] => 14,
1331            "u2" <- ["u1"] => 19,
1332            "u3" <- ["u1"] => 14,
1333        ],
1334        current_time: 100,
1335        read_policies: {
1336            "u1": ReadPolicy::lag_writes_by(1.into(), 1.into()),
1337            "u2": ReadPolicy::lag_writes_by(1.into(), 1.into()),
1338        },
1339    });
1340}