Skip to main content

delta_kernel/log_segment_files/
mod.rs

1//! [`LogSegmentFiles`] is a struct holding the result of listing the delta log. Currently, it
2//! exposes four APIs for listing:
3//! 1. `list_commits`: Lists all commit files between the provided start and end versions.
4//! 2. `list`: Lists all commit and checkpoint files between the provided start and end versions.
5//! 3. `list_with_checkpoint_hint`: Lists all commit and checkpoint files after the provided
6//!    checkpoint hint.
7//! 4. `list_with_backward_checkpoint_scan`: Scans backward from an end version in 1000-version
8//!    windows until a complete checkpoint is found or the log is exhausted.
9//!
10//! After listing, one can leverage the [`LogSegmentFiles`] to construct a [`LogSegment`].
11//!
12//! [`LogSegment`]: crate::log_segment::LogSegment
13
14use std::collections::HashMap;
15
16use delta_kernel_derive::internal_api;
17use itertools::Itertools;
18use tracing::{debug, info, instrument, warn};
19use url::Url;
20
21use crate::cancellation::{check_cancelled, CancellableIterator, CancellationTokenRef};
22use crate::last_checkpoint_hint::LastCheckpointHint;
23use crate::path::LogPathFileType::*;
24use crate::path::{
25    may_begin_listable_log_path, CheckpointInstance, LogPathFileType, ParsedLogPath,
26};
27use crate::{DeltaResult, Error, StorageHandler, Version};
28
29#[cfg(test)]
30mod tests;
31
32/// Represents the set of log files found during a listing operation in the Delta log directory.
33///
34/// - `ascending_commit_files`: All commit and staged commit files found, sorted by version. May
35///   contain gaps.
36/// - `ascending_compaction_files`: All compaction commit files found, sorted by version.
37/// - `checkpoint_parts`: All parts of the most recent complete checkpoint (all same version). Empty
38///   if no checkpoint found. A version can hold several complete checkpoints; see
39///   [`group_checkpoint_parts`] for which one this is.
40/// - `latest_crc_file`: The CRC file with the highest version, only if version >= checkpoint
41///   version.
42/// - `latest_commit_file`: The commit file with the highest version, or `None` if no commits were
43///   found. This field may be present even when `ascending_commit_files` is empty, such as when a
44///   checkpoint subsumes all commits. In that case, it is retained because downstream code (e.g.
45///   In-Commit Timestamp reading) needs access to the commit file at the snapshot version.
46/// - `max_published_version`: The highest published commit file version, or `None` if no published
47///   commits were found.
48#[derive(Debug, Default, Clone, PartialEq, Eq)]
49#[internal_api]
50pub(crate) struct LogSegmentFiles {
51    pub ascending_commit_files: Vec<ParsedLogPath>,
52    pub ascending_compaction_files: Vec<ParsedLogPath>,
53    pub checkpoint_parts: Vec<ParsedLogPath>,
54    pub latest_crc_file: Option<ParsedLogPath>,
55    pub latest_commit_file: Option<ParsedLogPath>,
56    pub max_published_version: Option<Version>,
57}
58
59/// Returns a lazy iterator of [`ParsedLogPath`]s from the filesystem over versions
60/// `[start_version, end_version]`. The iterator handles parsing, filtering out non-listable
61/// files (e.g. dot-prefixed files), and stopping at `end_version`. It stops consuming the
62/// underlying listing at the first path past the version-named region, so directories like
63/// `_staged_commits/` and `_sidecars/` are never paged through.
64///
65/// This is a thin wrapper around [`StorageHandler::list_from`] that provides the standard
66/// Delta log file discovery pipeline. Callers are responsible for handling the `log_tail`
67/// (catalog-provided commits) and tracking `max_published_version`.
68///
69/// With a `cancellation_token`, the listing becomes cancellable: the engine may interrupt its own
70/// I/O, and the returned iterator is polled against the token so cancellation arrives as a terminal
71/// [`Error::Cancelled`] rather than an early end.
72#[internal_api]
73pub(crate) fn list_delta_log_from_storage(
74    storage: &dyn StorageHandler,
75    log_root: &Url,
76    start_version: Version,
77    end_version: Version,
78    cancellation_token: Option<&CancellationTokenRef>,
79) -> DeltaResult<impl Iterator<Item = DeltaResult<ParsedLogPath>>> {
80    let start_from = log_root.join(&format!("{start_version:020}"))?;
81    let log_root_str = log_root.to_string();
82    let files = storage
83        .list_from_with_cancellation(&start_from, cancellation_token.cloned())?
84        // The listing is sorted by full path, so nothing relevant follows the first relative path
85        // past the version-named region (see `may_begin_listable_log_path`). Stopping there avoids
86        // paging through `_staged_commits/` and `_sidecars/`, which can hold thousands of files.
87        // A path that doesn't strip the log_root prefix is kept; parsing discards it.
88        // TODO(#2740): push the bound into the listing request itself.
89        .take_while(move |meta_res| match meta_res {
90            Ok(meta) => meta
91                .location
92                .as_str()
93                .strip_prefix(&log_root_str)
94                .is_none_or(may_begin_listable_log_path),
95            Err(_) => true,
96        })
97        .map(|meta| ParsedLogPath::try_from(meta?))
98        // NOTE: this filters out .crc files etc which start with "." - some engines
99        // produce `.something.parquet.crc` corresponding to `something.parquet`. Kernel
100        // doesn't care about these files. Critically, note these are _different_ than
101        // normal `version.crc` files which are listed + captured normally. Additionally
102        // we likely aren't even 'seeing' these files since lexicographically the string
103        // "." comes before the string "0".
104        .filter_map_ok(|path_opt| path_opt.filter(|p| p.should_list()))
105        .take_while(move |path_res| match path_res {
106            // discard any path with too-large version; keep errors
107            Ok(path) => path.version <= end_version,
108            Err(_) => true,
109        });
110    // Wrap the filtered pipeline so cancellation is checked as the iterator is consumed, outside
111    // the version `take_while` above. Checked inside, a cancelled listing would end with `None` and
112    // be indistinguishable from a complete one; outside, it surfaces as a terminal
113    // `Error::Cancelled`.
114    Ok(CancellableIterator::new(files, cancellation_token.cloned()))
115}
116
117/// Groups all checkpoint parts according to the checkpoint they belong to.
118///
119/// Several _complete_ checkpoints can legitimately share a version, say two multi-part checkpoints
120/// with different part counts, or two uuid-named ones. Each gets its own [`CheckpointInstance`]
121/// key, so the caller can pick a winner deterministically (see
122/// `ListingAccumulator::select_checkpoint_for_group`).
123///
124/// `parts` must arrive in ascending file name order, which log listing provides: a multi-part
125/// checkpoint only accumulates while its parts arrive in order.
126#[internal_api]
127fn group_checkpoint_parts(
128    parts: Vec<ParsedLogPath>,
129) -> HashMap<CheckpointInstance, Vec<ParsedLogPath>> {
130    debug_assert!(
131        parts.is_sorted_by_key(|p| &p.filename),
132        "checkpoint parts must arrive in ascending file name order"
133    );
134    let mut checkpoints: HashMap<CheckpointInstance, Vec<ParsedLogPath>> = HashMap::new();
135    for part_file in parts {
136        match &part_file.file_type {
137            // A single-file checkpoint is complete on its own. Keying uuid-named ones on file name
138            // keeps two of them at the same version separate.
139            ClassicCheckpoint | UuidCheckpoint => {
140                if let Some(instance) = CheckpointInstance::of(&part_file) {
141                    checkpoints.insert(instance, vec![part_file]);
142                }
143            }
144            MultiPartCheckpoint {
145                part_num: 1,
146                num_parts,
147            } => {
148                // Start a new multi-part checkpoint
149                checkpoints.insert(
150                    CheckpointInstance::MultiPart {
151                        num_parts: *num_parts,
152                    },
153                    vec![part_file],
154                );
155            }
156            MultiPartCheckpoint {
157                part_num,
158                num_parts,
159            } => {
160                // Continue a multi-part checkpoint.
161                // Checkpoint parts are required to be in-order from log listing to build
162                // a multi-part checkpoint
163                if let Some(part_files) = checkpoints.get_mut(&CheckpointInstance::MultiPart {
164                    num_parts: *num_parts,
165                }) {
166                    if *part_num as usize == 1 + part_files.len() {
167                        // Safe to append because all previous parts exist
168                        part_files.push(part_file);
169                    }
170                }
171            }
172            Commit | StagedCommit | CompactedCommit { .. } | Crc | Unknown => {}
173        }
174    }
175    checkpoints
176}
177
178/// Returns the version of the latest complete checkpoint in `files`, or `None` if no complete
179/// checkpoint exists. Skips 0-byte checkpoint files so they don't count toward completeness.
180fn find_complete_checkpoint_version(ascending_files: &[ParsedLogPath]) -> Option<Version> {
181    ascending_files
182        .iter()
183        .filter(|f| f.is_checkpoint() && should_process_log_file(f))
184        .chunk_by(|f| f.version)
185        .into_iter()
186        .filter_map(|(version, parts)| {
187            let owned: Vec<ParsedLogPath> = parts.cloned().collect();
188            group_checkpoint_parts(owned)
189                .iter()
190                .any(|(instance, part_files)| instance.is_complete(part_files))
191                .then_some(version)
192        })
193        .last()
194}
195
196/// Validates a log file's size. Returns `true` to keep the file, `false` to skip it
197/// (with a warning already emitted).
198///
199/// Compaction and checkpoint files are skipped when empty -- they have fallbacks
200/// (individual commits, older checkpoints). Commit and CRC files are kept even
201/// if empty; the warning ensures the corrupt file is identifiable in logs.
202#[internal_api]
203pub(crate) fn should_process_log_file(file: &ParsedLogPath) -> bool {
204    if file.location.size > 0 {
205        return true;
206    }
207    match file.file_type {
208        // Commit files are kept even if 0 bytes -- the downstream JSON handler might
209        // error, but the warning here ensures the corrupt file is identifiable in logs.
210        // We don't skip commits because they are the source of truth for table state.
211        Commit | StagedCommit => {
212            warn!(
213                "{:?} file is empty (0 bytes): {}",
214                file.file_type, file.location.location,
215            );
216            return true;
217        }
218        CompactedCommit { .. } => {
219            warn!(
220                "Skipping empty (0 byte) compacted log file {}, \
221                 falling back to individual commits",
222                file.location.location,
223            );
224        }
225        ClassicCheckpoint | UuidCheckpoint | MultiPartCheckpoint { .. } => {
226            warn!(
227                "Skipping empty (0 byte) checkpoint file: {}",
228                file.location.location,
229            );
230        }
231        // CRC files are optional and may report size 0 on some platforms.
232        // Keep them -- the CRC reader handles empty/invalid content.
233        Crc => {
234            warn!("CRC file is empty (0 bytes): {}", file.location.location,);
235            return true;
236        }
237        Unknown => return true,
238    }
239    false
240}
241
242/// Accumulates and groups log files during listing. Each "group" consists of all files that
243/// share the same version number (e.g., commit, checkpoint parts, CRC files).
244///
245/// We need to group by version because:
246/// 1. A version may have multiple checkpoint parts that must be collected before we can determine
247///    if the checkpoint is complete
248/// 2. If a complete checkpoint exists, we can discard all commits before it
249///
250/// Groups are flushed (processed) when we encounter a file with a different version or
251/// reach EOF, at which point we check for complete checkpoints and update our state.
252#[derive(Default)]
253struct ListingAccumulator {
254    /// The result being built up
255    output: LogSegmentFiles,
256    /// Staging area for checkpoint parts at the current version group; always empty when iteration
257    /// ends
258    pending_checkpoint_parts: Vec<ParsedLogPath>,
259    /// End-version bound used in process_file() to filter CompactedCommit files
260    // TODO(#2337): remove allow(dead_code) when log compaction is re-enabled
261    #[allow(dead_code)]
262    end_version: Option<Version>,
263    /// The version of the current group being accumulated
264    group_version: Option<Version>,
265}
266
267impl ListingAccumulator {
268    fn process_file(&mut self, file: ParsedLogPath) {
269        if !should_process_log_file(&file) {
270            return;
271        }
272        match file.file_type {
273            Commit | StagedCommit => self.output.ascending_commit_files.push(file),
274            // TODO(#2337): re-enable log compaction once testing is sufficient
275            // CompactedCommit { hi } if self.end_version.is_none_or(|end| hi <= end) => {
276            //     self.output.ascending_compaction_files.push(file);
277            // }
278            // CompactedCommit { .. } => (), // Failed the bounds check above
279            CompactedCommit { .. } => {
280                debug!(
281                    "Skipping unsupported log compaction file: {:?}",
282                    file.location
283                );
284            }
285            ClassicCheckpoint | UuidCheckpoint | MultiPartCheckpoint { .. } => {
286                self.pending_checkpoint_parts.push(file)
287            }
288            Crc => {
289                self.output.latest_crc_file.replace(file);
290            }
291            Unknown => {
292                // It is possible that there are other files being stashed away into
293                // _delta_log/  This is not necessarily forbidden, but something we
294                // want to know about in a debugging scenario
295                debug!(
296                    "Found file {} with unknown file type {:?} at version {}",
297                    file.filename, file.file_type, file.version
298                );
299            }
300        }
301    }
302
303    /// Called before processing each new file. If `file_version` differs from the current
304    /// `group_version`, finalizes the current group by calling `select_checkpoint_for_group`,
305    /// then advances `group_version` to the new version. On the first call (when
306    /// `group_version` is `None`), simply initializes it.
307    fn maybe_flush_and_advance(&mut self, file_version: Version) {
308        match self.group_version {
309            Some(gv) if file_version != gv => {
310                self.select_checkpoint_for_group(gv);
311                self.group_version = Some(file_version);
312            }
313            None => {
314                self.group_version = Some(file_version);
315            }
316            _ => {} // same version, no flush needed
317        }
318    }
319
320    /// Selects this version's checkpoint. Any of a version's complete checkpoints (see
321    /// [`group_checkpoint_parts`]) describes the same table state; the choice must be stable across
322    /// processes (matching Delta-Spark), so we take the greatest in [`CheckpointInstance`] order.
323    ///
324    /// When a complete checkpoint exists we drop the commits/compactions collected so far, keeping
325    /// only the latest commit.
326    fn select_checkpoint_for_group(&mut self, version: Version) {
327        let pending_checkpoint_parts = std::mem::take(&mut self.pending_checkpoint_parts);
328        if let Some((_, complete_checkpoint)) = group_checkpoint_parts(pending_checkpoint_parts)
329            .into_iter()
330            .filter(|(instance, part_files)| instance.is_complete(part_files))
331            .max_by(|(a, _), (b, _)| a.cmp(b))
332        {
333            self.output.checkpoint_parts = complete_checkpoint;
334            // Keep the commit at the checkpoint version (if any) before clearing all older commits.
335            self.output.latest_commit_file = self
336                .output
337                .ascending_commit_files
338                .last()
339                .filter(|c| c.version == version)
340                .cloned();
341            // Log replay only uses commits/compactions after a complete checkpoint
342            self.output.ascending_commit_files.clear();
343            self.output.ascending_compaction_files.clear();
344            // Drop CRC file if older than checkpoint (CRC must be >= checkpoint version)
345            if self
346                .output
347                .latest_crc_file
348                .as_ref()
349                .is_some_and(|crc| crc.version < version)
350            {
351                self.output.latest_crc_file = None;
352            }
353        }
354    }
355}
356
357/// Number of versions covered by each backward-scan window in
358/// `LogSegmentFiles::list_with_backward_checkpoint_scan`
359const BACKWARD_SCAN_WINDOW_SIZE: u64 = 1000;
360
361impl LogSegmentFiles {
362    /// Assembles a `LogSegmentFiles` from `fs_files` (an iterator of files
363    /// listed from storage) and `log_tail` (catalog-provided commits).
364    ///
365    /// - `fs_files`: files listed from storage in ascending version order
366    /// - `log_tail`: list of commits that takes precedence over the filesystem ones
367    /// - `start_version`: start version of the entire listing range provided; in practice, this is
368    ///   the lower bound (inclusive) for log_tail entries included in the result
369    /// - `end_version`: upper bound (inclusive) on versions to include, `None` means no bound
370    pub(crate) fn build_log_segment_files(
371        fs_files: impl Iterator<Item = DeltaResult<ParsedLogPath>>,
372        log_tail: Vec<ParsedLogPath>,
373        start_version: Version,
374        end_version: Option<Version>,
375    ) -> DeltaResult<Self> {
376        // check log_tail is only commits
377        // note that LogSegment checks no gaps/duplicates so we don't duplicate that here
378        debug_assert!(
379            log_tail.iter().all(|entry| entry.is_commit()),
380            "log_tail should only contain commits"
381        );
382
383        let log_tail_start_version = log_tail.first().map(|f| f.version);
384        let end = end_version.unwrap_or(Version::MAX);
385
386        let mut acc = ListingAccumulator {
387            end_version,
388            ..Default::default()
389        };
390
391        // Phase 1: Stream filesystem files lazily (no collect).
392        // We always list from the filesystem even when the log_tail covers the entire commit
393        // range, because non-commit files (CRC, checkpoints, compactions) only exist on the
394        // filesystem — the log_tail only provides commit files.
395        for file_result in fs_files {
396            let file = file_result?;
397
398            // Track max published commit version from ALL filesystem Commit files,
399            // including those that will be skipped because log_tail takes precedence.
400            if matches!(file.file_type, LogPathFileType::Commit) {
401                acc.output.max_published_version =
402                    acc.output.max_published_version.max(Some(file.version));
403            }
404
405            // Skip filesystem commits at versions covered by the log_tail (the log_tail
406            // is authoritative for commits). Non-commit files are always kept.
407            if file.is_commit()
408                && log_tail_start_version.is_some_and(|tail_start| file.version >= tail_start)
409            {
410                continue;
411            }
412
413            acc.maybe_flush_and_advance(file.version);
414            acc.process_file(file);
415        }
416
417        // Phase 2: Process log_tail entries. We do this after Phase 1 because log_tail commits
418        // start at log_tail_start_version and are in ascending version order — they always extend
419        // (or overlap with, but supersede) the filesystem-listed commits. Processing them after
420        // Phase 1 maintains ascending version order throughout, which is required by the checkpoint
421        // grouping logic. Note that Phase 1 already skipped filesystem commits at log_tail
422        // versions, so there's no duplication here.
423        //
424        // log_tail entries at versions before a checkpoint may still be included
425        // here - LogSegment::try_new is the safeguard that filters those out unconditionally
426        let filtered_log_tail = log_tail
427            .into_iter()
428            .filter(|entry| entry.version >= start_version && entry.version <= end);
429        for file in filtered_log_tail {
430            // Track max published version for published commits from the log_tail
431            if matches!(file.file_type, LogPathFileType::Commit) {
432                acc.output.max_published_version =
433                    acc.output.max_published_version.max(Some(file.version));
434            }
435
436            acc.maybe_flush_and_advance(file.version);
437            acc.process_file(file);
438        }
439
440        // Flush the final group
441        if let Some(gv) = acc.group_version {
442            acc.select_checkpoint_for_group(gv);
443        }
444
445        // Since ascending_commit_files is cleared at each checkpoint, if it's non-empty here
446        // it contains only commits after the most recent checkpoint. The last element is the
447        // highest version commit overall, so we update latest_commit_file to it. If it's empty,
448        // we keep the value set at the checkpoint (if a commit existed at the checkpoint version),
449        // or remains None.
450        if let Some(commit_file) = acc.output.ascending_commit_files.last() {
451            acc.output.latest_commit_file = Some(commit_file.clone());
452        }
453
454        Ok(acc.output)
455    }
456
457    pub(crate) fn ascending_commit_files(&self) -> &Vec<ParsedLogPath> {
458        &self.ascending_commit_files
459    }
460
461    /// The staged (unpublished) commit files, in ascending version order.
462    pub(crate) fn staged_commits(&self) -> impl Iterator<Item = &ParsedLogPath> {
463        self.ascending_commit_files
464            .iter()
465            .filter(|f| f.file_type == LogPathFileType::StagedCommit)
466    }
467
468    pub(crate) fn ascending_commit_files_mut(&mut self) -> &mut Vec<ParsedLogPath> {
469        &mut self.ascending_commit_files
470    }
471
472    pub(crate) fn checkpoint_parts(&self) -> &Vec<ParsedLogPath> {
473        &self.checkpoint_parts
474    }
475
476    pub(crate) fn latest_commit_file(&self) -> &Option<ParsedLogPath> {
477        &self.latest_commit_file
478    }
479
480    /// Iterator over every listed log path across all fields.
481    pub(crate) fn iter_all_paths(&self) -> impl Iterator<Item = &ParsedLogPath> {
482        self.ascending_commit_files
483            .iter()
484            .chain(&self.ascending_compaction_files)
485            .chain(&self.checkpoint_parts)
486            .chain(&self.latest_crc_file)
487            .chain(&self.latest_commit_file)
488    }
489
490    /// Estimated heap size in bytes, best-effort estimate.
491    pub(crate) fn estimated_heap_size_bytes(&self) -> usize {
492        let vec_buffer_bytes = (self.ascending_commit_files.capacity()
493            + self.ascending_compaction_files.capacity()
494            + self.checkpoint_parts.capacity())
495            * size_of::<ParsedLogPath>();
496        let path_bytes: usize = self
497            .iter_all_paths()
498            .map(ParsedLogPath::estimated_heap_size_bytes)
499            .sum();
500        vec_buffer_bytes + path_bytes
501    }
502
503    /// List all commits between the provided `start_version` (inclusive) and `end_version`
504    /// (inclusive). All other types are ignored.
505    ///
506    /// `log_tail` is a contiguous run of commits ending at the table's latest version. It takes
507    /// precedence over the filesystem listing, and is required for catalog-managed tables, whose
508    /// unbackfilled staged commits exist only here.
509    pub(crate) fn list_commits(
510        storage: &dyn StorageHandler,
511        log_root: &Url,
512        log_tail: Vec<ParsedLogPath>,
513        start_version: Option<Version>,
514        end_version: Option<Version>,
515        cancellation_token: Option<&CancellationTokenRef>,
516    ) -> DeltaResult<Self> {
517        debug_assert!(
518            log_tail.iter().all(|entry| entry.is_commit()),
519            "log_tail should only contain commits"
520        );
521        let start = start_version.unwrap_or(0);
522        let end = end_version.unwrap_or(Version::MAX);
523        let fs_iter =
524            list_delta_log_from_storage(storage, log_root, start, end, cancellation_token)?;
525
526        let log_tail_start_version = log_tail.first().map(|f| f.version);
527        let mut listed_commits = Vec::new();
528        let mut max_published_version: Option<Version> = None;
529        // Filesystem commits, skipping any covered by the log_tail.
530        for file_result in fs_iter {
531            let file = file_result?;
532            if file.file_type != LogPathFileType::Commit {
533                continue;
534            }
535            max_published_version = max_published_version.max(Some(file.version));
536            if log_tail_start_version.is_some_and(|tail_start| file.version >= tail_start) {
537                continue;
538            }
539            should_process_log_file(&file); // warns if 0 bytes
540            listed_commits.push(file);
541        }
542
543        // Log_tail commits, extending the filesystem prefix in ascending order.
544        for file in log_tail {
545            if file.version < start || file.version > end {
546                continue;
547            }
548            if file.file_type == LogPathFileType::Commit {
549                max_published_version = max_published_version.max(Some(file.version));
550            }
551            listed_commits.push(file);
552        }
553
554        let latest_commit_file = listed_commits.last().cloned();
555        Ok(LogSegmentFiles {
556            ascending_commit_files: listed_commits,
557            latest_commit_file,
558            max_published_version,
559            ..Default::default()
560        })
561    }
562
563    /// List all commit and checkpoint files with versions above the provided `start_version`
564    /// (inclusive). If successful, this returns a `LogSegmentFiles`.
565    ///
566    /// The `log_tail` is an optional sequence of commits provided by the caller, e.g. via
567    /// [`SnapshotBuilder::with_log_tail`]. It may contain either published or staged commits. The
568    /// `log_tail` must strictly adhere to being a 'tail' — a contiguous cover of versions `X..=Y`
569    /// where `Y` is the latest version of the table. If it overlaps with commits listed from the
570    /// filesystem, the `log_tail` will take precedence for commits; non-commit files (CRC,
571    /// checkpoints, compactions) are always taken from the filesystem.
572    // TODO: encode some of these guarantees in the output types. e.g. we could have:
573    // - SortedCommitFiles: Vec<ParsedLogPath>, is_ascending: bool, end_version: Version
574    // - CheckpointParts: Vec<ParsedLogPath>, checkpoint_version: Version (guarantee all same
575    //   version)
576    #[instrument(name = "log.list", skip_all, fields(start = ?start_version, end = ?end_version), err)]
577    pub(crate) fn list(
578        storage: &dyn StorageHandler,
579        log_root: &Url,
580        log_tail: Vec<ParsedLogPath>,
581        start_version: Option<Version>,
582        end_version: Option<Version>,
583        cancellation_token: Option<&CancellationTokenRef>,
584    ) -> DeltaResult<Self> {
585        let start = start_version.unwrap_or(0);
586        let end = end_version.unwrap_or(Version::MAX);
587        let fs_iter =
588            list_delta_log_from_storage(storage, log_root, start, end, cancellation_token)?;
589        Self::build_log_segment_files(fs_iter, log_tail, start, end_version)
590    }
591
592    /// List all commit and checkpoint files after the provided checkpoint. It is guaranteed that
593    /// all the returned [`ParsedLogPath`]s will have a version less than or equal to the
594    /// `end_version`.
595    ///
596    /// The hint only tells us where to start listing; it never influences which checkpoint is
597    /// selected at a version. A hint that turns out to describe a different checkpoint than the one
598    /// selected is logged and ignored, not an error.
599    pub(crate) fn list_with_checkpoint_hint(
600        checkpoint_metadata: &LastCheckpointHint,
601        storage: &dyn StorageHandler,
602        log_root: &Url,
603        log_tail: Vec<ParsedLogPath>,
604        end_version: Option<Version>,
605        cancellation_token: Option<&CancellationTokenRef>,
606    ) -> DeltaResult<Self> {
607        let listed_files = Self::list(
608            storage,
609            log_root,
610            log_tail,
611            Some(checkpoint_metadata.version),
612            end_version,
613            cancellation_token,
614        )?;
615
616        let Some(latest_checkpoint) = listed_files.checkpoint_parts.last() else {
617            // The hint names a checkpoint that no longer exists, and because the listing started
618            // at the hinted version, no checkpoint exists at or after it either. The log was
619            // modified out of band: the checkpoint was deleted without clearing the hint, or the
620            // table was dropped and recreated at the same path.
621            //
622            // Kernel fails rather than retrying the listing from version 0, because that recovery
623            // is unsound. Listing only checks that the commits it finds are contiguous, not that
624            // they start at version 0, so two distinct kinds of damage survive as a plausible but
625            // wrong snapshot instead of an error:
626            //   - A deleted log prefix leaves a contiguous suffix with no checkpoint. If commits
627            //     0-40 and the checkpoint are removed but 41.. remain, listing from version 0
628            //     replays 41 as if it were the start of history, dropping every action before it.
629            //   - A recreated table yields a segment mixing files from two different tables. A
630            //     table with a checkpoint at commit 3 is dropped and recreated at the same path;
631            //     the drop leaves commits 0-2 behind, the new table writes its own 0, 1, 2, ...,
632            //     and listing from version 0 sees one contiguous sequence whose low versions belong
633            //     to two different tables and replays it as a single history.
634            return Err(Error::invalid_checkpoint(
635                "Had a _last_checkpoint hint but didn't find any checkpoints",
636            ));
637        };
638        if latest_checkpoint.version != checkpoint_metadata.version {
639            info!(
640            "_last_checkpoint hint is out of date. _last_checkpoint version: {}. Using actual most recent: {}",
641            checkpoint_metadata.version,
642            latest_checkpoint.version
643        );
644        } else if !checkpoint_metadata.applies_to(&listed_files.checkpoint_parts) {
645            // Expected whenever a writer checkpoints a version another writer already checkpointed
646            // and leaves the hint alone. `applies_to` also makes `LogSegment::checkpoint_hint`
647            // yield `None`, so this logs exactly when the hint's fields get dropped.
648            debug!(
649                version = checkpoint_metadata.version,
650                hint_parts = checkpoint_metadata.parts.unwrap_or(1),
651                selected_parts = listed_files.checkpoint_parts.len(),
652                selected_checkpoint_part = %latest_checkpoint.filename,
653                "_last_checkpoint hint describes a different checkpoint than the one selected at \
654                 this version; using the checkpoint file's own fields"
655            );
656        }
657        Ok(listed_files)
658    }
659
660    /// Returns a [`LogSegmentFiles`] ending at `end_version`, rooted at the most recent complete
661    /// checkpoint at or before `end_version`, or rooted at version 0 if no checkpoint is found.
662    ///
663    /// To find the checkpoint without a full forward listing from version 0, this scans backward
664    /// from `end_version` in windows of size [`BACKWARD_SCAN_WINDOW_SIZE`], stopping as soon as
665    /// a complete checkpoint is found (or version 0 is reached).
666    /// Then, all files from the windows that were scanned are combined with `log_tail` to produce a
667    /// log segment rooted at the checkpoint version (or version 0 if no checkpoint) with all
668    /// commits after the checkpoint version. A log_tail commit at exactly the checkpoint
669    /// version may be included at this stage but will be filtered out by `LogSegment::try_new`.
670    ///
671    /// For example, given the desired end_version = 12500 and a checkpoint at v8900:
672    /// - Window 1 [11501, 12501): no checkpoint -> continue
673    /// - Window 2 [10501, 11501): no checkpoint -> continue
674    /// - Window 3 [9501, 10501): no checkpoint -> continue
675    /// - Window 4 [8501, 9501): checkpoint at v8900 found -> stop
676    /// All files from windows 1-4 are combined with `log_tail` to produce a log segment
677    /// rooted at the checkpoint at v8900 with all commits from v8901 to v12500.
678    #[instrument(name = "log.list_with_backward_checkpoint_scan", skip_all, fields(end = end_version), err)]
679    pub(crate) fn list_with_backward_checkpoint_scan(
680        storage: &dyn StorageHandler,
681        log_root: &Url,
682        log_tail: Vec<ParsedLogPath>,
683        end_version: Version,
684        cancellation_token: Option<&CancellationTokenRef>,
685    ) -> DeltaResult<Self> {
686        // Scan backward in 1000-version windows, collecting ALL file types, until a complete
687        // checkpoint is found or the log is exhausted.
688        let mut windows: Vec<Vec<ParsedLogPath>> = Vec::new();
689        let mut found_checkpoint_version: Option<Version> = None;
690        // upper is the exclusive upper bound of the next window; adding 1 includes end_version
691        // in the first window. The inclusive range passed to list_delta_log_from_storage is
692        // [lower, upper - 1].
693        let mut upper = end_version + 1;
694        while upper > 0 {
695            // Each window is collected eagerly, so check between windows too: a long backward scan
696            // would otherwise keep going after cancellation.
697            check_cancelled(cancellation_token)?;
698            let lower = upper.saturating_sub(BACKWARD_SCAN_WINDOW_SIZE);
699            let window_files: Vec<_> = list_delta_log_from_storage(
700                storage,
701                log_root,
702                lower,
703                upper - 1,
704                cancellation_token,
705            )?
706            .try_collect()?;
707
708            found_checkpoint_version = find_complete_checkpoint_version(&window_files);
709            windows.push(window_files);
710
711            if found_checkpoint_version.is_some() {
712                break;
713            }
714            upper = lower;
715        }
716
717        let fs_iter = windows.into_iter().rev().flatten().map(Ok);
718        let start = found_checkpoint_version.unwrap_or(0);
719        Self::build_log_segment_files(fs_iter, log_tail, start, Some(end_version))
720    }
721}