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