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