From 1c166d8506aad451411b4f13e09e2932f2a787bb Mon Sep 17 00:00:00 2001 From: Vignesh C 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