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;
10#[cfg(feature = "graph-cypher")]
11pub(crate) mod mixed;
12mod purge;
13
14/// Survivor-rewrite helpers shared with graph-segment consolidation.
15///
16/// Purge's per-segment gathers are re-exported unchanged so consolidation
17/// concatenates their survivor-order output across N inputs without forking
18/// the rewrite logic; purge's own behavior stays byte-identical.
19/// The pending purge intent decoder, for read-only store verification.
20pub(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/// Stable application document identifier.
67#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
68#[repr(transparent)]
69pub struct DocId(u128);
70
71impl DocId {
72    /// Constructs an identifier from its permanent integer value.
73    #[must_use]
74    pub const fn new(value: u128) -> Self {
75        Self(value)
76    }
77
78    /// Returns the permanent integer value.
79    #[must_use]
80    pub const fn get(self) -> u128 {
81        self.0
82    }
83}
84
85/// Monotonic application revision of one document.
86#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
87#[repr(transparent)]
88pub struct Revision(u64);
89
90impl Revision {
91    /// Constructs a revision from its permanent integer value.
92    #[must_use]
93    pub const fn new(value: u64) -> Self {
94        Self(value)
95    }
96
97    /// Returns the permanent integer value.
98    #[must_use]
99    pub const fn get(self) -> u64 {
100        self.0
101    }
102}
103
104/// One idempotency key identifying a particular document revision.
105#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
106pub struct DocumentVersion {
107    doc_id: DocId,
108    revision: Revision,
109}
110
111impl DocumentVersion {
112    /// Constructs a document/revision pair.
113    #[must_use]
114    pub const fn new(doc_id: DocId, revision: Revision) -> Self {
115        Self { doc_id, revision }
116    }
117
118    /// Returns the stable application document identifier.
119    #[must_use]
120    pub const fn doc_id(self) -> DocId {
121        self.doc_id
122    }
123
124    /// Returns the monotonic application revision.
125    #[must_use]
126    pub const fn revision(self) -> Revision {
127        self.revision
128    }
129}
130
131/// A precondition on one document's live revision, checked atomically with
132/// the batch that carries it.
133///
134/// Conditions are evaluated by the single writer against the latest committed
135/// state (active rows and sealed segments) as it was before the batch. A
136/// tombstoned id has no live document, so it matches [`Self::Absent`].
137#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
138pub enum ExpectedRevision {
139    /// A live document exists and its revision is exactly this value.
140    Exactly(Revision),
141    /// No live document exists: the id was never written or was deleted.
142    Absent,
143}
144
145/// One owned vector mutation supplied to an ingest batch.
146#[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    /// Constructs one document upsert.
162    #[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    /// Assigns the canonical `ts` clustering-key value.
179    #[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    /// Assigns opaque stored metadata that must remain physically purgeable.
187    #[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    /// Assigns the UTF-8 body indexed by the store's frozen text analyzer.
195    #[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    /// Assigns typed user-column values keyed by the store schema.
202    #[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    /// Makes the whole batch conditional on this document's live revision.
210    /// The condition is not persisted.
211    #[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    /// Returns the live-revision precondition, if any.
218    #[must_use]
219    pub const fn expected_revision(&self) -> Option<ExpectedRevision> {
220        self.expected_revision
221    }
222
223    /// Returns the document/revision idempotency key.
224    #[must_use]
225    pub const fn version(&self) -> DocumentVersion {
226        self.version
227    }
228
229    /// Returns the full-precision vector retained for future sealing.
230    #[must_use]
231    pub fn vector(&self) -> &[f32] {
232        &self.vector
233    }
234
235    /// Returns the canonical `ts` clustering-key value.
236    #[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    /// Returns the opaque stored metadata bytes.
246    #[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    /// Returns the optional UTF-8 body indexed by lexical search.
256    #[must_use]
257    pub fn text(&self) -> Option<&str> {
258        self.text.as_deref()
259    }
260
261    /// Returns the typed schema-column values in caller order.
262    #[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/// One atomic caller batch of document upserts.
294#[derive(Clone, Debug, Default, PartialEq)]
295pub struct IngestBatch {
296    documents: Vec<IngestDocument>,
297    epoch: Option<crate::epoch::EpochIdentity>,
298}
299
300/// One atomic caller batch of stable document ids to tombstone.
301#[derive(Clone, Debug, Default, Eq, PartialEq)]
302pub struct DeleteBatch {
303    doc_ids: Vec<DocId>,
304    expected: Vec<Option<ExpectedRevision>>,
305}
306
307impl DeleteBatch {
308    /// Takes ownership of document ids in caller order.
309    #[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    /// Takes ids in caller order, each with an optional live-revision
318    /// precondition; one failed condition aborts the whole batch.
319    #[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    /// Returns the requested stable document ids.
326    #[must_use]
327    pub fn doc_ids(&self) -> &[DocId] {
328        &self.doc_ids
329    }
330}
331
332impl IngestBatch {
333    /// Takes ownership of the upserts in caller order.
334    #[must_use]
335    pub const fn new(documents: Vec<IngestDocument>) -> Self {
336        Self {
337            documents,
338            epoch: None,
339        }
340    }
341
342    /// Declares the interpretation identity that produced every vector.
343    #[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    /// Returns the ordered document upserts.
350    #[must_use]
351    pub fn documents(&self) -> &[IngestDocument] {
352        &self.documents
353    }
354}
355
356/// A committed mutation's WAL and point-in-time generation coordinates.
357#[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    /// Returns the last WAL sequence covered by this acknowledgement.
369    #[must_use]
370    pub const fn seq(self) -> LogSeq {
371        self.seq
372    }
373
374    /// Returns the store generation on which this mutation acted.
375    #[must_use]
376    pub const fn generation(self) -> u64 {
377        self.generation
378    }
379}
380
381/// Typed ingest rejection that leaves active state unchanged.
382#[derive(Debug)]
383pub enum IngestError {
384    /// Lifecycle, access-mode, budget, or synchronization failure.
385    Store(StoreError),
386    /// The graph participant refused or could not complete the shared mutation.
387    #[cfg(feature = "graph-cypher")]
388    Graph(Box<crate::property_graph::query::completed::GraphQueryError>),
389    /// The caller supplied no document mutation.
390    EmptyBatch,
391    /// The batch's declared interpretation differs from the store identity.
392    EpochMismatch(crate::epoch::EpochMismatch),
393    /// The store is stamped but the batch did not declare its identity.
394    EpochUndeclared,
395    /// The batch declared an identity for an unstamped store.
396    EpochUnstamped,
397    /// The attempted revision precedes the highest active or sealed revision.
398    StaleRevision {
399        /// Document whose history would have moved backward.
400        doc_id: DocId,
401        /// Highest revision currently known across active and sealed state.
402        current: Revision,
403        /// Rejected older revision.
404        attempted: Revision,
405    },
406    /// A document's expected-revision condition did not hold; nothing was
407    /// written.
408    RevisionConflict {
409        /// Position of the failed condition in the caller's batch.
410        index: usize,
411        /// Document whose condition failed.
412        doc_id: DocId,
413        /// The caller's condition.
414        expected: ExpectedRevision,
415        /// Live revision at evaluation, or `None` when no live document exists.
416        current: Option<Revision>,
417    },
418    /// A vector could not be quantized under the frozen Bit4 contract.
419    Vector(crate::quant::QuantError),
420    /// The frozen analyzer or active lexical index rejected the text row.
421    Lexical(crate::fts::index::IndexError),
422    /// The frozen text analyzer configuration failed to compile.
423    Tokenizer(crate::fts::tokenizer::TokenizerError),
424    /// Typed values did not satisfy the store's declared schema.
425    Columns(crate::meta::BuildError),
426    /// A WAL operation payload could not be encoded.
427    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/// Borrowed structured vector request for store-owned data.
518#[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    /// Constructs a full-precision vector request.
527    #[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    /// Restricts candidates before ranking, including both hybrid legs.
537    #[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    /// Restricts candidates on both hybrid legs before ranking and fusion.
544    /// An empty slice selects no documents; order and duplicates do not matter.
545    #[must_use]
546    pub const fn with_eligible(mut self, ids: &'a [DocId]) -> Self {
547        self.eligible = Some(ids);
548        self
549    }
550
551    /// Returns the full-precision query coordinates.
552    #[must_use]
553    pub const fn vector(self) -> &'a [f32] {
554        self.vector
555    }
556}
557
558/// The immutable object namespace containing a segment-local row.
559#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
560pub enum RowSource {
561    /// The in-RAM active segment pinned for this query.
562    Active,
563    /// One immutable segment identified independently of manifest order.
564    Sealed(SegmentId),
565}
566
567/// Collision-free store row address `(source, local_row)`.
568#[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    /// Returns the active or immutable object namespace.
580    #[must_use]
581    pub const fn source(self) -> RowSource {
582        self.source
583    }
584
585    /// Returns the dense row number within `source`.
586    #[must_use]
587    pub const fn local_row(self) -> u32 {
588        self.local_row
589    }
590}
591
592/// One globally addressed store-search candidate.
593#[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    /// Returns the globally collision-free row address.
617    #[must_use]
618    pub const fn row_id(self) -> GlobalRowId {
619        self.row_id
620    }
621
622    /// Returns the document identity, or `None` for task-07 segments whose
623    /// frozen format predates task-10's document-version region.
624    #[must_use]
625    pub const fn document(self) -> Option<DocumentVersion> {
626        self.document
627    }
628
629    /// Returns the larger-is-better scan score.
630    #[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
640/// Permanent public ordering for globally merged vector candidates.
641///
642/// Score is descending; documented rows then use document ID ascending and
643/// revision descending before the physical row identity. Legacy rows without
644/// a document-version region fall back directly to physical identity.
645pub(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/// Global top-k results and aggregate deterministic scan counters.
664#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
665pub struct GraphSearchStats {
666    /// Sealed segments that completed graph traversal.
667    pub segments_traversed: usize,
668    /// Complete immutable graph validations performed before descriptor reuse.
669    pub graph_validations: usize,
670    /// Full segment scans performed to discover persisted entry seeds.
671    pub entry_seed_discoveries: usize,
672    /// Visited arrays cleared after their epoch byte wrapped.
673    pub visited_epoch_clears: usize,
674    /// Distinct graph candidates scored by the Bit4 traversal estimator.
675    pub candidates_scored: usize,
676    /// Retained graph candidates read from full-precision storage.
677    pub candidates_rescored: usize,
678    /// Sealed graph segments rejected by the query-local competitive bound.
679    pub segments_pruned_by_bound: usize,
680}
681
682/// Global top-k results and aggregate deterministic query counters.
683#[derive(Clone, Debug, PartialEq)]
684pub struct SearchOutcome {
685    /// Candidates merged across the active segment and the published epoch's sealed segments.
686    pub candidates: Vec<SearchCandidate>,
687    /// Producer-supplied squared-L2 ceiling used to normalize a bounded hybrid
688    /// vector leg. It is absent when the selected producer cannot yet provide
689    /// a corpus-wide bound.
690    pub vector_ceiling: Option<f64>,
691    /// Aggregate work from all per-segment query-pool executions.
692    pub stats: ScanStats,
693    /// Graph-only deterministic work; fields are zero when no segment used a graph.
694    pub graph_stats: GraphSearchStats,
695    /// Pinned active-state generation searched by this request.
696    pub generation: u64,
697    /// Embedding and tokenizer identity that interpreted this query.
698    pub epoch: Option<crate::epoch::EpochIdentity>,
699    /// Unconditional report of the executed query path.
700    pub diagnostics: crate::diag::QueryDiagnostics,
701}
702
703/// One store-backed lexical hit joined to its stable document identity.
704#[derive(Clone, Copy, Debug, PartialEq)]
705pub struct LexicalCandidate {
706    /// Stable application document version.
707    pub document: DocumentVersion,
708    /// Larger-is-better BM25 score.
709    pub score: f64,
710}
711
712/// Store-backed lexical hits and truthful diagnostics from one pinned generation.
713#[derive(Clone, Debug, PartialEq)]
714pub struct StoreLexicalSearchOutcome {
715    /// Globally ranked lexical hits.
716    pub candidates: Vec<LexicalCandidate>,
717    /// Active generation pinned with the immutable snapshot.
718    pub generation: u64,
719    /// Unconditional report of the lexical work actually performed.
720    pub diagnostics: crate::diag::QueryDiagnostics,
721}
722
723/// One explained structured lexical hit with owned persisted-source text.
724#[derive(Clone, Debug, PartialEq)]
725pub struct ExplainedLexicalCandidate {
726    /// Stable application document version.
727    pub document: DocumentVersion,
728    /// Larger-is-better boosted BM25 score.
729    pub score: f64,
730    /// Expansions that contributed to this row's score.
731    pub provenance: Vec<crate::fts::query::LexicalExpansion>,
732    /// UTF-8-valid window copied from the exact persisted source text.
733    pub snippet: crate::fts::query::OwnedLexicalSnippet,
734}
735
736/// Explained structured lexical hits from one pinned generation.
737#[derive(Clone, Debug, PartialEq)]
738pub struct StoreStructuredLexicalSearchOutcome {
739    /// Globally ranked explained hits.
740    pub candidates: Vec<ExplainedLexicalCandidate>,
741    /// Every vocabulary expansion and its exact boost, in deterministic order.
742    pub expansions: Vec<crate::fts::query::LexicalExpansion>,
743    /// Active generation pinned with the immutable snapshot.
744    pub generation: u64,
745    /// Unconditional report of the lexical work actually performed.
746    pub diagnostics: crate::diag::QueryDiagnostics,
747}
748
749/// Store-level exact vector/lexical fusion over stable DocIds.
750#[derive(Clone, Debug, PartialEq)]
751pub struct StoreHybridSearchOutcome {
752    /// Fused hits keyed by stable store document id.
753    pub hits: Vec<crate::fusion::FusedHit<DocId>>,
754    /// Structured lexical expansions used by the lexical leg.
755    pub lexical_expansions: Vec<crate::fts::query::LexicalExpansion>,
756    /// Active generation pinned with the immutable snapshot.
757    pub generation: u64,
758    /// Unconditional report including the existing fusion report type.
759    pub diagnostics: crate::diag::QueryDiagnostics,
760}
761
762/// Typed store lexical query failure.
763#[derive(Debug)]
764pub enum StoreLexicalError {
765    /// Store admission, cancellation, or lifecycle failed.
766    Query(crate::lifecycle::QueryError),
767    /// Lexical planning or scoring failed.
768    Lexical(crate::planner::LexicalFilterError),
769    /// A postings-bearing immutable segment has no document-identity region.
770    MissingDocumentIdentity {
771        /// Immutable segment that cannot join local rows to store DocIds.
772        segment_id: SegmentId,
773    },
774    /// A matching row predates the append-only stored-text region.
775    MissingStoredText {
776        /// Immutable segment containing the matching row.
777        segment_id: SegmentId,
778        /// Dense row whose source text was requested.
779        row: u32,
780    },
781    /// Stored text existed but none of the reported terms mapped to a source span.
782    MissingSnippetMatch {
783        /// Dense lexical source ordinal.
784        segment: u32,
785        /// Dense row within that source.
786        row: u32,
787    },
788    /// Structured query shape was invalid.
789    Structured(crate::fts::query::LexicalQueryError),
790    /// Snippet options were invalid.
791    Snippet(crate::fts::snippet::SnippetError),
792    /// The pinned tokenizer configuration could not be constructed.
793    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
915/// Returns the live revision of `doc_id` in the given committed state, or
916/// `None` when no live row exists. Tombstoned rows are not live.
917fn 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
937/// Evaluates every `(index, id, condition)` against one committed state and
938/// fails on the first that does not hold. Runs under the WAL writer lock
939/// before any mutation work, so a failure writes nothing.
940fn 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    /// Returns exact live corpus statistics for store-backed lexical scoring.
981    ///
982    /// # Errors
983    ///
984    /// Returns a store admission or segment error when the current lexical
985    /// corpus cannot be read, or an index error when the corpus is empty.
986    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    /// Commits one active-segment upsert and returns its WAL/generation coordinates.
1256    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(&current.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    /// Durably tombstones documents across active and sealed state before acknowledging.
1546    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    /// The body of [`Self::delete`] for a caller that already holds the WAL
1606    /// writer, so one mutation can resolve ids and tombstone them without
1607    /// another write landing in between. `doc_ids` must be non-empty.
1608    /// `expected` holds the revision condition of each id by position; a
1609    /// missing entry is unconditional.
1610    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(&current.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    /// Writes one WAL record per mutation: text-less rows before the first
1906    /// texted row, replacements, deletes, and a deleted id written again.
1907    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}