Hi Greg,
On Mon, Nov 2, 2026 at 7:46 PM Greg Burd <[email protected]> wrote:
> Manu, the v13 containment fixes are unchanged in v14, so your repros
> for them should still hold. The new parts are the header validation
> in 0002 and the UPDATE fast path in 0004, if you want to point your
> harness at them.
They hold: v14 gives the same results as v13, and 4(a) still fails
without Muzzammil's v1 and passes with it.
Header validation: of 1044 corrupt inline headers (4 to 12 bytes, both
forms, va_cmid 0-255), with five readers each under cassert+ASan, none
reads past the datum or fails an assertion. Hannu's toast_save_datum()
point reproduces: va_cmid 3 or >= 6 fails its Assert through UPDATE,
INSERT ... SELECT and CTAS, and his change makes all of them report
"compressed data is corrupt".
UPDATE fast path: 1M unrelated-column UPDATEs of values on a
non-current dictionary stay HOT and take 0.9 s and 128 MB of WAL, where
v13 takes 5.4-5.7 s and 460 MB; VACUUM FULL, CLUSTER, REPACK and REPACK
(CONCURRENTLY) then recompress them and retire the old dictionary.
Among 141 uses of the TOAST predicates and 24 other container paths,
Hannu's check_domain_for_new_field() case is the only one missing; his
one-line change fixes it in the repro. The tables are attached.
Manu
ZSTD TOAST compression, v14: containment, header validation, UPDATE fast path
=============================================================================
Base: master fa5ecff759b + v14-0001..0005 (git am, clean). Builds, all with
lz4 and zstd:
A v14, --enable-cassert, -fsanitize=address
AM A plus Muzzammil Sarwar's v1 for the PL/pgSQL R/W record fast path
N v14, -O2, no asserts
F A plus the two changes Hannu suggested on 02-11 (VARATT_IS_COMPRESSED_DICT
in check_domain_for_new_field(); toast_inline_compression_id() and an
ERRCODE_DATA_CORRUPTED error in toast_save_datum())
v13 v13-0001..0006 on the same base, cassert+ASan and -O2, for comparison
make check (243 tests) and contrib/amcheck pass on A, AM and F; 0 compiler
warnings, 0 ASan reports.
1. The v13 containment repros on v14
------------------------------------
Copies of dictionary-compressed values made while dictionary #1 is current;
then #2 is trained and the owning table rewritten (VACUUM FULL), which deletes
#1; every copy is read back against its md5. On A the output is the same as
on v13, line for line.
A AM
t itself (two zstd_dict, one lz4_dict column) 1003 ok 1003 ok
C1 INSERT ... SELECT into a plain text column 1003 ok 1003 ok
C2 into another table's zstd_dict column 1003 ok 1003 ok
C3 CREATE TABLE AS 1003 ok 1003 ok
C4 whole-row composite stored in a table 1003 ok 1003 ok
C5 array_agg stored 500 ok 500 ok
C6 UPDATE t SET b = a (other dictionary column) 100 ok 100 ok
C7 materialized view, and REFRESH ok ok
C8 partition row movement (300 rows p1 -> p2) 1200 ok 1200 ok
C9 WITH HOLD cursor fetched after the rewrite 1003 ok 1003 ok
C10 procedure, text variables across COMMIT ok ok
C10b procedure, records returned by a PL/pgSQL
function, across COMMIT (4(a)) ERROR (1) ok
C11 logical decoding peeked after the rewrite 1003 ok 1003 ok
C12 pg_statistic MCV and extended MCV ok ok
C13 verify_heapam(check_toast) on the tables 0 rows 0 rows
4(a) A1 DO + DROP TABLE, A2 PROCEDURE + DROP TABLE,
A3/A3r no DROP, other session trains #2 and runs
VACUUM FULL / REPACK ERROR (1) ok
A4 control (column into a text variable) ok ok
4(b) indirect pointer: trigger NEW := make_tuple_
indirect(NEW), test function, WITH HOLD cursor ok (7) ok (7)
4(c) C procedure, SPI_datumTransfer() + SPI_commit() ok (2) ok (2)
REPACK (CONCURRENTLY) with DML committed during the
copy (gdb holds it in copy_table_data()) 2008 ok -
(1) "compression dictionary N does not exist", as on v12 and v13 without v1.
2. Header validation (0002): toast_inline_compression_id()
----------------------------------------------------------
2a. Enumeration, in memory. Each header is handed to the readers by a test C
function that returns the bytes, unchanged, as a text datum in a malloc() block
of exactly its size, so ASan sees any read past the datum's end. va_tcinfo
claims 100 raw bytes; every byte after the header is 0x00.
VARSIZE 4-7 1 case each (the method bits lie past the end) 4
VARSIZE 8 method bits 0, 1, 2, 3 (va_cmid past the end) 4
VARSIZE 9-12 plain form, bits 0, 1, 2 12
long form, va_cmid 0-255 1024
total 1044
Readers: pg_column_compression(), length(), substr(v, 1, 5), octet_length(),
v::text, each in its own session on a UTF8 database.
On A: 0 reads past the datum, 0 failed assertions. Outcomes:
pg_column_ length substr octet_
compression ::text length
VARSIZE 4-7 4 corrupt corrupt corrupt corrupt
VARSIZE 8, plain pglz/lz4 2 method corrupt (2) size
plain form claiming zstd 5 corrupt corrupt corrupt size
VARSIZE 8, long bits 1 corrupt corrupt corrupt size
VARSIZE 9-12, plain pglz/lz4 8 method (2) (2) size
long form, va_cmid 0/1 8 corrupt corrupt corrupt size
long form, va_cmid 2 (zstd) 4 zstd corrupt corrupt size
long form, va_cmid 3 4 corrupt corrupt corrupt size
long form, va_cmid 4/5, < 13 B 8 zstd_dict / corrupt corrupt size
lz4_dict (3) (3)
long form, va_cmid 6-255 1000 (4) (4) (4) size
corrupt = "compressed data is corrupt" or "compressed {pglz,lz4,zstd} data
is corrupt"; size = the raw size in the header (100), as for any complete
header.
(2) pglz/lz4 decode the zero bytes: an error, or "" for a slice and for
the 9-byte lz4 one (a one-byte zero block is valid lz4).
(3) "compressed dictionary data is corrupt".
(4) elog "invalid compression method id N" (XX000).
(3) and (4) are the two items Hannu listed as optional polish; that is what
they give today. On v13 the same run reports a read past the datum for the
8-byte header with the long-form bits. F gives the same table as A.
2b. On disk, and toast_save_datum(). A table (id, w text STORAGE MAIN, v text),
v the last column, ~1.2 kB zstd or ~1.9 kB zstd_dict compressed inline.
Headers patched in the heap file with the server stopped. Then reads, and
three statements that make the toaster push the untouched datum out of line:
ins INSERT INTO h2 SELECT id, <1 kB incompressible>, v FROM h
ctas CREATE TABLE AS SELECT id, <1 kB incompressible> AS w, v FROM h
upd UPDATE h SET w = <1 kB incompressible>
"stored" is what the copy then holds.
zstd source, patch A (cassert) N (no asserts) F
none (control) ok, out of line same same
va_cmid 0 stored as a pglz same ERROR (5)
pointer, reads
"corrupt"
va_cmid 1 as an lz4 same ERROR (5)
pointer, "corrupt"
va_cmid 3 TRAP (6) out of line, method ERROR (5)
NULL, reads "corrupt"
va_cmid 6, 99, 255 TRAP (6) out of line, reads ERROR (5)
"invalid compression
method id N"
VARSIZE 8, long bits stays inline (7) same same
zstd_dict source
none (control) ins/ctas: lz4 (the target's method), upd: kept
VARSIZE 10 ins/ctas: ERROR (3); upd: kept inline as it was
va_cmid 4 -> 5 ins/ctas: "compressed lz4 data is corrupt";
upd: kept, out of line, reads the same error
(5) "compressed data is corrupt" at write time, in all three statements.
(6) failed Assert at toast_internals.c:342, in all three statements (12).
(7) a 4- to 8-byte value is never chosen to go out of line, so truncated
headers do not reach toast_save_datum().
All reads of the patched rows on A and N: an error, no ASan report.
3. UPDATE fast path (0004)
--------------------------
3a. Function, A against v13 (cassert). u: 2000 rows, 3000-byte JSON, inline
zstd_dict (338 B on average), fillfactor 50, all on dictionary #1; then #2
trained. The dictionary of each value is read from the raw datum.
v14 v13
UPDATE u SET n = n + 1, all rows kept on #1 recompressed
2000 HOT to #2, 1999 HOT
then VACUUM FULL / CLUSTER / REPACK / all on the current dictionary, one
REPACK (CONCURRENTLY), each after a dictionary left, md5 of every row ok
new training and unrelated UPDATE (both)
SET n = n + 1, d = d kept on the old one recompressed
SET d = d || '' current current
SET d = <another row's d> current current
BEFORE trigger NEW.d := OLD.d kept on the old one recompressed
BEFORE trigger NEW.d := NEW.d || 'x' current current
UPDATE ... RETURNING d, stored into
another table through a CTE not dictionary-compressed, readable
after the retire (both)
partitions: UPDATE inside p1 kept on p1's old recompressed
UPDATE of the key, 200 rows p1 -> p2 on p2's current dictionary, none on
a p1 dictionary (both)
copies made right after the unrelated
UPDATE (CTAS, whole-row, array_agg,
another zstd_dict column) none dictionary-compressed, all read
back after VACUUM FULL retired #1
log WARNING/ERROR/TRAP, ASan none
3b. Logical replication (A). Publisher: u (primary key) and uf (REPLICA
IDENTITY FULL), 500 rows each on a non-current dictionary. Subscriber: same
tables, zstd_dict, never trained. With the subscription disabled the
publisher runs the unrelated UPDATEs (values kept on the old dictionary),
trains again, runs VACUUM FULL on both tables (both old dictionaries deleted)
and VACUUM pg_toast_dictionary; then the subscription is enabled. The
walsender decodes the UPDATEs without error, both tables match row by row
(id, n, md5), and the subscriber stores the values with plain zstd.
3c. 1M unrelated-column UPDATEs, v14 (N) against v13, both -O2 without
asserts, same base, one dedicated 8-CPU lane, rounds alternating the build
order, lane calibrated clean before and after. u: 1,000,000 rows, inline
zstd_dict (331 B on average), fillfactor 50, 742 MB; each run starts from a
fresh VACUUM FULL, then CHECKPOINT, then one UPDATE u SET n = n + 1.
wal_level = replica, full_page_writes = off (WAL is record bytes only).
values on the current dictionary v13 v14
time, 3 rounds (ms) 984, 943, 988 1029, 885, 902
WAL 128.4 MB each 128.4 MB each
HOT 1,000,000 1,000,000
values on a non-current one
time, 3 rounds (ms) 5701, 5399, 5743 876, 868, 940
WAL (MB) 459.7, 460.0, 460.9 128.4 each
HOT 999,076 998,093 1,000,000 each
998,992
With the current dictionary the two are the same within the spread. With a
non-current one, v13 recompresses every value: 6.5x the time, 3.6x the WAL.
ZSTD TOAST compression, v14: audit of the containment checks
============================================================
Tree: master fa5ecff759b + v14-0001..0005. File:line below are in that tree.
The table was written from reading the code, before any of the tests in the
last section were run.
What is audited. 0004 keeps a dictionary datum inside its column with two
mechanisms: fill_val() sets HEAP_HASEXTERNAL for a tuple that holds one
(heaptuple.c:372-374), so everything that already flattens a tuple with TOAST
pointers also reaches it, and toast_tuple_init() decompresses it when it is
stored anywhere but its own column (toast_helper.c:205-221). The toaster does
not look inside composite, array or range values, so a dictionary datum can
escape where code decides "is this value self-contained?" by testing ONE datum
with VARATT_IS_EXTERNAL() instead of the tuple flag, and then keeps or copies
it. Hannu's check_domain_for_new_field() finding is of that kind.
Scope: every line in src/ and contrib/ (without src/test) that uses
VARATT_IS_EXTERNAL[_ONDISK|_INDIRECT|_EXPANDED|_NON_EXPANDED](),
VARATT_IS_EXTENDED(), VARATT_IS_COMPRESSED() or HeapTuple[Header]HasExternal():
141 lines in 47 files, grouped below by condition or function, plus 24 places
that decide self-containment without those macros.
OK = handles dictionary datums (how). NA = not a containment decision (why).
MISSING = takes the wrong branch for a dictionary datum.
A. Composite values and expanded records
MISSING expandedrecord.c:1553 check_domain_for_new_field: the new value
is tested with VARATT_IS_EXTERNAL() only, so dummy_erh is flattened
for the domain check without inlining it (Hannu, 02-11)
OK expandedrecord.c:472, 523 expanded_record_set_tuple: tuple flag
OK expandedrecord.c:707 ER_get_flat_size: VARATT_IS_COMPRESSED_DICT
OK expandedrecord.c:1160, 1191 expanded_record_set_field_internal: same
OK expandedrecord.c:1307, 1320 expanded_record_set_fields: same
OK expandedrecord.c:1523 check_domain_for_new_field, old fields: copies
erh's flag
OK expandedrecord.c:1617 check_domain_for_new_tuple: tuple flag
OK execTuples.c:2517 HeapTupleHeaderGetDatum: flag -> flatten
OK heaptuple.c:1009 heap_copy_tuple_as_datum: flag -> flatten
OK heaptuple.c:328-330 fill_val: sets HEAP_HASEXTERNAL (:372-374)
OK heaptoast.c:378 toast_flatten_tuple: VARATT_IS_COMPRESSED_DICT
OK heaptoast.c:493-494 toast_flatten_tuple_to_datum: external or
compressed -> detoast_attr
OK heaptoast.c:594 toast_build_flattened_tuple: as :378
NA expandedrecord.c:634 Assert (composite datums are flat)
NA execExprInterp.c:3757 FieldSelect on an expanded record (a read)
NA heaptuple.c:249 heap_compute_data_size: expanded only
NA pl_exec.c:836, 1092, 7698 expanded-object checks
B. Values that outlive the transaction or the lock
OK pl_exec.c:8920 assign_simple_var (non-atomic)
OK tstoreReceiver.c:163 WITH HOLD cursors
OK datum.c:201 datumTransfer / SPI_datumTransfer (v13)
OK detoast.c:51-59, 67-91, 114 detoast_external_attr: on disk, indirect
(v13) and inline dictionary datums
OK catcache.c:2198 tuple flag (and catalogs cannot use these
methods)
NA detoast.c:101 expanded object, flattened
NA datum.c:144 datumCopy: verbatim, as a TOAST pointer;
in-transaction copies
NA datum.c:474, 523 datumSerialize: workers of the same query
NA execTuples.c:199, 235 slot materialization: expanded only
NA expandeddatum.c:34; arrayfuncs.c:1852, 2273; array_expanded.c:76, 410
expanded-form dispatch (elements: section E)
C. Storing into a table, an index, or another table's TOAST
OK toast_helper.c:143 toast_tuple_init: a foreign external value is
fetched with detoast_external_attr, which
decompresses it
OK toast_helper.c:168-174 toast_dictionary_keep(); the UPDATE fast path
needs an old tuple of the same relation, no
relrewrite, and byte-identical datums
OK heapam.c:2263-2266, 3848-3854 insert / update: tuple flag -> toaster
OK heapam.c:9381, 9434 ExtractReplicaIdentity: flag -> flatten
OK rewriteheap.c:616-619 raw_heap_insert: flag -> toaster (relrewrite)
OK indextuple.c:108 index_form_tuple: decompresses (0004)
OK indextuple.c:121 recompression without a rel: plain zstd
(toast_internals.c:89-93), although
index.c:360 copies attcompression
OK brin_tuple.c:209, 221 as indextuple.c
OK repack.c:3101; pgrepack.c:214, 256-275 REPACK (CONCURRENTLY): the
dictionary datum travels inside the tuple and
reaches toast_tuple_init with relrewrite set
NA toast_helper.c:79-82, 249-252, 383 unchanged external value of the same
relation; size heuristics; deletion
NA heapam.c:3183-3185, 4639, 6526 deletion; has_external for replica
identity (a dictionary datum is inline, so in
WAL already); speculative abort
NA toast_internals.c:332 toast_save_datum: keep() ran before; header
validation is Hannu's point 2 (matrix, 2b)
NA toast_internals.c:53, 103-104, 241, 295, 569; heaptoast.c:720;
repack.c:3047 Asserts, deletion, slice fetch, indirect
D. Reading, reporting, decoding
NA detoast.c:149-199, 257-325, 388, 445, 519, 560, 617-640, 678-701
detoast_attr[_slice], sizes, preconditions
NA fmgr.c:1800, 1809, 1832 pg_detoast_datum*: decompress everything
NA varlena.c:716-717, 4292 text_substring; a reporting function
NA toast_compression.c:525, 534 toast_get_compression_id (reporting)
NA reorderbuffer.c:5187, 5306, 5345; proto.c:815; pgoutput.c:1405-1406;
test_decoding.c:594 decoding: the datum is inline in WAL, and
plugins decompress under the historic
snapshot, which still sees a retired
dictionary while the slot holds catalog_xmin
NA inv_api.c:142; ts_selfuncs.c:238 pg_largeobject is a catalog; Assert
NA verify_heapam.c:1621, 1751-1755, 1794; verify_nbtree.c:2873-2892;
pageinspect heapfuncs.c:371-373 checks; index tuples never hold one
NA toast_compression.h:93, 123; detoast.h:28, 69; htup_details.h:576, 753;
pl_exec.c:8726; heaptoast.c:346 definitions, comments
E. Places that decide without the macros
OK arrayfuncs.c:327, 1526, 2271, 3296, 3538, 6216, 6471-6473
array_in, array_recv, array_set_element,
array_map, construct_md_array, array_fill,
array_replace: PG_DETOAST_DATUM
OK arrayfuncs.c:5400 accumArrayResult: PG_DETOAST_DATUM_COPY
OK arrayfuncs.c:2501 array_set_element_expanded: only caller is
:2276, after :2271
OK rangetypes.c:2033, 2043 range_serialize: PG_DETOAST_DATUM_PACKED, so
the :2960 guard (toast pointers only) is never
reached by a dictionary datum; multiranges
store serialized ranges
OK spgdoinsert.c:1959, 1973 SP-GiST leaf and INCLUDE values
OK analyze.c:2243, 2590; mcv.c:764; extended_stats.c:1148 statistics
OK makefuncs.c:366; clauses.c:5995 makeConst, evaluate_expr
OK spi.c:1097; execTuples.c:2017 SPI_returntuple,
ExecFetchSlotHeapTupleDatum: tuple flag
NA tuplestore.c, tuplesort.c verbatim within the query; WITH HOLD goes
through tstoreReceiver.c:163
NA postgres_fdw.c:4591, 4622, 5198; dblink; copyto.c:526; logical
replication values sent through output/send functions
Result from reading: 1 MISSING (expandedrecord.c:1553), none other.
Tests (run after the table was written)
---------------------------------------
One inline zstd_dict value (326 B) taken through each path while its
dictionary is current; then (A) a second dictionary trained and VACUUM FULL on
the source, which deletes the first, and (B) DROP TABLE on the source; every
copy read back against the value's md5. v14 = cassert+ASan; v14+F = the same
with Hannu's two suggested changes. "stored" is how the copied value, or the
field / element inside it, is stored.
path stored v14 after A, B v14+F
ROW(d, 1)::comp_t plain ok, ok ok, ok
whole-row Var plain ok, - (1) ok, - (1)
UPDATE ... SET v.a = d plain ok, ok ok, ok
SQL function returning a row plain ok, ok ok, ok
PL/pgSQL RETURN record plain ok, ok ok, ok
record field := d plain ok, ok ok, ok
SELECT * INTO record plain ok, - (1) ok, - (1)
domain over composite,
field := d, CHECK stores
VALUE (Hannu's case) zstd_dict ERROR (2), ERROR plain; ok, ok
same, SELECT ... INTO plain ok, ok ok, ok
same, whole ROW() assigned plain ok, ok ok, ok
domain over text, CHECK
stores VALUE lz4 ok, ok ok, ok
ARRAY[d], array_agg,
a[1] := d, array_append,
array_fill, array_replace plain ok, ok (each) ok, ok (each)
range and multirange over
text plain ok, ok ok, ok
ADD COLUMN ... DEFAULT f()
(attmissingval) plain ok, ok ok, ok
COPY BINARY out and in lz4 ok, ok ok, ok
postgres_fdw INSERT
(loopback) lz4 ok, ok ok, ok
dblink_exec INSERT lz4 ok, ok ok, ok
btree index on the column,
index-only scan zstd (3) - -
(1) the column has the source's row type, so DROP TABLE ... CASCADE drops it.
(2) "compression dictionary N does not exist".
(3) index_form_tuple() decompresses it and recompresses without a rel.
No failed assertion and no ASan report in either build.
The rest of the paths in the table are covered by the containment matrix
(holdable cursors, procedure variables, SPI_datumTransfer, indirect pointers,
logical decoding, statistics, partitions, REPACK (CONCURRENTLY)).