1mod active;
4mod purge;
5
6pub(crate) mod purge_support {
12 pub(crate) use super::purge::{
13 OwnedFactors, append_survivor_columns, clustering_range, gather_survivor_codes,
14 gather_survivor_documents, gather_survivor_rescore,
15 };
16}
17mod retention;
18#[cfg(any(test, feature = "test-support"))]
19mod retention_fault;
20mod revise;
21mod seal;
22pub mod wal_payload;
23
24use std::path::PathBuf;
25use std::sync::Arc;
26
27use crate::lifecycle::{PublishedSnapshot, Store, StoreError, StoreState};
28use crate::meta::{ColumnId, ColumnInput, ColumnStoreBuilder, ColumnValue, PredicateValue, Schema};
29use crate::scan::ScanStats;
30use crate::segment::SegmentId;
31use crate::wal::{LogSeq, WalWriteError};
32
33pub use purge::{PurgeError, PurgeReport, PurgeToken};
34pub use retention::{DropPartitionReport, RetentionPolicy, RetentionPolicyError};
35#[cfg(any(test, feature = "test-support"))]
36pub use retention_fault::{
37 IngestRetentionCheckpoint, IngestRetentionFaultController, IngestRetentionFaultEffect,
38 IngestRetentionFaultKind, IngestRetentionFaultReceiptV1, IngestRetentionIoKind,
39 IngestRetentionOperation, IngestRetentionPurgeCrashCheckpoint, IngestRetentionTestFault,
40 PartialBatchAppendVfs, PurgeUnlinkErrorVfs,
41};
42
43pub(crate) use active::{ActiveSegment, ActiveState, SealedTombstoneDemand, StoreWal};
44
45#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47#[repr(transparent)]
48pub struct DocId(u128);
49
50impl DocId {
51 #[must_use]
53 pub const fn new(value: u128) -> Self {
54 Self(value)
55 }
56
57 #[must_use]
59 pub const fn get(self) -> u128 {
60 self.0
61 }
62}
63
64#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
66#[repr(transparent)]
67pub struct Revision(u64);
68
69impl Revision {
70 #[must_use]
72 pub const fn new(value: u64) -> Self {
73 Self(value)
74 }
75
76 #[must_use]
78 pub const fn get(self) -> u64 {
79 self.0
80 }
81}
82
83#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
85pub struct DocumentVersion {
86 doc_id: DocId,
87 revision: Revision,
88}
89
90impl DocumentVersion {
91 #[must_use]
93 pub const fn new(doc_id: DocId, revision: Revision) -> Self {
94 Self { doc_id, revision }
95 }
96
97 #[must_use]
99 pub const fn doc_id(self) -> DocId {
100 self.doc_id
101 }
102
103 #[must_use]
105 pub const fn revision(self) -> Revision {
106 self.revision
107 }
108}
109
110#[derive(Clone, Debug, PartialEq)]
112pub struct IngestDocument {
113 version: DocumentVersion,
114 vector: Vec<f32>,
115 timestamp: i64,
116 timestamp_present: bool,
117 metadata: Vec<u8>,
118 metadata_present: bool,
119 text: Option<String>,
120 columns: Vec<(ColumnId, PredicateValue)>,
121 columns_present: bool,
122}
123
124impl IngestDocument {
125 #[must_use]
127 pub fn new(version: DocumentVersion, vector: Vec<f32>) -> Self {
128 Self {
129 version,
130 vector,
131 timestamp: 0,
132 timestamp_present: false,
133 metadata: Vec::new(),
134 metadata_present: false,
135 text: None,
136 columns: Vec::new(),
137 columns_present: false,
138 }
139 }
140
141 #[must_use]
143 pub const fn with_timestamp(mut self, timestamp: i64) -> Self {
144 self.timestamp = timestamp;
145 self.timestamp_present = true;
146 self
147 }
148
149 #[must_use]
151 pub fn with_metadata(mut self, metadata: Vec<u8>) -> Self {
152 self.metadata = metadata;
153 self.metadata_present = true;
154 self
155 }
156
157 #[must_use]
159 pub fn with_text(mut self, text: impl Into<String>) -> Self {
160 self.text = Some(text.into());
161 self
162 }
163
164 #[must_use]
166 pub fn with_columns(mut self, columns: Vec<(ColumnId, PredicateValue)>) -> Self {
167 self.columns = columns;
168 self.columns_present = true;
169 self
170 }
171
172 #[must_use]
174 pub const fn version(&self) -> DocumentVersion {
175 self.version
176 }
177
178 #[must_use]
180 pub fn vector(&self) -> &[f32] {
181 &self.vector
182 }
183
184 #[must_use]
186 pub const fn timestamp(&self) -> i64 {
187 self.timestamp
188 }
189
190 pub(crate) const fn has_timestamp(&self) -> bool {
191 self.timestamp_present
192 }
193
194 #[must_use]
196 pub fn metadata(&self) -> &[u8] {
197 &self.metadata
198 }
199
200 pub(crate) const fn has_metadata(&self) -> bool {
201 self.metadata_present
202 }
203
204 #[must_use]
206 pub fn text(&self) -> Option<&str> {
207 self.text.as_deref()
208 }
209
210 #[must_use]
212 pub fn columns(&self) -> &[(ColumnId, PredicateValue)] {
213 &self.columns
214 }
215
216 pub(crate) const fn has_columns(&self) -> bool {
217 self.columns_present
218 }
219
220 pub(crate) fn column_inputs(&self) -> Vec<ColumnInput<'_>> {
221 column_inputs(&self.columns)
222 }
223}
224
225pub(crate) fn column_inputs(columns: &[(ColumnId, PredicateValue)]) -> Vec<ColumnInput<'_>> {
226 columns
227 .iter()
228 .map(|(column, value)| ColumnInput {
229 column: *column,
230 value: match value {
231 PredicateValue::U64(value) => ColumnValue::U64(*value),
232 PredicateValue::I64(value) => ColumnValue::I64(*value),
233 PredicateValue::F64(value) => ColumnValue::F64(*value),
234 PredicateValue::Bool(value) => ColumnValue::Bool(*value),
235 PredicateValue::String(value) => ColumnValue::String(value),
236 },
237 })
238 .collect()
239}
240
241#[derive(Clone, Debug, Default, PartialEq)]
243pub struct IngestBatch {
244 documents: Vec<IngestDocument>,
245 epoch: Option<crate::epoch::EpochIdentity>,
246}
247
248#[derive(Clone, Debug, Default, Eq, PartialEq)]
250pub struct DeleteBatch {
251 doc_ids: Vec<DocId>,
252}
253
254impl DeleteBatch {
255 #[must_use]
257 pub const fn new(doc_ids: Vec<DocId>) -> Self {
258 Self { doc_ids }
259 }
260
261 #[must_use]
263 pub fn doc_ids(&self) -> &[DocId] {
264 &self.doc_ids
265 }
266}
267
268impl IngestBatch {
269 #[must_use]
271 pub const fn new(documents: Vec<IngestDocument>) -> Self {
272 Self {
273 documents,
274 epoch: None,
275 }
276 }
277
278 #[must_use]
280 pub const fn with_epoch(mut self, epoch: crate::epoch::EpochIdentity) -> Self {
281 self.epoch = Some(epoch);
282 self
283 }
284
285 #[must_use]
287 pub fn documents(&self) -> &[IngestDocument] {
288 &self.documents
289 }
290}
291
292#[derive(Clone, Copy, Debug, Eq, PartialEq)]
294pub struct IngestAck {
295 seq: LogSeq,
296 generation: u64,
297}
298
299impl IngestAck {
300 #[must_use]
302 pub const fn seq(self) -> LogSeq {
303 self.seq
304 }
305
306 #[must_use]
308 pub const fn generation(self) -> u64 {
309 self.generation
310 }
311}
312
313#[derive(Debug)]
315pub enum IngestError {
316 Store(StoreError),
318 EmptyBatch,
320 EpochMismatch(crate::epoch::EpochMismatch),
322 EpochUndeclared,
324 EpochUnstamped,
326 StaleRevision {
328 doc_id: DocId,
330 current: Revision,
332 attempted: Revision,
334 },
335 Vector(crate::quant::QuantError),
337 Lexical(crate::fts::index::IndexError),
339 Tokenizer(crate::fts::tokenizer::TokenizerError),
341 Columns(crate::meta::BuildError),
343 Payload(wal_payload::PayloadError),
345}
346
347impl std::fmt::Display for IngestError {
348 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349 match self {
350 Self::Store(error) => error.fmt(formatter),
351 Self::EmptyBatch => formatter.write_str("ingest batch is empty"),
352 Self::EpochMismatch(error) => error.fmt(formatter),
353 Self::EpochUndeclared => formatter.write_str("ingest epoch is required by this store"),
354 Self::EpochUnstamped => {
355 formatter.write_str("an unstamped store cannot accept an epoch declaration")
356 }
357 Self::StaleRevision {
358 doc_id,
359 current,
360 attempted,
361 } => write!(
362 formatter,
363 "document {} revision {} is stale; current revision is {}",
364 doc_id.get(),
365 attempted.get(),
366 current.get()
367 ),
368 Self::Vector(error) => write!(formatter, "ingest vector: {error}"),
369 Self::Lexical(error) => write!(formatter, "ingest text: {error}"),
370 Self::Tokenizer(error) => write!(formatter, "ingest tokenizer: {error}"),
371 Self::Columns(error) => write!(formatter, "ingest columns: {error}"),
372 Self::Payload(error) => error.fmt(formatter),
373 }
374 }
375}
376
377impl std::error::Error for IngestError {
378 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
379 match self {
380 Self::Store(error) => Some(error),
381 Self::EpochMismatch(error) => Some(error),
382 Self::Vector(error) => Some(error),
383 Self::Lexical(error) => Some(error),
384 Self::Tokenizer(error) => Some(error),
385 Self::Columns(error) => Some(error),
386 Self::Payload(error) => Some(error),
387 Self::EmptyBatch
388 | Self::EpochUndeclared
389 | Self::EpochUnstamped
390 | Self::StaleRevision { .. } => None,
391 }
392 }
393}
394
395impl From<StoreError> for IngestError {
396 fn from(error: StoreError) -> Self {
397 Self::Store(error)
398 }
399}
400
401#[derive(Clone, Copy, Debug)]
403pub struct SearchRequest<'a> {
404 vector: &'a [f32],
405}
406
407impl<'a> SearchRequest<'a> {
408 #[must_use]
410 pub const fn new(vector: &'a [f32]) -> Self {
411 Self { vector }
412 }
413
414 #[must_use]
416 pub const fn vector(self) -> &'a [f32] {
417 self.vector
418 }
419}
420
421#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
423pub enum RowSource {
424 Active,
426 Sealed(SegmentId),
428}
429
430#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
432pub struct GlobalRowId {
433 source: RowSource,
434 local_row: u32,
435}
436
437impl GlobalRowId {
438 pub(crate) const fn new(source: RowSource, local_row: u32) -> Self {
439 Self { source, local_row }
440 }
441
442 #[must_use]
444 pub const fn source(self) -> RowSource {
445 self.source
446 }
447
448 #[must_use]
450 pub const fn local_row(self) -> u32 {
451 self.local_row
452 }
453}
454
455#[derive(Clone, Copy, Debug, PartialEq)]
457pub struct SearchCandidate {
458 row_id: GlobalRowId,
459 document: Option<DocumentVersion>,
460 score: f32,
461 exact_score: bool,
462}
463
464impl SearchCandidate {
465 pub(crate) const fn new(
466 row_id: GlobalRowId,
467 document: Option<DocumentVersion>,
468 score: f32,
469 exact_score: bool,
470 ) -> Self {
471 Self {
472 row_id,
473 document,
474 score,
475 exact_score,
476 }
477 }
478
479 #[must_use]
481 pub const fn row_id(self) -> GlobalRowId {
482 self.row_id
483 }
484
485 #[must_use]
488 pub const fn document(self) -> Option<DocumentVersion> {
489 self.document
490 }
491
492 #[must_use]
494 pub const fn score(self) -> f32 {
495 self.score
496 }
497
498 pub(crate) const fn exact_score(self) -> bool {
499 self.exact_score
500 }
501}
502
503pub(crate) fn compare_search_candidates(
509 left: &SearchCandidate,
510 right: &SearchCandidate,
511) -> std::cmp::Ordering {
512 right.score().total_cmp(&left.score()).then_with(|| {
513 match (left.document(), right.document()) {
514 (Some(left_document), Some(right_document)) => left_document
515 .doc_id()
516 .cmp(&right_document.doc_id())
517 .then_with(|| right_document.revision().cmp(&left_document.revision())),
518 (Some(_), None) => std::cmp::Ordering::Less,
519 (None, Some(_)) => std::cmp::Ordering::Greater,
520 (None, None) => std::cmp::Ordering::Equal,
521 }
522 .then_with(|| left.row_id().cmp(&right.row_id()))
523 })
524}
525
526#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
528pub struct GraphSearchStats {
529 pub segments_traversed: usize,
531 pub graph_validations: usize,
533 pub entry_seed_discoveries: usize,
535 pub visited_epoch_clears: usize,
537 pub candidates_scored: usize,
539 pub candidates_rescored: usize,
541 pub segments_pruned_by_bound: usize,
543}
544
545#[derive(Clone, Debug, PartialEq)]
547pub struct SearchOutcome {
548 pub candidates: Vec<SearchCandidate>,
550 pub vector_ceiling: Option<f64>,
554 pub stats: ScanStats,
556 pub graph_stats: GraphSearchStats,
558 pub generation: u64,
560 pub epoch: Option<crate::epoch::EpochIdentity>,
562 pub diagnostics: crate::diag::QueryDiagnostics,
564}
565
566#[derive(Clone, Copy, Debug, PartialEq)]
568pub struct LexicalCandidate {
569 pub document: DocumentVersion,
571 pub score: f64,
573}
574
575#[derive(Clone, Debug, PartialEq)]
577pub struct StoreLexicalSearchOutcome {
578 pub candidates: Vec<LexicalCandidate>,
580 pub generation: u64,
582 pub diagnostics: crate::diag::QueryDiagnostics,
584}
585
586#[derive(Clone, Debug, PartialEq)]
588pub struct ExplainedLexicalCandidate {
589 pub document: DocumentVersion,
591 pub score: f64,
593 pub provenance: Vec<crate::fts::query::LexicalExpansion>,
595 pub snippet: crate::fts::query::OwnedLexicalSnippet,
597}
598
599#[derive(Clone, Debug, PartialEq)]
601pub struct StoreStructuredLexicalSearchOutcome {
602 pub candidates: Vec<ExplainedLexicalCandidate>,
604 pub expansions: Vec<crate::fts::query::LexicalExpansion>,
606 pub generation: u64,
608 pub diagnostics: crate::diag::QueryDiagnostics,
610}
611
612#[derive(Clone, Debug, PartialEq)]
614pub struct StoreHybridSearchOutcome {
615 pub hits: Vec<crate::fusion::FusedHit<DocId>>,
617 pub lexical_expansions: Vec<crate::fts::query::LexicalExpansion>,
619 pub generation: u64,
621 pub diagnostics: crate::diag::QueryDiagnostics,
623}
624
625#[derive(Debug)]
627pub enum StoreLexicalError {
628 Query(crate::lifecycle::QueryError),
630 Lexical(crate::planner::LexicalFilterError),
632 MissingDocumentIdentity {
634 segment_id: SegmentId,
636 },
637 MissingStoredText {
639 segment_id: SegmentId,
641 row: u32,
643 },
644 MissingSnippetMatch {
646 segment: u32,
648 row: u32,
650 },
651 Structured(crate::fts::query::LexicalQueryError),
653 Snippet(crate::fts::snippet::SnippetError),
655 Tokenizer(crate::fts::tokenizer::TokenizerError),
657}
658
659impl std::fmt::Display for StoreLexicalError {
660 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
661 match self {
662 Self::Query(error) => error.fmt(formatter),
663 Self::Lexical(error) => error.fmt(formatter),
664 Self::MissingDocumentIdentity { segment_id } => write!(
665 formatter,
666 "sealed lexical segment {segment_id} has no document identity"
667 ),
668 Self::MissingStoredText { segment_id, row } => write!(
669 formatter,
670 "sealed lexical segment {segment_id} row {row} has no stored source text"
671 ),
672 Self::MissingSnippetMatch { segment, row } => write!(
673 formatter,
674 "lexical source {segment} row {row} has no snippet match span"
675 ),
676 Self::Structured(error) => error.fmt(formatter),
677 Self::Snippet(error) => error.fmt(formatter),
678 Self::Tokenizer(error) => error.fmt(formatter),
679 }
680 }
681}
682
683impl std::error::Error for StoreLexicalError {
684 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
685 match self {
686 Self::Query(error) => Some(error),
687 Self::Lexical(error) => Some(error),
688 Self::Structured(error) => Some(error),
689 Self::Snippet(error) => Some(error),
690 Self::Tokenizer(error) => Some(error),
691 Self::MissingDocumentIdentity { .. }
692 | Self::MissingStoredText { .. }
693 | Self::MissingSnippetMatch { .. } => None,
694 }
695 }
696}
697
698impl From<crate::lifecycle::QueryError> for StoreLexicalError {
699 fn from(error: crate::lifecycle::QueryError) -> Self {
700 Self::Query(error)
701 }
702}
703
704impl From<StoreError> for StoreLexicalError {
705 fn from(error: StoreError) -> Self {
706 Self::Query(crate::lifecycle::QueryError::Store(error))
707 }
708}
709
710impl From<crate::planner::LexicalFilterError> for StoreLexicalError {
711 fn from(error: crate::planner::LexicalFilterError) -> Self {
712 Self::Lexical(error)
713 }
714}
715
716impl From<crate::fts::query::LexicalQueryError> for StoreLexicalError {
717 fn from(error: crate::fts::query::LexicalQueryError) -> Self {
718 Self::Structured(error)
719 }
720}
721
722impl From<crate::fts::snippet::SnippetError> for StoreLexicalError {
723 fn from(error: crate::fts::snippet::SnippetError) -> Self {
724 Self::Snippet(error)
725 }
726}
727
728impl From<crate::fts::tokenizer::TokenizerError> for StoreLexicalError {
729 fn from(error: crate::fts::tokenizer::TokenizerError) -> Self {
730 Self::Tokenizer(error)
731 }
732}
733
734#[derive(Clone, Copy)]
735struct ResolvedRevision {
736 version: DocumentVersion,
737 seq: LogSeq,
738 active_row: Option<usize>,
739}
740
741struct RevisionProgress {
742 working: Option<ActiveSegment>,
743 records: Vec<(usize, u16, Vec<u8>)>,
744 replay_seq: Option<LogSeq>,
745 #[cfg(any(test, feature = "test-support"))]
746 replay_count: usize,
747 sealed_tombstones: Vec<DocId>,
748}
749
750fn resolve_revision(
751 active: &ActiveSegment,
752 sealed: &[purge::SealedDocumentMatch],
753 doc_id: DocId,
754 sealed_seq: LogSeq,
755) -> Option<ResolvedRevision> {
756 let active_existing = active.existing(doc_id);
757 let active_row = active_existing.map(|(row, _, _)| row);
758 let mut resolved = active_existing.map(|(_, version, seq)| ResolvedRevision {
759 version,
760 seq,
761 active_row,
762 });
763 for matched in sealed
764 .iter()
765 .filter(|matched| matched.version.doc_id() == doc_id)
766 {
767 if resolved.is_none_or(|current| matched.version.revision() > current.version.revision()) {
768 resolved = Some(ResolvedRevision {
769 version: matched.version,
770 seq: sealed_seq,
771 active_row,
772 });
773 }
774 }
775 resolved
776}
777
778fn validate_republish_generation(
779 expected_generation: u64,
780 actual_generation: u64,
781) -> Result<(), StoreError> {
782 if actual_generation != expected_generation {
783 return Err(StoreError::ConcurrentActiveMutation {
784 expected_generation,
785 actual_generation,
786 });
787 }
788 Ok(())
789}
790
791impl Store {
792 pub fn lexical_corpus_stats(&self) -> Result<crate::fts::bm25::CorpusStats, StoreLexicalError> {
799 let state = self
800 .state
801 .lock()
802 .map_err(|_| StoreError::Synchronization { component: "state" })?;
803 match *state {
804 StoreState::Open => {}
805 StoreState::Closing => return Err(StoreError::Closing.into()),
806 StoreState::Closed => return Err(StoreError::Closed.into()),
807 }
808 let active_guard = self
809 .active
810 .lock()
811 .map_err(|_| StoreError::Synchronization {
812 component: "active segment",
813 })?;
814 let active = active_guard.as_ref().ok_or(StoreError::Closed)?;
815 let snapshot = self
816 .snapshot
817 .read()
818 .map_err(|_| StoreError::Synchronization {
819 component: "published snapshot",
820 })?
821 .as_ref()
822 .cloned()
823 .ok_or(StoreError::Closed)?;
824 let mut index = crate::fts::index::LexicalIndex::new();
825 for segment in snapshot.segments() {
826 if let Some(postings) = segment.query_postings()? {
827 let alive = segment.query_alive()?;
828 index
829 .push_shared_with_live_rows(postings, alive.alive_bitmap())
830 .map_err(crate::planner::LexicalFilterError::from)?;
831 }
832 }
833 if active.segment.has_text() {
834 let sealed = active.segment.sealed_lexical(&self.accounting)?;
835 let alive = active.segment.alive()?;
836 index
837 .push_shared_with_live_rows(sealed, alive.alive_bitmap())
838 .map_err(crate::planner::LexicalFilterError::from)?;
839 }
840 index
841 .corpus_stats()
842 .map_err(crate::planner::LexicalFilterError::from)
843 .map_err(StoreLexicalError::from)
844 }
845
846 #[inline(always)]
847 fn apply_revision_decision(
848 &self,
849 current: &ActiveSegment,
850 sealed: &[purge::SealedDocumentMatch],
851 sealed_seq: LogSeq,
852 document: &IngestDocument,
853 remaining: &[IngestDocument],
854 progress: &mut RevisionProgress,
855 ) -> Result<(), IngestError> {
856 let segment = progress.working.as_ref().unwrap_or(current);
857 let existing = resolve_revision(segment, sealed, document.version().doc_id(), sealed_seq);
858 let action = revise::classify_revision(
859 existing.map(|resolved| (resolved.version, resolved.seq)),
860 document.version(),
861 );
862 match action {
863 revise::RevisionDecision::Replace => {
864 let row = existing.and_then(|resolved| resolved.active_row);
865 if progress.working.is_none() {
866 progress.working =
867 Some(current.copy_for_batch(document, remaining, &self.accounting)?);
868 }
869 let working = progress
870 .working
871 .as_mut()
872 .ok_or(StoreError::Synchronization {
873 component: "batch working segment",
874 })?;
875 let row = if let Some(row) = row {
876 working.replace_in_place(row, document, &self.accounting, &self.tokenizer)?;
877 row
878 } else {
879 working.insert_in_place(document, &self.accounting, &self.tokenizer)?
880 };
881 let (op, payload) = encode_persisted_upsert(document)?;
882 progress.records.push((row, op, payload));
883 if sealed
884 .iter()
885 .any(|matched| matched.version.doc_id() == document.version().doc_id())
886 && !progress
887 .sealed_tombstones
888 .contains(&document.version().doc_id())
889 {
890 progress.sealed_tombstones.push(document.version().doc_id());
891 }
892 }
893 revise::RevisionDecision::Insert => {
894 if progress.working.is_none() {
895 progress.working =
896 Some(current.copy_for_batch(document, remaining, &self.accounting)?);
897 }
898 let working = progress
899 .working
900 .as_mut()
901 .ok_or(StoreError::Synchronization {
902 component: "batch working segment",
903 })?;
904 let row = working.insert_in_place(document, &self.accounting, &self.tokenizer)?;
905 let (op, payload) = encode_persisted_upsert(document)?;
906 progress.records.push((row, op, payload));
907 }
908 revise::RevisionDecision::Replay { seq } => {
909 progress.replay_seq = Some(
910 progress
911 .replay_seq
912 .map_or(seq, |current: LogSeq| current.max(seq)),
913 );
914 #[cfg(any(test, feature = "test-support"))]
915 {
916 progress.replay_count = progress.replay_count.saturating_add(1);
917 }
918 }
919 revise::RevisionDecision::Reject { current } => {
920 return Err(IngestError::StaleRevision {
921 doc_id: document.version().doc_id(),
922 current,
923 attempted: document.version().revision(),
924 });
925 }
926 }
927 Ok(())
928 }
929
930 #[cfg(any(test, feature = "test-support"))]
931 fn record_post_ack_retry_receipt(
932 &self,
933 batch: &IngestBatch,
934 replay_count: usize,
935 seq: LogSeq,
936 generation: u64,
937 ) -> Result<(), IngestError> {
938 if let Some(controller) = self.ingest_retention_fault_controller.as_ref() {
939 let plan =
940 controller
941 .post_ack_retry_plan()
942 .map_err(|_| StoreError::Synchronization {
943 component: "ingest-retention controller",
944 })?;
945 if let Some((invocation_id, first_ack_seq, first_ack_generation)) = plan
946 && first_ack_seq == seq.get()
947 && first_ack_generation == generation
948 {
949 let batch_count = u64::try_from(batch.documents.len())
950 .map_err(|_| StoreError::ActiveRowOverflow)?;
951 let replay_count =
952 u64::try_from(replay_count).map_err(|_| StoreError::ActiveRowOverflow)?;
953 controller
954 .push_receipt(IngestRetentionFaultReceiptV1::post_ack_retry(
955 invocation_id,
956 batch_count,
957 replay_count,
958 seq.get(),
959 generation,
960 ))
961 .map_err(|_| StoreError::Synchronization {
962 component: "ingest-retention controller",
963 })?;
964 }
965 }
966 Ok(())
967 }
968
969 #[cfg(any(test, feature = "test-support"))]
970 fn recover_partial_batch_append(
971 &self,
972 writer: &mut StoreWal,
973 error: &StoreError,
974 partial_append_clean_wal: &Option<Vec<u8>>,
975 batch: &IngestBatch,
976 records: &[(usize, u16, Vec<u8>)],
977 ) -> Result<(), IngestError> {
978 if let Some(controller) = self.ingest_retention_fault_controller.as_ref()
979 && let Some(observation) =
980 controller
981 .take_partial_batch_append_observation()
982 .map_err(|_| StoreError::Synchronization {
983 component: "ingest-retention controller",
984 })?
985 {
986 let expected_detail = observation.detail.as_str();
987 match error {
988 StoreError::WalWrite(WalWriteError::Failed { kind, detail })
989 if *kind == std::io::ErrorKind::Other && detail.as_ref() == expected_detail => {
990 }
991 _ => {
992 return Err(StoreError::Synchronization {
993 component: "partial-batch-append typed WAL error",
994 }
995 .into());
996 }
997 }
998 let clean_wal =
999 partial_append_clean_wal
1000 .as_deref()
1001 .ok_or(StoreError::Synchronization {
1002 component: "partial-batch-append clean WAL image",
1003 })?;
1004 let wal_path = self.directory.join("wal.ze");
1005 writer.restore_after_failed_append(
1006 self.vfs.as_ref(),
1007 &wal_path,
1008 self.durability_policy,
1009 clean_wal,
1010 )?;
1011 let submitted_count =
1012 u64::try_from(batch.documents.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1013 let changed_records =
1014 u64::try_from(records.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1015 let encoded_bytes = u64::try_from(observation.encoded_bytes)
1016 .map_err(|_| StoreError::ActiveRowOverflow)?;
1017 let prefix_bytes = u64::try_from(observation.prefix_bytes)
1018 .map_err(|_| StoreError::ActiveRowOverflow)?;
1019 controller
1020 .push_receipt(IngestRetentionFaultReceiptV1::partial_batch_append(
1021 observation.invocation_id,
1022 submitted_count,
1023 changed_records,
1024 encoded_bytes,
1025 prefix_bytes,
1026 observation.detail,
1027 ))
1028 .map_err(|_| StoreError::Synchronization {
1029 component: "ingest-retention controller",
1030 })?;
1031 }
1032 Ok(())
1033 }
1034
1035 #[inline(always)]
1036 fn publish_committed_active(
1037 &self,
1038 active: &mut Option<ActiveState>,
1039 committed: Option<(PublishedSnapshot, Vec<PathBuf>)>,
1040 generation: u64,
1041 next: ActiveSegment,
1042 ) -> Result<Vec<PathBuf>, StoreError> {
1043 if let Some((remapped, replaced_paths)) = committed {
1044 let mut published = self
1045 .snapshot
1046 .write()
1047 .map_err(|_| StoreError::Synchronization {
1048 component: "published snapshot",
1049 })?;
1050 let previous = published.replace(Arc::new(remapped));
1051 *active = Some(ActiveState {
1052 generation,
1053 segment: Arc::new(next),
1054 });
1055 drop(published);
1056 drop(previous);
1057 Ok(replaced_paths)
1058 } else {
1059 *active = Some(ActiveState {
1060 generation,
1061 segment: Arc::new(next),
1062 });
1063 Ok(Vec::new())
1064 }
1065 }
1066
1067 pub fn ingest(&self, batch: IngestBatch) -> Result<IngestAck, IngestError> {
1069 let state = self
1070 .state
1071 .lock()
1072 .map_err(|_| StoreError::Synchronization { component: "state" })?;
1073 match *state {
1074 StoreState::Open => {}
1075 StoreState::Closing => return Err(StoreError::Closing.into()),
1076 StoreState::Closed => return Err(StoreError::Closed.into()),
1077 }
1078 drop(state);
1079 match (self.epoch_identity(), batch.epoch) {
1080 (Some(expected), Some(declared)) if expected != declared => {
1081 return Err(IngestError::EpochMismatch(crate::epoch::EpochMismatch {
1082 expected,
1083 declared,
1084 }));
1085 }
1086 (Some(_), None) => return Err(IngestError::EpochUndeclared),
1087 (None, Some(_)) => return Err(IngestError::EpochUnstamped),
1088 (Some(_), Some(_)) | (None, None) => {}
1089 }
1090 if batch.documents.is_empty() {
1091 return Err(IngestError::EmptyBatch);
1092 }
1093 if self.epoch.as_ref().is_some_and(|epoch| {
1094 epoch.embedding.document.normalization == crate::epoch::Normalization::L2
1095 }) {
1096 for document in &batch.documents {
1097 if let Some(squared_norm) =
1098 crate::graph::search::non_unit_squared_norm(document.vector())
1099 {
1100 return Err(IngestError::Vector(crate::quant::QuantError::NonUnitNorm {
1101 squared_norm_bits: squared_norm.to_bits(),
1102 tolerance_bits: crate::graph::search::UNIT_NORM_SQUARED_TOLERANCE.to_bits(),
1103 }));
1104 }
1105 }
1106 }
1107 for document in &batch.documents {
1108 validate_document_columns(&self.schema, document)?;
1109 }
1110 let mut wal = self
1111 .wal_writer
1112 .lock()
1113 .map_err(|_| StoreError::Synchronization {
1114 component: "WAL writer",
1115 })?;
1116 let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
1117 let (current_generation, current_segment) = {
1118 let active = self
1119 .active
1120 .lock()
1121 .map_err(|_| StoreError::Synchronization {
1122 component: "active segment",
1123 })?;
1124 let current = active.as_ref().ok_or(StoreError::Closed)?;
1125 (current.generation, Arc::clone(¤t.segment))
1126 };
1127 let snapshot = self
1128 .snapshot
1129 .read()
1130 .map_err(|_| StoreError::Synchronization {
1131 component: "published snapshot",
1132 })?
1133 .as_ref()
1134 .cloned()
1135 .ok_or(StoreError::Closed)?;
1136 let requested_ids = batch
1137 .documents
1138 .iter()
1139 .map(|document| document.version().doc_id())
1140 .collect::<Vec<_>>();
1141 let sealed = purge::sealed_document_matches(&snapshot, &requested_ids)?;
1142 let sealed_seq = LogSeq::new(snapshot.absorbed_through());
1143 let mut progress = RevisionProgress {
1144 working: None,
1145 records: Vec::new(),
1146 replay_seq: None,
1147 #[cfg(any(test, feature = "test-support"))]
1148 replay_count: 0,
1149 sealed_tombstones: Vec::new(),
1150 };
1151 for (index, document) in batch.documents.iter().enumerate() {
1152 let remaining = batch
1153 .documents
1154 .get(index..)
1155 .ok_or(StoreError::ActiveRowOverflow)?;
1156 self.apply_revision_decision(
1157 current_segment.as_ref(),
1158 &sealed,
1159 sealed_seq,
1160 document,
1161 remaining,
1162 &mut progress,
1163 )?;
1164 }
1165 let RevisionProgress {
1166 working,
1167 records,
1168 replay_seq,
1169 #[cfg(any(test, feature = "test-support"))]
1170 replay_count,
1171 sealed_tombstones,
1172 } = progress;
1173 let Some(mut next) = working else {
1174 let seq = replay_seq.ok_or(StoreError::Synchronization {
1175 component: "nonempty ingest replay sequence",
1176 })?;
1177 #[cfg(any(test, feature = "test-support"))]
1178 self.record_post_ack_retry_receipt(&batch, replay_count, seq, current_generation)?;
1179 return Ok(IngestAck {
1180 seq,
1181 generation: current_generation,
1182 });
1183 };
1184 let generation = current_generation
1185 .checked_add(1)
1186 .ok_or(StoreError::GenerationOverflow)?;
1187 let prepared = purge::prepare_sealed_tombstones(
1188 self.vfs.as_ref(),
1189 &self.directory,
1190 &snapshot,
1191 &sealed,
1192 &sealed_tombstones,
1193 writer.durable_end(),
1194 generation,
1195 writer.durable_end().saturating_add(1),
1196 self.durability_policy,
1197 &self.accounting,
1198 )?;
1199 let record_refs = records
1200 .iter()
1201 .map(|(_, op, payload)| (*op, payload.as_slice()))
1202 .collect::<Vec<_>>();
1203 #[cfg(any(test, feature = "test-support"))]
1204 let partial_append_clean_wal = match self.ingest_retention_fault_controller.as_ref() {
1205 Some(controller)
1206 if controller
1207 .partial_batch_append_plan()
1208 .map_err(|_| StoreError::Synchronization {
1209 component: "ingest-retention controller",
1210 })?
1211 .is_some() =>
1212 {
1213 let path = self.directory.join("wal.ze");
1214 Some(
1215 self.vfs
1216 .read(&path)
1217 .map_err(|source| StoreError::Io { path, source })?,
1218 )
1219 }
1220 Some(_) | None => None,
1221 };
1222 let sequences = match writer.commit_many(&record_refs) {
1223 Ok(sequences) => sequences,
1224 Err(error) => {
1225 if let Some(prepared) = prepared {
1226 prepared.abort(self.vfs.as_ref(), &self.directory, self.durability_policy)?;
1227 }
1228 #[cfg(any(test, feature = "test-support"))]
1229 self.recover_partial_batch_append(
1230 writer,
1231 &error,
1232 &partial_append_clean_wal,
1233 &batch,
1234 &records,
1235 )?;
1236 return Err(error.into());
1237 }
1238 };
1239 for ((row, _, _), sequence) in records
1240 .iter()
1241 .zip(sequences.start.get()..sequences.end.get())
1242 {
1243 next.set_sequence(*row, LogSeq::new(sequence))?;
1244 }
1245 let seq = LogSeq::new(sequences.end.get().saturating_sub(1));
1246 let committed = prepared
1247 .map(|prepared| {
1248 prepared.commit(
1249 self,
1250 self.vfs.as_ref(),
1251 &self.directory,
1252 self.durability_policy,
1253 )
1254 })
1255 .transpose()?;
1256 let mut active = self
1257 .active
1258 .lock()
1259 .map_err(|_| StoreError::Synchronization {
1260 component: "active segment",
1261 })?;
1262 let actual_generation = active.as_ref().ok_or(StoreError::Closed)?.generation;
1263 validate_republish_generation(current_generation, actual_generation)?;
1264 let replaced_paths =
1265 self.publish_committed_active(&mut active, committed, generation, next)?;
1266 drop(active);
1267 purge::unlink_replaced_segments(
1268 self.vfs.as_ref(),
1269 &self.directory,
1270 &replaced_paths,
1271 self.durability_policy,
1272 );
1273 drop(wal);
1274 Ok(IngestAck { seq, generation })
1275 }
1276
1277 pub fn delete(&self, batch: DeleteBatch) -> Result<IngestAck, IngestError> {
1279 if batch.doc_ids.is_empty() {
1280 return Err(IngestError::EmptyBatch);
1281 }
1282 let state = self
1283 .state
1284 .lock()
1285 .map_err(|_| StoreError::Synchronization { component: "state" })?;
1286 match *state {
1287 StoreState::Open => {}
1288 StoreState::Closing => return Err(StoreError::Closing.into()),
1289 StoreState::Closed => return Err(StoreError::Closed.into()),
1290 }
1291 drop(state);
1292 let mut wal = self
1293 .wal_writer
1294 .lock()
1295 .map_err(|_| StoreError::Synchronization {
1296 component: "WAL writer",
1297 })?;
1298 let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
1299 let (current_generation, current_segment) = {
1300 let active = self
1301 .active
1302 .lock()
1303 .map_err(|_| StoreError::Synchronization {
1304 component: "active segment",
1305 })?;
1306 let current = active.as_ref().ok_or(StoreError::Closed)?;
1307 (current.generation, Arc::clone(¤t.segment))
1308 };
1309 let snapshot = self
1310 .snapshot
1311 .read()
1312 .map_err(|_| StoreError::Synchronization {
1313 component: "published snapshot",
1314 })?
1315 .as_ref()
1316 .cloned()
1317 .ok_or(StoreError::Closed)?;
1318 let sealed = purge::sealed_document_matches(&snapshot, &batch.doc_ids)?;
1319 let generation = current_generation
1320 .checked_add(1)
1321 .ok_or(StoreError::GenerationOverflow)?;
1322 let (mut next, rows) = current_segment.tombstone(&batch.doc_ids, &self.accounting)?;
1323 let prepared = purge::prepare_sealed_tombstones(
1324 self.vfs.as_ref(),
1325 &self.directory,
1326 &snapshot,
1327 &sealed,
1328 &batch.doc_ids,
1329 writer.durable_end(),
1330 generation,
1331 writer.durable_end().saturating_add(1),
1332 self.durability_policy,
1333 &self.accounting,
1334 )?;
1335 let payload = wal_payload::encode_delete(&batch.doc_ids).map_err(IngestError::Payload)?;
1336 let seq = match writer.commit(wal_payload::DELETE_V1, &payload) {
1337 Ok(seq) => seq,
1338 Err(error) => {
1339 if let Some(prepared) = prepared {
1340 prepared.abort(self.vfs.as_ref(), &self.directory, self.durability_policy)?;
1341 }
1342 return Err(error.into());
1343 }
1344 };
1345 for row in rows {
1346 next.set_sequence(row, seq)?;
1347 }
1348 let committed = prepared
1349 .map(|prepared| {
1350 prepared.commit(
1351 self,
1352 self.vfs.as_ref(),
1353 &self.directory,
1354 self.durability_policy,
1355 )
1356 })
1357 .transpose()?;
1358 let mut active = self
1359 .active
1360 .lock()
1361 .map_err(|_| StoreError::Synchronization {
1362 component: "active segment",
1363 })?;
1364 let actual_generation = active.as_ref().ok_or(StoreError::Closed)?.generation;
1365 validate_republish_generation(current_generation, actual_generation)?;
1366 let replaced_paths =
1367 self.publish_committed_active(&mut active, committed, generation, next)?;
1368 drop(active);
1369 purge::unlink_replaced_segments(
1370 self.vfs.as_ref(),
1371 &self.directory,
1372 &replaced_paths,
1373 self.durability_policy,
1374 );
1375 Ok(IngestAck { seq, generation })
1376 }
1377}
1378
1379fn encode_persisted_upsert(document: &IngestDocument) -> Result<(u16, Vec<u8>), IngestError> {
1380 wal_payload::encode_upsert_v2(document)
1381 .map(|payload| (wal_payload::UPSERT_V2, payload))
1382 .map_err(IngestError::Payload)
1383}
1384
1385pub(crate) fn validate_document_columns(
1386 schema: &Schema,
1387 document: &IngestDocument,
1388) -> Result<(), IngestError> {
1389 let mut builder = ColumnStoreBuilder::new(schema.clone());
1390 builder
1391 .push_row(document.timestamp(), &document.column_inputs())
1392 .map(|_| ())
1393 .map_err(IngestError::Columns)
1394}
1395
1396impl From<WalWriteError> for IngestError {
1397 fn from(error: WalWriteError) -> Self {
1398 Self::Store(StoreError::WalWrite(error))
1399 }
1400}
1401
1402#[cfg(test)]
1403mod vector_order_tests {
1404 use super::*;
1405
1406 #[test]
1407 fn documented_candidate_precedes_legacy_candidate_at_an_equal_score() {
1408 let segment = SegmentId::from_bytes([0x44; 16]);
1409 let legacy = SearchCandidate::new(
1410 GlobalRowId::new(RowSource::Sealed(segment), 0),
1411 None,
1412 1.0,
1413 true,
1414 );
1415 let documented = SearchCandidate::new(
1416 GlobalRowId::new(RowSource::Sealed(segment), 9),
1417 Some(DocumentVersion::new(DocId::new(9), Revision::new(1))),
1418 1.0,
1419 true,
1420 );
1421
1422 assert_eq!(
1423 compare_search_candidates(&documented, &legacy),
1424 std::cmp::Ordering::Less,
1425 "documented-vs-legacy order fell back to physical row identity"
1426 );
1427 }
1428}
1429
1430#[cfg(test)]
1431mod active_publish_tests {
1432 use super::*;
1433
1434 #[test]
1435 fn mismatched_active_generation_on_republish_is_a_typed_error() {
1436 assert!(matches!(
1437 validate_republish_generation(4, 5),
1438 Err(StoreError::ConcurrentActiveMutation {
1439 expected_generation: 4,
1440 actual_generation: 5,
1441 })
1442 ));
1443 }
1444}
1445
1446#[cfg(all(test, feature = "allocation-audit"))]
1447#[allow(clippy::expect_used)]
1448mod batch_working_copy_tests {
1449 use tempfile::tempdir;
1450
1451 use crate::lifecycle::OpenOptions;
1452
1453 use super::*;
1454
1455 #[test]
1456 fn batch_ingest_clones_the_active_segment_at_most_once() {
1457 let directory = tempdir().expect("store directory");
1458 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1459 store
1460 .ingest(IngestBatch::new(vec![IngestDocument::new(
1461 DocumentVersion::new(DocId::new(1), Revision::new(1)),
1462 vec![1.0, 0.0],
1463 )]))
1464 .expect("seed active segment");
1465 let documents = (2_u128..=25)
1466 .map(|doc_id| {
1467 IngestDocument::new(
1468 DocumentVersion::new(DocId::new(doc_id), Revision::new(1)),
1469 vec![1.0, 0.0],
1470 )
1471 })
1472 .collect::<Vec<_>>();
1473
1474 let (result, report) = crate::allocation_audit::audit_engine_path(|| {
1475 store.ingest(IngestBatch::new(documents))
1476 });
1477
1478 result.expect("ingest audited batch");
1479 assert_eq!(
1480 report.full_segment_clones, 1,
1481 "one ingest batch made more than one full active-segment clone"
1482 );
1483 }
1484}