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#[derive(Debug, thiserror::Error)]
27pub enum StorageBackendError {
28    #[error(transparent)]
29    Memory(#[from] uqa_core::memory::MemoryError),
30    #[error(transparent)]
31    Cancelled(#[from] uqa_core::QueryCancelled),
32    #[error("text analysis failed: {0}")]
33    Analysis(#[from] uqa_analysis::AnalysisError),
34    #[error("payload serialization failed: {0}")]
35    Serde(#[from] serde_json::Error),
36    #[error("{backend} storage failed: {source}")]
37    Backend {
38        backend: &'static str,
39        #[source]
40        source: Box<dyn std::error::Error + Send + Sync>,
41    },
42    #[error("{0}")]
43    Other(String),
44}
45
46impl StorageBackendError {
47    pub fn backend(
48        backend: &'static str,
49        source: impl std::error::Error + Send + Sync + 'static,
50    ) -> Self {
51        Self::Backend {
52            backend,
53            source: Box::new(source),
54        }
55    }
56}
57
58pub type StorageBackendResult<T> = std::result::Result<T, StorageBackendError>;
59
60/// 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.
61#[derive(Clone, Debug)]
62pub struct StorageSessionAffinity(Arc<()>);
63
64impl StorageSessionAffinity {
65    #[must_use]
66    pub fn new() -> Self {
67        Self(Arc::new(()))
68    }
69}
70
71impl Default for StorageSessionAffinity {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl PartialEq for StorageSessionAffinity {
78    fn eq(&self, other: &Self) -> bool {
79        Arc::ptr_eq(&self.0, &other.0)
80    }
81}
82
83impl Eq for StorageSessionAffinity {}
84
85#[derive(Debug, thiserror::Error)]
86#[error("catalog and data backend must share one storage transaction context")]
87pub struct StorageSessionMismatch;
88
89/// Opaque transaction checkpoint identity. SQL savepoint names remain engine metadata and are never forwarded into a backend namespace.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub struct StorageSavepointId(u64);
92
93impl StorageSavepointId {
94    #[must_use]
95    pub fn allocate() -> Self {
96        static NEXT_ID: AtomicU64 = AtomicU64::new(1);
97        let id = uqa_core::atomic::try_update_u64(
98            &NEXT_ID,
99            Ordering::Relaxed,
100            Ordering::Relaxed,
101            |current| current.checked_add(1),
102        )
103        .expect("storage savepoint identity space exhausted");
104        Self(id)
105    }
106
107    pub fn backend_name(self) -> String {
108        self.0.to_string()
109    }
110}
111
112/// Session-bound catalog and physical storage handles created together.
113///
114/// Both handles must share the same transaction context. Keeping their
115/// construction behind one provider prevents a catalog write from escaping
116/// through a different connection or transaction than document/index writes.
117pub struct PersistentStorageSession {
118    pub catalog: Arc<dyn CatalogFacade>,
119    pub backend: Arc<dyn PersistentStorageBackend>,
120}
121
122/// 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.
123#[derive(Clone, Debug, PartialEq, Eq, Hash)]
124pub enum PersistentStorageIdentity {
125    File(PathBuf),
126    Opaque(String),
127}
128
129impl PersistentStorageIdentity {
130    /// 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.
131    pub fn for_database_path(path: &Path) -> StorageBackendResult<Self> {
132        let path = resolve_final_symlinks(path)?;
133        match std::fs::canonicalize(&path) {
134            Ok(canonical) => Ok(Self::File(canonical)),
135            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
136                let file_name = path.file_name().ok_or_else(|| {
137                    StorageBackendError::Other(format!(
138                        "database path `{}` has no file name",
139                        path.display()
140                    ))
141                })?;
142                let parent = path
143                    .parent()
144                    .filter(|parent| !parent.as_os_str().is_empty())
145                    .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
146                let parent = std::fs::canonicalize(&parent).map_err(|error| {
147                    StorageBackendError::Other(format!(
148                        "canonicalize database directory `{}`: {error}",
149                        parent.display()
150                    ))
151                })?;
152                Ok(Self::File(parent.join(file_name)))
153            }
154            Err(error) => Err(StorageBackendError::Other(format!(
155                "canonicalize database `{}`: {error}",
156                path.display()
157            ))),
158        }
159    }
160}
161
162fn resolve_final_symlinks(path: &Path) -> StorageBackendResult<PathBuf> {
163    let mut current = path.to_path_buf();
164    let mut followed = 0usize;
165    loop {
166        match std::fs::symlink_metadata(&current) {
167            Ok(metadata) if metadata.file_type().is_symlink() => {
168                followed += 1;
169                if followed > 40 {
170                    return Err(StorageBackendError::Other(format!(
171                        "database path `{}` has too many symbolic-link levels",
172                        path.display()
173                    )));
174                }
175                let target = std::fs::read_link(&current).map_err(|error| {
176                    StorageBackendError::Other(format!(
177                        "read database symbolic link `{}`: {error}",
178                        current.display()
179                    ))
180                })?;
181                current = if target.is_absolute() {
182                    target
183                } else {
184                    current
185                        .parent()
186                        .filter(|parent| !parent.as_os_str().is_empty())
187                        .unwrap_or_else(|| Path::new("."))
188                        .join(target)
189                };
190            }
191            Ok(_) => return Ok(current),
192            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current),
193            Err(error) => {
194                return Err(StorageBackendError::Other(format!(
195                    "inspect database path `{}`: {error}",
196                    current.display()
197                )))
198            }
199        }
200    }
201}
202
203/// How a catalog/backend pair retains writes between SQL commands. This contract is independent of the physical database's single-writer commit admission.
204#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
205pub enum StorageTransactionModel {
206    #[default]
207    ProviderSerialized,
208    /// Private writes, retained readers and savepoints share this database incarnation. Command refresh must preserve evaluated writes and commit must validate their original revisions.
209    VersionedConcurrent { database: crate::mvcc::DatabaseId },
210}
211
212impl StorageTransactionModel {
213    pub fn is_versioned(self) -> bool {
214        matches!(self, Self::VersionedConcurrent { .. })
215    }
216}
217
218impl PersistentStorageSession {
219    pub fn new(
220        catalog: Arc<dyn CatalogFacade>,
221        backend: Arc<dyn PersistentStorageBackend>,
222    ) -> Self {
223        Self { catalog, backend }
224    }
225
226    /// 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.
227    pub fn validate_transaction_affinity(&self) -> StorageBackendResult<()> {
228        let affinity = self.backend.transaction_affinity();
229        let model = self.backend.transaction_model();
230        if self.catalog.transaction_affinity() != affinity
231            || self.catalog.transaction_model() != model
232            || (model.is_versioned() && affinity.is_none())
233        {
234            return Err(StorageBackendError::backend(
235                "session",
236                StorageSessionMismatch,
237            ));
238        }
239        if model.is_versioned() && self.backend.write_cancellation().is_none() {
240            return Err(StorageBackendError::Other(
241                "versioned storage backend does not expose write cancellation".into(),
242            ));
243        }
244        Ok(())
245    }
246}
247
248/// Factory for independent sessions over one durable database.
249///
250/// A provider owns the database-level resource while each returned session
251/// owns its transaction state. This is the engine-facing extension point for
252/// `SQLite`, redb, and application-defined Key/Value stores.
253pub trait PersistentStorageProvider: Send + Sync {
254    fn open_session(&self) -> StorageBackendResult<PersistentStorageSession>;
255
256    /// 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.
257    fn open_session_with_cancellation(
258        &self,
259        cancellation: &uqa_core::CancellationToken,
260    ) -> StorageBackendResult<PersistentStorageSession> {
261        cancellation.check()?;
262        let session = self.open_session()?;
263        if session.backend.transaction_model().is_versioned() {
264            return Err(StorageBackendError::Other(
265                "cancellable independent storage sessions are not implemented by this provider"
266                    .into(),
267            ));
268        }
269        Ok(session)
270    }
271
272    /// 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.
273    fn open_initial_session(&self) -> StorageBackendResult<PersistentStorageSession> {
274        self.open_session()
275    }
276
277    /// 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.
278    fn storage_identity(&self) -> StorageBackendResult<Option<PersistentStorageIdentity>> {
279        Ok(None)
280    }
281
282    /// Protect engine-owned auxiliary files with the database's encryption
283    /// credential. Encrypted file providers must forward this capability;
284    /// `None` declares that their auxiliary storage may be unencrypted.
285    fn auxiliary_encryption_key(&self) -> Option<crate::StorageEncryptionKey> {
286        None
287    }
288}
289
290/// Factory plus transaction surface for persistent table/index storage.
291pub trait PersistentStorageBackend: Send + Sync {
292    /// 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.
293    fn retention_control(&self) -> Option<crate::read_control::StorageReadControl> {
294        None
295    }
296
297    /// Atomic auxiliary notification publication and fresh committed recovery. Session wrappers must forward this capability.
298    fn notification_publications(
299        &self,
300    ) -> Option<&dyn crate::notifications::NotificationPublicationStore> {
301        None
302    }
303
304    /// 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.
305    fn serializable_session(&self) -> Option<&dyn crate::mvcc::SerializableSession> {
306        None
307    }
308
309    /// 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.
310    fn write_cancellation(&self) -> Option<uqa_core::CancellationToken> {
311        None
312    }
313
314    /// Create an independent transaction context whose writes share the invoking execution's cancellation. Versioned providers must implement this without rebinding another session's token.
315    fn open_session_with_cancellation(
316        &self,
317        cancellation: &uqa_core::CancellationToken,
318    ) -> StorageBackendResult<PersistentStorageSession> {
319        cancellation.check()?;
320        if self.transaction_model().is_versioned() {
321            return Err(StorageBackendError::Other(
322                "cancellable independent storage sessions are not implemented by this backend"
323                    .into(),
324            ));
325        }
326        self.open_session()
327    }
328
329    /// 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.
330    fn open_controlled_session(
331        &self,
332        _control: &crate::read_control::StorageReadControl,
333    ) -> StorageBackendResult<PersistentStorageSession> {
334        Err(StorageBackendError::Other(
335            "controlled independent sessions are not implemented by this backend".into(),
336        ))
337    }
338
339    /// 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.
340    fn open_retained_read_session(
341        &self,
342        cancellation: &uqa_core::CancellationToken,
343    ) -> StorageBackendResult<PersistentStorageSession> {
344        cancellation.check()?;
345        Err(StorageBackendError::Other(
346            "retained read sessions are not implemented by this storage backend".into(),
347        ))
348    }
349
350    /// 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.
351    fn transaction_model(&self) -> StorageTransactionModel {
352        StorageTransactionModel::ProviderSerialized
353    }
354
355    /// 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.
356    fn identifier_allocator(&self) -> Option<&dyn crate::mvcc::IdentifierAllocator> {
357        None
358    }
359
360    /// Identity shared with the paired catalog's transaction context. Wrappers must delegate this when their underlying backend reports an identity.
361    fn transaction_affinity(&self) -> Option<StorageSessionAffinity> {
362        None
363    }
364
365    /// Return the stable database identity for independently constructed engines over this backend. File identities also enable cross-process row-lock coordination.
366    fn storage_identity(&self) -> StorageBackendResult<Option<PersistentStorageIdentity>> {
367        Ok(None)
368    }
369
370    /// Return the encryption credential for database-owned auxiliary files,
371    /// including engines constructed directly from this backend.
372    fn auxiliary_encryption_key(&self) -> Option<crate::StorageEncryptionKey> {
373        None
374    }
375
376    /// 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.
377    fn open_session(&self) -> StorageBackendResult<PersistentStorageSession> {
378        Err(StorageBackendError::Other(
379            "independent sessions are not implemented for this persistent backend".into(),
380        ))
381    }
382
383    /// 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.
384    fn supports_concurrent_pinned_read_and_write(&self) -> bool {
385        false
386    }
387
388    fn document_store(&self, table: &str) -> Box<dyn DocumentStore>;
389
390    /// Upgrade backend-owned document records before table handles are restored. Implementations must make the rewrite atomic and idempotent.
391    fn migrate_document_storage(&self) -> StorageBackendResult<()> {
392        Ok(())
393    }
394
395    fn inverted_index(&self, table: &str, analyzer: Analyzer) -> Box<dyn InvertedIndex>;
396
397    /// Upgrade backend-owned inverted-index values before table handles are
398    /// restored. Implementations must make the rewrite atomic and idempotent.
399    fn migrate_inverted_index_storage(&self) -> StorageBackendResult<()> {
400        Ok(())
401    }
402
403    fn vector_index(
404        &self,
405        table: &str,
406        field: &str,
407        dimensions: u32,
408        spec: VectorIndexSpec,
409        mode: VectorIndexOpenMode,
410    ) -> StorageBackendResult<Box<dyn VectorIndex>>;
411
412    /// 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.
413    fn diskann_index(
414        &self,
415        _binding: crate::diskann_index::DiskANNIndexBinding<'_>,
416        _options: crate::diskann_index::DiskANNIndexOptions,
417        _temporary: &crate::diskann_index::build::DiskANNTemporaryBudget,
418        _mode: VectorIndexOpenMode,
419    ) -> StorageBackendResult<Box<dyn VectorIndex>> {
420        Err(StorageBackendError::Other(
421            "this backend does not support catalog-bound DiskANN indexes".into(),
422        ))
423    }
424
425    /// 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.
426    fn diskann_journal_pruner(
427        &self,
428        _binding: crate::diskann_index::DiskANNIndexBinding<'_>,
429        _max_record_bytes: usize,
430    ) -> StorageBackendResult<Box<dyn crate::diskann_index::changes::DiskANNJournalPruner>> {
431        Err(StorageBackendError::Other(
432            "this backend does not support DiskANN journal maintenance".into(),
433        ))
434    }
435
436    /// 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.
437    fn diskann_maintenance_source(
438        &self,
439        _binding: crate::diskann_index::DiskANNIndexBinding<'_>,
440        _max_record_bytes: usize,
441    ) -> StorageBackendResult<Box<dyn crate::diskann_index::maintenance::DiskANNMaintenanceSource>>
442    {
443        Err(StorageBackendError::Other(
444            "this backend does not support DiskANN rebuild maintenance".into(),
445        ))
446    }
447
448    fn retire_diskann_index(
449        &self,
450        _binding: crate::diskann_index::DiskANNIndexBinding<'_>,
451    ) -> StorageBackendResult<()> {
452        Err(StorageBackendError::Other(
453            "this backend does not support DiskANN retirement".into(),
454        ))
455    }
456
457    fn drop_vector_index_metadata(&self, _table: &str, _field: &str) -> StorageBackendResult<()> {
458        Ok(())
459    }
460
461    /// Whether this backend maps logical `btree` indexes to durable postings.
462    fn persists_btree_indexes(&self) -> bool {
463        false
464    }
465
466    /// `Some(entries)` is a complete persisted index; `None` means it has not
467    /// been built yet and the engine must backfill it from documents once.
468    fn load_btree_index(
469        &self,
470        _table: &str,
471        _field: &crate::ValueIndexKey,
472    ) -> StorageBackendResult<Option<Vec<(DocId, Value)>>> {
473        Ok(None)
474    }
475
476    fn btree_index_fields(&self, _table: &str) -> StorageBackendResult<Vec<crate::ValueIndexKey>> {
477        Ok(Vec::new())
478    }
479
480    /// 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.
481    fn read_btree_index_entry(
482        &self,
483        _table: &str,
484        _field: &crate::ValueIndexKey,
485        _doc_id: DocId,
486    ) -> StorageBackendResult<crate::ValueIndexEntry> {
487        if self.persists_btree_indexes() {
488            return Err(crate::StorageBackendError::Other(
489                "point reads of stored B-tree entries are not implemented for this backend".into(),
490            ));
491        }
492        Ok(crate::ValueIndexEntry::Unbuilt)
493    }
494
495    /// Fields whose persisted posting support was found inconsistent during a
496    /// schema migration. The engine repairs these at its explicit open-time
497    /// write boundary and clears each durable retry marker only after success.
498    fn btree_index_repairs(&self) -> StorageBackendResult<Vec<(String, crate::ValueIndexKey)>> {
499        Ok(Vec::new())
500    }
501
502    fn clear_btree_index_repair(
503        &self,
504        _table: &str,
505        _field: &crate::ValueIndexKey,
506    ) -> StorageBackendResult<()> {
507        Ok(())
508    }
509
510    fn replace_btree_index(
511        &self,
512        _table: &str,
513        _field: &crate::ValueIndexKey,
514        _values: &[(DocId, Value)],
515    ) -> StorageBackendResult<()> {
516        Ok(())
517    }
518
519    /// Repair sparse support differences without requiring capable backends
520    /// to rewrite every already-valid posting. The complete replacement is
521    /// supplied for the storage-neutral fallback.
522    fn repair_btree_index(
523        &self,
524        table: &str,
525        field: &crate::ValueIndexKey,
526        complete: &[(DocId, Value)],
527        _stale_doc_ids: &[DocId],
528        _missing: &[(DocId, Value)],
529    ) -> StorageBackendResult<()> {
530        self.replace_btree_index(table, field, complete)
531    }
532
533    /// Replace several complete indexes for one table atomically. Backends
534    /// may override this to share one transaction and prepared statements.
535    fn replace_btree_indexes(
536        &self,
537        table: &str,
538        indexes: &[(&crate::ValueIndexKey, &[(DocId, Value)])],
539    ) -> StorageBackendResult<()> {
540        for (field, values) in indexes {
541            self.replace_btree_index(table, field, values)?;
542        }
543        Ok(())
544    }
545
546    fn apply_btree_index_write(
547        &self,
548        _table: &str,
549        _doc_id: DocId,
550        _values: Option<&BTreeMap<crate::ValueIndexKey, Value>>,
551    ) -> StorageBackendResult<()> {
552        Ok(())
553    }
554
555    /// 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.
556    fn apply_unused_btree_index_write(
557        &self,
558        table: &str,
559        doc_id: DocId,
560        values: &BTreeMap<crate::ValueIndexKey, Value>,
561        namespace: crate::document_store::identifiers::DocumentIdNamespace,
562    ) -> StorageBackendResult<()> {
563        let _ = namespace;
564        self.apply_btree_index_write(table, doc_id, Some(values))
565    }
566
567    fn drop_btree_index(
568        &self,
569        _table: &str,
570        _field: &crate::ValueIndexKey,
571    ) -> StorageBackendResult<()> {
572        Ok(())
573    }
574
575    fn clear_btree_indexes(&self, _table: &str) -> StorageBackendResult<()> {
576        Ok(())
577    }
578
579    /// 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.
580    fn reclaim_obsolete(&self) -> StorageBackendResult<()> {
581        if self.transaction_model().is_versioned() {
582            return Err(StorageBackendError::Other(
583                "versioned storage reclamation is not implemented by this backend".into(),
584            ));
585        }
586        Ok(())
587    }
588
589    /// 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.
590    fn vacuum(&self) -> StorageBackendResult<()> {
591        Ok(())
592    }
593
594    fn begin_transaction(&self) -> StorageBackendResult<()>;
595
596    /// Begin a transaction whose first operation is expected to be a read.
597    /// Backends with distinct lock modes may defer write-lock acquisition;
598    /// the default preserves existing transaction semantics.
599    fn begin_read_transaction(&self) -> StorageBackendResult<()> {
600        self.begin_transaction()
601    }
602
603    /// 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.
604    fn begin_upgradeable_transaction(&self) -> StorageBackendResult<()> {
605        self.begin_transaction()
606    }
607
608    /// 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.
609    fn refresh_transaction_snapshot(
610        &self,
611        _cancellation: &uqa_core::CancellationToken,
612    ) -> StorageBackendResult<()> {
613        Err(StorageBackendError::Other(
614            "transaction snapshot refresh is not supported by this backend".into(),
615        ))
616    }
617
618    /// Whether this session currently owns a pinned storage transaction.
619    fn in_transaction(&self) -> bool;
620
621    /// 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.
622    fn transaction_has_written(&self) -> StorageBackendResult<bool>;
623
624    /// Backend commit generation visible to this session, when available.
625    /// A changing value invalidates session-local catalog and index caches.
626    fn change_version(&self) -> StorageBackendResult<Option<u64>> {
627        Ok(None)
628    }
629
630    /// 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.
631    fn read_view_revision(
632        &self,
633    ) -> StorageBackendResult<Option<crate::key_value::KeyValueReadRevision>> {
634        Ok(None)
635    }
636
637    /// 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.
638    fn commit_monitor_version(&self) -> StorageBackendResult<Option<u64>> {
639        Ok(None)
640    }
641
642    /// 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.
643    fn change_version_monitor_is_nonblocking(&self) -> StorageBackendResult<bool> {
644        Ok(true)
645    }
646
647    /// Pin the transaction's read snapshot before cache restoration.
648    fn pin_transaction_snapshot(&self) -> StorageBackendResult<()> {
649        Ok(())
650    }
651
652    fn commit_transaction(&self) -> StorageBackendResult<()>;
653
654    fn rollback_transaction(&self) -> StorageBackendResult<()>;
655
656    fn savepoint(&self, id: StorageSavepointId) -> StorageBackendResult<()>;
657
658    fn release_savepoint(&self, id: StorageSavepointId) -> StorageBackendResult<()>;
659
660    fn rollback_to_savepoint(&self, id: StorageSavepointId) -> StorageBackendResult<()>;
661}
662
663#[cfg(all(test, unix))]
664mod identity_tests {
665    use super::*;
666
667    #[test]
668    fn dangling_database_symlink_keeps_the_target_identity_after_creation() {
669        use std::os::unix::fs::symlink;
670
671        let directory = tempfile::tempdir().unwrap();
672        let target = directory.path().join("target.db");
673        let link = directory.path().join("database.db");
674        symlink("target.db", &link).unwrap();
675
676        let before = PersistentStorageIdentity::for_database_path(&link).unwrap();
677        std::fs::File::create(&target).unwrap();
678        let after = PersistentStorageIdentity::for_database_path(&link).unwrap();
679
680        assert_eq!(before, after);
681        assert_eq!(
682            after,
683            PersistentStorageIdentity::File(target.canonicalize().unwrap())
684        );
685    }
686}