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