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}