fix: non-text column reads from compressed partitions (text[], inet, numeric)#2
Merged
Merged
Conversation
…numeric) Two production bugs found via the 2026-07-14 pingbacks incident (postmaster kill from a Superset chart reading hostnames text[] across ~503 compressed partitions; CNPG failover): 1. Emit-path type confusion (the crash + silent NULLs). classify_column defaults every unrecognized type (text[], inet, numeric, uuid, ...) to ColumnKind::Text; the write side losslessly stores the ::text rendering. But str_slices_to_text_datums_arena / str_to_text_datum built a RAW TEXT VARLENA for every type_oid except bpchar/jsonb and handed it to a slot whose attribute type is e.g. text[] (1009) — PG then reads text bytes as an ArrayType header: garbage ndim/dims produce silent NULLs from array_length(), and anything walking the array (unnest, array_out) computes nitems from garbage -> wild reads / huge allocations -> backend or postmaster death. Fix: only TEXTOID/VARCHAROID may take the raw-varlena fast path; every other type_oid is reconstructed via the type input function (getTypeInputInfo + OidInputFunctionCall) — the exact inverse of the write side's text serialization, so EXISTING compressed partitions become readable without recompression. Datum materialization only happens on the main backend thread, so the input-function calls are safe. 2. decompress_partition panicked "invalid UTF-8 in dictionary" on jsonb. Since upstream xataio#27 jsonb blobs hold PG's BINARY jsonb varlena payload; the scan path decodes them byte-level, but decompress_column_values still routed them through the UTF-8-validating text decoders. Fix: byte-level decode (decode_to_byte_slices / range-based LZ4) + new jsonb_binary_to_text (varlena wrap + jsonb_out; exact inverse of jsonb_text_to_binary). dictionary::parse_header's expect is now a descriptive error pointing at the byte-level API (pgrx already converted the unwind to a PG ERROR — the backend never died on this one, it was just cryptic). Pruning audit: batch quals / minmax / valbitmap / bloom are all gated on numeric or text oids, so text-ordered metadata can never wrongly prune segments for these column types — no changes needed there. Tests: tests/test_nontext_columns.py — pingbacks-shaped table (text[], inet, numeric, uuid, jsonb, NULL-mixed) through Dictionary/DictionaryLz4 and Lz4Blocked codecs; exact row equality before compression, after compression, and after deltax_decompress_partition; the literal incident query shape. Version: 0.2.0-dotcms.5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
These 4 xfail(strict) tests documented the exact non-text-fallthrough decode bug this branch fixes; they now XPASS. Remove the markers so they assert the correct behavior going forward. The NaN-colstats and NULL-segment_by xfails are unrelated and remain.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Incident
2026-07-14: a Superset chart (
SELECT array_length(hostnames,1) ... FROM pingbacks) reading thehostnames text[]column across ~503 compressed partitions killed the postmaster → CNPG failover. Smaller reads returned silent NULLs;deltax_decompress_partitionerroredinvalid UTF-8 in dictionary. Live cluster is mitigated (compression policy cleared, pingback_reader SELECT revoked); this PR is the durable fix (v0.2.0-dotcms.5).Root causes (two distinct bugs)
1. Emit-path type confusion — the crash + silent NULLs.
classify_columndefaults every unrecognized type (text[],inet,numeric,uuid, …) toColumnKind::Text; the write side losslessly stores the::textrendering. Butstr_slices_to_text_datums_arenabuilt a raw text varlena for everytype_oidexcept bpchar/jsonb and handed it to a slot whose attribute type is e.g.text[](1009). PG then reads text bytes as anArrayTypeheader — garbagendim/dims → silent NULLs fromarray_length(); anything walking the array (unnest,array_out) computesnitemsfrom garbage → wild reads / huge allocations → backend/postmaster death.Fix: only
TEXTOID/VARCHAROIDtake the raw-varlena fast path; every othertype_oidis reconstructed via the type input function (getTypeInputInfo+OidInputFunctionCall) — the exact inverse of the write side's text serialization, so EXISTING compressed partitions become readable without recompression. Materialization is main-backend-thread only, so the input-function calls are safe.2.
decompress_partitionpanickedinvalid UTF-8 in dictionaryon jsonb. Since upstream xataio#27, jsonb blobs hold PG's binary jsonb varlena payload; the scan path decodes them byte-level, butdecompress_column_valuesstill routed them through the UTF-8-validating text decoders. Fix: byte-level decode (decode_to_byte_slices/ range-based LZ4) + newjsonb_binary_to_text(varlena wrap +jsonb_out, exact inverse ofjsonb_text_to_binary).dictionary::parse_header's.expectis now a descriptive error pointing at the byte-level API (pgrx already converted that unwind to a PG ERROR — the backend never died on this one, just cryptic).Pruning audit
batch quals / minmax / valbitmap / bloom are all gated on numeric or text oids, so text-ordered metadata can never wrongly prune segments for these column types — no changes needed there.
Tests
tests/test_nontext_columns.py: pingbacks-shaped table (text[],inet,numeric,uuid,jsonb, NULL-mixed) through Dictionary/DictionaryLz4 and Lz4Blocked codecs; exact row equality before compression, after compression, and afterdeltax_decompress_partition; the literal incident query shape.Rollout (after merge)
Tag
v0.2.0-dotcms.5→ .deb build → ovh-k8s-cluster Dockerfile bump → rollpostgres-ovh-east→ decompress the 503 pingbacks partitions → re-GRANTpingback_reader. Pingbacks compression stays OFF until the write path is re-validated against arrays on a test table.🤖 Generated with Claude Code