Skip to main content

zeppelin_embed/ingest/
purge.rs

1//! Verifiable physical purge tokens, artifact rewriting, and recovery intent.
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use xxhash_rust::xxh3::xxh3_64;
8
9use crate::format::FormatFamily;
10use crate::format::frame::{FormatError, decode_artifact, encode_artifact};
11use crate::lifecycle::durability::{DurabilityPolicy, SyncRequirement};
12use crate::lifecycle::stats::Accounting;
13use crate::lifecycle::{PublishedSnapshot, Store, StoreError, StoreState};
14use crate::manifest::Manifest;
15use crate::manifest::io::{MANIFEST_FILE, load_manifest};
16use crate::meta::{
17    AliveSet, Column, ColumnInput, ColumnStore, ColumnStoreBuilder, ColumnValue, TIMESTAMP_COLUMN,
18};
19use crate::quant::Bit4Factors;
20use crate::segment::layout::Int8Factors;
21use crate::segment::reader::SegmentReader;
22use crate::segment::writer::{
23    SegmentBuild, SegmentDocumentVersions, SegmentFactors, SegmentPayloads, SegmentPostings,
24    SegmentStoredMetadata, SegmentStoredText, write_segment_with_documents_payloads,
25};
26use crate::segment::{ClusteringKeyRange, SegmentId, SegmentMeta};
27use crate::vfs::Vfs;
28use crate::wal::LogSeq;
29
30use super::{ActiveState, DocId, IngestDocument, SealedTombstoneDemand, wal_payload};
31
32pub(crate) const PURGE_INTENT_FILE: &str = "purge.ze";
33const PURGE_INTENT_TEMP_FILE: &str = ".purge.ze.tmp";
34const WAL_PURGE_TEMP_FILE: &str = ".wal.ze.purge.tmp";
35
36type WalImageRecords = (Vec<(u16, Vec<u8>)>, Vec<bool>);
37
38#[derive(Clone, Copy)]
39pub(crate) struct SealedDocumentMatch {
40    pub(crate) segment_index: usize,
41    pub(crate) row: usize,
42    pub(crate) version: super::DocumentVersion,
43}
44
45pub(crate) struct PreparedSealedTombstones {
46    manifest: Manifest,
47    snapshot: PublishedSnapshot,
48    replaced_paths: Vec<PathBuf>,
49    replacement_ids: Vec<SegmentId>,
50}
51
52impl PreparedSealedTombstones {
53    pub(crate) fn into_namespace_manifest(self) -> (Manifest, Vec<SegmentId>) {
54        (self.manifest, self.replacement_ids)
55    }
56
57    pub(crate) fn abort(
58        self,
59        vfs: &dyn Vfs,
60        directory: &Path,
61        policy: DurabilityPolicy,
62    ) -> Result<(), StoreError> {
63        cleanup_replacement_segments(vfs, directory, &self.replacement_ids, policy)
64    }
65
66    pub(crate) fn commit(
67        mut self,
68        publication: &mut super::active::ManifestPublication,
69        store: &Store,
70        vfs: &dyn Vfs,
71        directory: &Path,
72        policy: DurabilityPolicy,
73    ) -> Result<(PublishedSnapshot, Vec<PathBuf>), StoreError> {
74        self.manifest.epochs = store.epoch_registry(&self.manifest.epochs);
75        publication
76            .commit_manifest(vfs, directory, &self.manifest, policy)
77            .map_err(StoreError::Manifest)?;
78        Ok((self.snapshot, self.replaced_paths))
79    }
80}
81
82pub(crate) fn sealed_document_matches(
83    snapshot: &PublishedSnapshot,
84    ids: &[DocId],
85) -> Result<Vec<SealedDocumentMatch>, StoreError> {
86    sealed_document_matches_in(snapshot.segments(), ids)
87}
88
89fn all_sealed_document_matches(
90    snapshot: &PublishedSnapshot,
91    ids: &[DocId],
92) -> Result<Vec<SealedDocumentMatch>, StoreError> {
93    sealed_document_matches_in(snapshot.all_segments(), ids)
94}
95
96fn sealed_document_matches_in(
97    segments: &[SegmentReader],
98    ids: &[DocId],
99) -> Result<Vec<SealedDocumentMatch>, StoreError> {
100    // Each segment's identity-ordered index answers an id by binary search,
101    // so a write costs O(ids x segments x log rows), not a scan of every
102    // sealed row. Matches keep the (segment, ascending row) order of a scan.
103    let requested = ids.iter().copied().collect::<HashSet<_>>();
104    let mut matches = Vec::new();
105    let mut rows = Vec::new();
106    for (segment_index, segment) in segments.iter().enumerate() {
107        rows.clear();
108        for doc_id in &requested {
109            rows.extend(segment.query_rows_with_doc_id(*doc_id)?);
110        }
111        rows.sort_unstable();
112        for row in rows.iter().map(|row| *row as usize) {
113            if let Some(version) = segment.document_version(row).map_err(StoreError::Segment)? {
114                matches.push(SealedDocumentMatch {
115                    segment_index,
116                    row,
117                    version,
118                });
119            }
120        }
121    }
122    Ok(matches)
123}
124
125#[allow(clippy::too_many_arguments)]
126fn prepare_one_segment_replacement(
127    vfs: &dyn Vfs,
128    directory: &Path,
129    snapshot: &PublishedSnapshot,
130    manifest: &mut Manifest,
131    segment_index: usize,
132    rows: &mut Vec<usize>,
133    nonce: u64,
134    generation: u64,
135    policy: DurabilityPolicy,
136    replacement_ids: &mut Vec<SegmentId>,
137) -> Result<Option<(SegmentId, PathBuf)>, StoreError> {
138    if rows.is_empty() {
139        return Ok(None);
140    }
141    let reader = snapshot
142        .segments()
143        .get(segment_index)
144        .ok_or(StoreError::ActiveRowOverflow)?;
145    let alive = reader.alive().map_err(StoreError::Segment)?;
146    rows.retain(|row| {
147        u32::try_from(*row)
148            .ok()
149            .is_some_and(|row| alive.is_alive(row))
150    });
151    if rows.is_empty() {
152        return Ok(None);
153    }
154    let original = manifest
155        .segments
156        .iter()
157        .find(|segment| segment.id == reader.meta().id)
158        .cloned()
159        .ok_or_else(|| {
160            StoreError::Segment(crate::segment::SegmentError::Geometry(format!(
161                "manifest lost sealed mutation segment {}",
162                reader.meta().id
163            )))
164        })?;
165    let replacement_id = replacement_segment_id(original.id, nonce, generation);
166    replacement_ids.push(replacement_id);
167    let all_rows = (0..reader.meta().row_count as usize).collect::<Vec<_>>();
168    let mut replacement = rewrite_segment(
169        vfs,
170        directory,
171        reader,
172        &all_rows,
173        rows,
174        replacement_id,
175        policy,
176    )?;
177    replacement.epoch_id = original.epoch_id;
178    let target = manifest
179        .segments
180        .iter_mut()
181        .find(|segment| segment.id == original.id)
182        .ok_or_else(|| {
183            StoreError::Segment(crate::segment::SegmentError::Geometry(format!(
184                "manifest lost sealed mutation segment {}",
185                original.id
186            )))
187        })?;
188    *target = replacement;
189    Ok(Some((
190        replacement_id,
191        directory.join(original.id.file_name()),
192    )))
193}
194
195#[allow(clippy::too_many_arguments)]
196pub(crate) fn prepare_sealed_tombstones(
197    vfs: &dyn Vfs,
198    directory: &Path,
199    snapshot: &PublishedSnapshot,
200    matches: &[SealedDocumentMatch],
201    ids: &[DocId],
202    durable_end: u64,
203    generation: u64,
204    nonce: u64,
205    #[cfg(feature = "graph-cypher")] generation_absorbed_through: Option<u64>,
206    policy: DurabilityPolicy,
207    accounting: &Arc<Accounting>,
208) -> Result<Option<PreparedSealedTombstones>, StoreError> {
209    if matches.is_empty() || ids.is_empty() {
210        return Ok(None);
211    }
212    let requested = ids.iter().copied().collect::<HashSet<_>>();
213    let mut rows_by_segment = vec![Vec::<usize>::new(); snapshot.segments().len()];
214    let mut seen = HashSet::new();
215    for matched in matches {
216        if requested.contains(&matched.version.doc_id()) {
217            let rows = rows_by_segment
218                .get_mut(matched.segment_index)
219                .ok_or(StoreError::ActiveRowOverflow)?;
220            super::lookup::work(1);
221            if seen.insert((matched.segment_index, matched.row)) {
222                rows.push(matched.row);
223            }
224        }
225    }
226    let manifest_path = directory.join(MANIFEST_FILE);
227    let mut manifest =
228        load_manifest(vfs, &manifest_path, durable_end).map_err(StoreError::Manifest)?;
229    let mut replaced_paths = Vec::new();
230    let mut replacement_ids = Vec::new();
231    let prepared = (|| {
232        for (segment_index, rows) in rows_by_segment.iter_mut().enumerate() {
233            if let Some((_replacement_id, replaced_path)) = prepare_one_segment_replacement(
234                vfs,
235                directory,
236                snapshot,
237                &mut manifest,
238                segment_index,
239                rows,
240                nonce,
241                generation,
242                policy,
243                &mut replacement_ids,
244            )? {
245                replaced_paths.push(replaced_path);
246            }
247        }
248        if replacement_ids.is_empty() {
249            return Ok(None);
250        }
251        manifest.generation = generation;
252        #[cfg(feature = "graph-cypher")]
253        if let Some(graph) = &mut manifest.graph {
254            // The pending delete's WAL batch is counted in this generation,
255            // but neither replay watermark can move past unsealed documents.
256            if let Some(boundary) = generation_absorbed_through {
257                graph.generation_absorbed_through = Some(boundary);
258            }
259        }
260        let remapped = PublishedSnapshot::from_manifest(vfs, directory, &manifest, accounting)?;
261        Ok(Some(PreparedSealedTombstones {
262            manifest,
263            snapshot: remapped,
264            replaced_paths,
265            replacement_ids: replacement_ids.clone(),
266        }))
267    })();
268    if prepared.is_err() {
269        cleanup_replacement_segments(vfs, directory, &replacement_ids, policy)?;
270    }
271    prepared
272}
273
274/// Caller handle for one scheduled physical-purge guarantee.
275#[derive(Clone, Debug, Eq, PartialEq)]
276pub struct PurgeToken {
277    id: u64,
278    generation: u64,
279    unknown_ids: Vec<DocId>,
280    no_op: bool,
281}
282
283impl PurgeToken {
284    /// Returns the stable token identifier.
285    #[must_use]
286    pub const fn id(&self) -> u64 {
287        self.id
288    }
289
290    /// Returns the generation observed when the token was issued.
291    #[must_use]
292    pub const fn generation(&self) -> u64 {
293        self.generation
294    }
295
296    /// Returns requested identifiers absent from active and sealed state.
297    #[must_use]
298    pub fn unknown_ids(&self) -> &[DocId] {
299        &self.unknown_ids
300    }
301
302    /// Returns whether this token represents no physical work.
303    #[must_use]
304    pub const fn is_no_op(&self) -> bool {
305        self.no_op
306    }
307}
308
309/// Completed physical-purge accounting.
310#[derive(Clone, Debug, Eq, PartialEq)]
311pub struct PurgeReport {
312    generation: u64,
313    segments_rewritten: usize,
314    wal_rewritten: bool,
315    unknown_ids: Vec<DocId>,
316}
317
318impl PurgeReport {
319    /// Returns the generation at which the physical guarantee holds.
320    #[must_use]
321    pub const fn generation(&self) -> u64 {
322        self.generation
323    }
324
325    /// Returns the number of sealed segments physically rewritten.
326    #[must_use]
327    pub const fn segments_rewritten(&self) -> usize {
328        self.segments_rewritten
329    }
330
331    /// Returns whether the on-disk WAL was atomically replaced.
332    #[must_use]
333    pub const fn wal_rewritten(&self) -> bool {
334        self.wal_rewritten
335    }
336
337    /// Returns requested identifiers absent from active and sealed state.
338    #[must_use]
339    pub fn unknown_ids(&self) -> &[DocId] {
340        &self.unknown_ids
341    }
342
343    /// Returns whether no artifact needed mutation.
344    #[must_use]
345    pub const fn is_no_op(&self) -> bool {
346        self.segments_rewritten == 0 && !self.wal_rewritten
347    }
348}
349
350/// Typed physical-purge rejection.
351#[derive(Debug)]
352pub enum PurgeError {
353    /// Store lifecycle, format, filesystem, or synchronization failure.
354    Store(StoreError),
355    /// A graph delete rule refused removal of a document node.
356    #[cfg(feature = "graph-cypher")]
357    Delete(super::IngestError),
358    /// Rewriting one immutable segment would exceed available temporary disk.
359    InsufficientTempSpace {
360        /// Exact immutable segment bytes that would be duplicated temporarily.
361        segment_bytes: u64,
362        /// Available filesystem bytes observed before any purge write.
363        available_bytes: u64,
364        /// Integer floor of the strict 120-percent threshold.
365        required_bytes: u64,
366    },
367    /// One store already owns a scheduled purge that has not resolved.
368    PurgeInProgress,
369    /// A token was not issued by this store's current pending operation.
370    UnknownToken {
371        /// Rejected caller token identifier.
372        token_id: u64,
373    },
374    /// The durable purge-intent frame failed checksum or header validation.
375    IntentFormat(FormatError),
376    /// The durable purge-intent payload violated its fixed-width contract.
377    IntentDecode(String),
378    /// Re-encoding the surviving active state violated the WAL payload contract.
379    WalPayload(wal_payload::PayloadError),
380    /// Rewriting the WAL would discard an acknowledged, unabsorbed mutation.
381    WalRewriteWouldDropAcked {
382        /// First retained sequence not covered by the surviving active state.
383        seq: LogSeq,
384    },
385}
386
387impl std::fmt::Display for PurgeError {
388    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        match self {
390            Self::Store(error) => error.fmt(formatter),
391            #[cfg(feature = "graph-cypher")]
392            Self::Delete(error) => error.fmt(formatter),
393            Self::InsufficientTempSpace {
394                segment_bytes,
395                available_bytes,
396                required_bytes,
397            } => write!(
398                formatter,
399                "purge segment of {segment_bytes} bytes needs more than {required_bytes} temporary bytes, only {available_bytes} available"
400            ),
401            Self::PurgeInProgress => formatter.write_str("a physical purge is already pending"),
402            Self::UnknownToken { token_id } => {
403                write!(formatter, "purge token {token_id} is unknown")
404            }
405            Self::IntentFormat(error) => write!(formatter, "purge intent: {error}"),
406            Self::IntentDecode(detail) => write!(formatter, "purge intent decode failed: {detail}"),
407            Self::WalPayload(error) => write!(formatter, "purge WAL payload: {error}"),
408            Self::WalRewriteWouldDropAcked { seq } => write!(
409                formatter,
410                "purge WAL rewrite would drop acknowledged WAL sequence {}",
411                seq.get()
412            ),
413        }
414    }
415}
416
417impl std::error::Error for PurgeError {
418    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
419        match self {
420            Self::Store(error) => Some(error),
421            #[cfg(feature = "graph-cypher")]
422            Self::Delete(error) => Some(error),
423            Self::IntentFormat(error) => Some(error),
424            Self::WalPayload(error) => Some(error),
425            Self::InsufficientTempSpace { .. }
426            | Self::PurgeInProgress
427            | Self::UnknownToken { .. }
428            | Self::IntentDecode(_)
429            | Self::WalRewriteWouldDropAcked { .. } => None,
430        }
431    }
432}
433
434impl From<StoreError> for PurgeError {
435    fn from(error: StoreError) -> Self {
436        Self::Store(error)
437    }
438}
439
440pub(crate) struct PurgeIntent {
441    token_id: u64,
442    ids: Vec<DocId>,
443}
444
445fn surviving_sealed_matches(
446    snapshot: &PublishedSnapshot,
447    demands: &[SealedTombstoneDemand],
448) -> Result<Vec<SealedDocumentMatch>, StoreError> {
449    let requested = demands
450        .iter()
451        .map(|demand| demand.doc_id())
452        .collect::<Vec<_>>();
453    let sealed = sealed_document_matches(snapshot, &requested)?;
454    let mut surviving = Vec::new();
455    for matched in sealed {
456        if !demands.iter().any(|demand| demand.matches(matched.version)) {
457            continue;
458        }
459        let segment = snapshot
460            .segments()
461            .get(matched.segment_index)
462            .ok_or(StoreError::ActiveRowOverflow)?;
463        let row = u32::try_from(matched.row).map_err(|_| StoreError::ActiveRowOverflow)?;
464        if segment.alive().map_err(StoreError::Segment)?.is_alive(row) {
465            surviving.push(matched);
466        }
467    }
468    Ok(surviving)
469}
470
471fn partition_known_ids(
472    requested: Vec<DocId>,
473    active: &super::ActiveSegment,
474    sealed: &[SealedDocumentMatch],
475) -> (Vec<DocId>, Vec<DocId>) {
476    let mut known = Vec::new();
477    let mut unknown = Vec::new();
478    let mut present = super::lookup::set(active.doc_ids());
479    for matched in sealed {
480        super::lookup::work(1);
481        present.insert(matched.version.doc_id());
482    }
483    for id in requested {
484        let in_active = super::lookup::contains(&present, &id);
485        if in_active {
486            known.push(id);
487        } else {
488            unknown.push(id);
489        }
490    }
491    (known, unknown)
492}
493
494fn enforce_temp_space_for_targets(
495    snapshot: &PublishedSnapshot,
496    sealed: &[SealedDocumentMatch],
497    known: &[DocId],
498    available: u64,
499) -> Result<(), PurgeError> {
500    let known = super::lookup::set(known);
501    let targets: HashSet<_> = sealed
502        .iter()
503        .filter(|matched| super::lookup::contains(&known, &matched.version.doc_id()))
504        .map(|matched| matched.segment_index)
505        .collect();
506    for (segment_index, segment) in snapshot.all_segments().iter().enumerate() {
507        if super::lookup::contains(&targets, &segment_index) {
508            enforce_temp_space(segment.meta().file_size, available)?;
509        }
510    }
511    Ok(())
512}
513
514impl Store {
515    pub(crate) fn recover_sealed_tombstones(
516        &self,
517        demands: &[SealedTombstoneDemand],
518    ) -> Result<(), StoreError> {
519        if demands.is_empty() {
520            return Ok(());
521        }
522        let snapshot = self
523            .snapshot
524            .read()
525            .map_err(|_| StoreError::Synchronization {
526                component: "published snapshot",
527            })?
528            .as_ref()
529            .cloned()
530            .ok_or(StoreError::Closed)?;
531        let surviving = surviving_sealed_matches(&snapshot, demands)?;
532        if surviving.is_empty() {
533            return Ok(());
534        }
535        let state = self
536            .state
537            .lock()
538            .map_err(|_| StoreError::Synchronization { component: "state" })?;
539        match *state {
540            StoreState::Open => {}
541            StoreState::Closing => return Err(StoreError::Closing),
542            StoreState::Closed => return Err(StoreError::Closed),
543        }
544        let writer_lock = self
545            .writer_lock
546            .lock()
547            .map_err(|_| StoreError::Synchronization {
548                component: "writer lock",
549            })?;
550        if writer_lock.is_none() {
551            return Err(StoreError::SealedTombstoneRecoveryRequired);
552        }
553        let mut wal = self
554            .wal_writer
555            .lock()
556            .map_err(|_| StoreError::Synchronization {
557                component: "WAL writer",
558            })?;
559        let writer = wal
560            .as_mut()
561            .ok_or(StoreError::SealedTombstoneRecoveryRequired)?;
562        let mut active = self
563            .active
564            .lock()
565            .map_err(|_| StoreError::Synchronization {
566                component: "active segment",
567            })?;
568        let current = active.as_ref().ok_or(StoreError::Closed)?;
569        // Graph-free recovery retains main's repair publication and segment ID.
570        // Graph stores publish the already replayed delete generation exactly.
571        #[cfg(feature = "graph-cypher")]
572        let graph_enabled = snapshot.graph_manifest.is_some();
573        #[cfg(not(feature = "graph-cypher"))]
574        let graph_enabled = false;
575        let generation = if graph_enabled {
576            current.generation
577        } else {
578            current
579                .generation
580                .checked_add(1)
581                .ok_or(StoreError::GenerationOverflow)?
582        };
583        let active_segment = Arc::clone(&current.segment);
584        let mut ids = surviving
585            .iter()
586            .map(|matched| matched.version.doc_id())
587            .collect::<Vec<_>>();
588        ids.sort_unstable();
589        ids.dedup();
590        let prepared = prepare_sealed_tombstones(
591            self.vfs.as_ref(),
592            &self.directory,
593            &snapshot,
594            &surviving,
595            &ids,
596            writer.durable_end(),
597            generation,
598            writer.durable_end().saturating_add(1),
599            #[cfg(feature = "graph-cypher")]
600            Some(writer.durable_end()),
601            self.durability_policy,
602            &self.accounting,
603        )?;
604        let Some(prepared) = prepared else {
605            return Ok(());
606        };
607        let mut publication = writer.manifest_publication()?;
608        let (remapped, replaced_paths) = prepared.commit(
609            &mut publication,
610            self,
611            self.vfs.as_ref(),
612            &self.directory,
613            self.durability_policy,
614        )?;
615        let mut published = self
616            .snapshot
617            .write()
618            .map_err(|_| StoreError::Synchronization {
619                component: "published snapshot",
620            })?;
621        let previous = published.replace(Arc::new(remapped));
622        *active = Some(ActiveState {
623            generation,
624            segment: active_segment,
625        });
626        publication.complete();
627        drop(published);
628        drop(previous);
629        unlink_replaced_segments(
630            self,
631            self.vfs.as_ref(),
632            &self.directory,
633            &replaced_paths,
634            self.durability_policy,
635        );
636        drop(active);
637        drop(wal);
638        drop(writer_lock);
639        drop(state);
640        Ok(())
641    }
642
643    /// Schedules a physical purge and returns before artifact rewriting begins.
644    pub fn purge(&self, ids: &[DocId]) -> Result<PurgeToken, PurgeError> {
645        let available = self.available_purge_bytes()?;
646        self.purge_inner(ids, available, self.vfs.as_ref())
647    }
648
649    /// Test seam that supplies a deterministic free-space observation.
650    #[doc(hidden)]
651    pub fn purge_with_available_space(
652        &self,
653        ids: &[DocId],
654        available_bytes: u64,
655    ) -> Result<PurgeToken, PurgeError> {
656        self.purge_inner(ids, available_bytes, self.vfs.as_ref())
657    }
658
659    fn purge_inner(
660        &self,
661        ids: &[DocId],
662        available_bytes: u64,
663        vfs: &dyn Vfs,
664    ) -> Result<PurgeToken, PurgeError> {
665        let _maintenance = self
666            .maintenance
667            .lock()
668            .map_err(|_| StoreError::Synchronization {
669                component: "physical purge",
670            })?;
671        #[cfg(feature = "graph-cypher")]
672        let mut native_writer = self.native_document_writer()?;
673        #[cfg(feature = "graph-cypher")]
674        if let Some(writer) = native_writer.as_mut().and_then(|slot| slot.as_mut())
675            && self
676                .documents_have_native_nodes(ids)
677                .map_err(PurgeError::Delete)?
678        {
679            self.prepare_purge_intent(ids, available_bytes, vfs)?;
680            self.delete_native_documents_locked(&super::DeleteBatch::new(ids.to_vec()), writer)
681                .map_err(PurgeError::Delete)?;
682        }
683        let state = self
684            .state
685            .lock()
686            .map_err(|_| StoreError::Synchronization { component: "state" })?;
687        match *state {
688            StoreState::Open => {}
689            StoreState::Closing => return Err(StoreError::Closing.into()),
690            StoreState::Closed => return Err(StoreError::Closed.into()),
691        }
692        let writer_lock = self
693            .writer_lock
694            .lock()
695            .map_err(|_| StoreError::Synchronization {
696                component: "writer lock",
697            })?;
698        if writer_lock.is_none() {
699            return Err(StoreError::ReadOnly.into());
700        }
701        let wal = self
702            .wal_writer
703            .lock()
704            .map_err(|_| StoreError::Synchronization {
705                component: "WAL writer",
706            })?;
707        let token = self.schedule_purge_locked(
708            ids,
709            available_bytes,
710            vfs,
711            wal.as_ref().ok_or(StoreError::ReadOnly)?,
712        )?;
713        drop(writer_lock);
714        drop(state);
715        Ok(token)
716    }
717
718    /// Current free bytes on the store's volume, as `purge` observes them.
719    pub(crate) fn available_purge_bytes(&self) -> Result<u64, PurgeError> {
720        available_disk_bytes(&self.directory).map_err(|source| {
721            PurgeError::Store(StoreError::Io {
722                path: self.directory.clone(),
723                source,
724            })
725        })
726    }
727
728    /// Durably records a purge intent for `ids`. The caller holds the
729    /// maintenance, state and writer locks and has checked the store is open
730    /// and writable; this takes the active and snapshot locks itself.
731    pub(crate) fn schedule_purge_locked(
732        &self,
733        ids: &[DocId],
734        available_bytes: u64,
735        vfs: &dyn Vfs,
736        writer: &super::StoreWal,
737    ) -> Result<PurgeToken, PurgeError> {
738        let publication = writer.manifest_publication()?;
739        let (token, intent) = self.prepare_purge_intent(ids, available_bytes, vfs)?;
740        if token.no_op {
741            return Ok(token);
742        }
743        let token_id = intent.token_id;
744        let known = intent.ids;
745        #[cfg(any(test, feature = "test-seams"))]
746        let crash_target_ids = known.iter().map(|id| id.get()).collect::<Vec<_>>();
747        let publication = publication.arm();
748        write_intent(
749            vfs,
750            &self.directory,
751            &PurgeIntent {
752                token_id,
753                ids: known,
754            },
755            self.durability_policy,
756        )?;
757        #[cfg(any(test, feature = "test-seams"))]
758        if let Some(controller) = self.ingest_retention_fault_controller.as_ref() {
759            let plan = controller.take_purge_crash_boundary_plan().map_err(|_| {
760                StoreError::Synchronization {
761                    component: "ingest-retention controller",
762                }
763            })?;
764            if let Some((invocation_id, receipt_sink)) = plan {
765                let receipt = super::IngestRetentionFaultReceiptV1::purge_crash_boundary(
766                    invocation_id,
767                    crash_target_ids,
768                    token_id,
769                );
770                receipt
771                    .write_purge_crash_test_evidence(vfs, &receipt_sink)
772                    .map_err(|source| StoreError::Io {
773                        path: receipt_sink,
774                        source,
775                    })?;
776                std::process::abort();
777            }
778        }
779        publication.complete();
780        Ok(token)
781    }
782
783    /// Performs every definite purge refusal before a document/node deletion.
784    pub(crate) fn prepare_purge_intent(
785        &self,
786        ids: &[DocId],
787        available_bytes: u64,
788        vfs: &dyn Vfs,
789    ) -> Result<(PurgeToken, PurgeIntent), PurgeError> {
790        self.require_no_snapshot_views()?;
791        let intent_path = self.directory.join(PURGE_INTENT_FILE);
792        match vfs.open(&intent_path) {
793            Ok(_) => return Err(PurgeError::PurgeInProgress),
794            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
795            Err(source) => {
796                return Err(StoreError::Io {
797                    path: intent_path,
798                    source,
799                }
800                .into());
801            }
802        }
803        let mut requested = ids.to_vec();
804        requested.sort_unstable();
805        requested.dedup();
806        let active = self
807            .active
808            .lock()
809            .map_err(|_| StoreError::Synchronization {
810                component: "active segment",
811            })?;
812        let active_state = active.as_ref().ok_or(StoreError::Closed)?;
813        let snapshot = self
814            .snapshot
815            .read()
816            .map_err(|_| StoreError::Synchronization {
817                component: "published snapshot",
818            })?
819            .as_ref()
820            .cloned()
821            .ok_or(StoreError::Closed)?;
822        let sealed = all_sealed_document_matches(&snapshot, &requested)?;
823        let (known, unknown) = partition_known_ids(requested, &active_state.segment, &sealed);
824        enforce_temp_space_for_targets(&snapshot, &sealed, &known, available_bytes)?;
825        let token_id = purge_token_id(active_state.generation, &known);
826        let no_op = known.is_empty();
827        let token = PurgeToken {
828            id: token_id,
829            generation: active_state.generation,
830            unknown_ids: unknown,
831            no_op,
832        };
833        Ok((
834            token,
835            PurgeIntent {
836                token_id,
837                ids: known,
838            },
839        ))
840    }
841
842    /// Removes the intent written by [`Self::schedule_purge_locked`] when the
843    /// mutation it was scheduled for did not commit. Same locks held.
844    pub(crate) fn abandon_scheduled_purge_locked(
845        &self,
846        vfs: &dyn Vfs,
847        writer: &super::StoreWal,
848    ) -> Result<(), PurgeError> {
849        let publication = writer.manifest_publication()?.arm();
850        remove_intent(vfs, &self.directory, self.durability_policy)?;
851        publication.complete();
852        Ok(())
853    }
854
855    #[allow(clippy::too_many_arguments)]
856    fn replace_one_purged_segment(
857        &self,
858        vfs: &dyn Vfs,
859        manifest: &mut Manifest,
860        active_state: &mut ActiveState,
861        writer: &super::StoreWal,
862        original: &SegmentMeta,
863        ids: &super::lookup::LookupSet<DocId>,
864        token_id: u64,
865        policy: DurabilityPolicy,
866    ) -> Result<bool, PurgeError> {
867        let _durable_end = writer.durable_end();
868        let path = self.directory.join(original.id.file_name());
869        let reader = SegmentReader::open(vfs, &path, original.id).map_err(StoreError::Segment)?;
870        let survivors = survivor_rows(&reader, ids)?;
871        if survivors.len() == original.row_count as usize {
872            return Ok(false);
873        }
874        let generation = active_state
875            .generation
876            .max(manifest.generation)
877            .checked_add(1)
878            .ok_or(StoreError::GenerationOverflow)?;
879        let replacement_id = replacement_segment_id(original.id, token_id, generation);
880        let mut replacement = rewrite_segment(
881            vfs,
882            &self.directory,
883            &reader,
884            &survivors,
885            &[],
886            replacement_id,
887            policy,
888        )?;
889        replacement.epoch_id = original.epoch_id;
890        drop(reader);
891        let position = manifest
892            .segments
893            .iter()
894            .position(|segment| segment.id == original.id)
895            .ok_or_else(|| {
896                PurgeError::IntentDecode(format!("manifest lost purge segment {}", original.id))
897            })?;
898        let target = manifest.segments.get_mut(position).ok_or_else(|| {
899            PurgeError::IntentDecode("purge segment position is invalid".to_owned())
900        })?;
901        *target = replacement;
902        manifest.generation = generation;
903        #[cfg(feature = "graph-cypher")]
904        manifest
905            .record_generation_bump(_durable_end)
906            .map_err(StoreError::Manifest)?;
907        manifest.epochs = self.epoch_registry(&manifest.epochs);
908        let remapped =
909            PublishedSnapshot::from_manifest(vfs, &self.directory, manifest, &self.accounting)?;
910        let mut publication = writer.manifest_publication()?;
911        publication
912            .commit_manifest(vfs, &self.directory, manifest, policy)
913            .map_err(StoreError::Manifest)?;
914        let mut published = self
915            .snapshot
916            .write()
917            .map_err(|_| StoreError::Synchronization {
918                component: "published snapshot",
919            })?;
920        let previous = published.replace(Arc::new(remapped));
921        active_state.generation = generation;
922        publication.complete();
923        drop(published);
924        drop(previous);
925        if let Err(source) = vfs.delete(&path) {
926            #[cfg(any(test, feature = "test-seams"))]
927            if source.kind() == std::io::ErrorKind::Other
928                && let Some(controller) = self.ingest_retention_fault_controller.as_ref()
929            {
930                let invocation_id = controller
931                    .purge_unlink_error_plan(*original.id.as_bytes())
932                    .map_err(|_| StoreError::Synchronization {
933                        component: "ingest-retention controller",
934                    })?;
935                if let Some(invocation_id) = invocation_id {
936                    let intent_present = vfs.open(&self.directory.join(PURGE_INTENT_FILE)).is_ok();
937                    let old_path_linked = vfs.open(&path).is_ok();
938                    controller
939                        .push_receipt(super::IngestRetentionFaultReceiptV1::purge_unlink_error(
940                            invocation_id,
941                            *original.id.as_bytes(),
942                            *replacement_id.as_bytes(),
943                            original.id.file_name(),
944                            true,
945                            intent_present,
946                            old_path_linked,
947                        ))
948                        .map_err(|_| StoreError::Synchronization {
949                            component: "ingest-retention controller",
950                        })?;
951                }
952            }
953            return Err(StoreError::Io {
954                path: path.clone(),
955                source,
956            }
957            .into());
958        }
959        sync_directory(vfs, &self.directory, policy)?;
960        Ok(true)
961    }
962
963    fn commit_bumped_manifest(
964        &self,
965        vfs: &dyn Vfs,
966        manifest: &mut Manifest,
967        generation: u64,
968        writer: &super::StoreWal,
969        _generation_bump: bool,
970        policy: DurabilityPolicy,
971    ) -> Result<(), PurgeError> {
972        // The caller already advanced active generation or rewrote the WAL.
973        // Even a pre-rename failure requires recovery before another write.
974        let mut publication = writer.manifest_publication()?.arm();
975        let _durable_end = writer.durable_end();
976        manifest.generation = generation;
977        #[cfg(feature = "graph-cypher")]
978        if _generation_bump {
979            manifest
980                .record_generation_bump(_durable_end)
981                .map_err(StoreError::Manifest)?;
982        } else if let Some(graph) = &mut manifest.graph {
983            // WAL replay, not a fresh mutation, supplied this generation.
984            graph.generation_absorbed_through = Some(_durable_end);
985        }
986        manifest.epochs = self.epoch_registry(&manifest.epochs);
987        let remapped =
988            PublishedSnapshot::from_manifest(vfs, &self.directory, manifest, &self.accounting)?;
989        publication
990            .commit_manifest(vfs, &self.directory, manifest, policy)
991            .map_err(StoreError::Manifest)?;
992        let mut published = self
993            .snapshot
994            .write()
995            .map_err(|_| StoreError::Synchronization {
996                component: "published snapshot",
997            })?;
998        let previous = published.replace(Arc::new(remapped));
999        publication.complete();
1000        drop(published);
1001        drop(previous);
1002        Ok(())
1003    }
1004
1005    #[allow(clippy::too_many_arguments)]
1006    fn rewrite_wal_for_purge<'a>(
1007        &self,
1008        vfs: &dyn Vfs,
1009        writer: &'a mut super::StoreWal,
1010        manifest: &Manifest,
1011        intent: &PurgeIntent,
1012        next_active: &mut super::ActiveSegment,
1013        records: &[(u16, Vec<u8>)],
1014        tombstoned: &[bool],
1015        policy: DurabilityPolicy,
1016    ) -> Result<super::active::WalReplacement<'a>, PurgeError> {
1017        let retained_first_seq = LogSeq::new(
1018            manifest
1019                .log_seq
1020                .checked_add(1)
1021                .ok_or(StoreError::GenerationOverflow)?,
1022        );
1023        let durable_end = writer.durable_end();
1024        if durable_end >= retained_first_seq.get() {
1025            let snapshot = self
1026                .snapshot
1027                .read()
1028                .map_err(|_| StoreError::Synchronization {
1029                    component: "published snapshot",
1030                })?;
1031            let snapshot = snapshot.as_ref().ok_or(StoreError::Closed)?;
1032            ensure_wal_rewrite_covers_retained(
1033                vfs,
1034                &self.directory,
1035                manifest,
1036                &intent.ids,
1037                next_active,
1038                snapshot.all_segments(),
1039                self.private_preparation.as_ref(),
1040            )?;
1041        }
1042        let rewrite_first_seq = LogSeq::new(durable_end.checked_add(1).ok_or(
1043            StoreError::WalWrite(crate::wal::WalWriteError::SequenceExhausted),
1044        )?);
1045        #[cfg(feature = "graph-cypher")]
1046        let framed;
1047        #[cfg(feature = "graph-cypher")]
1048        let records = if manifest.graph.is_some() && records.len() > 1 {
1049            // Replay counts batches, so frame the replacement image as one batch
1050            // covered by the purge cutoff, including any trailing tombstones.
1051            let count = u32::try_from(records.len())
1052                .map_err(|_| PurgeError::WalPayload(wal_payload::PayloadError::LengthOverflow))?;
1053            framed = records
1054                .iter()
1055                .zip(0_u32..)
1056                .map(|((op, payload), index)| {
1057                    wal_payload::encode_mixed_batch_member(index, count, *op, payload)
1058                        .map(|payload| (wal_payload::MIXED_BATCH_MEMBER_V1, payload))
1059                        .map_err(PurgeError::WalPayload)
1060                })
1061                .collect::<Result<Vec<_>, _>>()?;
1062            framed.as_slice()
1063        } else {
1064            records
1065        };
1066        let publication =
1067            writer.rewrite(vfs, &self.directory, policy, rewrite_first_seq, records)?;
1068        assign_rewritten_sequences(next_active, rewrite_first_seq, tombstoned)?;
1069        Ok(publication)
1070    }
1071
1072    #[cfg(feature = "graph-cypher")]
1073    pub(crate) fn graph_checkpoint_through(
1074        &self,
1075        vfs: &dyn Vfs,
1076    ) -> Result<Option<u64>, StoreError> {
1077        let snapshot = self
1078            .snapshot
1079            .read()
1080            .map_err(|_| StoreError::Synchronization {
1081                component: "published snapshot",
1082            })?;
1083        let snapshot = snapshot.as_ref().ok_or(StoreError::Closed)?;
1084        if !snapshot.graph_enabled {
1085            return Ok(None);
1086        }
1087        let reader = crate::wal::WalReader::open(vfs, &self.directory.join("wal.ze"))
1088            .map_err(StoreError::Wal)?;
1089        let clean = reader.into_clean().map_err(StoreError::WalRecovery)?;
1090        let watermark = snapshot
1091            .absorbed_through()
1092            .min(snapshot.graph_absorbed_through);
1093        let decisions = crate::lifecycle::namespace_batch::transaction_decisions(
1094            vfs,
1095            &self.directory,
1096            clean.records(),
1097            watermark,
1098            self.private_preparation.as_ref(),
1099        )?;
1100        let mut through = None;
1101        for (seq, _, mutation) in super::atomic_batch::committed_mutations_with_decisions(
1102            clean.records(),
1103            watermark,
1104            |binding| decisions.get(&binding.transaction).copied(),
1105        )? {
1106            if seq.get() > snapshot.graph_absorbed_through
1107                && matches!(mutation, wal_payload::MutationPayload::GraphCommit(_))
1108            {
1109                through = Some(seq.get());
1110            }
1111        }
1112        Ok(through)
1113    }
1114
1115    /// Resolves only after rewritten artifacts are committed and old paths are unlinked.
1116    pub fn await_physical_purge(&self, token: PurgeToken) -> Result<PurgeReport, PurgeError> {
1117        self.await_physical_purge_on_vfs(token, self.vfs.as_ref())
1118    }
1119
1120    /// Test seam that runs the purge protocol through an observable filesystem.
1121    #[doc(hidden)]
1122    pub fn await_physical_purge_on_vfs(
1123        &self,
1124        token: PurgeToken,
1125        vfs: &dyn Vfs,
1126    ) -> Result<PurgeReport, PurgeError> {
1127        if token.no_op {
1128            return Ok(PurgeReport {
1129                generation: token.generation,
1130                segments_rewritten: 0,
1131                wal_rewritten: false,
1132                unknown_ids: token.unknown_ids,
1133            });
1134        }
1135        let _maintenance = self
1136            .maintenance
1137            .lock()
1138            .map_err(|_| StoreError::Synchronization {
1139                component: "physical purge",
1140            })?;
1141        let state = self
1142            .state
1143            .lock()
1144            .map_err(|_| StoreError::Synchronization { component: "state" })?;
1145        match *state {
1146            StoreState::Open => {}
1147            StoreState::Closing => return Err(StoreError::Closing.into()),
1148            StoreState::Closed => return Err(StoreError::Closed.into()),
1149        }
1150        let writer_lock = self
1151            .writer_lock
1152            .lock()
1153            .map_err(|_| StoreError::Synchronization {
1154                component: "writer lock",
1155            })?;
1156        if writer_lock.is_none() {
1157            return Err(StoreError::ReadOnly.into());
1158        }
1159        self.require_no_snapshot_views()?;
1160        let mut wal = self
1161            .wal_writer
1162            .lock()
1163            .map_err(|_| StoreError::Synchronization {
1164                component: "WAL writer",
1165            })?;
1166        let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
1167        let report = self.complete_physical_purge_locked(vfs, writer, token)?;
1168        drop(wal);
1169        drop(writer_lock);
1170        drop(state);
1171        Ok(report)
1172    }
1173
1174    /// Runs the scheduled purge protocol to completion. The caller holds the
1175    /// maintenance, state, writer and WAL locks; this takes the active lock.
1176    pub(crate) fn complete_physical_purge_locked(
1177        &self,
1178        vfs: &dyn Vfs,
1179        writer: &mut super::StoreWal,
1180        token: PurgeToken,
1181    ) -> Result<PurgeReport, PurgeError> {
1182        let intent = read_intent(vfs, &self.directory)?;
1183        let policy = if crate::lifecycle::namespace_batch::owns_purge_obligation(
1184            vfs,
1185            &self.directory,
1186            intent.token_id,
1187            self.private_preparation.as_ref(),
1188        )? {
1189            DurabilityPolicy::new(
1190                crate::lifecycle::durability::DurabilityMode::Durable,
1191                crate::lifecycle::durability::CommitTier::Durable,
1192            )
1193            .map_err(StoreError::Durability)?
1194        } else {
1195            self.durability_policy
1196        };
1197        if intent.token_id != token.id {
1198            return Err(PurgeError::UnknownToken { token_id: token.id });
1199        }
1200        #[cfg(feature = "graph-cypher")]
1201        let graph_checkpoint = self.graph_checkpoint_through(vfs)?;
1202        let mut active = self
1203            .active
1204            .lock()
1205            .map_err(|_| StoreError::Synchronization {
1206                component: "active segment",
1207            })?;
1208        let active_state = active.as_mut().ok_or(StoreError::Closed)?;
1209        #[cfg(feature = "graph-cypher")]
1210        if let Some(through) = graph_checkpoint {
1211            self.checkpoint_native_graph_locked(writer, active_state, through, vfs)
1212                .map_err(|error| StoreError::PurgeRecovery {
1213                    detail: format!("purge graph checkpoint: {error}"),
1214                })?;
1215        }
1216        let manifest_path = self.directory.join(MANIFEST_FILE);
1217        let mut manifest = match vfs.open(&manifest_path) {
1218            Ok(_) => load_manifest(vfs, &manifest_path, writer.durable_end())
1219                .map_err(StoreError::Manifest)?,
1220            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Manifest {
1221                #[cfg(feature = "graph-cypher")]
1222                graph: None,
1223                generation: active_state.generation,
1224                log_seq: 0,
1225                segments: Vec::new(),
1226                epochs: Vec::new(),
1227                epoch_alias: None,
1228                schema: crate::meta::Schema::new(Vec::new()).map_err(|source| {
1229                    StoreError::Segment(crate::segment::SegmentError::Columns(source.to_string()))
1230                })?,
1231            },
1232            Err(source) => {
1233                return Err(StoreError::Io {
1234                    path: manifest_path,
1235                    source,
1236                }
1237                .into());
1238            }
1239        };
1240        let mut rewritten = 0_usize;
1241        let purge_ids = super::lookup::set(&intent.ids);
1242        let original_segments = manifest.segments.clone();
1243        for original in original_segments {
1244            if self.replace_one_purged_segment(
1245                vfs,
1246                &mut manifest,
1247                active_state,
1248                writer,
1249                &original,
1250                &purge_ids,
1251                token.id,
1252                policy,
1253            )? {
1254                rewritten = rewritten.saturating_add(1);
1255            }
1256        }
1257        sweep_purge_orphans(vfs, &self.directory, &manifest, policy)?;
1258
1259        let active_has_target = active_state
1260            .segment
1261            .doc_ids()
1262            .iter()
1263            .any(|id| super::lookup::contains(&purge_ids, id));
1264        let mut next_active = if active_has_target {
1265            let (purged, removed) =
1266                active_state
1267                    .segment
1268                    .purge(&intent.ids, &self.accounting, &self.tokenizer)?;
1269            if removed == 0 {
1270                return Err(PurgeError::IntentDecode(
1271                    "active purge target disappeared during admission".to_owned(),
1272                ));
1273            }
1274            active_state.generation = active_state
1275                .generation
1276                .checked_add(1)
1277                .ok_or(StoreError::GenerationOverflow)?;
1278            purged
1279        } else {
1280            active_state
1281                .segment
1282                .purge(&[], &self.accounting, &self.tokenizer)?
1283                .0
1284        };
1285        let (records, tombstoned) = active_wal_records(&next_active)?;
1286        if manifest.generation < active_state.generation {
1287            self.commit_bumped_manifest(
1288                vfs,
1289                &mut manifest,
1290                active_state.generation,
1291                writer,
1292                active_has_target,
1293                policy,
1294            )?;
1295        }
1296        #[cfg(feature = "graph-cypher")]
1297        let retired_through = writer.durable_end();
1298        let publication = self.rewrite_wal_for_purge(
1299            vfs,
1300            writer,
1301            &manifest,
1302            &intent,
1303            &mut next_active,
1304            &records,
1305            &tombstoned,
1306            policy,
1307        )?;
1308        #[cfg(feature = "graph-cypher")]
1309        let writer = &*publication.wal;
1310        #[cfg(feature = "graph-cypher")]
1311        let history_folded = if let Some(graph) = &mut manifest.graph
1312            && !graph.generation_bumps.is_empty()
1313        {
1314            // The replacement WAL retires every old record. No retained graph
1315            // envelope needs the old ordering; restore these increments once,
1316            // before the replacement image. Keep the v2 boundary row that
1317            // proves a cutoff immediately before the new WAL header.
1318            let count = graph
1319                .generation_bumps
1320                .iter()
1321                .try_fold(0_u64, |total, (_, count)| {
1322                    total
1323                        .checked_add(*count)
1324                        .ok_or(StoreError::GenerationOverflow)
1325                })?;
1326            let folded = (retired_through, count);
1327            let changed = graph.generation_bumps.as_slice() != [folded];
1328            graph.generation_bumps.clear();
1329            graph.generation_bumps.push(folded);
1330            changed
1331        } else {
1332            false
1333        };
1334        #[cfg(feature = "graph-cypher")]
1335        if history_folded
1336            || manifest.graph.as_ref().is_some_and(|graph| {
1337                graph.generation_absorbed_through != Some(writer.durable_end())
1338            })
1339        {
1340            // Rewritten document batches were already counted in this
1341            // generation. Publish their new cutoff before purge returns or
1342            // another graph write can be acknowledged.
1343            self.commit_bumped_manifest(
1344                vfs,
1345                &mut manifest,
1346                active_state.generation,
1347                writer,
1348                false,
1349                policy,
1350            )?;
1351        }
1352        active_state.segment = Arc::new(next_active);
1353        #[cfg(feature = "graph-cypher")]
1354        self.publish_native_documents(active_state)?;
1355        remove_intent(vfs, &self.directory, policy)?;
1356        publication.complete();
1357        let generation = active_state.generation;
1358        drop(active);
1359        Ok(PurgeReport {
1360            generation,
1361            segments_rewritten: rewritten,
1362            wal_rewritten: true,
1363            unknown_ids: token.unknown_ids,
1364        })
1365    }
1366
1367    pub(crate) fn recover_pending_physical_purge(&self) -> Result<(), PurgeError> {
1368        let path = self.directory.join(PURGE_INTENT_FILE);
1369        match self.vfs.open(&path) {
1370            Ok(_) => {
1371                let intent = read_intent(self.vfs.as_ref(), &self.directory)?;
1372                // Accepted namespace deletes already have a committed logical
1373                // state. Ordinary purge intents still require writable recovery.
1374                if self
1375                    .writer_lock
1376                    .lock()
1377                    .map_err(|_| StoreError::Synchronization {
1378                        component: "writer lock",
1379                    })?
1380                    .is_none()
1381                {
1382                    return if crate::lifecycle::namespace_batch::owns_purge_obligation(
1383                        self.vfs.as_ref(),
1384                        &self.directory,
1385                        intent.token_id,
1386                        self.private_preparation.as_ref(),
1387                    )? {
1388                        Ok(())
1389                    } else {
1390                        Err(StoreError::ReadOnly.into())
1391                    };
1392                }
1393                let generation = self
1394                    .active
1395                    .lock()
1396                    .map_err(|_| StoreError::Synchronization {
1397                        component: "active segment",
1398                    })?
1399                    .as_ref()
1400                    .ok_or(StoreError::Closed)?
1401                    .generation;
1402                let token = PurgeToken {
1403                    id: intent.token_id,
1404                    generation,
1405                    unknown_ids: Vec::new(),
1406                    no_op: false,
1407                };
1408                self.await_physical_purge(token).map(|_| ())
1409            }
1410            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1411            Err(source) => Err(StoreError::Io { path, source }.into()),
1412        }
1413    }
1414}
1415
1416// Prepared delete records are the transaction-gated obligation. Adoption
1417// materializes the ordinary intent before recording local acceptance.
1418pub(crate) fn namespace_purge_intent(
1419    vfs: &dyn Vfs,
1420    directory: &Path,
1421    transaction: u128,
1422    ids: Vec<DocId>,
1423) -> Result<(), PurgeError> {
1424    if ids.is_empty() {
1425        return Ok(());
1426    }
1427    write_intent(
1428        vfs,
1429        directory,
1430        &PurgeIntent {
1431            token_id: xxh3_64(&transaction.to_le_bytes()),
1432            ids,
1433        },
1434        DurabilityPolicy::new(
1435            crate::lifecycle::durability::DurabilityMode::Durable,
1436            crate::lifecycle::durability::CommitTier::Durable,
1437        )
1438        .map_err(StoreError::Durability)?,
1439    )
1440}
1441
1442impl Store {
1443    pub(crate) fn complete_namespace_purge_locked(
1444        &self,
1445        writer: &mut super::StoreWal,
1446    ) -> Result<u64, PurgeError> {
1447        let intent = read_intent(self.vfs.as_ref(), &self.directory)?;
1448        let token = PurgeToken {
1449            id: intent.token_id,
1450            generation: 0,
1451            unknown_ids: Vec::new(),
1452            no_op: false,
1453        };
1454        self.complete_physical_purge_locked(self.vfs.as_ref(), writer, token)
1455            .map(|report| report.generation())
1456    }
1457}
1458
1459fn enforce_temp_space(segment_bytes: u64, available_bytes: u64) -> Result<(), PurgeError> {
1460    let required_bytes = segment_bytes
1461        .checked_mul(6)
1462        .map(|value| value / 5)
1463        .unwrap_or(u64::MAX);
1464    if available_bytes > required_bytes {
1465        Ok(())
1466    } else {
1467        Err(PurgeError::InsufficientTempSpace {
1468            segment_bytes,
1469            available_bytes,
1470            required_bytes,
1471        })
1472    }
1473}
1474
1475fn purge_token_id(generation: u64, ids: &[DocId]) -> u64 {
1476    let mut bytes = Vec::with_capacity(8_usize.saturating_add(ids.len().saturating_mul(16)));
1477    bytes.extend_from_slice(&generation.to_le_bytes());
1478    for id in ids {
1479        bytes.extend_from_slice(&id.get().to_le_bytes());
1480    }
1481    xxh3_64(&bytes)
1482}
1483
1484fn write_intent(
1485    vfs: &dyn Vfs,
1486    directory: &Path,
1487    intent: &PurgeIntent,
1488    policy: DurabilityPolicy,
1489) -> Result<(), PurgeError> {
1490    let count = u32::try_from(intent.ids.len())
1491        .map_err(|_| PurgeError::IntentDecode("purge id count exceeds u32".to_owned()))?;
1492    let mut payload =
1493        Vec::with_capacity(16_usize.saturating_add(intent.ids.len().saturating_mul(16)));
1494    payload.extend_from_slice(&intent.token_id.to_le_bytes());
1495    payload.extend_from_slice(&count.to_le_bytes());
1496    payload.extend_from_slice(&0_u32.to_le_bytes());
1497    for id in &intent.ids {
1498        payload.extend_from_slice(&id.get().to_le_bytes());
1499    }
1500    let bytes = encode_artifact(FormatFamily::PurgeIntent, 0, &payload);
1501    let temporary = directory.join(PURGE_INTENT_TEMP_FILE);
1502    let committed = directory.join(PURGE_INTENT_FILE);
1503    vfs.write(&temporary, &bytes)
1504        .map_err(|source| StoreError::Io {
1505            path: temporary.clone(),
1506            source,
1507        })?;
1508    if let SyncRequirement::Sync(kind) = policy.data_file_sync() {
1509        vfs.sync(&temporary, kind)
1510            .map_err(|source| StoreError::Io {
1511                path: temporary.clone(),
1512                source,
1513            })?;
1514    }
1515    vfs.rename(&temporary, &committed)
1516        .map_err(|source| StoreError::Io {
1517            path: committed,
1518            source,
1519        })?;
1520    sync_directory(vfs, directory, policy).map_err(PurgeError::from)
1521}
1522
1523pub(crate) fn read_intent(vfs: &dyn Vfs, directory: &Path) -> Result<PurgeIntent, PurgeError> {
1524    let path = directory.join(PURGE_INTENT_FILE);
1525    let bytes = vfs.read(&path).map_err(|source| StoreError::Io {
1526        path: path.clone(),
1527        source,
1528    })?;
1529    let decoded = decode_artifact(
1530        &path.display().to_string(),
1531        FormatFamily::PurgeIntent,
1532        &bytes,
1533    )
1534    .map_err(PurgeError::IntentFormat)?;
1535    let token_id = read_u64(decoded.payload, 0)?;
1536    let count = read_u32(decoded.payload, 8)? as usize;
1537    if read_u32(decoded.payload, 12)? != 0 {
1538        return Err(PurgeError::IntentDecode(
1539            "purge intent reserved field is non-zero".to_owned(),
1540        ));
1541    }
1542    let expected = 16_usize
1543        .checked_add(count.saturating_mul(16))
1544        .ok_or_else(|| PurgeError::IntentDecode("purge intent length overflow".to_owned()))?;
1545    if decoded.payload.len() != expected {
1546        return Err(PurgeError::IntentDecode(format!(
1547            "purge intent bytes {}, expected {expected}",
1548            decoded.payload.len()
1549        )));
1550    }
1551    let mut ids = Vec::with_capacity(count);
1552    for index in 0..count {
1553        let offset = 16_usize
1554            .checked_add(index.saturating_mul(16))
1555            .ok_or_else(|| PurgeError::IntentDecode("purge id offset overflow".to_owned()))?;
1556        let end = offset
1557            .checked_add(16)
1558            .ok_or_else(|| PurgeError::IntentDecode("purge id end overflow".to_owned()))?;
1559        let value = decoded
1560            .payload
1561            .get(offset..end)
1562            .and_then(|bytes| bytes.try_into().ok())
1563            .map(u128::from_le_bytes)
1564            .ok_or_else(|| PurgeError::IntentDecode(format!("purge id {index} is truncated")))?;
1565        ids.push(DocId::new(value));
1566    }
1567    Ok(PurgeIntent { token_id, ids })
1568}
1569
1570fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, PurgeError> {
1571    bytes
1572        .get(offset..offset.saturating_add(4))
1573        .and_then(|value| value.try_into().ok())
1574        .map(u32::from_le_bytes)
1575        .ok_or_else(|| PurgeError::IntentDecode(format!("u32 at {offset} is truncated")))
1576}
1577
1578fn read_u64(bytes: &[u8], offset: usize) -> Result<u64, PurgeError> {
1579    bytes
1580        .get(offset..offset.saturating_add(8))
1581        .and_then(|value| value.try_into().ok())
1582        .map(u64::from_le_bytes)
1583        .ok_or_else(|| PurgeError::IntentDecode(format!("u64 at {offset} is truncated")))
1584}
1585
1586fn remove_intent(
1587    vfs: &dyn Vfs,
1588    directory: &Path,
1589    policy: DurabilityPolicy,
1590) -> Result<(), PurgeError> {
1591    let path = directory.join(PURGE_INTENT_FILE);
1592    vfs.delete(&path)
1593        .map_err(|source| StoreError::Io { path, source })?;
1594    sync_directory(vfs, directory, policy).map_err(PurgeError::from)
1595}
1596
1597fn sync_directory(
1598    vfs: &dyn Vfs,
1599    directory: &Path,
1600    policy: DurabilityPolicy,
1601) -> Result<(), StoreError> {
1602    if let SyncRequirement::Sync(kind) = policy.directory_sync() {
1603        vfs.sync(directory, kind).map_err(|source| StoreError::Io {
1604            path: directory.to_path_buf(),
1605            source,
1606        })?;
1607    }
1608    Ok(())
1609}
1610
1611pub(crate) fn unlink_replaced_segments(
1612    store: &Store,
1613    vfs: &dyn Vfs,
1614    directory: &Path,
1615    replaced_paths: &[PathBuf],
1616    policy: DurabilityPolicy,
1617) {
1618    if store.has_snapshot_views() {
1619        return;
1620    }
1621    for path in replaced_paths {
1622        // The replacement manifest and snapshot are already published. A
1623        // failed unlink leaves an unreachable orphan for open-time cleanup;
1624        // it cannot roll back or deny the acknowledged mutation.
1625        let _ = vfs.delete(path);
1626    }
1627    if !replaced_paths.is_empty() {
1628        let _ = sync_directory(vfs, directory, policy);
1629    }
1630}
1631
1632fn cleanup_replacement_segments(
1633    vfs: &dyn Vfs,
1634    directory: &Path,
1635    replacement_ids: &[SegmentId],
1636    policy: DurabilityPolicy,
1637) -> Result<(), StoreError> {
1638    let mut deleted = false;
1639    for id in replacement_ids {
1640        for path in [
1641            directory.join(id.file_name()),
1642            directory.join(format!(".{}.tmp", id.file_name())),
1643        ] {
1644            match vfs.open(&path) {
1645                Ok(_) => {
1646                    vfs.delete(&path)
1647                        .map_err(|source| StoreError::Io { path, source })?;
1648                    deleted = true;
1649                }
1650                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1651                Err(source) => return Err(StoreError::Io { path, source }),
1652            }
1653        }
1654    }
1655    if deleted {
1656        sync_directory(vfs, directory, policy)?;
1657    }
1658    Ok(())
1659}
1660
1661fn survivor_rows(
1662    reader: &SegmentReader,
1663    ids: &super::lookup::LookupSet<DocId>,
1664) -> Result<Vec<usize>, PurgeError> {
1665    let mut survivors = Vec::with_capacity(reader.meta().row_count as usize);
1666    for row in 0..reader.meta().row_count as usize {
1667        let version = reader.document_version(row).map_err(StoreError::Segment)?;
1668        if version.is_none_or(|version| !super::lookup::contains(ids, &version.doc_id())) {
1669            survivors.push(row);
1670        }
1671    }
1672    Ok(survivors)
1673}
1674
1675pub(crate) enum OwnedFactors {
1676    Bit4(Vec<Bit4Factors>),
1677    Int8(Vec<Int8Factors>),
1678}
1679
1680impl OwnedFactors {
1681    fn borrowed(&self) -> SegmentFactors<'_> {
1682        match self {
1683            Self::Bit4(values) => SegmentFactors::Bit4(values),
1684            Self::Int8(values) => SegmentFactors::Int8(values),
1685        }
1686    }
1687}
1688
1689pub(crate) fn gather_survivor_codes(
1690    reader: &SegmentReader,
1691    survivors: &[usize],
1692    dims: usize,
1693) -> Result<(Vec<u8>, OwnedFactors), StoreError> {
1694    match reader.meta().scheme {
1695        4 => {
1696            let stride = dims.div_ceil(2);
1697            let source_codes = reader.bit4_codes().map_err(StoreError::Segment)?;
1698            let source_factors = reader.bit4_factors().map_err(StoreError::Segment)?;
1699            let mut codes = Vec::with_capacity(survivors.len().saturating_mul(stride));
1700            let mut factors = Vec::with_capacity(survivors.len());
1701            for row in survivors {
1702                let start = row
1703                    .checked_mul(stride)
1704                    .ok_or(StoreError::ActiveRowOverflow)?;
1705                let end = start
1706                    .checked_add(stride)
1707                    .ok_or(StoreError::ActiveRowOverflow)?;
1708                codes.extend_from_slice(
1709                    source_codes
1710                        .get(start..end)
1711                        .ok_or(StoreError::ActiveRowOverflow)?,
1712                );
1713                factors.push(
1714                    source_factors
1715                        .get(*row)
1716                        .copied()
1717                        .ok_or(StoreError::ActiveRowOverflow)?,
1718                );
1719            }
1720            Ok((codes, OwnedFactors::Bit4(factors)))
1721        }
1722        2 => {
1723            let stride = dims;
1724            let source_codes = reader.int8_codes().map_err(StoreError::Segment)?;
1725            let source_factors = reader.int8_factors().map_err(StoreError::Segment)?;
1726            let mut codes = Vec::with_capacity(survivors.len().saturating_mul(stride));
1727            let mut factors = Vec::with_capacity(survivors.len());
1728            for row in survivors {
1729                let start = row
1730                    .checked_mul(stride)
1731                    .ok_or(StoreError::ActiveRowOverflow)?;
1732                let end = start
1733                    .checked_add(stride)
1734                    .ok_or(StoreError::ActiveRowOverflow)?;
1735                for value in source_codes
1736                    .get(start..end)
1737                    .ok_or(StoreError::ActiveRowOverflow)?
1738                {
1739                    codes.push(value.to_ne_bytes()[0]);
1740                }
1741                factors.push(
1742                    source_factors
1743                        .get(*row)
1744                        .copied()
1745                        .ok_or(StoreError::ActiveRowOverflow)?,
1746                );
1747            }
1748            Ok((codes, OwnedFactors::Int8(factors)))
1749        }
1750        scheme => Err(StoreError::Segment(crate::segment::SegmentError::Geometry(
1751            format!("segment rewrite cannot encode scheme {scheme}"),
1752        ))),
1753    }
1754}
1755
1756pub(crate) fn gather_survivor_rescore(
1757    reader: &SegmentReader,
1758    survivors: &[usize],
1759    dims: usize,
1760) -> Result<Vec<f32>, StoreError> {
1761    let source_rescore = reader.rescore_f32().map_err(StoreError::Segment)?;
1762    let mut rescore = Vec::with_capacity(survivors.len().saturating_mul(dims));
1763    for row in survivors {
1764        let start = row.checked_mul(dims).ok_or(StoreError::ActiveRowOverflow)?;
1765        let end = start
1766            .checked_add(dims)
1767            .ok_or(StoreError::ActiveRowOverflow)?;
1768        rescore.extend_from_slice(
1769            source_rescore
1770                .get(start..end)
1771                .ok_or(StoreError::ActiveRowOverflow)?,
1772        );
1773    }
1774    Ok(rescore)
1775}
1776
1777fn gather_survivor_alive(
1778    reader: &SegmentReader,
1779    survivors: &[usize],
1780    additional_tombstones: &[usize],
1781) -> Result<AliveSet, StoreError> {
1782    let additional_tombstones = super::lookup::set(additional_tombstones);
1783    let source_alive = reader.alive().map_err(StoreError::Segment)?;
1784    let row_count = u32::try_from(survivors.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1785    let mut alive = AliveSet::new(row_count);
1786    for (new_row, old_row) in survivors.iter().enumerate() {
1787        let old_row_u32 = u32::try_from(*old_row).map_err(|_| StoreError::ActiveRowOverflow)?;
1788        if !source_alive.is_alive(old_row_u32)
1789            || super::lookup::contains(&additional_tombstones, old_row)
1790        {
1791            alive
1792                .tombstone(u32::try_from(new_row).map_err(|_| StoreError::ActiveRowOverflow)?)
1793                .map_err(|_| StoreError::ActiveRowOverflow)?;
1794        }
1795    }
1796    Ok(alive)
1797}
1798
1799pub(crate) fn gather_survivor_documents(
1800    reader: &SegmentReader,
1801    survivors: &[usize],
1802) -> Result<(Vec<DocId>, Vec<super::Revision>), StoreError> {
1803    let mut doc_ids = Vec::with_capacity(survivors.len());
1804    let mut revisions = Vec::with_capacity(survivors.len());
1805    for row in survivors {
1806        let version = reader
1807            .document_version(*row)
1808            .map_err(StoreError::Segment)?
1809            .ok_or_else(|| {
1810                StoreError::Segment(crate::segment::SegmentError::Geometry(
1811                    "purge target segment has no document-version row".to_owned(),
1812                ))
1813            })?;
1814        doc_ids.push(version.doc_id());
1815        revisions.push(version.revision());
1816    }
1817    Ok((doc_ids, revisions))
1818}
1819
1820type StoredMetadataGather = (Vec<u64>, Vec<u8>);
1821
1822fn gather_survivor_metadata(
1823    reader: &SegmentReader,
1824    survivors: &[usize],
1825) -> Result<Option<StoredMetadataGather>, StoreError> {
1826    let source_metadata = reader.stored_metadata().map_err(StoreError::Segment)?;
1827    let mut metadata_offsets = Vec::with_capacity(survivors.len());
1828    let mut metadata_bytes = Vec::new();
1829    if let Some(source) = source_metadata {
1830        for row in survivors {
1831            metadata_bytes.extend_from_slice(source.row(*row).ok_or_else(|| {
1832                StoreError::Segment(crate::segment::SegmentError::Geometry(format!(
1833                    "stored metadata row {row} is missing"
1834                )))
1835            })?);
1836            metadata_offsets.push(
1837                u64::try_from(metadata_bytes.len()).map_err(|_| StoreError::ActiveRowOverflow)?,
1838            );
1839        }
1840    }
1841    Ok(source_metadata.map(|_| (metadata_offsets, metadata_bytes)))
1842}
1843
1844type StoredTextGather = (Vec<u8>, Vec<u64>, Vec<u8>);
1845
1846fn gather_survivor_text(
1847    reader: &SegmentReader,
1848    survivors: &[usize],
1849) -> Result<Option<StoredTextGather>, StoreError> {
1850    let source_text = reader.stored_text().map_err(StoreError::Segment)?;
1851    let mut text_present = Vec::with_capacity(survivors.len());
1852    let mut text_end_offsets = Vec::with_capacity(survivors.len());
1853    let mut text_bytes = Vec::new();
1854    if let Some(source) = source_text {
1855        for row in survivors {
1856            match source.row(*row).ok_or_else(|| {
1857                StoreError::Segment(crate::segment::SegmentError::Geometry(format!(
1858                    "stored text row {row} is missing"
1859                )))
1860            })? {
1861                Some(text) => {
1862                    text_present.push(1);
1863                    text_bytes.extend_from_slice(text.as_bytes());
1864                }
1865                None => text_present.push(0),
1866            }
1867            text_end_offsets
1868                .push(u64::try_from(text_bytes.len()).map_err(|_| StoreError::ActiveRowOverflow)?);
1869        }
1870    }
1871    Ok(source_text.map(|_| (text_present, text_end_offsets, text_bytes)))
1872}
1873
1874fn rewrite_segment(
1875    vfs: &dyn Vfs,
1876    directory: &Path,
1877    reader: &SegmentReader,
1878    survivors: &[usize],
1879    additional_tombstones: &[usize],
1880    replacement_id: SegmentId,
1881    policy: DurabilityPolicy,
1882) -> Result<SegmentMeta, StoreError> {
1883    let dims = reader.meta().dims as usize;
1884    let (codes, factors) = gather_survivor_codes(reader, survivors, dims)?;
1885    let rescore = gather_survivor_rescore(reader, survivors, dims)?;
1886    let source_columns = reader.columns().map_err(StoreError::Segment)?;
1887    let columns = rewrite_columns(&source_columns, survivors)?;
1888    let alive = gather_survivor_alive(reader, survivors, additional_tombstones)?;
1889    let (doc_ids, revisions) = gather_survivor_documents(reader, survivors)?;
1890    let metadata = gather_survivor_metadata(reader, survivors)?;
1891    let text = gather_survivor_text(reader, survivors)?;
1892    let build = SegmentBuild {
1893        id: replacement_id,
1894        scheme: reader.meta().scheme,
1895        dims: reader.meta().dims,
1896        codes: &codes,
1897        factors: factors.borrowed(),
1898        rescore: &rescore,
1899        columns: &columns,
1900        alive: &alive,
1901    };
1902    let documents = SegmentDocumentVersions {
1903        doc_ids: &doc_ids,
1904        revisions: &revisions,
1905    };
1906    let postings = reader
1907        .postings()
1908        .map_err(StoreError::Segment)?
1909        .map(|postings| postings.retain_rows(survivors))
1910        .transpose()
1911        .map_err(crate::segment::SegmentError::from)
1912        .map_err(StoreError::Segment)?
1913        .map(|postings| postings.encode_region())
1914        .transpose()
1915        .map_err(crate::segment::SegmentError::from)
1916        .map_err(StoreError::Segment)?;
1917    let metadata = metadata
1918        .as_ref()
1919        .map(|(end_offsets, bytes)| SegmentStoredMetadata { end_offsets, bytes });
1920    let text = text
1921        .as_ref()
1922        .map(|(present, end_offsets, bytes)| SegmentStoredText {
1923            present,
1924            end_offsets,
1925            bytes,
1926        });
1927    let postings = postings.as_deref().map(|bytes| SegmentPostings { bytes });
1928    let written = write_segment_with_documents_payloads(
1929        vfs,
1930        directory,
1931        build,
1932        SegmentPayloads {
1933            documents,
1934            metadata,
1935            text,
1936            postings,
1937        },
1938        policy,
1939    )
1940    .map_err(StoreError::Segment)?;
1941    let mut written = written;
1942    written.clustering_key_range = clustering_range(&columns, &alive)?;
1943    Ok(written)
1944}
1945
1946fn rewrite_columns(source: &ColumnStore, survivors: &[usize]) -> Result<ColumnStore, StoreError> {
1947    let mut builder = ColumnStoreBuilder::new(source.schema().clone());
1948    append_survivor_columns(&mut builder, source, survivors)?;
1949    builder.finish().map_err(|source| {
1950        StoreError::Segment(crate::segment::SegmentError::Columns(source.to_string()))
1951    })
1952}
1953
1954pub(crate) fn append_survivor_columns(
1955    builder: &mut ColumnStoreBuilder,
1956    source: &ColumnStore,
1957    survivors: &[usize],
1958) -> Result<(), StoreError> {
1959    for row in survivors {
1960        let row_u32 = u32::try_from(*row).map_err(|_| StoreError::ActiveRowOverflow)?;
1961        let timestamp = source.timestamp(row_u32).ok_or_else(|| {
1962            StoreError::Segment(crate::segment::SegmentError::Columns(format!(
1963                "timestamp row {row} is missing"
1964            )))
1965        })?;
1966        let mut inputs = Vec::with_capacity(source.schema().user_column_count());
1967        for definition in source
1968            .schema()
1969            .columns()
1970            .iter()
1971            .filter(|definition| definition.id() != TIMESTAMP_COLUMN)
1972        {
1973            let column = source.column(definition.id()).ok_or_else(|| {
1974                StoreError::Segment(crate::segment::SegmentError::Columns(format!(
1975                    "column {} is missing",
1976                    definition.id().get()
1977                )))
1978            })?;
1979            let value = match column {
1980                Column::Id128(values) => values.get(row_u32).map(ColumnValue::Id128),
1981                Column::U64(values) => values.get(row_u32).map(ColumnValue::U64),
1982                Column::I64(values) => values.get(row_u32).map(ColumnValue::I64),
1983                Column::F64(values) => values.get(row_u32).map(ColumnValue::F64),
1984                Column::Bool(values) => values.get(row_u32).map(ColumnValue::Bool),
1985                Column::DictionaryString(values) => values.get(row_u32).map(ColumnValue::String),
1986                Column::RawString(values) => values.get(row_u32).map(ColumnValue::String),
1987            };
1988            if let Some(value) = value {
1989                inputs.push(ColumnInput {
1990                    column: definition.id(),
1991                    value,
1992                });
1993            }
1994        }
1995        builder.push_row(timestamp, &inputs).map_err(|source| {
1996            StoreError::Segment(crate::segment::SegmentError::Columns(source.to_string()))
1997        })?;
1998    }
1999    Ok(())
2000}
2001
2002pub(crate) fn clustering_range(
2003    columns: &ColumnStore,
2004    alive: &AliveSet,
2005) -> Result<ClusteringKeyRange, StoreError> {
2006    let mut bounds: Option<(i64, i64)> = None;
2007    for row in alive.iter_alive() {
2008        let timestamp = columns
2009            .timestamp(row)
2010            .ok_or(StoreError::ActiveRowOverflow)?;
2011        bounds = Some(match bounds {
2012            None => (timestamp, timestamp),
2013            Some((minimum, maximum)) => (minimum.min(timestamp), maximum.max(timestamp)),
2014        });
2015    }
2016    Ok(match bounds {
2017        Some((min_ts, max_ts)) => ClusteringKeyRange::Bounded { min_ts, max_ts },
2018        None => ClusteringKeyRange::Empty,
2019    })
2020}
2021
2022fn replacement_segment_id(original: SegmentId, token_id: u64, generation: u64) -> SegmentId {
2023    // XOR with the previous ID can cancel earlier generations/nonces and
2024    // overwrite another live segment. Keep the publication generation intact,
2025    // and distinguish sources within that generation with a seeded hash.
2026    let mut bytes = [0_u8; 16];
2027    for (target, value) in bytes.iter_mut().take(8).zip(generation.to_be_bytes()) {
2028        *target = value;
2029    }
2030    let source = xxhash_rust::xxh3::xxh3_64_with_seed(original.as_bytes(), token_id);
2031    for (target, value) in bytes.iter_mut().skip(8).zip(source.to_be_bytes()) {
2032        *target = value;
2033    }
2034    SegmentId::from_bytes(bytes)
2035}
2036
2037fn sweep_purge_orphans(
2038    vfs: &dyn Vfs,
2039    directory: &Path,
2040    manifest: &Manifest,
2041    policy: DurabilityPolicy,
2042) -> Result<(), PurgeError> {
2043    let reachable = manifest
2044        .segments
2045        .iter()
2046        .map(|segment| directory.join(segment.id.file_name()))
2047        .collect::<HashSet<PathBuf>>();
2048    let mut deleted = false;
2049    for path in vfs.list(directory).map_err(|source| StoreError::Io {
2050        path: directory.to_path_buf(),
2051        source,
2052    })? {
2053        let is_segment = path
2054            .file_name()
2055            .and_then(|name| name.to_str())
2056            .is_some_and(|name| name.starts_with("segment-") && name.ends_with(".zseg"));
2057        let is_segment_temp = path
2058            .file_name()
2059            .and_then(|name| name.to_str())
2060            .is_some_and(|name| name.starts_with(".segment-") && name.ends_with(".zseg.tmp"));
2061        let is_wal_temp = path
2062            .file_name()
2063            .is_some_and(|name| name == WAL_PURGE_TEMP_FILE);
2064        if (is_segment && !reachable.contains(&path)) || is_segment_temp || is_wal_temp {
2065            vfs.delete(&path)
2066                .map_err(|source| StoreError::Io { path, source })?;
2067            deleted = true;
2068        }
2069    }
2070    if deleted {
2071        sync_directory(vfs, directory, policy)?;
2072    }
2073    Ok(())
2074}
2075
2076fn active_wal_records(segment: &super::ActiveSegment) -> Result<WalImageRecords, PurgeError> {
2077    let dims = segment.dims().unwrap_or(0);
2078    let mut records = Vec::with_capacity(segment.row_count().saturating_add(1));
2079    let mut tombstoned = Vec::with_capacity(segment.row_count());
2080    let mut deleted = Vec::new();
2081    let alive = segment.alive()?;
2082    for row in 0..segment.row_count() {
2083        let start = row.checked_mul(dims).ok_or(StoreError::ActiveRowOverflow)?;
2084        let end = start
2085            .checked_add(dims)
2086            .ok_or(StoreError::ActiveRowOverflow)?;
2087        let version = segment.document(row).ok_or(StoreError::ActiveRowOverflow)?;
2088        let mut document = IngestDocument::new(
2089            version,
2090            segment
2091                .vectors()
2092                .get(start..end)
2093                .ok_or(StoreError::ActiveRowOverflow)?
2094                .to_vec(),
2095        )
2096        .with_timestamp(
2097            segment
2098                .timestamps()
2099                .get(row)
2100                .copied()
2101                .ok_or(StoreError::ActiveRowOverflow)?,
2102        )
2103        .with_metadata(
2104            segment
2105                .metadata(row)
2106                .ok_or(StoreError::ActiveRowOverflow)?
2107                .to_vec(),
2108        );
2109        if let Some(text) = segment.text(row)? {
2110            document = document.with_text(text);
2111        }
2112        document = document.with_columns(segment.column_values(row)?);
2113        records.push(super::encode_persisted_upsert(&document).map_err(purge_ingest_error)?);
2114        let row_u32 = u32::try_from(row).map_err(|_| StoreError::ActiveRowOverflow)?;
2115        let is_tombstoned = !alive.is_alive(row_u32);
2116        tombstoned.push(is_tombstoned);
2117        if is_tombstoned {
2118            deleted.push(version.doc_id());
2119        }
2120    }
2121    if !deleted.is_empty() {
2122        records.push((
2123            wal_payload::DELETE_V1,
2124            wal_payload::encode_delete(&deleted).map_err(PurgeError::WalPayload)?,
2125        ));
2126    }
2127    Ok((records, tombstoned))
2128}
2129
2130fn ensure_wal_rewrite_covers_retained(
2131    vfs: &dyn Vfs,
2132    directory: &Path,
2133    manifest: &Manifest,
2134    purged_ids: &[DocId],
2135    next_active: &super::ActiveSegment,
2136    sealed: &[SegmentReader],
2137    preparation: Option<&crate::lifecycle::namespace_batch::PrivatePreparation>,
2138) -> Result<(), PurgeError> {
2139    let purged_ids = super::lookup::set(purged_ids);
2140    let alive = next_active.alive()?;
2141    let mut active_versions = std::collections::HashMap::new();
2142    for (row, id) in next_active.doc_ids().iter().enumerate() {
2143        super::lookup::work(1);
2144        let version = next_active
2145            .document(row)
2146            .ok_or(StoreError::ActiveRowOverflow)?;
2147        let row_u32 = u32::try_from(row).map_err(|_| StoreError::ActiveRowOverflow)?;
2148        active_versions
2149            .entry(*id)
2150            .or_insert((version, !alive.is_alive(row_u32)));
2151    }
2152    #[cfg(feature = "graph-cypher")]
2153    let graph_enabled = manifest.graph.is_some();
2154    #[cfg(not(feature = "graph-cypher"))]
2155    let graph_enabled = false;
2156    let wal_path = directory.join("wal.ze");
2157    let reader = crate::wal::WalReader::open(vfs, &wal_path).map_err(StoreError::Wal)?;
2158    let clean = reader.into_clean().map_err(StoreError::WalRecovery)?;
2159    let absorbed_through = manifest.log_seq;
2160    let decisions = crate::lifecycle::namespace_batch::transaction_decisions(
2161        vfs,
2162        directory,
2163        clean.records(),
2164        absorbed_through,
2165        preparation,
2166    )?;
2167    let mutations = if decisions.is_empty() {
2168        super::atomic_batch::committed_mutations(clean.records(), absorbed_through)?
2169    } else {
2170        super::atomic_batch::committed_mutations_with_decisions(
2171            clean.records(),
2172            absorbed_through,
2173            |binding| decisions.get(&binding.transaction).copied(),
2174        )?
2175    };
2176    for (seq, _, mutation) in mutations {
2177        let covered = match mutation {
2178            #[cfg(feature = "graph-cypher")]
2179            super::wal_payload::MutationPayload::GraphCommit(_) => manifest
2180                .graph
2181                .as_ref()
2182                .is_some_and(|graph| seq.get() <= graph.graph_absorbed_through),
2183            super::wal_payload::MutationPayload::Upsert(document) => {
2184                let version = document.version();
2185                super::lookup::contains(&purged_ids, &version.doc_id())
2186                    || active_versions
2187                        .get(&version.doc_id())
2188                        .is_some_and(|(existing, _)| existing.revision() >= version.revision())
2189            }
2190            super::wal_payload::MutationPayload::Delete(ids) => {
2191                let mut covered = true;
2192                for id in ids {
2193                    if !super::lookup::contains(&purged_ids, &id)
2194                        && !active_versions
2195                            .get(&id)
2196                            .is_some_and(|(_, tombstoned)| *tombstoned)
2197                        && !delete_is_covered_by_manifest(
2198                            sealed,
2199                            id,
2200                            graph_enabled && !active_versions.contains_key(&id),
2201                        )?
2202                    {
2203                        covered = false;
2204                        break;
2205                    }
2206                }
2207                covered
2208            }
2209            super::wal_payload::MutationPayload::MetadataEdit(_)
2210            | super::wal_payload::MutationPayload::MixedBatchMember { .. }
2211            | super::wal_payload::MutationPayload::BatchMember { .. } => false,
2212        };
2213        if !covered {
2214            return Err(PurgeError::WalRewriteWouldDropAcked { seq });
2215        }
2216    }
2217    Ok(())
2218}
2219
2220// The manifest-selected segments and replacement active image jointly cover
2221// a delete: no sealed live copy, and either a persisted tombstone or, on graph
2222// stores only, no active copy. Graph-free stores require main's stored tombstone.
2223// Retention may have removed the segment that held the tombstone. Every
2224// retained upsert must still independently be covered by the rewrite check.
2225fn delete_is_covered_by_manifest(
2226    sealed: &[SegmentReader],
2227    id: DocId,
2228    active_absent: bool,
2229) -> Result<bool, StoreError> {
2230    let mut found = false;
2231    for segment in sealed {
2232        let rows = segment.query_rows_with_doc_id(id)?;
2233        if !rows.is_empty() {
2234            let alive = segment.alive().map_err(StoreError::Segment)?;
2235            if rows.iter().any(|row| alive.is_alive(*row)) {
2236                return Ok(false);
2237            }
2238            found = true;
2239        }
2240    }
2241    Ok(found || active_absent)
2242}
2243
2244fn purge_ingest_error(error: super::IngestError) -> PurgeError {
2245    match error {
2246        #[cfg(feature = "graph-cypher")]
2247        super::IngestError::Graph(error) => PurgeError::IntentDecode(format!(
2248            "active WAL rewrite rejected a graph mutation: {error}"
2249        )),
2250        super::IngestError::Store(error) => PurgeError::Store(error),
2251        super::IngestError::EpochMismatch(error) => {
2252            PurgeError::Store(StoreError::EpochMismatch(error))
2253        }
2254        super::IngestError::EpochUndeclared => PurgeError::Store(StoreError::EpochUndeclared),
2255        super::IngestError::EpochUnstamped => PurgeError::Store(StoreError::EpochUnstamped),
2256        super::IngestError::Payload(error) => PurgeError::WalPayload(error),
2257        super::IngestError::EmptyBatch => {
2258            PurgeError::IntentDecode("active WAL rewrite produced an empty batch error".to_owned())
2259        }
2260        super::IngestError::StaleRevision { .. } => PurgeError::IntentDecode(
2261            "active WAL rewrite produced a stale revision error".to_owned(),
2262        ),
2263        super::IngestError::RevisionConflict { .. } => PurgeError::IntentDecode(
2264            "active WAL rewrite produced a revision condition error".to_owned(),
2265        ),
2266        super::IngestError::Vector(error) => PurgeError::IntentDecode(format!(
2267            "active WAL rewrite rejected a persisted vector: {error}"
2268        )),
2269        super::IngestError::Lexical(error) => PurgeError::IntentDecode(format!(
2270            "active WAL rewrite rejected persisted text: {error}"
2271        )),
2272        super::IngestError::Tokenizer(error) => PurgeError::IntentDecode(format!(
2273            "active WAL rewrite rejected the frozen tokenizer: {error}"
2274        )),
2275        super::IngestError::Columns(error) => PurgeError::IntentDecode(format!(
2276            "active WAL rewrite rejected persisted columns: {error}"
2277        )),
2278    }
2279}
2280
2281fn assign_rewritten_sequences(
2282    segment: &mut super::ActiveSegment,
2283    first_seq: LogSeq,
2284    tombstoned: &[bool],
2285) -> Result<(), PurgeError> {
2286    let delete_seq = (!tombstoned.is_empty() && tombstoned.iter().any(|value| *value))
2287        .then(|| LogSeq::new(first_seq.get().saturating_add(segment.row_count() as u64)));
2288    for row in 0..segment.row_count() {
2289        let row_seq = LogSeq::new(first_seq.get().saturating_add(row as u64));
2290        let sequence = if tombstoned.get(row).copied().unwrap_or(false) {
2291            delete_seq.unwrap_or(row_seq)
2292        } else {
2293            row_seq
2294        };
2295        segment.set_sequence(row, sequence)?;
2296    }
2297    Ok(())
2298}
2299
2300#[cfg(unix)]
2301fn available_disk_bytes(path: &Path) -> std::io::Result<u64> {
2302    use std::ffi::CString;
2303    use std::os::unix::ffi::OsStrExt as _;
2304
2305    let encoded = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
2306        std::io::Error::new(
2307            std::io::ErrorKind::InvalidInput,
2308            "store path contains a NUL byte",
2309        )
2310    })?;
2311    let mut output = std::mem::MaybeUninit::<libc::statvfs>::uninit();
2312    // SAFETY: `encoded` is NUL-terminated and `output` points to writable storage
2313    // for exactly one `statvfs` result initialized by a successful call.
2314    let result = unsafe { libc::statvfs(encoded.as_ptr(), output.as_mut_ptr()) };
2315    if result != 0 {
2316        return Err(std::io::Error::last_os_error());
2317    }
2318    // SAFETY: the successful `statvfs` call initialized every field in `output`.
2319    let output = unsafe { output.assume_init() };
2320    let blocks = u128::from(output.f_bavail);
2321    let fragment = u128::from(output.f_frsize);
2322    u64::try_from(blocks.saturating_mul(fragment)).map_err(|_| {
2323        std::io::Error::new(
2324            std::io::ErrorKind::InvalidData,
2325            "available filesystem bytes exceed u64",
2326        )
2327    })
2328}
2329
2330/// Bytes available to this caller on the volume that actually holds `path`.
2331///
2332/// `GetDiskFreeSpaceExW` is quota-aware and resolves the volume from the store
2333/// path itself, so a store on a mount point or junction is measured against the
2334/// volume it really lives on rather than the one its path prefix suggests.
2335#[cfg(windows)]
2336fn available_disk_bytes(path: &Path) -> std::io::Result<u64> {
2337    crate::sys::windows::available_disk_bytes(path)
2338}
2339
2340#[cfg(not(any(unix, windows)))]
2341fn available_disk_bytes(_path: &Path) -> std::io::Result<u64> {
2342    Err(std::io::Error::new(
2343        std::io::ErrorKind::Unsupported,
2344        "physical purge free-space probing requires statvfs",
2345    ))
2346}
2347
2348#[cfg(test)]
2349#[allow(clippy::expect_used)]
2350mod tests {
2351    use super::{
2352        PURGE_INTENT_FILE, PurgeError, PurgeIntent, PurgeReport, PurgeToken, enforce_temp_space,
2353        purge_token_id, read_intent, read_u32, read_u64, remove_intent, write_intent,
2354    };
2355    use crate::format::FormatFamily;
2356    use crate::format::frame::{decode_artifact, encode_artifact};
2357    use crate::ingest::{DocId, wal_payload};
2358    use crate::lifecycle::StoreError;
2359    use crate::lifecycle::durability::{CommitTier, DurabilityMode, DurabilityPolicy};
2360    use crate::vfs::StdVfs;
2361    use crate::wal::LogSeq;
2362
2363    #[test]
2364    fn a_graph_free_purge_after_retention_matches_main() {
2365        use crate::ingest::{DeleteBatch, DocumentVersion, IngestBatch, IngestDocument, Revision};
2366        use crate::lifecycle::{OpenOptions, Store};
2367        let directory = tempfile::tempdir().expect("directory");
2368        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
2369        store
2370            .ingest(IngestBatch::new(vec![
2371                IngestDocument::new(
2372                    DocumentVersion::new(DocId::new(91), Revision::new(1)),
2373                    vec![1.0],
2374                )
2375                .with_timestamp(91),
2376            ]))
2377            .expect("A");
2378        store.seal().expect("seal A");
2379        store
2380            .ingest(IngestBatch::new(
2381                vec![92, 93]
2382                    .into_iter()
2383                    .map(|id| {
2384                        IngestDocument::new(
2385                            DocumentVersion::new(DocId::new(id), Revision::new(1)),
2386                            vec![1.0],
2387                        )
2388                    })
2389                    .collect(),
2390            ))
2391            .expect("B C");
2392        store
2393            .delete(DeleteBatch::new(vec![DocId::new(91)]))
2394            .expect("delete A");
2395        store.drop_partition(91..92).expect("drop A");
2396        let token = store.purge(&[DocId::new(92)]).expect("purge B");
2397        let before = std::fs::read(directory.path().join("wal.ze")).expect("WAL before");
2398        let result = store.await_physical_purge(token);
2399        assert!(
2400            matches!(result, Err(PurgeError::WalRewriteWouldDropAcked { seq }) if seq == LogSeq::new(4)),
2401            "{result:?}"
2402        );
2403        assert_eq!(
2404            std::fs::read(directory.path().join("wal.ze")).expect("WAL after"),
2405            before
2406        );
2407        // Measured on main 9d217196 in /private/tmp/w346-p1p2-main, a separate
2408        // temporary worktree: same test, graph-cypher, rejection at sequence 4.
2409        // Pin every durable byte, including the pending intent and unchanged WAL.
2410        let main = [
2411            (".ze-readers.lock", ""),
2412            ("manifest.ze", "5a4550454d4245440a000200000000002000000000000000700000000000000038000000000000000600000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6dd00afe67b79862c016c188e60eca8"),
2413            ("purge.ze", "5a4550454d4245440f00010000000000200000000000000058000000000000002000000000000000bf09f9ca40c7b1e501000000000000005c0000000000000000000000000000004c66d4b4b29ca4158fc5f105cfc7c200"),
2414            ("wal.ze", "5a4550454d4245440b000100000000002800000000000000000000000000000002000000000000002c000000020000000000000008000000000002000000010000005c0000000000000000000000000000000100000000000000010000000000803f14975d8581be8d602c000000030000000000000008000100000002000000010000005d0000000000000000000000000000000100000000000000010000000000803f6c6214603baa8205180000000400000000000000020001000000010000005b000000000000000000000000000000c95f15ae22a86feb"),
2415            ("writer.lock", ""),
2416        ].into_iter().map(|(name, hex)| {
2417            let bytes = hex.as_bytes().chunks_exact(2).map(|pair|
2418                u8::from_str_radix(std::str::from_utf8(pair).expect("hex pair"), 16).expect("hex byte")
2419            ).collect::<Vec<_>>();
2420            (name.to_owned(), bytes)
2421        }).collect::<std::collections::BTreeMap<_, _>>();
2422        let files = std::fs::read_dir(directory.path())
2423            .expect("files")
2424            .map(|entry| {
2425                let entry = entry.expect("entry");
2426                (
2427                    entry.file_name().to_string_lossy().into_owned(),
2428                    std::fs::read(entry.path()).expect("bytes"),
2429                )
2430            })
2431            .collect::<std::collections::BTreeMap<_, _>>();
2432        assert_eq!(files, main);
2433    }
2434
2435    #[test]
2436    fn a_graph_free_tombstone_repair_matches_main_bytes() {
2437        use crate::ingest::{DeleteBatch, DocumentVersion, IngestBatch, IngestDocument, Revision};
2438        use crate::lifecycle::{OpenOptions, Store};
2439        let directory = tempfile::tempdir().expect("directory");
2440        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
2441        assert_eq!(
2442            store
2443                .ingest(IngestBatch::new(vec![IngestDocument::new(
2444                    DocumentVersion::new(DocId::new(91), Revision::new(1)),
2445                    vec![1.0]
2446                )]))
2447                .expect("ingest")
2448                .generation(),
2449            1
2450        );
2451        assert_eq!(store.seal().expect("seal"), 2);
2452        let manifest_path = directory.path().join("manifest.ze");
2453        let old_manifest = std::fs::read(&manifest_path).expect("old manifest");
2454        let segment_path = directory
2455            .path()
2456            .join("segment-00000000000000020000000000000001.zseg");
2457        let old_segment = std::fs::read(&segment_path).expect("old segment");
2458        assert_eq!(
2459            store
2460                .delete(DeleteBatch::new(vec![DocId::new(91)]))
2461                .expect("delete")
2462                .generation(),
2463            3
2464        );
2465        // Lose only the delete publication: retain the synced WAL and old sealed bytes.
2466        drop(store);
2467        std::fs::write(&manifest_path, old_manifest).expect("restore manifest");
2468        std::fs::write(&segment_path, old_segment).expect("restore segment");
2469        let recovered = Store::open(directory.path(), OpenOptions::default()).expect("recover");
2470        let bytes = std::fs::read(&manifest_path).expect("repaired manifest");
2471        let manifest = crate::manifest::decode_manifest("repaired", &bytes).expect("decode");
2472        // Measured twice on main 9d217196 in a separate temporary worktree,
2473        // without stashing: review_main_bytes::record_main_repair_bytes.
2474        assert_eq!(manifest.generation, 4);
2475        assert_eq!(
2476            manifest
2477                .segments
2478                .first()
2479                .expect("replacement")
2480                .id
2481                .to_string(),
2482            "00000000000000049e7321e873816107"
2483        );
2484        let main_hex = concat!(
2485            "5a4550454d4245440a000200000000002000000000000000c4000000000000008c00000000000000",
2486            "04000000000000000100000000000000010000000000000000000000000000000000000000000000",
2487            "0000000000000000000000000000000000000000000000049e7321e8738161070100000004000000",
2488            "0100000070c001000000000000000000000000000000000000000000545352310100000001000000",
2489            "00000000000000000000000000000000000000000026433d9a49bbe058e92629d0d9c543",
2490        );
2491        let expected = main_hex
2492            .as_bytes()
2493            .chunks_exact(2)
2494            .map(|pair| {
2495                u8::from_str_radix(std::str::from_utf8(pair).expect("hex pair"), 16)
2496                    .expect("hex byte")
2497            })
2498            .collect::<Vec<_>>();
2499        assert_eq!(
2500            bytes, expected,
2501            "graph-free recovery must preserve main's complete manifest bytes"
2502        );
2503        assert_eq!(recovered.snapshot().expect("snapshot").generation(), 4);
2504        assert_eq!(
2505            recovered.count_documents(None, None).expect("count").count,
2506            0
2507        );
2508    }
2509
2510    fn graph_free_pending_purge_crash(ids: &[u128]) -> tempfile::TempDir {
2511        use crate::ingest::{DeleteBatch, DocumentVersion, IngestBatch, IngestDocument, Revision};
2512        use crate::lifecycle::{OpenOptions, Store};
2513        let directory = tempfile::tempdir().expect("directory");
2514        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
2515        store
2516            .ingest(IngestBatch::new(
2517                ids.iter()
2518                    .map(|id| {
2519                        IngestDocument::new(
2520                            DocumentVersion::new(DocId::new(*id), Revision::new(1)),
2521                            vec![1.0],
2522                        )
2523                    })
2524                    .collect(),
2525            ))
2526            .expect("ingest");
2527        store.seal().expect("seal");
2528        store
2529            .purge(&[DocId::new(if ids.len() == 1 { 91 } else { 92 })])
2530            .expect("schedule purge");
2531        let path = directory.path().join("manifest.ze");
2532        let before = std::fs::read(&path).expect("manifest before delete");
2533        let manifest = crate::manifest::decode_manifest("before", &before).expect("decode");
2534        let segment_path = directory.path().join(
2535            manifest
2536                .segments
2537                .first()
2538                .expect("sealed segment")
2539                .id
2540                .file_name(),
2541        );
2542        let segment = std::fs::read(&segment_path).expect("sealed bytes");
2543        assert_eq!(
2544            store
2545                .delete(DeleteBatch::new(vec![DocId::new(91)]))
2546                .expect("delete")
2547                .generation(),
2548            3
2549        );
2550        drop(store);
2551        // Keep the synced delete WAL; lose its manifest and segment publication.
2552        std::fs::write(&path, before).expect("restore manifest");
2553        std::fs::write(&segment_path, segment).expect("restore segment");
2554        directory
2555    }
2556
2557    #[test]
2558    fn a_graph_free_pending_purge_with_an_unmanifested_delete_matches_main() {
2559        use crate::lifecycle::{OpenOptions, Store};
2560        let directory = graph_free_pending_purge_crash(&[91]);
2561        let recovered = Store::open(directory.path(), OpenOptions::default()).expect("recover");
2562        let bytes = std::fs::read(directory.path().join("manifest.ze")).expect("manifest");
2563        let manifest = crate::manifest::decode_manifest("recovered", &bytes).expect("decode");
2564        // Measured on main 9d217196 in the separate temporary worktree
2565        // /private/tmp/w346-main-byte-reference: review_pending_purge::record_main_pending_purge.
2566        assert_eq!(manifest.generation, 4);
2567        assert_eq!(
2568            manifest
2569                .segments
2570                .first()
2571                .expect("replacement")
2572                .id
2573                .to_string(),
2574            "0000000000000004e326c259b011dc7d"
2575        );
2576        let main_hex = "5a4550454d4245440a000200000000002000000000000000c4000000000000008c0000000000000004000000000000000100000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004e326c259b011dc7d00000000040000000100000060800100000000000000000000000000000000000000000054535231010000000100000000000000000000000000000000000000000000002a37ad4743e41889dc2754017bb05dc9";
2577        let expected = main_hex
2578            .as_bytes()
2579            .chunks_exact(2)
2580            .map(|pair| {
2581                u8::from_str_radix(std::str::from_utf8(pair).expect("hex pair"), 16)
2582                    .expect("hex byte")
2583            })
2584            .collect::<Vec<_>>();
2585        assert_eq!(
2586            bytes, expected,
2587            "complete graph-free manifest must match main"
2588        );
2589        assert_eq!(recovered.snapshot().expect("snapshot").generation(), 4);
2590        assert_eq!(
2591            recovered.count_documents(None, None).expect("count").count,
2592            0
2593        );
2594    }
2595
2596    #[test]
2597    fn a_graph_free_pending_purge_of_another_document_preserves_main_refusal() {
2598        use crate::lifecycle::{OpenOptions, Store};
2599        let directory = graph_free_pending_purge_crash(&[91, 92, 93]);
2600        // The same independent main worktree reports this exact refusal.
2601        // Preserve it even though repairing first could make this state open.
2602        let error = Store::open(directory.path(), OpenOptions::default())
2603            .err()
2604            .expect("main refuses this crash state");
2605        assert!(
2606            matches!(error, StoreError::PurgeRecovery { ref detail } if detail == "purge WAL rewrite would drop acknowledged WAL sequence 4"),
2607            "{error:?}"
2608        );
2609    }
2610
2611    #[test]
2612    fn sealed_purge_lookup_work_is_linear() {
2613        use crate::ingest::{DocumentVersion, IngestBatch, IngestDocument, Revision};
2614        use crate::lifecycle::{OpenOptions, Store};
2615        for n in [128, 512, 2048] {
2616            let directory = tempfile::tempdir().expect("directory");
2617            let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
2618            let documents = (0..n)
2619                .map(|id| {
2620                    IngestDocument::new(
2621                        DocumentVersion::new(DocId::new(id as u128), Revision::new(1)),
2622                        vec![1.0],
2623                    )
2624                })
2625                .collect();
2626            store.ingest(IngestBatch::new(documents)).expect("ingest");
2627            store.seal().expect("seal");
2628            let snapshot = store.snapshot().expect("snapshot");
2629            let reader = snapshot.segments().first().expect("segment");
2630            let mut ids: Vec<_> = (0..n)
2631                .step_by(2)
2632                .rev()
2633                .map(|id| DocId::new(id as u128))
2634                .collect();
2635            ids.extend([DocId::new(0), DocId::new(u128::MAX)]);
2636            super::super::lookup::take_work();
2637            let requested = super::super::lookup::set(&ids);
2638            let survivors = super::survivor_rows(reader, &requested).expect("survivors");
2639            let work = super::super::lookup::take_work();
2640            assert_eq!(
2641                survivors,
2642                (0..n).filter(|id| id % 2 != 0).collect::<Vec<_>>()
2643            );
2644            assert!(work <= 2 * (n + ids.len()), "{n} rows: {work} lookup work");
2645        }
2646    }
2647
2648    #[test]
2649    fn purge_tokens_reports_and_errors_preserve_their_contract_values() {
2650        let unknown = DocId::new(19);
2651        let token = PurgeToken {
2652            id: 41,
2653            generation: 42,
2654            unknown_ids: vec![unknown],
2655            no_op: false,
2656        };
2657        assert_eq!(token.id(), 41);
2658        assert_eq!(token.generation(), 42);
2659        assert_eq!(token.unknown_ids(), &[unknown]);
2660        assert!(!token.is_no_op());
2661
2662        let report = PurgeReport {
2663            generation: 43,
2664            segments_rewritten: 2,
2665            wal_rewritten: true,
2666            unknown_ids: vec![unknown],
2667        };
2668        assert_eq!(report.generation(), 43);
2669        assert_eq!(report.segments_rewritten(), 2);
2670        assert!(report.wal_rewritten());
2671        assert_eq!(report.unknown_ids(), &[unknown]);
2672        assert!(!report.is_no_op());
2673
2674        let format = decode_artifact("purge", FormatFamily::PurgeIntent, &[])
2675            .expect_err("empty intent is invalid");
2676        let errors = [
2677            PurgeError::Store(StoreError::ReadOnly),
2678            PurgeError::InsufficientTempSpace {
2679                segment_bytes: 100,
2680                available_bytes: 120,
2681                required_bytes: 120,
2682            },
2683            PurgeError::PurgeInProgress,
2684            PurgeError::UnknownToken { token_id: 44 },
2685            PurgeError::IntentFormat(format),
2686            PurgeError::IntentDecode("bad payload".to_owned()),
2687            PurgeError::WalPayload(wal_payload::PayloadError::Truncated),
2688            PurgeError::WalRewriteWouldDropAcked {
2689                seq: LogSeq::new(45),
2690            },
2691        ];
2692        for error in errors {
2693            assert!(!error.to_string().is_empty());
2694            let sourced = std::error::Error::source(&error).is_some();
2695            assert_eq!(
2696                sourced,
2697                matches!(
2698                    error,
2699                    PurgeError::Store(_) | PurgeError::IntentFormat(_) | PurgeError::WalPayload(_)
2700                )
2701            );
2702        }
2703        let converted = PurgeError::from(StoreError::Closed);
2704        assert!(matches!(converted, PurgeError::Store(StoreError::Closed)));
2705
2706        let ids = [DocId::new(3), DocId::new(5)];
2707        assert_eq!(purge_token_id(7, &ids), purge_token_id(7, &ids));
2708        assert_ne!(purge_token_id(7, &ids), purge_token_id(8, &ids));
2709        assert!(enforce_temp_space(100, 121).is_ok());
2710        assert!(matches!(
2711            enforce_temp_space(100, 120),
2712            Err(PurgeError::InsufficientTempSpace {
2713                required_bytes: 120,
2714                ..
2715            })
2716        ));
2717    }
2718
2719    #[test]
2720    fn purge_intent_round_trip_rejects_reserved_and_length_corruption() {
2721        let directory = tempfile::tempdir().expect("purge intent test directory");
2722        let policy = DurabilityPolicy::new(DurabilityMode::Derived, CommitTier::None)
2723            .expect("derived durability");
2724        let intent = PurgeIntent {
2725            token_id: 0x0102_0304_0506_0708,
2726            ids: vec![DocId::new(11), DocId::new(12)],
2727        };
2728        write_intent(&StdVfs, directory.path(), &intent, policy).expect("write valid intent");
2729        let decoded = read_intent(&StdVfs, directory.path()).expect("read valid intent");
2730        assert_eq!(decoded.token_id, intent.token_id);
2731        assert_eq!(decoded.ids, intent.ids);
2732
2733        let path = directory.path().join(PURGE_INTENT_FILE);
2734        let bytes = std::fs::read(&path).expect("read framed intent");
2735        let payload = decode_artifact("purge", FormatFamily::PurgeIntent, &bytes)
2736            .expect("decode framed intent")
2737            .payload
2738            .to_vec();
2739
2740        let mut reserved = payload.clone();
2741        let field = reserved.get_mut(12).expect("reserved byte exists");
2742        *field = 1;
2743        std::fs::write(
2744            &path,
2745            encode_artifact(FormatFamily::PurgeIntent, 0, &reserved),
2746        )
2747        .expect("write reserved corruption");
2748        assert!(matches!(
2749            read_intent(&StdVfs, directory.path()),
2750            Err(PurgeError::IntentDecode(detail)) if detail.contains("reserved")
2751        ));
2752
2753        let truncated = payload.get(..payload.len() - 1).expect("truncate payload");
2754        std::fs::write(
2755            &path,
2756            encode_artifact(FormatFamily::PurgeIntent, 0, truncated),
2757        )
2758        .expect("write length corruption");
2759        assert!(matches!(
2760            read_intent(&StdVfs, directory.path()),
2761            Err(PurgeError::IntentDecode(detail)) if detail.contains("expected")
2762        ));
2763        assert!(read_u32(&[], 0).is_err());
2764        assert!(read_u64(&[], 0).is_err());
2765
2766        std::fs::write(&path, bytes).expect("restore valid intent");
2767        remove_intent(&StdVfs, directory.path(), policy).expect("remove valid intent");
2768        assert!(!path.exists());
2769    }
2770}