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