Files
yellowjacket/backend/queue/persistwriter.go
yonluandClaude Opus 5 878cf4b561 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
2026-08-14 13:33:29 -04:00

67 lines
2.4 KiB
Go

package queue
// Every queue write goes through SQLite's single writer connection
// (database.DB sets MaxOpenConns(1)), which a background pass can hold
// for seconds at a time — the discography backfill's per-row FTS
// maintenance is the measured example. Doing these writes inline meant
// SetQueue held q.mu across that wait, so the queue panel, the play
// button and every other bound method blocked behind a durability write
// nobody was waiting for: the track changed, the transport sat at
// paused, and the queue never arrived.
//
// So a write is *submitted*, not performed. Jobs run in submission
// order on one goroutine, each carrying its own snapshot, which is what
// keeps "clear and rewrite the queue" and "insert three tracks at 4"
// meaning the same thing they meant at the moment they were called. A
// job must therefore never touch q's fields — it has no lock and the
// state has moved on.
//
// SaveState is the one caller that still waits: shutdown is exactly the
// case where the write has to have happened before we return.
// persistQueueDepth is how many pending writes are buffered before a
// submission runs inline. These are user-driven mutations, so the
// buffer exists for a stalled writer rather than for throughput.
const persistQueueDepth = 256
// writer returns the persistence goroutine's channel, starting it on
// first use. Lazy because a Queue is constructed in tests that never
// write, and an idle goroutine per instance is a cost with no reader.
func (q *Queue) writer() chan func() {
q.persistOnce.Do(func() {
q.persistCh = make(chan func(), persistQueueDepth)
go func() {
for job := range q.persistCh {
job()
}
}()
})
return q.persistCh
}
// submitWrite queues a database write to run off the caller's lock.
// The caller may hold q.mu; the job may not touch q.
func (q *Queue) submitWrite(job func()) {
select {
case q.writer() <- job:
default:
// The buffer is full, which means the writer connection has
// been held for a long time. Running inline is the old
// behaviour and blocks the caller — but a dropped write is a
// queue that restores wrong, which is worse.
q.logger.Warn("Queue write buffer full, persisting inline")
job()
}
}
// flushWrites blocks until every write submitted so far has run.
func (q *Queue) flushWrites() {
done := make(chan struct{})
q.writer() <- func() { close(done) }
<-done
}