fix: prevent search index build from starving library scan for DB access

The search index build and library scan both write to the same
single-connection SQLite DB. The index build runs continuous batch
transactions that can starve the scan's clearLibraryTables call,
causing the scan to silently hang without logging.

Fix: decouple index build from SetContext. The build now starts
AFTER the soft scan completes on startup. For full rescans, the
PreClear hook stops the index build, and PostScan restarts it.

Also made StartBuild/StopBuild safe for multiple calls:
- StartBuild is a no-op if already running
- StopBuild is a no-op if not running (no deadlock on done channel)
- done channel created per-build, not in constructor
This commit is contained in:
2026-03-26 15:21:01 -04:00
parent 71516df651
commit bb015092d4
3 changed files with 60 additions and 8 deletions
+15 -2
View File
@@ -198,8 +198,17 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// orchestrate queue clearing and playlist restoration
// without depending on those packages directly.
yj.library.SetRescanHooks(library.RescanHooks{
PreClear: yj.queue.Clear,
PostScan: yj.playlist.RestoreAllPlaylists,
PreClear: func() {
yj.queue.Clear()
// Stop the search index build so it doesn't fight
// with the rescan for DB access.
yj.explore.StopIndexBuild()
},
PostScan: func() {
yj.playlist.RestoreAllPlaylists()
// Restart the index build now that the scan is done.
yj.explore.StartIndexBuild()
},
})
// Wire scan hooks so the playlist service can resolve
@@ -319,5 +328,9 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
if err := yj.library.SoftScanAllLibraries(); err != nil {
yj.logger.Error("soft scan failed", "err", err)
}
// Start the explore search index build AFTER the library
// scan completes so they don't fight for DB access.
yj.explore.StartIndexBuild()
}()
}
+13 -1
View File
@@ -63,7 +63,19 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
// OnStartup after the Wails runtime is initialised.
func (e *Service) SetContext(ctx context.Context) {
e.ctx = ctx
e.index.StartBuild(ctx)
}
// StartIndexBuild kicks off the background search index build.
// Call this after the library scan completes so the indexer doesn't
// starve the scan for DB access.
func (e *Service) StartIndexBuild() {
e.index.StartBuild(e.ctx)
}
// StopIndexBuild cancels the background search index build.
// Call before a full rescan to free the DB for the scan.
func (e *Service) StopIndexBuild() {
e.index.StopBuild()
}
// ---------------------------------------------------------------------------
+32 -5
View File
@@ -128,30 +128,57 @@ func NewSearchIndex(
lb: lb,
artistImg: artistImg,
logger: logger,
done: make(chan struct{}),
}
}
// StartBuild launches the background index build goroutine.
// Returns immediately.
func (si *SearchIndex) StartBuild(ctx context.Context) {
si.mu.Lock()
// Don't start if already running.
if si.cancel != nil {
si.mu.Unlock()
return
}
si.done = make(chan struct{})
si.mu.Unlock()
buildCtx, cancel := context.WithCancel(ctx)
si.mu.Lock()
si.cancel = cancel
si.mu.Unlock()
go func() {
defer close(si.done)
defer func() {
si.mu.Lock()
si.cancel = nil
si.mu.Unlock()
close(si.done)
}()
si.build(buildCtx)
}()
}
// StopBuild cancels an in-flight build and waits for it to finish.
// Safe to call even if no build is running.
func (si *SearchIndex) StopBuild() {
if si.cancel != nil {
si.cancel()
si.mu.RLock()
cancel := si.cancel
done := si.done
si.mu.RUnlock()
if cancel != nil {
cancel()
}
<-si.done
if done != nil {
<-done
}
}
// IsReady returns true once the index has been built at least once.