perf(library): send the track list as a dictionary-encoded column table
GetTracks answered with one JSON object per track: 20.5 MB at 26k tracks, ~350 bytes a row of repeated key names, four cover URLs that are identical across an album, and the album, artist and genre strings repeated on every track. Encoding it was ~170 MB of transient Go allocation, and parsing it the WebView's memory peak. GetTrackTable replaces it: one array per column, every repeated string stored once and sent as an index, genre lists interned, and only the fields the Tracks view reads. LastPlayed and the three larger cover tiers are left to the details dialog, which now fetches whole tracks by path. Each row still goes through trackFromRow, so this is an encoding of the one projection, not a second one. 167 bytes a track against 929 in the test library; a Go test holds it under a quarter of the object encoding, and a Vitest test reads the Go columns and the generated Track interface and fails if they drift. An empty library is now an empty table rather than the "no tracks in library" error the old binding returned. Closes #281
This commit is contained in:
1 parent
4a9fe1d207
commit
7a1412383f
42 files changed
+1011
-275
No files matched your search
@@ -19,10 +19,11 @@ import {
|
||||
lastArgs,
|
||||
resetHarness,
|
||||
} from '@test/support/harness';
|
||||
import { trackTable } from '@test/support/track-table';
|
||||
|
||||
const TRACKS = [
|
||||
{ ID: 1, Title: 'One', FilePath: '/a.mp3', PlayCount: 0, LastPlayed: '' },
|
||||
{ ID: 2, Title: 'Two', FilePath: '/b.mp3', PlayCount: 4, LastPlayed: 'x' },
|
||||
{ TrackName: 'One', FilePath: '/a.mp3', PlayCount: 0 },
|
||||
{ TrackName: 'Two', FilePath: '/b.mp3', PlayCount: 4 },
|
||||
];
|
||||
const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }];
|
||||
const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }];
|
||||
@@ -33,15 +34,10 @@ const LIBRARIES = [{ id: 7, name: 'Music' }, { id: 8, name: 'Field' }];
|
||||
/** Stub every read binding the store can reach. Unstubbed bindings
|
||||
* resolve undefined, which the store would cache as if it were data. */
|
||||
function stubReads(): void {
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
stub('library.Library.GetArtists', ARTISTS);
|
||||
stub('library.Library.GetGenres', GENRES);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
stub('library.Library.GetArtists', ARTISTS);
|
||||
stub('library.Library.GetGenres', GENRES);
|
||||
stub('library.Library.GetAlbumsByArtist', ALBUMS);
|
||||
stub('library.Library.GetAlbumsByArtist', ALBUMS);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES);
|
||||
}
|
||||
@@ -69,7 +65,7 @@ describe('library store: caching', () => {
|
||||
it('serves a second read from cache without touching the backend', async () => {
|
||||
await libraryStore.getTracks();
|
||||
|
||||
expect(calls('library.Library.GetTracks')).toHaveLength(0);
|
||||
expect(calls('library.Library.GetTrackTable')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent first reads into one backend call', async () => {
|
||||
@@ -88,11 +84,11 @@ describe('library store: caching', () => {
|
||||
|
||||
it('exposes cached collections synchronously once loaded', () => {
|
||||
expect([
|
||||
libraryStore.getCachedTracks(),
|
||||
libraryStore.getCachedTracks()?.map((t) => t.FilePath),
|
||||
libraryStore.getCachedAlbums(),
|
||||
libraryStore.cachedArtists,
|
||||
libraryStore.getCachedGenres(),
|
||||
]).toEqual([TRACKS, ALBUMS, ARTISTS, GENRES]);
|
||||
]).toEqual([['/a.mp3', '/b.mp3'], ALBUMS, ARTISTS, GENRES]);
|
||||
});
|
||||
|
||||
it('refetches everything when a scan completes', async () => {
|
||||
@@ -103,7 +99,7 @@ describe('library store: caching', () => {
|
||||
'library.Library.GetAlbums',
|
||||
'library.Library.GetArtists',
|
||||
'library.Library.GetGenres',
|
||||
'library.Library.GetTracks',
|
||||
'library.Library.GetTrackTable',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -111,7 +107,7 @@ describe('library store: caching', () => {
|
||||
emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' });
|
||||
await flush();
|
||||
|
||||
expect(calls('library.Library.GetTracks')).toHaveLength(1);
|
||||
expect(calls('library.Library.GetTrackTable')).toHaveLength(1);
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -140,10 +136,11 @@ describe('library store: caching', () => {
|
||||
it('patches the one track it names', () => {
|
||||
const tracks = libraryStore.getCachedTracks();
|
||||
|
||||
// LastPlayed is not on the list's rows (#281); trackCache
|
||||
// patches it on whole tracks.
|
||||
expect(tracks?.[0]).toMatchObject({
|
||||
FilePath: '/a.mp3',
|
||||
PlayCount: 9,
|
||||
LastPlayed: '2026-08-11 10:00:00',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,7 +189,7 @@ describe('library store: caching', () => {
|
||||
});
|
||||
|
||||
it('does not refetch the tracks', () => {
|
||||
expect(calls('library.Library.GetTracks')).toHaveLength(0);
|
||||
expect(calls('library.Library.GetTrackTable')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('splices the removed track out in place', () => {
|
||||
@@ -258,7 +255,7 @@ describe('library store: library filter', () => {
|
||||
libraryStore.setSelectedLibrary(7);
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('library.Library.GetTracks')).toEqual([7]);
|
||||
expect(lastArgs('library.Library.GetTrackTable')).toEqual([7]);
|
||||
});
|
||||
|
||||
it('ignores a redundant selection instead of invalidating', async () => {
|
||||
@@ -304,12 +301,12 @@ describe('library store: a fetch that is overtaken', () => {
|
||||
|
||||
it('serves the library that is selected, not the one that was in flight', async () => {
|
||||
const pending: Array<{ id: number; resolve: (v: unknown) => void }> = [];
|
||||
const byLibrary = (id: number) => [{ ID: id, Title: `Library ${id}` }];
|
||||
const byLibrary = (id: number) => trackTable([{ FilePath: `/lib-${id}.mp3` }]);
|
||||
|
||||
// Only the track fetch is held open; the other three settle at once,
|
||||
// so the test is about the overtaking and nothing else.
|
||||
stub(
|
||||
'library.Library.GetTracks',
|
||||
'library.Library.GetTrackTable',
|
||||
(id: number) =>
|
||||
new Promise((resolve) => {
|
||||
pending.push({ id, resolve });
|
||||
@@ -327,11 +324,13 @@ describe('library store: a fetch that is overtaken', () => {
|
||||
pending.find((p) => p.id === 8)?.resolve(byLibrary(8));
|
||||
await flush();
|
||||
|
||||
expect(libraryStore.getCachedTracks()).toEqual(byLibrary(8));
|
||||
expect(libraryStore.getCachedTracks()?.map((t) => t.FilePath)).toEqual([
|
||||
'/lib-8.mp3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('settles the waiters when the fetch they are waiting on fails', async () => {
|
||||
stubFailure('library.Library.GetTracks', 'sql: database is locked');
|
||||
stubFailure('library.Library.GetTrackTable', 'sql: database is locked');
|
||||
// Invalidation drops the cache and starts the fetch that fails.
|
||||
emit(Events.LibraryScanComplete);
|
||||
|
||||
|
||||
Reference in new issue
Block a user