From 586e130e2f74202f6492460b7002b851e4c8ec38 Mon Sep 17 00:00:00 2001 From: reshke Date: Thu, 3 Sep 2026 14:41:38 +0300 Subject: [PATCH v1] Injection point based test for lock ordering bug --- src/backend/commands/tablecmds.c | 10 ++ .../t/057_attach_partition_deadlock.pl | 150 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/test/recovery/t/057_attach_partition_deadlock.pl diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index fd144d783d9..6fd20a63920 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -99,6 +99,7 @@ #include "utils/acl.h" #include "utils/builtins.h" #include "utils/fmgroids.h" +#include "utils/injection_point.h" #include "utils/inval.h" #include "utils/lsyscache.h" #include "utils/memutils.h" @@ -6914,6 +6915,15 @@ ATSimpleRecursion(List **wqueue, Relation rel, ListCell *child; List *children; + /* + * Injection point to allow testing deadlocks between ALTER TABLE + * recursion (which locks children in OID order via + * find_all_inheritors) and concurrent ATTACH PARTITION (which locks + * ancestors in reverse hierarchy order via + * generate_partition_qual). + */ + INJECTION_POINT("alter-table-simple-recursion", NULL); + children = find_all_inheritors(relid, lockmode, NULL); /* diff --git a/src/test/recovery/t/057_attach_partition_deadlock.pl b/src/test/recovery/t/057_attach_partition_deadlock.pl new file mode 100644 index 00000000000..62c20cfe738 --- /dev/null +++ b/src/test/recovery/t/057_attach_partition_deadlock.pl @@ -0,0 +1,150 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test deadlock between ATTACH PARTITION and ALTER TABLE recursion. +# +# This test reproduces the deadlock described in the thread starting at: +# https://www.postgresql.org/message-id/CFACA0EB-7E6F-4FAA-9ACE-1FC2226D7482@gmail.com +# +# The root cause is inconsistent lock ordering: +# - ALTER TABLE ... ALTER COLUMN SET DEFAULT recurses to partitions via +# ATSimpleRecursion -> find_all_inheritors, which locks children in OID +# order (parent first, then children sorted by OID). +# - ATTACH PARTITION calls generate_partition_qual() which walks UP the +# partition tree, locking ancestors in reverse hierarchy order with +# AccessShareLock. +# +# When the OID order differs from the hierarchy order (e.g., a partitioned +# table has a higher OID than its parent), the two paths acquire locks in +# opposite orders, leading to a deadlock: +# - Session A (ALTER TABLE) holds AccessExclusiveLock on the parent and +# waits for AccessExclusiveLock on the child. +# - Session B (ATTACH PARTITION) holds AccessExclusiveLock on the child +# (target table) and waits for AccessShareLock on the parent (which +# conflicts with A's AccessExclusiveLock). +# +# The test uses an injection point in ATSimpleRecursion to pause Session A +# after it locks the root table but before it locks child partitions. This +# gives Session B time to lock the intermediate table and then block on the +# root. When Session A is released, it blocks on the intermediate table, +# completing the deadlock cycle. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; + +my $node = PostgreSQL::Test::Cluster->new('node'); +$node->init(); +# Use a short deadlock_timeout so the deadlock is detected quickly. +$node->append_conf('postgresql.conf', 'deadlock_timeout = 100ms'); +$node->start(); + +plan skip_all => 'Extension injection_points not installed' + unless $node->check_extension('injection_points'); + +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;'); + +# Set up a 3-level partition hierarchy: +# +# top (partitioned, root) +# └── mid (partitioned, partition of top) +# +# Plus a standalone table newpart that will be attached to mid. +# +# Lock ordering: +# - Session A (ALTER TABLE top ... SET DEFAULT): +# locks top (AEL) -> find_all_inheritors -> locks mid (AEL) +# - Session B (ALTER TABLE mid ATTACH PARTITION newpart): +# locks mid (AEL, target) -> locks newpart (AEL) -> +# generate_partition_qual(mid) -> locks top (ASL) +# +# Deadlock: +# - A holds AEL on top, wants AEL on mid +# - B holds AEL on mid, wants ASL on top (conflicts with A's AEL) + +$node->safe_psql( + 'postgres', q[ +CREATE TABLE top (id int) PARTITION BY RANGE (id); +CREATE TABLE mid PARTITION OF top FOR VALUES FROM (0) TO (1000000) + PARTITION BY RANGE (id); +CREATE TABLE newpart (id int); +]); + +my $s1 = $node->background_psql('postgres', on_error_stop => 0); +my $s2 = $node->background_psql('postgres', on_error_stop => 0); + +# Session A: attach the injection point locally, then start the ALTER TABLE. +# The injection point fires in ATSimpleRecursion after the root table is +# locked but before find_all_inheritors locks the children. +$s1->query_safe(q[ +SELECT injection_points_set_local(); +SELECT injection_points_attach('alter-table-simple-recursion', 'wait'); +]); + +$s1->query_until( + qr/starting_alter/, q[ +\echo starting_alter +ALTER TABLE top ALTER COLUMN id SET DEFAULT 0; +]); + +# Wait for Session A to hit the injection point. +$node->wait_for_event('client backend', 'alter-table-simple-recursion'); + +# Session B: start ATTACH PARTITION. This will: +# 1. Lock mid (AEL) -- target table +# 2. Lock newpart (AEL) -- attachrel +# 3. Call generate_partition_qual(mid) -> try to lock top (ASL) +# -> block here, because Session A holds AEL on top. +$s2->query_until( + qr/starting_attach/, q[ +\echo starting_attach +ALTER TABLE mid ATTACH PARTITION newpart FOR VALUES FROM (500000) TO (1000000); +]); + +# Wait for Session B to block waiting for AccessShareLock on top. +$node->poll_query_until( + 'postgres', qq[ + SELECT count(*) > 0 FROM pg_locks + WHERE locktype = 'relation' + AND relation = 'top'::regclass + AND mode = 'AccessShareLock' + AND NOT granted; +]) or die "Timed out waiting for Session B to block on top lock"; + +# Now wake up Session A. It will proceed to find_all_inheritors which tries +# to lock mid (AEL), but Session B holds AEL on mid, so Session A blocks. +# This completes the deadlock cycle: +# A holds AEL on top, waits for AEL on mid +# B holds AEL on mid, waits for ASL on top +$node->safe_psql('postgres', + q{SELECT injection_points_detach('alter-table-simple-recursion'); +SELECT injection_points_wakeup('alter-table-simple-recursion');}); + +# Wait for the deadlock to be detected and reported in the log. +my $log_offset = -s $node->logfile; +$node->wait_for_log(qr/deadlock detected/, $log_offset); + +note("deadlock detected"); + +# One session should have received a deadlock error, the other should +# succeed. Verify via the log. +my $log_contents = slurp_file($node->logfile, $log_offset); +like($log_contents, qr/Process .* waits for AccessExclusiveLock on relation .* of database/, + "deadlock detected with AccessExclusiveLock wait"); + +# Clean up: the session that got the deadlock error is done (psql returned +# the error). The other session should complete once the lock is released. +# Verify that the ATTACH PARTITION or ALTER TABLE completed in the surviving +# session by checking that newpart is now a partition of mid or that the +# default was set on top. +$s1->quit; +$s2->quit; + +$node->stop(); + +done_testing(); -- 2.43.0