Replace Postgres with SQLite
Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.
The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:
- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
declared type is what makes the driver return time.Time, and the
fixed-width UTC string is what makes ordering and comparison against
datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
formula out, guarded with max(0.0, ...) because cancellation returns a
tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
pragma is set on every connection.
Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
This commit is contained in:
@@ -3,22 +3,22 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
|
||||
var version = "dev"
|
||||
|
||||
type config struct {
|
||||
databaseURL string
|
||||
dbPath string
|
||||
adminUser string
|
||||
adminPass string
|
||||
addr string
|
||||
@@ -32,7 +32,6 @@ type config struct {
|
||||
|
||||
func loadConfig() config {
|
||||
c := config{
|
||||
databaseURL: os.Getenv("DATABASE_URL"),
|
||||
adminUser: env("ADMIN_USER", "admin"),
|
||||
adminPass: os.Getenv("ADMIN_PASSWORD"),
|
||||
addr: env("ADDR", ":8080"),
|
||||
@@ -41,9 +40,8 @@ func loadConfig() config {
|
||||
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
||||
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
|
||||
}
|
||||
if c.databaseURL == "" {
|
||||
fatal("DATABASE_URL is not set")
|
||||
}
|
||||
// The database lives beside the audio, so one volume is the whole backup.
|
||||
c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db"))
|
||||
// An admin panel that silently opens is worse than one that won't boot.
|
||||
if c.adminPass == "" {
|
||||
fatal("ADMIN_PASSWORD is not set")
|
||||
@@ -65,49 +63,67 @@ func fatal(msg string, args ...any) {
|
||||
|
||||
type app struct {
|
||||
cfg config
|
||||
pool *pgxpool.Pool
|
||||
db *sql.DB
|
||||
logins limiter // zero value is ready to use
|
||||
}
|
||||
|
||||
// openDB opens the file with the pragmas the schema assumes. foreign_keys is off by default in
|
||||
// SQLite, so without it every `on delete cascade` is decoration; WAL plus busy_timeout is what lets
|
||||
// a conversion goroutine write while a request reads; _txlock=immediate takes the write lock at
|
||||
// BEGIN rather than failing partway through a transaction that started out reading.
|
||||
//
|
||||
// _time_format and _timezone make Go write timestamps in exactly the shape datetime('now')
|
||||
// produces, so the two sources of a timestamp sort and compare against each other.
|
||||
func openDB(path string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite", "file:"+path+"?"+strings.Join([]string{
|
||||
"_pragma=busy_timeout(5000)",
|
||||
"_pragma=journal_mode(WAL)",
|
||||
"_pragma=foreign_keys(1)",
|
||||
"_pragma=synchronous(NORMAL)",
|
||||
"_time_format=datetime",
|
||||
"_timezone=UTC",
|
||||
"_txlock=immediate",
|
||||
}, "&"))
|
||||
}
|
||||
|
||||
// database/sql splits the row count off into a second return value. Every caller here only asks
|
||||
// whether the statement matched anything, and a driver that could not report a count would already
|
||||
// have failed at Exec.
|
||||
func affected(res sql.Result) int64 {
|
||||
n, _ := res.RowsAffected()
|
||||
return n
|
||||
}
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||
slog.Info("starting", "ctx", "startup", "version", version)
|
||||
cfg := loadConfig()
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, cfg.databaseURL)
|
||||
if err != nil {
|
||||
fatal("database connect", "error", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet.
|
||||
for i := 0; ; i++ {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
err = pool.Ping(pingCtx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if i == 10 {
|
||||
fatal("database unreachable", "error", err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
if err := migrate(ctx, pool); err != nil {
|
||||
fatal("migrations", "error", err)
|
||||
}
|
||||
if err := sweep(ctx, pool); err != nil {
|
||||
fatal("startup sweep", "error", err)
|
||||
}
|
||||
// The storage directories come first: the database file lives in one of them.
|
||||
for _, dir := range []string{"audio", "tmp", "avatars"} {
|
||||
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
|
||||
fatal("storage dir", "error", err, "dir", dir)
|
||||
}
|
||||
}
|
||||
|
||||
a := &app{cfg: cfg, pool: pool}
|
||||
db, err := openDB(cfg.dbPath)
|
||||
if err != nil {
|
||||
fatal("database open", "error", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
fatal("database unreachable", "error", err, "path", cfg.dbPath)
|
||||
}
|
||||
|
||||
if err := migrate(ctx, db); err != nil {
|
||||
fatal("migrations", "error", err)
|
||||
}
|
||||
if err := sweep(ctx, db); err != nil {
|
||||
fatal("startup sweep", "error", err)
|
||||
}
|
||||
|
||||
a := &app{cfg: cfg, db: db}
|
||||
|
||||
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
|
||||
// or the reverse proxy. A separate binary would need its own deploy and would race the
|
||||
@@ -127,7 +143,7 @@ func (a *app) memberMux() *http.ServeMux {
|
||||
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
||||
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.pool.Ping(r.Context()); err != nil {
|
||||
if err := a.db.PingContext(r.Context()); err != nil {
|
||||
http.Error(w, "db down", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
@@ -195,7 +211,7 @@ func (a *app) adminMux() *http.ServeMux {
|
||||
// (close the browser). Add a cookie session if a second admin ever needs one.
|
||||
//
|
||||
// No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the
|
||||
// env file next to the Postgres password already. The constant-time compare is the part that matters.
|
||||
// env file already. The constant-time compare is the part that matters.
|
||||
func (a *app) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
|
||||
Reference in New Issue
Block a user