From 47f7c7ee8cf7de5f8dd05673101e8bf501bfc625 Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Sun, 14 Sep 2025 15:00:32 +0300 Subject: [PATCH] rework + add test for concurrent AssetInfo delete --- app/database/services/info.py | 11 +++++----- tests-assets/test_crud.py | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/app/database/services/info.py b/app/database/services/info.py index e3da1bc8e..687431d59 100644 --- a/app/database/services/info.py +++ b/app/database/services/info.py @@ -379,11 +379,12 @@ async def touch_asset_info_by_id( async def delete_asset_info_by_id(session: AsyncSession, *, asset_info_id: str, owner_id: str) -> bool: - res = await session.execute(delete(AssetInfo).where( - AssetInfo.id == asset_info_id, - visible_owner_clause(owner_id), - )) - return bool(res.rowcount) + return ( + await session.execute(delete(AssetInfo).where( + AssetInfo.id == asset_info_id, + visible_owner_clause(owner_id), + ).returning(AssetInfo.id)) + ).scalar_one_or_none() is not None async def add_tags_to_asset_info( diff --git a/tests-assets/test_crud.py b/tests-assets/test_crud.py index 8836cc686..ba7f23f67 100644 --- a/tests-assets/test_crud.py +++ b/tests-assets/test_crud.py @@ -1,3 +1,4 @@ +import asyncio import uuid import aiohttp @@ -177,3 +178,43 @@ async def test_update_requires_at_least_one_field(http: aiohttp.ClientSession, a body = await r.json() assert r.status == 400 assert body["error"]["code"] == "INVALID_BODY" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("root", ["input", "output"]) +async def test_concurrent_delete_same_asset_info_single_204( + root: str, + http: aiohttp.ClientSession, + api_base: str, + asset_factory, + make_asset_bytes, +): + """ + Many concurrent DELETE for the same AssetInfo should result in: + - exactly one 204 No Content (the one that actually deleted) + - all others 404 Not Found (row already gone) + """ + scope = f"conc-del-{uuid.uuid4().hex[:6]}" + name = "to_delete.bin" + data = make_asset_bytes(name, 1536) + + created = await asset_factory(name, [root, "unit-tests", scope], {}, data) + aid = created["id"] + + # Hit the same endpoint N times in parallel. + n_tests = 4 + url = f"{api_base}/api/assets/{aid}?delete_content=false" + tasks = [asyncio.create_task(http.delete(url)) for _ in range(n_tests)] + responses = await asyncio.gather(*tasks) + + statuses = [r.status for r in responses] + # Drain bodies to close connections (optional but avoids warnings). + await asyncio.gather(*[r.read() for r in responses]) + + # Exactly one actual delete, the rest must be 404 + assert statuses.count(204) == 1, f"Expected exactly one 204; got: {statuses}" + assert statuses.count(404) == n_tests - 1, f"Expected {n_tests-1} 404; got: {statuses}" + + # The resource must be gone. + async with http.get(f"{api_base}/api/assets/{aid}") as rg: + assert rg.status == 404