Skip to content

Commit b5d276e

Browse files
Spinnichclaude
andcommitted
fix(collections): refresh smart collections per ROM, not per request
Recomputing every smart collection after a delete scanned the library once per collection, synchronously on the event loop and inside a single write transaction. Ask instead which of the changed ids each collection matches: that resolves as a primary-key lookup, so only the collections actually holding one of them pay for a recount. Follow the same refresh into `update_rom` and the metadata reset. Both change exactly the fields the saved filters match on, so cached counts and cover mosaics no longer wait for the next scan to catch up. Guard every call site. The ROM write is already committed by the time the refresh runs, so a failure there must not be reported back as a failed edit, and must not cost a bulk delete its per-ROM report. Drop the handler-level test for leaving a collection untouched on read; the endpoint test covers that invariant through the route the bug was reported against. Follow-up review of f4ac78c (#4029). Implemented with Claude Code (Opus 5), then reviewed and verified by me. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f4ac78c commit b5d276e

7 files changed

Lines changed: 182 additions & 28 deletions

File tree

backend/endpoints/roms/__init__.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ def safe_int_or_none(value: Any) -> int | None:
119119
return safe_int(value)
120120

121121

122+
def refresh_affected_smart_collections(rom_ids: Sequence[int]) -> None:
123+
"""Follow a library change into the cached smart collection membership.
124+
125+
The ROM write has already been committed, so a stale count is the worst
126+
this can cost, and reporting it back as a failed write would be a lie.
127+
"""
128+
try:
129+
db_collection_handler.refresh_smart_collections_for_roms(rom_ids)
130+
except Exception as e:
131+
log.error(f"Couldn't refresh smart collections for {rom_ids}: {e}")
132+
133+
122134
def build_unscoped_sidecar_cache_key(
123135
user_id: int,
124136
order_by: str,
@@ -1536,6 +1548,7 @@ async def update_rom(
15361548
raise RomNotFoundInDatabaseException(id)
15371549

15381550
db_rom_handler.invalidate_filter_values_cache()
1551+
refresh_affected_smart_collections([id])
15391552
return DetailedRomSchema.from_orm_with_request(rom, request)
15401553

15411554
provided_fields = form_data.model_fields_set
@@ -1915,6 +1928,7 @@ async def update_rom(
19151928
fire_and_forget(meta_playmatch_handler.submit_manual_match_suggestion(rom))
19161929

19171930
db_rom_handler.invalidate_filter_values_cache()
1931+
refresh_affected_smart_collections([id])
19181932
return DetailedRomSchema.from_orm_with_request(rom, request)
19191933

19201934

@@ -1981,7 +1995,7 @@ async def delete_roms(
19811995
perms = get_permissions(request)
19821996
assert_can(perms, PermEntity.ROMS, PermAction.DELETE)
19831997

1984-
successful_items = 0
1998+
deleted_ids: list[int] = []
19851999
failed_ids = []
19862000
errors = []
19872001

@@ -2036,19 +2050,19 @@ async def delete_roms(
20362050
f"Couldn't find resources to delete for {hl(str(rom.name or 'ROM'), color=BLUE)}"
20372051
)
20382052

2039-
successful_items += 1
2053+
deleted_ids.append(id)
20402054
except Exception as e:
20412055
failed_ids.append(id)
20422056
errors.append(f"Failed to delete ROM {id}: {str(e)}")
20432057

2044-
if successful_items:
2058+
if deleted_ids:
20452059
db_rom_handler.invalidate_filter_values_cache()
20462060
# Deleted ROMs would otherwise linger in the cached smart collection
20472061
# membership until the next scan.
2048-
db_collection_handler.refresh_smart_collections()
2062+
refresh_affected_smart_collections(deleted_ids)
20492063

20502064
return {
2051-
"successful_items": successful_items,
2065+
"successful_items": len(deleted_ids),
20522066
"failed_ids": failed_ids,
20532067
"errors": errors,
20542068
}

backend/endpoints/sockets/scan.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -975,8 +975,12 @@ async def stop_scan():
975975
db_rom_handler.invalidate_filter_values_cache()
976976

977977
# Smart collection membership is derived from the library, and is no
978-
# longer recomputed while serving a gallery page.
979-
db_collection_handler.refresh_smart_collections()
978+
# longer recomputed while serving a gallery page. The scan itself is
979+
# done, so a failure here must not report it as one.
980+
try:
981+
db_collection_handler.refresh_smart_collections()
982+
except Exception as e:
983+
log.error(f"Couldn't refresh smart collections after the scan: {e}")
980984

981985
# Export metadata files if enabled in config
982986
config = cm.get_config()

backend/handler/database/collections_handler.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,3 +632,37 @@ def refresh_smart_collections(
632632
self.refresh_smart_collection(id, session=session)
633633

634634
return len(ids)
635+
636+
@begin_session
637+
def refresh_smart_collections_for_roms(
638+
self,
639+
rom_ids: Sequence[int],
640+
session: Session = None, # type: ignore
641+
) -> int:
642+
"""Refresh the collections a handful of edited or deleted ROMs touch.
643+
644+
Editing one ROM rarely moves any collection, and asking whether given
645+
ids match is an indexed lookup, so only the collections that actually
646+
hold one of them pay for a recount. Membership is then recomputed whole,
647+
since a ROM that stayed a member can still have changed the stored order
648+
or the cover mosaic.
649+
"""
650+
from handler.database import db_rom_handler
651+
652+
if not rom_ids:
653+
return 0
654+
655+
candidates = set(rom_ids)
656+
refreshed = 0
657+
for smart_collection in session.scalars(select(SmartCollection)).all():
658+
matching = db_rom_handler.get_smart_collection_matches(
659+
smart_collection=smart_collection,
660+
rom_ids=candidates,
661+
user_id=smart_collection.user_id,
662+
session=session,
663+
)
664+
if matching or candidates & set(smart_collection.rom_ids):
665+
self.refresh_smart_collection(smart_collection.id, session=session)
666+
refreshed += 1
667+
668+
return refreshed

backend/handler/database/roms_handler.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,32 @@ def build_smart_collection_query(
618618
**db_collection_handler.get_smart_collection_criteria(smart_collection),
619619
)
620620

621+
@begin_session
622+
def get_smart_collection_matches(
623+
self,
624+
*,
625+
smart_collection: SmartCollection,
626+
rom_ids: Iterable[int],
627+
user_id: int | None,
628+
session: Session = None, # type: ignore
629+
) -> set[int]:
630+
"""Which of `rom_ids` currently match the collection's criteria.
631+
632+
Restricting the criteria to a few ids keeps this an indexed lookup, so a
633+
caller can ask whether one ROM moved without scanning the library.
634+
"""
635+
query = self._join_rom_user(select(Rom.id), user_id).filter(Rom.id.in_(rom_ids))
636+
return set(
637+
session.scalars(
638+
self.build_smart_collection_query(
639+
query=query,
640+
smart_collection=smart_collection,
641+
user_id=user_id,
642+
session=session,
643+
)
644+
)
645+
)
646+
621647
def _build_fulltext_boolean_query(self, term: str) -> str | None:
622648
words = FULLTEXT_BOOLEAN_OPERATORS_REGEX.sub(" ", term).split()
623649
if not words or any(len(word) < FULLTEXT_MIN_TOKEN_SIZE for word in words):

backend/tests/endpoints/roms/test_rom.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,40 @@ def test_update_rom_adds_region_tag_on_rename(
640640
assert body["regions"] == ["Europe"]
641641

642642

643+
@patch.object(FSRomsHandler, "rename_fs_rom")
644+
@patch.object(IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=None))
645+
def test_update_rom_refreshes_smart_collection_membership(
646+
rename_fs_rom_mock: AsyncMock,
647+
get_rom_by_id_mock: AsyncMock,
648+
client: TestClient,
649+
access_token: str,
650+
admin_user: User,
651+
rom: Rom,
652+
):
653+
# An edit changes what the saved filters match, so the cached counts have
654+
# to follow it rather than wait for the next scan.
655+
smart_collection = db_collection_handler.add_smart_collection(
656+
SmartCollection(
657+
name="European games",
658+
description="",
659+
user_id=admin_user.id,
660+
filter_criteria={"regions": ["Europe"]},
661+
)
662+
)
663+
db_collection_handler.refresh_smart_collection(smart_collection.id)
664+
665+
response = client.put(
666+
f"/api/roms/{rom.id}",
667+
headers={"Authorization": f"Bearer {access_token}"},
668+
data={"fs_name": "test_rom (Europe).zip"},
669+
)
670+
assert response.status_code == status.HTTP_200_OK
671+
672+
refreshed = db_collection_handler.get_smart_collection(smart_collection.id)
673+
assert refreshed is not None
674+
assert refreshed.rom_ids == [rom.id]
675+
676+
643677
# Minimal valid PNG (1x1 transparent pixel)
644678
_PNG_BYTES = (
645679
b"\x89PNG\r\n\x1a\n"
@@ -700,6 +734,28 @@ def test_delete_roms(client: TestClient, access_token: str, rom: Rom):
700734
assert body["successful_items"] == 1
701735

702736

737+
def test_delete_roms_reports_results_when_the_refresh_fails(
738+
client: TestClient, access_token: str, rom: Rom, mocker
739+
):
740+
# The deletes are already committed by this point, so a failure updating
741+
# cached smart collection membership must not cost the caller its report.
742+
mocker.patch.object(
743+
db_collection_handler,
744+
"refresh_smart_collections_for_roms",
745+
side_effect=RuntimeError("refresh exploded"),
746+
)
747+
748+
response = client.post(
749+
"/api/roms/delete",
750+
headers={"Authorization": f"Bearer {access_token}"},
751+
json={"roms": [rom.id], "delete_from_fs": []},
752+
)
753+
754+
assert response.status_code == status.HTTP_200_OK
755+
assert response.json()["successful_items"] == 1
756+
assert db_rom_handler.get_rom(rom.id) is None
757+
758+
703759
def test_delete_roms_reports_failed_ids(
704760
client: TestClient, access_token: str, rom: Rom
705761
):

backend/tests/endpoints/test_collection.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,9 @@ def test_update_recomputes_membership(
700700
def test_reading_a_smart_collection_does_not_touch_it(
701701
self, client, access_token: str, admin_user: User, rom: Rom
702702
):
703+
# Writing membership back while serving a page bumps `updated_at`, which
704+
# is the `?ts=` cover cache-buster, so every visit re-downloaded
705+
# thumbnails and every `updated_after` sync client saw a change.
703706
smart_collection = db_collection_handler.add_smart_collection(
704707
SmartCollection(
705708
name="All roms",
@@ -724,3 +727,5 @@ def test_reading_a_smart_collection_does_not_touch_it(
724727
after = db_collection_handler.get_smart_collection(smart_collection.id)
725728
assert after is not None
726729
assert after.updated_at == before.updated_at
730+
assert list(after.rom_ids) == list(before.rom_ids)
731+
assert after.path_covers_small == before.path_covers_small

backend/tests/handler/database/test_smart_collections.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -88,27 +88,6 @@ def test_filter_roms_by_smart_collection_applies_criteria(
8888
assert {rom.id for rom in roms} == {rom.id for rom in matching}
8989

9090

91-
def test_filter_roms_by_smart_collection_leaves_the_collection_untouched(
92-
platform: Platform, admin_user: User
93-
):
94-
# Writing membership back while serving a page bumps `updated_at`, which is
95-
# the `?ts=` cover cache-buster, so every visit re-downloads thumbnails.
96-
_add_rom(platform, "Rally One", manual_metadata={"genres": ["Racing"]})
97-
smart_collection = _add_smart_collection(admin_user, {"genres": ["Racing"]})
98-
before = db_collection_handler.get_smart_collection(smart_collection.id)
99-
assert before is not None
100-
updated_at, rom_ids = before.updated_at, list(before.rom_ids)
101-
102-
db_rom_handler.get_roms_scalar(
103-
smart_collection_id=smart_collection.id, user_id=admin_user.id
104-
)
105-
106-
after = db_collection_handler.get_smart_collection(smart_collection.id)
107-
assert after is not None
108-
assert after.updated_at == updated_at
109-
assert list(after.rom_ids) == rom_ids
110-
111-
11291
def test_filter_roms_by_smart_collection_narrows_with_the_gallery_filters(
11392
platform: Platform, admin_user: User
11493
):
@@ -295,6 +274,42 @@ def test_refresh_smart_collections_covers_every_collection(
295274
assert refreshed_puzzle is not None and refreshed_puzzle.rom_count == 2
296275

297276

277+
def test_refresh_for_roms_updates_the_collections_the_rom_moved_between(
278+
platform: Platform, admin_user: User
279+
):
280+
racing = _add_smart_collection(admin_user, {"genres": ["Racing"]}, name="Racing")
281+
puzzle = _add_smart_collection(admin_user, {"genres": ["Puzzle"]}, name="Puzzle")
282+
_add_rom(platform, "Rally One", manual_metadata={"genres": ["Racing"]})
283+
mover = _add_rom(platform, "Puzzler", manual_metadata={"genres": ["Puzzle"]})
284+
db_collection_handler.refresh_smart_collections()
285+
286+
db_rom_handler.update_rom(mover.id, {"manual_metadata": {"genres": ["Racing"]}})
287+
refreshed = db_collection_handler.refresh_smart_collections_for_roms([mover.id])
288+
289+
assert refreshed == 2
290+
racing_after = db_collection_handler.get_smart_collection(racing.id)
291+
puzzle_after = db_collection_handler.get_smart_collection(puzzle.id)
292+
assert racing_after is not None and racing_after.rom_count == 2
293+
assert puzzle_after is not None and puzzle_after.rom_count == 0
294+
295+
296+
def test_refresh_for_roms_leaves_untouched_collections_alone(
297+
platform: Platform, admin_user: User, mocker
298+
):
299+
# Recounting every collection for one edited ROM would scan the library
300+
# once per collection, which is the cost this issue is about.
301+
_add_smart_collection(admin_user, {"genres": ["Racing"]}, name="Racing")
302+
_add_rom(platform, "Rally One", manual_metadata={"genres": ["Racing"]})
303+
db_collection_handler.refresh_smart_collections()
304+
bystander = _add_rom(platform, "Puzzler", manual_metadata={"genres": ["Puzzle"]})
305+
306+
refresh = mocker.spy(db_collection_handler, "refresh_smart_collection")
307+
moved = db_collection_handler.refresh_smart_collections_for_roms([bystander.id])
308+
309+
assert moved == 0
310+
assert refresh.call_count == 0
311+
312+
298313
def test_cached_membership_belongs_to_the_owner(
299314
platform: Platform, admin_user: User, editor_user: User
300315
):

0 commit comments

Comments
 (0)