diff --git a/CHANGELOG.md b/CHANGELOG.md index e05e6bf..3730b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ easy to spot when bumping. ## [Unreleased] +## [8.4.3] - 2026-10-02 + +### Fixed + +- **genre-tree**: `useListFullGenrePlaylists` fetches the remaining pages at most 4 at a time instead of all in parallel. The canonical tree's 17 concurrent requests OOM-killed grow-front's proxy container and returned 502s. + ## [8.4.2] - 2026-10-02 ### Fixed diff --git a/packages/app-kit/package.json b/packages/app-kit/package.json index d295b40..199c5ae 100644 --- a/packages/app-kit/package.json +++ b/packages/app-kit/package.json @@ -1,6 +1,6 @@ { "name": "@behindthemusictree/app-kit", - "version": "8.4.2", + "version": "8.4.3", "description": "Shared transport, auth, popup, UI, player, and genre-tree plumbing for BehindTheMusicTree React apps", "repository": { "type": "git", diff --git a/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts b/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts index 6c7fa60..bfe267b 100644 --- a/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts +++ b/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts @@ -185,6 +185,28 @@ describe("useGenrePlaylist", () => { expect(result.overallTotal).toBe(250); }); + it("keeps at most 4 page requests in flight and preserves page order", async () => { + let inFlight = 0; + let maxInFlight = 0; + fetchMock.mockImplementation(async (_url, _a, _b, _c, { page }: { page: number }) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, (18 - page) % 5)); + inFlight--; + return { overallTotal: 18, next: null, previous: null, results: [{ uuid: `gp${page}` }], page, pageSize: 1, totalPages: 18 }; + }); + renderHook(() => useListFullGenrePlaylists("reference", getBackendBaseUrl)); + const { queryFn } = useQueryWithParseMock.mock.calls[0][0]; + + const result = await queryFn(); + + expect(fetchMock).toHaveBeenCalledTimes(18); + expect(maxInFlight).toBe(4); + expect(result.results.map((gp: { uuid: string }) => gp.uuid)).toEqual( + Array.from({ length: 18 }, (_, i) => `gp${i + 1}`), + ); + }); + it("returns empty results with a single request when totalPages is 0", async () => { fetchMock.mockResolvedValueOnce({ overallTotal: 0, diff --git a/packages/app-kit/src/genre-tree/useGenrePlaylist.ts b/packages/app-kit/src/genre-tree/useGenrePlaylist.ts index d22cd69..33c0313 100644 --- a/packages/app-kit/src/genre-tree/useGenrePlaylist.ts +++ b/packages/app-kit/src/genre-tree/useGenrePlaylist.ts @@ -14,6 +14,8 @@ import { Scope } from "../transport/lib/scope"; // grow-api clamps pageSize to PAGINATION_PAGE_SIZE_MAX (100); asking for more only hides the real page count. const FULL_LIST_PAGE_SIZE = 100; +// An unbounded fan-out (17 parallel pages through grow-front's proxy) OOM-killed its 96m container. +const FULL_LIST_CONCURRENCY = 4; type RawPaginatedResponse = { overallTotal: number; @@ -28,9 +30,8 @@ type RawPaginatedResponse = { /** * Backends may clamp `pageSize` below what's requested (e.g. a server-side max page size), so a * single request can silently return fewer results than `overallTotal`. Reads `totalPages` from the - * first page, then fetches the rest in parallel so load time doesn't scale with the page count. + * first page, then fetches the rest with at most FULL_LIST_CONCURRENCY requests in flight. */ -// ponytail: unbounded Promise.all; browsers cap per-host connections, add a limiter if pages reach the hundreds. const fetchAllPages = async (fetchPage: (page: number) => Promise): Promise => { // fetch-wrapper resolves null (instead of throwing) when a handleError callback swallowed the failure. const fetchRequiredPage = async (page: number) => { @@ -39,8 +40,16 @@ const fetchAllPages = async (fetchPage: (page: number) => Promise): Pro return response as RawPaginatedResponse; }; const first = await fetchRequiredPage(1); - const rest = await Promise.all( - Array.from({ length: Math.max(first.totalPages - 1, 0) }, (_, i) => fetchRequiredPage(i + 2)), + const remaining = Math.max(first.totalPages - 1, 0); + const rest: RawPaginatedResponse[] = new Array(remaining); + let nextIndex = 0; + await Promise.all( + Array.from({ length: Math.min(FULL_LIST_CONCURRENCY, remaining) }, async () => { + while (nextIndex < remaining) { + const index = nextIndex++; + rest[index] = await fetchRequiredPage(index + 2); + } + }), ); const results = [first, ...rest].flatMap((response) => response.results); const last = rest[rest.length - 1] ?? first;