From b68560387388d0443a71cf02a39c8570010a0669 Mon Sep 17 00:00:00 2001 From: Ajin Cherian Date: Wed, 4 Nov 2026 20:54:14 +1100 Subject: [PATCH v1] Fix tablesync of tables with no published columns. Since v18, the initial table synchronization relies on the column list returned by pg_get_publication_tables() to skip generated columns that are not published, rather than excluding generated columns in its own pg_attribute query. When a table has no explicit column list, that function reports the set of publishable columns, but it returns NULL when there are none, for example for a table whose only columns are VIRTUAL generated columns, or STORED generated columns with publish_generated_columns = none. The subscriber interpreted that NULL as "no column list, replicate all columns", so the tablesync worker tried to replicate the unpublished generated columns. This either failed with "logical replication target relation ... is missing replicated column", or, if the subscriber table had a regular column of the same name, copied the generated values even though incremental replication never sends them. Fix by also fetching whether the attrs reported by the publisher is NULL, and, for v18+ publishers, treating that as no columns being published. Older publishers exclude generated columns in the pg_attribute query, so they are not affected. Reported-by: Peter Smith Discussion: https://postgr.es/m/CAHut+Pu7CuuqSJNzXUXiBfhByd16j4JtLXeNEr=QtPMjjH02Rg@mail.gmail.com --- src/backend/replication/logical/tablesync.c | 27 ++++++-- src/test/subscription/t/011_generated.pl | 77 +++++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/backend/replication/logical/tablesync.c b/src/backend/replication/logical/tablesync.c index 723012fb0c0..3ef90ac957f 100644 --- a/src/backend/replication/logical/tablesync.c +++ b/src/backend/replication/logical/tablesync.c @@ -735,6 +735,7 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel, int natt; StringInfo pub_names = NULL; Bitmapset *included_cols = NULL; + bool no_cols_published = false; int server_version = walrcv_server_version(LogRepWorkerWalRcvConn); lrel->nspname = nspname; @@ -787,7 +788,7 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel, { WalRcvExecResult *pubres; TupleTableSlot *tslot; - Oid attrsRow[] = {INT2VECTOROID}; + Oid attrsRow[] = {INT2VECTOROID, BOOLOID}; /* Build the pub_names comma-separated string. */ pub_names = makeStringInfo(); @@ -808,7 +809,8 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel, appendStringInfo(&cmd, "SELECT DISTINCT" " (CASE WHEN (array_length(gpt.attrs, 1) = c.relnatts)" - " THEN NULL ELSE gpt.attrs END)" + " THEN NULL ELSE gpt.attrs END)," + " gpt.attrs IS NULL" " FROM pg_get_publication_tables(ARRAY[%s], %u) gpt," " pg_class c" " WHERE c.oid = gpt.relid", @@ -819,7 +821,8 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel, appendStringInfo(&cmd, "SELECT DISTINCT" " (CASE WHEN (array_length(gpt.attrs, 1) = c.relnatts)" - " THEN NULL ELSE gpt.attrs END)" + " THEN NULL ELSE gpt.attrs END)," + " gpt.attrs IS NULL" " FROM pg_publication p," " LATERAL pg_get_publication_tables(p.pubname) gpt," " pg_class c" @@ -876,6 +879,21 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel, included_cols = bms_add_member(included_cols, elems[natt]); } + /* + * Since v16, pg_get_publication_tables() reports the full set of + * published columns even when no column list was specified, so a + * NULL there means that no column of the table is published (for + * example, a table whose only columns are generated columns that + * are not published). The CASE above folds that into the same + * NULL as "all columns", so look at the raw value instead. + * + * Before v18 the column query below excludes generated columns + * itself, so this only matters for v18+ publishers. + */ + if (server_version >= 180000 && + DatumGetBool(slot_getattr(tslot, 2, &isnull))) + no_cols_published = true; + ExecClearTuple(tslot); } ExecDropSingleTupleTableSlot(tslot); @@ -938,7 +956,8 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel, Assert(!isnull); /* If the column is not in the column list, skip it. */ - if (included_cols != NULL && !bms_is_member(attnum, included_cols)) + if (no_cols_published || + (included_cols != NULL && !bms_is_member(attnum, included_cols))) { ExecClearTuple(slot); continue; diff --git a/src/test/subscription/t/011_generated.pl b/src/test/subscription/t/011_generated.pl index af551fa43b4..c7a4a8edf1e 100644 --- a/src/test/subscription/t/011_generated.pl +++ b/src/test/subscription/t/011_generated.pl @@ -328,6 +328,83 @@ is( $result, qq(|2| $node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION sub1"); $node_publisher->safe_psql('postgres', "DROP PUBLICATION pub1"); +# ============================================================================= +# Exercise the initial sync of tables whose only columns are generated columns +# that are not published, i.e. tables for which no columns are replicated: +# +# - a table with only a VIRTUAL generated column (never published); +# - a table with only a STORED generated column, with +# publish_generated_columns = 'none'. +# +# The tablesync worker must not try to replicate the generated columns. The +# subscriber-only columns must get their default values, and the value of a +# generated column must not be copied into a subscriber column of the same +# name. Test both text and binary mode. +# ============================================================================= + +$node_publisher->safe_psql( + 'postgres', qq( + CREATE TABLE tab_virt_only_text (a int GENERATED ALWAYS AS (99) VIRTUAL); + CREATE TABLE tab_virt_only_bin (a int GENERATED ALWAYS AS (99) VIRTUAL); + CREATE TABLE tab_stored_only_text (b int GENERATED ALWAYS AS (77) STORED); + CREATE TABLE tab_stored_only_bin (b int GENERATED ALWAYS AS (77) STORED); + INSERT INTO tab_virt_only_text DEFAULT VALUES; + INSERT INTO tab_virt_only_bin DEFAULT VALUES; + INSERT INTO tab_stored_only_text DEFAULT VALUES; + INSERT INTO tab_stored_only_bin DEFAULT VALUES; + CREATE PUBLICATION regress_pub_gen_only_text FOR TABLE tab_virt_only_text, tab_stored_only_text WITH (publish_generated_columns = none); + CREATE PUBLICATION regress_pub_gen_only_bin FOR TABLE tab_virt_only_bin, tab_stored_only_bin WITH (publish_generated_columns = none); +)); + +# The subscriber tables for tab_virt_only_* have no column named "a", and the +# ones for tab_stored_only_* have a regular column "b". +$node_subscriber->safe_psql( + 'postgres', qq( + CREATE TABLE tab_virt_only_text (c text DEFAULT 'sub-default'); + CREATE TABLE tab_virt_only_bin (c text DEFAULT 'sub-default'); + CREATE TABLE tab_stored_only_text (b int, c text DEFAULT 'sub-default'); + CREATE TABLE tab_stored_only_bin (b int, c text DEFAULT 'sub-default'); +)); +$node_subscriber->safe_psql('postgres', + "CREATE SUBSCRIPTION regress_sub_gen_only_text CONNECTION '$publisher_connstr' PUBLICATION regress_pub_gen_only_text WITH (binary = false)" +); +$node_subscriber->safe_psql('postgres', + "CREATE SUBSCRIPTION regress_sub_gen_only_bin CONNECTION '$publisher_connstr' PUBLICATION regress_pub_gen_only_bin WITH (binary = true)" +); + +$node_subscriber->wait_for_subscription_sync($node_publisher, + 'regress_sub_gen_only_text'); +$node_subscriber->wait_for_subscription_sync($node_publisher, + 'regress_sub_gen_only_bin'); + +is( $node_subscriber->safe_psql('postgres', + "SELECT c FROM tab_virt_only_text"), + 'sub-default', + 'unpublished VIRTUAL generated column is not synced (text)'); +is( $node_subscriber->safe_psql('postgres', + "SELECT c FROM tab_virt_only_bin"), + 'sub-default', + 'unpublished VIRTUAL generated column is not synced (binary)'); +is( $node_subscriber->safe_psql('postgres', + "SELECT b, c FROM tab_stored_only_text"), + '|sub-default', + 'unpublished STORED generated column is not synced (text)'); +is( $node_subscriber->safe_psql('postgres', + "SELECT b, c FROM tab_stored_only_bin"), + '|sub-default', + 'unpublished STORED generated column is not synced (binary)'); + +# cleanup +$node_subscriber->safe_psql('postgres', + "DROP SUBSCRIPTION regress_sub_gen_only_text"); +$node_subscriber->safe_psql('postgres', + "DROP SUBSCRIPTION regress_sub_gen_only_bin"); +$node_publisher->safe_psql( + 'postgres', qq( + DROP PUBLICATION regress_pub_gen_only_text; + DROP PUBLICATION regress_pub_gen_only_bin; +)); + # ============================================================================= # The following test verifies the expected error when replicating to a # generated subscriber column. Test the following combinations: -- 2.47.3