fix(playback): submit a durability write, do not perform it
Every write goes through one connection — MaxOpenConns(1), because SQLite has one writer — and a background pass can hold it for a long time. The player and the queue wrote inline from paths that hold their own mutexes, so a contended writer did not merely slow persistence down: SetQueue blocked in LoadFile's saveState and then in persistState, while holding q.mu and p.mu. That is the exact shape of the report: the track changed and the transport sat at paused, nothing appeared in the queue, and the play button did nothing because Queue.Play waited on the same held q.mu. Diagnosed by profiling the running app — 91% of its CPU was BackfillLibraryDiscographies → upsertBatch, with four of its six workers parked in sql.(*DB).conn. Jobs now run in submission order on one goroutine per component, each carrying its own snapshot. A job must not touch the component's fields — it holds no lock and the state has moved on — which is why persistTracks clones. SaveState still flushes and waits, because that is the one caller for which the row has to exist on return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
@@ -181,6 +181,12 @@ type Queue struct {
|
||||
// setQueueGen is incremented each time SetQueue is called. Background
|
||||
// goroutines check this to detect if they have been superseded.
|
||||
setQueueGen atomic.Int64
|
||||
|
||||
// persistCh carries database writes to the single goroutine that
|
||||
// runs them, so no mutation path waits on the writer connection
|
||||
// while holding mu. See persistwriter.go.
|
||||
persistOnce sync.Once
|
||||
persistCh chan func()
|
||||
}
|
||||
|
||||
// NewQueue creates a new queue manager.
|
||||
@@ -1061,6 +1067,13 @@ func (q *Queue) Play() {
|
||||
"Resume requested but player not ready",
|
||||
"err", err,
|
||||
)
|
||||
|
||||
// A play button that does nothing and says nothing is the
|
||||
// fault PlaybackFailed exists for (errors.C1); this was the
|
||||
// one press path still ending at a log line.
|
||||
if q.currentIndex < len(q.tracks) {
|
||||
q.emitPlaybackFailed(q.tracks[q.currentIndex], err)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
@@ -1427,6 +1440,13 @@ func (q *Queue) CompactAfterLibraryRemoval() {
|
||||
// Reload surviving tracks from the database. The CASCADE delete
|
||||
// already removed the rows from queue_tracks — we just need to
|
||||
// reload and reindex.
|
||||
//
|
||||
// This is one of the two places that reads back what it wrote, so
|
||||
// it waits for the pending writes first: a queue built moments ago
|
||||
// may still be in flight, and reloading from rows it has not
|
||||
// reached yet would replace the queue with the previous one.
|
||||
q.flushWrites()
|
||||
|
||||
rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx)
|
||||
if err != nil {
|
||||
q.logger.Error("could not reload queue tracks after library removal", "err", err)
|
||||
|
||||
Reference in New Issue
Block a user