From 1d75638deaf2d79e8ab17e036fb63e423b1909ed Mon Sep 17 00:00:00 2001 From: Yang Cen Date: Thu, 13 Aug 2026 21:22:42 +0800 Subject: [PATCH] fix: make table existence manifest-authoritative (#3919) ## What is the bug? #3731 tries to distinguish a missing table from a corrupt table after Lance returns `DatasetNotFound`. It does that by listing the database parent and treating a physical `.lance` entry as evidence that the table exists. That premise is not sound for a listing database. Table creation writes data before atomically committing the first manifest, so the same physical prefix can represent a live concurrent create, abandoned uncommitted data, or an old empty directory. It is not evidence of a committed table. The parent listing also makes every missing-table open, including the create-on-miss path, perform work proportional to the number of sibling tables. Cloud `list_with_delimiter` exhausts all pages before returning. ## How does this PR fix the problem? This PR makes the committed Lance manifest the sole table-existence authority for listing-database opens: - `DatasetNotFound` maps directly to `TableNotFound`; no parent or target storage probe runs. - Other Lance load errors continue to propagate unchanged. - A physical directory, object prefix, or uncommitted data file alone does not block `Create`. - Concurrent `Create` requests are arbitrated by the conditional version-1 manifest commit: one succeeds and the loser receives `TableAlreadyExists`. - `table_names` is documented as physical discovery, not an atomic table-existence check. Its snapshot can contain an entry that is still being created, has only uncommitted storage, or is concurrently dropped. This removes the need for a new Lance object-store capability. LanceDB remains on the official Lance `v11.0.0-beta.6` dependency from `main`; the merge commit for lance-format/lance#7722 is an ancestor of that tag, so the ambiguous-GCS-500 corruption-prevention fix is retained. ## Performance evidence Lower is better. The benchmark uses real `.lance` directories with marker objects on the local filesystem; fixture creation and teardown are outside the timed region. Baseline is `origin/main` at `6fb976cf`, candidate is `e1240751`. Both were built from the same lockfile on the same macOS arm64 machine with the repository's `release` profile (fat LTO), then executed in alternating baseline/candidate order for three pairs. Each run used 10 warmups and 100 distinct missing-table opens per scale. The table reports the median of the three run-level percentiles. | Scenario / metric | Baseline | This PR | Benefit | | --- | ---: | ---: | ---: | | 1,000 real sibling directories, p50 | 11.905 ms | 21.042 us | 566x speedup | | 10,000 real sibling directories, p50 | 143.630 ms | 18.375 us | 7,817x speedup | | 100,000 real sibling directories, p50 | 1.991 s | 19.917 us | 99,984x speedup | | 100,000 real sibling directories, p95 | 2.346 s | 25.792 us | 90,965x speedup | These results validate removal of the sibling-cardinality dependency in this local-filesystem workload; they are not an extrapolation to production GCS latency. A structural object-store regression test separately asserts that opening one missing table performs zero parent-scoped `list`, `list_with_offset`, or `list_with_delimiter` calls. Run with: ```bash BENCH_SIBLINGS=1000,10000,100000 BENCH_WARMUPS=10 BENCH_TRIALS=100 \ cargo run --locked --release --quiet -p lancedb --example bench_open_missing_table ``` ## Correctness and compatibility boundaries - An empty `.lance` directory or orphan data without a committed manifest now opens as `TableNotFound` and may be replaced by a successful `Create`. - Two synchronized creators sharing one object store deterministically produce one success and one conditional-manifest conflict mapped to `TableAlreadyExists`. - A readable manifest remains authoritative; non-`DatasetNotFound` corruption, external-manifest, authorization, and object-store errors are not folded into `TableNotFound`. - `TableCorrupted` remains in the public error enum for compatibility, but this listing-database fallback no longer synthesizes it from an ambiguous physical footprint. - Reliably distinguishing `Missing`, `Creating`, and `Corrupt` would require explicit authoritative lifecycle/catalog metadata (for example a leased creation record). It cannot be inferred from a directory or prefix, and is outside this incident fix. ## Validation - `cargo fmt --all -- --check` - `cargo check --quiet --locked -p lancedb --features remote --tests --examples` - `cargo clippy --quiet --locked -p lancedb --features remote --tests --examples -- -D warnings` - `cargo test --quiet --locked -p lancedb --features remote --tests` - library: 843 passed, 1 ignored - integration groups: 39 passed, 6 passed, 5 passed - focused coverage for empty directories, orphan data, physical listing snapshots, zero parent listings, and concurrent manifest arbitration --- rust/lancedb/Cargo.toml | 3 + .../examples/bench_open_missing_table.rs | 150 +++++++++ rust/lancedb/src/connection.rs | 12 +- rust/lancedb/src/database/listing.rs | 117 ++++++- rust/lancedb/src/table.rs | 299 ++++++++++++------ 5 files changed, 473 insertions(+), 108 deletions(-) create mode 100644 rust/lancedb/examples/bench_open_missing_table.rs diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index e33b86b12..23dc86e15 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -188,6 +188,9 @@ required-features = ["bedrock"] [[example]] name = "bench_streaming_dataloader" +[[example]] +name = "bench_open_missing_table" + [[example]] name = "simple" diff --git a/rust/lancedb/examples/bench_open_missing_table.rs b/rust/lancedb/examples/bench_open_missing_table.rs new file mode 100644 index 000000000..8e6b16e11 --- /dev/null +++ b/rust/lancedb/examples/bench_open_missing_table.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +// Release benchmark for opening a missing table as sibling-table cardinality grows. +// +// The fixture uses real `.lance` directories and marker files. Fixture creation is +// outside the timed section. Defaults intentionally cover 1k, 10k, and 100k siblings +// with 10 warmups and 100 distinct missing-table opens per scale: +// +// ```text +// cargo run --release -p lancedb --example bench_open_missing_table +// ``` +// +// `BENCH_SIBLINGS`, `BENCH_WARMUPS`, and `BENCH_TRIALS` override those defaults. +// Reduced settings are useful only as a smoke test. Performance comparisons require +// the same machine, filesystem, fixture sizes, settings, lockfile, and alternating +// baseline/candidate execution order. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use lancedb::connection::Connection; +use lancedb::{Error, connect}; +use object_store::ObjectStoreExt as _; +use object_store::path::Path; + +const MAX_SIBLINGS: usize = 1_000_000; +const MAX_WARMUPS: usize = 10_000; +const MAX_TRIALS: usize = 100_000; + +fn env_usize(key: &str, default: usize, max: usize) -> Result { + let value = match std::env::var(key) { + Ok(value) => value + .parse() + .with_context(|| format!("invalid {key} value: {value}"))?, + Err(std::env::VarError::NotPresent) => default, + Err(error) => return Err(error).with_context(|| format!("reading {key}")), + }; + if value == 0 || value > max { + bail!("{key} must be between 1 and {max}"); + } + Ok(value) +} + +fn sibling_counts() -> Result> { + let raw = std::env::var("BENCH_SIBLINGS").unwrap_or_else(|_| "1000,10000,100000".into()); + let mut counts = raw + .split(',') + .map(|value| { + value + .trim() + .parse::() + .with_context(|| format!("invalid BENCH_SIBLINGS value: {value}")) + }) + .collect::>>()?; + counts.sort_unstable(); + counts.dedup(); + if counts.is_empty() || counts[0] == 0 || counts[counts.len() - 1] > MAX_SIBLINGS { + bail!("BENCH_SIBLINGS values must be between 1 and {MAX_SIBLINGS}"); + } + Ok(counts) +} + +async fn add_siblings( + store: &object_store::local::LocalFileSystem, + start: usize, + end: usize, +) -> Result<()> { + for index in start..end { + let marker = Path::from(format!("sibling_{index:06}.lance/_marker")); + store + .put(&marker, bytes::Bytes::new().into()) + .await + .with_context(|| format!("creating benchmark marker {marker}"))?; + } + Ok(()) +} + +async fn time_missing_open(db: &Connection, name: &str) -> Result { + let started = Instant::now(); + let result = db.open_table(name).execute().await; + let elapsed = started.elapsed(); + match result { + Err(Error::TableNotFound { .. }) => Ok(elapsed), + Err(error) => bail!("expected TableNotFound for {name}, got {error:?}"), + Ok(_) => bail!("benchmark missing-table name unexpectedly exists: {name}"), + } +} + +fn percentile(sorted: &[Duration], percentile: usize) -> Duration { + let rank = (sorted.len() * percentile).div_ceil(100).saturating_sub(1); + sorted[rank] +} + +#[tokio::main] +async fn main() -> Result<()> { + let counts = sibling_counts()?; + let warmups = env_usize("BENCH_WARMUPS", 10, MAX_WARMUPS)?; + let trials = env_usize("BENCH_TRIALS", 100, MAX_TRIALS)?; + + let fixture = tempfile::tempdir().context("creating benchmark fixture")?; + let database_path = fixture.path(); + let fixture_store = object_store::local::LocalFileSystem::new_with_prefix(database_path) + .context("creating benchmark object store")?; + let db = connect(database_path.to_str().context("non-UTF-8 fixture path")?) + .execute() + .await?; + + println!( + "config: siblings={counts:?} warmups={warmups} trials={trials} profile={} os={} arch={}", + if cfg!(debug_assertions) { + "debug" + } else { + "release" + }, + std::env::consts::OS, + std::env::consts::ARCH, + ); + println!("lower is better; fixture setup and teardown are excluded"); + println!("| siblings | samples | p50 | p95 | max |"); + println!("| ---: | ---: | ---: | ---: | ---: |"); + + let mut created = 0; + for sibling_count in counts { + add_siblings(&fixture_store, created, sibling_count).await?; + created = sibling_count; + + for index in 0..warmups { + let name = format!("__missing_warmup_{sibling_count}_{index}"); + let _ = time_missing_open(&db, &name).await?; + } + + let mut samples = Vec::with_capacity(trials); + for index in 0..trials { + let name = format!("__missing_trial_{sibling_count}_{index}"); + samples.push(time_missing_open(&db, &name).await?); + } + samples.sort_unstable(); + + println!( + "| {sibling_count} | {} | {:?} | {:?} | {:?} |", + samples.len(), + percentile(&samples, 50), + percentile(&samples, 95), + samples[samples.len() - 1], + ); + } + + Ok(()) +} diff --git a/rust/lancedb/src/connection.rs b/rust/lancedb/src/connection.rs index dd53a2d2e..1f2708d4e 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -409,6 +409,11 @@ impl Connection { /// /// The names will be returned in lexicographical order (ascending) /// + /// Listing databases discover physical `*.lance` entries without opening every + /// dataset. The result is a point-in-time discovery snapshot: an entry may still be + /// under creation, may contain only uncommitted storage, or may be concurrently + /// dropped before it is opened. + /// /// The parameters `page_token` and `limit` can be used to paginate the results pub fn table_names(&self) -> TableNamesBuilder { TableNamesBuilder::new(self.internal.clone()) @@ -456,10 +461,9 @@ impl Connection { /// /// # Returns /// Created [`TableRef`], or [`Error::TableNotFound`] if the table does not exist. - /// If the table's storage is present but holds no readable dataset (for example a - /// `.lance` directory left behind by an interrupted drop and re-create, which - /// [`Self::table_names`] still lists) this returns [`Error::TableCorrupted`] - /// instead. + /// On listing databases, a committed Lance manifest is authoritative for table + /// existence. Uncommitted files or a physical `.lance` directory alone do not + /// make a table openable. pub fn open_table(&self, name: impl Into) -> OpenTableBuilder { OpenTableBuilder::new( self.internal.clone(), diff --git a/rust/lancedb/src/database/listing.rs b/rust/lancedb/src/database/listing.rs index 0ab3614e7..f284320c6 100644 --- a/rust/lancedb/src/database/listing.rs +++ b/rust/lancedb/src/database/listing.rs @@ -1291,16 +1291,21 @@ impl Database for ListingDatabase { mod tests { use super::*; use crate::Table; + use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream}; use crate::connection::ConnectRequest; use crate::data::scannable::Scannable; use crate::database::{CreateTableMode, CreateTableRequest}; use crate::query::QueryRequest; use crate::table::{AnyQuery, WriteOptions}; use arrow_array::{Int32Array, RecordBatch, StringArray}; - use arrow_schema::{DataType, Field, Schema}; - use futures::TryStreamExt; + use arrow_schema::{DataType, Field, Schema, SchemaRef}; + use futures::{TryStreamExt, stream::once}; use std::path::PathBuf; + use std::sync::Arc; + use std::time::Duration; use tempfile::tempdir; + use tokio::sync::Barrier; + use tokio::time::timeout; async fn setup_database() -> (tempfile::TempDir, ListingDatabase) { let tempdir = tempdir().unwrap(); @@ -1324,6 +1329,114 @@ mod tests { (tempdir, db) } + struct BarrierScannable { + batch: RecordBatch, + barrier: Arc, + } + + impl Scannable for BarrierScannable { + fn schema(&self) -> SchemaRef { + self.batch.schema() + } + + fn scan_as_stream(&mut self) -> SendableRecordBatchStream { + let batch = self.batch.clone(); + let schema = batch.schema(); + let barrier = self.barrier.clone(); + Box::pin(SimpleRecordBatchStream { + schema, + stream: once(async move { + barrier.wait().await; + Ok(batch) + }), + }) + } + } + + fn create_request(name: &str, data: Box) -> CreateTableRequest { + CreateTableRequest { + name: name.to_string(), + namespace_path: vec![], + data, + mode: CreateTableMode::Create, + write_options: Default::default(), + location: None, + namespace_client: None, + } + } + + #[tokio::test] + async fn test_create_ignores_uncommitted_storage_without_manifest() { + let (tmp_dir, db) = setup_database().await; + let data_dir = tmp_dir.path().join("test.lance/data"); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(data_dir.join("orphan.lance"), b"uncommitted").unwrap(); + + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + let batch = + RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1]))]).unwrap(); + + let table = db + .create_table(create_request("test", Box::new(batch))) + .await + .unwrap(); + assert_eq!(table.count_rows(None).await.unwrap(), 1); + } + + #[tokio::test] + async fn test_concurrent_create_is_arbitrated_by_manifest_commit() { + let uri = format!("memory:///concurrent-create-{}", uuid::Uuid::new_v4()); + let db = crate::connect(&uri).execute().await.unwrap(); + let store: Arc = + Arc::new(object_store::memory::InMemory::new()); + let table_url = url::Url::parse("memory:///database/test.lance").unwrap(); + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + let batch = + RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1]))]).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + + #[allow(deprecated)] + let request = |batch, barrier| { + let mut request = create_request("test", Box::new(BarrierScannable { batch, barrier })); + request.write_options = WriteOptions { + lance_write_params: Some(lance::dataset::WriteParams { + store_params: Some(ObjectStoreParams { + object_store: Some((store.clone(), table_url.clone())), + ..Default::default() + }), + commit_handler: Some(Arc::new( + lance_table::io::commit::ConditionalPutCommitHandler, + )), + ..Default::default() + }), + }; + request + }; + + let left = db + .database() + .create_table(request(batch.clone(), barrier.clone())); + let right = db.database().create_table(request(batch, barrier)); + let (left, right) = timeout(Duration::from_secs(30), async { tokio::join!(left, right) }) + .await + .expect("concurrent creates deadlocked"); + + let results = [left, right]; + assert_eq!( + results.iter().filter(|result| result.is_ok()).count(), + 1, + "expected one successful create, got {results:?}" + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(Error::TableAlreadyExists { .. }))) + .count(), + 1, + "expected one manifest conflict, got {results:?}" + ); + } + #[tokio::test] async fn test_listing_database_root_ops_do_not_create_manifest() { let tempdir = tempdir().unwrap(); diff --git a/rust/lancedb/src/table.rs b/rust/lancedb/src/table.rs index d03ac823f..32b6bcebc 100644 --- a/rust/lancedb/src/table.rs +++ b/rust/lancedb/src/table.rs @@ -50,7 +50,6 @@ use crate::DistanceType; use crate::blob::BlobRangeRequest; use crate::data::scannable::{PeekedScannable, Scannable, estimate_write_partitions}; use crate::database::Database; -use crate::database::listing::LANCE_FILE_EXTENSION; use crate::database::read_freshness::TableFreshness; use crate::embeddings::{EmbeddingDefinition, EmbeddingRegistry, MemoryRegistry}; use crate::error::{Error, Result}; @@ -152,55 +151,6 @@ pub(crate) fn map_namespace_lance_error(err: lance::Error, table_name: &str) -> } } -/// Map a `lance::Error::DatasetNotFound` for the table at `uri` into a `lancedb::Error`. -/// -/// Lance reports "there is nothing at this location" and "there is a table directory -/// here but nothing loadable inside it" with the same error. Only the first is a -/// `TableNotFound`: a `.lance` directory left behind by an interrupted drop and -/// re-create is still reported by `Connection::table_names`, so callers need to be able -/// to tell "never existed" from "exists but is broken". -/// -/// See . -async fn map_dataset_not_found( - uri: &str, - name: &str, - params: ReadParams, - err: lance::Error, -) -> Error { - let name = name.to_string(); - let source = Box::new(err); - if table_dir_exists(uri, params).await.unwrap_or(false) { - Error::TableCorrupted { name, source } - } else { - Error::TableNotFound { name, source } - } -} - -/// Whether a table directory is present at `uri`, even though no dataset could be -/// loaded from it. -/// -/// This looks for a `.lance` entry in the parent directory, which is exactly what -/// `ListingDatabase::table_names` lists, so the two APIs agree on whether a table is -/// present. Probing `uri` itself would not work: object stores have no empty -/// directories to probe, and on a local filesystem the interesting case is precisely an -/// empty directory. -async fn table_dir_exists(uri: &str, params: ReadParams) -> Result { - let (object_store, path, _) = DatasetBuilder::from_uri(uri) - .with_read_params(params) - .build_object_store() - .await?; - // Only `*.lance` entries are ever reported as tables, so nothing else can produce - // the list-then-open mismatch this guards against. - if path.extension() != Some(LANCE_FILE_EXTENSION) { - return Ok(false); - } - let (Some(parent), Some(dir_name)) = (path.parent(), path.filename()) else { - return Ok(false); - }; - let entries = object_store.read_dir(parent).await?; - Ok(entries.iter().any(|entry| entry.as_str() == dir_name)) -} - /// Defines the type of column #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ColumnKind { @@ -2420,8 +2370,6 @@ impl NativeTable { None => false, }; - // Kept so that a `DatasetNotFound` can be re-checked against storage below. - let recovery_params = params.clone(); let mut builder = DatasetBuilder::from_uri(uri).with_read_params(params); // Set up commit handler when managed_versioning is enabled @@ -2440,7 +2388,12 @@ impl NativeTable { let dataset = match builder.load().await { Ok(dataset) => dataset, Err(e @ lance::Error::DatasetNotFound { .. }) => { - return Err(map_dataset_not_found(uri, name, recovery_params, e).await); + // The manifest load is the existence check. A physical prefix may be + // from a concurrent or abandoned create, so it cannot refine this error. + return Err(Error::TableNotFound { + name: name.to_string(), + source: Box::new(e), + }); } Err(e) => return Err(e.into()), }; @@ -3708,7 +3661,7 @@ pub struct FragmentSummaryStats { #[allow(deprecated)] mod tests { use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::Duration; use arrow_array::{ @@ -3790,73 +3743,50 @@ mod tests { ); } - /// Write a table and then break it, leaving the `.lance` directory in place. - /// - /// `remove_all` reproduces an interrupted drop + re-create (the directory is left - /// empty); otherwise only the manifests are removed, leaving the data files behind. - async fn write_then_corrupt_table(dir: &std::path::Path, remove_all: bool) -> String { - let dataset_path = dir.join("test.lance"); - let uri = dataset_path.to_str().unwrap().to_string(); - - let batch = make_test_batches(); - let reader = RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()); - Dataset::write(reader, &uri, None).await.unwrap(); - - if remove_all { - for entry in std::fs::read_dir(&dataset_path).unwrap() { - let entry = entry.unwrap(); - if entry.file_type().unwrap().is_dir() { - std::fs::remove_dir_all(entry.path()).unwrap(); - } else { - std::fs::remove_file(entry.path()).unwrap(); - } - } - assert_eq!(std::fs::read_dir(&dataset_path).unwrap().count(), 0); - } else { - let versions = dataset_path.join("_versions"); - assert!(versions.is_dir(), "expected manifests under {versions:?}"); - std::fs::remove_dir_all(&versions).unwrap(); - assert!(std::fs::read_dir(&dataset_path).unwrap().count() > 0); - } - - uri - } - #[tokio::test] - async fn test_open_corrupt_empty_dir() { + async fn test_open_not_found_when_empty_directory_exists() { let tmp_dir = tempdir().unwrap(); - let uri = write_then_corrupt_table(tmp_dir.path(), true).await; + let dataset_path = tmp_dir.path().join("test.lance"); + std::fs::create_dir(&dataset_path).unwrap(); - let err = NativeTable::open(&uri).await.unwrap_err(); + let err = NativeTable::open(dataset_path.to_str().unwrap()) + .await + .unwrap_err(); assert!( - matches!(&err, Error::TableCorrupted { name, .. } if name == "test"), + matches!(&err, Error::TableNotFound { name, .. } if name == "test"), "got {err:?}" ); } #[tokio::test] - async fn test_open_corrupt_missing_manifest() { + async fn test_open_not_found_when_only_uncommitted_storage_exists() { let tmp_dir = tempdir().unwrap(); - let uri = write_then_corrupt_table(tmp_dir.path(), false).await; + let dataset_path = tmp_dir.path().join("test.lance"); + let data_dir = dataset_path.join("data"); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(data_dir.join("orphan.lance"), b"uncommitted").unwrap(); - let err = NativeTable::open(&uri).await.unwrap_err(); + let err = NativeTable::open(dataset_path.to_str().unwrap()) + .await + .unwrap_err(); assert!( - matches!(&err, Error::TableCorrupted { name, .. } if name == "test"), + matches!(&err, Error::TableNotFound { name, .. } if name == "test"), "got {err:?}" ); } - /// A table listed by `table_names()` must not be reported as missing by - /// `open_table()`. See . + /// Listing databases discover physical `*.lance` entries. That snapshot is not an + /// authoritative table-existence check: only a committed manifest makes a table + /// openable, and the entry could also be concurrently created or dropped. #[tokio::test] - async fn test_open_table_corrupt_is_still_listed() { + async fn test_table_names_may_include_uncommitted_storage() { let tmp_dir = tempdir().unwrap(); let db = connect(tmp_dir.path().to_str().unwrap()) .execute() .await .unwrap(); - write_then_corrupt_table(tmp_dir.path(), true).await; + std::fs::create_dir(tmp_dir.path().join("test.lance")).unwrap(); assert_eq!( db.table_names().execute().await.unwrap(), @@ -3864,12 +3794,177 @@ mod tests { ); let err = db.open_table("test").execute().await.unwrap_err(); assert!( - matches!(&err, Error::TableCorrupted { name, .. } if name == "test"), + matches!(&err, Error::TableNotFound { name, .. } if name == "test"), + "physical storage without a committed manifest is not a table: {err:?}" + ); + } + + #[derive(Debug)] + struct ParentListGuardStore { + inner: Arc, + parent: object_store::path::Path, + parent_list_calls: Arc, + } + + impl std::fmt::Display for ParentListGuardStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ParentListGuardStore") + } + } + + #[async_trait::async_trait] + #[deny(clippy::missing_trait_methods)] + impl object_store::ObjectStore for ParentListGuardStore { + async fn put_opts( + &self, + location: &object_store::path::Path, + payload: object_store::PutPayload, + opts: object_store::PutOptions, + ) -> object_store::Result { + self.inner.put_opts(location, payload, opts).await + } + + async fn put_multipart_opts( + &self, + location: &object_store::path::Path, + opts: object_store::PutMultipartOptions, + ) -> object_store::Result> { + self.inner.put_multipart_opts(location, opts).await + } + + async fn get_opts( + &self, + location: &object_store::path::Path, + options: object_store::GetOptions, + ) -> object_store::Result { + self.inner.get_opts(location, options).await + } + + async fn get_ranges( + &self, + location: &object_store::path::Path, + ranges: &[std::ops::Range], + ) -> object_store::Result> { + self.inner.get_ranges(location, ranges).await + } + + fn delete_stream( + &self, + locations: futures::stream::BoxStream< + 'static, + object_store::Result, + >, + ) -> futures::stream::BoxStream<'static, object_store::Result> + { + self.inner.delete_stream(locations) + } + + fn list( + &self, + prefix: Option<&object_store::path::Path>, + ) -> futures::stream::BoxStream<'static, object_store::Result> + { + if prefix == Some(&self.parent) { + self.parent_list_calls.fetch_add(1, Ordering::Relaxed); + } + self.inner.list(prefix) + } + + fn list_with_offset( + &self, + prefix: Option<&object_store::path::Path>, + offset: &object_store::path::Path, + ) -> futures::stream::BoxStream<'static, object_store::Result> + { + if prefix == Some(&self.parent) { + self.parent_list_calls.fetch_add(1, Ordering::Relaxed); + } + self.inner.list_with_offset(prefix, offset) + } + + async fn list_with_delimiter( + &self, + prefix: Option<&object_store::path::Path>, + ) -> object_store::Result { + if prefix == Some(&self.parent) { + self.parent_list_calls.fetch_add(1, Ordering::Relaxed); + } + self.inner.list_with_delimiter(prefix).await + } + + async fn copy_opts( + &self, + from: &object_store::path::Path, + to: &object_store::path::Path, + options: object_store::CopyOptions, + ) -> object_store::Result<()> { + self.inner.copy_opts(from, to, options).await + } + + async fn rename_opts( + &self, + from: &object_store::path::Path, + to: &object_store::path::Path, + options: object_store::RenameOptions, + ) -> object_store::Result<()> { + self.inner.rename_opts(from, to, options).await + } + } + + #[derive(Debug)] + struct ParentListGuardWrapper { + parent_list_calls: Arc, + } + + impl WrappingObjectStore for ParentListGuardWrapper { + fn wrap( + &self, + _store_prefix: &str, + inner: Arc, + ) -> Arc { + Arc::new(ParentListGuardStore { + inner, + parent: object_store::path::Path::from("database"), + parent_list_calls: self.parent_list_calls.clone(), + }) + } + } + + #[tokio::test] + async fn test_open_missing_never_lists_database_parent() { + let parent_list_calls = Arc::new(AtomicUsize::new(0)); + let params = ReadParams { + store_options: Some(ObjectStoreParams { + object_store_wrapper: Some(Arc::new(ParentListGuardWrapper { + parent_list_calls: parent_list_calls.clone(), + })), + ..Default::default() + }), + ..Default::default() + }; + + let err = NativeTable::open_with_params( + "memory:///database/missing.lance", + "missing", + Vec::new(), + None, + Some(params), + None, + None, + HashSet::new(), + None, + ) + .await + .unwrap_err(); + + assert!( + matches!(&err, Error::TableNotFound { name, .. } if name == "missing"), "got {err:?}" ); - assert!( - err.to_string().contains("exists but could not be loaded"), - "got {err}" + assert_eq!( + parent_list_calls.load(Ordering::Relaxed), + 0, + "opening one missing table must not enumerate sibling tables" ); }