158 lines
6.2 KiB
Go
158 lines
6.2 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
|
)
|
|
|
|
// Every SQLite file this service opens — the main database, the
|
|
// per-webhook event databases, and the archive databases — is opened
|
|
// through OpenSQLite, so the durability settings below are properties
|
|
// of the service rather than of one call site.
|
|
//
|
|
// modernc.org/sqlite installs no busy handler and issues no pragmas of
|
|
// its own: it executes only the pragmas named in explicit `_pragma=`
|
|
// DSN parameters, and gorm.io/driver/sqlite adds none when it is
|
|
// handed an existing *sql.DB. Every setting therefore has to be
|
|
// spelled out here or it is simply not in effect.
|
|
// SQLite URI open modes.
|
|
const (
|
|
// SQLiteModeCreate creates the database file when it is missing.
|
|
SQLiteModeCreate = "rwc"
|
|
|
|
// SQLiteModeExisting requires the file to exist already.
|
|
SQLiteModeExisting = "rw"
|
|
)
|
|
|
|
const (
|
|
// SQLiteBusyTimeout is how long SQLite retries a lock conflict
|
|
// before returning SQLITE_BUSY.
|
|
//
|
|
// Under WAL a reader never blocks a writer, so the only conflict
|
|
// left is writer against writer: this process's delivery workers
|
|
// against each other, or against another process holding the write
|
|
// lock. Those clear in milliseconds. Ten seconds is far above that
|
|
// and still well inside the receiver's request budget, so an
|
|
// inbound webhook waits rather than being rejected with a 500.
|
|
SQLiteBusyTimeout = 10 * time.Second
|
|
|
|
// sqliteMaxOpenConns bounds the connection pool for one database
|
|
// file.
|
|
//
|
|
// The pool needs a bound at all because database/sql cannot detect
|
|
// a connection left mid-transaction: modernc.org/sqlite implements
|
|
// neither driver.Validator nor driver.SessionResetter, so a
|
|
// connection whose COMMIT failed is returned to the pool with its
|
|
// transaction still open and handed out again indefinitely. That is
|
|
// what turned four `database is locked` errors into 593
|
|
// `cannot start a transaction within a transaction` in
|
|
// https://git.eeqj.de/sneak/webhooker/issues/256.
|
|
//
|
|
// Four is above the one writer SQLite allows at a time, so reads
|
|
// still proceed while a write is in flight, and low enough that
|
|
// contention is resolved by the busy handler rather than by piling
|
|
// up connections against a lock only one of them can hold.
|
|
sqliteMaxOpenConns = 4
|
|
|
|
// sqliteMaxIdleConns keeps the pool warm without holding every
|
|
// connection open through an idle period.
|
|
sqliteMaxIdleConns = 2
|
|
|
|
// sqliteConnMaxLifetime and sqliteConnMaxIdleTime retire pooled
|
|
// connections on a schedule. With _txlock=immediate a failed
|
|
// COMMIT should no longer be reachable, but these bound the damage
|
|
// if one happens anyway: a poisoned connection is closed and
|
|
// replaced within the lifetime instead of wedging the file until
|
|
// the process restarts.
|
|
sqliteConnMaxLifetime = 5 * time.Minute
|
|
sqliteConnMaxIdleTime = time.Minute
|
|
)
|
|
|
|
// SQLiteDSN builds the connection string for one database file.
|
|
//
|
|
// mode is the SQLite URI open mode: "rwc" to create the file when it
|
|
// is missing, "rw" to require that it already exists.
|
|
//
|
|
// Three settings carry the fix for
|
|
// https://git.eeqj.de/sneak/webhooker/issues/256 and none of them is
|
|
// optional:
|
|
//
|
|
// - journal_mode=WAL, so a reader — an operator running
|
|
// `sqlite3 <db> .dump` over their own data — takes a snapshot
|
|
// instead of blocking every writer behind it.
|
|
//
|
|
// - busy_timeout, so a writer that does meet a lock waits for it.
|
|
// Without one SQLite gives up immediately; nothing above it
|
|
// retries.
|
|
//
|
|
// - _txlock=immediate, so every transaction takes the write lock at
|
|
// BEGIN. A deferred transaction acquires it lazily on its first
|
|
// write, and that upgrade returns SQLITE_BUSY *without* consulting
|
|
// the busy handler, because SQLite cannot block a transaction that
|
|
// may already hold a read snapshot. Such a COMMIT then fails while
|
|
// the transaction stays open on the connection. A busy timeout
|
|
// alone does not prevent this; BEGIN IMMEDIATE does, by putting
|
|
// the wait somewhere the handler applies.
|
|
//
|
|
// Note what is absent: `cache=shared`. Under a shared cache an
|
|
// in-process conflict is reported as SQLITE_LOCKED rather than
|
|
// SQLITE_BUSY, and the busy handler does not retry SQLITE_LOCKED — so
|
|
// leaving it in would have defeated the busy timeout for exactly the
|
|
// contention this service generates. Dropping it is part of the fix,
|
|
// not housekeeping.
|
|
//
|
|
// synchronous is deliberately left at SQLite's default of FULL: this
|
|
// is a webhook receiver whose one promise is that an event it answered
|
|
// 200 for is durable.
|
|
// The order of the _pragma parameters is load-bearing.
|
|
// modernc.org/sqlite executes them in the order they appear, on every
|
|
// new connection, before the connection is handed to the pool. Setting
|
|
// journal_mode first means that pragma itself runs with no busy
|
|
// handler installed: the pool opens connections lazily, so the moment
|
|
// a new one is created is a moment the database is under load, and
|
|
// PRAGMA journal_mode takes a lock. It would fail immediately with
|
|
// SQLITE_BUSY and fail the query that caused the connection to be
|
|
// opened. busy_timeout is therefore set first, so every pragma after
|
|
// it — and the whole life of the connection — is covered.
|
|
func SQLiteDSN(path, mode string) string {
|
|
q := url.Values{}
|
|
q.Set("mode", mode)
|
|
q.Set("_txlock", "immediate")
|
|
q.Add(
|
|
"_pragma",
|
|
fmt.Sprintf(
|
|
"busy_timeout(%d)",
|
|
SQLiteBusyTimeout.Milliseconds(),
|
|
),
|
|
)
|
|
q.Add("_pragma", "journal_mode(WAL)")
|
|
|
|
return "file:" + path + "?" + q.Encode()
|
|
}
|
|
|
|
// OpenSQLite opens the SQLite file at path with the service's
|
|
// durability settings and pool bounds applied. mode is the SQLite URI
|
|
// open mode ("rwc" or "rw").
|
|
//
|
|
// The handle is returned rather than a *gorm.DB because the callers
|
|
// wrap it in gorm themselves with their own logger.
|
|
func OpenSQLite(path, mode string) (*sql.DB, error) {
|
|
sqlDB, err := sql.Open("sqlite", SQLiteDSN(path, mode))
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"opening sqlite database %s: %w", path, err,
|
|
)
|
|
}
|
|
|
|
sqlDB.SetMaxOpenConns(sqliteMaxOpenConns)
|
|
sqlDB.SetMaxIdleConns(sqliteMaxIdleConns)
|
|
sqlDB.SetConnMaxLifetime(sqliteConnMaxLifetime)
|
|
sqlDB.SetConnMaxIdleTime(sqliteConnMaxIdleTime)
|
|
|
|
return sqlDB, nil
|
|
}
|