Skip to main content

lance_nd/
writer.rs

1//! Write nd record batches in several calls and commit them once.
2
3use std::collections::{HashSet, VecDeque};
4use std::pin::pin;
5use std::sync::Arc;
6
7use arrow::array::{RecordBatch, RecordBatchIterator};
8use arrow::datatypes::{FieldRef, Schema, SchemaRef};
9use futures::{FutureExt, Stream};
10use lance::Dataset;
11use lance::dataset::fragment::write::FragmentCreateBuilder;
12use lance::dataset::transaction::{Operation, Transaction};
13use lance::dataset::{CommitBuilder, NewColumnTransform, WriteMode as LanceWriteMode, WriteParams};
14use lance::table::format::Fragment;
15use lance_core::datatypes::{OnMissing, OnTypeMismatch, Schema as LanceSchema};
16use nd_arrow_array::NdRecordBatch;
17use nd_arrow_array::extension::{GLOBAL_ATTRIBUTES_FIELD, is_global_attributes_field};
18use tokio::task::JoinHandle;
19
20use crate::cast::Casting;
21use crate::encode::{RowDecoder, encode_rows};
22use crate::error::{Error, Result};
23use crate::format::check_format;
24use crate::options::ColumnOptions;
25use crate::schema::{concat_conformed, holds_schema, merge_schemas, missing_fields, used_fields};
26use crate::stats::{STATS_PREFIX, is_stats_name};
27use crate::store::{StorageOptions, open_dataset, store_params};
28use crate::widen::{promotions, widen};
29use crate::write::{
30    FILE_VERSION, WriteMode, WriteOptions, next_group, prepare_batch, table_defaults,
31    table_has_stats,
32};
33
34/// A writer that makes all its rows visible in one commit.
35///
36/// [`NdWriter::write`] buffers rows. When the buffer has about
37/// [`WriteOptions::target_fragment_bytes`] on disk, as estimated from the
38/// fragments written so far, a Tokio task writes it as one
39/// Lance fragment, and up to [`WriteOptions::write_parallelism`] fragments
40/// are written at the same time. Readers do not see the fragments.
41/// [`NdWriter::finish`] writes the rest and commits all of them in one new
42/// table version, in the order of the rows. The writer must run on a Tokio
43/// runtime.
44///
45/// An append writer adds new fields to the table before it writes the first
46/// fragment with them, so readers can see new empty columns before
47/// [`NdWriter::finish`]. `finish` then commits a plain append.
48///
49/// A writer that is aborted or dropped commits nothing, and stops its
50/// fragment writes. Its data files stay until a Lance cleanup removes them. One exception: under
51/// [`Casting::Promote`], a write that widens the table first commits the
52/// rows written before it.
53pub struct NdWriter {
54    uri: String,
55    /// The table at the start, for an append or an overwrite.
56    base: Option<Dataset>,
57    /// `true` when `finish` replaces the table.
58    overwrite: bool,
59    defaults: ColumnOptions,
60    /// `true` when the rows get stats.
61    stats: bool,
62    options: WriteOptions,
63    /// The table schema with all staged fields, as Arrow and as Lance.
64    schema: Option<(Schema, LanceSchema)>,
65    /// Rows for the next fragment, and their size in memory.
66    buffer: Vec<RecordBatch>,
67    buffered_bytes: usize,
68    /// Fragment writes that run, in the order of their rows, with the
69    /// size of their rows in memory.
70    pending: VecDeque<(JoinHandle<Result<Vec<Fragment>>>, usize)>,
71    fragments: Vec<Fragment>,
72    /// The bytes in memory and on disk of the fragments written so far.
73    written: DiskRatio,
74    /// The error of a failed fragment write. `finish` then fails too.
75    failed: Option<Arc<Error>>,
76}
77
78impl NdWriter {
79    /// A writer for a new table at `uri`.
80    ///
81    /// `options.mode` is [`WriteMode::Create`], which fails when the table
82    /// exists, or [`WriteMode::Overwrite`], which replaces the table at
83    /// [`NdWriter::finish`].
84    #[tracing::instrument(skip_all, fields(uri = %uri, mode = ?options.mode), err)]
85    pub async fn create(uri: &str, options: &WriteOptions) -> Result<Self> {
86        let existing = open_existing(uri, &options.storage_options).await?;
87        let overwrite = match options.mode {
88            WriteMode::Create if existing.is_some() => {
89                return Err(Error::TableExists(uri.to_string()));
90            }
91            WriteMode::Create => false,
92            WriteMode::Overwrite => true,
93            WriteMode::Append => {
94                return Err(Error::InvalidArgument(
95                    "use NdWriter::append to append to a table".to_string(),
96                ));
97            }
98        };
99        Ok(Self {
100            uri: uri.to_string(),
101            base: existing,
102            overwrite,
103            defaults: options.column_defaults.clone().unwrap_or_default(),
104            stats: options.stats,
105            options: options.clone(),
106            schema: None,
107            buffer: Vec::new(),
108            buffered_bytes: 0,
109            pending: VecDeque::new(),
110            fragments: Vec::new(),
111            written: DiskRatio::default(),
112            failed: None,
113        })
114    }
115
116    /// A writer that appends to the table at `uri`. Fails when the table
117    /// does not exist. `options.mode` is ignored.
118    #[tracing::instrument(skip_all, fields(uri = %uri), err)]
119    pub async fn append(uri: &str, options: &WriteOptions) -> Result<Self> {
120        let dataset = open_existing(uri, &options.storage_options)
121            .await?
122            .ok_or_else(|| Error::TableNotFound(uri.to_string()))?;
123        check_format(&dataset)?;
124        let defaults = table_defaults(&dataset, options)?;
125        let stats = table_has_stats(&dataset);
126        let schema = (Schema::from(dataset.schema()), dataset.schema().clone());
127        Ok(Self {
128            uri: uri.to_string(),
129            base: Some(dataset),
130            overwrite: false,
131            defaults,
132            stats,
133            options: options.clone(),
134            schema: Some(schema),
135            buffer: Vec::new(),
136            buffered_bytes: 0,
137            pending: VecDeque::new(),
138            fragments: Vec::new(),
139            written: DiskRatio::default(),
140            failed: None,
141        })
142    }
143
144    /// Write a stream of rows, without a commit. Each nd record batch is one row.
145    ///
146    /// The writer encodes the rows in groups, see [`WriteOptions`]. A group
147    /// that fails a check, for example a type change, is rejected: the
148    /// writer returns the error and stays usable, and the groups before it
149    /// stay written. A failed fragment write makes the writer fail:
150    /// [`NdWriter::finish`] then commits nothing.
151    pub async fn write(&mut self, rows: impl Stream<Item = Result<NdRecordBatch>>) -> Result<()> {
152        self.check()?;
153        let mut rows = pin!(rows);
154        while let Some(group) = next_group(&mut rows, &self.options).await? {
155            let batch = encode_rows(&group)?;
156            self.promote(&batch).await?;
157            let table = self.schema.as_ref().map(|(arrow, _)| arrow);
158            let batch = prepare_batch(batch, &self.defaults, &self.options, self.stats, table)?;
159            self.push(batch).await?;
160        }
161        Ok(())
162    }
163
164    /// Write the rows of an encoded record batch, without a commit.
165    ///
166    /// `batch` holds nd columns and an optional `__global_attrs__` column,
167    /// as [`crate::encode_rows`] gives them. The writer checks the
168    /// columns, but it does not decode the rows. A batch that fails a check
169    /// is rejected as a whole, and the writer stays usable. A failed
170    /// fragment write makes the writer fail.
171    pub async fn write_batch(&mut self, batch: RecordBatch) -> Result<()> {
172        self.check()?;
173        self.write_encoded(batch).await
174    }
175
176    fn check(&self) -> Result<()> {
177        match &self.failed {
178            Some(error) => Err(Error::WriterFailed(error.clone())),
179            None => Ok(()),
180        }
181    }
182
183    fn fail_on(&mut self, result: Result<()>) -> Result<()> {
184        result.map_err(|error| {
185            let error = Arc::new(error);
186            self.failed = Some(error.clone());
187            Error::WriterFailed(error)
188        })
189    }
190
191    async fn write_encoded(&mut self, batch: RecordBatch) -> Result<()> {
192        if let Some(field) = batch.schema().fields().iter().find(|field| {
193            is_stats_name(field.name())
194                || (field.name() == GLOBAL_ATTRIBUTES_FIELD && !is_global_attributes_field(field))
195        }) {
196            return Err(Error::SchemaMismatch(format!(
197                "variable name '{}' is reserved, also each name that starts with {STATS_PREFIX}",
198                field.name()
199            )));
200        }
201        // Fails for a column that is not an nd column or the global attributes.
202        RowDecoder::try_new(&batch)?;
203        if batch.num_rows() == 0 {
204            return Ok(());
205        }
206        let batch = nullable_global_attributes(batch)?;
207        self.promote(&batch).await?;
208        let table = self.schema.as_ref().map(|(arrow, _)| arrow);
209        let batch = prepare_batch(batch, &self.defaults, &self.options, self.stats, table)?;
210        self.push(batch).await
211    }
212
213    /// Widen the variables of `batch` in the table as [`Casting::Promote`]
214    /// needs. The staged fragments use the old field ids that a widen
215    /// replaces, so the rows written so far are committed first.
216    async fn promote(&mut self, batch: &RecordBatch) -> Result<()> {
217        if self.options.casting != Casting::Promote {
218            return Ok(());
219        }
220        let Some((arrow, _)) = &self.schema else {
221            return Ok(());
222        };
223        let widenings = promotions(batch, arrow);
224        if widenings.is_empty() {
225            return Ok(());
226        }
227        let result = async {
228            let staged =
229                !(self.buffer.is_empty() && self.pending.is_empty() && self.fragments.is_empty());
230            if staged || self.base.is_none() {
231                self.commit().await?;
232            }
233            let dataset = self.base.as_mut().expect("a committed table");
234            for (variable, to) in &widenings {
235                widen(dataset, variable, to).boxed().await?;
236            }
237            self.schema = Some((Schema::from(dataset.schema()), dataset.schema().clone()));
238            Ok(())
239        }
240        .await;
241        self.fail_on(result)
242    }
243
244    /// Grow the staged schema with `batch`, then buffer it. A full buffer
245    /// becomes a fragment.
246    ///
247    /// A schema conflict changes nothing, so it only rejects `batch`. A
248    /// failed fragment write makes the writer fail.
249    async fn push(&mut self, batch: RecordBatch) -> Result<()> {
250        // Most rows bring no new field. A merge copies the full staged
251        // schema, which is slow with thousands of variables.
252        let grows = match &self.schema {
253            Some((arrow, _)) => !holds_schema(arrow, &batch.schema()),
254            None => true,
255        };
256        if grows {
257            let (arrow, lance) = match &self.schema {
258                Some((arrow, lance)) => (
259                    merge_schemas(arrow, &batch.schema())?,
260                    lance.merge(batch.schema().as_ref())?,
261                ),
262                None => (
263                    batch.schema().as_ref().clone(),
264                    LanceSchema::try_from(batch.schema().as_ref())?,
265                ),
266            };
267            let mut lance = lance;
268            lance.set_field_id(self.base_max_field_id());
269            self.schema = Some((arrow, lance));
270        }
271        self.buffered_bytes += batch.get_array_memory_size();
272        self.buffer.push(batch);
273        if self.written.on_disk(self.buffered_bytes) >= self.options.target_fragment_bytes as f64 {
274            let result = self.flush().boxed().await;
275            return self.fail_on(result);
276        }
277        Ok(())
278    }
279
280    /// Start a fragment write of the buffer. Wait for the oldest write when
281    /// too many run.
282    #[tracing::instrument(level = "debug", skip_all, fields(rows = self.buffer.len(), bytes = self.buffered_bytes))]
283    async fn flush(&mut self) -> Result<()> {
284        if self.buffer.is_empty() {
285            return Ok(());
286        }
287        self.evolve().await?;
288        let batches = std::mem::take(&mut self.buffer);
289        let memory = std::mem::take(&mut self.buffered_bytes);
290        let (arrow, lance) = self.schema.as_ref().expect("a buffered row has a schema");
291        // Only the columns of the buffered rows: the other columns read as
292        // null. Rows of one buffer often share a schema.
293        let schemas: Vec<SchemaRef> = batches
294            .iter()
295            .map(|batch| batch.schema())
296            .collect::<HashSet<_>>()
297            .into_iter()
298            .collect();
299        let present = Arc::new(used_fields(arrow, &schemas));
300        // One batch per fragment: Lance 11 fails to read back a fragment of
301        // several batches when a struct column has nulls and large lists.
302        let batch = concat_conformed(&present, &batches)?;
303        let projected =
304            lance.project_by_schema(present.as_ref(), OnMissing::Error, OnTypeMismatch::Error)?;
305        let uri = self.uri.clone();
306        let store_params = store_params(&self.options.storage_options);
307        let task = tokio::spawn(async move {
308            let params = WriteParams {
309                mode: LanceWriteMode::Append,
310                data_storage_version: Some(FILE_VERSION),
311                store_params,
312                ..Default::default()
313            };
314            let reader = RecordBatchIterator::new([Ok(batch)], present);
315            Ok(FragmentCreateBuilder::new(&uri)
316                .schema(&projected)
317                .write_params(&params)
318                .write_fragments(reader)
319                .await?)
320        });
321        self.pending.push_back((task, memory));
322        while self.pending.len() > self.options.write_parallelism.max(1) {
323            self.wait_oldest().await?;
324        }
325        Ok(())
326    }
327
328    /// Add the staged fields that the table lacks to the table, as all-null
329    /// columns, and take the field ids of the table. Fragments then always
330    /// use the ids of the table.
331    ///
332    /// Lance validates a Merge commit in a time that grows with the square of
333    /// the schema fields, which takes minutes with thousands of variables.
334    /// `add_columns` skips that validation.
335    async fn evolve(&mut self) -> Result<()> {
336        let (Some(base), Some((arrow, _))) = (self.base.as_mut(), self.schema.as_ref()) else {
337            return Ok(());
338        };
339        if self.overwrite {
340            return Ok(());
341        }
342        let missing = missing_fields(Schema::from(base.schema()).fields(), arrow.fields());
343        if missing.is_empty() {
344            return Ok(());
345        }
346        // Boxed: the add_columns future is large, and would grow the future
347        // of each write beyond the compiler's layout depth.
348        base.add_columns(
349            NewColumnTransform::AllNulls(Arc::new(Schema::new(missing))),
350            None,
351            None,
352        )
353        .boxed()
354        .await?;
355        self.schema = Some((Schema::from(base.schema()), base.schema().clone()));
356        Ok(())
357    }
358
359    /// Wait for the oldest fragment write and keep its fragments.
360    async fn wait_oldest(&mut self) -> Result<()> {
361        let Some((task, memory)) = self.pending.pop_front() else {
362            return Ok(());
363        };
364        let fragments = task.await??;
365        self.written.add(memory, &fragments);
366        self.fragments.extend(fragments);
367        Ok(())
368    }
369
370    /// The highest field id of the table that this writer appends to.
371    fn base_max_field_id(&self) -> Option<i32> {
372        match (&self.base, self.overwrite) {
373            (Some(dataset), false) => Some(dataset.manifest.max_field_id()),
374            _ => None,
375        }
376    }
377
378    /// Commit all written rows in one new table version.
379    ///
380    /// Without rows, a new table or an overwrite fails, and an append returns
381    /// the table unchanged.
382    #[tracing::instrument(skip_all, fields(uri = %self.uri), err)]
383    pub async fn finish(mut self) -> Result<Dataset> {
384        if let Some(error) = &self.failed {
385            return Err(Error::WriterFailed(error.clone()));
386        }
387        // Boxed without its type: the commit future is large, and its full
388        // type would exceed the compiler's layout and Send depth for callers.
389        self.commit().boxed().await
390    }
391
392    /// Commit the staged rows. The writer then appends to the new version.
393    #[tracing::instrument(level = "debug", skip_all, fields(uri = %self.uri))]
394    async fn commit(&mut self) -> Result<Dataset> {
395        self.flush().boxed().await?;
396        while !self.pending.is_empty() {
397            self.wait_oldest().await?;
398        }
399        let Some((_, schema)) = self.schema.clone().filter(|_| !self.fragments.is_empty()) else {
400            return match (&self.base, self.overwrite) {
401                (Some(dataset), false) => Ok(dataset.clone()),
402                _ => Err(Error::InvalidArgument(format!(
403                    "no rows to create the table at {}",
404                    self.uri
405                ))),
406            };
407        };
408        let fragments = std::mem::take(&mut self.fragments);
409        let dataset = match (self.base.take(), self.overwrite) {
410            (None, overwrite) => {
411                // Another writer can create the table after `create`.
412                let storage_options = &self.options.storage_options;
413                if !overwrite && open_existing(&self.uri, storage_options).await?.is_some() {
414                    return Err(Error::TableExists(self.uri.clone()));
415                }
416                let operation = overwrite_operation(numbered(fragments, 0), schema);
417                let mut builder =
418                    CommitBuilder::new(self.uri.as_str()).with_storage_format(FILE_VERSION);
419                if let Some(params) = store_params(storage_options) {
420                    builder = builder.with_store_params(params);
421                }
422                builder
423                    .execute(Transaction::new(0, operation, None))
424                    .await?
425            }
426            (Some(base), true) => {
427                let version = base.version().version;
428                let operation = overwrite_operation(numbered(fragments, 0), schema);
429                CommitBuilder::new(Arc::new(base))
430                    .execute(Transaction::new(version, operation, None))
431                    .await?
432            }
433            // No new fields: new rows only, which Lance merges with concurrent appends.
434            (Some(base), false) if schema.max_field_id() == base.schema().max_field_id() => {
435                commit_rows(base, fragments, &schema).boxed().await?
436            }
437            (Some(base), false) => {
438                let version = base.version().version;
439                let mut all: Vec<Fragment> = base
440                    .get_fragments()
441                    .iter()
442                    .map(|f| f.metadata().clone())
443                    .collect();
444                let first_id = base.manifest.max_fragment_id().map_or(0, |id| id + 1);
445                all.extend(numbered(fragments, first_id));
446                // New fields are nullable, so old rows read them as null.
447                let operation = Operation::Merge {
448                    fragments: all,
449                    schema,
450                    preserves_nullability: true,
451                };
452                CommitBuilder::new(Arc::new(base))
453                    .execute(Transaction::new(version, operation, None))
454                    .await?
455            }
456        };
457        tracing::info!(version = dataset.version().version, "committed the rows");
458        self.base = Some(dataset.clone());
459        self.overwrite = false;
460        self.schema = Some((Schema::from(dataset.schema()), dataset.schema().clone()));
461        Ok(dataset)
462    }
463
464    /// Drop all written rows without a commit. Stops the fragment writes
465    /// that run. A drop of the writer does the same.
466    pub fn abort(self) {}
467}
468
469impl Drop for NdWriter {
470    /// Stop the fragment writes that run. Their rows are never committed.
471    fn drop(&mut self) {
472        for (task, _) in &self.pending {
473            task.abort();
474        }
475    }
476}
477
478/// `batch` with a nullable `__global_attrs__` field, as [`crate::encode_rows`]
479/// gives it. Lance fills new attributes of older fragments with nulls, and
480/// a read fails when the field is not nullable.
481fn nullable_global_attributes(batch: RecordBatch) -> Result<RecordBatch> {
482    let schema = batch.schema();
483    let Some((index, field)) = schema.column_with_name(GLOBAL_ATTRIBUTES_FIELD) else {
484        return Ok(batch);
485    };
486    if field.is_nullable() {
487        return Ok(batch);
488    }
489    let mut fields: Vec<FieldRef> = schema.fields().iter().cloned().collect();
490    fields[index] = Arc::new(field.clone().with_nullable(true));
491    let schema = Schema::new_with_metadata(fields, schema.metadata().clone());
492    Ok(RecordBatch::try_new(
493        Arc::new(schema),
494        batch.columns().to_vec(),
495    )?)
496}
497
498/// The bytes in memory and on disk of the written fragments, to estimate
499/// the size on disk of the buffered rows.
500#[derive(Debug, Default)]
501struct DiskRatio {
502    memory: u64,
503    disk: u64,
504}
505
506impl DiskRatio {
507    /// The lowest ratio of bytes on disk to bytes in memory. It limits the
508    /// buffer to 8 times the target, for data that compresses very well.
509    const MIN: f64 = 1.0 / 8.0;
510
511    /// Count the rows of `memory` bytes that became `fragments`. A data file
512    /// of unknown size counts nothing.
513    fn add(&mut self, memory: usize, fragments: &[Fragment]) {
514        let files = fragments.iter().flat_map(|fragment| fragment.files.iter());
515        let sizes: Option<Vec<u64>> = files
516            .map(|file| file.file_size_bytes.get().map(|size| size.get()))
517            .collect();
518        if let Some(sizes) = sizes {
519            self.memory += memory as u64;
520            self.disk += sizes.iter().sum::<u64>();
521        }
522    }
523
524    /// The estimated bytes on disk of `memory` bytes in memory. Before the
525    /// first fragment, the bytes in memory.
526    fn on_disk(&self, memory: usize) -> f64 {
527        let ratio = if self.memory == 0 {
528            1.0
529        } else {
530            (self.disk as f64 / self.memory as f64).max(Self::MIN)
531        };
532        memory as f64 * ratio
533    }
534}
535
536/// The most tries of a row commit that a concurrent commit preempts.
537const MAX_COMMIT_ATTEMPTS: usize = 10;
538
539/// Commit `fragments` as new rows of `base`, with the fields of `staged`.
540///
541/// The commit is an `Update` with new fragments only. Lance merges it with
542/// concurrent appends, deletes and compactions. A concurrent schema change,
543/// for example a widen, makes Lance reject it. Then the writer checks that
544/// the table still has each field of the fragments, and tries again.
545#[tracing::instrument(level = "debug", skip_all, fields(fragments = fragments.len()))]
546async fn commit_rows(
547    mut base: Dataset,
548    fragments: Vec<Fragment>,
549    staged: &LanceSchema,
550) -> Result<Dataset> {
551    for attempt in 1..=MAX_COMMIT_ATTEMPTS {
552        check_fields(&base, &fragments, staged)?;
553        let operation = Operation::Update {
554            removed_fragment_ids: Vec::new(),
555            updated_fragments: Vec::new(),
556            new_fragments: fragments.clone(),
557            fields_modified: Vec::new(),
558            compacted_sstables: Vec::new(),
559            fields_for_preserving_frag_bitmap: Vec::new(),
560            update_mode: None,
561            inserted_rows_filter: None,
562            updated_fragment_offsets: None,
563        };
564        let version = base.version().version;
565        match CommitBuilder::new(Arc::new(base.clone()))
566            .execute(Transaction::new(version, operation, None))
567            .await
568        {
569            Ok(dataset) => return Ok(dataset),
570            Err(lance::Error::RetryableCommitConflict { .. }) if attempt < MAX_COMMIT_ATTEMPTS => {
571                tracing::debug!(attempt, "a concurrent commit preempted the rows, try again");
572                base.checkout_latest().await?;
573            }
574            Err(error) => return Err(error.into()),
575        }
576    }
577    unreachable!("the last attempt returns")
578}
579
580/// Fails when `table` lacks a field that the data files of `fragments` hold.
581/// A widen gives a variable new field ids, so rows with the old ids would
582/// read as absent.
583fn check_fields(table: &Dataset, fragments: &[Fragment], staged: &LanceSchema) -> Result<()> {
584    let ids: HashSet<i32> = table.schema().field_ids().into_iter().collect();
585    let lost = fragments
586        .iter()
587        .flat_map(|fragment| fragment.files.iter())
588        .flat_map(|file| file.fields.iter())
589        .find(|id| **id >= 0 && !ids.contains(*id));
590    match lost {
591        Some(id) => Err(Error::SchemaMismatch(format!(
592            "the table changed or dropped '{}' during the write, nothing is committed",
593            staged
594                .field_path(*id)
595                .unwrap_or_else(|_| format!("field {id}"))
596        ))),
597        None => Ok(()),
598    }
599}
600
601/// Give the fragments ids from `first_id` on. Lance assigns ids only for appends.
602fn numbered(mut fragments: Vec<Fragment>, first_id: u64) -> Vec<Fragment> {
603    for (id, fragment) in (first_id..).zip(&mut fragments) {
604        fragment.id = id;
605    }
606    fragments
607}
608
609fn overwrite_operation(fragments: Vec<Fragment>, schema: LanceSchema) -> Operation {
610    Operation::Overwrite {
611        fragments,
612        schema,
613        config_upsert_values: None,
614        initial_bases: None,
615    }
616}
617
618/// The table at `uri`, or `None` when it does not exist.
619pub(crate) async fn open_existing(uri: &str, options: &StorageOptions) -> Result<Option<Dataset>> {
620    match open_dataset(uri, options).await {
621        Ok(dataset) => Ok(Some(dataset)),
622        Err(lance::Error::DatasetNotFound { .. }) => Ok(None),
623        Err(error) => Err(error.into()),
624    }
625}