signal-bot/signal_bot/user_lock.py
Krzysztof kuhy Rudnicki 4af32f8bd5 Handle frames side by side, and budget the bot's own share of a reply
Bot.listen awaited every frame to completion, so one Commons fetch or one
attachment upload held up every text reply behind it (DOCS-latency.md).
Each frame now runs in its own task, never more than LIMIT at once; the
reader waits for a slot before spawning, so a burst applies back-pressure
at the websocket instead of fanning out upstream. One person's commands
stay serialised (user_lock.py): two quick !kot would otherwise both pass
the cooldown gate before either recorded a use.

Every served command reports its timings split into the bot's own time
and everybody else's (served.py); /_selftest carries that block and
tool/latency.py holds own_ms to 100 ms at p95, with the command's
cooldown switched off for the run and put back afterwards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:35:34 +02:00

51 lines
1.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""One person's commands run one at a time; everybody else's interleave.
Handling frames concurrently (``inflight.py``) opens a race the sequential
loop never had: two ``!kot`` from the same person in the same second would
both pass the cooldown gate before either had recorded a use, and both be
served. Serialising per person closes it without serialising the group --
the gate and the write it guards happen under that person's lock, so the
second attempt sees the first one's use and is refused, exactly as before.
Locks are dropped when nobody holds or wants them, so the table cannot grow
with every person who ever typed a command.
"""
from __future__ import annotations
import asyncio
import contextlib
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import AsyncIterator
class UserLocks:
"""A lock per person, created on first use and forgotten when idle."""
def __init__(self) -> None:
"""Start with nobody waiting on anything."""
self._locks: dict[str, asyncio.Lock] = {}
self._waiting: dict[str, int] = {}
@property
def held(self) -> frozenset[str]:
"""Who has a lock right now -- held or queued for. For tests."""
return frozenset(self._locks)
@contextlib.asynccontextmanager
async def holding(self, user: str) -> AsyncIterator[None]:
"""Run the block as the only thing this person has going."""
lock = self._locks.setdefault(user, asyncio.Lock())
self._waiting[user] = self._waiting.get(user, 0) + 1
try:
async with lock:
yield
finally:
self._waiting[user] -= 1
if self._waiting[user] == 0:
del self._waiting[user]
del self._locks[user]