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}