From 1d335c518073b37dab215189669de06debd8e0bc Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 01:17:54 -0400 Subject: [PATCH] perf(queue): stop a finished track refetching the whole library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recordPlay` emitted `TrackMetadataChanged`, which the frontend correctly reads as "tags were rewritten" and answers by discarding every cached collection: measured at 8 binding calls, 71.18 MB across the IPC and a 765 ms longest task per two track changes at 50 000 tracks — once per song, while clearing the user's selection. It now emits `TrackPlayCountChanged` with everything needed to patch the one track in place, read back with `UPDATE ... RETURNING` so the count cannot drift from the stored one. Measured after: 0 calls, 0 MB, 0 ms. --- backend/queue/playhistory.go | 37 +++++++++-- backend/queue/playhistory_test.go | 104 ++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 backend/queue/playhistory_test.go diff --git a/backend/queue/playhistory.go b/backend/queue/playhistory.go index ba60a37..19985db 100644 --- a/backend/queue/playhistory.go +++ b/backend/queue/playhistory.go @@ -36,14 +36,25 @@ func (q *Queue) recordPlay(audioFileID int64) { return } - // Update denormalized columns on audio_files. - _, err = q.db.ExecContext( + // Update the denormalized columns on audio_files, and read back what + // they became in the same statement: the event below has to carry the + // new values, and a follow-up SELECT could race another play. + // + // RETURNING requires the writer connection — the read pool is a + // separate sql.DB, and this is a write. + var ( + playCount int64 + filePath string + ) + + err = q.db.QueryRowWriter( `UPDATE audio_files SET play_count = play_count + 1, last_played = ? - WHERE id = ?`, + WHERE id = ? + RETURNING play_count, file_path`, now, audioFileID, - ) + ).Scan(&playCount, &filePath) if err != nil { q.logger.Error( "failed to update play count", @@ -57,8 +68,22 @@ func (q *Queue) recordPlay(audioFileID int64) { q.logger.Info( "Play recorded", "audioFileId", audioFileID, + "playCount", playCount, ) - // Notify frontend so the track list refreshes play count. - events.Emit(q.ctx, events.TrackMetadataChanged) + // Deliberately NOT TrackMetadataChanged. That event means "the tags + // on disk were rewritten", which can change an album, an artist or a + // genre, so the frontend answers it by discarding its whole library + // cache and refetching — measured at ~37 MB across the IPC and ~0.8 s + // of blocked main thread, once per finished song, plus clearing + // whatever the user had selected in the track list (perf.C1/C2). + // + // A play count is one integer on one row, and this payload carries + // enough for the frontend to patch it in place. + events.Emit(q.ctx, events.TrackPlayCountChanged, map[string]any{ + "audioFileId": audioFileID, + "filePath": filePath, + "playCount": playCount, + "lastPlayed": now, + }) } diff --git a/backend/queue/playhistory_test.go b/backend/queue/playhistory_test.go new file mode 100644 index 0000000..f245209 --- /dev/null +++ b/backend/queue/playhistory_test.go @@ -0,0 +1,104 @@ +package queue + +import ( + "testing" + + "yellowjacket/backend/events" +) + +// A finished track used to emit TrackMetadataChanged — the event that +// means "the tags on disk were rewritten" — which the frontend answers +// by discarding its entire library cache and refetching tracks, albums, +// artists and genres. Measured on a 50 000-track library that is +// ~37 MB across the IPC and ~0.8 s of blocked main thread per song, and +// it cleared the user's track selection every time (audit perf.C1/C2). +// +// So the assertion that matters is not only that the new event fires: +// it is that the old one *stops*. A change that added +// TrackPlayCountChanged and left TrackMetadataChanged in place would +// look right in the payload and fix nothing. +func TestRecordPlay_EmitsPlayCountNotMetadataChanged(t *testing.T) { + t.Parallel() + + q, db, rec := setupRecordedQueue(t) + paths := seedAudioFiles(t, db, 2) + + q.SetQueue(paths, 0, false) + rec.Reset() + + q.recordPlay(1) + + if n := rec.Count(events.TrackMetadataChanged); n != 0 { + t.Errorf( + "recording a play emitted TrackMetadataChanged %d time(s); "+ + "that event invalidates the whole library cache", + n, + ) + } + + ev, ok := rec.Last(events.TrackPlayCountChanged) + if !ok { + t.Fatalf( + "no TrackPlayCountChanged emitted; got %v", rec.Names(), + ) + } + + payload, ok := ev.Payload().(map[string]any) + if !ok { + t.Fatalf("payload is %T, want map[string]any", ev.Payload()) + } + + // The point of the payload is that it is enough to patch one track + // in place, so a consumer never needs to refetch anything. A + // missing field here means a consumer has to. + for _, key := range []string{ + "audioFileId", "filePath", "playCount", "lastPlayed", + } { + if _, ok := payload[key]; !ok { + t.Errorf("payload is missing %q: %v", key, payload) + } + } + + if got := payload["filePath"]; got != paths[0] { + t.Errorf("filePath: got %v, want %v", got, paths[0]) + } + + if got, want := payload["playCount"], int64(1); got != want { + t.Errorf("playCount: got %v (%T), want %v", got, got, want) + } +} + +// The count comes from the database rather than from a counter the +// event handler keeps, so two plays report 1 then 2 — and a frontend +// that renders the payload cannot drift from the stored value. +func TestRecordPlay_ReportsTheStoredCount(t *testing.T) { + t.Parallel() + + q, db, rec := setupRecordedQueue(t) + paths := seedAudioFiles(t, db, 1) + + q.SetQueue(paths, 0, false) + rec.Reset() + + q.recordPlay(1) + q.recordPlay(1) + + got := make([]any, 0, 2) + + for _, ev := range rec.Named(events.TrackPlayCountChanged) { + payload, ok := ev.Payload().(map[string]any) + if !ok { + t.Fatalf("payload is %T, want map[string]any", ev.Payload()) + } + + got = append(got, payload["playCount"]) + } + + if len(got) != 2 { + t.Fatalf("got %d events, want 2", len(got)) + } + + if got[0] != int64(1) || got[1] != int64(2) { + t.Errorf("play counts: got %v, want [1 2]", got) + } +}