Skip to main content

uni_store/backend/
traits.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Core [`StorageBackend`] trait definition.
5
6use std::pin::Pin;
7use std::sync::Arc;
8
9use anyhow::Result;
10use arrow_array::RecordBatch;
11use arrow_schema::Schema as ArrowSchema;
12use async_trait::async_trait;
13use futures::Stream;
14use uni_common::core::schema::TokenizerConfig;
15
16use super::branching::ForkBranching;
17use super::types::*;
18
19/// A record batch stream returned by [`StorageBackend::scan_stream`].
20pub type RecordBatchStream = Pin<Box<dyn Stream<Item = Result<RecordBatch>> + Send>>;
21
22/// RAII guard serializing writes to a single table, from
23/// [`StorageBackend::lock_table_for_write`].
24///
25/// Held across a multi-step read-modify-write (e.g. an index backfill's
26/// scan → transform → [`StorageBackend::replace_table_atomic`]) so a concurrent
27/// [`StorageBackend::write`] append cannot interleave between the read and the
28/// overwrite and be silently discarded. A no-op for backends without per-table
29/// write locking.
30#[must_use = "the table write lock is released as soon as the guard is dropped"]
31pub struct TableWriteGuard(
32    // Held purely for its `Drop` side effect (releasing the per-table mutex).
33    #[expect(dead_code, reason = "guard is held only to release the lock on drop")]
34    Option<tokio::sync::OwnedMutexGuard<()>>,
35);
36
37impl std::fmt::Debug for TableWriteGuard {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("TableWriteGuard").finish_non_exhaustive()
40    }
41}
42
43impl TableWriteGuard {
44    /// A no-op guard for backends that do not serialize writes per table.
45    pub fn none() -> Self {
46        Self(None)
47    }
48
49    /// Wrap an owned per-table mutex guard.
50    pub fn held(guard: tokio::sync::OwnedMutexGuard<()>) -> Self {
51        Self(Some(guard))
52    }
53}
54
55/// Core storage backend trait.
56///
57/// All persistent storage operations go through this trait. Backends must be
58/// thread-safe ([`Send`] + [`Sync`]) and have a static lifetime for use with
59/// `Arc<dyn StorageBackend>`.
60///
61/// # Design Principles
62///
63/// - **Arrow-native**: All data interchange uses Arrow [`RecordBatch`].
64/// - **SQL-string filters**: Filter expressions use SQL-like strings initially.
65///   Backends that don't support SQL must parse/translate these strings.
66/// - **Capabilities via default methods**: Optional features (vector search, FTS)
67///   have default implementations that return "not supported" errors.
68/// - **Table-level operations**: The backend manages individual tables (not the
69///   higher-level graph schema). Table naming conventions are in [`super::table_names`].
70#[async_trait]
71pub trait StorageBackend: Send + Sync + 'static {
72    // ========================
73    // Table Lifecycle
74    // ========================
75
76    /// List all table names in the backend.
77    async fn table_names(&self) -> Result<Vec<String>>;
78
79    /// Check if a table exists.
80    async fn table_exists(&self, name: &str) -> Result<bool>;
81
82    /// Create a new table with initial data batches.
83    async fn create_table(&self, name: &str, batches: Vec<RecordBatch>) -> Result<()>;
84
85    /// Create a new empty table with the given schema.
86    async fn create_empty_table(&self, name: &str, schema: Arc<ArrowSchema>) -> Result<()>;
87
88    /// Open a table if it exists, or create it with the given schema.
89    async fn open_or_create_table(&self, name: &str, schema: Arc<ArrowSchema>) -> Result<()>;
90
91    /// Drop a table by name.
92    async fn drop_table(&self, name: &str) -> Result<()>;
93
94    /// Notify the backend that a table now exists, even though no
95    /// `create_table` / `create_empty_table` / `open_or_create_table`
96    /// went through this trait. The default implementation is a
97    /// no-op; backends that cache existence (e.g. `LanceDbBackend`'s
98    /// `existence_cache` from issue #55) override to invalidate the
99    /// stale negative entry. Used by `BranchedBackend` after it
100    /// creates a fork-side dataset directly through the Lance branch
101    /// primitives — that path does not call `create_table` on the
102    /// inner backend, so without this hook the inner backend's
103    /// existence cache silently keeps reporting `false` for the
104    /// just-created table.
105    async fn notify_table_created(&self, name: &str) {
106        let _ = name;
107    }
108
109    // ========================
110    // Read Operations
111    // ========================
112
113    /// Scan a table, collecting all matching rows into batches.
114    async fn scan(&self, request: ScanRequest) -> Result<Vec<RecordBatch>>;
115
116    /// Scan a table, returning a streaming iterator over record batches.
117    async fn scan_stream(&self, request: ScanRequest) -> Result<RecordBatchStream>;
118
119    /// Get the Arrow schema for a table. Returns `None` if the table doesn't exist.
120    async fn get_table_schema(&self, name: &str) -> Result<Option<Arc<ArrowSchema>>>;
121
122    /// Count rows in a table, optionally with a filter.
123    async fn count_rows(&self, table_name: &str, filter: Option<&FilterExpr>) -> Result<usize>;
124
125    // ========================
126    // Write Operations
127    // ========================
128
129    /// Write record batches to a table.
130    async fn write(
131        &self,
132        table_name: &str,
133        batches: Vec<RecordBatch>,
134        mode: WriteMode,
135    ) -> Result<()>;
136
137    /// Upsert via Lance MergeInsert. Source rows are joined to the
138    /// target on the columns in `on`; matched rows have `UpdateAll`
139    /// applied (i.e. every column present in the source overrides the
140    /// target's value for that column; columns not in the source are
141    /// preserved). Unmatched source rows are DROPPED — partial writes
142    /// never INSERT (CREATE goes through `write` with `WriteMode::Append`).
143    ///
144    /// Used by `Writer::flush_stream_l1` when
145    /// `UniConfig::partial_lance_writes` is on.
146    async fn merge_insert(
147        &self,
148        _table_name: &str,
149        _on: &[&str],
150        _batches: Vec<RecordBatch>,
151    ) -> Result<()> {
152        anyhow::bail!("merge_insert not supported by this backend")
153    }
154
155    /// Delete rows matching a filter expression.
156    async fn delete_rows(&self, table_name: &str, filter: &FilterExpr) -> Result<()>;
157
158    /// Atomically replace a table's contents.
159    ///
160    /// Handles the case where batches may be empty (clears the table) and the
161    /// table may not exist yet (creates it).
162    async fn replace_table_atomic(
163        &self,
164        name: &str,
165        batches: Vec<RecordBatch>,
166        schema: Arc<ArrowSchema>,
167    ) -> Result<()>;
168
169    /// Acquire the per-table write lock, returning a guard held until dropped.
170    ///
171    /// A caller performing a read-modify-write that spans multiple backend calls —
172    /// a scan followed by [`Self::replace_table_atomic`], as the MUVERA FDE backfill
173    /// does — must hold this across the whole sequence. Otherwise a concurrent
174    /// [`Self::write`] append can land between the read and the full-table overwrite
175    /// and be silently lost. [`Self::write`] / [`Self::merge_insert`] take the same
176    /// lock internally, so holding it here makes them mutually exclusive.
177    ///
178    /// Backends without per-table write locking return a no-op guard.
179    async fn lock_table_for_write(&self, name: &str) -> TableWriteGuard {
180        let _ = name;
181        TableWriteGuard::none()
182    }
183
184    // ========================
185    // Versioning / MVCC
186    // ========================
187
188    /// Get the current version of a table. Returns `None` if the table doesn't exist.
189    async fn get_table_version(&self, table_name: &str) -> Result<Option<u64>>;
190
191    /// Roll back a table to a specific version.
192    async fn rollback_table(&self, table_name: &str, target_version: u64) -> Result<()>;
193
194    // ========================
195    // Maintenance
196    // ========================
197
198    /// Optimize a table (compaction, cleanup, etc.).
199    async fn optimize_table(&self, table_name: &str) -> Result<()>;
200
201    /// Recover a table from crash state (incomplete staging writes, etc.).
202    async fn recover_staging(&self, table_name: &str) -> Result<()>;
203
204    // ========================
205    // Cache Management
206    // ========================
207
208    /// Invalidate any cached state for a table.
209    fn invalidate_cache(&self, _table_name: &str) {}
210
211    /// Clear all cached state.
212    fn clear_cache(&self) {}
213
214    // ========================
215    // Metadata
216    // ========================
217
218    /// Get the base URI for this backend's storage location.
219    fn base_uri(&self) -> &str;
220
221    /// Copy-on-write branching support, if this backend provides it.
222    ///
223    /// `Some` unlocks the fork engine; `None` means forks are unavailable on
224    /// this backend rather than silently unisolated. Returning `None` is the
225    /// default so that a new backend cannot accidentally claim fork support it
226    /// has not implemented.
227    fn branching(&self) -> Option<Arc<dyn ForkBranching>> {
228        None
229    }
230
231    // ========================
232    // Capability Checks
233    // ========================
234
235    /// Whether this backend supports vector similarity search.
236    fn supports_vector_search(&self) -> bool {
237        false
238    }
239
240    /// Whether this backend supports full-text search.
241    fn supports_full_text_search(&self) -> bool {
242        false
243    }
244
245    /// Whether this backend supports scalar indexes.
246    fn supports_scalar_index(&self) -> bool {
247        false
248    }
249
250    // ========================
251    // Optional Capabilities
252    // ========================
253
254    /// Perform a vector similarity search.
255    #[expect(clippy::too_many_arguments)]
256    async fn vector_search(
257        &self,
258        _table: &str,
259        _column: &str,
260        _query: &[f32],
261        _k: usize,
262        _metric: DistanceMetric,
263        _filter: FilterExpr,
264        _opts: VectorQueryOpts,
265    ) -> Result<Vec<RecordBatch>> {
266        anyhow::bail!("Vector search not supported by this backend")
267    }
268
269    /// Late-interaction (ColBERT / MaxSim) search over a multi-vector column.
270    ///
271    /// `query` is a set of per-token vectors; each row's `List<FixedSizeList>`
272    /// column is scored by MaxSim. Defaults to unsupported.
273    #[expect(clippy::too_many_arguments)]
274    async fn multivector_search(
275        &self,
276        _table: &str,
277        _column: &str,
278        _query: &[Vec<f32>],
279        _k: usize,
280        _metric: DistanceMetric,
281        _filter: FilterExpr,
282        _opts: VectorQueryOpts,
283    ) -> Result<Vec<RecordBatch>> {
284        anyhow::bail!("Multi-vector search not supported by this backend")
285    }
286
287    /// Perform a full-text search.
288    async fn full_text_search(
289        &self,
290        _table: &str,
291        _column: &str,
292        _query: &str,
293        _k: usize,
294        _filter: FilterExpr,
295    ) -> Result<Vec<RecordBatch>> {
296        anyhow::bail!("Full-text search not supported by this backend")
297    }
298
299    /// Create a named vector (ANN) index on a column with the given parameters.
300    ///
301    /// `name` is the index name to assign; an existing index of the same name is
302    /// replaced. `params` selects the physical index shape and metric.
303    ///
304    /// # Errors
305    /// Returns an error if the backend does not support vector indexing or the
306    /// build fails.
307    async fn create_vector_index(
308        &self,
309        _table: &str,
310        _column: &str,
311        _name: &str,
312        _params: VectorIndexParams,
313    ) -> Result<()> {
314        anyhow::bail!("Vector indexing not supported by this backend")
315    }
316
317    /// Create a full-text search index over one or more columns.
318    ///
319    /// `name` is the index name (`None` lets the backend choose a default).
320    /// `tokenizer` selects the analyzer pipeline (tokenizer, stemming,
321    /// stop words, ...). `with_positions` enables phrase/position postings
322    /// (it is ignored for tokenizers that cannot store positions, e.g. N-gram).
323    ///
324    /// # Errors
325    /// Returns an error if the backend does not support FTS, the tokenizer
326    /// configuration is invalid, or the build fails.
327    async fn create_fts_index(
328        &self,
329        _table: &str,
330        _columns: &[&str],
331        _name: Option<&str>,
332        _tokenizer: &TokenizerConfig,
333        _with_positions: bool,
334    ) -> Result<()> {
335        anyhow::bail!("FTS indexing not supported by this backend")
336    }
337
338    /// Create a scalar index over one or more columns.
339    ///
340    /// `name` is the index name (`None` lets the backend choose a default).
341    ///
342    /// # Errors
343    /// Returns an error if the backend does not support scalar indexing or the
344    /// build fails.
345    async fn create_scalar_index(
346        &self,
347        _table: &str,
348        _columns: &[&str],
349        _index_type: ScalarIndexType,
350        _name: Option<&str>,
351    ) -> Result<()> {
352        anyhow::bail!("Scalar indexing not supported by this backend")
353    }
354
355    /// Drop an index by name.
356    async fn drop_index(&self, _table: &str, _index_name: &str) -> Result<()> {
357        anyhow::bail!("Index drop not supported by this backend")
358    }
359
360    /// List all indexes on a table.
361    async fn list_indexes(&self, _table: &str) -> Result<Vec<IndexInfo>> {
362        Ok(vec![])
363    }
364}