Files
pad/internal/server/document_lock_contention_test.go
xarmian 773222368c fix(store): serialize document renames and stop reading the pool inside transactions (BUG-2778) (#1208)
Two deadlocks, one in the database and one in the application.

THE DATABASE ONE, which is what BUG-2778 was filed about. A document
rename takes row locks in two stages inside one transaction:
updateLinksInTx writes every OTHER document whose content links the old
title, then the final UPDATE writes THIS document. Two concurrent renames
of documents that link to each other therefore take the same two locks in
opposite orders, and Postgres aborts one with SQLSTATE 40P01 — a 500 on an
ordinary rename. A throwaway probe against the unfixed code deadlocked on
12 of 12 rounds; this is deterministic, not theoretical.

The fix serializes renames per workspace with a dedicated advisory key
(`pad:document-rename:<ws>`), taken before any row lock and whenever a
title is supplied. No-op on SQLite, whose single writer cannot produce the
cycle. `SET LOCAL lock_timeout = '5s'` bounds the wait, because a
transaction that waits with a pool connection already in hand converts
contention into pool exhaustion; the handler maps 55P03 and 40P01 to a
retryable 503 rather than a generic 500.

WHY NOT `ORDER BY id`, which is what I proposed when I FILED this from
reading rather than from a repro: each transaction's cascade set is a
single row, and the cycle is cascade-then-self, so ordering the cascade
leaves it exactly as reachable. Run as a mutation, that fix fails the
regression test. Reproducing the bug is what refuted my own diagnosis.

THE APPLICATION ONE, found in review and larger than the filed bug. Seven
production paths issued a read through the connection POOL from inside an
open transaction. Under a saturated pool the second connection never
arrives, so the transaction cannot finish and never releases what it holds
— no SQLSTATE names this and no lock timeout breaks it. All seven now take
their executor: the document and item slug scans, both version checks, the
done-field lookup on the item update and move paths, the open-children
guard's collection read, and the OAuth startup backfill's workspace
lookup. Three of those were found only after asking for the true
population rather than for a sample; two were INDIRECT (through
GetCollection), which a grep for `s.db` inside transaction bodies cannot
see.

The instrument is a one-connection pool, which makes the hazard
deterministic instead of load-dependent.

ALSO FIXED, adjacent and found by the same reviews:

- The rename decided from a PRE-LOCK snapshot: the lock made the cascade
  safe against another rename and then handed it a stale OLD TITLE. It now
  re-reads under the lock and decides from that row.
- A concurrent soft-delete could commit mid-rename, leaving the cascade's
  rewrites behind while the caller was told not-found. The final UPDATE
  now carries `deleted_at IS NULL` with a checked row count, so the rename
  and its cascade land together or not at all.
- Class sweep (CONVE-18) of the same unordered-scan-then-per-row-update
  shape: the attachment remap (items and comments) and the outbox user-ref
  scrub are now ordered. Scoped claim — it orders those per-row updates,
  not every lock in those transactions — and NOT reproduced, unlike the
  rename.

18 mutations aimed, 15 die. The three survivors are written down where
they live: one is an equivalent mutant (the cascade is the first
row-locking stage, so "lock before the cascade" and "lock at the top of
the transaction" are the same order), one is a guard covering a window no
seam can currently schedule, and one is a class-sweep fix with no repro.
2026-08-25 22:40:58 -04:00

37 lines
1.5 KiB
Go

package server
import (
"errors"
"testing"
)
// BUG-2778. A rename serializes per workspace with a 5s lock timeout, so
// contention surfaces as SQLSTATE 55P03 — and a deadlock Postgres chooses to
// break surfaces as 40P01. Both mean "try again"; a generic 500 tells the
// caller the opposite.
//
// This drives the classifier directly rather than manufacturing contention
// through the HTTP stack: producing a real 55P03 needs two concurrent
// transactions and a Postgres backend, and the property under test is which
// errors are classified retryable — not whether Postgres emits them.
func TestIsRetryableLockError(t *testing.T) {
for _, tc := range []struct {
name string
err error
want bool
}{
{"lock timeout", errors.New(`ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)`), true},
{"deadlock detected", errors.New(`ERROR: deadlock detected (SQLSTATE 40P01)`), true},
// Controls: a classifier that answered true for everything would pass
// the two legs above on its own.
{"unique violation", errors.New(`ERROR: duplicate key value violates unique constraint (SQLSTATE 23505)`), false},
{"invalid byte sequence", errors.New(`ERROR: invalid byte sequence for encoding "UTF8" (SQLSTATE 22021)`), false},
{"plain error", errors.New("update document: connection refused"), false},
{"nil", nil, false},
} {
if got := isRetryableLockError(tc.err); got != tc.want {
t.Errorf("%s: isRetryableLockError = %v, want %v", tc.name, got, tc.want)
}
}
}