From 9e64435e663dad036aad73b0d35ff38a376542a4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 8 Oct 2026 00:33:04 +0000 Subject: [PATCH] Queue SQLite writes on one connection so none fails as locked (closes #223) internal/database now opens the database with one connection. pixa's own reads and writes wait for it in turn instead of competing for SQLite's lock, where a write that kept losing could wait past the five-second busy timeout and fail with "database is locked". The busy timeout stays, for another program writing to the same file. With one connection, a query run while rows or a transaction are still open would wait forever. No code in internal/database or internal/imgcache does that; the comment on DB() tells callers. README.md says pixa uses one connection. Model: opus-5-5 --- README.md | 9 +++++---- TODO.md | 7 +++++++ internal/database/database.go | 18 ++++++++++++------ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bfa0451..ff97eb2 100644 --- a/README.md +++ b/README.md @@ -529,10 +529,11 @@ Key settings in more detail: - `signing_key` — HMAC secret for URL signatures - `db_url` — the SQLite database to open; omitted, it is `file:/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the - database in WAL mode. pixa adds `_pragma=busy_timeout(5000)` to any `db_url`, - so a write that finds another in progress waits up to five seconds for it - instead of failing. WAL mode comes only from the URL: keep - `_pragma=journal_mode(WAL)` in one you set + database in WAL mode. pixa opens one connection to it, so its own reads and + writes run one at a time. It adds `_pragma=busy_timeout(5000)` to any + `db_url`, so a write that finds another program writing to the file waits up + to five seconds for it instead of failing. WAL mode comes only from the URL: + keep `_pragma=journal_mode(WAL)` in one you set - `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk cache entirely; omitted defaults to 75% of the sum of the free space on the filesystem containing `/cache/` and the bytes of source and diff --git a/TODO.md b/TODO.md index 3cc0295..50b39fa 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,13 @@ P2: security: per-IP rate limiting on the image routes # Completed Steps +- 2026-10-08 SQLite writes no longer fail with "database is locked" under load + (closes #223): `internal/database` opens the database with one connection, so + pixa's own reads and writes wait for it in turn instead of competing for + SQLite's lock, where a write that kept losing could wait past the five-second + busy timeout and be lost. The busy timeout stays, for another program writing + to the same file. No code in pixa keeps rows or a transaction open while it + runs another query, which with one connection would wait forever. - 2026-10-05 the format `auto` (closes #88): a format in the `/v1/image/` path, an encrypted URL's token and the generator page's format choice, chosen for each request from `Accept` once the signature or token is checked: AVIF when diff --git a/internal/database/database.go b/internal/database/database.go index 88d9840..3ac5108 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -237,17 +237,18 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error { return nil } -// DB returns the underlying sql.DB. +// DB returns the underlying sql.DB. It has one connection, so close any +// rows and end any transaction before running another query on it; a +// query run while they are open waits forever. func (s *Database) DB() *sql.DB { return s.db } func (s *Database) connect(ctx context.Context) error { - // Requests and the eviction pass write on separate connections. With - // a busy timeout, a write that finds another one in progress waits up - // to five seconds for it instead of failing at once with "database is - // locked". The driver runs each _pragma parameter on every connection - // it opens. + // With a busy timeout, a write that finds another program writing to + // the same database file waits up to five seconds for it instead of + // failing at once with "database is locked". The driver runs each + // _pragma parameter on every connection it opens. separator := "?" if strings.Contains(s.config.DBURL, "?") { separator = "&" @@ -264,6 +265,11 @@ func (s *Database) connect(ctx context.Context) error { return err } + // One connection: pixa's own reads and writes wait for it in turn + // instead of competing for SQLite's lock, where a write that keeps + // losing can wait past the busy timeout and be lost. + db.SetMaxOpenConns(1) + err = db.PingContext(ctx) if err != nil { s.log.Error("failed to ping database", "error", err)