mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
773222368c
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.
184 lines
5.8 KiB
Go
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
|
|
}
|