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        #[cfg(feature = "graph-cypher")]
666        {
667            self.with_native_document_recovery(
668                || self.purge_attempt(ids, available_bytes, vfs),
669                |error| matches!(error, PurgeError::Delete(error) if error.is_native_reclaim_debt()),
670                PurgeError::Delete,
671            )
672        }
673        #[cfg(not(feature = "graph-cypher"))]
674        self.purge_attempt(ids, available_bytes, vfs)
675    }
676
677    fn purge_attempt(
678        &self,
679        ids: &[DocId],
680        available_bytes: u64,
681        vfs: &dyn Vfs,
682    ) -> Result<PurgeToken, PurgeError> {
683        let _maintenance = self
684            .maintenance
685            .lock()
686            .map_err(|_| StoreError::Synchronization {
687                component: "physical purge",
688            })?;
689        #[cfg(feature = "graph-cypher")]
690        let mut native_writer = self.native_document_writer()?;
691        #[cfg(feature = "graph-cypher")]
692        if let Some(writer) = native_writer.as_mut().and_then(|slot| slot.as_mut())
693            && self
694                .documents_have_native_nodes(ids)
695                .map_err(PurgeError::Delete)?
696        {
697            // Validate only; schedule_purge_locked writes the durable intent.
698            self.prepare_purge_intent(ids, available_bytes, vfs)?;
699            self.delete_native_documents_locked(&super::DeleteBatch::new(ids.to_vec()), writer)
700                .map_err(PurgeError::Delete)?;
701        }
702        let state = self
703            .state
704            .lock()
705            .map_err(|_| StoreError::Synchronization { component: "state" })?;
706        match *state {
707            StoreState::Open => {}
708            StoreState::Closing => return Err(StoreError::Closing.into()),
709            StoreState::Closed => return Err(StoreError::Closed.into()),
710        }
711        let writer_lock = self
712            .writer_lock
713            .lock()
714            .map_err(|_| StoreError::Synchronization {
715                component: "writer lock",
716            })?;
717        if writer_lock.is_none() {
718            return Err(StoreError::ReadOnly.into());
719        }
720        let wal = self
721            .wal_writer
722            .lock()
723            .map_err(|_| StoreError::Synchronization {
724                component: "WAL writer",
725            })?;
726        let token = self.schedule_purge_locked(
727            ids,
728            available_bytes,
729            vfs,
730            wal.as_ref().ok_or(StoreError::ReadOnly)?,
731        )?;
732        drop(writer_lock);
733        drop(state);
734        Ok(token)
735    }
736
737    /// Current free bytes on the store's volume, as `purge` observes them.
738    pub(crate) fn available_purge_bytes(&self) -> Result<u64, PurgeError> {
739        available_disk_bytes(&self.directory).map_err(|source| {
740            PurgeError::Store(StoreError::Io {
741                path: self.directory.clone(),
742                source,
743            })
744        })
745    }
746
747    /// Durably records a purge intent for `ids`. The caller holds the
748    /// maintenance, state and writer locks and has checked the store is open
749    /// and writable; this takes the active and snapshot locks itself.
750    pub(crate) fn schedule_purge_locked(
751        &self,
752        ids: &[DocId],
753        available_bytes: u64,
754        vfs: &dyn Vfs,
755        writer: &super::StoreWal,
756    ) -> Result<PurgeToken, PurgeError> {
757        let publication = writer.manifest_publication()?;
758        let (token, intent) = self.prepare_purge_intent(ids, available_bytes, vfs)?;
759        if token.no_op {
760            return Ok(token);
761        }
762        let token_id = intent.token_id;
763        let known = intent.ids;
764        #[cfg(any(test, feature = "test-seams"))]
765        let crash_target_ids = known.iter().map(|id| id.get()).collect::<Vec<_>>();
766        let publication = publication.arm();
767        write_intent(
768            vfs,
769            &self.directory,
770            &PurgeIntent {
771                token_id,
772                ids: known,
773            },
774            self.durability_policy,
775        )?;
776        #[cfg(any(test, feature = "test-seams"))]
777        if let Some(controller) = self.ingest_retention_fault_controller.as_ref() {
778            let plan = controller.take_purge_crash_boundary_plan().map_err(|_| {
779                StoreError::Synchronization {
780                    component: "ingest-retention controller",
781                }
782            })?;
783            if let Some((invocation_id, receipt_sink)) = plan {
784                let receipt = super::IngestRetentionFaultReceiptV1::purge_crash_boundary(
785                    invocation_id,
786                    crash_target_ids,
787                    token_id,
788                );
789                receipt
790                    .write_purge_crash_test_evidence(vfs, &receipt_sink)
791                    .map_err(|source| StoreError::Io {
792                        path: receipt_sink,
793                        source,
794                    })?;
795                std::process::abort();
796            }
797        }
798        publication.complete();
799        Ok(token)
800    }
801
802    /// Performs every definite purge refusal before a document/node deletion.
803    pub(crate) fn prepare_purge_intent(
804        &self,
805        ids: &[DocId],
806        available_bytes: u64,
807        vfs: &dyn Vfs,
808    ) -> Result<(PurgeToken, PurgeIntent), PurgeError> {
809        self.require_no_snapshot_views()?;
810        let intent_path = self.directory.join(PURGE_INTENT_FILE);
811        match vfs.open(&intent_path) {
812            Ok(_) => return Err(PurgeError::PurgeInProgress),
813            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
814            Err(source) => {
815                return Err(StoreError::Io {
816                    path: intent_path,
817                    source,
818                }
819                .into());
820            }
821        }
822        let mut requested = ids.to_vec();
823        requested.sort_unstable();
824        requested.dedup();
825        let active = self
826            .active
827            .lock()
828            .map_err(|_| StoreError::Synchronization {
829                component: "active segment",
830            })?;
831        let active_state = active.as_ref().ok_or(StoreError::Closed)?;
832        let snapshot = self
833            .snapshot
834            .read()
835            .map_err(|_| StoreError::Synchronization {
836                component: "published snapshot",
837            })?
838            .as_ref()
839            .cloned()
840            .ok_or(StoreError::Closed)?;
841        let sealed = all_sealed_document_matches(&snapshot, &requested)?;
842        let (known, unknown) = partition_known_ids(requested, &active_state.segment, &sealed);
843        enforce_temp_space_for_targets(&snapshot, &sealed, &known, available_bytes)?;
844        let token_id = purge_token_id(active_state.generation, &known);
845        let no_op = known.is_empty();
846        let token = PurgeToken {
847            id: token_id,
848            generation: active_state.generation,
849            unknown_ids: unknown,
850            no_op,
851        };
852        Ok((
853            token,
854            PurgeIntent {
855                token_id,
856                ids: known,
857            },
858        ))
859    }
860
861    /// Removes the intent written by [`Self::schedule_purge_locked`] when the
862    /// mutation it was scheduled for did not commit. Same locks held.
863    pub(crate) fn abandon_scheduled_purge_locked(
864        &self,
865        vfs: &dyn Vfs,
866        writer: &super::StoreWal,
867    ) -> Result<(), PurgeError> {
868        let publication = writer.manifest_publication()?.arm();
869        remove_intent(vfs, &self.directory, self.durability_policy)?;
870        publication.complete();
871        Ok(())
872    }
873
874    #[allow(clippy::too_many_arguments)]
875    fn replace_one_purged_segment(
876        &self,
877        vfs: &dyn Vfs,
878        manifest: &mut Manifest,
879        active_state: &mut ActiveState,
880        writer: &super::StoreWal,
881        original: &SegmentMeta,
882        ids: &super::lookup::LookupSet<DocId>,
883        token_id: u64,
884        policy: DurabilityPolicy,
885    ) -> Result<bool, PurgeError> {
886        let _durable_end = writer.durable_end();
887        let path = self.directory.join(original.id.file_name());
888        let reader = SegmentReader::open(vfs, &path, original.id).map_err(StoreError::Segment)?;
889        let survivors = survivor_rows(&reader, ids)?;
890        if survivors.len() == original.row_count as usize {
891            return Ok(false);
892        }
893        let generation = active_state
894            .generation
895            .max(manifest.generation)
896            .checked_add(1)
897            .ok_or(StoreError::GenerationOverflow)?;
898        let replacement_id = replacement_segment_id(original.id, token_id, generation);
899        let mut replacement = rewrite_segment(
900            vfs,
901            &self.directory,
902            &reader,
903            &survivors,
904            &[],
905            replacement_id,
906            policy,
907        )?;
908        replacement.epoch_id = original.epoch_id;
909        drop(reader);
910        let position = manifest
911            .segments
912            .iter()
913            .position(|segment| segment.id == original.id)
914            .ok_or_else(|| {
915                PurgeError::IntentDecode(format!("manifest lost purge segment {}", original.id))
916            })?;
917        let target = manifest.segments.get_mut(position).ok_or_else(|| {
918            PurgeError::IntentDecode("purge segment position is invalid".to_owned())
919        })?;
920        *target = replacement;
921        manifest.generation = generation;
922        #[cfg(feature = "graph-cypher")]
923        manifest
924            .record_generation_bump(_durable_end)
925            .map_err(StoreError::Manifest)?;
926        manifest.epochs = self.epoch_registry(&manifest.epochs);
927        let remapped =
928            PublishedSnapshot::from_manifest(vfs, &self.directory, manifest, &self.accounting)?;
929        let mut publication = writer.manifest_publication()?;
930        publication
931            .commit_manifest(vfs, &self.directory, manifest, policy)
932            .map_err(StoreError::Manifest)?;
933        let mut published = self
934            .snapshot
935            .write()
936            .map_err(|_| StoreError::Synchronization {
937                component: "published snapshot",
938            })?;
939        let previous = published.replace(Arc::new(remapped));
940        active_state.generation = generation;
941        publication.complete();
942        drop(published);
943        drop(previous);
944        if let Err(source) = vfs.delete(&path) {
945            #[cfg(any(test, feature = "test-seams"))]
946            if source.kind() == std::io::ErrorKind::Other
947                && let Some(controller) = self.ingest_retention_fault_controller.as_ref()
948            {
949                let invocation_id = controller
950                    .purge_unlink_error_plan(*original.id.as_bytes())
951                    .map_err(|_| StoreError::Synchronization {
952                        component: "ingest-retention controller",
953                    })?;
954                if let Some(invocation_id) = invocation_id {
955                    let intent_present = vfs.open(&self.directory.join(PURGE_INTENT_FILE)).is_ok();
956                    let old_path_linked = vfs.open(&path).is_ok();
957                    controller
958                        .push_receipt(super::IngestRetentionFaultReceiptV1::purge_unlink_error(
959                            invocation_id,
960                            *original.id.as_bytes(),
961                            *replacement_id.as_bytes(),
962                            original.id.file_name(),
963                            true,
964                            intent_present,
965                            old_path_linked,
966                        ))
967                        .map_err(|_| StoreError::Synchronization {
968                            component: "ingest-retention controller",
969                        })?;
970                }
971            }
972            return Err(StoreError::Io {
973                path: path.clone(),
974                source,
975            }
976            .into());
977        }
978        sync_directory(vfs, &self.directory, policy)?;
979        Ok(true)
980    }
981
982    fn commit_bumped_manifest(
983        &self,
984        vfs: &dyn Vfs,
985        manifest: &mut Manifest,
986        generation: u64,
987        writer: &super::StoreWal,
988        _generation_bump: bool,
989        policy: DurabilityPolicy,
990    ) -> Result<(), PurgeError> {
991        // The caller already advanced active generation or rewrote the WAL.
992        // Even a pre-rename failure requires recovery before another write.
993        let mut publication = writer.manifest_publication()?.arm();
994        let _durable_end = writer.durable_end();
995        manifest.generation = generation;
996        #[cfg(feature = "graph-cypher")]
997        if _generation_bump {
998            manifest
999                .record_generation_bump(_durable_end)
1000                .map_err(StoreError::Manifest)?;
1001        } else if let Some(graph) = &mut manifest.graph {
1002            // WAL replay, not a fresh mutation, supplied this generation.
1003            graph.generation_absorbed_through = Some(_durable_end);
1004        }
1005        manifest.epochs = self.epoch_registry(&manifest.epochs);
1006        let remapped =
1007            PublishedSnapshot::from_manifest(vfs, &self.directory, manifest, &self.accounting)?;
1008        publication
1009            .commit_manifest(vfs, &self.directory, manifest, policy)
1010            .map_err(StoreError::Manifest)?;
1011        let mut published = self
1012            .snapshot
1013            .write()
1014            .map_err(|_| StoreError::Synchronization {
1015                component: "published snapshot",
1016            })?;
1017        let previous = published.replace(Arc::new(remapped));
1018        publication.complete();
1019        drop(published);
1020        drop(previous);
1021        Ok(())
1022    }
1023
1024    #[allow(clippy::too_many_arguments)]
1025    fn rewrite_wal_for_purge<'a>(
1026        &self,
1027        vfs: &dyn Vfs,
1028        writer: &'a mut super::StoreWal,
1029        manifest: &Manifest,
1030        intent: &PurgeIntent,
1031        next_active: &mut super::ActiveSegment,
1032        records: &[(u16, Vec<u8>)],
1033        tombstoned: &[bool],
1034        policy: DurabilityPolicy,
1035    ) -> Result<super::active::WalReplacement<'a>, PurgeError> {
1036        let retained_first_seq = LogSeq::new(
1037            manifest
1038                .log_seq
1039                .checked_add(1)
1040                .ok_or(StoreError::GenerationOverflow)?,
1041        );
1042        let durable_end = writer.durable_end();
1043        if durable_end >= retained_first_seq.get() {
1044            let snapshot = self
1045                .snapshot
1046                .read()
1047                .map_err(|_| StoreError::Synchronization {
1048                    component: "published snapshot",
1049                })?;
1050            let snapshot = snapshot.as_ref().ok_or(StoreError::Closed)?;
1051            ensure_wal_rewrite_covers_retained(
1052                vfs,
1053                &self.directory,
1054                manifest,
1055                &intent.ids,
1056                next_active,
1057                snapshot.all_segments(),
1058                self.private_preparation.as_ref(),
1059            )?;
1060        }
1061        let rewrite_first_seq = LogSeq::new(durable_end.checked_add(1).ok_or(
1062            StoreError::WalWrite(crate::wal::WalWriteError::SequenceExhausted),
1063        )?);
1064        #[cfg(feature = "graph-cypher")]
1065        let framed;
1066        #[cfg(feature = "graph-cypher")]
1067        let records = if manifest.graph.is_some() && records.len() > 1 {
1068            // Replay counts batches, so frame the replacement image as one batch
1069            // covered by the purge cutoff, including any trailing tombstones.
1070            let count = u32::try_from(records.len())
1071                .map_err(|_| PurgeError::WalPayload(wal_payload::PayloadError::LengthOverflow))?;
1072            framed = records
1073                .iter()
1074                .zip(0_u32..)
1075                .map(|((op, payload), index)| {
1076                    wal_payload::encode_mixed_batch_member(index, count, *op, payload)
1077                        .map(|payload| (wal_payload::MIXED_BATCH_MEMBER_V1, payload))
1078                        .map_err(PurgeError::WalPayload)
1079                })
1080                .collect::<Result<Vec<_>, _>>()?;
1081            framed.as_slice()
1082        } else {
1083            records
1084        };
1085        let publication =
1086            writer.rewrite(vfs, &self.directory, policy, rewrite_first_seq, records)?;
1087        assign_rewritten_sequences(next_active, rewrite_first_seq, tombstoned)?;
1088        Ok(publication)
1089    }
1090
1091    #[cfg(feature = "graph-cypher")]
1092    pub(crate) fn graph_checkpoint_through(
1093        &self,
1094        vfs: &dyn Vfs,
1095    ) -> Result<Option<u64>, StoreError> {
1096        let snapshot = self
1097            .snapshot
1098            .read()
1099            .map_err(|_| StoreError::Synchronization {
1100                component: "published snapshot",
1101            })?;
1102        let snapshot = snapshot.as_ref().ok_or(StoreError::Closed)?;
1103        if !snapshot.graph_enabled {
1104            return Ok(None);
1105        }
1106        let reader = crate::wal::WalReader::open(vfs, &self.directory.join("wal.ze"))
1107            .map_err(StoreError::Wal)?;
1108        let clean = reader.into_clean().map_err(StoreError::WalRecovery)?;
1109        let watermark = snapshot
1110            .absorbed_through()
1111            .min(snapshot.graph_absorbed_through);
1112        let decisions = crate::lifecycle::namespace_batch::transaction_decisions(
1113            vfs,
1114            &self.directory,
1115            clean.records(),
1116            watermark,
1117            self.private_preparation.as_ref(),
1118        )?;
1119        let mut through = None;
1120        for (seq, _, mutation) in super::atomic_batch::committed_mutations_with_decisions(
1121            clean.records(),
1122            watermark,
1123            |binding| decisions.get(&binding.transaction).copied(),
1124        )? {
1125            if seq.get() > snapshot.graph_absorbed_through
1126                && matches!(mutation, wal_payload::MutationPayload::GraphCommit(_))
1127            {
1128                through = Some(seq.get());
1129            }
1130        }
1131        Ok(through)
1132    }
1133
1134    /// Resolves only after rewritten artifacts are committed and old paths are unlinked.
1135    pub fn await_physical_purge(&self, token: PurgeToken) -> Result<PurgeReport, PurgeError> {
1136        self.await_physical_purge_on_vfs(token, self.vfs.as_ref())
1137    }
1138
1139    /// Test seam that runs the purge protocol through an observable filesystem.
1140    #[doc(hidden)]
1141    pub fn await_physical_purge_on_vfs(
1142        &self,
1143        token: PurgeToken,
1144        vfs: &dyn Vfs,
1145    ) -> Result<PurgeReport, PurgeError> {
1146        if token.no_op {
1147            return Ok(PurgeReport {
1148                generation: token.generation,
1149                segments_rewritten: 0,
1150                wal_rewritten: false,
1151                unknown_ids: token.unknown_ids,
1152            });
1153        }
1154        let _maintenance = self
1155            .maintenance
1156            .lock()
1157            .map_err(|_| StoreError::Synchronization {
1158                component: "physical purge",
1159            })?;
1160        let state = self
1161            .state
1162            .lock()
1163            .map_err(|_| StoreError::Synchronization { component: "state" })?;
1164        match *state {
1165            StoreState::Open => {}
1166            StoreState::Closing => return Err(StoreError::Closing.into()),
1167            StoreState::Closed => return Err(StoreError::Closed.into()),
1168        }
1169        let writer_lock = self
1170            .writer_lock
1171            .lock()
1172            .map_err(|_| StoreError::Synchronization {
1173                component: "writer lock",
1174            })?;
1175        if writer_lock.is_none() {
1176            return Err(StoreError::ReadOnly.into());
1177        }
1178        self.require_no_snapshot_views()?;
1179        let mut wal = self
1180            .wal_writer
1181            .lock()
1182            .map_err(|_| StoreError::Synchronization {
1183                component: "WAL writer",
1184            })?;
1185        let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
1186        let report = self.complete_physical_purge_locked(vfs, writer, token)?;
1187        drop(wal);
1188        drop(writer_lock);
1189        drop(state);
1190        Ok(report)
1191    }
1192
1193    /// Runs the scheduled purge protocol to completion. The caller holds the
1194    /// maintenance, state, writer and WAL locks; this takes the active lock.
1195    pub(crate) fn complete_physical_purge_locked(
1196        &self,
1197        vfs: &dyn Vfs,
1198        writer: &mut super::StoreWal,
1199        token: PurgeToken,
1200    ) -> Result<PurgeReport, PurgeError> {
1201        let intent = read_intent(vfs, &self.directory)?;
1202        let policy = if crate::lifecycle::namespace_batch::owns_purge_obligation(
1203            vfs,
1204            &self.directory,
1205            intent.token_id,
1206            self.private_preparation.as_ref(),
1207        )? {
1208            DurabilityPolicy::new(
1209                crate::lifecycle::durability::DurabilityMode::Durable,
1210                crate::lifecycle::durability::CommitTier::Durable,
1211            )
1212            .map_err(StoreError::Durability)?
1213        } else {
1214            self.durability_policy
1215        };
1216        if intent.token_id != token.id {
1217            return Err(PurgeError::UnknownToken { token_id: token.id });
1218        }
1219        #[cfg(feature = "graph-cypher")]
1220        let graph_checkpoint = self.graph_checkpoint_through(vfs)?;
1221        let mut active = self
1222            .active
1223            .lock()
1224            .map_err(|_| StoreError::Synchronization {
1225                component: "active segment",
1226            })?;
1227        let active_state = active.as_mut().ok_or(StoreError::Closed)?;
1228        #[cfg(feature = "graph-cypher")]
1229        if let Some(through) = graph_checkpoint {
1230            self.checkpoint_native_graph_locked(writer, active_state, through, vfs)
1231                .map_err(|error| StoreError::PurgeRecovery {
1232                    detail: format!("purge graph checkpoint: {error}"),
1233                })?;
1234        }
1235        let manifest_path = self.directory.join(MANIFEST_FILE);
1236        let mut manifest = match vfs.open(&manifest_path) {
1237            Ok(_) => load_manifest(vfs, &manifest_path, writer.durable_end())
1238                .map_err(StoreError::Manifest)?,
1239            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Manifest {
1240                #[cfg(feature = "graph-cypher")]
1241                graph: None,
1242                generation: active_state.generation,
1243                log_seq: 0,
1244                segments: Vec::new(),
1245                epochs: Vec::new(),
1246                epoch_alias: None,
1247                schema: crate::meta::Schema::new(Vec::new()).map_err(|source| {
1248                    StoreError::Segment(crate::segment::SegmentError::Columns(source.to_string()))
1249                })?,
1250            },
1251            Err(source) => {
1252                return Err(StoreError::Io {
1253                    path: manifest_path,
1254                    source,
1255                }
1256                .into());
1257            }
1258        };
1259        let mut rewritten = 0_usize;
1260        let purge_ids = super::lookup::set(&intent.ids);
1261        let original_segments = manifest.segments.clone();
1262        for original in original_segments {
1263            if self.replace_one_purged_segment(
1264                vfs,
1265                &mut manifest,
1266                active_state,
1267                writer,
1268                &original,
1269                &purge_ids,
1270                token.id,
1271                policy,
1272            )? {
1273                rewritten = rewritten.saturating_add(1);
1274            }
1275        }
1276        sweep_purge_orphans(vfs, &self.directory, &manifest, policy)?;
1277
1278        let active_has_target = active_state
1279            .segment
1280            .doc_ids()
1281            .iter()
1282            .any(|id| super::lookup::contains(&purge_ids, id));
1283        let mut next_active = if active_has_target {
1284            let (purged, removed) =
1285                active_state
1286                    .segment
1287                    .purge(&intent.ids, &self.accounting, &self.tokenizer)?;
1288            if removed == 0 {
1289                return Err(PurgeError::IntentDecode(
1290                    "active purge target disappeared during admission".to_owned(),
1291                ));
1292            }
1293            active_state.generation = active_state
1294                .generation
1295                .checked_add(1)
1296                .ok_or(StoreError::GenerationOverflow)?;
1297            purged
1298        } else {
1299            active_state
1300                .segment
1301                .purge(&[], &self.accounting, &self.tokenizer)?
1302                .0
1303        };
1304        let (records, tombstoned) = active_wal_records(&next_active)?;
1305        if manifest.generation < active_state.generation {
1306            self.commit_bumped_manifest(
1307                vfs,
1308                &mut manifest,
1309                active_state.generation,
1310                writer,
1311                active_has_target,
1312                policy,
1313            )?;
1314        }
1315        #[cfg(feature = "graph-cypher")]
1316        let retired_through = writer.durable_end();
1317        let publication = self.rewrite_wal_for_purge(
1318            vfs,
1319            writer,
1320            &manifest,
1321            &intent,
1322            &mut next_active,
1323            &records,
1324            &tombstoned,
1325            policy,
1326        )?;
1327        #[cfg(feature = "graph-cypher")]
1328        let writer = &*publication.wal;
1329        #[cfg(feature = "graph-cypher")]
1330        let history_folded = if let Some(graph) = &mut manifest.graph
1331            && !graph.generation_bumps.is_empty()
1332        {
1333            // The replacement WAL retires every old record. No retained graph
1334            // envelope needs the old ordering; restore these increments once,
1335            // before the replacement image. Keep the v2 boundary row that
1336            // proves a cutoff immediately before the new WAL header.
1337            let count = graph
1338                .generation_bumps
1339                .iter()
1340                .try_fold(0_u64, |total, (_, count)| {
1341                    total
1342                        .checked_add(*count)
1343                        .ok_or(StoreError::GenerationOverflow)
1344                })?;
1345            let folded = (retired_through, count);
1346            let changed = graph.generation_bumps.as_slice() != [folded];
1347            graph.generation_bumps.clear();
1348            graph.generation_bumps.push(folded);
1349            changed
1350        } else {
1351            false
1352        };
1353        #[cfg(feature = "graph-cypher")]
1354        if history_folded
1355            || manifest.graph.as_ref().is_some_and(|graph| {
1356                graph.generation_absorbed_through != Some(writer.durable_end())
1357            })
1358        {
1359            // Rewritten document batches were already counted in this
1360            // generation. Publish their new cutoff before purge returns or
1361            // another graph write can be acknowledged.
1362            self.commit_bumped_manifest(
1363                vfs,
1364                &mut manifest,
1365                active_state.generation,
1366                writer,
1367                false,
1368                policy,
1369            )?;
1370        }
1371        active_state.segment = Arc::new(next_active);
1372        #[cfg(feature = "graph-cypher")]
1373        self.publish_native_documents(active_state)?;
1374        remove_intent(vfs, &self.directory, policy)?;
1375        publication.complete();
1376        let generation = active_state.generation;
1377        drop(active);
1378        Ok(PurgeReport {
1379            generation,
1380            segments_rewritten: rewritten,
1381            wal_rewritten: true,
1382            unknown_ids: token.unknown_ids,
1383        })
1384    }
1385
1386    pub(crate) fn recover_pending_physical_purge(&self) -> Result<(), PurgeError> {
1387        let path = self.directory.join(PURGE_INTENT_FILE);
1388        match self.vfs.open(&path) {
1389            Ok(_) => {
1390                let intent = read_intent(self.vfs.as_ref(), &self.directory)?;
1391                // Accepted namespace deletes already have a committed logical
1392                // state. Ordinary purge intents still require writable recovery.
1393                if self
1394                    .writer_lock
1395                    .lock()
1396                    .map_err(|_| StoreError::Synchronization {
1397                        component: "writer lock",
1398                    })?
1399                    .is_none()
1400                {
1401                    return if crate::lifecycle::namespace_batch::owns_purge_obligation(
1402                        self.vfs.as_ref(),
1403                        &self.directory,
1404                        intent.token_id,
1405                        self.private_preparation.as_ref(),
1406                    )? {
1407                        Ok(())
1408                    } else {
1409                        Err(StoreError::ReadOnly.into())
1410                    };
1411                }
1412                let generation = self
1413                    .active
1414                    .lock()
1415                    .map_err(|_| StoreError::Synchronization {
1416                        component: "active segment",
1417                    })?
1418                    .as_ref()
1419                    .ok_or(StoreError::Closed)?
1420                    .generation;
1421                let token = PurgeToken {
1422                    id: intent.token_id,
1423                    generation,
1424                    unknown_ids: Vec::new(),
1425                    no_op: false,
1426                };
1427                self.await_physical_purge(token).map(|_| ())
1428            }
1429            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1430            Err(source) => Err(StoreError::Io { path, source }.into()),
1431        }
1432    }
1433}
1434
1435// Prepared delete records are the transaction-gated obligation. Adoption
1436// materializes the ordinary intent before recording local acceptance.
1437pub(crate) fn namespace_purge_intent(
1438    vfs: &dyn Vfs,
1439    directory: &Path,
1440    transaction: u128,
1441    ids: Vec<DocId>,
1442) -> Result<(), PurgeError> {
1443    if ids.is_empty() {
1444        return Ok(());
1445    }
1446    write_intent(
1447        vfs,
1448        directory,
1449        &PurgeIntent {
1450            token_id: xxh3_64(&transaction.to_le_bytes()),
1451            ids,
1452        },
1453        DurabilityPolicy::new(
1454            crate::lifecycle::durability::DurabilityMode::Durable,
1455            crate::lifecycle::durability::CommitTier::Durable,
1456        )
1457        .map_err(StoreError::Durability)?,
1458    )
1459}
1460
1461impl Store {
1462    pub(crate) fn complete_namespace_purge_locked(
1463        &self,
1464        writer: &mut super::StoreWal,
1465    ) -> Result<u64, PurgeError> {
1466        let intent = read_intent(self.vfs.as_ref(), &self.directory)?;
1467        let token = PurgeToken {
1468            id: intent.token_id,
1469            generation: 0,
1470            unknown_ids: Vec::new(),
1471            no_op: false,
1472        };
1473        self.complete_physical_purge_locked(self.vfs.as_ref(), writer, token)
1474            .map(|report| report.generation())
1475    }
1476}
1477
1478fn enforce_temp_space(segment_bytes: u64, available_bytes: u64) -> Result<(), PurgeError> {
1479    let required_bytes = segment_bytes
1480        .checked_mul(6)
1481        .map(|value| value / 5)
1482        .unwrap_or(u64::MAX);
1483    if available_bytes > required_bytes {
1484        Ok(())
1485    } else {
1486        Err(PurgeError::InsufficientTempSpace {
1487            segment_bytes,
1488            available_bytes,
1489            required_bytes,
1490        })
1491    }
1492}
1493
1494fn purge_token_id(generation: u64, ids: &[DocId]) -> u64 {
1495    let mut bytes = Vec::with_capacity(8_usize.saturating_add(ids.len().saturating_mul(16)));
1496    bytes.extend_from_slice(&generation.to_le_bytes());
1497    for id in ids {
1498        bytes.extend_from_slice(&id.get().to_le_bytes());
1499    }
1500    xxh3_64(&bytes)
1501}
1502
1503fn write_intent(
1504    vfs: &dyn Vfs,
1505    directory: &Path,
1506    intent: &PurgeIntent,
1507    policy: DurabilityPolicy,
1508) -> Result<(), PurgeError> {
1509    let count = u32::try_from(intent.ids.len())
1510        .map_err(|_| PurgeError::IntentDecode("purge id count exceeds u32".to_owned()))?;
1511    let mut payload =
1512        Vec::with_capacity(16_usize.saturating_add(intent.ids.len().saturating_mul(16)));
1513    payload.extend_from_slice(&intent.token_id.to_le_bytes());
1514    payload.extend_from_slice(&count.to_le_bytes());
1515    payload.extend_from_slice(&0_u32.to_le_bytes());
1516    for id in &intent.ids {
1517        payload.extend_from_slice(&id.get().to_le_bytes());
1518    }
1519    let bytes = encode_artifact(FormatFamily::PurgeIntent, 0, &payload);
1520    let temporary = directory.join(PURGE_INTENT_TEMP_FILE);
1521    let committed = directory.join(PURGE_INTENT_FILE);
1522    vfs.write(&temporary, &bytes)
1523        .map_err(|source| StoreError::Io {
1524            path: temporary.clone(),
1525            source,
1526        })?;
1527    if let SyncRequirement::Sync(kind) = policy.data_file_sync() {
1528        vfs.sync(&temporary, kind)
1529            .map_err(|source| StoreError::Io {
1530                path: temporary.clone(),
1531                source,
1532            })?;
1533    }
1534    vfs.rename(&temporary, &committed)
1535        .map_err(|source| StoreError::Io {
1536            path: committed,
1537            source,
1538        })?;
1539    sync_directory(vfs, directory, policy).map_err(PurgeError::from)
1540}
1541
1542pub(crate) fn read_intent(vfs: &dyn Vfs, directory: &Path) -> Result<PurgeIntent, PurgeError> {
1543    let path = directory.join(PURGE_INTENT_FILE);
1544    let bytes = vfs.read(&path).map_err(|source| StoreError::Io {
1545        path: path.clone(),
1546        source,
1547    })?;
1548    let decoded = decode_artifact(
1549        &path.display().to_string(),
1550        FormatFamily::PurgeIntent,
1551        &bytes,
1552    )
1553    .map_err(PurgeError::IntentFormat)?;
1554    let token_id = read_u64(decoded.payload, 0)?;
1555    let count = read_u32(decoded.payload, 8)? as usize;
1556    if read_u32(decoded.payload, 12)? != 0 {
1557        return Err(PurgeError::IntentDecode(
1558            "purge intent reserved field is non-zero".to_owned(),
1559        ));
1560    }
1561    let expected = 16_usize
1562        .checked_add(count.saturating_mul(16))
1563        .ok_or_else(|| PurgeError::IntentDecode("purge intent length overflow".to_owned()))?;
1564    if decoded.payload.len() != expected {
1565        return Err(PurgeError::IntentDecode(format!(
1566            "purge intent bytes {}, expected {expected}",
1567            decoded.payload.len()
1568        )));
1569    }
1570    let mut ids = Vec::with_capacity(count);
1571    for index in 0..count {
1572        let offset = 16_usize
1573            .checked_add(index.saturating_mul(16))
1574            .ok_or_else(|| PurgeError::IntentDecode("purge id offset overflow".to_owned()))?;
1575        let end = offset
1576            .checked_add(16)
1577            .ok_or_else(|| PurgeError::IntentDecode("purge id end overflow".to_owned()))?;
1578        let value = decoded
1579            .payload
1580            .get(offset..end)
1581            .and_then(|bytes| bytes.try_into().ok())
1582            .map(u128::from_le_bytes)
1583            .ok_or_else(|| PurgeError::IntentDecode(format!("purge id {index} is truncated")))?;
1584        ids.push(DocId::new(value));
1585    }
1586    Ok(PurgeIntent { token_id, ids })
1587}
1588
1589fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, PurgeError> {
1590    bytes
1591        .get(offset..offset.saturating_add(4))
1592        .and_then(|value| value.try_into().ok())
1593        .map(u32::from_le_bytes)
1594        .ok_or_else(|| PurgeError::IntentDecode(format!("u32 at {offset} is truncated")))
1595}
1596
1597fn read_u64(bytes: &[u8], offset: usize) -> Result<u64, PurgeError> {
1598    bytes
1599        .get(offset..offset.saturating_add(8))
1600        .and_then(|value| value.try_into().ok())
1601        .map(u64::from_le_bytes)
1602        .ok_or_else(|| PurgeError::IntentDecode(format!("u64 at {offset} is truncated")))
1603}
1604
1605fn remove_intent(
1606    vfs: &dyn Vfs,
1607    directory: &Path,
1608    policy: DurabilityPolicy,
1609) -> Result<(), PurgeError> {
1610    let path = directory.join(PURGE_INTENT_FILE);
1611    vfs.delete(&path)
1612        .map_err(|source| StoreError::Io { path, source })?;
1613    sync_directory(vfs, directory, policy).map_err(PurgeError::from)
1614}
1615
1616fn sync_directory(
1617    vfs: &dyn Vfs,
1618    directory: &Path,
1619    policy: DurabilityPolicy,
1620) -> Result<(), StoreError> {
1621    if let SyncRequirement::Sync(kind) = policy.directory_sync() {
1622        vfs.sync(directory, kind).map_err(|source| StoreError::Io {
1623            path: directory.to_path_buf(),
1624            source,
1625        })?;
1626    }
1627    Ok(())
1628}
1629
1630pub(crate) fn unlink_replaced_segments(
1631    store: &Store,
1632    vfs: &dyn Vfs,
1633    directory: &Path,
1634    replaced_paths: &[PathBuf],
1635    policy: DurabilityPolicy,
1636) {
1637    if store.has_snapshot_views() {
1638        return;
1639    }
1640    for path in replaced_paths {
1641        // The replacement manifest and snapshot are already published. A
1642        // failed unlink leaves an unreachable orphan for open-time cleanup;
1643        // it cannot roll back or deny the acknowledged mutation.
1644        let _ = vfs.delete(path);
1645    }
1646    if !replaced_paths.is_empty() {
1647        let _ = sync_directory(vfs, directory, policy);
1648    }
1649}
1650
1651fn cleanup_replacement_segments(
1652    vfs: &dyn Vfs,
1653    directory: &Path,
1654    replacement_ids: &[SegmentId],
1655    policy: DurabilityPolicy,
1656) -> Result<(), StoreError> {
1657    let mut deleted = false;
1658    for id in replacement_ids {
1659        for path in [
1660            directory.join(id.file_name()),
1661            directory.join(format!(".{}.tmp", id.file_name())),
1662        ] {
1663            match vfs.open(&path) {
1664                Ok(_) => {
1665                    vfs.delete(&path)
1666                        .map_err(|source| StoreError::Io { path, source })?;
1667                    deleted = true;
1668                }
1669                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1670                Err(source) => return Err(StoreError::Io { path, source }),
1671            }
1672        }
1673    }
1674    if deleted {
1675        sync_directory(vfs, directory, policy)?;
1676    }
1677    Ok(())
1678}
1679
1680fn survivor_rows(
1681    reader: &SegmentReader,
1682    ids: &super::lookup::LookupSet<DocId>,
1683) -> Result<Vec<usize>, PurgeError> {
1684    let mut survivors = Vec::with_capacity(reader.meta().row_count as usize);
1685    for row in 0..reader.meta().row_count as usize {
1686        let version = reader.document_version(row).map_err(StoreError::Segment)?;
1687        if version.is_none_or(|version| !super::lookup::contains(ids, &version.doc_id())) {
1688            survivors.push(row);
1689        }
1690    }
1691    Ok(survivors)
1692}
1693
1694pub(crate) enum OwnedFactors {
1695    Bit4(Vec<Bit4Factors>),
1696    Int8(Vec<Int8Factors>),
1697}
1698
1699impl OwnedFactors {
1700    fn borrowed(&self) -> SegmentFactors<'_> {
1701        match self {
1702            Self::Bit4(values) => SegmentFactors::Bit4(values),
1703            Self::Int8(values) => SegmentFactors::Int8(values),
1704        }
1705    }
1706}
1707
1708pub(crate) fn gather_survivor_codes(
1709    reader: &SegmentReader,
1710    survivors: &[usize],
1711    dims: usize,
1712) -> Result<(Vec<u8>, OwnedFactors), StoreError> {
1713    match reader.meta().scheme {
1714        4 => {
1715            let stride = dims.div_ceil(2);
1716            let source_codes = reader.bit4_codes().map_err(StoreError::Segment)?;
1717            let source_factors = reader.bit4_factors().map_err(StoreError::Segment)?;
1718            let mut codes = Vec::with_capacity(survivors.len().saturating_mul(stride));
1719            let mut factors = Vec::with_capacity(survivors.len());
1720            for row in survivors {
1721                let start = row
1722                    .checked_mul(stride)
1723                    .ok_or(StoreError::ActiveRowOverflow)?;
1724                let end = start
1725                    .checked_add(stride)
1726                    .ok_or(StoreError::ActiveRowOverflow)?;
1727                codes.extend_from_slice(
1728                    source_codes
1729                        .get(start..end)
1730                        .ok_or(StoreError::ActiveRowOverflow)?,
1731                );
1732                factors.push(
1733                    source_factors
1734                        .get(*row)
1735                        .copied()
1736                        .ok_or(StoreError::ActiveRowOverflow)?,
1737                );
1738            }
1739            Ok((codes, OwnedFactors::Bit4(factors)))
1740        }
1741        2 => {
1742            let stride = dims;
1743            let source_codes = reader.int8_codes().map_err(StoreError::Segment)?;
1744            let source_factors = reader.int8_factors().map_err(StoreError::Segment)?;
1745            let mut codes = Vec::with_capacity(survivors.len().saturating_mul(stride));
1746            let mut factors = Vec::with_capacity(survivors.len());
1747            for row in survivors {
1748                let start = row
1749                    .checked_mul(stride)
1750                    .ok_or(StoreError::ActiveRowOverflow)?;
1751                let end = start
1752                    .checked_add(stride)
1753                    .ok_or(StoreError::ActiveRowOverflow)?;
1754                for value in source_codes
1755                    .get(start..end)
1756                    .ok_or(StoreError::ActiveRowOverflow)?
1757                {
1758                    codes.push(value.to_ne_bytes()[0]);
1759                }
1760                factors.push(
1761                    source_factors
1762                        .get(*row)
1763                        .copied()
1764                        .ok_or(StoreError::ActiveRowOverflow)?,
1765                );
1766            }
1767            Ok((codes, OwnedFactors::Int8(factors)))
1768        }
1769        scheme => Err(StoreError::Segment(crate::segment::SegmentError::Geometry(
1770            format!("segment rewrite cannot encode scheme {scheme}"),
1771        ))),
1772    }
1773}
1774
1775pub(crate) fn gather_survivor_rescore(
1776    reader: &SegmentReader,
1777    survivors: &[usize],
1778    dims: usize,
1779) -> Result<Vec<f32>, StoreError> {
1780    let source_rescore = reader.rescore_f32().map_err(StoreError::Segment)?;
1781    let mut rescore = Vec::with_capacity(survivors.len().saturating_mul(dims));
1782    for row in survivors {
1783        let start = row.checked_mul(dims).ok_or(StoreError::ActiveRowOverflow)?;
1784        let end = start
1785            .checked_add(dims)
1786            .ok_or(StoreError::ActiveRowOverflow)?;
1787        rescore.extend_from_slice(
1788            source_rescore
1789                .get(start..end)
1790                .ok_or(StoreError::ActiveRowOverflow)?,
1791        );
1792    }
1793    Ok(rescore)
1794}
1795
1796fn gather_survivor_alive(
1797    reader: &SegmentReader,
1798    survivors: &[usize],
1799    additional_tombstones: &[usize],
1800) -> Result<AliveSet, StoreError> {
1801    let additional_tombstones = super::lookup::set(additional_tombstones);
1802    let source_alive = reader.alive().map_err(StoreError::Segment)?;
1803    let row_count = u32::try_from(survivors.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1804    let mut alive = AliveSet::new(row_count);
1805    for (new_row, old_row) in survivors.iter().enumerate() {
1806        let old_row_u32 = u32::try_from(*old_row).map_err(|_| StoreError::ActiveRowOverflow)?;
1807        if !source_alive.is_alive(old_row_u32)
1808            || super::lookup::contains(&additional_tombstones, old_row)
1809        {
1810            alive
1811                .tombstone(u32::try_from(new_row).map_err(|_| StoreError::ActiveRowOverflow)?)
1812                .map_err(|_| StoreError::ActiveRowOverflow)?;
1813        }
1814    }
1815    Ok(alive)
1816}
1817
1818pub(crate) fn gather_survivor_documents(
1819    reader: &SegmentReader,
1820    survivors: &[usize],
1821) -> Result<(Vec<DocId>, Vec<super::Revision>), StoreError> {
1822    let mut doc_ids = Vec::with_capacity(survivors.len());
1823    let mut revisions = Vec::with_capacity(survivors.len());
1824    for row in survivors {
1825        let version = reader
1826            .document_version(*row)
1827            .map_err(StoreError::Segment)?
1828            .ok_or_else(|| {
1829                StoreError::Segment(crate::segment::SegmentError::Geometry(
1830                    "purge target segment has no document-version row".to_owned(),
1831                ))
1832            })?;
1833        doc_ids.push(version.doc_id());
1834        revisions.push(version.revision());
1835    }
1836    Ok((doc_ids, revisions))
1837}
1838
1839type StoredMetadataGather = (Vec<u64>, Vec<u8>);
1840
1841fn gather_survivor_metadata(
1842    reader: &SegmentReader,
1843    survivors: &[usize],
1844) -> Result<Option<StoredMetadataGather>, StoreError> {
1845    let source_metadata = reader.stored_metadata().map_err(StoreError::Segment)?;
1846    let mut metadata_offsets = Vec::with_capacity(survivors.len());
1847    let mut metadata_bytes = Vec::new();
1848    if let Some(source) = source_metadata {
1849        for row in survivors {
1850            metadata_bytes.extend_from_slice(source.row(*row).ok_or_else(|| {
1851                StoreError::Segment(crate::segment::SegmentError::Geometry(format!(
1852                    "stored metadata row {row} is missing"
1853                )))
1854            })?);
1855            metadata_offsets.push(
1856                u64::try_from(metadata_bytes.len()).map_err(|_| StoreError::ActiveRowOverflow)?,
1857            );
1858        }
1859    }
1860    Ok(source_metadata.map(|_| (metadata_offsets, metadata_bytes)))
1861}
1862
1863type StoredTextGather = (Vec<u8>, Vec<u64>, Vec<u8>);
1864
1865fn gather_survivor_text(
1866    reader: &SegmentReader,
1867    survivors: &[usize],
1868) -> Result<Option<StoredTextGather>, StoreError> {
1869    let source_text = reader.stored_text().map_err(StoreError::Segment)?;
1870    let mut text_present = Vec::with_capacity(survivors.len());
1871    let mut text_end_offsets = Vec::with_capacity(survivors.len());
1872    let mut text_bytes = Vec::new();
1873    if let Some(source) = source_text {
1874        for row in survivors {
1875            match source.row(*row).ok_or_else(|| {
1876                StoreError::Segment(crate::segment::SegmentError::Geometry(format!(
1877                    "stored text row {row} is missing"
1878                )))
1879            })? {
1880                Some(text) => {
1881                    text_present.push(1);
1882                    text_bytes.extend_from_slice(text.as_bytes());
1883                }
1884                None => text_present.push(0),
1885            }
1886            text_end_offsets
1887                .push(u64::try_from(text_bytes.len()).map_err(|_| StoreError::ActiveRowOverflow)?);
1888        }
1889    }
1890    Ok(source_text.map(|_| (text_present, text_end_offsets, text_bytes)))
1891}
1892
1893fn rewrite_segment(
1894    vfs: &dyn Vfs,
1895    directory: &Path,
1896    reader: &SegmentReader,
1897    survivors: &[usize],
1898    additional_tombstones: &[usize],
1899    replacement_id: SegmentId,
1900    policy: DurabilityPolicy,
1901) -> Result<SegmentMeta, StoreError> {
1902    let dims = reader.meta().dims as usize;
1903    let (codes, factors) = gather_survivor_codes(reader, survivors, dims)?;
1904    let rescore = gather_survivor_rescore(reader, survivors, dims)?;
1905    let source_columns = reader.columns().map_err(StoreError::Segment)?;
1906    let columns = rewrite_columns(&source_columns, survivors)?;
1907    let alive = gather_survivor_alive(reader, survivors, additional_tombstones)?;
1908    let (doc_ids, revisions) = gather_survivor_documents(reader, survivors)?;
1909    let metadata = gather_survivor_metadata(reader, survivors)?;
1910    let text = gather_survivor_text(reader, survivors)?;
1911    let build = SegmentBuild {
1912        id: replacement_id,
1913        scheme: reader.meta().scheme,
1914        dims: reader.meta().dims,
1915        codes: &codes,
1916        factors: factors.borrowed(),
1917        rescore: &rescore,
1918        columns: &columns,
1919        alive: &alive,
1920    };
1921    let documents = SegmentDocumentVersions {
1922        doc_ids: &doc_ids,
1923        revisions: &revisions,
1924    };
1925    let postings = reader
1926        .postings()
1927        .map_err(StoreError::Segment)?
1928        .map(|postings| postings.retain_rows(survivors))
1929        .transpose()
1930        .map_err(crate::segment::SegmentError::from)
1931        .map_err(StoreError::Segment)?
1932        .map(|postings| postings.encode_region())
1933        .transpose()
1934        .map_err(crate::segment::SegmentError::from)
1935        .map_err(StoreError::Segment)?;
1936    let metadata = metadata
1937        .as_ref()
1938        .map(|(end_offsets, bytes)| SegmentStoredMetadata { end_offsets, bytes });
1939    let text = text
1940        .as_ref()
1941        .map(|(present, end_offsets, bytes)| SegmentStoredText {
1942            present,
1943            end_offsets,
1944            bytes,
1945        });
1946    let postings = postings.as_deref().map(|bytes| SegmentPostings { bytes });
1947    let written = write_segment_with_documents_payloads(
1948        vfs,
1949        directory,
1950        build,
1951        SegmentPayloads {
1952            documents,
1953            metadata,
1954            text,
1955            postings,
1956        },
1957        policy,
1958    )
1959    .map_err(StoreError::Segment)?;
1960    let mut written = written;
1961    written.clustering_key_range = clustering_range(&columns, &alive)?;
1962    Ok(written)
1963}
1964
1965fn rewrite_columns(source: &ColumnStore, survivors: &[usize]) -> Result<ColumnStore, StoreError> {
1966    let mut builder = ColumnStoreBuilder::new(source.schema().clone());
1967    append_survivor_columns(&mut builder, source, survivors)?;
1968    builder.finish().map_err(|source| {
1969        StoreError::Segment(crate::segment::SegmentError::Columns(source.to_string()))
1970    })
1971}
1972
1973pub(crate) fn append_survivor_columns(
1974    builder: &mut ColumnStoreBuilder,
1975    source: &ColumnStore,
1976    survivors: &[usize],
1977) -> Result<(), StoreError> {
1978    for row in survivors {
1979        let row_u32 = u32::try_from(*row).map_err(|_| StoreError::ActiveRowOverflow)?;
1980        let timestamp = source.timestamp(row_u32).ok_or_else(|| {
1981            StoreError::Segment(crate::segment::SegmentError::Columns(format!(
1982                "timestamp row {row} is missing"
1983            )))
1984        })?;
1985        let mut inputs = Vec::with_capacity(source.schema().user_column_count());
1986        for definition in source
1987            .schema()
1988            .columns()
1989            .iter()
1990            .filter(|definition| definition.id() != TIMESTAMP_COLUMN)
1991        {
1992            let column = source.column(definition.id()).ok_or_else(|| {
1993                StoreError::Segment(crate::segment::SegmentError::Columns(format!(
1994                    "column {} is missing",
1995                    definition.id().get()
1996                )))
1997            })?;
1998            let value = match column {
1999                Column::Id128(values) => values.get(row_u32).map(ColumnValue::Id128),
2000                Column::U64(values) => values.get(row_u32).map(ColumnValue::U64),
2001                Column::I64(values) => values.get(row_u32).map(ColumnValue::I64),
2002                Column::F64(values) => values.get(row_u32).map(ColumnValue::F64),
2003                Column::Bool(values) => values.get(row_u32).map(ColumnValue::Bool),
2004                Column::DictionaryString(values) => values.get(row_u32).map(ColumnValue::String),
2005                Column::RawString(values) => values.get(row_u32).map(ColumnValue::String),
2006            };
2007            if let Some(value) = value {
2008                inputs.push(ColumnInput {
2009                    column: definition.id(),
2010                    value,
2011                });
2012            }
2013        }
2014        builder.push_row(timestamp, &inputs).map_err(|source| {
2015            StoreError::Segment(crate::segment::SegmentError::Columns(source.to_string()))
2016        })?;
2017    }
2018    Ok(())
2019}
2020
2021pub(crate) fn clustering_range(
2022    columns: &ColumnStore,
2023    alive: &AliveSet,
2024) -> Result<ClusteringKeyRange, StoreError> {
2025    let mut bounds: Option<(i64, i64)> = None;
2026    for row in alive.iter_alive() {
2027        let timestamp = columns
2028            .timestamp(row)
2029            .ok_or(StoreError::ActiveRowOverflow)?;
2030        bounds = Some(match bounds {
2031            None => (timestamp, timestamp),
2032            Some((minimum, maximum)) => (minimum.min(timestamp), maximum.max(timestamp)),
2033        });
2034    }
2035    Ok(match bounds {
2036        Some((min_ts, max_ts)) => ClusteringKeyRange::Bounded { min_ts, max_ts },
2037        None => ClusteringKeyRange::Empty,
2038    })
2039}
2040
2041fn replacement_segment_id(original: SegmentId, token_id: u64, generation: u64) -> SegmentId {
2042    // XOR with the previous ID can cancel earlier generations/nonces and
2043    // overwrite another live segment. Keep the publication generation intact,
2044    // and distinguish sources within that generation with a seeded hash.
2045    let mut bytes = [0_u8; 16];
2046    for (target, value) in bytes.iter_mut().take(8).zip(generation.to_be_bytes()) {
2047        *target = value;
2048    }
2049    let source = xxhash_rust::xxh3::xxh3_64_with_seed(original.as_bytes(), token_id);
2050    for (target, value) in bytes.iter_mut().skip(8).zip(source.to_be_bytes()) {
2051        *target = value;
2052    }
2053    SegmentId::from_bytes(bytes)
2054}
2055
2056fn sweep_purge_orphans(
2057    vfs: &dyn Vfs,
2058    directory: &Path,
2059    manifest: &Manifest,
2060    policy: DurabilityPolicy,
2061) -> Result<(), PurgeError> {
2062    let reachable = manifest
2063        .segments
2064        .iter()
2065        .map(|segment| directory.join(segment.id.file_name()))
2066        .collect::<HashSet<PathBuf>>();
2067    let mut deleted = false;
2068    for path in vfs.list(directory).map_err(|source| StoreError::Io {
2069        path: directory.to_path_buf(),
2070        source,
2071    })? {
2072        let is_segment = path
2073            .file_name()
2074            .and_then(|name| name.to_str())
2075            .is_some_and(|name| name.starts_with("segment-") && name.ends_with(".zseg"));
2076        let is_segment_temp = path
2077            .file_name()
2078            .and_then(|name| name.to_str())
2079            .is_some_and(|name| name.starts_with(".segment-") && name.ends_with(".zseg.tmp"));
2080        let is_wal_temp = path
2081            .file_name()
2082            .is_some_and(|name| name == WAL_PURGE_TEMP_FILE);
2083        if (is_segment && !reachable.contains(&path)) || is_segment_temp || is_wal_temp {
2084            vfs.delete(&path)
2085                .map_err(|source| StoreError::Io { path, source })?;
2086            deleted = true;
2087        }
2088    }
2089    if deleted {
2090        sync_directory(vfs, directory, policy)?;
2091    }
2092    Ok(())
2093}
2094
2095fn active_wal_records(segment: &super::ActiveSegment) -> Result<WalImageRecords, PurgeError> {
2096    let dims = segment.dims().unwrap_or(0);
2097    let mut records = Vec::with_capacity(segment.row_count().saturating_add(1));
2098    let mut tombstoned = Vec::with_capacity(segment.row_count());
2099    let mut deleted = Vec::new();
2100    let alive = segment.alive()?;
2101    for row in 0..segment.row_count() {
2102        let start = row.checked_mul(dims).ok_or(StoreError::ActiveRowOverflow)?;
2103        let end = start
2104            .checked_add(dims)
2105            .ok_or(StoreError::ActiveRowOverflow)?;
2106        let version = segment.document(row).ok_or(StoreError::ActiveRowOverflow)?;
2107        let mut document = IngestDocument::new(
2108            version,
2109            segment
2110                .vectors()
2111                .get(start..end)
2112                .ok_or(StoreError::ActiveRowOverflow)?
2113                .to_vec(),
2114        )
2115        .with_timestamp(
2116            segment
2117                .timestamps()
2118                .get(row)
2119                .copied()
2120                .ok_or(StoreError::ActiveRowOverflow)?,
2121        )
2122        .with_metadata(
2123            segment
2124                .metadata(row)
2125                .ok_or(StoreError::ActiveRowOverflow)?
2126                .to_vec(),
2127        );
2128        if let Some(text) = segment.text(row)? {
2129            document = document.with_text(text);
2130        }
2131        document = document.with_columns(segment.column_values(row)?);
2132        records.push(super::encode_persisted_upsert(&document).map_err(purge_ingest_error)?);
2133        let row_u32 = u32::try_from(row).map_err(|_| StoreError::ActiveRowOverflow)?;
2134        let is_tombstoned = !alive.is_alive(row_u32);
2135        tombstoned.push(is_tombstoned);
2136        if is_tombstoned {
2137            deleted.push(version.doc_id());
2138        }
2139    }
2140    if !deleted.is_empty() {
2141        records.push((
2142            wal_payload::DELETE_V1,
2143            wal_payload::encode_delete(&deleted).map_err(PurgeError::WalPayload)?,
2144        ));
2145    }
2146    Ok((records, tombstoned))
2147}
2148
2149fn ensure_wal_rewrite_covers_retained(
2150    vfs: &dyn Vfs,
2151    directory: &Path,
2152    manifest: &Manifest,
2153    purged_ids: &[DocId],
2154    next_active: &super::ActiveSegment,
2155    sealed: &[SegmentReader],
2156    preparation: Option<&crate::lifecycle::namespace_batch::PrivatePreparation>,
2157) -> Result<(), PurgeError> {
2158    let purged_ids = super::lookup::set(purged_ids);
2159    let alive = next_active.alive()?;
2160    let mut active_versions = std::collections::HashMap::new();
2161    for (row, id) in next_active.doc_ids().iter().enumerate() {
2162        super::lookup::work(1);
2163        let version = next_active
2164            .document(row)
2165            .ok_or(StoreError::ActiveRowOverflow)?;
2166        let row_u32 = u32::try_from(row).map_err(|_| StoreError::ActiveRowOverflow)?;
2167        active_versions
2168            .entry(*id)
2169            .or_insert((version, !alive.is_alive(row_u32)));
2170    }
2171    #[cfg(feature = "graph-cypher")]
2172    let graph_enabled = manifest.graph.is_some();
2173    #[cfg(not(feature = "graph-cypher"))]
2174    let graph_enabled = false;
2175    let wal_path = directory.join("wal.ze");
2176    let reader = crate::wal::WalReader::open(vfs, &wal_path).map_err(StoreError::Wal)?;
2177    let clean = reader.into_clean().map_err(StoreError::WalRecovery)?;
2178    let absorbed_through = manifest.log_seq;
2179    let decisions = crate::lifecycle::namespace_batch::transaction_decisions(
2180        vfs,
2181        directory,
2182        clean.records(),
2183        absorbed_through,
2184        preparation,
2185    )?;
2186    let mutations = if decisions.is_empty() {
2187        super::atomic_batch::committed_mutations(clean.records(), absorbed_through)?
2188    } else {
2189        super::atomic_batch::committed_mutations_with_decisions(
2190            clean.records(),
2191            absorbed_through,
2192            |binding| decisions.get(&binding.transaction).copied(),
2193        )?
2194    };
2195    for (seq, _, mutation) in mutations {
2196        let covered = match mutation {
2197            #[cfg(feature = "graph-cypher")]
2198            super::wal_payload::MutationPayload::GraphCommit(_) => manifest
2199                .graph
2200                .as_ref()
2201                .is_some_and(|graph| seq.get() <= graph.graph_absorbed_through),
2202            super::wal_payload::MutationPayload::Upsert(document) => {
2203                let version = document.version();
2204                super::lookup::contains(&purged_ids, &version.doc_id())
2205                    || active_versions
2206                        .get(&version.doc_id())
2207                        .is_some_and(|(existing, _)| existing.revision() >= version.revision())
2208            }
2209            super::wal_payload::MutationPayload::Delete(ids) => {
2210                let mut covered = true;
2211                for id in ids {
2212                    if !super::lookup::contains(&purged_ids, &id)
2213                        && !active_versions
2214                            .get(&id)
2215                            .is_some_and(|(_, tombstoned)| *tombstoned)
2216                        && !delete_is_covered_by_manifest(
2217                            sealed,
2218                            id,
2219                            graph_enabled && !active_versions.contains_key(&id),
2220                        )?
2221                    {
2222                        covered = false;
2223                        break;
2224                    }
2225                }
2226                covered
2227            }
2228            super::wal_payload::MutationPayload::MetadataEdit(_)
2229            | super::wal_payload::MutationPayload::MixedBatchMember { .. }
2230            | super::wal_payload::MutationPayload::BatchMember { .. } => false,
2231        };
2232        if !covered {
2233            return Err(PurgeError::WalRewriteWouldDropAcked { seq });
2234        }
2235    }
2236    Ok(())
2237}
2238
2239// The manifest-selected segments and replacement active image jointly cover
2240// a delete: no sealed live copy, and either a persisted tombstone or, on graph
2241// stores only, no active copy. Graph-free stores require main's stored tombstone.
2242// Retention may have removed the segment that held the tombstone. Every
2243// retained upsert must still independently be covered by the rewrite check.
2244fn delete_is_covered_by_manifest(
2245    sealed: &[SegmentReader],
2246    id: DocId,
2247    active_absent: bool,
2248) -> Result<bool, StoreError> {
2249    let mut found = false;
2250    for segment in sealed {
2251        let rows = segment.query_rows_with_doc_id(id)?;
2252        if !rows.is_empty() {
2253            let alive = segment.alive().map_err(StoreError::Segment)?;
2254            if rows.iter().any(|row| alive.is_alive(*row)) {
2255                return Ok(false);
2256            }
2257            found = true;
2258        }
2259    }
2260    Ok(found || active_absent)
2261}
2262
2263fn purge_ingest_error(error: super::IngestError) -> PurgeError {
2264    match error {
2265        #[cfg(feature = "graph-cypher")]
2266        super::IngestError::Graph(error) => PurgeError::IntentDecode(format!(
2267            "active WAL rewrite rejected a graph mutation: {error}"
2268        )),
2269        super::IngestError::Store(error) => PurgeError::Store(error),
2270        super::IngestError::EpochMismatch(error) => {
2271            PurgeError::Store(StoreError::EpochMismatch(error))
2272        }
2273        super::IngestError::EpochUndeclared => PurgeError::Store(StoreError::EpochUndeclared),
2274        super::IngestError::EpochUnstamped => PurgeError::Store(StoreError::EpochUnstamped),
2275        super::IngestError::Payload(error) => PurgeError::WalPayload(error),
2276        super::IngestError::EmptyBatch => {
2277            PurgeError::IntentDecode("active WAL rewrite produced an empty batch error".to_owned())
2278        }
2279        super::IngestError::StaleRevision { .. } => PurgeError::IntentDecode(
2280            "active WAL rewrite produced a stale revision error".to_owned(),
2281        ),
2282        super::IngestError::RevisionConflict { .. } => PurgeError::IntentDecode(
2283            "active WAL rewrite produced a revision condition error".to_owned(),
2284        ),
2285        super::IngestError::Vector(error) => PurgeError::IntentDecode(format!(
2286            "active WAL rewrite rejected a persisted vector: {error}"
2287        )),
2288        super::IngestError::Lexical(error) => PurgeError::IntentDecode(format!(
2289            "active WAL rewrite rejected persisted text: {error}"
2290        )),
2291        super::IngestError::Tokenizer(error) => PurgeError::IntentDecode(format!(
2292            "active WAL rewrite rejected the frozen tokenizer: {error}"
2293        )),
2294        super::IngestError::Columns(error) => PurgeError::IntentDecode(format!(
2295            "active WAL rewrite rejected persisted columns: {error}"
2296        )),
2297    }
2298}
2299
2300fn assign_rewritten_sequences(
2301    segment: &mut super::ActiveSegment,
2302    first_seq: LogSeq,
2303    tombstoned: &[bool],
2304) -> Result<(), PurgeError> {
2305    let delete_seq = (!tombstoned.is_empty() && tombstoned.iter().any(|value| *value))
2306        .then(|| LogSeq::new(first_seq.get().saturating_add(segment.row_count() as u64)));
2307    for row in 0..segment.row_count() {
2308        let row_seq = LogSeq::new(first_seq.get().saturating_add(row as u64));
2309        let sequence = if tombstoned.get(row).copied().unwrap_or(false) {
2310            delete_seq.unwrap_or(row_seq)
2311        } else {
2312            row_seq
2313        };
2314        segment.set_sequence(row, sequence)?;
2315    }
2316    Ok(())
2317}
2318
2319#[cfg(unix)]
2320fn available_disk_bytes(path: &Path) -> std::io::Result<u64> {
2321    use std::ffi::CString;
2322    use std::os::unix::ffi::OsStrExt as _;
2323
2324    let encoded = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
2325        std::io::Error::new(
2326            std::io::ErrorKind::InvalidInput,
2327            "store path contains a NUL byte",
2328        )
2329    })?;
2330    let mut output = std::mem::MaybeUninit::<libc::statvfs>::uninit();
2331    // SAFETY: `encoded` is NUL-terminated and `output` points to writable storage
2332    // for exactly one `statvfs` result initialized by a successful call.
2333    let result = unsafe { libc::statvfs(encoded.as_ptr(), output.as_mut_ptr()) };
2334    if result != 0 {
2335        return Err(std::io::Error::last_os_error());
2336    }
2337    // SAFETY: the successful `statvfs` call initialized every field in `output`.
2338    let output = unsafe { output.assume_init() };
2339    let blocks = u128::from(output.f_bavail);
2340    let fragment = u128::from(output.f_frsize);
2341    u64::try_from(blocks.saturating_mul(fragment)).map_err(|_| {
2342        std::io::Error::new(
2343            std::io::ErrorKind::InvalidData,
2344            "available filesystem bytes exceed u64",
2345        )
2346    })
2347}
2348
2349/// Bytes available to this caller on the volume that actually holds `path`.
2350///
2351/// `GetDiskFreeSpaceExW` is quota-aware and resolves the volume from the store
2352/// path itself, so a store on a mount point or junction is measured against the
2353/// volume it really lives on rather than the one its path prefix suggests.
2354#[cfg(windows)]
2355fn available_disk_bytes(path: &Path) -> std::io::Result<u64> {
2356    crate::sys::windows::available_disk_bytes(path)
2357}
2358
2359#[cfg(not(any(unix, windows)))]
2360fn available_disk_bytes(_path: &Path) -> std::io::Result<u64> {
2361    Err(std::io::Error::new(
2362        std::io::ErrorKind::Unsupported,
2363        "physical purge free-space probing requires statvfs",
2364    ))
2365}
2366
2367#[cfg(test)]
2368#[allow(clippy::expect_used)]
2369mod tests {
2370    use super::{
2371        PURGE_INTENT_FILE, PurgeError, PurgeIntent, PurgeReport, PurgeToken, enforce_temp_space,
2372        purge_token_id, read_intent, read_u32, read_u64, remove_intent, write_intent,
2373    };
2374    use crate::format::FormatFamily;
2375    use crate::format::frame::{decode_artifact, encode_artifact};
2376    use crate::ingest::{DocId, wal_payload};
2377    use crate::lifecycle::StoreError;
2378    use crate::lifecycle::durability::{CommitTier, DurabilityMode, DurabilityPolicy};
2379    use crate::vfs::StdVfs;
2380    use crate::wal::LogSeq;
2381
2382    #[test]
2383    fn a_graph_free_purge_after_retention_matches_main() {
2384        use crate::ingest::{DeleteBatch, DocumentVersion, IngestBatch, IngestDocument, Revision};
2385        use crate::lifecycle::{OpenOptions, Store};
2386        let directory = tempfile::tempdir().expect("directory");
2387        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
2388        store
2389            .ingest(IngestBatch::new(vec![
2390                IngestDocument::new(
2391                    DocumentVersion::new(DocId::new(91), Revision::new(1)),
2392                    vec![1.0],
2393                )
2394                .with_timestamp(91),
2395            ]))
2396            .expect("A");
2397        store.seal().expect("seal A");
2398        store
2399            .ingest(IngestBatch::new(
2400                vec![92, 93]
2401                    .into_iter()
2402                    .map(|id| {
2403                        IngestDocument::new(
2404                            DocumentVersion::new(DocId::new(id), Revision::new(1)),
2405                            vec![1.0],
2406                        )
2407                    })
2408                    .collect(),
2409            ))
2410            .expect("B C");
2411        store
2412            .delete(DeleteBatch::new(vec![DocId::new(91)]))
2413            .expect("delete A");
2414        store.drop_partition(91..92).expect("drop A");
2415        let token = store.purge(&[DocId::new(92)]).expect("purge B");
2416        let before = std::fs::read(directory.path().join("wal.ze")).expect("WAL before");
2417        let result = store.await_physical_purge(token);
2418        assert!(
2419            matches!(result, Err(PurgeError::WalRewriteWouldDropAcked { seq }) if seq == LogSeq::new(4)),
2420            "{result:?}"
2421        );
2422        assert_eq!(
2423            std::fs::read(directory.path().join("wal.ze")).expect("WAL after"),
2424            before
2425        );
2426        // Measured on main 9d217196 in /private/tmp/w346-p1p2-main, a separate
2427        // temporary worktree: same test, graph-cypher, rejection at sequence 4.
2428        // Pin every durable byte, including the pending intent and unchanged WAL.
2429        let main = [
2430            (".ze-readers.lock", ""),
2431            ("manifest.ze", "5a4550454d4245440a000200000000002000000000000000700000000000000038000000000000000600000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6dd00afe67b79862c016c188e60eca8"),
2432            ("purge.ze", "5a4550454d4245440f00010000000000200000000000000058000000000000002000000000000000bf09f9ca40c7b1e501000000000000005c0000000000000000000000000000004c66d4b4b29ca4158fc5f105cfc7c200"),
2433            ("wal.ze", "5a4550454d4245440b000100000000002800000000000000000000000000000002000000000000002c000000020000000000000008000000000002000000010000005c0000000000000000000000000000000100000000000000010000000000803f14975d8581be8d602c000000030000000000000008000100000002000000010000005d0000000000000000000000000000000100000000000000010000000000803f6c6214603baa8205180000000400000000000000020001000000010000005b000000000000000000000000000000c95f15ae22a86feb"),
2434            ("writer.lock", ""),
2435        ].into_iter().map(|(name, hex)| {
2436            let bytes = hex.as_bytes().chunks_exact(2).map(|pair|
2437                u8::from_str_radix(std::str::from_utf8(pair).expect("hex pair"), 16).expect("hex byte")
2438            ).collect::<Vec<_>>();
2439            (name.to_owned(), bytes)
2440        }).collect::<std::collections::BTreeMap<_, _>>();
2441        let files = std::fs::read_dir(directory.path())
2442            .expect("files")
2443            .map(|entry| {
2444                let entry = entry.expect("entry");
2445                (
2446                    entry.file_name().to_string_lossy().into_owned(),
2447                    std::fs::read(entry.path()).expect("bytes"),
2448                )
2449            })
2450            .collect::<std::collections::BTreeMap<_, _>>();
2451        assert_eq!(files, main);
2452    }
2453
2454    #[test]
2455    fn a_graph_free_tombstone_repair_matches_main_bytes() {
2456        use crate::ingest::{DeleteBatch, DocumentVersion, IngestBatch, IngestDocument, Revision};
2457        use crate::lifecycle::{OpenOptions, Store};
2458        let directory = tempfile::tempdir().expect("directory");
2459        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
2460        assert_eq!(
2461            store
2462                .ingest(IngestBatch::new(vec![IngestDocument::new(
2463                    DocumentVersion::new(DocId::new(91), Revision::new(1)),
2464                    vec![1.0]
2465                )]))
2466                .expect("ingest")
2467                .generation(),
2468            1
2469        );
2470        assert_eq!(store.seal().expect("seal"), 2);
2471        let manifest_path = directory.path().join("manifest.ze");
2472        let old_manifest = std::fs::read(&manifest_path).expect("old manifest");
2473        let segment_path = directory
2474            .path()
2475            .join("segment-00000000000000020000000000000001.zseg");
2476        let old_segment = std::fs::read(&segment_path).expect("old segment");
2477        assert_eq!(
2478            store
2479                .delete(DeleteBatch::new(vec![DocId::new(91)]))
2480                .expect("delete")
2481                .generation(),
2482            3
2483        );
2484        // Lose only the delete publication: retain the synced WAL and old sealed bytes.
2485        drop(store);
2486        std::fs::write(&manifest_path, old_manifest).expect("restore manifest");
2487        std::fs::write(&segment_path, old_segment).expect("restore segment");
2488        let recovered = Store::open(directory.path(), OpenOptions::default()).expect("recover");
2489        let bytes = std::fs::read(&manifest_path).expect("repaired manifest");
2490        let manifest = crate::manifest::decode_manifest("repaired", &bytes).expect("decode");
2491        // Measured twice on main 9d217196 in a separate temporary worktree,
2492        // without stashing: review_main_bytes::record_main_repair_bytes.
2493        assert_eq!(manifest.generation, 4);
2494        assert_eq!(
2495            manifest
2496                .segments
2497                .first()
2498                .expect("replacement")
2499                .id
2500                .to_string(),
2501            "00000000000000049e7321e873816107"
2502        );
2503        let main_hex = concat!(
2504            "5a4550454d4245440a000200000000002000000000000000c4000000000000008c00000000000000",
2505            "04000000000000000100000000000000010000000000000000000000000000000000000000000000",
2506            "0000000000000000000000000000000000000000000000049e7321e8738161070100000004000000",
2507            "0100000070c001000000000000000000000000000000000000000000545352310100000001000000",
2508            "00000000000000000000000000000000000000000026433d9a49bbe058e92629d0d9c543",
2509        );
2510        let expected = main_hex
2511            .as_bytes()
2512            .chunks_exact(2)
2513            .map(|pair| {
2514                u8::from_str_radix(std::str::from_utf8(pair).expect("hex pair"), 16)
2515                    .expect("hex byte")
2516            })
2517            .collect::<Vec<_>>();
2518        assert_eq!(
2519            bytes, expected,
2520            "graph-free recovery must preserve main's complete manifest bytes"
2521        );
2522        assert_eq!(recovered.snapshot().expect("snapshot").generation(), 4);
2523        assert_eq!(
2524            recovered.count_documents(None, None).expect("count").count,
2525            0
2526        );
2527    }
2528
2529    fn graph_free_pending_purge_crash(ids: &[u128]) -> tempfile::TempDir {
2530        use crate::ingest::{DeleteBatch, DocumentVersion, IngestBatch, IngestDocument, Revision};
2531        use crate::lifecycle::{OpenOptions, Store};
2532        let directory = tempfile::tempdir().expect("directory");
2533        let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
2534        store
2535            .ingest(IngestBatch::new(
2536                ids.iter()
2537                    .map(|id| {
2538                        IngestDocument::new(
2539                            DocumentVersion::new(DocId::new(*id), Revision::new(1)),
2540                            vec![1.0],
2541                        )
2542                    })
2543                    .collect(),
2544            ))
2545            .expect("ingest");
2546        store.seal().expect("seal");
2547        store
2548            .purge(&[DocId::new(if ids.len() == 1 { 91 } else { 92 })])
2549            .expect("schedule purge");
2550        let path = directory.path().join("manifest.ze");
2551        let before = std::fs::read(&path).expect("manifest before delete");
2552        let manifest = crate::manifest::decode_manifest("before", &before).expect("decode");
2553        let segment_path = directory.path().join(
2554            manifest
2555                .segments
2556                .first()
2557                .expect("sealed segment")
2558                .id
2559                .file_name(),
2560        );
2561        let segment = std::fs::read(&segment_path).expect("sealed bytes");
2562        assert_eq!(
2563            store
2564                .delete(DeleteBatch::new(vec![DocId::new(91)]))
2565                .expect("delete")
2566                .generation(),
2567            3
2568        );
2569        drop(store);
2570        // Keep the synced delete WAL; lose its manifest and segment publication.
2571        std::fs::write(&path, before).expect("restore manifest");
2572        std::fs::write(&segment_path, segment).expect("restore segment");
2573        directory
2574    }
2575
2576    #[test]
2577    fn a_graph_free_pending_purge_with_an_unmanifested_delete_matches_main() {
2578        use crate::lifecycle::{OpenOptions, Store};
2579        let directory = graph_free_pending_purge_crash(&[91]);
2580        let recovered = Store::open(directory.path(), OpenOptions::default()).expect("recover");
2581        let bytes = std::fs::read(directory.path().join("manifest.ze")).expect("manifest");
2582        let manifest = crate::manifest::decode_manifest("recovered", &bytes).expect("decode");
2583        // Measured on main 9d217196 in the separate temporary worktree
2584        // /private/tmp/w346-main-byte-reference: review_pending_purge::record_main_pending_purge.
2585        assert_eq!(manifest.generation, 4);
2586        assert_eq!(
2587            manifest
2588                .segments
2589                .first()
2590                .expect("replacement")
2591                .id
2592                .to_string(),
2593            "0000000000000004e326c259b011dc7d"
2594        );
2595        let main_hex = "5a4550454d4245440a000200000000002000000000000000c4000000000000008c0000000000000004000000000000000100000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004e326c259b011dc7d00000000040000000100000060800100000000000000000000000000000000000000000054535231010000000100000000000000000000000000000000000000000000002a37ad4743e41889dc2754017bb05dc9";
2596        let expected = main_hex
2597            .as_bytes()
2598            .chunks_exact(2)
2599            .map(|pair| {
2600                u8::from_str_radix(std::str::from_utf8(pair).expect("hex pair"), 16)
2601                    .expect("hex byte")
2602            })
2603            .collect::<Vec<_>>();
2604        assert_eq!(
2605            bytes, expected,
2606            "complete graph-free manifest must match main"
2607        );
2608        assert_eq!(recovered.snapshot().expect("snapshot").generation(), 4);
2609        assert_eq!(
2610            recovered.count_documents(None, None).expect("count").count,
2611            0
2612        );
2613    }
2614
2615    #[test]
2616    fn a_graph_free_pending_purge_of_another_document_preserves_main_refusal() {
2617        use crate::lifecycle::{OpenOptions, Store};
2618        let directory = graph_free_pending_purge_crash(&[91, 92, 93]);
2619        // The same independent main worktree reports this exact refusal.
2620        // Preserve it even though repairing first could make this state open.
2621        let error = Store::open(directory.path(), OpenOptions::default())
2622            .err()
2623            .expect("main refuses this crash state");
2624        assert!(
2625            matches!(error, StoreError::PurgeRecovery { ref detail } if detail == "purge WAL rewrite would drop acknowledged WAL sequence 4"),
2626            "{error:?}"
2627        );
2628    }
2629
2630    #[test]
2631    fn sealed_purge_lookup_work_is_linear() {
2632        use crate::ingest::{DocumentVersion, IngestBatch, IngestDocument, Revision};
2633        use crate::lifecycle::{OpenOptions, Store};
2634        for n in [128, 512, 2048] {
2635            let directory = tempfile::tempdir().expect("directory");
2636            let store = Store::open(directory.path(), OpenOptions::default()).expect("open");
2637            let documents = (0..n)
2638                .map(|id| {
2639                    IngestDocument::new(
2640                        DocumentVersion::new(DocId::new(id as u128), Revision::new(1)),
2641                        vec![1.0],
2642                    )
2643                })
2644                .collect();
2645            store.ingest(IngestBatch::new(documents)).expect("ingest");
2646            store.seal().expect("seal");
2647            let snapshot = store.snapshot().expect("snapshot");
2648            let reader = snapshot.segments().first().expect("segment");
2649            let mut ids: Vec<_> = (0..n)
2650                .step_by(2)
2651                .rev()
2652                .map(|id| DocId::new(id as u128))
2653                .collect();
2654            ids.extend([DocId::new(0), DocId::new(u128::MAX)]);
2655            super::super::lookup::take_work();
2656            let requested = super::super::lookup::set(&ids);
2657            let survivors = super::survivor_rows(reader, &requested).expect("survivors");
2658            let work = super::super::lookup::take_work();
2659            assert_eq!(
2660                survivors,
2661                (0..n).filter(|id| id % 2 != 0).collect::<Vec<_>>()
2662            );
2663            assert!(work <= 2 * (n + ids.len()), "{n} rows: {work} lookup work");
2664        }
2665    }
2666
2667    #[test]
2668    fn purge_tokens_reports_and_errors_preserve_their_contract_values() {
2669        let unknown = DocId::new(19);
2670        let token = PurgeToken {
2671            id: 41,
2672            generation: 42,
2673            unknown_ids: vec![unknown],
2674            no_op: false,
2675        };
2676        assert_eq!(token.id(), 41);
2677        assert_eq!(token.generation(), 42);
2678        assert_eq!(token.unknown_ids(), &[unknown]);
2679        assert!(!token.is_no_op());
2680
2681        let report = PurgeReport {
2682            generation: 43,
2683            segments_rewritten: 2,
2684            wal_rewritten: true,
2685            unknown_ids: vec![unknown],
2686        };
2687        assert_eq!(report.generation(), 43);
2688        assert_eq!(report.segments_rewritten(), 2);
2689        assert!(report.wal_rewritten());
2690        assert_eq!(report.unknown_ids(), &[unknown]);
2691        assert!(!report.is_no_op());
2692
2693        let format = decode_artifact("purge", FormatFamily::PurgeIntent, &[])
2694            .expect_err("empty intent is invalid");
2695        let errors = [
2696            PurgeError::Store(StoreError::ReadOnly),
2697            PurgeError::InsufficientTempSpace {
2698                segment_bytes: 100,
2699                available_bytes: 120,
2700                required_bytes: 120,
2701            },
2702            PurgeError::PurgeInProgress,
2703            PurgeError::UnknownToken { token_id: 44 },
2704            PurgeError::IntentFormat(format),
2705            PurgeError::IntentDecode("bad payload".to_owned()),
2706            PurgeError::WalPayload(wal_payload::PayloadError::Truncated),
2707            PurgeError::WalRewriteWouldDropAcked {
2708                seq: LogSeq::new(45),
2709            },
2710        ];
2711        for error in errors {
2712            assert!(!error.to_string().is_empty());
2713            let sourced = std::error::Error::source(&error).is_some();
2714            assert_eq!(
2715                sourced,
2716                matches!(
2717                    error,
2718                    PurgeError::Store(_) | PurgeError::IntentFormat(_) | PurgeError::WalPayload(_)
2719                )
2720            );
2721        }
2722        let converted = PurgeError::from(StoreError::Closed);
2723        assert!(matches!(converted, PurgeError::Store(StoreError::Closed)));
2724
2725        let ids = [DocId::new(3), DocId::new(5)];
2726        assert_eq!(purge_token_id(7, &ids), purge_token_id(7, &ids));
2727        assert_ne!(purge_token_id(7, &ids), purge_token_id(8, &ids));
2728        assert!(enforce_temp_space(100, 121).is_ok());
2729        assert!(matches!(
2730            enforce_temp_space(100, 120),
2731            Err(PurgeError::InsufficientTempSpace {
2732                required_bytes: 120,
2733                ..
2734            })
2735        ));
2736    }
2737
2738    #[test]
2739    fn purge_intent_round_trip_rejects_reserved_and_length_corruption() {
2740        let directory = tempfile::tempdir().expect("purge intent test directory");
2741        let policy = DurabilityPolicy::new(DurabilityMode::Derived, CommitTier::None)
2742            .expect("derived durability");
2743        let intent = PurgeIntent {
2744            token_id: 0x0102_0304_0506_0708,
2745            ids: vec![DocId::new(11), DocId::new(12)],
2746        };
2747        write_intent(&StdVfs, directory.path(), &intent, policy).expect("write valid intent");
2748        let decoded = read_intent(&StdVfs, directory.path()).expect("read valid intent");
2749        assert_eq!(decoded.token_id, intent.token_id);
2750        assert_eq!(decoded.ids, intent.ids);
2751
2752        let path = directory.path().join(PURGE_INTENT_FILE);
2753        let bytes = std::fs::read(&path).expect("read framed intent");
2754        let payload = decode_artifact("purge", FormatFamily::PurgeIntent, &bytes)
2755            .expect("decode framed intent")
2756            .payload
2757            .to_vec();
2758
2759        let mut reserved = payload.clone();
2760        let field = reserved.get_mut(12).expect("reserved byte exists");
2761        *field = 1;
2762        std::fs::write(
2763            &path,
2764            encode_artifact(FormatFamily::PurgeIntent, 0, &reserved),
2765        )
2766        .expect("write reserved corruption");
2767        assert!(matches!(
2768            read_intent(&StdVfs, directory.path()),
2769            Err(PurgeError::IntentDecode(detail)) if detail.contains("reserved")
2770        ));
2771
2772        let truncated = payload.get(..payload.len() - 1).expect("truncate payload");
2773        std::fs::write(
2774            &path,
2775            encode_artifact(FormatFamily::PurgeIntent, 0, truncated),
2776        )
2777        .expect("write length corruption");
2778        assert!(matches!(
2779            read_intent(&StdVfs, directory.path()),
2780            Err(PurgeError::IntentDecode(detail)) if detail.contains("expected")
2781        ));
2782        assert!(read_u32(&[], 0).is_err());
2783        assert!(read_u64(&[], 0).is_err());
2784
2785        std::fs::write(&path, bytes).expect("restore valid intent");
2786        remove_intent(&StdVfs, directory.path(), policy).expect("remove valid intent");
2787        assert!(!path.exists());
2788    }
2789}