Skip to main content

zeppelin_embed/lifecycle/
materialize.rs

1//! Scoped ownership of text returned from a ranked store query.
2
3use super::{PublishedSnapshot, QueryCancellation, QueryError, SnapshotLease, Store, StoreError};
4use crate::ingest::{
5    ActiveSegment, DocId, DocumentVersion, GlobalRowId, RowSource, SearchCandidate,
6};
7use std::cell::Cell;
8use std::sync::Arc;
9
10/// Preserves a deferred vector producer's typed error separately from search.
11#[derive(Debug)]
12pub enum HybridPreparationError<E> {
13    /// The caller could not produce a validated query vector.
14    Preparation(E),
15    /// Admission, retrieval, fusion, cancellation or materialization failed.
16    Search(crate::fusion::FusionError),
17}
18
19impl<E> From<crate::fusion::FusionError> for HybridPreparationError<E> {
20    fn from(error: crate::fusion::FusionError) -> Self {
21        Self::Search(error)
22    }
23}
24
25impl<E> From<QueryError> for HybridPreparationError<E> {
26    fn from(error: QueryError) -> Self {
27        Self::Search(error.into())
28    }
29}
30
31impl<E: std::fmt::Display> std::fmt::Display for HybridPreparationError<E> {
32    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::Preparation(error) => error.fmt(formatter),
35            Self::Search(error) => error.fmt(formatter),
36        }
37    }
38}
39
40impl<E: std::error::Error + 'static> std::error::Error for HybridPreparationError<E> {
41    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
42        match self {
43            Self::Preparation(error) => Some(error),
44            Self::Search(error) => Some(error),
45        }
46    }
47}
48
49#[cfg(any(test, feature = "test-support"))]
50#[derive(Default)]
51pub(super) struct TestMaterializationWork {
52    pub(super) admissions: std::sync::atomic::AtomicU64,
53    pub(super) reverse_version_lookups: std::sync::atomic::AtomicU64,
54    pub(super) direct_row_lookups: std::sync::atomic::AtomicU64,
55    #[cfg(feature = "query-timing")]
56    pub(super) lexical_admission_locks: std::sync::atomic::AtomicU64,
57    #[cfg(feature = "query-timing")]
58    pub(super) lexical_admission_lock_nanos: std::sync::atomic::AtomicU64,
59}
60
61#[cfg(all(feature = "query-timing", any(test, feature = "test-support")))]
62impl TestMaterializationWork {
63    pub(super) fn observe_lexical_lock(&self, started: std::time::Instant) {
64        use std::sync::atomic::Ordering::Relaxed;
65        self.lexical_admission_locks.fetch_add(1, Relaxed);
66        self.lexical_admission_lock_nanos.fetch_add(
67            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
68            Relaxed,
69        );
70    }
71}
72
73/// Literal lifecycle and source-lookup observations for directed query tests.
74#[cfg(any(test, feature = "test-support"))]
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct MaterializationTestCounters {
77    /// Invocations of vector or lexical admission, including standalone stored_text.
78    pub admissions: u64,
79    /// Invocations of active or sealed reverse document-version lookup.
80    pub reverse_version_lookups: u64,
81    /// Ranked physical rows requested through the scoped materializer.
82    pub direct_row_lookups: u64,
83    /// Measured state, active, and snapshot lock acquisitions in lexical admission.
84    #[cfg(feature = "query-timing")]
85    pub lexical_admission_locks: u64,
86    /// Wall nanoseconds in those acquisitions, including uncontended call overhead.
87    #[cfg(feature = "query-timing")]
88    pub lexical_admission_lock_nanos: u64,
89}
90
91/// One owned source row corresponding to a ranked query hit.
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct MaterializedRow {
94    /// The exact document version whose row was scored.
95    pub document: DocumentVersion,
96    /// Original physical row coordinates within the retrieval snapshot.
97    pub row_id: GlobalRowId,
98    /// One owned copy of the stored UTF-8 body.
99    pub text: String,
100}
101
102/// A ranked row could not be materialized faithfully.
103#[derive(Debug)]
104pub enum MaterializationError {
105    /// The caller requested a rank outside the returned list.
106    RankOutOfRange {
107        /// Zero-based rank requested by the caller.
108        rank: usize,
109        /// Number of ranked hits available.
110        returned: usize,
111    },
112    /// The ranked row has no application identity.
113    MissingIdentity {
114        /// Physical row lacking an application identity.
115        row_id: GlobalRowId,
116    },
117    /// The ranked document has no stored text.
118    MissingText {
119        /// Physical row lacking stored text.
120        row_id: GlobalRowId,
121        /// Ranked application identity.
122        document: DocumentVersion,
123    },
124    /// The ranked physical source is absent from the admitted snapshot.
125    MissingSource {
126        /// Physical row whose source is absent.
127        row_id: GlobalRowId,
128    },
129    /// A lexical source ordinal has no physical source in the admitted assembly.
130    InvalidLexicalSource {
131        /// Source ordinal from the ranked lexical hit.
132        source: u32,
133    },
134    /// A fused application key has no retained source identity.
135    MissingFusedIdentity {
136        /// Fused application key lacking source coordinates.
137        document: DocId,
138    },
139    /// The physical row does not contain the version that was ranked.
140    IdentityMismatch {
141        /// Physical row that failed identity validation.
142        row_id: GlobalRowId,
143        /// Application identity retained during ranking.
144        expected: DocumentVersion,
145        /// Application identity found at the physical row.
146        actual: Option<DocumentVersion>,
147    },
148    /// The result could not reserve its owned text buffer.
149    AllocationFailed {
150        /// UTF-8 text bytes requested from the allocator.
151        bytes: usize,
152    },
153    /// A materialization work count overflowed.
154    ArithmeticOverflow,
155    /// Cancellation or deadline prevented complete materialization.
156    Query(QueryError),
157    /// Storage validation or lifecycle failure.
158    Storage(StoreError),
159}
160
161impl std::fmt::Display for MaterializationError {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        match self {
164            Self::RankOutOfRange { rank, returned } => {
165                write!(f, "result rank {rank} is outside {returned} returned rows")
166            }
167            Self::MissingIdentity { row_id } => {
168                write!(f, "result row {row_id:?} has no document identity")
169            }
170            Self::MissingText { row_id, document } => write!(
171                f,
172                "result row {row_id:?} for {document:?} has no stored text"
173            ),
174            Self::MissingSource { row_id } => {
175                write!(f, "result row {row_id:?} is outside the admitted snapshot")
176            }
177            Self::InvalidLexicalSource { source } => write!(
178                f,
179                "lexical result source {source} is outside the admitted assembly"
180            ),
181            Self::MissingFusedIdentity { document } => write!(
182                f,
183                "fused document {document:?} has no ranked source identity"
184            ),
185            Self::IdentityMismatch {
186                row_id,
187                expected,
188                actual,
189            } => write!(
190                f,
191                "result row {row_id:?} expected {expected:?}, found {actual:?}"
192            ),
193            Self::AllocationFailed { bytes } => {
194                write!(f, "result text allocation of {bytes} bytes failed")
195            }
196            Self::ArithmeticOverflow => f.write_str("result materialization counter overflow"),
197            Self::Query(error) => error.fmt(f),
198            Self::Storage(error) => error.fmt(f),
199        }
200    }
201}
202impl std::error::Error for MaterializationError {
203    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
204        match self {
205            Self::Storage(error) => Some(error),
206            Self::Query(error) => Some(error),
207            _ => None,
208        }
209    }
210}
211
212/// Rank-addressed text access available only inside a Store query callback.
213pub struct QueryMaterializer<'a> {
214    #[cfg(any(test, feature = "test-support"))]
215    pub(super) store: &'a Store,
216    pub(super) snapshot: &'a PublishedSnapshot,
217    pub(super) active: &'a ActiveSegment,
218    pub(super) cancellation: &'a QueryCancellation<'a>,
219    pub(super) address: &'a dyn Fn(usize) -> Result<ResultAddress, MaterializationError>,
220    pub(super) counts: Cell<crate::diag::MaterializationCounters>,
221}
222
223#[derive(Clone, Copy, Eq, PartialEq)]
224pub(super) struct ResultAddress {
225    pub(super) row_id: GlobalRowId,
226    pub(super) document: Option<DocumentVersion>,
227}
228
229pub(super) type HybridAddresses = super::stats::Accounted<Vec<(DocId, ResultAddress)>>;
230
231impl From<&SearchCandidate> for ResultAddress {
232    fn from(candidate: &SearchCandidate) -> Self {
233        Self {
234            row_id: candidate.row_id(),
235            document: candidate.document(),
236        }
237    }
238}
239
240impl QueryMaterializer<'_> {
241    /// Copies the exact stored text and version associated with one result rank.
242    pub fn text(&self, rank: usize) -> Result<MaterializedRow, MaterializationError> {
243        self.check()?;
244        let address = (self.address)(rank)?;
245        self.materialize_address(address)
246    }
247
248    /// Plants a wrong or missing ranked identity at the actual physical-row validation seam.
249    #[cfg(any(test, feature = "test-support"))]
250    pub fn text_with_test_document(
251        &self,
252        rank: usize,
253        document: Option<DocumentVersion>,
254    ) -> Result<MaterializedRow, MaterializationError> {
255        self.check()?;
256        let mut address = (self.address)(rank)?;
257        address.document = document;
258        self.materialize_address(address)
259    }
260
261    fn materialize_address(
262        &self,
263        address: ResultAddress,
264    ) -> Result<MaterializedRow, MaterializationError> {
265        let row_id = address.row_id;
266        let document = address
267            .document
268            .ok_or(MaterializationError::MissingIdentity { row_id })?;
269        #[cfg(any(test, feature = "test-support"))]
270        self.store
271            .text_materialization_work
272            .direct_row_lookups
273            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
274        let mut counts = self.counts.get();
275        counts.row_lookups = counts
276            .row_lookups
277            .checked_add(1)
278            .ok_or(MaterializationError::ArithmeticOverflow)?;
279        self.counts.set(counts);
280        let row = row_id.local_row() as usize;
281        let (actual, source) = match row_id.source() {
282            RowSource::Active => (
283                self.active.document(row),
284                self.active
285                    .text(row)
286                    .map_err(MaterializationError::Storage)?,
287            ),
288            RowSource::Sealed(id) => {
289                let segment = self
290                    .snapshot
291                    .segments()
292                    .iter()
293                    .find(|segment| segment.meta().id == id)
294                    .ok_or(MaterializationError::MissingSource { row_id })?;
295                let actual = segment
296                    .document_version(row)
297                    .map_err(StoreError::Segment)
298                    .map_err(MaterializationError::Storage)?;
299                let source = segment
300                    .query_stored_text()
301                    .map_err(StoreError::Segment)
302                    .map_err(MaterializationError::Storage)?
303                    .and_then(|text| text.row(row).flatten());
304                (actual, source)
305            }
306        };
307        if actual != Some(document) {
308            return Err(MaterializationError::IdentityMismatch {
309                row_id,
310                expected: document,
311                actual,
312            });
313        }
314        let source = source.ok_or(MaterializationError::MissingText { row_id, document })?;
315        let mut text = String::new();
316        text.try_reserve_exact(source.len()).map_err(|_| {
317            MaterializationError::AllocationFailed {
318                bytes: source.len(),
319            }
320        })?;
321        text.push_str(source);
322        let mut counts = self.counts.get();
323        counts.text_copies = counts
324            .text_copies
325            .checked_add(1)
326            .ok_or(MaterializationError::ArithmeticOverflow)?;
327        counts.text_bytes = counts
328            .text_bytes
329            .checked_add(source.len() as u64)
330            .ok_or(MaterializationError::ArithmeticOverflow)?;
331        self.counts.set(counts);
332        self.check()?;
333        Ok(MaterializedRow {
334            document,
335            row_id,
336            text,
337        })
338    }
339
340    fn check(&self) -> Result<(), MaterializationError> {
341        self.cancellation
342            .check_graph()
343            .map_err(super::map_scan_error)
344            .map_err(MaterializationError::Query)
345    }
346}
347
348impl Store {
349    /// Starts lexical retrieval before obtaining a caller-owned query vector.
350    /// Both legs and returned text use one admission pinned before the producer
351    /// runs. All submitted lexical work is joined on producer failure or panic.
352    ///
353    /// The returned vector borrow must remain valid for the complete query.
354    /// Producer errors retain their original type; core failures are separate.
355    ///
356    /// # Errors
357    /// Returns the producer's error or a typed admission/search failure.
358    pub fn search_hybrid_with_text_deferred<'vector, E, R>(
359        &self,
360        prepare_vector: impl FnOnce() -> Result<crate::ingest::SearchRequest<'vector>, E>,
361        lexical: &crate::fts::search::TermQuery,
362        query: &crate::fusion::HybridQuery,
363        options: impl Into<super::SearchOptions>,
364        control: super::QueryControl,
365        finish: impl FnOnce(&crate::ingest::StoreHybridSearchOutcome, &QueryMaterializer<'_>) -> R,
366    ) -> Result<(crate::ingest::StoreHybridSearchOutcome, R), HybridPreparationError<E>> {
367        self.search_hybrid_prepared_then(
368            prepare_vector,
369            super::PinnedLexicalQuery::Term(lexical),
370            query,
371            options.into(),
372            control,
373            true,
374            |mut outcome, snapshot, active, cancellation, addresses| {
375                cancellation.check_graph().map_err(super::map_scan_error)?;
376                let address = |rank| {
377                    let hit =
378                        outcome
379                            .hits
380                            .get(rank)
381                            .ok_or(MaterializationError::RankOutOfRange {
382                                rank,
383                                returned: outcome.hits.len(),
384                            })?;
385                    let addresses = addresses
386                        .ok_or(MaterializationError::MissingFusedIdentity { document: hit.key })?;
387                    let index = addresses
388                        .binary_search_by_key(&hit.key, |(key, _)| *key)
389                        .map_err(|_| MaterializationError::MissingFusedIdentity {
390                            document: hit.key,
391                        })?;
392                    addresses
393                        .get(index)
394                        .map(|(_, address)| *address)
395                        .ok_or(MaterializationError::MissingFusedIdentity { document: hit.key })
396                };
397                let materializer = QueryMaterializer {
398                    #[cfg(any(test, feature = "test-support"))]
399                    store: self,
400                    snapshot,
401                    active,
402                    cancellation,
403                    address: &address,
404                    counts: Cell::new(Default::default()),
405                };
406                let value = finish(&outcome, &materializer);
407                let counts = materializer.counts.get();
408                outcome.diagnostics.materialization = Some(counts);
409                cancellation.check_graph().map_err(super::map_scan_error)?;
410                Ok((outcome, value))
411            },
412        )
413    }
414
415    /// Reads literal counters without resetting concurrent observations.
416    #[cfg(any(test, feature = "test-support"))]
417    #[must_use]
418    pub fn query_materialization_test_counters(&self) -> MaterializationTestCounters {
419        use std::sync::atomic::Ordering::Relaxed;
420        MaterializationTestCounters {
421            admissions: self.text_materialization_work.admissions.load(Relaxed),
422            reverse_version_lookups: self
423                .text_materialization_work
424                .reverse_version_lookups
425                .load(Relaxed),
426            direct_row_lookups: self
427                .text_materialization_work
428                .direct_row_lookups
429                .load(Relaxed),
430            #[cfg(feature = "query-timing")]
431            lexical_admission_locks: self
432                .text_materialization_work
433                .lexical_admission_locks
434                .load(Relaxed),
435            #[cfg(feature = "query-timing")]
436            lexical_admission_lock_nanos: self
437                .text_materialization_work
438                .lexical_admission_lock_nanos
439                .load(Relaxed),
440        }
441    }
442    /// Runs hybrid fusion and constructs caller-owned results before releasing its snapshot.
443    pub fn search_hybrid_with_text<R>(
444        &self,
445        vector: crate::ingest::SearchRequest<'_>,
446        lexical: &crate::fts::search::TermQuery,
447        query: &crate::fusion::HybridQuery,
448        options: impl Into<super::SearchOptions>,
449        control: super::QueryControl,
450        finish: impl FnOnce(&crate::ingest::StoreHybridSearchOutcome, &QueryMaterializer<'_>) -> R,
451    ) -> Result<(crate::ingest::StoreHybridSearchOutcome, R), crate::fusion::FusionError> {
452        self.search_hybrid_with_text_deferred(
453            || Ok::<_, std::convert::Infallible>(vector),
454            lexical,
455            query,
456            options,
457            control,
458            finish,
459        )
460        .map_err(|error| match error {
461            HybridPreparationError::Preparation(never) => match never {},
462            HybridPreparationError::Search(error) => error,
463        })
464    }
465
466    /// Runs a lexical query and constructs caller-owned results before releasing its snapshot.
467    pub fn search_lexical_with_text<R>(
468        &self,
469        query: &crate::fts::search::TermQuery,
470        k: usize,
471        control: super::QueryControl,
472        finish: impl FnOnce(&crate::ingest::StoreLexicalSearchOutcome, &QueryMaterializer<'_>) -> R,
473    ) -> Result<(crate::ingest::StoreLexicalSearchOutcome, R), crate::ingest::StoreLexicalError>
474    {
475        self.search_lexical_then(
476            query,
477            k,
478            control,
479            |mut outcome, snapshot, active, assembly, hits, cancellation| {
480                cancellation.check_graph().map_err(super::map_scan_error)?;
481                let address = |rank| {
482                    let candidate = outcome.candidates.get(rank).ok_or(
483                        MaterializationError::RankOutOfRange {
484                            rank,
485                            returned: outcome.candidates.len(),
486                        },
487                    )?;
488                    let hit = hits.get(rank).ok_or(MaterializationError::RankOutOfRange {
489                        rank,
490                        returned: hits.len(),
491                    })?;
492                    Ok(ResultAddress {
493                        row_id: lexical_row_id(snapshot, &assembly.sources, hit.doc)?,
494                        document: Some(candidate.document),
495                    })
496                };
497                let materializer = QueryMaterializer {
498                    #[cfg(any(test, feature = "test-support"))]
499                    store: self,
500                    snapshot,
501                    active,
502                    cancellation,
503                    address: &address,
504                    counts: Cell::new(Default::default()),
505                };
506                let value = finish(&outcome, &materializer);
507                let counts = materializer.counts.get();
508                outcome.diagnostics.materialization = Some(counts);
509                cancellation.check_graph().map_err(super::map_scan_error)?;
510                Ok((outcome, value))
511            },
512        )
513    }
514
515    /// Runs a dense query and constructs owned caller results through rank-addressed text access.
516    pub fn search_with_text<R>(
517        &self,
518        request: crate::ingest::SearchRequest<'_>,
519        k: usize,
520        options: impl Into<super::SearchOptions>,
521        control: super::QueryControl,
522        finish: impl FnOnce(&crate::ingest::SearchOutcome, &QueryMaterializer<'_>) -> R,
523    ) -> Result<(crate::ingest::SearchOutcome, R), super::QueryError> {
524        self.search_with_graph_bound_mode_then(
525            request,
526            k,
527            options.into(),
528            control,
529            super::GraphBoundMode::Shared,
530            |mut outcome, admitted, control| {
531                let lease =
532                    SnapshotLease::new_at(Arc::clone(&admitted.snapshot), admitted.generation);
533                let cancellation = QueryCancellation::new(control, &lease);
534                cancellation.check_graph().map_err(super::map_scan_error)?;
535                let address = |rank| {
536                    outcome.candidates.get(rank).map(ResultAddress::from).ok_or(
537                        MaterializationError::RankOutOfRange {
538                            rank,
539                            returned: outcome.candidates.len(),
540                        },
541                    )
542                };
543                let materializer = QueryMaterializer {
544                    #[cfg(any(test, feature = "test-support"))]
545                    store: self,
546                    counts: Cell::new(Default::default()),
547                    snapshot: &admitted.snapshot,
548                    active: &admitted.active_segment,
549                    cancellation: &cancellation,
550                    address: &address,
551                };
552                let value = finish(&outcome, &materializer);
553                let counts = materializer.counts.get();
554                outcome.diagnostics.materialization = Some(counts);
555                cancellation.check_graph().map_err(super::map_scan_error)?;
556                Ok((outcome, value))
557            },
558        )
559    }
560}
561
562fn lexical_row_id(
563    snapshot: &PublishedSnapshot,
564    sources: &[super::StructuredLexicalSource],
565    doc: crate::fts::search::GlobalDocId,
566) -> Result<GlobalRowId, MaterializationError> {
567    let source =
568        sources
569            .get(doc.segment as usize)
570            .ok_or(MaterializationError::InvalidLexicalSource {
571                source: doc.segment,
572            })?;
573    let source = match source {
574        super::StructuredLexicalSource::Active => RowSource::Active,
575        super::StructuredLexicalSource::Sealed(ordinal) => RowSource::Sealed(
576            snapshot
577                .segments()
578                .get(*ordinal)
579                .ok_or(MaterializationError::InvalidLexicalSource {
580                    source: doc.segment,
581                })?
582                .meta()
583                .id,
584        ),
585    };
586    Ok(GlobalRowId::new(source, doc.row))
587}