Fix nested timetz reads from Iceberg - #482
Conversation
Signed-off-by: Mark Nefedov <mvnefedov@avito.ru>
|
Were things being written out correctly? Like did this result in data drift in updates? |
Signed-off-by: Mark Nefedov <mvnefedov@avito.ru>
|
Initial writes were always correct. All write paths UTC-normalize from the authoritative PostgreSQL value (row-by-row via But yes, updates could drift persisted data. The row-by-row UPDATE path (scan → I've added a regression test that locks this in at the raw-storage level: it writes identical nested values from a UTC and a non-UTC session, verifies the stored digits match, then updates each row from its session and re-reads the new snapshot's raw values ( |
Signed-off-by: Mark Nefedov <mvnefedov@avito.ru>
Signed-off-by: Mark Nefedov <mvnefedov@avito.ru>
|
Found a regression in this PR, |
Signed-off-by: Mark Nefedov <mvnefedov@avito.ru>
|
Added tests and rerun full suite, should be fine now. |
There was a problem hiding this comment.
The diagnosis is right and the shape of the fix is the right one: replacing the two
hand-rolled special cases (BuildStructWithIntervalProjection,
BuildMapWithIntervalProjection) with a single recursive
AppendIcebergReadConversion that mirrors the write path's AppendRewriteExpression
is a real simplification, and it makes depths that were previously broken or
unreachable work. I verified the underlying bug and the fix by hand:
COPY-ing a composite with a timetz leaf out of Iceberg returns 05:00-04 on
main (wrong instant) and the correct 05:00+00 with this branch.
One blocking regression, then documentation drift, then cleanups.
1. Blocking: COPY ... FROM '<iceberg metadata>' hard-fails on nested timetz
Binder Error: Column "s" referenced that exists in the SELECT clause -
but this column cannot be referenced before it is defined
Reproducer:
CREATE TYPE slot AS (label text, at_time timetz);
CREATE TABLE src (id int, s slot) USING iceberg;
CREATE TABLE dst (id int, s slot) USING iceberg;
INSERT INTO src VALUES (1, ROW('open', '09:00:00+04')::slot);
COPY dst FROM '<src metadata_location>' WITH (FORMAT 'iceberg'); -- errorsCause: the new generic block in TupleDescToProjectionList inherits the legacy
alias suppression from the code it replaced:
char *columnAliasString =
!addCast ? psprintf(" AS %s", duckdb_quote_identifier(columnName)) : "";Under addCast (the COPY FROM pushdown path) the pre-PR expression was
"s"::STRUCT(label VARCHAR, at_time TIME) — a cast of a bare column reference, which
DuckDB implicitly still names s, so the write-side rewrite wrapper could bind it.
The new CASE WHEN s IS NOT NULL THEN struct_pack(...) ELSE NULL END gets an
auto-generated name instead, and the wrapper's reference to s no longer resolves.
The emitted SQL shows it plainly — id carries its alias, the struct column does not:
SELECT id, ... struct_pack(label := s.label, at_time := CAST(...)) AS s
FROM (SELECT id::INTEGER AS id,
CASE WHEN s IS NOT NULL THEN struct_pack(...) ELSE NULL END
FROM read_parquet(...))The !addCast comment on BuildColumnProjection justifies the suppression with "the
caller is going to do its own casting or alias", but in this path nobody does.
This is not purely new: an interval leaf hits the same wall on main today (I
confirmed it), because BuildStructWithIntervalProjection had the identical alias
pattern. So the PR widens a pre-existing bug from interval to any nested
timetz — but since it unifies both into one block, one fix covers both.
Suggested fix — do what BuildStorageToSurfaceProjection already does ~100 lines
above (cast to the surface type, always alias), which also restores the type
enforcement addCast exists to provide:
if (nativeProjection != NULL)
{
- char *columnAliasString =
- !addCast ? psprintf(" AS %s", duckdb_quote_identifier(columnName)) : "";
+ const char *castTargetType =
+ GetFullDuckDBTypeNameForPGType(MakePGType(columnTypeId,
+ columnTypeMod),
+ DATA_FORMAT_PARQUET);
if (hasColumns)
appendStringInfoString(&projection, ", ");
- appendStringInfo(&projection, "%s%s",
- nativeProjection, columnAliasString);
+ appendStringInfo(&projection, "CAST(%s AS %s) AS %s",
+ nativeProjection, castTargetType,
+ duckdb_quote_identifier(columnName));
hasColumns = true;
continue;
}With this applied the timetz COPY returns the correct 05:00:00+00, the
pre-existing interval COPY failure is fixed as well, and 134 tests across
test_iceberg_timetz_type, test_iceberg_interval_type, test_iceberg_uuid_compat,
test_domain, test_iceberg_empty_table, test_compatibility_mode,
test_duckdb_reserved_keywords, test_iceberg_types and test_complex_types pass.
Worth a regression test: COPY dst FROM '<iceberg metadata>' WITH (FORMAT 'iceberg')
into a target holding both a composite-with-timetz and a composite-with-interval.
2. The PR description and a test docstring describe code that was reverted
Commit b1dfa54 ("Preserve timetz typing in Iceberg write rewrites") restored
CAST(CAST((%s) AS TIMETZ) AT TIME ZONE 'UTC' AS TIME) on the write side, but:
- the PR description still says the write path drops the inner
CAST(... AS TIMETZ); _assert_timetz_utc_cast_in_explain's docstring (rewritten in9ec998d) still
claims the wrapper emitsCAST((<expr>) AT TIME ZONE 'UTC' AS TIME).
The assertion itself only greps for AT TIME ZONE 'UTC' AS TIME, so it passes under
either form and doesn't catch the drift. Please re-sync both before merge — the
description is what reviewers and git log will read later.
3. No new interval test, though the interval read path was rewritten wholesale
Interval no longer goes through BuildStructWithIntervalProjection /
BuildMapWithIntervalProjection (both deleted) but through the new recursion, and all
519 new test lines are timetz. test_iceberg_interval_type.py has no
array-of-composite-with-interval and no map-with-composite-value case — exactly the
depths the deleted code got wrong (it emitted {field: "col"."field"} against a
list). I checked those depths do work now, so this is about locking in the newly
gained coverage rather than a suspected defect.
4. GetEmptyIcebergInputTypeName does discarded work at every node
DuckDBTypeInfo duckdbType = ChooseCompatibleDuckDBType(typeOid, typmod, DATA_FORMAT_ICEBERG, false);
DuckDBTypeInfo storageType = GuessStorageType(duckdbType, DATA_FORMAT_ICEBERG);
if (!TypeNeedsIcebergReadConversion(typeOid))
return storageType.typeName;Both calls render the full nested type name and are thrown away for every node that
does need conversion; they belong below the early-out. Related:
AppendIcebergReadConversion re-runs TypeNeedsIcebergReadConversion — itself a full
subtree walk — at each level, so a wide composite costs O(depth x subtree) syscache
lookups per planned query. Harmless at depth 2, worth a thought.
5. QuoteDuckDBStructKeySQL is now dead code
It was introduced by #297 specifically for the struct-literal projection builder this
PR deletes, and has no callers left. Switching to duckdb_quote_identifier is the
correct call, since struct_pack(name := ...) takes identifiers rather than string
literals — I verified a composite with field names containing '', "" and the
DuckDB reserved word pivot round-trips correctly. Please drop the function, its
declaration in struct_conversion.h, and the cross-reference in
QuoteDuckDBStructKey's comment, so the next reader isn't sent to a dead helper.
6. Nits
duckdb_quote_identifier((char *) columnName)(4 sites) — the parameter is already
const char *; the mirrored write-side code omits the cast.Assert(converted); (void) converted;three times is noisier than the write side,
which just ignores the return value.- The new
ReadEmptyDataSourcecomment aboutpreferVarchardescribes an unreachable
combination:preferVarcharis only set for CSV/JSON reads incopy.c, and
compaction reads useDATA_FORMAT_PARQUET, so it is never true alongside
DATA_FORMAT_ICEBERG.
Things I checked that are fine
- Lambda variable naming (
_x%dkeyed ondepth) cannot shadow: the composite
branch is the only one that keepsdepth, and it never emits a lambda. - Map detection correctly precedes the generic domain unwrap —
pg_mapis a domain
over an array of key/value composites, and unwrapping first would lose the MAP shape. - The
CASE WHEN ... IS NOT NULLguard aroundstruct_packis necessary (struct_pack
resets validity) and mirrors the write side. - Unbounded mutual recursion isn't reachable: PostgreSQL rejects recursive composites
("composite type node cannot be made a member of itself"). - Rebases cleanly onto current
main(18 commits, including "Support iceberg schema
type change" #223); neither new commit touches the projection code. - Builds warning-free under the repo's flags.
Out of scope, but found while probing
- A
timetzMAP key can't be looked up after a round-trip:
'09:00:00+04'comes back as'05:00:00+00', and PostgreSQL'stimetz=is
offset-sensitive ('09:00+04' = '05:00+00'is FALSE), so
map_type.extract(m, '09:00:00+04'::timetz)returns NULL. Inherent to Iceberg's
UTC-normalized TIMETZ storage and true for top-leveltimetztoo, so it predates
this PR — but nested keys make it easier to hit. - Two pre-existing write-path failures, unrelated to this PR:
INSERTinto an
Iceberg table fails for a domain-over-array column
(columns={'v':'LIST'}in the CSV reader) and for a composite that has had an
attribute dropped (field_idsstill contains........pg.dropped.1........).
NotablyGetEmptyIcebergInputTypeNamein this PR does skipattisdropped.
(removing changes requested status, since I'm traveling)
Summary
Reading a
timetzvalue stored inside a composite, array, or map in an Iceberg table returned a different instant depending on the session timezone:'12:30:00+04'stored as08:30:00UTC came back as08:30:00-04underAmerica/New_York. Top-leveltimetzcolumns were unaffected.The read projection only promoted top-level TIMETZ back to
TIMETZ(and had interval-only helpers for structs and maps), so nested leaves reached PostgreSQL as plainTIMEdigits andtimetz_inattached the session offset.Changes
BuildStructWithIntervalProjection/BuildMapWithIntervalProjectionhelpers with one recursive conversion (AppendIcebergReadConversion) that mirrors the write-sideAppendRewriteExpressiontraversal and handles both INTERVAL and TIMETZ at any nesting depth — arrays, composites, maps, and domains — and composes with the compatibility-mode storage casts inBuildStorageToSurfaceProjection.CAST(... AS TIMETZ)from the write-sideAppendTimeTzUtcCast: it existed precisely because nested TIMETZ leaves read back from Iceberg arrived as plainTIME— the case this PR fixes at the source — so every source feeding an Iceberg write now exposes genuineTIMETZ.timetz(composite, array-of-composite, map, and acompatibility_mode = 'snowflake'mix with uuid) under a non-UTC session timezone — the condition that masked the bug in the existing tests.Testing
Built the working tree and ran the timetz, interval, uuid-compat, and reserved-keyword suites in the repo's PG18 development image: 17 passed. The changed engine objects also compile against PG16 and PG17 from the same image.
Fixes #332.