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}