Skip to main content

uqa_storage/
backend.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Persistent storage backend factory.
8//!
9//! This boundary keeps the engine from constructing SQLite-backed stores
10//! directly. Alternative persistent backends can implement the same factory
11//! without changing query execution code.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17
18use uqa_analysis::Analyzer;
19use uqa_core::{DocId, Value};
20
21use crate::document_store::DocumentStore;
22use crate::inverted_index::InvertedIndex;
23use crate::vector_index::{VectorIndex, VectorIndexOpenMode, VectorIndexSpec};
24use crate::CatalogFacade;
25
26/// A session's completed record commit usable after the caller classifies its changes as data-only. A provider with cache generations also certifies that catalog, registry, graph and storage-schema generations stayed unchanged. Providers without those generations rely on the caller's classification. This is commit evidence, not a read of the latest database state.
27#[derive(Clone)]
28pub struct CommittedDataRevision {
29    pub revision: crate::key_value::KeyValueReadRevision,
30    pub change_version: u64,
31}
32
33impl CommittedDataRevision {
34    pub fn from_receipt(receipt: crate::mvcc::CommitReceipt) -> Self {
35        Self {
36            revision: crate::key_value::KeyValueReadRevision::records(
37                receipt.transaction.database(),
38                receipt.sequence,
39                None,
40            ),
41            change_version: receipt.sequence.as_u64(),
42        }
43    }
44}
45
46#[derive(Debug, thiserror::Error)]
47pub enum StorageBackendError {
48    #[error(transparent)]
49    Memory(#[from] uqa_core::memory::MemoryError),
50    #[error(transparent)]
51    Cancelled(#[from] uqa_core::QueryCancelled),
52    #[error("text analysis failed: {0}")]
53    Analysis(#[from] uqa_analysis::AnalysisError),
54    #[error("payload serialization failed: {0}")]
55    Serde(#[from] serde_json::Error),
56    #[error("{backend} storage failed: {source}")]
57    Backend {
58        backend: &'static str,
59        #[source]
60        source: Box<dyn std::error::Error + Send + Sync>,
61    },
62    #[error("{0}")]
63    Other(String),
64}
65
66impl StorageBackendError {
67    pub fn backend(
68        backend: &'static str,
69        source: impl std::error::Error + Send + Sync + 'static,
70    ) -> Self {
71        Self::Backend {
72            backend,
73            source: Box::new(source),
74        }
75    }
76}
77
78pub type StorageBackendResult<T> = std::result::Result<T, StorageBackendError>;
79
80/// Process-local identity of one storage transaction context. Clone it for handles sharing that context; allocate a new identity for each independent session. This is not a durable database or transaction ID.
81#[derive(Clone, Debug)]
82pub struct StorageSessionAffinity(Arc<()>);
83
84impl StorageSessionAffinity {
85    #[must_use]
86    pub fn new() -> Self {
87        Self(Arc::new(()))
88    }
89}
90
91impl Default for StorageSessionAffinity {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl PartialEq for StorageSessionAffinity {
98    fn eq(&self, other: &Self) -> bool {
99        Arc::ptr_eq(&self.0, &other.0)
100    }
101}
102
103impl Eq for StorageSessionAffinity {}
104
105#[derive(Debug, thiserror::Error)]
106#[error("catalog and data backend must share one storage transaction context")]
107pub struct StorageSessionMismatch;
108
109/// Opaque transaction checkpoint identity. SQL savepoint names remain engine metadata and are never forwarded into a backend namespace.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub struct StorageSavepointId(u64);
112
113impl StorageSavepointId {
114    #[must_use]
115    pub fn allocate() -> Self {
116        static NEXT_ID: AtomicU64 = AtomicU64::new(1);
117        let id = uqa_core::atomic::try_update_u64(
118            &NEXT_ID,
119            Ordering::Relaxed,
120            Ordering::Relaxed,
121            |current| current.checked_add(1),
122        )
123        .expect("storage savepoint identity space exhausted");
124        Self(id)
125    }
126
127    pub fn backend_name(self) -> String {
128        self.0.to_string()
129    }
130}
131
132/// Session-bound catalog and physical storage handles created together.
133///
134/// Both handles must share the same transaction context. Keeping their
135/// construction behind one provider prevents a catalog write from escaping
136/// through a different connection or transaction than document/index writes.
137pub struct PersistentStorageSession {
138    pub catalog: Arc<dyn CatalogFacade>,
139    pub backend: Arc<dyn PersistentStorageBackend>,
140}
141
142/// Stable identity of one durable database. File identities allow engine coordination to extend across independently constructed providers and OS processes; opaque identities coordinate providers inside one process.
143#[derive(Clone, Debug, PartialEq, Eq, Hash)]
144pub enum PersistentStorageIdentity {
145    File(PathBuf),
146    Opaque(String),
147}
148
149impl PersistentStorageIdentity {
150    /// Resolve the stable file identity for a database path. The database file itself may not exist yet because backends materialize it on first write, so a missing file anchors the identity on its canonicalized parent directory instead of failing.
151    pub fn for_database_path(path: &Path) -> StorageBackendResult<Self> {
152        let path = resolve_final_symlinks(path)?;
153        match std::fs::canonicalize(&path) {
154            Ok(canonical) => Ok(Self::File(canonical)),
155            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
156                let file_name = path.file_name().ok_or_else(|| {
157                    StorageBackendError::Other(format!(
158                        "database path `{}` has no file name",
159                        path.display()
160                    ))
161                })?;
162                let parent = path
163                    .parent()
164                    .filter(|parent| !parent.as_os_str().is_empty())
165                    .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
166                let parent = std::fs::canonicalize(&parent).map_err(|error| {
167                    StorageBackendError::Other(format!(
168                        "canonicalize database directory `{}`: {error}",
169                        parent.display()
170                    ))
171                })?;
172                Ok(Self::File(parent.join(file_name)))
173            }
174            Err(error) => Err(StorageBackendError::Other(format!(
175                "canonicalize database `{}`: {error}",
176                path.display()
177            ))),
178        }
179    }
180}
181
182fn resolve_final_symlinks(path: &Path) -> StorageBackendResult<PathBuf> {
183    let mut current = path.to_path_buf();
184    let mut followed = 0usize;
185    loop {
186        match std::fs::symlink_metadata(&current) {
187            Ok(metadata) if metadata.file_type().is_symlink() => {
188                followed += 1;
189                if followed > 40 {
190                    return Err(StorageBackendError::Other(format!(
191                        "database path `{}` has too many symbolic-link levels",
192                        path.display()
193                    )));
194                }
195                let target = std::fs::read_link(&current).map_err(|error| {
196                    StorageBackendError::Other(format!(
197                        "read database symbolic link `{}`: {error}",
198                        current.display()
199                    ))
200                })?;
201                current = if target.is_absolute() {
202                    target
203                } else {
204                    current
205                        .parent()
206                        .filter(|parent| !parent.as_os_str().is_empty())
207                        .unwrap_or_else(|| Path::new("."))
208                        .join(target)
209                };
210            }
211            Ok(_) => return Ok(current),
212            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current),
213            Err(error) => {
214                return Err(StorageBackendError::Other(format!(
215                    "inspect database path `{}`: {error}",
216                    current.display()
217                )))
218            }
219        }
220    }
221}
222
223/// How a catalog/backend pair retains writes between SQL commands. This contract is independent of the physical database's single-writer commit admission.
224#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
225pub enum StorageTransactionModel {
226    #[default]
227    ProviderSerialized,
228    /// Private writes, retained readers and savepoints share this database incarnation. Command refresh must preserve evaluated writes and commit must validate their original revisions.
229    VersionedConcurrent { database: crate::mvcc::DatabaseId },
230}
231
232impl StorageTransactionModel {
233    pub fn is_versioned(self) -> bool {
234        matches!(self, Self::VersionedConcurrent { .. })
235    }
236}
237
238impl PersistentStorageSession {
239    pub fn new(
240        catalog: Arc<dyn CatalogFacade>,
241        backend: Arc<dyn PersistentStorageBackend>,
242    ) -> Self {
243        Self { catalog, backend }
244    }
245
246    /// Check the transaction model, database incarnation and session affinity before restoration, migration or attachment. Versioned pairs require a matching reported affinity and backend write cancellation; serialized pairs may omit both capabilities.
247    pub fn validate_transaction_affinity(&self) -> StorageBackendResult<()> {
248        let affinity = self.backend.transaction_affinity();
249        let model = self.backend.transaction_model();
250        if self.catalog.transaction_affinity() != affinity
251            || self.catalog.transaction_model() != model
252            || (model.is_versioned() && affinity.is_none())
253        {
254            return Err(StorageBackendError::backend(
255                "session",
256                StorageSessionMismatch,
257            ));
258        }
259        if model.is_versioned() && self.backend.write_cancellation().is_none() {
260            return Err(StorageBackendError::Other(
261                "versioned storage backend does not expose write cancellation".into(),
262            ));
263        }
264        Ok(())
265    }
266}
267
268/// Factory for independent sessions over one durable database.
269///
270/// A provider owns the database-level resource while each returned session
271/// owns its transaction state. This is the engine-facing extension point for
272/// `SQLite`, redb, and application-defined Key/Value stores.
273pub trait PersistentStorageProvider: Send + Sync {
274    fn open_session(&self) -> StorageBackendResult<PersistentStorageSession>;
275
276    /// Open an autonomous session whose writes can be cancelled by the caller. Versioned factories and wrappers must preserve their catalog/backend pair and forward this construction capability.
277    fn open_session_with_cancellation(
278        &self,
279        cancellation: &uqa_core::CancellationToken,
280    ) -> StorageBackendResult<PersistentStorageSession> {
281        cancellation.check()?;
282        let session = self.open_session()?;
283        if session.backend.transaction_model().is_versioned() {
284            return Err(StorageBackendError::Other(
285                "cancellable independent storage sessions are not implemented by this provider"
286                    .into(),
287            ));
288        }
289        Ok(session)
290    }
291
292    /// Open handles for initial Engine restoration. Providers may defer catalog schema preparation until `CatalogFacade::initialize_storage` runs inside the owning transaction; ordinary session factories must return an initialized catalog.
293    fn open_initial_session(&self) -> StorageBackendResult<PersistentStorageSession> {
294        self.open_session()
295    }
296
297    /// Return the database identity shared by every session this provider opens. Custom providers that cannot expose a stable identity may keep the default; engines built from the same `Arc` provider still share an in-process coordinator.
298    fn storage_identity(&self) -> StorageBackendResult<Option<PersistentStorageIdentity>> {
299        Ok(None)
300    }
301
302    /// Protect engine-owned auxiliary files with the database's encryption
303    /// credential. Encrypted file providers must forward this capability;
304    /// `None` declares that their auxiliary storage may be unencrypted.
305    fn auxiliary_encryption_key(&self) -> Option<crate::StorageEncryptionKey> {
306        None
307    }
308}
309
310/// Factory plus transaction surface for persistent table/index storage.
311pub trait PersistentStorageBackend: Send + Sync {
312    /// Retain the durability obligation of a persistent sequence value until this session completes. Capable wrappers must forward this together with `open_sequence_value_session`; unsupported providers publish sequence values synchronously.
313    fn require_sequence_value_durability(&self) -> StorageBackendResult<()> {
314        Ok(())
315    }
316
317    /// Open an independent sequence value session. A provider may defer WAL synchronization only when the invoking session retains the matching completion obligation. The default retains ordinary synchronous publication.
318    fn open_sequence_value_session(
319        &self,
320        cancellation: &uqa_core::CancellationToken,
321    ) -> StorageBackendResult<PersistentStorageSession> {
322        self.open_session_with_cancellation(cancellation)
323    }
324
325    /// Shared allowance for this session's private state and retained query resources. Versioned wrappers must forward it; nested retained readers keep the original allowance rather than granting another limit.
326    fn retention_control(&self) -> Option<crate::read_control::StorageReadControl> {
327        None
328    }
329
330    /// Atomic auxiliary notification publication and fresh committed recovery. Session wrappers must forward this capability.
331    fn notification_publications(
332        &self,
333    ) -> Option<&dyn crate::notifications::NotificationPublicationStore> {
334        None
335    }
336
337    /// Original participant admission and logical observations, shared with the paired catalog transaction. Retained readers preserve original attribution without owning completion. Wrappers must forward this capability; SQL access-path coverage remains the execution owner's responsibility.
338    fn serializable_session(&self) -> Option<&dyn crate::mvcc::SerializableSession> {
339        None
340    }
341
342    /// Cancellation for this session's physical write admission and publication. Retained reads and rollback cleanup must remain usable after cancellation. Versioned wrappers must forward the underlying token.
343    fn write_cancellation(&self) -> Option<uqa_core::CancellationToken> {
344        None
345    }
346
347    /// Create an independent transaction context whose writes share the invoking execution's cancellation. Versioned providers must implement this without rebinding another session's token.
348    fn open_session_with_cancellation(
349        &self,
350        cancellation: &uqa_core::CancellationToken,
351    ) -> StorageBackendResult<PersistentStorageSession> {
352        cancellation.check()?;
353        if self.transaction_model().is_versioned() {
354            return Err(StorageBackendError::Other(
355                "cancellable independent storage sessions are not implemented by this backend"
356                    .into(),
357            ));
358        }
359        self.open_session()
360    }
361
362    /// Admit a maintenance writer to the invoking operation's existing allowance and write cancellation, while retaining independent transaction completion and cleanup. Capable wrappers must forward this rather than allocate another retention limit.
363    fn open_controlled_session(
364        &self,
365        _control: &crate::read_control::StorageReadControl,
366    ) -> StorageBackendResult<PersistentStorageSession> {
367        Err(StorageBackendError::Other(
368            "controlled independent sessions are not implemented by this backend".into(),
369        ))
370    }
371
372    /// Bind an independent read-only catalog/backend pair to this exact committed/private view before restoration. Both handles must retain the original snapshot and logical reader attribution without taking ownership of transaction completion. Versioned wrappers must forward this capability.
373    fn open_retained_read_session(
374        &self,
375        cancellation: &uqa_core::CancellationToken,
376    ) -> StorageBackendResult<PersistentStorageSession> {
377        cancellation.check()?;
378        Err(StorageBackendError::Other(
379            "retained read sessions are not implemented by this storage backend".into(),
380        ))
381    }
382
383    /// Transaction ownership shared with the paired catalog. Legacy providers retain serialized Engine writer admission; versioned providers keep private writes and support command refresh without ending the transaction.
384    fn transaction_model(&self) -> StorageTransactionModel {
385        StorageTransactionModel::ProviderSerialized
386    }
387
388    /// Durable identifier allocation independent of logical transaction undo. A missing capability retains serialized allocation; it cannot establish support for concurrent writers. Wrappers must forward the underlying capability.
389    fn identifier_allocator(&self) -> Option<&dyn crate::mvcc::IdentifierAllocator> {
390        None
391    }
392
393    /// Identity shared with the paired catalog's transaction context. Wrappers must delegate this when their underlying backend reports an identity.
394    fn transaction_affinity(&self) -> Option<StorageSessionAffinity> {
395        None
396    }
397
398    /// Return the stable database identity for independently constructed engines over this backend. File identities also enable cross-process row-lock coordination.
399    fn storage_identity(&self) -> StorageBackendResult<Option<PersistentStorageIdentity>> {
400        Ok(None)
401    }
402
403    /// Return the encryption credential for database-owned auxiliary files,
404    /// including engines constructed directly from this backend.
405    fn auxiliary_encryption_key(&self) -> Option<crate::StorageEncryptionKey> {
406        None
407    }
408
409    /// Open a transaction-isolated catalog/backend pair over the same durable database. Engines constructed from already-open backends retain this factory so a row-lock recheck can read the latest committed tuple while the caller's statement snapshot remains pinned.
410    fn open_session(&self) -> StorageBackendResult<PersistentStorageSession> {
411        Err(StorageBackendError::Other(
412            "independent sessions are not implemented for this persistent backend".into(),
413        ))
414    }
415
416    /// Whether an independently pinned read session can remain open while another session writes the same database. Backends that return `false` use a detached fixed snapshot for `REPEATABLE READ` and `SERIALIZABLE` transactions.
417    fn supports_concurrent_pinned_read_and_write(&self) -> bool {
418        false
419    }
420
421    fn document_store(&self, table: &str) -> Box<dyn DocumentStore>;
422
423    /// Upgrade backend-owned document records before table handles are restored. Implementations must make the rewrite atomic and idempotent.
424    fn migrate_document_storage(&self) -> StorageBackendResult<()> {
425        Ok(())
426    }
427
428    fn inverted_index(&self, table: &str, analyzer: Analyzer) -> Box<dyn InvertedIndex>;
429
430    /// Upgrade backend-owned inverted-index values before table handles are
431    /// restored. Implementations must make the rewrite atomic and idempotent.
432    fn migrate_inverted_index_storage(&self) -> StorageBackendResult<()> {
433        Ok(())
434    }
435
436    fn vector_index(
437        &self,
438        table: &str,
439        field: &str,
440        dimensions: u32,
441        spec: VectorIndexSpec,
442        mode: VectorIndexOpenMode,
443    ) -> StorageBackendResult<Box<dyn VectorIndex>>;
444
445    /// Open a catalog-bound `DiskANN` owner on this exact backend session. Creation joins the active catalog transaction and returns an initialized index; restoration never builds missing state.
446    fn diskann_index(
447        &self,
448        _binding: crate::diskann_index::DiskANNIndexBinding<'_>,
449        _options: crate::diskann_index::DiskANNIndexOptions,
450        _temporary: &crate::diskann_index::build::DiskANNTemporaryBudget,
451        _mode: VectorIndexOpenMode,
452    ) -> StorageBackendResult<Box<dyn VectorIndex>> {
453        Err(StorageBackendError::Other(
454            "this backend does not support catalog-bound DiskANN indexes".into(),
455        ))
456    }
457
458    /// Capture a finite change-journal pass without loading graph or resident PQ data. The returned handle uses this exact session's active transactions and preserves its original catalog, coverage and resource controls; wrappers supporting `DiskANN` maintenance must forward it.
459    fn diskann_journal_pruner(
460        &self,
461        _binding: crate::diskann_index::DiskANNIndexBinding<'_>,
462        _max_record_bytes: usize,
463    ) -> StorageBackendResult<Box<dyn crate::diskann_index::changes::DiskANNJournalPruner>> {
464        Err(StorageBackendError::Other(
465            "this backend does not support DiskANN journal maintenance".into(),
466        ))
467    }
468
469    /// Retain one actual committed canonical/catalog view for exact change accounting, pruning and one evaluated rebuild. Construction never completes this session's enclosing transaction or reloads the old resident graph.
470    fn diskann_maintenance_source(
471        &self,
472        _binding: crate::diskann_index::DiskANNIndexBinding<'_>,
473        _max_record_bytes: usize,
474    ) -> StorageBackendResult<Box<dyn crate::diskann_index::maintenance::DiskANNMaintenanceSource>>
475    {
476        Err(StorageBackendError::Other(
477            "this backend does not support DiskANN rebuild maintenance".into(),
478        ))
479    }
480
481    fn retire_diskann_index(
482        &self,
483        _binding: crate::diskann_index::DiskANNIndexBinding<'_>,
484    ) -> StorageBackendResult<()> {
485        Err(StorageBackendError::Other(
486            "this backend does not support DiskANN retirement".into(),
487        ))
488    }
489
490    fn drop_vector_index_metadata(&self, _table: &str, _field: &str) -> StorageBackendResult<()> {
491        Ok(())
492    }
493
494    /// Whether this backend maps logical `btree` indexes to durable postings.
495    fn persists_btree_indexes(&self) -> bool {
496        false
497    }
498
499    /// `Some(entries)` is a complete persisted index; `None` means it has not
500    /// been built yet and the engine must backfill it from documents once.
501    fn load_btree_index(
502        &self,
503        _table: &str,
504        _field: &crate::ValueIndexKey,
505    ) -> StorageBackendResult<Option<Vec<(DocId, Value)>>> {
506        Ok(None)
507    }
508
509    fn btree_index_fields(&self, _table: &str) -> StorageBackendResult<Vec<crate::ValueIndexKey>> {
510        Ok(Vec::new())
511    }
512
513    /// Probe complete durable postings by Core value equality on the caller's transaction boundary, without hydrating the field. `None` declines an unbuilt index, unsupported value/comparison domain, or snapshot representation. A supported probe must decline if any stored key requires a fallible or typed SQL comparison; NULL matches only the stored NULL bucket. IDs are sorted and unique, including private replacements and deletions.
514    fn probe_btree_index_equal(
515        &self,
516        _table: &str,
517        _field: &crate::ValueIndexKey,
518        _value: &Value,
519    ) -> StorageBackendResult<Option<Vec<DocId>>> {
520        Ok(None)
521    }
522
523    /// Read one previously evaluated key on the caller's retained transaction boundary, without loading the posting collection. An unbuilt index is distinct from an absent row and a stored NULL.
524    fn read_btree_index_entry(
525        &self,
526        _table: &str,
527        _field: &crate::ValueIndexKey,
528        _doc_id: DocId,
529    ) -> StorageBackendResult<crate::ValueIndexEntry> {
530        if self.persists_btree_indexes() {
531            return Err(crate::StorageBackendError::Other(
532                "point reads of stored B-tree entries are not implemented for this backend".into(),
533            ));
534        }
535        Ok(crate::ValueIndexEntry::Unbuilt)
536    }
537
538    /// Fields whose persisted posting support was found inconsistent during a
539    /// schema migration. The engine repairs these at its explicit open-time
540    /// write boundary and clears each durable retry marker only after success.
541    fn btree_index_repairs(&self) -> StorageBackendResult<Vec<(String, crate::ValueIndexKey)>> {
542        Ok(Vec::new())
543    }
544
545    fn clear_btree_index_repair(
546        &self,
547        _table: &str,
548        _field: &crate::ValueIndexKey,
549    ) -> StorageBackendResult<()> {
550        Ok(())
551    }
552
553    fn replace_btree_index(
554        &self,
555        _table: &str,
556        _field: &crate::ValueIndexKey,
557        _values: &[(DocId, Value)],
558    ) -> StorageBackendResult<()> {
559        Ok(())
560    }
561
562    /// Repair sparse support differences without requiring capable backends
563    /// to rewrite every already-valid posting. The complete replacement is
564    /// supplied for the storage-neutral fallback.
565    fn repair_btree_index(
566        &self,
567        table: &str,
568        field: &crate::ValueIndexKey,
569        complete: &[(DocId, Value)],
570        _stale_doc_ids: &[DocId],
571        _missing: &[(DocId, Value)],
572    ) -> StorageBackendResult<()> {
573        self.replace_btree_index(table, field, complete)
574    }
575
576    /// Replace several complete indexes for one table atomically. Backends
577    /// may override this to share one transaction and prepared statements.
578    fn replace_btree_indexes(
579        &self,
580        table: &str,
581        indexes: &[(&crate::ValueIndexKey, &[(DocId, Value)])],
582    ) -> StorageBackendResult<()> {
583        for (field, values) in indexes {
584            self.replace_btree_index(table, field, values)?;
585        }
586        Ok(())
587    }
588
589    fn apply_btree_index_write(
590        &self,
591        _table: &str,
592        _doc_id: DocId,
593        _values: Option<&BTreeMap<crate::ValueIndexKey, Value>>,
594    ) -> StorageBackendResult<()> {
595        Ok(())
596    }
597
598    /// Write the index entries of a document whose identity no document of the table ever had, as the caller has established from the identity watermark of `namespace`. A provider that keys its entries by that namespace may write them without reading what they would replace; the default writes them as any other document's.
599    fn apply_unused_btree_index_write(
600        &self,
601        table: &str,
602        doc_id: DocId,
603        values: &BTreeMap<crate::ValueIndexKey, Value>,
604        namespace: crate::document_store::identifiers::DocumentIdNamespace,
605    ) -> StorageBackendResult<()> {
606        let _ = namespace;
607        self.apply_btree_index_write(table, doc_id, Some(values))
608    }
609
610    fn drop_btree_index(
611        &self,
612        _table: &str,
613        _field: &crate::ValueIndexKey,
614    ) -> StorageBackendResult<()> {
615        Ok(())
616    }
617
618    fn clear_btree_indexes(&self, _table: &str) -> StorageBackendResult<()> {
619        Ok(())
620    }
621
622    /// Reclaim obsolete logical records and history outside a transaction without rewriting the whole physical database. Versioned providers must implement this capability; stores that eagerly remove obsolete values may keep the default.
623    fn reclaim_obsolete(&self) -> StorageBackendResult<()> {
624        if self.transaction_model().is_versioned() {
625            return Err(StorageBackendError::Other(
626                "versioned storage reclamation is not implemented by this backend".into(),
627            ));
628        }
629        Ok(())
630    }
631
632    /// Reclaim backend-owned storage and compact the physical database outside a transaction. Backends whose logical stores eagerly remove obsolete values may keep the no-op default; durable backends with file-level compaction should override it.
633    fn vacuum(&self) -> StorageBackendResult<()> {
634        Ok(())
635    }
636
637    fn begin_transaction(&self) -> StorageBackendResult<()>;
638
639    /// Begin a transaction whose first operation is expected to be a read.
640    /// Backends with distinct lock modes may defer write-lock acquisition;
641    /// the default preserves existing transaction semantics.
642    fn begin_read_transaction(&self) -> StorageBackendResult<()> {
643        self.begin_transaction()
644    }
645
646    /// Begin an atomic transaction that may remain read-only or perform writes after its initial reads. Backends without read-to-write promotion acquire a writer transaction immediately.
647    fn begin_upgradeable_transaction(&self) -> StorageBackendResult<()> {
648        self.begin_transaction()
649    }
650
651    /// Advance command visibility without ending the active logical transaction or replaying its writes. SQL isolation controls when this is permitted. Providers without logical snapshot refresh reject the request.
652    fn refresh_transaction_snapshot(
653        &self,
654        _cancellation: &uqa_core::CancellationToken,
655    ) -> StorageBackendResult<()> {
656        Err(StorageBackendError::Other(
657            "transaction snapshot refresh is not supported by this backend".into(),
658        ))
659    }
660
661    /// Whether this session currently owns a pinned storage transaction.
662    fn in_transaction(&self) -> bool;
663
664    /// Whether the current transaction changed user data, catalog or index state. Engine uses this to enforce read-only statement transactions, including unclassified helper writes. Typed auxiliary notification publication is excluded and never grants ordinary write access.
665    fn transaction_has_written(&self) -> StorageBackendResult<bool>;
666
667    /// Backend commit generation visible to this session, when available.
668    /// A changing value invalidates session-local catalog and index caches.
669    fn change_version(&self) -> StorageBackendResult<Option<u64>> {
670        Ok(None)
671    }
672
673    /// Exact identity of the current committed and private record view for cache reuse. Equal identities guarantee equal record visibility, including after savepoint undo; providers without this capability return `None`. This process-local token must never be persisted or substituted for SQL isolation or serializable observations.
674    fn read_view_revision(
675        &self,
676    ) -> StorageBackendResult<Option<crate::key_value::KeyValueReadRevision>> {
677        Ok(None)
678    }
679
680    /// Evidence for adopting this session's last completed commit after the caller classifies it as data-only, without opening a new snapshot. A provider whose catalog exposes cache generations must also prove catalog/registry/graph/schema preservation; an untracked catalog relies on the caller's classification. Return `None` while a transaction is active, after rollback/read-only completion, or when the required proof is unavailable. Later peer commits must not be folded into this revision. The default retains ordinary refresh.
681    fn committed_data_revision(&self) -> StorageBackendResult<Option<CommittedDataRevision>> {
682        Ok(None)
683    }
684
685    /// A value that changes whenever any session or process commits to this database, read without a transaction of this session. Two equal values prove that no commit lies between the two reads, so whatever was read from the committed state in between still is that state. `None` when the backend has no such monitor; a caller then reads the committed state again.
686    fn commit_monitor_version(&self) -> StorageBackendResult<Option<u64>> {
687        Ok(None)
688    }
689
690    /// Whether reading [`Self::change_version`] can proceed while this session owns its pinned transaction. An independent monitor can also be unsafe for a reader when a pending writer is waiting for that reader's lock.
691    fn change_version_monitor_is_nonblocking(&self) -> StorageBackendResult<bool> {
692        Ok(true)
693    }
694
695    /// Pin the transaction's read snapshot before cache restoration.
696    fn pin_transaction_snapshot(&self) -> StorageBackendResult<()> {
697        Ok(())
698    }
699
700    fn commit_transaction(&self) -> StorageBackendResult<()>;
701
702    fn rollback_transaction(&self) -> StorageBackendResult<()>;
703
704    fn savepoint(&self, id: StorageSavepointId) -> StorageBackendResult<()>;
705
706    fn release_savepoint(&self, id: StorageSavepointId) -> StorageBackendResult<()>;
707
708    fn rollback_to_savepoint(&self, id: StorageSavepointId) -> StorageBackendResult<()>;
709}
710
711#[cfg(all(test, unix))]
712mod identity_tests {
713    use super::*;
714
715    #[test]
716    fn dangling_database_symlink_keeps_the_target_identity_after_creation() {
717        use std::os::unix::fs::symlink;
718
719        let directory = tempfile::tempdir().unwrap();
720        let target = directory.path().join("target.db");
721        let link = directory.path().join("database.db");
722        symlink("target.db", &link).unwrap();
723
724        let before = PersistentStorageIdentity::for_database_path(&link).unwrap();
725        std::fs::File::create(&target).unwrap();
726        let after = PersistentStorageIdentity::for_database_path(&link).unwrap();
727
728        assert_eq!(before, after);
729        assert_eq!(
730            after,
731            PersistentStorageIdentity::File(target.canonicalize().unwrap())
732        );
733    }
734}