From c1b0f49feed1692d7d83c6ac127b0641882b100d Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Tue, 14 Jul 2026 20:22:18 -0400 Subject: [PATCH 1/2] fix: non-text column reads from compressed partitions (text[], inet, numeric) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/compress.rs | 77 ++++++++++ src/compression/dictionary.rs | 17 ++- src/scan/exec/datum_utils.rs | 61 ++++---- tests/test_nontext_columns.py | 280 ++++++++++++++++++++++++++++++++++ 6 files changed, 401 insertions(+), 38 deletions(-) create mode 100644 tests/test_nontext_columns.py diff --git a/Cargo.lock b/Cargo.lock index fbe93b4..be67175 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "pg_deltax" -version = "0.2.0-dotcms.4" +version = "0.2.0-dotcms.5" dependencies = [ "ahash", "cardinality-estimator", diff --git a/Cargo.toml b/Cargo.toml index ec1c032..8d268e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pg_deltax" -version = "0.2.0-dotcms.4" +version = "0.2.0-dotcms.5" edition = "2024" resolver = "3" license = "Apache-2.0" diff --git a/src/compress.rs b/src/compress.rs index 01892c9..e3fc1f3 100644 --- a/src/compress.rs +++ b/src/compress.rs @@ -1509,6 +1509,38 @@ pub(crate) unsafe fn jsonb_text_to_binary(text: &str) -> Vec { } } +/// Convert a binary jsonb varlena payload (everything after the varlena +/// header, as produced by `jsonb_text_to_binary` / `JsonbRaw`) back to its +/// canonical JSON text by wrapping it in a fresh varlena header and calling +/// PG's jsonb output function. Exact inverse of `jsonb_text_to_binary`. +/// Must only be called from the main backend thread. +pub(crate) unsafe fn jsonb_binary_to_text(payload: &[u8]) -> String { + unsafe { + let total_len = pgrx::pg_sys::VARHDRSZ + payload.len(); + let varlena = pgrx::pg_sys::palloc(total_len) as *mut pgrx::pg_sys::varlena; + pgrx::set_varsize_4b(varlena, total_len as i32); + std::ptr::copy_nonoverlapping( + payload.as_ptr(), + (varlena as *mut u8).add(pgrx::pg_sys::VARHDRSZ), + payload.len(), + ); + let mut typoutput: pgrx::pg_sys::Oid = pgrx::pg_sys::InvalidOid; + let mut typisvarlena: bool = false; + pgrx::pg_sys::getTypeOutputInfo(pgrx::pg_sys::JSONBOID, &mut typoutput, &mut typisvarlena); + let cstr = pgrx::pg_sys::OidOutputFunctionCall( + typoutput, + pgrx::pg_sys::Datum::from(varlena as usize), + ); + let text = std::ffi::CStr::from_ptr(cstr) + .to_str() + .expect("jsonb_out produced invalid UTF-8") + .to_string(); + pgrx::pg_sys::pfree(cstr.cast()); + pgrx::pg_sys::pfree(varlena.cast()); + text + } +} + /// Sort typed columns in-place by the given order_by column indices. /// Computes a permutation from the sort keys, then reorders all columns by that permutation. pub(crate) fn sort_typed_columns( @@ -3487,6 +3519,51 @@ fn decompress_column_values(blob: &[u8], data_type: &str) -> Vec> let total_count = cc.row_count as usize; let dt = data_type.to_lowercase(); + // jsonb blobs store PG's BINARY jsonb varlena payload (not UTF-8 text — + // see `jsonb_text_to_binary` / upstream #27), so they must be decoded via + // the byte-level codec entry points and converted back to canonical JSON + // text through jsonb's output function. Routing them through the + // UTF-8-validating text decoders below panics with "invalid UTF-8 in + // dictionary" / "invalid UTF-8 in LZ4 data". + if dt == "jsonb" { + let non_null_count = count_non_null(&cc.null_bitmap, total_count); + let strings: Vec = match cc.type_tag { + CompressionType::Dictionary => { + compression::dictionary::decode_to_byte_slices(&cc.data, non_null_count) + .iter() + .map(|b| unsafe { jsonb_binary_to_text(b) }) + .collect() + } + CompressionType::DictionaryLz4 => { + let normalized = compression::dictionary::normalize_lz4(&cc.data); + compression::dictionary::decode_to_byte_slices(&normalized, non_null_count) + .iter() + .map(|b| unsafe { jsonb_binary_to_text(b) }) + .collect() + } + CompressionType::Lz4 => { + let (buf, ranges) = compression::lz4::decode_to_ranges(&cc.data, non_null_count); + ranges + .iter() + .map(|&(off, len)| unsafe { jsonb_binary_to_text(&buf[off..off + len]) }) + .collect() + } + CompressionType::Lz4Blocked => { + let (buf, ranges) = + compression::lz4::decode_to_ranges_blocked(&cc.data, non_null_count, None); + ranges + .iter() + .map(|&(off, len)| unsafe { jsonb_binary_to_text(&buf[off..off + len]) }) + .collect() + } + other => pgrx::error!( + "pg_deltax: unexpected compression type {:?} for jsonb column", + other + ), + }; + return compression::reinsert_nulls(&strings, &cc.null_bitmap, total_count); + } + match cc.type_tag { CompressionType::Gorilla => { if dt.contains("timestamp") || dt == "date" { diff --git a/src/compression/dictionary.rs b/src/compression/dictionary.rs index 3b893a1..83aa1cb 100644 --- a/src/compression/dictionary.rs +++ b/src/compression/dictionary.rs @@ -37,11 +37,22 @@ pub fn parse_header(data: &[u8]) -> DictHeader<'_> { offset += 2; let mut dict: Vec<&str> = Vec::with_capacity(dict_size); - for _ in 0..dict_size { + for entry_idx in 0..dict_size { let str_len = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) as usize; offset += 4; - let s = std::str::from_utf8(&data[offset..offset + str_len]) - .expect("invalid UTF-8 in dictionary"); + let s = std::str::from_utf8(&data[offset..offset + str_len]).unwrap_or_else(|e| { + // Reaching this means a non-text payload (e.g. binary jsonb) was + // routed through the text dictionary decoder — an encoding-dispatch + // bug in the caller, which should use `parse_header_bytes` / + // `decode_to_byte_slices` instead. The panic unwinds into a regular + // PG ERROR (pgrx catches it at the FFI boundary); it never takes + // down the backend. + panic!( + "invalid UTF-8 in dictionary entry {} of {} (len {}): {}; \ + non-text payloads must be decoded via the byte-level dictionary API", + entry_idx, dict_size, str_len, e + ) + }); offset += str_len; dict.push(s); } diff --git a/src/scan/exec/datum_utils.rs b/src/scan/exec/datum_utils.rs index fd5ab5a..d521892 100644 --- a/src/scan/exec/datum_utils.rs +++ b/src/scan/exec/datum_utils.rs @@ -413,7 +413,7 @@ pub(super) unsafe fn decompress_blob_to_datums_truncated( /// /// Instead of allocating a PG varlena datum for every row and then filtering, /// this matches the LIKE pattern against raw `&str` slices (zero-copy) and only -/// calls `str_to_text_datum()` for rows that match. Non-matching rows get a +/// builds real datums (via `str_slices_to_text_datums_arena`) for rows that match. Non-matching rows get a /// dummy datum that will never be read (the returned selection vector marks them /// as filtered out). /// @@ -1419,8 +1419,6 @@ pub(super) unsafe fn decompress_jsonb_blob_with_selection( } } -/// Create a text/varchar/bpchar datum from a Rust string. -/// Allocates in the current memory context. /// Compare two strings using PG's collation-aware comparison. /// Returns negative if a < b, 0 if equal, positive if a > b. #[inline] @@ -1436,50 +1434,47 @@ pub(super) unsafe fn collation_strcmp(a: &str, b: &str) -> i32 { } } -pub(super) unsafe fn str_to_text_datum( - s: &str, - type_oid: pg_sys::Oid, - typmod: i32, -) -> pg_sys::Datum { - unsafe { - // bpchar needs the type input function for padding; jsonb stores - // as canonical text and needs the input function to produce a real - // jsonb binary Datum (otherwise jsonb operators segfault). - if type_oid == pg_sys::BPCHAROID || type_oid == pg_sys::JSONBOID { - let cstr = std::ffi::CString::new(s).unwrap(); - let mut typinput: pg_sys::Oid = pg_sys::InvalidOid; - let mut typioparam: pg_sys::Oid = pg_sys::InvalidOid; - pg_sys::getTypeInputInfo(type_oid, &mut typinput, &mut typioparam); - pg_sys::OidInputFunctionCall(typinput, cstr.as_ptr() as *mut _, typioparam, typmod) - } else { - // text/varchar: direct varlena construction (avoids type input function lookup) - let text = pg_sys::cstring_to_text_with_len(s.as_ptr() as *const _, s.len() as i32); - pg_sys::Datum::from(text as usize) - } - } -} - /// Allocate text/varchar datums from string slices using a single contiguous allocation. /// /// Instead of N individual palloc calls (one per string), this allocates one /// large block and packs all varlena headers + string data sequentially. /// This dramatically improves cache locality during the per-row emit loop. /// -/// For bpchar, falls back to per-string allocation (needs type input function for padding). +/// For anything that isn't text/varchar, falls back to per-string +/// reconstruction through the type input function (bpchar padding, and +/// non-text types stored via their text rendering: text[], inet, numeric, ...). pub(super) unsafe fn str_slices_to_text_datums_arena( slices: &[&str], type_oid: pg_sys::Oid, typmod: i32, ) -> Vec { - // bpchar needs the input function for padding. jsonb should normally go - // through `byte_slices_to_jsonb_datums_arena` (the bytes are binary, not - // UTF-8); this branch is only a safety net for any caller that still - // hands us text. - if type_oid == pg_sys::BPCHAROID || type_oid == pg_sys::JSONBOID { + // Only text/varchar attributes may take the raw-varlena arena fast path. + // Any other type_oid means the stored strings are TEXT RENDERINGS of a + // different type — bpchar (padding), jsonb (safety net; normally goes + // through `byte_slices_to_jsonb_datums_arena`), and the classify_column + // fallthrough types (text[], inet, numeric, uuid, ...). Those must be + // reconstructed via the type input function so the resulting Datum + // matches the attribute's real binary representation — handing a raw + // text varlena to a slot whose attribute type is e.g. text[] makes PG + // read text bytes as an ArrayType header: garbage dims/pointers, silent + // NULLs or a backend crash. One getTypeInputInfo lookup, then one + // input-function call per value. + if !matches!(type_oid, pg_sys::TEXTOID | pg_sys::VARCHAROID) { return unsafe { + let mut typinput: pg_sys::Oid = pg_sys::InvalidOid; + let mut typioparam: pg_sys::Oid = pg_sys::InvalidOid; + pg_sys::getTypeInputInfo(type_oid, &mut typinput, &mut typioparam); slices .iter() - .map(|s| str_to_text_datum(s, type_oid, typmod)) + .map(|s| { + let cstr = std::ffi::CString::new(*s).unwrap(); + pg_sys::OidInputFunctionCall( + typinput, + cstr.as_ptr() as *mut _, + typioparam, + typmod, + ) + }) .collect() }; } diff --git a/tests/test_nontext_columns.py b/tests/test_nontext_columns.py new file mode 100644 index 0000000..c728ddd --- /dev/null +++ b/tests/test_nontext_columns.py @@ -0,0 +1,280 @@ +"""Integration tests for non-text column types stored via the Text fallback. + +Regression tests for the 2026-07-14 `pingbacks` incident: columns whose type +falls through `classify_column`'s default arm (text[], inet, numeric, uuid, ...) +are compressed as their TEXT rendering (`::text` cast on the compress SELECT). +Reads must reconstruct real typed datums via the type input function — handing +PG a raw text varlena tagged with the column's type oid makes it read text +bytes as e.g. an ArrayType header (garbage dims/pointers → silent NULLs or a +backend crash). + +Also covers `deltax_decompress_partition` on jsonb columns, whose dictionary / +LZ4 blobs hold BINARY jsonb payloads and previously panicked the text decoders +with "invalid UTF-8 in dictionary". +""" + +MOCK_NOW = "2025-01-15 12:00:00+00" +BASE_TS = "2025-01-15 00:00:00+00" + +# Column list + text renderings/typed expressions used for exact-match +# comparison before compression, after compression, and after decompression. +SNAPSHOT_SQL = """ + SELECT n, + cluster_id, + default_host, + hostnames::text, + array_length(hostnames, 1), + ip_address::text, + host(ip_address), + geo_lat::text, + round(geo_lat, 3)::text, + uid::text, + meta::text + FROM {table} + ORDER BY n +""" + + +def setup_pingbacks_table(conn, table_name="pingbacks"): + """Create a partitioned table shaped like the incident's pingbacks table.""" + conn.execute(f"SET pg_deltax.mock_now = '{MOCK_NOW}'") + conn.execute(f""" + CREATE TABLE {table_name} ( + ts TIMESTAMPTZ NOT NULL, + n INTEGER NOT NULL, + cluster_id VARCHAR NOT NULL, + default_host TEXT, + hostnames TEXT[], + ip_address INET, + geo_lat NUMERIC, + uid UUID, + meta JSONB + ) + """) + conn.execute( + f"SELECT deltax.deltax_create_table('{table_name}', 'ts', '1 day'::interval)" + ) + conn.execute( + f"SELECT deltax.deltax_enable_compression('{table_name}', " + "segment_by => ARRAY['cluster_id'], " + "order_by => ARRAY['ts'])" + ) + conn.commit() + + +def insert_low_cardinality(conn, table_name="pingbacks", n_rows=200): + """Low-cardinality values → Dictionary / DictionaryLz4 codecs. + + Mirrors the incident data: 125-distinct hostnames over 929 rows was + dictionary-encoded. Every 4th row is NULL for the fallthrough-typed + columns to exercise null-bitmap reinsertion. + """ + hostname_arrays = [ + "ARRAY['Be Part of Research','test.dotcms.com']", + "ARRAY['demo.dotcms.com']", + "ARRAY['a b c','d,e','f\"g']", # spaces, comma, quote — array_out quoting + "ARRAY['x.example.org','y.example.org','z.example.org']", + "ARRAY[]::text[]", + "ARRAY['single']", + "ARRAY['multi word host','another.example']", + "ARRAY['h1','h2','h3','h4']", + ] + ips = ["10.0.1.7", "192.168.44.5", "34.231.41.127", "2001:db8::1", "172.16.0.9"] + lats = ["39.0469000", "-12.5000", "0.0001", "89.999999", "39.0469"] + uuids = [ + "0e37df36-f698-11e6-8dd4-cb9ced3df976", + "6ecd8c99-4036-403d-bf84-cf8400f67836", + "3f333df6-90a4-4fda-8dd3-9485d27cee36", + ] + metas = ['{"k": 1}', '{"k": 2, "tags": ["a", "b"]}', '{"nested": {"x": 1.5}}'] + + values = [] + for i in range(n_rows): + ts = f"'{BASE_TS}'::timestamptz + interval '{i} minutes'" + cluster = f"'cl-{i % 3}'" + host = f"'host-{i % 6}.dotcms.com'" + if i % 4 == 3: + hostnames = "NULL" + ip = "NULL" + lat = "NULL" + uid = "NULL" + meta = "NULL" + else: + hostnames = hostname_arrays[i % len(hostname_arrays)] + ip = f"'{ips[i % len(ips)]}'::inet" + lat = f"'{lats[i % len(lats)]}'::numeric" + uid = f"'{uuids[i % len(uuids)]}'::uuid" + meta = f"'{metas[i % len(metas)]}'::jsonb" + values.append( + f"({ts}, {i}, {cluster}, {host}, {hostnames}, {ip}, {lat}, {uid}, {meta})" + ) + + conn.execute( + f"INSERT INTO {table_name} " + "(ts, n, cluster_id, default_host, hostnames, ip_address, geo_lat, uid, meta) " + "VALUES " + ", ".join(values) + ) + conn.commit() + + +def insert_high_cardinality(conn, table_name="pingbacks", n_rows=60): + """Unique-per-row values → cardinality > 50% of rows → Lz4Blocked codec. + + Also gives the text[] column > 32 distinct values (past the valbitmap + distinct cap) with NULLs mixed in. + """ + values = [] + for i in range(n_rows): + ts = f"'{BASE_TS}'::timestamptz + interval '{i} minutes'" + cluster = f"'cl-{i % 2}'" + host = f"'host-{i}.dotcms.com'" + if i % 5 == 4: + hostnames = "NULL" + ip = "NULL" + lat = "NULL" + uid = "NULL" + meta = "NULL" + else: + hostnames = ( + f"ARRAY['u-{i}.example.com','v-{i}.example.com','w space {i}']" + ) + ip = f"'10.{(i >> 8) & 255}.{i & 255}.{(i * 7) % 250 + 1}'::inet" + lat = f"'{i}.{i:06d}'::numeric" + uid = f"'00000000-0000-4000-8000-{i:012d}'::uuid" + meta = f'\'{{"i": {i}, "s": "row-{i}"}}\'::jsonb' + values.append( + f"({ts}, {i}, {cluster}, {host}, {hostnames}, {ip}, {lat}, {uid}, {meta})" + ) + + conn.execute( + f"INSERT INTO {table_name} " + "(ts, n, cluster_id, default_host, hostnames, ip_address, geo_lat, uid, meta) " + "VALUES " + ", ".join(values) + ) + conn.commit() + + +def find_partition(conn, table_name="pingbacks"): + partitions = conn.execute( + f"SELECT partition_name FROM deltax.deltax_partition_info('{table_name}') " + "WHERE range_start <= '2025-01-15'::timestamptz " + "AND range_end > '2025-01-15'::timestamptz" + ).fetchall() + assert len(partitions) > 0 + return partitions[0][0] + + +def compress_partition(conn, part_name, table_name="pingbacks"): + result = conn.execute( + f"SELECT deltax.deltax_compress_partition('{part_name}')" + ).fetchone()[0] + conn.commit() + assert "Compressed" in result + is_compressed = conn.execute( + f"SELECT is_compressed FROM deltax.deltax_partition_info('{table_name}') " + f"WHERE partition_name = '{part_name}'" + ).fetchone()[0] + assert is_compressed is True + + +def _roundtrip(db, insert_fn): + setup_pingbacks_table(db) + insert_fn(db) + + before = db.execute(SNAPSHOT_SQL.format(table="pingbacks")).fetchall() + assert len(before) > 0 + + part_name = find_partition(db) + compress_partition(db, part_name) + + # Reads through the compressed custom scan must match pre-compression + # exactly — typed datums reconstructed from the stored text renderings. + after_compress = db.execute(SNAPSHOT_SQL.format(table="pingbacks")).fetchall() + assert after_compress == before, ( + "non-text column values changed after compression — the emit path is " + "handing back wrong datums" + ) + + # Decompress must restore the identical rows (incident: this errored with + # 'invalid UTF-8 in dictionary' on binary jsonb dictionaries). + result = db.execute( + f"SELECT deltax.deltax_decompress_partition('{part_name}')" + ).fetchone()[0] + db.commit() + assert "Decompressed" in result + + after_decompress = db.execute(SNAPSHOT_SQL.format(table="pingbacks")).fetchall() + assert after_decompress == before, ( + "non-text column values changed after decompress_partition" + ) + + +class TestNonTextColumns: + def test_low_cardinality_roundtrip(self, db): + """Dictionary/DictionaryLz4 codecs: text[], inet, numeric, uuid, jsonb.""" + _roundtrip(db, insert_low_cardinality) + + def test_high_cardinality_roundtrip(self, db): + """Lz4Blocked codec: >32-distinct text[] plus unique inet/numeric/uuid.""" + _roundtrip(db, insert_high_cardinality) + + def test_array_length_incident_query(self, db): + """The exact incident query shape: array_length() over a compressed + partition, both with and without LIMIT. Previously returned silent + NULLs (small LIMIT) or crashed the backend (full scan).""" + setup_pingbacks_table(db) + insert_low_cardinality(db) + + expected = db.execute( + "SELECT n, array_length(hostnames, 1) FROM pingbacks ORDER BY n" + ).fetchall() + expected_limited = db.execute( + "SELECT array_length(hostnames, 1) FROM pingbacks " + "WHERE hostnames IS NOT NULL ORDER BY n LIMIT 5" + ).fetchall() + assert any(v is not None for (v,) in expected_limited) + + part_name = find_partition(db) + compress_partition(db, part_name) + + got = db.execute( + "SELECT n, array_length(hostnames, 1) FROM pingbacks ORDER BY n" + ).fetchall() + assert got == expected + + got_limited = db.execute( + "SELECT array_length(hostnames, 1) FROM pingbacks " + "WHERE hostnames IS NOT NULL ORDER BY n LIMIT 5" + ).fetchall() + assert got_limited == expected_limited + + def test_typed_functions_on_compressed(self, db): + """Type-specific functions must work on datums read from compressed + partitions: unnest(text[]), inet <<= cidr, numeric aggregation.""" + setup_pingbacks_table(db) + insert_low_cardinality(db) + + pre = { + "unnest": db.execute( + "SELECT count(*) FROM (SELECT unnest(hostnames) FROM pingbacks) s" + ).fetchone()[0], + "inet": db.execute( + "SELECT count(*) FROM pingbacks WHERE ip_address <<= '10.0.0.0/8'::cidr" + ).fetchone()[0], + "sum": db.execute( + "SELECT sum(geo_lat)::text FROM pingbacks" + ).fetchone()[0], + } + + part_name = find_partition(db) + compress_partition(db, part_name) + + assert db.execute( + "SELECT count(*) FROM (SELECT unnest(hostnames) FROM pingbacks) s" + ).fetchone()[0] == pre["unnest"] + assert db.execute( + "SELECT count(*) FROM pingbacks WHERE ip_address <<= '10.0.0.0/8'::cidr" + ).fetchone()[0] == pre["inet"] + assert db.execute( + "SELECT sum(geo_lat)::text FROM pingbacks" + ).fetchone()[0] == pre["sum"] From ac51380f17561d0fe09e462cc5eef4ce1f24f818 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Tue, 14 Jul 2026 21:08:57 -0400 Subject: [PATCH 2/2] test: un-xfail numeric/time/uuid/bytea fallback regressions (now fixed) 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. --- tests/correctness/test_codecs_extended.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/correctness/test_codecs_extended.py b/tests/correctness/test_codecs_extended.py index 2cadbb6..68e3bec 100644 --- a/tests/correctness/test_codecs_extended.py +++ b/tests/correctness/test_codecs_extended.py @@ -275,7 +275,6 @@ def test_direct_backfill_null_segment_by_regression(db): ) -@pytest.mark.xfail(strict=True, reason="numeric fallback columns currently decode with invalid numeric text") def test_numeric_fallback_type_regression(db): db.execute("SET pg_deltax.mock_now = '2025-01-20 12:00:00+00'") for table_name in ("numeric_fallback_plain", "numeric_fallback"): @@ -333,7 +332,6 @@ def test_numeric_fallback_type_regression(db): ) -@pytest.mark.xfail(strict=True, reason="time fallback columns currently decode to invalid time values") def test_time_fallback_type_regression(db): db.execute("SET pg_deltax.mock_now = '2025-01-20 12:00:00+00'") for table_name in ("time_fallback_plain", "time_fallback"): @@ -385,7 +383,6 @@ def test_time_fallback_type_regression(db): ) -@pytest.mark.xfail(strict=True, reason="uuid fallback columns currently decode from raw text bytes") def test_uuid_fallback_type_regression(db): db.execute("SET pg_deltax.mock_now = '2025-01-20 12:00:00+00'") for table_name in ("uuid_fallback_plain", "uuid_fallback"): @@ -437,7 +434,6 @@ def test_uuid_fallback_type_regression(db): ) -@pytest.mark.xfail(strict=True, reason="bytea fallback columns currently decode escaped text bytes") def test_bytea_fallback_type_regression(db): db.execute("SET pg_deltax.mock_now = '2025-01-20 12:00:00+00'") for table_name in ("bytea_fallback_plain", "bytea_fallback"):