v1-0001-refresh-table.patch
application/octet-stream
Filename: v1-0001-refresh-table.patch
Type: application/octet-stream
Part: 0
Patch
Format: format-patch
Series: patch v1-0001
Subject: Add ALTER SUBSCRIPTION ... REFRESH TABLE to resync one table
| File | + | − |
|---|---|---|
| src/backend/commands/subscriptioncmds.c | 180 | 0 |
| src/backend/parser/gram.y | 11 | 0 |
| src/include/nodes/parsenodes.h | 2 | 0 |
| src/test/subscription/t/039_refresh_table.pl | 198 | 0 |
From 569385da0bb7c3d40aec99842c7ea0504f8ba674 Mon Sep 17 00:00:00 2001
From: cagrib <cagri.biroglu@adyen.com>
Date: Tue, 7 Jul 2026 14:17:19 +0200
Subject: [PATCH v1] Add ALTER SUBSCRIPTION ... REFRESH TABLE to resync one
table
REFRESH PUBLICATION only starts syncing tables that were newly added to
the publication; it never re-copies a table that is already part of the
subscription. Today the only ways to re-seed a single already-subscribed
table are to churn publication membership (which is not local to one
subscriber and can disturb sibling subscribers or cascade downstream) or
to hand-edit pg_subscription_rel (unsupported and racy against the apply
worker).
Add a subscriber-local command:
ALTER SUBSCRIPTION name REFRESH TABLE table_name
[ WITH (copy_data = true, truncate = true) ]
It resets the state of just the named relation in pg_subscription_rel
back to init (optionally truncating the local copy first) so that a
tablesync worker re-copies that one table, reusing the existing table
synchronization machinery. Publication membership and other tables are
left untouched. This mirrors REFRESH SEQUENCES, which already
re-synchronizes already-subscribed sequences.
This first version requires the subscription to be disabled, which avoids
racing the apply worker's cached relation state.
A TAP test is included.
---
src/backend/commands/subscriptioncmds.c | 180 +++++++++++++++++
src/backend/parser/gram.y | 11 ++
src/include/nodes/parsenodes.h | 2 +
src/test/subscription/t/039_refresh_table.pl | 198 +++++++++++++++++++
4 files changed, 391 insertions(+)
create mode 100644 src/test/subscription/t/039_refresh_table.pl
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 4292e7fb8f4..87029a5c034 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -82,6 +82,7 @@
#define SUBOPT_LSN 0x00020000
#define SUBOPT_ORIGIN 0x00040000
#define SUBOPT_CONFLICT_LOG_DEST 0x00080000
+#define SUBOPT_TRUNCATE 0x00100000
/* check if the 'val' has 'bits' set */
#define IsSet(val, bits) (((val) & (bits)) == (bits))
@@ -99,6 +100,7 @@ typedef struct SubOpts
bool enabled;
bool create_slot;
bool copy_data;
+ bool truncate;
bool refresh;
bool binary;
char streaming;
@@ -183,6 +185,8 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->create_slot = true;
if (IsSet(supported_opts, SUBOPT_COPY_DATA))
opts->copy_data = true;
+ if (IsSet(supported_opts, SUBOPT_TRUNCATE))
+ opts->truncate = true;
if (IsSet(supported_opts, SUBOPT_REFRESH))
opts->refresh = true;
if (IsSet(supported_opts, SUBOPT_BINARY))
@@ -264,6 +268,15 @@ parse_subscription_options(ParseState *pstate, List *stmt_options,
opts->specified_opts |= SUBOPT_COPY_DATA;
opts->copy_data = defGetBoolean(defel);
}
+ else if (IsSet(supported_opts, SUBOPT_TRUNCATE) &&
+ strcmp(defel->defname, "truncate") == 0)
+ {
+ if (IsSet(opts->specified_opts, SUBOPT_TRUNCATE))
+ errorConflictingDefElem(defel, pstate);
+
+ opts->specified_opts |= SUBOPT_TRUNCATE;
+ opts->truncate = defGetBoolean(defel);
+ }
else if (IsSet(supported_opts, SUBOPT_SYNCHRONOUS_COMMIT) &&
strcmp(defel->defname, "synchronous_commit") == 0)
{
@@ -1405,6 +1418,133 @@ AlterSubscription_refresh_seq(Subscription *sub)
PG_END_TRY();
}
+/*
+ * Resynchronize a single already-subscribed table.
+ *
+ * Resets the relation's pg_subscription_rel state back to init (optionally
+ * truncating the local copy first) so that, once the subscription is enabled,
+ * a tablesync worker re-copies just this one table. This is subscriber-local
+ * and does not touch publication membership, so sibling subscribers are
+ * unaffected.
+ *
+ * The caller must ensure the subscription is disabled: with no apply worker
+ * running there is no cached relation state to invalidate and no race against
+ * a concurrently launched tablesync worker.
+ */
+static void
+AlterSubscription_refresh_table(Subscription *sub, bool copy_data,
+ bool truncate, RangeVar *rv)
+{
+ Relation rel;
+ Oid relid;
+ char relstate;
+ XLogRecPtr relstatelsn;
+
+ /*
+ * Lock pg_subscription_rel with AccessExclusiveLock, matching
+ * AlterSubscription_refresh(), so the relation's state cannot change under
+ * us for the duration of the command.
+ */
+ rel = table_open(SubscriptionRelRelationId, AccessExclusiveLock);
+
+ relid = RangeVarGetRelid(rv, AccessShareLock, false);
+
+ if (get_rel_relkind(relid) == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ errcode(ERRCODE_WRONG_OBJECT_TYPE),
+ errmsg("cannot refresh sequence \"%s\" as a table",
+ rv->relname),
+ errhint("Use ALTER SUBSCRIPTION ... REFRESH SEQUENCES instead."));
+
+ /* The relation must already be part of the subscription. */
+ relstate = GetSubscriptionRelState(sub->oid, relid, &relstatelsn);
+ if (relstate == SUBREL_STATE_UNKNOWN)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("table \"%s\" is not part of the subscription \"%s\"",
+ rv->relname, sub->name));
+
+ /* Stop any leftover tablesync worker for this relation. */
+ logicalrep_worker_stop(WORKERTYPE_TABLESYNC, sub->oid, relid);
+
+ /*
+ * If the relation was caught mid-sync, drop its tablesync origin. For
+ * READY state the tablesync worker already dropped it, so nothing is
+ * needed there. Pass missing_ok = true as the origin may not exist yet.
+ */
+ if (relstate != SUBREL_STATE_READY)
+ {
+ char originname[NAMEDATALEN];
+
+ ReplicationOriginNameForLogicalRep(sub->oid, relid, originname,
+ sizeof(originname));
+ replorigin_drop_by_name(originname, true, false);
+ }
+
+ /*
+ * Likewise drop the tablesync slot on the publisher for a mid-sync
+ * relation. READY/SYNCDONE relations have no such slot, so the common
+ * case needs no publisher connection at all.
+ */
+ if (relstate != SUBREL_STATE_READY && relstate != SUBREL_STATE_SYNCDONE)
+ {
+ char syncslotname[NAMEDATALEN] = {0};
+ char *err = NULL;
+ WalReceiverConn *wrconn;
+ bool must_use_password;
+
+ load_file("libpqwalreceiver", false);
+
+ must_use_password = sub->passwordrequired && !sub->ownersuperuser;
+ wrconn = walrcv_connect(sub->conninfo, true, true, must_use_password,
+ sub->name, &err);
+ if (!wrconn)
+ ereport(ERROR,
+ errcode(ERRCODE_CONNECTION_FAILURE),
+ errmsg("subscription \"%s\" could not connect to the publisher: %s",
+ sub->name, err));
+
+ PG_TRY();
+ {
+ ReplicationSlotNameForTablesync(sub->oid, relid, syncslotname,
+ sizeof(syncslotname));
+ ReplicationSlotDropAtPubNode(wrconn, syncslotname, true);
+ }
+ PG_FINALLY();
+ {
+ walrcv_disconnect(wrconn);
+ }
+ PG_END_TRY();
+ }
+
+ /* Optionally clear the local copy so the re-copy starts from empty. */
+ if (copy_data && truncate)
+ {
+ TruncateStmt *tstmt = makeNode(TruncateStmt);
+
+ tstmt->relations = list_make1(rv);
+ tstmt->restart_seqs = false;
+ tstmt->behavior = DROP_RESTRICT;
+
+ ExecuteTruncate(tstmt);
+ }
+
+ /*
+ * Reset the relation state. With copy_data it goes back to init so a
+ * tablesync worker re-copies it on enable; otherwise straight to ready.
+ */
+ UpdateSubscriptionRelState(sub->oid, relid,
+ copy_data ? SUBREL_STATE_INIT : SUBREL_STATE_READY,
+ InvalidXLogRecPtr, false);
+
+ ereport(DEBUG1,
+ errmsg_internal("table \"%s.%s\" of subscription \"%s\" reset for resync",
+ get_namespace_name(get_rel_namespace(relid)),
+ get_rel_name(relid), sub->name));
+
+ table_close(rel, NoLock);
+}
+
/*
* Common checks for altering failover, two_phase, and retain_dead_tuples
* options.
@@ -1644,6 +1784,10 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
supported_opts = SUBOPT_COPY_DATA;
break;
+ case ALTER_SUBSCRIPTION_REFRESH_TABLE:
+ supported_opts = SUBOPT_COPY_DATA | SUBOPT_TRUNCATE;
+ break;
+
case ALTER_SUBSCRIPTION_SKIP:
supported_opts = SUBOPT_LSN;
break;
@@ -2296,6 +2440,42 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
break;
}
+ case ALTER_SUBSCRIPTION_REFRESH_TABLE:
+ {
+ /*
+ * The first version requires the subscription to be disabled.
+ * With no apply worker running there is no cached relation
+ * state to invalidate and no race against a concurrently
+ * launched tablesync worker while we reset the relation.
+ */
+ if (sub->enabled)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("%s is not allowed for enabled subscriptions",
+ "ALTER SUBSCRIPTION ... REFRESH TABLE"),
+ errhint("Disable the subscription with ALTER SUBSCRIPTION ... DISABLE first."));
+
+ /* TRUNCATE only makes sense when we are going to re-copy. */
+ if (!opts.copy_data)
+ {
+ if (IsSet(opts.specified_opts, SUBOPT_TRUNCATE) &&
+ opts.truncate)
+ ereport(ERROR,
+ errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("truncate = true requires copy_data = true"));
+
+ opts.truncate = false;
+ }
+
+ PreventInTransactionBlock(isTopLevel,
+ "ALTER SUBSCRIPTION ... REFRESH TABLE");
+
+ AlterSubscription_refresh_table(sub, opts.copy_data,
+ opts.truncate, stmt->relation);
+
+ break;
+ }
+
case ALTER_SUBSCRIPTION_SKIP:
{
/* ALTER SUBSCRIPTION ... SKIP supports only LSN option */
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..b71dd58ecf4 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -11617,6 +11617,17 @@ AlterSubscriptionStmt:
n->subname = $3;
$$ = (Node *) n;
}
+ | ALTER SUBSCRIPTION name REFRESH TABLE qualified_name opt_definition
+ {
+ AlterSubscriptionStmt *n =
+ makeNode(AlterSubscriptionStmt);
+
+ n->kind = ALTER_SUBSCRIPTION_REFRESH_TABLE;
+ n->subname = $3;
+ n->relation = $6;
+ n->options = $7;
+ $$ = (Node *) n;
+ }
| ALTER SUBSCRIPTION name ADD_P PUBLICATION name_list opt_definition
{
AlterSubscriptionStmt *n =
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index e03556399ab..9c5208b7bc1 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -4566,6 +4566,7 @@ typedef enum AlterSubscriptionType
ALTER_SUBSCRIPTION_DROP_PUBLICATION,
ALTER_SUBSCRIPTION_REFRESH_PUBLICATION,
ALTER_SUBSCRIPTION_REFRESH_SEQUENCES,
+ ALTER_SUBSCRIPTION_REFRESH_TABLE,
ALTER_SUBSCRIPTION_ENABLED,
ALTER_SUBSCRIPTION_SKIP,
} AlterSubscriptionType;
@@ -4578,6 +4579,7 @@ typedef struct AlterSubscriptionStmt
char *servername; /* Server name of publisher */
char *conninfo; /* Connection string to publisher */
List *publication; /* One or more publication to subscribe to */
+ RangeVar *relation; /* Table to resync (for REFRESH TABLE) */
List *options; /* List of DefElem nodes */
} AlterSubscriptionStmt;
diff --git a/src/test/subscription/t/039_refresh_table.pl b/src/test/subscription/t/039_refresh_table.pl
new file mode 100644
index 00000000000..b5d16e96bbe
--- /dev/null
+++ b/src/test/subscription/t/039_refresh_table.pl
@@ -0,0 +1,198 @@
+
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Tests for ALTER SUBSCRIPTION ... REFRESH TABLE, which re-copies a single
+# already-subscribed table on the subscriber without touching publication
+# membership or other tables. The first version requires the subscription
+# to be disabled.
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize publisher and subscriber nodes
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf',
+ "wal_retrieve_retry_interval = 1ms");
+$node_subscriber->start;
+
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+
+# Preexisting content on the publisher: two published tables.
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ CREATE TABLE tab_res (a int primary key, b text);
+ CREATE TABLE tab_other (a int primary key, b text);
+ INSERT INTO tab_res SELECT g, 'p' || g FROM generate_series(1, 100) g;
+ INSERT INTO tab_other SELECT g, 'q' || g FROM generate_series(1, 100) g;
+ CREATE PUBLICATION tap_pub FOR TABLE tab_res, tab_other;
+));
+
+# Matching structure on the subscriber, plus objects used for error cases.
+$node_subscriber->safe_psql(
+ 'postgres', qq(
+ CREATE TABLE tab_res (a int primary key, b text);
+ CREATE TABLE tab_other (a int primary key, b text);
+ CREATE TABLE tab_local (a int primary key);
+ CREATE SEQUENCE seq_local;
+));
+
+$node_subscriber->safe_psql('postgres',
+ "CREATE SUBSCRIPTION tap_sub CONNECTION '$publisher_connstr' PUBLICATION tap_pub"
+);
+
+# Wait for initial sync of both tables.
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'tap_sub');
+
+is( $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_res"),
+ '100', 'initial sync of tab_res');
+is( $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_other"),
+ '100', 'initial sync of tab_other');
+
+# A small helper: run SQL expected to fail, and check the error message.
+sub refresh_should_fail
+{
+ my ($sql, $pattern, $desc) = @_;
+ my ($ret, $stdout, $stderr) = ('', '', '');
+ $ret = $node_subscriber->psql('postgres', $sql,
+ stdout => \$stdout, stderr => \$stderr);
+ ok($ret != 0 && $stderr =~ /$pattern/,
+ $desc)
+ or diag("got ret=$ret stderr=$stderr");
+}
+
+# REFRESH TABLE is not allowed while the subscription is enabled.
+refresh_should_fail(
+ "ALTER SUBSCRIPTION tap_sub REFRESH TABLE tab_res",
+ qr/not allowed for enabled subscriptions/,
+ 'REFRESH TABLE rejected while enabled');
+
+# Disable the subscription and wait for its workers to stop.
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION tap_sub DISABLE");
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) = 0 FROM pg_stat_subscription WHERE subname = 'tap_sub' AND pid IS NOT NULL"
+) or die "Timed out waiting for subscription workers to stop";
+
+# A table that is not part of the subscription is rejected.
+refresh_should_fail(
+ "ALTER SUBSCRIPTION tap_sub REFRESH TABLE tab_local",
+ qr/is not part of the subscription/,
+ 'REFRESH TABLE rejected for table not in subscription');
+
+# A sequence cannot be refreshed as a table.
+refresh_should_fail(
+ "ALTER SUBSCRIPTION tap_sub REFRESH TABLE seq_local",
+ qr/cannot refresh sequence/,
+ 'REFRESH TABLE rejected for a sequence');
+
+# truncate = true requires copy_data = true.
+refresh_should_fail(
+ "ALTER SUBSCRIPTION tap_sub REFRESH TABLE tab_res WITH (copy_data = false, truncate = true)",
+ qr/truncate = true requires copy_data = true/,
+ 'REFRESH TABLE rejected with truncate but no copy_data');
+
+# The command cannot run inside a transaction block.
+refresh_should_fail(
+ "BEGIN; ALTER SUBSCRIPTION tap_sub REFRESH TABLE tab_res;",
+ qr/cannot run inside a transaction block/,
+ 'REFRESH TABLE rejected inside a transaction block');
+
+# Introduce drift on the subscriber, only in tab_res.
+$node_subscriber->safe_psql(
+ 'postgres', qq(
+ DELETE FROM tab_res WHERE a <= 40;
+ UPDATE tab_res SET b = 'CORRUPT' WHERE a = 60;
+));
+is( $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_res"),
+ '60', 'drift introduced in tab_res');
+
+# Record the pre-refresh state of the other table.
+my $other_state_before = $node_subscriber->safe_psql('postgres',
+ "SELECT srsubstate FROM pg_subscription_rel r JOIN pg_class c ON c.oid = r.srrelid WHERE c.relname = 'tab_other'"
+);
+is($other_state_before, 'r', 'tab_other is ready before refresh');
+
+# Resync just tab_res (defaults: copy_data = true, truncate = true).
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION tap_sub REFRESH TABLE tab_res");
+
+# Only tab_res is reset to init; tab_other is untouched; local copy truncated.
+is( $node_subscriber->safe_psql(
+ 'postgres',
+ "SELECT srsubstate FROM pg_subscription_rel r JOIN pg_class c ON c.oid = r.srrelid WHERE c.relname = 'tab_res'"
+ ),
+ 'i',
+ 'tab_res reset to init state');
+is( $node_subscriber->safe_psql(
+ 'postgres',
+ "SELECT srsubstate FROM pg_subscription_rel r JOIN pg_class c ON c.oid = r.srrelid WHERE c.relname = 'tab_other'"
+ ),
+ 'r',
+ 'tab_other left untouched (still ready)');
+is( $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_res"),
+ '0', 'tab_res truncated locally by REFRESH while disabled');
+
+# Re-enable and wait for the single table to re-copy.
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION tap_sub ENABLE");
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'tap_sub');
+
+is( $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_res"),
+ '100', 'tab_res re-copied after enable');
+is( $node_subscriber->safe_psql('postgres',
+ "SELECT b FROM tab_res WHERE a = 60"),
+ 'p60', 'tab_res corruption repaired by resync');
+
+# Full content matches the publisher.
+my $pub_md5 = $node_publisher->safe_psql('postgres',
+ "SELECT md5(string_agg(a || ':' || b, ',' ORDER BY a)) FROM tab_res");
+my $sub_md5 = $node_subscriber->safe_psql('postgres',
+ "SELECT md5(string_agg(a || ':' || b, ',' ORDER BY a)) FROM tab_res");
+is($sub_md5, $pub_md5, 'tab_res matches publisher after resync');
+
+# tab_other was never disturbed.
+is( $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_other"),
+ '100', 'tab_other intact throughout');
+
+# Ongoing replication still works for the resynced table.
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO tab_res VALUES (101, 'p101')");
+$node_publisher->wait_for_catchup('tap_sub');
+is( $node_subscriber->safe_psql('postgres',
+ "SELECT count(*) FROM tab_res WHERE a = 101"),
+ '1', 'streaming resumes on resynced table');
+
+# copy_data = false variant: reset to ready without re-copying or truncating.
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION tap_sub DISABLE");
+$node_subscriber->poll_query_until('postgres',
+ "SELECT count(*) = 0 FROM pg_stat_subscription WHERE subname = 'tap_sub' AND pid IS NOT NULL"
+) or die "Timed out waiting for subscription workers to stop";
+
+$node_subscriber->safe_psql('postgres', "DELETE FROM tab_res WHERE a <= 10");
+$node_subscriber->safe_psql('postgres',
+ "ALTER SUBSCRIPTION tap_sub REFRESH TABLE tab_res WITH (copy_data = false)"
+);
+is( $node_subscriber->safe_psql(
+ 'postgres',
+ "SELECT srsubstate FROM pg_subscription_rel r JOIN pg_class c ON c.oid = r.srrelid WHERE c.relname = 'tab_res'"
+ ),
+ 'r',
+ 'copy_data = false sets state directly to ready');
+is( $node_subscriber->safe_psql('postgres', "SELECT count(*) FROM tab_res"),
+ '91', 'copy_data = false does not truncate or re-copy');
+
+$node_subscriber->safe_psql('postgres', "ALTER SUBSCRIPTION tap_sub ENABLE");
+
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION tap_sub");
+$node_subscriber->stop('fast');
+$node_publisher->stop('fast');
+
+done_testing();
--
2.54.0