perf(queue): stop a finished track refetching the whole library
`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.
This commit is contained in:
@@ -36,14 +36,25 @@ func (q *Queue) recordPlay(audioFileID int64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update denormalized columns on audio_files.
|
// Update the denormalized columns on audio_files, and read back what
|
||||||
_, err = q.db.ExecContext(
|
// 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
|
`UPDATE audio_files
|
||||||
SET play_count = play_count + 1,
|
SET play_count = play_count + 1,
|
||||||
last_played = ?
|
last_played = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?
|
||||||
|
RETURNING play_count, file_path`,
|
||||||
now, audioFileID,
|
now, audioFileID,
|
||||||
)
|
).Scan(&playCount, &filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.logger.Error(
|
q.logger.Error(
|
||||||
"failed to update play count",
|
"failed to update play count",
|
||||||
@@ -57,8 +68,22 @@ func (q *Queue) recordPlay(audioFileID int64) {
|
|||||||
q.logger.Info(
|
q.logger.Info(
|
||||||
"Play recorded",
|
"Play recorded",
|
||||||
"audioFileId", audioFileID,
|
"audioFileId", audioFileID,
|
||||||
|
"playCount", playCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notify frontend so the track list refreshes play count.
|
// Deliberately NOT TrackMetadataChanged. That event means "the tags
|
||||||
events.Emit(q.ctx, events.TrackMetadataChanged)
|
// 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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user