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