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