1use 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#[derive(Debug)]
12pub enum HybridPreparationError<E> {
13 Preparation(E),
15 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#[cfg(any(test, feature = "test-support"))]
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct MaterializationTestCounters {
77 pub admissions: u64,
79 pub reverse_version_lookups: u64,
81 pub direct_row_lookups: u64,
83 #[cfg(feature = "query-timing")]
85 pub lexical_admission_locks: u64,
86 #[cfg(feature = "query-timing")]
88 pub lexical_admission_lock_nanos: u64,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct MaterializedRow {
94 pub document: DocumentVersion,
96 pub row_id: GlobalRowId,
98 pub text: String,
100}
101
102#[derive(Debug)]
104pub enum MaterializationError {
105 RankOutOfRange {
107 rank: usize,
109 returned: usize,
111 },
112 MissingIdentity {
114 row_id: GlobalRowId,
116 },
117 MissingText {
119 row_id: GlobalRowId,
121 document: DocumentVersion,
123 },
124 MissingSource {
126 row_id: GlobalRowId,
128 },
129 InvalidLexicalSource {
131 source: u32,
133 },
134 MissingFusedIdentity {
136 document: DocId,
138 },
139 IdentityMismatch {
141 row_id: GlobalRowId,
143 expected: DocumentVersion,
145 actual: Option<DocumentVersion>,
147 },
148 AllocationFailed {
150 bytes: usize,
152 },
153 ArithmeticOverflow,
155 Query(QueryError),
157 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
212pub 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 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 #[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 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 #[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 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 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 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}