Database Performance
Crewship stores everything in a single SQLite file. That is a deliberate choice — no separate database process to run, back up, or upgrade — but it comes with one property that shapes the whole design: SQLite allows exactly one writer at a time, for the entire database. This page is what an operator needs to reason about that: what the ceiling is, where it actually binds, and which knobs exist.The write-lock model
WAL mode (enabled at open) lets readers run alongside the writer, so reads never queue behind a write. Writes do queue — behind each other, across every table. A write tojournal_entries and a write to missions contend
for the same lock.
The connection pool is capped at 5. That does not give five concurrent
writers; it gives roughly four readers plus one writer, which is the shape
WAL is designed for. Writers that arrive while the lock is held wait out
busy_timeout (30 s) rather than failing.
The 30-second
busy_timeout is not arbitrary. It was raised from 5 s after
a background sweeper held the write lock long enough that a concurrent
login lockout check timed out and surfaced to the user as “Invalid email or
password”. See internal/database/database.go.Measured ceilings
Benchmarked against the real schema on a 12-core host, with agents on a realistic duty cycle — think for 300–900 ms, then commit one small transaction:
One ten-step agent assignment costs roughly 20–25 write transactions; a
ten-step routine run costs 30–35. So a hundred agents each finishing an
assignment every minute is on the order of 37 write transactions per
second, against a measured ceiling near 2,900.
Throughput is not the constraint. What degrades first is tail latency,
and the causes are specific: WAL checkpointing (below) and background
sweepers that hold the lock while they work.
WAL checkpointing
Every commit appends frames to the-wal sidecar file. Those frames are
folded back into the main database by a checkpoint. Left alone, SQLite
runs that checkpoint inline, inside whichever write transaction happens to
push the WAL past its threshold — so the cost lands on a random agent.
Crewship’s daemon instead disables SQLite’s inline autocheckpoint and runs
a dedicated checkpointer goroutine (internal/database/checkpoint.go).
Measured at 100 concurrent agents, three runs per policy:
Two results are worth knowing, because both contradict the obvious guess:
- A
PASSIVE-only checkpointer is worse than doing nothing.PASSIVEfolds frames back but never resets the-walfile, so the file grows without bound. OnlyTRUNCATEreturns the space. - The win is not from checkpointing more often. It is from checkpointing on a goroutine that is not serving a request. The work still costs what it costs; it just stops being billed to an agent.
PASSIVE on each tick (3 µs when there is nothing
to do) and escalates to TRUNCATE only once the -wal file exceeds
16 MiB. On shutdown it truncates once more, so a restart never inherits a
large WAL.
Operational notes
- The
-walfile growing steadily while the daemon runs is not normal. It means the checkpointer is not running or cannot get a window. Check the logs forwal checkpoint failed. - A large
-walafter an unclean shutdown is normal. SQLite replays it on the next open. - Only the long-running daemon disables autocheckpoint. Short-lived
commands (
crewship telemetry,crewship admin …) keep SQLite’s built-in behaviour, because they never run the checkpointer. This pairing is enforced by a test — seeTestDaemonPairsManagedWALWithCheckpointer. - Do not copy a live database file by itself. Use
crewship backup, which takes a consistent snapshot, or copy the.db,-waland-shmfiles together.
Background sweepers
Roughly two dozen periodic goroutines write to the database — expiring exposures, timing out approvals, draining queues, running retention. Because they share the single write lock with live agent traffic, the metric that matters for each is how long it holds the lock, not how often it runs. The rule when adding or changing one:Tuning knobs
All of these live ininternal/database/database.go and
internal/database/checkpoint.go, and each carries a comment explaining
the measurement behind its value. Change them only with a measurement of
your own.