From 1c166d8506aad451411b4f13e09e2932f2a787bb Mon Sep 17 00:00:00 2001
From: Vignesh C <vignesh21@gmail.com>
Date: Wed, 7 Oct 2026 11:12:52 +0530
Subject: [PATCH] Test showing dependency tracker does not track self
 referencing foreign keys.

Test showing dependency tracker does not track self referencing foreign keys.
---
 src/backend/replication/logical/worker.c      |   2 +
 .../t/039_parallel_apply_self_ref_fkey.pl     | 281 ++++++++++++++++++
 2 files changed, 283 insertions(+)
 create mode 100644 src/test/subscription/t/039_parallel_apply_self_ref_fkey.pl

diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index 08f9b1b514b..5e9fb44e475 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -2789,6 +2789,8 @@ apply_handle_begin(StringInfo s)
 			break;
 
 		case TRANS_PARALLEL_APPLY:
+			/* For testing worker death before it is tracked as STARTED. */
+			INJECTION_POINT("parallel-worker-before-xact-start", NULL);
 			/* Hold the lock until the end of the transaction. */
 			pa_lock_transaction(MyParallelShared->xid, AccessExclusiveLock);
 			pa_set_xact_state(MyParallelShared, PARALLEL_TRANS_STARTED);
diff --git a/src/test/subscription/t/039_parallel_apply_self_ref_fkey.pl b/src/test/subscription/t/039_parallel_apply_self_ref_fkey.pl
new file mode 100644
index 00000000000..541f2462517
--- /dev/null
+++ b/src/test/subscription/t/039_parallel_apply_self_ref_fkey.pl
@@ -0,0 +1,281 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test that dependency tracking via foreign keys (get_local_fkeys(),
+# relation.c) is not silently skipped for self-referencing foreign keys.
+# This currently fails: a parallel apply worker can race ahead of another
+# transaction it actually depends on and hit a spurious foreign key
+# violation.
+#
+# get_local_fkeys() classifies each FK constraint on a table using
+#
+#     if (con->confrelid == relid)       trig_type = RI_TRIGGER_PK;
+#     else if (con->conrelid == relid)   trig_type = RI_TRIGGER_FK;
+#
+# For a self-referencing FK, both conrelid and confrelid equal relid, so the
+# first branch always wins: the constraint is recorded only in
+# entry->local_refkeys (as if this table were purely the referenced side),
+# and never in entry->local_fkeys (the referencing side). Since
+# check_and_record_fkey_dependency() (worker.c) only consults local_fkeys to
+# decide whether an incoming row's FK value must wait for an earlier,
+# still-in-flight transaction that inserted the referenced key, that check
+# never runs at all for a self-referencing constraint -- not "sometimes
+# misses it", but structurally never performed, because one constraint can
+# only ever be classified into one of the two lists.
+#
+#  - Part 1 (self-referencing "emp" table) shows the bug: with the root
+#    row's worker frozen before it does anything, the child row -- applied
+#    directly by the leader because no worker is free -- should have to
+#    wait for it, but doesn't, and fails with a foreign key violation
+#    instead. This part of the test currently fails; once the dependency
+#    is correctly recorded, the leader will wait instead of erroring, and
+#    it will pass.
+#
+#  - Part 2 (two separate "parent"/"child2" tables) is a control showing
+#    the tracking mechanism does work correctly for an ordinary, non-self-
+#    referencing foreign key under the exact same setup: the leader
+#    correctly waits for the still-frozen parent-row worker, and the
+#    child-table insert goes through once that worker is released, without
+#    ever raising a violation.
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+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;
+# Only one parallel apply worker: the root/parent transaction takes it, so
+# the very next transaction has no free worker and is applied directly by
+# the leader -- which is the path that needs to find the dependency.
+$node_subscriber->append_conf(
+	'postgresql.conf', qq[
+max_parallel_apply_workers_per_subscription = 1
+log_min_messages = debug1
+]);
+$node_subscriber->start;
+$node_subscriber->safe_psql('postgres', 'CREATE EXTENSION injection_points');
+
+### Part 1: self-referencing foreign key -- demonstrates the bug.
+
+$node_publisher->safe_psql('postgres',
+	'CREATE TABLE emp (id int PRIMARY KEY, mgr_id int REFERENCES emp(id), name text)'
+);
+$node_subscriber->safe_psql('postgres',
+	'CREATE TABLE emp (id int PRIMARY KEY, mgr_id int REFERENCES emp(id), name text)'
+);
+
+$node_publisher->safe_psql('postgres', 'CREATE PUBLICATION pub FOR TABLE emp');
+
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION sub CONNECTION '$publisher_connstr' PUBLICATION pub"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sub');
+
+# Enable the self-referencing constraint's own internal triggers for
+# replica mode: without this, get_local_fkeys() skips the constraint entirely
+# and the race below would prove nothing.
+my @emp_fk_triggers = split(
+	/\n/,
+	$node_subscriber->safe_psql(
+		'postgres', qq[
+	SELECT tgname FROM pg_trigger
+	WHERE tgrelid = 'emp'::regclass AND tgconstraint > 0
+]));
+foreach my $trig (@emp_fk_triggers)
+{
+	$node_subscriber->safe_psql('postgres',
+		qq[ALTER TABLE emp ENABLE REPLICA TRIGGER "$trig"]);
+}
+
+# Warm-up: absorb the one-time RELATION message for "emp", and let it fully
+# commit, before the real race.
+$node_publisher->safe_psql('postgres', "INSERT INTO emp VALUES (1, NULL, 'warmup')");
+$node_publisher->wait_for_catchup('sub');
+
+# Freeze the (only) parallel apply worker right after it is assigned the
+# root-row transaction, before it drains anything beyond the BEGIN message.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-xact-start', 'wait')"
+);
+
+my $log_offset = -s $node_subscriber->logfile;
+
+# TX1: the root row. Dispatched to the only parallel worker, which
+# freezes immediately, before inserting anything.
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO emp VALUES (2, NULL, 'root2')");
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-xact-start');
+
+# TX2: the child row, referencing TX1's root. With no free parallel worker,
+# this is applied directly by the leader. If the dependency on TX1 is
+# correctly recorded, the leader will find it (logged as "found conflicting
+# referenced key change") and wait for TX1 to finish before trying this
+# insert; if not, it races ahead and hits a live foreign key violation
+# instead. Either outcome happens within a fraction of a second, so wait
+# for whichever log line appears first.
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO emp VALUES (3, 2, 'child3')");
+
+$node_subscriber->wait_for_log(
+	qr/violates foreign key constraint.*emp|found conflicting referenced key change/,
+	$log_offset);
+
+my $log_contents = slurp_file($node_subscriber->logfile, $log_offset);
+unlike(
+	$log_contents,
+	qr/violates foreign key constraint.*emp/,
+	'self-referencing FK: the child row insert must not race ahead of the '
+	  . 'still in-flight root row insert; the dependency should have been '
+	  . 'recorded for this self-referencing constraint'
+);
+
+# If the dependency was correctly found, the leader is now waiting on the
+# still-frozen root-row worker and needs it released to make progress. If
+# not, the bug reproduced: the leader has already errored out, and that
+# error's exit also SIGTERMed the still-frozen root-row worker
+# (logicalrep_worker_detach() tears down every parallel worker for the
+# subscription whenever the leader exits), so there is nothing left to
+# wake up. Either way, detach the injection point so a subsequent retried
+# apply doesn't freeze again, then confirm the subscription converges to
+# the correct end state.
+if ($log_contents =~ qr/found conflicting referenced key change/)
+{
+	$node_subscriber->safe_psql('postgres',
+		"SELECT injection_points_wakeup('parallel-worker-before-xact-start')");
+}
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_detach('parallel-worker-before-xact-start')");
+$node_publisher->wait_for_catchup('sub');
+my $result = $node_subscriber->safe_psql('postgres',
+	"SELECT id, mgr_id, name FROM emp ORDER BY id");
+is( $result, "1||warmup\n2||root2\n3|2|child3",
+	'all rows are eventually replicated correctly, whether the race was '
+	  . 'avoided (dependency correctly tracked) or recovered from via retry'
+);
+
+### Part 2: control -- an ordinary, non-self-referencing foreign key between
+### two distinct tables, under the exact same setup, does not misbehave.
+
+$node_publisher->safe_psql('postgres', qq[
+	CREATE TABLE parent (id int PRIMARY KEY);
+	CREATE TABLE child2 (id int PRIMARY KEY, parent_id int REFERENCES parent(id));
+]);
+$node_subscriber->safe_psql('postgres', qq[
+	CREATE TABLE parent (id int PRIMARY KEY);
+	CREATE TABLE child2 (id int PRIMARY KEY, parent_id int REFERENCES parent(id));
+]);
+
+$node_publisher->safe_psql('postgres',
+	'ALTER PUBLICATION pub ADD TABLE parent, child2');
+$node_subscriber->safe_psql('postgres',
+	'ALTER SUBSCRIPTION sub REFRESH PUBLICATION');
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sub');
+
+# Enable this (ordinary, non-self-referencing) constraint's own internal
+# triggers for replica mode too, same reason as for "emp" above.
+my @parent_fk_triggers = split(
+	/\n/,
+	$node_subscriber->safe_psql(
+		'postgres', qq[
+	SELECT tgname FROM pg_trigger
+	WHERE tgrelid = 'parent'::regclass AND tgconstraint > 0
+]));
+foreach my $trig (@parent_fk_triggers)
+{
+	$node_subscriber->safe_psql('postgres',
+		qq[ALTER TABLE parent ENABLE REPLICA TRIGGER "$trig"]);
+}
+
+my @child2_fk_triggers = split(
+	/\n/,
+	$node_subscriber->safe_psql(
+		'postgres', qq[
+	SELECT tgname FROM pg_trigger
+	WHERE tgrelid = 'child2'::regclass AND tgconstraint > 0
+]));
+foreach my $trig (@child2_fk_triggers)
+{
+	$node_subscriber->safe_psql('postgres',
+		qq[ALTER TABLE child2 ENABLE REPLICA TRIGGER "$trig"]);
+}
+
+# Warm-up: absorb the one-time RELATION messages for "parent" and "child2",
+# and let them fully commit, before the real race.
+$node_publisher->safe_psql('postgres', qq[
+	INSERT INTO parent VALUES (10);
+	INSERT INTO child2 VALUES (11, 10);
+]);
+$node_publisher->wait_for_catchup('sub');
+
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_attach('parallel-worker-before-xact-start', 'wait')"
+);
+
+my $log_offset2 = -s $node_subscriber->logfile;
+
+# TX1: the parent row, dispatched to the only parallel worker, which
+# freezes immediately, before inserting anything.
+$node_publisher->safe_psql('postgres', 'INSERT INTO parent VALUES (20)');
+$node_subscriber->wait_for_event('logical replication parallel worker',
+	'parallel-worker-before-xact-start');
+
+# TX2: the child row, referencing TX1's parent. No free parallel worker, so
+# applied directly by the leader. With the dependency correctly recorded
+# (this is a regular, non-self-referencing FK), the leader should find and
+# wait for it -- confirmed by the specific "found conflicting referenced
+# key" debug line that check_and_record_key_dependency() logs only for
+# this exact mechanism (not the blanket, value-agnostic table-wide or
+# commit-order waits that also exist). A lock-based wait check doesn't
+# work here: the parent-row worker is frozen before it ever calls
+# pa_lock_transaction(), so there is no lock actually held for the leader
+# to visibly block on.
+$node_publisher->safe_psql('postgres', 'INSERT INTO child2 VALUES (30, 20)');
+
+$node_subscriber->wait_for_log(
+	qr/found conflicting referenced key change on table/,
+	$log_offset2);
+
+my $log_contents2 = slurp_file($node_subscriber->logfile, $log_offset2);
+unlike(
+	$log_contents2,
+	qr/violates foreign key constraint/,
+	'control: an ordinary, non-self-referencing FK correctly makes the '
+	  . 'leader wait for the still-frozen parent-row worker, with no '
+	  . 'spurious violation'
+);
+
+# Release the parent-row worker now that we have confirmed the leader was
+# correctly waiting on it, and confirm the child row goes through cleanly.
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_wakeup('parallel-worker-before-xact-start')");
+$node_subscriber->safe_psql('postgres',
+	"SELECT injection_points_detach('parallel-worker-before-xact-start')");
+$node_publisher->wait_for_catchup('sub');
+
+my $log_contents3 = slurp_file($node_subscriber->logfile, $log_offset2);
+unlike(
+	$log_contents3,
+	qr/violates foreign key constraint/,
+	'control: the child2 insert went through with no violation once the '
+	  . 'parent row it depends on actually committed'
+);
+
+my $result2 = $node_subscriber->safe_psql('postgres',
+	"SELECT id, parent_id FROM child2 ORDER BY id");
+is($result2, "11|10\n30|20", 'the child2 rows were applied correctly');
+
+$node_subscriber->stop('immediate');
+$node_publisher->stop('immediate');
+
+done_testing();
-- 
2.55.0

