Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: incomplete multi-part checkpoint handling when no hint is provided #641

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 62 additions & 22 deletions kernel/src/log_segment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@
//! files.

use crate::actions::{get_log_schema, Metadata, Protocol, METADATA_NAME, PROTOCOL_NAME};
use crate::path::ParsedLogPath;
use crate::path::{LogPathFileType, ParsedLogPath};
use crate::schema::SchemaRef;
use crate::snapshot::CheckpointMetadata;
use crate::utils::require;
use crate::{
DeltaResult, Engine, EngineData, Error, Expression, ExpressionRef, FileSystemClient, Version,
};
use itertools::Itertools;
use std::cmp::Ordering;
use std::convert::identity;
use std::sync::{Arc, LazyLock};
use tracing::warn;
Expand Down Expand Up @@ -313,28 +312,33 @@ fn list_log_files_with_version(
let mut checkpoint_parts = vec![];
let mut max_checkpoint_version = start_version;

for parsed_path in list_log_files(fs_client, log_root, start_version, end_version)? {
let parsed_path = parsed_path?;
if parsed_path.is_commit() {
commit_files.push(parsed_path);
} else if parsed_path.is_checkpoint() {
let path_version = parsed_path.version;
match max_checkpoint_version {
None => {
checkpoint_parts.push(parsed_path);
max_checkpoint_version = Some(path_version);
}
Some(checkpoint_version) => match path_version.cmp(&checkpoint_version) {
Ordering::Greater => {
max_checkpoint_version = Some(path_version);
checkpoint_parts.clear();
checkpoint_parts.push(parsed_path);
}
Ordering::Equal => checkpoint_parts.push(parsed_path),
Ordering::Less => {}
},
let log_files = list_log_files(fs_client, log_root, start_version, end_version)?;

for (version, files) in &log_files
.filter_map(|res| match res {
Ok(path) => Some(path),
Err(e) => {
warn!("Error processing path: {:?}", e);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skipping ParsedLogPath::try_from() errors here as we were already filtering them out

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think it's dangerous to skip the errors here. It's best to try to return the error somehow. Does it work if you do something like chunk_by(|path| path.map(|x| x.version)).

The proposed approach tries chunking by DeltaResult<Version> instead of chunking by Version. The hope is to return the Err if we encounter it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chunk_by function requires that the keys it uses for grouping implement the PartialEq trait so it can compare them, but the Error in the DeltaResult does not implement the trait so it doesn't work...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use try_collect to handle errors before grouping.

let log_files = list_log_files(fs_client, log_root, start_version, end_version)?;

let log_files: Vec<ParsedLogPath> = log_files.try_collect()?;

for (version, files) in &log_files.into_iter().chunk_by(|path| path.version)

Copy link
Collaborator

@OussamaSaoudi OussamaSaoudi Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was rly tricky and I got nerdsniped thinking about the iterator stuff lol. But look into process_result.

I think I might've adapted your code right, but double check:

    let mut checkpoint_parts = vec![];
    let mut max_checkpoint_version = start_version;
    let mut commit_files = Vec::with_capacity(10);
    process_results(log_files, |iter| {
        let log_files = iter.chunk_by(move |x| x.version);

        for (version, files) in &log_files {
            let mut new_checkpoint_parts = vec![];

            for file in files {
                if file.is_commit() {
                    commit_files.push(file);
                } else if file.is_checkpoint() {
                    new_checkpoint_parts.push(file);
                }
            }
            if validate_checkpoint_parts(version, &new_checkpoint_parts) {
                max_checkpoint_version = Some(version);
                checkpoint_parts = new_checkpoint_parts;
            }
        }
    })?;
    Ok((commit_files, checkpoint_parts))

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: With ordered listing, checkpoint files always come before commit files. So in theory, we know whether we have a complete checkpoint by the time we encounter the commit file of a given version. Not sure if that allows simpler code or not tho.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use try collect and propagate the errors.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely agreed that we want to propagate errors. I wanted to avoid try_collect on all the log files because there could be a lot of them. process_results + chunky_by should only have 1 commit at a time in memory.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice find withprocess_results, it works nicely!

Copy link
Collaborator Author

@sebastiantia sebastiantia Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearing the commit files when encountering complete checkpoints makes sense, and thank you for the helpful context @scovich

None
}
})
.chunk_by(|path| path.version)
{
let mut new_checkpoint_parts = vec![];

for file in files {
if file.is_commit() {
commit_files.push(file);
} else if file.is_checkpoint() {
new_checkpoint_parts.push(file);
}
}
if validate_checkpoint_parts(version, &new_checkpoint_parts)
&& (max_checkpoint_version.is_none() || Some(version) >= max_checkpoint_version)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can just validate. iirc the listing should be in order. So the latest checkpoint should always be greater.

Copy link
Collaborator Author

@sebastiantia sebastiantia Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm right, looks like we were assuming that to begin with as we returned commit files in the order given from list_log_files, and soon after require that they are ascending in version # here...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gets rid of max_checkpoint_version entirely :)

{
max_checkpoint_version = Some(version);
checkpoint_parts = new_checkpoint_parts;
}
}

Ok((commit_files, checkpoint_parts))
Expand Down Expand Up @@ -377,3 +381,39 @@ fn list_log_files_with_checkpoint(
}
Ok((commit_files, checkpoint_parts))
}

/// Validates that all the checkpoint parts belong to the same checkpoint version and that all parts
/// are present. Returns `true` if we have a complete checkpoint, `false` otherwise.
fn validate_checkpoint_parts(version: u64, checkpoint_parts: &Vec<ParsedLogPath>) -> bool {
if checkpoint_parts.is_empty() {
return false;
}

match checkpoint_parts.last().map(|file| &file.file_type) {
Some(LogPathFileType::MultiPartCheckpoint { num_parts, .. }) => {
if *num_parts as usize != checkpoint_parts.len() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it may be worth checking that:

  1. all the checkpoint parts are indeed of type MultiPartCheckpoint
  2. that the set of multi-part checkpoints has parts 0..n.

@zachschuermann what do you think?

Copy link
Collaborator

@scovich scovich Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it's actually 1..=n. And yes, we should check. Also have to be careful because technically there could be two incomplete checkpoints with different num_parts for the same version. Also, we MUST accept at most one checkpoint -- even if multiple complete checkpoints are available -- so this function needs to filter, not just check.

Unfortunately, the poorly-chosen naming convention for multi-part checkpoint files means they interleave:

00000000000000000010.checkpoint.0000000001.0000000003.parquet
00000000000000000010.checkpoint.0000000001.0000000004.parquet
00000000000000000010.checkpoint.0000000002.0000000003.parquet
00000000000000000010.checkpoint.0000000002.0000000004.parquet
00000000000000000010.checkpoint.0000000003.0000000003.parquet
00000000000000000010.checkpoint.0000000003.0000000004.parquet
00000000000000000010.checkpoint.0000000004.0000000004.parquet

... which makes it a lot harder to identify the files of a given checkpoint and also means we can't just return a subslice in case there were multiple checkpoints to choose from.

We'd probably need to build a hash map keyed by number of parts:

let mut checkpoints = HashMap::new();
for part_file in checkpoint_parts {
    use LogPathFileType::*;
    match &file.file_type {
        SinglePartCheckpoint 
            | UuidCheckpoint(_) 
            | MultiPartCheckpoint { part_num: 1, num_parts: 1 } => 
        {
           // All single-file checkpoints are equivalent, just keep one
           checkpoints.insert(1, vec![part_file]);
        }
        MultiPartCheckpoint { part_num: 1, num_parts } => {
            // Start a new multi-part checkpoint with at least 2 parts
            checkpoints.insert(num_parts, vec![part_file]);
        }
        MultiPartCheckpoint { part_num, num_parts } => {
            // Continue a new multi-part checkpoint with at least 2 parts
            if let Some(part_files) = checkpoints.get_mut(num_parts) {
                if part_num == 1 + part_files.size() {
                    // Safe to append because all previous parts exist
                    part_files.append(part_file); 
                }
            }
        }
        Commit | CompactedCommit { .. } | Unknown => {} // invalid file type => do nothing
    } 
}
checkpoints
    .into_iter()
    .find(|(num_parts, part_files)| part_files.len() == num_parts)
    .map_or(vec![], |(_, part_files)| part_files)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this reminds me to use match statements to their full power in the future. Thx for the example Ryan!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yes, did not consider the multiple incomplete checkpoints. I'll introduce tests to cover some of these scenarios. And thanks a lot for the example!

warn!(
"Found a multi-part checkpoint at version {}. Found {} parts, expected {}",
version,
checkpoint_parts.len(),
num_parts
);
return false;
}
}
Some(LogPathFileType::SinglePartCheckpoint) => {
if checkpoint_parts.len() != 1 {
warn!(
"Found a single-part checkpoint at version {}. Found {} parts",
version,
checkpoint_parts.len()
);
return false;
}
}
// TODO: Include UuidCheckpoint once we actually support v2 checkpoints
_ => {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think uuidcheckpoint should return false, since we can't read that checkpoint. In general, beware catchall cases in match statements

I also wonder if we should panic/error if we ever get a commit file here, since that should not be happening.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, good catch. I think returning an Error in the catchall case would be a good idea as we really should not get anything other than LogPathFileType::SinglePartCheckpoint or LogPathFileType::MultiPartCheckpoint here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say handle the Uuid case separately from the catchall. Leave a comment that says this case will be supported in CheckpointV2.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+10 avoid catchall in match statements. Better to enumerate the known-invalid cases, so that when a new case shows up the compiler forces us to categorize it.

}

true
}
47 changes: 44 additions & 3 deletions kernel/src/log_segment/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,8 @@ fn build_snapshot_with_bad_checkpoint_hint_fails() {
assert!(log_segment.is_err())
}

#[ignore]
#[test]
fn build_snapshot_with_missing_checkpoint_part_no_hint() {
// TODO: Handle checkpoints correctly so that this test passes: https://github.com/delta-io/delta-kernel-rs/issues/497

// Part 2 of 3 is missing from checkpoint 5. The Snapshot should be made of checkpoint
// number 3 and commit files 4 to 7.
let (client, log_root) = build_log_with_paths_and_checkpoint(
Expand Down Expand Up @@ -296,6 +293,50 @@ fn build_snapshot_with_missing_checkpoint_part_no_hint() {
assert_eq!(versions, expected_versions);
}

#[test]
fn build_snapshot_with_out_of_date_last_checkpoint_and_incomplete_recent_checkpoint() {
// When the _last_checkpoint is out of date and the most recent checkpoint is incomplete, the
// Snapshot should be made of the most recent complete checkpoint and the commit files that
// follow it.
let checkpoint_metadata = CheckpointMetadata {
version: 3,
size: 10,
parts: None,
size_in_bytes: None,
num_of_add_files: None,
checkpoint_schema: None,
checksum: None,
};

let (client, log_root) = build_log_with_paths_and_checkpoint(
&[
delta_path_for_version(0, "json"),
delta_path_for_version(1, "checkpoint.parquet"),
delta_path_for_version(2, "json"),
delta_path_for_version(3, "checkpoint.parquet"),
delta_path_for_version(4, "json"),
delta_path_for_multipart_checkpoint(5, 1, 3),
// Part 2 is missing!
delta_path_for_multipart_checkpoint(5, 3, 3),
delta_path_for_version(5, "json"),
delta_path_for_version(6, "json"),
delta_path_for_version(7, "json"),
],
Some(&checkpoint_metadata),
);

let log_segment =
LogSegment::for_snapshot(client.as_ref(), log_root, checkpoint_metadata, None).unwrap();
let commit_files = log_segment.ascending_commit_files;
let checkpoint_parts = log_segment.checkpoint_parts;

assert_eq!(checkpoint_parts.len(), 1);

let versions = commit_files.into_iter().map(|x| x.version).collect_vec();
let expected_versions = vec![4, 5, 6, 7];
assert_eq!(versions, expected_versions);
}

#[test]
fn build_snapshot_without_checkpoints() {
let (client, log_root) = build_log_with_paths_and_checkpoint(
Expand Down
Loading