Skip to main content

zeppelin_embed/ingest/
mod.rs

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