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
120 lines
3.0 KiB
Go
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))
|
|
}
|
|
}
|