Repository navigation
Batch DuckLake data file registration in the catalog - #164
Merged
Merged
Conversation
Registering data files wrote every catalog row with a statement of its own: one per data file, one per partition value and one per column statistic, about 30 round trips to the catalog database per file of a table with 28 columns. A commit registering 1,000 files spent about 12 seconds waiting on them. The rows now go out as JDBC batches of up to 1,000 rows, which the driver pipelines, so a commit waits on a few round trips per thousand rows. The statistics kept for the whole table are updated and created in two batches the same way. This is shared by add_files, INSERT, CREATE TABLE AS and MERGE. The rows written are unchanged, which new tests pin for add_files and INSERT. add_files inspected at most 8 files at a time. Inspecting a file mostly waits on object storage for its length and its footer, so the limit is now the ducklake.add-files-procedure.max-concurrent-file-reads property, 32 by default. The check for files the catalog already records compared names with = ANY of an array parameter, which a generic plan evaluates by comparing every row with every name. It now uses a semi-join against the names, which the database hashes whatever plan it picks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
add_filesis slow when it registers many small files. In production, on a table with 28 columns and files of about 43 KB holding 200 rows each, registering 1,000 files took 19.8 s and registering 5,000 files took 104.8 s, not counting client overhead. About 63% of that was the commit, at roughly 13 ms per file; the rest was reading the files.The commit wrote every catalog row with a statement of its own: one per data file, one per partition value and one per column statistic. With 28 columns that is about 30 round trips to the catalog database per file, which at about 0.4 ms each accounts for the 13 ms. The same code path (
addDataFiles) registers the files of INSERT, CREATE TABLE AS and MERGE, which paid the same cost per file and per column.What changed
DuckLakeCommit.insertDataFilesregisters all files of a commit. It sends the rows ofducklake_data_file,ducklake_file_partition_valueandducklake_file_column_statsas JDBC batches of up to 1,000 rows, which the driver pipelines, so a commit waits on a few round trips per thousand rows rather than one per row. The batches are built and executed inside the commit action, so a retried commit builds them again from scratch, and the transaction is unchanged.writeTableColumnStatstakes every column at once: one batch of updates, then one batch of inserts for the columns no row was updated for. It now requires distinct columns, since every update runs before the first insert.ducklake_data_fileprimary key still surfaces with SQLState 23505, which the driver's batch exception carries, and is retried as before.add_filesread footers with at most 8 threads. A file costs a length lookup and a tail read on object storage, so the threads mostly wait: about 60 ms per file per thread in production. The limit is nowducklake.add-files-procedure.max-concurrent-file-reads, 32 by default, documented inducklake.md. A read in flight holds a footer buffer (48 KB by default) and one object store connection, so 32 keeps about 15 concurrent calls within the default S3 pool of 500 connections, and the coordinator does little CPU work per read.= ANY(:fileNames). PostgreSQL hashes the array only in a plan made for the bound values; a generic plan, which a statement prepared on the server can switch to after a few executions on a pooled connection, compares every row with every name. The check now usesIN (SELECT unnest(:fileNames)), which selects the same rows and hashes the names in every plan. On a synthetic table of 2 million recorded files with 5,000 names, the old form took 1.2 s with a custom plan and 19 s with a generic one; the new form takes 1.3 to 1.4 s with either. The scan itself remains, since nothing indexespath, and the regular expression is unchanged.Benchmark
BenchmarkDuckLakeAddFiles(test sources) registers copies of a 28-column, 200-row, 46 KB Parquet file throughadd_files, against the PostgreSQL container the tests use and files on the local file system. It splits the time the way the production measurement did: a second call with the same files reads them all and fails at the start of the commit, and the commit takes the rest.-Dround-trip=200usroutes the connector's catalog connections through a proxy that delays them; that measured 430 to 470 µs per statement, close to production. Medians of the commit time:Locally the read phase takes 50 to 300 ms and says nothing about object storage. Driving the file reader through a file system that adds 30 ms per request, 1,000 files took 8.4 s with 8 threads (production: 7.5 s), 4.2 s with 16, 2.1 s with 32 and 1.1 s with 64, so reading scales with the thread count.
Projected for production at 28 columns: 1,000 files in about 1.9 s of reading and 0.2 s of commit, down from 19.8 s; 5,000 files in about 9.4 s of reading and 1 s of commit, down from 104.8 s. Reading now dominates, and raising the property shortens it further.
Tests
TestDuckLakeDataFileRowspins every catalog row the commit writes: data files, partition values, file column statistics, name mappings, table statistics, table column statistics, the snapshot and the import receipt. One test does this for a partitionedadd_filesof four files that cover a shared and a separate name mapping, a file spanning partitions, NULL partition values, a missing column, NaN and unknown bounds. The other covers a partitioned INSERT, then an INSERT afterADD COLUMNthat updates the statistics of some columns and creates them for another. Sizes are read from the Parquet files themselves. Both tests also check that DuckDB reads the rows and the row ids the files were given. They were written and passing before the change; swapping two bound values makes both fail.TestDuckLakeAddFiles.testDeleteFilesCannotBeRegisteredcovers the delete file branch of the recorded file check.trino-ducklakesuite passes locally withTZ=UTC: 275 tests, no failures. The-P cichecks (checkstyle, modernizer, dependency analysis, SpotBugs, PMD, Javadoc) and Error Prone pass locally.🤖 Generated with Claude Code