Skip to content

Commit 3fa9f0b

Browse files
authored
feat: Use compacted log files in log-replay (delta-io#950)
## What changes are proposed in this pull request? Make kernel use compacted files returned from listing to compute a cover for the range of required commits, favoring compaction files. We use a greedy algorithm in the case of overlaps, but do try to account for different sized compactions. If we find multiple compactions that start at the same version, we will pick the one that extends furthest. ## How was this change tested? Unit tests and e2e test in `read.rs`
1 parent 42586b1 commit 3fa9f0b

4 files changed

Lines changed: 294 additions & 36 deletions

File tree

kernel/src/log_segment.rs

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ use crate::schema::SchemaRef;
1515
use crate::snapshot::LastCheckpointHint;
1616
use crate::utils::require;
1717
use crate::{
18-
DeltaResult, Engine, EngineData, Error, Expression, ParquetHandler, Predicate, PredicateRef,
19-
RowVisitor, StorageHandler, Version,
18+
DeltaResult, Engine, EngineData, Error, Expression, FileMeta, ParquetHandler, Predicate,
19+
PredicateRef, RowVisitor, StorageHandler, Version,
2020
};
2121
use delta_kernel_derive::internal_api;
2222

2323
use itertools::Itertools;
24-
use tracing::warn;
24+
use tracing::{debug, warn};
2525
use url::Url;
2626

2727
#[cfg(test)]
@@ -225,17 +225,15 @@ impl LogSegment {
225225
checkpoint_read_schema: SchemaRef,
226226
meta_predicate: Option<PredicateRef>,
227227
) -> DeltaResult<impl Iterator<Item = DeltaResult<ActionsBatch>> + Send> {
228-
// `replay` expects commit files to be sorted in descending order, so we reverse the sorted
229-
// commit files
230-
let commit_files: Vec<_> = self
231-
.ascending_commit_files
232-
.iter()
233-
.rev()
234-
.map(|f| f.location.clone())
235-
.collect();
228+
// `replay` expects commit files to be sorted in descending order, so the return value here is correct
229+
let commits_and_compactions = self.find_commit_cover();
236230
let commit_stream = engine
237231
.json_handler()
238-
.read_json_files(&commit_files, commit_read_schema, meta_predicate.clone())?
232+
.read_json_files(
233+
&commits_and_compactions,
234+
commit_read_schema,
235+
meta_predicate.clone(),
236+
)?
239237
.map_ok(|batch| ActionsBatch::new(batch, true));
240238

241239
let checkpoint_stream =
@@ -244,6 +242,49 @@ impl LogSegment {
244242
Ok(commit_stream.chain(checkpoint_stream))
245243
}
246244

245+
/// find a minimal set to cover the range of commits we want. This is greedy so not always
246+
/// optimal, but we assume there are rarely overlapping compactions so this is okay. NB: This
247+
/// returns files is DESCENDING ORDER, as that's what `replay` expects. This function assumes
248+
/// that all files in `self.ascending_commit_files` and `self.ascending_compaction_files` are in
249+
/// range for this log segment. This invariant is maintained by our listing code.
250+
fn find_commit_cover(&self) -> Vec<FileMeta> {
251+
// Create an iterator sorted in ascending order by (initial version, end version), e.g.
252+
// [00.json, 00.09.compacted.json, 00.99.compacted.json, 01.json, 02.json, ..., 10.json,
253+
// 10.19.compacted.json, 11.json, ...]
254+
let all_files = itertools::Itertools::merge_by(
255+
self.ascending_commit_files.iter(),
256+
self.ascending_compaction_files.iter(),
257+
|path_a, path_b| path_a.version <= path_b.version,
258+
);
259+
260+
let mut last_pushed: Option<&ParsedLogPath> = None;
261+
262+
let mut selected_files = vec![];
263+
for next in all_files {
264+
match last_pushed {
265+
// Resolve version number ties in favor of the later file (it covers a wider range)
266+
Some(prev) if prev.version == next.version => {
267+
let removed = selected_files.pop();
268+
debug!("Selecting {next:?} rather than {removed:?}, it covers a wider range");
269+
}
270+
// Skip later files whose start overlaps with the previous end
271+
Some(&ParsedLogPath {
272+
file_type: LogPathFileType::CompactedCommit { hi },
273+
..
274+
}) if next.version <= hi => {
275+
debug!("Skipping log file {next:?}, it's already covered.");
276+
continue;
277+
}
278+
_ => {} // just fall through
279+
}
280+
debug!("Provisionally selecting {next:?}");
281+
last_pushed = Some(next);
282+
selected_files.push(next.location.clone());
283+
}
284+
selected_files.reverse();
285+
selected_files
286+
}
287+
247288
/// Returns an iterator over checkpoint data, processing sidecar files when necessary.
248289
///
249290
/// By default, `create_checkpoint_stream` checks for the presence of sidecar files, and

kernel/src/log_segment/tests.rs

Lines changed: 222 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::{path::PathBuf, sync::Arc};
44
use crate::object_store::{memory::InMemory, path::Path, ObjectStore};
55
use futures::executor::block_on;
66
use itertools::Itertools;
7+
use test_log::test;
78
use url::Url;
89

910
use crate::actions::visitors::AddVisitor;
@@ -27,7 +28,7 @@ use crate::snapshot::LastCheckpointHint;
2728
use crate::utils::test_utils::{assert_batch_matches, Action};
2829
use crate::{
2930
DeltaResult, Engine as _, EngineData, Expression, FileMeta, PredicateRef, RowVisitor,
30-
StorageHandler, Table,
31+
StorageHandler, Table, Version,
3132
};
3233
use test_utils::{compacted_log_path_for_versions, delta_path_for_version};
3334

@@ -1325,12 +1326,12 @@ fn test_create_checkpoint_stream_reads_checkpoint_file_and_returns_sidecar_batch
13251326
Ok(())
13261327
}
13271328

1328-
fn test_compaction_listing(
1329+
fn create_segment_for(
13291330
commit_versions: &[u64],
13301331
compaction_versions: &[(u64, u64)],
13311332
checkpoint_version: Option<u64>,
13321333
version_to_load: Option<u64>,
1333-
) {
1334+
) -> LogSegment {
13341335
let mut paths: Vec<Path> = commit_versions
13351336
.iter()
13361337
.map(|version| delta_path_for_version(*version, "json"))
@@ -1347,10 +1348,46 @@ fn test_compaction_listing(
13471348
));
13481349
}
13491350
let (storage, log_root) = build_log_with_paths_and_checkpoint(&paths, None);
1350-
let log_segment =
1351-
LogSegment::for_snapshot(storage.as_ref(), log_root.clone(), None, version_to_load)
1352-
.unwrap();
1351+
LogSegment::for_snapshot(storage.as_ref(), log_root.clone(), None, version_to_load).unwrap()
1352+
}
13531353

