Skip to main content

mz_persist_client/internal/
state.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
10use anyhow::ensure;
11use async_stream::{stream, try_stream};
12use differential_dataflow::difference::Monoid;
13use mz_persist::metrics::ColumnarMetrics;
14use proptest::prelude::{Arbitrary, Strategy};
15use std::borrow::Cow;
16use std::cmp::Ordering;
17use std::collections::BTreeMap;
18use std::fmt::{Debug, Formatter};
19use std::marker::PhantomData;
20use std::ops::ControlFlow::{self, Break, Continue};
21use std::ops::{Deref, DerefMut};
22use std::time::Duration;
23
24use arrow::array::{Array, ArrayData, make_array};
25use arrow::datatypes::DataType;
26use bytes::Bytes;
27use differential_dataflow::Hashable;
28use differential_dataflow::lattice::Lattice;
29use differential_dataflow::trace::Description;
30use differential_dataflow::trace::implementations::BatchContainer;
31use futures::Stream;
32use futures_util::StreamExt;
33use itertools::Itertools;
34use mz_dyncfg::{Config, ParameterScope};
35use mz_ore::cast::CastFrom;
36use mz_ore::now::EpochMillis;
37use mz_ore::soft_panic_or_log;
38use mz_ore::vec::PartialOrdVecExt;
39use mz_persist::indexed::encoding::{BatchColumnarFormat, BlobTraceUpdates};
40use mz_persist::location::{Blob, SeqNo};
41use mz_persist_types::arrow::{ArrayBound, ProtoArrayData};
42use mz_persist_types::columnar::{ColumnEncoder, Schema};
43use mz_persist_types::schema::{SchemaId, backward_compatible};
44use mz_persist_types::{Codec, Codec64};
45use mz_proto::ProtoType;
46use mz_proto::RustType;
47use proptest_derive::Arbitrary;
48use semver::Version;
49use serde::ser::SerializeStruct;
50use serde::{Serialize, Serializer};
51use timely::PartialOrder;
52use timely::order::TotalOrder;
53use timely::progress::{Antichain, Timestamp};
54use tracing::info;
55use uuid::Uuid;
56
57use crate::critical::{CriticalReaderId, Opaque};
58use crate::error::InvalidUsage;
59use crate::internal::encoding::{
60    LazyInlineBatchPart, LazyPartStats, LazyProto, MetadataKey, MetadataMap, parse_id,
61};
62use crate::internal::gc::GcReq;
63use crate::internal::machine::retry_external;
64use crate::internal::paths::{BlobKey, PartId, PartialBatchKey, PartialRollupKey, WriterKey};
65use crate::internal::trace::{
66    ActiveCompaction, ApplyMergeResult, FueledMergeReq, FueledMergeRes, Trace,
67};
68use crate::metrics::Metrics;
69use crate::read::LeasedReaderId;
70use crate::schema::CaESchema;
71use crate::write::WriterId;
72use crate::{PersistConfig, ShardId};
73
74include!(concat!(
75    env!("OUT_DIR"),
76    "/mz_persist_client.internal.state.rs"
77));
78
79include!(concat!(
80    env!("OUT_DIR"),
81    "/mz_persist_client.internal.diff.rs"
82));
83
84/// Determines how often to write rollups, assigning a maintenance task after
85/// `rollup_threshold` seqnos have passed since the last rollup.
86///
87/// Tuning note: in the absence of a long reader seqno hold, and with
88/// incremental GC, this threshold will determine about how many live diffs are
89/// held in Consensus. Lowering this value decreases the live diff count at the
90/// cost of more maintenance work + blob writes.
91pub(crate) const ROLLUP_THRESHOLD: Config<usize> = Config::new(
92    "persist_rollup_threshold",
93    128,
94    "The number of seqnos between rollups.",
95    ParameterScope::Environment,
96);
97
98/// Determines how long to wait before an active rollup is considered
99/// "stuck" and a new rollup is started.
100pub(crate) const ROLLUP_FALLBACK_THRESHOLD_MS: Config<usize> = Config::new(
101    "persist_rollup_fallback_threshold_ms",
102    5000,
103    "The number of milliseconds before a worker claims an already claimed rollup.",
104    ParameterScope::Environment,
105);
106
107/// Feature flag the new active rollup tracking mechanism.
108/// We musn't enable this until we are fully deployed on the new version.
109pub(crate) const ROLLUP_USE_ACTIVE_ROLLUP: Config<bool> = Config::new(
110    "persist_rollup_use_active_rollup",
111    true,
112    "Whether to use the new active rollup tracking mechanism.",
113    ParameterScope::Environment,
114);
115
116/// Determines how long to wait before an active GC is considered
117/// "stuck" and a new GC is started.
118pub(crate) const GC_FALLBACK_THRESHOLD_MS: Config<usize> = Config::new(
119    "persist_gc_fallback_threshold_ms",
120    900000,
121    "The number of milliseconds before a worker claims an already claimed GC.",
122    ParameterScope::Environment,
123);
124
125/// See the config description string.
126pub(crate) const GC_MIN_VERSIONS: Config<usize> = Config::new(
127    "persist_gc_min_versions",
128    32,
129    "The number of un-GCd versions that may exist in state before we'll trigger a GC.",
130    ParameterScope::Environment,
131);
132
133/// See the config description string.
134pub(crate) const GC_MAX_VERSIONS: Config<usize> = Config::new(
135    "persist_gc_max_versions",
136    128_000,
137    "The maximum number of versions to GC in a single GC run.",
138    ParameterScope::Environment,
139);
140
141/// Feature flag the new active GC tracking mechanism.
142/// We musn't enable this until we are fully deployed on the new version.
143pub(crate) const GC_USE_ACTIVE_GC: Config<bool> = Config::new(
144    "persist_gc_use_active_gc",
145    false,
146    "Whether to use the new active GC tracking mechanism.",
147    ParameterScope::Environment,
148);
149
150pub(crate) const ENABLE_INCREMENTAL_COMPACTION: Config<bool> = Config::new(
151    "persist_enable_incremental_compaction",
152    true,
153    "Whether to enable incremental compaction.",
154    ParameterScope::Environment,
155);
156
157/// A token to disambiguate state commands that could not otherwise be
158/// idempotent.
159#[derive(Arbitrary, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
160#[serde(into = "String")]
161pub struct IdempotencyToken(pub(crate) [u8; 16]);
162
163impl std::fmt::Display for IdempotencyToken {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        write!(f, "i{}", Uuid::from_bytes(self.0))
166    }
167}
168
169impl std::fmt::Debug for IdempotencyToken {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        write!(f, "IdempotencyToken({})", Uuid::from_bytes(self.0))
172    }
173}
174
175impl std::str::FromStr for IdempotencyToken {
176    type Err = String;
177
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        parse_id("i", "IdempotencyToken", s).map(IdempotencyToken)
180    }
181}
182
183impl From<IdempotencyToken> for String {
184    fn from(x: IdempotencyToken) -> Self {
185        x.to_string()
186    }
187}
188
189impl IdempotencyToken {
190    pub(crate) fn new() -> Self {
191        IdempotencyToken(*Uuid::new_v4().as_bytes())
192    }
193    pub(crate) const SENTINEL: IdempotencyToken = IdempotencyToken([17u8; 16]);
194}
195
196#[derive(Clone, Debug, PartialEq, Serialize)]
197pub struct LeasedReaderState<T> {
198    /// The seqno capability of this reader.
199    pub seqno: SeqNo,
200    /// The since capability of this reader.
201    pub since: Antichain<T>,
202    /// UNIX_EPOCH timestamp (in millis) of this reader's most recent heartbeat
203    pub last_heartbeat_timestamp_ms: u64,
204    /// Duration (in millis) allowed after [Self::last_heartbeat_timestamp_ms]
205    /// after which this reader may be expired
206    pub lease_duration_ms: u64,
207    /// For debugging.
208    pub debug: HandleDebugState,
209}
210
211#[derive(Clone, Debug, PartialEq, Serialize)]
212pub struct CriticalReaderState<T> {
213    /// The since capability of this reader.
214    pub since: Antichain<T>,
215    /// An opaque token matched on by compare_and_downgrade_since.
216    pub opaque: Opaque,
217    /// For debugging.
218    pub debug: HandleDebugState,
219}
220
221#[derive(Clone, Debug, PartialEq, Serialize)]
222pub struct WriterState<T> {
223    /// UNIX_EPOCH timestamp (in millis) of this writer's most recent heartbeat
224    pub last_heartbeat_timestamp_ms: u64,
225    /// Duration (in millis) allowed after [Self::last_heartbeat_timestamp_ms]
226    /// after which this writer may be expired
227    pub lease_duration_ms: u64,
228    /// The idempotency token of the most recent successful compare_and_append
229    /// by this writer.
230    pub most_recent_write_token: IdempotencyToken,
231    /// The upper of the most recent successful compare_and_append by this
232    /// writer.
233    pub most_recent_write_upper: Antichain<T>,
234    /// For debugging.
235    pub debug: HandleDebugState,
236}
237
238/// Debugging info for a reader or writer.
239#[derive(Arbitrary, Clone, Debug, Default, PartialEq, Serialize)]
240pub struct HandleDebugState {
241    /// Hostname of the persist user that registered this writer or reader. For
242    /// critical readers, this is the _most recent_ registration.
243    pub hostname: String,
244    /// Plaintext description of this writer or reader's intent.
245    pub purpose: String,
246}
247
248/// Part of the updates in a Batch.
249///
250/// Either a pointer to ones stored in Blob or the updates themselves inlined.
251#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
252#[serde(tag = "type")]
253pub enum BatchPart<T> {
254    Hollow(HollowBatchPart<T>),
255    Inline {
256        updates: LazyInlineBatchPart,
257        ts_rewrite: Option<Antichain<T>>,
258        schema_id: Option<SchemaId>,
259
260        /// ID of a schema that has since been deprecated and exists only to cleanly roundtrip.
261        deprecated_schema_id: Option<SchemaId>,
262    },
263}
264
265fn decode_structured_lower(lower: &LazyProto<ProtoArrayData>) -> Option<ArrayBound> {
266    let try_decode = |lower: &LazyProto<ProtoArrayData>| {
267        let proto = lower.decode()?;
268        let data = ArrayData::from_proto(proto)?;
269        ensure!(data.len() == 1);
270        Ok(ArrayBound::new(make_array(data), 0))
271    };
272
273    let decoded: anyhow::Result<ArrayBound> = try_decode(lower);
274
275    match decoded {
276        Ok(bound) => Some(bound),
277        Err(e) => {
278            soft_panic_or_log!("failed to decode bound: {e:#?}");
279            None
280        }
281    }
282}
283
284impl<T> BatchPart<T> {
285    pub fn hollow_bytes(&self) -> usize {
286        match self {
287            BatchPart::Hollow(x) => x.encoded_size_bytes,
288            BatchPart::Inline { .. } => 0,
289        }
290    }
291
292    pub fn is_inline(&self) -> bool {
293        matches!(self, BatchPart::Inline { .. })
294    }
295
296    pub fn inline_bytes(&self) -> usize {
297        match self {
298            BatchPart::Hollow(_) => 0,
299            BatchPart::Inline { updates, .. } => updates.encoded_size_bytes(),
300        }
301    }
302
303    pub fn writer_key(&self) -> Option<WriterKey> {
304        match self {
305            BatchPart::Hollow(x) => x.key.split().map(|(writer, _part)| writer),
306            BatchPart::Inline { .. } => None,
307        }
308    }
309
310    pub fn encoded_size_bytes(&self) -> usize {
311        match self {
312            BatchPart::Hollow(x) => x.encoded_size_bytes,
313            BatchPart::Inline { updates, .. } => updates.encoded_size_bytes(),
314        }
315    }
316
317    // A user-interpretable identifier or description of the part (for logs and
318    // such).
319    pub fn printable_name(&self) -> &str {
320        match self {
321            BatchPart::Hollow(x) => x.key.0.as_str(),
322            BatchPart::Inline { .. } => "<inline>",
323        }
324    }
325
326    pub fn stats(&self) -> Option<&LazyPartStats> {
327        match self {
328            BatchPart::Hollow(x) => x.stats.as_ref(),
329            BatchPart::Inline { .. } => None,
330        }
331    }
332
333    pub fn key_lower(&self) -> &[u8] {
334        match self {
335            BatchPart::Hollow(x) => x.key_lower.as_slice(),
336            // We don't duplicate the lowest key because this can be
337            // considerable overhead for small parts.
338            //
339            // The empty key might not be a tight lower bound, but it is a valid
340            // lower bound. If a caller is interested in a tighter lower bound,
341            // the data is inline.
342            BatchPart::Inline { .. } => &[],
343        }
344    }
345
346    pub fn structured_key_lower(&self) -> Option<ArrayBound> {
347        let part = match self {
348            BatchPart::Hollow(part) => part,
349            BatchPart::Inline { .. } => return None,
350        };
351
352        decode_structured_lower(part.structured_key_lower.as_ref()?)
353    }
354
355    pub fn ts_rewrite(&self) -> Option<&Antichain<T>> {
356        match self {
357            BatchPart::Hollow(x) => x.ts_rewrite.as_ref(),
358            BatchPart::Inline { ts_rewrite, .. } => ts_rewrite.as_ref(),
359        }
360    }
361
362    pub fn schema_id(&self) -> Option<SchemaId> {
363        match self {
364            BatchPart::Hollow(x) => x.schema_id,
365            BatchPart::Inline { schema_id, .. } => *schema_id,
366        }
367    }
368
369    pub fn deprecated_schema_id(&self) -> Option<SchemaId> {
370        match self {
371            BatchPart::Hollow(x) => x.deprecated_schema_id,
372            BatchPart::Inline {
373                deprecated_schema_id,
374                ..
375            } => *deprecated_schema_id,
376        }
377    }
378}
379
380impl<T: Timestamp + Codec64> BatchPart<T> {
381    pub fn is_structured_only(&self, metrics: &ColumnarMetrics) -> bool {
382        match self {
383            BatchPart::Hollow(x) => matches!(x.format, Some(BatchColumnarFormat::Structured)),
384            BatchPart::Inline { updates, .. } => {
385                let inline_part = updates.decode::<T>(metrics).expect("valid inline part");
386                matches!(inline_part.updates, BlobTraceUpdates::Structured { .. })
387            }
388        }
389    }
390
391    pub fn diffs_sum<D: Codec64 + Monoid>(&self, metrics: &ColumnarMetrics) -> Option<D> {
392        match self {
393            BatchPart::Hollow(x) => x.diffs_sum.map(D::decode),
394            BatchPart::Inline { updates, .. } => Some(
395                updates
396                    .decode::<T>(metrics)
397                    .expect("valid inline part")
398                    .updates
399                    .diffs_sum(),
400            ),
401        }
402    }
403}
404
405/// An ordered list of parts, generally stored as part of a larger run.
406#[derive(Debug, Clone)]
407pub struct HollowRun<T> {
408    /// Pointers usable to retrieve the updates.
409    pub(crate) parts: Vec<RunPart<T>>,
410}
411
412/// A reference to a [HollowRun], including the key in the blob store and some denormalized
413/// metadata.
414#[derive(Debug, Eq, PartialEq, Clone, Serialize)]
415pub struct HollowRunRef<T> {
416    pub key: PartialBatchKey,
417
418    /// The size of the referenced run object, plus all of the hollow objects it contains.
419    pub hollow_bytes: usize,
420
421    /// The size of the largest individual part in the run; useful for sizing compaction.
422    pub max_part_bytes: usize,
423
424    /// The lower bound of the data in this part, ordered by the codec ordering.
425    pub key_lower: Vec<u8>,
426
427    /// The lower bound of the data in this part, ordered by the structured ordering.
428    pub structured_key_lower: Option<LazyProto<ProtoArrayData>>,
429
430    pub diffs_sum: Option<[u8; 8]>,
431
432    pub(crate) _phantom_data: PhantomData<T>,
433}
434impl<T: Eq> PartialOrd<Self> for HollowRunRef<T> {
435    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
436        Some(self.cmp(other))
437    }
438}
439
440impl<T: Eq> Ord for HollowRunRef<T> {
441    fn cmp(&self, other: &Self) -> Ordering {
442        self.key.cmp(&other.key)
443    }
444}
445
446impl<T> HollowRunRef<T> {
447    pub fn writer_key(&self) -> Option<WriterKey> {
448        Some(self.key.split()?.0)
449    }
450}
451
452impl<T: Timestamp + Codec64> HollowRunRef<T> {
453    /// Stores the given runs and returns a [HollowRunRef] that points to them.
454    pub async fn set<D: Codec64 + Monoid>(
455        shard_id: ShardId,
456        blob: &dyn Blob,
457        writer: &WriterKey,
458        data: HollowRun<T>,
459        metrics: &Metrics,
460    ) -> Self {
461        let hollow_bytes = data.parts.iter().map(|p| p.hollow_bytes()).sum();
462        let max_part_bytes = data
463            .parts
464            .iter()
465            .map(|p| p.max_part_bytes())
466            .max()
467            .unwrap_or(0);
468        let key_lower = data
469            .parts
470            .first()
471            .map_or(vec![], |p| p.key_lower().to_vec());
472        let structured_key_lower = match data.parts.first() {
473            Some(RunPart::Many(r)) => r.structured_key_lower.clone(),
474            Some(RunPart::Single(BatchPart::Hollow(p))) => p.structured_key_lower.clone(),
475            Some(RunPart::Single(BatchPart::Inline { .. })) | None => None,
476        };
477        let diffs_sum = data
478            .parts
479            .iter()
480            .map(|p| {
481                p.diffs_sum::<D>(&metrics.columnar)
482                    .expect("valid diffs sum")
483            })
484            .reduce(|mut a, b| {
485                a.plus_equals(&b);
486                a
487            })
488            .expect("valid diffs sum")
489            .encode();
490
491        let key = PartialBatchKey::new(writer, &PartId::new());
492        let blob_key = key.complete(&shard_id);
493        let bytes = Bytes::from(prost::Message::encode_to_vec(&data.into_proto()));
494        let () = retry_external(&metrics.retries.external.hollow_run_set, || {
495            blob.set(&blob_key, bytes.clone())
496        })
497        .await;
498        Self {
499            key,
500            hollow_bytes,
501            max_part_bytes,
502            key_lower,
503            structured_key_lower,
504            diffs_sum: Some(diffs_sum),
505            _phantom_data: Default::default(),
506        }
507    }
508
509    /// Retrieve the [HollowRun] that this reference points to.
510    /// The caller is expected to ensure that this ref is the result of calling [HollowRunRef::set]
511    /// with the same shard id and backing store.
512    pub async fn get(
513        &self,
514        shard_id: ShardId,
515        blob: &dyn Blob,
516        metrics: &Metrics,
517    ) -> Option<HollowRun<T>> {
518        let blob_key = self.key.complete(&shard_id);
519        let mut bytes = retry_external(&metrics.retries.external.hollow_run_get, || {
520            blob.get(&blob_key)
521        })
522        .await?;
523        let proto_runs: ProtoHollowRun =
524            prost::Message::decode(&mut bytes).expect("illegal state: invalid proto bytes");
525        let runs = proto_runs
526            .into_rust()
527            .expect("illegal state: invalid encoded runs proto");
528        Some(runs)
529    }
530}
531
532/// Part of the updates in a run.
533///
534/// Either a pointer to ones stored in Blob or a single part stored inline.
535#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
536#[serde(untagged)]
537pub enum RunPart<T> {
538    Single(BatchPart<T>),
539    Many(HollowRunRef<T>),
540}
541
542impl<T: Ord> PartialOrd<Self> for RunPart<T> {
543    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
544        Some(self.cmp(other))
545    }
546}
547
548impl<T: Ord> Ord for RunPart<T> {
549    fn cmp(&self, other: &Self) -> Ordering {
550        match (self, other) {
551            (RunPart::Single(a), RunPart::Single(b)) => a.cmp(b),
552            (RunPart::Single(_), RunPart::Many(_)) => Ordering::Less,
553            (RunPart::Many(_), RunPart::Single(_)) => Ordering::Greater,
554            (RunPart::Many(a), RunPart::Many(b)) => a.cmp(b),
555        }
556    }
557}
558
559impl<T> RunPart<T> {
560    #[cfg(test)]
561    pub fn expect_hollow_part(&self) -> &HollowBatchPart<T> {
562        match self {
563            RunPart::Single(BatchPart::Hollow(hollow)) => hollow,
564            _ => panic!("expected hollow part!"),
565        }
566    }
567
568    pub fn hollow_bytes(&self) -> usize {
569        match self {
570            Self::Single(p) => p.hollow_bytes(),
571            Self::Many(r) => r.hollow_bytes,
572        }
573    }
574
575    pub fn is_inline(&self) -> bool {
576        match self {
577            Self::Single(p) => p.is_inline(),
578            Self::Many(_) => false,
579        }
580    }
581
582    pub fn inline_bytes(&self) -> usize {
583        match self {
584            Self::Single(p) => p.inline_bytes(),
585            Self::Many(_) => 0,
586        }
587    }
588
589    pub fn max_part_bytes(&self) -> usize {
590        match self {
591            Self::Single(p) => p.encoded_size_bytes(),
592            Self::Many(r) => r.max_part_bytes,
593        }
594    }
595
596    pub fn writer_key(&self) -> Option<WriterKey> {
597        match self {
598            Self::Single(p) => p.writer_key(),
599            Self::Many(r) => r.writer_key(),
600        }
601    }
602
603    pub fn encoded_size_bytes(&self) -> usize {
604        match self {
605            Self::Single(p) => p.encoded_size_bytes(),
606            Self::Many(r) => r.hollow_bytes,
607        }
608    }
609
610    pub fn schema_id(&self) -> Option<SchemaId> {
611        match self {
612            Self::Single(p) => p.schema_id(),
613            Self::Many(_) => None,
614        }
615    }
616
617    // A user-interpretable identifier or description of the part (for logs and
618    // such).
619    pub fn printable_name(&self) -> &str {
620        match self {
621            Self::Single(p) => p.printable_name(),
622            Self::Many(r) => r.key.0.as_str(),
623        }
624    }
625
626    pub fn stats(&self) -> Option<&LazyPartStats> {
627        match self {
628            Self::Single(p) => p.stats(),
629            // TODO: if we kept stats we could avoid fetching the metadata here.
630            Self::Many(_) => None,
631        }
632    }
633
634    pub fn key_lower(&self) -> &[u8] {
635        match self {
636            Self::Single(p) => p.key_lower(),
637            Self::Many(r) => r.key_lower.as_slice(),
638        }
639    }
640
641    pub fn structured_key_lower(&self) -> Option<ArrayBound> {
642        match self {
643            Self::Single(p) => p.structured_key_lower(),
644            Self::Many(_) => None,
645        }
646    }
647
648    pub fn ts_rewrite(&self) -> Option<&Antichain<T>> {
649        match self {
650            Self::Single(p) => p.ts_rewrite(),
651            Self::Many(_) => None,
652        }
653    }
654}
655
656impl<T> RunPart<T>
657where
658    T: Timestamp + Codec64,
659{
660    pub fn diffs_sum<D: Codec64 + Monoid>(&self, metrics: &ColumnarMetrics) -> Option<D> {
661        match self {
662            Self::Single(p) => p.diffs_sum(metrics),
663            Self::Many(hollow_run) => hollow_run.diffs_sum.map(D::decode),
664        }
665    }
666}
667
668/// A blob was missing!
669#[derive(Clone, Debug)]
670pub struct MissingBlob(BlobKey);
671
672impl std::fmt::Display for MissingBlob {
673    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
674        write!(f, "unexpectedly missing key: {}", self.0)
675    }
676}
677
678impl std::error::Error for MissingBlob {}
679
680impl<T: Timestamp + Codec64 + Sync> RunPart<T> {
681    pub fn part_stream<'a>(
682        &'a self,
683        shard_id: ShardId,
684        blob: &'a dyn Blob,
685        metrics: &'a Metrics,
686    ) -> impl Stream<Item = Result<Cow<'a, BatchPart<T>>, MissingBlob>> + Send + 'a {
687        try_stream! {
688            match self {
689                RunPart::Single(p) => {
690                    yield Cow::Borrowed(p);
691                }
692                RunPart::Many(r) => {
693                    let fetched = r.get(shard_id, blob, metrics).await
694                        .ok_or_else(|| MissingBlob(r.key.complete(&shard_id)))?;
695                    for run_part in fetched.parts {
696                        for await batch_part in
697                            run_part.part_stream(shard_id, blob, metrics).boxed()
698                        {
699                            yield Cow::Owned(batch_part?.into_owned());
700                        }
701                    }
702                }
703            }
704        }
705    }
706}
707
708impl<T: Ord> PartialOrd for BatchPart<T> {
709    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
710        Some(self.cmp(other))
711    }
712}
713
714impl<T: Ord> Ord for BatchPart<T> {
715    fn cmp(&self, other: &Self) -> Ordering {
716        match (self, other) {
717            (BatchPart::Hollow(s), BatchPart::Hollow(o)) => s.cmp(o),
718            (
719                BatchPart::Inline {
720                    updates: s_updates,
721                    ts_rewrite: s_ts_rewrite,
722                    schema_id: s_schema_id,
723                    deprecated_schema_id: s_deprecated_schema_id,
724                },
725                BatchPart::Inline {
726                    updates: o_updates,
727                    ts_rewrite: o_ts_rewrite,
728                    schema_id: o_schema_id,
729                    deprecated_schema_id: o_deprecated_schema_id,
730                },
731            ) => (
732                s_updates,
733                s_ts_rewrite.as_ref().map(|x| x.elements()),
734                s_schema_id,
735                s_deprecated_schema_id,
736            )
737                .cmp(&(
738                    o_updates,
739                    o_ts_rewrite.as_ref().map(|x| x.elements()),
740                    o_schema_id,
741                    o_deprecated_schema_id,
742                )),
743            (BatchPart::Hollow(_), BatchPart::Inline { .. }) => Ordering::Less,
744            (BatchPart::Inline { .. }, BatchPart::Hollow(_)) => Ordering::Greater,
745        }
746    }
747}
748
749/// What order are the parts in this run in?
750#[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd, Serialize)]
751pub(crate) enum RunOrder {
752    /// They're in no particular order.
753    Unordered,
754    /// They're ordered based on the codec-encoded K/V bytes.
755    Codec,
756    /// They're ordered by the natural ordering of the structured data.
757    Structured,
758}
759
760#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Serialize, Copy, Hash)]
761pub struct RunId(pub(crate) [u8; 16]);
762
763impl std::fmt::Display for RunId {
764    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
765        write!(f, "ri{}", Uuid::from_bytes(self.0))
766    }
767}
768
769impl std::fmt::Debug for RunId {
770    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
771        write!(f, "RunId({})", Uuid::from_bytes(self.0))
772    }
773}
774
775impl std::str::FromStr for RunId {
776    type Err = String;
777
778    fn from_str(s: &str) -> Result<Self, Self::Err> {
779        parse_id("ri", "RunId", s).map(RunId)
780    }
781}
782
783impl From<RunId> for String {
784    fn from(x: RunId) -> Self {
785        x.to_string()
786    }
787}
788
789impl RunId {
790    pub(crate) fn new() -> Self {
791        RunId(*Uuid::new_v4().as_bytes())
792    }
793}
794
795impl Arbitrary for RunId {
796    type Parameters = ();
797    type Strategy = proptest::strategy::BoxedStrategy<Self>;
798    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
799        Strategy::prop_map(proptest::prelude::any::<u128>(), |n| {
800            RunId(*Uuid::from_u128(n).as_bytes())
801        })
802        .boxed()
803    }
804}
805
806/// Metadata shared across a run.
807#[derive(Clone, Debug, Default, PartialEq, Eq, Ord, PartialOrd, Serialize)]
808pub struct RunMeta {
809    /// If none, Persist should infer the order based on the proto metadata.
810    pub(crate) order: Option<RunOrder>,
811    /// All parts in a run should have the same schema.
812    pub(crate) schema: Option<SchemaId>,
813
814    /// ID of a schema that has since been deprecated and exists only to cleanly roundtrip.
815    pub(crate) deprecated_schema: Option<SchemaId>,
816
817    /// If set, a UUID that uniquely identifies this run.
818    pub(crate) id: Option<RunId>,
819
820    /// The number of updates in this run, or `None` if the number is unknown.
821    pub(crate) len: Option<usize>,
822
823    /// Additional unstructured metadata.
824    #[serde(skip_serializing_if = "MetadataMap::is_empty")]
825    pub(crate) meta: MetadataMap,
826}
827
828/// Metadata key for [RunMeta::bounds_truncated].
829const RUN_META_BOUNDS_TRUNCATED: MetadataKey<bool> = MetadataKey::new("truncated");
830
831impl RunMeta {
832    /// Whether this run's parts may hold updates outside the registered desc
833    /// of the batch that contains them.
834    ///
835    /// Set when a batch is appended under a desc narrower than the one it was
836    /// written with (truncation). Readers filter such updates out against the
837    /// registered desc, but per-part statistics like `diffs_sum` are computed
838    /// at write time over everything physically in the part, so accounting
839    /// that compares those statistics against data seen through a read must
840    /// skip runs with this bit set.
841    pub(crate) fn bounds_truncated(&self) -> bool {
842        self.meta.get(RUN_META_BOUNDS_TRUNCATED).unwrap_or(false)
843    }
844
845    /// Marks this run as possibly holding updates outside its batch's
846    /// registered desc. See [Self::bounds_truncated].
847    pub(crate) fn set_bounds_truncated(&mut self) {
848        self.meta.set(RUN_META_BOUNDS_TRUNCATED, true);
849    }
850}
851
852/// A subset of a [HollowBatch] corresponding 1:1 to a blob.
853#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
854pub struct HollowBatchPart<T> {
855    /// Pointer usable to retrieve the updates.
856    pub key: PartialBatchKey,
857    /// Miscellaneous metadata.
858    #[serde(skip_serializing_if = "MetadataMap::is_empty")]
859    pub meta: MetadataMap,
860    /// The encoded size of this part.
861    pub encoded_size_bytes: usize,
862    /// A lower bound on the keys in the part. (By default, this the minimum
863    /// possible key: `vec![]`.)
864    #[serde(serialize_with = "serialize_part_bytes")]
865    pub key_lower: Vec<u8>,
866    /// A lower bound on the keys in the part, stored as structured data.
867    #[serde(serialize_with = "serialize_lazy_proto")]
868    pub structured_key_lower: Option<LazyProto<ProtoArrayData>>,
869    /// Aggregate statistics about data contained in this part.
870    #[serde(serialize_with = "serialize_part_stats")]
871    pub stats: Option<LazyPartStats>,
872    /// A frontier to which timestamps in this part are advanced on read, if
873    /// set.
874    ///
875    /// A value of `Some([T::minimum()])` is functionally the same as `None`,
876    /// but we maintain the distinction between the two for some internal sanity
877    /// checking of invariants as well as metrics. If this ever becomes an
878    /// issue, everything still works with this as just `Antichain<T>`.
879    pub ts_rewrite: Option<Antichain<T>>,
880    /// A Codec64 encoded sum of all diffs in this part, if known.
881    ///
882    /// This is `None` if this part was written before we started storing this
883    /// information, or if it was written when the dyncfg was off.
884    ///
885    /// It could also make sense to model this as part of the pushdown stats, if
886    /// we later decide that's of some benefit.
887    #[serde(serialize_with = "serialize_diffs_sum")]
888    pub diffs_sum: Option<[u8; 8]>,
889    /// Columnar format that this batch was written in.
890    ///
891    /// This is `None` if this part was written before we started writing structured
892    /// columnar data.
893    pub format: Option<BatchColumnarFormat>,
894    /// The schemas used to encode the data in this batch part.
895    ///
896    /// Or None for historical data written before the schema registry was
897    /// added.
898    pub schema_id: Option<SchemaId>,
899
900    /// ID of a schema that has since been deprecated and exists only to cleanly roundtrip.
901    pub deprecated_schema_id: Option<SchemaId>,
902}
903
904/// A differential trace batch, but with the updates themselves stored externally.
905#[derive(Clone, PartialEq, Eq)]
906pub struct HollowBatch<T> {
907    /// Describes the times of the updates in the batch.
908    pub desc: Description<T>,
909    /// The number of updates in the batch.
910    pub len: usize,
911    /// Pointers usable to retrieve the updates.
912    pub(crate) parts: Vec<RunPart<T>>,
913    /// Runs of sequential sorted batch parts, stored as indices into `parts`.
914    /// ex.
915    /// ```text
916    ///     parts=[p1, p2, p3], runs=[]     --> run  is  [p1, p2, p2]
917    ///     parts=[p1, p2, p3], runs=[1]    --> runs are [p1] and [p2, p3]
918    ///     parts=[p1, p2, p3], runs=[1, 2] --> runs are [p1], [p2], [p3]
919    /// ```
920    pub(crate) run_splits: Vec<usize>,
921    /// Run-level metadata: the first entry has metadata for the first run, and so on.
922    /// If there's no corresponding entry for a particular run, it's assumed to be [RunMeta::default()].
923    pub(crate) run_meta: Vec<RunMeta>,
924}
925
926impl<T: Debug> Debug for HollowBatch<T> {
927    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
928        let HollowBatch {
929            desc,
930            parts,
931            len,
932            run_splits: runs,
933            run_meta,
934        } = self;
935        f.debug_struct("HollowBatch")
936            .field(
937                "desc",
938                &(
939                    desc.lower().elements(),
940                    desc.upper().elements(),
941                    desc.since().elements(),
942                ),
943            )
944            .field("parts", &parts)
945            .field("len", &len)
946            .field("runs", &runs)
947            .field("run_meta", &run_meta)
948            .finish()
949    }
950}
951
952impl<T: Serialize> serde::Serialize for HollowBatch<T> {
953    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
954        let HollowBatch {
955            desc,
956            len,
957            // Both parts and runs are covered by the self.runs call.
958            parts: _,
959            run_splits: _,
960            run_meta: _,
961        } = self;
962        let mut s = s.serialize_struct("HollowBatch", 5)?;
963        let () = s.serialize_field("lower", &desc.lower().elements())?;
964        let () = s.serialize_field("upper", &desc.upper().elements())?;
965        let () = s.serialize_field("since", &desc.since().elements())?;
966        let () = s.serialize_field("len", len)?;
967        let () = s.serialize_field("part_runs", &self.runs().collect::<Vec<_>>())?;
968        s.end()
969    }
970}
971
972impl<T: Ord> PartialOrd for HollowBatch<T> {
973    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
974        Some(self.cmp(other))
975    }
976}
977
978impl<T: Ord> Ord for HollowBatch<T> {
979    fn cmp(&self, other: &Self) -> Ordering {
980        // Deconstruct self and other so we get a compile failure if new fields
981        // are added.
982        let HollowBatch {
983            desc: self_desc,
984            parts: self_parts,
985            len: self_len,
986            run_splits: self_runs,
987            run_meta: self_run_meta,
988        } = self;
989        let HollowBatch {
990            desc: other_desc,
991            parts: other_parts,
992            len: other_len,
993            run_splits: other_runs,
994            run_meta: other_run_meta,
995        } = other;
996        (
997            self_desc.lower().elements(),
998            self_desc.upper().elements(),
999            self_desc.since().elements(),
1000            self_parts,
1001            self_len,
1002            self_runs,
1003            self_run_meta,
1004        )
1005            .cmp(&(
1006                other_desc.lower().elements(),
1007                other_desc.upper().elements(),
1008                other_desc.since().elements(),
1009                other_parts,
1010                other_len,
1011                other_runs,
1012                other_run_meta,
1013            ))
1014    }
1015}
1016
1017impl<T: Timestamp + Codec64 + Sync> HollowBatch<T> {
1018    pub(crate) fn part_stream<'a>(
1019        &'a self,
1020        shard_id: ShardId,
1021        blob: &'a dyn Blob,
1022        metrics: &'a Metrics,
1023    ) -> impl Stream<Item = Result<Cow<'a, BatchPart<T>>, MissingBlob>> + 'a {
1024        stream! {
1025            for part in &self.parts {
1026                for await part in part.part_stream(shard_id, blob, metrics) {
1027                    yield part;
1028                }
1029            }
1030        }
1031    }
1032}
1033impl<T> HollowBatch<T> {
1034    /// Construct an in-memory hollow batch from the given metadata.
1035    ///
1036    /// This method checks that `runs` is a sequence of valid indices into `parts`. The caller
1037    /// is responsible for ensuring that the defined runs are valid.
1038    ///
1039    /// `len` should represent the number of valid updates in the referenced parts.
1040    pub(crate) fn new(
1041        desc: Description<T>,
1042        parts: Vec<RunPart<T>>,
1043        len: usize,
1044        run_meta: Vec<RunMeta>,
1045        run_splits: Vec<usize>,
1046    ) -> Self {
1047        debug_assert!(
1048            run_splits.is_strictly_sorted(),
1049            "run indices should be strictly increasing"
1050        );
1051        mz_ore::soft_assert_no_log!(
1052            run_splits.first().map_or(true, |i| *i > 0),
1053            "run indices should be positive"
1054        );
1055        mz_ore::soft_assert_no_log!(
1056            run_splits.last().map_or(true, |i| *i < parts.len()),
1057            "run indices should be valid indices into parts"
1058        );
1059        mz_ore::soft_assert_no_log!(
1060            parts.is_empty() || run_meta.len() == run_splits.len() + 1,
1061            "all metadata should correspond to a run"
1062        );
1063
1064        Self {
1065            desc,
1066            len,
1067            parts,
1068            run_splits,
1069            run_meta,
1070        }
1071    }
1072
1073    /// Construct a batch of a single run with default metadata. Mostly interesting for tests.
1074    pub(crate) fn new_run(desc: Description<T>, parts: Vec<RunPart<T>>, len: usize) -> Self {
1075        let run_meta = if parts.is_empty() {
1076            vec![]
1077        } else {
1078            vec![RunMeta::default()]
1079        };
1080        Self {
1081            desc,
1082            len,
1083            parts,
1084            run_splits: vec![],
1085            run_meta,
1086        }
1087    }
1088
1089    #[cfg(test)]
1090    pub(crate) fn new_run_for_test(
1091        desc: Description<T>,
1092        parts: Vec<RunPart<T>>,
1093        len: usize,
1094        run_id: RunId,
1095    ) -> Self {
1096        let run_meta = if parts.is_empty() {
1097            vec![]
1098        } else {
1099            let mut meta = RunMeta::default();
1100            meta.id = Some(run_id);
1101            vec![meta]
1102        };
1103        Self {
1104            desc,
1105            len,
1106            parts,
1107            run_splits: vec![],
1108            run_meta,
1109        }
1110    }
1111
1112    /// An empty hollow batch, representing no updates over the given desc.
1113    pub(crate) fn empty(desc: Description<T>) -> Self {
1114        Self {
1115            desc,
1116            len: 0,
1117            parts: vec![],
1118            run_splits: vec![],
1119            run_meta: vec![],
1120        }
1121    }
1122
1123    pub(crate) fn runs(&self) -> impl Iterator<Item = (&RunMeta, &[RunPart<T>])> {
1124        let run_ends = self
1125            .run_splits
1126            .iter()
1127            .copied()
1128            .chain(std::iter::once(self.parts.len()));
1129        let run_metas = self.run_meta.iter();
1130        let run_parts = run_ends
1131            .scan(0, |start, end| {
1132                let range = *start..end;
1133                *start = end;
1134                Some(range)
1135            })
1136            .filter(|range| !range.is_empty())
1137            .map(|range| &self.parts[range]);
1138        run_metas.zip_eq(run_parts)
1139    }
1140
1141    pub(crate) fn inline_bytes(&self) -> usize {
1142        self.parts.iter().map(|x| x.inline_bytes()).sum()
1143    }
1144
1145    pub(crate) fn is_empty(&self) -> bool {
1146        self.parts.is_empty()
1147    }
1148
1149    pub(crate) fn part_count(&self) -> usize {
1150        self.parts.len()
1151    }
1152
1153    /// The sum of the encoded sizes of all parts in the batch.
1154    pub fn encoded_size_bytes(&self) -> usize {
1155        self.parts.iter().map(|p| p.encoded_size_bytes()).sum()
1156    }
1157}
1158
1159// See the comment on [Batch::rewrite_ts] for why this is TotalOrder.
1160impl<T: Timestamp + TotalOrder> HollowBatch<T> {
1161    pub(crate) fn rewrite_ts(
1162        &mut self,
1163        frontier: &Antichain<T>,
1164        new_upper: Antichain<T>,
1165    ) -> Result<(), String> {
1166        if !PartialOrder::less_than(frontier, &new_upper) {
1167            return Err(format!(
1168                "rewrite frontier {:?} !< rewrite upper {:?}",
1169                frontier.elements(),
1170                new_upper.elements(),
1171            ));
1172        }
1173        if PartialOrder::less_than(&new_upper, self.desc.upper()) {
1174            return Err(format!(
1175                "rewrite upper {:?} < batch upper {:?}",
1176                new_upper.elements(),
1177                self.desc.upper().elements(),
1178            ));
1179        }
1180
1181        // The following are things that it seems like we could support, but
1182        // initially we don't because we don't have a use case for them.
1183        if PartialOrder::less_than(frontier, self.desc.lower()) {
1184            return Err(format!(
1185                "rewrite frontier {:?} < batch lower {:?}",
1186                frontier.elements(),
1187                self.desc.lower().elements(),
1188            ));
1189        }
1190        if self.desc.since() != &Antichain::from_elem(T::minimum()) {
1191            return Err(format!(
1192                "batch since {:?} != minimum antichain {:?}",
1193                self.desc.since().elements(),
1194                [T::minimum()],
1195            ));
1196        }
1197        for part in self.parts.iter() {
1198            let Some(ts_rewrite) = part.ts_rewrite() else {
1199                continue;
1200            };
1201            if PartialOrder::less_than(frontier, ts_rewrite) {
1202                return Err(format!(
1203                    "rewrite frontier {:?} < batch rewrite {:?}",
1204                    frontier.elements(),
1205                    ts_rewrite.elements(),
1206                ));
1207            }
1208        }
1209
1210        self.desc = Description::new(
1211            self.desc.lower().clone(),
1212            new_upper,
1213            self.desc.since().clone(),
1214        );
1215        for part in &mut self.parts {
1216            match part {
1217                RunPart::Single(BatchPart::Hollow(part)) => {
1218                    part.ts_rewrite = Some(frontier.clone())
1219                }
1220                RunPart::Single(BatchPart::Inline { ts_rewrite, .. }) => {
1221                    *ts_rewrite = Some(frontier.clone())
1222                }
1223                RunPart::Many(runs) => {
1224                    // Currently unreachable: we only apply rewrites to user batches, and we don't
1225                    // ever generate runs of >1 part for those.
1226                    panic!("unexpected rewrite of a hollow runs ref: {runs:?}");
1227                }
1228            }
1229        }
1230        Ok(())
1231    }
1232}
1233
1234impl<T: Ord> PartialOrd for HollowBatchPart<T> {
1235    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1236        Some(self.cmp(other))
1237    }
1238}
1239
1240impl<T: Ord> Ord for HollowBatchPart<T> {
1241    fn cmp(&self, other: &Self) -> Ordering {
1242        // Deconstruct self and other so we get a compile failure if new fields
1243        // are added.
1244        let HollowBatchPart {
1245            key: self_key,
1246            meta: self_meta,
1247            encoded_size_bytes: self_encoded_size_bytes,
1248            key_lower: self_key_lower,
1249            structured_key_lower: self_structured_key_lower,
1250            stats: self_stats,
1251            ts_rewrite: self_ts_rewrite,
1252            diffs_sum: self_diffs_sum,
1253            format: self_format,
1254            schema_id: self_schema_id,
1255            deprecated_schema_id: self_deprecated_schema_id,
1256        } = self;
1257        let HollowBatchPart {
1258            key: other_key,
1259            meta: other_meta,
1260            encoded_size_bytes: other_encoded_size_bytes,
1261            key_lower: other_key_lower,
1262            structured_key_lower: other_structured_key_lower,
1263            stats: other_stats,
1264            ts_rewrite: other_ts_rewrite,
1265            diffs_sum: other_diffs_sum,
1266            format: other_format,
1267            schema_id: other_schema_id,
1268            deprecated_schema_id: other_deprecated_schema_id,
1269        } = other;
1270        (
1271            self_key,
1272            self_meta,
1273            self_encoded_size_bytes,
1274            self_key_lower,
1275            self_structured_key_lower,
1276            self_stats,
1277            self_ts_rewrite.as_ref().map(|x| x.elements()),
1278            self_diffs_sum,
1279            self_format,
1280            self_schema_id,
1281            self_deprecated_schema_id,
1282        )
1283            .cmp(&(
1284                other_key,
1285                other_meta,
1286                other_encoded_size_bytes,
1287                other_key_lower,
1288                other_structured_key_lower,
1289                other_stats,
1290                other_ts_rewrite.as_ref().map(|x| x.elements()),
1291                other_diffs_sum,
1292                other_format,
1293                other_schema_id,
1294                other_deprecated_schema_id,
1295            ))
1296    }
1297}
1298
1299/// A pointer to a rollup stored externally.
1300#[derive(Arbitrary, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1301pub struct HollowRollup {
1302    /// Pointer usable to retrieve the rollup.
1303    pub key: PartialRollupKey,
1304    /// The encoded size of this rollup, if known.
1305    pub encoded_size_bytes: Option<usize>,
1306    /// Whether the state this rolls up is finalized, recorded only while
1307    /// `persist_gc_remove_finalized_rollups` is on. GC keeps these.
1308    pub finalized: bool,
1309}
1310
1311/// A pointer to a blob stored externally.
1312#[derive(Debug)]
1313pub enum HollowBlobRef<'a, T> {
1314    Batch(&'a HollowBatch<T>),
1315    Rollup(&'a HollowRollup),
1316}
1317
1318/// A rollup that is currently being computed.
1319#[derive(
1320    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Arbitrary, Serialize
1321)]
1322pub struct ActiveRollup {
1323    pub seqno: SeqNo,
1324    pub start_ms: u64,
1325}
1326
1327/// A garbage collection request that is currently being computed.
1328#[derive(
1329    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Arbitrary, Serialize
1330)]
1331pub struct ActiveGc {
1332    pub seqno: SeqNo,
1333    pub start_ms: u64,
1334}
1335
1336/// A sentinel for a state transition that was a no-op.
1337///
1338/// Critically, this also indicates that the no-op state transition was not
1339/// committed through compare_and_append and thus is _not linearized_.
1340#[derive(Debug)]
1341#[cfg_attr(any(test, debug_assertions), derive(PartialEq))]
1342pub struct NoOpStateTransition<T>(pub T);
1343
1344// TODO: Document invariants.
1345#[derive(Debug, Clone)]
1346#[cfg_attr(any(test, debug_assertions), derive(PartialEq))]
1347pub struct StateCollections<T> {
1348    /// The version of this state. This is typically identical to the version of the code
1349    /// that wrote it, but may diverge during 0dt upgrades and similar operations when a
1350    /// new version of code is intentionally interoperating with an older state format.
1351    pub(crate) version: Version,
1352
1353    // - Invariant: `<= all reader.since`
1354    // - Invariant: Doesn't regress across state versions.
1355    pub(crate) last_gc_req: SeqNo,
1356
1357    // - Invariant: There is a rollup with `seqno <= self.seqno_since`.
1358    pub(crate) rollups: BTreeMap<SeqNo, HollowRollup>,
1359
1360    /// The rollup that is currently being computed.
1361    pub(crate) active_rollup: Option<ActiveRollup>,
1362    /// The gc request that is currently being computed.
1363    pub(crate) active_gc: Option<ActiveGc>,
1364
1365    pub(crate) leased_readers: BTreeMap<LeasedReaderId, LeasedReaderState<T>>,
1366    pub(crate) critical_readers: BTreeMap<CriticalReaderId, CriticalReaderState<T>>,
1367    pub(crate) writers: BTreeMap<WriterId, WriterState<T>>,
1368    pub(crate) schemas: BTreeMap<SchemaId, EncodedSchemas>,
1369
1370    // - Invariant: `trace.since == meet(all reader.since)`
1371    // - Invariant: `trace.since` doesn't regress across state versions.
1372    // - Invariant: `trace.upper` doesn't regress across state versions.
1373    // - Invariant: `trace` upholds its own invariants.
1374    pub(crate) trace: Trace<T>,
1375}
1376
1377/// A key and val [Codec::Schema] encoded via [Codec::encode_schema].
1378///
1379/// This strategy of directly serializing the schema objects requires that
1380/// persist users do the right thing. Specifically, that an encoded schema
1381/// doesn't in some later version of mz decode to an in-mem object that acts
1382/// differently. In a sense, the current system (before the introduction of the
1383/// schema registry) where schemas are passed in unchecked to reader and writer
1384/// registration calls also has the same defect, so seems fine.
1385///
1386/// An alternative is to write down here some persist-specific representation of
1387/// the schema (e.g. the arrow DataType). This is a lot more work and also has
1388/// the potential to lead down a similar failure mode to the mz_persist_types
1389/// `Data` trait, where the boilerplate isn't worth the safety. Given that we
1390/// can always migrate later by rehydrating these, seems fine to start with the
1391/// easy thing.
1392#[derive(Debug, Clone, Serialize, PartialEq)]
1393pub struct EncodedSchemas {
1394    /// A full in-mem `K::Schema` impl encoded via [Codec::encode_schema].
1395    pub key: Bytes,
1396    /// The arrow `DataType` produced by this `K::Schema` at the time it was
1397    /// registered, encoded as a `ProtoDataType`.
1398    pub key_data_type: Bytes,
1399    /// A full in-mem `V::Schema` impl encoded via [Codec::encode_schema].
1400    pub val: Bytes,
1401    /// The arrow `DataType` produced by this `V::Schema` at the time it was
1402    /// registered, encoded as a `ProtoDataType`.
1403    pub val_data_type: Bytes,
1404}
1405
1406impl EncodedSchemas {
1407    pub(crate) fn decode_data_type(buf: &[u8]) -> DataType {
1408        let proto = prost::Message::decode(buf).expect("valid ProtoDataType");
1409        DataType::from_proto(proto).expect("valid DataType")
1410    }
1411}
1412
1413#[derive(Debug)]
1414#[cfg_attr(test, derive(PartialEq))]
1415pub enum CompareAndAppendBreak<T> {
1416    AlreadyCommitted,
1417    Upper {
1418        shard_upper: Antichain<T>,
1419        writer_upper: Antichain<T>,
1420    },
1421    InvalidUsage(InvalidUsage<T>),
1422    InlineBackpressure,
1423}
1424
1425#[derive(Debug)]
1426#[cfg_attr(test, derive(PartialEq))]
1427pub enum SnapshotErr<T> {
1428    AsOfNotYetAvailable(SeqNo, Upper<T>),
1429    AsOfHistoricalDistinctionsLost(Since<T>),
1430}
1431
1432impl<T> StateCollections<T>
1433where
1434    T: Timestamp + Lattice + Codec64,
1435{
1436    pub fn add_rollup(
1437        &mut self,
1438        add_rollup: (SeqNo, &HollowRollup),
1439    ) -> ControlFlow<NoOpStateTransition<bool>, bool> {
1440        let (rollup_seqno, rollup) = add_rollup;
1441        let applied = match self.rollups.get(&rollup_seqno) {
1442            Some(x) => x.key == rollup.key,
1443            None => {
1444                // PER-16: refuse to insert a rollup at a seqno that GC has
1445                // already physically removed. `apply_unbatched_idempotent_cmd`
1446                // replays the captured `(rollup_seqno, rollup)` tuple on
1447                // every retry. If the original CAS committed indeterminately
1448                // and a concurrent GC then removed the rollup, the retry
1449                // would otherwise observe an empty map entry and re-insert
1450                // the same `PartialRollupKey` at the same seqno, producing
1451                // a second Insert (and, later, a second Delete) for that
1452                // key in the diff stream and tripping the `assert!` in
1453                // `find_removable_blobs`.
1454                //
1455                // We use the smallest seqno currently in `self.rollups` as
1456                // the GC watermark. GC's `remove_rollups` always keeps the
1457                // latest rollup `<= seqno_since` and removes the older
1458                // ones, so the surviving minimum is strictly above every
1459                // seqno that has been removed. (Unlike `last_gc_req`, this
1460                // is only advanced by an actual physical removal, not by
1461                // `maybe_gc` deciding to *request* one — `last_gc_req` runs
1462                // ahead of removals and would falsely reject legitimate
1463                // late `add_rollup` calls for older seqnos.)
1464                if let Some(min_kept) = self.rollups.keys().next() {
1465                    if rollup_seqno < *min_kept {
1466                        return Continue(false);
1467                    }
1468                }
1469                self.active_rollup = None;
1470                self.rollups.insert(rollup_seqno, rollup.to_owned());
1471                true
1472            }
1473        };
1474        // This state transition is a no-op if applied is false but we
1475        // still commit the state change so that this gets linearized
1476        // (maybe we're looking at old state).
1477        Continue(applied)
1478    }
1479
1480    pub fn remove_rollups(
1481        &mut self,
1482        remove_rollups: &[(SeqNo, PartialRollupKey)],
1483        remove_finalized: bool,
1484    ) -> ControlFlow<NoOpStateTransition<Vec<SeqNo>>, Vec<SeqNo>> {
1485        if self.is_tombstone() {
1486            if !remove_finalized || !self.is_finalized() {
1487                return Break(NoOpStateTransition(vec![]));
1488            }
1489            return self.remove_finalized_rollups(remove_rollups);
1490        }
1491
1492        // This state transition is called at the end of the GC process, so we
1493        // need to unset the `active_gc` field.
1494        let active_gc_was_set = self.active_gc.take().is_some();
1495
1496        if remove_rollups.is_empty() {
1497            return if active_gc_was_set {
1498                Continue(vec![])
1499            } else {
1500                Break(NoOpStateTransition(vec![]))
1501            };
1502        }
1503
1504        let mut removed = vec![];
1505        for (seqno, key) in remove_rollups {
1506            let removed_key = self.rollups.remove(seqno);
1507            mz_ore::soft_assert_no_log!(
1508                removed_key.as_ref().map_or(true, |x| &x.key == key),
1509                "rollup at {} to be removed has key {:?} in state, but GC asked to remove {}",
1510                seqno,
1511                removed_key,
1512                key
1513            );
1514
1515            if removed_key.is_some() {
1516                removed.push(*seqno);
1517            }
1518        }
1519
1520        Continue(removed)
1521    }
1522
1523    /// Removes only rollups written before the shard was finalized. Each
1524    /// removal asks for a new rollup, so removing those too would loop.
1525    ///
1526    /// NOTE: A no-op must not commit, even just to clear `active_gc`. See the
1527    /// soft assert in `compute_next_state_locked`.
1528    fn remove_finalized_rollups(
1529        &mut self,
1530        remove_rollups: &[(SeqNo, PartialRollupKey)],
1531    ) -> ControlFlow<NoOpStateTransition<Vec<SeqNo>>, Vec<SeqNo>> {
1532        let latest = self.rollups.keys().next_back().copied();
1533        let mut removed = vec![];
1534        for (seqno, key) in remove_rollups {
1535            if Some(*seqno) == latest {
1536                mz_ore::soft_panic_or_log!("GC asked to remove a finalized shard's latest rollup");
1537                continue;
1538            }
1539            // Misses are expected when GC replays a committed removal.
1540            if self
1541                .rollups
1542                .get(seqno)
1543                .is_some_and(|x| &x.key == key && !x.finalized)
1544            {
1545                self.rollups.remove(seqno);
1546                removed.push(*seqno);
1547            }
1548        }
1549        if removed.is_empty() {
1550            return Break(NoOpStateTransition(vec![]));
1551        }
1552        self.active_gc = None;
1553        Continue(removed)
1554    }
1555
1556    pub fn register_leased_reader(
1557        &mut self,
1558        hostname: &str,
1559        reader_id: &LeasedReaderId,
1560        purpose: &str,
1561        seqno: SeqNo,
1562        lease_duration: Duration,
1563        heartbeat_timestamp_ms: u64,
1564        use_critical_since: bool,
1565    ) -> ControlFlow<
1566        NoOpStateTransition<(LeasedReaderState<T>, SeqNo)>,
1567        (LeasedReaderState<T>, SeqNo),
1568    > {
1569        let since = if use_critical_since {
1570            self.critical_since()
1571                .unwrap_or_else(|| self.trace.since().clone())
1572        } else {
1573            self.trace.since().clone()
1574        };
1575        let reader_state = LeasedReaderState {
1576            debug: HandleDebugState {
1577                hostname: hostname.to_owned(),
1578                purpose: purpose.to_owned(),
1579            },
1580            seqno,
1581            since,
1582            last_heartbeat_timestamp_ms: heartbeat_timestamp_ms,
1583            lease_duration_ms: u64::try_from(lease_duration.as_millis())
1584                .expect("lease duration as millis should fit within u64"),
1585        };
1586
1587        // If the shard-global upper and since are both the empty antichain,
1588        // then no further writes can ever commit and no further reads can be
1589        // served. Optimize this by no-op-ing reader registration so that we can
1590        // settle the shard into a final unchanging tombstone state.
1591        if self.is_tombstone() {
1592            return Break(NoOpStateTransition((reader_state, self.seqno_since(seqno))));
1593        }
1594
1595        // TODO: Handle if the reader or writer already exists.
1596        self.leased_readers
1597            .insert(reader_id.clone(), reader_state.clone());
1598        Continue((reader_state, self.seqno_since(seqno)))
1599    }
1600
1601    pub fn register_critical_reader(
1602        &mut self,
1603        hostname: &str,
1604        reader_id: &CriticalReaderId,
1605        opaque: Opaque,
1606        purpose: &str,
1607    ) -> ControlFlow<NoOpStateTransition<CriticalReaderState<T>>, CriticalReaderState<T>> {
1608        let state = CriticalReaderState {
1609            debug: HandleDebugState {
1610                hostname: hostname.to_owned(),
1611                purpose: purpose.to_owned(),
1612            },
1613            since: self.trace.since().clone(),
1614            opaque,
1615        };
1616
1617        // We expire all readers if the upper and since both advance to the
1618        // empty antichain. Gracefully handle this. At the same time,
1619        // short-circuit the cmd application so we don't needlessly create new
1620        // SeqNos.
1621        if self.is_tombstone() {
1622            return Break(NoOpStateTransition(state));
1623        }
1624
1625        let state = match self.critical_readers.get_mut(reader_id) {
1626            Some(existing_state) => {
1627                existing_state.debug = state.debug;
1628                existing_state.clone()
1629            }
1630            None => {
1631                self.critical_readers
1632                    .insert(reader_id.clone(), state.clone());
1633                state
1634            }
1635        };
1636        Continue(state)
1637    }
1638
1639    pub fn register_schema<K: Codec, V: Codec>(
1640        &mut self,
1641        key_schema: &K::Schema,
1642        val_schema: &V::Schema,
1643    ) -> ControlFlow<NoOpStateTransition<Option<SchemaId>>, Option<SchemaId>> {
1644        fn encode_data_type(data_type: &DataType) -> Bytes {
1645            let proto = data_type.into_proto();
1646            prost::Message::encode_to_vec(&proto).into()
1647        }
1648
1649        // Look for an existing registered SchemaId for these schemas.
1650        //
1651        // The common case is that this should be a recent one, so as a minor
1652        // optimization, do this search in reverse order.
1653        //
1654        // TODO: Note that this impl is `O(schemas)`. Combined with the
1655        // possibility of cmd retries, it's possible but unlikely for this to
1656        // get expensive. We could maintain a reverse map to speed this up in
1657        // necessary. This would either need to work on the encoded
1658        // representation (which, we'd have to fall back to the linear scan) or
1659        // we'd need to add a Hash/Ord bound to Schema.
1660        let existing_id = self.schemas.iter().rev().find(|(_, x)| {
1661            K::decode_schema(&x.key) == *key_schema && V::decode_schema(&x.val) == *val_schema
1662        });
1663        match existing_id {
1664            Some((schema_id, _)) => {
1665                // TODO: Validate that the decoded schemas still produce records
1666                // of the recorded DataType, to detect shenanigans. Probably
1667                // best to wait until we've turned on Schema2 in prod and thus
1668                // committed to the current mappings.
1669                Break(NoOpStateTransition(Some(*schema_id)))
1670            }
1671            None if self.is_tombstone() => {
1672                // TODO: Is this right?
1673                Break(NoOpStateTransition(None))
1674            }
1675            None if self.schemas.is_empty() => {
1676                // We'll have to do something more sophisticated here to
1677                // generate the next id if/when we start supporting the removal
1678                // of schemas.
1679                let id = SchemaId(self.schemas.len());
1680                let key_data_type = mz_persist_types::columnar::data_type::<K>(key_schema)
1681                    .expect("valid key schema");
1682                let val_data_type = mz_persist_types::columnar::data_type::<V>(val_schema)
1683                    .expect("valid val schema");
1684                let prev = self.schemas.insert(
1685                    id,
1686                    EncodedSchemas {
1687                        key: K::encode_schema(key_schema),
1688                        key_data_type: encode_data_type(&key_data_type),
1689                        val: V::encode_schema(val_schema),
1690                        val_data_type: encode_data_type(&val_data_type),
1691                    },
1692                );
1693                assert_eq!(prev, None);
1694                Continue(Some(id))
1695            }
1696            None => {
1697                info!(
1698                    "register_schemas got {:?} expected {:?}",
1699                    key_schema,
1700                    self.schemas
1701                        .iter()
1702                        .map(|(id, x)| (id, K::decode_schema(&x.key)))
1703                        .collect::<Vec<_>>()
1704                );
1705                // Until we implement persist schema changes, only allow at most
1706                // one registered schema.
1707                Break(NoOpStateTransition(None))
1708            }
1709        }
1710    }
1711
1712    pub fn compare_and_evolve_schema<K: Codec, V: Codec>(
1713        &mut self,
1714        expected: SchemaId,
1715        key_schema: &K::Schema,
1716        val_schema: &V::Schema,
1717    ) -> ControlFlow<NoOpStateTransition<CaESchema<K, V>>, CaESchema<K, V>> {
1718        fn data_type<T>(schema: &impl Schema<T>) -> DataType {
1719            // To be defensive, create an empty batch and inspect the resulting
1720            // data type (as opposed to something like allowing the `Schema` to
1721            // declare the DataType).
1722            let array = Schema::encoder(schema).expect("valid schema").finish();
1723            Array::data_type(&array).clone()
1724        }
1725
1726        let (current_id, current) = self
1727            .schemas
1728            .last_key_value()
1729            .expect("all shards have a schema");
1730
1731        let current_key = K::decode_schema(&current.key);
1732        let current_key_dt = EncodedSchemas::decode_data_type(&current.key_data_type);
1733        let current_val = V::decode_schema(&current.val);
1734        let current_val_dt = EncodedSchemas::decode_data_type(&current.val_data_type);
1735
1736        let key_dt = data_type(key_schema);
1737        let val_dt = data_type(val_schema);
1738
1739        // If the schema is exactly the same as the current one, no-op. NOTE:
1740        // this check has to come before the `expected` one, otherwise the
1741        // command is not idempotent: `apply_unbatched_idempotent_cmd` retries
1742        // indeterminate errors, so a CaS that committed but lost its response
1743        // gets re-run against state that already carries its own evolution.
1744        //
1745        // Returning Ok when `*current_id != expected` is safe: register_schema
1746        // never mints two ids for the same content, so a content match is the
1747        // current schema and a stale `expected` just lags the committed retry.
1748        if current_key == *key_schema
1749            && current_key_dt == key_dt
1750            && current_val == *val_schema
1751            && current_val_dt == val_dt
1752        {
1753            return Break(NoOpStateTransition(CaESchema::Ok(*current_id)));
1754        }
1755
1756        if *current_id != expected {
1757            return Break(NoOpStateTransition(CaESchema::ExpectedMismatch {
1758                schema_id: *current_id,
1759                key: current_key,
1760                val: current_val,
1761            }));
1762        }
1763
1764        let key_fn = backward_compatible(&current_key_dt, &key_dt);
1765        let val_fn = backward_compatible(&current_val_dt, &val_dt);
1766        let (Some(key_fn), Some(val_fn)) = (key_fn, val_fn) else {
1767            return Break(NoOpStateTransition(CaESchema::Incompatible));
1768        };
1769        // Persist initially disallows dropping columns. This would require a
1770        // bunch more work (e.g. not safe to use the latest schema in
1771        // compaction) and isn't initially necessary in mz.
1772        if key_fn.contains_drop() || val_fn.contains_drop() {
1773            return Break(NoOpStateTransition(CaESchema::Incompatible));
1774        }
1775
1776        // We'll have to do something more sophisticated here to
1777        // generate the next id if/when we start supporting the removal
1778        // of schemas.
1779        let id = SchemaId(self.schemas.len());
1780        self.schemas.insert(
1781            id,
1782            EncodedSchemas {
1783                key: K::encode_schema(key_schema),
1784                key_data_type: prost::Message::encode_to_vec(&key_dt.into_proto()).into(),
1785                val: V::encode_schema(val_schema),
1786                val_data_type: prost::Message::encode_to_vec(&val_dt.into_proto()).into(),
1787            },
1788        );
1789        Continue(CaESchema::Ok(id))
1790    }
1791
1792    pub fn compare_and_append(
1793        &mut self,
1794        batch: &HollowBatch<T>,
1795        writer_id: &WriterId,
1796        heartbeat_timestamp_ms: u64,
1797        lease_duration_ms: u64,
1798        idempotency_token: &IdempotencyToken,
1799        debug_info: &HandleDebugState,
1800        inline_writes_total_max_bytes: usize,
1801        claim_compaction_percent: usize,
1802        claim_compaction_min_version: Option<&Version>,
1803    ) -> ControlFlow<CompareAndAppendBreak<T>, Vec<FueledMergeReq<T>>> {
1804        // We expire all writers if the upper and since both advance to the
1805        // empty antichain. Gracefully handle this. At the same time,
1806        // short-circuit the cmd application so we don't needlessly create new
1807        // SeqNos.
1808        if self.is_tombstone() {
1809            assert_eq!(self.trace.upper(), &Antichain::new());
1810            return Break(CompareAndAppendBreak::Upper {
1811                shard_upper: Antichain::new(),
1812                // This writer might have been registered before the shard upper
1813                // was advanced, which would make this pessimistic in the
1814                // Indeterminate handling of compare_and_append at the machine
1815                // level, but that's fine.
1816                writer_upper: Antichain::new(),
1817            });
1818        }
1819
1820        let writer_state = self
1821            .writers
1822            .entry(writer_id.clone())
1823            .or_insert_with(|| WriterState {
1824                last_heartbeat_timestamp_ms: heartbeat_timestamp_ms,
1825                lease_duration_ms,
1826                most_recent_write_token: IdempotencyToken::SENTINEL,
1827                most_recent_write_upper: Antichain::from_elem(T::minimum()),
1828                debug: debug_info.clone(),
1829            });
1830
1831        if PartialOrder::less_than(batch.desc.upper(), batch.desc.lower()) {
1832            return Break(CompareAndAppendBreak::InvalidUsage(
1833                InvalidUsage::InvalidBounds {
1834                    lower: batch.desc.lower().clone(),
1835                    upper: batch.desc.upper().clone(),
1836                },
1837            ));
1838        }
1839
1840        // If the time interval is empty, the list of updates must also be
1841        // empty.
1842        if batch.desc.upper() == batch.desc.lower() && !batch.is_empty() {
1843            return Break(CompareAndAppendBreak::InvalidUsage(
1844                InvalidUsage::InvalidEmptyTimeInterval {
1845                    lower: batch.desc.lower().clone(),
1846                    upper: batch.desc.upper().clone(),
1847                    keys: batch
1848                        .parts
1849                        .iter()
1850                        .map(|x| x.printable_name().to_owned())
1851                        .collect(),
1852                },
1853            ));
1854        }
1855
1856        if idempotency_token == &writer_state.most_recent_write_token {
1857            // If the last write had the same idempotency_token, then this must
1858            // have already committed. Sanity check that the most recent write
1859            // upper matches and that the shard upper is at least the write
1860            // upper, if it's not something very suspect is going on.
1861            assert_eq!(batch.desc.upper(), &writer_state.most_recent_write_upper);
1862            assert!(
1863                PartialOrder::less_equal(batch.desc.upper(), self.trace.upper()),
1864                "{:?} vs {:?}",
1865                batch.desc.upper(),
1866                self.trace.upper()
1867            );
1868            return Break(CompareAndAppendBreak::AlreadyCommitted);
1869        }
1870
1871        let shard_upper = self.trace.upper();
1872        if shard_upper != batch.desc.lower() {
1873            return Break(CompareAndAppendBreak::Upper {
1874                shard_upper: shard_upper.clone(),
1875                writer_upper: writer_state.most_recent_write_upper.clone(),
1876            });
1877        }
1878
1879        let new_inline_bytes = batch.inline_bytes();
1880        if new_inline_bytes > 0 {
1881            let mut existing_inline_bytes = 0;
1882            self.trace
1883                .map_batches(|x| existing_inline_bytes += x.inline_bytes());
1884            // TODO: For very small batches, it may actually _increase_ the size
1885            // of state to flush them out. Consider another threshold under
1886            // which an inline part can be appended no matter what.
1887            if existing_inline_bytes + new_inline_bytes >= inline_writes_total_max_bytes {
1888                return Break(CompareAndAppendBreak::InlineBackpressure);
1889            }
1890        }
1891
1892        let mut merge_reqs = if batch.desc.upper() != batch.desc.lower() {
1893            self.trace.push_batch(batch.clone())
1894        } else {
1895            Vec::new()
1896        };
1897
1898        // NB: we don't claim unclaimed compactions when the recording flag is off, even if we'd
1899        // otherwise be allowed to, to avoid triggering the same compactions in every writer.
1900        let all_empty_reqs = merge_reqs
1901            .iter()
1902            .all(|req| req.inputs.iter().all(|b| b.batch.is_empty()));
1903        if all_empty_reqs && !batch.is_empty() {
1904            let mut reqs_to_take = claim_compaction_percent / 100;
1905            if (usize::cast_from(idempotency_token.hashed()) % 100)
1906                < (claim_compaction_percent % 100)
1907            {
1908                reqs_to_take += 1;
1909            }
1910            let threshold_ms = heartbeat_timestamp_ms.saturating_sub(lease_duration_ms);
1911            let min_writer = claim_compaction_min_version.map(WriterKey::for_version);
1912            merge_reqs.extend(
1913                // We keep the oldest `reqs_to_take` batches, under the theory that they're least
1914                // likely to be compacted soon for other reasons.
1915                self.trace
1916                    .fueled_merge_reqs_before_ms(threshold_ms, min_writer)
1917                    .take(reqs_to_take),
1918            )
1919        }
1920
1921        for req in &merge_reqs {
1922            self.trace.claim_compaction(
1923                req.id,
1924                ActiveCompaction {
1925                    start_ms: heartbeat_timestamp_ms,
1926                },
1927            )
1928        }
1929
1930        mz_ore::soft_assert_eq_no_log!(self.trace.upper(), batch.desc.upper());
1931        writer_state.most_recent_write_token = idempotency_token.clone();
1932        // The writer's most recent upper should only go forward.
1933        assert!(
1934            PartialOrder::less_equal(&writer_state.most_recent_write_upper, batch.desc.upper()),
1935            "{:?} vs {:?}",
1936            writer_state.most_recent_write_upper,
1937            batch.desc.upper()
1938        );
1939        writer_state
1940            .most_recent_write_upper
1941            .clone_from(batch.desc.upper());
1942
1943        // Heartbeat the writer state to keep our idempotency token alive.
1944        writer_state.last_heartbeat_timestamp_ms = std::cmp::max(
1945            heartbeat_timestamp_ms,
1946            writer_state.last_heartbeat_timestamp_ms,
1947        );
1948
1949        Continue(merge_reqs)
1950    }
1951
1952    pub fn apply_merge_res<D: Codec64 + Monoid + PartialEq>(
1953        &mut self,
1954        res: &FueledMergeRes<T>,
1955        metrics: &ColumnarMetrics,
1956    ) -> ControlFlow<NoOpStateTransition<ApplyMergeResult>, ApplyMergeResult> {
1957        // We expire all writers if the upper and since both advance to the
1958        // empty antichain. Gracefully handle this. At the same time,
1959        // short-circuit the cmd application so we don't needlessly create new
1960        // SeqNos.
1961        if self.is_tombstone() {
1962            return Break(NoOpStateTransition(ApplyMergeResult::NotAppliedNoMatch));
1963        }
1964
1965        let apply_merge_result = self.trace.apply_merge_res_checked::<D>(res, metrics);
1966        Continue(apply_merge_result)
1967    }
1968
1969    pub fn spine_exert(
1970        &mut self,
1971        fuel: usize,
1972    ) -> ControlFlow<NoOpStateTransition<Vec<FueledMergeReq<T>>>, Vec<FueledMergeReq<T>>> {
1973        let (merge_reqs, did_work) = self.trace.exert(fuel);
1974        if did_work {
1975            Continue(merge_reqs)
1976        } else {
1977            assert!(merge_reqs.is_empty());
1978            // Break if we have nothing useful to do to save the seqno (and
1979            // resulting crdb traffic)
1980            Break(NoOpStateTransition(Vec::new()))
1981        }
1982    }
1983
1984    pub fn downgrade_since(
1985        &mut self,
1986        reader_id: &LeasedReaderId,
1987        seqno: SeqNo,
1988        outstanding_seqno: SeqNo,
1989        new_since: &Antichain<T>,
1990        heartbeat_timestamp_ms: u64,
1991    ) -> ControlFlow<NoOpStateTransition<Since<T>>, Since<T>> {
1992        // We expire all readers if the upper and since both advance to the
1993        // empty antichain. Gracefully handle this. At the same time,
1994        // short-circuit the cmd application so we don't needlessly create new
1995        // SeqNos.
1996        if self.is_tombstone() {
1997            return Break(NoOpStateTransition(Since(Antichain::new())));
1998        }
1999
2000        // The only way to have a missing reader in state is if it's been expired... and in that
2001        // case, we behave the same as though that reader had been downgraded to the empty antichain.
2002        let Some(reader_state) = self.leased_reader(reader_id) else {
2003            tracing::warn!(
2004                "Leased reader {reader_id} was expired due to inactivity. Did the machine go to sleep?",
2005            );
2006            return Break(NoOpStateTransition(Since(Antichain::new())));
2007        };
2008
2009        // Also use this as an opportunity to heartbeat the reader and downgrade
2010        // the seqno capability.
2011        reader_state.last_heartbeat_timestamp_ms = std::cmp::max(
2012            heartbeat_timestamp_ms,
2013            reader_state.last_heartbeat_timestamp_ms,
2014        );
2015
2016        let seqno = {
2017            assert!(
2018                outstanding_seqno >= reader_state.seqno,
2019                "SeqNos cannot go backward; however, oldest leased SeqNo ({:?}) \
2020                    is behind current reader_state ({:?})",
2021                outstanding_seqno,
2022                reader_state.seqno,
2023            );
2024            std::cmp::min(outstanding_seqno, seqno)
2025        };
2026
2027        reader_state.seqno = seqno;
2028
2029        let reader_current_since = if PartialOrder::less_than(&reader_state.since, new_since) {
2030            reader_state.since.clone_from(new_since);
2031            self.update_since();
2032            new_since.clone()
2033        } else {
2034            // No-op, but still commit the state change so that this gets
2035            // linearized.
2036            reader_state.since.clone()
2037        };
2038
2039        Continue(Since(reader_current_since))
2040    }
2041
2042    pub fn compare_and_downgrade_since(
2043        &mut self,
2044        reader_id: &CriticalReaderId,
2045        expected_opaque: &Opaque,
2046        (new_opaque, new_since): (&Opaque, &Antichain<T>),
2047    ) -> ControlFlow<
2048        NoOpStateTransition<Result<Since<T>, (Opaque, Since<T>)>>,
2049        Result<Since<T>, (Opaque, Since<T>)>,
2050    > {
2051        // We expire all readers if the upper and since both advance to the
2052        // empty antichain. Gracefully handle this. At the same time,
2053        // short-circuit the cmd application so we don't needlessly create new
2054        // SeqNos.
2055        if self.is_tombstone() {
2056            // Match the idempotence behavior below of ignoring the token if
2057            // since is already advanced enough (in this case, because it's a
2058            // tombstone, we know it's the empty antichain).
2059            return Break(NoOpStateTransition(Ok(Since(Antichain::new()))));
2060        }
2061
2062        let reader_state = self.critical_reader(reader_id);
2063
2064        if reader_state.opaque != *expected_opaque {
2065            // No-op, but still commit the state change so that this gets
2066            // linearized.
2067            return Continue(Err((
2068                reader_state.opaque.clone(),
2069                Since(reader_state.since.clone()),
2070            )));
2071        }
2072
2073        reader_state.opaque = new_opaque.clone();
2074        if PartialOrder::less_equal(&reader_state.since, new_since) {
2075            reader_state.since.clone_from(new_since);
2076            self.update_since();
2077            Continue(Ok(Since(new_since.clone())))
2078        } else {
2079            // no work to be done -- the reader state's `since` is already sufficiently
2080            // advanced. we may someday need to revisit this branch when it's possible
2081            // for two `since` frontiers to be incomparable.
2082            Continue(Ok(Since(reader_state.since.clone())))
2083        }
2084    }
2085
2086    pub fn expire_leased_reader(
2087        &mut self,
2088        reader_id: &LeasedReaderId,
2089    ) -> ControlFlow<NoOpStateTransition<bool>, bool> {
2090        // We expire all readers if the upper and since both advance to the
2091        // empty antichain. Gracefully handle this. At the same time,
2092        // short-circuit the cmd application so we don't needlessly create new
2093        // SeqNos.
2094        if self.is_tombstone() {
2095            return Break(NoOpStateTransition(false));
2096        }
2097
2098        let existed = self.leased_readers.remove(reader_id).is_some();
2099        if existed {
2100            // TODO(database-issues#6885): Re-enable this
2101            //
2102            // Temporarily disabling this because we think it might be the cause
2103            // of the remap since bug. Specifically, a clusterd process has a
2104            // ReadHandle for maintaining the once and one inside a Listen. If
2105            // we crash and stay down for longer than the read lease duration,
2106            // it's possible that an expiry of them both in quick succession
2107            // jumps the since forward to the Listen one.
2108            //
2109            // Don't forget to update the downgrade_since when this gets
2110            // switched back on.
2111            //
2112            // self.update_since();
2113        }
2114        // No-op if existed is false, but still commit the state change so that
2115        // this gets linearized.
2116        Continue(existed)
2117    }
2118
2119    pub fn expire_critical_reader(
2120        &mut self,
2121        reader_id: &CriticalReaderId,
2122    ) -> ControlFlow<NoOpStateTransition<bool>, bool> {
2123        // We expire all readers if the upper and since both advance to the
2124        // empty antichain. Gracefully handle this. At the same time,
2125        // short-circuit the cmd application so we don't needlessly create new
2126        // SeqNos.
2127        if self.is_tombstone() {
2128            return Break(NoOpStateTransition(false));
2129        }
2130
2131        let existed = self.critical_readers.remove(reader_id).is_some();
2132        if existed {
2133            // TODO(database-issues#6885): Re-enable this
2134            //
2135            // Temporarily disabling this because we think it might be the cause
2136            // of the remap since bug. Specifically, a clusterd process has a
2137            // ReadHandle for maintaining the once and one inside a Listen. If
2138            // we crash and stay down for longer than the read lease duration,
2139            // it's possible that an expiry of them both in quick succession
2140            // jumps the since forward to the Listen one.
2141            //
2142            // Don't forget to update the downgrade_since when this gets
2143            // switched back on.
2144            //
2145            // self.update_since();
2146        }
2147        // This state transition is a no-op if existed is false, but we still
2148        // commit the state change so that this gets linearized (maybe we're
2149        // looking at old state).
2150        Continue(existed)
2151    }
2152
2153    pub fn expire_writer(
2154        &mut self,
2155        writer_id: &WriterId,
2156    ) -> ControlFlow<NoOpStateTransition<bool>, bool> {
2157        // We expire all writers if the upper and since both advance to the
2158        // empty antichain. Gracefully handle this. At the same time,
2159        // short-circuit the cmd application so we don't needlessly create new
2160        // SeqNos.
2161        if self.is_tombstone() {
2162            return Break(NoOpStateTransition(false));
2163        }
2164
2165        let existed = self.writers.remove(writer_id).is_some();
2166        // This state transition is a no-op if existed is false, but we still
2167        // commit the state change so that this gets linearized (maybe we're
2168        // looking at old state).
2169        Continue(existed)
2170    }
2171
2172    fn leased_reader(&mut self, id: &LeasedReaderId) -> Option<&mut LeasedReaderState<T>> {
2173        self.leased_readers.get_mut(id)
2174    }
2175
2176    fn critical_reader(&mut self, id: &CriticalReaderId) -> &mut CriticalReaderState<T> {
2177        self.critical_readers
2178            .get_mut(id)
2179            .unwrap_or_else(|| {
2180                panic!(
2181                    "Unknown CriticalReaderId({}). It was either never registered, or has been manually expired.",
2182                    id
2183                )
2184            })
2185    }
2186
2187    fn critical_since(&self) -> Option<Antichain<T>> {
2188        let mut critical_sinces = self.critical_readers.values().map(|r| &r.since);
2189        let mut since = critical_sinces.next().cloned()?;
2190        for s in critical_sinces {
2191            since.meet_assign(s);
2192        }
2193        Some(since)
2194    }
2195
2196    fn update_since(&mut self) {
2197        let mut sinces_iter = self
2198            .leased_readers
2199            .values()
2200            .map(|x| &x.since)
2201            .chain(self.critical_readers.values().map(|x| &x.since));
2202        let mut since = match sinces_iter.next() {
2203            Some(since) => since.clone(),
2204            None => {
2205                // If there are no current readers, leave `since` unchanged so
2206                // it doesn't regress.
2207                return;
2208            }
2209        };
2210        while let Some(s) = sinces_iter.next() {
2211            since.meet_assign(s);
2212        }
2213        self.trace.downgrade_since(&since);
2214    }
2215
2216    fn seqno_since(&self, seqno: SeqNo) -> SeqNo {
2217        let mut seqno_since = seqno;
2218        for cap in self.leased_readers.values() {
2219            seqno_since = std::cmp::min(seqno_since, cap.seqno);
2220        }
2221        // critical_readers don't hold a seqno capability.
2222        seqno_since
2223    }
2224
2225    fn tombstone_batch() -> HollowBatch<T> {
2226        HollowBatch::empty(Description::new(
2227            Antichain::from_elem(T::minimum()),
2228            Antichain::new(),
2229            Antichain::new(),
2230        ))
2231    }
2232
2233    pub(crate) fn is_tombstone(&self) -> bool {
2234        self.trace.upper().is_empty()
2235            && self.trace.since().is_empty()
2236            && self.writers.is_empty()
2237            && self.leased_readers.is_empty()
2238            && self.critical_readers.is_empty()
2239    }
2240
2241    /// A tombstone that has shrunk to a single empty batch.
2242    pub(crate) fn is_finalized(&self) -> bool {
2243        self.is_tombstone() && self.is_single_empty_batch()
2244    }
2245
2246    pub(crate) fn is_single_empty_batch(&self) -> bool {
2247        let mut batch_count = 0;
2248        let mut is_empty = true;
2249        self.trace.map_batches(|b| {
2250            batch_count += 1;
2251            is_empty &= b.is_empty()
2252        });
2253        batch_count <= 1 && is_empty
2254    }
2255
2256    pub fn become_tombstone_and_shrink(&mut self) -> ControlFlow<NoOpStateTransition<()>, ()> {
2257        assert_eq!(self.trace.upper(), &Antichain::new());
2258        assert_eq!(self.trace.since(), &Antichain::new());
2259
2260        // Remember our current state, so we can decide whether we have to
2261        // record a transition in durable state.
2262        let was_tombstone = self.is_tombstone();
2263
2264        // Enter the "tombstone" state, if we're not in it already.
2265        self.writers.clear();
2266        self.leased_readers.clear();
2267        self.critical_readers.clear();
2268
2269        mz_ore::soft_assert_no_log!(self.is_tombstone());
2270
2271        // Now that we're in a "tombstone" state -- ie. nobody can read the data from a shard or write to
2272        // it -- the actual contents of our batches no longer matter.
2273        // This method progressively replaces batches in our state with simpler versions, to allow
2274        // freeing up resources and to reduce the state size. (Since the state is unreadable, this
2275        // is not visible to clients.) We do this a little bit at a time to avoid really large state
2276        // transitions... most operations happen incrementally, and large single writes can overwhelm
2277        // a backing store. See comments for why we believe the relevant diffs are reasonably small.
2278
2279        let mut to_replace = None;
2280        let mut batch_count = 0;
2281        self.trace.map_batches(|b| {
2282            batch_count += 1;
2283            if !b.is_empty() && to_replace.is_none() {
2284                to_replace = Some(b.desc.clone());
2285            }
2286        });
2287        if let Some(desc) = to_replace {
2288            // We have a nonempty batch: replace it with an empty batch and return.
2289            // This should not produce an excessively large diff: if it did, we wouldn't have been
2290            // able to append that batch in the first place.
2291            let result = self.trace.apply_tombstone_merge(&desc);
2292            assert!(
2293                result.matched(),
2294                "merge with a matching desc should always match"
2295            );
2296            Continue(())
2297        } else if batch_count > 1 {
2298            // All our batches are empty, but we have more than one of them. Replace the whole set
2299            // with a new single-batch trace.
2300            // This produces a diff with a size proportional to the number of batches, but since
2301            // Spine keeps a logarithmic number of batches this should never be excessively large.
2302            let mut new_trace = Trace::default();
2303            new_trace.downgrade_since(&Antichain::new());
2304            let merge_reqs = new_trace.push_batch(Self::tombstone_batch());
2305            assert_eq!(merge_reqs, Vec::new());
2306            self.trace = new_trace;
2307            Continue(())
2308        } else if !was_tombstone {
2309            // We were not tombstoned before, so have to make sure this state
2310            // transition is recorded.
2311            Continue(())
2312        } else {
2313            // All our batches are empty, and there's only one... there's no shrinking this
2314            // tombstone further.
2315            Break(NoOpStateTransition(()))
2316        }
2317    }
2318}
2319
2320// TODO: Document invariants.
2321#[derive(Debug)]
2322#[cfg_attr(any(test, debug_assertions), derive(Clone, PartialEq))]
2323pub struct State<T> {
2324    pub(crate) shard_id: ShardId,
2325
2326    pub(crate) seqno: SeqNo,
2327    /// A strictly increasing wall time of when this state was written, in
2328    /// milliseconds since the unix epoch.
2329    pub(crate) walltime_ms: u64,
2330    /// Hostname of the persist user that created this version of state. For
2331    /// debugging.
2332    pub(crate) hostname: String,
2333    pub(crate) collections: StateCollections<T>,
2334}
2335
2336/// A newtype wrapper of State that guarantees the K, V, and D codecs match the
2337/// ones in durable storage.
2338pub struct TypedState<K, V, T, D> {
2339    pub(crate) state: State<T>,
2340
2341    // According to the docs, PhantomData is to "mark things that act like they
2342    // own a T". State doesn't actually own K, V, or D, just the ability to
2343    // produce them. Using the `fn() -> T` pattern gets us the same variance as
2344    // T [1], but also allows State to correctly derive Send+Sync.
2345    //
2346    // [1]:
2347    //     https://doc.rust-lang.org/nomicon/phantom-data.html#table-of-phantomdata-patterns
2348    pub(crate) _phantom: PhantomData<fn() -> (K, V, D)>,
2349}
2350
2351impl<K, V, T: Clone, D> TypedState<K, V, T, D> {
2352    #[cfg(any(test, debug_assertions))]
2353    pub(crate) fn clone(&self, hostname: String) -> Self {
2354        TypedState {
2355            state: State {
2356                shard_id: self.shard_id.clone(),
2357                seqno: self.seqno.clone(),
2358                walltime_ms: self.walltime_ms,
2359                hostname,
2360                collections: self.collections.clone(),
2361            },
2362            _phantom: PhantomData,
2363        }
2364    }
2365
2366    pub(crate) fn clone_for_rollup(&self) -> Self {
2367        TypedState {
2368            state: State {
2369                shard_id: self.shard_id.clone(),
2370                seqno: self.seqno.clone(),
2371                walltime_ms: self.walltime_ms,
2372                hostname: self.hostname.clone(),
2373                collections: self.collections.clone(),
2374            },
2375            _phantom: PhantomData,
2376        }
2377    }
2378}
2379
2380impl<K, V, T: Debug, D> Debug for TypedState<K, V, T, D> {
2381    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2382        // Deconstruct self so we get a compile failure if new fields
2383        // are added.
2384        let TypedState { state, _phantom } = self;
2385        f.debug_struct("TypedState").field("state", state).finish()
2386    }
2387}
2388
2389// Impl PartialEq regardless of the type params.
2390#[cfg(any(test, debug_assertions))]
2391impl<K, V, T: PartialEq, D> PartialEq for TypedState<K, V, T, D> {
2392    fn eq(&self, other: &Self) -> bool {
2393        // Deconstruct self and other so we get a compile failure if new fields
2394        // are added.
2395        let TypedState {
2396            state: self_state,
2397            _phantom,
2398        } = self;
2399        let TypedState {
2400            state: other_state,
2401            _phantom,
2402        } = other;
2403        self_state == other_state
2404    }
2405}
2406
2407impl<K, V, T, D> Deref for TypedState<K, V, T, D> {
2408    type Target = State<T>;
2409
2410    fn deref(&self) -> &Self::Target {
2411        &self.state
2412    }
2413}
2414
2415impl<K, V, T, D> DerefMut for TypedState<K, V, T, D> {
2416    fn deref_mut(&mut self) -> &mut Self::Target {
2417        &mut self.state
2418    }
2419}
2420
2421impl<K, V, T, D> TypedState<K, V, T, D>
2422where
2423    K: Codec,
2424    V: Codec,
2425    T: Timestamp + Lattice + Codec64,
2426    D: Codec64,
2427{
2428    pub fn new(
2429        applier_version: Version,
2430        shard_id: ShardId,
2431        hostname: String,
2432        walltime_ms: u64,
2433    ) -> Self {
2434        let state = State {
2435            shard_id,
2436            seqno: SeqNo::minimum(),
2437            walltime_ms,
2438            hostname,
2439            collections: StateCollections {
2440                version: applier_version,
2441                last_gc_req: SeqNo::minimum(),
2442                rollups: BTreeMap::new(),
2443                active_rollup: None,
2444                active_gc: None,
2445                leased_readers: BTreeMap::new(),
2446                critical_readers: BTreeMap::new(),
2447                writers: BTreeMap::new(),
2448                schemas: BTreeMap::new(),
2449                trace: Trace::default(),
2450            },
2451        };
2452        TypedState {
2453            state,
2454            _phantom: PhantomData,
2455        }
2456    }
2457
2458    pub fn clone_apply<R, E, WorkFn>(
2459        &self,
2460        cfg: &PersistConfig,
2461        work_fn: &mut WorkFn,
2462    ) -> ControlFlow<E, (R, Self)>
2463    where
2464        WorkFn: FnMut(SeqNo, &PersistConfig, &mut StateCollections<T>) -> ControlFlow<E, R>,
2465    {
2466        // We do not increment the version by default, though work_fn can if it chooses to.
2467        let mut new_state = State {
2468            shard_id: self.shard_id,
2469            seqno: self.seqno.next(),
2470            walltime_ms: (cfg.now)(),
2471            hostname: cfg.hostname.clone(),
2472            collections: self.collections.clone(),
2473        };
2474
2475        // Make sure walltime_ms is strictly increasing, in case clocks are
2476        // offset.
2477        if new_state.walltime_ms <= self.walltime_ms {
2478            new_state.walltime_ms = self.walltime_ms + 1;
2479        }
2480
2481        let work_ret = work_fn(new_state.seqno, cfg, &mut new_state.collections)?;
2482        let new_state = TypedState {
2483            state: new_state,
2484            _phantom: PhantomData,
2485        };
2486        Continue((work_ret, new_state))
2487    }
2488}
2489
2490#[derive(Copy, Clone, Debug)]
2491pub struct GcConfig {
2492    pub use_active_gc: bool,
2493    pub fallback_threshold_ms: u64,
2494    pub min_versions: usize,
2495    pub max_versions: usize,
2496    /// Mirrors `persist_gc_remove_finalized_rollups`.
2497    pub remove_finalized_rollups: bool,
2498}
2499
2500impl<T> State<T>
2501where
2502    T: Timestamp + Lattice + Codec64,
2503{
2504    pub fn shard_id(&self) -> ShardId {
2505        self.shard_id
2506    }
2507
2508    pub fn seqno(&self) -> SeqNo {
2509        self.seqno
2510    }
2511
2512    pub fn since(&self) -> &Antichain<T> {
2513        self.collections.trace.since()
2514    }
2515
2516    pub fn upper(&self) -> &Antichain<T> {
2517        self.collections.trace.upper()
2518    }
2519
2520    pub fn spine_batch_count(&self) -> usize {
2521        self.collections.trace.num_spine_batches()
2522    }
2523
2524    pub fn size_metrics(&self) -> StateSizeMetrics {
2525        let mut ret = StateSizeMetrics::default();
2526        self.blobs().for_each(|x| match x {
2527            HollowBlobRef::Batch(x) => {
2528                ret.hollow_batch_count += 1;
2529                ret.batch_part_count += x.part_count();
2530                ret.num_updates += x.len;
2531
2532                let batch_size = x.encoded_size_bytes();
2533                for x in x.parts.iter() {
2534                    if x.is_inline() {
2535                        ret.inline_part_count += 1;
2536                    }
2537                }
2538                ret.largest_batch_bytes = std::cmp::max(ret.largest_batch_bytes, batch_size);
2539                ret.state_batches_bytes += batch_size;
2540            }
2541            HollowBlobRef::Rollup(x) => {
2542                ret.state_rollup_count += 1;
2543                ret.state_rollups_bytes += x.encoded_size_bytes.unwrap_or_default()
2544            }
2545        });
2546        ret
2547    }
2548
2549    pub fn latest_rollup(&self) -> (&SeqNo, &HollowRollup) {
2550        // We maintain the invariant that every version of state has at least
2551        // one rollup.
2552        self.collections
2553            .rollups
2554            .iter()
2555            .rev()
2556            .next()
2557            .expect("State should have at least one rollup if seqno > minimum")
2558    }
2559
2560    pub(crate) fn seqno_since(&self) -> SeqNo {
2561        self.collections.seqno_since(self.seqno)
2562    }
2563
2564    // Returns whether the cmd proposing this state has been selected to perform
2565    // background garbage collection work.
2566    //
2567    // If it was selected, this information is recorded in the state itself for
2568    // commit along with the cmd's state transition. This helps us to avoid
2569    // redundant work.
2570    //
2571    // Correctness does not depend on a gc assignment being executed, nor on
2572    // them being executed in the order they are given. But it is expected that
2573    // gc assignments are best-effort respected. In practice, cmds like
2574    // register_foo or expire_foo, where it would be awkward, ignore gc.
2575    pub fn maybe_gc(&mut self, is_write: bool, now: u64, cfg: GcConfig) -> Option<GcReq> {
2576        let GcConfig {
2577            use_active_gc,
2578            fallback_threshold_ms,
2579            min_versions,
2580            max_versions,
2581            remove_finalized_rollups,
2582        } = cfg;
2583        // This is an arbitrary-ish threshold that scales with seqno, but never
2584        // gets particularly big. It probably could be much bigger and certainly
2585        // could use a tuning pass at some point.
2586        let gc_threshold = if use_active_gc {
2587            u64::cast_from(min_versions)
2588        } else {
2589            std::cmp::max(
2590                1,
2591                u64::cast_from(self.seqno.0.next_power_of_two().trailing_zeros()),
2592            )
2593        };
2594        let new_seqno_since = self.seqno_since();
2595        // Collect until the new seqno since... or the old since plus the max number of versions,
2596        // whatever is less.
2597        let gc_until_seqno = new_seqno_since.min(SeqNo(
2598            self.collections
2599                .last_gc_req
2600                .0
2601                .saturating_add(u64::cast_from(max_versions)),
2602        ));
2603        let should_gc = new_seqno_since
2604            .0
2605            .saturating_sub(self.collections.last_gc_req.0)
2606            >= gc_threshold;
2607
2608        // If we wouldn't otherwise gc, check if we have an active gc. If we do, and
2609        // it's been a while since it started, we should gc.
2610        let should_gc = if use_active_gc && !should_gc {
2611            match self.collections.active_gc {
2612                Some(active_gc) => now.saturating_sub(active_gc.start_ms) > fallback_threshold_ms,
2613                None => false,
2614            }
2615        } else {
2616            should_gc
2617        };
2618        // Assign GC traffic preferentially to writers, falling back to anyone
2619        // generating new state versions if there are no writers.
2620        let should_gc = should_gc && (is_write || self.collections.writers.is_empty());
2621        // Always assign GC work to a tombstoned shard to have the chance to
2622        // clean up any residual blobs. This is safe (won't cause excess gc)
2623        // as a tombstone only commits a few rollups and, once finalized, the
2624        // `remove_rollups` that drop the older ones.
2625        let tombstone_needs_gc = self.collections.is_tombstone();
2626        let should_gc = should_gc || (tombstone_needs_gc && !remove_finalized_rollups);
2627        let should_gc = if use_active_gc {
2628            // If we have an active gc, we should only gc if the active gc is
2629            // sufficiently old. This is to avoid doing more gc work than
2630            // necessary.
2631            should_gc
2632                && match self.collections.active_gc {
2633                    Some(active) => now.saturating_sub(active.start_ms) > fallback_threshold_ms,
2634                    None => true,
2635                }
2636        } else {
2637            should_gc
2638        };
2639        // A no-op GC on a tombstone leaves `active_gc` set, which would block
2640        // GC on the final `add_rollup`. Nothing commits after that, so the old
2641        // rollups would leak.
2642        let should_gc = should_gc || (tombstone_needs_gc && remove_finalized_rollups);
2643        if should_gc {
2644            self.collections.last_gc_req = gc_until_seqno;
2645            Some(GcReq {
2646                shard_id: self.shard_id,
2647                new_seqno_since: gc_until_seqno,
2648            })
2649        } else {
2650            None
2651        }
2652    }
2653
2654    /// Return the number of gc-ineligible state versions.
2655    pub fn seqnos_held(&self) -> usize {
2656        usize::cast_from(self.seqno.0.saturating_sub(self.seqno_since().0))
2657    }
2658
2659    /// Expire all readers and writers up to the given walltime_ms.
2660    pub fn expire_at(&mut self, walltime_ms: EpochMillis) -> ExpiryMetrics {
2661        let mut metrics = ExpiryMetrics::default();
2662        let shard_id = self.shard_id();
2663        self.collections.leased_readers.retain(|id, state| {
2664            let retain = state.last_heartbeat_timestamp_ms + state.lease_duration_ms >= walltime_ms;
2665            if !retain {
2666                info!(
2667                    "Force expiring reader {id} ({}) of shard {shard_id} due to inactivity",
2668                    state.debug.purpose
2669                );
2670                metrics.readers_expired += 1;
2671            }
2672            retain
2673        });
2674        // critical_readers don't need forced expiration. (In fact, that's the point!)
2675        self.collections.writers.retain(|id, state| {
2676            let retain =
2677                (state.last_heartbeat_timestamp_ms + state.lease_duration_ms) >= walltime_ms;
2678            if !retain {
2679                info!(
2680                    "Force expiring writer {id} ({}) of shard {shard_id} due to inactivity",
2681                    state.debug.purpose
2682                );
2683                metrics.writers_expired += 1;
2684            }
2685            retain
2686        });
2687        metrics
2688    }
2689
2690    /// Returns the batches that contain updates up to (and including) the given `as_of`. The
2691    /// result `Vec` contains blob keys, along with a [`Description`] of what updates in the
2692    /// referenced parts are valid to read.
2693    pub fn snapshot(&self, as_of: &Antichain<T>) -> Result<Vec<HollowBatch<T>>, SnapshotErr<T>> {
2694        if PartialOrder::less_than(as_of, self.collections.trace.since()) {
2695            return Err(SnapshotErr::AsOfHistoricalDistinctionsLost(Since(
2696                self.collections.trace.since().clone(),
2697            )));
2698        }
2699        let upper = self.collections.trace.upper();
2700        if PartialOrder::less_equal(upper, as_of) {
2701            return Err(SnapshotErr::AsOfNotYetAvailable(
2702                self.seqno,
2703                Upper(upper.clone()),
2704            ));
2705        }
2706
2707        let batches = self
2708            .collections
2709            .trace
2710            .batches()
2711            .filter(|b| !PartialOrder::less_than(as_of, b.desc.lower()))
2712            .cloned()
2713            .collect();
2714        Ok(batches)
2715    }
2716
2717    // NB: Unlike the other methods here, this one is read-only.
2718    pub fn verify_listen(&self, as_of: &Antichain<T>) -> Result<(), Since<T>> {
2719        if PartialOrder::less_than(as_of, self.collections.trace.since()) {
2720            return Err(Since(self.collections.trace.since().clone()));
2721        }
2722        Ok(())
2723    }
2724
2725    pub fn next_listen_batch(&self, frontier: &Antichain<T>) -> Result<HollowBatch<T>, SeqNo> {
2726        // TODO: Avoid the O(n^2) here: `next_listen_batch` is called once per
2727        // batch and this iterates through all batches to find the next one.
2728        self.collections
2729            .trace
2730            .batches()
2731            .find(|b| {
2732                PartialOrder::less_equal(b.desc.lower(), frontier)
2733                    && PartialOrder::less_than(frontier, b.desc.upper())
2734            })
2735            .cloned()
2736            .ok_or(self.seqno)
2737    }
2738
2739    pub fn active_rollup(&self) -> Option<ActiveRollup> {
2740        self.collections.active_rollup
2741    }
2742
2743    pub fn need_rollup(
2744        &self,
2745        threshold: usize,
2746        use_active_rollup: bool,
2747        fallback_threshold_ms: u64,
2748        now: u64,
2749    ) -> Option<SeqNo> {
2750        let (latest_rollup_seqno, _) = self.latest_rollup();
2751
2752        // Tombstoned shards require one final rollup. However, because we
2753        // write a rollup as of SeqNo X and then link it in using a state
2754        // transition (in this case from X to X+1), the minimum number of
2755        // live diffs is actually two. Detect when we're in this minimal
2756        // two diff state and stop the (otherwise) infinite iteration.
2757        if self.collections.is_tombstone() && latest_rollup_seqno.next() < self.seqno {
2758            return Some(self.seqno);
2759        }
2760
2761        let seqnos_since_last_rollup = self.seqno.0.saturating_sub(latest_rollup_seqno.0);
2762
2763        if use_active_rollup {
2764            // If sequnos_since_last_rollup>threshold, and there is no existing rollup in progress,
2765            // we should start a new rollup.
2766            // If there is an active rollup, we should check if it has been running too long.
2767            // If it has, we should start a new rollup.
2768            // This is to guard against a worker dying/taking too long/etc.
2769            if seqnos_since_last_rollup > u64::cast_from(threshold) {
2770                match self.active_rollup() {
2771                    Some(active_rollup) => {
2772                        if now.saturating_sub(active_rollup.start_ms) > fallback_threshold_ms {
2773                            return Some(self.seqno);
2774                        }
2775                    }
2776                    None => {
2777                        return Some(self.seqno);
2778                    }
2779                }
2780            }
2781        } else {
2782            // every `threshold` seqnos since the latest rollup, assign rollup maintenance.
2783            // we avoid assigning rollups to every seqno past the threshold to avoid handles
2784            // racing / performing redundant work.
2785            if seqnos_since_last_rollup > 0
2786                && seqnos_since_last_rollup % u64::cast_from(threshold) == 0
2787            {
2788                return Some(self.seqno);
2789            }
2790
2791            // however, since maintenance is best-effort and could fail, do assign rollup
2792            // work to every seqno after a fallback threshold to ensure one is written.
2793            if seqnos_since_last_rollup
2794                > u64::cast_from(
2795                    threshold * PersistConfig::DEFAULT_FALLBACK_ROLLUP_THRESHOLD_MULTIPLIER,
2796                )
2797            {
2798                return Some(self.seqno);
2799            }
2800        }
2801
2802        None
2803    }
2804
2805    pub(crate) fn blobs(&self) -> impl Iterator<Item = HollowBlobRef<'_, T>> {
2806        let batches = self.collections.trace.batches().map(HollowBlobRef::Batch);
2807        let rollups = self.collections.rollups.values().map(HollowBlobRef::Rollup);
2808        batches.chain(rollups)
2809    }
2810}
2811
2812fn serialize_part_bytes<S: Serializer>(val: &[u8], s: S) -> Result<S::Ok, S::Error> {
2813    let val = hex::encode(val);
2814    val.serialize(s)
2815}
2816
2817fn serialize_lazy_proto<S: Serializer, T: prost::Message + Default>(
2818    val: &Option<LazyProto<T>>,
2819    s: S,
2820) -> Result<S::Ok, S::Error> {
2821    val.as_ref()
2822        .map(|lazy| hex::encode(&lazy.into_proto()))
2823        .serialize(s)
2824}
2825
2826fn serialize_part_stats<S: Serializer>(
2827    val: &Option<LazyPartStats>,
2828    s: S,
2829) -> Result<S::Ok, S::Error> {
2830    // These bytes come from blob and are never validated on the way in, so a
2831    // malformed or newer-version encoding reaches here intact. Report it as
2832    // absent rather than panicking, and keep the field's shape stable for
2833    // consumers of the inspect-state output by logging the failure instead of
2834    // serializing a differently typed value in its place.
2835    let stats = val.as_ref().and_then(|x| match x.try_decode() {
2836        Ok(stats) => Some(stats.key),
2837        Err(err) => {
2838            tracing::warn!("undecodable part stats, reporting as absent: {err}");
2839            None
2840        }
2841    });
2842    stats.serialize(s)
2843}
2844
2845fn serialize_diffs_sum<S: Serializer>(val: &Option<[u8; 8]>, s: S) -> Result<S::Ok, S::Error> {
2846    // This is only used for debugging, so hack to assume that D is i64.
2847    let val = val.map(i64::decode);
2848    val.serialize(s)
2849}
2850
2851// This Serialize impl is used for debugging/testing and exposed via SQL. It's
2852// intentionally gated from users, so not strictly subject to our backward
2853// compatibility guarantees, but still probably best to be thoughtful about
2854// making unnecessary changes. Additionally, it's nice to make the output as
2855// nice to use as possible without tying our hands for the actual code usages.
2856impl<T: Serialize + Timestamp + Lattice> Serialize for State<T> {
2857    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2858        let State {
2859            shard_id,
2860            seqno,
2861            walltime_ms,
2862            hostname,
2863            collections:
2864                StateCollections {
2865                    version: applier_version,
2866                    last_gc_req,
2867                    rollups,
2868                    active_rollup,
2869                    active_gc,
2870                    leased_readers,
2871                    critical_readers,
2872                    writers,
2873                    schemas,
2874                    trace,
2875                },
2876        } = self;
2877        let mut s = s.serialize_struct("State", 13)?;
2878        let () = s.serialize_field("applier_version", &applier_version.to_string())?;
2879        let () = s.serialize_field("shard_id", shard_id)?;
2880        let () = s.serialize_field("seqno", seqno)?;
2881        let () = s.serialize_field("walltime_ms", walltime_ms)?;
2882        let () = s.serialize_field("hostname", hostname)?;
2883        let () = s.serialize_field("last_gc_req", last_gc_req)?;
2884        let () = s.serialize_field("rollups", rollups)?;
2885        let () = s.serialize_field("active_rollup", active_rollup)?;
2886        let () = s.serialize_field("active_gc", active_gc)?;
2887        let () = s.serialize_field("leased_readers", leased_readers)?;
2888        let () = s.serialize_field("critical_readers", critical_readers)?;
2889        let () = s.serialize_field("writers", writers)?;
2890        let () = s.serialize_field("schemas", schemas)?;
2891        let () = s.serialize_field("since", &trace.since().elements())?;
2892        let () = s.serialize_field("upper", &trace.upper().elements())?;
2893        let trace = trace.flatten();
2894        let () = s.serialize_field("batches", &trace.legacy_batches.keys().collect::<Vec<_>>())?;
2895        let () = s.serialize_field("hollow_batches", &trace.hollow_batches)?;
2896        let () = s.serialize_field("spine_batches", &trace.spine_batches)?;
2897        let () = s.serialize_field("merges", &trace.merges)?;
2898        s.end()
2899    }
2900}
2901
2902#[derive(Debug, Default)]
2903pub struct StateSizeMetrics {
2904    pub hollow_batch_count: usize,
2905    pub batch_part_count: usize,
2906    pub num_updates: usize,
2907    pub largest_batch_bytes: usize,
2908    pub state_batches_bytes: usize,
2909    pub state_rollups_bytes: usize,
2910    pub state_rollup_count: usize,
2911    pub inline_part_count: usize,
2912}
2913
2914#[derive(Default)]
2915pub struct ExpiryMetrics {
2916    pub(crate) readers_expired: usize,
2917    pub(crate) writers_expired: usize,
2918}
2919
2920/// Wrapper for Antichain that represents a Since
2921#[derive(Debug, Clone, PartialEq)]
2922pub struct Since<T>(pub Antichain<T>);
2923
2924/// Wrapper for Antichain that represents an Upper
2925#[derive(Debug, PartialEq)]
2926pub struct Upper<T>(pub Antichain<T>);
2927
2928#[cfg(test)]
2929pub(crate) mod tests {
2930    use std::ops::Range;
2931    use std::str::FromStr;
2932
2933    use bytes::Bytes;
2934    use mz_build_info::DUMMY_BUILD_INFO;
2935    use mz_dyncfg::ConfigUpdates;
2936    use mz_ore::now::SYSTEM_TIME;
2937    use mz_ore::{assert_none, assert_ok};
2938    use mz_proto::RustType;
2939    use proptest::prelude::*;
2940    use proptest::strategy::ValueTree;
2941
2942    use crate::InvalidUsage::{InvalidBounds, InvalidEmptyTimeInterval};
2943    use crate::cache::PersistClientCache;
2944    use crate::internal::encoding::any_some_lazy_part_stats;
2945    use crate::internal::paths::RollupId;
2946    use crate::internal::trace::tests::any_trace;
2947    use crate::tests::new_test_client_cache;
2948    use crate::{Diagnostics, PersistLocation};
2949
2950    use super::*;
2951
2952    const LEASE_DURATION_MS: u64 = 900 * 1000;
2953    fn debug_state() -> HandleDebugState {
2954        HandleDebugState {
2955            hostname: "debug".to_owned(),
2956            purpose: "finding the bugs".to_owned(),
2957        }
2958    }
2959
2960    pub fn any_hollow_batch_with_exact_runs<T: Arbitrary + Timestamp>(
2961        num_runs: usize,
2962    ) -> impl Strategy<Value = HollowBatch<T>> {
2963        (
2964            any::<T>(),
2965            any::<T>(),
2966            any::<T>(),
2967            proptest::collection::vec(any_run_part::<T>(), num_runs + 1..20),
2968            any::<usize>(),
2969        )
2970            .prop_map(move |(t0, t1, since, parts, len)| {
2971                let (lower, upper) = if t0 <= t1 {
2972                    (Antichain::from_elem(t0), Antichain::from_elem(t1))
2973                } else {
2974                    (Antichain::from_elem(t1), Antichain::from_elem(t0))
2975                };
2976                let since = Antichain::from_elem(since);
2977
2978                let run_splits = (1..num_runs)
2979                    .map(|i| i * parts.len() / num_runs)
2980                    .collect::<Vec<_>>();
2981
2982                let run_meta = (0..num_runs)
2983                    .map(|_| {
2984                        let mut meta = RunMeta::default();
2985                        meta.id = Some(RunId::new());
2986                        meta
2987                    })
2988                    .collect::<Vec<_>>();
2989
2990                HollowBatch::new(
2991                    Description::new(lower, upper, since),
2992                    parts,
2993                    len % 10,
2994                    run_meta,
2995                    run_splits,
2996                )
2997            })
2998    }
2999
3000    pub fn any_hollow_batch<T: Arbitrary + Timestamp>() -> impl Strategy<Value = HollowBatch<T>> {
3001        Strategy::prop_map(
3002            (
3003                any::<T>(),
3004                any::<T>(),
3005                any::<T>(),
3006                proptest::collection::vec(any_run_part::<T>(), 0..20),
3007                any::<usize>(),
3008                0..=10usize,
3009                proptest::collection::vec(any::<RunId>(), 10),
3010            ),
3011            |(t0, t1, since, parts, len, num_runs, run_ids)| {
3012                let (lower, upper) = if t0 <= t1 {
3013                    (Antichain::from_elem(t0), Antichain::from_elem(t1))
3014                } else {
3015                    (Antichain::from_elem(t1), Antichain::from_elem(t0))
3016                };
3017                let since = Antichain::from_elem(since);
3018                if num_runs > 0 && parts.len() > 2 && num_runs < parts.len() {
3019                    let run_splits = (1..num_runs)
3020                        .map(|i| i * parts.len() / num_runs)
3021                        .collect::<Vec<_>>();
3022
3023                    let run_meta = (0..num_runs)
3024                        .enumerate()
3025                        .map(|(i, _)| {
3026                            let mut meta = RunMeta::default();
3027                            meta.id = Some(run_ids[i]);
3028                            meta
3029                        })
3030                        .collect::<Vec<_>>();
3031
3032                    HollowBatch::new(
3033                        Description::new(lower, upper, since),
3034                        parts,
3035                        len % 10,
3036                        run_meta,
3037                        run_splits,
3038                    )
3039                } else {
3040                    HollowBatch::new_run_for_test(
3041                        Description::new(lower, upper, since),
3042                        parts,
3043                        len % 10,
3044                        run_ids[0],
3045                    )
3046                }
3047            },
3048        )
3049    }
3050
3051    pub fn any_batch_part<T: Arbitrary + Timestamp>() -> impl Strategy<Value = BatchPart<T>> {
3052        Strategy::prop_map(
3053            (
3054                any::<bool>(),
3055                any_hollow_batch_part(),
3056                any::<Option<T>>(),
3057                any::<Option<SchemaId>>(),
3058                any::<Option<SchemaId>>(),
3059            ),
3060            |(is_hollow, hollow, ts_rewrite, schema_id, deprecated_schema_id)| {
3061                if is_hollow {
3062                    BatchPart::Hollow(hollow)
3063                } else {
3064                    let updates = LazyInlineBatchPart::from_proto(Bytes::new()).unwrap();
3065                    let ts_rewrite = ts_rewrite.map(Antichain::from_elem);
3066                    BatchPart::Inline {
3067                        updates,
3068                        ts_rewrite,
3069                        schema_id,
3070                        deprecated_schema_id,
3071                    }
3072                }
3073            },
3074        )
3075    }
3076
3077    pub fn any_run_part<T: Arbitrary + Timestamp>() -> impl Strategy<Value = RunPart<T>> {
3078        Strategy::prop_map(any_batch_part(), |part| RunPart::Single(part))
3079    }
3080
3081    pub fn any_hollow_batch_part<T: Arbitrary + Timestamp>()
3082    -> impl Strategy<Value = HollowBatchPart<T>> {
3083        Strategy::prop_map(
3084            (
3085                any::<PartialBatchKey>(),
3086                any::<usize>(),
3087                any::<Vec<u8>>(),
3088                any_some_lazy_part_stats(),
3089                any::<Option<T>>(),
3090                any::<[u8; 8]>(),
3091                any::<Option<BatchColumnarFormat>>(),
3092                any::<Option<SchemaId>>(),
3093                any::<Option<SchemaId>>(),
3094            ),
3095            |(
3096                key,
3097                encoded_size_bytes,
3098                key_lower,
3099                stats,
3100                ts_rewrite,
3101                diffs_sum,
3102                format,
3103                schema_id,
3104                deprecated_schema_id,
3105            )| {
3106                HollowBatchPart {
3107                    key,
3108                    meta: Default::default(),
3109                    encoded_size_bytes,
3110                    key_lower,
3111                    structured_key_lower: None,
3112                    stats,
3113                    ts_rewrite: ts_rewrite.map(Antichain::from_elem),
3114                    diffs_sum: Some(diffs_sum),
3115                    format,
3116                    schema_id,
3117                    deprecated_schema_id,
3118                }
3119            },
3120        )
3121    }
3122
3123    pub fn any_leased_reader_state<T: Arbitrary>() -> impl Strategy<Value = LeasedReaderState<T>> {
3124        Strategy::prop_map(
3125            (
3126                any::<SeqNo>(),
3127                any::<Option<T>>(),
3128                any::<u64>(),
3129                any::<u64>(),
3130                any::<HandleDebugState>(),
3131            ),
3132            |(seqno, since, last_heartbeat_timestamp_ms, mut lease_duration_ms, debug)| {
3133                // lease_duration_ms of 0 means this state was written by an old
3134                // version of code, which means we'll migrate it in the decode
3135                // path. Avoid.
3136                if lease_duration_ms == 0 {
3137                    lease_duration_ms += 1;
3138                }
3139                LeasedReaderState {
3140                    seqno,
3141                    since: since.map_or_else(Antichain::new, Antichain::from_elem),
3142                    last_heartbeat_timestamp_ms,
3143                    lease_duration_ms,
3144                    debug,
3145                }
3146            },
3147        )
3148    }
3149
3150    pub fn any_critical_reader_state<T>() -> impl Strategy<Value = CriticalReaderState<T>>
3151    where
3152        T: Arbitrary,
3153    {
3154        Strategy::prop_map(
3155            (
3156                any::<Option<T>>(),
3157                any::<Opaque>(),
3158                any::<HandleDebugState>(),
3159            ),
3160            |(since, opaque, debug)| CriticalReaderState {
3161                since: since.map_or_else(Antichain::new, Antichain::from_elem),
3162                opaque,
3163                debug,
3164            },
3165        )
3166    }
3167
3168    pub fn any_writer_state<T: Arbitrary>() -> impl Strategy<Value = WriterState<T>> {
3169        Strategy::prop_map(
3170            (
3171                any::<u64>(),
3172                any::<u64>(),
3173                any::<IdempotencyToken>(),
3174                any::<Option<T>>(),
3175                any::<HandleDebugState>(),
3176            ),
3177            |(
3178                last_heartbeat_timestamp_ms,
3179                lease_duration_ms,
3180                most_recent_write_token,
3181                most_recent_write_upper,
3182                debug,
3183            )| WriterState {
3184                last_heartbeat_timestamp_ms,
3185                lease_duration_ms,
3186                most_recent_write_token,
3187                most_recent_write_upper: most_recent_write_upper
3188                    .map_or_else(Antichain::new, Antichain::from_elem),
3189                debug,
3190            },
3191        )
3192    }
3193
3194    pub fn any_encoded_schemas() -> impl Strategy<Value = EncodedSchemas> {
3195        Strategy::prop_map(
3196            (
3197                any::<Vec<u8>>(),
3198                any::<Vec<u8>>(),
3199                any::<Vec<u8>>(),
3200                any::<Vec<u8>>(),
3201            ),
3202            |(key, key_data_type, val, val_data_type)| EncodedSchemas {
3203                key: Bytes::from(key),
3204                key_data_type: Bytes::from(key_data_type),
3205                val: Bytes::from(val),
3206                val_data_type: Bytes::from(val_data_type),
3207            },
3208        )
3209    }
3210
3211    pub fn any_state<T: Arbitrary + Timestamp + Lattice>(
3212        num_trace_batches: Range<usize>,
3213    ) -> impl Strategy<Value = State<T>> {
3214        let part1 = (
3215            any::<ShardId>(),
3216            any::<SeqNo>(),
3217            any::<u64>(),
3218            any::<String>(),
3219            any::<SeqNo>(),
3220            proptest::collection::btree_map(any::<SeqNo>(), any::<HollowRollup>(), 1..3),
3221            proptest::option::of(any::<ActiveRollup>()),
3222        );
3223
3224        let part2 = (
3225            proptest::option::of(any::<ActiveGc>()),
3226            proptest::collection::btree_map(
3227                any::<LeasedReaderId>(),
3228                any_leased_reader_state::<T>(),
3229                1..3,
3230            ),
3231            proptest::collection::btree_map(
3232                any::<CriticalReaderId>(),
3233                any_critical_reader_state::<T>(),
3234                1..3,
3235            ),
3236            proptest::collection::btree_map(any::<WriterId>(), any_writer_state::<T>(), 0..3),
3237            proptest::collection::btree_map(any::<SchemaId>(), any_encoded_schemas(), 0..3),
3238            any_trace::<T>(num_trace_batches),
3239        );
3240
3241        (part1, part2).prop_map(
3242            |(
3243                (shard_id, seqno, walltime_ms, hostname, last_gc_req, rollups, active_rollup),
3244                (active_gc, leased_readers, critical_readers, writers, schemas, trace),
3245            )| State {
3246                shard_id,
3247                seqno,
3248                walltime_ms,
3249                hostname,
3250                collections: StateCollections {
3251                    version: Version::new(1, 2, 3),
3252                    last_gc_req,
3253                    rollups,
3254                    active_rollup,
3255                    active_gc,
3256                    leased_readers,
3257                    critical_readers,
3258                    writers,
3259                    schemas,
3260                    trace,
3261                },
3262            },
3263        )
3264    }
3265
3266    pub(crate) fn hollow<T: Timestamp>(
3267        lower: T,
3268        upper: T,
3269        keys: &[&str],
3270        len: usize,
3271    ) -> HollowBatch<T> {
3272        HollowBatch::new_run(
3273            Description::new(
3274                Antichain::from_elem(lower),
3275                Antichain::from_elem(upper),
3276                Antichain::from_elem(T::minimum()),
3277            ),
3278            keys.iter()
3279                .map(|x| {
3280                    RunPart::Single(BatchPart::Hollow(HollowBatchPart {
3281                        key: PartialBatchKey((*x).to_owned()),
3282                        meta: Default::default(),
3283                        encoded_size_bytes: 0,
3284                        key_lower: vec![],
3285                        structured_key_lower: None,
3286                        stats: None,
3287                        ts_rewrite: None,
3288                        diffs_sum: None,
3289                        format: None,
3290                        schema_id: None,
3291                        deprecated_schema_id: None,
3292                    }))
3293                })
3294                .collect(),
3295            len,
3296        )
3297    }
3298
3299    #[mz_ore::test]
3300    fn downgrade_since() {
3301        let mut state = TypedState::<(), (), u64, i64>::new(
3302            DUMMY_BUILD_INFO.semver_version(),
3303            ShardId::new(),
3304            "".to_owned(),
3305            0,
3306        );
3307        let reader = LeasedReaderId::new();
3308        let seqno = SeqNo::minimum();
3309        let now = SYSTEM_TIME.clone();
3310        let _ = state.collections.register_leased_reader(
3311            "",
3312            &reader,
3313            "",
3314            seqno,
3315            Duration::from_secs(10),
3316            now(),
3317            false,
3318        );
3319
3320        // The shard global since == 0 initially.
3321        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(0));
3322
3323        // Greater
3324        assert_eq!(
3325            state.collections.downgrade_since(
3326                &reader,
3327                seqno,
3328                seqno,
3329                &Antichain::from_elem(2),
3330                now()
3331            ),
3332            Continue(Since(Antichain::from_elem(2)))
3333        );
3334        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(2));
3335        // Equal (no-op)
3336        assert_eq!(
3337            state.collections.downgrade_since(
3338                &reader,
3339                seqno,
3340                seqno,
3341                &Antichain::from_elem(2),
3342                now()
3343            ),
3344            Continue(Since(Antichain::from_elem(2)))
3345        );
3346        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(2));
3347        // Less (no-op)
3348        assert_eq!(
3349            state.collections.downgrade_since(
3350                &reader,
3351                seqno,
3352                seqno,
3353                &Antichain::from_elem(1),
3354                now()
3355            ),
3356            Continue(Since(Antichain::from_elem(2)))
3357        );
3358        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(2));
3359
3360        // Create a second reader.
3361        let reader2 = LeasedReaderId::new();
3362        let _ = state.collections.register_leased_reader(
3363            "",
3364            &reader2,
3365            "",
3366            seqno,
3367            Duration::from_secs(10),
3368            now(),
3369            false,
3370        );
3371
3372        // Shard since doesn't change until the meet (min) of all reader sinces changes.
3373        assert_eq!(
3374            state.collections.downgrade_since(
3375                &reader2,
3376                seqno,
3377                seqno,
3378                &Antichain::from_elem(3),
3379                now()
3380            ),
3381            Continue(Since(Antichain::from_elem(3)))
3382        );
3383        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(2));
3384        // Shard since == 3 when all readers have since >= 3.
3385        assert_eq!(
3386            state.collections.downgrade_since(
3387                &reader,
3388                seqno,
3389                seqno,
3390                &Antichain::from_elem(5),
3391                now()
3392            ),
3393            Continue(Since(Antichain::from_elem(5)))
3394        );
3395        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(3));
3396
3397        // Shard since unaffected readers with since > shard since expiring.
3398        assert_eq!(
3399            state.collections.expire_leased_reader(&reader),
3400            Continue(true)
3401        );
3402        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(3));
3403
3404        // Create a third reader.
3405        let reader3 = LeasedReaderId::new();
3406        let _ = state.collections.register_leased_reader(
3407            "",
3408            &reader3,
3409            "",
3410            seqno,
3411            Duration::from_secs(10),
3412            now(),
3413            false,
3414        );
3415
3416        // Shard since doesn't change until the meet (min) of all reader sinces changes.
3417        assert_eq!(
3418            state.collections.downgrade_since(
3419                &reader3,
3420                seqno,
3421                seqno,
3422                &Antichain::from_elem(10),
3423                now()
3424            ),
3425            Continue(Since(Antichain::from_elem(10)))
3426        );
3427        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(3));
3428
3429        // Shard since advances when reader with the minimal since expires.
3430        assert_eq!(
3431            state.collections.expire_leased_reader(&reader2),
3432            Continue(true)
3433        );
3434        // TODO(database-issues#6885): expiry temporarily doesn't advance since
3435        // Switch this assertion back when we re-enable this.
3436        //
3437        // assert_eq!(state.collections.trace.since(), &Antichain::from_elem(10));
3438        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(3));
3439
3440        // Shard since unaffected when all readers are expired.
3441        assert_eq!(
3442            state.collections.expire_leased_reader(&reader3),
3443            Continue(true)
3444        );
3445        // TODO(database-issues#6885): expiry temporarily doesn't advance since
3446        // Switch this assertion back when we re-enable this.
3447        //
3448        // assert_eq!(state.collections.trace.since(), &Antichain::from_elem(10));
3449        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(3));
3450    }
3451
3452    #[mz_ore::test]
3453    fn compare_and_downgrade_since() {
3454        let mut state = TypedState::<(), (), u64, i64>::new(
3455            DUMMY_BUILD_INFO.semver_version(),
3456            ShardId::new(),
3457            "".to_owned(),
3458            0,
3459        );
3460        let reader = CriticalReaderId::new();
3461        let _ = state
3462            .collections
3463            .register_critical_reader("", &reader, Opaque::encode(&0u64), "");
3464
3465        // The shard global since == 0 initially.
3466        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(0));
3467        // The initial opaque value should be set.
3468        assert_eq!(
3469            state
3470                .collections
3471                .critical_reader(&reader)
3472                .opaque
3473                .decode::<u64>(),
3474            u64::MIN
3475        );
3476
3477        // Greater
3478        assert_eq!(
3479            state.collections.compare_and_downgrade_since(
3480                &reader,
3481                &Opaque::encode(&0u64),
3482                (&Opaque::encode(&1u64), &Antichain::from_elem(2)),
3483            ),
3484            Continue(Ok(Since(Antichain::from_elem(2))))
3485        );
3486        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(2));
3487        assert_eq!(
3488            state
3489                .collections
3490                .critical_reader(&reader)
3491                .opaque
3492                .decode::<u64>(),
3493            1
3494        );
3495        // Equal (no-op)
3496        assert_eq!(
3497            state.collections.compare_and_downgrade_since(
3498                &reader,
3499                &Opaque::encode(&1u64),
3500                (&Opaque::encode(&2u64), &Antichain::from_elem(2)),
3501            ),
3502            Continue(Ok(Since(Antichain::from_elem(2))))
3503        );
3504        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(2));
3505        assert_eq!(
3506            state
3507                .collections
3508                .critical_reader(&reader)
3509                .opaque
3510                .decode::<u64>(),
3511            2
3512        );
3513        // Less (no-op)
3514        assert_eq!(
3515            state.collections.compare_and_downgrade_since(
3516                &reader,
3517                &Opaque::encode(&2u64),
3518                (&Opaque::encode(&3u64), &Antichain::from_elem(1)),
3519            ),
3520            Continue(Ok(Since(Antichain::from_elem(2))))
3521        );
3522        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(2));
3523        assert_eq!(
3524            state
3525                .collections
3526                .critical_reader(&reader)
3527                .opaque
3528                .decode::<u64>(),
3529            3
3530        );
3531    }
3532
3533    #[mz_ore::test]
3534    fn compare_and_append() {
3535        let state = &mut TypedState::<String, String, u64, i64>::new(
3536            DUMMY_BUILD_INFO.semver_version(),
3537            ShardId::new(),
3538            "".to_owned(),
3539            0,
3540        )
3541        .collections;
3542
3543        let writer_id = WriterId::new();
3544        let now = SYSTEM_TIME.clone();
3545
3546        // State is initially empty.
3547        assert_eq!(state.trace.num_spine_batches(), 0);
3548        assert_eq!(state.trace.num_hollow_batches(), 0);
3549        assert_eq!(state.trace.num_updates(), 0);
3550
3551        // Cannot insert a batch with a lower != current shard upper.
3552        assert_eq!(
3553            state.compare_and_append(
3554                &hollow(1, 2, &["key1"], 1),
3555                &writer_id,
3556                now(),
3557                LEASE_DURATION_MS,
3558                &IdempotencyToken::new(),
3559                &debug_state(),
3560                0,
3561                100,
3562                None
3563            ),
3564            Break(CompareAndAppendBreak::Upper {
3565                shard_upper: Antichain::from_elem(0),
3566                writer_upper: Antichain::from_elem(0)
3567            })
3568        );
3569
3570        // Insert an empty batch with an upper > lower..
3571        assert!(
3572            state
3573                .compare_and_append(
3574                    &hollow(0, 5, &[], 0),
3575                    &writer_id,
3576                    now(),
3577                    LEASE_DURATION_MS,
3578                    &IdempotencyToken::new(),
3579                    &debug_state(),
3580                    0,
3581                    100,
3582                    None
3583                )
3584                .is_continue()
3585        );
3586
3587        // Cannot insert a batch with a upper less than the lower.
3588        assert_eq!(
3589            state.compare_and_append(
3590                &hollow(5, 4, &["key1"], 1),
3591                &writer_id,
3592                now(),
3593                LEASE_DURATION_MS,
3594                &IdempotencyToken::new(),
3595                &debug_state(),
3596                0,
3597                100,
3598                None
3599            ),
3600            Break(CompareAndAppendBreak::InvalidUsage(InvalidBounds {
3601                lower: Antichain::from_elem(5),
3602                upper: Antichain::from_elem(4)
3603            }))
3604        );
3605
3606        // Cannot insert a nonempty batch with an upper equal to lower.
3607        assert_eq!(
3608            state.compare_and_append(
3609                &hollow(5, 5, &["key1"], 1),
3610                &writer_id,
3611                now(),
3612                LEASE_DURATION_MS,
3613                &IdempotencyToken::new(),
3614                &debug_state(),
3615                0,
3616                100,
3617                None
3618            ),
3619            Break(CompareAndAppendBreak::InvalidUsage(
3620                InvalidEmptyTimeInterval {
3621                    lower: Antichain::from_elem(5),
3622                    upper: Antichain::from_elem(5),
3623                    keys: vec!["key1".to_owned()],
3624                }
3625            ))
3626        );
3627
3628        // Can insert an empty batch with an upper equal to lower.
3629        assert!(
3630            state
3631                .compare_and_append(
3632                    &hollow(5, 5, &[], 0),
3633                    &writer_id,
3634                    now(),
3635                    LEASE_DURATION_MS,
3636                    &IdempotencyToken::new(),
3637                    &debug_state(),
3638                    0,
3639                    100,
3640                    None
3641                )
3642                .is_continue()
3643        );
3644    }
3645
3646    #[mz_ore::test]
3647    fn snapshot() {
3648        let now = SYSTEM_TIME.clone();
3649
3650        let mut state = TypedState::<String, String, u64, i64>::new(
3651            DUMMY_BUILD_INFO.semver_version(),
3652            ShardId::new(),
3653            "".to_owned(),
3654            0,
3655        );
3656        // Cannot take a snapshot with as_of == shard upper.
3657        assert_eq!(
3658            state.snapshot(&Antichain::from_elem(0)),
3659            Err(SnapshotErr::AsOfNotYetAvailable(
3660                SeqNo(0),
3661                Upper(Antichain::from_elem(0))
3662            ))
3663        );
3664
3665        // Cannot take a snapshot with as_of > shard upper.
3666        assert_eq!(
3667            state.snapshot(&Antichain::from_elem(5)),
3668            Err(SnapshotErr::AsOfNotYetAvailable(
3669                SeqNo(0),
3670                Upper(Antichain::from_elem(0))
3671            ))
3672        );
3673
3674        let writer_id = WriterId::new();
3675
3676        // Advance upper to 5.
3677        assert!(
3678            state
3679                .collections
3680                .compare_and_append(
3681                    &hollow(0, 5, &["key1"], 1),
3682                    &writer_id,
3683                    now(),
3684                    LEASE_DURATION_MS,
3685                    &IdempotencyToken::new(),
3686                    &debug_state(),
3687                    0,
3688                    100,
3689                    None
3690                )
3691                .is_continue()
3692        );
3693
3694        // Can take a snapshot with as_of < upper.
3695        assert_eq!(
3696            state.snapshot(&Antichain::from_elem(0)),
3697            Ok(vec![hollow(0, 5, &["key1"], 1)])
3698        );
3699
3700        // Can take a snapshot with as_of >= shard since, as long as as_of < shard_upper.
3701        assert_eq!(
3702            state.snapshot(&Antichain::from_elem(4)),
3703            Ok(vec![hollow(0, 5, &["key1"], 1)])
3704        );
3705
3706        // Cannot take a snapshot with as_of >= upper.
3707        assert_eq!(
3708            state.snapshot(&Antichain::from_elem(5)),
3709            Err(SnapshotErr::AsOfNotYetAvailable(
3710                SeqNo(0),
3711                Upper(Antichain::from_elem(5))
3712            ))
3713        );
3714        assert_eq!(
3715            state.snapshot(&Antichain::from_elem(6)),
3716            Err(SnapshotErr::AsOfNotYetAvailable(
3717                SeqNo(0),
3718                Upper(Antichain::from_elem(5))
3719            ))
3720        );
3721
3722        let reader = LeasedReaderId::new();
3723        // Advance the since to 2.
3724        let _ = state.collections.register_leased_reader(
3725            "",
3726            &reader,
3727            "",
3728            SeqNo::minimum(),
3729            Duration::from_secs(10),
3730            now(),
3731            false,
3732        );
3733        assert_eq!(
3734            state.collections.downgrade_since(
3735                &reader,
3736                SeqNo::minimum(),
3737                SeqNo::minimum(),
3738                &Antichain::from_elem(2),
3739                now()
3740            ),
3741            Continue(Since(Antichain::from_elem(2)))
3742        );
3743        assert_eq!(state.collections.trace.since(), &Antichain::from_elem(2));
3744        // Cannot take a snapshot with as_of < shard_since.
3745        assert_eq!(
3746            state.snapshot(&Antichain::from_elem(1)),
3747            Err(SnapshotErr::AsOfHistoricalDistinctionsLost(Since(
3748                Antichain::from_elem(2)
3749            )))
3750        );
3751
3752        // Advance the upper to 10 via an empty batch.
3753        assert!(
3754            state
3755                .collections
3756                .compare_and_append(
3757                    &hollow(5, 10, &[], 0),
3758                    &writer_id,
3759                    now(),
3760                    LEASE_DURATION_MS,
3761                    &IdempotencyToken::new(),
3762                    &debug_state(),
3763                    0,
3764                    100,
3765                    None
3766                )
3767                .is_continue()
3768        );
3769
3770        // Can still take snapshots at times < upper.
3771        assert_eq!(
3772            state.snapshot(&Antichain::from_elem(7)),
3773            Ok(vec![hollow(0, 5, &["key1"], 1), hollow(5, 10, &[], 0)])
3774        );
3775
3776        // Cannot take snapshots with as_of >= upper.
3777        assert_eq!(
3778            state.snapshot(&Antichain::from_elem(10)),
3779            Err(SnapshotErr::AsOfNotYetAvailable(
3780                SeqNo(0),
3781                Upper(Antichain::from_elem(10))
3782            ))
3783        );
3784
3785        // Advance upper to 15.
3786        assert!(
3787            state
3788                .collections
3789                .compare_and_append(
3790                    &hollow(10, 15, &["key2"], 1),
3791                    &writer_id,
3792                    now(),
3793                    LEASE_DURATION_MS,
3794                    &IdempotencyToken::new(),
3795                    &debug_state(),
3796                    0,
3797                    100,
3798                    None
3799                )
3800                .is_continue()
3801        );
3802
3803        // Filter out batches whose lowers are less than the requested as of (the
3804        // batches that are too far in the future for the requested as_of).
3805        assert_eq!(
3806            state.snapshot(&Antichain::from_elem(9)),
3807            Ok(vec![hollow(0, 5, &["key1"], 1), hollow(5, 10, &[], 0)])
3808        );
3809
3810        // Don't filter out batches whose lowers are <= the requested as_of.
3811        assert_eq!(
3812            state.snapshot(&Antichain::from_elem(10)),
3813            Ok(vec![
3814                hollow(0, 5, &["key1"], 1),
3815                hollow(5, 10, &[], 0),
3816                hollow(10, 15, &["key2"], 1)
3817            ])
3818        );
3819
3820        assert_eq!(
3821            state.snapshot(&Antichain::from_elem(11)),
3822            Ok(vec![
3823                hollow(0, 5, &["key1"], 1),
3824                hollow(5, 10, &[], 0),
3825                hollow(10, 15, &["key2"], 1)
3826            ])
3827        );
3828    }
3829
3830    #[mz_ore::test]
3831    fn next_listen_batch() {
3832        let mut state = TypedState::<String, String, u64, i64>::new(
3833            DUMMY_BUILD_INFO.semver_version(),
3834            ShardId::new(),
3835            "".to_owned(),
3836            0,
3837        );
3838
3839        // Empty collection never has any batches to listen for, regardless of the
3840        // current frontier.
3841        assert_eq!(
3842            state.next_listen_batch(&Antichain::from_elem(0)),
3843            Err(SeqNo(0))
3844        );
3845        assert_eq!(state.next_listen_batch(&Antichain::new()), Err(SeqNo(0)));
3846
3847        let writer_id = WriterId::new();
3848        let now = SYSTEM_TIME.clone();
3849
3850        // Add two batches of data, one from [0, 5) and then another from [5, 10).
3851        assert!(
3852            state
3853                .collections
3854                .compare_and_append(
3855                    &hollow(0, 5, &["key1"], 1),
3856                    &writer_id,
3857                    now(),
3858                    LEASE_DURATION_MS,
3859                    &IdempotencyToken::new(),
3860                    &debug_state(),
3861                    0,
3862                    100,
3863                    None
3864                )
3865                .is_continue()
3866        );
3867        assert!(
3868            state
3869                .collections
3870                .compare_and_append(
3871                    &hollow(5, 10, &["key2"], 1),
3872                    &writer_id,
3873                    now(),
3874                    LEASE_DURATION_MS,
3875                    &IdempotencyToken::new(),
3876                    &debug_state(),
3877                    0,
3878                    100,
3879                    None
3880                )
3881                .is_continue()
3882        );
3883
3884        // All frontiers in [0, 5) return the first batch.
3885        for t in 0..=4 {
3886            assert_eq!(
3887                state.next_listen_batch(&Antichain::from_elem(t)),
3888                Ok(hollow(0, 5, &["key1"], 1))
3889            );
3890        }
3891
3892        // All frontiers in [5, 10) return the second batch.
3893        for t in 5..=9 {
3894            assert_eq!(
3895                state.next_listen_batch(&Antichain::from_elem(t)),
3896                Ok(hollow(5, 10, &["key2"], 1))
3897            );
3898        }
3899
3900        // There is no batch currently available for t = 10.
3901        assert_eq!(
3902            state.next_listen_batch(&Antichain::from_elem(10)),
3903            Err(SeqNo(0))
3904        );
3905
3906        // By definition, there is no frontier ever at the empty antichain which
3907        // is the time after all possible times.
3908        assert_eq!(state.next_listen_batch(&Antichain::new()), Err(SeqNo(0)));
3909    }
3910
3911    #[mz_ore::test]
3912    fn expire_writer() {
3913        let mut state = TypedState::<String, String, u64, i64>::new(
3914            DUMMY_BUILD_INFO.semver_version(),
3915            ShardId::new(),
3916            "".to_owned(),
3917            0,
3918        );
3919        let now = SYSTEM_TIME.clone();
3920
3921        let writer_id_one = WriterId::new();
3922
3923        let writer_id_two = WriterId::new();
3924
3925        // Writer is eligible to write
3926        assert!(
3927            state
3928                .collections
3929                .compare_and_append(
3930                    &hollow(0, 2, &["key1"], 1),
3931                    &writer_id_one,
3932                    now(),
3933                    LEASE_DURATION_MS,
3934                    &IdempotencyToken::new(),
3935                    &debug_state(),
3936                    0,
3937                    100,
3938                    None
3939                )
3940                .is_continue()
3941        );
3942
3943        assert!(
3944            state
3945                .collections
3946                .expire_writer(&writer_id_one)
3947                .is_continue()
3948        );
3949
3950        // Other writers should still be able to write
3951        assert!(
3952            state
3953                .collections
3954                .compare_and_append(
3955                    &hollow(2, 5, &["key2"], 1),
3956                    &writer_id_two,
3957                    now(),
3958                    LEASE_DURATION_MS,
3959                    &IdempotencyToken::new(),
3960                    &debug_state(),
3961                    0,
3962                    100,
3963                    None
3964                )
3965                .is_continue()
3966        );
3967    }
3968
3969    #[mz_ore::test]
3970    fn maybe_gc_active_gc() {
3971        const GC_CONFIG: GcConfig = GcConfig {
3972            use_active_gc: true,
3973            fallback_threshold_ms: 5000,
3974            min_versions: 99,
3975            max_versions: 500,
3976            remove_finalized_rollups: true,
3977        };
3978        let now_fn = SYSTEM_TIME.clone();
3979
3980        let mut state = TypedState::<String, String, u64, i64>::new(
3981            DUMMY_BUILD_INFO.semver_version(),
3982            ShardId::new(),
3983            "".to_owned(),
3984            0,
3985        );
3986
3987        let now = now_fn();
3988        // Empty state doesn't need gc, regardless of is_write.
3989        assert_eq!(state.maybe_gc(true, now, GC_CONFIG), None);
3990        assert_eq!(state.maybe_gc(false, now, GC_CONFIG), None);
3991
3992        // Artificially advance the seqno so the seqno_since advances past our
3993        // internal gc_threshold.
3994        state.seqno = SeqNo(100);
3995        assert_eq!(state.seqno_since(), SeqNo(100));
3996
3997        // When a writer is present, non-writes don't gc.
3998        let writer_id = WriterId::new();
3999        let _ = state.collections.compare_and_append(
4000            &hollow(1, 2, &["key1"], 1),
4001            &writer_id,
4002            now,
4003            LEASE_DURATION_MS,
4004            &IdempotencyToken::new(),
4005            &debug_state(),
4006            0,
4007            100,
4008            None,
4009        );
4010        assert_eq!(state.maybe_gc(false, now, GC_CONFIG), None);
4011
4012        // A write will gc though.
4013        assert_eq!(
4014            state.maybe_gc(true, now, GC_CONFIG),
4015            Some(GcReq {
4016                shard_id: state.shard_id,
4017                new_seqno_since: SeqNo(100)
4018            })
4019        );
4020
4021        // But if we write down an active gc, we won't gc.
4022        state.collections.active_gc = Some(ActiveGc {
4023            seqno: state.seqno,
4024            start_ms: now,
4025        });
4026
4027        state.seqno = SeqNo(200);
4028        assert_eq!(state.seqno_since(), SeqNo(200));
4029
4030        assert_eq!(state.maybe_gc(true, now, GC_CONFIG), None);
4031
4032        state.seqno = SeqNo(300);
4033        assert_eq!(state.seqno_since(), SeqNo(300));
4034        // But if we advance the time past the threshold, we will gc.
4035        let new_now = now + GC_CONFIG.fallback_threshold_ms + 1;
4036        assert_eq!(
4037            state.maybe_gc(true, new_now, GC_CONFIG),
4038            Some(GcReq {
4039                shard_id: state.shard_id,
4040                new_seqno_since: SeqNo(300)
4041            })
4042        );
4043
4044        // Even if the sequence number doesn't pass the threshold, if the
4045        // active gc is expired, we will gc.
4046
4047        state.seqno = SeqNo(301);
4048        assert_eq!(state.seqno_since(), SeqNo(301));
4049        assert_eq!(
4050            state.maybe_gc(true, new_now, GC_CONFIG),
4051            Some(GcReq {
4052                shard_id: state.shard_id,
4053                new_seqno_since: SeqNo(301)
4054            })
4055        );
4056
4057        state.collections.active_gc = None;
4058
4059        // Artificially advance the seqno (again) so the seqno_since advances
4060        // past our internal gc_threshold (again).
4061        state.seqno = SeqNo(400);
4062        assert_eq!(state.seqno_since(), SeqNo(400));
4063
4064        let now = now_fn();
4065
4066        // If there are no writers, even a non-write will gc.
4067        let _ = state.collections.expire_writer(&writer_id);
4068        assert_eq!(
4069            state.maybe_gc(false, now, GC_CONFIG),
4070            Some(GcReq {
4071                shard_id: state.shard_id,
4072                new_seqno_since: SeqNo(400)
4073            })
4074        );
4075
4076        // Upper-bound the number of seqnos we'll attempt to collect in one go.
4077        let previous_seqno = state.seqno;
4078        state.seqno = SeqNo(10_000);
4079        assert_eq!(state.seqno_since(), SeqNo(10_000));
4080
4081        let now = now_fn();
4082        assert_eq!(
4083            state.maybe_gc(true, now, GC_CONFIG),
4084            Some(GcReq {
4085                shard_id: state.shard_id,
4086                new_seqno_since: SeqNo(previous_seqno.0 + u64::cast_from(GC_CONFIG.max_versions))
4087            })
4088        );
4089    }
4090
4091    #[mz_ore::test]
4092    fn maybe_gc_classic() {
4093        const GC_CONFIG: GcConfig = GcConfig {
4094            use_active_gc: false,
4095            fallback_threshold_ms: 5000,
4096            min_versions: 16,
4097            max_versions: 128,
4098            remove_finalized_rollups: true,
4099        };
4100        const NOW_MS: u64 = 0;
4101
4102        let mut state = TypedState::<String, String, u64, i64>::new(
4103            DUMMY_BUILD_INFO.semver_version(),
4104            ShardId::new(),
4105            "".to_owned(),
4106            0,
4107        );
4108
4109        // Empty state doesn't need gc, regardless of is_write.
4110        assert_eq!(state.maybe_gc(true, NOW_MS, GC_CONFIG), None);
4111        assert_eq!(state.maybe_gc(false, NOW_MS, GC_CONFIG), None);
4112
4113        // Artificially advance the seqno so the seqno_since advances past our
4114        // internal gc_threshold.
4115        state.seqno = SeqNo(100);
4116        assert_eq!(state.seqno_since(), SeqNo(100));
4117
4118        // When a writer is present, non-writes don't gc.
4119        let writer_id = WriterId::new();
4120        let now = SYSTEM_TIME.clone();
4121        let _ = state.collections.compare_and_append(
4122            &hollow(1, 2, &["key1"], 1),
4123            &writer_id,
4124            now(),
4125            LEASE_DURATION_MS,
4126            &IdempotencyToken::new(),
4127            &debug_state(),
4128            0,
4129            100,
4130            None,
4131        );
4132        assert_eq!(state.maybe_gc(false, NOW_MS, GC_CONFIG), None);
4133
4134        // A write will gc though.
4135        assert_eq!(
4136            state.maybe_gc(true, NOW_MS, GC_CONFIG),
4137            Some(GcReq {
4138                shard_id: state.shard_id,
4139                new_seqno_since: SeqNo(100)
4140            })
4141        );
4142
4143        // Artificially advance the seqno (again) so the seqno_since advances
4144        // past our internal gc_threshold (again).
4145        state.seqno = SeqNo(200);
4146        assert_eq!(state.seqno_since(), SeqNo(200));
4147
4148        // If there are no writers, even a non-write will gc.
4149        let _ = state.collections.expire_writer(&writer_id);
4150        assert_eq!(
4151            state.maybe_gc(false, NOW_MS, GC_CONFIG),
4152            Some(GcReq {
4153                shard_id: state.shard_id,
4154                new_seqno_since: SeqNo(200)
4155            })
4156        );
4157    }
4158
4159    #[mz_ore::test]
4160    fn need_rollup_active_rollup() {
4161        const ROLLUP_THRESHOLD: usize = 3;
4162        const ROLLUP_USE_ACTIVE_ROLLUP: bool = true;
4163        const ROLLUP_FALLBACK_THRESHOLD_MS: u64 = 5000;
4164        let now = SYSTEM_TIME.clone();
4165
4166        mz_ore::test::init_logging();
4167        let mut state = TypedState::<String, String, u64, i64>::new(
4168            DUMMY_BUILD_INFO.semver_version(),
4169            ShardId::new(),
4170            "".to_owned(),
4171            0,
4172        );
4173
4174        let rollup_seqno = SeqNo(5);
4175        let rollup = HollowRollup {
4176            key: PartialRollupKey::new(rollup_seqno, &RollupId::new()),
4177            encoded_size_bytes: None,
4178            finalized: false,
4179        };
4180
4181        assert!(
4182            state
4183                .collections
4184                .add_rollup((rollup_seqno, &rollup))
4185                .is_continue()
4186        );
4187
4188        // shouldn't need a rollup at the seqno of the rollup
4189        state.seqno = SeqNo(5);
4190        assert_none!(state.need_rollup(
4191            ROLLUP_THRESHOLD,
4192            ROLLUP_USE_ACTIVE_ROLLUP,
4193            ROLLUP_FALLBACK_THRESHOLD_MS,
4194            now()
4195        ));
4196
4197        // shouldn't need a rollup at seqnos less than our threshold
4198        state.seqno = SeqNo(6);
4199        assert_none!(state.need_rollup(
4200            ROLLUP_THRESHOLD,
4201            ROLLUP_USE_ACTIVE_ROLLUP,
4202            ROLLUP_FALLBACK_THRESHOLD_MS,
4203            now()
4204        ));
4205        state.seqno = SeqNo(7);
4206        assert_none!(state.need_rollup(
4207            ROLLUP_THRESHOLD,
4208            ROLLUP_USE_ACTIVE_ROLLUP,
4209            ROLLUP_FALLBACK_THRESHOLD_MS,
4210            now()
4211        ));
4212        state.seqno = SeqNo(8);
4213        assert_none!(state.need_rollup(
4214            ROLLUP_THRESHOLD,
4215            ROLLUP_USE_ACTIVE_ROLLUP,
4216            ROLLUP_FALLBACK_THRESHOLD_MS,
4217            now()
4218        ));
4219
4220        let mut current_time = now();
4221        // hit our threshold! we should need a rollup
4222        state.seqno = SeqNo(9);
4223        assert_eq!(
4224            state
4225                .need_rollup(
4226                    ROLLUP_THRESHOLD,
4227                    ROLLUP_USE_ACTIVE_ROLLUP,
4228                    ROLLUP_FALLBACK_THRESHOLD_MS,
4229                    current_time
4230                )
4231                .expect("rollup"),
4232            SeqNo(9)
4233        );
4234
4235        state.collections.active_rollup = Some(ActiveRollup {
4236            seqno: SeqNo(9),
4237            start_ms: current_time,
4238        });
4239
4240        // There is now an active rollup, so we shouldn't need a rollup.
4241        assert_none!(state.need_rollup(
4242            ROLLUP_THRESHOLD,
4243            ROLLUP_USE_ACTIVE_ROLLUP,
4244            ROLLUP_FALLBACK_THRESHOLD_MS,
4245            current_time
4246        ));
4247
4248        state.seqno = SeqNo(10);
4249        // We still don't need a rollup, even though the seqno is greater than
4250        // the rollup threshold.
4251        assert_none!(state.need_rollup(
4252            ROLLUP_THRESHOLD,
4253            ROLLUP_USE_ACTIVE_ROLLUP,
4254            ROLLUP_FALLBACK_THRESHOLD_MS,
4255            current_time
4256        ));
4257
4258        // But if we wait long enough, we should need a rollup again.
4259        current_time += u64::cast_from(ROLLUP_FALLBACK_THRESHOLD_MS) + 1;
4260        assert_eq!(
4261            state
4262                .need_rollup(
4263                    ROLLUP_THRESHOLD,
4264                    ROLLUP_USE_ACTIVE_ROLLUP,
4265                    ROLLUP_FALLBACK_THRESHOLD_MS,
4266                    current_time
4267                )
4268                .expect("rollup"),
4269            SeqNo(10)
4270        );
4271
4272        state.seqno = SeqNo(9);
4273        // Clear the active rollup and ensure we need a rollup again.
4274        state.collections.active_rollup = None;
4275        let rollup_seqno = SeqNo(9);
4276        let rollup = HollowRollup {
4277            key: PartialRollupKey::new(rollup_seqno, &RollupId::new()),
4278            encoded_size_bytes: None,
4279            finalized: false,
4280        };
4281        assert!(
4282            state
4283                .collections
4284                .add_rollup((rollup_seqno, &rollup))
4285                .is_continue()
4286        );
4287
4288        state.seqno = SeqNo(11);
4289        // We shouldn't need a rollup at seqnos less than our threshold
4290        assert_none!(state.need_rollup(
4291            ROLLUP_THRESHOLD,
4292            ROLLUP_USE_ACTIVE_ROLLUP,
4293            ROLLUP_FALLBACK_THRESHOLD_MS,
4294            current_time
4295        ));
4296        // hit our threshold! we should need a rollup
4297        state.seqno = SeqNo(13);
4298        assert_eq!(
4299            state
4300                .need_rollup(
4301                    ROLLUP_THRESHOLD,
4302                    ROLLUP_USE_ACTIVE_ROLLUP,
4303                    ROLLUP_FALLBACK_THRESHOLD_MS,
4304                    current_time
4305                )
4306                .expect("rollup"),
4307            SeqNo(13)
4308        );
4309    }
4310
4311    #[mz_ore::test]
4312    fn need_rollup_classic() {
4313        const ROLLUP_THRESHOLD: usize = 3;
4314        const ROLLUP_USE_ACTIVE_ROLLUP: bool = false;
4315        const ROLLUP_FALLBACK_THRESHOLD_MS: u64 = 0;
4316        const NOW: u64 = 0;
4317
4318        mz_ore::test::init_logging();
4319        let mut state = TypedState::<String, String, u64, i64>::new(
4320            DUMMY_BUILD_INFO.semver_version(),
4321            ShardId::new(),
4322            "".to_owned(),
4323            0,
4324        );
4325
4326        let rollup_seqno = SeqNo(5);
4327        let rollup = HollowRollup {
4328            key: PartialRollupKey::new(rollup_seqno, &RollupId::new()),
4329            encoded_size_bytes: None,
4330            finalized: false,
4331        };
4332
4333        assert!(
4334            state
4335                .collections
4336                .add_rollup((rollup_seqno, &rollup))
4337                .is_continue()
4338        );
4339
4340        // shouldn't need a rollup at the seqno of the rollup
4341        state.seqno = SeqNo(5);
4342        assert_none!(state.need_rollup(
4343            ROLLUP_THRESHOLD,
4344            ROLLUP_USE_ACTIVE_ROLLUP,
4345            ROLLUP_FALLBACK_THRESHOLD_MS,
4346            NOW
4347        ));
4348
4349        // shouldn't need a rollup at seqnos less than our threshold
4350        state.seqno = SeqNo(6);
4351        assert_none!(state.need_rollup(
4352            ROLLUP_THRESHOLD,
4353            ROLLUP_USE_ACTIVE_ROLLUP,
4354            ROLLUP_FALLBACK_THRESHOLD_MS,
4355            NOW
4356        ));
4357        state.seqno = SeqNo(7);
4358        assert_none!(state.need_rollup(
4359            ROLLUP_THRESHOLD,
4360            ROLLUP_USE_ACTIVE_ROLLUP,
4361            ROLLUP_FALLBACK_THRESHOLD_MS,
4362            NOW
4363        ));
4364
4365        // hit our threshold! we should need a rollup
4366        state.seqno = SeqNo(8);
4367        assert_eq!(
4368            state
4369                .need_rollup(
4370                    ROLLUP_THRESHOLD,
4371                    ROLLUP_USE_ACTIVE_ROLLUP,
4372                    ROLLUP_FALLBACK_THRESHOLD_MS,
4373                    NOW
4374                )
4375                .expect("rollup"),
4376            SeqNo(8)
4377        );
4378
4379        // but we don't need rollups for every seqno > the threshold
4380        state.seqno = SeqNo(9);
4381        assert_none!(state.need_rollup(
4382            ROLLUP_THRESHOLD,
4383            ROLLUP_USE_ACTIVE_ROLLUP,
4384            ROLLUP_FALLBACK_THRESHOLD_MS,
4385            NOW
4386        ));
4387
4388        // we only need a rollup each `ROLLUP_THRESHOLD` beyond our current seqno
4389        state.seqno = SeqNo(11);
4390        assert_eq!(
4391            state
4392                .need_rollup(
4393                    ROLLUP_THRESHOLD,
4394                    ROLLUP_USE_ACTIVE_ROLLUP,
4395                    ROLLUP_FALLBACK_THRESHOLD_MS,
4396                    NOW
4397                )
4398                .expect("rollup"),
4399            SeqNo(11)
4400        );
4401
4402        // add another rollup and ensure we're always picking the latest
4403        let rollup_seqno = SeqNo(6);
4404        let rollup = HollowRollup {
4405            key: PartialRollupKey::new(rollup_seqno, &RollupId::new()),
4406            encoded_size_bytes: None,
4407            finalized: false,
4408        };
4409        assert!(
4410            state
4411                .collections
4412                .add_rollup((rollup_seqno, &rollup))
4413                .is_continue()
4414        );
4415
4416        state.seqno = SeqNo(8);
4417        assert_none!(state.need_rollup(
4418            ROLLUP_THRESHOLD,
4419            ROLLUP_USE_ACTIVE_ROLLUP,
4420            ROLLUP_FALLBACK_THRESHOLD_MS,
4421            NOW
4422        ));
4423        state.seqno = SeqNo(9);
4424        assert_eq!(
4425            state
4426                .need_rollup(
4427                    ROLLUP_THRESHOLD,
4428                    ROLLUP_USE_ACTIVE_ROLLUP,
4429                    ROLLUP_FALLBACK_THRESHOLD_MS,
4430                    NOW
4431                )
4432                .expect("rollup"),
4433            SeqNo(9)
4434        );
4435
4436        // and ensure that after a fallback point, we assign every seqno work
4437        let fallback_seqno = SeqNo(
4438            rollup_seqno.0
4439                * u64::cast_from(PersistConfig::DEFAULT_FALLBACK_ROLLUP_THRESHOLD_MULTIPLIER),
4440        );
4441        state.seqno = fallback_seqno;
4442        assert_eq!(
4443            state
4444                .need_rollup(
4445                    ROLLUP_THRESHOLD,
4446                    ROLLUP_USE_ACTIVE_ROLLUP,
4447                    ROLLUP_FALLBACK_THRESHOLD_MS,
4448                    NOW
4449                )
4450                .expect("rollup"),
4451            fallback_seqno
4452        );
4453        state.seqno = fallback_seqno.next();
4454        assert_eq!(
4455            state
4456                .need_rollup(
4457                    ROLLUP_THRESHOLD,
4458                    ROLLUP_USE_ACTIVE_ROLLUP,
4459                    ROLLUP_FALLBACK_THRESHOLD_MS,
4460                    NOW
4461                )
4462                .expect("rollup"),
4463            fallback_seqno.next()
4464        );
4465    }
4466
4467    #[mz_ore::test]
4468    fn idempotency_token_sentinel() {
4469        assert_eq!(
4470            IdempotencyToken::SENTINEL.to_string(),
4471            "i11111111-1111-1111-1111-111111111111"
4472        );
4473    }
4474
4475    /// This test generates an "arbitrary" State, but uses a fixed seed for the
4476    /// randomness, so that it's deterministic. This lets us assert the
4477    /// serialization of that State against a golden file that's committed,
4478    /// making it easy to see what the serialization (used in an upcoming
4479    /// INSPECT feature) looks like.
4480    ///
4481    /// This golden will have to be updated each time we change State, but
4482    /// that's a feature, not a bug.
4483    #[mz_ore::test]
4484    #[cfg_attr(miri, ignore)] // too slow
4485    fn state_inspect_serde_json() {
4486        const STATE_SERDE_JSON: &str = include_str!("state_serde.json");
4487        let mut runner = proptest::test_runner::TestRunner::deterministic();
4488        let tree = any_state::<u64>(6..8).new_tree(&mut runner).unwrap();
4489        let json = serde_json::to_string_pretty(&tree.current()).unwrap();
4490        assert_eq!(
4491            json.trim(),
4492            STATE_SERDE_JSON.trim(),
4493            "\n\nNEW GOLDEN\n{}\n",
4494            json
4495        );
4496    }
4497
4498    #[mz_persist_proc::test(tokio::test)]
4499    #[cfg_attr(miri, ignore)] // too slow
4500    async fn sneaky_downgrades(dyncfgs: ConfigUpdates) {
4501        let mut clients = new_test_client_cache(&dyncfgs);
4502        let shard_id = ShardId::new();
4503
4504        async fn open_and_write(
4505            clients: &mut PersistClientCache,
4506            version: semver::Version,
4507            shard_id: ShardId,
4508        ) -> Result<(), tokio::task::JoinError> {
4509            clients.cfg.build_version = version.clone();
4510            clients.clear_state_cache();
4511            let client = clients.open(PersistLocation::new_in_mem()).await.unwrap();
4512            // Run in a task so we can catch the panic.
4513            mz_ore::task::spawn(|| version.to_string(), async move {
4514                let () = client
4515                    .upgrade_version::<String, (), u64, i64>(shard_id, Diagnostics::for_tests())
4516                    .await
4517                    .expect("valid usage");
4518                let (mut write, _) = client.expect_open::<String, (), u64, i64>(shard_id).await;
4519                let current = *write.upper().as_option().unwrap();
4520                // Do a write so that we tag the state with the version.
4521                write
4522                    .expect_compare_and_append_batch(&mut [], current, current + 1)
4523                    .await;
4524            })
4525            .into_tokio_handle()
4526            .await
4527        }
4528
4529        // Start at v0.10.0.
4530        let res = open_and_write(&mut clients, Version::new(0, 10, 0), shard_id).await;
4531        assert_ok!(res);
4532
4533        // Upgrade to v0.11.0 is allowed.
4534        let res = open_and_write(&mut clients, Version::new(0, 11, 0), shard_id).await;
4535        assert_ok!(res);
4536
4537        // Downgrade to v0.10.0 is no longer allowed.
4538        let res = open_and_write(&mut clients, Version::new(0, 10, 0), shard_id).await;
4539        assert!(res.unwrap_err().is_panic());
4540
4541        // Downgrade to v0.9.0 is _NOT_ allowed.
4542        let res = open_and_write(&mut clients, Version::new(0, 9, 0), shard_id).await;
4543        assert!(res.unwrap_err().is_panic());
4544    }
4545
4546    #[mz_ore::test]
4547    fn runid_roundtrip() {
4548        proptest!(|(runid: RunId)| {
4549            let runid_str = runid.to_string();
4550            let parsed = RunId::from_str(&runid_str);
4551            prop_assert_eq!(parsed, Ok(runid));
4552        });
4553    }
4554
4555    /// Regression for PER-16. `add_rollup` must refuse to (re-)insert a
4556    /// rollup at a seqno that GC has already physically removed.
4557    /// Without this guard, an `apply_unbatched_idempotent_cmd` retry of
4558    /// `add_rollup` that runs after a concurrent GC removed the rollup's
4559    /// original map entry will re-insert it under the same key, producing
4560    /// duplicate Insert events in the diff stream and (later) duplicate
4561    /// Delete events that trip the `assert!` in `gc.rs`'s
4562    /// `find_removable_blobs`.
4563    ///
4564    /// This test reaches the bug state via the public
4565    /// `add_rollup` → `add_rollup` (newer) → `remove_rollups` (the older
4566    /// one) → `add_rollup` (retry of the older one) sequence, mirroring
4567    /// how a delayed retry of an indeterminate add_rollup commit can race
4568    /// a concurrent GC pass that has since added a newer rollup and
4569    /// removed the older one.
4570    #[mz_ore::test]
4571    fn add_rollup_idempotent_across_gc_removal() {
4572        let mut state = TypedState::<String, String, u64, i64>::new(
4573            DUMMY_BUILD_INFO.semver_version(),
4574            ShardId::new(),
4575            "".to_owned(),
4576            0,
4577        );
4578
4579        let older_seqno = SeqNo(10);
4580        let older = HollowRollup {
4581            key: PartialRollupKey::new(older_seqno, &RollupId::new()),
4582            encoded_size_bytes: None,
4583            finalized: false,
4584        };
4585        let newer_seqno = SeqNo(20);
4586        let newer = HollowRollup {
4587            key: PartialRollupKey::new(newer_seqno, &RollupId::new()),
4588            encoded_size_bytes: None,
4589            finalized: false,
4590        };
4591        let add_older = |state: &mut StateCollections<u64>| state.add_rollup((older_seqno, &older));
4592
4593        // First attempt commits the older rollup.
4594        assert_eq!(add_older(&mut state.collections), Continue(true));
4595        // A repeat at the same seqno with the same key is the pre-existing
4596        // idempotent no-op and should report applied=true.
4597        assert_eq!(add_older(&mut state.collections), Continue(true));
4598        assert_eq!(state.collections.rollups.len(), 1);
4599
4600        // The shard keeps making progress and a later command commits a
4601        // newer rollup. This is what gives GC something to keep when it
4602        // later removes the older entry.
4603        assert_eq!(
4604            state.collections.add_rollup((newer_seqno, &newer)),
4605            Continue(true),
4606        );
4607
4608        // The GC worker, having found a kept rollup at `newer_seqno`,
4609        // commits the removal of the older one. State.rollups now only
4610        // contains the kept rollup; the older entry is gone.
4611        let _ = state
4612            .collections
4613            .remove_rollups(&[(older_seqno, older.key.clone())], false);
4614        assert!(!state.collections.rollups.contains_key(&older_seqno));
4615        assert!(state.collections.rollups.contains_key(&newer_seqno));
4616
4617        // The retry replays the same captured `(older_seqno, older)`
4618        // tuple. Even though no entry references the key anymore,
4619        // re-inserting it must be refused: `older_seqno` falls below the
4620        // smallest live rollup seqno, which is the GC watermark.
4621        assert_eq!(add_older(&mut state.collections), Continue(false));
4622        assert!(!state.collections.rollups.contains_key(&older_seqno));
4623        assert_eq!(state.collections.rollups.len(), 1);
4624    }
4625
4626    #[mz_ore::test]
4627    fn finalized_rollups() {
4628        let mut state = TypedState::<String, String, u64, i64>::new(
4629            DUMMY_BUILD_INFO.semver_version(),
4630            ShardId::new(),
4631            "".to_owned(),
4632            0,
4633        );
4634        state.collections.trace.downgrade_since(&Antichain::new());
4635        let _ = state
4636            .collections
4637            .trace
4638            .push_batch(StateCollections::tombstone_batch());
4639        assert!(state.collections.is_finalized());
4640
4641        let rollup = |seqno, finalized| HollowRollup {
4642            key: PartialRollupKey::new(SeqNo(seqno), &RollupId::new()),
4643            encoded_size_bytes: None,
4644            finalized,
4645        };
4646        let (old, kept, latest) = (rollup(1, false), rollup(3, true), rollup(5, true));
4647        for (seqno, rollup) in [(1, &old), (3, &kept), (5, &latest)] {
4648            assert_eq!(
4649                state.collections.add_rollup((SeqNo(seqno), rollup)),
4650                Continue(true)
4651            );
4652        }
4653        let remove_old = [(SeqNo(1), old.key.clone()), (SeqNo(3), kept.key.clone())];
4654
4655        // The final rollup at v5 is added at v6, and GC's removal of v1 at v7
4656        // asks for one more.
4657        for (seqno, need) in [(6, None), (7, Some(SeqNo(7)))] {
4658            state.seqno = SeqNo(seqno);
4659            assert_eq!(state.need_rollup(3, false, 0, 0), need);
4660        }
4661
4662        let noop = Break(NoOpStateTransition(vec![]));
4663        assert_eq!(
4664            state.collections.remove_rollups(&remove_old, false),
4665            noop,
4666            "kill switch off"
4667        );
4668
4669        state.collections.active_gc = Some(ActiveGc {
4670            seqno: SeqNo(6),
4671            start_ms: 0,
4672        });
4673        assert_eq!(state.collections.remove_rollups(&[], true), noop);
4674        assert!(
4675            state.collections.active_gc.is_some(),
4676            "a no-op must not commit to clear active_gc"
4677        );
4678
4679        assert_eq!(
4680            state.collections.remove_rollups(&remove_old, true),
4681            Continue(vec![SeqNo(1)])
4682        );
4683        assert_eq!(state.collections.active_gc, None);
4684        assert_eq!(
4685            state.collections.rollups.keys().collect::<Vec<_>>(),
4686            vec![&SeqNo(3), &SeqNo(5)]
4687        );
4688        assert_eq!(
4689            state.collections.remove_rollups(&remove_old, true),
4690            noop,
4691            "replayed removal"
4692        );
4693    }
4694}