This commit is contained in:
Georges-Antoine Assi
2026-06-25 19:17:26 -04:00
parent e4660aca4c
commit ba6d0ef9db
4 changed files with 13 additions and 33 deletions

View File

@@ -1,11 +1,5 @@
"""Enforce unique (platform_id, fs_name) on roms
A platform folder can't physically hold two entries with the same name, so a
ROM is uniquely identified by (platform_id, fs_name). The index covering this
pair was non-unique, which let two concurrent scans (e.g. the manual scan the
patcher fires after uploading a patched ROM, racing a filesystem-watcher or
scheduled scan) both insert the same ROM, producing duplicate library entries.
This migration removes any pre-existing duplicates (keeping the lowest id) and
upgrades the index to unique so the duplicate can never be created again.
@@ -32,13 +26,7 @@ def upgrade() -> None:
connection = op.get_bind()
# Drop duplicate roms sharing (platform_id, fs_name), keeping the lowest id.
# The nested derived table is required so MySQL/MariaDB don't reject reading
# the same table being deleted from; it's a valid no-op on PostgreSQL too.
# Dependent rows (files, user props, assets, collections, ...) are removed by
# their ON DELETE CASCADE foreign keys.
connection.execute(
sa.text(
"""
connection.execute(sa.text("""
DELETE FROM roms
WHERE id NOT IN (
SELECT keep_id FROM (
@@ -47,9 +35,7 @@ def upgrade() -> None:
GROUP BY platform_id, fs_name
) AS keepers
)
"""
)
)
"""))
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.drop_index(INDEX_NAME, if_exists=True)

View File

@@ -28,6 +28,7 @@ from fastapi.responses import Response
from fastapi_pagination import resolve_params
from fastapi_pagination.limit_offset import LimitOffsetPage, LimitOffsetParams
from pydantic import BaseModel, Field
from sqlalchemy.exc import IntegrityError
from starlette.responses import FileResponse
from config import (
@@ -1484,16 +1485,6 @@ async def update_rom(
# Re-parse tags from the filename so region/language/revision/version/tags
# stay in sync whenever the fs_name changes.
if new_fs_name != rom.fs_name:
# (platform_id, fs_name) is unique, so reject a rename that would collide
# with another ROM on this platform before the DB update would fail.
if db_rom_handler.get_roms_by_fs_name(
platform_id=rom.platform_id, fs_names={new_fs_name}
):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A ROM named '{new_fs_name}' already exists on this platform",
)
parsed_tags = fs_rom_handler.parse_tags(new_fs_name)
cleaned_data.update(
{
@@ -1608,7 +1599,14 @@ async def update_rom(
f"Updating {hl(cleaned_data.get('name', ''), color=BLUE)} [{hl(cleaned_data.get('fs_name', ''))}] with data {cleaned_data}"
)
db_rom_handler.update_rom(id, cleaned_data)
try:
db_rom_handler.update_rom(id, cleaned_data)
except IntegrityError as exc:
log.error(f"Failed to update ROM {id}: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update ROM {id}: {exc}",
) from exc
# Rename the file/folder if the name has changed
should_update_fs = new_fs_name != rom.fs_name

View File

@@ -271,9 +271,7 @@ async def _identify_rom(
)
)
except IntegrityError:
# A concurrent scan already created this ROM ((platform_id, fs_name)
# is unique). That scan owns the full processing of this file, so
# skip it here instead of inserting a duplicate library entry.
# A concurrent scan already created this ROM, so skip it here.
log.debug(
f"Skipping {hl(fs_rom['fs_name'])}: already created by a concurrent scan"
)

View File

@@ -208,9 +208,7 @@ class Rom(BaseModel):
libretro_id: Mapped[str | None] = mapped_column(String(length=64), default=None)
__table_args__ = (
# A platform folder can't hold two entries with the same name on disk,
# so (platform_id, fs_name) is unique. Enforcing it also stops racing
# scans from inserting the same ROM twice.
# Enforce unique fs name per platform to avoid duplicates
Index("idx_roms_platform_id_fs_name", "platform_id", "fs_name", unique=True),
# Covers the sibling_roms view self-join
Index(