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 = 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.