1mod active;
4mod atomic_batch;
5mod delete_matching;
6mod lookup;
7mod namespace_stage;
8pub(crate) use namespace_stage::NamespaceStage;
9mod merge;
10mod purge;
11
12pub(crate) use purge::{PURGE_INTENT_FILE, namespace_purge_intent, read_intent};
19
20pub(crate) mod purge_support {
21 pub(crate) use super::purge::{
22 OwnedFactors, PURGE_INTENT_FILE, append_survivor_columns, clustering_range,
23 gather_survivor_codes, gather_survivor_documents, gather_survivor_rescore,
24 };
25}
26mod retention;
27#[cfg(any(test, feature = "test-seams"))]
28mod retention_fault;
29mod revise;
30mod seal;
31pub mod wal_payload;
32
33use std::path::PathBuf;
34use std::sync::Arc;
35
36use crate::lifecycle::{PublishedSnapshot, Store, StoreError, StoreState};
37use crate::meta::{ColumnId, ColumnInput, ColumnStoreBuilder, ColumnValue, PredicateValue, Schema};
38use crate::scan::ScanStats;
39use crate::segment::SegmentId;
40use crate::wal::{LogSeq, WalWriteError};
41
42pub use delete_matching::{DeleteMatchingError, DeleteMatchingReport};
43pub use purge::{PurgeError, PurgeReport, PurgeToken};
44pub use retention::{DropPartitionReport, RetentionPolicy, RetentionPolicyError};
45#[cfg(any(test, feature = "test-seams"))]
46pub use retention_fault::{
47 IngestRetentionCheckpoint, IngestRetentionFaultController, IngestRetentionFaultEffect,
48 IngestRetentionFaultKind, IngestRetentionFaultReceiptV1, IngestRetentionIoKind,
49 IngestRetentionOperation, IngestRetentionPurgeCrashCheckpoint, IngestRetentionTestFault,
50 PartialBatchAppendVfs, PurgeUnlinkErrorVfs,
51};
52
53pub(crate) use active::{ActiveSegment, ActiveState, SealedTombstoneDemand, StoreWal};
54pub(crate) use atomic_batch::cut_interrupted_append;
55
56#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58#[repr(transparent)]
59pub struct DocId(u128);
60
61impl DocId {
62 #[must_use]
64 pub const fn new(value: u128) -> Self {
65 Self(value)
66 }
67
68 #[must_use]
70 pub const fn get(self) -> u128 {
71 self.0
72 }
73}
74
75#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
77#[repr(transparent)]
78pub struct Revision(u64);
79
80impl Revision {
81 #[must_use]
83 pub const fn new(value: u64) -> Self {
84 Self(value)
85 }
86
87 #[must_use]
89 pub const fn get(self) -> u64 {
90 self.0
91 }
92}
93
94#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
96pub struct DocumentVersion {
97 doc_id: DocId,
98 revision: Revision,
99}
100
101impl DocumentVersion {
102 #[must_use]
104 pub const fn new(doc_id: DocId, revision: Revision) -> Self {
105 Self { doc_id, revision }
106 }
107
108 #[must_use]
110 pub const fn doc_id(self) -> DocId {
111 self.doc_id
112 }
113
114 #[must_use]
116 pub const fn revision(self) -> Revision {
117 self.revision
118 }
119}
120
121#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
128pub enum ExpectedRevision {
129 Exactly(Revision),
131 Absent,
133}
134
135#[derive(Clone, Debug, PartialEq)]
137pub struct IngestDocument {
138 version: DocumentVersion,
139 expected_revision: Option<ExpectedRevision>,
140 vector: Vec<f32>,
141 timestamp: i64,
142 timestamp_present: bool,
143 metadata: Vec<u8>,
144 metadata_present: bool,
145 text: Option<String>,
146 columns: Vec<(ColumnId, PredicateValue)>,
147 columns_present: bool,
148}
149
150impl IngestDocument {
151 #[must_use]
153 pub fn new(version: DocumentVersion, vector: Vec<f32>) -> Self {
154 Self {
155 version,
156 expected_revision: None,
157 vector,
158 timestamp: 0,
159 timestamp_present: false,
160 metadata: Vec::new(),
161 metadata_present: false,
162 text: None,
163 columns: Vec::new(),
164 columns_present: false,
165 }
166 }
167
168 #[must_use]
170 pub const fn with_timestamp(mut self, timestamp: i64) -> Self {
171 self.timestamp = timestamp;
172 self.timestamp_present = true;
173 self
174 }
175
176 #[must_use]
178 pub fn with_metadata(mut self, metadata: Vec<u8>) -> Self {
179 self.metadata = metadata;
180 self.metadata_present = true;
181 self
182 }
183
184 #[must_use]
186 pub fn with_text(mut self, text: impl Into<String>) -> Self {
187 self.text = Some(text.into());
188 self
189 }
190
191 #[must_use]
193 pub fn with_columns(mut self, columns: Vec<(ColumnId, PredicateValue)>) -> Self {
194 self.columns = columns;
195 self.columns_present = true;
196 self
197 }
198
199 #[must_use]
202 pub const fn with_expected_revision(mut self, expected: ExpectedRevision) -> Self {
203 self.expected_revision = Some(expected);
204 self
205 }
206
207 #[must_use]
209 pub const fn expected_revision(&self) -> Option<ExpectedRevision> {
210 self.expected_revision
211 }
212
213 #[must_use]
215 pub const fn version(&self) -> DocumentVersion {
216 self.version
217 }
218
219 #[must_use]
221 pub fn vector(&self) -> &[f32] {
222 &self.vector
223 }
224
225 #[must_use]
227 pub const fn timestamp(&self) -> i64 {
228 self.timestamp
229 }
230
231 pub(crate) const fn has_timestamp(&self) -> bool {
232 self.timestamp_present
233 }
234
235 #[must_use]
237 pub fn metadata(&self) -> &[u8] {
238 &self.metadata
239 }
240
241 pub(crate) const fn has_metadata(&self) -> bool {
242 self.metadata_present
243 }
244
245 #[must_use]
247 pub fn text(&self) -> Option<&str> {
248 self.text.as_deref()
249 }
250
251 #[must_use]
253 pub fn columns(&self) -> &[(ColumnId, PredicateValue)] {
254 &self.columns
255 }
256
257 pub(crate) const fn has_columns(&self) -> bool {
258 self.columns_present
259 }
260
261 pub(crate) fn column_inputs(&self) -> Vec<ColumnInput<'_>> {
262 column_inputs(&self.columns)
263 }
264}
265
266pub(crate) fn column_inputs(columns: &[(ColumnId, PredicateValue)]) -> Vec<ColumnInput<'_>> {
267 columns
268 .iter()
269 .map(|(column, value)| ColumnInput {
270 column: *column,
271 value: match value {
272 PredicateValue::Id128(value) => ColumnValue::Id128(*value),
273 PredicateValue::U64(value) => ColumnValue::U64(*value),
274 PredicateValue::I64(value) => ColumnValue::I64(*value),
275 PredicateValue::F64(value) => ColumnValue::F64(*value),
276 PredicateValue::Bool(value) => ColumnValue::Bool(*value),
277 PredicateValue::String(value) => ColumnValue::String(value),
278 },
279 })
280 .collect()
281}
282
283#[derive(Clone, Debug, Default, PartialEq)]
285pub struct IngestBatch {
286 documents: Vec<IngestDocument>,
287 epoch: Option<crate::epoch::EpochIdentity>,
288}
289
290#[derive(Clone, Debug, Default, Eq, PartialEq)]
292pub struct DeleteBatch {
293 doc_ids: Vec<DocId>,
294 expected: Vec<Option<ExpectedRevision>>,
295}
296
297impl DeleteBatch {
298 #[must_use]
300 pub const fn new(doc_ids: Vec<DocId>) -> Self {
301 Self {
302 doc_ids,
303 expected: Vec::new(),
304 }
305 }
306
307 #[must_use]
310 pub fn conditional(targets: Vec<(DocId, Option<ExpectedRevision>)>) -> Self {
311 let (doc_ids, expected) = targets.into_iter().unzip();
312 Self { doc_ids, expected }
313 }
314
315 #[must_use]
317 pub fn doc_ids(&self) -> &[DocId] {
318 &self.doc_ids
319 }
320}
321
322impl IngestBatch {
323 #[must_use]
325 pub const fn new(documents: Vec<IngestDocument>) -> Self {
326 Self {
327 documents,
328 epoch: None,
329 }
330 }
331
332 #[must_use]
334 pub const fn with_epoch(mut self, epoch: crate::epoch::EpochIdentity) -> Self {
335 self.epoch = Some(epoch);
336 self
337 }
338
339 #[must_use]
341 pub fn documents(&self) -> &[IngestDocument] {
342 &self.documents
343 }
344}
345
346#[derive(Clone, Copy, Debug, Eq, PartialEq)]
348pub struct IngestAck {
349 seq: LogSeq,
350 generation: u64,
351}
352
353impl IngestAck {
354 #[must_use]
356 pub const fn seq(self) -> LogSeq {
357 self.seq
358 }
359
360 #[must_use]
362 pub const fn generation(self) -> u64 {
363 self.generation
364 }
365}
366
367#[derive(Debug)]
369pub enum IngestError {
370 Store(StoreError),
372 EmptyBatch,
374 EpochMismatch(crate::epoch::EpochMismatch),
376 EpochUndeclared,
378 EpochUnstamped,
380 StaleRevision {
382 doc_id: DocId,
384 current: Revision,
386 attempted: Revision,
388 },
389 RevisionConflict {
392 index: usize,
394 doc_id: DocId,
396 expected: ExpectedRevision,
398 current: Option<Revision>,
400 },
401 Vector(crate::quant::QuantError),
403 Lexical(crate::fts::index::IndexError),
405 Tokenizer(crate::fts::tokenizer::TokenizerError),
407 Columns(crate::meta::BuildError),
409 Payload(wal_payload::PayloadError),
411}
412
413impl std::fmt::Display for IngestError {
414 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415 match self {
416 Self::Store(error) => error.fmt(formatter),
417 Self::EmptyBatch => formatter.write_str("ingest batch is empty"),
418 Self::EpochMismatch(error) => error.fmt(formatter),
419 Self::EpochUndeclared => formatter.write_str("ingest epoch is required by this store"),
420 Self::EpochUnstamped => {
421 formatter.write_str("an unstamped store cannot accept an epoch declaration")
422 }
423 Self::StaleRevision {
424 doc_id,
425 current,
426 attempted,
427 } => write!(
428 formatter,
429 "document {} revision {} is stale; current revision is {}",
430 doc_id.get(),
431 attempted.get(),
432 current.get()
433 ),
434 Self::RevisionConflict {
435 index,
436 doc_id,
437 expected,
438 current,
439 } => {
440 write!(
441 formatter,
442 "revision condition failed for document {} at batch index {index}: expected ",
443 doc_id.get()
444 )?;
445 match expected {
446 ExpectedRevision::Exactly(revision) => {
447 write!(formatter, "revision {}", revision.get())?;
448 }
449 ExpectedRevision::Absent => formatter.write_str("no live document")?,
450 }
451 match current {
452 Some(revision) => write!(formatter, ", current revision is {}", revision.get()),
453 None => formatter.write_str(", no live document exists"),
454 }
455 }
456 Self::Vector(error) => write!(formatter, "ingest vector: {error}"),
457 Self::Lexical(error) => write!(formatter, "ingest text: {error}"),
458 Self::Tokenizer(error) => write!(formatter, "ingest tokenizer: {error}"),
459 Self::Columns(error) => write!(formatter, "ingest columns: {error}"),
460 Self::Payload(error) => error.fmt(formatter),
461 }
462 }
463}
464
465impl std::error::Error for IngestError {
466 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
467 match self {
468 Self::Store(error) => Some(error),
469 Self::EpochMismatch(error) => Some(error),
470 Self::Vector(error) => Some(error),
471 Self::Lexical(error) => Some(error),
472 Self::Tokenizer(error) => Some(error),
473 Self::Columns(error) => Some(error),
474 Self::Payload(error) => Some(error),
475 Self::EmptyBatch
476 | Self::EpochUndeclared
477 | Self::EpochUnstamped
478 | Self::StaleRevision { .. }
479 | Self::RevisionConflict { .. } => None,
480 }
481 }
482}
483
484impl From<StoreError> for IngestError {
485 fn from(error: StoreError) -> Self {
486 Self::Store(error)
487 }
488}
489
490#[derive(Clone, Copy, Debug)]
492pub struct SearchRequest<'a> {
493 vector: &'a [f32],
494 pub(crate) filter: Option<&'a crate::lifecycle::QueryFilter>,
495}
496
497impl<'a> SearchRequest<'a> {
498 #[must_use]
500 pub const fn new(vector: &'a [f32]) -> Self {
501 Self {
502 vector,
503 filter: None,
504 }
505 }
506
507 #[must_use]
509 pub const fn with_filter(mut self, filter: Option<&'a crate::lifecycle::QueryFilter>) -> Self {
510 self.filter = filter;
511 self
512 }
513
514 #[must_use]
516 pub const fn vector(self) -> &'a [f32] {
517 self.vector
518 }
519}
520
521#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
523pub enum RowSource {
524 Active,
526 Sealed(SegmentId),
528}
529
530#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
532pub struct GlobalRowId {
533 source: RowSource,
534 local_row: u32,
535}
536
537impl GlobalRowId {
538 pub(crate) const fn new(source: RowSource, local_row: u32) -> Self {
539 Self { source, local_row }
540 }
541
542 #[must_use]
544 pub const fn source(self) -> RowSource {
545 self.source
546 }
547
548 #[must_use]
550 pub const fn local_row(self) -> u32 {
551 self.local_row
552 }
553}
554
555#[derive(Clone, Copy, Debug, PartialEq)]
557pub struct SearchCandidate {
558 row_id: GlobalRowId,
559 document: Option<DocumentVersion>,
560 score: f32,
561 exact_score: bool,
562}
563
564impl SearchCandidate {
565 pub(crate) const fn new(
566 row_id: GlobalRowId,
567 document: Option<DocumentVersion>,
568 score: f32,
569 exact_score: bool,
570 ) -> Self {
571 Self {
572 row_id,
573 document,
574 score,
575 exact_score,
576 }
577 }
578
579 #[must_use]
581 pub const fn row_id(self) -> GlobalRowId {
582 self.row_id
583 }
584
585 #[must_use]
588 pub const fn document(self) -> Option<DocumentVersion> {
589 self.document
590 }
591
592 #[must_use]
594 pub const fn score(self) -> f32 {
595 self.score
596 }
597
598 pub(crate) const fn exact_score(self) -> bool {
599 self.exact_score
600 }
601}
602
603pub(crate) fn compare_search_candidates(
609 left: &SearchCandidate,
610 right: &SearchCandidate,
611) -> std::cmp::Ordering {
612 right.score().total_cmp(&left.score()).then_with(|| {
613 match (left.document(), right.document()) {
614 (Some(left_document), Some(right_document)) => left_document
615 .doc_id()
616 .cmp(&right_document.doc_id())
617 .then_with(|| right_document.revision().cmp(&left_document.revision())),
618 (Some(_), None) => std::cmp::Ordering::Less,
619 (None, Some(_)) => std::cmp::Ordering::Greater,
620 (None, None) => std::cmp::Ordering::Equal,
621 }
622 .then_with(|| left.row_id().cmp(&right.row_id()))
623 })
624}
625
626#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
628pub struct GraphSearchStats {
629 pub segments_traversed: usize,
631 pub graph_validations: usize,
633 pub entry_seed_discoveries: usize,
635 pub visited_epoch_clears: usize,
637 pub candidates_scored: usize,
639 pub candidates_rescored: usize,
641 pub segments_pruned_by_bound: usize,
643}
644
645#[derive(Clone, Debug, PartialEq)]
647pub struct SearchOutcome {
648 pub candidates: Vec<SearchCandidate>,
650 pub vector_ceiling: Option<f64>,
654 pub stats: ScanStats,
656 pub graph_stats: GraphSearchStats,
658 pub generation: u64,
660 pub epoch: Option<crate::epoch::EpochIdentity>,
662 pub diagnostics: crate::diag::QueryDiagnostics,
664}
665
666#[derive(Clone, Copy, Debug, PartialEq)]
668pub struct LexicalCandidate {
669 pub document: DocumentVersion,
671 pub score: f64,
673}
674
675#[derive(Clone, Debug, PartialEq)]
677pub struct StoreLexicalSearchOutcome {
678 pub candidates: Vec<LexicalCandidate>,
680 pub generation: u64,
682 pub diagnostics: crate::diag::QueryDiagnostics,
684}
685
686#[derive(Clone, Debug, PartialEq)]
688pub struct ExplainedLexicalCandidate {
689 pub document: DocumentVersion,
691 pub score: f64,
693 pub provenance: Vec<crate::fts::query::LexicalExpansion>,
695 pub snippet: crate::fts::query::OwnedLexicalSnippet,
697}
698
699#[derive(Clone, Debug, PartialEq)]
701pub struct StoreStructuredLexicalSearchOutcome {
702 pub candidates: Vec<ExplainedLexicalCandidate>,
704 pub expansions: Vec<crate::fts::query::LexicalExpansion>,
706 pub generation: u64,
708 pub diagnostics: crate::diag::QueryDiagnostics,
710}
711
712#[derive(Clone, Debug, PartialEq)]
714pub struct StoreHybridSearchOutcome {
715 pub hits: Vec<crate::fusion::FusedHit<DocId>>,
717 pub lexical_expansions: Vec<crate::fts::query::LexicalExpansion>,
719 pub generation: u64,
721 pub diagnostics: crate::diag::QueryDiagnostics,
723}
724
725#[derive(Debug)]
727pub enum StoreLexicalError {
728 Query(crate::lifecycle::QueryError),
730 Lexical(crate::planner::LexicalFilterError),
732 MissingDocumentIdentity {
734 segment_id: SegmentId,
736 },
737 MissingStoredText {
739 segment_id: SegmentId,
741 row: u32,
743 },
744 MissingSnippetMatch {
746 segment: u32,
748 row: u32,
750 },
751 Structured(crate::fts::query::LexicalQueryError),
753 Snippet(crate::fts::snippet::SnippetError),
755 Tokenizer(crate::fts::tokenizer::TokenizerError),
757}
758
759impl std::fmt::Display for StoreLexicalError {
760 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
761 match self {
762 Self::Query(error) => error.fmt(formatter),
763 Self::Lexical(error) => error.fmt(formatter),
764 Self::MissingDocumentIdentity { segment_id } => write!(
765 formatter,
766 "sealed lexical segment {segment_id} has no document identity"
767 ),
768 Self::MissingStoredText { segment_id, row } => write!(
769 formatter,
770 "sealed lexical segment {segment_id} row {row} has no stored source text"
771 ),
772 Self::MissingSnippetMatch { segment, row } => write!(
773 formatter,
774 "lexical source {segment} row {row} has no snippet match span"
775 ),
776 Self::Structured(error) => error.fmt(formatter),
777 Self::Snippet(error) => error.fmt(formatter),
778 Self::Tokenizer(error) => error.fmt(formatter),
779 }
780 }
781}
782
783impl std::error::Error for StoreLexicalError {
784 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
785 match self {
786 Self::Query(error) => Some(error),
787 Self::Lexical(error) => Some(error),
788 Self::Structured(error) => Some(error),
789 Self::Snippet(error) => Some(error),
790 Self::Tokenizer(error) => Some(error),
791 Self::MissingDocumentIdentity { .. }
792 | Self::MissingStoredText { .. }
793 | Self::MissingSnippetMatch { .. } => None,
794 }
795 }
796}
797
798impl From<crate::lifecycle::QueryError> for StoreLexicalError {
799 fn from(error: crate::lifecycle::QueryError) -> Self {
800 Self::Query(error)
801 }
802}
803
804impl From<StoreError> for StoreLexicalError {
805 fn from(error: StoreError) -> Self {
806 Self::Query(crate::lifecycle::QueryError::Store(error))
807 }
808}
809
810impl From<crate::planner::LexicalFilterError> for StoreLexicalError {
811 fn from(error: crate::planner::LexicalFilterError) -> Self {
812 Self::Lexical(error)
813 }
814}
815
816impl From<crate::fts::query::LexicalQueryError> for StoreLexicalError {
817 fn from(error: crate::fts::query::LexicalQueryError) -> Self {
818 Self::Structured(error)
819 }
820}
821
822impl From<crate::fts::snippet::SnippetError> for StoreLexicalError {
823 fn from(error: crate::fts::snippet::SnippetError) -> Self {
824 Self::Snippet(error)
825 }
826}
827
828impl From<crate::fts::tokenizer::TokenizerError> for StoreLexicalError {
829 fn from(error: crate::fts::tokenizer::TokenizerError) -> Self {
830 Self::Tokenizer(error)
831 }
832}
833
834#[derive(Clone, Copy)]
835struct ResolvedRevision {
836 version: DocumentVersion,
837 seq: LogSeq,
838 active_row: Option<usize>,
839}
840
841struct RevisionProgress {
842 working: Option<ActiveSegment>,
843 records: Vec<(usize, u16, Vec<u8>)>,
844 replay_seq: Option<LogSeq>,
845 #[cfg(any(test, feature = "test-seams"))]
846 replay_count: usize,
847 sealed_tombstones: Vec<DocId>,
848}
849
850fn resolve_revision(
851 active: &ActiveSegment,
852 sealed: &[purge::SealedDocumentMatch],
853 doc_id: DocId,
854 sealed_seq: LogSeq,
855) -> Option<ResolvedRevision> {
856 let active_existing = active.existing(doc_id);
857 let active_row = active_existing.map(|(row, _, _)| row);
858 let mut resolved = active_existing.map(|(_, version, seq)| ResolvedRevision {
859 version,
860 seq,
861 active_row,
862 });
863 for matched in sealed
864 .iter()
865 .filter(|matched| matched.version.doc_id() == doc_id)
866 {
867 if resolved.is_none_or(|current| matched.version.revision() > current.version.revision()) {
868 resolved = Some(ResolvedRevision {
869 version: matched.version,
870 seq: sealed_seq,
871 active_row,
872 });
873 }
874 }
875 resolved
876}
877
878fn live_revision(
881 active: &ActiveSegment,
882 snapshot: &PublishedSnapshot,
883 doc_id: DocId,
884) -> Result<Option<Revision>, StoreError> {
885 if let Some((row, version, _)) = active.existing(doc_id)
886 && !active.is_tombstoned(row)
887 {
888 return Ok(Some(version.revision()));
889 }
890 for segment in snapshot.segments() {
891 for row in segment.query_rows_for_doc_id(doc_id)? {
892 if let Some(version) = segment.document_version(row).map_err(StoreError::Segment)? {
893 return Ok(Some(version.revision()));
894 }
895 }
896 }
897 Ok(None)
898}
899
900fn check_revision_conditions(
904 active: &ActiveSegment,
905 snapshot: &PublishedSnapshot,
906 conditions: impl Iterator<Item = (usize, DocId, Option<ExpectedRevision>)>,
907) -> Result<(), IngestError> {
908 for (index, doc_id, expected) in conditions {
909 let Some(expected) = expected else {
910 continue;
911 };
912 let current = live_revision(active, snapshot, doc_id)?;
913 let holds = match expected {
914 ExpectedRevision::Exactly(revision) => current == Some(revision),
915 ExpectedRevision::Absent => current.is_none(),
916 };
917 if !holds {
918 return Err(IngestError::RevisionConflict {
919 index,
920 doc_id,
921 expected,
922 current,
923 });
924 }
925 }
926 Ok(())
927}
928
929fn validate_republish_generation(
930 expected_generation: u64,
931 actual_generation: u64,
932) -> Result<(), StoreError> {
933 if actual_generation != expected_generation {
934 return Err(StoreError::ConcurrentActiveMutation {
935 expected_generation,
936 actual_generation,
937 });
938 }
939 Ok(())
940}
941
942impl Store {
943 pub fn lexical_corpus_stats(&self) -> Result<crate::fts::bm25::CorpusStats, StoreLexicalError> {
950 let state = self
951 .state
952 .lock()
953 .map_err(|_| StoreError::Synchronization { component: "state" })?;
954 match *state {
955 StoreState::Open => {}
956 StoreState::Closing => return Err(StoreError::Closing.into()),
957 StoreState::Closed => return Err(StoreError::Closed.into()),
958 }
959 let active_guard = self
960 .active
961 .lock()
962 .map_err(|_| StoreError::Synchronization {
963 component: "active segment",
964 })?;
965 let active = active_guard.as_ref().ok_or(StoreError::Closed)?;
966 let snapshot = self
967 .snapshot
968 .read()
969 .map_err(|_| StoreError::Synchronization {
970 component: "published snapshot",
971 })?
972 .as_ref()
973 .cloned()
974 .ok_or(StoreError::Closed)?;
975 let mut index = crate::fts::index::LexicalIndex::new();
976 for segment in snapshot.segments() {
977 if let Some(postings) = segment.query_postings()? {
978 let alive = segment.query_alive()?;
979 index
980 .push_shared_with_live_rows(postings, alive.alive_bitmap())
981 .map_err(crate::planner::LexicalFilterError::from)?;
982 }
983 }
984 if active.segment.has_text() {
985 let sealed = active.segment.sealed_lexical(&self.accounting)?;
986 let alive = active.segment.alive()?;
987 index
988 .push_shared_with_live_rows(sealed, alive.alive_bitmap())
989 .map_err(crate::planner::LexicalFilterError::from)?;
990 }
991 index
992 .corpus_stats()
993 .map_err(crate::planner::LexicalFilterError::from)
994 .map_err(StoreLexicalError::from)
995 }
996
997 #[inline(always)]
998 fn apply_revision_decision(
999 &self,
1000 current: &ActiveSegment,
1001 sealed: &[purge::SealedDocumentMatch],
1002 sealed_seq: LogSeq,
1003 document: &IngestDocument,
1004 remaining: &[IngestDocument],
1005 progress: &mut RevisionProgress,
1006 ) -> Result<(), IngestError> {
1007 let segment = progress.working.as_ref().unwrap_or(current);
1008 let existing = resolve_revision(segment, sealed, document.version().doc_id(), sealed_seq);
1009 let action = revise::classify_revision(
1010 existing.map(|resolved| (resolved.version, resolved.seq)),
1011 document.version(),
1012 );
1013 match action {
1014 revise::RevisionDecision::Replace => {
1015 let row = existing.and_then(|resolved| resolved.active_row);
1016 if progress.working.is_none() {
1017 progress.working =
1018 Some(current.copy_for_batch(document, remaining, &self.accounting)?);
1019 }
1020 let working = progress
1021 .working
1022 .as_mut()
1023 .ok_or(StoreError::Synchronization {
1024 component: "batch working segment",
1025 })?;
1026 let row = if let Some(row) = row {
1027 working.replace_in_place(row, document, &self.accounting, &self.tokenizer)?;
1028 row
1029 } else {
1030 working.insert_in_place(document, &self.accounting, &self.tokenizer)?
1031 };
1032 let (op, payload) = encode_persisted_upsert(document)?;
1033 progress.records.push((row, op, payload));
1034 if sealed
1035 .iter()
1036 .any(|matched| matched.version.doc_id() == document.version().doc_id())
1037 && !progress
1038 .sealed_tombstones
1039 .contains(&document.version().doc_id())
1040 {
1041 progress.sealed_tombstones.push(document.version().doc_id());
1042 }
1043 }
1044 revise::RevisionDecision::Insert => {
1045 if progress.working.is_none() {
1046 progress.working =
1047 Some(current.copy_for_batch(document, remaining, &self.accounting)?);
1048 }
1049 let working = progress
1050 .working
1051 .as_mut()
1052 .ok_or(StoreError::Synchronization {
1053 component: "batch working segment",
1054 })?;
1055 let row = working.insert_in_place(document, &self.accounting, &self.tokenizer)?;
1056 let (op, payload) = encode_persisted_upsert(document)?;
1057 progress.records.push((row, op, payload));
1058 }
1059 revise::RevisionDecision::Replay { seq } => {
1060 progress.replay_seq = Some(
1061 progress
1062 .replay_seq
1063 .map_or(seq, |current: LogSeq| current.max(seq)),
1064 );
1065 #[cfg(any(test, feature = "test-seams"))]
1066 {
1067 progress.replay_count = progress.replay_count.saturating_add(1);
1068 }
1069 }
1070 revise::RevisionDecision::Reject { current } => {
1071 return Err(IngestError::StaleRevision {
1072 doc_id: document.version().doc_id(),
1073 current,
1074 attempted: document.version().revision(),
1075 });
1076 }
1077 }
1078 Ok(())
1079 }
1080
1081 #[cfg(any(test, feature = "test-seams"))]
1082 fn record_post_ack_retry_receipt(
1083 &self,
1084 batch: &IngestBatch,
1085 replay_count: usize,
1086 seq: LogSeq,
1087 generation: u64,
1088 ) -> Result<(), IngestError> {
1089 if let Some(controller) = self.ingest_retention_fault_controller.as_ref() {
1090 let plan =
1091 controller
1092 .post_ack_retry_plan()
1093 .map_err(|_| StoreError::Synchronization {
1094 component: "ingest-retention controller",
1095 })?;
1096 if let Some((invocation_id, first_ack_seq, first_ack_generation)) = plan
1097 && first_ack_seq == seq.get()
1098 && first_ack_generation == generation
1099 {
1100 let batch_count = u64::try_from(batch.documents.len())
1101 .map_err(|_| StoreError::ActiveRowOverflow)?;
1102 let replay_count =
1103 u64::try_from(replay_count).map_err(|_| StoreError::ActiveRowOverflow)?;
1104 controller
1105 .push_receipt(IngestRetentionFaultReceiptV1::post_ack_retry(
1106 invocation_id,
1107 batch_count,
1108 replay_count,
1109 seq.get(),
1110 generation,
1111 ))
1112 .map_err(|_| StoreError::Synchronization {
1113 component: "ingest-retention controller",
1114 })?;
1115 }
1116 }
1117 Ok(())
1118 }
1119
1120 #[cfg(any(test, feature = "test-seams"))]
1121 fn recover_partial_batch_append(
1122 &self,
1123 writer: &mut StoreWal,
1124 error: &StoreError,
1125 partial_append_clean_wal: &Option<Vec<u8>>,
1126 batch: &IngestBatch,
1127 records: &[(usize, u16, Vec<u8>)],
1128 ) -> Result<(), IngestError> {
1129 if let Some(controller) = self.ingest_retention_fault_controller.as_ref()
1130 && let Some(observation) =
1131 controller
1132 .take_partial_batch_append_observation()
1133 .map_err(|_| StoreError::Synchronization {
1134 component: "ingest-retention controller",
1135 })?
1136 {
1137 let expected_detail = observation.detail.as_str();
1138 match error {
1139 StoreError::WalWrite(WalWriteError::Failed { kind, detail })
1140 if *kind == std::io::ErrorKind::Other && detail.as_ref() == expected_detail => {
1141 }
1142 _ => {
1143 return Err(StoreError::Synchronization {
1144 component: "partial-batch-append typed WAL error",
1145 }
1146 .into());
1147 }
1148 }
1149 let clean_wal =
1150 partial_append_clean_wal
1151 .as_deref()
1152 .ok_or(StoreError::Synchronization {
1153 component: "partial-batch-append clean WAL image",
1154 })?;
1155 let wal_path = self.directory.join("wal.ze");
1156 writer.restore_after_failed_append(
1157 self.vfs.as_ref(),
1158 &wal_path,
1159 self.durability_policy,
1160 clean_wal,
1161 )?;
1162 let submitted_count =
1163 u64::try_from(batch.documents.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1164 let changed_records =
1165 u64::try_from(records.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1166 let encoded_bytes = u64::try_from(observation.encoded_bytes)
1167 .map_err(|_| StoreError::ActiveRowOverflow)?;
1168 let prefix_bytes = u64::try_from(observation.prefix_bytes)
1169 .map_err(|_| StoreError::ActiveRowOverflow)?;
1170 controller
1171 .push_receipt(IngestRetentionFaultReceiptV1::partial_batch_append(
1172 observation.invocation_id,
1173 submitted_count,
1174 changed_records,
1175 encoded_bytes,
1176 prefix_bytes,
1177 observation.detail,
1178 ))
1179 .map_err(|_| StoreError::Synchronization {
1180 component: "ingest-retention controller",
1181 })?;
1182 }
1183 Ok(())
1184 }
1185
1186 #[inline(always)]
1187 fn publish_committed_active(
1188 &self,
1189 active: &mut Option<ActiveState>,
1190 committed: Option<(PublishedSnapshot, Vec<PathBuf>)>,
1191 generation: u64,
1192 next: ActiveSegment,
1193 ) -> Result<Vec<PathBuf>, StoreError> {
1194 if let Some((remapped, replaced_paths)) = committed {
1195 let mut published = self
1196 .snapshot
1197 .write()
1198 .map_err(|_| StoreError::Synchronization {
1199 component: "published snapshot",
1200 })?;
1201 let previous = published.replace(Arc::new(remapped));
1202 *active = Some(ActiveState {
1203 generation,
1204 segment: Arc::new(next),
1205 });
1206 drop(published);
1207 drop(previous);
1208 Ok(replaced_paths)
1209 } else {
1210 *active = Some(ActiveState {
1211 generation,
1212 segment: Arc::new(next),
1213 });
1214 Ok(Vec::new())
1215 }
1216 }
1217
1218 pub fn ingest(&self, batch: IngestBatch) -> Result<IngestAck, IngestError> {
1220 let state = self
1221 .state
1222 .lock()
1223 .map_err(|_| StoreError::Synchronization { component: "state" })?;
1224 match *state {
1225 StoreState::Open => {}
1226 StoreState::Closing => return Err(StoreError::Closing.into()),
1227 StoreState::Closed => return Err(StoreError::Closed.into()),
1228 }
1229 drop(state);
1230 match (self.epoch_identity(), batch.epoch) {
1231 (Some(expected), Some(declared)) if expected != declared => {
1232 return Err(IngestError::EpochMismatch(crate::epoch::EpochMismatch {
1233 expected,
1234 declared,
1235 }));
1236 }
1237 (Some(_), None) => return Err(IngestError::EpochUndeclared),
1238 (None, Some(_)) => return Err(IngestError::EpochUnstamped),
1239 (Some(_), Some(_)) | (None, None) => {}
1240 }
1241 if batch.documents.is_empty() {
1242 return Err(IngestError::EmptyBatch);
1243 }
1244 if self.epoch.as_ref().is_some_and(|epoch| {
1245 epoch.embedding.document.normalization == crate::epoch::Normalization::L2
1246 }) {
1247 for document in &batch.documents {
1248 if let Some(squared_norm) =
1249 crate::graph::search::non_unit_squared_norm(document.vector())
1250 {
1251 return Err(IngestError::Vector(crate::quant::QuantError::NonUnitNorm {
1252 squared_norm_bits: squared_norm.to_bits(),
1253 tolerance_bits: crate::graph::search::UNIT_NORM_SQUARED_TOLERANCE.to_bits(),
1254 }));
1255 }
1256 }
1257 }
1258 for document in &batch.documents {
1259 validate_document_columns(&self.schema, document)?;
1260 }
1261 let mut wal = self
1262 .wal_writer
1263 .lock()
1264 .map_err(|_| StoreError::Synchronization {
1265 component: "WAL writer",
1266 })?;
1267 let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
1268 let (current_generation, current_segment) = {
1269 let active = self
1270 .active
1271 .lock()
1272 .map_err(|_| StoreError::Synchronization {
1273 component: "active segment",
1274 })?;
1275 let current = active.as_ref().ok_or(StoreError::Closed)?;
1276 (current.generation, Arc::clone(¤t.segment))
1277 };
1278 let snapshot = self
1279 .snapshot
1280 .read()
1281 .map_err(|_| StoreError::Synchronization {
1282 component: "published snapshot",
1283 })?
1284 .as_ref()
1285 .cloned()
1286 .ok_or(StoreError::Closed)?;
1287 check_revision_conditions(
1288 current_segment.as_ref(),
1289 &snapshot,
1290 batch.documents.iter().enumerate().map(|(index, document)| {
1291 (
1292 index,
1293 document.version().doc_id(),
1294 document.expected_revision(),
1295 )
1296 }),
1297 )?;
1298 let requested_ids = batch
1299 .documents
1300 .iter()
1301 .map(|document| document.version().doc_id())
1302 .collect::<Vec<_>>();
1303 let sealed = purge::sealed_document_matches(&snapshot, &requested_ids)?;
1304 let sealed_seq = LogSeq::new(snapshot.absorbed_through());
1305 let mut progress = RevisionProgress {
1306 working: None,
1307 records: Vec::new(),
1308 replay_seq: None,
1309 #[cfg(any(test, feature = "test-seams"))]
1310 replay_count: 0,
1311 sealed_tombstones: Vec::new(),
1312 };
1313 for (index, document) in batch.documents.iter().enumerate() {
1314 let remaining = batch
1315 .documents
1316 .get(index..)
1317 .ok_or(StoreError::ActiveRowOverflow)?;
1318 self.apply_revision_decision(
1319 current_segment.as_ref(),
1320 &sealed,
1321 sealed_seq,
1322 document,
1323 remaining,
1324 &mut progress,
1325 )?;
1326 }
1327 let RevisionProgress {
1328 working,
1329 records,
1330 replay_seq,
1331 #[cfg(any(test, feature = "test-seams"))]
1332 replay_count,
1333 sealed_tombstones,
1334 } = progress;
1335 let Some(mut next) = working else {
1336 let seq = replay_seq.ok_or(StoreError::Synchronization {
1337 component: "nonempty ingest replay sequence",
1338 })?;
1339 #[cfg(any(test, feature = "test-seams"))]
1340 self.record_post_ack_retry_receipt(&batch, replay_count, seq, current_generation)?;
1341 return Ok(IngestAck {
1342 seq,
1343 generation: current_generation,
1344 });
1345 };
1346 let records = atomic_batch::frame_batch(records)?;
1347 let generation = current_generation
1348 .checked_add(1)
1349 .ok_or(StoreError::GenerationOverflow)?;
1350 let prepared = purge::prepare_sealed_tombstones(
1351 self.vfs.as_ref(),
1352 &self.directory,
1353 &snapshot,
1354 &sealed,
1355 &sealed_tombstones,
1356 writer.durable_end(),
1357 generation,
1358 writer.durable_end().saturating_add(1),
1359 self.durability_policy,
1360 &self.accounting,
1361 )?;
1362 let record_refs = records
1363 .iter()
1364 .map(|(_, op, payload)| (*op, payload.as_slice()))
1365 .collect::<Vec<_>>();
1366 #[cfg(any(test, feature = "test-seams"))]
1367 let partial_append_clean_wal = match self.ingest_retention_fault_controller.as_ref() {
1368 Some(controller)
1369 if controller
1370 .partial_batch_append_plan()
1371 .map_err(|_| StoreError::Synchronization {
1372 component: "ingest-retention controller",
1373 })?
1374 .is_some() =>
1375 {
1376 let path = self.directory.join("wal.ze");
1377 Some(
1378 self.vfs
1379 .read(&path)
1380 .map_err(|source| StoreError::Io { path, source })?,
1381 )
1382 }
1383 Some(_) | None => None,
1384 };
1385 let sequences = match writer.commit_many(&record_refs) {
1386 Ok(sequences) => sequences,
1387 Err(error) => {
1388 if let Some(prepared) = prepared {
1389 prepared.abort(self.vfs.as_ref(), &self.directory, self.durability_policy)?;
1390 }
1391 #[cfg(any(test, feature = "test-seams"))]
1392 self.recover_partial_batch_append(
1393 writer,
1394 &error,
1395 &partial_append_clean_wal,
1396 &batch,
1397 &records,
1398 )?;
1399 return Err(error.into());
1400 }
1401 };
1402 for ((row, _, _), sequence) in records
1403 .iter()
1404 .zip(sequences.start.get()..sequences.end.get())
1405 {
1406 next.set_sequence(*row, LogSeq::new(sequence))?;
1407 }
1408 let seq = LogSeq::new(sequences.end.get().saturating_sub(1));
1409 let committed = prepared
1410 .map(|prepared| {
1411 prepared.commit(
1412 self,
1413 self.vfs.as_ref(),
1414 &self.directory,
1415 self.durability_policy,
1416 )
1417 })
1418 .transpose()?;
1419 let mut active = self
1420 .active
1421 .lock()
1422 .map_err(|_| StoreError::Synchronization {
1423 component: "active segment",
1424 })?;
1425 let actual_generation = active.as_ref().ok_or(StoreError::Closed)?.generation;
1426 validate_republish_generation(current_generation, actual_generation)?;
1427 let replaced_paths =
1428 self.publish_committed_active(&mut active, committed, generation, next)?;
1429 drop(active);
1430 purge::unlink_replaced_segments(
1431 self,
1432 self.vfs.as_ref(),
1433 &self.directory,
1434 &replaced_paths,
1435 self.durability_policy,
1436 );
1437 drop(wal);
1438 Ok(IngestAck { seq, generation })
1439 }
1440
1441 pub fn delete(&self, batch: DeleteBatch) -> Result<IngestAck, IngestError> {
1443 if batch.doc_ids.is_empty() {
1444 return Err(IngestError::EmptyBatch);
1445 }
1446 let state = self
1447 .state
1448 .lock()
1449 .map_err(|_| StoreError::Synchronization { component: "state" })?;
1450 match *state {
1451 StoreState::Open => {}
1452 StoreState::Closing => return Err(StoreError::Closing.into()),
1453 StoreState::Closed => return Err(StoreError::Closed.into()),
1454 }
1455 drop(state);
1456 let mut wal = self
1457 .wal_writer
1458 .lock()
1459 .map_err(|_| StoreError::Synchronization {
1460 component: "WAL writer",
1461 })?;
1462 let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
1463 self.delete_with_writer(writer, &batch.doc_ids, &batch.expected)
1464 }
1465
1466 pub(crate) fn delete_with_writer(
1472 &self,
1473 writer: &mut StoreWal,
1474 doc_ids: &[DocId],
1475 expected: &[Option<ExpectedRevision>],
1476 ) -> Result<IngestAck, IngestError> {
1477 let (current_generation, current_segment) = {
1478 let active = self
1479 .active
1480 .lock()
1481 .map_err(|_| StoreError::Synchronization {
1482 component: "active segment",
1483 })?;
1484 let current = active.as_ref().ok_or(StoreError::Closed)?;
1485 (current.generation, Arc::clone(¤t.segment))
1486 };
1487 let snapshot = self
1488 .snapshot
1489 .read()
1490 .map_err(|_| StoreError::Synchronization {
1491 component: "published snapshot",
1492 })?
1493 .as_ref()
1494 .cloned()
1495 .ok_or(StoreError::Closed)?;
1496 check_revision_conditions(
1497 current_segment.as_ref(),
1498 &snapshot,
1499 doc_ids
1500 .iter()
1501 .enumerate()
1502 .map(|(index, doc_id)| (index, *doc_id, expected.get(index).copied().flatten())),
1503 )?;
1504 let sealed = purge::sealed_document_matches(&snapshot, doc_ids)?;
1505 let generation = current_generation
1506 .checked_add(1)
1507 .ok_or(StoreError::GenerationOverflow)?;
1508 let (mut next, rows) = current_segment.tombstone(doc_ids, &self.accounting)?;
1509 let prepared = purge::prepare_sealed_tombstones(
1510 self.vfs.as_ref(),
1511 &self.directory,
1512 &snapshot,
1513 &sealed,
1514 doc_ids,
1515 writer.durable_end(),
1516 generation,
1517 writer.durable_end().saturating_add(1),
1518 self.durability_policy,
1519 &self.accounting,
1520 )?;
1521 let payload = wal_payload::encode_delete(doc_ids).map_err(IngestError::Payload)?;
1522 let seq = match writer.commit(wal_payload::DELETE_V1, &payload) {
1523 Ok(seq) => seq,
1524 Err(error) => {
1525 if let Some(prepared) = prepared {
1526 prepared.abort(self.vfs.as_ref(), &self.directory, self.durability_policy)?;
1527 }
1528 return Err(error.into());
1529 }
1530 };
1531 for row in rows {
1532 next.set_sequence(row, seq)?;
1533 }
1534 let committed = prepared
1535 .map(|prepared| {
1536 prepared.commit(
1537 self,
1538 self.vfs.as_ref(),
1539 &self.directory,
1540 self.durability_policy,
1541 )
1542 })
1543 .transpose()?;
1544 let mut active = self
1545 .active
1546 .lock()
1547 .map_err(|_| StoreError::Synchronization {
1548 component: "active segment",
1549 })?;
1550 let actual_generation = active.as_ref().ok_or(StoreError::Closed)?.generation;
1551 validate_republish_generation(current_generation, actual_generation)?;
1552 let replaced_paths =
1553 self.publish_committed_active(&mut active, committed, generation, next)?;
1554 drop(active);
1555 purge::unlink_replaced_segments(
1556 self,
1557 self.vfs.as_ref(),
1558 &self.directory,
1559 &replaced_paths,
1560 self.durability_policy,
1561 );
1562 Ok(IngestAck { seq, generation })
1563 }
1564}
1565
1566fn encode_persisted_upsert(document: &IngestDocument) -> Result<(u16, Vec<u8>), IngestError> {
1567 wal_payload::encode_upsert_v2(document)
1568 .map(|payload| (wal_payload::UPSERT_V2, payload))
1569 .map_err(IngestError::Payload)
1570}
1571
1572pub(crate) fn validate_document_columns(
1573 schema: &Schema,
1574 document: &IngestDocument,
1575) -> Result<(), IngestError> {
1576 let mut builder = ColumnStoreBuilder::new(schema.clone());
1577 builder
1578 .push_row(document.timestamp(), &document.column_inputs())
1579 .map(|_| ())
1580 .map_err(IngestError::Columns)
1581}
1582
1583impl From<WalWriteError> for IngestError {
1584 fn from(error: WalWriteError) -> Self {
1585 Self::Store(StoreError::WalWrite(error))
1586 }
1587}
1588
1589#[cfg(test)]
1590mod vector_order_tests {
1591 use super::*;
1592
1593 #[test]
1594 fn documented_candidate_precedes_legacy_candidate_at_an_equal_score() {
1595 let segment = SegmentId::from_bytes([0x44; 16]);
1596 let legacy = SearchCandidate::new(
1597 GlobalRowId::new(RowSource::Sealed(segment), 0),
1598 None,
1599 1.0,
1600 true,
1601 );
1602 let documented = SearchCandidate::new(
1603 GlobalRowId::new(RowSource::Sealed(segment), 9),
1604 Some(DocumentVersion::new(DocId::new(9), Revision::new(1))),
1605 1.0,
1606 true,
1607 );
1608
1609 assert_eq!(
1610 compare_search_candidates(&documented, &legacy),
1611 std::cmp::Ordering::Less,
1612 "documented-vs-legacy order fell back to physical row identity"
1613 );
1614 }
1615}
1616
1617#[cfg(test)]
1618mod active_publish_tests {
1619 use super::*;
1620
1621 #[test]
1622 fn mismatched_active_generation_on_republish_is_a_typed_error() {
1623 assert!(matches!(
1624 validate_republish_generation(4, 5),
1625 Err(StoreError::ConcurrentActiveMutation {
1626 expected_generation: 4,
1627 actual_generation: 5,
1628 })
1629 ));
1630 }
1631}
1632
1633#[cfg(all(test, feature = "allocation-audit"))]
1634#[allow(clippy::expect_used)]
1635mod batch_working_copy_tests {
1636 use tempfile::tempdir;
1637
1638 use crate::lifecycle::OpenOptions;
1639
1640 use super::*;
1641
1642 #[test]
1643 fn batch_ingest_clones_the_active_segment_at_most_once() {
1644 let directory = tempdir().expect("store directory");
1645 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1646 store
1647 .ingest(IngestBatch::new(vec![IngestDocument::new(
1648 DocumentVersion::new(DocId::new(1), Revision::new(1)),
1649 vec![1.0, 0.0],
1650 )]))
1651 .expect("seed active segment");
1652 let documents = (2_u128..=25)
1653 .map(|doc_id| {
1654 IngestDocument::new(
1655 DocumentVersion::new(DocId::new(doc_id), Revision::new(1)),
1656 vec![1.0, 0.0],
1657 )
1658 })
1659 .collect::<Vec<_>>();
1660
1661 let (result, report) = crate::allocation_audit::audit_engine_path(|| {
1662 store.ingest(IngestBatch::new(documents))
1663 });
1664
1665 result.expect("ingest audited batch");
1666 assert_eq!(
1667 report.full_segment_clones, 1,
1668 "one ingest batch made more than one full active-segment clone"
1669 );
1670 }
1671}
1672
1673#[cfg(test)]
1674#[allow(clippy::expect_used)]
1675mod wal_replay_tests {
1676 use tempfile::tempdir;
1677
1678 use crate::lifecycle::{DocumentFields, OpenOptions, StoredDocument};
1679
1680 use super::*;
1681
1682 fn document(doc_id: u128, revision: u64, text: Option<&str>) -> IngestDocument {
1683 let document = IngestDocument::new(
1684 DocumentVersion::new(DocId::new(doc_id), Revision::new(revision)),
1685 vec![1.0, doc_id as f32],
1686 );
1687 match text {
1688 Some(text) => document.with_text(text),
1689 None => document,
1690 }
1691 }
1692
1693 pub(super) fn write_mixed_wal(store: &Store) {
1696 for doc_id in 1_u128..=40 {
1697 let text = format!("alpha beta note {doc_id}");
1698 let text = (doc_id > 3 && doc_id % 7 != 0).then_some(text.as_str());
1699 store
1700 .ingest(IngestBatch::new(vec![document(doc_id, 1, text)]))
1701 .expect("ingest one row");
1702 }
1703 for doc_id in [3_u128, 10, 21] {
1704 store
1705 .ingest(IngestBatch::new(vec![document(
1706 doc_id,
1707 2,
1708 Some("gamma replaced text"),
1709 )]))
1710 .expect("replace one row");
1711 }
1712 store
1713 .delete(DeleteBatch::new(vec![
1714 DocId::new(5),
1715 DocId::new(6),
1716 DocId::new(10),
1717 ]))
1718 .expect("delete rows");
1719 store
1720 .ingest(IngestBatch::new(vec![document(
1721 5,
1722 2,
1723 Some("delta revived"),
1724 )]))
1725 .expect("write a deleted id again");
1726 }
1727
1728 fn observed(store: &Store) -> (Vec<Option<StoredDocument>>, crate::fts::bm25::CorpusStats) {
1729 let ids = (1_u128..=41).map(DocId::new).collect::<Vec<_>>();
1730 (
1731 store
1732 .get_documents(&ids, DocumentFields::TEXT)
1733 .expect("read documents"),
1734 store.lexical_corpus_stats().expect("corpus stats"),
1735 )
1736 }
1737
1738 #[test]
1739 fn sealed_revision_lookup_finds_every_sealed_copy_of_an_id() {
1740 let directory = tempdir().expect("store directory");
1741 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1742 for (doc_id, revision) in [(1_u128, 1_u64), (2, 1), (3, 1)] {
1743 store
1744 .ingest(IngestBatch::new(vec![document(
1745 doc_id,
1746 revision,
1747 Some("first"),
1748 )]))
1749 .expect("ingest first segment");
1750 }
1751 store.seal().expect("seal first segment");
1752 store
1753 .ingest(IngestBatch::new(vec![document(2, 2, Some("second"))]))
1754 .expect("replace a sealed row");
1755 store.seal().expect("seal second segment");
1756
1757 let stale = store.ingest(IngestBatch::new(vec![document(2, 1, Some("stale"))]));
1758 assert!(
1759 matches!(stale, Err(IngestError::StaleRevision { .. })),
1760 "a revision older than a sealed one was accepted: {stale:?}"
1761 );
1762 store
1763 .ingest(IngestBatch::new(vec![document(2, 3, Some("third"))]))
1764 .expect("write a newer revision");
1765 store
1766 .delete(DeleteBatch::new(vec![DocId::new(3)]))
1767 .expect("delete a sealed row");
1768 let documents = store
1769 .get_documents(
1770 &[DocId::new(1), DocId::new(2), DocId::new(3)],
1771 DocumentFields::TEXT,
1772 )
1773 .expect("read documents");
1774 let texts = documents
1775 .iter()
1776 .map(|document| document.as_ref().and_then(|document| document.text.clone()))
1777 .collect::<Vec<_>>();
1778 assert_eq!(
1779 texts,
1780 vec![Some("first".to_owned()), Some("third".to_owned()), None]
1781 );
1782 store.close().expect("close store");
1783 }
1784
1785 #[test]
1786 fn wal_replay_reopens_the_exact_live_state() {
1787 let directory = tempdir().expect("store directory");
1788 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1789 write_mixed_wal(&store);
1790 let before = observed(&store);
1791 store.close().expect("close store");
1792
1793 let reopened = Store::open(directory.path(), OpenOptions::default()).expect("reopen");
1794 assert_eq!(observed(&reopened), before);
1795 reopened.close().expect("close reopened store");
1796 }
1797}
1798
1799#[cfg(all(test, feature = "allocation-audit"))]
1800#[allow(clippy::expect_used)]
1801mod wal_replay_copy_tests {
1802 use tempfile::tempdir;
1803
1804 use crate::lifecycle::OpenOptions;
1805
1806 use super::*;
1807
1808 #[test]
1809 fn wal_replay_clones_the_active_segment_at_most_once() {
1810 let directory = tempdir().expect("store directory");
1811 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1812 super::wal_replay_tests::write_mixed_wal(&store);
1813 store.close().expect("close store");
1814
1815 let (reopened, report) = crate::allocation_audit::audit_engine_path(|| {
1816 Store::open(directory.path(), OpenOptions::default())
1817 });
1818
1819 reopened
1820 .expect("reopen")
1821 .close()
1822 .expect("close reopened store");
1823 assert!(
1824 report.full_segment_clones <= 1,
1825 "WAL replay cloned the active segment {} times",
1826 report.full_segment_clones
1827 );
1828 }
1829}