Foreign keys apply to every connection, not just the lucky one (#163) #165
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
qwc-open/asiakirjat!165
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/sqlite-pragmas-per-connection"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #163.
The root cause of the production instance that would not start after the 0.9.0 upgrade.
What was wrong
dbis a*sqlx.DB— a pool. The pragma reached whichever connection served it and every connection opened afterwards kept SQLite's default of foreign keys off. Sinceforeign_keysis per-connection, enforcement was a coin toss.ProjectStore.Deleteis a bareDELETE FROM projects WHERE id = ?that relies entirely on cascade. Served by one of those connections, it deleted the project and silently left its versions, upload logs, access rows and API tokens behind. Nothing reads orphan rows, so this was invisible for months — until migration 011 had to rebuildproject_access, and rebuilds do enforce constraints. The INSERT failed, the transaction rolled back, and the database was left marked dirty at 11 with the instance unable to start.The fix
Both pragmas move into the DSN. The driver applies them in its connection-open path (
applyQueryParams,vendor/modernc.org/sqlite/conn.go:74), so they hold for every connection the pool ever creates. A DSN that already sets one keeps the operator's value.Startup now also asserts foreign keys are genuinely on and refuses to run otherwise. That is deliberate: the failure being replaced was silent for months and only surfaced as a broken upgrade months later. A loud startup error is much the better trade.
journal_modeis a property of the database file rather than the connection, so repeating it per connection is a no-op after the first. It travels along to keep both settings in one place.The regression test
TestSQLiteCascadesOnEveryConnectionholds four connections open at once and asserts a cascade actually fires on each — the real behaviour, not the pragma value read back.Confirmed to fail against the old code before being kept. It reproduces production exactly:
Connection 1 — the one that got the pragma — passes. That is the whole bug in three lines of output.
TestSQLiteDSNcovers the DSN rewriting, including leaving an operator's own pragma alone and handing an unparseable query to the driver untouched rather than burying its error message.Why staging did not catch it
Staging took the same upgrade and ran through it without trouble. That is consistent with the diagnosis rather than against it: the failure needs data, not just code. A database only carries these orphans if a project was deleted on one of the connections that happened to have foreign keys off, and staging had no such deletion history — so 011 found nothing to violate the constraint and applied cleanly.
Worth remembering for the next migration that rebuilds a table: a green staging run says nothing about production when the failure depends on accumulated data.
Notes for review
PRAGMA foreign_key_check;reports them. A migration cannot help, because a database stuck dirty at 11 never reaches one.reference/configuration.md.🤖 Written with Claude Code