Seen on spock 5.0.9 and 5.0.10 (pgedge-postgres:17-spock5-standard, PG
17.10). The relevant code is unchanged on current main.
Problem
spock_table_data_filtered() (spock_functions.c) builds
SELECT * FROM <table> [WHERE <row filters>], runs it through SPI and puts
the result tuples into the tuplestore as-is:
rc = SPI_execute(query.data, true, 0);
...
for (i = 0; i < SPI_processed; i++)
{
HeapTuple tup = SPI_tuptable->vals[i];
tuplestore_puttuple(tupstore, tup);
}
The tuplestore descriptor is the relation's tuple descriptor (the function
asserts equalTupleDescs(tupdesc, reltupdesc) earlier). But SELECT *
omits dropped columns, so SPI materializes its tuples with
natts = live columns only. Consumers then deform those tuples against the
relation descriptor, which still contains the dropped attributes. Every
attribute after the first dropped position is read at the wrong offset:
attributes beyond the tuple's natts come back NULL, attributes within it
return a neighbouring column's datum, and a misread varlena pointer
detoasts into a segfault.
This function feeds the provider-side COPY of every filtered initial sync
and every sub_resync_table, so the result is either silently wrong data
on the subscriber (the sync reports success) or a crashed provider
backend. In our case the subscriber's sync worker retried every ~20 s and
kept a production database in crash recovery until we disabled the
subscription.
Reproduction (silent-NULL variant)
CREATE TABLE public.tdf_repro (id int PRIMARY KEY, a text, junk text, b text);
INSERT INTO public.tdf_repro SELECT g, 'a'||g, 'x', 'b'||g FROM generate_series(1,100) g;
ALTER TABLE public.tdf_repro DROP COLUMN junk;
SELECT spock.repset_create('tmp_tdf_repro');
SELECT spock.repset_add_table('tmp_tdf_repro','public.tdf_repro', false, row_filter := 'id > 0');
SELECT count(*), max(b)
FROM (SELECT b FROM spock.table_data_filtered(
NULL::public.tdf_repro, 'public.tdf_repro'::regclass,
ARRAY['tmp_tdf_repro'])) x;
-- expected: 100 | b99 actual: 100 | NULL
With mixed fixed-width/varlena types after the dropped column it segfaults
instead. Our production table (137 live columns, dropped attnum 67,
timestamps and text above it) reliably crashed the provider in the sync
worker's COPY:
LOG: server process (PID ...) was terminated by signal 11: Segmentation fault
DETAIL: Failed process was running: COPY (SELECT "id","xid",... FROM
spock.table_data_filtered('"public"."vtab_work_order"'::regclass, ...)) TO stdout
FATAL: the database system is in recovery mode
Impact
- Any filtered or column-scoped initial sync / resync on a relation with
dropped columns delivers wrong data or crashes the provider. Long-lived
application schemas (Odoo, Django, ...) almost always have dropped
columns.
- A column list whose attributes all sit before the relation's first
dropped attnum happens to work, which hides the bug until a table with a
different layout is added.
Workaround
The function can be replaced at SQL level on the provider. A plpgsql
version routes through the regular executor, whose tuple conversion
handles dropped columns (NULL datums at the dropped positions). Same
signature and semantics (OR of the non-null row filters of the named
repsets containing the table; none -> all rows); the subscriber's COPY
doesn't care about the implementation language. We verified it
row-identical against direct table reads on the 137-column table above,
including the COPY that previously segfaulted:
CREATE OR REPLACE FUNCTION spock.table_data_filtered(
reltyp anyelement, relation regclass, repsets text[])
RETURNS SETOF anyelement
LANGUAGE plpgsql STABLE
SET search_path TO ''
AS $tdf$
DECLARE
filters text[];
where_clause text := '';
BEGIN
IF relation IS NULL THEN RAISE EXCEPTION 'relation cannot be NULL'; END IF;
IF repsets IS NULL THEN RAISE EXCEPTION 'repsets cannot be NULL'; END IF;
SELECT pg_catalog.array_agg(
'(' || pg_catalog.pg_get_expr(t.set_row_filter, t.set_reloid) || ')')
INTO filters
FROM spock.replication_set_table t
JOIN spock.replication_set s ON s.set_id = t.set_id
WHERE t.set_reloid = relation
AND s.set_name = ANY (repsets)
AND t.set_row_filter IS NOT NULL;
IF filters IS NOT NULL THEN
where_clause := ' WHERE ' || pg_catalog.array_to_string(filters, ' OR ');
END IF;
RETURN QUERY EXECUTE 'SELECT * FROM ' || relation::pg_catalog.text || where_clause;
END
$tdf$;
(Run with session-local spock.enable_ddl_replication = off so the
replacement doesn't travel through auto-DDL. An extension upgrade may put
the C version back.)
Suggested fix
Build the stored tuples against the relation descriptor instead of passing
SPI tuples through: deform each SPI tuple with SPI_tuptable->tupdesc and
re-form it with the relation descriptor (NULL datums at dropped
positions), or generate an explicit column list with NULL::<type>
placeholders for dropped attributes instead of SELECT *.
Seen on spock 5.0.9 and 5.0.10 (
pgedge-postgres:17-spock5-standard, PG17.10). The relevant code is unchanged on current main.
Problem
spock_table_data_filtered()(spock_functions.c) buildsSELECT * FROM <table> [WHERE <row filters>], runs it through SPI and putsthe result tuples into the tuplestore as-is:
The tuplestore descriptor is the relation's tuple descriptor (the function
asserts
equalTupleDescs(tupdesc, reltupdesc)earlier). ButSELECT *omits dropped columns, so SPI materializes its tuples with
natts = live columns only. Consumers then deform those tuples against the
relation descriptor, which still contains the dropped attributes. Every
attribute after the first dropped position is read at the wrong offset:
attributes beyond the tuple's natts come back NULL, attributes within it
return a neighbouring column's datum, and a misread varlena pointer
detoasts into a segfault.
This function feeds the provider-side COPY of every filtered initial sync
and every
sub_resync_table, so the result is either silently wrong dataon the subscriber (the sync reports success) or a crashed provider
backend. In our case the subscriber's sync worker retried every ~20 s and
kept a production database in crash recovery until we disabled the
subscription.
Reproduction (silent-NULL variant)
With mixed fixed-width/varlena types after the dropped column it segfaults
instead. Our production table (137 live columns, dropped attnum 67,
timestamps and text above it) reliably crashed the provider in the sync
worker's COPY:
Impact
dropped columns delivers wrong data or crashes the provider. Long-lived
application schemas (Odoo, Django, ...) almost always have dropped
columns.
dropped attnum happens to work, which hides the bug until a table with a
different layout is added.
Workaround
The function can be replaced at SQL level on the provider. A plpgsql
version routes through the regular executor, whose tuple conversion
handles dropped columns (NULL datums at the dropped positions). Same
signature and semantics (OR of the non-null row filters of the named
repsets containing the table; none -> all rows); the subscriber's COPY
doesn't care about the implementation language. We verified it
row-identical against direct table reads on the 137-column table above,
including the COPY that previously segfaulted:
(Run with session-local
spock.enable_ddl_replication = offso thereplacement doesn't travel through auto-DDL. An extension upgrade may put
the C version back.)
Suggested fix
Build the stored tuples against the relation descriptor instead of passing
SPI tuples through: deform each SPI tuple with
SPI_tuptable->tupdescandre-form it with the relation descriptor (NULL datums at dropped
positions), or generate an explicit column list with
NULL::<type>placeholders for dropped attributes instead of
SELECT *.