perf(sync): resolve RetroArch core aliases in one query (#5192)

* fix(sync): resolve RetroArch paths across emulators sharing a core folder

Several RomM emulator ids map to one RetroArch folder (mednafen_psx_hw,
beetle_psx_hw and the raw "Beetle PSX HW" all sync through
saves/Beetle PSX HW/). The manifest built paths from whichever id an
asset carried, but GET parsed the folder back to the canonical id and
matched it exactly, so an asset filed under another alias was advertised
and then 404'd. Uploads also started a second autosave history beside
the alias's.

The manifest now lists the newest asset per folder, and GET, PUT and
DELETE resolve a path across every alias. Rewrites of an existing
unslotted save or state stay under that row's own emulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): prefer on-disk alias saves and share newest-per-key grouping

pr-ready step 2 (code-review --fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(sync): one alias helper and a lineage computed once

pr-ready step 3 (simplify).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): type the alias fixture's asset kind

pr-ready step 4 (review-polish).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): isolate the asset dir per RetroArch sync test

The alias tests write different bytes to the same asset paths, and
user and ROM ids repeat across xdist worker databases, so parallel
runs raced on those files.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* perf(sync): resolve RetroArch core aliases in one query

The saves handler now filters on a collection of lineages and looks up a file name across several folders, so the RetroArch alias resolvers issue one query each instead of one per alias.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* perf(sync): filter a RetroArch delete to autosave and unslotted saves in SQL

