2026-01-20 21:52:44 +00:00
|
|
|
package database
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
fix(database): set connection pool limits for Postgres (#350)
* fix(database): set connection pool limits for Postgres
OpenPostgres returned sqlx.Open's handle with database/sql's defaults:
no cap on open connections and two idle ones. Under load nearly every
request opened a new Postgres session, ran its few statements and closed
it again, paying a backend fork and SCRAM authentication each time.
Set the pool limits the issue suggests: 32 open and 32 idle connections,
idle connections closed after 5 minutes and every connection recycled
after 30 minutes. The values live in named constants because the mnd
linter rejects the literals inline.
The test (skipped without PROXY_DATABASE_URL, like the other Postgres
tests) takes 16 connections from the pool, releases them and checks that
all 16 stay idle; with the default pool only two survive.
Fixes #323
* test(database): pin the pool properties instead of the wiring
Review pointed out that the pool test compared MaxOpenConnections to the
constant it was set from, so the assertion followed the constant and
would have accepted postgresMaxOpenConns = 0 (unlimited), and that its
burst of 16 only proved MaxIdleConns >= 16.
The burst now takes postgresMaxIdleConns connections, so every
configured idle slot has to survive the release, and the open cap is
checked to be finite and large enough for that burst before any
connection is taken, so a cap below the idle count fails fast instead
of blocking in db.Conn. The doc comment now says which settings the
test covers; the idle-time and lifetime settings only show up in
DBStats.MaxIdleTimeClosed and MaxLifetimeClosed after minutes of
wall-clock time and stay unexercised.
* test(database): guard the pool test against a vacuous burst
Review showed that with the burst tied to postgresMaxIdleConns the test
also passed for a constant of 2, database/sql's default, or of 0, where
it took no connections at all. It now fails outright unless the
configured idle count exceeds the default, and every connection it
takes is released in a cleanup, so an assertion failure mid-burst no
longer leaves sessions open for the rest of the test binary.
2026-09-16 09:13:38 +02:00
|
|
|
"time"
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-01-29 16:06:56 +00:00
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
|
_ "github.com/lib/pq"
|
2026-01-20 21:52:44 +00:00
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const SchemaVersion = 1
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
const dirPermissions = 0755
|
|
|
|
|
|
fix(database): set connection pool limits for Postgres (#350)
* fix(database): set connection pool limits for Postgres
OpenPostgres returned sqlx.Open's handle with database/sql's defaults:
no cap on open connections and two idle ones. Under load nearly every
request opened a new Postgres session, ran its few statements and closed
it again, paying a backend fork and SCRAM authentication each time.
Set the pool limits the issue suggests: 32 open and 32 idle connections,
idle connections closed after 5 minutes and every connection recycled
after 30 minutes. The values live in named constants because the mnd
linter rejects the literals inline.
The test (skipped without PROXY_DATABASE_URL, like the other Postgres
tests) takes 16 connections from the pool, releases them and checks that
all 16 stay idle; with the default pool only two survive.
Fixes #323
* test(database): pin the pool properties instead of the wiring
Review pointed out that the pool test compared MaxOpenConnections to the
constant it was set from, so the assertion followed the constant and
would have accepted postgresMaxOpenConns = 0 (unlimited), and that its
burst of 16 only proved MaxIdleConns >= 16.
The burst now takes postgresMaxIdleConns connections, so every
configured idle slot has to survive the release, and the open cap is
checked to be finite and large enough for that burst before any
connection is taken, so a cap below the idle count fails fast instead
of blocking in db.Conn. The doc comment now says which settings the
test covers; the idle-time and lifetime settings only show up in
DBStats.MaxIdleTimeClosed and MaxLifetimeClosed after minutes of
wall-clock time and stay unexercised.
* test(database): guard the pool test against a vacuous burst
Review showed that with the burst tied to postgresMaxIdleConns the test
also passed for a constant of 2, database/sql's default, or of 0, where
it took no connections at all. It now fails outright unless the
configured idle count exceeds the default, and every connection it
takes is released in a cleanup, so an assertion failure mid-burst no
longer leaves sessions open for the rest of the test binary.
2026-09-16 09:13:38 +02:00
|
|
|
// Postgres connection pool limits. database/sql keeps only two idle
|
|
|
|
|
// connections by default, which opens a new Postgres session for almost every
|
|
|
|
|
// request under load.
|
|
|
|
|
const (
|
|
|
|
|
postgresMaxOpenConns = 32
|
|
|
|
|
postgresMaxIdleConns = 32
|
|
|
|
|
postgresConnMaxIdleTime = 5 * time.Minute
|
|
|
|
|
postgresConnMaxLifetime = 30 * time.Minute
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-29 16:06:56 +00:00
|
|
|
type Dialect string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
DialectSQLite Dialect = "sqlite"
|
|
|
|
|
DialectPostgres Dialect = "postgres"
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
type DB struct {
|
2026-01-29 16:06:56 +00:00
|
|
|
*sqlx.DB
|
|
|
|
|
dialect Dialect
|
|
|
|
|
path string
|
2026-09-30 23:09:04 -07:00
|
|
|
hits *hitBatch
|
2026-01-29 16:06:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (db *DB) Dialect() Dialect {
|
|
|
|
|
return db.dialect
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func Exists(path string) bool {
|
|
|
|
|
_, err := os.Stat(path)
|
|
|
|
|
return err == nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func Create(path string) (*DB, error) {
|
|
|
|
|
if Exists(path) {
|
|
|
|
|
if err := os.Remove(path); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("removing existing database: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db, err := Open(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := db.CreateSchema(); err != nil {
|
|
|
|
|
_ = db.Close()
|
|
|
|
|
return nil, fmt.Errorf("creating schema: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return db, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func Open(path string) (*DB, error) {
|
|
|
|
|
if dir := filepath.Dir(path); dir != "." && dir != "/" {
|
2026-03-18 10:59:29 +00:00
|
|
|
if err := os.MkdirAll(dir, dirPermissions); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
return nil, fmt.Errorf("creating database directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
// Add busy_timeout to handle concurrent writes
|
|
|
|
|
sqlDB, err := sqlx.Open("sqlite", path+"?_busy_timeout=5000")
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("opening database: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
// Limit connections to 1 for SQLite to serialize writes
|
|
|
|
|
sqlDB.SetMaxOpenConns(1)
|
|
|
|
|
|
2026-01-29 16:06:56 +00:00
|
|
|
db := &DB{DB: sqlDB, dialect: DialectSQLite, path: path}
|
2026-01-20 21:52:44 +00:00
|
|
|
if err := db.OptimizeForReads(); err != nil {
|
|
|
|
|
_ = sqlDB.Close()
|
|
|
|
|
return nil, fmt.Errorf("optimizing database: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return db, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func OpenOrCreate(path string) (*DB, error) {
|
|
|
|
|
if Exists(path) {
|
|
|
|
|
return Open(path)
|
|
|
|
|
}
|
|
|
|
|
return Create(path)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:06:56 +00:00
|
|
|
func OpenPostgres(url string) (*DB, error) {
|
|
|
|
|
sqlDB, err := sqlx.Open("postgres", url)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("opening postgres database: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
fix(database): set connection pool limits for Postgres (#350)
* fix(database): set connection pool limits for Postgres
OpenPostgres returned sqlx.Open's handle with database/sql's defaults:
no cap on open connections and two idle ones. Under load nearly every
request opened a new Postgres session, ran its few statements and closed
it again, paying a backend fork and SCRAM authentication each time.
Set the pool limits the issue suggests: 32 open and 32 idle connections,
idle connections closed after 5 minutes and every connection recycled
after 30 minutes. The values live in named constants because the mnd
linter rejects the literals inline.
The test (skipped without PROXY_DATABASE_URL, like the other Postgres
tests) takes 16 connections from the pool, releases them and checks that
all 16 stay idle; with the default pool only two survive.
Fixes #323
* test(database): pin the pool properties instead of the wiring
Review pointed out that the pool test compared MaxOpenConnections to the
constant it was set from, so the assertion followed the constant and
would have accepted postgresMaxOpenConns = 0 (unlimited), and that its
burst of 16 only proved MaxIdleConns >= 16.
The burst now takes postgresMaxIdleConns connections, so every
configured idle slot has to survive the release, and the open cap is
checked to be finite and large enough for that burst before any
connection is taken, so a cap below the idle count fails fast instead
of blocking in db.Conn. The doc comment now says which settings the
test covers; the idle-time and lifetime settings only show up in
DBStats.MaxIdleTimeClosed and MaxLifetimeClosed after minutes of
wall-clock time and stay unexercised.
* test(database): guard the pool test against a vacuous burst
Review showed that with the burst tied to postgresMaxIdleConns the test
also passed for a constant of 2, database/sql's default, or of 0, where
it took no connections at all. It now fails outright unless the
configured idle count exceeds the default, and every connection it
takes is released in a cleanup, so an assertion failure mid-burst no
longer leaves sessions open for the rest of the test binary.
2026-09-16 09:13:38 +02:00
|
|
|
sqlDB.SetMaxOpenConns(postgresMaxOpenConns)
|
|
|
|
|
sqlDB.SetMaxIdleConns(postgresMaxIdleConns)
|
|
|
|
|
sqlDB.SetConnMaxIdleTime(postgresConnMaxIdleTime)
|
|
|
|
|
sqlDB.SetConnMaxLifetime(postgresConnMaxLifetime)
|
|
|
|
|
|
2026-01-29 16:06:56 +00:00
|
|
|
if err := sqlDB.Ping(); err != nil {
|
|
|
|
|
_ = sqlDB.Close()
|
|
|
|
|
return nil, fmt.Errorf("connecting to postgres: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &DB{DB: sqlDB, dialect: DialectPostgres}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func OpenPostgresOrCreate(url string) (*DB, error) {
|
|
|
|
|
db, err := OpenPostgres(url)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var exists bool
|
|
|
|
|
err = db.Get(&exists, "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'schema_info')")
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = db.Close()
|
|
|
|
|
return nil, fmt.Errorf("checking schema: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !exists {
|
|
|
|
|
if err := db.CreateSchema(); err != nil {
|
|
|
|
|
_ = db.Close()
|
|
|
|
|
return nil, fmt.Errorf("creating schema: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return db, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
func (db *DB) OptimizeForBulkWrites() error {
|
2026-01-29 16:06:56 +00:00
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
_, err := db.Exec(`
|
|
|
|
|
PRAGMA synchronous = OFF;
|
|
|
|
|
PRAGMA journal_mode = WAL;
|
|
|
|
|
PRAGMA cache_size = -64000;
|
|
|
|
|
`)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (db *DB) OptimizeForReads() error {
|
2026-01-29 16:06:56 +00:00
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
_, err := db.Exec(`
|
|
|
|
|
PRAGMA synchronous = NORMAL;
|
|
|
|
|
PRAGMA journal_mode = WAL;
|
|
|
|
|
`)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (db *DB) Path() string {
|
|
|
|
|
return db.path
|
|
|
|
|
}
|
2026-01-29 16:06:56 +00:00
|
|
|
|
|
|
|
|
func (db *DB) Rebind(query string) string {
|
|
|
|
|
return db.DB.Rebind(query)
|
|
|
|
|
}
|