pg_lake_iceberg: add SameIcebergStoredRepresentation helper - #488
pg_lake_iceberg: add SameIcebergStoredRepresentation helper#488sfc-gh-dachristensen wants to merge 5 commits into
Conversation
2554d9b to
ede545a
Compare
Add an exported SameIcebergRepresentation(oldOid, oldMod, newOid, newMod) to pg_lake_iceberg that returns true when two Postgres types map to the same Iceberg representation. It derives each type's Iceberg field via PostgresTypeToIcebergField and compares the field trees for type equality (ignoring field ids and defaults), normalizing unsupported numerics to double first to match MaybeConvertUnsupportedNumericColumnsToDouble in the create path. This is true for pairs that differ only in ways Iceberg does not model: varchar length changes and text/varchar/char (all `string`), smallint vs integer (both `int`), time vs timetz (both `time`), and so on. It is a pure representation question; it does not decide whether a given type change is otherwise permitted. Callers such as pg_lake_replication use it to tell whether an ALTER COLUMN TYPE leaves the stored Iceberg schema unchanged. Includes a SQL-callable test wrapper and pytest coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: David Christensen <david.christensen@snowflake.com>
ede545a to
645e996
Compare
There was a problem hiding this comment.
SameIcebergRepresentation doesn't reproduce the changelog storage shape, and one direction silently over-allows.
who actually calls this
- old = the live PG attribute type of the tracked column (
att->atttypid/atttypmod) - new = the ALTER target type
two facts matter:
- the tracked table is a plain PG heap table, not an iceberg table, so
oldis the genuine surface type (numeric(50,2)[],uuid[], ...), not pre-rewritten tofloat8[]. - the schema this must protect is the changelog table, which snowflake_cdc creates as a real pg_lake iceberg table with
compatibility_mode='snowflake'. so the changelog goes through the full create path (recursive numeric->double AND compat), while this helper reproduces neither.
the two gaps land on opposite sides
return TRUE means "let the ALTER through, replicate nothing", so a wrong TRUE is a silent, permanent mirror divergence and a wrong FALSE is merely over-conservative.
(a) nested unsupported numeric, wrong TRUE, the dangerous one. NormalizeTypeForIcebergSchema only converts the top-level numeric; a nested one falls through to the string fallback. but the changelog create path converts nested numeric to double recursively (MaybeConvertUnsupportedNumericColumnsToDouble -> ConvertTypeTree/NumericLeafToDouble, the create_table.c comment literally says "including nested ones"). so the helper sees list<string> where the changelog stores list<double>. reachable:
-- tracked heap column; changelog stores list<double>
CREATE TABLE t (a numeric(50,2)[]);
-- ... add t to a snowflake_cdc publication ...
ALTER TABLE t ALTER COLUMN a TYPE text[]; -- succeeds WITHOUT a USING clausesame_iceberg_representation('numeric(50,2)[]','text[]') = t (both list<string> to the helper), so AlterColumnTypeIsMirrorNoop lets it through emitting nothing, but the changelog schema genuinely changes list<double> -> list<string> and the mirror is permanently wrong. i checked locally: numeric(50,2)[] -> text[] (and -> varchar[]) is assignment-castable with no USING, so the USING/COLLATE guard does not close this. the reverse text[]->numeric[] and composite->composite both need USING, so the hole is specifically array-of-nested-numeric -> string-family array.
(b) nested uuid under compat, over-restrictive today, but the helper is compat-blind by construction. the changelog stores nested uuid as string (compat), while this helper computes native iceberg uuid (PostgresTypeToIcebergField with no compat). so same_iceberg_representation('uuid[]','text[]') = f and the caller blocks a change that is actually a changelog no-op. for today's snowflake mode that is just too conservative, not a correctness bug, agreed. but i wouldn't file it under "harmless over-restriction". the real issue is structural: SameIcebergRepresentation never takes compatibility_mode as an input, so it is frozen to a single storage shaping. compatibility_mode is going to evolve, and the first mode that makes two different surface types share one storage type (exactly how nested uuid and text already both land on string) turns this same blindness into a wrong TRUE, i.e. it flips from the safe side to the silent-divergence side. so the ask is: make compat a parameter now, otherwise this contract quietly rots as modes change.
net: SameIcebergFieldType is fine, the inputs just aren't changelog-shaped. the header's "never produces a false match" line is itself the false statement, case (a) is exactly a false match.
fix
derive both sides through the same transforms the changelog create uses, not top-level-only + compat-less:
- recursive numeric via
MaybeConvertType/ConvertTypeTree(already inrel_utils.c) instead ofNormalizeTypeForIcebergSchema - apply the snowflake compat storage mapping so nested
uuid->string
do that identically on old and new and both (a) and (b) collapse.
even simpler for #704: the changelog iceberg table already exists when the ALTER runs, so compare the new type's derived storage field against the existing stored field from field_id_mappings and drop the re-derived old entirely. that also survives unsupported_numeric_as_double GUC flips and historical divergence, which a pure re-derivation can't.
if recursive/compat is out of scope for this PR: at minimum fix the header (surface-only, can false-match), and tell the #704 side to block nested types (or gate them on a catalog read) so the over-allow can't ship.
|
|
||
| # Type pairs whose Iceberg representation is identical (ignoring anything | ||
| # Iceberg does not model: text length, which text-family member, etc.). | ||
| SAME_REPRESENTATION = [ |
There was a problem hiding this comment.
can we add nested coverage here too? arrays/composites with unsupported numeric, and nested uuid under compatibility_mode='snowflake'. right now its only top-level scalars, so the one case that actually matters is untested: numeric(50,2)[] vs text[] returns t while the changelog stores list<double> vs list<string>. the nested uuid over-block is worth pinning too so a later change doesn't flip it into an over-allow.
minimum cases:
numeric(50,2)[]vsdouble precision[]-> same as create pathnumeric(50,2)[]vstext[]-> different- composite with unsupported numeric field vs rewritten
float8composite - unbounded
numeric[]vsfloat8[] - nested
uuidvstextunder snowflake mode
There was a problem hiding this comment.
Added in 958a516: the pinned-GUC test now also checks numeric(50,2)[] and numeric[] against a genuine double precision[] (so it pins that the converted leaf really is double, not just equal to another numeric), and a new test_composite_unsupported_numeric_matches_float8_composite exercises the struct recursion — a composite with an unsupported-numeric field matches a float8 composite but differs from a text one. Combined with the existing nested numeric[] vs text[] and nested-uuid-under-snowflake cases, that covers all five you listed.
| * an Iceberg decimal; the create path stores it as double, but only when | ||
| * pg_lake_iceberg.unsupported_numeric_as_double is enabled (see | ||
| * MaybeConvertUnsupportedNumericColumnsToDouble). When it is disabled the | ||
| * numeric is left alone and maps to the "string" fallback. Mirror both cases |
There was a problem hiding this comment.
with the GUC off, create rejects unsupported numeric rather than storing string. PostgresBaseTypeIdToIcebergTypeName still falls back to string, so the helper can say numeric ~ text even though that type never lands in an iceberg table (same underlying "natural mapping != what create does" as the nested cases).
fix the comment to match create behavior (reject when GUC off). optional: when GUC is off, treat unsupported numeric as non-representable / return false against everything except itself-as-string if you want a pure mapping answer.
| * compares the resulting field trees for type equality. | ||
| */ | ||
| bool | ||
| SameIcebergRepresentation(Oid oldTypeOid, int32 oldTypeMod, |
There was a problem hiding this comment.
nit: maybe give this its own .c? iceberg_field.c is already the convert/ensure path, and this predicate will grow once nested normalize / compat are handled. own file like same_iceberg_representation.c is enough, no need to move it to engine.
|
Reworked this to address the nested case. Summary of the change and the reasoning: Root cause. The old helper normalized only the top-level type before deriving the Iceberg field, so a nested unsupported numeric fell through to the Fix. Derivation is now conversion-aware. unsupported_numeric_as_double. The stock conversion captures the flag in a context instead of reading the live GUC. The GUC is The compatibility concern. You were right that this matters, and The callback is also the extension point for a caller whose storage rules are not modeled in pg_lake: it can supply its own leaf conversion with the same position information rather than pushing that policy into this helper. Also moved the whole thing into |
Move the representation comparison out of iceberg_field.c into a dedicated
iceberg_representation.{c,h} and rebuild it around a position-aware leaf
conversion callback so it reproduces the schema the create path actually
stores -- recursively, and without catalog side effects.
The previous helper normalized only the top-level type before deriving the
Iceberg field, so a nested unsupported numeric fell through to the "string"
fallback. That made numeric(50,2)[] compare equal to text[] (both looked like
list<string>) while the create path stores list<double> vs list<string> -- a
false match that could let an ALTER COLUMN TYPE through while silently
diverging the stored schema.
Changes:
- PostgresTypeToIcebergField gains an internal conversion-aware form
(PostgresTypeToIcebergFieldConverted) that threads an
IcebergLeafConversionFn plus an IcebergTypePosition (depth + parent
container) through the existing array/composite/map recursion and applies
the conversion at each scalar leaf. Deriving only builds in-memory Field
structs, so this stays free of the catalog writes that reusing
MaybeConvertType would incur. A leaf reported unrepresentable yields a NULL
field.
- The stock create-path conversion mirrors unsupported-numeric handling
(double when unsupported_numeric_as_double is on, unrepresentable when off,
since CREATE errors on such a numeric at any level). The flag is captured in
a context rather than read from the live GUC, which is PGC_USERSET and may
have changed since the target table was created.
- SameIcebergStoredRepresentation reproduces the full stored schema: the
numeric leaf conversion during derivation, then ApplyCompatibilityStorageMapping
over the derived tree. That covers the depth-dependent compatibility case
(a nested uuid is stored as string under snowflake mode, a top-level uuid
stays native) by reusing pg_lake's existing mapping rather than duplicating
it, and keeps the position in the callback contract for callers whose leaf
rules vary by nesting.
- Tests cover top-level vs nested for both GUC states and the snowflake
compatibility uuid mapping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: David Christensen <david.christensen@snowflake.com>
e857964 to
d08ad1a
Compare
| * tree. This is what an ALTER-time caller (e.g. pg_lake_replication deciding | ||
| * whether an ALTER COLUMN TYPE leaves the changelog schema unchanged) should | ||
| * use. |
There was a problem hiding this comment.
We don't want caller details here; it's irrelevant to this project.
| @@ -0,0 +1,189 @@ | |||
| /* | |||
| * Copyright 2025 Snowflake Inc. | |||
There was a problem hiding this comment.
It's 2026
|
the derivation rework looks right, and building the field tree in-memory (no catalog side effects) is the correct call. one thing i want to push on before we bake it in: re-deriving both sides answers "would a fresh create of old and new match under today's settings", not "does new match what's actually stored". for a mirror those can drift apart. concrete way it bites: what if we derive only the smaller, separate: can we collapse to one entry point? the generic |
|
Pushed the cleanup in da57503:
On the ground-truth point: agreed, and I don't think it changes this PR. Re-deriving |
Address review: drop the second public entry point and the leaf-conversion extension seam so there is a single way to ask the representation question and no surface that reads as "plug in your own storage rules". - Remove the generic SameIcebergRepresentation (numeric-only, compat-blind). It sat next to SameIcebergStoredRepresentation and was easy to grab by mistake, which would quietly reintroduce the nested-uuid gap. Callers use SameIcebergStoredRepresentation, which reproduces the full stored schema. - Remove IcebergTypePosition / IcebergParentKind. The unsupported-numeric rule is uniform across nesting levels (position was unused), and the one depth-dependent transform -- the compatibility uuid mapping -- is already a separate, depth-aware Field-tree pass (ApplyCompatibilityStorageMapping). - Make IcebergCreatePathLeafConversion file-static and reframe the leaf- conversion callback as an internal mechanism iceberg_representation.c uses to reproduce create-path storage, rather than a public hook for callers whose rules are not pg_lake's. No behavior change; the derivation and the tests are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: David Christensen <david.christensen@snowflake.com>
da57503 to
ede0989
Compare
| @@ -0,0 +1,69 @@ | |||
| /* | |||
| * Copyright 2025 Snowflake Inc. | |||
There was a problem hiding this comment.
it's 2026.
| @@ -0,0 +1,80 @@ | |||
| /* | |||
| * Copyright 2025 Snowflake Inc. | |||
There was a problem hiding this comment.
It's 2026.
- Drop the caller-specific detail (pg_lake_replication / changelog / ALTER) from the SameIcebergStoredRepresentation header comment; it describes what the function does, not who calls it. - Bump the copyright year on the new files to 2026. - Extend the tests per review: an unsupported-numeric array now also compares equal to a genuine double precision[] (pinning that the converted leaf really is `double`, not just equal to another numeric), and a new composite case exercises the struct recursion -- a composite with an unsupported-numeric field matches a float8 composite but not a text one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: David Christensen <david.christensen@snowflake.com>
…quivalence A caller that already holds the actually-stored Iceberg field for a column (e.g. pg_lake_replication reading field_id_mappings of the changelog table) wants ground truth for the old side, not a re-derivation under today's settings -- the two-sided SameIcebergStoredRepresentation can drift when the unsupported_numeric_as_double GUC flips or the derivation logic changes across releases. Expose the two primitives that make the one-sided comparison possible: - DeriveIcebergStoredField(type, context): the create-path stored field for a single type (numeric leaf conversion + compatibility storage mapping; NULL when unrepresentable). - IcebergFieldsEquivalent(a, b): the field-tree equality comparator, previously the static SameIcebergFieldType. SameIcebergStoredRepresentation is now a thin wrapper over the two, unchanged externally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: David Christensen <david.christensen@snowflake.com>
|
pushed an alternative for the API shape: #495 (same base as this PR, so the diffs compare directly). the derivation rework here is right — the disagreement is only about what gets exported. 1. one code path, exercised by every caller. this matters more here than DRY usually does, because the whole PR is about edge cases. a duplicated model is only ever as correct as the tests that pin it — and for nested oversized numerics, nested same argument for the numeric rule. 2. drop the two-sided entry point — it fails open. a transform we don't model cancels out on both sides and yields a spurious 3. one-sided derivation deletes the rest. no net: ~23% less production code, and your tests carried over unmodified (GUC pinned with to be clear this is me trying to work out a shape i'd be comfortable maintaining, not a rejection of the work here — does the reasoning hold up from your side? happy to be talked out of any of it, particularly if you see a caller that genuinely wants the two-sided form. |
|
I think this makes sense as a simplification; to the extent we can use the exact same code paths, this would be a better approach. |
|
Closing in lieu of #495 |
Problem
Deciding whether an
ALTER COLUMN ... TYPEleaves an Iceberg table's stored schema unchanged means asking "do these two Postgres types get stored with the same Iceberg representation?" The transforms that shape how a column is physically stored already live inpg_lake— the create path's recursive unsupported-numeric-to-double conversion, and the compatibility-mode storage mapping — but there is no single entry point that reproduces them, so consumers reimplement the comparison (and can get it wrong: a top-level-only check treatsnumeric(50,2)[]as equal totext[]while the create path storeslist<double>vslist<string>).Solution
Add an exported
SameIcebergStoredRepresentation(oldType, newType, context)topg_lake_iceberg. It reproduces the full stored schema for each type and compares the derived Iceberg field trees for type equality (ignoring field ids and defaults, which are per-derivation):PostgresTypeToIcebergFieldConverted) that applies the create path's unsupported-numeric rule at every scalar leaf, recursively — so a nestednumeric(50,2)[]becomeslist<double>, not thestringfallback;ApplyCompatibilityStorageMapping), so e.g. a nesteduuidis stored asstringundercompatibility_mode='snowflake'while a top-leveluuidstays native.The create-path settings that shape storage —
unsupported_numeric_as_doubleandcompatibility_mode— are passed in via anIcebergCreatePathContextrather than read from live state, because the GUC isPGC_USERSETand the mode is per-table: the comparison must use the values in effect when the target table was created. With the GUC off, an unsupported numeric has no faithful stored form (CREATE rejects it at any level), so the leaf is reported unrepresentable and the comparison is conservativelyfalserather than treatingnumericas equal totext.It answers only the representation question; it does not decide whether a given type change is otherwise permitted (casts,
USINGclauses, engine policy) — that stays with the caller.Test plan
pg_lake_same_iceberg_representation(old_type text, new_type text, unsupported_numeric_as_double bool, compatibility_mode text)and a pytest inpg_lake_iceberg/tests/pytests/, covering:numeric(50,2)[]vstext[](different — the false match this fixes), unsupported-numeric arrays vs a genuinedouble precision[](same), and a composite with an unsupported-numeric field vs a float8 composite (same — exercises the struct recursion);doubleat every level when on; unrepresentable (conservatively false) when off, with bounded numerics unaffected;uuid≈text(bothstring), top-leveluuid≠text.make check-indentclean (pgindent PG18 + black); builds with-Werror.