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