Skip to main content

uni_store/backend/
lance.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Lance implementation of the [`StorageBackend`] trait.
5
6use std::collections::HashMap;
7use std::pin::Pin;
8use std::sync::Arc;
9
10use anyhow::{Result, anyhow};
11use arrow_array::RecordBatch;
12use arrow_schema::Schema as ArrowSchema;
13use async_trait::async_trait;
14use dashmap::DashMap;
15use futures::{Stream, StreamExt, TryStreamExt};
16
17use uni_common::core::schema::TokenizerConfig;
18
19use super::lance_branch;
20use super::lance_directory::LanceDirectory;
21use super::traits::{RecordBatchStream, StorageBackend};
22use super::types::*;
23
24/// Lance implementation of [`StorageBackend`].
25///
26/// Built directly on `lance::Dataset` via [`LanceDirectory`]; the `lancedb`
27/// layer it once wrapped has been removed. All Lance-specific code is confined
28/// to this module and its siblings (`lance_branch`, `lance_directory`).
29pub struct LanceDbBackend {
30    /// The directory of Lance datasets this backend addresses.
31    ///
32    /// Owns table-name → dataset-path resolution and dataset opens; see its
33    /// module docs for the layout contract it must uphold.
34    directory: LanceDirectory,
35    base_uri: String,
36    /// Per-table write serialization mutex. Acquired by `write` and
37    /// `create_table` around the check-then-create. Without this, two
38    /// concurrent async-flush streams that both observe a table as
39    /// not-yet-existing can both succeed at `create_table`, and Lance's
40    /// CreateTableMode::Create (default) does NOT atomically reject
41    /// the second — observed under in-memory backend, where the
42    /// second Create writes a new dataset that REPLACES the first,
43    /// silently losing the first's batch. Per-table mutex preserves
44    /// parallelism across different tables (different labels).
45    table_write_locks: DashMap<String, Arc<tokio::sync::Mutex<()>>>,
46    /// Existence cache populated lazily by [`Self::table_exists`].
47    ///
48    /// Avoids paying for [`LanceDirectory::table_names`] (which lists every
49    /// table in the database) on every `table_exists` call. uni-db's
50    /// query planner calls `table_exists` per-table per-query, so without
51    /// this cache, post-flush latency scales with total schema size.
52    /// Updated synchronously by `create_table`, `create_empty_table`,
53    /// `open_or_create_table`, and `drop_table` so the cache is the
54    /// authoritative source after first population. See issue #55.
55    existence_cache: DashMap<String, bool>,
56    /// Schema cache populated lazily by [`Self::get_table_schema`].
57    ///
58    /// Lance schemas are stable for the table's lifetime under our usage
59    /// (we never alter columns in place — schema-evolving migrations would
60    /// drop/recreate the table). Caching avoids the per-query
61    /// dataset open + schema conversion for every Cypher query that
62    /// scans a label or edge type. See issue #55.
63    schema_cache: DashMap<String, Arc<ArrowSchema>>,
64}
65
66/// Map uni's backend-neutral metric onto Lance's.
67fn distance_metric_of(metric: DistanceMetric) -> lance_linalg::distance::MetricType {
68    match metric {
69        DistanceMetric::L2 => lance_linalg::distance::MetricType::L2,
70        DistanceMetric::Cosine => lance_linalg::distance::MetricType::Cosine,
71        DistanceMetric::Dot => lance_linalg::distance::MetricType::Dot,
72    }
73}
74
75impl LanceDbBackend {
76    /// Connect to a LanceDB database at the given URI.
77    pub async fn connect(
78        uri: &str,
79        storage_options: Option<HashMap<String, String>>,
80    ) -> Result<Self> {
81        let directory = LanceDirectory::connect(uri, storage_options).await?;
82
83        Ok(Self {
84            directory,
85            base_uri: uri.to_string(),
86            table_write_locks: DashMap::new(),
87            existence_cache: DashMap::new(),
88            schema_cache: DashMap::new(),
89        })
90    }
91
92    /// Get or insert the per-table write mutex used to serialize
93    /// concurrent `write` / `create_table` against the same table.
94    /// See `table_write_locks` field doc for context.
95    fn write_lock_for(&self, name: &str) -> Arc<tokio::sync::Mutex<()>> {
96        self.table_write_locks
97            .entry(name.to_string())
98            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
99            .clone()
100    }
101
102    /// Write `batches` to `table` with `mode`, on raw Lance.
103    ///
104    /// `schema` travels separately so the empty case works: an empty `batches`
105    /// carries no schema, and `WriteMode::Create` on an empty vector is how a
106    /// schema-only table gets materialized. A single zero-row batch is what
107    /// actually conveys the schema to Lance — the same normalization
108    /// `LanceBranching::reader` performs.
109    ///
110    /// This is the one place primary writes reach storage, so storage options
111    /// are threaded exactly once, via [`LanceDirectory::write_params`].
112    async fn write_batches(
113        &self,
114        table: &str,
115        mut batches: Vec<RecordBatch>,
116        schema: Arc<ArrowSchema>,
117        mode: lance::dataset::WriteMode,
118    ) -> Result<()> {
119        if batches.is_empty() {
120            batches.push(RecordBatch::new_empty(schema.clone()));
121        }
122        let uri = self.directory.dataset_uri(table);
123        let params = self.directory.write_params(mode);
124        let reader = arrow_array::RecordBatchIterator::new(batches.into_iter().map(Ok), schema);
125        lance::Dataset::write(reader, &uri, Some(params))
126            .await
127            .map_err(|e| anyhow!("Write to '{}' ({:?}) failed: {}", table, mode, e))?;
128        Ok(())
129    }
130
131    /// Execute a scan query on the primary branch.
132    ///
133    /// Mirrors [`Self::execute_branch_scan`] with one deliberate difference:
134    /// scalar-index pushdown stays **enabled** here. The branch path disables
135    /// it because a fork's `base_paths` chain can't resolve a BTree's
136    /// `page_lookup.lance` past one level (#106); primary has no such chain,
137    /// so it keeps the acceleration. The two paths therefore differ in plan,
138    /// never in result set.
139    async fn execute_primary_scan(&self, request: &ScanRequest) -> Result<RecordBatchStream> {
140        let dataset = self.directory.open(&request.table_name).await?;
141        let mut scanner = dataset.scan();
142
143        if let ColumnProjection::Columns(cols) = &request.columns {
144            scanner.project(cols).map_err(|e| {
145                anyhow!(
146                    "Project columns {:?} on '{}': {}",
147                    cols,
148                    request.table_name,
149                    e
150                )
151            })?;
152        }
153
154        if !request.filter.is_trivially_true() {
155            let sql = request.filter.to_sql()?;
156            scanner
157                .filter(&sql)
158                .map_err(|e| anyhow!("Filter '{}' on '{}': {}", sql, request.table_name, e))?;
159        }
160
161        if let Some(limit) = request.limit {
162            scanner
163                .limit(Some(limit as i64), None)
164                .map_err(|e| anyhow!("Limit on scan of '{}': {}", request.table_name, e))?;
165        }
166
167        let stream = scanner
168            .try_into_stream()
169            .await
170            .map_err(|e| anyhow!("Scan stream on '{}': {}", request.table_name, e))?;
171
172        let mapped: Pin<Box<dyn Stream<Item = Result<RecordBatch>> + Send>> =
173            Box::pin(stream.map(|r| r.map_err(|e| anyhow!("{}", e))));
174        Ok(mapped)
175    }
176
177    /// Execute a scan query on a Lance branch via the lower-level lance crate.
178    async fn execute_branch_scan(
179        &self,
180        request: &ScanRequest,
181        branch: &str,
182    ) -> Result<RecordBatchStream> {
183        let uri = self.directory.dataset_uri(&request.table_name);
184        let dataset = lance_branch::open_branch(&uri, branch).await?;
185
186        let mut scanner = dataset.scan();
187        // Disable scalar-index pushdown on branch scans: a fork's `base_paths` chain
188        // (child -> parent -> main) resolves data fragments but NOT a scalar (BTree) index's
189        // `_indices/<id>/page_lookup.lance` across >1 level, so a filtered branch scan would
190        // error on a nested fork (#106). This is result-set neutral — the filter still matches
191        // the same rows via a sequential scan — and fork datasets are small, so the lost
192        // acceleration is negligible. The primary (non-branch) scan path keeps the index.
193        scanner.use_scalar_index(false);
194
195        if let ColumnProjection::Columns(cols) = &request.columns {
196            scanner.project(cols).map_err(|e| {
197                anyhow!(
198                    "Project columns {:?} on '{}@{}': {}",
199                    cols,
200                    request.table_name,
201                    branch,
202                    e
203                )
204            })?;
205        }
206
207        if !request.filter.is_trivially_true() {
208            let sql = request.filter.to_sql()?;
209            scanner.filter(&sql).map_err(|e| {
210                anyhow!(
211                    "Filter '{}' on '{}@{}': {}",
212                    sql,
213                    request.table_name,
214                    branch,
215                    e
216                )
217            })?;
218        }
219
220        if let Some(limit) = request.limit {
221            scanner
222                .limit(Some(limit as i64), None)
223                .map_err(|e| anyhow!("Limit on branched scan failed: {}", e))?;
224        }
225
226        let stream = scanner.try_into_stream().await.map_err(|e| {
227            anyhow!(
228                "Branched scan stream on '{}@{}': {}",
229                request.table_name,
230                branch,
231                e
232            )
233        })?;
234
235        let mapped: Pin<Box<dyn Stream<Item = Result<RecordBatch>> + Send>> =
236            Box::pin(stream.map(|r| r.map_err(|e| anyhow!("{}", e))));
237        Ok(mapped)
238    }
239
240    /// Run a scan, dispatching to the primary or branch path based on `request.branch`.
241    async fn execute_scan_stream(&self, request: &ScanRequest) -> Result<RecordBatchStream> {
242        if let Some(branch) = request.branch.clone() {
243            return self.execute_branch_scan(request, &branch).await;
244        }
245        self.execute_primary_scan(request).await
246    }
247}
248
249#[async_trait]
250impl StorageBackend for LanceDbBackend {
251    // ========================
252    // Table Lifecycle
253    // ========================
254
255    async fn table_names(&self) -> Result<Vec<String>> {
256        self.directory
257            .table_names()
258            .await
259            .map_err(|e| anyhow!("Failed to list tables: {}", e))
260    }
261
262    async fn table_exists(&self, name: &str) -> Result<bool> {
263        if let Some(entry) = self.existence_cache.get(name) {
264            return Ok(*entry);
265        }
266        let tables = self.table_names().await?;
267        let exists = tables.iter().any(|t| t == name);
268        // entry().or_insert preserves a value written by a concurrent
269        // create_table/drop_table during our `table_names` await, which
270        // is the authoritative state. Plain `insert` would race and
271        // could overwrite a writer's `true` with our stale `false`.
272        let final_value = *self
273            .existence_cache
274            .entry(name.to_string())
275            .or_insert(exists);
276        Ok(final_value)
277    }
278
279    async fn create_table(&self, name: &str, batches: Vec<RecordBatch>) -> Result<()> {
280        // L6: reject names unsafe for the dataset path / Lance branch names
281        // (a schemaless bad label/edge-type would otherwise panic Lance).
282        crate::backend::table_names::validate_table_name(name)?;
283        if batches.is_empty() {
284            return Err(anyhow!(
285                "Cannot create table '{}' with empty data. Use create_empty_table instead.",
286                name
287            ));
288        }
289        // Serialize concurrent create_table / write per-table. Without
290        // this, two threads that both observed "table doesn't exist"
291        // can both call create_table; CreateTableMode::Create's
292        // exists-error is not perfectly atomic on some backends
293        // (notably in-memory in lancedb 0.27.1), and the second Create
294        // overwrites the first's data. See `table_write_locks` field doc.
295        let lock = self.write_lock_for(name);
296        let _guard = lock.lock().await;
297        // Re-check existence under the lock. If a sibling stream
298        // created the table while we were waiting, fall back to Append
299        // (calling the inner machinery directly since we already hold
300        // the per-table write lock).
301        let schema = batches[0].schema();
302        if self.table_exists(name).await? {
303            self.write_batches(name, batches, schema, lance::dataset::WriteMode::Append)
304                .await
305                .map_err(|e| anyhow!("Failed to append (fallback from create) to '{name}': {e}"))?;
306            return Ok(());
307        }
308        self.write_batches(name, batches, schema, lance::dataset::WriteMode::Create)
309            .await
310            .map_err(|e| anyhow!("Failed to create table '{name}': {e}"))?;
311        self.existence_cache.insert(name.to_string(), true);
312        Ok(())
313    }
314
315    async fn create_empty_table(&self, name: &str, schema: Arc<ArrowSchema>) -> Result<()> {
316        // L6: reject unsafe names before they reach Lance.
317        crate::backend::table_names::validate_table_name(name)?;
318        self.write_batches(name, Vec::new(), schema, lance::dataset::WriteMode::Create)
319            .await
320            .map_err(|e| anyhow!("Failed to create empty table '{name}': {e}"))?;
321        self.existence_cache.insert(name.to_string(), true);
322        Ok(())
323    }
324
325    async fn open_or_create_table(&self, name: &str, schema: Arc<ArrowSchema>) -> Result<()> {
326        if self.table_exists(name).await? {
327            // Just verify it can be opened
328            self.directory.open(name).await?;
329        } else {
330            self.create_empty_table(name, schema).await?;
331        }
332        Ok(())
333    }
334
335    async fn drop_table(&self, name: &str) -> Result<()> {
336        self.schema_cache.remove(name);
337        self.directory
338            .remove_table(name)
339            .await
340            .map_err(|e| anyhow!("Failed to drop table '{}': {}", name, e))?;
341        self.existence_cache.insert(name.to_string(), false);
342        Ok(())
343    }
344
345    async fn notify_table_created(&self, name: &str) {
346        // BranchedBackend creates fork-side datasets via Lance's branch
347        // primitives directly, bypassing this backend's create_table.
348        // Without this hook the existence_cache (issue #55) would keep
349        // a stale `false` and cause queries to silently see no rows.
350        self.existence_cache.insert(name.to_string(), true);
351    }
352
353    // ========================
354    // Read Operations
355    // ========================
356
357    async fn scan(&self, request: ScanRequest) -> Result<Vec<RecordBatch>> {
358        // Fail closed (review C1): a scan error — transient I/O, an unparsable
359        // filter, a corrupt fragment — MUST propagate, never collapse into an
360        // empty result. Callers such as the MERGE existence-check treat "no
361        // rows" as "row absent" and would create a duplicate node on a silently
362        // swallowed error. The previous `Err(_) => Ok(vec![])` defeated that
363        // fail-closed contract.
364        //
365        // The one benign not-an-error is a not-yet-created table, which
366        // genuinely means "no rows". Detect that explicitly via `table_exists`
367        // (the existence cache is kept correct for fork/branch datasets by
368        // `notify_table_created`) so a missing table stays empty while every
369        // real failure surfaces.
370        if !self.table_exists(&request.table_name).await? {
371            return Ok(vec![]);
372        }
373
374        let stream = self.execute_scan_stream(&request).await?;
375
376        stream
377            .try_collect()
378            .await
379            .map_err(|e| anyhow!("Failed to collect scan results: {}", e))
380    }
381
382    async fn scan_stream(&self, request: ScanRequest) -> Result<RecordBatchStream> {
383        self.execute_scan_stream(&request).await
384    }
385
386    async fn get_table_schema(&self, name: &str) -> Result<Option<Arc<ArrowSchema>>> {
387        if let Some(entry) = self.schema_cache.get(name) {
388            return Ok(Some(entry.clone()));
389        }
390        match self.directory.open(name).await {
391            Ok(dataset) => {
392                // `Dataset::schema()` is Lance's own schema type; the trait
393                // hands out Arrow. The conversion is what lancedb's
394                // `Table::schema()` did internally.
395                let schema: Arc<ArrowSchema> = Arc::new(dataset.schema().into());
396                self.schema_cache.insert(name.to_string(), schema.clone());
397                Ok(Some(schema))
398            }
399            // Pre-existing behavior, preserved deliberately: any open failure
400            // reads as "table absent", which also hides real I/O errors.
401            Err(_) => Ok(None),
402        }
403    }
404
405    async fn count_rows(&self, table_name: &str, filter: Option<&FilterExpr>) -> Result<usize> {
406        let dataset = self.directory.open(table_name).await?;
407        let predicate = filter.map(FilterExpr::to_sql).transpose()?;
408        dataset
409            .count_rows(predicate)
410            .await
411            .map_err(|e| anyhow!("Failed to count rows in '{}': {}", table_name, e))
412    }
413
414    // ========================
415    // Write Operations
416    // ========================
417
418    async fn write(
419        &self,
420        table_name: &str,
421        batches: Vec<RecordBatch>,
422        mode: WriteMode,
423    ) -> Result<()> {
424        if batches.is_empty() {
425            return Ok(());
426        }
427
428        // Serialize per-table writes. Lance's optimistic concurrency on
429        // commit is sufficient for parallel Appends in theory, but
430        // under async-flush we observed two concurrent Append/Create
431        // mixes producing data loss on the in-memory backend. Holding
432        // a per-table mutex eliminates that whole class of races at a
433        // cost of serializing writes per-table (parallelism preserved
434        // across different tables).
435        let lock = self.write_lock_for(table_name);
436        let _guard = lock.lock().await;
437
438        let schema = batches[0].schema();
439        // lancedb's `add(..).mode(Overwrite)` is `WriteMode::Overwrite`, which
440        // commits the new contents as a fresh version rather than mutating in
441        // place — that is where `replace_table_atomic`'s atomicity comes from.
442        let lance_mode = match mode {
443            WriteMode::Append => lance::dataset::WriteMode::Append,
444            WriteMode::Overwrite => lance::dataset::WriteMode::Overwrite,
445        };
446        self.write_batches(table_name, batches, schema, lance_mode)
447            .await?;
448
449        Ok(())
450    }
451
452    async fn merge_insert(
453        &self,
454        table_name: &str,
455        on: &[&str],
456        batches: Vec<RecordBatch>,
457    ) -> Result<()> {
458        if batches.is_empty() {
459            return Ok(());
460        }
461
462        // Serialize per-table writes (same as `write`).
463        let lock = self.write_lock_for(table_name);
464        let _guard = lock.lock().await;
465
466        // Build a reader for the partial-column source. The first batch's
467        // schema describes the source subschema; Lance compares it against
468        // the target via `allow_subschema=true` internally.
469        let schema = batches[0].schema();
470        let reader = arrow_array::RecordBatchIterator::new(batches.into_iter().map(Ok), schema);
471        // `MergeInsertBuilder::try_new` takes owned join-key names.
472        let on_owned: Vec<String> = on.iter().map(|s| (*s).to_string()).collect();
473
474        // lancedb's merge_insert is exactly this builder — `try_new` + the
475        // when-clauses + `try_build` — so behavior including partial-subschema
476        // sources is unchanged. Deliberately NOT setting `WhenNotMatched`
477        // beyond the default `DoNothing`: partial writes only update existing
478        // rows; CREATE goes through the full-row Append path. Unmatched source
479        // rows are dropped.
480        let dataset = self.directory.open(table_name).await?;
481        let mut builder = lance::dataset::MergeInsertBuilder::try_new(Arc::new(dataset), on_owned)
482            .map_err(|e| anyhow!("merge_insert builder on '{}': {}", table_name, e))?;
483        builder
484            .when_matched(lance::dataset::WhenMatched::UpdateAll)
485            .when_not_matched(lance::dataset::WhenNotMatched::DoNothing);
486        let job = builder
487            .try_build()
488            .map_err(|e| anyhow!("merge_insert build on '{}': {}", table_name, e))?;
489        job.execute_reader(Box::new(reader))
490            .await
491            .map_err(|e| anyhow!("merge_insert on '{}': {}", table_name, e))?;
492        Ok(())
493    }
494
495    async fn delete_rows(&self, table_name: &str, filter: &FilterExpr) -> Result<()> {
496        let mut dataset = self.directory.open(table_name).await?;
497        dataset
498            .delete(&filter.to_sql()?)
499            .await
500            .map_err(|e| anyhow!("Failed to delete from '{}': {}", table_name, e))?;
501        Ok(())
502    }
503
504    async fn replace_table_atomic(
505        &self,
506        name: &str,
507        batches: Vec<RecordBatch>,
508        schema: Arc<ArrowSchema>,
509    ) -> Result<()> {
510        // Clean up leftover staging table
511        let staging_name = format!("{}_staging", name);
512        if self.table_exists(&staging_name).await? {
513            self.drop_table(&staging_name).await?;
514        }
515
516        if self.table_exists(name).await? {
517            if batches.is_empty() {
518                // Clear, not overwrite: an empty Overwrite would drop the
519                // schema along with the rows. `delete("true")` keeps the table
520                // and its schema, which is what callers expect from "replace
521                // with nothing".
522                let mut dataset = self.directory.open(name).await?;
523                dataset
524                    .delete("true")
525                    .await
526                    .map_err(|e| anyhow!("Failed to clear table '{}': {}", name, e))?;
527            } else {
528                let batch_schema = batches[0].schema();
529                self.write_batches(
530                    name,
531                    batches,
532                    batch_schema,
533                    lance::dataset::WriteMode::Overwrite,
534                )
535                .await
536                .map_err(|e| anyhow!("Failed to overwrite table '{}': {}", name, e))?;
537            }
538            // Invalidate cache since data changed
539        } else if batches.is_empty() {
540            self.create_empty_table(name, schema).await?;
541        } else {
542            self.create_table(name, batches).await?;
543        }
544        Ok(())
545    }
546
547    async fn lock_table_for_write(&self, name: &str) -> crate::backend::traits::TableWriteGuard {
548        // Same per-table mutex `write` / `merge_insert` / `create_table` take, exposed as
549        // an owned guard so a multi-step read-modify-write (the MUVERA FDE backfill's
550        // scan → overwrite) can hold it across both calls and serialize against flush
551        // appends. `replace_table_atomic`'s table-exists path takes no internal lock, so a
552        // holder calling it does not deadlock.
553        crate::backend::traits::TableWriteGuard::held(self.write_lock_for(name).lock_owned().await)
554    }
555
556    // ========================
557    // Versioning / MVCC
558    // ========================
559
560    async fn get_table_version(&self, table_name: &str) -> Result<Option<u64>> {
561        if !self.table_exists(table_name).await? {
562            return Ok(None);
563        }
564        let dataset = self.directory.open(table_name).await?;
565        Ok(Some(dataset.version().version))
566    }
567
568    async fn rollback_table(&self, table_name: &str, target_version: u64) -> Result<()> {
569        // lancedb's protocol was `checkout(v)` then `restore()`: pin the handle
570        // to the target version, then commit that as a new version. Opening
571        // directly at the version is the same first step, and `restore` is
572        // Lance's own — lancedb only forwarded it.
573        let mut dataset = self
574            .directory
575            .open_at_version(table_name, target_version)
576            .await
577            .map_err(|e| {
578                anyhow!(
579                    "Failed to checkout version {} for '{}': {}",
580                    target_version,
581                    table_name,
582                    e
583                )
584            })?;
585        dataset.restore().await.map_err(|e| {
586            anyhow!(
587                "Failed to restore '{}' to version {}: {}",
588                table_name,
589                target_version,
590                e
591            )
592        })?;
593        Ok(())
594    }
595
596    // ========================
597    // Maintenance
598    // ========================
599
600    async fn optimize_table(&self, table_name: &str) -> Result<()> {
601        let mut dataset = self.directory.open(table_name).await?;
602
603        // The three steps lancedb's `OptimizeAction::All` performed, in order
604        // (`lancedb/src/table/optimize.rs:172-186`). Its `OptimizeStats` were
605        // discarded by the caller, so only the effects need to match.
606        lance::dataset::optimize::compact_files(
607            &mut dataset,
608            lance::dataset::optimize::CompactionOptions::default(),
609            None,
610        )
611        .await
612        .map_err(|e| anyhow!("Failed to compact '{}': {}", table_name, e))?;
613
614        // Prune versions older than 7 days, matching lancedb's hardcoded
615        // window. This is safe for forks despite the "retention must not drop
616        // below the longest live fork chain" invariant: Lance's cleanup is
617        // branch-aware — it calls `find_referenced_branches()` and then
618        // `retain_branch_lineage_files()`, and `clean_referenced_branches`
619        // defaults to false, so versions a live fork branch still needs are
620        // retained regardless of age (`lance/src/dataset/cleanup.rs:146,181,930`).
621        let policy = lance::dataset::cleanup::CleanupPolicy {
622            before_timestamp: Some(chrono::Utc::now() - chrono::Duration::days(7)),
623            ..Default::default()
624        };
625        lance::dataset::cleanup::cleanup_old_versions(&dataset, policy)
626            .await
627            .map_err(|e| anyhow!("Failed to prune old versions of '{}': {}", table_name, e))?;
628
629        lance::index::DatasetIndexExt::optimize_indices(
630            &mut dataset,
631            &lance_index::optimize::OptimizeOptions::default(),
632        )
633        .await
634        .map_err(|e| anyhow!("Failed to optimize indices on '{}': {}", table_name, e))?;
635
636        Ok(())
637    }
638
639    async fn recover_staging(&self, name: &str) -> Result<()> {
640        let staging_name = format!("{}_staging", name);
641
642        if !self.table_exists(&staging_name).await? {
643            return Ok(());
644        }
645
646        let main_exists = self.table_exists(name).await?;
647
648        if main_exists {
649            log::info!("Cleaning up leftover staging table: {}", staging_name);
650            self.drop_table(&staging_name).await?;
651        } else {
652            log::warn!("Recovering table '{}' from staging after crash", name);
653
654            let staging = self.directory.open(&staging_name).await?;
655            let schema: Arc<ArrowSchema> = Arc::new(staging.schema().into());
656
657            let stream = staging
658                .scan()
659                .try_into_stream()
660                .await
661                .map_err(|e| anyhow!("Failed to query staging: {}", e))?;
662            let batches: Vec<RecordBatch> = stream
663                .try_collect()
664                .await
665                .map_err(|e| anyhow!("Failed to collect staging data: {}", e))?;
666
667            if batches.is_empty() {
668                self.create_empty_table(name, schema).await?;
669            } else {
670                self.create_table(name, batches).await?;
671            }
672
673            self.drop_table(&staging_name).await?;
674            log::info!("Successfully recovered table '{}' from staging", name);
675        }
676
677        Ok(())
678    }
679
680    // ========================
681    // Cache Management
682    // ========================
683
684    /// No-op, as before the lancedb removal.
685    ///
686    /// These only ever cleared the `lancedb::Table` cache, which was never
687    /// populated (a cached handle is version-pinned and would drop rows
688    /// committed later), so both calls were already no-ops. That is preserved
689    /// verbatim rather than quietly extended to `schema_cache`: changing what
690    /// an explicit invalidation does is a behavior change, not a port. Worth
691    /// revisiting — `schema_cache` is now the only cache, so a caller asking
692    /// to invalidate currently gets nothing — but as its own piece of work.
693    fn invalidate_cache(&self, _table_name: &str) {}
694
695    /// No-op — see [`Self::invalidate_cache`].
696    fn clear_cache(&self) {}
697
698    // ========================
699    // Metadata
700    // ========================
701
702    fn base_uri(&self) -> &str {
703        &self.base_uri
704    }
705
706    fn branching(&self) -> Option<Arc<dyn crate::backend::branching::ForkBranching>> {
707        Some(Arc::new(super::lance_branch::LanceBranching::new(
708            self.base_uri.clone(),
709        )))
710    }
711
712    // ========================
713    // Capability Checks
714    // ========================
715
716    fn supports_vector_search(&self) -> bool {
717        true
718    }
719
720    fn supports_full_text_search(&self) -> bool {
721        true
722    }
723
724    fn supports_scalar_index(&self) -> bool {
725        true
726    }
727
728    // ========================
729    // Optional Capabilities
730    // ========================
731
732    // async_trait rewrites the signature, so clippy's arg count doesn't trip the
733    // `too_many_arguments` lint here — use allow (expect would be unfulfilled).
734    #[allow(clippy::too_many_arguments)]
735    async fn vector_search(
736        &self,
737        table: &str,
738        column: &str,
739        query: &[f32],
740        k: usize,
741        metric: DistanceMetric,
742        filter: FilterExpr,
743        opts: VectorQueryOpts,
744    ) -> Result<Vec<RecordBatch>> {
745        let dataset = self.directory.open(table).await?;
746        let key = arrow_array::Float32Array::from(query.to_vec());
747        let mut scanner = dataset.scan();
748        scanner
749            .nearest(column, &key, k)
750            .map_err(|e| anyhow!("Failed to create vector search on '{}': {}", table, e))?;
751        // The metric is passed explicitly rather than left to the index's own:
752        // Lance uses it to decide whether an existing index is usable for this
753        // query at all (`scanner.rs:3577`), which is what lancedb's
754        // `.distance_type(..)` was doing.
755        scanner.distance_metric(distance_metric_of(metric));
756
757        if let Some(n) = opts.nprobes {
758            scanner.nprobes(n);
759        }
760        if let Some(r) = opts.refine_factor {
761            scanner.refine(r);
762        }
763        if let Some(ef) = opts.ef {
764            scanner.ef(ef);
765        }
766        if !filter.is_trivially_true() {
767            let sql = filter.to_sql()?;
768            // lancedb's `only_if` defaulted to prefilter (`query.rs:782`), so
769            // prefiltering here is exact parity — and it is also the correct
770            // semantic: postfiltering would let excluded rows consume top-k
771            // slots and shrink the result below k.
772            scanner.prefilter(true);
773            scanner
774                .filter(&sql)
775                .map_err(|e| anyhow!("Vector search filter '{}' on '{}': {}", sql, table, e))?;
776        }
777
778        scanner
779            .try_into_stream()
780            .await
781            .map_err(|e| anyhow!("Vector search execution failed on '{}': {}", table, e))?
782            .try_collect()
783            .await
784            .map_err(|e| {
785                anyhow!(
786                    "Failed to collect vector search results from '{}': {}",
787                    table,
788                    e
789                )
790            })
791    }
792
793    #[allow(clippy::too_many_arguments)]
794    async fn multivector_search(
795        &self,
796        table: &str,
797        column: &str,
798        query: &[Vec<f32>],
799        k: usize,
800        metric: DistanceMetric,
801        filter: FilterExpr,
802        opts: VectorQueryOpts,
803    ) -> Result<Vec<RecordBatch>> {
804        if query.is_empty() {
805            return Err(anyhow!("multivector_search on '{}': empty query", table));
806        }
807        let dataset = self.directory.open(table).await?;
808
809        // Late-interaction (MaxSim) query. lancedb expressed this as
810        // `vector_search(first)` then `add_query_vector(..)` per remaining
811        // token, which it accumulated into a list of query vectors. Lance's
812        // `nearest` takes that shape directly: a `ListArray` whose every
813        // element is one query vector of the column's dimension — it detects
814        // multivector from the array type and validates each entry's length
815        // against the column dim (`scanner.rs:1450-1472`).
816        let mut builder =
817            arrow_array::builder::ListBuilder::new(arrow_array::builder::Float32Builder::new());
818        for token in query {
819            builder.values().append_slice(token);
820            builder.append(true);
821        }
822        let key = builder.finish();
823
824        let mut scanner = dataset.scan();
825        scanner
826            .nearest(column, &key, k)
827            .map_err(|e| anyhow!("Failed to create multivector search on '{}': {}", table, e))?;
828        scanner.distance_metric(distance_metric_of(metric));
829
830        if let Some(n) = opts.nprobes {
831            scanner.nprobes(n);
832        }
833        if let Some(r) = opts.refine_factor {
834            scanner.refine(r);
835        }
836        if let Some(ef) = opts.ef {
837            scanner.ef(ef);
838        }
839        if !filter.is_trivially_true() {
840            let sql = filter.to_sql()?;
841            // Prefilter, as in `vector_search` — see the note there.
842            scanner.prefilter(true);
843            scanner.filter(&sql).map_err(|e| {
844                anyhow!("Multivector search filter '{}' on '{}': {}", sql, table, e)
845            })?;
846        }
847
848        scanner
849            .try_into_stream()
850            .await
851            .map_err(|e| anyhow!("Multivector search execution failed on '{}': {}", table, e))?
852            .try_collect()
853            .await
854            .map_err(|e| {
855                anyhow!(
856                    "Failed to collect multivector search results from '{}': {}",
857                    table,
858                    e
859                )
860            })
861    }
862
863    async fn full_text_search(
864        &self,
865        table: &str,
866        column: &str,
867        query: &str,
868        k: usize,
869        filter: FilterExpr,
870    ) -> Result<Vec<RecordBatch>> {
871        use lance_index::scalar::FullTextSearchQuery;
872        use lance_index::scalar::inverted::query::MatchQuery;
873
874        let dataset = self.directory.open(table).await?;
875
876        // These are `lance_index` types already — lancedb only forwarded them
877        // to the same scanner, so the query object is unchanged.
878        let match_query = MatchQuery::new(query.to_string()).with_column(Some(column.to_string()));
879        let fts_query = FullTextSearchQuery {
880            query: match_query.into(),
881            limit: Some(k as i64),
882            wand_factor: None,
883        };
884
885        let mut scanner = dataset.scan();
886        scanner
887            .full_text_search(fts_query)
888            .map_err(|e| anyhow!("FTS query on '{}': {}", table, e))?;
889        // `k` is applied both inside the FTS query and as a scan limit, as
890        // before — the inner bound caps the BM25 candidate set, the outer one
891        // the returned rows.
892        scanner
893            .limit(Some(k as i64), None)
894            .map_err(|e| anyhow!("FTS limit on '{}': {}", table, e))?;
895
896        if !filter.is_trivially_true() {
897            let sql = filter.to_sql()?;
898            scanner
899                .filter(&sql)
900                .map_err(|e| anyhow!("FTS filter '{}' on '{}': {}", sql, table, e))?;
901        }
902
903        scanner
904            .try_into_stream()
905            .await
906            .map_err(|e| anyhow!("FTS search execution failed on '{}': {}", table, e))?
907            .try_collect()
908            .await
909            .map_err(|e| anyhow!("Failed to collect FTS results from '{}': {}", table, e))
910    }
911
912    async fn create_vector_index(
913        &self,
914        table: &str,
915        column: &str,
916        name: &str,
917        params: VectorIndexParams,
918    ) -> Result<()> {
919        use lance::index::vector::VectorIndexParams as LanceVectorParams;
920        use lance_index::vector::hnsw::builder::HnswBuildParams;
921        use lance_index::vector::ivf::IvfBuildParams;
922        use lance_index::vector::pq::PQBuildParams;
923        use lance_index::vector::sq::builder::SQBuildParams;
924
925        let dt = match params.metric {
926            DistanceMetric::L2 => lance_linalg::distance::MetricType::L2,
927            DistanceMetric::Cosine => lance_linalg::distance::MetricType::Cosine,
928            DistanceMetric::Dot => lance_linalg::distance::MetricType::Dot,
929        };
930
931        // The stage params are built explicitly and fed to the `with_*_params`
932        // constructors rather than the positional shorthands (`ivf_pq(..)`),
933        // because the shorthands demand values lancedb never asked us for
934        // (e.g. `max_iterations`). Going through `..Default::default()` keeps
935        // whatever lancedb's builders were defaulting to.
936        let hnsw = |m: u32, ef_construction: u32| HnswBuildParams {
937            m: m as usize,
938            ef_construction: ef_construction as usize,
939            ..Default::default()
940        };
941
942        let lance_params = match params.kind {
943            // Flat is a single-partition IVF, matching the prior mapping.
944            VectorIndexKind::Flat => {
945                LanceVectorParams::with_ivf_flat_params(dt, IvfBuildParams::new(1))
946            }
947            VectorIndexKind::IvfFlat { num_partitions } => LanceVectorParams::with_ivf_flat_params(
948                dt,
949                IvfBuildParams::new(num_partitions as usize),
950            ),
951            VectorIndexKind::IvfPq {
952                num_partitions,
953                num_sub_vectors,
954                num_bits,
955            } => LanceVectorParams::with_ivf_pq_params(
956                dt,
957                IvfBuildParams::new(num_partitions as usize),
958                PQBuildParams {
959                    num_sub_vectors: num_sub_vectors as usize,
960                    num_bits: usize::from(num_bits),
961                    ..Default::default()
962                },
963            ),
964            VectorIndexKind::IvfSq { num_partitions } => LanceVectorParams::with_ivf_sq_params(
965                dt,
966                IvfBuildParams::new(num_partitions as usize),
967                SQBuildParams::default(),
968            ),
969            VectorIndexKind::IvfRq {
970                num_partitions,
971                num_bits,
972            } => LanceVectorParams::ivf_rq(
973                num_partitions as usize,
974                // `None` means "whatever the backend defaults to"; RaBitQ's
975                // canonical default is 8 bits, which is also what lancedb's
976                // `IvfRqIndexBuilder::default()` left in place.
977                num_bits.unwrap_or(8),
978                dt,
979            ),
980            VectorIndexKind::HnswFlat {
981                m,
982                ef_construction,
983                num_partitions,
984            } => LanceVectorParams::ivf_hnsw(
985                dt,
986                IvfBuildParams::new(num_partitions as usize),
987                hnsw(m, ef_construction),
988            ),
989            VectorIndexKind::HnswSq {
990                m,
991                ef_construction,
992                num_partitions,
993            } => LanceVectorParams::with_ivf_hnsw_sq_params(
994                dt,
995                IvfBuildParams::new(num_partitions as usize),
996                hnsw(m, ef_construction),
997                SQBuildParams::default(),
998            ),
999            VectorIndexKind::HnswPq {
1000                m,
1001                ef_construction,
1002                num_sub_vectors,
1003                num_partitions,
1004            } => LanceVectorParams::with_ivf_hnsw_pq_params(
1005                dt,
1006                IvfBuildParams::new(num_partitions as usize),
1007                hnsw(m, ef_construction),
1008                PQBuildParams {
1009                    num_sub_vectors: num_sub_vectors as usize,
1010                    // 8 bits matches the prior `PQBuildParams::new(_, 8)` default.
1011                    num_bits: 8,
1012                    ..Default::default()
1013                },
1014            ),
1015        };
1016
1017        let mut dataset = self.directory.open(table).await?;
1018        lance::index::DatasetIndexExt::create_index(
1019            &mut dataset,
1020            &[column],
1021            lance_index::IndexType::Vector,
1022            Some(name.to_string()),
1023            &lance_params,
1024            true,
1025        )
1026        .await
1027        // `create_index` hands back the new IndexMetadata; the trait returns unit.
1028        .map(|_| ())
1029        .map_err(|e| {
1030            anyhow!(
1031                "Failed to create vector index '{}' on '{}.{}': {}",
1032                name,
1033                table,
1034                column,
1035                e
1036            )
1037        })
1038    }
1039
1040    async fn create_scalar_index(
1041        &self,
1042        table: &str,
1043        columns: &[&str],
1044        index_type: ScalarIndexType,
1045        name: Option<&str>,
1046    ) -> Result<()> {
1047        // Lance discriminates the three scalar flavors by `BuiltinIndexType`
1048        // inside `ScalarIndexParams`, where lancedb used three distinct
1049        // `Index::*` variants.
1050        let builtin = match index_type {
1051            ScalarIndexType::BTree => lance_index::scalar::BuiltinIndexType::BTree,
1052            ScalarIndexType::Bitmap => lance_index::scalar::BuiltinIndexType::Bitmap,
1053            ScalarIndexType::LabelList => lance_index::scalar::BuiltinIndexType::LabelList,
1054        };
1055        let params = lance_index::scalar::ScalarIndexParams::for_builtin(builtin);
1056
1057        let mut dataset = self.directory.open(table).await?;
1058        lance::index::DatasetIndexExt::create_index(
1059            &mut dataset,
1060            columns,
1061            lance_index::IndexType::Scalar,
1062            name.map(str::to_string),
1063            &params,
1064            true,
1065        )
1066        .await
1067        // `create_index` hands back the new IndexMetadata; the trait returns unit.
1068        .map(|_| ())
1069        .map_err(|e| {
1070            anyhow!(
1071                "Failed to create {:?} index on '{}.{:?}': {}",
1072                index_type,
1073                table,
1074                columns,
1075                e
1076            )
1077        })
1078    }
1079
1080    async fn create_fts_index(
1081        &self,
1082        table: &str,
1083        columns: &[&str],
1084        name: Option<&str>,
1085        tokenizer: &TokenizerConfig,
1086        with_positions: bool,
1087    ) -> Result<()> {
1088        // Translate the requested analyzer pipeline into Lance params. A
1089        // config error (bad ngram bounds, unsupported stop-word language) is
1090        // surfaced here before we touch the table.
1091        //
1092        // `to_inverted_params` already returns `InvertedIndexParams`, which is
1093        // a `lance_index` type — lancedb only wrapped it in `Index::FTS`, so
1094        // this is the same params object reaching the same builder.
1095        let params =
1096            super::fts_analyzer::to_inverted_params(tokenizer, with_positions).map_err(|e| {
1097                anyhow!(
1098                    "invalid FTS tokenizer config for '{}.{:?}': {}",
1099                    table,
1100                    columns,
1101                    e
1102                )
1103            })?;
1104
1105        let mut dataset = self.directory.open(table).await?;
1106        lance::index::DatasetIndexExt::create_index(
1107            &mut dataset,
1108            columns,
1109            lance_index::IndexType::Inverted,
1110            name.map(str::to_string),
1111            &params,
1112            true,
1113        )
1114        .await
1115        // `create_index` hands back the new IndexMetadata; the trait returns unit.
1116        .map(|_| ())
1117        .map_err(|e| {
1118            // Custom tokenizers (`lindera/*`, `jieba/*`) need dictionary files
1119            // under `LANCE_LANGUAGE_MODEL_HOME`; make that failure legible.
1120            if matches!(tokenizer, TokenizerConfig::Custom { .. })
1121                || matches!(
1122                    tokenizer,
1123                    TokenizerConfig::Analyzer(a)
1124                        if matches!(&a.base, uni_common::core::schema::BaseTokenizer::Custom(_))
1125                )
1126            {
1127                anyhow!(
1128                    "Failed to create FTS index on '{}.{:?}' with custom tokenizer {:?}: {}. \
1129                     CJK/custom tokenizers require dictionary files under the directory named by \
1130                     the LANCE_LANGUAGE_MODEL_HOME environment variable (uni does not ship them).",
1131                    table,
1132                    columns,
1133                    tokenizer,
1134                    e
1135                )
1136            } else {
1137                anyhow!(
1138                    "Failed to create FTS index on '{}.{:?}': {}",
1139                    table,
1140                    columns,
1141                    e
1142                )
1143            }
1144        })
1145    }
1146
1147    async fn drop_index(&self, table: &str, index_name: &str) -> Result<()> {
1148        let mut dataset = self.directory.open(table).await?;
1149        lance::index::DatasetIndexExt::drop_index(&mut dataset, index_name)
1150            .await
1151            .map_err(|e| {
1152                anyhow!(
1153                    "Failed to drop index '{}' on '{}': {}",
1154                    index_name,
1155                    table,
1156                    e
1157                )
1158            })
1159    }
1160
1161    async fn list_indexes(&self, table: &str) -> Result<Vec<IndexInfo>> {
1162        let dataset = self.directory.open(table).await?;
1163        let indices = lance::index::DatasetIndexExt::load_indices(&dataset)
1164            .await
1165            .map_err(|e| anyhow!("Failed to list indexes on '{}': {}", table, e))?;
1166
1167        // `columns` is what callers actually use — all four production consumers
1168        // of this method filter on `idx.columns.contains(..)` and none reads
1169        // `index_type`. Lance's `IndexMetadata` carries field *ids* rather than
1170        // names, so resolve them through the dataset schema.
1171        let schema = dataset.schema();
1172        Ok(indices
1173            .iter()
1174            .map(|idx| IndexInfo {
1175                name: idx.name.clone(),
1176                columns: idx
1177                    .fields
1178                    .iter()
1179                    .filter_map(|fid| schema.field_by_id(*fid).map(|f| f.name.clone()))
1180                    .collect(),
1181                // Lance's `IndexMetadata` carries no index-type discriminant
1182                // (the type lives in the opaque `index_details` protobuf), so
1183                // this is reported as unknown rather than fabricated. Safe
1184                // because no consumer reads it — verified across all four
1185                // production callers of `list_indexes`, which filter on
1186                // `columns` alone. Populate it properly if that ever changes.
1187                index_type: String::from("unknown"),
1188            })
1189            .collect())
1190    }
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196    use arrow_array::{Int64Array, UInt64Array};
1197    use arrow_schema::{DataType, Field};
1198    use tempfile::TempDir;
1199
1200    async fn create_test_backend() -> (TempDir, LanceDbBackend) {
1201        let temp_dir = TempDir::new().unwrap();
1202        let uri = temp_dir.path().to_str().unwrap();
1203        let backend = LanceDbBackend::connect(uri, None).await.unwrap();
1204        (temp_dir, backend)
1205    }
1206
1207    fn test_schema() -> Arc<ArrowSchema> {
1208        Arc::new(ArrowSchema::new(vec![
1209            Field::new("id", DataType::UInt64, false),
1210            Field::new("value", DataType::Int64, false),
1211        ]))
1212    }
1213
1214    fn test_batch(ids: Vec<u64>, values: Vec<i64>) -> RecordBatch {
1215        RecordBatch::try_new(
1216            test_schema(),
1217            vec![
1218                Arc::new(UInt64Array::from(ids)),
1219                Arc::new(Int64Array::from(values)),
1220            ],
1221        )
1222        .unwrap()
1223    }
1224
1225    /// `LanceDirectory` reimplements lancedb's table listing and path layout
1226    /// from its source (`database/listing.rs:724,941`). Those are compatibility
1227    /// contracts, not APIs, so assert equivalence directly against lancedb
1228    /// rather than against a hand-written expectation: create tables through
1229    /// the lancedb path, then require both sides to agree.
1230    ///
1231    /// If lancedb ever changes its layout, this fails loudly instead of
1232    /// silently detaching primary reads from fork branch reads.
1233    #[tokio::test]
1234    async fn lance_directory_listing_matches_lancedb() {
1235        use crate::backend::lance_directory::LanceDirectory;
1236
1237        let (dir, backend) = create_test_backend().await;
1238        let uri = dir.path().to_str().unwrap();
1239        let directory = LanceDirectory::connect(uri, None).await.unwrap();
1240
1241        // A fresh directory: both must report no tables. `read_dir` on a
1242        // never-written base path must not be an error.
1243        assert!(directory.table_names().await.unwrap().is_empty());
1244
1245        // Names chosen to exercise sort order and uni's real naming scheme
1246        // (`vertices_{label}`, `adjacency_{type}_{dir}`), including an
1247        // underscore-heavy name and one that sorts before the others.
1248        for name in [
1249            "vertices_Person",
1250            "adjacency_KNOWS_fwd",
1251            "deltas_KNOWS_bwd",
1252            "vertices_Zebra",
1253        ] {
1254            backend
1255                .create_table(name, vec![test_batch(vec![1], vec![10])])
1256                .await
1257                .unwrap();
1258        }
1259
1260        let via_lancedb = backend.table_names().await.unwrap();
1261        let via_directory = directory.table_names().await.unwrap();
1262
1263        let mut expected = via_lancedb.clone();
1264        expected.sort();
1265        assert_eq!(
1266            via_directory, expected,
1267            "LanceDirectory listing diverged from lancedb's: {via_directory:?} vs {expected:?}"
1268        );
1269
1270        // Every listed name must resolve to an openable dataset — this is what
1271        // makes primary and the fork branch path agree on the layout.
1272        for name in &via_directory {
1273            directory.open(name).await.unwrap();
1274        }
1275    }
1276
1277    #[tokio::test]
1278    async fn lock_table_for_write_provides_mutual_exclusion() {
1279        // The MUVERA FDE backfill holds this guard across its scan→overwrite so a
1280        // concurrent flush append cannot interleave and be lost (issue #96). Prove the
1281        // guard actually serializes two holders of the same table name: a second
1282        // acquisition must not proceed while the first is held, and a different table
1283        // name must not block.
1284        use std::sync::Arc;
1285        use std::sync::atomic::{AtomicBool, Ordering};
1286
1287        let (_dir, backend) = create_test_backend().await;
1288        let backend = Arc::new(backend);
1289
1290        let held = backend.lock_table_for_write("vertices_Doc").await;
1291
1292        // A different table name is independent — acquiring it must not block.
1293        let other = tokio::time::timeout(
1294            std::time::Duration::from_secs(1),
1295            backend.lock_table_for_write("vertices_Other"),
1296        )
1297        .await;
1298        assert!(
1299            other.is_ok(),
1300            "a different table's lock must be independent"
1301        );
1302        drop(other);
1303
1304        // A second acquisition of the SAME name must block until the first is dropped.
1305        let entered = Arc::new(AtomicBool::new(false));
1306        let b2 = Arc::clone(&backend);
1307        let e2 = Arc::clone(&entered);
1308        let waiter = tokio::spawn(async move {
1309            let _g = b2.lock_table_for_write("vertices_Doc").await;
1310            e2.store(true, Ordering::SeqCst);
1311        });
1312
1313        // While we hold the guard, the waiter must not have acquired it.
1314        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1315        assert!(
1316            !entered.load(Ordering::SeqCst),
1317            "second holder acquired the same-name lock while it was still held"
1318        );
1319
1320        drop(held);
1321        // Now the waiter can proceed.
1322        tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
1323            .await
1324            .expect("waiter did not acquire the lock after release")
1325            .unwrap();
1326        assert!(entered.load(Ordering::SeqCst));
1327    }
1328
1329    #[tokio::test]
1330    async fn test_table_lifecycle() {
1331        let (_dir, backend) = create_test_backend().await;
1332
1333        // Create empty table
1334        backend
1335            .create_empty_table("test", test_schema())
1336            .await
1337            .unwrap();
1338        assert!(backend.table_exists("test").await.unwrap());
1339
1340        let names = backend.table_names().await.unwrap();
1341        assert!(names.contains(&"test".to_string()));
1342
1343        // Drop table
1344        backend.drop_table("test").await.unwrap();
1345        assert!(!backend.table_exists("test").await.unwrap());
1346    }
1347
1348    #[tokio::test]
1349    async fn test_scan_with_filter() {
1350        let (_dir, backend) = create_test_backend().await;
1351
1352        backend
1353            .create_table("test", vec![test_batch(vec![1, 2, 3], vec![100, 200, 300])])
1354            .await
1355            .unwrap();
1356
1357        // Scan all
1358        let batches = backend.scan(ScanRequest::all("test")).await.unwrap();
1359        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1360        assert_eq!(total, 3);
1361
1362        // Scan with filter
1363        let batches = backend
1364            .scan(ScanRequest::all("test").with_filter(FilterExpr::compare(
1365                "id",
1366                CmpOp::Gt,
1367                Scalar::Int(1),
1368            )))
1369            .await
1370            .unwrap();
1371        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1372        assert_eq!(total, 2);
1373    }
1374
1375    /// Fail-closed contract (review C1): a scan against an existing table that
1376    /// errors (here: an unparsable SQL filter) must surface as `Err`, never be
1377    /// silently masked into `Ok(vec![])` — otherwise the MERGE existence-check
1378    /// would read "no rows" and create a duplicate. A scan against a table that
1379    /// simply doesn't exist still legitimately returns an empty result.
1380    #[tokio::test]
1381    async fn test_scan_propagates_errors_but_tolerates_missing_table() {
1382        let (_dir, backend) = create_test_backend().await;
1383
1384        // Missing table → empty, not an error.
1385        let batches = backend.scan(ScanRequest::all("never_created")).await;
1386        assert!(
1387            matches!(batches, Ok(ref b) if b.is_empty()),
1388            "scan of a non-existent table must be Ok(empty), got {batches:?}"
1389        );
1390
1391        backend
1392            .create_table("test", vec![test_batch(vec![1, 2, 3], vec![100, 200, 300])])
1393            .await
1394            .unwrap();
1395
1396        // A real scan failure on an existing table (unparsable filter referencing
1397        // a non-existent column) must propagate as Err, not collapse to empty.
1398        let result = backend
1399            .scan(
1400                ScanRequest::all("test")
1401                    .with_filter(FilterExpr::equals("no_such_column", Scalar::Int(1))),
1402            )
1403            .await;
1404        assert!(
1405            result.is_err(),
1406            "a scan failure on an existing table must propagate as Err, got Ok"
1407        );
1408    }
1409
1410    #[tokio::test]
1411    async fn test_write_append_and_overwrite() {
1412        let (_dir, backend) = create_test_backend().await;
1413
1414        backend
1415            .create_table("test", vec![test_batch(vec![1, 2], vec![100, 200])])
1416            .await
1417            .unwrap();
1418        assert_eq!(backend.count_rows("test", None).await.unwrap(), 2);
1419
1420        // Append
1421        backend
1422            .write(
1423                "test",
1424                vec![test_batch(vec![3], vec![300])],
1425                WriteMode::Append,
1426            )
1427            .await
1428            .unwrap();
1429        assert_eq!(backend.count_rows("test", None).await.unwrap(), 3);
1430
1431        // Overwrite
1432        backend
1433            .write(
1434                "test",
1435                vec![test_batch(vec![10], vec![1000])],
1436                WriteMode::Overwrite,
1437            )
1438            .await
1439            .unwrap();
1440        assert_eq!(backend.count_rows("test", None).await.unwrap(), 1);
1441    }
1442
1443    #[tokio::test]
1444    async fn test_replace_table_atomic() {
1445        let (_dir, backend) = create_test_backend().await;
1446
1447        backend
1448            .create_table("test", vec![test_batch(vec![1, 2, 3], vec![100, 200, 300])])
1449            .await
1450            .unwrap();
1451
1452        // Replace with new data
1453        backend
1454            .replace_table_atomic(
1455                "test",
1456                vec![test_batch(vec![4, 5], vec![400, 500])],
1457                test_schema(),
1458            )
1459            .await
1460            .unwrap();
1461        assert_eq!(backend.count_rows("test", None).await.unwrap(), 2);
1462    }
1463
1464    #[tokio::test]
1465    async fn test_version_and_rollback() {
1466        let (_dir, backend) = create_test_backend().await;
1467
1468        backend
1469            .create_table("test", vec![test_batch(vec![1], vec![100])])
1470            .await
1471            .unwrap();
1472
1473        let v1 = backend.get_table_version("test").await.unwrap().unwrap();
1474        assert!(v1 > 0);
1475
1476        // Append to create a new version
1477        backend
1478            .write(
1479                "test",
1480                vec![test_batch(vec![2], vec![200])],
1481                WriteMode::Append,
1482            )
1483            .await
1484            .unwrap();
1485        assert_eq!(backend.count_rows("test", None).await.unwrap(), 2);
1486
1487        // Rollback to v1
1488        backend.rollback_table("test", v1).await.unwrap();
1489        assert_eq!(backend.count_rows("test", None).await.unwrap(), 1);
1490    }
1491
1492    #[tokio::test]
1493    async fn test_recover_staging() {
1494        let (_dir, backend) = create_test_backend().await;
1495
1496        // No staging table — should be a no-op
1497        backend.recover_staging("test").await.unwrap();
1498        assert!(!backend.table_exists("test").await.unwrap());
1499    }
1500
1501    #[tokio::test]
1502    async fn test_get_table_schema() {
1503        let (_dir, backend) = create_test_backend().await;
1504
1505        // Non-existent table
1506        assert!(backend.get_table_schema("missing").await.unwrap().is_none());
1507
1508        // Create table and check schema
1509        backend
1510            .create_empty_table("test", test_schema())
1511            .await
1512            .unwrap();
1513        let schema = backend.get_table_schema("test").await.unwrap().unwrap();
1514        assert_eq!(schema.fields().len(), 2);
1515    }
1516
1517    #[tokio::test]
1518    async fn test_cache_invalidation() {
1519        // The `table_cache` was removed for async-flush correctness
1520        // (see `get_or_open_table` doc comment). `invalidate_cache`
1521        // and `clear_cache` are still public on the backend trait but
1522        // are no-ops on `table_cache` now (they retain the legacy
1523        // signature for callers). This test now just exercises that
1524        // scan-then-invalidate doesn't error out.
1525        let (_dir, backend) = create_test_backend().await;
1526
1527        backend
1528            .create_table("test", vec![test_batch(vec![1], vec![100])])
1529            .await
1530            .unwrap();
1531
1532        let _ = backend.scan(ScanRequest::all("test")).await.unwrap();
1533        backend.invalidate_cache("test"); // no-op now, just check it doesn't panic
1534        let _ = backend.scan(ScanRequest::all("test")).await.unwrap();
1535        backend.clear_cache();
1536        let _ = backend.scan(ScanRequest::all("test")).await.unwrap();
1537    }
1538}