1 Commits
Author SHA1 Message Date
clawbot 9e64435e66 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 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
2026-10-08 00:33:04 +00:00
4 changed files with 13 additions and 14 deletions
+4 -6
View File
@@ -530,12 +530,10 @@ 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. Requests wait while eviction runs one of its writes run one at a time. It adds `_pragma=busy_timeout(5000)` to any
queries, some of which read a whole table. pixa adds `db_url`, so a write that finds another program writing to the file waits up
`_pragma=busy_timeout(5000)` to any `db_url`, so a write that finds another to five seconds for it instead of failing. WAL mode comes only from the URL:
program writing to the file waits up to five seconds for it instead of keep `_pragma=journal_mode(WAL)` in one you set
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 run on it one at a time instead of competing for 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 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,10 +13,11 @@ 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, as one request's writes and // writes to it from several goroutines at once, so the writes run on
// the background eviction pass do. Every write must succeed, none failing // separate connections, as one request's writes and the background eviction
// with "database is locked", whether or not db_url already has parameters, // pass do. Every write must succeed, none failing with "database is locked",
// and the parameters it has must still apply. // whether or not db_url already has parameters, and the parameters it has
// 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 run on it one at a // One connection: pixa's own reads and writes wait for it in turn
// time instead of competing for SQLite's lock, where a write that // instead of competing for SQLite's lock, where a write that keeps
// keeps losing can wait past the busy timeout and be lost. // losing can wait past the busy timeout and be lost.
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
err = db.PingContext(ctx) err = db.PingContext(ctx)