Skip to content

Commit 4fac604

Browse files
authored
Merge pull request #4062 from Spinnich/fix/gallery-window-skip-total
perf(roms): let a gallery window fetch skip the library count
2 parents 79a8322 + 1e514ec commit 4fac604

6 files changed

Lines changed: 138 additions & 8 deletions

File tree

backend/endpoints/roms/__init__.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from fastapi.responses import Response
2828
from fastapi_pagination import resolve_params
2929
from fastapi_pagination.limit_offset import LimitOffsetPage, LimitOffsetParams
30+
from fastapi_pagination.types import GreaterEqualZero
3031
from pydantic import BaseModel, Field
3132
from sqlalchemy.exc import IntegrityError
3233
from starlette.responses import FileResponse
@@ -324,6 +325,7 @@ class CustomLimitOffsetParams(LimitOffsetParams):
324325

325326

326327
class CustomLimitOffsetPage[T: BaseModel](LimitOffsetPage[T]):
328+
total: GreaterEqualZero | None
327329
char_index: dict[str, int]
328330
rom_id_index: list[int]
329331
filter_values: RomFiltersDict
@@ -348,6 +350,17 @@ def get_roms(
348350
)
349351
),
350352
] = True,
353+
with_total: Annotated[
354+
bool,
355+
Query(
356+
description=(
357+
"Whether to count the full result set. Set to false when the caller"
358+
" already knows the total, e.g. paging through a gallery it has"
359+
" sized; total then comes back null, unless the rom id index is"
360+
" being built and already carries it."
361+
)
362+
),
363+
] = True,
351364
search_term: Annotated[
352365
str | None,
353366
Query(description="Search term to filter roms."),
@@ -831,9 +844,20 @@ def _transform(items: Sequence[Rom]) -> list[SimpleRomSchema]:
831844
for item in items
832845
]
833846

847+
def resolve_total() -> int | None:
848+
if with_rom_id_index:
849+
# The index already spans the result set, so the count is free.
850+
return len(rom_id_index)
851+
# Without the index the count is its own scan of the filtered set,
852+
# so a caller scrolling a gallery it already sized opts out.
853+
return (
854+
db_rom_handler.get_rom_count(query=query, session=session)
855+
if with_total
856+
else None
857+
)
858+
834859
params = resolve_params()
835860
if with_rom_id_index:
836-
total = len(rom_id_index)
837861
page_ids = list(rom_id_index[params.offset : params.offset + params.limit])
838862
if page_ids:
839863
page_rows = session.scalars(query.where(Rom.id.in_(page_ids))).all()
@@ -847,12 +871,11 @@ def _transform(items: Sequence[Rom]) -> list[SimpleRomSchema]:
847871
page_items = list(
848872
session.scalars(query.offset(params.offset).limit(params.limit)).all()
849873
)
850-
total = db_rom_handler.get_rom_count(query=query, session=session)
851874

