fix(ws): per-character lock for inventory_delta to prevent FK race

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 00:47:59 +02:00
parent e512c1c296
commit aeddaf9925

24
main.py
View file

@ -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")