Skip to main content

chorus_client/
segment.rs

1use std::collections::{BTreeSet, HashMap, HashSet};
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::{Context, Poll};
5use std::time::Duration;
6
7use bytes::Bytes;
8use futures::future::join_all;
9use futures::stream::BoxStream;
10use futures::{Stream, StreamExt};
11use serde::{Deserialize, Serialize};
12
13use crate::error::Error;
14use crate::manifest::{
15    Manifest, ManifestAccess, ManifestRecord, ManifestUpdate, PendingFold, MANIFEST_OBJECT,
16};
17use crate::manifest_store::{GcsManifestStore, ManifestStore};
18use crate::metrics::{Metrics, MetricsRecorder, NoopMetricsRecorder};
19#[cfg(test)]
20use crate::protocol::PendingCommit;
21use crate::protocol::{
22    canonical_prefix, majority, valid_format, AttemptedBytes, ClientConfig, CommitRange,
23    ProtocolError, QuorumVolume, RecoveredTail, RecoveryCandidate, Writer,
24};
25use crate::record::RecordFrame;
26use crate::transport::{Replica, ReplicaFactory, ReplicaSnapshot, TransportCode, TransportError};
27
28#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
29/// Quorum-derived state of one opaque segment-object id across replicas.
30pub(crate) struct SegmentDescriptor {
31    /// Object id (the name under `<prefix>/segments/`): claimed writer epoch
32    /// plus a per-incarnation counter, so name order matches creation order.
33    /// Identity is decoupled from position: every base lives in the
34    /// manifest — the segment directory for sealed segments, `tail_base`
35    /// for the active one — so a successor is created before its base is
36    /// known.
37    pub id: String,
38    /// First global record index, from the manifest's segment directory.
39    pub base_record_index: u64,
40    /// Inclusive record end, derived from the following segment's base (or
41    /// the committed tail base for the most recent seal).
42    pub end_record_index: u64,
43    /// Full-object CRC32C committed in the manifest directory.
44    pub crc32c: u32,
45    /// Number of zones where listing observed the object.
46    pub copies: usize,
47    /// Number of observed copies finalized by GCS.
48    pub finalized_copies: usize,
49    /// The fold CAS committed this seal, but the maintenance task has not
50    /// finalized the object yet. Repair skips it — there is no finalized
51    /// source to copy from until the seal lands, by design rather than by
52    /// loss — and the maintenance task clears the flag once it seals.
53    #[serde(default)]
54    pub seal_pending: bool,
55}
56
57#[cfg(test)]
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub(crate) struct CatalogSegment {
60    pub id: String,
61    pub base_record_index: u64,
62    pub end_record_index: Option<u64>,
63    pub crc32c: Option<u32>,
64    pub copies: usize,
65    pub finalized_copies: usize,
66    pub seal_pending: bool,
67}
68
69#[cfg(test)]
70impl From<&SegmentDescriptor> for CatalogSegment {
71    fn from(segment: &SegmentDescriptor) -> Self {
72        Self {
73            id: segment.id.clone(),
74            base_record_index: segment.base_record_index,
75            end_record_index: Some(segment.end_record_index),
76            crc32c: Some(segment.crc32c),
77            copies: segment.copies,
78            finalized_copies: segment.finalized_copies,
79            seal_pending: segment.seal_pending,
80        }
81    }
82}
83
84#[derive(Clone)]
85/// WAL namespace: the zonal replica factories for segment data (one per
86/// zone) plus one regional factory hosting the manifest control register.
87pub struct SegmentedVolume {
88    factories: Vec<Arc<dyn ReplicaFactory>>,
89    manifest_store: Arc<dyn ManifestStore>,
90    bucket_names: Vec<String>,
91    prefix: String,
92    client_config: ClientConfig,
93    metrics: Arc<Metrics>,
94}
95
96/// Wall-clock cost of the recovery phases that finish before the replay stream
97/// is handed back. `epoch_claim` is the manifest CAS that fences prior writers;
98/// `prepare` covers directory adoption and repair, committed-seal enforcement,
99/// and the appendable-candidate takeover walk. Replay and [`Recovery::start`]
100/// are timed by the caller. This is diagnostic only and is never read on the
101/// correctness path.
102#[derive(Clone, Copy, Debug, Default)]
103pub struct RecoveryTimings {
104    /// Duration of the manifest claim CAS that fences prior writers.
105    pub epoch_claim: Duration,
106    /// Duration of directory adoption, committed-seal enforcement, and the
107    /// appendable-candidate takeover walk.
108    pub prepare: Duration,
109}
110
111/// Startup recovery stream and capability to resume from a fenced frontier.
112///
113/// Recovery claims an epoch, adopts the directory, and walks the manifest's
114/// ordered `[tail, pending?]` candidates with takeover-before-size fencing.
115/// This stream then emits the fixed range `[from, end)`. Consume it through
116/// `None` before calling [`start`](Self::start), which resumes the takeover
117/// handle for the first empty frontier or conditionally creates the bootstrap
118/// object for a new log.
119pub struct Recovery {
120    /// Inclusive database checkpoint supplied to recovery.
121    pub from: WalSeqNo,
122    /// Exclusive recovered boundary and base of the resumed empty frontier.
123    pub end: WalSeqNo,
124    /// Phase timings collected up to the point the replay stream is returned.
125    pub timings: RecoveryTimings,
126    volume: SegmentedVolume,
127    manifest: Manifest,
128    writer_state: RecoveredWriterState,
129    inner: StartupReplayStream,
130    completed: bool,
131    failed: bool,
132}
133
134/// A swapped-out segment handed to the maintenance task, which finalizes
135/// it off the hot path.
136pub(crate) struct SwappedSegment {
137    pub id: String,
138    pub base_record_index: u64,
139    pub end_record_index: u64,
140    /// Digest committed beside this seal in the manifest. The live writer is
141    /// destructive to seal, so maintenance retains the decision separately
142    /// for idempotent reconstruction after any fast-path failure.
143    pub digest: String,
144    /// Full-object CRC32C committed in the same fold CAS.
145    pub crc32c: u32,
146    pub writer: Writer,
147}
148
149/// A rotation whose in-memory flip has landed but whose background fold has
150/// not. Admissions already route to the preregistered successor while the
151/// swapped-out segment drains toward its admitted end. Once the engine's
152/// in-order completion stream passes `end_record_index`, the fold can publish
153/// the old tail's seal, advance the manifest tail to the consumed pending id,
154/// and register the already provisioned refill in one CAS.
155pub(crate) struct PendingSwap {
156    pub id: String,
157    pub base_record_index: u64,
158    pub end_record_index: u64,
159    /// Digest over the frozen admitted byte range. Admission to this segment
160    /// stopped at the swap, so this equals the committed digest at CAS time.
161    pub digest: String,
162    /// Full-object CRC32C over the same frozen admitted byte range.
163    pub crc32c: u32,
164    /// The consumed pending segment the fold names as the new tail.
165    pub successor_id: String,
166    /// Live rotation retains the old writer for maintenance finalization.
167    /// Recovery has already enforced a finalized quorum, so no writer remains.
168    pub writer: Option<Writer>,
169}
170
171impl PendingSwap {
172    /// Hand the drained, sealed-in-the-manifest segment to maintenance.
173    pub(crate) fn into_segment(self) -> Option<SwappedSegment> {
174        Some(SwappedSegment {
175            id: self.id,
176            base_record_index: self.base_record_index,
177            end_record_index: self.end_record_index,
178            digest: self.digest,
179            crc32c: self.crc32c,
180            writer: self.writer?,
181        })
182    }
183}
184
185/// Recovery's conservative basis for deleting dead-incarnation segment objects.
186///
187/// `keep` is the full manifest directory, including truncation tombstones, plus
188/// the tail from the snapshot read immediately after claiming `claimed_epoch`.
189/// The snapshot may be stale when maintenance eventually uses it. That is safe:
190/// deletion is additionally restricted to ids minted strictly before the claim.
191/// This incarnation and every later one can add only ids at or above that epoch,
192/// and no later manifest decision can resurrect an unreferenced below-epoch id
193/// into replay.
194#[derive(Clone)]
195pub(crate) struct DeadSegmentSweep {
196    keep: HashSet<String>,
197    pub(crate) claimed_epoch: u64,
198}
199
200pub(crate) struct DeadSegmentSweepReport {
201    pub(crate) orphan_segments: usize,
202    pub(crate) deleted_objects: usize,
203    pub(crate) deferred_operations: usize,
204    pub(crate) failure: Option<Error>,
205}
206
207/// Everything `prepare_recovery` learns and decides: the enforced sealed
208/// chain, the replay boundary, and the identity the writer starts from.
209struct RecoveredWriterState {
210    sealed_segments: Vec<SegmentDescriptor>,
211    base_record_index: u64,
212    checkpoint_floor: u64,
213    active_id: String,
214    active_writer: Option<Writer>,
215    spare: Option<(String, Writer)>,
216    pending_fold: Option<PendingSwap>,
217    next_segment_seq: u64,
218    dead_segment_sweep: DeadSegmentSweep,
219}
220
221struct RecoveredPredecessor {
222    id: String,
223    base_record_index: u64,
224    end_record_index: u64,
225    digest: String,
226    crc32c: u32,
227    had_proven_gap: bool,
228    deferred_seal: Option<Box<RecoveredSeal>>,
229}
230
231struct RecoveredSeal {
232    volume: QuorumVolume,
233    tail: RecoveredTail,
234}
235
236impl RecoveredPredecessor {
237    async fn enforce_seal(&mut self, metrics: &Metrics) -> Result<(), Error> {
238        let Some(seal) = self.deferred_seal.take() else {
239            return Ok(());
240        };
241        seal.volume.enforce_seal(seal.tail.canonical()).await?;
242        metrics.segments_sealed.increment();
243        Ok(())
244    }
245
246    fn into_pending_fold(self, successor_id: String) -> PendingSwap {
247        debug_assert!(
248            self.deferred_seal.is_none(),
249            "recovered predecessor must be sealed before writer admission"
250        );
251        PendingSwap {
252            id: self.id,
253            base_record_index: self.base_record_index,
254            end_record_index: self.end_record_index,
255            digest: self.digest,
256            crc32c: self.crc32c,
257            successor_id,
258            writer: None,
259        }
260    }
261}
262
263enum RecoveredManifestCandidate {
264    Absent,
265    Empty {
266        reusable_writer: Option<Box<Writer>>,
267    },
268    NonEmpty(RecoveredPredecessor),
269}
270
271impl Stream for Recovery {
272    type Item = Result<WalRecord, crate::Error>;
273
274    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
275        match self.inner.as_mut().poll_next(context) {
276            Poll::Ready(Some(Ok(record))) => Poll::Ready(Some(Ok(record))),
277            Poll::Ready(Some(Err(error))) => {
278                self.failed = true;
279                Poll::Ready(Some(Err(error)))
280            }
281            Poll::Ready(None) => {
282                self.completed = true;
283                Poll::Ready(None)
284            }
285            Poll::Pending => Poll::Pending,
286        }
287    }
288}
289
290impl Recovery {
291    /// Number of sealed segments in the recovered chain. Diagnostic only.
292    pub fn sealed_segment_count(&self) -> usize {
293        self.writer_state.sealed_segments.len()
294    }
295
296    /// Conditionally create the segment at [`end`](Self::end) and start the WAL.
297    ///
298    /// This consumes the recovery so startup replay cannot overlap append
299    /// admission. Calling it before the stream reaches `None`, or after a replay
300    /// item failed, returns [`crate::Error::RecoveryIncomplete`].
301    pub async fn start(
302        self,
303        config: crate::WalEngineConfig,
304    ) -> Result<crate::WalHandle, crate::Error> {
305        if !self.completed || self.failed {
306            return Err(crate::Error::RecoveryIncomplete);
307        }
308        let mut manifest = self.manifest;
309        manifest.validate_owner().await?;
310        let writer = self.volume.open_writer(self.writer_state, manifest).await?;
311        crate::engine::WalEngine::start(writer, config)
312    }
313}
314
315/// Low-level recovered chain used by the engine and internal tests.
316pub(crate) struct SegmentedWriter {
317    manifest: Manifest,
318    factories: Vec<Arc<dyn ReplicaFactory>>,
319    prefix: String,
320    client_config: ClientConfig,
321    sealed_segments: Vec<SegmentDescriptor>,
322    segment_writer: Writer,
323    active_id: String,
324    base_record_index: u64,
325    checkpoint_floor: u64,
326    dead_segment_sweep: DeadSegmentSweep,
327    /// Per-incarnation counter behind [`segment_id`]: with the claimed
328    /// epoch it yields unique, creation-ordered ids without randomness.
329    next_segment_seq: u64,
330    /// Pre-provisioned successor: object created and every zone's append
331    /// session opened ahead of need, so rotation swaps lanes instead of
332    /// creating anything on the hot path.
333    spare: Option<PreparedSpare>,
334    pending_fold: Option<PendingSwap>,
335    max_replica_lag_bytes: usize,
336    lane_stall_timeout: Duration,
337    metrics: Arc<Metrics>,
338}
339
340pub(crate) struct ProvisionParts {
341    pub(crate) factories: Vec<Arc<dyn ReplicaFactory>>,
342    pub(crate) prefix: String,
343    pub(crate) client_config: ClientConfig,
344    pub(crate) max_replica_lag_bytes: usize,
345    pub(crate) lane_stall_timeout: Duration,
346    pub(crate) metrics: Arc<Metrics>,
347    pub(crate) manifest: ManifestAccess,
348}
349
350struct PreparedSpare {
351    id: String,
352    writer: Writer,
353    registered: bool,
354}
355
356pub(crate) struct RegisteredSpare {
357    pub(crate) id: String,
358    pub(crate) writer: Writer,
359    pub(crate) manifest: ManifestUpdate,
360}
361
362/// Quorum future for a record submitted directly to [`SegmentedWriter`].
363#[cfg(test)]
364pub(crate) struct RecordPendingCommit {
365    /// Stable global record index used by replay and checkpoints.
366    pub global_record_index: u64,
367    inner: PendingCommit,
368}
369
370#[cfg(test)]
371impl RecordPendingCommit {
372    /// Wait for majority-quorum durability and return the global record
373    /// index. Use [`Error::may_have_committed`] to classify an error.
374    pub(crate) async fn wait(self) -> Result<u64, Error> {
375        self.inner.wait().await?;
376        Ok(self.global_record_index)
377    }
378}
379
380pub(crate) struct RecordCommitRange {
381    base_record_index: u64,
382    first_global_record_index: u64,
383    end_global_record_index: u64,
384    inner: CommitRange,
385}
386
387impl RecordCommitRange {
388    pub(crate) fn first_global_record_index(&self) -> u64 {
389        self.first_global_record_index
390    }
391
392    pub(crate) fn end_global_record_index(&self) -> u64 {
393        self.end_global_record_index
394    }
395
396    pub(crate) fn progress(&mut self) -> (u64, Option<Error>) {
397        let (committed, failure) = self.inner.progress();
398        (
399            self.base_record_index + committed as u64,
400            failure.map(Error::from),
401        )
402    }
403
404    pub(crate) async fn changed(&mut self) -> Result<(), Error> {
405        self.inner.changed().await.map_err(Error::from)
406    }
407}
408
409#[derive(Clone, Debug, Eq, PartialEq)]
410/// Result of one application-triggered truncation pass.
411pub struct TruncationReport {
412    /// Individual zonal objects deleted during this pass.
413    pub deleted_objects: usize,
414    /// Segments now absent from a quorum and removed from the chain.
415    pub deleted_segments: usize,
416}
417
418#[derive(Clone, Debug, Default, Eq, PartialEq)]
419/// Result of one best-effort immutable sealed-segment repair pass.
420pub struct RepairReport {
421    /// Sealed segments inspected.
422    pub segments_examined: usize,
423    /// Missing or divergent zonal objects copied and sealed successfully.
424    pub objects_repaired: usize,
425    /// Zonal copies already matching the immutable sealed source.
426    pub objects_already_healthy: usize,
427    /// Transiently unavailable targets left for a later pass.
428    pub transient_failures: usize,
429}
430
431#[derive(Clone, Debug, Eq, PartialEq)]
432/// One opaque application record emitted by startup replay.
433pub struct WalRecord {
434    /// Sequence number assigned by the application when the record was admitted.
435    pub seqno: WalSeqNo,
436    /// Opaque database WAL payload.
437    pub payload: Bytes,
438}
439
440#[derive(
441    Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
442)]
443/// Application-assigned record number and durable checkpoint boundary.
444///
445/// An append uses the value as that record's identity. A recovery or truncation
446/// checkpoint uses the same value as the first record still needed by the
447/// database. Consequently, after durably applying record `n`, persist
448/// [`WalSeqNo::record(n + 1)`](Self::record) as the next checkpoint.
449pub struct WalSeqNo {
450    /// Zero-based record index represented by this boundary.
451    pub record_index: u64,
452}
453
454impl WalSeqNo {
455    /// Beginning of an untruncated log.
456    pub const ZERO: Self = Self { record_index: 0 };
457
458    /// Construct a record-boundary sequence number.
459    pub const fn record(record_index: u64) -> Self {
460        Self { record_index }
461    }
462}
463
464impl WalRecord {
465    /// Exclusive replay/checkpoint boundary immediately after this record.
466    pub fn next_seqno(&self) -> WalSeqNo {
467        WalSeqNo::record(self.seqno.record_index + 1)
468    }
469}
470
471type StartupReplayStream = BoxStream<'static, Result<WalRecord, Error>>;
472
473/// Startup recovery, manifest-backed catalog reconstruction, and replay.
474mod recovery {
475    use super::*;
476
477    impl SegmentedVolume {
478        /// Bind 1, 3, or 5 GCS Rapid zonal buckets to one WAL object prefix.
479        /// Any other factory count returns [`crate::Error`]. Durability and
480        /// availability follow the strict-majority quorum: a single replica
481        /// tolerates no zone loss, three tolerate one, five tolerate two.
482        ///
483        /// Each element must target a different zone. Keep both the list order
484        /// and the factory's diagnostic zone index stable across restarts; recovery
485        /// treats list position as the replica identity. The prefix is dedicated to
486        /// this WAL. The zonal data prefix contains opaque, creation-ordered
487        /// segment ids; the same prefix in the regional bucket contains the
488        /// manifest control object.
489        ///
490        /// The client validates replica count and bucket-name consistency but
491        /// cannot verify physical placement. Operators must provision Rapid
492        /// buckets in distinct zones of one region. Pass full v2 bucket resource
493        /// names and use a gRPC-compatible endpoint such as
494        /// `https://storage.googleapis.com`; Cloud Storage regional JSON/XML
495        /// endpoints do not support gRPC.
496        ///
497        /// Registers persist the ordered zonal bucket names in
498        /// `chorus.buckets`; its length is the replica count. A later wrong,
499        /// reordered, or duplicate set is rejected, and registers without the
500        /// binding fail closed.
501        ///
502        /// Independent processes may recover the same prefix concurrently. GCS
503        /// append takeover and conditional segment creation elect one writer; the
504        /// loser receives a terminal fencing or conditional-create error. Rotation
505        /// policy is supplied later through [`crate::WalEngineConfig`], not through
506        /// this storage description.
507        pub fn new(
508            factories: Vec<crate::GrpcReplicaFactory>,
509            manifest_factory: crate::GrpcReplicaFactory,
510            prefix: impl Into<String>,
511            client_config: ClientConfig,
512        ) -> Result<Self, Error> {
513            Self::new_with_metrics_recorder(
514                factories,
515                manifest_factory,
516                prefix,
517                client_config,
518                Arc::new(NoopMetricsRecorder),
519            )
520        }
521
522        /// Bind a WAL namespace and emit metrics through `metrics_recorder`.
523        ///
524        /// Chorus registers all handles during construction and updates them
525        /// directly on event paths. The recorder owns aggregation and export; the
526        /// WAL does not retain a readable metrics registry.
527        ///
528        /// Most handles have no labels. When one backend recorder serves several
529        /// volumes, wrap it per volume and inject a stable volume label into every
530        /// registration; otherwise those volumes share indistinguishable series.
531        pub fn new_with_metrics_recorder(
532            factories: Vec<crate::GrpcReplicaFactory>,
533            manifest_factory: crate::GrpcReplicaFactory,
534            prefix: impl Into<String>,
535            client_config: ClientConfig,
536            metrics_recorder: Arc<dyn MetricsRecorder>,
537        ) -> Result<Self, Error> {
538            Self::new_with_factories_and_metrics_recorder(
539                factories
540                    .into_iter()
541                    .map(|factory| Arc::new(factory) as Arc<dyn ReplicaFactory>)
542                    .collect(),
543                Arc::new(manifest_factory) as Arc<dyn ReplicaFactory>,
544                prefix,
545                client_config,
546                metrics_recorder,
547            )
548        }
549
550        #[cfg(test)]
551        pub(crate) fn new_with_factories(
552            factories: Vec<Arc<dyn ReplicaFactory>>,
553            manifest_factory: Arc<dyn ReplicaFactory>,
554            prefix: impl Into<String>,
555            client_config: ClientConfig,
556        ) -> Result<Self, Error> {
557            Self::new_with_factories_and_metrics_recorder(
558                factories,
559                manifest_factory,
560                prefix,
561                client_config,
562                Arc::new(NoopMetricsRecorder),
563            )
564        }
565
566        /// Construct a volume over trait-object factories, for the deterministic
567        /// simulation transport. Available only with the `dst-support` feature;
568        /// production code uses the concrete-factory constructors above.
569        #[cfg(feature = "dst-support")]
570        pub fn new_with_dyn_factories_and_metrics_recorder(
571            factories: Vec<Arc<dyn ReplicaFactory>>,
572            manifest_factory: Arc<dyn ReplicaFactory>,
573            prefix: impl Into<String>,
574            client_config: ClientConfig,
575            metrics_recorder: Arc<dyn MetricsRecorder>,
576        ) -> Result<Self, Error> {
577            Self::new_with_factories_and_metrics_recorder(
578                factories,
579                manifest_factory,
580                prefix,
581                client_config,
582                metrics_recorder,
583            )
584        }
585
586        pub(crate) fn new_with_factories_and_metrics_recorder(
587            factories: Vec<Arc<dyn ReplicaFactory>>,
588            manifest_factory: Arc<dyn ReplicaFactory>,
589            prefix: impl Into<String>,
590            client_config: ClientConfig,
591            metrics_recorder: Arc<dyn MetricsRecorder>,
592        ) -> Result<Self, Error> {
593            let prefix = prefix.into().trim_end_matches('/').to_string();
594            let object = format!("{prefix}/{MANIFEST_OBJECT}");
595            let manifest_store = Arc::new(GcsManifestStore::new(manifest_factory.replica(&object)));
596            Self::build(
597                factories,
598                manifest_store,
599                prefix,
600                client_config,
601                metrics_recorder,
602            )
603        }
604
605        /// Bind a WAL namespace whose manifest register lives in a caller-supplied
606        /// [`ManifestStore`] instead of the default regional GCS object.
607        ///
608        /// The store must be scoped to exactly this WAL (one register per WAL
609        /// prefix) and provide the linearizable compare-and-swap semantics the
610        /// trait documents; Firestore, Spanner, or a SQL row with optimistic
611        /// locking all qualify. Segment data still lives in the zonal factories.
612        /// The required bucket binding covers the ordered zonal data factories;
613        /// the caller remains responsible for scoping the custom store to this WAL.
614        pub fn new_with_manifest_store(
615            factories: Vec<crate::GrpcReplicaFactory>,
616            manifest_store: Arc<dyn ManifestStore>,
617            prefix: impl Into<String>,
618            client_config: ClientConfig,
619        ) -> Result<Self, Error> {
620            Self::new_with_manifest_store_and_metrics_recorder(
621                factories,
622                manifest_store,
623                prefix,
624                client_config,
625                Arc::new(NoopMetricsRecorder),
626            )
627        }
628
629        /// [`Self::new_with_manifest_store`] with a metrics recorder.
630        pub fn new_with_manifest_store_and_metrics_recorder(
631            factories: Vec<crate::GrpcReplicaFactory>,
632            manifest_store: Arc<dyn ManifestStore>,
633            prefix: impl Into<String>,
634            client_config: ClientConfig,
635            metrics_recorder: Arc<dyn MetricsRecorder>,
636        ) -> Result<Self, Error> {
637            Self::build(
638                factories
639                    .into_iter()
640                    .map(|factory| Arc::new(factory) as Arc<dyn ReplicaFactory>)
641                    .collect(),
642                manifest_store,
643                prefix.into().trim_end_matches('/').to_string(),
644                client_config,
645                metrics_recorder,
646            )
647        }
648
649        #[cfg(test)]
650        pub(crate) fn new_with_factories_and_manifest_store(
651            factories: Vec<Arc<dyn ReplicaFactory>>,
652            manifest_store: Arc<dyn ManifestStore>,
653            prefix: impl Into<String>,
654            client_config: ClientConfig,
655        ) -> Result<Self, Error> {
656            Self::build(
657                factories,
658                manifest_store,
659                prefix.into().trim_end_matches('/').to_string(),
660                client_config,
661                Arc::new(NoopMetricsRecorder),
662            )
663        }
664
665        fn build(
666            factories: Vec<Arc<dyn ReplicaFactory>>,
667            manifest_store: Arc<dyn ManifestStore>,
668            prefix: String,
669            client_config: ClientConfig,
670            metrics_recorder: Arc<dyn MetricsRecorder>,
671        ) -> Result<Self, Error> {
672            if !crate::protocol::SUPPORTED_REPLICA_COUNTS.contains(&factories.len()) {
673                return Err(ProtocolError::ReplicaCount.into());
674            }
675            let bucket_names: Vec<String> = factories
676                .iter()
677                .map(|factory| factory.bucket_name().to_string())
678                .collect();
679            let replica_count = factories.len();
680            Ok(Self {
681                factories,
682                manifest_store,
683                bucket_names,
684                prefix,
685                client_config,
686                metrics: Arc::new(Metrics::new(metrics_recorder.as_ref(), replica_count)),
687            })
688        }
689
690        fn quorum(&self) -> usize {
691            majority(self.factories.len())
692        }
693
694        /// Open the manifest register for this WAL prefix.
695        async fn open_manifest(&self) -> Result<Manifest, Error> {
696            Manifest::open(
697                Arc::clone(&self.manifest_store),
698                self.client_config.clone(),
699                Arc::clone(&self.metrics),
700                self.factories.len(),
701                self.bucket_names.clone(),
702            )
703            .await
704            .map_err(Into::into)
705        }
706
707        /// Fence and seal the prior writer, then prepare database startup replay.
708        ///
709        /// `checkpoint` is the first record not represented by the database's durable
710        /// state. The returned [`Recovery`] emits the fixed range
711        /// `[checkpoint, Recovery::end)` and does not create an appendable segment
712        /// yet. Apply every emitted record, consume the stream through `None`, then
713        /// call [`Recovery::start`] to conditionally create the manifest-selected
714        /// active segment and begin admission.
715        pub async fn recover(&self, checkpoint: WalSeqNo) -> Result<Recovery, Error> {
716            self.metrics.recoveries_run.increment();
717            let mut manifest = self.open_manifest().await?;
718            let claim_started = tokio::time::Instant::now();
719            manifest.claim().await.map_err(Error::from)?;
720            let epoch_claim = claim_started.elapsed();
721            tracing::info!(
722                epoch = manifest.record().epoch,
723                "WAL recovery epoch claimed"
724            );
725            self.recover_with_manifest(checkpoint, manifest, epoch_claim)
726                .await
727        }
728
729        /// Recover from the truncation floor committed in the manifest.
730        ///
731        /// This is intended for diagnostic and maintenance tools that do not own a
732        /// database checkpoint. Database integrations should use [`Self::recover`]
733        /// with their durable replay boundary.
734        pub async fn recover_from_committed_floor(&self) -> Result<Recovery, Error> {
735            self.metrics.recoveries_run.increment();
736            let mut manifest = self.open_manifest().await?;
737            let claim_started = tokio::time::Instant::now();
738            manifest.claim().await.map_err(Error::from)?;
739            let epoch_claim = claim_started.elapsed();
740            tracing::info!(
741                epoch = manifest.record().epoch,
742                "WAL recovery epoch claimed"
743            );
744            let checkpoint = WalSeqNo::record(manifest.record().trunc);
745            self.recover_with_manifest(checkpoint, manifest, epoch_claim)
746                .await
747        }
748
749        /// Recover the committed chain and repair immutable sealed segment copies.
750        ///
751        /// This volume-level primitive is intended for diagnostic and maintenance
752        /// tools that need a detailed [`RepairReport`]. Applications should run the
753        /// WAL engine, which performs the same repair automatically in the
754        /// background. The operation claims the WAL writer epoch and creates the
755        /// next active segment, just like normal recovery startup.
756        pub async fn repair_sealed_segments(&self) -> Result<RepairReport, Error> {
757            let mut recovery = self.recover_from_committed_floor().await?;
758            while let Some(record) = recovery.next().await {
759                record?;
760            }
761            let Recovery {
762                volume,
763                manifest,
764                writer_state,
765                ..
766            } = recovery;
767            let mut manifest = manifest;
768            manifest.validate_owner().await?;
769            let mut writer = volume.open_writer(writer_state, manifest).await?;
770            writer.repair_sealed_segments().await
771        }
772
773        async fn recover_with_manifest(
774            &self,
775            checkpoint: WalSeqNo,
776            mut manifest: Manifest,
777            epoch_claim: Duration,
778        ) -> Result<Recovery, Error> {
779            let prepare_started = tokio::time::Instant::now();
780            let writer_state = self.prepare_recovery(checkpoint, &mut manifest).await?;
781            let prepare = prepare_started.elapsed();
782            let end = WalSeqNo::record(writer_state.base_record_index);
783            tracing::info!(
784                replay_from = checkpoint.record_index,
785                replay_end = end.record_index,
786                "WAL recovery replay range prepared"
787            );
788            let inner = self.scan_sealed_range(&writer_state.sealed_segments, checkpoint, end);
789            Ok(Recovery {
790                from: checkpoint,
791                end,
792                timings: RecoveryTimings {
793                    epoch_claim,
794                    prepare,
795                },
796                volume: self.clone(),
797                manifest,
798                writer_state,
799                inner,
800                completed: false,
801                failed: false,
802            })
803        }
804
805        /// Recover from any reachable zone quorum and return an exclusive writer.
806        ///
807        /// This compatibility helper consumes the startup replay internally before
808        /// creating the next segment. Database integrations should use
809        /// [`SegmentedVolume::recover`] so they can apply that replay themselves.
810        #[cfg(test)]
811        pub(crate) async fn recover_writer(&self) -> Result<SegmentedWriter, Error> {
812            self.recover_writer_from(WalSeqNo::ZERO).await
813        }
814
815        /// Recover using the durable resume boundary stored in the database's own
816        /// checkpoint.
817        ///
818        /// The manifest truncation floor prevents deleted history from returning.
819        /// This boundary selects the database replay start and must not lie below
820        /// that committed floor. `recover_writer()` is only appropriate while the
821        /// database checkpoint remains zero.
822        #[cfg(test)]
823        pub(crate) async fn recover_writer_from(
824            &self,
825            checkpoint: WalSeqNo,
826        ) -> Result<SegmentedWriter, Error> {
827            self.metrics.recoveries_run.increment();
828            let mut manifest = self.open_manifest().await?;
829            manifest.claim().await.map_err(Error::from)?;
830            tracing::info!(
831                epoch = manifest.record().epoch,
832                "WAL recovery epoch claimed"
833            );
834            let writer_state = self.prepare_recovery(checkpoint, &mut manifest).await?;
835            let mut replay = self.scan_sealed_range(
836                &writer_state.sealed_segments,
837                checkpoint,
838                WalSeqNo::record(writer_state.base_record_index),
839            );
840            while let Some(record) = replay.next().await {
841                record?;
842            }
843            self.open_writer(writer_state, manifest).await
844        }
845
846        async fn prepare_recovery(
847            &self,
848            checkpoint: WalSeqNo,
849            manifest: &mut Manifest,
850        ) -> Result<RecoveredWriterState, Error> {
851            let adopted: ManifestRecord = manifest.record().clone();
852            let claimed_epoch = adopted.epoch;
853            let bootstrap_id = segment_id(claimed_epoch, 0);
854            let mut next_segment_seq =
855                u64::from(adopted.tail_id.as_deref() == Some(bootstrap_id.as_str()));
856            let mut fresh_id = || {
857                let id = segment_id(claimed_epoch, next_segment_seq);
858                next_segment_seq += 1;
859                id
860            };
861            if checkpoint.record_index < adopted.trunc {
862                return Err(Error::InvalidCatalog(format!(
863                    "checkpoint {} lies below the committed truncation floor {}: \
864                 those records were deleted after the database checkpointed them",
865                    checkpoint.record_index, adopted.trunc
866                )));
867            }
868            // The caller's checkpoint positions replay; it carries NO authority
869            // over which chain members exist. Pruning and catalog inclusion use
870            // only the committed manifest floor — otherwise a checkpoint past
871            // the most recent sealed segment (`seal_id`) would exclude it from
872            // enforcement before its seal has landed on every zone.
873            let floor = adopted.trunc;
874            // the committed segment directory is the chain authority
875            let directory = adopted.segments.clone();
876            // Carry janitor authority to maintenance instead of listing buckets
877            // on the recovery path. The complete directory (including
878            // truncation tombstones) and tail protect every manifest-accounted
879            // id. The strict epoch guard in `sweep_dead_segments` makes this
880            // snapshot safe even after it becomes stale: later decisions can
881            // only introduce ids at this claimed epoch or above.
882            let dead_segment_sweep = DeadSegmentSweep {
883                keep: directory
884                    .iter()
885                    .map(|entry| entry.id.clone())
886                    .chain(adopted.tail_id.clone())
887                    .chain(adopted.pending_id.clone())
888                    .collect(),
889                claimed_epoch,
890            };
891            // Derive each entry's inclusive end from contiguity: the next
892            // entry's base, or the committed tail base for the last entry. The
893            // encoding cannot disagree with the chain because it never stores
894            // an end.
895            let mut chain = Vec::with_capacity(directory.len());
896            for (index, entry) in directory.iter().enumerate() {
897                let next_base = directory
898                    .get(index + 1)
899                    .map_or(adopted.tail_base, |next| next.base);
900                let end = next_base.checked_sub(1).ok_or_else(|| {
901                    Error::InvalidCatalog("segment bases must be positive and increasing".into())
902                })?;
903                if end < entry.base {
904                    return Err(Error::InvalidCatalog(format!(
905                        "directory entry {} at base {} extends past the following base {next_base}",
906                        entry.id, entry.base
907                    )));
908                }
909                // entries wholly below the committed floor are truncation
910                // tombstones: deleted history kept in the register only until
911                // every zonal copy is confirmed gone. The next truncation pass
912                // retries their deletes; recovery just leaves them out.
913                if end < floor {
914                    continue;
915                }
916                chain.push((entry.id.clone(), entry.base, end, entry.crc32c));
917            }
918            if let Some((_, first_base, _, _)) = chain.first() {
919                if *first_base > checkpoint.record_index && *first_base > adopted.trunc {
920                    return Err(Error::InvalidCatalog(format!(
921                        "oldest segment starts at {first_base}, after checkpoint boundary {}",
922                        checkpoint.record_index
923                    )));
924                }
925            }
926            // future truncation calls must not regress below what the caller
927            // has already durably applied
928            let checkpoint_floor = adopted.trunc.max(checkpoint.record_index);
929            let mut sealed_segments = Vec::with_capacity(chain.len() + 1);
930            for (id, base, end, crc32c) in &chain {
931                // The most recent seal may still have enforcement in flight, so
932                // recover and enforce it against the committed digest. Older
933                // entries once reached a finalized quorum, but may since have
934                // lost replicas. Before startup continues, use the directory
935                // CRC32C to find any surviving canonical copy, repair reachable
936                // missing or divergent copies, and require a restored quorum.
937                // This closes the window where startup could admit new commits
938                // while old committed history survived on only one zone.
939                if adopted.seal_id.as_deref() == Some(id.as_str()) {
940                    let expected_digest = (adopted.seal_base == Some(*base))
941                        .then(|| adopted.seal_digest.clone())
942                        .flatten();
943                    let sealed = self
944                        .recover_and_seal_segment(
945                            id,
946                            *base,
947                            *end,
948                            Some((*end - *base + 1) as usize),
949                            expected_digest.as_deref(),
950                            *crc32c,
951                        )
952                        .await?;
953                    sealed_segments.push(sealed);
954                } else {
955                    let mut sealed = SegmentDescriptor {
956                        id: id.clone(),
957                        base_record_index: *base,
958                        end_record_index: *end,
959                        crc32c: *crc32c,
960                        copies: 0,
961                        finalized_copies: 0,
962                        seal_pending: false,
963                    };
964                    let healthy =
965                        restore_sealed_quorum(&self.factories, &self.prefix, &sealed).await?;
966                    sealed.copies = healthy;
967                    sealed.finalized_copies = healthy;
968                    sealed_segments.push(sealed);
969                }
970            }
971
972            // Walk the only two admissible appendable candidates in manifest
973            // order. Every size observation follows a takeover fence. The first
974            // empty candidate is the write frontier; non-empty predecessors are
975            // validated, replayed, and finalized before recovery advances.
976            let tail_id = adopted
977                .tail_id
978                .clone()
979                .ok_or_else(|| Error::InvalidCatalog("claimed manifest has no tail id".into()))?;
980            let mut predecessors = Vec::with_capacity(2);
981            let mut next_record_index = adopted.tail_base;
982            let mut active = None;
983            let mut spare = None;
984            let mut pending_fold = None;
985
986            if tail_id == bootstrap_id && adopted.pending_id.is_none() {
987                // A claim that initialized an empty register minted this id.
988                // Defer both object creates until replay has completed.
989                active = Some((tail_id.clone(), None));
990            } else {
991                let tail = self
992                    .recover_manifest_candidate(tail_id.clone(), next_record_index)
993                    .await?;
994                match tail {
995                    RecoveredManifestCandidate::Empty {
996                        reusable_writer: Some(writer),
997                    } => {
998                        active = Some((tail_id.clone(), Some(*writer)));
999                    }
1000                    RecoveredManifestCandidate::Empty {
1001                        reusable_writer: None,
1002                        ..
1003                    } => {
1004                        let active_id = fresh_id();
1005                        let pending_id = fresh_id();
1006                        let (_, active_writer) = self.provision_segment(active_id.clone()).await?;
1007                        let (_, pending_writer) =
1008                            self.provision_segment(pending_id.clone()).await?;
1009                        manifest
1010                            .replace_empty_frontier(
1011                                Some(&tail_id),
1012                                adopted.pending_id.as_deref(),
1013                                adopted.tail_base,
1014                                active_id.clone(),
1015                                pending_id.clone(),
1016                            )
1017                            .await
1018                            .map_err(Error::from)?;
1019                        tracing::info!(
1020                            old_tail = %tail_id,
1021                            new_tail = %active_id,
1022                            "recovery retired a quorum-only empty tail frontier"
1023                        );
1024                        active = Some((active_id, Some(active_writer)));
1025                        spare = Some((pending_id, pending_writer));
1026                    }
1027                    RecoveredManifestCandidate::NonEmpty(predecessor) => {
1028                        next_record_index = predecessor.end_record_index + 1;
1029                        sealed_segments.push(SegmentDescriptor {
1030                            id: predecessor.id.clone(),
1031                            base_record_index: predecessor.base_record_index,
1032                            end_record_index: predecessor.end_record_index,
1033                            crc32c: predecessor.crc32c,
1034                            copies: self.quorum(),
1035                            finalized_copies: self.quorum(),
1036                            seal_pending: false,
1037                        });
1038                        predecessors.push(predecessor);
1039                    }
1040                    RecoveredManifestCandidate::Absent => {
1041                        let active_id = fresh_id();
1042                        let pending_id = fresh_id();
1043                        let (_, active_writer) = self.provision_segment(active_id.clone()).await?;
1044                        let (_, pending_writer) =
1045                            self.provision_segment(pending_id.clone()).await?;
1046                        manifest
1047                            .replace_empty_frontier(
1048                                Some(&tail_id),
1049                                adopted.pending_id.as_deref(),
1050                                adopted.tail_base,
1051                                active_id.clone(),
1052                                pending_id.clone(),
1053                            )
1054                            .await
1055                            .map_err(Error::from)?;
1056                        tracing::info!(
1057                            old_tail = %tail_id,
1058                            new_tail = %active_id,
1059                            "recovery retired an absent tail frontier"
1060                        );
1061                        active = Some((active_id, Some(active_writer)));
1062                        spare = Some((pending_id, pending_writer));
1063                    }
1064                }
1065            }
1066
1067            if active.is_some() {
1068                let deferred_bootstrap =
1069                    active.as_ref().is_some_and(|(_, writer)| writer.is_none());
1070                if spare.is_none() && !deferred_bootstrap {
1071                    if let Some(pending_id) = adopted.pending_id.clone() {
1072                        match self
1073                            .recover_manifest_candidate(pending_id.clone(), next_record_index)
1074                            .await?
1075                        {
1076                            RecoveredManifestCandidate::Empty {
1077                                reusable_writer: Some(writer),
1078                            } => {
1079                                spare = Some((pending_id, *writer));
1080                            }
1081                            RecoveredManifestCandidate::Empty {
1082                                reusable_writer: None,
1083                                ..
1084                            }
1085                            | RecoveredManifestCandidate::Absent => {
1086                                let active_id = fresh_id();
1087                                let replacement_pending_id = fresh_id();
1088                                let (_, active_writer) =
1089                                    self.provision_segment(active_id.clone()).await?;
1090                                let (_, pending_writer) = self
1091                                    .provision_segment(replacement_pending_id.clone())
1092                                    .await?;
1093                                manifest
1094                                    .replace_empty_frontier(
1095                                        Some(&tail_id),
1096                                        Some(&pending_id),
1097                                        adopted.tail_base,
1098                                        active_id.clone(),
1099                                        replacement_pending_id.clone(),
1100                                    )
1101                                    .await
1102                                    .map_err(Error::from)?;
1103                                tracing::info!(
1104                                    old_tail = %tail_id,
1105                                    old_pending = %pending_id,
1106                                    new_tail = %active_id,
1107                                    "recovery retired a quorum-only empty pending frontier"
1108                                );
1109                                active = Some((active_id, Some(active_writer)));
1110                                spare = Some((replacement_pending_id, pending_writer));
1111                            }
1112                            RecoveredManifestCandidate::NonEmpty(_) => {
1113                                return Err(Error::InvalidCatalog(
1114                                    "pending segment is non-empty after an empty tail frontier"
1115                                        .into(),
1116                                ));
1117                            }
1118                        }
1119                    } else {
1120                        let pending_id = fresh_id();
1121                        let (_, pending_writer) =
1122                            self.provision_segment(pending_id.clone()).await?;
1123                        manifest
1124                            .register_pending(pending_id.clone())
1125                            .await
1126                            .map_err(Error::from)?;
1127                        spare = Some((pending_id, pending_writer));
1128                    }
1129                }
1130            } else {
1131                match adopted.pending_id.clone() {
1132                    Some(pending_id) => match self
1133                        .recover_manifest_candidate(pending_id.clone(), next_record_index)
1134                        .await?
1135                    {
1136                        RecoveredManifestCandidate::Empty {
1137                            reusable_writer: Some(writer),
1138                        } => {
1139                            let mut predecessor = predecessors
1140                                .pop()
1141                                .expect("non-empty tail produced one predecessor");
1142                            predecessor.enforce_seal(&self.metrics).await?;
1143                            pending_fold = Some(predecessor.into_pending_fold(pending_id.clone()));
1144                            active = Some((pending_id, Some(*writer)));
1145                        }
1146                        RecoveredManifestCandidate::Empty {
1147                            reusable_writer: None,
1148                            ..
1149                        } => {
1150                            let mut predecessor = predecessors
1151                                .pop()
1152                                .expect("non-empty tail produced one predecessor");
1153                            predecessor.enforce_seal(&self.metrics).await?;
1154                            let active_id = fresh_id();
1155                            let replacement_pending_id = fresh_id();
1156                            let (_, active_writer) =
1157                                self.provision_segment(active_id.clone()).await?;
1158                            let (_, pending_writer) = self
1159                                .provision_segment(replacement_pending_id.clone())
1160                                .await?;
1161                            manifest
1162                                .fold_pending(&PendingFold {
1163                                    old_tail_id: predecessor.id,
1164                                    old_tail_base: predecessor.base_record_index,
1165                                    old_tail_end: predecessor.end_record_index,
1166                                    old_tail_digest: predecessor.digest,
1167                                    old_tail_crc32c: predecessor.crc32c,
1168                                    consumed_pending_id: pending_id,
1169                                    successor_tail_id: active_id.clone(),
1170                                    refill_pending_id: replacement_pending_id.clone(),
1171                                })
1172                                .await
1173                                .map_err(Error::from)?;
1174                            active = Some((active_id, Some(active_writer)));
1175                            spare = Some((replacement_pending_id, pending_writer));
1176                        }
1177                        RecoveredManifestCandidate::NonEmpty(mut predecessor) => {
1178                            if predecessors.first().is_some_and(|tail| tail.had_proven_gap) {
1179                                // The reachable quorum positively observed
1180                                // physical tail bytes beyond the longest
1181                                // compatible complete-record prefix. Global
1182                                // completions are in order, so no pending
1183                                // record above that hole could have been
1184                                // acknowledged. Retire the speculative pending
1185                                // object and resume from the recovered tail
1186                                // boundary.
1187                                let mut old_tail = predecessors
1188                                    .pop()
1189                                    .expect("non-empty tail produced one predecessor");
1190                                let active_id = fresh_id();
1191                                let replacement_pending_id = fresh_id();
1192                                let (_, active_writer) =
1193                                    self.provision_segment(active_id.clone()).await?;
1194                                let (_, pending_writer) = self
1195                                    .provision_segment(replacement_pending_id.clone())
1196                                    .await?;
1197                                manifest
1198                                    .fold_pending(&PendingFold {
1199                                        old_tail_id: old_tail.id.clone(),
1200                                        old_tail_base: old_tail.base_record_index,
1201                                        old_tail_end: old_tail.end_record_index,
1202                                        old_tail_digest: old_tail.digest.clone(),
1203                                        old_tail_crc32c: old_tail.crc32c,
1204                                        consumed_pending_id: pending_id,
1205                                        successor_tail_id: active_id.clone(),
1206                                        refill_pending_id: replacement_pending_id.clone(),
1207                                    })
1208                                    .await
1209                                    .map_err(Error::from)?;
1210                                old_tail.enforce_seal(&self.metrics).await?;
1211                                tracing::warn!(
1212                                    discarded_pending = %predecessor.id,
1213                                    new_tail = %active_id,
1214                                    tail_base = next_record_index,
1215                                    "recovery discarded speculative pending records above an uncommitted tail gap"
1216                                );
1217                                active = Some((active_id, Some(active_writer)));
1218                                spare = Some((replacement_pending_id, pending_writer));
1219                            } else {
1220                                next_record_index = predecessor.end_record_index + 1;
1221                                sealed_segments.push(SegmentDescriptor {
1222                                    id: predecessor.id.clone(),
1223                                    base_record_index: predecessor.base_record_index,
1224                                    end_record_index: predecessor.end_record_index,
1225                                    crc32c: predecessor.crc32c,
1226                                    copies: self.quorum(),
1227                                    finalized_copies: self.quorum(),
1228                                    seal_pending: false,
1229                                });
1230                                predecessor.enforce_seal(&self.metrics).await?;
1231                                predecessors.push(predecessor);
1232                                let refill_id = fresh_id();
1233                                let (_, refill_writer) =
1234                                    self.provision_segment(refill_id.clone()).await?;
1235                                let mut first = predecessors.remove(0);
1236                                first.enforce_seal(&self.metrics).await?;
1237                                manifest
1238                                    .fold_pending(&PendingFold {
1239                                        old_tail_id: first.id.clone(),
1240                                        old_tail_base: first.base_record_index,
1241                                        old_tail_end: first.end_record_index,
1242                                        old_tail_digest: first.digest.clone(),
1243                                        old_tail_crc32c: first.crc32c,
1244                                        consumed_pending_id: pending_id.clone(),
1245                                        successor_tail_id: pending_id.clone(),
1246                                        refill_pending_id: refill_id.clone(),
1247                                    })
1248                                    .await
1249                                    .map_err(Error::from)?;
1250                                let mut last = predecessors
1251                                    .pop()
1252                                    .expect("non-empty pending produced a predecessor");
1253                                last.enforce_seal(&self.metrics).await?;
1254                                let replacement_pending_id = fresh_id();
1255                                let (_, pending_writer) = self
1256                                    .provision_segment(replacement_pending_id.clone())
1257                                    .await?;
1258                                manifest
1259                                    .fold_pending(&PendingFold {
1260                                        old_tail_id: last.id,
1261                                        old_tail_base: last.base_record_index,
1262                                        old_tail_end: last.end_record_index,
1263                                        old_tail_digest: last.digest,
1264                                        old_tail_crc32c: last.crc32c,
1265                                        consumed_pending_id: refill_id.clone(),
1266                                        successor_tail_id: refill_id.clone(),
1267                                        refill_pending_id: replacement_pending_id.clone(),
1268                                    })
1269                                    .await
1270                                    .map_err(Error::from)?;
1271                                active = Some((refill_id, Some(refill_writer)));
1272                                spare = Some((replacement_pending_id, pending_writer));
1273                            }
1274                        }
1275                        RecoveredManifestCandidate::Absent => {
1276                            return Err(Error::InvalidCatalog(
1277                                "manifest pending segment is absent on a quorum".into(),
1278                            ));
1279                        }
1280                    },
1281                    None => {
1282                        let pending_id = fresh_id();
1283                        let (_, pending_writer) =
1284                            self.provision_segment(pending_id.clone()).await?;
1285                        manifest
1286                            .register_pending(pending_id.clone())
1287                            .await
1288                            .map_err(Error::from)?;
1289                        let mut predecessor = predecessors
1290                            .pop()
1291                            .expect("non-empty tail produced one predecessor");
1292                        predecessor.enforce_seal(&self.metrics).await?;
1293                        pending_fold = Some(predecessor.into_pending_fold(pending_id.clone()));
1294                        active = Some((pending_id, Some(pending_writer)));
1295                    }
1296                }
1297            }
1298            let (active_id, active_writer) =
1299                active.expect("recovery always establishes an empty frontier");
1300            if checkpoint.record_index > next_record_index {
1301                return Err(Error::InvalidCatalog(format!(
1302                    "checkpoint {} lies past the committed end {}",
1303                    checkpoint.record_index, next_record_index
1304                )));
1305            }
1306            self.metrics
1307                .recovery_segments_adopted
1308                .add(sealed_segments.len() as u64);
1309            Ok(RecoveredWriterState {
1310                sealed_segments,
1311                base_record_index: next_record_index,
1312                checkpoint_floor,
1313                active_id,
1314                active_writer,
1315                spare,
1316                pending_fold,
1317                next_segment_seq,
1318                dead_segment_sweep,
1319            })
1320        }
1321
1322        pub(super) async fn open_writer(
1323            &self,
1324            state: RecoveredWriterState,
1325            mut manifest: Manifest,
1326        ) -> Result<SegmentedWriter, Error> {
1327            let RecoveredWriterState {
1328                sealed_segments,
1329                base_record_index,
1330                checkpoint_floor,
1331                active_id,
1332                active_writer,
1333                mut spare,
1334                pending_fold,
1335                mut next_segment_seq,
1336                dead_segment_sweep,
1337            } = state;
1338            // a segment object's metadata is fixed at creation and never
1339            // touched again: its base lives in the manifest, so open append
1340            // sessions never see a metageneration change
1341            let writer = match active_writer {
1342                Some(writer) => writer,
1343                None => {
1344                    let object = self.segment_object(&active_id);
1345                    self.volume_for(&object)?.create_writer().await?
1346                }
1347            };
1348            if spare.is_none() {
1349                if pending_fold.is_none() && manifest.record().pending_id.is_some() {
1350                    return Err(Error::InvalidCatalog(
1351                        "recovery did not acquire the manifest pending segment".into(),
1352                    ));
1353                }
1354                let pending_id = segment_id(manifest.record().epoch, next_segment_seq);
1355                next_segment_seq = next_segment_seq.checked_add(1).ok_or_else(|| {
1356                    Error::InvalidCatalog("segment id sequence overflowed u64".into())
1357                })?;
1358                let (_, pending_writer) = self.provision_segment(pending_id.clone()).await?;
1359                if pending_fold.is_none() {
1360                    manifest
1361                        .register_pending(pending_id.clone())
1362                        .await
1363                        .map_err(Error::from)?;
1364                }
1365                spare = Some((pending_id, pending_writer));
1366            }
1367            // the create is not epoch-guarded (zonal operations never are), so a
1368            // recoverer deposed after validate_owner can still win it. Re-check
1369            // ownership now that the object exists: a deposed creator abdicates
1370            // here instead of running as a zombie writer whose first manifest
1371            // CAS would fence it anyway. This shrinks the window; the next
1372            // owner's takeover and the fold CAS's quorum intersection carry the
1373            // safety argument for what remains.
1374            manifest.validate_owner().await.map_err(Error::from)?;
1375            self.metrics.open_segments.add(1);
1376            self.metrics
1377                .committed_records_watermark
1378                .set_u64(base_record_index);
1379            let spare_registered = pending_fold.is_none();
1380            Ok(SegmentedWriter {
1381                factories: self.factories.clone(),
1382                prefix: self.prefix.clone(),
1383                client_config: self.client_config.clone(),
1384                manifest,
1385                sealed_segments,
1386                segment_writer: writer,
1387                active_id,
1388                base_record_index,
1389                checkpoint_floor,
1390                dead_segment_sweep,
1391                next_segment_seq,
1392                spare: spare.map(|(id, writer)| PreparedSpare {
1393                    id,
1394                    writer,
1395                    registered: spare_registered,
1396                }),
1397                pending_fold,
1398                max_replica_lag_bytes: usize::MAX,
1399                lane_stall_timeout: crate::protocol::DEFAULT_LANE_STALL_TIMEOUT,
1400                metrics: Arc::clone(&self.metrics),
1401            })
1402        }
1403
1404        fn scan_sealed_range(
1405            &self,
1406            segments: &[SegmentDescriptor],
1407            from: WalSeqNo,
1408            end: WalSeqNo,
1409        ) -> StartupReplayStream {
1410            debug_assert!(from.record_index <= end.record_index);
1411            let segments = segments.to_vec();
1412            let factories = self.factories.clone();
1413            let prefix = self.prefix.clone();
1414            async_stream::try_stream! {
1415            for segment in segments {
1416                let segment_end = segment.end_record_index;
1417                if segment_end < from.record_index || segment.base_record_index >= end.record_index {
1418                    continue;
1419                }
1420                let records = read_sealed_segment(
1421                    &factories,
1422                    &format!("{prefix}/segments/{}", segment.id),
1423                    &segment,
1424                )
1425                .await?;
1426                for record in replay_records(&segment, &records, from, end)? {
1427                    yield record;
1428                }
1429            }
1430        }
1431        .boxed()
1432        }
1433
1434        async fn finalized_segment_descriptor(
1435            &self,
1436            id: &str,
1437            base_record_index: u64,
1438            object: &str,
1439            expected_records: Option<u64>,
1440            expected_digest: Option<&str>,
1441            expected_crc32c: u32,
1442        ) -> Result<Option<SegmentDescriptor>, Error> {
1443            let replicas = replicas_for(&self.factories, object);
1444            // Stat-only fast path: a committed seal records the canonical range
1445            // (its record count is known from the manifest, `expected_records`)
1446            // and its full-object CRC32C. If a quorum of replicas are already
1447            // finalized carrying that exact CRC32C, the seal is enforced on a
1448            // quorum and recovery can adopt it from metadata alone, without
1449            // downloading the sealed bytes -- the dominant cost on the recovery
1450            // path. This is the same finalized-object CRC32C the stat-only
1451            // repair health pre-check trusts, under the non-Byzantine CRC32C
1452            // threat model. Any shortfall (missing, unfinalized, or
1453            // CRC-divergent copies) falls through to the authoritative byte read
1454            // and rewrite below, which re-enforces the seal.
1455            if let Some(expected) = expected_records {
1456                let stats = join_all(replicas.iter().map(|replica| replica.stat())).await;
1457                let matching = stats
1458                    .into_iter()
1459                    .flatten()
1460                    .filter(|snapshot| {
1461                        snapshot.finalized
1462                            && valid_format(&snapshot.metadata)
1463                            && snapshot.crc32c == Some(expected_crc32c)
1464                    })
1465                    .count();
1466                if matching >= self.quorum() {
1467                    let last_offset = expected.checked_sub(1).ok_or_else(|| {
1468                        Error::InvalidCatalog("committed seal has zero records".into())
1469                    })?;
1470                    let end = base_record_index.checked_add(last_offset).ok_or_else(|| {
1471                        Error::InvalidCatalog("segment end overflowed u64".into())
1472                    })?;
1473                    return Ok(Some(SegmentDescriptor {
1474                        id: id.to_string(),
1475                        base_record_index,
1476                        end_record_index: end,
1477                        crc32c: expected_crc32c,
1478                        copies: matching,
1479                        finalized_copies: matching,
1480                        seal_pending: false,
1481                    }));
1482                }
1483            }
1484            let reads = join_all(replicas.iter().map(|replica| replica.snapshot())).await;
1485            let finalized: Vec<_> = reads
1486                .into_iter()
1487                .flatten()
1488                .filter(|snapshot| snapshot.finalized && valid_format(&snapshot.metadata))
1489                .collect();
1490            if finalized.len() < self.quorum() {
1491                return Ok(None);
1492            }
1493            let mut groups: HashMap<Vec<u8>, Vec<ReplicaSnapshot>> = HashMap::new();
1494            for snapshot in finalized {
1495                if RecordFrame::decode_all(&snapshot.bytes).is_ok() {
1496                    groups
1497                        .entry(snapshot.bytes.clone())
1498                        .or_default()
1499                        .push(snapshot);
1500                }
1501            }
1502            let Some((canonical_bytes, finalized)) = groups
1503                .into_iter()
1504                .filter(|(_, copies)| copies.len() >= self.quorum())
1505                .filter(|(bytes, _)| {
1506                    expected_records.is_none_or(|expected| {
1507                        RecordFrame::decode_all(bytes)
1508                            .is_ok_and(|records| records.len() as u64 == expected)
1509                    })
1510                })
1511                .max_by(|(left, _), (right, _)| left.len().cmp(&right.len()).then(left.cmp(right)))
1512            else {
1513                return Ok(None);
1514            };
1515            let record_count = RecordFrame::decode_all(&canonical_bytes)?.len() as u64;
1516            let Some(last_record_offset) = record_count.checked_sub(1) else {
1517                return Err(Error::InvalidCatalog(format!(
1518                    "finalized segment {base_record_index} is empty"
1519                )));
1520            };
1521            if let Some(expected) = expected_digest {
1522                let digest = crate::protocol::digest_bytes(&canonical_bytes);
1523                if digest != expected {
1524                    return Err(Error::InvalidCatalog(format!(
1525                    "finalized segment {base_record_index} does not match the committed seal digest"
1526                )));
1527                }
1528            }
1529            let actual = crc32c::crc32c(&canonical_bytes);
1530            if actual != expected_crc32c {
1531                return Err(Error::InvalidCatalog(format!(
1532                    "finalized segment {base_record_index} has CRC32C {actual:08x}, expected {expected_crc32c:08x}"
1533                )));
1534            }
1535            let end = base_record_index
1536                .checked_add(last_record_offset)
1537                .ok_or_else(|| Error::InvalidCatalog("segment end overflowed u64".into()))?;
1538            Ok(Some(SegmentDescriptor {
1539                id: id.to_string(),
1540                base_record_index,
1541                end_record_index: end,
1542                crc32c: expected_crc32c,
1543                copies: finalized.len(),
1544                finalized_copies: finalized.len(),
1545                seal_pending: false,
1546            }))
1547        }
1548
1549        /// Finish a seal whose decision is already committed: either by the
1550        /// manifest (`expected_digest`) or by the following segment's base
1551        /// (`end_record_index`). No new decision is taken here.
1552        async fn recover_and_seal_segment(
1553            &self,
1554            id: &str,
1555            base_record_index: u64,
1556            end_record_index: u64,
1557            canonical_records: Option<usize>,
1558            expected_digest: Option<&str>,
1559            expected_crc32c: u32,
1560        ) -> Result<SegmentDescriptor, Error> {
1561            let object = self.segment_object(id);
1562            if let Some(completed) = self
1563                .finalized_segment_descriptor(
1564                    id,
1565                    base_record_index,
1566                    &object,
1567                    canonical_records.map(|count| count as u64),
1568                    expected_digest,
1569                    expected_crc32c,
1570                )
1571                .await?
1572            {
1573                if completed.end_record_index != end_record_index {
1574                    return Err(Error::InvalidCatalog(format!(
1575                    "segment {base_record_index} seals at {:?}, following segment requires {end_record_index}",
1576                    completed.end_record_index
1577                )));
1578                }
1579                return Ok(completed);
1580            }
1581
1582            let sealed = enforce_committed_seal(
1583                &self.factories,
1584                &self.prefix,
1585                &self.client_config,
1586                Arc::clone(&self.metrics),
1587                id,
1588                base_record_index,
1589                end_record_index,
1590                expected_digest,
1591                expected_crc32c,
1592            )
1593            .await?;
1594            self.metrics.segments_sealed.increment();
1595            Ok(sealed)
1596        }
1597
1598        /// A segment object's metadata is fixed at creation: the format marker
1599        /// and nothing else. Chain position lives in the manifest's segment
1600        /// directory, so rewrites carry the same constant metadata.
1601        fn volume_for(&self, object: &str) -> Result<QuorumVolume, Error> {
1602            QuorumVolume::with_metadata(
1603                replicas_for(&self.factories, object),
1604                self.client_config.clone(),
1605                crate::protocol::protocol_metadata(),
1606                Arc::clone(&self.metrics),
1607            )
1608            .map_err(Into::into)
1609        }
1610
1611        fn segments_prefix(&self) -> String {
1612            format!("{}/segments/", self.prefix)
1613        }
1614
1615        fn segment_object(&self, id: &str) -> String {
1616            format!("{}{id}", self.segments_prefix())
1617        }
1618
1619        async fn provision_segment(&self, id: String) -> Result<(String, Writer), Error> {
1620            provision_spare(
1621                self.factories.clone(),
1622                self.prefix.clone(),
1623                self.client_config.clone(),
1624                usize::MAX,
1625                crate::protocol::DEFAULT_LANE_STALL_TIMEOUT,
1626                id,
1627                Arc::clone(&self.metrics),
1628            )
1629            .await
1630        }
1631
1632        async fn recover_manifest_candidate(
1633            &self,
1634            id: String,
1635            base_record_index: u64,
1636        ) -> Result<RecoveredManifestCandidate, Error> {
1637            let object = self.segment_object(&id);
1638            let volume = self.volume_for(&object)?;
1639            match volume.recover_candidate(None).await? {
1640                RecoveryCandidate::Absent => Ok(RecoveredManifestCandidate::Absent),
1641                RecoveryCandidate::Empty { reusable_writer } => {
1642                    Ok(RecoveredManifestCandidate::Empty { reusable_writer })
1643                }
1644                RecoveryCandidate::NonEmpty(tail) => {
1645                    let record_count = u64::try_from(tail.len()).map_err(|_| {
1646                        Error::InvalidCatalog("recovered record count does not fit in u64".into())
1647                    })?;
1648                    let last_record_offset = record_count.checked_sub(1).ok_or_else(|| {
1649                        Error::InvalidCatalog(
1650                            "non-empty recovery candidate contains no complete records".into(),
1651                        )
1652                    })?;
1653                    let end_record_index = base_record_index
1654                        .checked_add(last_record_offset)
1655                        .ok_or_else(|| {
1656                            Error::InvalidCatalog("segment end overflowed u64".into())
1657                        })?;
1658                    let digest = tail.digest();
1659                    let crc32c = tail.crc32c();
1660                    let had_proven_gap = tail.had_discarded_suffix();
1661                    Ok(RecoveredManifestCandidate::NonEmpty(RecoveredPredecessor {
1662                        id,
1663                        base_record_index,
1664                        end_record_index,
1665                        digest,
1666                        crc32c,
1667                        had_proven_gap,
1668                        deferred_seal: Some(Box::new(RecoveredSeal { volume, tail })),
1669                    }))
1670                }
1671            }
1672        }
1673    }
1674}
1675
1676/// Active writer operation, spare adoption, and rotation transitions.
1677mod writer {
1678    use super::*;
1679
1680    impl Drop for SegmentedWriter {
1681        fn drop(&mut self) {
1682            self.metrics.open_segments.add(-1);
1683        }
1684    }
1685
1686    impl SegmentedWriter {
1687        pub(crate) fn metrics(&self) -> Arc<Metrics> {
1688            Arc::clone(&self.metrics)
1689        }
1690
1691        pub(crate) fn client_config(&self) -> ClientConfig {
1692            self.client_config.clone()
1693        }
1694
1695        pub(crate) fn set_max_replica_lag_bytes(&mut self, limit: usize) {
1696            self.max_replica_lag_bytes = limit;
1697            self.segment_writer.set_max_replica_lag_bytes(limit);
1698            if let Some(spare) = &mut self.spare {
1699                spare.writer.set_max_replica_lag_bytes(limit);
1700            }
1701        }
1702
1703        pub(crate) fn set_lane_stall_timeout(&mut self, timeout: Duration) {
1704            self.lane_stall_timeout = timeout;
1705            self.segment_writer.set_lane_stall_timeout(timeout);
1706            if let Some(spare) = &mut self.spare {
1707                spare.writer.set_lane_stall_timeout(timeout);
1708            }
1709        }
1710
1711        pub(crate) async fn shutdown_background_tasks(&mut self) {
1712            self.segment_writer.shutdown_background_tasks().await;
1713            if let Some(spare) = &mut self.spare {
1714                spare.writer.shutdown_background_tasks().await;
1715            }
1716        }
1717
1718        fn quorum(&self) -> usize {
1719            majority(self.factories.len())
1720        }
1721
1722        /// Return the current in-memory segment chain, sealed segments first
1723        /// and the active segment last.
1724        #[cfg(test)]
1725        pub(crate) fn catalog(&self) -> Vec<CatalogSegment> {
1726            let mut segments = self
1727                .sealed_segments
1728                .iter()
1729                .map(CatalogSegment::from)
1730                .collect::<Vec<_>>();
1731            segments.push(CatalogSegment {
1732                id: self.active_id.clone(),
1733                base_record_index: self.base_record_index,
1734                end_record_index: None,
1735                crc32c: None,
1736                copies: self.quorum(),
1737                finalized_copies: 0,
1738                seal_pending: false,
1739            });
1740            segments
1741        }
1742
1743        /// Base record index of the active appendable segment.
1744        #[cfg(test)]
1745        pub(crate) fn active_segment_base(&self) -> u64 {
1746            self.base_record_index
1747        }
1748
1749        /// Exclusive global record end of the active canonical prefix.
1750        pub(crate) fn committed_record_end(&self) -> u64 {
1751            self.base_record_index + self.segment_writer.committed_len() as u64
1752        }
1753
1754        /// Encoded bytes already committed in the active object when the engine
1755        /// adopts this writer. Engine-lifetime admissions are accounted separately
1756        /// so a segment swap can rebase queued bytes onto the successor without
1757        /// retaining record payloads in control-plane state.
1758        pub(crate) fn active_segment_bytes(&self) -> usize {
1759            self.segment_writer.physical_size()
1760        }
1761
1762        /// Whether the manifest can catalog the active object if it gains a
1763        /// committed record and the process then crashes. Recovery may create
1764        /// an empty tail with no remaining slot, but admission must wait for
1765        /// truncation before making that tail part of committed history.
1766        pub(crate) fn active_segment_has_seal_room(&self) -> bool {
1767            self.manifest.directory_has_room(1)
1768        }
1769
1770        /// Application checkpoint supplied during recovery or truncation.
1771        #[cfg(test)]
1772        pub(crate) fn checkpoint_floor(&self) -> u64 {
1773            self.checkpoint_floor
1774        }
1775
1776        /// Return whether the configured encoded-size threshold was crossed.
1777        ///
1778        /// A full segment directory defers rotation: the fold CAS would have no
1779        /// room for the new entry, so the active segment grows past the advisory
1780        /// target until truncation frees retained history. The engine's separate
1781        /// hard active-segment ceiling bounds that deferral and returns admission
1782        /// backpressure rather than allowing provider-limit growth.
1783        ///
1784        /// Reserve room for two entries, not one. A normal swap folds only the
1785        /// old tail (the pending becomes the new appendable tail). But a crash
1786        /// in the swap window leaves the consumed pending carrying acknowledged
1787        /// records that recovery cannot re-adopt as an appendable frontier, so
1788        /// recovery seals both the old tail and that pending — two directory
1789        /// entries. Admitting a swap with room for only one wedges that
1790        /// recovery on [`ProtocolError::SegmentDirectoryFull`]; gating on two
1791        /// keeps the swap window always recoverable.
1792        pub(crate) fn rotation_due(&self, max_segment_bytes: usize) -> bool {
1793            self.segment_writer.committed_len() != 0
1794                && !self.segment_writer.is_poisoned()
1795                && self.segment_writer.physical_size() >= max_segment_bytes
1796                && self.manifest.directory_has_room(2)
1797        }
1798
1799        /// Re-read the epoch-free directory fields changed by maintenance, then
1800        /// evaluate rotation against the fresh capacity. The writer epoch and
1801        /// owner are untouched: truncation removes only entries below the
1802        /// committed floor, but without this refresh an engine held at the hard
1803        /// active-segment ceiling would keep trusting its pre-truncation full
1804        /// directory until another unrelated manifest operation.
1805        pub(crate) async fn refresh_rotation_due(
1806            &mut self,
1807            max_segment_bytes: usize,
1808        ) -> Result<bool, Error> {
1809            self.manifest
1810                .refreshed_record()
1811                .await
1812                .map_err(Error::from)?;
1813            Ok(self.rotation_due(max_segment_bytes))
1814        }
1815
1816        /// Submit already-formed atomic records to the segment pipeline.
1817        ///
1818        /// Database integrations should use [`crate::WalHandle::enqueue_append`]
1819        /// so caller-assigned sequence numbers are checked during admission and the
1820        /// engine manages its continuously replenished window.
1821        #[cfg(test)]
1822        pub(crate) async fn enqueue_records(
1823            &mut self,
1824            records: Vec<RecordFrame>,
1825            on_attempted: AttemptedBytes,
1826        ) -> Result<Vec<RecordPendingCommit>, Error> {
1827            let pending = self
1828                .segment_writer
1829                .enqueue_data_window(records, on_attempted)
1830                .await?
1831                .into_pending();
1832            Ok(pending
1833                .into_iter()
1834                .map(|inner| RecordPendingCommit {
1835                    global_record_index: self.base_record_index + inner.logical_offset,
1836                    inner,
1837                })
1838                .collect())
1839        }
1840
1841        pub(crate) async fn enqueue_records_for_engine(
1842            &mut self,
1843            records: Vec<RecordFrame>,
1844            on_attempted: AttemptedBytes,
1845        ) -> Result<RecordCommitRange, Error> {
1846            let range = self
1847                .segment_writer
1848                .enqueue_data_window(records, on_attempted)
1849                .await?;
1850            let first_offset = range.first_offset() as u64;
1851            let end_offset = range.end_offset() as u64;
1852            Ok(RecordCommitRange {
1853                base_record_index: self.base_record_index,
1854                first_global_record_index: self.base_record_index + first_offset,
1855                end_global_record_index: self.base_record_index + end_offset,
1856                inner: range,
1857            })
1858        }
1859
1860        /// Whether a spare successor is provisioned and ready to swap in.
1861        pub(crate) fn spare_ready(&self) -> bool {
1862            self.spare.as_ref().is_some_and(|spare| spare.registered)
1863        }
1864
1865        /// Rotation can assign the successor base only after the old segment's
1866        /// admitted prefix is fully committed. Otherwise a later segment could
1867        /// become durable above an unresolved global sequence gap.
1868        pub(crate) fn swap_boundary_ready(&self) -> bool {
1869            let admitted = self.segment_writer.admitted_len();
1870            admitted != 0 && admitted == self.segment_writer.committed_len()
1871        }
1872
1873        /// Whether the engine should start provisioning a spare. One spare is
1874        /// kept warm at all times: it is an empty object plus one idle session
1875        /// per zone, and a session the service expires self-heals through
1876        /// handle resume on the first post-swap append.
1877        pub(crate) fn spare_wanted(&self) -> bool {
1878            self.spare.is_none()
1879        }
1880
1881        pub(crate) fn unregistered_spare_ready(&self) -> bool {
1882            self.spare.as_ref().is_some_and(|spare| !spare.registered)
1883        }
1884
1885        pub(crate) fn adopt_registered_spare(&mut self, spare: RegisteredSpare) {
1886            self.manifest.install_update(spare.manifest);
1887            self.adopt_spare(spare.id, spare.writer, true);
1888        }
1889
1890        pub(crate) fn adopt_unregistered_spare(&mut self, id: String, writer: Writer) {
1891            self.adopt_spare(id, writer, false);
1892        }
1893
1894        fn adopt_spare(&mut self, id: String, mut writer: Writer, registered: bool) {
1895            writer.set_max_replica_lag_bytes(self.max_replica_lag_bytes);
1896            writer.set_lane_stall_timeout(self.lane_stall_timeout);
1897            self.spare = Some(PreparedSpare {
1898                id,
1899                writer,
1900                registered,
1901            });
1902        }
1903
1904        /// Allocate the next creation-ordered segment id under this writer's
1905        /// claimed epoch. An id whose provisioning fails is simply abandoned —
1906        /// gaps are fine, and a later recovery makes the partial orphan eligible
1907        /// for asynchronous maintenance cleanup.
1908        pub(crate) fn next_segment_id(&mut self) -> String {
1909            let id = segment_id(self.manifest.record().epoch, self.next_segment_seq);
1910            self.next_segment_seq += 1;
1911            id
1912        }
1913
1914        pub(crate) fn provision_parts(&self) -> Result<ProvisionParts, Error> {
1915            Ok(ProvisionParts {
1916                factories: self.factories.clone(),
1917                prefix: self.prefix.clone(),
1918                client_config: self.client_config.clone(),
1919                max_replica_lag_bytes: self.max_replica_lag_bytes,
1920                lane_stall_timeout: self.lane_stall_timeout,
1921                metrics: Arc::clone(&self.metrics),
1922                manifest: self.manifest.off_path_access().map_err(Error::from)?,
1923            })
1924        }
1925
1926        pub(crate) fn take_recovered_fold(&mut self) -> Option<PendingSwap> {
1927            self.pending_fold.take()
1928        }
1929
1930        /// Freeze the old segment at its fully committed boundary, wait for its
1931        /// already-dispatched digest work, and switch lanes to the pending
1932        /// segment's pre-opened sessions. The pending id is already durable in
1933        /// the manifest, so this hot-path operation publishes no control-plane
1934        /// decision.
1935        /// Nothing touches the spare's object metadata, so its open append
1936        /// sessions never see a metageneration change.
1937        pub(crate) async fn begin_swap(&mut self) -> Result<Option<PendingSwap>, Error> {
1938            if self.segment_writer.is_poisoned() {
1939                return Err(Error::Poisoned);
1940            }
1941            let admitted = self.segment_writer.admitted_len();
1942            if admitted == 0 {
1943                return Ok(None);
1944            }
1945            let committed = self.segment_writer.committed_len();
1946            if admitted != committed {
1947                return Err(Error::Internal(format!(
1948                    "rotation boundary has {committed} of {admitted} admitted records committed"
1949                )));
1950            }
1951            let Some(spare) = self.spare.take() else {
1952                return Err(Error::Internal("rotation swap without a spare".into()));
1953            };
1954            if !spare.registered {
1955                self.spare = Some(spare);
1956                return Err(Error::Internal(
1957                    "rotation swap attempted with an unregistered spare".into(),
1958                ));
1959            }
1960            let PreparedSpare {
1961                id: spare_id,
1962                writer: spare_writer,
1963                ..
1964            } = spare;
1965            let end = self.base_record_index + committed as u64 - 1;
1966            // admission to this segment stops here, so the admitted digest is
1967            // frozen and equals the committed digest once the drain completes
1968            let digest = self.segment_writer.seal_digest().await;
1969            let crc32c = self.segment_writer.seal_crc32c();
1970            let old_base = self.base_record_index;
1971            self.base_record_index = end + 1;
1972            let old_id = std::mem::replace(&mut self.active_id, spare_id.clone());
1973            let old_writer = std::mem::replace(&mut self.segment_writer, spare_writer);
1974            tracing::info!(
1975                segment_base = old_base,
1976                segment_end = end,
1977                "WAL rotation flipped to registered pending segment"
1978            );
1979            Ok(Some(PendingSwap {
1980                id: old_id,
1981                base_record_index: old_base,
1982                end_record_index: end,
1983                digest,
1984                crc32c,
1985                successor_id: spare_id,
1986                writer: Some(old_writer),
1987            }))
1988        }
1989
1990        pub(crate) fn pending_fold_request(
1991            &self,
1992            swap: &PendingSwap,
1993        ) -> Result<PendingFold, Error> {
1994            let spare = self
1995                .spare
1996                .as_ref()
1997                .filter(|spare| !spare.registered)
1998                .ok_or_else(|| Error::Internal("pending fold has no refill spare".into()))?;
1999            Ok(PendingFold {
2000                old_tail_id: swap.id.clone(),
2001                old_tail_base: swap.base_record_index,
2002                old_tail_end: swap.end_record_index,
2003                old_tail_digest: swap.digest.clone(),
2004                old_tail_crc32c: swap.crc32c,
2005                consumed_pending_id: swap.successor_id.clone(),
2006                successor_tail_id: swap.successor_id.clone(),
2007                refill_pending_id: spare.id.clone(),
2008            })
2009        }
2010
2011        pub(crate) fn confirm_fold(
2012            &mut self,
2013            swap: &PendingSwap,
2014            update: ManifestUpdate,
2015        ) -> Result<(), Error> {
2016            let quorum = self.quorum();
2017            self.manifest.install_update(update);
2018            let spare = self
2019                .spare
2020                .as_mut()
2021                .ok_or_else(|| Error::Internal("fold completed without a refill spare".into()))?;
2022            spare.registered = true;
2023            tracing::info!(
2024                segment_base = swap.base_record_index,
2025                segment_end = swap.end_record_index,
2026                "WAL pending segment fold committed"
2027            );
2028            if let Some(segment) = self
2029                .sealed_segments
2030                .iter_mut()
2031                .find(|segment| segment.id == swap.id)
2032            {
2033                segment.seal_pending = swap.writer.is_some();
2034            } else {
2035                self.sealed_segments.push(SegmentDescriptor {
2036                    id: swap.id.clone(),
2037                    base_record_index: swap.base_record_index,
2038                    end_record_index: swap.end_record_index,
2039                    crc32c: swap.crc32c,
2040                    copies: quorum,
2041                    finalized_copies: usize::from(swap.writer.is_none()) * quorum,
2042                    seal_pending: swap.writer.is_some(),
2043                });
2044            }
2045            self.metrics.rotations_completed.increment();
2046            Ok(())
2047        }
2048
2049        #[cfg(test)]
2050        pub(crate) async fn rotate(&mut self) -> Result<(), Error> {
2051            if self.segment_writer.is_poisoned() {
2052                return Err(Error::Poisoned);
2053            }
2054            let attempted = self.segment_writer.admitted_len();
2055            let committed = self.segment_writer.committed_len();
2056            if attempted != committed {
2057                return Err(Error::Internal(format!(
2058                    "automatic rotation found {committed} of {attempted} records committed"
2059                )));
2060            }
2061            if committed == 0 {
2062                return Ok(());
2063            }
2064            let swap = self.begin_swap().await?.ok_or_else(|| {
2065                Error::Internal("non-empty test rotation produced no swap".into())
2066            })?;
2067            let parts = self.provision_parts()?;
2068            let manifest = parts.manifest.clone();
2069            let refill_id = self.next_segment_id();
2070            let (refill_id, refill) = provision_spare(
2071                parts.factories,
2072                parts.prefix,
2073                parts.client_config,
2074                parts.max_replica_lag_bytes,
2075                parts.lane_stall_timeout,
2076                refill_id,
2077                parts.metrics,
2078            )
2079            .await?;
2080            self.adopt_unregistered_spare(refill_id, refill);
2081            let fold = self.pending_fold_request(&swap)?;
2082            let update = fold_registered_pending(manifest, fold).await?;
2083            self.confirm_fold(&swap, update)?;
2084            let sealed_id = swap.id.clone();
2085            let mut segment = swap
2086                .into_segment()
2087                .ok_or_else(|| Error::Internal("live rotation lost its old writer".into()))?;
2088            segment.writer.seal().await?;
2089            self.metrics.segments_sealed.increment();
2090            tracing::info!(
2091                segment_base = segment.base_record_index,
2092                segment_end = segment.end_record_index,
2093                "WAL segment rotation completed"
2094            );
2095            let quorum = self.quorum();
2096            if let Some(descriptor) = self
2097                .sealed_segments
2098                .iter_mut()
2099                .find(|descriptor| descriptor.id == sealed_id)
2100            {
2101                descriptor.finalized_copies = quorum;
2102                descriptor.seal_pending = false;
2103            }
2104            Ok(())
2105        }
2106
2107        /// Re-replicate immutable sealed segments to missing or divergent zones.
2108        ///
2109        /// This operation never repairs the active appendable segment. The WAL
2110        /// engine runs equivalent full passes at startup and on its configured
2111        /// periodic maintenance interval; degraded rotations schedule a targeted
2112        /// pass for only the new seal. Diagnostic tools may invoke this directly
2113        /// while they exclusively own the writer.
2114        pub(crate) async fn repair_sealed_segments(&mut self) -> Result<RepairReport, Error> {
2115            let floor = self
2116                .manifest
2117                .refreshed_record()
2118                .await
2119                .map_err(Error::from)?
2120                .trunc;
2121            repair_sealed_pass(&self.factories, &self.prefix, &self.sealed_segments, floor).await
2122        }
2123
2124        /// Delete whole sealed segments whose inclusive end is below `floor`.
2125        /// The engine routes truncation through the maintenance task; this
2126        /// writer-level form serves diagnostics and tests that own the writer.
2127        #[cfg(test)]
2128        pub(crate) async fn truncate_before(
2129            &mut self,
2130            floor: WalSeqNo,
2131        ) -> Result<TruncationReport, Error> {
2132            truncate_pass(
2133                &self.factories,
2134                &self.prefix,
2135                &mut self.sealed_segments,
2136                &mut self.checkpoint_floor,
2137                &mut self.manifest,
2138                floor,
2139            )
2140            .await
2141        }
2142
2143        /// Everything the background maintenance task needs, cloned out of the
2144        /// writer so the task shares no mutable state with the engine.
2145        pub(crate) fn maintenance_config(
2146            &self,
2147            repair_interval: Option<std::time::Duration>,
2148        ) -> crate::maintenance::MaintenanceConfig {
2149            crate::maintenance::MaintenanceConfig {
2150                factories: self.factories.clone(),
2151                manifest_store: self.manifest.store(),
2152                bucket_names: self.manifest.bucket_names().to_vec(),
2153                prefix: self.prefix.clone(),
2154                client_config: self.client_config.clone(),
2155                checkpoint_floor: self.checkpoint_floor,
2156                dead_segment_sweep: self.dead_segment_sweep.clone(),
2157                repair_interval,
2158            }
2159        }
2160
2161        /// Snapshot of the sealed catalog for the maintenance watch channel.
2162        pub(crate) fn sealed_segments_snapshot(&self) -> Vec<SegmentDescriptor> {
2163            self.sealed_segments.clone()
2164        }
2165    }
2166}
2167
2168/// A fresh segment id: the claimed writer epoch plus a per-incarnation
2169/// counter, fixed-width hex so lexicographic name order equals creation
2170/// order. Identity still never encodes position — the chain authority is
2171/// the manifest's segment directory — but ordered names make bucket
2172/// listings legible and ids collision-free without randomness (epochs are
2173/// CAS-granted to one incarnation; the counter is local).
2174pub(crate) fn segment_id(epoch: u64, seq: u64) -> String {
2175    crate::manifest::segment_id(epoch, seq)
2176}
2177
2178/// The claimed-epoch prefix baked into every id by [`segment_id`]. `None`
2179/// for names that do not follow the scheme — callers must leave those alone.
2180fn id_epoch(id: &str) -> Option<u64> {
2181    let (epoch, _) = id.split_once('-')?;
2182    if epoch.len() != 16 {
2183        return None;
2184    }
2185    u64::from_str_radix(epoch, 16).ok()
2186}
2187
2188/// Full object name for a segment id.
2189pub(crate) fn segment_object(prefix: &str, id: &str) -> String {
2190    format!("{prefix}/segments/{id}")
2191}
2192
2193async fn read_sealed_segment(
2194    factories: &[Arc<dyn ReplicaFactory>],
2195    object: &str,
2196    segment: &SegmentDescriptor,
2197) -> Result<Vec<RecordFrame>, Error> {
2198    let replicas = replicas_for(factories, object);
2199    let expected_end = segment.end_record_index;
2200    let expected_records = expected_end
2201        .checked_sub(segment.base_record_index)
2202        .and_then(|span| span.checked_add(1))
2203        .ok_or_else(|| {
2204            Error::InvalidCatalog(format!(
2205                "sealed segment {} has invalid end {expected_end}",
2206                segment.base_record_index
2207            ))
2208        })?;
2209    let expected_crc32c = segment.crc32c;
2210    if replicas.is_empty() {
2211        return Err(Error::InvalidCatalog(
2212            "sealed segment has no configured replicas".into(),
2213        ));
2214    }
2215    let mut snapshots = Vec::new();
2216    for replica in &replicas {
2217        if let Ok(snapshot) = replica.snapshot().await {
2218            if snapshot.crc32c == Some(expected_crc32c)
2219                && crc32c::crc32c(&snapshot.bytes) == expected_crc32c
2220            {
2221                if let Some(records) = decoded_sealed_snapshot(&snapshot, expected_records) {
2222                    return Ok(records);
2223                }
2224            }
2225            snapshots.push(snapshot);
2226        }
2227    }
2228    snapshots.retain(|snapshot| decoded_sealed_snapshot(snapshot, expected_records).is_some());
2229    let quorum = majority(replicas.len());
2230    if snapshots.len() < quorum {
2231        return Err(Error::NoReadQuorum);
2232    }
2233    let records = canonical_prefix(&snapshots, quorum)?;
2234    if records.len() as u64 != expected_records {
2235        return Err(Error::InvalidSegmentData(format!(
2236            "segment {} should contain {expected_records} records but contains {}",
2237            segment.base_record_index,
2238            records.len()
2239        )));
2240    }
2241    let actual_crc32c = encoded_records_crc32c(&records)?;
2242    if actual_crc32c != expected_crc32c {
2243        return Err(Error::InvalidSegmentData(format!(
2244            "segment {} quorum bytes have CRC32C {actual_crc32c:08x}, expected {expected_crc32c:08x}",
2245            segment.base_record_index
2246        )));
2247    }
2248    Ok(records)
2249}
2250
2251fn decoded_sealed_snapshot(
2252    snapshot: &ReplicaSnapshot,
2253    expected_records: u64,
2254) -> Option<Vec<RecordFrame>> {
2255    if !snapshot.finalized || !valid_format(&snapshot.metadata) {
2256        return None;
2257    }
2258    let records = RecordFrame::decode_all(&snapshot.bytes).ok()?;
2259    (records.len() as u64 == expected_records).then_some(records)
2260}
2261
2262fn encoded_records_crc32c(records: &[RecordFrame]) -> Result<u32, Error> {
2263    let mut crc32c = 0;
2264    for record in records {
2265        crc32c = crc32c::crc32c_append(crc32c, &record.encode()?);
2266    }
2267    Ok(crc32c)
2268}
2269
2270/// Provision a spare successor under an engine-allocated ordered id:
2271/// conditional create on every zone, append sessions opened —
2272/// everything rotation needs, done entirely off the hot path. Its metadata
2273/// is fixed at creation and never touched again. A crash before the swap
2274/// leaves an orphan outside the manifest directory that a later recovery
2275/// delegates to asynchronous maintenance cleanup.
2276mod rotation {
2277    use super::*;
2278
2279    pub(crate) fn provision_replicas(
2280        factories: &[Arc<dyn ReplicaFactory>],
2281        prefix: &str,
2282        id: &str,
2283    ) -> Vec<Arc<dyn Replica>> {
2284        replicas_for(factories, &segment_object(prefix, id))
2285    }
2286
2287    pub(crate) async fn provision_spare_with_replicas(
2288        replicas: Vec<Arc<dyn Replica>>,
2289        config: ClientConfig,
2290        max_replica_lag_bytes: usize,
2291        lane_stall_timeout: Duration,
2292        id: String,
2293        metrics: Arc<Metrics>,
2294    ) -> Result<(String, Writer), Error> {
2295        let mut writer = QuorumVolume::with_metadata(
2296            replicas,
2297            config,
2298            crate::protocol::protocol_metadata(),
2299            metrics,
2300        )?
2301        .create_writer()
2302        .await?;
2303        writer.set_max_replica_lag_bytes(max_replica_lag_bytes);
2304        writer.set_lane_stall_timeout(lane_stall_timeout);
2305        Ok((id, writer))
2306    }
2307
2308    pub(crate) async fn provision_spare(
2309        factories: Vec<Arc<dyn ReplicaFactory>>,
2310        prefix: String,
2311        config: ClientConfig,
2312        max_replica_lag_bytes: usize,
2313        lane_stall_timeout: Duration,
2314        id: String,
2315        metrics: Arc<Metrics>,
2316    ) -> Result<(String, Writer), Error> {
2317        let replicas = provision_replicas(&factories, &prefix, &id);
2318        provision_spare_with_replicas(
2319            replicas,
2320            config,
2321            max_replica_lag_bytes,
2322            lane_stall_timeout,
2323            id,
2324            metrics,
2325        )
2326        .await
2327    }
2328
2329    pub(crate) async fn provision_registered_spare_with_replicas(
2330        parts: ProvisionParts,
2331        id: String,
2332        replicas: Vec<Arc<dyn Replica>>,
2333    ) -> Result<RegisteredSpare, Error> {
2334        let ProvisionParts {
2335            client_config,
2336            max_replica_lag_bytes,
2337            lane_stall_timeout,
2338            metrics,
2339            manifest,
2340            ..
2341        } = parts;
2342        let (id, writer) = provision_spare_with_replicas(
2343            replicas,
2344            client_config,
2345            max_replica_lag_bytes,
2346            lane_stall_timeout,
2347            id,
2348            metrics,
2349        )
2350        .await?;
2351        let manifest = manifest
2352            .register_pending(id.clone())
2353            .await
2354            .map_err(Error::from)?;
2355        Ok(RegisteredSpare {
2356            id,
2357            writer,
2358            manifest,
2359        })
2360    }
2361
2362    pub(crate) async fn fold_registered_pending(
2363        manifest: ManifestAccess,
2364        fold: PendingFold,
2365    ) -> Result<ManifestUpdate, Error> {
2366        manifest.fold_pending(fold).await.map_err(Error::from)
2367    }
2368}
2369
2370pub(crate) use rotation::{
2371    fold_registered_pending, provision_registered_spare_with_replicas, provision_replicas,
2372    provision_spare, provision_spare_with_replicas,
2373};
2374
2375/// Reconstruct and enforce a seal whose record range is already decided.
2376///
2377/// This path owns no live append lanes and takes no new protocol decision. A
2378/// read quorum is fenced and reduced to the exact committed record count, the
2379/// resulting bytes are checked against the manifest digest when one names the
2380/// decision, and [`QuorumVolume::enforce_seal`] installs that immutable prefix
2381/// on a finalized quorum. Consequently the whole operation is idempotent:
2382/// maintenance may retry it after [`Writer::seal`] consumed its lanes, and
2383/// recovery may repeat it after a crash at any intermediate rewrite.
2384mod maintenance {
2385    use super::*;
2386
2387    #[allow(clippy::too_many_arguments)]
2388    pub(crate) async fn enforce_committed_seal(
2389        factories: &[Arc<dyn ReplicaFactory>],
2390        prefix: &str,
2391        client_config: &ClientConfig,
2392        metrics: Arc<Metrics>,
2393        id: &str,
2394        base_record_index: u64,
2395        end_record_index: u64,
2396        expected_digest: Option<&str>,
2397        expected_crc32c: u32,
2398    ) -> Result<SegmentDescriptor, Error> {
2399        let expected_records_u64 = end_record_index
2400            .checked_sub(base_record_index)
2401            .and_then(|span| span.checked_add(1))
2402            .ok_or_else(|| {
2403                Error::InvalidCatalog(format!(
2404                    "segment {base_record_index} has invalid committed end {end_record_index}"
2405                ))
2406            })?;
2407        let expected_records = usize::try_from(expected_records_u64).map_err(|_| {
2408            Error::InvalidCatalog(format!(
2409                "segment {base_record_index} record count does not fit in memory"
2410            ))
2411        })?;
2412        let object = segment_object(prefix, id);
2413        let volume = QuorumVolume::with_metadata(
2414            replicas_for(factories, &object),
2415            client_config.clone(),
2416            crate::protocol::protocol_metadata(),
2417            metrics,
2418        )?;
2419        let recovered = volume.recover_for_seal(Some(expected_records)).await?;
2420        if recovered.len() != expected_records {
2421            return Err(Error::InvalidCatalog(format!(
2422                "segment {base_record_index} recovered {} records, expected {expected_records}",
2423                recovered.len()
2424            )));
2425        }
2426        if let Some(expected) = expected_digest {
2427            let actual = recovered.digest();
2428            if actual != expected {
2429                return Err(Error::InvalidCatalog(format!(
2430                "segment {base_record_index} recovered seal digest {actual}, expected {expected}"
2431            )));
2432            }
2433        }
2434        let actual = recovered.crc32c();
2435        if actual != expected_crc32c {
2436            return Err(Error::InvalidCatalog(format!(
2437                "segment {base_record_index} recovered CRC32C {actual:08x}, expected {expected_crc32c:08x}"
2438            )));
2439        }
2440        volume.enforce_seal(recovered.canonical()).await?;
2441        let quorum = majority(factories.len());
2442        Ok(SegmentDescriptor {
2443            id: id.to_string(),
2444            base_record_index,
2445            end_record_index,
2446            crc32c: expected_crc32c,
2447            copies: quorum,
2448            finalized_copies: quorum,
2449            seal_pending: false,
2450        })
2451    }
2452
2453    /// One repair pass over `segments` at the committed `floor`: stat-precheck
2454    /// every copy, re-replicate from a healthy finalized source only when a copy
2455    /// is missing, rotted, or divergent. Free of writer state so the background
2456    /// maintenance task can run it concurrently with appends.
2457    pub(crate) async fn repair_sealed_pass(
2458        factories: &[Arc<dyn ReplicaFactory>],
2459        prefix: &str,
2460        segments: &[SegmentDescriptor],
2461        floor: u64,
2462    ) -> Result<RepairReport, Error> {
2463        let mut report = RepairReport::default();
2464        for segment in segments {
2465            if segment.end_record_index < floor {
2466                continue;
2467            }
2468            if segment.seal_pending {
2469                // The fold CAS committed this seal but maintenance has not
2470                // finalized the object yet: there is no finalized source to copy
2471                // from by design, not by loss. The seal's own follow-up pass
2472                // returns here with the flag cleared.
2473                continue;
2474            }
2475            report.segments_examined += 1;
2476            let object = segment_object(prefix, &segment.id);
2477            let expected_crc32c = segment.crc32c;
2478
2479            // Health pre-check by stat only. The committed CRC32C independently
2480            // identifies every healthy copy without a content read.
2481            let replicas = replicas_for(factories, &object);
2482            let stats = join_all(replicas.iter().map(|replica| replica.stat())).await;
2483            let mut targets = Vec::new();
2484            for (replica, stat) in replicas.iter().zip(stats) {
2485                let healthy = stat.is_ok_and(|stat| {
2486                    stat.finalized
2487                        && valid_format(&stat.metadata)
2488                        && stat.crc32c == Some(expected_crc32c)
2489                });
2490                if healthy {
2491                    report.objects_already_healthy += 1;
2492                } else {
2493                    targets.push(Arc::clone(replica));
2494                }
2495            }
2496            if targets.is_empty() {
2497                continue;
2498            }
2499
2500            // `seal_pending` already filtered the only segment that legitimately
2501            // has no finalized copy, so a missing read quorum here is real loss
2502            // and aborts the pass loudly.
2503            let bytes = read_one_sealed_source(factories, &object, segment).await?;
2504            // a rewritten copy carries the constant creation metadata: the
2505            // format marker only, like every segment object
2506            let metadata = crate::protocol::protocol_metadata();
2507            for replica in targets {
2508                match repair_sealed_copy(
2509                    &replica,
2510                    Bytes::from(bytes.clone()),
2511                    metadata.clone(),
2512                    expected_crc32c,
2513                )
2514                .await
2515                {
2516                    Ok(true) => report.objects_repaired += 1,
2517                    Ok(false) => report.objects_already_healthy += 1,
2518                    Err(error) if error.code.transient() => report.transient_failures += 1,
2519                    Err(error) => return Err(error.into()),
2520                }
2521            }
2522        }
2523        Ok(report)
2524    }
2525
2526    /// Restore one manifest-owned historical segment to a finalized quorum.
2527    ///
2528    /// Startup needs this stronger gate before it can admit new work, but it
2529    /// need not wait for an unreachable minority once a quorum is healthy.
2530    /// The manifest CRC32C identifies a surviving canonical source. Recovery
2531    /// reads every reachable copy because a metadata stat can still report the
2532    /// original provider checksum when the content read detects storage rot;
2533    /// only finalized, well-formed bytes whose computed CRC matches count.
2534    /// Each successful repair is finalized and checksum-verified before
2535    /// counting.
2536    pub(crate) async fn restore_sealed_quorum(
2537        factories: &[Arc<dyn ReplicaFactory>],
2538        prefix: &str,
2539        segment: &SegmentDescriptor,
2540    ) -> Result<usize, Error> {
2541        let quorum = majority(factories.len());
2542        let expected_crc32c = segment.crc32c;
2543        let object = segment_object(prefix, &segment.id);
2544        let replicas = replicas_for(factories, &object);
2545        let expected_records = segment
2546            .end_record_index
2547            .checked_sub(segment.base_record_index)
2548            .and_then(|span| span.checked_add(1))
2549            .ok_or_else(|| {
2550                Error::InvalidCatalog(format!(
2551                    "sealed segment {} has no valid committed end",
2552                    segment.base_record_index
2553                ))
2554            })?;
2555        let snapshots = join_all(replicas.iter().map(|replica| replica.snapshot())).await;
2556        let mut healthy = 0;
2557        let mut repair_targets = Vec::new();
2558        let mut canonical = None;
2559        for (replica, snapshot) in replicas.iter().zip(snapshots) {
2560            match snapshot {
2561                Ok(snapshot)
2562                    if snapshot.crc32c == Some(expected_crc32c)
2563                        && crc32c::crc32c(&snapshot.bytes) == expected_crc32c
2564                        && decoded_sealed_snapshot(&snapshot, expected_records).is_some() =>
2565                {
2566                    healthy += 1;
2567                    canonical.get_or_insert(snapshot.bytes);
2568                }
2569                _ => repair_targets.push(Arc::clone(replica)),
2570            }
2571        }
2572        if healthy >= quorum {
2573            return Ok(healthy);
2574        }
2575
2576        let bytes = canonical.ok_or(Error::NoReadQuorum)?;
2577        let metadata = crate::protocol::protocol_metadata();
2578        for replica in repair_targets {
2579            match repair_sealed_copy(
2580                &replica,
2581                Bytes::from(bytes.clone()),
2582                metadata.clone(),
2583                expected_crc32c,
2584            )
2585            .await
2586            {
2587                Ok(_) => {
2588                    healthy += 1;
2589                    if healthy >= quorum {
2590                        return Ok(healthy);
2591                    }
2592                }
2593                Err(error) if error.code.transient() => {}
2594                Err(error) => return Err(error.into()),
2595            }
2596        }
2597        Err(Error::NoReadQuorum)
2598    }
2599
2600    /// One floor-committed truncation pass, free of writer state so the
2601    /// background maintenance task can run it (serialized with repair on the
2602    /// same task) concurrently with appends. The truncator discipline is
2603    /// epoch-free: the manifest CAS raises `chorus.trunc` monotonically while
2604    /// preserving every other field.
2605    ///
2606    /// The work list is the register's segment directory, not this process's
2607    /// chain snapshot: an entry leaves the directory only once its copy is
2608    /// confirmed deleted on every zone, so an entry whose zone slept through a
2609    /// pass survives as a tombstone the next pass retries — no bucket relisting
2610    /// and no separate tombstone objects. The cached `(directory, tail_base)`
2611    /// pair is internally consistent even when stale (the tail base only moves
2612    /// in the same CAS that appends an entry), so derived ends are always right
2613    /// for the entries the cache knows about.
2614    pub(crate) async fn truncate_pass(
2615        factories: &[Arc<dyn ReplicaFactory>],
2616        prefix: &str,
2617        sealed_segments: &mut Vec<SegmentDescriptor>,
2618        checkpoint_floor: &mut u64,
2619        manifest: &mut Manifest,
2620        floor: WalSeqNo,
2621    ) -> Result<TruncationReport, Error> {
2622        let floor = floor.record_index;
2623        if floor < *checkpoint_floor {
2624            return Err(Error::CheckpointRegression {
2625                current: WalSeqNo::record(*checkpoint_floor),
2626                requested: WalSeqNo::record(floor),
2627            });
2628        }
2629        // commit the floor through the regional register before deleting
2630        // anything: recovery and repair ignore objects below it, so a stale
2631        // zone cannot resurrect deleted history even if the database loses
2632        // its own checkpoint
2633        manifest.raise_trunc(floor).await.map_err(Error::from)?;
2634        let report =
2635            delete_segments_below_committed_floor(factories, prefix, sealed_segments, manifest)
2636                .await?;
2637        *checkpoint_floor = floor;
2638        Ok(report)
2639    }
2640
2641    /// Delete segment objects left behind by dead writer incarnations.
2642    ///
2643    /// The carried keep set is the recovery claim's complete directory and tail
2644    /// snapshot. It is deliberately allowed to age: an id is eligible only when
2645    /// it is absent from that set and its embedded epoch is strictly below the
2646    /// claimed epoch. The claiming incarnation and every successor mint ids at
2647    /// or above that boundary, so a later manifest can never make an eligible
2648    /// below-epoch orphan part of replay. Unknown formats, kept ids, and ids at
2649    /// or above the boundary are never deleted.
2650    ///
2651    /// Each delete is generation-matched and therefore idempotent. Transient
2652    /// per-zone failures are counted as deferred work so maintenance retains the
2653    /// sweep for a later pass. Terminal failures are also retained in the report
2654    /// while the pass continues best-effort in other zones; they remain
2655    /// maintenance-only and retry on a future tick or restart.
2656    pub(crate) async fn sweep_dead_segments(
2657        factories: &[Arc<dyn ReplicaFactory>],
2658        prefix: &str,
2659        sweep: &DeadSegmentSweep,
2660    ) -> DeadSegmentSweepReport {
2661        let objects_prefix = format!("{prefix}/segments/");
2662        let listed = join_all(
2663            factories
2664                .iter()
2665                .map(|factory| factory.list(&objects_prefix)),
2666        )
2667        .await;
2668        let mut ids = BTreeSet::new();
2669        let mut deferred_operations = 0usize;
2670        let mut failure = None;
2671        for result in listed {
2672            match result {
2673                Ok(objects) => {
2674                    for object in objects {
2675                        // The sweep is a janitor, not a format gate.
2676                        if !valid_format(&object.metadata) {
2677                            continue;
2678                        }
2679                        if let Some(id) = object.name.strip_prefix(&objects_prefix) {
2680                            ids.insert(id.to_string());
2681                        }
2682                    }
2683                }
2684                Err(error) if error.code.transient() => deferred_operations += 1,
2685                Err(error) => {
2686                    deferred_operations += 1;
2687                    if failure.is_none() {
2688                        failure = Some(error.into());
2689                    }
2690                }
2691            }
2692        }
2693
2694        let mut orphan_segments = 0usize;
2695        let mut deleted_objects = 0usize;
2696        for id in ids {
2697            if sweep.keep.contains(&id) {
2698                continue;
2699            }
2700            let Some(epoch) = id_epoch(&id) else {
2701                continue;
2702            };
2703            if epoch >= sweep.claimed_epoch {
2704                continue;
2705            }
2706
2707            orphan_segments += 1;
2708            let object = segment_object(prefix, &id);
2709            for replica in replicas_for(factories, &object) {
2710                match replica.stat().await {
2711                    Ok(snapshot) => match replica.delete(snapshot.generation).await {
2712                        Ok(()) => deleted_objects += 1,
2713                        Err(error) if error.code == TransportCode::NotFound => {}
2714                        Err(error) if error.code.transient() => deferred_operations += 1,
2715                        Err(error) => {
2716                            deferred_operations += 1;
2717                            if failure.is_none() {
2718                                failure = Some(error.into());
2719                            }
2720                        }
2721                    },
2722                    Err(error) if error.code == TransportCode::NotFound => {}
2723                    Err(error) if error.code.transient() => deferred_operations += 1,
2724                    Err(error) => {
2725                        deferred_operations += 1;
2726                        if failure.is_none() {
2727                            failure = Some(error.into());
2728                        }
2729                    }
2730                }
2731            }
2732        }
2733
2734        DeadSegmentSweepReport {
2735            orphan_segments,
2736            deleted_objects,
2737            deferred_operations,
2738            failure,
2739        }
2740    }
2741
2742    /// Retry deletion tombstones already authorized by the committed manifest
2743    /// floor, without advancing that floor.
2744    ///
2745    /// This is the autonomous part of truncation maintenance: startup and periodic
2746    /// ticks may remove storage and directory entries for history the application
2747    /// previously made unreachable, but they never choose a new checkpoint. The
2748    /// manifest is refreshed first so a task that slept through the original
2749    /// `truncate_before` observes both the latest monotone floor and every
2750    /// surviving tombstone.
2751    pub(crate) async fn cleanup_tombstones_pass(
2752        factories: &[Arc<dyn ReplicaFactory>],
2753        prefix: &str,
2754        sealed_segments: &mut Vec<SegmentDescriptor>,
2755        manifest: &mut Manifest,
2756    ) -> Result<TruncationReport, Error> {
2757        manifest.refreshed_record().await.map_err(Error::from)?;
2758        delete_segments_below_committed_floor(factories, prefix, sealed_segments, manifest).await
2759    }
2760
2761    /// Delete directory entries wholly below the manifest's cached floor and drop
2762    /// only fully absent entries from the register.
2763    ///
2764    /// Generation-matched deletes make every zonal action idempotent. The pending
2765    /// seal exclusion is intentionally shared by application truncation and
2766    /// autonomous cleanup: a committed floor authorizes deletion, but the engine's
2767    /// finalized-quorum gate still needs its source object until enforcement
2768    /// completes.
2769    async fn delete_segments_below_committed_floor(
2770        factories: &[Arc<dyn ReplicaFactory>],
2771        prefix: &str,
2772        sealed_segments: &mut Vec<SegmentDescriptor>,
2773        manifest: &mut Manifest,
2774    ) -> Result<TruncationReport, Error> {
2775        let record = manifest.record().clone();
2776        let floor = record.trunc;
2777        let directory = record.segments.clone();
2778        // A swap whose maintenance seal has not settled yet is published with
2779        // `seal_pending` in the chain snapshot (the engine sends the snapshot
2780        // before it enqueues the seal). Deleting copies underneath that
2781        // in-flight seal would fail it and gate rotation until restart, so
2782        // those entries wait for a later pass.
2783        let seal_in_flight: HashSet<&str> = sealed_segments
2784            .iter()
2785            .filter(|segment| segment.seal_pending)
2786            .map(|segment| segment.id.as_str())
2787            .collect();
2788        let mut deleted_objects = 0usize;
2789        let mut deleted_bases = HashSet::new();
2790        let mut fully_deleted = HashSet::new();
2791        for (index, entry) in directory.iter().enumerate() {
2792            let next_base = directory
2793                .get(index + 1)
2794                .map_or(record.tail_base, |next| next.base);
2795            let Some(end) = next_base.checked_sub(1) else {
2796                continue;
2797            };
2798            if end >= floor {
2799                continue;
2800            }
2801            if seal_in_flight.contains(entry.id.as_str()) {
2802                continue;
2803            }
2804            let object = segment_object(prefix, &entry.id);
2805            let replicas = replicas_for(factories, &object);
2806            let snapshots = join_all(replicas.iter().map(|replica| replica.stat())).await;
2807            let mut absent = 0usize;
2808            for (replica, snapshot) in replicas.iter().zip(snapshots) {
2809                match snapshot {
2810                    // Any copy of a directory entry below the committed floor is
2811                    // deletable, finalized or not: the entry is a committed
2812                    // seal, so admission to the object ended long ago and a
2813                    // straggling unfinalized lane copy is just deleted history.
2814                    Ok(snapshot) => match replica.delete(snapshot.generation).await {
2815                        Ok(()) => {
2816                            deleted_objects += 1;
2817                            absent += 1;
2818                        }
2819                        Err(error) if error.code == TransportCode::NotFound => absent += 1,
2820                        Err(error) if error.code.transient() => {}
2821                        Err(error) => return Err(error.into()),
2822                    },
2823                    Err(error) if error.code == TransportCode::NotFound => absent += 1,
2824                    Err(error) if error.code.transient() => {}
2825                    Err(error) => return Err(error.into()),
2826                }
2827            }
2828            if absent >= majority(replicas.len()) {
2829                deleted_bases.insert(entry.base);
2830            }
2831            if absent == replicas.len() {
2832                fully_deleted.insert(entry.id.clone());
2833            }
2834        }
2835        let chain_before = sealed_segments.len();
2836        sealed_segments.retain(|segment| !deleted_bases.contains(&segment.base_record_index));
2837        let deleted_segments = chain_before - sealed_segments.len();
2838        manifest
2839            .remove_segments(&fully_deleted, floor)
2840            .await
2841            .map_err(Error::from)?;
2842        Ok(TruncationReport {
2843            deleted_objects,
2844            deleted_segments,
2845        })
2846    }
2847
2848    async fn read_one_sealed_source(
2849        factories: &[Arc<dyn ReplicaFactory>],
2850        object: &str,
2851        segment: &SegmentDescriptor,
2852    ) -> Result<Vec<u8>, Error> {
2853        let records = read_sealed_segment(factories, object, segment).await?;
2854        let mut bytes = Vec::new();
2855        for record in records {
2856            bytes.extend_from_slice(&record.encode()?);
2857        }
2858        Ok(bytes)
2859    }
2860
2861    async fn repair_sealed_copy(
2862        replica: &Arc<dyn Replica>,
2863        bytes: Bytes,
2864        metadata: HashMap<String, String>,
2865        expected_crc32c: u32,
2866    ) -> Result<bool, TransportError> {
2867        let current = match replica.snapshot().await {
2868            Ok(snapshot) => Some(snapshot),
2869            Err(error) if error.code == TransportCode::NotFound => None,
2870            Err(error) if error.code == TransportCode::DataLoss => {
2871                // Rotted copy: the content read fails but the content-blind stat
2872                // still names the generation. Delete exactly that generation and
2873                // fall through to recreate from the healthy canonical bytes. A
2874                // racing repairer makes the guarded delete fail, and the next
2875                // pass converges.
2876                let stat = replica.stat().await?;
2877                match replica.delete(stat.generation).await {
2878                    Ok(()) => {}
2879                    Err(error) if error.code == TransportCode::NotFound => {}
2880                    Err(error) => return Err(error),
2881                }
2882                None
2883            }
2884            Err(error) => return Err(error),
2885        };
2886        if current.as_ref().is_some_and(|snapshot| {
2887            snapshot.finalized
2888                && snapshot.bytes == bytes
2889                && valid_format(&snapshot.metadata)
2890                && snapshot.crc32c == Some(expected_crc32c)
2891                && metadata
2892                    .iter()
2893                    .all(|(key, value)| snapshot.metadata.get(key) == Some(value))
2894        }) {
2895            return Ok(false);
2896        }
2897
2898        let mut token = match current {
2899            Some(snapshot) => {
2900                replica
2901                    .replace_appendable(&snapshot, bytes.clone(), metadata)
2902                    .await?
2903            }
2904            None => {
2905                let created = replica.create_appendable(metadata).await?;
2906                let mut token = replica.takeover(&created).await?;
2907                if !bytes.is_empty() {
2908                    token.persisted_size = replica.append(&token, 0, bytes.to_vec()).await?;
2909                }
2910                token
2911            }
2912        };
2913        token.persisted_size = bytes.len() as i64;
2914        let finalized = replica.finalize(&mut token, bytes.len() as i64).await?;
2915        if finalized.crc32c != Some(expected_crc32c) {
2916            let actual = finalized
2917                .crc32c
2918                .map(|crc| format!("{crc:08x}"))
2919                .unwrap_or_else(|| "missing".into());
2920            return Err(TransportError {
2921                zone: finalized.zone,
2922                code: TransportCode::DataLoss,
2923                message: format!(
2924                    "repaired sealed object reported CRC32C {actual}, expected {expected_crc32c:08x}"
2925                ),
2926            });
2927        }
2928        Ok(true)
2929    }
2930}
2931
2932pub(crate) use maintenance::{
2933    cleanup_tombstones_pass, enforce_committed_seal, repair_sealed_pass, restore_sealed_quorum,
2934    sweep_dead_segments, truncate_pass,
2935};
2936
2937fn replay_records(
2938    segment: &SegmentDescriptor,
2939    frames: &[RecordFrame],
2940    from: WalSeqNo,
2941    end: WalSeqNo,
2942) -> Result<Vec<WalRecord>, Error> {
2943    let mut records = Vec::new();
2944    for (offset, frame) in frames.iter().enumerate() {
2945        let record_index = segment.base_record_index + offset as u64;
2946        if record_index < from.record_index || record_index >= end.record_index {
2947            continue;
2948        }
2949        records.push(WalRecord {
2950            seqno: WalSeqNo::record(record_index),
2951            payload: frame.payload.clone(),
2952        });
2953    }
2954    Ok(records)
2955}
2956
2957fn replicas_for(factories: &[Arc<dyn ReplicaFactory>], object: &str) -> Vec<Arc<dyn Replica>> {
2958    factories
2959        .iter()
2960        .map(|factory| factory.replica(object))
2961        .collect()
2962}