1354+
#[test]
1355+
fn test_list_log_files_with_version() -> DeltaResult<()> {
1356+
let (storage, log_root) = build_log_with_paths_and_checkpoint(
1357+
&[
1358+
delta_path_for_version(0, "json"),
1359+
delta_path_for_version(0, "crc"),
1360+
delta_path_for_version(1, "json"),
1361+
delta_path_for_version(1, "crc"),
1362+
delta_path_for_version(2, "json"),
1363+
],
1364+
None,
1365+
);
1366+
let result = list_log_files_with_version(storage.as_ref(), &log_root, Some(0), None)?;
1367+
let latest_crc = result.latest_crc_file.unwrap();
1368+
assert_eq!(
1369+
latest_crc.location.location.path(),
1370+
"/_delta_log/00000000000000000001.crc".to_string()
1371+
);
1372+
assert_eq!(latest_crc.version, 1);
1373+
assert_eq!(latest_crc.filename, "00000000000000000001.crc".to_string());
1374+
assert_eq!(latest_crc.extension, "crc".to_string());
1375+
assert_eq!(latest_crc.file_type, LogPathFileType::Crc);
1376+
Ok(())
1377+
}
1378+
1379+
fn test_compaction_listing(
1380+
commit_versions: &[u64],
1381+
compaction_versions: &[(u64, u64)],
1382+
checkpoint_version: Option<u64>,
1383+
version_to_load: Option<u64>,
1384+
) {
1385+
let log_segment = create_segment_for(
1386+
commit_versions,
1387+
compaction_versions,
1388+
checkpoint_version,
1389+
version_to_load,
1390+
);
13541391
let version_to_load = version_to_load.unwrap_or(u64::MAX);
13551392
let checkpoint_cuttoff = checkpoint_version.map(|v| v as i64).unwrap_or(-1);
13561393
let expected_commit_versions: Vec<&u64> = commit_versions
@@ -1478,27 +1515,188 @@ fn test_compaction_starts_at_checkpoint() {
14781515
);
14791516
}
14801517

