Skip to content

DuckLake: enforce partition predicates on temporal keys - #18

Open
fuziontech wants to merge 2 commits into
james/ducklake-write-round-tripsfrom
james/ducklake-temporal-partition-predicates
Open

DuckLake: enforce partition predicates on temporal keys#18
fuziontech wants to merge 2 commits into
james/ducklake-write-round-tripsfrom
james/ducklake-temporal-partition-predicates

Conversation

@fuziontech

Copy link
Copy Markdown
Member

Stacked on #14 (test-only). Retarget to master once #14 merges; the diff here is the two top commits.

Summary

  • isEnforceableType admitted only integer, boolean and unbounded varchar partition keys, so a predicate on a DATE / TIMESTAMP / TIMESTAMP WITH TIME ZONE identity-partition column was only fail-open pruned, never enforced — and a whole-partition DELETE … WHERE day BETWEEN … on such a key read and rewrote every row of those partitions instead of ending the files. A customer's SQLMesh pipeline partitions 34 tables by a TIMESTAMPTZ day column and replaces day intervals every hour; this is its hot path.
  • Temporal identity keys are now enforceable when every visible file's recorded value for the key parses. applyFilter checks that with one SELECT DISTINCT partition_key_index, partition_value per constrained temporal key (one row per distinct value, not per file; a LEFT JOIN keeps "no value recorded" distinct from SQL NULL). If any value is undecidable (infinity, -infinity, BC dates, 5-digit years — exactly what StatsValueParser already refuses), the column stays unenforced for that statement: rows are still filtered, and a DELETE takes the row-level path for the whole statement, because Trino's delete is all-or-nothing per table and a metadata delete over the other files could otherwise leave matching rows behind.
  • year()/month()/day()/hour() transform partitions are unchanged: fail-open pruned, never enforced.
  • Write format untouched. Parser accepts what both writers produce: 2026-01-01, 2026-01-01 12:34:56[.mmm|.uuuuuu][+00|+02|+05:30]; a value without fractional seconds compares equal to the precision-6 predicate (2026-01-01 00:00:00+00TIMESTAMP '2026-01-01 00:00:00 UTC').

Tests

  • TestDuckLakeWriteRoundTrips: the DATE and TIMESTAMPTZ whole-day DELETE tests flip from "reads every row" to getProcessedInputPositions() == 0, files ended, no delete file, DuckDB round-trip kept; testDeletingWholeDaysBesideAnInfiniteDateLeavesItAlone pins the row-level fallback (exact files ended, no delete file, the infinity row intact in both engines, positions == rows of the deleted days); testReadingADayBesideAnInfiniteDatePrunesTheOtherDays covers read-side pruning with stats pruning off.
  • TestStatsValueParser: fail-open forms, signed vs bare 5-digit years, +05:30, and the precision pair above.
  • Module: 145 tests, 0 failures; -P ci clean.
  • Reviewed by a logic-review pass (APPROVE): enforcement cannot prune a file containing a matching row for the admitted types; fail-open never yields "no match"; the split manager's "enforced but undecidable" throw is unreachable because both sides read the same rows at the same pinned snapshot.

Follow-ups, not here

  • Exact sentinel mapping for infinity/-infinity (DuckDB's INT32_MAX days / INT64_MAX µs) would make such files decidable; needs its own decision since it relies on the encoding.
  • A per-transaction cache keyed by (snapshot, table, key) would remove the second DISTINCT query applyFilter's convergence call issues.
  • Docs: TIMESTAMP(p>6) and TIMESTAMPTZ(p≠6) are never enforced (parser returns empty); the Performance paragraph describes the precision-6 case.

https://claude.ai/code/session_01WCY5Jf2BQPCVKJTZU1TpEe

isEnforceableType excluded DATE and every timestamp type, so a DELETE
covering whole partitions of a daily key read and rewrote every row of
those partitions instead of ending their files. A pipeline that replaces
day intervals paid for a full scan on every run.

Identity partition keys of type DATE, TIMESTAMP(p) and TIMESTAMP(p) WITH
TIME ZONE are now enforced, with a fail-open parse. A value the
connector cannot read back is never guessed at in either direction: the
file is not pruned, and it is not counted as matching. DuckDB writes
four such values: infinity, -infinity, a BC date, and a year of five or
more digits.

The connector may not prune such a file, and it cannot apply a predicate
to the rows of a file on its own. So one of these values in a table
leaves the whole predicate with the engine, which keeps filtering.
Enforcement is therefore decided per table: applyFilter enforces a
temporal key only when the partition value of every visible data file
decides the column. It checks that over the same files, and through the
same code, that the split manager prunes by.

The parse itself does not change. StatsValueParser already read these
values as unknown. Its unit tests now pin that, along with the offset
forms a writer outside UTC records.

Transform partitions are unaffected. year, month, day and hour are not
identity keys, so predicates on them stay unenforced and their pruning
stays fail-open.

Claude-Session: https://claude.ai/code/session_01WCY5Jf2BQPCVKJTZU1TpEe
The check that every visible file's value parses listed the data files of
the table. That is one row per file on a table Portola files by day and
fills with thousands of files, paid at plan time, on every query with a
predicate on the day.

JdbcDuckLakeMetastore.distinctPartitionValues asks for the distinct
values of one partition key instead: one row per day, not one per file.
It returns each value in the shape DuckLakeDataFileEntry carries, so
PartitionTransforms still reads it, and a file recording nothing under
the key still comes back as the empty map that says so.

It is a separate query from the one the split manager prunes by, because
applyFilter decides enforcement before any file list is loaded. It runs
only for a temporal key that a predicate constrains, and it is the third
query enforcement already costs there.

The parser tests now pin the day key a pipeline writes: midnight with no
fractional seconds parses to the value TIMESTAMP '2026-01-01 00:00:00
UTC' carries at precision 6, and the same instant written with trailing
zeros parses to the same value.

A possible follow-up, deliberately not taken here: map infinity and
-infinity to the exact sentinels DuckDB encodes them as, INT32_MAX days
and INT64_MAX microseconds. Trino reads those same numbers back, so such
a file would become prunable and a whole-partition DELETE beside one
would stay on the metadata path. It would tie the connector to DuckDB's
sentinel encoding, so it needs its own decision.

Claude-Session: https://claude.ai/code/session_01WCY5Jf2BQPCVKJTZU1TpEe
@github-actions github-actions Bot added the docs label Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant