mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-23 18:54:16 +00:00
`go fix` carries the modernize analyzers now, and the tree had drifted behind
them. This is the mechanical result, reviewed rather than trusted: the tool is
capable of rewriting code into something that no longer tests or does what it
did, so every non-test change was read individually and the concurrency-bearing
packages were re-run under -race.
Production code, four changes, all semantics-preserving:
- leases/manager.go: wg.Add(1) + go + defer wg.Done() becomes wg.Go. The
comment above that function turns on Add happening before the goroutine
starts, so that a Wait cannot return before the worker has run. wg.Go does
the Add synchronously on the calling goroutine, so the invariant it
describes still holds.
- auth/token/handler.go: strings.Fields -> strings.FieldsSeq, same splitting,
iterated rather than allocated.
- hold/gc/gc.go: a hand-written map copy -> maps.Copy.
- hold/pds/scan_broadcaster.go: three-clause loop -> range over int.
The rest are tests. The one worth naming is carstore_contention_test.go, where a
careless rewrite could have quietly stopped exercising contention: go fix
converted the reader and side-table goroutines to loopWG.Go but correctly
declined to touch the writer loop, which passes its index as a parameter. The
writer/reader/side-table shape and the stop channel are unchanged, so the test
still contends over the same carstore transactions.
Verified: go build for hold and appview, `make lint` 0 issues, the deploy and
credential-helper modules 0 issues, `make test` green across all 43 packages,
and -race green on leases, hold/pds, hold/gc and auth/token. The scanner module's
two lint findings are unchanged from HEAD and are in files go fix never touched.
Kept separate from the HTTP/2 commit so that one stays readable, and so this can
be reverted on its own if a modernization turns out to matter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
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 := range n {
|
|
b := blockformat.NewBlock(fmt.Appendf(nil, "%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 := range writers {
|
|
wg.Add(1)
|
|
go func(w int) {
|
|
defer wg.Done()
|
|
for i := range iters {
|
|
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)
|
|
}
|
|
}
|