1#[doc(hidden)]
18pub mod seed;
19
20use crate::VectorExecutionReport;
21
22mod text;
23mod vector_index;
24use hawdb_core::{HawDBError, Result, RuntimeTaskContext};
25use hawdb_plan_cypher::VectorPhysicalPlan;
26use std::collections::BTreeMap;
27use std::mem::size_of;
28use std::num::NonZeroUsize;
29pub use text::{TextSeedExecutionOutput, TextSeedExecutionRequest, TextSeedExecutionRow};
30pub use vector_index::{
31 VectorIndexExecutionOutput, VectorIndexExecutionRequest, VectorIndexExecutionRow,
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct ExternalReadResultBudget {
36 pub max_rows: usize,
37 pub max_memory_bytes: NonZeroUsize,
38}
39
40#[derive(Debug, Clone, Copy)]
41pub struct ExternalReadResourceContract<'a> {
42 pub priority: u8,
43 pub max_parallelism: NonZeroUsize,
44 pub max_working_memory_bytes: NonZeroUsize,
45 pub result: ExternalReadResultBudget,
46 pub task_context: Option<&'a RuntimeTaskContext>,
47}
48
49impl ExternalReadResourceContract<'_> {
50 pub fn checkpoint(&self) -> Result<()> {
51 match self.task_context {
52 Some(task_context) => task_context.checkpoint().map_err(|reason| {
53 HawDBError::Execution(format!("external read task stopped: {reason}"))
54 }),
55 None => Ok(()),
56 }
57 }
58
59 pub fn reserved_memory_bytes(&self) -> usize {
60 self.max_working_memory_bytes
61 .get()
62 .saturating_add(self.result.max_memory_bytes.get())
63 }
64}
65
66pub struct VectorSeedExecutionRequest<'a> {
67 pub embedding: &'a [f32],
68 pub metadata_filters: &'a BTreeMap<String, String>,
69 pub vector_plan: &'a VectorPhysicalPlan,
70 pub resources: ExternalReadResourceContract<'a>,
71}
72
73pub struct VectorSeedExecutionRow {
74 pub id: String,
75 pub external_id: Option<String>,
76 pub score: f64,
77}
78
79pub struct VectorSeedExecutionOutput {
80 pub rows: Vec<VectorSeedExecutionRow>,
81 pub report: VectorExecutionReport,
82}
83
84impl VectorSeedExecutionOutput {
85 pub fn estimated_memory_bytes(&self) -> usize {
86 self.rows
87 .capacity()
88 .saturating_mul(size_of::<VectorSeedExecutionRow>())
89 .saturating_add(self.rows.iter().fold(0usize, |total, row| {
90 total.saturating_add(row.id.capacity()).saturating_add(
91 row.external_id
92 .as_ref()
93 .map_or(0, |external_id| external_id.capacity()),
94 )
95 }))
96 }
97
98 pub fn validate_result_budget(&self, budget: ExternalReadResultBudget) -> Result<()> {
99 if self.rows.len() > budget.max_rows {
100 return Err(HawDBError::read_budget_exceeded(
101 hawdb_core::ReadBudgetResource::Rows,
102 budget.max_rows,
103 format!(
104 "external vector read returned {} rows, exceeding result row budget {}",
105 self.rows.len(),
106 budget.max_rows
107 ),
108 ));
109 }
110 let memory_bytes = self.estimated_memory_bytes();
111 if memory_bytes > budget.max_memory_bytes.get() {
112 return Err(HawDBError::Execution(format!(
113 "external vector read returned {memory_bytes} estimated bytes, exceeding result memory budget {}",
114 budget.max_memory_bytes
115 )));
116 }
117 Ok(())
118 }
119}
120
121pub trait ExternalReadOperator {
122 fn vector_index_definition(
124 &mut self,
125 _index: &str,
126 ) -> Result<(
127 hawdb_core::VectorIndexDescriptor,
128 hawdb_core::VectorIndexReadIdentity,
129 )> {
130 Err(HawDBError::Execution(
131 "vector eligibility requires a bound index definition".into(),
132 ))
133 }
134
135 fn execute_vector_index(
136 &mut self,
137 request: VectorIndexExecutionRequest<'_>,
138 ) -> Result<Box<dyn VectorIndexExecutionOutput>> {
139 request.resources.checkpoint()?;
140 Err(HawDBError::Execution(
141 "vector index search requires a bound canonical/index read domain".into(),
142 ))
143 }
144
145 fn execute_complete_text_seed(
146 &mut self,
147 request: TextSeedExecutionRequest<'_>,
148 ) -> Result<TextSeedExecutionOutput> {
149 request.resources.checkpoint()?;
150 Err(HawDBError::Execution(
151 "complete text search requires a provider with source-exhaustion support".into(),
152 ))
153 }
154
155 fn execute_text_seed(
156 &mut self,
157 request: TextSeedExecutionRequest<'_>,
158 ) -> Result<TextSeedExecutionOutput> {
159 request.resources.checkpoint()?;
160 Err(HawDBError::Execution(
161 "text search capability is unavailable without a text projection".into(),
162 ))
163 }
164
165 fn execute_vector_seed(
166 &mut self,
167 request: VectorSeedExecutionRequest<'_>,
168 ) -> Result<VectorSeedExecutionOutput>;
169}
170
171#[doc(hidden)]
172pub struct NoExternalReadOperator;
173
174impl ExternalReadOperator for NoExternalReadOperator {
175 fn execute_vector_seed(
176 &mut self,
177 _request: VectorSeedExecutionRequest<'_>,
178 ) -> Result<VectorSeedExecutionOutput> {
179 Err(HawDBError::Execution(
180 "vector search capability is unavailable without a search projection".to_string(),
181 ))
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::{
189 VectorCompressionMode, VectorExecutionBackend, VectorFallbackReasonCode, VectorScoreSource,
190 };
191 use hawdb_core::RuntimeCancellationToken;
192 use hawdb_plan_cypher::VectorCandidateSource;
193
194 pub(super) fn empty_report() -> VectorExecutionReport {
195 VectorExecutionReport {
196 backend: VectorExecutionBackend::ScalarFlat,
197 compression_mode: VectorCompressionMode::Disabled,
198 candidate_source: VectorCandidateSource::Scalar,
199 backend_selection_reason: None,
200 estimated_raw_vector_bytes: None,
201 filter_selectivity_per_million: None,
202 candidate_score_source: VectorScoreSource::RawVector,
203 final_score_source: VectorScoreSource::RawVector,
204 generated_candidate_count: 0,
205 descriptor_pruned_count: 0,
206 scalar_filtered_count: 0,
207 residual_filtered_count: 0,
208 candidate_scan_rounds: 0,
209 reranked_candidate_count: 0,
210 returned_count: 0,
211 raw_vector_bytes_read: 0,
212 candidate_scan_metrics: None,
213 index_covered_document_count: None,
214 index_candidate_document_count: None,
215 index_coverage_complete: None,
216 fallback_reason_codes: Vec::<VectorFallbackReasonCode>::new(),
217 }
218 }
219
220 #[test]
221 fn external_vector_output_fails_closed_on_row_and_memory_budgets() {
222 let output = VectorSeedExecutionOutput {
223 rows: vec![VectorSeedExecutionRow {
224 id: "memory-1".to_string(),
225 external_id: Some("external-1".to_string()),
226 score: 1.0,
227 }],
228 report: empty_report(),
229 };
230
231 let row_error = output
232 .validate_result_budget(ExternalReadResultBudget {
233 max_rows: 0,
234 max_memory_bytes: NonZeroUsize::new(1024).unwrap(),
235 })
236 .unwrap_err();
237 assert!(matches!(row_error, HawDBError::ReadBudgetExceeded(cause)
238 if cause.resource == hawdb_core::ReadBudgetResource::Rows && cause.limit == 0));
239 let memory_error = output
240 .validate_result_budget(ExternalReadResultBudget {
241 max_rows: 1,
242 max_memory_bytes: NonZeroUsize::MIN,
243 })
244 .unwrap_err();
245 assert!(matches!(memory_error, HawDBError::Execution(_)));
246 }
247
248 #[test]
249 fn external_read_contract_observes_cancellation() {
250 let cancellation = RuntimeCancellationToken::new();
251 let task_context = RuntimeTaskContext::without_deadline(cancellation.clone());
252 let contract = ExternalReadResourceContract {
253 priority: 128,
254 max_parallelism: NonZeroUsize::MIN,
255 max_working_memory_bytes: NonZeroUsize::MIN,
256 result: ExternalReadResultBudget {
257 max_rows: 1,
258 max_memory_bytes: NonZeroUsize::MIN,
259 },
260 task_context: Some(&task_context),
261 };
262
263 assert!(contract.checkpoint().is_ok());
264 assert!(cancellation.cancel());
265 assert!(contract.checkpoint().is_err());
266 }
267}