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