Commit 608e7d2
pg_lake: clamp string and binary values to per-column byte limits on Iceberg writes
Some downstream consumers of Iceberg tables impose per-column byte
caps smaller than what PostgreSQL values in the source can carry.
For example, on Snowflake the column-type byte ceilings are:
- STRING / VARCHAR : 16 MiB default, up to 128 MiB when declared
with an explicit larger length.
- BINARY : 8 MiB default, up to 64 MiB.
- OBJECT / ARRAY /
VARIANT : 128 MiB.
Without a guard, rows whose individual values exceed the target
column's cap reach the consumer and surface as opaque "value too
long" errors when the consumer ingests them. This adds an opt-in,
GUC-driven clamp at write time so values that would exceed a
configurable byte limit are normalized in place before they are
written to the Iceberg table.
Two new GUCs (PGC_USERSET, GUC_UNIT_BYTE, default 0 = disabled, no
behavior change unless set):
- pg_lake_engine.iceberg_max_string_bytes governs text, varchar,
bpchar, jsonb, json, and the aggregate JSON-serialized size of
array, composite, and map values that land in STRING / OBJECT /
ARRAY columns on the consumer side.
- pg_lake_engine.iceberg_max_binary_bytes governs bytea.
Operators set the GUCs to the target column's actual ceiling, which
may be the default (16 MiB / 8 MiB on Snowflake) or a higher value
(up to 128 MiB / 64 MiB) when the destination column was declared
with an explicit larger length.
Per-leaf behavior, applied recursively through arrays, composites,
maps, and domains via IcebergSizeClampDatum:
- text/varchar/bpchar: truncate at a UTF-8 character boundary
(pg_mbcliplen).
- bytea: byte-truncate.
- jsonb/json: replace with NULL, since truncating the
serialized form would yield invalid JSON.
For jsonb the byte limit applies to the text-serialized form (what
the consumer ultimately receives), not the on-disk binary varlena, so
the size check serializes via jsonb_out before deciding. For json
(stored as text) the varlena length matches what the consumer sees,
so VARSIZE_ANY_EXHDR is used directly.
In addition, an aggregate-size check NULLs an entire array or
composite when the sum of its leaf byte sizes exceeds
iceberg_max_string_bytes — the limit on the JSON-serialized form
that downstream consumers receive when an array or struct lands in
a STRING / OBJECT / ARRAY column. Snowflake stores semi-structured
data without per-record headers, so sum-of-leaves is a close-enough
approximation of the serialized JSON length without paying for an
extra serialization pass. Each recursion level reports its post-
clamp byte size up to its parent so the roll-up costs no extra walk.
Aggregate clamping covers all containers, not just those whose
elements/fields are themselves clampable. For arrays of non-string
element types (e.g. int[]), the array's varlena content size
(VARSIZE_ANY_EXHDR) serves as a cheap upper-bound proxy for the JSON
serialization length; for composite fields of non-clampable types,
the field's typlen-based size is folded into the aggregate.
The new size clamp slots into the same FDW write path as the existing
out_of_range_values clamp. ClampAndCheckConstraints invokes
IcebergSizeClampSlotInPlace after the temporal/numeric clamp and before
ExecConstraints, so a NOT NULL column whose value is replaced with NULL
fails the constraint (mirroring the existing numeric-NaN clamp
behavior). PgLakeModifyState gains a needsSizeClamping flag computed
once at init via TupleDescNeedsIcebergSizeClamping, so the per-row work
is skipped entirely on tables whose columns cannot trigger size
clamping; the slot helper is also short-circuited unless at least one
GUC is non-zero.
Tests: pg_lake_table/tests/pytests/test_iceberg_size_clamping.py
covers UTF-8 boundary truncation, bytea byte truncation, jsonb/json →
NULL, NOT NULL constraint violation after clamp-to-NULL, recursion
into text[] and composite types, aggregate-size NULLing of array and
composite values whose leaves individually fit (including int[] and
all-non-clampable composite fields), the disabled-by-default no-op
path, and the only-one-GUC-set path.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Slot <marco.slot@snowflake.com>1 parent 139aeca commit 608e7d2
7 files changed
Lines changed: 1099 additions & 0 deletions
File tree
- pg_lake_engine
- include/pg_lake/pgduck
- src
- pgduck
- pg_lake_table
- src/fdw
- tests/pytests
Lines changed: 24 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
Lines changed: 19 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| 47 | + | |
47 | 48 | | |
48 | 49 | | |
49 | 50 | | |
| |||
186 | 187 | | |
187 | 188 | | |
188 | 189 | | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
189 | 217 | | |
190 | 218 | | |
191 | 219 | | |
| |||
0 commit comments