Skip to content

table_data_filtered() returns corrupt rows for tables with dropped columns (wrong/NULL data, provider segfault) #528

Description

@ilexius

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions