Files
pad/internal/store/versions.go
T
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

184 lines
5.8 KiB
Go

package store
import (
"database/sql"
"fmt"
"time"
"github.com/PerpetualSoftware/pad/internal/diff"
"github.com/PerpetualSoftware/pad/internal/models"
)
// VersionThrottleInterval is the minimum time between version snapshots
// during continuous editing. Edits within this window are consolidated.
const VersionThrottleInterval = 1 * time.Hour
// ShouldCreateVersion determines whether a new version snapshot should be created,
// based on time since last version, actor/source changes, and content type.
func (s *Store) ShouldCreateVersion(documentID, actor, source string) (bool, error) {
return s.shouldCreateVersionQ(s.db, documentID, actor, source)
}
// shouldCreateVersionQ is ShouldCreateVersion against a caller-supplied
// executor. UpdateDocument calls it from INSIDE its transaction, and a read
// issued against the pool from there needs a second connection while the
// first is held — the application-level deadlock BUG-2778's other half is
// about. This is the more reachable instance of the two: it fires on any
// content edit, not only a rename.
func (s *Store) shouldCreateVersionQ(q rowQueryer, documentID, actor, source string) (bool, error) {
latest, err := s.getLatestVersionRawQ(q, documentID)
if err != nil {
return false, err
}
// No versions yet — always create the first one
if latest == nil {
return true, nil
}
// Actor changed (user ↔ agent) — always snapshot
if latest.CreatedBy != actor {
return true, nil
}
// Source changed (web ↔ cli ↔ skill) — always snapshot
if latest.Source != source {
return true, nil
}
// Throttle: only create if enough time has passed
elapsed := time.Since(latest.CreatedAt)
return elapsed >= VersionThrottleInterval, nil
}
// GetLatestVersion returns the most recent version for a document with full content
// (diffs are resolved against current document content).
func (s *Store) GetLatestVersion(documentID string) (*models.Version, error) {
return s.getLatestVersionRaw(documentID)
}
// getLatestVersionRaw returns the most recent version without resolving diffs.
func (s *Store) getLatestVersionRaw(documentID string) (*models.Version, error) {
return s.getLatestVersionRawQ(s.db, documentID)
}
// getLatestVersionRawQ is getLatestVersionRaw against a caller-supplied
// executor, so an in-transaction caller reads through its own transaction.
func (s *Store) getLatestVersionRawQ(q rowQueryer, documentID string) (*models.Version, error) {
var v models.Version
var createdAt string
var isDiff bool
err := q.QueryRow(s.q(`
SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at
FROM versions
WHERE document_id = ?
ORDER BY created_at DESC
LIMIT 1
`), documentID).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
v.IsDiff = isDiff
v.CreatedAt = parseTime(createdAt)
return &v, nil
}
func (s *Store) ListVersions(documentID string) ([]models.Version, error) {
rows, err := s.db.Query(s.q(`
SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at
FROM versions
WHERE document_id = ?
ORDER BY created_at DESC
`), documentID)
if err != nil {
return nil, err
}
defer rows.Close()
var versions []models.Version
for rows.Next() {
var v models.Version
var createdAt string
var isDiff bool
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt); err != nil {
return nil, err
}
v.IsDiff = isDiff
v.CreatedAt = parseTime(createdAt)
versions = append(versions, v)
}
return versions, rows.Err()
}
// ListVersionsResolved returns versions with full content (diffs resolved).
// Requires the current document content to reconstruct diff-based versions.
func (s *Store) ListVersionsResolved(documentID, currentContent string) ([]models.Version, error) {
versions, err := s.ListVersions(documentID)
if err != nil {
return nil, err
}
// Resolve diffs: walk from newest to oldest, applying reverse patches.
// The newest diff version patches current content → that version's content.
// The next older diff patches that result → its content, etc.
content := currentContent
for i := range versions {
if !versions[i].IsDiff {
// Full content — use as-is, and this becomes the base for older diffs
content = versions[i].Content
continue
}
// Apply reverse patch: content at this point → version's content
resolved, err := diff.ApplyPatch(content, versions[i].Content)
if err != nil {
// If patch fails, mark it but don't break the whole list
versions[i].Content = fmt.Sprintf("[patch error: %v]", err)
versions[i].IsDiff = false
continue
}
versions[i].Content = resolved
versions[i].IsDiff = false
content = resolved
}
return versions, nil
}
func (s *Store) GetVersion(id string) (*models.Version, error) {
var v models.Version
var createdAt string
var isDiff bool
err := s.db.QueryRow(s.q(`
SELECT id, document_id, content, change_summary, created_by, source, is_diff, created_at
FROM versions
WHERE id = ?
`), id).Scan(&v.ID, &v.DocumentID, &v.Content, &v.ChangeSummary, &v.CreatedBy, &v.Source, &isDiff, &createdAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
v.IsDiff = isDiff
v.CreatedAt = parseTime(createdAt)
return &v, nil
}
// GetVersionResolved returns a version with full content reconstructed.
// For diff-based versions, this requires walking the version chain.
func (s *Store) GetVersionResolved(id, documentID, currentContent string) (*models.Version, error) {
// Get all versions to find and resolve the target
versions, err := s.ListVersionsResolved(documentID, currentContent)
if err != nil {
return nil, err
}
for _, v := range versions {
if v.ID == id {
return &v, nil
}
}
return nil, nil
}