Hi,
Initial table synchronization mishandles a zero-column published table
when the subscriber has additional columns. In text mode, COPY can put
an empty string into a subscriber-only column instead of its default.
Subsequent inserts through the apply worker use the default correctly.
In binary mode, the initial copy fails with a field-count mismatch.
I reproduced this on master (20devel) at:
77705d1e5a616339c3e92f586bbfa7b6bb562beb
For example, use two fresh instances with logical replication enabled
on the publisher:
-- Publisher:
CREATE TABLE lr02_t ();
INSERT INTO lr02_t DEFAULT VALUES;
CREATE PUBLICATION lr02_pub FOR TABLE lr02_t WITH (publish = 'insert');
-- Subscriber (replace the connection string):
CREATE TABLE lr02_t (v text DEFAULT 'subscriber-default');
CREATE SUBSCRIPTION lr02_sub
CONNECTION '<publisher connection string>'
PUBLICATION lr02_pub WITH (copy_data = true, binary = false);
On the subscriber, wait for this query to return 'r':
SELECT srsubstate FROM pg_subscription_rel
WHERE srsubid = (SELECT oid FROM pg_subscription
WHERE subname = 'lr02_sub');
Then run:
SELECT quote_nullable(v) FROM lr02_t;
The result is '' instead of 'subscriber-default'. Inserting another row
with DEFAULT VALUES on the publisher produces a correctly defaulted row
on the subscriber once replication catches up. Thus initial and
incremental replication disagree for the same source data.
Repeating the example on fresh instances with binary = true instead
makes initial synchronization fail with:
ERROR: row field count is 0, expected 1
make_copy_attnamelist() returns NIL for the zero-column source, but
BeginCopyFrom() interprets NIL as an unspecified column list and selects
all target non-dropped, non-generated columns. Those columns are then
treated as input columns, so their default expressions are not prepared.
The attached patch adds BeginCopyFromExtended() with a flag indicating
that the column list is explicit. Tablesync uses that entry point, so
an empty list remains empty. This is resolved before default expressions
and parser state are initialized. The existing BeginCopyFrom() interface
retains its behavior for SQL COPY, file_fdw and other callers.
The change keeps row parsing, default evaluation, constraints, triggers
and partition routing in the existing COPY path. There are no SQL syntax
or replication protocol changes. Its scope is the handling of an empty
input column list after remote column discovery; it does not change
which publisher columns are selected for replication.
The patch includes text and binary regression coverage in
008_diff_schema.pl. With only the test changes applied to the baseline,
three assertions fail and two dependent assertions are skipped. With
the fix, all 11 assertions pass.
To reproduce the failure, apply just the test portion to a clean
checkout of the baseline:
git apply --include=src/test/subscription/t/008_diff_schema.pl \
/path/to/v1-0001-Fix-initial-sync-of-zero-column-published-tables.patch
>From a configured and built tree with TAP tests enabled, run:
make -j8 -C src/test/subscription check \
PROVE_TESTS=t/008_diff_schema.pl
For an out-of-tree build, run the make command from the build directory.
Applying the remaining source/header changes and rebuilding makes the
test pass.
Testing used Linux x86_64, GCC 15.2, -O2 -g, with assertions disabled.
The targeted subscription tests 004_sync, 008_diff_schema, 014_binary
and 031_column_list pass (64 assertions). A separate 24-assertion test
also passes, covering local defaults, identity values, NULLs, subscriber
stored generated columns, replica triggers, partition routing, dropped
publisher columns and NOT NULL checks in text and binary modes.
The test_setup, copy, copyselect, copydml and copyencoding regression
tests, and the file_fdw regression test, also pass. I have not tested
older major versions or run the full test suite.
Comments on the internal COPY interface change are welcome.
Best regards,
mingjiegao
From 165fcc3a5ed488905cbf8b066913b71f8e7ffe9a Mon Sep 17 00:00:00 2001
From: mingjiegao <[email protected]>
Date: Sat, 31 Oct 2026 23:06:26 +0800
Subject: [PATCH v1] Fix initial sync of zero-column published tables
Initial table synchronization passes an empty column-name list to
BeginCopyFrom() when the publisher has no columns. BeginCopyFrom()
interprets that as an unspecified list and selects all target columns
that are neither dropped nor generated, including columns that exist
only on the subscriber. Text COPY can therefore store an empty string
instead of the subscriber's default, while binary COPY fails because
the row contains zero fields.
Add BeginCopyFromExtended() so callers can distinguish an explicitly
empty input column list from an unspecified list. Use it in tablesync,
leaving the existing BeginCopyFrom() interface and behavior unchanged.
Resolve the explicit list before defaults and parser state are
initialized, so the usual COPY machinery supplies the subscriber-only
values and handles constraints, triggers and partition routing.
Add regression tests for initial and incremental zero-column rows in
text and binary transfer modes.
---
src/backend/commands/copyfrom.c | 26 +++++++-
src/backend/replication/logical/tablesync.c | 3 +-
src/include/commands/copy.h | 5 ++
src/test/subscription/t/008_diff_schema.pl | 72 +++++++++++++++++++++
4 files changed, 104 insertions(+), 2 deletions(-)
diff --git a/src/backend/commands/copyfrom.c b/src/backend/commands/copyfrom.c
index 746b4fdf6c3..534eb1ea343 100644
--- a/src/backend/commands/copyfrom.c
+++ b/src/backend/commands/copyfrom.c
@@ -1540,6 +1540,27 @@ BeginCopyFrom(ParseState *pstate,
copy_data_source_cb data_source_cb,
List *attnamelist,
List *options)
+{
+ return BeginCopyFromExtended(pstate, rel, whereClause, filename,
+ is_program, data_source_cb, attnamelist,
+ options, false);
+}
+
+/*
+ * Like BeginCopyFrom(), but if attnamelist_is_explicit is true, a NIL
+ * attnamelist means zero input columns rather than all target columns.
+ * This is needed when a logical replication source has no columns.
+ */
+CopyFromState
+BeginCopyFromExtended(ParseState *pstate,
+ Relation rel,
+ Node *whereClause,
+ const char *filename,
+ bool is_program,
+ copy_data_source_cb data_source_cb,
+ List *attnamelist,
+ List *options,
+ bool attnamelist_is_explicit)
{
CopyFromState cstate;
bool pipe = (filename == NULL);
@@ -1590,7 +1611,10 @@ BeginCopyFrom(ParseState *pstate,
/* process common options or initialization */
/* Generate or convert list of attributes to process */
- cstate->attnumlist = CopyGetAttnums(tupDesc, cstate->rel, attnamelist);
+ if (attnamelist_is_explicit && attnamelist == NIL)
+ cstate->attnumlist = NIL;
+ else
+ cstate->attnumlist = CopyGetAttnums(tupDesc, cstate->rel, attnamelist);
num_phys_attrs = tupDesc->natts;
diff --git a/src/backend/replication/logical/tablesync.c b/src/backend/replication/logical/tablesync.c
index 4015e861a64..723012fb0c0 100644
--- a/src/backend/replication/logical/tablesync.c
+++ b/src/backend/replication/logical/tablesync.c
@@ -1211,7 +1211,8 @@ copy_table(Relation rel)
NULL, false, false);
attnamelist = make_copy_attnamelist(relmapentry);
- cstate = BeginCopyFrom(pstate, rel, NULL, NULL, false, copy_read_data, attnamelist, options);
+ cstate = BeginCopyFromExtended(pstate, rel, NULL, NULL, false,
+ copy_read_data, attnamelist, options, true);
/* Do the copy */
(void) CopyFrom(cstate);
diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h
index abecfe51098..74203f3547f 100644
--- a/src/include/commands/copy.h
+++ b/src/include/commands/copy.h
@@ -114,6 +114,11 @@ extern void ProcessCopyOptions(ParseState *pstate, CopyFormatOptions *opts_out,
extern CopyFromState BeginCopyFrom(ParseState *pstate, Relation rel, Node *whereClause,
const char *filename,
bool is_program, copy_data_source_cb data_source_cb, List *attnamelist, List *options);
+extern CopyFromState BeginCopyFromExtended(ParseState *pstate, Relation rel,
+ Node *whereClause, const char *filename,
+ bool is_program, copy_data_source_cb data_source_cb,
+ List *attnamelist, List *options,
+ bool attnamelist_is_explicit);
extern void EndCopyFrom(CopyFromState cstate);
extern bool NextCopyFrom(CopyFromState cstate, ExprContext *econtext,
Datum *values, bool *nulls);
diff --git a/src/test/subscription/t/008_diff_schema.pl b/src/test/subscription/t/008_diff_schema.pl
index 96e3d6f5888..7b66c58f9d2 100644
--- a/src/test/subscription/t/008_diff_schema.pl
+++ b/src/test/subscription/t/008_diff_schema.pl
@@ -31,6 +31,78 @@ $node_subscriber->safe_psql('postgres',
# Setup logical replication
my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+# A zero-column publisher still sends rows. Subscriber-only columns must
+# get their defaults during initial COPY as well as incremental replication.
+foreach my $binary ('false', 'true')
+{
+ $node_publisher->safe_psql(
+ 'postgres', q{
+ CREATE TABLE tab_nocols ();
+ INSERT INTO tab_nocols DEFAULT VALUES;
+ INSERT INTO tab_nocols DEFAULT VALUES;
+ CREATE PUBLICATION pub_nocols FOR TABLE tab_nocols
+ WITH (publish = 'insert');
+ });
+ $node_subscriber->safe_psql('postgres',
+ "CREATE TABLE tab_nocols (local_value text DEFAULT 'subscriber-default')"
+ );
+ $node_subscriber->safe_psql('postgres',
+ "CREATE SUBSCRIPTION sub_nocols CONNECTION '$publisher_connstr' PUBLICATION pub_nocols WITH (binary = $binary)"
+ );
+
+ # Stop waiting if the copy fails, so that a field-count error does not
+ # obscure the regression behind a timeout.
+ $node_subscriber->poll_query_until(
+ 'postgres', q{
+ SELECT EXISTS (
+ SELECT 1 FROM pg_subscription_rel
+ WHERE srsubid = (SELECT oid FROM pg_subscription
+ WHERE subname = 'sub_nocols')
+ AND srsubstate = 'r')
+ OR EXISTS (
+ SELECT 1 FROM pg_stat_subscription_stats
+ WHERE subname = 'sub_nocols' AND sync_table_error_count > 0)
+ }) or die "timed out waiting for zero-column table synchronization";
+ my $ready = $node_subscriber->safe_psql(
+ 'postgres', q{
+ SELECT srsubstate = 'r' FROM pg_subscription_rel
+ WHERE srsubid = (SELECT oid FROM pg_subscription
+ WHERE subname = 'sub_nocols')
+ });
+ is($ready, 't', "zero-column table synchronized (binary = $binary)");
+
+ SKIP:
+ {
+ skip 'initial synchronization failed', 2 if $ready ne 't';
+
+ is( $node_subscriber->safe_psql(
+ 'postgres', q{
+ SELECT count(*) FROM tab_nocols
+ WHERE local_value = 'subscriber-default'
+ }),
+ '2',
+ "initial zero-column rows use local defaults (binary = $binary)");
+
+ $node_publisher->safe_psql('postgres',
+ "INSERT INTO tab_nocols DEFAULT VALUES");
+ $node_publisher->wait_for_catchup('sub_nocols');
+ is( $node_subscriber->safe_psql(
+ 'postgres', q{
+ SELECT count(*) FROM tab_nocols
+ WHERE local_value = 'subscriber-default'
+ }),
+ '3',
+ "incremental zero-column rows use local defaults (binary = $binary)"
+ );
+ }
+
+ $node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sub_nocols');
+ $node_publisher->safe_psql('postgres',
+ 'DROP PUBLICATION pub_nocols; DROP TABLE tab_nocols');
+ $node_subscriber->safe_psql('postgres', 'DROP TABLE tab_nocols');
+}
+
$node_publisher->safe_psql('postgres',
"CREATE PUBLICATION tap_pub FOR ALL TABLES");
base-commit: 77705d1e5a616339c3e92f586bbfa7b6bb562beb
--
2.53.0