fix: bound SQLite WAL growth and prevent worker hangs (#49)

The worker's SQLite WAL could grow unbounded (observed 19MB) and wedge the
DB, hanging Claude Code on every prompt. No checkpoint ever truncated the
WAL (only PASSIVE auto-checkpoint, which cannot reclaim the file), the
connection-scoped pragmas were set via a single Exec so only one pooled
connection received them (e.g. busy_timeout=0 on the rest), and the
maintenance service that would optimize/checkpoint was never wired up.

- Register a sqlite3 ConnectHook driver so all pragmas (busy_timeout,
  journal_mode, synchronous, cache_size, foreign_keys, journal_size_limit)
  apply to every pooled connection; enable safe connection recycling.
- Add Store.Checkpoint (TRUNCATE), checkpoint-on-Close, and a periodic
  size-gated checkpoint loop with configurable interval/threshold.
- Wire up the previously-dead maintenance service; make trigger_maintenance
  actually run DB maintenance instead of only recalculating scores.
- Harden the user-prompt hook to honor its deadline and fail open so a
  slow worker can never stall a prompt.
- Add regression tests for WAL truncation, checkpoint-on-close, and
  per-connection pragmas.
This commit is contained in:
2026-06-01 16:40:27 +01:00
parent f78370a531
commit b7b82ce22f
10 changed files with 957 additions and 93 deletions
+21 -3
View File
@@ -83,8 +83,20 @@ func (s *Service) Start(ctx context.Context) {
Bool("cleanup_stale", s.config.CleanupStaleObservations).
Msg("Starting maintenance scheduler")
// Initial run after 5 minutes (allow system to stabilize)
time.Sleep(5 * time.Minute)
// Initial run after 5 minutes (allow system to stabilize).
// Use a cancellable timer so shutdown (ctx cancel / Stop) is not blocked for up to 5m.
initialDelay := time.NewTimer(5 * time.Minute)
select {
case <-ctx.Done():
initialDelay.Stop()
s.log.Info().Msg("Maintenance shutting down before initial run (context cancellation)")
return
case <-s.stopCh:
initialDelay.Stop()
s.log.Info().Msg("Maintenance shutting down before initial run (stop signal)")
return
case <-initialDelay.C:
}
s.runMaintenance(ctx)
ticker := time.NewTicker(interval)
@@ -288,7 +300,13 @@ func (s *Service) Stats() map[string]any {
}
}
// RunNow triggers an immediate maintenance run.
// RunNow triggers an immediate maintenance run in the background.
func (s *Service) RunNow(ctx context.Context) {
go s.runMaintenance(ctx)
}
// RunNowSync triggers an immediate maintenance run and blocks until it completes.
// Use this when the caller needs to report a synchronous result (e.g. an HTTP handler).
func (s *Service) RunNowSync(ctx context.Context) {
s.runMaintenance(ctx)
}