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<crate::fts::index::IndexError> for IngestError {
506 fn from(error: crate::fts::index::IndexError) -> Self {
507 Self::Lexical(error)
508 }
509}
510
511impl From<StoreError> for IngestError {
512 fn from(error: StoreError) -> Self {
513 Self::Store(error)
514 }
515}
516
517#[derive(Clone, Copy, Debug)]
519pub struct SearchRequest<'a> {
520 vector: &'a [f32],
521 pub(crate) filter: Option<&'a crate::lifecycle::QueryFilter>,
522 pub(crate) eligible: Option<&'a [DocId]>,
523}
524
525impl<'a> SearchRequest<'a> {
526 #[must_use]
528 pub const fn new(vector: &'a [f32]) -> Self {
529 Self {
530 vector,
531 filter: None,
532 eligible: None,
533 }
534 }
535
536 #[must_use]
538 pub const fn with_filter(mut self, filter: Option<&'a crate::lifecycle::QueryFilter>) -> Self {
539 self.filter = filter;
540 self
541 }
542
543 #[must_use]
546 pub const fn with_eligible(mut self, ids: &'a [DocId]) -> Self {
547 self.eligible = Some(ids);
548 self
549 }
550
551 #[must_use]
553 pub const fn vector(self) -> &'a [f32] {
554 self.vector
555 }
556}
557
558#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
560pub enum RowSource {
561 Active,
563 Sealed(SegmentId),
565}
566
567#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
569pub struct GlobalRowId {
570 source: RowSource,
571 local_row: u32,
572}
573
574impl GlobalRowId {
575 pub(crate) const fn new(source: RowSource, local_row: u32) -> Self {
576 Self { source, local_row }
577 }
578
579 #[must_use]
581 pub const fn source(self) -> RowSource {
582 self.source
583 }
584
585 #[must_use]
587 pub const fn local_row(self) -> u32 {
588 self.local_row
589 }
590}
591
592#[derive(Clone, Copy, Debug, PartialEq)]
594pub struct SearchCandidate {
595 row_id: GlobalRowId,
596 document: Option<DocumentVersion>,
597 score: f32,
598 exact_score: bool,
599}
600
601impl SearchCandidate {
602 pub(crate) const fn new(
603 row_id: GlobalRowId,
604 document: Option<DocumentVersion>,
605 score: f32,
606 exact_score: bool,
607 ) -> Self {
608 Self {
609 row_id,
610 document,
611 score,
612 exact_score,
613 }
614 }
615
616 #[must_use]
618 pub const fn row_id(self) -> GlobalRowId {
619 self.row_id
620 }
621
622 #[must_use]
625 pub const fn document(self) -> Option<DocumentVersion> {
626 self.document
627 }
628
629 #[must_use]
631 pub const fn score(self) -> f32 {
632 self.score
633 }
634
635 pub(crate) const fn exact_score(self) -> bool {
636 self.exact_score
637 }
638}
639
640pub(crate) fn compare_search_candidates(
646 left: &SearchCandidate,
647 right: &SearchCandidate,
648) -> std::cmp::Ordering {
649 right.score().total_cmp(&left.score()).then_with(|| {
650 match (left.document(), right.document()) {
651 (Some(left_document), Some(right_document)) => left_document
652 .doc_id()
653 .cmp(&right_document.doc_id())
654 .then_with(|| right_document.revision().cmp(&left_document.revision())),
655 (Some(_), None) => std::cmp::Ordering::Less,
656 (None, Some(_)) => std::cmp::Ordering::Greater,
657 (None, None) => std::cmp::Ordering::Equal,
658 }
659 .then_with(|| left.row_id().cmp(&right.row_id()))
660 })
661}
662
663#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
665pub struct GraphSearchStats {
666 pub segments_traversed: usize,
668 pub graph_validations: usize,
670 pub entry_seed_discoveries: usize,
672 pub visited_epoch_clears: usize,
674 pub candidates_scored: usize,
676 pub candidates_rescored: usize,
678 pub segments_pruned_by_bound: usize,
680}
681
682#[derive(Clone, Debug, PartialEq)]
684pub struct SearchOutcome {
685 pub candidates: Vec<SearchCandidate>,
687 pub vector_ceiling: Option<f64>,
691 pub stats: ScanStats,
693 pub graph_stats: GraphSearchStats,
695 pub generation: u64,
697 pub epoch: Option<crate::epoch::EpochIdentity>,
699 pub diagnostics: crate::diag::QueryDiagnostics,
701}
702
703#[derive(Clone, Copy, Debug, PartialEq)]
705pub struct LexicalCandidate {
706 pub document: DocumentVersion,
708 pub score: f64,
710}
711
712#[derive(Clone, Debug, PartialEq)]
714pub struct StoreLexicalSearchOutcome {
715 pub candidates: Vec<LexicalCandidate>,
717 pub generation: u64,
719 pub diagnostics: crate::diag::QueryDiagnostics,
721}
722
723#[derive(Clone, Debug, PartialEq)]
725pub struct ExplainedLexicalCandidate {
726 pub document: DocumentVersion,
728 pub score: f64,
730 pub provenance: Vec<crate::fts::query::LexicalExpansion>,
732 pub snippet: crate::fts::query::OwnedLexicalSnippet,
734}
735
736#[derive(Clone, Debug, PartialEq)]
738pub struct StoreStructuredLexicalSearchOutcome {
739 pub candidates: Vec<ExplainedLexicalCandidate>,
741 pub expansions: Vec<crate::fts::query::LexicalExpansion>,
743 pub generation: u64,
745 pub diagnostics: crate::diag::QueryDiagnostics,
747}
748
749#[derive(Clone, Debug, PartialEq)]
751pub struct StoreHybridSearchOutcome {
752 pub hits: Vec<crate::fusion::FusedHit<DocId>>,
754 pub lexical_expansions: Vec<crate::fts::query::LexicalExpansion>,
756 pub generation: u64,
758 pub diagnostics: crate::diag::QueryDiagnostics,
760}
761
762#[derive(Debug)]
764pub enum StoreLexicalError {
765 Query(crate::lifecycle::QueryError),
767 Lexical(crate::planner::LexicalFilterError),
769 MissingDocumentIdentity {
771 segment_id: SegmentId,
773 },
774 MissingStoredText {
776 segment_id: SegmentId,
778 row: u32,
780 },
781 MissingSnippetMatch {
783 segment: u32,
785 row: u32,
787 },
788 Structured(crate::fts::query::LexicalQueryError),
790 Snippet(crate::fts::snippet::SnippetError),
792 Tokenizer(crate::fts::tokenizer::TokenizerError),
794}
795
796impl std::fmt::Display for StoreLexicalError {
797 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
798 match self {
799 Self::Query(error) => error.fmt(formatter),
800 Self::Lexical(error) => error.fmt(formatter),
801 Self::MissingDocumentIdentity { segment_id } => write!(
802 formatter,
803 "sealed lexical segment {segment_id} has no document identity"
804 ),
805 Self::MissingStoredText { segment_id, row } => write!(
806 formatter,
807 "sealed lexical segment {segment_id} row {row} has no stored source text"
808 ),
809 Self::MissingSnippetMatch { segment, row } => write!(
810 formatter,
811 "lexical source {segment} row {row} has no snippet match span"
812 ),
813 Self::Structured(error) => error.fmt(formatter),
814 Self::Snippet(error) => error.fmt(formatter),
815 Self::Tokenizer(error) => error.fmt(formatter),
816 }
817 }
818}
819
820impl std::error::Error for StoreLexicalError {
821 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
822 match self {
823 Self::Query(error) => Some(error),
824 Self::Lexical(error) => Some(error),
825 Self::Structured(error) => Some(error),
826 Self::Snippet(error) => Some(error),
827 Self::Tokenizer(error) => Some(error),
828 Self::MissingDocumentIdentity { .. }
829 | Self::MissingStoredText { .. }
830 | Self::MissingSnippetMatch { .. } => None,
831 }
832 }
833}
834
835impl From<crate::lifecycle::QueryError> for StoreLexicalError {
836 fn from(error: crate::lifecycle::QueryError) -> Self {
837 Self::Query(error)
838 }
839}
840
841impl From<StoreError> for StoreLexicalError {
842 fn from(error: StoreError) -> Self {
843 Self::Query(crate::lifecycle::QueryError::Store(error))
844 }
845}
846
847impl From<crate::planner::LexicalFilterError> for StoreLexicalError {
848 fn from(error: crate::planner::LexicalFilterError) -> Self {
849 Self::Lexical(error)
850 }
851}
852
853impl From<crate::fts::query::LexicalQueryError> for StoreLexicalError {
854 fn from(error: crate::fts::query::LexicalQueryError) -> Self {
855 Self::Structured(error)
856 }
857}
858
859impl From<crate::fts::snippet::SnippetError> for StoreLexicalError {
860 fn from(error: crate::fts::snippet::SnippetError) -> Self {
861 Self::Snippet(error)
862 }
863}
864
865impl From<crate::fts::tokenizer::TokenizerError> for StoreLexicalError {
866 fn from(error: crate::fts::tokenizer::TokenizerError) -> Self {
867 Self::Tokenizer(error)
868 }
869}
870
871#[derive(Clone, Copy)]
872struct ResolvedRevision {
873 version: DocumentVersion,
874 seq: LogSeq,
875 active_row: Option<usize>,
876}
877
878struct RevisionProgress {
879 working: Option<ActiveSegment>,
880 records: Vec<(usize, u16, Vec<u8>)>,
881 replay_seq: Option<LogSeq>,
882 #[cfg(any(test, feature = "test-seams"))]
883 replay_count: usize,
884 sealed_tombstones: Vec<DocId>,
885}
886
887fn resolve_revision(
888 active: &ActiveSegment,
889 sealed: &[purge::SealedDocumentMatch],
890 doc_id: DocId,
891 sealed_seq: LogSeq,
892) -> Option<ResolvedRevision> {
893 let active_existing = active.existing(doc_id);
894 let active_row = active_existing.map(|(row, _, _)| row);
895 let mut resolved = active_existing.map(|(_, version, seq)| ResolvedRevision {
896 version,
897 seq,
898 active_row,
899 });
900 for matched in sealed
901 .iter()
902 .filter(|matched| matched.version.doc_id() == doc_id)
903 {
904 if resolved.is_none_or(|current| matched.version.revision() > current.version.revision()) {
905 resolved = Some(ResolvedRevision {
906 version: matched.version,
907 seq: sealed_seq,
908 active_row,
909 });
910 }
911 }
912 resolved
913}
914
915fn live_revision(
918 active: &ActiveSegment,
919 snapshot: &PublishedSnapshot,
920 doc_id: DocId,
921) -> Result<Option<Revision>, StoreError> {
922 if let Some((row, version, _)) = active.existing(doc_id)
923 && !active.is_tombstoned(row)
924 {
925 return Ok(Some(version.revision()));
926 }
927 for segment in snapshot.segments() {
928 for row in segment.query_rows_for_doc_id(doc_id)? {
929 if let Some(version) = segment.document_version(row).map_err(StoreError::Segment)? {
930 return Ok(Some(version.revision()));
931 }
932 }
933 }
934 Ok(None)
935}
936
937fn check_revision_conditions(
941 active: &ActiveSegment,
942 snapshot: &PublishedSnapshot,
943 conditions: impl Iterator<Item = (usize, DocId, Option<ExpectedRevision>)>,
944) -> Result<(), IngestError> {
945 for (index, doc_id, expected) in conditions {
946 let Some(expected) = expected else {
947 continue;
948 };
949 let current = live_revision(active, snapshot, doc_id)?;
950 let holds = match expected {
951 ExpectedRevision::Exactly(revision) => current == Some(revision),
952 ExpectedRevision::Absent => current.is_none(),
953 };
954 if !holds {
955 return Err(IngestError::RevisionConflict {
956 index,
957 doc_id,
958 expected,
959 current,
960 });
961 }
962 }
963 Ok(())
964}
965
966fn validate_republish_generation(
967 expected_generation: u64,
968 actual_generation: u64,
969) -> Result<(), StoreError> {
970 if actual_generation != expected_generation {
971 return Err(StoreError::ConcurrentActiveMutation {
972 expected_generation,
973 actual_generation,
974 });
975 }
976 Ok(())
977}
978
979impl Store {
980 pub fn lexical_corpus_stats(&self) -> Result<crate::fts::bm25::CorpusStats, StoreLexicalError> {
987 let state = self
988 .state
989 .lock()
990 .map_err(|_| StoreError::Synchronization { component: "state" })?;
991 match *state {
992 StoreState::Open => {}
993 StoreState::Closing => return Err(StoreError::Closing.into()),
994 StoreState::Closed => return Err(StoreError::Closed.into()),
995 }
996 let active_guard = self
997 .active
998 .lock()
999 .map_err(|_| StoreError::Synchronization {
1000 component: "active segment",
1001 })?;
1002 let active = active_guard.as_ref().ok_or(StoreError::Closed)?;
1003 let snapshot = self
1004 .snapshot
1005 .read()
1006 .map_err(|_| StoreError::Synchronization {
1007 component: "published snapshot",
1008 })?
1009 .as_ref()
1010 .cloned()
1011 .ok_or(StoreError::Closed)?;
1012 let mut index = crate::fts::index::LexicalIndex::new();
1013 for segment in snapshot.segments() {
1014 if let Some(postings) = segment.query_postings()? {
1015 let alive = segment.query_alive()?;
1016 index
1017 .push_shared_with_live_rows(postings, alive.alive_bitmap())
1018 .map_err(crate::planner::LexicalFilterError::from)?;
1019 }
1020 }
1021 if active.segment.has_text() {
1022 let sealed = active.segment.sealed_lexical(&self.accounting)?;
1023 let alive = active.segment.alive()?;
1024 index
1025 .push_shared_with_live_rows(sealed, alive.alive_bitmap())
1026 .map_err(crate::planner::LexicalFilterError::from)?;
1027 }
1028 index
1029 .corpus_stats()
1030 .map_err(crate::planner::LexicalFilterError::from)
1031 .map_err(StoreLexicalError::from)
1032 }
1033
1034 #[inline(always)]
1035 fn apply_revision_decision(
1036 &self,
1037 current: &ActiveSegment,
1038 sealed: &[purge::SealedDocumentMatch],
1039 sealed_seq: LogSeq,
1040 document: &IngestDocument,
1041 remaining: &[IngestDocument],
1042 progress: &mut RevisionProgress,
1043 ) -> Result<(), IngestError> {
1044 let segment = progress.working.as_ref().unwrap_or(current);
1045 let existing = resolve_revision(segment, sealed, document.version().doc_id(), sealed_seq);
1046 let action = revise::classify_revision(
1047 existing.map(|resolved| (resolved.version, resolved.seq)),
1048 document.version(),
1049 );
1050 match action {
1051 revise::RevisionDecision::Replace => {
1052 let row = existing.and_then(|resolved| resolved.active_row);
1053 if progress.working.is_none() {
1054 progress.working =
1055 Some(current.copy_for_batch(document, remaining, &self.accounting)?);
1056 }
1057 let working = progress
1058 .working
1059 .as_mut()
1060 .ok_or(StoreError::Synchronization {
1061 component: "batch working segment",
1062 })?;
1063 let row = if let Some(row) = row {
1064 working.replace_in_place(row, document, &self.accounting, &self.tokenizer)?;
1065 row
1066 } else {
1067 working.insert_in_place(document, &self.accounting, &self.tokenizer)?
1068 };
1069 let (op, payload) = encode_persisted_upsert(document)?;
1070 progress.records.push((row, op, payload));
1071 if sealed
1072 .iter()
1073 .any(|matched| matched.version.doc_id() == document.version().doc_id())
1074 && !progress
1075 .sealed_tombstones
1076 .contains(&document.version().doc_id())
1077 {
1078 progress.sealed_tombstones.push(document.version().doc_id());
1079 }
1080 }
1081 revise::RevisionDecision::Insert => {
1082 if progress.working.is_none() {
1083 progress.working =
1084 Some(current.copy_for_batch(document, remaining, &self.accounting)?);
1085 }
1086 let working = progress
1087 .working
1088 .as_mut()
1089 .ok_or(StoreError::Synchronization {
1090 component: "batch working segment",
1091 })?;
1092 let row = working.insert_in_place(document, &self.accounting, &self.tokenizer)?;
1093 let (op, payload) = encode_persisted_upsert(document)?;
1094 progress.records.push((row, op, payload));
1095 }
1096 revise::RevisionDecision::Replay { seq } => {
1097 progress.replay_seq = Some(
1098 progress
1099 .replay_seq
1100 .map_or(seq, |current: LogSeq| current.max(seq)),
1101 );
1102 #[cfg(any(test, feature = "test-seams"))]
1103 {
1104 progress.replay_count = progress.replay_count.saturating_add(1);
1105 }
1106 }
1107 revise::RevisionDecision::Reject { current } => {
1108 return Err(IngestError::StaleRevision {
1109 doc_id: document.version().doc_id(),
1110 current,
1111 attempted: document.version().revision(),
1112 });
1113 }
1114 }
1115 Ok(())
1116 }
1117
1118 #[cfg(any(test, feature = "test-seams"))]
1119 fn record_post_ack_retry_receipt(
1120 &self,
1121 batch: &IngestBatch,
1122 replay_count: usize,
1123 seq: LogSeq,
1124 generation: u64,
1125 ) -> Result<(), IngestError> {
1126 if let Some(controller) = self.ingest_retention_fault_controller.as_ref() {
1127 let plan =
1128 controller
1129 .post_ack_retry_plan()
1130 .map_err(|_| StoreError::Synchronization {
1131 component: "ingest-retention controller",
1132 })?;
1133 if let Some((invocation_id, first_ack_seq, first_ack_generation)) = plan
1134 && first_ack_seq == seq.get()
1135 && first_ack_generation == generation
1136 {
1137 let batch_count = u64::try_from(batch.documents.len())
1138 .map_err(|_| StoreError::ActiveRowOverflow)?;
1139 let replay_count =
1140 u64::try_from(replay_count).map_err(|_| StoreError::ActiveRowOverflow)?;
1141 controller
1142 .push_receipt(IngestRetentionFaultReceiptV1::post_ack_retry(
1143 invocation_id,
1144 batch_count,
1145 replay_count,
1146 seq.get(),
1147 generation,
1148 ))
1149 .map_err(|_| StoreError::Synchronization {
1150 component: "ingest-retention controller",
1151 })?;
1152 }
1153 }
1154 Ok(())
1155 }
1156
1157 #[cfg(any(test, feature = "test-seams"))]
1158 fn recover_partial_batch_append(
1159 &self,
1160 writer: &mut StoreWal,
1161 error: &StoreError,
1162 partial_append_clean_wal: &Option<Vec<u8>>,
1163 batch: &IngestBatch,
1164 records: &[(usize, u16, Vec<u8>)],
1165 ) -> Result<(), IngestError> {
1166 if let Some(controller) = self.ingest_retention_fault_controller.as_ref()
1167 && let Some(observation) =
1168 controller
1169 .take_partial_batch_append_observation()
1170 .map_err(|_| StoreError::Synchronization {
1171 component: "ingest-retention controller",
1172 })?
1173 {
1174 let expected_detail = observation.detail.as_str();
1175 match error {
1176 StoreError::WalWrite(WalWriteError::Failed { kind, detail })
1177 if *kind == std::io::ErrorKind::Other && detail.as_ref() == expected_detail => {
1178 }
1179 _ => {
1180 return Err(StoreError::Synchronization {
1181 component: "partial-batch-append typed WAL error",
1182 }
1183 .into());
1184 }
1185 }
1186 let clean_wal =
1187 partial_append_clean_wal
1188 .as_deref()
1189 .ok_or(StoreError::Synchronization {
1190 component: "partial-batch-append clean WAL image",
1191 })?;
1192 let wal_path = self.directory.join("wal.ze");
1193 writer.restore_after_failed_append(
1194 self.vfs.as_ref(),
1195 &wal_path,
1196 self.durability_policy,
1197 clean_wal,
1198 )?;
1199 let submitted_count =
1200 u64::try_from(batch.documents.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1201 let changed_records =
1202 u64::try_from(records.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1203 let encoded_bytes = u64::try_from(observation.encoded_bytes)
1204 .map_err(|_| StoreError::ActiveRowOverflow)?;
1205 let prefix_bytes = u64::try_from(observation.prefix_bytes)
1206 .map_err(|_| StoreError::ActiveRowOverflow)?;
1207 controller
1208 .push_receipt(IngestRetentionFaultReceiptV1::partial_batch_append(
1209 observation.invocation_id,
1210 submitted_count,
1211 changed_records,
1212 encoded_bytes,
1213 prefix_bytes,
1214 observation.detail,
1215 ))
1216 .map_err(|_| StoreError::Synchronization {
1217 component: "ingest-retention controller",
1218 })?;
1219 }
1220 Ok(())
1221 }
1222
1223 #[inline(always)]
1224 fn publish_committed_active(
1225 &self,
1226 active: &mut Option<ActiveState>,
1227 committed: Option<(PublishedSnapshot, Vec<PathBuf>)>,
1228 generation: u64,
1229 next: ActiveSegment,
1230 ) -> Result<Vec<PathBuf>, StoreError> {
1231 if let Some((remapped, replaced_paths)) = committed {
1232 let mut published = self
1233 .snapshot
1234 .write()
1235 .map_err(|_| StoreError::Synchronization {
1236 component: "published snapshot",
1237 })?;
1238 let previous = published.replace(Arc::new(remapped));
1239 *active = Some(ActiveState {
1240 generation,
1241 segment: Arc::new(next),
1242 });
1243 drop(published);
1244 drop(previous);
1245 Ok(replaced_paths)
1246 } else {
1247 *active = Some(ActiveState {
1248 generation,
1249 segment: Arc::new(next),
1250 });
1251 Ok(Vec::new())
1252 }
1253 }
1254
1255 pub fn ingest(&self, batch: IngestBatch) -> Result<IngestAck, IngestError> {
1257 #[cfg(feature = "graph-cypher")]
1258 {
1259 self.with_native_document_recovery(
1260 || self.ingest_attempt(&batch),
1261 IngestError::is_native_reclaim_debt,
1262 |error| error,
1263 )
1264 }
1265 #[cfg(not(feature = "graph-cypher"))]
1266 self.ingest_attempt(&batch)
1267 }
1268
1269 fn ingest_attempt(&self, batch: &IngestBatch) -> Result<IngestAck, IngestError> {
1270 let state = self
1271 .state
1272 .lock()
1273 .map_err(|_| StoreError::Synchronization { component: "state" })?;
1274 match *state {
1275 StoreState::Open => {}
1276 StoreState::Closing => return Err(StoreError::Closing.into()),
1277 StoreState::Closed => return Err(StoreError::Closed.into()),
1278 }
1279 drop(state);
1280 match (self.epoch_identity(), batch.epoch) {
1281 (Some(expected), Some(declared)) if expected != declared => {
1282 return Err(IngestError::EpochMismatch(crate::epoch::EpochMismatch {
1283 expected,
1284 declared,
1285 }));
1286 }
1287 (Some(_), None) => return Err(IngestError::EpochUndeclared),
1288 (None, Some(_)) => return Err(IngestError::EpochUnstamped),
1289 (Some(_), Some(_)) | (None, None) => {}
1290 }
1291 if batch.documents.is_empty() {
1292 return Err(IngestError::EmptyBatch);
1293 }
1294 if self.epoch.as_ref().is_some_and(|epoch| {
1295 epoch.embedding.document.normalization == crate::epoch::Normalization::L2
1296 }) {
1297 for document in &batch.documents {
1298 if let Some(squared_norm) =
1299 crate::graph::search::non_unit_squared_norm(document.vector())
1300 {
1301 return Err(IngestError::Vector(crate::quant::QuantError::NonUnitNorm {
1302 squared_norm_bits: squared_norm.to_bits(),
1303 tolerance_bits: crate::graph::search::UNIT_NORM_SQUARED_TOLERANCE.to_bits(),
1304 }));
1305 }
1306 }
1307 }
1308 for document in &batch.documents {
1309 validate_document_columns(&self.schema, document)?;
1310 }
1311 #[cfg(feature = "graph-cypher")]
1312 let mut native_writer = self.native_document_writer()?;
1313 #[cfg(feature = "graph-cypher")]
1314 if let Some(writer) = native_writer.as_mut().and_then(|slot| slot.as_mut())
1315 && self.documents_have_native_nodes(
1316 &batch
1317 .documents
1318 .iter()
1319 .map(|document| document.version().doc_id())
1320 .collect::<Vec<_>>(),
1321 )?
1322 {
1323 let result =
1324 self.apply_native_mixed_locked(batch, writer)
1325 .map_err(|error| match error {
1326 crate::lifecycle::native_graph::NativeGraphError::Ingest(error) => error,
1327 error => IngestError::Graph(Box::new(
1328 crate::property_graph::query::completed::GraphQueryError::from(error),
1329 )),
1330 })?;
1331 let generation = result
1332 .changed_generation()
1333 .unwrap_or(result.admitted_generation())
1334 .get();
1335 return Ok(IngestAck::mixed(result.seq, generation));
1336 }
1337 #[cfg(test)]
1338 active_publish_tests::after_mutation_admission();
1339 let mut wal = self
1340 .wal_writer
1341 .lock()
1342 .map_err(|_| StoreError::Synchronization {
1343 component: "WAL writer",
1344 })?;
1345 let Some(writer) = wal.as_mut() else {
1346 drop(wal);
1347 let state = self
1348 .state
1349 .lock()
1350 .map_err(|_| StoreError::Synchronization { component: "state" })?;
1351 return Err(match *state {
1352 StoreState::Closing => StoreError::Closing,
1353 StoreState::Closed => StoreError::Closed,
1354 StoreState::Open => StoreError::ReadOnly,
1355 }
1356 .into());
1357 };
1358 let mut publication = writer.manifest_publication()?;
1359 let (current_generation, current_segment) = {
1360 let active = self
1361 .active
1362 .lock()
1363 .map_err(|_| StoreError::Synchronization {
1364 component: "active segment",
1365 })?;
1366 let current = active.as_ref().ok_or(StoreError::Closed)?;
1367 (current.generation, Arc::clone(¤t.segment))
1368 };
1369 let snapshot = self
1370 .snapshot
1371 .read()
1372 .map_err(|_| StoreError::Synchronization {
1373 component: "published snapshot",
1374 })?
1375 .as_ref()
1376 .cloned()
1377 .ok_or(StoreError::Closed)?;
1378 check_revision_conditions(
1379 current_segment.as_ref(),
1380 &snapshot,
1381 batch.documents.iter().enumerate().map(|(index, document)| {
1382 (
1383 index,
1384 document.version().doc_id(),
1385 document.expected_revision(),
1386 )
1387 }),
1388 )?;
1389 let requested_ids = batch
1390 .documents
1391 .iter()
1392 .map(|document| document.version().doc_id())
1393 .collect::<Vec<_>>();
1394 let sealed = purge::sealed_document_matches(&snapshot, &requested_ids)?;
1395 let sealed_seq = LogSeq::new(snapshot.absorbed_through());
1396 let mut progress = RevisionProgress {
1397 working: None,
1398 records: Vec::new(),
1399 replay_seq: None,
1400 #[cfg(any(test, feature = "test-seams"))]
1401 replay_count: 0,
1402 sealed_tombstones: Vec::new(),
1403 };
1404 for (index, document) in batch.documents.iter().enumerate() {
1405 let remaining = batch
1406 .documents
1407 .get(index..)
1408 .ok_or(StoreError::ActiveRowOverflow)?;
1409 self.apply_revision_decision(
1410 current_segment.as_ref(),
1411 &sealed,
1412 sealed_seq,
1413 document,
1414 remaining,
1415 &mut progress,
1416 )?;
1417 }
1418 let RevisionProgress {
1419 working,
1420 records,
1421 replay_seq,
1422 #[cfg(any(test, feature = "test-seams"))]
1423 replay_count,
1424 sealed_tombstones,
1425 } = progress;
1426 let Some(mut next) = working else {
1427 let seq = replay_seq.ok_or(StoreError::Synchronization {
1428 component: "nonempty ingest replay sequence",
1429 })?;
1430 #[cfg(any(test, feature = "test-seams"))]
1431 self.record_post_ack_retry_receipt(batch, replay_count, seq, current_generation)?;
1432 return Ok(IngestAck {
1433 seq,
1434 generation: current_generation,
1435 });
1436 };
1437 let records = atomic_batch::frame_batch(records)?;
1438 let generation = current_generation
1439 .checked_add(1)
1440 .ok_or(StoreError::GenerationOverflow)?;
1441 let prepared = purge::prepare_sealed_tombstones(
1442 self.vfs.as_ref(),
1443 &self.directory,
1444 &snapshot,
1445 &sealed,
1446 &sealed_tombstones,
1447 writer.durable_end(),
1448 generation,
1449 writer.durable_end().saturating_add(1),
1450 #[cfg(feature = "graph-cypher")]
1451 Some(
1452 writer
1453 .durable_end()
1454 .checked_add(records.len() as u64)
1455 .ok_or(StoreError::GenerationOverflow)?,
1456 ),
1457 self.durability_policy,
1458 &self.accounting,
1459 )?;
1460 let record_refs = records
1461 .iter()
1462 .map(|(_, op, payload)| (*op, payload.as_slice()))
1463 .collect::<Vec<_>>();
1464 #[cfg(any(test, feature = "test-seams"))]
1465 let partial_append_clean_wal = match self.ingest_retention_fault_controller.as_ref() {
1466 Some(controller)
1467 if controller
1468 .partial_batch_append_plan()
1469 .map_err(|_| StoreError::Synchronization {
1470 component: "ingest-retention controller",
1471 })?
1472 .is_some() =>
1473 {
1474 let path = self.directory.join("wal.ze");
1475 Some(
1476 self.vfs
1477 .read(&path)
1478 .map_err(|source| StoreError::Io { path, source })?,
1479 )
1480 }
1481 Some(_) | None => None,
1482 };
1483 publication = publication.arm();
1484 let sequences = match writer.commit_many(&record_refs) {
1485 Ok(sequences) => sequences,
1486 Err(error) => {
1487 if let Some(prepared) = prepared {
1488 prepared.abort(self.vfs.as_ref(), &self.directory, self.durability_policy)?;
1489 }
1490 #[cfg(any(test, feature = "test-seams"))]
1491 self.recover_partial_batch_append(
1492 writer,
1493 &error,
1494 &partial_append_clean_wal,
1495 batch,
1496 &records,
1497 )?;
1498 publication.on_wal_error(&error);
1499 return Err(error.into());
1500 }
1501 };
1502 for ((row, _, _), sequence) in records
1503 .iter()
1504 .zip(sequences.start.get()..sequences.end.get())
1505 {
1506 next.set_sequence(*row, LogSeq::new(sequence))?;
1507 }
1508 let seq = LogSeq::new(sequences.end.get().saturating_sub(1));
1509 let committed = prepared
1510 .map(|prepared| {
1511 prepared.commit(
1512 &mut publication,
1513 self,
1514 self.vfs.as_ref(),
1515 &self.directory,
1516 self.durability_policy,
1517 )
1518 })
1519 .transpose()?;
1520 let mut active = self
1521 .active
1522 .lock()
1523 .map_err(|_| StoreError::Synchronization {
1524 component: "active segment",
1525 })?;
1526 let actual_generation = active.as_ref().ok_or(StoreError::Closed)?.generation;
1527 validate_republish_generation(current_generation, actual_generation)?;
1528 let replaced_paths =
1529 self.publish_committed_active(&mut active, committed, generation, next)?;
1530 #[cfg(feature = "graph-cypher")]
1531 self.publish_native_documents(active.as_ref().ok_or(StoreError::Closed)?)?;
1532 publication.complete();
1533 drop(active);
1534 purge::unlink_replaced_segments(
1535 self,
1536 self.vfs.as_ref(),
1537 &self.directory,
1538 &replaced_paths,
1539 self.durability_policy,
1540 );
1541 drop(wal);
1542 Ok(IngestAck { seq, generation })
1543 }
1544
1545 pub fn delete(&self, batch: DeleteBatch) -> Result<IngestAck, IngestError> {
1547 #[cfg(feature = "graph-cypher")]
1548 {
1549 self.with_native_document_recovery(
1550 || self.delete_attempt(&batch),
1551 IngestError::is_native_reclaim_debt,
1552 |error| error,
1553 )
1554 }
1555 #[cfg(not(feature = "graph-cypher"))]
1556 self.delete_attempt(&batch)
1557 }
1558
1559 fn delete_attempt(&self, batch: &DeleteBatch) -> Result<IngestAck, IngestError> {
1560 if batch.doc_ids.is_empty() {
1561 return Err(IngestError::EmptyBatch);
1562 }
1563 #[cfg(feature = "graph-cypher")]
1564 let mut native_writer = self.native_document_writer()?;
1565 #[cfg(feature = "graph-cypher")]
1566 if let Some(writer) = native_writer.as_mut().and_then(|slot| slot.as_mut())
1567 && self.documents_have_native_nodes(batch.doc_ids())?
1568 {
1569 return self.delete_native_documents_locked(batch, writer);
1570 }
1571 let state = self
1572 .state
1573 .lock()
1574 .map_err(|_| StoreError::Synchronization { component: "state" })?;
1575 match *state {
1576 StoreState::Open => {}
1577 StoreState::Closing => return Err(StoreError::Closing.into()),
1578 StoreState::Closed => return Err(StoreError::Closed.into()),
1579 }
1580 drop(state);
1581 #[cfg(test)]
1582 active_publish_tests::after_mutation_admission();
1583 let mut wal = self
1584 .wal_writer
1585 .lock()
1586 .map_err(|_| StoreError::Synchronization {
1587 component: "WAL writer",
1588 })?;
1589 let Some(writer) = wal.as_mut() else {
1590 drop(wal);
1591 let state = self
1592 .state
1593 .lock()
1594 .map_err(|_| StoreError::Synchronization { component: "state" })?;
1595 return Err(match *state {
1596 StoreState::Closing => StoreError::Closing,
1597 StoreState::Closed => StoreError::Closed,
1598 StoreState::Open => StoreError::ReadOnly,
1599 }
1600 .into());
1601 };
1602 self.delete_with_writer(writer, &batch.doc_ids, &batch.expected)
1603 }
1604
1605 pub(crate) fn delete_with_writer(
1611 &self,
1612 writer: &mut StoreWal,
1613 doc_ids: &[DocId],
1614 expected: &[Option<ExpectedRevision>],
1615 ) -> Result<IngestAck, IngestError> {
1616 let (current_generation, current_segment) = {
1617 let active = self
1618 .active
1619 .lock()
1620 .map_err(|_| StoreError::Synchronization {
1621 component: "active segment",
1622 })?;
1623 let current = active.as_ref().ok_or(StoreError::Closed)?;
1624 (current.generation, Arc::clone(¤t.segment))
1625 };
1626 let snapshot = self
1627 .snapshot
1628 .read()
1629 .map_err(|_| StoreError::Synchronization {
1630 component: "published snapshot",
1631 })?
1632 .as_ref()
1633 .cloned()
1634 .ok_or(StoreError::Closed)?;
1635 check_revision_conditions(
1636 current_segment.as_ref(),
1637 &snapshot,
1638 doc_ids
1639 .iter()
1640 .enumerate()
1641 .map(|(index, doc_id)| (index, *doc_id, expected.get(index).copied().flatten())),
1642 )?;
1643 let sealed = purge::sealed_document_matches(&snapshot, doc_ids)?;
1644 let generation = current_generation
1645 .checked_add(1)
1646 .ok_or(StoreError::GenerationOverflow)?;
1647 let (mut next, rows) = current_segment.tombstone(doc_ids, &self.accounting)?;
1648 let prepared = purge::prepare_sealed_tombstones(
1649 self.vfs.as_ref(),
1650 &self.directory,
1651 &snapshot,
1652 &sealed,
1653 doc_ids,
1654 writer.durable_end(),
1655 generation,
1656 writer.durable_end().saturating_add(1),
1657 #[cfg(feature = "graph-cypher")]
1658 Some(
1659 writer
1660 .durable_end()
1661 .checked_add(1)
1662 .ok_or(StoreError::GenerationOverflow)?,
1663 ),
1664 self.durability_policy,
1665 &self.accounting,
1666 )?;
1667 let payload = wal_payload::encode_delete(doc_ids).map_err(IngestError::Payload)?;
1668 let mut publication = writer.manifest_publication()?.arm();
1669 let seq = match writer.commit(wal_payload::DELETE_V1, &payload) {
1670 Ok(seq) => seq,
1671 Err(error) => {
1672 if let Some(prepared) = prepared {
1673 prepared.abort(self.vfs.as_ref(), &self.directory, self.durability_policy)?;
1674 }
1675 publication.on_wal_error(&error);
1676 return Err(error.into());
1677 }
1678 };
1679 for row in rows {
1680 next.set_sequence(row, seq)?;
1681 }
1682 let committed = prepared
1683 .map(|prepared| {
1684 prepared.commit(
1685 &mut publication,
1686 self,
1687 self.vfs.as_ref(),
1688 &self.directory,
1689 self.durability_policy,
1690 )
1691 })
1692 .transpose()?;
1693 let mut active = self
1694 .active
1695 .lock()
1696 .map_err(|_| StoreError::Synchronization {
1697 component: "active segment",
1698 })?;
1699 let actual_generation = active.as_ref().ok_or(StoreError::Closed)?.generation;
1700 validate_republish_generation(current_generation, actual_generation)?;
1701 let replaced_paths =
1702 self.publish_committed_active(&mut active, committed, generation, next)?;
1703 #[cfg(feature = "graph-cypher")]
1704 self.publish_native_documents(active.as_ref().ok_or(StoreError::Closed)?)?;
1705 publication.complete();
1706 drop(active);
1707 purge::unlink_replaced_segments(
1708 self,
1709 self.vfs.as_ref(),
1710 &self.directory,
1711 &replaced_paths,
1712 self.durability_policy,
1713 );
1714 Ok(IngestAck { seq, generation })
1715 }
1716}
1717
1718fn encode_persisted_upsert(document: &IngestDocument) -> Result<(u16, Vec<u8>), IngestError> {
1719 wal_payload::encode_upsert_v2(document)
1720 .map(|payload| (wal_payload::UPSERT_V2, payload))
1721 .map_err(IngestError::Payload)
1722}
1723
1724pub(crate) fn validate_document_columns(
1725 schema: &Schema,
1726 document: &IngestDocument,
1727) -> Result<(), IngestError> {
1728 let mut builder = ColumnStoreBuilder::new(schema.clone());
1729 builder
1730 .push_row(document.timestamp(), &document.column_inputs())
1731 .map(|_| ())
1732 .map_err(IngestError::Columns)
1733}
1734
1735impl From<WalWriteError> for IngestError {
1736 fn from(error: WalWriteError) -> Self {
1737 Self::Store(StoreError::WalWrite(error))
1738 }
1739}
1740
1741#[cfg(test)]
1742mod vector_order_tests {
1743 use super::*;
1744
1745 #[test]
1746 fn documented_candidate_precedes_legacy_candidate_at_an_equal_score() {
1747 let segment = SegmentId::from_bytes([0x44; 16]);
1748 let legacy = SearchCandidate::new(
1749 GlobalRowId::new(RowSource::Sealed(segment), 0),
1750 None,
1751 1.0,
1752 true,
1753 );
1754 let documented = SearchCandidate::new(
1755 GlobalRowId::new(RowSource::Sealed(segment), 9),
1756 Some(DocumentVersion::new(DocId::new(9), Revision::new(1))),
1757 1.0,
1758 true,
1759 );
1760
1761 assert_eq!(
1762 compare_search_candidates(&documented, &legacy),
1763 std::cmp::Ordering::Less,
1764 "documented-vs-legacy order fell back to physical row identity"
1765 );
1766 }
1767}
1768
1769#[cfg(test)]
1770mod active_publish_tests {
1771 use super::*;
1772
1773 thread_local! {
1774 static AFTER_ADMISSION: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
1775 const { std::cell::RefCell::new(None) };
1776 }
1777
1778 pub(super) fn after_mutation_admission() {
1779 let action = AFTER_ADMISSION.with(|hook| hook.borrow_mut().take());
1780 if let Some(action) = action {
1781 action();
1782 }
1783 }
1784
1785 #[test]
1786 #[allow(clippy::expect_used)]
1787 fn ze448_close_after_ingest_admission_returns_closed() {
1788 let directory = tempfile::tempdir().expect("directory");
1789 let store = Arc::new(
1790 Store::open(
1791 directory.path().join("store"),
1792 crate::lifecycle::OpenOptions::new(),
1793 )
1794 .expect("store"),
1795 );
1796 let closing = Arc::clone(&store);
1797 AFTER_ADMISSION.with(|hook| {
1798 *hook.borrow_mut() = Some(Box::new(move || closing.close().expect("close")));
1799 });
1800 let document = IngestDocument::new(
1801 DocumentVersion::new(DocId::new(1), Revision::new(1)),
1802 vec![1.0],
1803 );
1804 let result = store.ingest(IngestBatch::new(vec![document]));
1805 assert!(
1806 matches!(result, Err(IngestError::Store(StoreError::Closed))),
1807 "{result:?}"
1808 );
1809 }
1810
1811 #[test]
1812 #[allow(clippy::expect_used)]
1813 fn ze449_close_after_delete_admission_returns_closed() {
1814 let directory = tempfile::tempdir().expect("directory");
1815 let store = Arc::new(
1816 Store::open(
1817 directory.path().join("store"),
1818 crate::lifecycle::OpenOptions::new(),
1819 )
1820 .expect("store"),
1821 );
1822 let closing = Arc::clone(&store);
1823 AFTER_ADMISSION.with(|hook| {
1824 *hook.borrow_mut() = Some(Box::new(move || closing.close().expect("close")));
1825 });
1826 let result = store.delete(DeleteBatch::new(vec![DocId::new(1)]));
1827 assert!(
1828 matches!(result, Err(IngestError::Store(StoreError::Closed))),
1829 "{result:?}"
1830 );
1831 }
1832
1833 #[test]
1834 fn mismatched_active_generation_on_republish_is_a_typed_error() {
1835 assert!(matches!(
1836 validate_republish_generation(4, 5),
1837 Err(StoreError::ConcurrentActiveMutation {
1838 expected_generation: 4,
1839 actual_generation: 5,
1840 })
1841 ));
1842 }
1843}
1844
1845#[cfg(all(test, feature = "allocation-audit"))]
1846#[allow(clippy::expect_used)]
1847mod batch_working_copy_tests {
1848 use tempfile::tempdir;
1849
1850 use crate::lifecycle::OpenOptions;
1851
1852 use super::*;
1853
1854 #[test]
1855 fn batch_ingest_clones_the_active_segment_at_most_once() {
1856 let directory = tempdir().expect("store directory");
1857 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1858 store
1859 .ingest(IngestBatch::new(vec![IngestDocument::new(
1860 DocumentVersion::new(DocId::new(1), Revision::new(1)),
1861 vec![1.0, 0.0],
1862 )]))
1863 .expect("seed active segment");
1864 let documents = (2_u128..=25)
1865 .map(|doc_id| {
1866 IngestDocument::new(
1867 DocumentVersion::new(DocId::new(doc_id), Revision::new(1)),
1868 vec![1.0, 0.0],
1869 )
1870 })
1871 .collect::<Vec<_>>();
1872
1873 let (result, report) = crate::allocation_audit::audit_engine_path(|| {
1874 store.ingest(IngestBatch::new(documents))
1875 });
1876
1877 result.expect("ingest audited batch");
1878 assert_eq!(
1879 report.full_segment_clones, 1,
1880 "one ingest batch made more than one full active-segment clone"
1881 );
1882 }
1883}
1884
1885#[cfg(test)]
1886#[allow(clippy::expect_used)]
1887mod wal_replay_tests {
1888 use tempfile::tempdir;
1889
1890 use crate::lifecycle::{DocumentFields, OpenOptions, StoredDocument};
1891
1892 use super::*;
1893
1894 fn document(doc_id: u128, revision: u64, text: Option<&str>) -> IngestDocument {
1895 let document = IngestDocument::new(
1896 DocumentVersion::new(DocId::new(doc_id), Revision::new(revision)),
1897 vec![1.0, doc_id as f32],
1898 );
1899 match text {
1900 Some(text) => document.with_text(text),
1901 None => document,
1902 }
1903 }
1904
1905 pub(super) fn write_mixed_wal(store: &Store) {
1908 for doc_id in 1_u128..=40 {
1909 let text = format!("alpha beta note {doc_id}");
1910 let text = (doc_id > 3 && doc_id % 7 != 0).then_some(text.as_str());
1911 store
1912 .ingest(IngestBatch::new(vec![document(doc_id, 1, text)]))
1913 .expect("ingest one row");
1914 }
1915 for doc_id in [3_u128, 10, 21] {
1916 store
1917 .ingest(IngestBatch::new(vec![document(
1918 doc_id,
1919 2,
1920 Some("gamma replaced text"),
1921 )]))
1922 .expect("replace one row");
1923 }
1924 store
1925 .delete(DeleteBatch::new(vec![
1926 DocId::new(5),
1927 DocId::new(6),
1928 DocId::new(10),
1929 ]))
1930 .expect("delete rows");
1931 store
1932 .ingest(IngestBatch::new(vec![document(
1933 5,
1934 2,
1935 Some("delta revived"),
1936 )]))
1937 .expect("write a deleted id again");
1938 }
1939
1940 fn observed(store: &Store) -> (Vec<Option<StoredDocument>>, crate::fts::bm25::CorpusStats) {
1941 let ids = (1_u128..=41).map(DocId::new).collect::<Vec<_>>();
1942 (
1943 store
1944 .get_documents(&ids, DocumentFields::TEXT)
1945 .expect("read documents"),
1946 store.lexical_corpus_stats().expect("corpus stats"),
1947 )
1948 }
1949
1950 #[test]
1951 fn sealed_revision_lookup_finds_every_sealed_copy_of_an_id() {
1952 let directory = tempdir().expect("store directory");
1953 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1954 for (doc_id, revision) in [(1_u128, 1_u64), (2, 1), (3, 1)] {
1955 store
1956 .ingest(IngestBatch::new(vec![document(
1957 doc_id,
1958 revision,
1959 Some("first"),
1960 )]))
1961 .expect("ingest first segment");
1962 }
1963 store.seal().expect("seal first segment");
1964 store
1965 .ingest(IngestBatch::new(vec![document(2, 2, Some("second"))]))
1966 .expect("replace a sealed row");
1967 store.seal().expect("seal second segment");
1968
1969 let stale = store.ingest(IngestBatch::new(vec![document(2, 1, Some("stale"))]));
1970 assert!(
1971 matches!(stale, Err(IngestError::StaleRevision { .. })),
1972 "a revision older than a sealed one was accepted: {stale:?}"
1973 );
1974 store
1975 .ingest(IngestBatch::new(vec![document(2, 3, Some("third"))]))
1976 .expect("write a newer revision");
1977 store
1978 .delete(DeleteBatch::new(vec![DocId::new(3)]))
1979 .expect("delete a sealed row");
1980 let documents = store
1981 .get_documents(
1982 &[DocId::new(1), DocId::new(2), DocId::new(3)],
1983 DocumentFields::TEXT,
1984 )
1985 .expect("read documents");
1986 let texts = documents
1987 .iter()
1988 .map(|document| document.as_ref().and_then(|document| document.text.clone()))
1989 .collect::<Vec<_>>();
1990 assert_eq!(
1991 texts,
1992 vec![Some("first".to_owned()), Some("third".to_owned()), None]
1993 );
1994 store.close().expect("close store");
1995 }
1996
1997 #[test]
1998 fn wal_replay_reopens_the_exact_live_state() {
1999 let directory = tempdir().expect("store directory");
2000 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
2001 write_mixed_wal(&store);
2002 let before = observed(&store);
2003 store.close().expect("close store");
2004
2005 let reopened = Store::open(directory.path(), OpenOptions::default()).expect("reopen");
2006 assert_eq!(observed(&reopened), before);
2007 reopened.close().expect("close reopened store");
2008 }
2009}
2010
2011#[cfg(all(test, feature = "allocation-audit"))]
2012#[allow(clippy::expect_used)]
2013mod wal_replay_copy_tests {
2014 use tempfile::tempdir;
2015
2016 use crate::lifecycle::OpenOptions;
2017
2018 use super::*;
2019
2020 #[test]
2021 fn wal_replay_clones_the_active_segment_at_most_once() {
2022 let directory = tempdir().expect("store directory");
2023 let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
2024 super::wal_replay_tests::write_mixed_wal(&store);
2025 store.close().expect("close store");
2026
2027 let (reopened, report) = crate::allocation_audit::audit_engine_path(|| {
2028 Store::open(directory.path(), OpenOptions::default())
2029 });
2030
2031 reopened
2032 .expect("reopen")
2033 .close()
2034 .expect("close reopened store");
2035 assert!(
2036 report.full_segment_clones <= 1,
2037 "WAL replay cloned the active segment {} times",
2038 report.full_segment_clones
2039 );
2040 }
2041}