store: migrations under BEGIN IMMEDIATE, the version read again inside
test / test (push) Canceled after 0s

D-S3. A deferred BEGIN starts nothing until the first access (SQLite,
lang_transaction.html § 2.2), and migrate() read user_version outside the
transaction: after an upgrade the panel and dmarc-ingest, opening the file
at once, could both read the old version, both begin, and the second
applied the next migration again on the migrated file - "already exists",
that Open failing (nothing damaged: its transaction rolled back). The DSN
now has _txlock=immediate (modernc.org/sqlite v1.60.1, sqlite.go
applyQueryParams() sets beginMode, tx.go newTx() issues "begin immediate"
for any transaction not read-only), so a migration holds the write lock
from its BEGIN, and migrate() reads user_version again inside it and
skips one another process applied meanwhile.

Test TestTwoOpensMigrateOnce: a raw connection holds BEGIN IMMEDIATE on a
new file while two Opens read version 0 and queue on their first BEGIN;
released, both succeed and user_version is the number of migrations.
Without the fix it fails as the register said: "apply migration
0001_init.sql: SQL logic error: table settings already exists (1)".
Stand (2026-10-15): go vet and go test ./... pass on the server (24
packages); make e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 01:19:18 +03:00
co-authored by Claude Opus 5.5
parent 9ca50d33fd
commit 3c63ecc1ad
5 changed files with 90 additions and 4 deletions
+55
View File
@@ -8,6 +8,7 @@ import (
"sort"
"strings"
"testing"
"time"
)
// 2.0 starts from one baseline: the 1.x chain is gone from the binary, not
@@ -383,3 +384,57 @@ func TestBaselineConstraints(t *testing.T) {
t.Errorf("after deleting the user %d inbound assignment(s) stayed (%v)", n, err)
}
}
// Two processes opening one file that needs migrating - the panel and
// dmarc-ingest after an upgrade - each apply every migration at most once:
// both read the old version, both wait for the write lock, and the second
// finds the work done (assumptions D-S3). Before _txlock=immediate and the
// re-read, the second ran 0001 again and failed with "already exists".
func TestTwoOpensMigrateOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "selfpost.db")
// The blocker holds the write lock while both opens read user_version 0
// and queue on their first BEGIN IMMEDIATE.
blocker, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)")
if err != nil {
t.Fatal(err)
}
defer blocker.Close()
blocker.SetMaxOpenConns(1)
if _, err := blocker.Exec("BEGIN IMMEDIATE"); err != nil {
t.Fatal(err)
}
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
go func() {
st, err := Open(path)
if err == nil {
err = st.Close()
}
errs <- err
}()
}
time.Sleep(500 * time.Millisecond) // both are waiting on the lock by now
if _, err := blocker.Exec("COMMIT"); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
t.Fatalf("open %d: %v", i, err)
}
}
st, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
var version int
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
t.Fatal(err)
}
entries, _ := fs.ReadDir(migrationsFS, "migrations")
if version != len(entries) {
t.Fatalf("user_version %d, want %d", version, len(entries))
}
}