Files
yellowjacket/backend/queue/persistwriter_test.go
T
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

120 lines
3.0 KiB
Go

package queue
import (
"testing"
"time"
)
// mustFinish fails the test if fn has not finished within d.
func mustFinish(t *testing.T, d time.Duration, what string, fn func()) {
t.Helper()
done := make(chan struct{})
go func() {
fn()
close(done)
}()
select {
case <-done:
case <-time.After(d):
t.Fatalf("%s did not finish within %s", what, d)
}
}
// The bug this exists for: SQLite's writer is a single connection, a
// background pass can hold it for seconds, and the queue used to do its
// writes inline while holding q.mu. So starting an album left the
// track changed, the transport at paused and the queue panel empty —
// SetQueue was still waiting on a durability write, and every other
// bound method was waiting on SetQueue.
//
// A stalled writer must cost nothing but durability.
func TestStalledWriterDoesNotBlockTheQueue(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
// Occupy the persistence goroutine the way a held write connection
// does, and keep it occupied for the rest of the test.
release := make(chan struct{})
defer close(release)
q.submitWrite(func() { <-release })
mustFinish(t, 5*time.Second, "SetQueue", func() {
q.SetQueue(paths, 0, false, Source{Type: "album", ID: 1, Label: "Test"})
})
mustFinish(t, 5*time.Second, "GetState", func() {
if got := len(q.GetState().Tracks); got != len(paths) {
t.Errorf("tracks = %d, want %d", got, len(paths))
}
})
mustFinish(t, 5*time.Second, "Play", func() { q.Play() })
mustFinish(t, 5*time.Second, "AddTrack", func() { q.AddTrack(paths[0]) })
mustFinish(t, 5*time.Second, "RemoveTrack", func() { q.RemoveTrack(0) })
}
// Order is the whole reason these run on one goroutine: "clear and
// rewrite the queue" followed by "insert at 4" is not the same thing in
// the other order.
func TestWritesRunInSubmissionOrder(t *testing.T) {
t.Parallel()
q, _ := setupTestQueue(t)
var order []int
for i := range 20 {
q.submitWrite(func() { order = append(order, i) })
}
q.flushWrites()
if len(order) != 20 {
t.Fatalf("ran %d writes, want 20", len(order))
}
for i, got := range order {
if got != i {
t.Fatalf("write %d ran at position %d", got, i)
}
}
}
// Making the writes asynchronous introduces one hazard the inline
// version could not have: a path that reads back what it wrote. There
// are two — RestoreState and CompactAfterLibraryRemoval — and both must
// see the writes that are still in flight, or they rebuild the queue
// from the one before it.
func TestRestoreStateWaitsForPendingWrites(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 4)
// Hold the writer so SetQueue's rows are provably still pending.
release := make(chan struct{})
q.submitWrite(func() { <-release })
q.SetQueue(paths, 0, false, Source{})
go func() {
time.Sleep(50 * time.Millisecond)
close(release)
}()
q.RestoreState()
if got := len(q.GetState().Tracks); got != len(paths) {
t.Errorf("tracks after restore = %d, want %d", got, len(paths))
}
}