From 97ba667151eafc055984930f483e198183a9c9c0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 22 Jul 2026 17:43:32 +0200 Subject: [PATCH] fix(store): acquire reindex writer gate unbounded to stop warmup crash reindexEdgesSetOriented acquired writeMu with a bounded LockContext (sqliteBusyRetryWindow, 15s default). A resolver reindex is a mandatory graph.Store mutation with no error channel, so ReindexEdges routes any failure through panicOnFatal, which does not swallow context.DeadlineExceeded. When a sibling writer (a warmup checkpoint or a deferred-enrich mutation) held writeMu past the window, the gate timed out and crashed the whole daemon over a recoverable lock-wait. Acquire the gate with a plain unbounded Lock, matching the sibling mandatory-write paths (ReindexEdge, setEdgeProvenanceBatchSetOriented, RemoveEdge). Waiting cannot hang: beginWriteContext never re-locks writeMu, Compact's unbounded VACUUM runs before the warmup resolve in the same goroutine, and the lock order is always resolver mutex then writeMu. Per-transaction SQLITE_BUSY contention remains bounded by withSQLiteBusyRetry. --- internal/graph/store_sqlite/reindex_set.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/graph/store_sqlite/reindex_set.go b/internal/graph/store_sqlite/reindex_set.go index 64f5e6a42..2ba685b8e 100644 --- a/internal/graph/store_sqlite/reindex_set.go +++ b/internal/graph/store_sqlite/reindex_set.go @@ -72,12 +72,22 @@ func (s *Store) reindexEdgesSetOriented(batch []graph.EdgeReindex) (sqliteReinde return stats, nil } - gateCtx, cancelGate := context.WithTimeout(context.Background(), s.sqliteBusyRetryWindow()) - gateErr := s.writeMu.LockContext(gateCtx) - cancelGate() - if gateErr != nil { - return stats, fmt.Errorf("store_sqlite: reindex writer gate: %w", gateErr) - } + // A resolver reindex is a mandatory graph mutation: the graph.Store + // interface has no error channel (it mirrors the in-memory store's + // "everything succeeds" contract), so ReindexEdges routes any failure here + // through panicOnFatal. Acquire the writer gate with an UNBOUNDED Lock, + // exactly like the single-edge ReindexEdge and the sibling + // setEdgeProvenanceBatchSetOriented. A bounded LockContext(sqliteBusyRetryWindow) + // abandons the batch when a sibling writer -- a warmup checkpoint, or a + // deferred-enrich mutation -- holds writeMu past the window; that surfaces as + // context.DeadlineExceeded, which is NOT one of panicOnFatal's swallowed + // teardown races, so it crashes the whole daemon over a recoverable + // lock-wait. The gate is only ever held by short sibling writers plus + // context-bounded maintenance (Compact's unbounded VACUUM is a dedicated + // daemon path, never concurrent with warmup resolve), so waiting cannot + // hang. Per-transaction SQLITE_BUSY contention is still bounded by + // withSQLiteBusyRetry below. + s.writeMu.Lock() defer s.writeMu.Unlock() for txStart := 0; txStart < len(batch); txStart += reindexChunkSize {