mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 04:04:15 +00:00
A PRAGMA is per-connection, and database/sql opens one connection per concurrent caller. hold_db.go and scan_broadcaster.go each set busy_timeout = 5000 with a one-shot query against the pool, which configures whichever connection happened to serve it and leaves every other writer at zero. Probing the live pools showed exactly that: conn 0 at 5000, conns 1 and 2 at 0. That is the database is locked on COMMIT, and the carstore had no busy_timeout and no WAL on any connection at all. Opening a database now goes through a connector that runs the PRAGMA on every connection the pool creates, ported from the appview, which had already solved this. Applied to the carstore, the shared hold DB, the records index, the event broadcaster and the scan broadcaster. In-memory databases are left unwrapped, since libsql gives each connection its own, and the embedded replica path still skips PRAGMAs because a remote rejects them. WAL is not a new risk: production was already WAL, because journal mode is a persistent property of the file and OpenHoldDB has been setting it. This makes the carstore's own opener agree rather than leaving standalone and test databases in rollback-journal mode. A database that refuses WAL logs a warning instead of failing boot, so a network mount cannot stop the hold starting. NewHoldPDS's file mode opened the same file a second time for the records index; it now shares the carstore's pool. Two pools on one file buy nothing, since SQLite's write lock is per database, so the second only adds contenders. Production already shared a pool and was unaffected by that half. Note for anyone reading OpenHoldDB: its foreign_keys pragma has the same one-connection scope, but libsql reports foreign_keys on for every fresh connection anyway, so it is decorative rather than broken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
161 lines
4.4 KiB
Go
161 lines
4.4 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/bluesky-social/indigo/models"
|
|
blockformat "github.com/ipfs/go-block-format"
|
|
"github.com/ipfs/go-cid"
|
|
)
|
|
|
|
// isLockErr reports whether err is a SQLite busy/locked error rather than a
|
|
// logic error. Contention tests must fail on lock errors specifically, so a
|
|
// typo cannot masquerade as a reproduction.
|
|
func isLockErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := strings.ToLower(err.Error())
|
|
return strings.Contains(s, "database is locked") ||
|
|
strings.Contains(s, "database table is locked") ||
|
|
strings.Contains(s, "sqlite_busy") ||
|
|
strings.Contains(s, "busy")
|
|
}
|
|
|
|
func makeBlocks(t *testing.T, n int, salt string) (cid.Cid, map[cid.Cid]blockformat.Block) {
|
|
t.Helper()
|
|
blks := make(map[cid.Cid]blockformat.Block, n)
|
|
var root cid.Cid
|
|
for i := 0; i < n; i++ {
|
|
b := blockformat.NewBlock([]byte(fmt.Sprintf("%s-block-%d-%s", salt, i, strings.Repeat("x", 2048))))
|
|
blks[b.Cid()] = b
|
|
if i == 0 {
|
|
root = b.Cid()
|
|
}
|
|
}
|
|
return root, blks
|
|
}
|
|
|
|
// writeShards hammers the carstore with concurrent shard writes and reports any
|
|
// lock error it hits. This is the exact path every hold record write takes.
|
|
func writeShards(t *testing.T, sqs *SQLiteStore, writers, iters int) error {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var firstErr error
|
|
|
|
for w := 0; w < writers; w++ {
|
|
wg.Add(1)
|
|
go func(w int) {
|
|
defer wg.Done()
|
|
for i := 0; i < iters; i++ {
|
|
salt := fmt.Sprintf("w%d-i%d", w, i)
|
|
root, blks := makeBlocks(t, 12, salt)
|
|
rev := fmt.Sprintf("rev-%03d-%03d", w, i)
|
|
_, err := sqs.writeNewShard(ctx, root, rev, models.Uid(1), i, blks, nil)
|
|
if err != nil {
|
|
mu.Lock()
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
}
|
|
}(w)
|
|
}
|
|
wg.Wait()
|
|
return firstErr
|
|
}
|
|
|
|
// TestCarstoreConcurrentWritesSharedPool reproduces the production topology:
|
|
// one *sql.DB pool (OpenHoldDB) shared by the carstore, records index, events
|
|
// and scan broadcaster. database/sql opens a fresh connection per concurrent
|
|
// caller, and busy_timeout is per-connection, so any connection beyond the
|
|
// first commits with busy_timeout=0 and fails immediately under contention.
|
|
func TestCarstoreConcurrentWritesSharedPool(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "db.sqlite3")
|
|
h, err := OpenHoldDB(path, LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("OpenHoldDB: %v", err)
|
|
}
|
|
defer h.Close()
|
|
|
|
sqs, err := NewSQLiteStoreWithDB(path, h.DB)
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteStoreWithDB: %v", err)
|
|
}
|
|
|
|
if err := writeShards(t, sqs, 8, 25); err != nil {
|
|
if isLockErr(err) {
|
|
t.Fatalf("shared-pool carstore write hit a lock error: %v", err)
|
|
}
|
|
t.Fatalf("shared-pool carstore write failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCarstoreConcurrentWritesTwoOpeners covers the topology where a second
|
|
// subsystem (the scan broadcaster or the event broadcaster, when they are not
|
|
// handed the shared pool) opens <dir>/db.sqlite3 again through its own pool
|
|
// while the carstore is writing.
|
|
func TestCarstoreConcurrentWritesTwoOpeners(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sqs, err := NewSqliteStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewSqliteStore: %v", err)
|
|
}
|
|
defer sqs.Close()
|
|
|
|
// Second, independent pool on the same file, opened the way every hold
|
|
// subsystem now opens one.
|
|
ri, err := OpenLocalDB(filepath.Join(dir, "db.sqlite3"))
|
|
if err != nil {
|
|
t.Fatalf("open records index: %v", err)
|
|
}
|
|
defer ri.Close()
|
|
if _, err := ri.Exec("CREATE TABLE IF NOT EXISTS records (collection TEXT, rkey TEXT, cid TEXT, PRIMARY KEY (collection, rkey))"); err != nil {
|
|
t.Fatalf("create records table: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
stop := make(chan struct{})
|
|
idxErr := make(chan error, 1)
|
|
go func() {
|
|
var err error
|
|
for i := 0; ; i++ {
|
|
select {
|
|
case <-stop:
|
|
idxErr <- err
|
|
return
|
|
default:
|
|
}
|
|
_, e := ri.ExecContext(ctx,
|
|
"INSERT INTO records (collection, rkey, cid) VALUES (?, ?, ?) ON CONFLICT (collection, rkey) DO UPDATE SET cid=excluded.cid",
|
|
"io.atcr.hold.scan", fmt.Sprintf("rkey-%d", i), fmt.Sprintf("cid-%d", i))
|
|
if e != nil && err == nil {
|
|
err = e
|
|
}
|
|
}
|
|
}()
|
|
|
|
csErr := writeShards(t, sqs, 4, 30)
|
|
close(stop)
|
|
riResult := <-idxErr
|
|
|
|
for _, e := range []error{csErr, riResult} {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if isLockErr(e) {
|
|
t.Fatalf("two-opener write hit a lock error: %v", e)
|
|
}
|
|
t.Fatalf("two-opener write failed: %v", e)
|
|
}
|
|
}
|