852875
return CustomLimitOffsetPage.create(
853876
_transform(page_items),
854877
params,
855-
total=total,
878+
total=resolve_total(),
856879
char_index=char_index_dict,
857880
rom_id_index=list(rom_id_index),
858881
filter_values=filter_values,

backend/tests/endpoints/roms/test_rom.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,63 @@ def test_get_roms_without_rom_id_index(
313313
assert items[0]["id"] == rom.id
314314

315315

316+
def test_get_roms_without_total(
317+
client: TestClient, access_token: str, rom: Rom, platform: Platform
318+
):
319+
params = {
320+
"platform_id": platform.id,
321+
"limit": 15,
322+
"with_rom_id_index": False,
323+
}
324+
325+
with patch.object(
326+
db_rom_handler, "get_rom_count", wraps=db_rom_handler.get_rom_count
327+
) as get_rom_count:
328+
response = client.get(
329+
"/api/roms",
330+
headers={"Authorization": f"Bearer {access_token}"},
331+
params={**params, "with_total": False},
332+
)
333+
assert response.status_code == status.HTTP_200_OK
334+
335+
# The point of the opt-out: no second scan of the filtered set.
336+
get_rom_count.assert_not_called()
337+
338+
body = response.json()
339+
assert body["total"] is None
340+
341+
items = body["items"]
342+
assert len(items) == 1
343+
assert items[0]["id"] == rom.id
344+
345+
# Control: the count still runs for callers that ask for it.
346+
response = client.get(
347+
"/api/roms",
348+
headers={"Authorization": f"Bearer {access_token}"},
349+
params=params,
350+
)
351+
assert response.status_code == status.HTTP_200_OK
352+
assert response.json()["total"] == 1
353+
get_rom_count.assert_called_once()
354+
355+
356+
def test_get_roms_keeps_total_from_the_rom_id_index(
357+
client: TestClient, access_token: str, rom: Rom, platform: Platform
358+
):
359+
# The index already carries the count, so opting out of the separate
360+
# count query costs the caller nothing there.
361+
response = client.get(
362+
"/api/roms",
363+
headers={"Authorization": f"Bearer {access_token}"},
364+
params={"platform_id": platform.id, "with_total": False},
365+
)
366+
assert response.status_code == status.HTTP_200_OK
367+
368+
body = response.json()
369+
assert body["total"] == 1
370+
assert body["rom_id_index"] == [rom.id]
371+
372+
316373
def test_get_roms_filter_by_metadata_providers(
317374
client: TestClient, access_token: str, rom: Rom, platform: Platform
318375
):

frontend/src/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_.ts

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/services/api/rom.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,10 @@ export interface GetRomsParams {
183183
playerCountsLogic?: string | null;
184184
metadataProvidersLogic?: string | null;
185185
tagsLogic?: string | null;
186-
// Skip the char index / filter-value / id-index aggregations server-side
187186
withCharIndex?: boolean;
188187
withFilterValues?: boolean;
189188
withRomIdIndex?: boolean;
189+
withTotal?: boolean;
190190
// Cancel an in-flight request
191191
signal?: AbortSignal;
192192
}
@@ -238,6 +238,7 @@ async function getRoms({
238238
withCharIndex = undefined,
239239
withFilterValues = undefined,
240240
withRomIdIndex = undefined,
241+
withTotal = undefined,
241242
signal = undefined,
242243
}: GetRomsParams) {
243244
const params = {
@@ -351,6 +352,7 @@ async function getRoms({
351352
...(withRomIdIndex !== undefined
352353
? { with_rom_id_index: withRomIdIndex }
353354
: {}),
355+
...(withTotal !== undefined ? { with_total: withTotal } : {}),
354356
};
355357

356358
return api.get<GetRomsResponse>(`/roms`, {

frontend/src/v2/stores/galleryRoms.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ describe("galleryRoms windowed fetch", () => {
165165
expect(params.withCharIndex).toBeUndefined();
166166
expect(params.withFilterValues).toBeUndefined();
167167
expect(params.withRomIdIndex).toBeUndefined();
168+
expect(params.withTotal).toBeUndefined();
168169
});
169170

170171
it("does not clobber the filter drawer when filter values are skipped", async () => {
@@ -225,6 +226,48 @@ describe("galleryRoms windowed fetch", () => {
225226
expect(store.charIndex).toEqual({ A: 0, B: 10 });
226227
});
227228

229+
// Skipping the id index used to make the backend fall back to a full COUNT,
230+
// so every scroll batch re-counted the library for a total the page already
231+
// had (issue #4053).
232+
it("skips the total on a window the bootstrap already sized", async () => {
233+
getRoms.mockImplementation((params: { limit?: number }) => {
234+
if (params.limit === 1) {
235+
return Promise.resolve({
236+
data: { total: 500, items: [], char_index: {}, rom_id_index: [] },
237+
});
238+
}
239+
// The backend returns a null total when the count is skipped.
240+
return Promise.resolve({
241+
data: { total: null, items: [], char_index: {}, rom_id_index: [] },
242+
});
243+
});
244+
const store = storeGalleryRoms();
245+
246+
await store.fetchInitialMetadata();
247+
expect(store.total).toBe(500);
248+
249+
store.syncVisibleWindows([72]);
250+
await flushPromises();
251+
252+
const windowCall = getRoms.mock.calls.find((c) => c[0].offset === 72);
253+
expect(windowCall?.[0].withTotal).toBe(false);
254+
// The null total must not blank the size the bootstrap established.
255+
expect(store.total).toBe(500);
256+
});
257+
258+
// The very first window doubles as the bootstrap when nothing has loaded
259+
// yet, so it still has to bring the total back with it.
260+
it("asks for the total on the first window when no bootstrap ran", async () => {
261+
getRoms.mockResolvedValue(windowResponse(0, 300));
262+
const store = storeGalleryRoms();
263+
264+
store.syncVisibleWindows([0]);
265+
await flushPromises();
266+
267+
expect(getRoms.mock.calls[0][0].withTotal).toBeUndefined();
268+
expect(store.total).toBe(300);
269+
});
270+
228271
it("does not mark a window loaded when the context is invalidated mid-apply", async () => {
229272
// Controllable frame yield so we can interleave a context switch between
230273
// the batched-apply's frames.

frontend/src/v2/stores/galleryRoms.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,10 @@ export default defineStore("v2GalleryRoms", {
523523
// object when these are skipped, so re-applying it would wipe the
524524
// populated values and blank the AlphaStrip / filter drawer. The id
525525
// index is a full-library scan we already paid for in the bootstrap, so
526-
// window fetches opt out of recomputing it.
526+
// window fetches opt out of recomputing it. Dropping it makes the backend
527+
// count the result set separately instead, which is the same scan under
528+
// another name for a total the bootstrap already gave us, so opt out of
529+
// that too and keep the window fetch to just its page of covers.
527530
const withAggregations = !this.metadataLoaded;
528531

529532
try {
@@ -535,6 +538,7 @@ export default defineStore("v2GalleryRoms", {
535538
withCharIndex: false,
536539
withFilterValues: false,
537540
withRomIdIndex: false,
541+
withTotal: false,
538542
}),
539543
signal: controller.signal,
540544
});
@@ -547,9 +551,10 @@ export default defineStore("v2GalleryRoms", {
547551

548552
const data = response.data;
549553
// Only apply the full metadata when this window actually fetched the
550-
// aggregations (the very first window before the bootstrap resolved).
554+
// aggregations (a window reached before the bootstrap resolved).
551555
// Otherwise char_index / filter_values come back empty and would
552-
// clobber what the bootstrap populated, so just refresh `total`.
556+
// clobber what the bootstrap populated; `total` comes back null and
557+
// the guard below leaves the established size alone.
553558
if (offset === 0 && withAggregations) {
554559
this._applyMetadata(data, galleryFilter, platformsStore);
555560
} else if (data.total !== null && data.total !== undefined) {

0 commit comments

Comments
 (0)