fix: stop racing on the WAL switch at worker startup - #10
Merged
Merged
Conversation
Every boot and deploy cost a full 900s aggregation cycle: 14:00:17 aggregate worker started interval=900s 14:00:18 aggregate cycle failed: OperationalError: database is locked One second after start, on every restart, because all four workers open the database in the same instant. The obvious reading is lock contention, but it does not survive the evidence: Python's sqlite3 already waits five seconds by default, and the failure came after one. Something was refusing to wait at all. It was the journal_mode switch. journal_mode is a PERSISTENT property of the database file, so re-issuing it on every open was already pointless — and it is not free: switching journal mode needs an exclusive lock, and SQLite answers SQLITE_BUSY for it immediately rather than honouring the busy handler. No timeout could ever have rescued it. The workers were racing on a no-op. It now reads the current mode and writes only when it differs. The busy timeout is raised to 30s as well. That is defence in depth for the schema/migration block, which every open runs inside a write transaction, and not the fix for this bug. Four tests. The one that guards the real cause traces the statements issued during a second open and asserts the WAL switch is not among them; reverting the conditional fails it. The concurrency test is behavioural only and says so — a 0.4s hold would pass under the old five-second default too, and claiming otherwise would be a test that looks like a guard without being one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
Every boot and deploy cost a full 900s aggregation cycle:
One second after start, every restart, because all four workers open the database in the same instant.
Why the obvious fix was wrong
I started with "raise
busy_timeout" — and the evidence killed it. Python'ssqlite3already waits 5 seconds by default, and the failure came after one. Something was refusing to wait at all.It was the journal-mode switch:
journal_modeis a persistent property of the database file, so re-issuing it every open was already pointless. And it isn't free: switching journal mode needs an exclusive lock, and SQLite answersSQLITE_BUSYfor it immediately rather than honouring the busy handler. No timeout could ever have rescued it.Four workers were racing on a no-op.
The busy timeout is raised to 30s as well — genuine defence in depth for the schema/migration block, which every open runs inside a write transaction. But it is not what fixes this.
Tests
Four, and I checked each against a negative control:
wal_switch_is_skipped_when_already_enabledbusy_timeout_is_raised_above_the_python_defaultwal_is_still_enableda_concurrent_open_waits_instead_of_raisingThat last note matters. It was my first test, it passed before and after the fix, and I nearly shipped it as evidence. A test that looks like a guard without being one is worse than no test.
Why not MySQL
Raised, and worth recording: FinTick is deliberately standard-library-only (
AGENTS.md), so MySQL means a driver dependency and a dependency step at deploy. Splitting engines dev/prod would also break the debugging playbook that found the untickered-instrument bug — pull the prod DB, replay locally. At ~3,900 posts and a 7.6MB file, SQLite is nowhere near its limits; this was four processes colliding in one second, not load.Suite: 150 passed, 13 pre-existing macOS-only
test_service_setupfailures.🤖 Generated with Claude Code