diff --git a/README.md b/README.md index 75c0d1d..f2ee4e2 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,12 @@ Key settings in more detail: waits for an upstream connection and for a processing slot (up to 10 seconds each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds - `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 - `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 8c3c6df..89a78f1 100644 --- a/TODO.md +++ b/TODO.md @@ -31,6 +31,12 @@ P2: security: referer blacklist # Completed Steps +- 2026-10-04 SQLite writes no longer fail with "database is locked" (closes + #198): pixa adds `_pragma=busy_timeout(5000)` to every `db_url`, so a write + that finds another in progress on another connection waits up to five seconds + for it, and the default `db_url` turns on WAL mode with + `_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a + parameter the driver reads, so the database was never in WAL mode. - 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup` only passes through a periodic pass (closes #189): it slept for three eviction intervals before writing its file, and a startup pass still running diff --git a/config.example.yml b/config.example.yml index be42aa4..098d936 100644 --- a/config.example.yml +++ b/config.example.yml @@ -34,9 +34,10 @@ maintenance_mode: false state_dir: ./data # SQLite database URL (default: -# file:/state.sqlite3?_journal_mode=WAL). An empty value aborts -# startup; leave the key out to use the default. -# db_url: "file:./data/state.sqlite3?_journal_mode=WAL" +# file:/state.sqlite3?_pragma=journal_mode(WAL)). pixa adds +# _pragma=busy_timeout(5000) to it. An empty value aborts startup; leave the +# key out to use the default. +# db_url: "file:./data/state.sqlite3?_pragma=journal_mode(WAL)" # Image proxy settings # HMAC signing key for URL signatures (required, at least 32 characters) diff --git a/internal/config/config.go b/internal/config/config.go index a9f7b72..e48a814 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -320,7 +320,10 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) { settingName(keyDBURL), errValueEmpty) } - c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir) + // The driver sets the journal mode only through a _pragma + // parameter. WAL lets reads go on while a write is in progress. + c.DBURL = fmt.Sprintf( + "file:%s/state.sqlite3?_pragma=journal_mode(WAL)", c.StateDir) } if loader.err != nil { diff --git a/internal/database/database.go b/internal/database/database.go index ab59f75..88d9840 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -243,7 +243,17 @@ func (s *Database) DB() *sql.DB { } func (s *Database) connect(ctx context.Context) error { - dbURL := s.config.DBURL + // 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. + separator := "?" + if strings.Contains(s.config.DBURL, "?") { + separator = "&" + } + + dbURL := s.config.DBURL + separator + "_pragma=busy_timeout(5000)" s.log.Info("connecting to database", "url", dbURL)