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<StoreError> for IngestError {
506    fn from(error: StoreError) -> Self {
507        Self::Store(error)
508    }
509}
510
511/// Borrowed structured vector request for store-owned data.
512#[derive(Clone, Copy, Debug)]
513pub struct SearchRequest<'a> {
514    vector: &'a [f32],
515    pub(crate) filter: Option<&'a crate::lifecycle::QueryFilter>,
516    pub(crate) eligible: Option<&'a [DocId]>,
517}
518
519impl<'a> SearchRequest<'a> {
520    /// Constructs a full-precision vector request.
521    #[must_use]
522    pub const fn new(vector: &'a [f32]) -> Self {
523        Self {
524            vector,
525            filter: None,
526            eligible: None,
527        }
528    }
529
530    /// Restricts candidates before ranking, including both hybrid legs.
531    #[must_use]
532    pub const fn with_filter(mut self, filter: Option<&'a crate::lifecycle::QueryFilter>) -> Self {
533        self.filter = filter;
534        self
535    }
536
537    /// Restricts candidates on both hybrid legs before ranking and fusion.
538    /// An empty slice selects no documents; order and duplicates do not matter.
539    #[must_use]
540    pub const fn with_eligible(mut self, ids: &'a [DocId]) -> Self {
541        self.eligible = Some(ids);
542        self
543    }
544
545    /// Returns the full-precision query coordinates.
546    #[must_use]
547    pub const fn vector(self) -> &'a [f32] {
548        self.vector
549    }
550}
551
552/// The immutable object namespace containing a segment-local row.
553#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
554pub enum RowSource {
555    /// The in-RAM active segment pinned for this query.
556    Active,
557    /// One immutable segment identified independently of manifest order.
558    Sealed(SegmentId),
559}
560
561/// Collision-free store row address `(source, local_row)`.
562#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
563pub struct GlobalRowId {
564    source: RowSource,
565    local_row: u32,
566}
567
568impl GlobalRowId {
569    pub(crate) const fn new(source: RowSource, local_row: u32) -> Self {
570        Self { source, local_row }
571    }
572
573    /// Returns the active or immutable object namespace.
574    #[must_use]
575    pub const fn source(self) -> RowSource {
576        self.source
577    }
578
579    /// Returns the dense row number within `source`.
580    #[must_use]
581    pub const fn local_row(self) -> u32 {
582        self.local_row
583    }
584}
585
586/// One globally addressed store-search candidate.
587#[derive(Clone, Copy, Debug, PartialEq)]
588pub struct SearchCandidate {
589    row_id: GlobalRowId,
590    document: Option<DocumentVersion>,
591    score: f32,
592    exact_score: bool,
593}
594
595impl SearchCandidate {
596    pub(crate) const fn new(
597        row_id: GlobalRowId,
598        document: Option<DocumentVersion>,
599        score: f32,
600        exact_score: bool,
601    ) -> Self {
602        Self {
603            row_id,
604            document,
605            score,
606            exact_score,
607        }
608    }
609
610    /// Returns the globally collision-free row address.
611    #[must_use]
612    pub const fn row_id(self) -> GlobalRowId {
613        self.row_id
614    }
615
616    /// Returns the document identity, or `None` for task-07 segments whose
617    /// frozen format predates task-10's document-version region.
618    #[must_use]
619    pub const fn document(self) -> Option<DocumentVersion> {
620        self.document
621    }
622
623    /// Returns the larger-is-better scan score.
624    #[must_use]
625    pub const fn score(self) -> f32 {
626        self.score
627    }
628
629    pub(crate) const fn exact_score(self) -> bool {
630        self.exact_score
631    }
632}
633
634/// Permanent public ordering for globally merged vector candidates.
635///
636/// Score is descending; documented rows then use document ID ascending and
637/// revision descending before the physical row identity. Legacy rows without
638/// a document-version region fall back directly to physical identity.
639pub(crate) fn compare_search_candidates(
640    left: &SearchCandidate,
641    right: &SearchCandidate,
642) -> std::cmp::Ordering {
643    right.score().total_cmp(&left.score()).then_with(|| {
644        match (left.document(), right.document()) {
645            (Some(left_document), Some(right_document)) => left_document
646                .doc_id()
647                .cmp(&right_document.doc_id())
648                .then_with(|| right_document.revision().cmp(&left_document.revision())),
649            (Some(_), None) => std::cmp::Ordering::Less,
650            (None, Some(_)) => std::cmp::Ordering::Greater,
651            (None, None) => std::cmp::Ordering::Equal,
652        }
653        .then_with(|| left.row_id().cmp(&right.row_id()))
654    })
655}
656
657/// Global top-k results and aggregate deterministic scan counters.
658#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
659pub struct GraphSearchStats {
660    /// Sealed segments that completed graph traversal.
661    pub segments_traversed: usize,
662    /// Complete immutable graph validations performed before descriptor reuse.
663    pub graph_validations: usize,
664    /// Full segment scans performed to discover persisted entry seeds.
665    pub entry_seed_discoveries: usize,
666    /// Visited arrays cleared after their epoch byte wrapped.
667    pub visited_epoch_clears: usize,
668    /// Distinct graph candidates scored by the Bit4 traversal estimator.
669    pub candidates_scored: usize,
670    /// Retained graph candidates read from full-precision storage.
671    pub candidates_rescored: usize,
672    /// Sealed graph segments rejected by the query-local competitive bound.
673    pub segments_pruned_by_bound: usize,
674}
675
676/// Global top-k results and aggregate deterministic query counters.
677#[derive(Clone, Debug, PartialEq)]
678pub struct SearchOutcome {
679    /// Candidates merged across the active segment and the published epoch's sealed segments.
680    pub candidates: Vec<SearchCandidate>,
681    /// Producer-supplied squared-L2 ceiling used to normalize a bounded hybrid
682    /// vector leg. It is absent when the selected producer cannot yet provide
683    /// a corpus-wide bound.
684    pub vector_ceiling: Option<f64>,
685    /// Aggregate work from all per-segment query-pool executions.
686    pub stats: ScanStats,
687    /// Graph-only deterministic work; fields are zero when no segment used a graph.
688    pub graph_stats: GraphSearchStats,
689    /// Pinned active-state generation searched by this request.
690    pub generation: u64,
691    /// Embedding and tokenizer identity that interpreted this query.
692    pub epoch: Option<crate::epoch::EpochIdentity>,
693    /// Unconditional report of the executed query path.
694    pub diagnostics: crate::diag::QueryDiagnostics,
695}
696
697/// One store-backed lexical hit joined to its stable document identity.
698#[derive(Clone, Copy, Debug, PartialEq)]
699pub struct LexicalCandidate {
700    /// Stable application document version.
701    pub document: DocumentVersion,
702    /// Larger-is-better BM25 score.
703    pub score: f64,
704}
705
706/// Store-backed lexical hits and truthful diagnostics from one pinned generation.
707#[derive(Clone, Debug, PartialEq)]
708pub struct StoreLexicalSearchOutcome {
709    /// Globally ranked lexical hits.
710    pub candidates: Vec<LexicalCandidate>,
711    /// Active generation pinned with the immutable snapshot.
712    pub generation: u64,
713    /// Unconditional report of the lexical work actually performed.
714    pub diagnostics: crate::diag::QueryDiagnostics,
715}
716
717/// One explained structured lexical hit with owned persisted-source text.
718#[derive(Clone, Debug, PartialEq)]
719pub struct ExplainedLexicalCandidate {
720    /// Stable application document version.
721    pub document: DocumentVersion,
722    /// Larger-is-better boosted BM25 score.
723    pub score: f64,
724    /// Expansions that contributed to this row's score.
725    pub provenance: Vec<crate::fts::query::LexicalExpansion>,
726    /// UTF-8-valid window copied from the exact persisted source text.
727    pub snippet: crate::fts::query::OwnedLexicalSnippet,
728}
729
730/// Explained structured lexical hits from one pinned generation.
731#[derive(Clone, Debug, PartialEq)]
732pub struct StoreStructuredLexicalSearchOutcome {
733    /// Globally ranked explained hits.
734    pub candidates: Vec<ExplainedLexicalCandidate>,
735    /// Every vocabulary expansion and its exact boost, in deterministic order.
736    pub expansions: Vec<crate::fts::query::LexicalExpansion>,
737    /// Active generation pinned with the immutable snapshot.
738    pub generation: u64,
739    /// Unconditional report of the lexical work actually performed.
740    pub diagnostics: crate::diag::QueryDiagnostics,
741}
742
743/// Store-level exact vector/lexical fusion over stable DocIds.
744#[derive(Clone, Debug, PartialEq)]
745pub struct StoreHybridSearchOutcome {
746    /// Fused hits keyed by stable store document id.
747    pub hits: Vec<crate::fusion::FusedHit<DocId>>,
748    /// Structured lexical expansions used by the lexical leg.
749    pub lexical_expansions: Vec<crate::fts::query::LexicalExpansion>,
750    /// Active generation pinned with the immutable snapshot.
751    pub generation: u64,
752    /// Unconditional report including the existing fusion report type.
753    pub diagnostics: crate::diag::QueryDiagnostics,
754}
755
756/// Typed store lexical query failure.
757#[derive(Debug)]
758pub enum StoreLexicalError {
759    /// Store admission, cancellation, or lifecycle failed.
760    Query(crate::lifecycle::QueryError),
761    /// Lexical planning or scoring failed.
762    Lexical(crate::planner::LexicalFilterError),
763    /// A postings-bearing immutable segment has no document-identity region.
764    MissingDocumentIdentity {
765        /// Immutable segment that cannot join local rows to store DocIds.
766        segment_id: SegmentId,
767    },
768    /// A matching row predates the append-only stored-text region.
769    MissingStoredText {
770        /// Immutable segment containing the matching row.
771        segment_id: SegmentId,
772        /// Dense row whose source text was requested.
773        row: u32,
774    },
775    /// Stored text existed but none of the reported terms mapped to a source span.
776    MissingSnippetMatch {
777        /// Dense lexical source ordinal.
778        segment: u32,
779        /// Dense row within that source.
780        row: u32,
781    },
782    /// Structured query shape was invalid.
783    Structured(crate::fts::query::LexicalQueryError),
784    /// Snippet options were invalid.
785    Snippet(crate::fts::snippet::SnippetError),
786    /// The pinned tokenizer configuration could not be constructed.
787    Tokenizer(crate::fts::tokenizer::TokenizerError),
788}
789
790impl std::fmt::Display for StoreLexicalError {
791    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
792        match self {
793            Self::Query(error) => error.fmt(formatter),
794            Self::Lexical(error) => error.fmt(formatter),
795            Self::MissingDocumentIdentity { segment_id } => write!(
796                formatter,
797                "sealed lexical segment {segment_id} has no document identity"
798            ),
799            Self::MissingStoredText { segment_id, row } => write!(
800                formatter,
801                "sealed lexical segment {segment_id} row {row} has no stored source text"
802            ),
803            Self::MissingSnippetMatch { segment, row } => write!(
804                formatter,
805                "lexical source {segment} row {row} has no snippet match span"
806            ),
807            Self::Structured(error) => error.fmt(formatter),
808            Self::Snippet(error) => error.fmt(formatter),
809            Self::Tokenizer(error) => error.fmt(formatter),
810        }
811    }
812}
813
814impl std::error::Error for StoreLexicalError {
815    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
816        match self {
817            Self::Query(error) => Some(error),
818            Self::Lexical(error) => Some(error),
819            Self::Structured(error) => Some(error),
820            Self::Snippet(error) => Some(error),
821            Self::Tokenizer(error) => Some(error),
822            Self::MissingDocumentIdentity { .. }
823            | Self::MissingStoredText { .. }
824            | Self::MissingSnippetMatch { .. } => None,
825        }
826    }
827}
828
829impl From<crate::lifecycle::QueryError> for StoreLexicalError {
830    fn from(error: crate::lifecycle::QueryError) -> Self {
831        Self::Query(error)
832    }
833}
834
835impl From<StoreError> for StoreLexicalError {
836    fn from(error: StoreError) -> Self {
837        Self::Query(crate::lifecycle::QueryError::Store(error))
838    }
839}
840
841impl From<crate::planner::LexicalFilterError> for StoreLexicalError {
842    fn from(error: crate::planner::LexicalFilterError) -> Self {
843        Self::Lexical(error)
844    }
845}
846
847impl From<crate::fts::query::LexicalQueryError> for StoreLexicalError {
848    fn from(error: crate::fts::query::LexicalQueryError) -> Self {
849        Self::Structured(error)
850    }
851}
852
853impl From<crate::fts::snippet::SnippetError> for StoreLexicalError {
854    fn from(error: crate::fts::snippet::SnippetError) -> Self {
855        Self::Snippet(error)
856    }
857}
858
859impl From<crate::fts::tokenizer::TokenizerError> for StoreLexicalError {
860    fn from(error: crate::fts::tokenizer::TokenizerError) -> Self {
861        Self::Tokenizer(error)
862    }
863}
864
865#[derive(Clone, Copy)]
866struct ResolvedRevision {
867    version: DocumentVersion,
868    seq: LogSeq,
869    active_row: Option<usize>,
870}
871
872struct RevisionProgress {
873    working: Option<ActiveSegment>,
874    records: Vec<(usize, u16, Vec<u8>)>,
875    replay_seq: Option<LogSeq>,
876    #[cfg(any(test, feature = "test-seams"))]
877    replay_count: usize,
878    sealed_tombstones: Vec<DocId>,
879}
880
881fn resolve_revision(
882    active: &ActiveSegment,
883    sealed: &[purge::SealedDocumentMatch],
884    doc_id: DocId,
885    sealed_seq: LogSeq,
886) -> Option<ResolvedRevision> {
887    let active_existing = active.existing(doc_id);
888    let active_row = active_existing.map(|(row, _, _)| row);
889    let mut resolved = active_existing.map(|(_, version, seq)| ResolvedRevision {
890        version,
891        seq,
892        active_row,
893    });
894    for matched in sealed
895        .iter()
896        .filter(|matched| matched.version.doc_id() == doc_id)
897    {
898        if resolved.is_none_or(|current| matched.version.revision() > current.version.revision()) {
899            resolved = Some(ResolvedRevision {
900                version: matched.version,
901                seq: sealed_seq,
902                active_row,
903            });
904        }
905    }
906    resolved
907}
908
909/// Returns the live revision of `doc_id` in the given committed state, or
910/// `None` when no live row exists. Tombstoned rows are not live.
911fn live_revision(
912    active: &ActiveSegment,
913    snapshot: &PublishedSnapshot,
914    doc_id: DocId,
915) -> Result<Option<Revision>, StoreError> {
916    if let Some((row, version, _)) = active.existing(doc_id)
917        && !active.is_tombstoned(row)
918    {
919        return Ok(Some(version.revision()));
920    }
921    for segment in snapshot.segments() {
922        for row in segment.query_rows_for_doc_id(doc_id)? {
923            if let Some(version) = segment.document_version(row).map_err(StoreError::Segment)? {
924                return Ok(Some(version.revision()));
925            }
926        }
927    }
928    Ok(None)
929}
930
931/// Evaluates every `(index, id, condition)` against one committed state and
932/// fails on the first that does not hold. Runs under the WAL writer lock
933/// before any mutation work, so a failure writes nothing.
934fn check_revision_conditions(
935    active: &ActiveSegment,
936    snapshot: &PublishedSnapshot,
937    conditions: impl Iterator<Item = (usize, DocId, Option<ExpectedRevision>)>,
938) -> Result<(), IngestError> {
939    for (index, doc_id, expected) in conditions {
940        let Some(expected) = expected else {
941            continue;
942        };
943        let current = live_revision(active, snapshot, doc_id)?;
944        let holds = match expected {
945            ExpectedRevision::Exactly(revision) => current == Some(revision),
946            ExpectedRevision::Absent => current.is_none(),
947        };
948        if !holds {
949            return Err(IngestError::RevisionConflict {
950                index,
951                doc_id,
952                expected,
953                current,
954            });
955        }
956    }
957    Ok(())
958}
959
960fn validate_republish_generation(
961    expected_generation: u64,
962    actual_generation: u64,
963) -> Result<(), StoreError> {
964    if actual_generation != expected_generation {
965        return Err(StoreError::ConcurrentActiveMutation {
966            expected_generation,
967            actual_generation,
968        });
969    }
970    Ok(())
971}
972
973impl Store {
974    /// Returns exact live corpus statistics for store-backed lexical scoring.
975    ///
976    /// # Errors
977    ///
978    /// Returns a store admission or segment error when the current lexical
979    /// corpus cannot be read, or an index error when the corpus is empty.
980    pub fn lexical_corpus_stats(&self) -> Result<crate::fts::bm25::CorpusStats, StoreLexicalError> {
981        let state = self
982            .state
983            .lock()
984            .map_err(|_| StoreError::Synchronization { component: "state" })?;
985        match *state {
986            StoreState::Open => {}
987            StoreState::Closing => return Err(StoreError::Closing.into()),
988            StoreState::Closed => return Err(StoreError::Closed.into()),
989        }
990        let active_guard = self
991            .active
992            .lock()
993            .map_err(|_| StoreError::Synchronization {
994                component: "active segment",
995            })?;
996        let active = active_guard.as_ref().ok_or(StoreError::Closed)?;
997        let snapshot = self
998            .snapshot
999            .read()
1000            .map_err(|_| StoreError::Synchronization {
1001                component: "published snapshot",
1002            })?
1003            .as_ref()
1004            .cloned()
1005            .ok_or(StoreError::Closed)?;
1006        let mut index = crate::fts::index::LexicalIndex::new();
1007        for segment in snapshot.segments() {
1008            if let Some(postings) = segment.query_postings()? {
1009                let alive = segment.query_alive()?;
1010                index
1011                    .push_shared_with_live_rows(postings, alive.alive_bitmap())
1012                    .map_err(crate::planner::LexicalFilterError::from)?;
1013            }
1014        }
1015        if active.segment.has_text() {
1016            let sealed = active.segment.sealed_lexical(&self.accounting)?;
1017            let alive = active.segment.alive()?;
1018            index
1019                .push_shared_with_live_rows(sealed, alive.alive_bitmap())
1020                .map_err(crate::planner::LexicalFilterError::from)?;
1021        }
1022        index
1023            .corpus_stats()
1024            .map_err(crate::planner::LexicalFilterError::from)
1025            .map_err(StoreLexicalError::from)
1026    }
1027
1028    #[inline(always)]
1029    fn apply_revision_decision(
1030        &self,
1031        current: &ActiveSegment,
1032        sealed: &[purge::SealedDocumentMatch],
1033        sealed_seq: LogSeq,
1034        document: &IngestDocument,
1035        remaining: &[IngestDocument],
1036        progress: &mut RevisionProgress,
1037    ) -> Result<(), IngestError> {
1038        let segment = progress.working.as_ref().unwrap_or(current);
1039        let existing = resolve_revision(segment, sealed, document.version().doc_id(), sealed_seq);
1040        let action = revise::classify_revision(
1041            existing.map(|resolved| (resolved.version, resolved.seq)),
1042            document.version(),
1043        );
1044        match action {
1045            revise::RevisionDecision::Replace => {
1046                let row = existing.and_then(|resolved| resolved.active_row);
1047                if progress.working.is_none() {
1048                    progress.working =
1049                        Some(current.copy_for_batch(document, remaining, &self.accounting)?);
1050                }
1051                let working = progress
1052                    .working
1053                    .as_mut()
1054                    .ok_or(StoreError::Synchronization {
1055                        component: "batch working segment",
1056                    })?;
1057                let row = if let Some(row) = row {
1058                    working.replace_in_place(row, document, &self.accounting, &self.tokenizer)?;
1059                    row
1060                } else {
1061                    working.insert_in_place(document, &self.accounting, &self.tokenizer)?
1062                };
1063                let (op, payload) = encode_persisted_upsert(document)?;
1064                progress.records.push((row, op, payload));
1065                if sealed
1066                    .iter()
1067                    .any(|matched| matched.version.doc_id() == document.version().doc_id())
1068                    && !progress
1069                        .sealed_tombstones
1070                        .contains(&document.version().doc_id())
1071                {
1072                    progress.sealed_tombstones.push(document.version().doc_id());
1073                }
1074            }
1075            revise::RevisionDecision::Insert => {
1076                if progress.working.is_none() {
1077                    progress.working =
1078                        Some(current.copy_for_batch(document, remaining, &self.accounting)?);
1079                }
1080                let working = progress
1081                    .working
1082                    .as_mut()
1083                    .ok_or(StoreError::Synchronization {
1084                        component: "batch working segment",
1085                    })?;
1086                let row = working.insert_in_place(document, &self.accounting, &self.tokenizer)?;
1087                let (op, payload) = encode_persisted_upsert(document)?;
1088                progress.records.push((row, op, payload));
1089            }
1090            revise::RevisionDecision::Replay { seq } => {
1091                progress.replay_seq = Some(
1092                    progress
1093                        .replay_seq
1094                        .map_or(seq, |current: LogSeq| current.max(seq)),
1095                );
1096                #[cfg(any(test, feature = "test-seams"))]
1097                {
1098                    progress.replay_count = progress.replay_count.saturating_add(1);
1099                }
1100            }
1101            revise::RevisionDecision::Reject { current } => {
1102                return Err(IngestError::StaleRevision {
1103                    doc_id: document.version().doc_id(),
1104                    current,
1105                    attempted: document.version().revision(),
1106                });
1107            }
1108        }
1109        Ok(())
1110    }
1111
1112    #[cfg(any(test, feature = "test-seams"))]
1113    fn record_post_ack_retry_receipt(
1114        &self,
1115        batch: &IngestBatch,
1116        replay_count: usize,
1117        seq: LogSeq,
1118        generation: u64,
1119    ) -> Result<(), IngestError> {
1120        if let Some(controller) = self.ingest_retention_fault_controller.as_ref() {
1121            let plan =
1122                controller
1123                    .post_ack_retry_plan()
1124                    .map_err(|_| StoreError::Synchronization {
1125                        component: "ingest-retention controller",
1126                    })?;
1127            if let Some((invocation_id, first_ack_seq, first_ack_generation)) = plan
1128                && first_ack_seq == seq.get()
1129                && first_ack_generation == generation
1130            {
1131                let batch_count = u64::try_from(batch.documents.len())
1132                    .map_err(|_| StoreError::ActiveRowOverflow)?;
1133                let replay_count =
1134                    u64::try_from(replay_count).map_err(|_| StoreError::ActiveRowOverflow)?;
1135                controller
1136                    .push_receipt(IngestRetentionFaultReceiptV1::post_ack_retry(
1137                        invocation_id,
1138                        batch_count,
1139                        replay_count,
1140                        seq.get(),
1141                        generation,
1142                    ))
1143                    .map_err(|_| StoreError::Synchronization {
1144                        component: "ingest-retention controller",
1145                    })?;
1146            }
1147        }
1148        Ok(())
1149    }
1150
1151    #[cfg(any(test, feature = "test-seams"))]
1152    fn recover_partial_batch_append(
1153        &self,
1154        writer: &mut StoreWal,
1155        error: &StoreError,
1156        partial_append_clean_wal: &Option<Vec<u8>>,
1157        batch: &IngestBatch,
1158        records: &[(usize, u16, Vec<u8>)],
1159    ) -> Result<(), IngestError> {
1160        if let Some(controller) = self.ingest_retention_fault_controller.as_ref()
1161            && let Some(observation) =
1162                controller
1163                    .take_partial_batch_append_observation()
1164                    .map_err(|_| StoreError::Synchronization {
1165                        component: "ingest-retention controller",
1166                    })?
1167        {
1168            let expected_detail = observation.detail.as_str();
1169            match error {
1170                StoreError::WalWrite(WalWriteError::Failed { kind, detail })
1171                    if *kind == std::io::ErrorKind::Other && detail.as_ref() == expected_detail => {
1172                }
1173                _ => {
1174                    return Err(StoreError::Synchronization {
1175                        component: "partial-batch-append typed WAL error",
1176                    }
1177                    .into());
1178                }
1179            }
1180            let clean_wal =
1181                partial_append_clean_wal
1182                    .as_deref()
1183                    .ok_or(StoreError::Synchronization {
1184                        component: "partial-batch-append clean WAL image",
1185                    })?;
1186            let wal_path = self.directory.join("wal.ze");
1187            writer.restore_after_failed_append(
1188                self.vfs.as_ref(),
1189                &wal_path,
1190                self.durability_policy,
1191                clean_wal,
1192            )?;
1193            let submitted_count =
1194                u64::try_from(batch.documents.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1195            let changed_records =
1196                u64::try_from(records.len()).map_err(|_| StoreError::ActiveRowOverflow)?;
1197            let encoded_bytes = u64::try_from(observation.encoded_bytes)
1198                .map_err(|_| StoreError::ActiveRowOverflow)?;
1199            let prefix_bytes = u64::try_from(observation.prefix_bytes)
1200                .map_err(|_| StoreError::ActiveRowOverflow)?;
1201            controller
1202                .push_receipt(IngestRetentionFaultReceiptV1::partial_batch_append(
1203                    observation.invocation_id,
1204                    submitted_count,
1205                    changed_records,
1206                    encoded_bytes,
1207                    prefix_bytes,
1208                    observation.detail,
1209                ))
1210                .map_err(|_| StoreError::Synchronization {
1211                    component: "ingest-retention controller",
1212                })?;
1213        }
1214        Ok(())
1215    }
1216
1217    #[inline(always)]
1218    fn publish_committed_active(
1219        &self,
1220        active: &mut Option<ActiveState>,
1221        committed: Option<(PublishedSnapshot, Vec<PathBuf>)>,
1222        generation: u64,
1223        next: ActiveSegment,
1224    ) -> Result<Vec<PathBuf>, StoreError> {
1225        if let Some((remapped, replaced_paths)) = committed {
1226            let mut published = self
1227                .snapshot
1228                .write()
1229                .map_err(|_| StoreError::Synchronization {
1230                    component: "published snapshot",
1231                })?;
1232            let previous = published.replace(Arc::new(remapped));
1233            *active = Some(ActiveState {
1234                generation,
1235                segment: Arc::new(next),
1236            });
1237            drop(published);
1238            drop(previous);
1239            Ok(replaced_paths)
1240        } else {
1241            *active = Some(ActiveState {
1242                generation,
1243                segment: Arc::new(next),
1244            });
1245            Ok(Vec::new())
1246        }
1247    }
1248
1249    /// Commits one active-segment upsert and returns its WAL/generation coordinates.
1250    pub fn ingest(&self, batch: IngestBatch) -> Result<IngestAck, IngestError> {
1251        let state = self
1252            .state
1253            .lock()
1254            .map_err(|_| StoreError::Synchronization { component: "state" })?;
1255        match *state {
1256            StoreState::Open => {}
1257            StoreState::Closing => return Err(StoreError::Closing.into()),
1258            StoreState::Closed => return Err(StoreError::Closed.into()),
1259        }
1260        drop(state);
1261        match (self.epoch_identity(), batch.epoch) {
1262            (Some(expected), Some(declared)) if expected != declared => {
1263                return Err(IngestError::EpochMismatch(crate::epoch::EpochMismatch {
1264                    expected,
1265                    declared,
1266                }));
1267            }
1268            (Some(_), None) => return Err(IngestError::EpochUndeclared),
1269            (None, Some(_)) => return Err(IngestError::EpochUnstamped),
1270            (Some(_), Some(_)) | (None, None) => {}
1271        }
1272        if batch.documents.is_empty() {
1273            return Err(IngestError::EmptyBatch);
1274        }
1275        if self.epoch.as_ref().is_some_and(|epoch| {
1276            epoch.embedding.document.normalization == crate::epoch::Normalization::L2
1277        }) {
1278            for document in &batch.documents {
1279                if let Some(squared_norm) =
1280                    crate::graph::search::non_unit_squared_norm(document.vector())
1281                {
1282                    return Err(IngestError::Vector(crate::quant::QuantError::NonUnitNorm {
1283                        squared_norm_bits: squared_norm.to_bits(),
1284                        tolerance_bits: crate::graph::search::UNIT_NORM_SQUARED_TOLERANCE.to_bits(),
1285                    }));
1286                }
1287            }
1288        }
1289        for document in &batch.documents {
1290            validate_document_columns(&self.schema, document)?;
1291        }
1292        #[cfg(feature = "graph-cypher")]
1293        let mut native_writer = self.native_document_writer()?;
1294        #[cfg(feature = "graph-cypher")]
1295        if let Some(writer) = native_writer.as_mut().and_then(|slot| slot.as_mut())
1296            && self.documents_have_native_nodes(
1297                &batch
1298                    .documents
1299                    .iter()
1300                    .map(|document| document.version().doc_id())
1301                    .collect::<Vec<_>>(),
1302            )?
1303        {
1304            let result =
1305                self.apply_native_mixed_locked(&batch, writer)
1306                    .map_err(|error| match error {
1307                        crate::lifecycle::native_graph::NativeGraphError::Ingest(error) => error,
1308                        error => IngestError::Graph(Box::new(
1309                            crate::property_graph::query::completed::GraphQueryError::from(error),
1310                        )),
1311                    })?;
1312            let generation = result
1313                .changed_generation()
1314                .unwrap_or(result.admitted_generation())
1315                .get();
1316            return Ok(IngestAck::mixed(result.seq, generation));
1317        }
1318        let mut wal = self
1319            .wal_writer
1320            .lock()
1321            .map_err(|_| StoreError::Synchronization {
1322                component: "WAL writer",
1323            })?;
1324        let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
1325        let mut publication = writer.manifest_publication()?;
1326        let (current_generation, current_segment) = {
1327            let active = self
1328                .active
1329                .lock()
1330                .map_err(|_| StoreError::Synchronization {
1331                    component: "active segment",
1332                })?;
1333            let current = active.as_ref().ok_or(StoreError::Closed)?;
1334            (current.generation, Arc::clone(&current.segment))
1335        };
1336        let snapshot = self
1337            .snapshot
1338            .read()
1339            .map_err(|_| StoreError::Synchronization {
1340                component: "published snapshot",
1341            })?
1342            .as_ref()
1343            .cloned()
1344            .ok_or(StoreError::Closed)?;
1345        check_revision_conditions(
1346            current_segment.as_ref(),
1347            &snapshot,
1348            batch.documents.iter().enumerate().map(|(index, document)| {
1349                (
1350                    index,
1351                    document.version().doc_id(),
1352                    document.expected_revision(),
1353                )
1354            }),
1355        )?;
1356        let requested_ids = batch
1357            .documents
1358            .iter()
1359            .map(|document| document.version().doc_id())
1360            .collect::<Vec<_>>();
1361        let sealed = purge::sealed_document_matches(&snapshot, &requested_ids)?;
1362        let sealed_seq = LogSeq::new(snapshot.absorbed_through());
1363        let mut progress = RevisionProgress {
1364            working: None,
1365            records: Vec::new(),
1366            replay_seq: None,
1367            #[cfg(any(test, feature = "test-seams"))]
1368            replay_count: 0,
1369            sealed_tombstones: Vec::new(),
1370        };
1371        for (index, document) in batch.documents.iter().enumerate() {
1372            let remaining = batch
1373                .documents
1374                .get(index..)
1375                .ok_or(StoreError::ActiveRowOverflow)?;
1376            self.apply_revision_decision(
1377                current_segment.as_ref(),
1378                &sealed,
1379                sealed_seq,
1380                document,
1381                remaining,
1382                &mut progress,
1383            )?;
1384        }
1385        let RevisionProgress {
1386            working,
1387            records,
1388            replay_seq,
1389            #[cfg(any(test, feature = "test-seams"))]
1390            replay_count,
1391            sealed_tombstones,
1392        } = progress;
1393        let Some(mut next) = working else {
1394            let seq = replay_seq.ok_or(StoreError::Synchronization {
1395                component: "nonempty ingest replay sequence",
1396            })?;
1397            #[cfg(any(test, feature = "test-seams"))]
1398            self.record_post_ack_retry_receipt(&batch, replay_count, seq, current_generation)?;
1399            return Ok(IngestAck {
1400                seq,
1401                generation: current_generation,
1402            });
1403        };
1404        let records = atomic_batch::frame_batch(records)?;
1405        let generation = current_generation
1406            .checked_add(1)
1407            .ok_or(StoreError::GenerationOverflow)?;
1408        let prepared = purge::prepare_sealed_tombstones(
1409            self.vfs.as_ref(),
1410            &self.directory,
1411            &snapshot,
1412            &sealed,
1413            &sealed_tombstones,
1414            writer.durable_end(),
1415            generation,
1416            writer.durable_end().saturating_add(1),
1417            #[cfg(feature = "graph-cypher")]
1418            Some(
1419                writer
1420                    .durable_end()
1421                    .checked_add(records.len() as u64)
1422                    .ok_or(StoreError::GenerationOverflow)?,
1423            ),
1424            self.durability_policy,
1425            &self.accounting,
1426        )?;
1427        let record_refs = records
1428            .iter()
1429            .map(|(_, op, payload)| (*op, payload.as_slice()))
1430            .collect::<Vec<_>>();
1431        #[cfg(any(test, feature = "test-seams"))]
1432        let partial_append_clean_wal = match self.ingest_retention_fault_controller.as_ref() {
1433            Some(controller)
1434                if controller
1435                    .partial_batch_append_plan()
1436                    .map_err(|_| StoreError::Synchronization {
1437                        component: "ingest-retention controller",
1438                    })?
1439                    .is_some() =>
1440            {
1441                let path = self.directory.join("wal.ze");
1442                Some(
1443                    self.vfs
1444                        .read(&path)
1445                        .map_err(|source| StoreError::Io { path, source })?,
1446                )
1447            }
1448            Some(_) | None => None,
1449        };
1450        publication = publication.arm();
1451        let sequences = match writer.commit_many(&record_refs) {
1452            Ok(sequences) => sequences,
1453            Err(error) => {
1454                if let Some(prepared) = prepared {
1455                    prepared.abort(self.vfs.as_ref(), &self.directory, self.durability_policy)?;
1456                }
1457                #[cfg(any(test, feature = "test-seams"))]
1458                self.recover_partial_batch_append(
1459                    writer,
1460                    &error,
1461                    &partial_append_clean_wal,
1462                    &batch,
1463                    &records,
1464                )?;
1465                publication.on_wal_error(&error);
1466                return Err(error.into());
1467            }
1468        };
1469        for ((row, _, _), sequence) in records
1470            .iter()
1471            .zip(sequences.start.get()..sequences.end.get())
1472        {
1473            next.set_sequence(*row, LogSeq::new(sequence))?;
1474        }
1475        let seq = LogSeq::new(sequences.end.get().saturating_sub(1));
1476        let committed = prepared
1477            .map(|prepared| {
1478                prepared.commit(
1479                    &mut publication,
1480                    self,
1481                    self.vfs.as_ref(),
1482                    &self.directory,
1483                    self.durability_policy,
1484                )
1485            })
1486            .transpose()?;
1487        let mut active = self
1488            .active
1489            .lock()
1490            .map_err(|_| StoreError::Synchronization {
1491                component: "active segment",
1492            })?;
1493        let actual_generation = active.as_ref().ok_or(StoreError::Closed)?.generation;
1494        validate_republish_generation(current_generation, actual_generation)?;
1495        let replaced_paths =
1496            self.publish_committed_active(&mut active, committed, generation, next)?;
1497        #[cfg(feature = "graph-cypher")]
1498        self.publish_native_documents(active.as_ref().ok_or(StoreError::Closed)?)?;
1499        publication.complete();
1500        drop(active);
1501        purge::unlink_replaced_segments(
1502            self,
1503            self.vfs.as_ref(),
1504            &self.directory,
1505            &replaced_paths,
1506            self.durability_policy,
1507        );
1508        drop(wal);
1509        Ok(IngestAck { seq, generation })
1510    }
1511
1512    /// Durably tombstones documents across active and sealed state before acknowledging.
1513    pub fn delete(&self, batch: DeleteBatch) -> Result<IngestAck, IngestError> {
1514        if batch.doc_ids.is_empty() {
1515            return Err(IngestError::EmptyBatch);
1516        }
1517        #[cfg(feature = "graph-cypher")]
1518        let mut native_writer = self.native_document_writer()?;
1519        #[cfg(feature = "graph-cypher")]
1520        if let Some(writer) = native_writer.as_mut().and_then(|slot| slot.as_mut())
1521            && self.documents_have_native_nodes(batch.doc_ids())?
1522        {
1523            return self.delete_native_documents_locked(&batch, writer);
1524        }
1525        let state = self
1526            .state
1527            .lock()
1528            .map_err(|_| StoreError::Synchronization { component: "state" })?;
1529        match *state {
1530            StoreState::Open => {}
1531            StoreState::Closing => return Err(StoreError::Closing.into()),
1532            StoreState::Closed => return Err(StoreError::Closed.into()),
1533        }
1534        drop(state);
1535        let mut wal = self
1536            .wal_writer
1537            .lock()
1538            .map_err(|_| StoreError::Synchronization {
1539                component: "WAL writer",
1540            })?;
1541        let writer = wal.as_mut().ok_or(StoreError::ReadOnly)?;
1542        self.delete_with_writer(writer, &batch.doc_ids, &batch.expected)
1543    }
1544
1545    /// The body of [`Self::delete`] for a caller that already holds the WAL
1546    /// writer, so one mutation can resolve ids and tombstone them without
1547    /// another write landing in between. `doc_ids` must be non-empty.
1548    /// `expected` holds the revision condition of each id by position; a
1549    /// missing entry is unconditional.
1550    pub(crate) fn delete_with_writer(
1551        &self,
1552        writer: &mut StoreWal,
1553        doc_ids: &[DocId],
1554        expected: &[Option<ExpectedRevision>],
1555    ) -> Result<IngestAck, IngestError> {
1556        let (current_generation, current_segment) = {
1557            let active = self
1558                .active
1559                .lock()
1560                .map_err(|_| StoreError::Synchronization {
1561                    component: "active segment",
1562                })?;
1563            let current = active.as_ref().ok_or(StoreError::Closed)?;
1564            (current.generation, Arc::clone(&current.segment))
1565        };
1566        let snapshot = self
1567            .snapshot
1568            .read()
1569            .map_err(|_| StoreError::Synchronization {
1570                component: "published snapshot",
1571            })?
1572            .as_ref()
1573            .cloned()
1574            .ok_or(StoreError::Closed)?;
1575        check_revision_conditions(
1576            current_segment.as_ref(),
1577            &snapshot,
1578            doc_ids
1579                .iter()
1580                .enumerate()
1581                .map(|(index, doc_id)| (index, *doc_id, expected.get(index).copied().flatten())),
1582        )?;
1583        let sealed = purge::sealed_document_matches(&snapshot, doc_ids)?;
1584        let generation = current_generation
1585            .checked_add(1)
1586            .ok_or(StoreError::GenerationOverflow)?;
1587        let (mut next, rows) = current_segment.tombstone(doc_ids, &self.accounting)?;
1588        let prepared = purge::prepare_sealed_tombstones(
1589            self.vfs.as_ref(),
1590            &self.directory,
1591            &snapshot,
1592            &sealed,
1593            doc_ids,
1594            writer.durable_end(),
1595            generation,
1596            writer.durable_end().saturating_add(1),
1597            #[cfg(feature = "graph-cypher")]
1598            Some(
1599                writer
1600                    .durable_end()
1601                    .checked_add(1)
1602                    .ok_or(StoreError::GenerationOverflow)?,
1603            ),
1604            self.durability_policy,
1605            &self.accounting,
1606        )?;
1607        let payload = wal_payload::encode_delete(doc_ids).map_err(IngestError::Payload)?;
1608        let mut publication = writer.manifest_publication()?.arm();
1609        let seq = match writer.commit(wal_payload::DELETE_V1, &payload) {
1610            Ok(seq) => seq,
1611            Err(error) => {
1612                if let Some(prepared) = prepared {
1613                    prepared.abort(self.vfs.as_ref(), &self.directory, self.durability_policy)?;
1614                }
1615                publication.on_wal_error(&error);
1616                return Err(error.into());
1617            }
1618        };
1619        for row in rows {
1620            next.set_sequence(row, seq)?;
1621        }
1622        let committed = prepared
1623            .map(|prepared| {
1624                prepared.commit(
1625                    &mut publication,
1626                    self,
1627                    self.vfs.as_ref(),
1628                    &self.directory,
1629                    self.durability_policy,
1630                )
1631            })
1632            .transpose()?;
1633        let mut active = self
1634            .active
1635            .lock()
1636            .map_err(|_| StoreError::Synchronization {
1637                component: "active segment",
1638            })?;
1639        let actual_generation = active.as_ref().ok_or(StoreError::Closed)?.generation;
1640        validate_republish_generation(current_generation, actual_generation)?;
1641        let replaced_paths =
1642            self.publish_committed_active(&mut active, committed, generation, next)?;
1643        #[cfg(feature = "graph-cypher")]
1644        self.publish_native_documents(active.as_ref().ok_or(StoreError::Closed)?)?;
1645        publication.complete();
1646        drop(active);
1647        purge::unlink_replaced_segments(
1648            self,
1649            self.vfs.as_ref(),
1650            &self.directory,
1651            &replaced_paths,
1652            self.durability_policy,
1653        );
1654        Ok(IngestAck { seq, generation })
1655    }
1656}
1657
1658fn encode_persisted_upsert(document: &IngestDocument) -> Result<(u16, Vec<u8>), IngestError> {
1659    wal_payload::encode_upsert_v2(document)
1660        .map(|payload| (wal_payload::UPSERT_V2, payload))
1661        .map_err(IngestError::Payload)
1662}
1663
1664pub(crate) fn validate_document_columns(
1665    schema: &Schema,
1666    document: &IngestDocument,
1667) -> Result<(), IngestError> {
1668    let mut builder = ColumnStoreBuilder::new(schema.clone());
1669    builder
1670        .push_row(document.timestamp(), &document.column_inputs())
1671        .map(|_| ())
1672        .map_err(IngestError::Columns)
1673}
1674
1675impl From<WalWriteError> for IngestError {
1676    fn from(error: WalWriteError) -> Self {
1677        Self::Store(StoreError::WalWrite(error))
1678    }
1679}
1680
1681#[cfg(test)]
1682mod vector_order_tests {
1683    use super::*;
1684
1685    #[test]
1686    fn documented_candidate_precedes_legacy_candidate_at_an_equal_score() {
1687        let segment = SegmentId::from_bytes([0x44; 16]);
1688        let legacy = SearchCandidate::new(
1689            GlobalRowId::new(RowSource::Sealed(segment), 0),
1690            None,
1691            1.0,
1692            true,
1693        );
1694        let documented = SearchCandidate::new(
1695            GlobalRowId::new(RowSource::Sealed(segment), 9),
1696            Some(DocumentVersion::new(DocId::new(9), Revision::new(1))),
1697            1.0,
1698            true,
1699        );
1700
1701        assert_eq!(
1702            compare_search_candidates(&documented, &legacy),
1703            std::cmp::Ordering::Less,
1704            "documented-vs-legacy order fell back to physical row identity"
1705        );
1706    }
1707}
1708
1709#[cfg(test)]
1710mod active_publish_tests {
1711    use super::*;
1712
1713    #[test]
1714    fn mismatched_active_generation_on_republish_is_a_typed_error() {
1715        assert!(matches!(
1716            validate_republish_generation(4, 5),
1717            Err(StoreError::ConcurrentActiveMutation {
1718                expected_generation: 4,
1719                actual_generation: 5,
1720            })
1721        ));
1722    }
1723}
1724
1725#[cfg(all(test, feature = "allocation-audit"))]
1726#[allow(clippy::expect_used)]
1727mod batch_working_copy_tests {
1728    use tempfile::tempdir;
1729
1730    use crate::lifecycle::OpenOptions;
1731
1732    use super::*;
1733
1734    #[test]
1735    fn batch_ingest_clones_the_active_segment_at_most_once() {
1736        let directory = tempdir().expect("store directory");
1737        let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1738        store
1739            .ingest(IngestBatch::new(vec![IngestDocument::new(
1740                DocumentVersion::new(DocId::new(1), Revision::new(1)),
1741                vec![1.0, 0.0],
1742            )]))
1743            .expect("seed active segment");
1744        let documents = (2_u128..=25)
1745            .map(|doc_id| {
1746                IngestDocument::new(
1747                    DocumentVersion::new(DocId::new(doc_id), Revision::new(1)),
1748                    vec![1.0, 0.0],
1749                )
1750            })
1751            .collect::<Vec<_>>();
1752
1753        let (result, report) = crate::allocation_audit::audit_engine_path(|| {
1754            store.ingest(IngestBatch::new(documents))
1755        });
1756
1757        result.expect("ingest audited batch");
1758        assert_eq!(
1759            report.full_segment_clones, 1,
1760            "one ingest batch made more than one full active-segment clone"
1761        );
1762    }
1763}
1764
1765#[cfg(test)]
1766#[allow(clippy::expect_used)]
1767mod wal_replay_tests {
1768    use tempfile::tempdir;
1769
1770    use crate::lifecycle::{DocumentFields, OpenOptions, StoredDocument};
1771
1772    use super::*;
1773
1774    fn document(doc_id: u128, revision: u64, text: Option<&str>) -> IngestDocument {
1775        let document = IngestDocument::new(
1776            DocumentVersion::new(DocId::new(doc_id), Revision::new(revision)),
1777            vec![1.0, doc_id as f32],
1778        );
1779        match text {
1780            Some(text) => document.with_text(text),
1781            None => document,
1782        }
1783    }
1784
1785    /// Writes one WAL record per mutation: text-less rows before the first
1786    /// texted row, replacements, deletes, and a deleted id written again.
1787    pub(super) fn write_mixed_wal(store: &Store) {
1788        for doc_id in 1_u128..=40 {
1789            let text = format!("alpha beta note {doc_id}");
1790            let text = (doc_id > 3 && doc_id % 7 != 0).then_some(text.as_str());
1791            store
1792                .ingest(IngestBatch::new(vec![document(doc_id, 1, text)]))
1793                .expect("ingest one row");
1794        }
1795        for doc_id in [3_u128, 10, 21] {
1796            store
1797                .ingest(IngestBatch::new(vec![document(
1798                    doc_id,
1799                    2,
1800                    Some("gamma replaced text"),
1801                )]))
1802                .expect("replace one row");
1803        }
1804        store
1805            .delete(DeleteBatch::new(vec![
1806                DocId::new(5),
1807                DocId::new(6),
1808                DocId::new(10),
1809            ]))
1810            .expect("delete rows");
1811        store
1812            .ingest(IngestBatch::new(vec![document(
1813                5,
1814                2,
1815                Some("delta revived"),
1816            )]))
1817            .expect("write a deleted id again");
1818    }
1819
1820    fn observed(store: &Store) -> (Vec<Option<StoredDocument>>, crate::fts::bm25::CorpusStats) {
1821        let ids = (1_u128..=41).map(DocId::new).collect::<Vec<_>>();
1822        (
1823            store
1824                .get_documents(&ids, DocumentFields::TEXT)
1825                .expect("read documents"),
1826            store.lexical_corpus_stats().expect("corpus stats"),
1827        )
1828    }
1829
1830    #[test]
1831    fn sealed_revision_lookup_finds_every_sealed_copy_of_an_id() {
1832        let directory = tempdir().expect("store directory");
1833        let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1834        for (doc_id, revision) in [(1_u128, 1_u64), (2, 1), (3, 1)] {
1835            store
1836                .ingest(IngestBatch::new(vec![document(
1837                    doc_id,
1838                    revision,
1839                    Some("first"),
1840                )]))
1841                .expect("ingest first segment");
1842        }
1843        store.seal().expect("seal first segment");
1844        store
1845            .ingest(IngestBatch::new(vec![document(2, 2, Some("second"))]))
1846            .expect("replace a sealed row");
1847        store.seal().expect("seal second segment");
1848
1849        let stale = store.ingest(IngestBatch::new(vec![document(2, 1, Some("stale"))]));
1850        assert!(
1851            matches!(stale, Err(IngestError::StaleRevision { .. })),
1852            "a revision older than a sealed one was accepted: {stale:?}"
1853        );
1854        store
1855            .ingest(IngestBatch::new(vec![document(2, 3, Some("third"))]))
1856            .expect("write a newer revision");
1857        store
1858            .delete(DeleteBatch::new(vec![DocId::new(3)]))
1859            .expect("delete a sealed row");
1860        let documents = store
1861            .get_documents(
1862                &[DocId::new(1), DocId::new(2), DocId::new(3)],
1863                DocumentFields::TEXT,
1864            )
1865            .expect("read documents");
1866        let texts = documents
1867            .iter()
1868            .map(|document| document.as_ref().and_then(|document| document.text.clone()))
1869            .collect::<Vec<_>>();
1870        assert_eq!(
1871            texts,
1872            vec![Some("first".to_owned()), Some("third".to_owned()), None]
1873        );
1874        store.close().expect("close store");
1875    }
1876
1877    #[test]
1878    fn wal_replay_reopens_the_exact_live_state() {
1879        let directory = tempdir().expect("store directory");
1880        let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1881        write_mixed_wal(&store);
1882        let before = observed(&store);
1883        store.close().expect("close store");
1884
1885        let reopened = Store::open(directory.path(), OpenOptions::default()).expect("reopen");
1886        assert_eq!(observed(&reopened), before);
1887        reopened.close().expect("close reopened store");
1888    }
1889}
1890
1891#[cfg(all(test, feature = "allocation-audit"))]
1892#[allow(clippy::expect_used)]
1893mod wal_replay_copy_tests {
1894    use tempfile::tempdir;
1895
1896    use crate::lifecycle::OpenOptions;
1897
1898    use super::*;
1899
1900    #[test]
1901    fn wal_replay_clones_the_active_segment_at_most_once() {
1902        let directory = tempdir().expect("store directory");
1903        let store = Store::open(directory.path(), OpenOptions::default()).expect("open store");
1904        super::wal_replay_tests::write_mixed_wal(&store);
1905        store.close().expect("close store");
1906
1907        let (reopened, report) = crate::allocation_audit::audit_engine_path(|| {
1908            Store::open(directory.path(), OpenOptions::default())
1909        });
1910
1911        reopened
1912            .expect("reopen")
1913            .close()
1914            .expect("close reopened store");
1915        assert!(
1916            report.full_segment_clones <= 1,
1917            "WAL replay cloned the active segment {} times",
1918            report.full_segment_clones
1919        );
1920    }
1921}