1 Commits
Author SHA1 Message Date
clawbot fc080147af Queue SQLite writes on one connection so none fails as locked (closes #223)
check / check (push) Canceled after 0s
internal/database now opens the database with one connection. pixa's
own reads and writes run on it one at a time 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 and that requests wait while
eviction runs one of its queries.

Model: opus-5-5
2026-10-08 02:00:17 +00:00
4 changed files with 14 additions and 13 deletions
+6 -4
View File
@@ -530,10 +530,12 @@ Key settings in more detail:
- `db_url` — the SQLite database to open; omitted, it is - `db_url` — the SQLite database to open; omitted, it is
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the `file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
database in WAL mode. pixa opens one connection to it, so its own reads and 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 writes run one at a time. Requests wait while eviction runs one of its
`db_url`, so a write that finds another program writing to the file waits up queries, some of which read a whole table. pixa adds
to five seconds for it instead of failing. WAL mode comes only from the URL: `_pragma=busy_timeout(5000)` to any `db_url`, so a write that finds another
keep `_pragma=journal_mode(WAL)` in one you set 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_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 cache entirely; omitted defaults to 75% of the sum of the free space on the
filesystem containing `<state_dir>/cache/` and the bytes of source and filesystem containing `<state_dir>/cache/` and the bytes of source and
+1 -1
View File
@@ -32,7 +32,7 @@ P2: security: per-IP rate limiting on the image routes
- 2026-10-08 SQLite writes no longer fail with "database is locked" under load - 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 (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 pixa's own reads and writes run on it one at a time instead of competing for
SQLite's lock, where a write that kept losing could wait past the five-second 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 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 to the same file. No code in pixa keeps rows or a transaction open while it
@@ -13,11 +13,10 @@ import (
) )
// TestConcurrentWritesAllSucceed opens a database the way pixad does and // TestConcurrentWritesAllSucceed opens a database the way pixad does and
// writes to it from several goroutines at once, so the writes run on // writes to it from several goroutines at once, as one request's writes and
// separate connections, as one request's writes and the background eviction // the background eviction pass do. Every write must succeed, none failing
// pass do. Every write must succeed, none failing with "database is locked", // with "database is locked", whether or not db_url already has parameters,
// whether or not db_url already has parameters, and the parameters it has // and the parameters it has must still apply.
// must still apply.
func TestConcurrentWritesAllSucceed(t *testing.T) { func TestConcurrentWritesAllSucceed(t *testing.T) {
t.Parallel() t.Parallel()
+3 -3
View File
@@ -265,9 +265,9 @@ func (s *Database) connect(ctx context.Context) error {
return err return err
} }
// One connection: pixa's own reads and writes wait for it in turn // One connection: pixa's own reads and writes run on it one at a
// instead of competing for SQLite's lock, where a write that keeps // time instead of competing for SQLite's lock, where a write that
// losing can wait past the busy timeout and be lost. // keeps losing can wait past the busy timeout and be lost.
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
err = db.PingContext(ctx) err = db.PingContext(ctx)