Skip to main content

lance_table/io/
commit.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Trait for commit implementations.
5//!
6//! In Lance, a transaction is committed by writing the next manifest file.
7//! However, care should be taken to ensure that the manifest file is written
8//! only once, even if there are concurrent writers. Different stores have
9//! different abilities to handle concurrent writes, so a trait is provided
10//! to allow for different implementations.
11//!
12//! The trait [CommitHandler] can be implemented to provide different commit
13//! strategies. The default implementation for most object stores is
14//! [RenameCommitHandler], which writes the manifest to a temporary path, then
15//! renames the temporary path to the final path if no object already exists
16//! at the final path. This is an atomic operation in most object stores, but
17//! not in AWS S3. So for AWS S3, the default commit handler is
18//! [UnsafeCommitHandler], which writes the manifest to the final path without
19//! any checks.
20//!
21//! When providing your own commit handler, most often you are implementing in
22//! terms of a lock. The trait [CommitLock] can be implemented as a simpler
23//! alternative to [CommitHandler].
24
25use std::io;
26use std::pin::Pin;
27use std::sync::Arc;
28use std::sync::atomic::AtomicBool;
29use std::{fmt::Debug, fs::DirEntry};
30
31use super::manifest::write_manifest;
32use futures::Stream;
33use futures::future::Either;
34use futures::{
35    StreamExt, TryStreamExt,
36    future::{self, BoxFuture},
37    stream::BoxStream,
38};
39use lance_file::format::{MAGIC, MAJOR_VERSION, MINOR_VERSION};
40use lance_io::object_writer::{ObjectWriter, WriteResult, get_etag};
41use log::warn;
42use object_store::ObjectStoreExt as OSObjectStoreExt;
43use object_store::PutOptions;
44use object_store::{Error as ObjectStoreError, ObjectStore as OSObjectStore, path::Path};
45use tracing::info;
46use url::Url;
47
48#[cfg(feature = "dynamodb")]
49pub mod dynamodb;
50pub mod external_manifest;
51
52use lance_core::{Error, Result};
53use lance_io::object_store::{ObjectStore, ObjectStoreExt, ObjectStoreParams};
54use lance_io::traits::{WriteExt, Writer};
55
56use crate::format::{IndexMetadata, Manifest, Transaction, is_detached_version};
57use lance_core::utils::tracing::{AUDIT_MODE_CREATE, AUDIT_TYPE_MANIFEST, TRACE_FILE_AUDIT};
58#[cfg(feature = "dynamodb")]
59use {
60    self::external_manifest::{ExternalManifestCommitHandler, ExternalManifestStore},
61    aws_credential_types::provider::ProvideCredentials,
62    aws_credential_types::provider::error::CredentialsError,
63    lance_io::object_store::{StorageOptions, providers::aws::build_aws_credential},
64    object_store::aws::AmazonS3ConfigKey,
65    object_store::aws::AwsCredentialProvider,
66    std::borrow::Cow,
67    std::time::{Duration, SystemTime},
68};
69
70pub const VERSIONS_DIR: &str = "_versions";
71const MANIFEST_EXTENSION: &str = "manifest";
72const DETACHED_VERSION_PREFIX: &str = "d";
73/// File name for the JSON version hint file, stored under `_versions/`.
74///
75/// The file contains `{"version":N}` where `N` is the latest committed version
76/// at the time of writing. It enables O(1)/O(k) latest-version lookup via HEAD
77/// requests on object stores where listing is not lexicographically ordered
78/// (e.g. S3 Express, local filesystem) instead of an O(n) listing.
79const VERSION_HINT_FILE: &str = "latest_version_hint.json";
80
81/// How manifest files should be named.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum ManifestNamingScheme {
84    /// `_versions/{version}.manifest`
85    V1,
86    /// `_manifests/{u64::MAX - version}.manifest`
87    ///
88    /// Zero-padded and reversed for O(1) lookup of latest version on object stores.
89    V2,
90}
91
92impl ManifestNamingScheme {
93    pub fn manifest_path(&self, base: &Path, version: u64) -> Path {
94        if is_detached_version(version) {
95            // Detached versions should never show up first in a list operation which
96            // means it needs to come lexicographically after all attached manifest
97            // files and so we add the prefix `d`.  There is no need to invert the
98            // version number since detached versions are not part of the version
99            base.clone().join(VERSIONS_DIR).join(format!(
100                "{DETACHED_VERSION_PREFIX}{version}.{MANIFEST_EXTENSION}"
101            ))
102        } else {
103            let directory = base.clone().join(VERSIONS_DIR);
104            match self {
105                Self::V1 => directory.join(format!("{version}.{MANIFEST_EXTENSION}")),
106                Self::V2 => {
107                    let inverted_version = u64::MAX - version;
108                    directory.join(format!("{inverted_version:020}.{MANIFEST_EXTENSION}"))
109                }
110            }
111        }
112    }
113
114    pub fn parse_version(&self, filename: &str) -> Option<u64> {
115        let file_number = filename
116            .split_once('.')
117            // Detached versions will fail the `parse` step, which is ok.
118            .and_then(|(version_str, _)| version_str.parse::<u64>().ok());
119        match self {
120            Self::V1 => file_number,
121            Self::V2 => file_number.map(|v| u64::MAX - v),
122        }
123    }
124
125    /// Parse a detached version from a filename like `d123456.manifest`.
126    ///
127    /// Returns the full version number with the detached mask bit set.
128    pub fn parse_detached_version(filename: &str) -> Option<u64> {
129        if !filename.starts_with(DETACHED_VERSION_PREFIX) {
130            return None;
131        }
132        let without_prefix = &filename[DETACHED_VERSION_PREFIX.len()..];
133        without_prefix
134            .split_once('.')
135            .and_then(|(version_str, _)| version_str.parse::<u64>().ok())
136    }
137
138    pub fn detect_scheme(filename: &str) -> Option<Self> {
139        if filename.starts_with(DETACHED_VERSION_PREFIX) {
140            // Currently, detached versions must imply V2
141            return Some(Self::V2);
142        }
143        if filename.ends_with(MANIFEST_EXTENSION) {
144            const V2_LEN: usize = 20 + 1 + MANIFEST_EXTENSION.len();
145            if filename.len() == V2_LEN {
146                Some(Self::V2)
147            } else {
148                Some(Self::V1)
149            }
150        } else {
151            None
152        }
153    }
154
155    pub fn detect_scheme_staging(filename: &str) -> Self {
156        // We shouldn't have to worry about detached versions here since there is no
157        // such thing as "detached" and "staged" at the same time.
158        if filename.chars().nth(20) == Some('.') {
159            Self::V2
160        } else {
161            Self::V1
162        }
163    }
164}
165
166/// Migrate all V1 manifests to V2 naming scheme.
167///
168/// This function will rename all V1 manifests to V2 naming scheme.
169///
170/// This function is idempotent, and can be run multiple times without
171/// changing the state of the object store.
172///
173/// However, it should not be run while other concurrent operations are happening.
174/// And it should also run until completion before resuming other operations.
175pub async fn migrate_scheme_to_v2(object_store: &ObjectStore, dataset_base: &Path) -> Result<()> {
176    object_store
177        .inner
178        .list(Some(&dataset_base.clone().join(VERSIONS_DIR)))
179        .try_filter(|res| {
180            let res = if let Some(filename) = res.location.filename() {
181                ManifestNamingScheme::detect_scheme(filename) == Some(ManifestNamingScheme::V1)
182            } else {
183                false
184            };
185            future::ready(res)
186        })
187        .try_for_each_concurrent(object_store.io_parallelism(), |meta| async move {
188            let filename = meta.location.filename().unwrap();
189            let version = ManifestNamingScheme::V1.parse_version(filename).unwrap();
190            let path = ManifestNamingScheme::V2.manifest_path(dataset_base, version);
191            object_store.inner.rename(&meta.location, &path).await?;
192            Ok(())
193        })
194        .await?;
195
196    Ok(())
197}
198
199/// Function that writes the manifest to the object store.
200///
201/// Returns the size of the written manifest.
202pub type ManifestWriter = for<'a> fn(
203    object_store: &'a ObjectStore,
204    manifest: &'a mut Manifest,
205    indices: Option<Vec<IndexMetadata>>,
206    path: &'a Path,
207    transaction: Option<Transaction>,
208) -> BoxFuture<'a, Result<WriteResult>>;
209
210/// Canonical manifest writer; its function item type exactly matches `ManifestWriter`.
211/// Rationale: keep a crate-local writer implementation so call sites can pass this function
212/// directly without non-primitive casts or lifetime coercions.
213pub fn write_manifest_file_to_path<'a>(
214    object_store: &'a ObjectStore,
215    manifest: &'a mut Manifest,
216    indices: Option<Vec<IndexMetadata>>,
217    path: &'a Path,
218    transaction: Option<Transaction>,
219) -> BoxFuture<'a, Result<WriteResult>> {
220    Box::pin(async move {
221        let mut object_writer = ObjectWriter::new(object_store, path).await?;
222        let pos = write_manifest(&mut object_writer, manifest, indices, transaction).await?;
223        object_writer
224            .write_magics(pos, MAJOR_VERSION, MINOR_VERSION, MAGIC)
225            .await?;
226        let res = Writer::shutdown(&mut object_writer).await?;
227        info!(target: TRACE_FILE_AUDIT, mode=AUDIT_MODE_CREATE, r#type=AUDIT_TYPE_MANIFEST, path = path.to_string());
228        Ok(res)
229    })
230}
231
232#[derive(Debug, Clone)]
233pub struct ManifestLocation {
234    /// The version the manifest corresponds to.
235    pub version: u64,
236    /// Path of the manifest file, relative to the table root.
237    pub path: Path,
238    /// Size, in bytes, of the manifest file. If it is not known, this field should be `None`.
239    pub size: Option<u64>,
240    /// Naming scheme of the manifest file.
241    pub naming_scheme: ManifestNamingScheme,
242    /// Optional opaque object generation token observed at `path`.
243    ///
244    /// An ETag is not necessarily a content checksum and may change when an
245    /// object is rewritten with identical bytes. In particular, S3 Express
246    /// returns an object-specific opaque value. Callers must not treat it as a
247    /// content checksum, logical manifest identity, or dataset-incarnation
248    /// identity. The generic
249    /// [`ExternalManifestStore`](crate::io::commit::external_manifest::ExternalManifestStore)
250    /// workflow therefore neither persists nor validates it: COPY and external
251    /// index publication are not atomic, so an otherwise correct equivalent
252    /// materialization can make a stored token stale before it is published.
253    ///
254    /// When present, the token still distinguishes the physical object
255    /// generation observed by this caller and can prevent reuse of an older
256    /// cached Dataset at the same URI and version. Conversely, `None` must not
257    /// be interpreted as proof that two observations belong to the same dataset
258    /// incarnation.
259    pub e_tag: Option<String>,
260    /// A token unique to this manifest record in the commit handler's store,
261    /// where it keeps one (`ExternalManifestStore::get_identity`). A dataset
262    /// recreated at the same version has a different one.
263    pub identity: Option<String>,
264}
265
266impl TryFrom<object_store::ObjectMeta> for ManifestLocation {
267    type Error = Error;
268
269    fn try_from(meta: object_store::ObjectMeta) -> Result<Self> {
270        let filename = meta.location.filename().ok_or_else(|| {
271            Error::internal("ObjectMeta location does not have a filename".to_string())
272        })?;
273        let scheme = ManifestNamingScheme::detect_scheme(filename)
274            .ok_or_else(|| Error::internal(format!("Invalid manifest filename: '{}'", filename)))?;
275        let version = scheme
276            .parse_version(filename)
277            .ok_or_else(|| Error::internal(format!("Invalid manifest filename: '{}'", filename)))?;
278        Ok(Self {
279            version,
280            path: meta.location,
281            size: Some(meta.size),
282            naming_scheme: scheme,
283            e_tag: meta.e_tag,
284            identity: None,
285        })
286    }
287}
288
289/// Get the latest manifest path.
290///
291/// - Local filesystem: a single directory read.
292/// - Stores where listing is not lexicographically ordered (e.g. S3 Express):
293///   the version hint (read the hint file, then probe higher versions with
294///   HEADs), falling back to a listing if the hint is missing or stale. A full
295///   listing on these stores is O(n) in the number of versions.
296/// - Lexicographically ordered stores (e.g. S3 Standard, GCS): the listing
297///   already resolves the latest version in roughly one request.
298async fn current_manifest_path(
299    object_store: &ObjectStore,
300    base: &Path,
301) -> Result<ManifestLocation> {
302    if object_store.has_direct_local_paths() {
303        if let Ok(Some(location)) = current_manifest_local(base) {
304            return Ok(location);
305        }
306    } else if uses_version_hint(object_store)
307        && let Some(location) = read_version_hint_and_probe(object_store, base).await
308    {
309        return Ok(location);
310    }
311
312    resolve_version_from_listing(object_store, base).await
313}
314
315/// JSON body of the version hint file: `{"version":N}`.
316#[derive(serde::Serialize, serde::Deserialize)]
317struct VersionHint {
318    version: u64,
319}
320
321/// Set `LANCE_USE_VERSION_HINT=0` (or `false`) to globally disable the version
322/// hint — writers stop emitting the hint file and readers stop consulting it,
323/// falling back to plain listing. Intended as a benchmark/escape-hatch knob;
324/// the hint is on by default.
325const VERSION_HINT_ENV: &str = "LANCE_USE_VERSION_HINT";
326
327fn version_hint_globally_enabled() -> bool {
328    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
329    *ENABLED.get_or_init(|| match std::env::var(VERSION_HINT_ENV) {
330        Ok(v) => !matches!(
331            v.trim().to_ascii_lowercase().as_str(),
332            "0" | "false" | "off"
333        ),
334        Err(_) => true,
335    })
336}
337
338/// Whether this object store benefits from a version hint.
339///
340/// On stores where listing is lexicographically ordered (S3 Standard, GCS,
341/// Azure, ...) the latest version is already resolved in roughly one request,
342/// so the hint would only add a write per commit for nothing. We write (and
343/// read) it only on stores where listing is not lexicographically ordered —
344/// S3 Express and the local filesystem. Can be force-disabled with the
345/// `LANCE_USE_VERSION_HINT=0` environment variable.
346pub fn uses_version_hint(object_store: &ObjectStore) -> bool {
347    version_hint_globally_enabled() && !object_store.list_is_lexically_ordered
348}
349
350/// Path to the JSON version hint file for a dataset.
351fn version_hint_path(base: &Path) -> Path {
352    base.clone().join(VERSIONS_DIR).join(VERSION_HINT_FILE)
353}
354
355/// Write the version hint file after a successful commit.
356///
357/// The hint is stored as JSON: `{"version":N}`. This write is best-effort —
358/// failures are logged and ignored, since the hint only accelerates reads and
359/// never affects correctness (readers verify the hinted version and probe
360/// upward from there). It is a no-op for detached versions and for stores that
361/// do not benefit from a hint (see [`uses_version_hint`]).
362pub async fn write_version_hint(object_store: &ObjectStore, base: &Path, version: u64) {
363    if is_detached_version(version) || !uses_version_hint(object_store) {
364        return;
365    }
366    let hint_path = version_hint_path(base);
367    let content = serde_json::to_vec(&VersionHint { version }).expect("serialize version hint");
368    if let Err(e) = object_store.put(&hint_path, content.as_slice()).await {
369        warn!("Failed to write version hint file for version {version}: {e}");
370    }
371}
372
373/// Read the latest version from the hint file, or `None` if it does not exist
374/// or cannot be parsed.
375async fn read_version_from_hint(object_store: &ObjectStore, base: &Path) -> Option<u64> {
376    let bytes = object_store
377        .inner
378        .get(&version_hint_path(base))
379        .await
380        .ok()?
381        .bytes()
382        .await
383        .ok()?;
384    Some(serde_json::from_slice::<VersionHint>(&bytes).ok()?.version)
385}
386
387/// Read the version hint and probe upward to find the true latest manifest.
388///
389/// Returns `None` if the hint file is missing, the hinted version no longer
390/// exists, or any error occurred — callers should fall back to listing.
391async fn read_version_hint_and_probe(
392    object_store: &ObjectStore,
393    base: &Path,
394) -> Option<ManifestLocation> {
395    let hint_version = read_version_from_hint(object_store, base).await?;
396    let (version, scheme, mut probed) = probe_versions_upward(object_store, base, hint_version)
397        .await
398        .ok()
399        .flatten()?;
400    // `probed` is non-empty and its last entry is the highest version found.
401    let (_, meta) = probed.pop()?;
402    Some(ManifestLocation {
403        version,
404        path: scheme.manifest_path(base, version),
405        size: Some(meta.size),
406        naming_scheme: scheme,
407        e_tag: meta.e_tag,
408        identity: None,
409    })
410}
411
412/// Maximum version gap between the hint and the read version for which we use
413/// the hint-based parallel-HEAD path; beyond this a single (paginated) listing
414/// is cheaper, so callers fall back to it.
415const MAX_HINT_PROBE_GAP: u64 = 1000;
416
417/// Probe `from_version`, then `from_version + 1`, `+ 2`, ... with HEAD requests
418/// until one is not found.
419///
420/// Assumes attached versions are contiguous above `from_version` (true in
421/// practice: every commit increments by one, and cleanup only removes *old*
422/// versions, never ones newer than the latest). A `NotFound` therefore marks
423/// the end of the history.
424///
425/// - `Ok(Some((true_latest_version, naming_scheme, [(version, meta), ...])))`:
426///   the vec covers every version from `from_version` through the true latest
427///   in ascending order.
428/// - `Ok(None)`: `from_version` itself does not exist (a `NotFound` for both
429///   naming schemes) — i.e. the hint pointed past the end.
430/// - `Err(_)`: a transient object-store error was hit, so the probed range may
431///   be incomplete; callers should fall back to a full listing rather than
432///   trust a possibly-stale result.
433async fn probe_versions_upward(
434    object_store: &ObjectStore,
435    base: &Path,
436    from_version: u64,
437) -> Result<
438    Option<(
439        u64,
440        ManifestNamingScheme,
441        Vec<(u64, object_store::ObjectMeta)>,
442    )>,
443> {
444    // Newer datasets use V2; fall back to V1 if the V2 path is not found.
445    let mut scheme = ManifestNamingScheme::V2;
446    let meta = match object_store
447        .inner
448        .head(&scheme.manifest_path(base, from_version))
449        .await
450    {
451        Ok(meta) => meta,
452        Err(ObjectStoreError::NotFound { .. }) => {
453            scheme = ManifestNamingScheme::V1;
454            match object_store
455                .inner
456                .head(&scheme.manifest_path(base, from_version))
457                .await
458            {
459                Ok(meta) => meta,
460                Err(ObjectStoreError::NotFound { .. }) => return Ok(None),
461                Err(e) => return Err(e.into()),
462            }
463        }
464        Err(e) => return Err(e.into()),
465    };
466
467    let mut probed = vec![(from_version, meta)];
468    let mut version = from_version;
469    loop {
470        let next = version + 1;
471        match object_store
472            .inner
473            .head(&scheme.manifest_path(base, next))
474            .await
475        {
476            Ok(meta) => {
477                probed.push((next, meta));
478                version = next;
479            }
480            // NotFound means we found the latest version.
481            Err(ObjectStoreError::NotFound { .. }) => break,
482            // A transient error means a newer version might exist that we
483            // failed to observe — surface it so callers fall back to listing.
484            Err(e) => return Err(e.into()),
485        }
486    }
487    Ok(Some((version, scheme, probed)))
488}
489
490/// List manifest locations with version `> since_version` using the version
491/// hint, in descending order of version.
492///
493/// Returns `None` if the hint is missing or stale enough that this is not
494/// usable — callers should fall back to a full listing. `Some(vec![])` is the
495/// fast path where the hint confirms there are no new versions.
496async fn list_manifests_since_version_with_hint(
497    object_store: &ObjectStore,
498    base: &Path,
499    since_version: u64,
500) -> Option<Vec<ManifestLocation>> {
501    let hint_version = read_version_from_hint(object_store, base).await?;
502
503    // A reader that is very far behind is cheaper to serve with one paginated
504    // listing than with thousands of HEADs.
505    if hint_version.saturating_sub(since_version) > MAX_HINT_PROBE_GAP {
506        return None;
507    }
508
509    // If the hint is not newer than the read version, the only versions that
510    // could exist are right above it; otherwise start at the hint.
511    let probe_from = if hint_version > since_version {
512        hint_version
513    } else {
514        since_version + 1
515    };
516
517    let (scheme, probed) = match probe_versions_upward(object_store, base, probe_from).await {
518        Ok(Some((_true_latest, scheme, probed))) => (scheme, probed),
519        // Nothing at `probe_from`. If we were probing from the hint, the hint
520        // is stale — bail to a full listing. If we were probing from
521        // `since_version + 1`, there are simply no new versions.
522        Ok(None) if hint_version > since_version => return None,
523        Ok(None) => return Some(Vec::new()),
524        // Transient error: don't trust the hint path, fall back to listing.
525        Err(_) => return None,
526    };
527
528    let mut locations: Vec<ManifestLocation> = probed
529        .into_iter()
530        .filter(|(v, _)| *v > since_version)
531        .map(|(version, meta)| ManifestLocation {
532            version,
533            path: scheme.manifest_path(base, version),
534            size: Some(meta.size),
535            naming_scheme: scheme,
536            e_tag: meta.e_tag,
537            identity: None,
538        })
539        .collect();
540
541    // Fill the gap between `since_version` and the hint with HEADs (the probe
542    // above already covered `hint_version` and up). The range is contiguous, so
543    // any error here (including a `NotFound`) means we can't trust the hint path
544    // — fall back to a full listing.
545    if hint_version > since_version + 1 {
546        let gap_locations: Vec<ManifestLocation> =
547            futures::stream::iter((since_version + 1)..hint_version)
548                .map(|version| async move {
549                    object_store
550                        .inner
551                        .head(&scheme.manifest_path(base, version))
552                        .await
553                        .map(|meta| ManifestLocation {
554                            version,
555                            path: scheme.manifest_path(base, version),
556                            size: Some(meta.size),
557                            naming_scheme: scheme,
558                            e_tag: meta.e_tag,
559                            identity: None,
560                        })
561                })
562                .buffer_unordered(object_store.io_parallelism())
563                .try_collect()
564                .await
565                .ok()?;
566        locations.extend(gap_locations);
567    }
568
569    locations.sort_by_key(|loc| std::cmp::Reverse(loc.version));
570    Some(locations)
571}
572
573/// Resolve the latest manifest by listing the versions directory.
574async fn resolve_version_from_listing(
575    object_store: &ObjectStore,
576    base: &Path,
577) -> Result<ManifestLocation> {
578    let manifest_files = object_store.list(Some(base.clone().join(VERSIONS_DIR)));
579
580    let mut valid_manifests = manifest_files.try_filter_map(|res| {
581        let filename = res.location.filename().unwrap();
582        if let Some(scheme) = ManifestNamingScheme::detect_scheme(filename) {
583            // Only include if we can parse a version (skip detached versions)
584            if scheme.parse_version(filename).is_some() {
585                future::ready(Ok(Some((scheme, res))))
586            } else {
587                future::ready(Ok(None))
588            }
589        } else {
590            future::ready(Ok(None))
591        }
592    });
593
594    let first = valid_manifests.next().await.transpose()?;
595    match (first, object_store.list_is_lexically_ordered) {
596        // If the first valid manifest we see is V2, we can assume that we are using
597        // V2 naming scheme for all manifests.
598        (Some((scheme @ ManifestNamingScheme::V2, meta)), true) => {
599            let version = scheme
600                .parse_version(meta.location.filename().unwrap())
601                .unwrap();
602
603            // Sanity check: verify at least for the first 1k files that they are all V2
604            // and that the version numbers are decreasing. We use the first 1k because
605            // this is the typical size of an object store list endpoint response page.
606            for (scheme, meta) in valid_manifests.take(999).try_collect::<Vec<_>>().await? {
607                if scheme != ManifestNamingScheme::V2 {
608                    warn!(
609                        "Found V1 Manifest in a V2 directory. Use `migrate_manifest_paths_v2` \
610                         to migrate the directory."
611                    );
612                    break;
613                }
614                let next_version = scheme
615                    .parse_version(meta.location.filename().unwrap())
616                    .unwrap();
617                if next_version >= version {
618                    warn!(
619                        "List operation was expected to be lexically ordered, but was not. This \
620                         could mean a corrupt read. Please make a bug report on the lance-format/lance \
621                         GitHub repository."
622                    );
623                    break;
624                }
625            }
626
627            Ok(ManifestLocation {
628                version,
629                path: meta.location,
630                size: Some(meta.size),
631                naming_scheme: scheme,
632                e_tag: meta.e_tag,
633                identity: None,
634            })
635        }
636        // If the list is not lexically ordered, we need to iterate all manifests
637        // to find the latest version. This works for both V1 and V2 schemes.
638        (Some((first_scheme, meta)), _) => {
639            let mut current_version = first_scheme
640                .parse_version(meta.location.filename().unwrap())
641                .unwrap();
642            let mut current_meta = meta;
643            let scheme = first_scheme;
644
645            while let Some((entry_scheme, meta)) = valid_manifests.next().await.transpose()? {
646                if entry_scheme != scheme {
647                    return Err(Error::internal(format!(
648                        "Found multiple manifest naming schemes in the same directory: {:?} and {:?}. \
649                         Use `migrate_manifest_paths_v2` to migrate the directory.",
650                        scheme, entry_scheme
651                    )));
652                }
653                let version = entry_scheme
654                    .parse_version(meta.location.filename().unwrap())
655                    .unwrap();
656                if version > current_version {
657                    current_version = version;
658                    current_meta = meta;
659                }
660            }
661            Ok(ManifestLocation {
662                version: current_version,
663                path: current_meta.location,
664                size: Some(current_meta.size),
665                naming_scheme: scheme,
666                e_tag: current_meta.e_tag,
667                identity: None,
668            })
669        }
670        (None, _) => Err(Error::not_found(
671            base.clone().join(VERSIONS_DIR).to_string(),
672        )),
673    }
674}
675
676// This is an optimized function that searches for the latest manifest. In
677// object_store, list operations lookup metadata for each file listed. This
678// method only gets the metadata for the found latest manifest.
679fn current_manifest_local(base: &Path) -> std::io::Result<Option<ManifestLocation>> {
680    let path = lance_io::local::to_local_path(&base.clone().join(VERSIONS_DIR));
681    let entries = std::fs::read_dir(path)?;
682
683    let mut latest_entry: Option<(u64, DirEntry, ManifestNamingScheme)> = None;
684
685    let mut scheme: Option<ManifestNamingScheme> = None;
686
687    for entry in entries {
688        let entry = entry?;
689        let filename_raw = entry.file_name();
690        let filename = filename_raw.to_string_lossy();
691
692        let Some(entry_scheme) = ManifestNamingScheme::detect_scheme(&filename) else {
693            // Need to ignore temporary files, such as
694            // .tmp_7.manifest_9c100374-3298-4537-afc6-f5ee7913666d
695            continue;
696        };
697
698        if let Some(scheme) = scheme {
699            if scheme != entry_scheme {
700                return Err(io::Error::new(
701                    io::ErrorKind::InvalidData,
702                    format!(
703                        "Found multiple manifest naming schemes in the same directory: {:?} and {:?}",
704                        scheme, entry_scheme
705                    ),
706                ));
707            }
708        } else {
709            scheme = Some(entry_scheme);
710        }
711
712        let Some(version) = entry_scheme.parse_version(&filename) else {
713            continue;
714        };
715
716        if let Some((latest_version, _, _)) = &latest_entry {
717            if version > *latest_version {
718                latest_entry = Some((version, entry, entry_scheme));
719            }
720        } else {
721            latest_entry = Some((version, entry, entry_scheme));
722        }
723    }
724
725    if let Some((version, entry, naming_scheme)) = latest_entry {
726        let metadata = entry.metadata()?;
727        Ok(Some(ManifestLocation {
728            version,
729            path: naming_scheme.manifest_path(base, version),
730            size: Some(metadata.len()),
731            naming_scheme,
732            e_tag: Some(get_etag(&metadata)),
733            identity: None,
734        }))
735    } else {
736        Ok(None)
737    }
738}
739
740fn list_manifests<'a>(
741    base_path: &Path,
742    object_store: &'a dyn OSObjectStore,
743) -> impl Stream<Item = Result<ManifestLocation>> + 'a {
744    object_store
745        .read_dir_all(&base_path.clone().join(VERSIONS_DIR), None)
746        .filter_map(|obj_meta| {
747            futures::future::ready(
748                obj_meta
749                    .map(|m| ManifestLocation::try_from(m).ok())
750                    .transpose(),
751            )
752        })
753        .boxed()
754}
755
756/// Convert object metadata to ManifestLocation for detached manifests.
757fn detached_manifest_location_from_meta(
758    meta: object_store::ObjectMeta,
759) -> Option<ManifestLocation> {
760    let filename = meta.location.filename()?;
761    let version = ManifestNamingScheme::parse_detached_version(filename)?;
762    Some(ManifestLocation {
763        version,
764        path: meta.location,
765        size: Some(meta.size),
766        naming_scheme: ManifestNamingScheme::V2,
767        e_tag: meta.e_tag,
768        identity: None,
769    })
770}
771
772/// List all detached manifest files in the versions directory.
773pub fn list_detached_manifests<'a>(
774    base_path: &Path,
775    object_store: &'a dyn OSObjectStore,
776) -> impl Stream<Item = Result<ManifestLocation>> + 'a {
777    object_store
778        .read_dir_all(&base_path.clone().join(VERSIONS_DIR), None)
779        .filter_map(|obj_meta| {
780            futures::future::ready(
781                obj_meta
782                    .map(detached_manifest_location_from_meta)
783                    .transpose(),
784            )
785        })
786        .boxed()
787}
788
789pub(crate) fn make_staging_manifest_path(base: &Path) -> Result<Path> {
790    let id = uuid::Uuid::new_v4().to_string();
791    Path::parse(format!("{base}-{id}")).map_err(|e| Error::io_source(Box::new(e)))
792}
793
794#[cfg(feature = "dynamodb")]
795const DDB_URL_QUERY_KEY: &str = "ddbTableName";
796
797/// Object-store listing of `_versions/`; the `CommitHandler` defaults.
798pub(crate) fn default_list_manifest_locations<'a>(
799    base_path: &Path,
800    object_store: &'a ObjectStore,
801    sorted_descending: bool,
802) -> BoxStream<'a, Result<ManifestLocation>> {
803    let underlying_stream = list_manifests(base_path, &object_store.inner);
804
805    if !sorted_descending {
806        return underlying_stream.boxed();
807    }
808
809    async fn sort_stream(
810        input_stream: impl futures::Stream<Item = Result<ManifestLocation>> + Unpin,
811    ) -> Result<impl Stream<Item = Result<ManifestLocation>> + Unpin> {
812        let mut locations = input_stream.try_collect::<Vec<_>>().await?;
813        locations.sort_by_key(|m| std::cmp::Reverse(m.version));
814        Ok(futures::stream::iter(locations.into_iter().map(Ok)))
815    }
816
817    // If the object store supports lexicographically ordered lists and
818    // the naming scheme is V2, we can use an optimized list operation.
819    if object_store.list_is_lexically_ordered {
820        // We don't know the naming scheme until we see the first manifest.
821        let mut peekable = underlying_stream.peekable();
822
823        futures::stream::once(async move {
824            let naming_scheme = match Pin::new(&mut peekable).peek().await {
825                Some(Ok(m)) => m.naming_scheme,
826                // If we get an error or no manifests are found, we default
827                // to V2 naming scheme, since it doesn't matter.
828                Some(Err(_)) => ManifestNamingScheme::V2,
829                None => ManifestNamingScheme::V2,
830            };
831
832            if naming_scheme == ManifestNamingScheme::V2 {
833                // If the first manifest is V2, we can use the optimized list operation.
834                Ok(Either::Left(peekable))
835            } else {
836                sort_stream(peekable).await.map(Either::Right)
837            }
838        })
839        .try_flatten()
840        .boxed()
841    } else {
842        // If the object store does not support lexicographically ordered lists,
843        // we need to sort the manifests in memory. Systems where this isn't
844        // supported (local fs, S3 express) are typically fast enough
845        // that this is not a problem.
846        futures::stream::once(sort_stream(underlying_stream))
847            .try_flatten()
848            .boxed()
849    }
850}
851
852pub(crate) fn default_list_manifest_locations_since<'a>(
853    base_path: &Path,
854    object_store: &'a ObjectStore,
855    since_version: u64,
856) -> BoxStream<'a, Result<ManifestLocation>> {
857    if !uses_version_hint(object_store) {
858        return default_list_manifest_locations(base_path, object_store, true)
859            .try_take_while(move |loc| future::ready(Ok(loc.version > since_version)))
860            .boxed();
861    }
862
863    let base_path = base_path.clone();
864    futures::stream::once(async move {
865        let locations =
866            match list_manifests_since_version_with_hint(object_store, &base_path, since_version)
867                .await
868            {
869                Some(locations) => locations,
870                None => {
871                    let mut locations = list_manifests(&base_path, &object_store.inner)
872                        .try_collect::<Vec<_>>()
873                        .await?;
874                    locations.retain(|loc| loc.version > since_version);
875                    locations.sort_by_key(|loc| std::cmp::Reverse(loc.version));
876                    locations
877                }
878            };
879        Ok::<_, Error>(futures::stream::iter(locations.into_iter().map(Ok)))
880    })
881    .try_flatten()
882    .boxed()
883}
884
885/// Handle commits that prevent conflicting writes.
886///
887/// Commit implementations ensure that if there are multiple concurrent writers
888/// attempting to write the next version of a table, only one will win. In order
889/// to work, all writers must use the same commit handler type.
890/// This trait is also responsible for resolving where the manifests live.
891///
892// TODO: pub(crate)
893#[async_trait::async_trait]
894#[allow(clippy::too_many_arguments)]
895pub trait CommitHandler: Debug + Send + Sync {
896    /// Whether a not-found result from [`Self::resolve_version_location`] is
897    /// definitive immediately after a commit attempt.
898    ///
899    /// Handlers backed by an eventually consistent or external source of
900    /// truth should keep the conservative default. This prevents callers from
901    /// deleting files that a newly committed manifest may reference while the
902    /// manifest is not yet visible through the resolver.
903    fn is_version_not_found_definitive(&self) -> bool {
904        false
905    }
906
907    /// Whether an error should still be returned after readback proves that
908    /// the manifest from the current commit attempt landed.
909    ///
910    /// The conservative default preserves errors from custom handlers. Built-in
911    /// object-store handlers override this because their commit errors may be
912    /// ambiguous transport failures whose successful outcome is authoritative.
913    fn propagate_commit_error_after_success(&self) -> bool {
914        true
915    }
916
917    async fn resolve_latest_location(
918        &self,
919        base_path: &Path,
920        object_store: &ObjectStore,
921    ) -> Result<ManifestLocation> {
922        Ok(current_manifest_path(object_store, base_path).await?)
923    }
924
925    async fn resolve_version_location(
926        &self,
927        base_path: &Path,
928        version: u64,
929        object_store: &dyn OSObjectStore,
930    ) -> Result<ManifestLocation> {
931        default_resolve_version(base_path, version, object_store).await
932    }
933
934    /// Check whether an attached manifest version exists without loading it.
935    ///
936    /// The default implementation probes the deterministic manifest path for
937    /// the given naming scheme. Commit handlers with an external source of
938    /// truth should override this method.
939    async fn version_exists(
940        &self,
941        base_path: &Path,
942        version: u64,
943        object_store: &dyn OSObjectStore,
944        naming_scheme: ManifestNamingScheme,
945    ) -> Result<bool> {
946        let path = naming_scheme.manifest_path(base_path, version);
947        match object_store.head(&path).await {
948            Ok(_) => Ok(true),
949            Err(ObjectStoreError::NotFound { .. }) => Ok(false),
950            Err(e) => Err(e.into()),
951        }
952    }
953
954    /// List detached manifest locations.
955    ///
956    /// Returns a stream of detached manifest locations in arbitrary order.
957    fn list_detached_manifest_locations<'a>(
958        &self,
959        base_path: &Path,
960        object_store: &'a ObjectStore,
961    ) -> BoxStream<'a, Result<ManifestLocation>> {
962        list_detached_manifests(base_path, &object_store.inner).boxed()
963    }
964
965    /// If `sorted_descending` is `true`, the stream will yield manifests in descending
966    /// order of version. When the object store has a lexicographically
967    /// ordered list and the naming scheme is V2, this will use an optimized
968    /// list operation. Otherwise, it will list all manifests and sort them
969    /// in memory. When `sorted_descending` is `false`, the stream will yield manifests
970    /// in arbitrary order.
971    fn list_manifest_locations<'a>(
972        &self,
973        base_path: &Path,
974        object_store: &'a ObjectStore,
975        sorted_descending: bool,
976    ) -> BoxStream<'a, Result<ManifestLocation>> {
977        default_list_manifest_locations(base_path, object_store, sorted_descending)
978    }
979
980    /// List manifest locations with version `> since_version`, in descending
981    /// order of version.
982    ///
983    /// On lexically-ordered stores this is the standard listing with early
984    /// termination. On non-lexically-ordered stores (e.g. S3 Express) it uses
985    /// the version hint to avoid an O(n) listing, falling back to a full
986    /// listing if the hint is missing or stale.
987    fn list_manifest_locations_since<'a>(
988        &self,
989        base_path: &Path,
990        object_store: &'a ObjectStore,
991        since_version: u64,
992    ) -> BoxStream<'a, Result<ManifestLocation>> {
993        default_list_manifest_locations_since(base_path, object_store, since_version)
994    }
995
996    /// Commit a manifest.
997    ///
998    /// This function should return an [CommitError::CommitConflict] if another
999    /// transaction has already been committed to the path.
1000    async fn commit(
1001        &self,
1002        manifest: &mut Manifest,
1003        indices: Option<Vec<IndexMetadata>>,
1004        base_path: &Path,
1005        object_store: &ObjectStore,
1006        manifest_writer: ManifestWriter,
1007        naming_scheme: ManifestNamingScheme,
1008        transaction: Option<Transaction>,
1009    ) -> std::result::Result<ManifestLocation, CommitError>;
1010
1011    /// Whether [`Self::commit_after`] is available.
1012    fn supports_predecessor_condition(&self) -> bool {
1013        false
1014    }
1015
1016    /// The identity of the latest manifest, for [`Self::commit_after`].
1017    /// `None` where the handler cannot condition on it.
1018    async fn resolve_latest_identity(
1019        &self,
1020        _base_path: &Path,
1021        _object_store: &ObjectStore,
1022    ) -> Result<Option<PredecessorIdentity>> {
1023        Ok(None)
1024    }
1025
1026    /// The identity of the manifest at `version` as the handler's store
1027    /// records it now; `None` where it keeps none or has no record.
1028    async fn resolve_identity(
1029        &self,
1030        _base_path: &Path,
1031        _object_store: &ObjectStore,
1032        _version: u64,
1033    ) -> Result<Option<PredecessorIdentity>> {
1034        Ok(None)
1035    }
1036
1037    /// Commit only if `predecessor` is still the manifest at its version,
1038    /// decided with the reservation; otherwise [`Error::PrerequisiteFailed`],
1039    /// never a conflict.
1040    #[allow(clippy::too_many_arguments)]
1041    async fn commit_after(
1042        &self,
1043        _manifest: &mut Manifest,
1044        _indices: Option<Vec<IndexMetadata>>,
1045        _base_path: &Path,
1046        _object_store: &ObjectStore,
1047        _manifest_writer: ManifestWriter,
1048        _naming_scheme: ManifestNamingScheme,
1049        _transaction: Option<Transaction>,
1050        _predecessor: &PredecessorIdentity,
1051    ) -> std::result::Result<ManifestLocation, CommitError> {
1052        Err(CommitError::OtherError(Error::not_supported(
1053            "this commit handler cannot condition publication on the predecessor manifest",
1054        )))
1055    }
1056
1057    /// Retire the record for `version` after its manifest was removed, only
1058    /// while the record still carries `identity`; a no-op otherwise.
1059    async fn forget_version(
1060        &self,
1061        _base_path: &Path,
1062        _version: u64,
1063        _identity: &str,
1064    ) -> Result<()> {
1065        Ok(())
1066    }
1067
1068    /// Delete the recorded manifest information for a dataset at the base_path
1069    async fn delete(&self, _base_path: &Path) -> Result<()> {
1070        Ok(())
1071    }
1072}
1073
1074async fn default_resolve_version(
1075    base_path: &Path,
1076    version: u64,
1077    object_store: &dyn OSObjectStore,
1078) -> Result<ManifestLocation> {
1079    if is_detached_version(version) {
1080        return Ok(ManifestLocation {
1081            version,
1082            // Detached versions are not supported with V1 naming scheme.  If we need
1083            // to support in the future we could use a different prefix (e.g. 'x' or something)
1084            naming_scheme: ManifestNamingScheme::V2,
1085            // Both V1 and V2 should give the same path for detached versions
1086            path: ManifestNamingScheme::V2.manifest_path(base_path, version),
1087            size: None,
1088            e_tag: None,
1089            identity: None,
1090        });
1091    }
1092
1093    // try V2, fallback to V1.
1094    let scheme = ManifestNamingScheme::V2;
1095    let path = scheme.manifest_path(base_path, version);
1096    match object_store.head(&path).await {
1097        Ok(meta) => Ok(ManifestLocation {
1098            version,
1099            path,
1100            size: Some(meta.size),
1101            naming_scheme: scheme,
1102            e_tag: meta.e_tag,
1103            identity: None,
1104        }),
1105        Err(ObjectStoreError::NotFound { .. }) => {
1106            // fallback to V1
1107            let scheme = ManifestNamingScheme::V1;
1108            Ok(ManifestLocation {
1109                version,
1110                path: scheme.manifest_path(base_path, version),
1111                size: None,
1112                naming_scheme: scheme,
1113                e_tag: None,
1114                identity: None,
1115            })
1116        }
1117        Err(e) => Err(e.into()),
1118    }
1119}
1120/// Adapt an object_store credentials into AWS SDK creds
1121#[cfg(feature = "dynamodb")]
1122#[derive(Debug)]
1123struct OSObjectStoreToAwsCredAdaptor(AwsCredentialProvider);
1124
1125#[cfg(feature = "dynamodb")]
1126impl ProvideCredentials for OSObjectStoreToAwsCredAdaptor {
1127    fn provide_credentials<'a>(
1128        &'a self,
1129    ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
1130    where
1131        Self: 'a,
1132    {
1133        aws_credential_types::provider::future::ProvideCredentials::new(async {
1134            let creds = self
1135                .0
1136                .get_credential()
1137                .await
1138                .map_err(|e| CredentialsError::provider_error(Box::new(e)))?;
1139            Ok(aws_credential_types::Credentials::new(
1140                &creds.key_id,
1141                &creds.secret_key,
1142                creds.token.clone(),
1143                Some(
1144                    SystemTime::now()
1145                        .checked_add(Duration::from_secs(
1146                            60 * 10, //  10 min
1147                        ))
1148                        .expect("overflow"),
1149                ),
1150                "",
1151            ))
1152        })
1153    }
1154}
1155
1156#[cfg(feature = "dynamodb")]
1157async fn build_dynamodb_external_store(
1158    table_name: &str,
1159    creds: AwsCredentialProvider,
1160    region: &str,
1161    endpoint: Option<String>,
1162    app_name: &str,
1163) -> Result<Arc<dyn ExternalManifestStore>> {
1164    use super::commit::dynamodb::DynamoDBExternalManifestStore;
1165    use aws_sdk_dynamodb::{
1166        Client,
1167        config::{IdentityCache, Region, retry::RetryConfig},
1168    };
1169
1170    let mut dynamodb_config = aws_sdk_dynamodb::config::Builder::new()
1171        .behavior_version_latest()
1172        .region(Some(Region::new(region.to_string())))
1173        .credentials_provider(OSObjectStoreToAwsCredAdaptor(creds))
1174        // caching should be handled by passed AwsCredentialProvider
1175        .identity_cache(IdentityCache::no_cache())
1176        // Be more resilient to transient network issues.
1177        // 5 attempts = 1 initial + 4 retries with exponential backoff.
1178        .retry_config(RetryConfig::standard().with_max_attempts(5));
1179
1180    if let Some(endpoint) = endpoint {
1181        dynamodb_config = dynamodb_config.endpoint_url(endpoint);
1182    }
1183    let client = Client::from_conf(dynamodb_config.build());
1184
1185    DynamoDBExternalManifestStore::new_external_store(client.into(), table_name, app_name).await
1186}
1187
1188pub async fn commit_handler_from_url(
1189    url_or_path: &str,
1190    // This looks unused if dynamodb feature disabled
1191    #[allow(unused_variables)] options: &Option<ObjectStoreParams>,
1192) -> Result<Arc<dyn CommitHandler>> {
1193    let local_handler: Arc<dyn CommitHandler> = if cfg!(windows) {
1194        Arc::new(RenameCommitHandler)
1195    } else {
1196        Arc::new(ConditionalPutCommitHandler)
1197    };
1198
1199    let url = match Url::parse(url_or_path) {
1200        Ok(url) if url.scheme().len() == 1 && cfg!(windows) => {
1201            // On Windows, the drive is parsed as a scheme
1202            return Ok(local_handler);
1203        }
1204        Ok(url) => url,
1205        Err(_) => {
1206            return Ok(local_handler);
1207        }
1208    };
1209
1210    match url.scheme() {
1211        "file" | "file-object-store" => Ok(local_handler),
1212        "s3" | "gs" | "az" | "abfss" | "memory" | "oss" | "tos" | "shared-memory" | "goosefs" => {
1213            Ok(Arc::new(ConditionalPutCommitHandler))
1214        }
1215        "cos" => Ok(Arc::new(TencentCosCommitHandler)),
1216        #[cfg(not(feature = "dynamodb"))]
1217        "s3+ddb" => Err(Error::invalid_input_source(
1218            "`s3+ddb://` scheme requires `dynamodb` feature to be enabled".into(),
1219        )),
1220        #[cfg(feature = "dynamodb")]
1221        "s3+ddb" => {
1222            if url.query_pairs().count() != 1 {
1223                return Err(Error::invalid_input_source(
1224                    "`s3+ddb://` scheme and expects exactly one query `ddbTableName`".into(),
1225                ));
1226            }
1227            let table_name = match url.query_pairs().next() {
1228                Some((Cow::Borrowed(key), Cow::Borrowed(table_name)))
1229                    if key == DDB_URL_QUERY_KEY =>
1230                {
1231                    if table_name.is_empty() {
1232                        return Err(Error::invalid_input_source(
1233                            "`s3+ddb://` scheme requires non empty dynamodb table name".into(),
1234                        ));
1235                    }
1236                    table_name
1237                }
1238                _ => {
1239                    return Err(Error::invalid_input_source(
1240                        "`s3+ddb://` scheme and expects exactly one query `ddbTableName`".into(),
1241                    ));
1242                }
1243            };
1244            let options = options.clone().unwrap_or_default();
1245            let storage_options_raw =
1246                StorageOptions(options.storage_options().cloned().unwrap_or_default());
1247            let dynamo_endpoint = get_dynamodb_endpoint(&storage_options_raw);
1248            let storage_options = storage_options_raw.as_s3_options();
1249
1250            let region = storage_options.get(&AmazonS3ConfigKey::Region).cloned();
1251
1252            // Get accessor from the options
1253            let accessor = options.get_accessor();
1254
1255            let provider_scheme = storage_options_raw.aws_provider_scheme()?;
1256
1257            let (aws_creds, region) = build_aws_credential(
1258                options.s3_credentials_refresh_offset,
1259                options.aws_credentials.clone(),
1260                Some(&storage_options),
1261                region,
1262                accessor,
1263                provider_scheme,
1264            )
1265            .await?;
1266
1267            Ok(Arc::new(ExternalManifestCommitHandler {
1268                external_manifest_store: build_dynamodb_external_store(
1269                    table_name,
1270                    aws_creds.clone(),
1271                    &region,
1272                    dynamo_endpoint,
1273                    "lancedb",
1274                )
1275                .await?,
1276            }))
1277        }
1278        _ => Ok(Arc::new(UnsafeCommitHandler)),
1279    }
1280}
1281
1282#[cfg(feature = "dynamodb")]
1283fn get_dynamodb_endpoint(storage_options: &StorageOptions) -> Option<String> {
1284    if let Some(endpoint) = storage_options.0.get("dynamodb_endpoint") {
1285        Some(endpoint.clone())
1286    } else {
1287        std::env::var("DYNAMODB_ENDPOINT").ok()
1288    }
1289}
1290
1291/// Errors that can occur when committing a manifest.
1292#[derive(Debug)]
1293pub enum CommitError {
1294    /// Another transaction has already been written to the path
1295    CommitConflict,
1296    /// Something else went wrong
1297    OtherError(Error),
1298}
1299
1300impl From<Error> for CommitError {
1301    fn from(e: Error) -> Self {
1302        Self::OtherError(e)
1303    }
1304}
1305
1306impl From<CommitError> for Error {
1307    fn from(e: CommitError) -> Self {
1308        match e {
1309            CommitError::CommitConflict => Self::internal("Commit conflict".to_string()),
1310            CommitError::OtherError(e) => e,
1311        }
1312    }
1313}
1314
1315/// Whether we have issued a warning about using the unsafe commit handler.
1316static WARNED_ON_UNSAFE_COMMIT: AtomicBool = AtomicBool::new(false);
1317
1318/// A naive commit implementation that does not prevent conflicting writes.
1319///
1320/// This will log a warning the first time it is used.
1321pub struct UnsafeCommitHandler;
1322
1323#[async_trait::async_trait]
1324#[allow(clippy::too_many_arguments)]
1325impl CommitHandler for UnsafeCommitHandler {
1326    fn is_version_not_found_definitive(&self) -> bool {
1327        true
1328    }
1329
1330    fn propagate_commit_error_after_success(&self) -> bool {
1331        false
1332    }
1333
1334    async fn commit(
1335        &self,
1336        manifest: &mut Manifest,
1337        indices: Option<Vec<IndexMetadata>>,
1338        base_path: &Path,
1339        object_store: &ObjectStore,
1340        manifest_writer: ManifestWriter,
1341        naming_scheme: ManifestNamingScheme,
1342        transaction: Option<Transaction>,
1343    ) -> std::result::Result<ManifestLocation, CommitError> {
1344        // Log a one-time warning
1345        if !WARNED_ON_UNSAFE_COMMIT.load(std::sync::atomic::Ordering::Relaxed) {
1346            WARNED_ON_UNSAFE_COMMIT.store(true, std::sync::atomic::Ordering::Relaxed);
1347            log::warn!(
1348                "Using unsafe commit handler. Concurrent writes may result in data loss. \
1349                 Consider providing a commit handler that prevents conflicting writes."
1350            );
1351        }
1352
1353        let version_path = naming_scheme.manifest_path(base_path, manifest.version);
1354        let res =
1355            manifest_writer(object_store, manifest, indices, &version_path, transaction).await?;
1356
1357        write_version_hint(object_store, base_path, manifest.version).await;
1358
1359        Ok(ManifestLocation {
1360            version: manifest.version,
1361            size: Some(res.size as u64),
1362            naming_scheme,
1363            path: version_path,
1364            e_tag: res.e_tag,
1365            identity: None,
1366        })
1367    }
1368}
1369
1370impl Debug for UnsafeCommitHandler {
1371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1372        f.debug_struct("UnsafeCommitHandler").finish()
1373    }
1374}
1375
1376/// A commit implementation that uses a lock to prevent conflicting writes.
1377#[async_trait::async_trait]
1378pub trait CommitLock: Debug {
1379    type Lease: CommitLease;
1380
1381    /// Attempt to lock the table for the given version.
1382    ///
1383    /// If it is already locked by another transaction, wait until it is unlocked.
1384    /// Once it is unlocked, return [CommitError::CommitConflict] if the version
1385    /// has already been committed. Otherwise, return the lock.
1386    ///
1387    /// To prevent poisoned locks, it's recommended to set a timeout on the lock
1388    /// of at least 30 seconds.
1389    ///
1390    /// It is not required that the lock tracks the version. It is provided in
1391    /// case the locking is handled by a catalog service that needs to know the
1392    /// current version of the table.
1393    async fn lock(&self, version: u64) -> std::result::Result<Self::Lease, CommitError>;
1394}
1395
1396#[async_trait::async_trait]
1397pub trait CommitLease: Send + Sync {
1398    /// Return the lease, indicating whether the commit was successful.
1399    ///
1400    /// Implementations should tolerate being called more than once: if a commit
1401    /// is cancelled (e.g. by a timeout) while `release` is in flight, a
1402    /// best-effort `release(false)` may be issued afterwards from the drop path.
1403    async fn release(&self, success: bool) -> std::result::Result<(), CommitError>;
1404}
1405
1406/// Guards a [CommitLease] so the lock is released even if the commit future is
1407/// dropped (e.g. cancelled by a commit timeout) before reaching an explicit
1408/// release.
1409///
1410/// [CommitLease::release] is async and cannot be awaited from `Drop`, so on the
1411/// drop path we spawn a best-effort background task that releases the lock with
1412/// `success = false`. Without this, a cancelled commit would leak the lock until
1413/// the lease's own TTL expired, blocking other writers in the meantime.
1414struct LeaseGuard<L: CommitLease + 'static> {
1415    lease: Option<L>,
1416}
1417
1418impl<L: CommitLease + 'static> LeaseGuard<L> {
1419    fn new(lease: L) -> Self {
1420        Self { lease: Some(lease) }
1421    }
1422
1423    /// Explicitly release the lease, consuming the guard so `Drop` is a no-op.
1424    async fn release(mut self, success: bool) -> std::result::Result<(), CommitError> {
1425        // Keep the lease inside the guard across the await so that, if this
1426        // future is cancelled mid-release (e.g. the release call itself hangs
1427        // and the commit timeout fires), `Drop` still issues a best-effort
1428        // release. Only clear it once the release has fully completed.
1429        let result = {
1430            let lease = self
1431                .lease
1432                .as_ref()
1433                .expect("LeaseGuard released more than once");
1434            lease.release(success).await
1435        };
1436        self.lease = None;
1437        result
1438    }
1439}
1440
1441impl<L: CommitLease + 'static> Drop for LeaseGuard<L> {
1442    fn drop(&mut self) {
1443        if let Some(lease) = self.lease.take() {
1444            // The guard was dropped without an explicit release, meaning the
1445            // commit future was cancelled while holding the lock. We can't await
1446            // in `Drop`, so spawn a best-effort release. If there is no runtime,
1447            // leave the lease for its TTL to reclaim.
1448            if let Ok(handle) = tokio::runtime::Handle::try_current() {
1449                handle.spawn(async move {
1450                    let _ = lease.release(false).await;
1451                });
1452            }
1453        }
1454    }
1455}
1456
1457#[async_trait::async_trait]
1458impl<T: CommitLock + Send + Sync> CommitHandler for T
1459where
1460    T::Lease: 'static,
1461{
1462    fn is_version_not_found_definitive(&self) -> bool {
1463        true
1464    }
1465
1466    async fn commit(
1467        &self,
1468        manifest: &mut Manifest,
1469        indices: Option<Vec<IndexMetadata>>,
1470        base_path: &Path,
1471        object_store: &ObjectStore,
1472        manifest_writer: ManifestWriter,
1473        naming_scheme: ManifestNamingScheme,
1474        transaction: Option<Transaction>,
1475    ) -> std::result::Result<ManifestLocation, CommitError> {
1476        let path = naming_scheme.manifest_path(base_path, manifest.version);
1477        // Hold the lease in a guard so the lock is released even if this future
1478        // is cancelled before we reach an explicit release below. The explicit
1479        // releases are still preferred since they report the correct success
1480        // flag and surface release errors; the guard only covers cancellation.
1481        let lease = LeaseGuard::new(self.lock(manifest.version).await?);
1482
1483        // Head the location and make sure it's not already committed
1484        match object_store.inner.head(&path).await {
1485            Ok(_) => {
1486                // The path already exists, so it's already committed
1487                // Release the lock
1488                lease.release(false).await?;
1489
1490                return Err(CommitError::CommitConflict);
1491            }
1492            Err(ObjectStoreError::NotFound { .. }) => {}
1493            Err(e) => {
1494                // Something else went wrong
1495                // Release the lock
1496                lease.release(false).await?;
1497
1498                return Err(CommitError::OtherError(e.into()));
1499            }
1500        }
1501        let res = manifest_writer(object_store, manifest, indices, &path, transaction).await;
1502
1503        // Release the lock
1504        lease.release(res.is_ok()).await?;
1505
1506        let res = res?;
1507
1508        write_version_hint(object_store, base_path, manifest.version).await;
1509
1510        Ok(ManifestLocation {
1511            version: manifest.version,
1512            size: Some(res.size as u64),
1513            naming_scheme,
1514            path,
1515            e_tag: res.e_tag,
1516            identity: None,
1517        })
1518    }
1519}
1520
1521#[async_trait::async_trait]
1522impl<T: CommitLock + Send + Sync> CommitHandler for Arc<T>
1523where
1524    T::Lease: 'static,
1525{
1526    fn is_version_not_found_definitive(&self) -> bool {
1527        self.as_ref().is_version_not_found_definitive()
1528    }
1529
1530    fn propagate_commit_error_after_success(&self) -> bool {
1531        self.as_ref().propagate_commit_error_after_success()
1532    }
1533
1534    async fn commit(
1535        &self,
1536        manifest: &mut Manifest,
1537        indices: Option<Vec<IndexMetadata>>,
1538        base_path: &Path,
1539        object_store: &ObjectStore,
1540        manifest_writer: ManifestWriter,
1541        naming_scheme: ManifestNamingScheme,
1542        transaction: Option<Transaction>,
1543    ) -> std::result::Result<ManifestLocation, CommitError> {
1544        self.as_ref()
1545            .commit(
1546                manifest,
1547                indices,
1548                base_path,
1549                object_store,
1550                manifest_writer,
1551                naming_scheme,
1552                transaction,
1553            )
1554            .await
1555    }
1556}
1557
1558/// A commit implementation that uses a temporary path and renames the object.
1559///
1560/// This only works for object stores that support atomic rename if not exist.
1561pub struct RenameCommitHandler;
1562
1563#[async_trait::async_trait]
1564impl CommitHandler for RenameCommitHandler {
1565    fn is_version_not_found_definitive(&self) -> bool {
1566        true
1567    }
1568
1569    fn propagate_commit_error_after_success(&self) -> bool {
1570        false
1571    }
1572
1573    async fn commit(
1574        &self,
1575        manifest: &mut Manifest,
1576        indices: Option<Vec<IndexMetadata>>,
1577        base_path: &Path,
1578        object_store: &ObjectStore,
1579        manifest_writer: ManifestWriter,
1580        naming_scheme: ManifestNamingScheme,
1581        transaction: Option<Transaction>,
1582    ) -> std::result::Result<ManifestLocation, CommitError> {
1583        // Create a temporary object, then use `rename_if_not_exists` to commit.
1584        // If failed, clean up the temporary object.
1585
1586        let path = naming_scheme.manifest_path(base_path, manifest.version);
1587        let tmp_path = make_staging_manifest_path(&path)?;
1588
1589        let res = manifest_writer(object_store, manifest, indices, &tmp_path, transaction).await?;
1590
1591        match object_store
1592            .inner
1593            .rename_if_not_exists(&tmp_path, &path)
1594            .await
1595        {
1596            Ok(_) => {
1597                // Successfully committed
1598                write_version_hint(object_store, base_path, manifest.version).await;
1599                Ok(ManifestLocation {
1600                    version: manifest.version,
1601                    path,
1602                    size: Some(res.size as u64),
1603                    naming_scheme,
1604                    e_tag: None, // Re-name can change e-tag.,
1605                    identity: None,
1606                })
1607            }
1608            Err(ObjectStoreError::AlreadyExists { .. }) => {
1609                // Another transaction has already been committed
1610                // Attempt to clean up temporary object, but ignore errors if we can't
1611                let _ = object_store.delete(&tmp_path).await;
1612
1613                return Err(CommitError::CommitConflict);
1614            }
1615            Err(e) => {
1616                // Something else went wrong
1617                return Err(CommitError::OtherError(e.into()));
1618            }
1619        }
1620    }
1621}
1622
1623impl Debug for RenameCommitHandler {
1624    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1625        f.debug_struct("RenameCommitHandler").finish()
1626    }
1627}
1628
1629pub struct ConditionalPutCommitHandler;
1630
1631#[async_trait::async_trait]
1632impl CommitHandler for ConditionalPutCommitHandler {
1633    fn is_version_not_found_definitive(&self) -> bool {
1634        true
1635    }
1636
1637    fn propagate_commit_error_after_success(&self) -> bool {
1638        false
1639    }
1640
1641    async fn commit(
1642        &self,
1643        manifest: &mut Manifest,
1644        indices: Option<Vec<IndexMetadata>>,
1645        base_path: &Path,
1646        object_store: &ObjectStore,
1647        manifest_writer: ManifestWriter,
1648        naming_scheme: ManifestNamingScheme,
1649        transaction: Option<Transaction>,
1650    ) -> std::result::Result<ManifestLocation, CommitError> {
1651        let path = naming_scheme.manifest_path(base_path, manifest.version);
1652
1653        let memory_store = ObjectStore::memory();
1654        let dummy_path = "dummy";
1655        manifest_writer(
1656            &memory_store,
1657            manifest,
1658            indices,
1659            &dummy_path.into(),
1660            transaction,
1661        )
1662        .await?;
1663        let dummy_data = memory_store.read_one_all(&dummy_path.into()).await?;
1664        let size = dummy_data.len() as u64;
1665        let res = object_store
1666            .inner
1667            .put_opts(
1668                &path,
1669                dummy_data.into(),
1670                PutOptions {
1671                    mode: object_store::PutMode::Create,
1672                    ..Default::default()
1673                },
1674            )
1675            .await
1676            .map_err(|err| match err {
1677                ObjectStoreError::AlreadyExists { .. } | ObjectStoreError::Precondition { .. } => {
1678                    CommitError::CommitConflict
1679                }
1680                _ => CommitError::OtherError(err.into()),
1681            })?;
1682
1683        write_version_hint(object_store, base_path, manifest.version).await;
1684
1685        Ok(ManifestLocation {
1686            version: manifest.version,
1687            path,
1688            size: Some(size),
1689            naming_scheme,
1690            e_tag: res.e_tag,
1691            identity: None,
1692        })
1693    }
1694}
1695
1696impl Debug for ConditionalPutCommitHandler {
1697    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1698        f.debug_struct("ConditionalPutCommitHandler").finish()
1699    }
1700}
1701
1702/// A read-capable handler that prevents unsafe default commits to Tencent COS.
1703///
1704/// COS silently ignores its put-if-not-exists header on buckets that have ever
1705/// had versioning enabled. Since that bucket history cannot be inferred from
1706/// the URI or storage options, using [`ConditionalPutCommitHandler`] here can
1707/// let concurrent writers overwrite the same manifest without reporting a
1708/// conflict.
1709struct TencentCosCommitHandler;
1710
1711#[async_trait::async_trait]
1712impl CommitHandler for TencentCosCommitHandler {
1713    fn is_version_not_found_definitive(&self) -> bool {
1714        true
1715    }
1716
1717    async fn commit(
1718        &self,
1719        _manifest: &mut Manifest,
1720        _indices: Option<Vec<IndexMetadata>>,
1721        _base_path: &Path,
1722        _object_store: &ObjectStore,
1723        _manifest_writer: ManifestWriter,
1724        _naming_scheme: ManifestNamingScheme,
1725        _transaction: Option<Transaction>,
1726    ) -> std::result::Result<ManifestLocation, CommitError> {
1727        Err(CommitError::OtherError(Error::not_supported(
1728            "Default writes to Tencent COS are disabled because COS does not reliably enforce \
1729             put-if-not-exists after bucket versioning has ever been enabled. Provide a \
1730             distributed commit_lock in Python or a custom CommitHandler in Rust.",
1731        )))
1732    }
1733}
1734
1735impl Debug for TencentCosCommitHandler {
1736    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1737        f.debug_struct("TencentCosCommitHandler").finish()
1738    }
1739}
1740
1741/// A manifest as a commit handler identifies it: its version and a token
1742/// unique to that physical manifest, so a dataset recreated at the same
1743/// version is told apart.
1744#[derive(Debug, Clone, PartialEq, Eq)]
1745pub struct PredecessorIdentity {
1746    pub version: u64,
1747    pub identity: String,
1748}
1749
1750#[derive(Debug, Clone)]
1751pub struct CommitConfig {
1752    pub num_retries: u32,
1753    pub skip_auto_cleanup: bool,
1754    // TODO: add isolation_level
1755}
1756
1757impl Default for CommitConfig {
1758    fn default() -> Self {
1759        Self {
1760            num_retries: 20,
1761            skip_auto_cleanup: false,
1762        }
1763    }
1764}
1765
1766#[cfg(test)]
1767mod tests {
1768    use std::sync::atomic::AtomicUsize;
1769
1770    use lance_core::utils::tempfile::TempObjDir;
1771
1772    use super::*;
1773
1774    #[test]
1775    fn test_manifest_naming_scheme() {
1776        let v1 = ManifestNamingScheme::V1;
1777        let v2 = ManifestNamingScheme::V2;
1778
1779        assert_eq!(
1780            v1.manifest_path(&Path::from("base"), 0),
1781            Path::from("base/_versions/0.manifest")
1782        );
1783        assert_eq!(
1784            v1.manifest_path(&Path::from("base"), 42),
1785            Path::from("base/_versions/42.manifest")
1786        );
1787
1788        assert_eq!(
1789            v2.manifest_path(&Path::from("base"), 0),
1790            Path::from("base/_versions/18446744073709551615.manifest")
1791        );
1792        assert_eq!(
1793            v2.manifest_path(&Path::from("base"), 42),
1794            Path::from("base/_versions/18446744073709551573.manifest")
1795        );
1796
1797        assert_eq!(v1.parse_version("0.manifest"), Some(0));
1798        assert_eq!(v1.parse_version("42.manifest"), Some(42));
1799        assert_eq!(
1800            v1.parse_version("42.manifest-cee4fbbb-eb19-4ea3-8ca7-54f5ec33dedc"),
1801            Some(42)
1802        );
1803
1804        assert_eq!(v2.parse_version("18446744073709551615.manifest"), Some(0));
1805        assert_eq!(v2.parse_version("18446744073709551573.manifest"), Some(42));
1806        assert_eq!(
1807            v2.parse_version("18446744073709551573.manifest-cee4fbbb-eb19-4ea3-8ca7-54f5ec33dedc"),
1808            Some(42)
1809        );
1810
1811        assert_eq!(ManifestNamingScheme::detect_scheme("0.manifest"), Some(v1));
1812        assert_eq!(
1813            ManifestNamingScheme::detect_scheme("18446744073709551615.manifest"),
1814            Some(v2)
1815        );
1816        assert_eq!(ManifestNamingScheme::detect_scheme("something else"), None);
1817    }
1818
1819    #[tokio::test]
1820    async fn test_manifest_naming_migration() {
1821        let object_store = ObjectStore::memory();
1822        let base = Path::from("base");
1823        let versions_dir = base.clone().join(VERSIONS_DIR);
1824
1825        // Write two v1 files and one v1
1826        let original_files = vec![
1827            versions_dir.clone().join("irrelevant"),
1828            ManifestNamingScheme::V1.manifest_path(&base, 0),
1829            ManifestNamingScheme::V2.manifest_path(&base, 1),
1830        ];
1831        for path in original_files {
1832            object_store.put(&path, b"".as_slice()).await.unwrap();
1833        }
1834
1835        migrate_scheme_to_v2(&object_store, &base).await.unwrap();
1836
1837        let expected_files = vec![
1838            ManifestNamingScheme::V2.manifest_path(&base, 1),
1839            ManifestNamingScheme::V2.manifest_path(&base, 0),
1840            versions_dir.clone().join("irrelevant"),
1841        ];
1842        let actual_files = object_store
1843            .inner
1844            .list(Some(&versions_dir))
1845            .map_ok(|res| res.location)
1846            .try_collect::<Vec<_>>()
1847            .await
1848            .unwrap();
1849        assert_eq!(actual_files, expected_files);
1850    }
1851
1852    #[tokio::test]
1853    #[rstest::rstest]
1854    async fn test_list_manifests_sorted(
1855        #[values(true, false)] lexical_list_store: bool,
1856        #[values(ManifestNamingScheme::V1, ManifestNamingScheme::V2)]
1857        naming_scheme: ManifestNamingScheme,
1858    ) {
1859        let tempdir;
1860        let (object_store, base) = if lexical_list_store {
1861            (Box::new(ObjectStore::memory()), Path::from("base"))
1862        } else {
1863            tempdir = TempObjDir::default();
1864            let path = tempdir.clone().join("base");
1865            let store = Box::new(ObjectStore::local());
1866            assert!(!store.list_is_lexically_ordered);
1867            (store, path)
1868        };
1869
1870        // Write 12 manifest files, latest first
1871        let mut expected_paths = Vec::new();
1872        for i in (0..12).rev() {
1873            let path = naming_scheme.manifest_path(&base, i);
1874            object_store.put(&path, b"".as_slice()).await.unwrap();
1875            expected_paths.push(path);
1876        }
1877
1878        let actual_versions = ConditionalPutCommitHandler
1879            .list_manifest_locations(&base, &object_store, true)
1880            .map_ok(|location| location.path)
1881            .try_collect::<Vec<_>>()
1882            .await
1883            .unwrap();
1884
1885        assert_eq!(actual_versions, expected_paths);
1886    }
1887
1888    #[tokio::test]
1889    #[rstest::rstest]
1890    async fn test_current_manifest_path(
1891        #[values(true, false)] lexical_list_store: bool,
1892        #[values(ManifestNamingScheme::V1, ManifestNamingScheme::V2)]
1893        naming_scheme: ManifestNamingScheme,
1894    ) {
1895        // Use memory store for both cases to avoid local FS special codepath.
1896        // Modify list_is_lexically_ordered to simulate different object stores.
1897        let mut object_store = ObjectStore::memory();
1898        object_store.list_is_lexically_ordered = lexical_list_store;
1899        let object_store = Box::new(object_store);
1900        let base = Path::from("base");
1901
1902        // Write 12 manifest files in non-sequential order
1903        for version in [5, 2, 11, 0, 8, 3, 10, 1, 7, 4, 9, 6] {
1904            let path = naming_scheme.manifest_path(&base, version);
1905            object_store.put(&path, b"".as_slice()).await.unwrap();
1906        }
1907
1908        let location = current_manifest_path(&object_store, &base).await.unwrap();
1909
1910        assert_eq!(location.version, 11);
1911        assert_eq!(location.naming_scheme, naming_scheme);
1912        assert_eq!(location.path, naming_scheme.manifest_path(&base, 11));
1913    }
1914
1915    /// A memory store that reports `list_is_lexically_ordered == false`, like
1916    /// S3 Express, so the version-hint paths are exercised.
1917    fn non_lexical_memory_store() -> Box<ObjectStore> {
1918        let mut object_store = ObjectStore::memory();
1919        object_store.list_is_lexically_ordered = false;
1920        Box::new(object_store)
1921    }
1922
1923    #[tokio::test]
1924    async fn test_write_version_hint() {
1925        let base = Path::from("base");
1926
1927        // No hint is written on lexically-ordered stores (it would not be read).
1928        let lexical = ObjectStore::memory();
1929        write_version_hint(&lexical, &base, 42).await;
1930        assert_eq!(read_version_from_hint(&lexical, &base).await, None);
1931
1932        let object_store = non_lexical_memory_store();
1933        write_version_hint(&object_store, &base, 42).await;
1934        assert_eq!(read_version_from_hint(&object_store, &base).await, Some(42));
1935
1936        // A later commit overwrites the hint.
1937        write_version_hint(&object_store, &base, 100).await;
1938        assert_eq!(
1939            read_version_from_hint(&object_store, &base).await,
1940            Some(100)
1941        );
1942
1943        // Detached versions are never written to the hint.
1944        write_version_hint(
1945            &object_store,
1946            &base,
1947            crate::format::DETACHED_VERSION_MASK | 7,
1948        )
1949        .await;
1950        assert_eq!(
1951            read_version_from_hint(&object_store, &base).await,
1952            Some(100)
1953        );
1954
1955        // A corrupt / non-JSON hint file is treated as missing.
1956        let hint_path = version_hint_path(&base);
1957        object_store
1958            .put(&hint_path, b"not json".as_slice())
1959            .await
1960            .unwrap();
1961        assert_eq!(read_version_from_hint(&object_store, &base).await, None);
1962    }
1963
1964    #[tokio::test]
1965    #[rstest::rstest]
1966    async fn test_read_version_hint_and_probe(
1967        #[values(ManifestNamingScheme::V1, ManifestNamingScheme::V2)]
1968        naming_scheme: ManifestNamingScheme,
1969    ) {
1970        let object_store = non_lexical_memory_store();
1971        let base = Path::from("base");
1972
1973        // No hint file yet.
1974        assert!(
1975            read_version_hint_and_probe(&object_store, &base)
1976                .await
1977                .is_none()
1978        );
1979
1980        for version in 1..=5 {
1981            object_store
1982                .put(&naming_scheme.manifest_path(&base, version), b"".as_slice())
1983                .await
1984                .unwrap();
1985        }
1986
1987        // Stale hint: should probe forward and find version 5.
1988        write_version_hint(&object_store, &base, 3).await;
1989        let location = read_version_hint_and_probe(&object_store, &base)
1990            .await
1991            .unwrap();
1992        assert_eq!(location.version, 5);
1993        assert_eq!(location.naming_scheme, naming_scheme);
1994
1995        // Up-to-date hint: returns version 5 directly.
1996        write_version_hint(&object_store, &base, 5).await;
1997        let location = read_version_hint_and_probe(&object_store, &base)
1998            .await
1999            .unwrap();
2000        assert_eq!(location.version, 5);
2001
2002        // Hint points past the latest version: not usable.
2003        write_version_hint(&object_store, &base, 10).await;
2004        assert!(
2005            read_version_hint_and_probe(&object_store, &base)
2006                .await
2007                .is_none()
2008        );
2009    }
2010
2011    #[tokio::test]
2012    async fn test_list_manifests_since_version_with_hint() {
2013        let object_store = non_lexical_memory_store();
2014        let base = Path::from("base");
2015        let scheme = ManifestNamingScheme::V2;
2016
2017        for version in 1..=10 {
2018            object_store
2019                .put(&scheme.manifest_path(&base, version), b"".as_slice())
2020                .await
2021                .unwrap();
2022        }
2023
2024        // No hint yet -> not usable, caller must fall back.
2025        assert!(
2026            list_manifests_since_version_with_hint(&object_store, &base, 7)
2027                .await
2028                .is_none()
2029        );
2030
2031        // Hint exactly at the read version -> fast path, nothing new.
2032        write_version_hint(&object_store, &base, 10).await;
2033        assert!(matches!(
2034            list_manifests_since_version_with_hint(&object_store, &base, 10).await,
2035            Some(v) if v.is_empty()
2036        ));
2037
2038        // Hint ahead of the read version, with a gap to fill (8, 9) plus probing
2039        // from the hint (10). Results are descending by version.
2040        let locations = list_manifests_since_version_with_hint(&object_store, &base, 7)
2041            .await
2042            .unwrap();
2043        assert_eq!(
2044            locations.iter().map(|l| l.version).collect::<Vec<_>>(),
2045            vec![10, 9, 8]
2046        );
2047
2048        // Slightly stale hint (points at 8) still probes up to the true latest.
2049        write_version_hint(&object_store, &base, 8).await;
2050        let locations = list_manifests_since_version_with_hint(&object_store, &base, 7)
2051            .await
2052            .unwrap();
2053        assert_eq!(
2054            locations.iter().map(|l| l.version).collect::<Vec<_>>(),
2055            vec![10, 9, 8]
2056        );
2057
2058        // Hint points past the latest -> not usable, caller falls back.
2059        write_version_hint(&object_store, &base, 20).await;
2060        assert!(
2061            list_manifests_since_version_with_hint(&object_store, &base, 7)
2062                .await
2063                .is_none()
2064        );
2065    }
2066
2067    #[tokio::test]
2068    async fn test_current_manifest_path_with_hint_non_lexical() {
2069        // Simulate S3 Express (non-lexically ordered list) with many versions.
2070        let object_store = non_lexical_memory_store();
2071        let base = Path::from("base");
2072        let naming_scheme = ManifestNamingScheme::V2;
2073
2074        for version in 1..=100 {
2075            object_store
2076                .put(&naming_scheme.manifest_path(&base, version), b"".as_slice())
2077                .await
2078                .unwrap();
2079        }
2080
2081        // Slightly stale hint: probing from 98 still resolves the true latest.
2082        write_version_hint(&object_store, &base, 98).await;
2083        let location = current_manifest_path(&object_store, &base).await.unwrap();
2084        assert_eq!(location.version, 100);
2085    }
2086
2087    #[tokio::test]
2088    async fn test_current_manifest_path_with_stale_hint_falls_back_to_listing() {
2089        let object_store = non_lexical_memory_store();
2090        let base = Path::from("base");
2091        let naming_scheme = ManifestNamingScheme::V2;
2092
2093        // Only version 5 exists, but the hint claims version 10.
2094        object_store
2095            .put(&naming_scheme.manifest_path(&base, 5), b"".as_slice())
2096            .await
2097            .unwrap();
2098        write_version_hint(&object_store, &base, 10).await;
2099
2100        // The stale hint is ignored; listing finds version 5.
2101        let location = current_manifest_path(&object_store, &base).await.unwrap();
2102        assert_eq!(location.version, 5);
2103    }
2104
2105    #[test]
2106    fn test_parse_detached_version() {
2107        // Valid detached version filenames
2108        assert_eq!(
2109            ManifestNamingScheme::parse_detached_version("d12345.manifest"),
2110            Some(12345)
2111        );
2112        assert_eq!(
2113            ManifestNamingScheme::parse_detached_version("d9223372036854775808.manifest"),
2114            Some(9223372036854775808)
2115        );
2116
2117        // Invalid: not starting with 'd' prefix
2118        assert_eq!(
2119            ManifestNamingScheme::parse_detached_version("12345.manifest"),
2120            None
2121        );
2122
2123        // Invalid: regular V2 manifest
2124        assert_eq!(
2125            ManifestNamingScheme::parse_detached_version("18446744073709551615.manifest"),
2126            None
2127        );
2128
2129        // Invalid: no extension
2130        assert_eq!(ManifestNamingScheme::parse_detached_version("d12345"), None);
2131    }
2132
2133    #[tokio::test]
2134    async fn test_list_detached_manifests() {
2135        use crate::format::DETACHED_VERSION_MASK;
2136        use futures::TryStreamExt;
2137
2138        let object_store = ObjectStore::memory();
2139        let base = Path::from("base");
2140        let versions_dir = base.clone().join(VERSIONS_DIR);
2141
2142        // Create some regular manifests
2143        for version in [1, 2, 3] {
2144            let path = ManifestNamingScheme::V2.manifest_path(&base, version);
2145            object_store.put(&path, b"".as_slice()).await.unwrap();
2146        }
2147
2148        // Create some detached manifests
2149        let detached_versions: Vec<u64> = vec![
2150            100 | DETACHED_VERSION_MASK,
2151            200 | DETACHED_VERSION_MASK,
2152            300 | DETACHED_VERSION_MASK,
2153        ];
2154        for version in &detached_versions {
2155            let path = versions_dir.clone().join(format!("d{}.manifest", version));
2156            object_store.put(&path, b"".as_slice()).await.unwrap();
2157        }
2158
2159        // List detached manifests
2160        let detached_locations: Vec<ManifestLocation> =
2161            list_detached_manifests(&base, &object_store.inner)
2162                .try_collect()
2163                .await
2164                .unwrap();
2165
2166        assert_eq!(detached_locations.len(), 3);
2167        for loc in &detached_locations {
2168            assert_eq!(loc.naming_scheme, ManifestNamingScheme::V2);
2169        }
2170
2171        let mut found_versions: Vec<u64> = detached_locations.iter().map(|l| l.version).collect();
2172        found_versions.sort();
2173        let mut expected_versions = detached_versions.clone();
2174        expected_versions.sort();
2175        assert_eq!(found_versions, expected_versions);
2176    }
2177
2178    #[tokio::test]
2179    #[rstest::rstest]
2180    #[case::memory("memory://bucket-a/ds")]
2181    #[case::shared_memory("shared-memory://bucket-a/ds")]
2182    #[case::s3("s3://bucket-a/ds")]
2183    #[case::gs("gs://bucket-a/ds")]
2184    #[case::az("az://bucket-a/ds")]
2185    #[case::abfss("abfss://bucket-a/ds")]
2186    #[case::oss("oss://bucket-a/ds")]
2187    #[case::tos("tos://bucket-a/ds")]
2188    #[case::goosefs("goosefs://bucket-a/ds")]
2189    async fn test_commit_handler_from_url_conditional_put_schemes(#[case] url: &str) {
2190        // Every scheme whose store supports atomic put-if-not-exists must
2191        // route to ConditionalPutCommitHandler — otherwise concurrent writers
2192        // fall through to UnsafeCommitHandler and silently clobber each
2193        // other's manifests.
2194        let handler = commit_handler_from_url(url, &None).await.unwrap();
2195        assert_eq!(
2196            format!("{:?}", handler),
2197            "ConditionalPutCommitHandler",
2198            "{url} should route to ConditionalPutCommitHandler",
2199        );
2200    }
2201
2202    /// A [CommitLock] whose lease records whether it was released, so we can
2203    /// assert the lock does not leak when the commit future is cancelled.
2204    #[derive(Debug)]
2205    struct TrackingLock {
2206        released: Arc<AtomicBool>,
2207    }
2208
2209    struct TrackingLease {
2210        released: Arc<AtomicBool>,
2211    }
2212
2213    #[async_trait::async_trait]
2214    impl CommitLock for TrackingLock {
2215        type Lease = TrackingLease;
2216        async fn lock(&self, _version: u64) -> std::result::Result<Self::Lease, CommitError> {
2217            Ok(TrackingLease {
2218                released: self.released.clone(),
2219            })
2220        }
2221    }
2222
2223    #[async_trait::async_trait]
2224    impl CommitLease for TrackingLease {
2225        async fn release(&self, _success: bool) -> std::result::Result<(), CommitError> {
2226            self.released
2227                .store(true, std::sync::atomic::Ordering::SeqCst);
2228            Ok(())
2229        }
2230    }
2231
2232    /// A [CommitLock] whose lease hangs on its first `release` call but completes
2233    /// on subsequent ones, so we can assert the drop-path best-effort release
2234    /// fires when a commit is cancelled *during* the explicit release.
2235    #[derive(Debug)]
2236    struct HangingReleaseLock {
2237        release_calls: Arc<AtomicUsize>,
2238        released: Arc<AtomicBool>,
2239    }
2240
2241    struct HangingReleaseLease {
2242        release_calls: Arc<AtomicUsize>,
2243        released: Arc<AtomicBool>,
2244    }
2245
2246    #[async_trait::async_trait]
2247    impl CommitLock for HangingReleaseLock {
2248        type Lease = HangingReleaseLease;
2249        async fn lock(&self, _version: u64) -> std::result::Result<Self::Lease, CommitError> {
2250            Ok(HangingReleaseLease {
2251                release_calls: self.release_calls.clone(),
2252                released: self.released.clone(),
2253            })
2254        }
2255    }
2256
2257    #[async_trait::async_trait]
2258    impl CommitLease for HangingReleaseLease {
2259        async fn release(&self, _success: bool) -> std::result::Result<(), CommitError> {
2260            // The first release (the explicit one) hangs, simulating a release
2261            // call that stalls long enough for the commit timeout to fire. The
2262            // best-effort release issued from `Drop` is the second call and
2263            // succeeds.
2264            if self
2265                .release_calls
2266                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
2267                == 0
2268            {
2269                future::pending::<()>().await;
2270                unreachable!()
2271            }
2272            self.released
2273                .store(true, std::sync::atomic::Ordering::SeqCst);
2274            Ok(())
2275        }
2276    }
2277
2278    /// A manifest writer that succeeds immediately, so the commit reaches the
2279    /// explicit lease release.
2280    fn succeeding_manifest_writer<'a>(
2281        _object_store: &'a ObjectStore,
2282        _manifest: &'a mut Manifest,
2283        _indices: Option<Vec<IndexMetadata>>,
2284        _path: &'a Path,
2285        _transaction: Option<Transaction>,
2286    ) -> BoxFuture<'a, Result<WriteResult>> {
2287        Box::pin(async move { Ok(WriteResult::default()) })
2288    }
2289
2290    fn test_manifest() -> Manifest {
2291        use std::collections::HashMap;
2292
2293        use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema};
2294        use lance_core::datatypes::Schema;
2295        use lance_file::version::LanceFileVersion;
2296
2297        use crate::format::DataStorageFormat;
2298
2299        let arrow_schema = ArrowSchema::new(vec![ArrowField::new("i", DataType::Int32, false)]);
2300        Manifest::new(
2301            Schema::try_from(&arrow_schema).unwrap(),
2302            Arc::new(vec![]),
2303            DataStorageFormat::new(LanceFileVersion::Stable.resolve()),
2304            HashMap::new(),
2305        )
2306    }
2307
2308    #[tokio::test]
2309    async fn test_cos_commit_requires_custom_handler() {
2310        let handler = commit_handler_from_url("cos://bucket-a/ds", &None)
2311            .await
2312            .unwrap();
2313        assert_eq!(format!("{:?}", handler), "TencentCosCommitHandler");
2314
2315        let mut manifest = test_manifest();
2316        let error = handler
2317            .commit(
2318                &mut manifest,
2319                None,
2320                &Path::from("test"),
2321                &ObjectStore::memory(),
2322                succeeding_manifest_writer,
2323                ManifestNamingScheme::V2,
2324                None,
2325            )
2326            .await
2327            .unwrap_err();
2328        let CommitError::OtherError(error) = error else {
2329            panic!("expected a not-supported commit error");
2330        };
2331        assert!(matches!(error, Error::NotSupported { .. }));
2332        assert!(error.to_string().contains("distributed commit_lock"));
2333    }
2334
2335    /// A manifest writer that never completes, simulating a hung object store.
2336    fn hanging_manifest_writer<'a>(
2337        _object_store: &'a ObjectStore,
2338        _manifest: &'a mut Manifest,
2339        _indices: Option<Vec<IndexMetadata>>,
2340        _path: &'a Path,
2341        _transaction: Option<Transaction>,
2342    ) -> BoxFuture<'a, Result<WriteResult>> {
2343        Box::pin(async move {
2344            future::pending::<()>().await;
2345            unreachable!()
2346        })
2347    }
2348
2349    /// Cancelling a commit (as a commit timeout does) while the lock is held must
2350    /// still release the lock; otherwise it leaks until the lease's TTL expires.
2351    #[tokio::test]
2352    async fn test_commit_lock_released_on_cancellation() {
2353        use std::sync::atomic::Ordering;
2354        use std::time::Duration;
2355
2356        let released = Arc::new(AtomicBool::new(false));
2357        let lock = TrackingLock {
2358            released: released.clone(),
2359        };
2360
2361        let object_store = ObjectStore::memory();
2362        let base_path = Path::from("test");
2363        let mut manifest = test_manifest();
2364
2365        // The commit will hang on the manifest writer while holding the lock.
2366        // Cancel it the same way a commit timeout would: drop the future.
2367        let commit_fut = lock.commit(
2368            &mut manifest,
2369            None,
2370            &base_path,
2371            &object_store,
2372            hanging_manifest_writer,
2373            ManifestNamingScheme::V2,
2374            None,
2375        );
2376        let timed_out = tokio::time::timeout(Duration::from_millis(50), commit_fut).await;
2377        assert!(timed_out.is_err(), "commit should not have completed");
2378
2379        // The drop guard releases the lock on a background task; wait for it.
2380        for _ in 0..100 {
2381            if released.load(Ordering::SeqCst) {
2382                break;
2383            }
2384            tokio::time::sleep(Duration::from_millis(10)).await;
2385        }
2386        assert!(
2387            released.load(Ordering::SeqCst),
2388            "lock must be released after the commit future is cancelled"
2389        );
2390    }
2391
2392    /// Cancelling a commit *during* the explicit lease release (e.g. the release
2393    /// call itself hangs and the commit timeout fires) must still release the
2394    /// lock via the drop-path best-effort release.
2395    #[tokio::test]
2396    async fn test_commit_lock_released_on_cancellation_during_release() {
2397        use std::sync::atomic::Ordering;
2398        use std::time::Duration;
2399
2400        let release_calls = Arc::new(AtomicUsize::new(0));
2401        let released = Arc::new(AtomicBool::new(false));
2402        let lock = HangingReleaseLock {
2403            release_calls: release_calls.clone(),
2404            released: released.clone(),
2405        };
2406
2407        let object_store = ObjectStore::memory();
2408        let base_path = Path::from("test");
2409        let mut manifest = test_manifest();
2410
2411        // The manifest writer succeeds, so the commit reaches the explicit
2412        // release, which hangs. Cancel it the same way a commit timeout would.
2413        let commit_fut = lock.commit(
2414            &mut manifest,
2415            None,
2416            &base_path,
2417            &object_store,
2418            succeeding_manifest_writer,
2419            ManifestNamingScheme::V2,
2420            None,
2421        );
2422        let timed_out = tokio::time::timeout(Duration::from_millis(50), commit_fut).await;
2423        assert!(timed_out.is_err(), "commit should not have completed");
2424
2425        // The drop guard issues a best-effort release on a background task; wait
2426        // for it. This is the second release call (the first one hung).
2427        for _ in 0..100 {
2428            if released.load(Ordering::SeqCst) {
2429                break;
2430            }
2431            tokio::time::sleep(Duration::from_millis(10)).await;
2432        }
2433        assert!(
2434            released.load(Ordering::SeqCst),
2435            "lock must be released even when cancelled during the explicit release"
2436        );
2437        assert_eq!(
2438            release_calls.load(Ordering::SeqCst),
2439            2,
2440            "expected the hung explicit release plus one best-effort drop release"
2441        );
2442    }
2443}