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. # Held in a set so they aren't garbage-collected mid-flight.
_inventory_delta_tasks: set[asyncio.Task] = set() _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): async def _handle_inventory_delta(data: dict):
"""Forward an inventory_delta to inventory-service and broadcast to browsers. """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 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, the plugin WebSocket receive loop. If the receive loop blocks long enough,
Starlette stops processing keepalives and the connection drops. 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: try:
action = data.get("action") action = data.get("action")
char_name = data.get("character_name", "unknown") char_name = data.get("character_name", "unknown")