Skip to main content

hyphae_engine/
facade.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::{collections::BTreeSet, path::Path, time::Instant};
4
5use hyphae_query::{
6    ExecutionLimits, Query, QueryError, QueryResult, Record, execute, validate_query,
7};
8use hyphae_retrieval::{
9    RetrievalError, RetrievalLimits, RetrievalOutcome, RetrievalRequest, VectorRecord, retrieve,
10};
11use hyphae_storage::{
12    AppendOutcome, BackupError, BackupInfo, CompactionOutcome, MAX_SCAN_PAGE_ENTRIES, Mutation,
13    RestoreInfo, SnapshotInfo, StorageEngine, StorageError, StorageRecoveryReport, restore_backup,
14    verify_backup,
15};
16use thiserror::Error;
17use uuid::Uuid;
18
19use crate::{
20    DocumentError, ProofError, ResultProof, ResultProofArtifact, decode_document, encode_document,
21};
22
23/// Failure while operating the embeddable Hyphae facade.
24#[derive(Debug, Error)]
25pub enum EngineError {
26    /// Durable embedded storage failed.
27    #[error(transparent)]
28    Storage(#[from] StorageError),
29
30    /// Portable backup creation, verification, or restore failed.
31    #[error(transparent)]
32    Backup(#[from] BackupError),
33
34    /// Canonical document encoding or verification failed.
35    #[error(transparent)]
36    Document(#[from] DocumentError),
37
38    /// Structured query validation or execution failed.
39    #[error(transparent)]
40    Query(#[from] QueryError),
41
42    /// Exact semantic retrieval failed.
43    #[error(transparent)]
44    Retrieval(#[from] RetrievalError),
45
46    /// Canonical result-proof creation failed.
47    #[error(transparent)]
48    Proof(#[from] ProofError),
49
50    /// One atomic document batch repeats a key.
51    #[error("atomic document batch contains a duplicate key")]
52    DuplicateDocumentKey,
53}
54
55/// Newly opened embeddable engine and durable recovery evidence.
56#[derive(Debug)]
57pub struct OpenedEngine {
58    /// Ready engine facade.
59    pub engine: HyphaeEngine,
60    /// Log verification and index replay evidence.
61    pub recovery: StorageRecoveryReport,
62}
63
64/// Embeddable autonomous Hyphae engine.
65#[derive(Debug)]
66pub struct HyphaeEngine {
67    storage: StorageEngine,
68}
69
70impl HyphaeEngine {
71    /// Opens one exclusively owned data directory and completes recovery.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error for directory contention, corruption, unsupported
76    /// formats, snapshot mismatch, or failed index replay.
77    pub fn open(path: impl AsRef<Path>) -> Result<OpenedEngine, EngineError> {
78        let opened = StorageEngine::open(path)?;
79        Ok(OpenedEngine {
80            engine: Self {
81                storage: opened.storage,
82            },
83            recovery: opened.recovery,
84        })
85    }
86
87    /// Returns the owned data-directory path.
88    pub fn data_path(&self) -> &Path {
89        self.storage.data_path()
90    }
91
92    /// Atomically stores one canonical structured record.
93    ///
94    /// # Errors
95    ///
96    /// Returns a document codec or durable storage error.
97    pub fn put_record(
98        &mut self,
99        transaction_id: Uuid,
100        record: &Record,
101    ) -> Result<AppendOutcome, EngineError> {
102        self.put_records(transaction_id, std::slice::from_ref(record))
103    }
104
105    /// Atomically stores a batch of canonical structured records.
106    ///
107    /// Encoding every document and checking duplicate keys happens before the
108    /// log append, so a codec failure cannot partially commit the batch.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error for duplicate batch keys, document bounds, key bounds,
113    /// idempotency conflicts, or durable storage failures.
114    pub fn put_records(
115        &mut self,
116        transaction_id: Uuid,
117        records: &[Record],
118    ) -> Result<AppendOutcome, EngineError> {
119        let mut keys = BTreeSet::new();
120        let mut mutations = Vec::with_capacity(records.len());
121        for record in records {
122            if !keys.insert(record.key.as_slice()) {
123                return Err(EngineError::DuplicateDocumentKey);
124            }
125            mutations.push(Mutation::put(
126                record.key.clone(),
127                encode_document(&record.value)?,
128            ));
129        }
130        Ok(self.storage.write(transaction_id, &mutations)?)
131    }
132
133    /// Atomically deletes one structured record.
134    ///
135    /// # Errors
136    ///
137    /// Returns a key-validation, idempotency, or durable storage error.
138    pub fn delete_record(
139        &mut self,
140        transaction_id: Uuid,
141        key: &[u8],
142    ) -> Result<AppendOutcome, EngineError> {
143        self.delete_records(transaction_id, &[key])
144    }
145
146    /// Atomically deletes a batch of structured records.
147    ///
148    /// Duplicate keys are rejected before the log append. Deleting a missing
149    /// key remains a successful durable operation.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error for duplicate keys, invalid key bounds, idempotency
154    /// conflicts, or durable storage failures.
155    pub fn delete_records(
156        &mut self,
157        transaction_id: Uuid,
158        keys: &[&[u8]],
159    ) -> Result<AppendOutcome, EngineError> {
160        let mut unique = BTreeSet::new();
161        let mut mutations = Vec::with_capacity(keys.len());
162        for key in keys {
163            if !unique.insert(*key) {
164                return Err(EngineError::DuplicateDocumentKey);
165            }
166            mutations.push(Mutation::delete(*key));
167        }
168        Ok(self.storage.write(transaction_id, &mutations)?)
169    }
170
171    /// Gets and verifies one structured record by binary key.
172    ///
173    /// # Errors
174    ///
175    /// Returns a key, storage, or canonical document verification error.
176    pub fn get_record(&self, key: &[u8]) -> Result<Option<Record>, EngineError> {
177        self.storage
178            .get(key)?
179            .map(|encoded| {
180                Ok(Record {
181                    key: key.to_vec(),
182                    value: decode_document(&encoded)?,
183                })
184            })
185            .transpose()
186    }
187
188    /// Gets one structured record and binds the complete result, including
189    /// absence, to a canonical snapshot witness.
190    ///
191    /// # Errors
192    ///
193    /// Returns a key, storage, document, snapshot, or result-proof error.
194    pub fn get_record_with_proof(&self, key: &[u8]) -> Result<ResultProofArtifact, EngineError> {
195        let result = self.get_record(key)?;
196        let snapshot = self.snapshot()?;
197        let proof = ResultProof::for_get(&snapshot, key.to_vec(), result)?;
198        Ok(ResultProofArtifact { proof, snapshot })
199    }
200
201    /// Executes deterministic structured query over all durable documents.
202    ///
203    /// Storage scan and document decoding consume the same wall-clock timeout;
204    /// the reference executor receives only the remaining duration.
205    ///
206    /// # Errors
207    ///
208    /// Returns a storage, document, query validation, global budget, aggregate,
209    /// or timeout error. No partial page is returned.
210    pub fn query(
211        &self,
212        query: &Query,
213        limits: &ExecutionLimits,
214    ) -> Result<QueryResult, EngineError> {
215        validate_query(query, limits)?;
216        let started = Instant::now();
217        let mut records = Vec::new();
218        let mut after = None;
219        loop {
220            if started.elapsed() >= limits.timeout {
221                return Err(QueryError::TimedOut.into());
222            }
223            let loaded = u64::try_from(records.len()).unwrap_or(u64::MAX);
224            let remaining = limits.max_scanned_records.saturating_sub(loaded);
225            let remaining_entries = match usize::try_from(remaining) {
226                Ok(value) => value,
227                Err(_) => usize::MAX,
228            };
229            let page_limit = remaining_entries
230                .saturating_add(1)
231                .min(MAX_SCAN_PAGE_ENTRIES);
232            let page = self.storage.scan_page(after.as_deref(), page_limit)?;
233            for entry in page.entries {
234                if u64::try_from(records.len()).unwrap_or(u64::MAX) >= limits.max_scanned_records {
235                    return Err(QueryError::ScannedBudgetExceeded {
236                        maximum: limits.max_scanned_records,
237                    }
238                    .into());
239                }
240                records.push(Record {
241                    key: entry.key,
242                    value: decode_document(&entry.value)?,
243                });
244            }
245            let Some(next_after) = page.next_after else {
246                break;
247            };
248            after = Some(next_after);
249        }
250
251        let elapsed = started.elapsed();
252        let Some(timeout) = limits.timeout.checked_sub(elapsed) else {
253            return Err(QueryError::TimedOut.into());
254        };
255        if timeout.is_zero() {
256            return Err(QueryError::TimedOut.into());
257        }
258        let execution_limits = ExecutionLimits {
259            timeout,
260            ..limits.clone()
261        };
262        Ok(execute(&[records.as_slice()], query, &execution_limits)?)
263    }
264
265    /// Executes one structured query and binds its complete logical result to
266    /// a canonical snapshot witness at the same locked checkpoint.
267    ///
268    /// # Errors
269    ///
270    /// Returns any ordinary query error plus snapshot or proof creation
271    /// failures. No proof is returned for a partial or failed query.
272    pub fn query_with_proof(
273        &self,
274        query: &Query,
275        limits: &ExecutionLimits,
276    ) -> Result<ResultProofArtifact, EngineError> {
277        let result = self.query(query, limits)?;
278        let snapshot = self.snapshot()?;
279        let proof = ResultProof::for_query(&snapshot, query.clone(), result)?;
280        Ok(ResultProofArtifact { proof, snapshot })
281    }
282
283    /// Executes exact provider-neutral vector retrieval without persisting or
284    /// producing embeddings.
285    ///
286    /// # Errors
287    ///
288    /// Returns vector, shape, duplicate-key, budget, or timeout errors.
289    pub fn retrieve_vectors(
290        shards: &[&[VectorRecord]],
291        request: &RetrievalRequest,
292        limits: &RetrievalLimits,
293    ) -> Result<RetrievalOutcome, EngineError> {
294        Ok(retrieve(shards, request, limits)?)
295    }
296
297    /// Creates or reuses a verified logical snapshot.
298    ///
299    /// # Errors
300    ///
301    /// Returns a stale-handle, index, or snapshot error.
302    pub fn snapshot(&self) -> Result<SnapshotInfo, EngineError> {
303        Ok(self.storage.snapshot()?)
304    }
305
306    /// Commits an anchored compaction generation.
307    ///
308    /// # Errors
309    ///
310    /// Returns a stale-handle, snapshot, segment, or manifest error.
311    pub fn compact(&mut self) -> Result<CompactionOutcome, EngineError> {
312        Ok(self.storage.compact()?)
313    }
314
315    /// Creates an atomic portable backup at the locked logical checkpoint.
316    ///
317    /// # Errors
318    ///
319    /// Returns a snapshot, destination, synchronization, or promotion error.
320    pub fn backup(&self, destination: impl AsRef<Path>) -> Result<BackupInfo, EngineError> {
321        Ok(self.storage.backup(destination)?)
322    }
323
324    /// Verifies a portable backup without opening a live data directory.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error for a malformed layout, metadata mismatch, or corrupt
329    /// snapshot.
330    pub fn verify_backup(path: impl AsRef<Path>) -> Result<BackupInfo, EngineError> {
331        Ok(verify_backup(path)?)
332    }
333
334    /// Restores a backup to a new atomically activated data directory.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error before destination activation if verification, index
339    /// reconstruction, reopen, or filesystem synchronization fails.
340    pub fn restore_backup(
341        backup: impl AsRef<Path>,
342        destination: impl AsRef<Path>,
343    ) -> Result<RestoreInfo, EngineError> {
344        Ok(restore_backup(backup, destination)?)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use std::{collections::BTreeMap, fs, path::PathBuf};
351
352    use hyphae_query::{
353        AggregationPlan, CompareOperator, FieldPath, Filter, Metric, MetricValue, NamedMetric,
354        NullPlacement, SortDirection, SortField, Value,
355    };
356    use uuid::Uuid;
357
358    use super::{EngineError, ExecutionLimits, HyphaeEngine, Query, Record};
359
360    struct TestDirectory {
361        path: PathBuf,
362    }
363
364    impl TestDirectory {
365        fn new(name: &str) -> std::io::Result<Self> {
366            let path = std::env::temp_dir().join(format!(
367                "hyphae-engine-{name}-{}-{}",
368                std::process::id(),
369                Uuid::now_v7()
370            ));
371            fs::create_dir_all(&path)?;
372            Ok(Self { path })
373        }
374
375        fn path(&self) -> &std::path::Path {
376            &self.path
377        }
378    }
379
380    impl Drop for TestDirectory {
381        fn drop(&mut self) {
382            let _ignored = fs::remove_dir_all(&self.path);
383        }
384    }
385
386    fn value(score: i64, group: &str) -> Value {
387        Value::Object(BTreeMap::from([
388            ("group".to_owned(), Value::String(group.to_owned())),
389            ("score".to_owned(), Value::Integer(score)),
390        ]))
391    }
392
393    #[test]
394    fn durable_documents_query_identically_after_compaction_and_reopen()
395    -> Result<(), Box<dyn std::error::Error>> {
396        let temporary = TestDirectory::new("engine-query-reopen")?;
397        let root = temporary.path().join("data");
398        let mut opened = HyphaeEngine::open(&root)?;
399        opened.engine.put_records(
400            Uuid::now_v7(),
401            &[
402                Record::new(b"a", value(10, "x")),
403                Record::new(b"b", value(8, "x")),
404                Record::new(b"c", value(7, "y")),
405                Record::new(b"d", value(2, "y")),
406            ],
407        )?;
408        let request = Query {
409            filter: Filter::Compare {
410                path: FieldPath::field("score"),
411                operator: CompareOperator::GreaterOrEqual,
412                value: Value::Integer(7),
413            },
414            sort: vec![SortField {
415                path: FieldPath::field("score"),
416                direction: SortDirection::Descending,
417                nulls: NullPlacement::Last,
418            }],
419            cursor: None,
420            limit: 2,
421            aggregation: Some(AggregationPlan {
422                group_by: Vec::new(),
423                metrics: vec![NamedMetric {
424                    name: "count".to_owned(),
425                    metric: Metric::Count,
426                }],
427            }),
428        };
429        let before = opened.engine.query(&request, &ExecutionLimits::default())?;
430        assert_eq!(before.rows.len(), 2);
431        assert_eq!(
432            before
433                .aggregation
434                .as_ref()
435                .map(|aggregation| { aggregation.groups[0].metrics[0].value.clone() }),
436            Some(MetricValue::Count(3))
437        );
438        opened.engine.compact()?;
439        drop(opened);
440
441        let reopened = HyphaeEngine::open(&root)?;
442        let after = reopened
443            .engine
444            .query(&request, &ExecutionLimits::default())?;
445        assert_eq!(before, after);
446        assert_eq!(
447            reopened.engine.get_record(b"a")?.map(|record| record.value),
448            Some(value(10, "x"))
449        );
450        Ok(())
451    }
452
453    #[test]
454    fn facade_enforces_scan_budget_before_building_a_partial_page()
455    -> Result<(), Box<dyn std::error::Error>> {
456        let temporary = TestDirectory::new("engine-query-budget")?;
457        let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
458        opened.engine.put_records(
459            Uuid::now_v7(),
460            &[
461                Record::new(b"a", Value::Null),
462                Record::new(b"b", Value::Null),
463            ],
464        )?;
465        let limits = ExecutionLimits {
466            max_scanned_records: 1,
467            ..ExecutionLimits::default()
468        };
469        let result = opened.engine.query(
470            &Query {
471                filter: Filter::MatchAll,
472                sort: Vec::new(),
473                cursor: None,
474                limit: 1,
475                aggregation: None,
476            },
477            &limits,
478        );
479        assert!(matches!(
480            result,
481            Err(EngineError::Query(
482                hyphae_query::QueryError::ScannedBudgetExceeded { maximum: 1 }
483            ))
484        ));
485        Ok(())
486    }
487}