diff --git a/main.py b/main.py index 04db6054..8f689f07 100644 --- a/main.py +++ b/main.py @@ -2491,6 +2491,19 @@ _inventory_http_client: httpx.AsyncClient | None = None # Held in a set so they aren't garbage-collected mid-flight. _inventory_delta_tasks: set[asyncio.Task] = set() +# Per-character locks so deltas for the SAME character serialize +# (preventing FK race conditions in inventory-service's DELETE+INSERT path) +# while deltas for DIFFERENT characters still run concurrently. +_inventory_char_locks: Dict[str, asyncio.Lock] = {} + + +def _get_inventory_char_lock(char_name: str) -> asyncio.Lock: + lock = _inventory_char_locks.get(char_name) + if lock is None: + lock = asyncio.Lock() + _inventory_char_locks[char_name] = lock + return lock + async def _handle_inventory_delta(data: dict): """Forward an inventory_delta to inventory-service and broadcast to browsers. @@ -2498,7 +2511,18 @@ async def _handle_inventory_delta(data: dict): Runs as a background task so a slow inventory-service POST never blocks the plugin WebSocket receive loop. If the receive loop blocks long enough, Starlette stops processing keepalives and the connection drops. + + Per-character serialization via _inventory_char_locks: rapid deltas for + the SAME character (e.g. mana burn, ID refresh, loot bursts) processed + one at a time so they don't race in inventory-service's DELETE+INSERT + path (FK violations on item_combat_stats etc). """ + char_name = data.get("character_name", "unknown") + async with _get_inventory_char_lock(char_name): + await _do_handle_inventory_delta(data) + + +async def _do_handle_inventory_delta(data: dict): try: action = data.get("action") char_name = data.get("character_name", "unknown")