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