Repository navigation
fix: Lock the table before dropping mirroring triggers so disable cannot deadlock on a partitioned parent - #35
Conversation
…rent first on drop DROP TRIGGER on a partitioned parent deletes the cloned child triggers before the parent trigger, so it locks the partitions before the parent and deadlocks against statements entering through the parent. On oauth_sessions in production that killed application statements for 53 seconds before the drop itself was chosen as the victim. disable_mirroring and disable_retired_mirroring now turn the trigger off with ALTER TABLE ... DISABLE TRIGGER, which locks the parent first and never blocks reads, and leave the trigger and function in place. --drop removes them under an explicit ACCESS EXCLUSIVE lock on the table, which swap and unswap now use as well. The mirroring commands take --lock-timeout (default 5s), and status reports whether each trigger is enabled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
…der in tests Plain `status` now prints whether each mirroring trigger is enabled, so a disabled trigger is distinguishable from an active one without --json. Status tests walk enable, disable, re-enable and drop for both targets, and the drop test asserts LOCK TABLE on the parent is issued before DROP TRIGGER. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… adding a disable mode Remove the disable-vs-drop split, the trigger enabled state in status, and the --drop flag. The deadlock fix is the lock order alone: lock the source table before DROP TRIGGER so the walk over the partitions can never run against a statement that already holds the parent. Keep --lock-timeout on the enable and disable mirroring commands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Reworked in bc20425 after discussing the design: the disable-without-drop mode, the trigger enabled state in status, and the --drop flag are gone. The disable commands now lock the table and drop the trigger, with the lock wait bounded by --lock-timeout. The two earlier commits are left in place rather than force-pushed so the alternative is easy to revisit. @greptileai review |
This comment has been minimized.
This comment has been minimized.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rigger Re-running enable_mirroring or enable_retired_mirroring on a partitioned table drops the existing trigger first, which walks the partitions before the parent exactly as disable did. Take the same parent lock up front. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Good catch on the enable path: @greptileai review |
|
|
||
| After the swap, the original mirroring triggers are no longer needed since the tables have been swapped. | ||
|
|
||
| The table is locked before the trigger is dropped, so this is safe to run against a busy partitioned table. The lock wait is bounded by `--lock-timeout` (default: `5s`). |
There was a problem hiding this comment.
Nit: The new option breaks deadlocks, but whether it's "safe" depends on how much contention is acceptable. Since we're realistically the only users no need to change the verbiage.
There was a problem hiding this comment.
Good call! Updated and I think the comments are more accurate now.
Why
On 2026-10-09
disable_retired_mirroringonoauth_sessionsin production deadlocked against live traffic for 53 seconds (AUTH-7221).DROP TRIGGERon a partitioned parent removes the cloned child triggers first, taking an ACCESS EXCLUSIVE lock on each partition before the parent, while every application statement locks the parent first and then the partitions it touches. Two walkers over the same relations in opposite orders is a deadlock, and Postgres killed an application statement roughly once a second until the drop itself lost.disable_mirroringanddisable_retired_mirroringnow lock the source table explicitly before dropping the trigger. Once the parent is held, no statement can be holding a partition the drop will wait on, so the walk over the partitions cannot form a cycle.enable_mirroringandenable_retired_mirroringtake the same lock first. They drop any existing trigger before recreating it, so a re-run on a mirrored partitioned table had the same hazard.enable_mirroring,enable_retired_mirroringand both disable commands take--lock-timeout(default5s, same asswap), so a contended run fails fast and is retried instead of queueing live traffic behind it.Proof
New partitioned-table tests in
src/mirroring.test.ts, run against the local Docker Postgres (28 files, 400 tests passing):LOCK TABLE ... IN ACCESS EXCLUSIVE MODE, thenDROP TRIGGER, thenDROP FUNCTION, andpg_locksshows the ACCESS EXCLUSIVE lock granted on the parent to this backend at that point.LOCK TABLEprecedes theDROP TRIGGER IF EXISTS, and re-running enable leaves exactly one trigger on the parent and each partition.lock_timeoutis set to the requested value inside the transaction.Not proven
SELECT ... FROM posts WHERE id = 1 FOR UPDATEon a partitionedposts, rundisable_retired_mirroringin another, then issue a second statement through the parent from the first session. Postgres reportsdeadlock detected. With this branch the drop waits on the first transaction and completes after it commits.core-db-productionis only observable when the runner runs this build againstoauth_sessions.Relates to AUTH-7221
🤖 Generated with Claude Code