Addresses Greptile's review on #5192: saves_at_path loaded every slot of
the matching lineages and dropped the named slots in Python.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Georges-Antoine Assi
2026-10-08 18:54:27 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 79d1feba6c
commit 57ebac8efe
3 changed files with 184 additions and 57 deletions
+67 -20
View File
@@ -2,7 +2,7 @@ import functools
from collections.abc import Callable, Collection, Mapping, Sequence
from typing import Any, Literal
from sqlalchemy import Select, asc, delete, desc, func, or_, select, update
from sqlalchemy import Select, and_, asc, delete, desc, false, func, or_, select, update
from sqlalchemy.engine import Row
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ColumnElement
@@ -22,10 +22,28 @@ _VERSION_COLUMNS = (Save.user_id, Save.rom_id, Save.slot, Save.content_hash)
_SLOT_MOVE_ATTEMPTS = 3
def _in_lineage(lineage: SaveLineage) -> tuple[ColumnElement[bool], ...]:
return (
Save.emulator.is_not_distinct_from(lineage.emulator),
func.lower(Save.file_extension) == lineage.file_extension,
def _emulator_in(emulators: Collection[str | None]) -> ColumnElement[bool]:
named = sorted({emulator for emulator in emulators if emulator is not None})
clauses = [Save.emulator.in_(named)] if named else []
if None in emulators:
clauses.append(Save.emulator.is_(None))
return or_(false(), *clauses)
def _in_lineages(lineages: Collection[SaveLineage]) -> ColumnElement[bool]:
"""Saves in any of ``lineages``."""
emulators_by_extension: dict[str, set[str | None]] = {}
for emulator, file_extension in lineages:
emulators_by_extension.setdefault(file_extension, set()).add(emulator)
return or_(
false(),
*(
and_(
func.lower(Save.file_extension) == file_extension,
_emulator_in(emulators),
)
for file_extension, emulators in emulators_by_extension.items()
),
)
@@ -111,13 +129,23 @@ class DBSavesHandler(DBBaseHandler):
session: Session = INJECTED_SESSION,
) -> Save | None:
return session.scalars(
select(Save)
.filter_by(
rom_id=rom_id, user_id=user_id, file_path=file_path, file_name=file_name
)
.limit(1)
_at_paths_query(user_id, rom_id, [file_path], file_name).limit(1)
).first()
@begin_session
def get_saves_at_paths(
self,
user_id: int,
rom_id: int,
file_paths: Collection[str],
file_name: str,
session: Session = INJECTED_SESSION,
) -> Sequence[Save]:
"""Every save named ``file_name`` in any of ``file_paths``."""
return session.scalars(
_at_paths_query(user_id, rom_id, file_paths, file_name)
).all()
@begin_session
def get_save_by_content_hash(
self,
@@ -140,14 +168,14 @@ class DBSavesHandler(DBBaseHandler):
user_id: int,
rom_id: int,
slot: str,
lineage: SaveLineage,
lineages: Collection[SaveLineage],
session: Session = INJECTED_SESSION,
) -> Save | None:
"""The newest version of ``lineage`` in ``slot`` whose file is on disk."""
"""The newest version in ``slot`` across ``lineages`` whose file is on disk."""
return session.scalar(
select(Save)
.filter_by(user_id=user_id, rom_id=rom_id, slot=slot, missing_from_fs=False)
.where(*_in_lineage(lineage))
.where(_in_lineages(lineages))
.order_by(desc(Save.updated_at), desc(Save.id))
.limit(1)
)
@@ -191,8 +219,9 @@ class DBSavesHandler(DBBaseHandler):
slot: str | None = None,
slot_not_null: bool = False,
slot_is_null: bool = False,
with_unslotted: bool = False,
file_name_prefix: str | None = None,
lineage: SaveLineage | None = None,
lineages: Collection[SaveLineage] | None = None,
order_by: Literal["updated_at", "created_at"] | None = None,
order_dir: Literal["asc", "desc"] = "desc",
) -> Select[Save]:
@@ -208,7 +237,12 @@ class DBSavesHandler(DBBaseHandler):
)
if slot is not None:
query = query.filter(Save.slot == slot)
# `with_unslotted` widens `slot` to also keep saves outside any slot.
query = query.filter(
or_(Save.slot == slot, Save.slot.is_(None))
if with_unslotted
else Save.slot == slot
)
if slot_not_null:
query = query.filter(Save.slot.is_not(None))
@@ -221,8 +255,8 @@ class DBSavesHandler(DBBaseHandler):
Save.file_name.startswith(file_name_prefix, autoescape=True)
)
if lineage is not None:
query = query.where(*_in_lineage(lineage))
if lineages is not None:
query = query.where(_in_lineages(lineages))
if order_by:
order_col = getattr(Save, order_by)
@@ -241,8 +275,9 @@ class DBSavesHandler(DBBaseHandler):
slot: str | None = None,
slot_not_null: bool = False,
slot_is_null: bool = False,
with_unslotted: bool = False,
file_name_prefix: str | None = None,
lineage: SaveLineage | None = None,
lineages: Collection[SaveLineage] | None = None,
order_by: Literal["updated_at", "created_at"] | None = None,
order_dir: Literal["asc", "desc"] = "desc",
session: Session = INJECTED_SESSION,
@@ -254,8 +289,9 @@ class DBSavesHandler(DBBaseHandler):
slot=slot,
slot_not_null=slot_not_null,
slot_is_null=slot_is_null,
with_unslotted=with_unslotted,
file_name_prefix=file_name_prefix,
lineage=lineage,
lineages=lineages,
order_by=order_by,
order_dir=order_dir,
)
@@ -473,7 +509,7 @@ class DBSavesHandler(DBBaseHandler):
.offset(keep)
)
if lineage is not None:
past_keep = past_keep.where(*_in_lineage(lineage))
past_keep = past_keep.where(_in_lineages([lineage]))
# Before this session holds a connection, since ensuring takes its own.
if not self._any(past_keep):
return []
@@ -599,6 +635,17 @@ class DBSavesHandler(DBBaseHandler):
).all()
def _at_paths_query(
user_id: int, rom_id: int, file_paths: Collection[str], file_name: str
) -> Select[Save]:
return select(Save).where(
Save.user_id == user_id,
Save.rom_id == rom_id,
Save.file_path.in_(file_paths),
Save.file_name == file_name,
)
def _version_query(id: int) -> Select[*tuple[Any, ...]]:
return select(*_VERSION_COLUMNS).where(Save.id == id)
+28 -36
View File
@@ -253,25 +253,20 @@ def advertised_save_name(save: Save) -> str:
return f"{save.rom.fs_name_no_ext}{os.path.splitext(name)[1]}"
def _distinct(saves: Iterable[Save]) -> list[Save]:
"""``saves`` without repeats, which a case-insensitive collation returns per alias."""
return list({save.id: save for save in saves}.values())
def _unslotted_saves_at(
user: User, rom: Rom, emulator: str | None, file_name: str
) -> list[Save]:
"""The unslotted saves at a RetroArch path, under any of ``emulator``'s aliases."""
saves = [
db_save_handler.get_save_by_path(
user_id=user.id,
rom_id=rom.id,
file_path=build_asset_file_path(user, rom, "saves", alias),
file_name=file_name,
)
for alias in retroarch_aliases(emulator)
]
return _distinct([save for save in saves if save and save.slot is None])
saves = db_save_handler.get_saves_at_paths(
user_id=user.id,
rom_id=rom.id,
file_paths=[
build_asset_file_path(user, rom, "saves", alias)
for alias in retroarch_aliases(emulator)
],
file_name=file_name,
)
return [save for save in saves if save.slot is None]
def _unslotted_save_at(
@@ -295,13 +290,12 @@ def _alias_lineages(lineage: SaveLineage) -> list[SaveLineage]:
def _lineage_head(user: User, rom: Rom, lineage: SaveLineage) -> Save | None:
"""The newest `autosave` version across ``lineage``'s aliases."""
heads = [
db_save_handler.get_lineage_head(
user_id=user.id, rom_id=rom.id, slot=AUTOSAVE_SLOT, lineage=alias
)
for alias in _alias_lineages(lineage)
]
return max((head for head in heads if head), key=recency_key, default=None)
return db_save_handler.get_lineage_head(
user_id=user.id,
rom_id=rom.id,
slot=AUTOSAVE_SLOT,
lineages=_alias_lineages(lineage),
)
def resolve_save(
@@ -321,22 +315,20 @@ def saves_at_path(
if not lineage:
return _unslotted_saves_at(user, rom, emulator, requested_file_name)
saves: list[Save] = []
folded = requested_file_name.casefold()
for alias in _alias_lineages(lineage):
saves += db_save_handler.get_saves(
user_id=user.id, rom_ids=[rom.id], slot=AUTOSAVE_SLOT, lineage=alias
return [
save
for save in db_save_handler.get_saves(
user_id=user.id,
rom_ids=[rom.id],
slot=AUTOSAVE_SLOT,
with_unslotted=True,
lineages=_alias_lineages(lineage),
)
# The manifest hides these behind the head, so whatever their case they
# would resurface once the versions go.
saves += [
save
for save in db_save_handler.get_saves(
user_id=user.id, rom_ids=[rom.id], slot_is_null=True, lineage=alias
)
if save.file_name.casefold() == folded
]
return _distinct(saves)
# The manifest hides unslotted ones behind the head, so whatever their
# case they would resurface once the versions go.
if save.slot is not None or save.file_name.casefold() == folded
]
SaveUpload = Literal["created", "updated", "unchanged"]
@@ -422,12 +422,84 @@ class TestDBSavesHandlerSlotFiltering:
user_id=admin_user.id,
rom_id=rom.id,
slot="autosave",
lineage=SaveLineage("snes9x", "srm"),
lineages=[SaveLineage("snes9x", "srm")],
)
assert sorted(save.file_name for save in heads) == ["new.SRM", "other.srm"]
assert head is not None and head.file_name == "new.SRM"
def test_a_lineage_head_is_the_newest_across_lineages(
self, admin_user: User, rom: Rom
):
self._lineage_versions(admin_user, rom)
make_save(
rom,
admin_user,
"unfiled.srm",
emulator=None,
slot="autosave",
updated_at=datetime(2030, 1, 1, tzinfo=UTC),
)
def head(*lineages: SaveLineage) -> str | None:
save = db_save_handler.get_lineage_head(
user_id=admin_user.id,
rom_id=rom.id,
slot="autosave",
lineages=lineages,
)
return save.file_name if save else None
assert head(SaveLineage("snes9x", "srm"), SaveLineage(None, "srm")) == (
"unfiled.srm"
)
assert (
head(SaveLineage("snes9x", "srm"), SaveLineage("bsnes", "srm"))
== "other.srm"
)
assert head(SaveLineage(None, "rtc")) is None
assert head() is None
def test_get_saves_filters_on_a_set_of_lineages(self, admin_user: User, rom: Rom):
self._lineage_versions(admin_user, rom)
make_save(rom, admin_user, "unfiled.srm", emulator=None, slot="autosave")
def names(*lineages: SaveLineage) -> set[str]:
saves = db_save_handler.get_saves(
user_id=admin_user.id, rom_ids=[rom.id], lineages=lineages
)
return {save.file_name for save in saves}
assert names(SaveLineage("bsnes", "srm"), SaveLineage(None, "srm")) == {
"other.srm",
"unfiled.srm",
}
assert names(SaveLineage("snes9x", "rtc"), SaveLineage("bsnes", "srm")) == {
"clock.rtc",
"other.srm",
}
assert names(SaveLineage(None, "srm")) == {"unfiled.srm"}
assert names() == set()
def test_get_saves_at_paths_matches_any_listed_folder(
self, admin_user: User, rom: Rom
):
for file_path in ("saves/snes9x", "saves/bsnes", "saves/other"):
make_save(rom, admin_user, "game.srm", file_path=file_path)
make_save(rom, admin_user, "else.srm", file_path="saves/snes9x")
saves = db_save_handler.get_saves_at_paths(
user_id=admin_user.id,
rom_id=rom.id,
file_paths=["saves/snes9x", "saves/bsnes"],
file_name="game.srm",
)
assert sorted(save.file_path for save in saves) == [
"saves/bsnes",
"saves/snes9x",
]
def test_get_saves_with_null_slot_filter(self, admin_user: User, rom: Rom):
make_save(
rom,
@@ -449,6 +521,22 @@ class TestDBSavesHandlerSlotFiltering:
all_saves = db_save_handler.get_saves(user_id=admin_user.id, rom_ids=[rom.id])
assert len(all_saves) >= 2
def test_get_saves_with_unslotted_widens_the_slot_filter(
self, admin_user: User, rom: Rom
):
make_save(rom, admin_user, "autosave.srm", slot="autosave")
make_save(rom, admin_user, "named.srm", slot="Main")
make_save(rom, admin_user, "unslotted.srm", slot=None)
saves = db_save_handler.get_saves(
user_id=admin_user.id,
rom_ids=[rom.id],
slot="autosave",
with_unslotted=True,
)
assert {save.file_name for save in saves} == {"autosave.srm", "unslotted.srm"}
def test_get_saves_order_by(self, admin_user: User, rom: Rom):
from datetime import datetime, timedelta, timezone