408 lines
11 KiB
Go
408 lines
11 KiB
Go
package db
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"modernc.org/sqlite"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
func TestSQLiteDSNOptions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dsn string
|
|
wantDSN string
|
|
wantBusyTimeout string
|
|
wantJournalMode string
|
|
wantTxLock string
|
|
}{
|
|
{
|
|
name: "plain filename gets default timeout",
|
|
dsn: "/tmp/smail.db",
|
|
wantBusyTimeout: "busy_timeout(1000)",
|
|
},
|
|
{
|
|
name: "timeout at cap is preserved byte for byte",
|
|
dsn: "file:smail.db?_txlock=deferred&_pragma=BUSY_TIMEOUT%20%3D%201000&mode=memory",
|
|
wantDSN: "file:smail.db?_txlock=deferred&_pragma=BUSY_TIMEOUT%20%3D%201000&mode=memory",
|
|
wantBusyTimeout: "BUSY_TIMEOUT = 1000",
|
|
wantTxLock: "deferred",
|
|
},
|
|
{
|
|
name: "explicit timeout is capped and other options are preserved",
|
|
dsn: "file:smail.db?mode=memory&_pragma=journal_mode(WAL)&_pragma=busy_timeout%20%3D%20600000&_txlock=deferred",
|
|
wantBusyTimeout: "busy_timeout(1000)",
|
|
wantJournalMode: "journal_mode(WAL)",
|
|
wantTxLock: "deferred",
|
|
},
|
|
{
|
|
name: "negative timeout is preserved byte for byte",
|
|
dsn: "file:smail.db?cache=shared&_pragma=%20busy_timeout%20%28%20-1%20%29%20",
|
|
wantDSN: "file:smail.db?cache=shared&_pragma=%20busy_timeout%20%28%20-1%20%29%20",
|
|
wantBusyTimeout: " busy_timeout ( -1 ) ",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := sqliteDSNWithReliabilityOptions(tt.dsn)
|
|
if err != nil {
|
|
t.Fatalf("sqliteDSNWithReliabilityOptions() error = %v", err)
|
|
}
|
|
if tt.wantDSN != "" && got != tt.wantDSN {
|
|
t.Errorf("sqliteDSNWithReliabilityOptions() = %q, want byte-for-byte %q", got, tt.wantDSN)
|
|
}
|
|
|
|
queryText := ""
|
|
if _, after, ok := strings.Cut(got, "?"); ok {
|
|
queryText = after
|
|
}
|
|
query, err := url.ParseQuery(queryText)
|
|
if err != nil {
|
|
t.Fatalf("ParseQuery(%q): %v", queryText, err)
|
|
}
|
|
if got := query.Get("_txlock"); got != tt.wantTxLock {
|
|
t.Errorf("_txlock = %q, want %q", got, tt.wantTxLock)
|
|
}
|
|
if !containsString(query["_pragma"], tt.wantBusyTimeout) {
|
|
t.Errorf("_pragma = %q, want %q", query["_pragma"], tt.wantBusyTimeout)
|
|
}
|
|
if got := countPragma(query["_pragma"], "busy_timeout"); got != 1 {
|
|
t.Errorf("busy_timeout pragma count = %d, want 1: %q", got, query["_pragma"])
|
|
}
|
|
if tt.wantJournalMode != "" && !containsString(query["_pragma"], tt.wantJournalMode) {
|
|
t.Errorf("_pragma = %q, want preserved %q", query["_pragma"], tt.wantJournalMode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSQLiteDSNRejectsInvalidBusyTimeout(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dsn string
|
|
}{
|
|
{
|
|
name: "duplicate timeout pragmas",
|
|
dsn: "file:smail.db?_pragma=busy_timeout(10)&_pragma=BUSY_TIMEOUT%20%3D%2020",
|
|
},
|
|
{
|
|
name: "missing timeout value",
|
|
dsn: "file:smail.db?_pragma=busy_timeout",
|
|
},
|
|
{
|
|
name: "nonnumeric timeout",
|
|
dsn: "file:smail.db?_pragma=busy_timeout(forever)",
|
|
},
|
|
{
|
|
name: "timeout with trailing junk",
|
|
dsn: "file:smail.db?_pragma=busy_timeout(10)%20milliseconds",
|
|
},
|
|
{
|
|
name: "overflowing timeout",
|
|
dsn: "file:smail.db?_pragma=busy_timeout(9223372036854775808)",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if _, err := sqliteDSNWithReliabilityOptions(tt.dsn); err == nil {
|
|
t.Fatalf("sqliteDSNWithReliabilityOptions(%q) error = nil, want error", tt.dsn)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSQLiteDSNWarnsWhenBusyTimeoutIsCapped(t *testing.T) {
|
|
logger := log.StandardLogger()
|
|
originalOutput := logger.Out
|
|
originalLevel := logger.GetLevel()
|
|
var output bytes.Buffer
|
|
logger.SetOutput(&output)
|
|
logger.SetLevel(log.WarnLevel)
|
|
t.Cleanup(func() {
|
|
logger.SetOutput(originalOutput)
|
|
logger.SetLevel(originalLevel)
|
|
})
|
|
|
|
if _, err := sqliteDSNWithReliabilityOptions("file:smail.db?_pragma=busy_timeout(600000)"); err != nil {
|
|
t.Fatalf("sqliteDSNWithReliabilityOptions() error = %v", err)
|
|
}
|
|
for _, want := range []string{"600000ms", "1000ms"} {
|
|
if !strings.Contains(output.String(), want) {
|
|
t.Errorf("warning = %q, want it to contain %q", output.String(), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSQLiteBusyTimeoutAppliesToEveryConnection(t *testing.T) {
|
|
dsn, err := sqliteDSNWithReliabilityOptions(filepath.Join(t.TempDir(), "connections.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
database, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer database.Close()
|
|
database.SetMaxOpenConns(2)
|
|
|
|
first, err := database.Conn(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer first.Close()
|
|
second, err := database.Conn(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer second.Close()
|
|
|
|
for index, connection := range []*sql.Conn{first, second} {
|
|
var timeout int
|
|
if err = connection.QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&timeout); err != nil {
|
|
t.Fatalf("connection %d: %v", index+1, err)
|
|
}
|
|
if timeout != sqliteBusyTimeoutMilliseconds {
|
|
t.Errorf("connection %d busy_timeout = %d, want %d", index+1, timeout, sqliteBusyTimeoutMilliseconds)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsSQLiteBusyOrLockedUsesDriverCode(t *testing.T) {
|
|
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "busy.db")+"?_pragma=busy_timeout(1)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer engine.Close()
|
|
if _, err = engine.Exec("CREATE TABLE audit (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
busyErr := sqliteBusyError(t, engine.DataSourceName())
|
|
var sqliteErr *sqlite.Error
|
|
if !errors.As(busyErr, &sqliteErr) {
|
|
t.Fatalf("error type = %T, want *sqlite.Error", busyErr)
|
|
}
|
|
if !IsSQLiteBusyOrLocked(fmt.Errorf("wrapped: %w", busyErr)) {
|
|
t.Fatalf("IsSQLiteBusyOrLocked(%v) = false", busyErr)
|
|
}
|
|
if IsSQLiteBusyOrLocked(errors.New("database is locked")) {
|
|
t.Fatal("plain error text must not be classified as SQLITE_BUSY")
|
|
}
|
|
}
|
|
|
|
func TestTransactionRetriesWholeSQLiteTransaction(t *testing.T) {
|
|
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "retry.db")+"?_pragma=busy_timeout(10)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer engine.Close()
|
|
engine.SetMaxOpenConns(1)
|
|
if _, err = engine.Exec("CREATE TABLE audit (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
busyErr := sqliteBusyError(t, engine.DataSourceName())
|
|
|
|
attempts := 0
|
|
err = transactionWithRetry(engine, 3, time.Millisecond, func(session *xorm.Session) error {
|
|
attempts++
|
|
if attempts == 1 {
|
|
return busyErr
|
|
}
|
|
_, insertErr := session.Exec("INSERT INTO audit(value) VALUES ('saved')")
|
|
return insertErr
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("transactionWithRetry() error = %v", err)
|
|
}
|
|
if attempts != 2 {
|
|
t.Fatalf("attempts = %d, want 2", attempts)
|
|
}
|
|
assertAuditRowCount(t, engine, 1)
|
|
}
|
|
|
|
func TestTransactionRetriesSQLiteBusyCommit(t *testing.T) {
|
|
engine, blocker, readTx := newCommitBusyTest(t, 10)
|
|
|
|
attempts := 0
|
|
err := transactionWithRetry(engine, 2, 5*time.Millisecond, func(session *xorm.Session) error {
|
|
attempts++
|
|
if attempts == 2 {
|
|
if rollbackErr := readTx.Rollback(); rollbackErr != nil {
|
|
return fmt.Errorf("release read transaction: %w", rollbackErr)
|
|
}
|
|
}
|
|
_, insertErr := session.Exec("INSERT INTO audit(value) VALUES ('saved')")
|
|
return insertErr
|
|
})
|
|
_ = blocker.Close()
|
|
if err != nil {
|
|
t.Fatalf("transactionWithRetry() error = %v", err)
|
|
}
|
|
if attempts != 2 {
|
|
t.Fatalf("attempts = %d, want 2", attempts)
|
|
}
|
|
assertAuditRowCount(t, engine, 2)
|
|
}
|
|
|
|
func TestPermanentSQLiteBusyCommitLeavesConnectionUsable(t *testing.T) {
|
|
engine, blocker, readTx := newCommitBusyTest(t, 5)
|
|
|
|
attempts := 0
|
|
err := transactionWithRetry(engine, 2, time.Millisecond, func(session *xorm.Session) error {
|
|
attempts++
|
|
_, insertErr := session.Exec("INSERT INTO audit(value) VALUES ('rolled back')")
|
|
return insertErr
|
|
})
|
|
if err == nil || !IsSQLiteBusyOrLocked(err) {
|
|
t.Fatalf("transactionWithRetry() error = %v, want SQLITE_BUSY", err)
|
|
}
|
|
if attempts != 2 {
|
|
t.Fatalf("attempts = %d, want 2", attempts)
|
|
}
|
|
if err = readTx.Rollback(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = blocker.Close()
|
|
|
|
err = runTransaction(engine, func(session *xorm.Session) error {
|
|
_, insertErr := session.Exec("INSERT INTO audit(value) VALUES ('after busy')")
|
|
return insertErr
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("transaction after BUSY = %v", err)
|
|
}
|
|
assertAuditRowCount(t, engine, 2)
|
|
}
|
|
|
|
func TestTransactionDoesNotClassifyBusyText(t *testing.T) {
|
|
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "text.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer engine.Close()
|
|
|
|
attempts := 0
|
|
wantErr := errors.New("database is locked")
|
|
err = transactionWithRetry(engine, 3, time.Millisecond, func(*xorm.Session) error {
|
|
attempts++
|
|
return wantErr
|
|
})
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("transactionWithRetry() error = %v, want %v", err, wantErr)
|
|
}
|
|
if attempts != 1 {
|
|
t.Fatalf("attempts = %d, want 1", attempts)
|
|
}
|
|
}
|
|
|
|
func newCommitBusyTest(t *testing.T, timeoutMilliseconds int) (*xorm.Engine, *sql.DB, *sql.Tx) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "commit-busy.db")
|
|
dsn := fmt.Sprintf("%s?_pragma=busy_timeout(%d)", path, timeoutMilliseconds)
|
|
engine, err := xorm.NewEngine("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = engine.Close() })
|
|
engine.SetMaxOpenConns(1)
|
|
engine.SetMaxIdleConns(1)
|
|
if _, err = engine.Exec("CREATE TABLE audit (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = engine.Exec("INSERT INTO audit(value) VALUES ('seed')"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
blocker, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
readTx, err := blocker.Begin()
|
|
if err != nil {
|
|
_ = blocker.Close()
|
|
t.Fatal(err)
|
|
}
|
|
var count int
|
|
if err = readTx.QueryRow("SELECT count(*) FROM audit").Scan(&count); err != nil {
|
|
_ = readTx.Rollback()
|
|
_ = blocker.Close()
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = readTx.Rollback()
|
|
_ = blocker.Close()
|
|
})
|
|
return engine, blocker, readTx
|
|
}
|
|
|
|
func sqliteBusyError(t *testing.T, dsn string) error {
|
|
t.Helper()
|
|
first, err := xorm.NewEngine("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer first.Close()
|
|
second, err := xorm.NewEngine("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer second.Close()
|
|
|
|
tx := first.NewSession()
|
|
defer tx.Close()
|
|
if err = tx.Begin(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = tx.Exec("INSERT INTO audit(value) VALUES ('rolled back')"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, busyErr := second.Exec("INSERT INTO audit(value) VALUES ('blocked')")
|
|
_ = tx.Rollback()
|
|
if busyErr == nil {
|
|
t.Fatal("expected SQLITE_BUSY")
|
|
}
|
|
return busyErr
|
|
}
|
|
|
|
func assertAuditRowCount(t *testing.T, engine *xorm.Engine, want int) {
|
|
t.Helper()
|
|
var count int
|
|
if _, err := engine.SQL("SELECT count(*) FROM audit").Get(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != want {
|
|
t.Fatalf("audit rows = %d, want %d", count, want)
|
|
}
|
|
}
|
|
|
|
func containsString(values []string, want string) bool {
|
|
for _, value := range values {
|
|
if value == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func countPragma(values []string, name string) int {
|
|
count := 0
|
|
for _, value := range values {
|
|
if sqlitePragmaName(value) == name {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|