Skip to main content

zeppelin_embed/ingest/
mod.rs

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