From aeddaf992583bbdb308a3dd8566e0e42d4f58770 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 00:47:59 +0200 Subject: [PATCH] fix(ws): per-character lock for inventory_delta to prevent FK race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit moved inventory_delta handling to fire-and-forget asyncio tasks. That removed the WS-loop blockage but introduced a race: when the same character generated multiple deltas in quick succession (mana burn, ID refresh, loot bursts), the tasks ran concurrently and inventory-service's DELETE-then-INSERT path raced on the items table: asyncpg.exceptions.ForeignKeyViolationError: update or delete on table 'items' violates foreign key constraint 'item_combat_stats_item_id_fkey' The 500 errors caused inventory_delta updates to be dropped silently (likely the source of the 'items in wrong container' bug the user reported earlier — every delta returning 500 means the DB never updates). Fix: per-character asyncio.Lock — deltas for the same character serialize, deltas for different characters still run in parallel. Restores correctness without losing the non-blocking-WS-loop benefit. Co-Authored-By: Claude Opus 4.6 (1M context) --- main.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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")