diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 4c68d5a..c80516c 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -10,9 +10,14 @@ import ( // Open connects to a sqlite3 database with foreign keys enforced func Open(dbFile string) (*sql.DB, error) { - db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", dbFile)) + // busy_timeout avoids immediate SQLITE_BUSY errors under concurrent access. + // foreign_keys ensures FK constraints are enforced for this connection. + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000", dbFile)) if err != nil { return nil, fmt.Errorf("failed to open db: %w", err) } + // WAL improves concurrency between readers and writers. + _, _ = db.Exec("PRAGMA journal_mode=WAL;") + _, _ = db.Exec("PRAGMA busy_timeout=5000;") return db, nil } diff --git a/internal/episodes/worker.go b/internal/episodes/worker.go index bcefda9..e112475 100644 --- a/internal/episodes/worker.go +++ b/internal/episodes/worker.go @@ -15,14 +15,22 @@ func RegisterWorker(lc fx.Lifecycle, svc domain.EpisodeService, metrics *observa ctx, cancel := context.WithCancel(context.Background()) lc.Append(fx.Hook{ - OnStart: func(context.Context) error { + OnStart: func(startCtx context.Context) error { + // Tie worker lifetime to fx lifecycle start context cancellation. + go func() { + <-startCtx.Done() + cancel() + }() go func() { observability.Info("episodes_worker_start", "episodes", "", nil) ticker := time.NewTicker(workerInterval) defer ticker.Stop() for { - if err := svc.RefreshTrackedDue(ctx, 25); err != nil { + tickCtx, tickCancel := context.WithTimeout(ctx, 45*time.Second) + err := svc.RefreshTrackedDue(tickCtx, 25) + tickCancel() + if err != nil { metrics.ObserveWorkerTick("episodes_availability", err) observability.Warn( "episodes_worker_tick_failed",