fix(state): keep disk I/O out of the intern write lock - #795
Merged
Conversation
The time-series intern cache held its exclusive lock across the allocating SQLite INSERT, and hydrate held it across two full-table scans. Both run from the control tick, and every API reader of the metric catalog, name lists and chart series waits on the same lock. Allocation now writes with no map lock held and takes the lock only to publish the id; hydrate scans into local maps and swaps them in. A second mutex serializes allocators so the row and the cached entry cannot disagree, and so hydrate's swap cannot drop an id allocated meanwhile. ON CONFLICT keeps a concurrent duplicate name on one row. Co-Authored-By: Claude Opus 5 <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.
The hazard
go/internal/state/store_ts.gointerns driver and metric names to integer idsso a sample row stays small. The cache guarded its maps with one
sync.RWMutexand did its disk work while holding that mutex exclusively:
driverIDandmetricIDtook the write lock, then issued the allocatingINSERT(and, for a relabelled unit, anUPDATE) with it still held.hydrateInternheld it across two full-table scans.All three run inside the control tick (
main.go:2753→:3634→RecordTickWithOptionalHistory). Every read-only surface that answers the API —MetricsCatalog,MetricNames,DriverNames,LoadSeries,LoadSeriesBuckets,LatestSample,SamplesBefore— takes the read side ofthat same mutex.
So on a slow SD card, the first sample of a new metric parked every API reader
until SQLite committed. That is the shape of the 2026-07-16 prune incident
(blocking disk work inside something the whole system waits on), expressed as a
lock instead of a channel. The hot path is unaffected — a known name has always
answered under the read lock — but a new driver, a new metric, a driver that
starts reporting a unit, and every process boot all hit it, and a busy SQLite
writer stretches the window well past a tick.
The fix
The standard three-step for an intern cache:
RLock;hydrateInternscans into local maps and swaps them in under the lock.A second mutex,
allocMu, serializes the disk half. It buys two things thelock-free version does not:
same metric's unit at once (without it, DB and cache can end up with
different units, and the map's value survives the restart that reloads the
row's);
scan and the swap.
Readers never take
allocMu, so a stuck write still cannot reach them. Lockorder is documented on the type:
allocMufirst,musecond, never thereverse.
Concurrent allocation of the same new name is handled by the schema:
ts_drivers.nameandts_metrics.nameare bothUNIQUE, soINSERT … ON CONFLICTresolves to the existing row instead of failing the wholesample batch.
metricIDfolds allocate-and-relabel into one upsert whoseCOALESCE(NULLIF(excluded.unit, ''), ts_metrics.unit)keeps an empty unit fromerasing a label already stored — the previous behaviour, in one statement.
Tests
go/internal/state/store_ts_intern_test.go:TestInternAllocationDoesNotBlockReaders— the regression. It makes thedisk write slow the way a loaded Pi does, by holding SQLite's write lock on a
second connection, then requires the four read surfaces to answer while two
allocations are stuck on it. Against the old code
MetricsCatalogtook741 ms (bound: 250 ms); with the fix the reads return in microseconds.
TestInternConcurrentSameNameYieldsOneID— 24 goroutines interning thesame new driver / metric agree on one id and leave one row.
TestInternUnderConcurrentReadersAndWriters— the post-2026-07-16 rulethat DB work is volume-tested with a simultaneous writer: four writers stream
480 overlapping new metric names across six drivers while four readers poll
the intern cache. No errors, no reader parked, every name on exactly one row.
Slowest read went from 639 µs to 123 µs on this box; the point is the bound,
not the number.
make verifyclean.-raceclean for the new tests.Note on an unrelated flake
TestPruneLargeBacklogWithConcurrentWriterfails intermittently under-raceon a loaded machine (1 in 3 runs) — on
masteras well as here. It exerciseshistory_hotonly and never touches the intern path. Not addressed in this PR.🤖 Generated with Claude Code