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