1518+
enum ExpectedFile {
1519+
Commit(Version),
1520+
Compaction(Version, Version),
1521+
}
1522+
1523+
fn test_commit_cover(
1524+
commit_versions: &[u64],
1525+
compaction_versions: &[(u64, u64)],
1526+
checkpoint_version: Option<u64>,
1527+
version_to_load: Option<u64>,
1528+
expected_files: &[ExpectedFile],
1529+
) {
1530+
let log_segment = create_segment_for(
1531+
commit_versions,
1532+
compaction_versions,
1533+
checkpoint_version,
1534+
version_to_load,
1535+
);
1536+
let cover = log_segment.find_commit_cover();
1537+
// our test-utils include "_delta_log" in the path, which is already in log_segment.log_root, so
1538+
// we don't use them. TODO: Unify this
1539+
let expected_locations = expected_files.iter().map(|ef| match ef {
1540+
ExpectedFile::Commit(version) => log_segment
1541+
.log_root
1542+
.join(&format!("{version:020}.json"))
1543+
.expect("Couldn't join"),
1544+
ExpectedFile::Compaction(lo, hi) => log_segment
1545+
.log_root
1546+
.join(&format!("{lo:020}.{hi:020}.compacted.json"))
1547+
.expect("Couldn't join"),
1548+
});
1549+
assert_eq!(cover.len(), expected_locations.len());
1550+
for (location, expected_location) in cover.iter().zip(expected_locations) {
1551+
assert_eq!(location.location, expected_location);
1552+
}
1553+
}
1554+
14811555
#[test]
1482-
fn test_list_log_files_with_version() -> DeltaResult<()> {
1483-
let (storage, log_root) = build_log_with_paths_and_checkpoint(
1556+
fn test_commit_cover_one_compaction() {
1557+
test_commit_cover(
1558+
&[0, 1, 2],
1559+
&[(1, 2)],
1560+
None, // checkpoint version
1561+
None, // version to load
1562+
&[ExpectedFile::Compaction(1, 2), ExpectedFile::Commit(0)],
1563+
);
1564+
}
1565+
1566+
#[test]
1567+
fn test_commit_cover_in_version_range() {
1568+
test_commit_cover(
1569+
&[0, 1, 2, 3],
1570+
&[(1, 2)],
1571+
None, // checkpoint version
1572+
Some(2), // version to load
1573+
&[ExpectedFile::Compaction(1, 2), ExpectedFile::Commit(0)],
1574+
);
1575+
}
1576+
1577+
#[test]
1578+
fn test_commit_cover_out_of_version_range() {
1579+
test_commit_cover(
1580+
&[0, 1, 2, 3, 4],
1581+
&[(1, 3)],
1582+
None, // checkpoint version
1583+
Some(2), // version to load
14841584
&[
1485-
delta_path_for_version(0, "json"),
1486-
delta_path_for_version(0, "crc"),
1487-
delta_path_for_version(1, "json"),
1488-
delta_path_for_version(1, "crc"),
1489-
delta_path_for_version(2, "json"),
1585+
ExpectedFile::Commit(2),
1586+
ExpectedFile::Commit(1),
1587+
ExpectedFile::Commit(0),
14901588
],
1491-
None,
14921589
);
1493-
let result = list_log_files_with_version(storage.as_ref(), &log_root, Some(0), None)?;
1494-
let latest_crc = result.latest_crc_file.unwrap();
1495-
assert_eq!(
1496-
latest_crc.location.location.path(),
1497-
"/_delta_log/00000000000000000001.crc".to_string()
1590+
}
1591+
1592+
#[test]
1593+
fn test_commit_cover_multi_compaction() {
1594+
test_commit_cover(
1595+
&[0, 1, 2, 3, 4, 5],
1596+
&[(1, 2), (3, 5)],
1597+
None, // checkpoint version
1598+
None, //version to load
1599+
&[
1600+
ExpectedFile::Compaction(3, 5),
1601+
ExpectedFile::Compaction(1, 2),
1602+
ExpectedFile::Commit(0),
1603+
],
1604+
);
1605+
}
1606+
1607+
#[test]
1608+
fn test_commit_cover_multi_compaction_one_out_of_range() {
1609+
test_commit_cover(
1610+
&[0, 1, 2, 3, 4, 5],
1611+
&[(1, 2), (3, 5)],
1612+
None, // checkpoint version
1613+
Some(4), // version to load
1614+
&[
1615+
ExpectedFile::Commit(4),
1616+
ExpectedFile::Commit(3),
1617+
ExpectedFile::Compaction(1, 2),
1618+
ExpectedFile::Commit(0),
1619+
],
1620+
);
1621+
}
1622+
1623+
#[test]
1624+
fn test_commit_cover_compaction_with_checkpoint() {
1625+
test_commit_cover(
1626+
&[0, 1, 2, 4, 5],
1627+
&[(1, 2), (4, 5)],
1628+
Some(3), // checkpoint version
1629+
None, // version to load
1630+
&[ExpectedFile::Compaction(4, 5)],
1631+
);
1632+
}
1633+
1634+
#[test]
1635+
fn test_commit_cover_too_early_with_checkpoint() {
1636+
test_commit_cover(
1637+
&[0, 1, 2, 4, 5],
1638+
&[(1, 2)],
1639+
Some(3), // checkpoint version
1640+
None, // version to load
1641+
&[ExpectedFile::Commit(5), ExpectedFile::Commit(4)],
1642+
);
1643+
}
1644+
1645+
#[test]
1646+
fn test_commit_cover_starts_at_checkpoint() {
1647+
test_commit_cover(
1648+
&[0, 1, 2, 4, 5],
1649+
&[(3, 5)],
1650+
Some(3), // checkpoint version
1651+
None, // version to load
1652+
&[ExpectedFile::Commit(5), ExpectedFile::Commit(4)],
1653+
);
1654+
}
1655+
1656+
#[test]
1657+
fn test_commit_cover_wider_range() {
1658+
test_commit_cover(
1659+
&Vec::from_iter(0..20),
1660+
&[(0, 5), (0, 10), (5, 10), (13, 19)],
1661+
None, // checkpoint version
1662+
None, // version to load
1663+
&[
1664+
ExpectedFile::Compaction(13, 19),
1665+
ExpectedFile::Commit(12),
1666+
ExpectedFile::Commit(11),
1667+
ExpectedFile::Compaction(0, 10),
1668+
],
1669+
);
1670+
}
1671+
1672+
#[test]
1673+
fn test_commit_cover_no_compactions() {
1674+
test_commit_cover(
1675+
&Vec::from_iter(0..4),
1676+
&[],
1677+
None, // checkpoint version
1678+
None, // version to load
1679+
&[
1680+
ExpectedFile::Commit(3),
1681+
ExpectedFile::Commit(2),
1682+
ExpectedFile::Commit(1),
1683+
ExpectedFile::Commit(0),
1684+
],
1685+
);
1686+
}
1687+
1688+
#[test]
1689+
fn test_commit_cover_minimal_overlap() {
1690+
test_commit_cover(
1691+
&Vec::from_iter(0..6),
1692+
&[(0, 2), (2, 5)],
1693+
None, // checkpoint version
1694+
None, // version to load
1695+
&[
1696+
ExpectedFile::Commit(5),
1697+
ExpectedFile::Commit(4),
1698+
ExpectedFile::Commit(3),
1699+
ExpectedFile::Compaction(0, 2),
1700+
],
14981701
);
1499-
assert_eq!(latest_crc.version, 1);
1500-
assert_eq!(latest_crc.filename, "00000000000000000001.crc".to_string());
1501-
assert_eq!(latest_crc.extension, "crc".to_string());
1502-
assert_eq!(latest_crc.file_type, LogPathFileType::Crc);
1503-
Ok(())
15041702
}
12.6 KB
Binary file not shown.

0 commit comments

Comments
 (0)