Skip to content

Commit 629e1c2

Browse files
authored
Merge pull request #4039 from Spinnich/perf/smart-collection-gallery-query
perf(collections): serve smart and standard collections from a composed query
2 parents dea964f + f4aaa6b commit 629e1c2

14 files changed

Lines changed: 1133 additions & 215 deletions

File tree

backend/endpoints/collections.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
SmartCollectionSchema,
1515
VirtualCollectionSchema,
1616
)
17+
from endpoints.roms import refresh_affected_smart_collections
1718
from exceptions.endpoint_exceptions import (
1819
CollectionAlreadyExistsException,
1920
CollectionNotFoundInDatabaseException,
@@ -201,8 +202,10 @@ async def add_smart_collection(
201202
SmartCollection(**cleaned_data)
202203
)
203204

204-
# Fetch the ROMs to update the database model
205-
smart_collection = created_smart_collection.update_properties(request.user.id)
205+
smart_collection = (
206+
db_collection_handler.refresh_smart_collection(created_smart_collection.id)
207+
or created_smart_collection
208+
)
206209

207210
return SmartCollectionSchema.model_validate(smart_collection)
208211

@@ -557,6 +560,7 @@ async def add_roms_to_collection(
557560
updated_collection = db_collection_handler.add_roms_to_collection(
558561
id, payload.rom_ids
559562
)
563+
refresh_affected_smart_collections(payload.rom_ids, membership_only=True)
560564
return CollectionSchema.model_validate(updated_collection)
561565

562566

@@ -586,6 +590,7 @@ async def remove_roms_from_collection(
586590
updated_collection = db_collection_handler.remove_roms_from_collection(
587591
id, payload.rom_ids
588592
)
593+
refresh_affected_smart_collections(payload.rom_ids, membership_only=True)
589594
return CollectionSchema.model_validate(updated_collection)
590595

591596

@@ -642,8 +647,9 @@ async def update_smart_collection(
642647
id, cleaned_data
643648
)
644649

645-
# Fetch the ROMs to update the database model
646-
smart_collection = updated_smart_collection.update_properties(request.user.id)
650+
smart_collection = (
651+
db_collection_handler.refresh_smart_collection(id) or updated_smart_collection
652+
)
647653

648654
return SmartCollectionSchema.model_validate(smart_collection)
649655

backend/endpoints/roms/__init__.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
assert_rom_visible,
5353
get_permissions,
5454
)
55-
from handler.database import db_rom_handler, db_save_handler
55+
from handler.database import db_collection_handler, db_rom_handler, db_save_handler
5656
from handler.database.base_handler import sync_session
5757
from handler.filesystem import fs_resource_handler, fs_rom_handler
5858
from handler.filesystem.assets_handler import validate_image_upload
@@ -112,13 +112,33 @@
112112
router.include_router(patch_router)
113113

114114

115+
# RomUser fields the statuses filter branches on.
116+
STATUS_MEMBERSHIP_FIELDS = frozenset({"status", "now_playing", "backlogged", "hidden"})
117+
118+
115119
def safe_int_or_none(value: Any) -> int | None:
116120
if value is None or value == "":
117121
return None
118122

119123
return safe_int(value)
120124

121125

126+
def refresh_affected_smart_collections(
127+
rom_ids: Sequence[int], membership_only: bool = False
128+
) -> None:
129+
"""Follow a change into the cached smart collection membership.
130+
131+
The write has already been committed, so a stale count is the worst this
132+
can cost, and reporting it back as a failed write would be a lie.
133+
"""
134+
try:
135+
db_collection_handler.refresh_smart_collections_for_roms(
136+
rom_ids, membership_only=membership_only
137+
)
138+
except Exception as e:
139+
log.error(f"Couldn't refresh smart collections for {rom_ids}: {e}")
140+
141+
122142
def build_unscoped_sidecar_cache_key(
123143
user_id: int,
124144
order_by: str,
@@ -613,8 +633,8 @@ def get_roms(
613633
query = db_rom_handler.filter_roms(
614634
query=unfiltered_query,
615635
user_id=request.user.id,
616-
hidden_platform_ids=perms.hidden_platform_ids,
617-
hidden_rom_ids=perms.hidden_rom_ids,
636+
hidden_platform_ids=perms.hidden_platform_ids, # type: ignore
637+
hidden_rom_ids=perms.hidden_rom_ids, # type: ignore
618638
platform_ids=platform_ids,
619639
collection_id=collection_id,
620640
virtual_collection_id=virtual_collection_id,
@@ -1536,6 +1556,7 @@ async def update_rom(
15361556
raise RomNotFoundInDatabaseException(id)
15371557

15381558
db_rom_handler.invalidate_filter_values_cache()
1559+
refresh_affected_smart_collections([id])
15391560
return DetailedRomSchema.from_orm_with_request(rom, request)
15401561

15411562
provided_fields = form_data.model_fields_set
@@ -1915,6 +1936,7 @@ async def update_rom(
19151936
fire_and_forget(meta_playmatch_handler.submit_manual_match_suggestion(rom))
19161937

19171938
db_rom_handler.invalidate_filter_values_cache()
1939+
refresh_affected_smart_collections([id])
19181940
return DetailedRomSchema.from_orm_with_request(rom, request)
19191941

19201942

@@ -1981,7 +2003,7 @@ async def delete_roms(
19812003
perms = get_permissions(request)
19822004
assert_can(perms, PermEntity.ROMS, PermAction.DELETE)
19832005

1984-
successful_items = 0
2006+
deleted_ids: list[int] = []
19852007
failed_ids = []
19862008
errors = []
19872009

@@ -2036,16 +2058,19 @@ async def delete_roms(
20362058
f"Couldn't find resources to delete for {hl(str(rom.name or 'ROM'), color=BLUE)}"
20372059
)
20382060

2039-
successful_items += 1
2061+
deleted_ids.append(id)
20402062
except Exception as e:
20412063
failed_ids.append(id)
20422064
errors.append(f"Failed to delete ROM {id}: {str(e)}")
20432065

2044-
if successful_items:
2066+
if deleted_ids:
20452067
db_rom_handler.invalidate_filter_values_cache()
2068+
# Deleted ROMs would otherwise linger in the cached smart collection
2069+
# membership until the next scan.
2070+
refresh_affected_smart_collections(deleted_ids)
20462071

20472072
return {
2048-
"successful_items": successful_items,
2073+
"successful_items": len(deleted_ids),
20492074
"failed_ids": failed_ids,
20502075
"errors": errors,
20512076
}
@@ -2098,4 +2123,9 @@ async def update_rom_user(
20982123
if "hidden" in cleaned_data:
20992124
db_rom_handler.invalidate_filter_values_cache()
21002125

2126+
# The statuses filter reads all four of these, and `hidden` also drops the
2127+
# ROM from every user-scoped query, so any of them can move membership.
2128+
if STATUS_MEMBERSHIP_FIELDS & cleaned_data.keys():
2129+
refresh_affected_smart_collections([id], membership_only=True)
2130+
21012131
return RomUserSchema.model_validate(rom_user)

backend/endpoints/saves.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from decorators.auth import protected_route
1212
from endpoints.responses.assets import SaveSchema, SaveSummarySchema, SlotSummarySchema
1313
from endpoints.responses.device import DeviceSyncSchema
14+
from endpoints.roms import refresh_affected_smart_collections
1415
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
1516
from handler.auth.constants import Scope
1617
from handler.auth.dependencies import assert_rom_visible
@@ -391,6 +392,8 @@ async def add_save(
391392
rom_user.id, {"last_played": datetime.now(timezone.utc)}
392393
)
393394

395+
refresh_affected_smart_collections([rom.id], membership_only=True)
396+
394397
return _build_save_schema(db_save, _syncs_for_save(db_save.id, device), device)
395398

396399

@@ -684,6 +687,9 @@ def update_save_visibility(
684687
save.screenshot.id, {"is_public": is_public}
685688
)
686689

690+
# Sharing a save exposes it to every other user's `has_saves` filter.
691+
refresh_affected_smart_collections([save.rom_id], membership_only=True)
692+
687693
return _build_save_schema(updated)
688694

689695

@@ -712,13 +718,16 @@ async def delete_saves(
712718
log.error(error)
713719
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
714720

721+
affected_rom_ids: set[int] = set()
722+
715723
for save_id in saves:
716724
save = db_save_handler.get_save(user_id=request.user.id, id=save_id)
717725
if not save:
718726
error = f"Save with ID {save_id} not found"
719727
log.error(error)
720728
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
721729

730+
affected_rom_ids.add(save.rom_id)
722731
db_save_handler.delete_save(save_id)
723732

724733
log.info(
@@ -741,6 +750,8 @@ async def delete_saves(
741750
error = f"Screenshot file {hl(save.screenshot.file_name)} not found for save {hl(save.file_name)}[{hl(save.rom.platform_slug)}]"
742751
log.error(error)
743752

753+
refresh_affected_smart_collections(list(affected_rom_ids), membership_only=True)
754+
744755
return saves
745756

746757

backend/endpoints/sockets/scan.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@
2626
)
2727
from exceptions.socket_exceptions import ScanStoppedException
2828
from handler.auth.constants import Scope
29-
from handler.database import db_firmware_handler, db_platform_handler, db_rom_handler
29+
from handler.database import (
30+
db_collection_handler,
31+
db_firmware_handler,
32+
db_platform_handler,
33+
db_rom_handler,
34+
)
3035
from handler.filesystem import (
3136
fs_firmware_handler,
3237
fs_platform_handler,
@@ -969,6 +974,14 @@ async def stop_scan():
969974
# The library changed; drop cached filter values.
970975
db_rom_handler.invalidate_filter_values_cache()
971976

977+
# Smart collection membership is derived from the library, and is no
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}")
984+
972985
# Export metadata files if enabled in config
973986
config = cm.get_config()
974987

backend/endpoints/states.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from decorators.auth import protected_route
88
from endpoints.responses.assets import StateSchema
9+
from endpoints.roms import refresh_affected_smart_collections
910
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
1011
from handler.auth.constants import Scope
1112
from handler.auth.dependencies import assert_rom_visible
@@ -169,6 +170,8 @@ async def add_state(
169170
if not rom:
170171
raise RomNotFoundInDatabaseException(rom_id)
171172

173+
refresh_affected_smart_collections([rom.id], membership_only=True)
174+
172175
return StateSchema.model_validate(db_state)
173176

174177

@@ -357,6 +360,9 @@ def update_state_visibility(
357360
state.screenshot.id, {"is_public": is_public}
358361
)
359362

363+
# Sharing a state exposes it to every other user's `has_states` filter.
364+
refresh_affected_smart_collections([state.rom_id], membership_only=True)
365+
360366
return StateSchema.model_validate(updated)
361367

362368

@@ -385,13 +391,16 @@ async def delete_states(
385391
log.error(error)
386392
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
387393

394+
affected_rom_ids: set[int] = set()
395+
388396
for state_id in states:
389397
state = db_state_handler.get_state(user_id=request.user.id, id=state_id)
390398
if not state:
391399
error = f"State with ID {state_id} not found"
392400
log.error(error)
393401
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
394402

403+
affected_rom_ids.add(state.rom_id)
395404
db_state_handler.delete_state(state_id)
396405
log.info(
397406
f"Deleting state {hl(state.file_name)} [{state.rom.platform_slug}] from filesystem"
@@ -414,4 +423,6 @@ async def delete_states(
414423
error = f"Screenshot file {hl(state.screenshot.file_name)} not found for state {hl(state.file_name)}[{hl(state.rom.platform_slug)}]"
415424
log.error(error)
416425

426+
refresh_affected_smart_collections(list(affected_rom_ids), membership_only=True)
427+
417428
return states

0 commit comments

Comments
 (0)