signal-bot/signal_bot/served.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

57 lines
2.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""What one command cost, split into the bot's own time and everybody else's.
``DOCS-latency.md`` found 72% of a ``!kot`` reply inside the signal-cli
container and most of the rest on Commons -- none of it this repository's
code. So "the bot is slow" needs a number that excludes both, or every
measurement is a measurement of somebody else. ``own_ms`` is that number:
the wall clock from the frame being read to the handler returning, minus
every upstream request and every send. It is what ``/_selftest`` reports
and what ``tool/latency.py`` holds to a budget.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
# The bot's own share of one reply, at the 95th percentile. The whole
# reply is bounded by the container, and is reported, not budgeted.
OWN_BUDGET_MS: Final = 100
@dataclass(frozen=True, slots=True)
class Served:
"""The timings of one command attempt, in milliseconds."""
command: str
outcome: str
# Frame read to a slot free: the queue behind other people's frames.
queue_ms: int
# Slot to handler: parse, scope, tally, resolve, and this person's lock.
dispatch_ms: int
# The handler, wall clock, everything included.
handler_ms: int
# Of which: outbound sends to signal-cli...
send_ms: int
# ...and requests to Commons, the cat API, and the like.
upstream_ms: int
@property
def own_ms(self) -> int:
"""Time spent in this repository's code, and nowhere else."""
return self.queue_ms + self.dispatch_ms + self.handler_ms - self.send_ms - self.upstream_ms
def as_dict(self) -> dict[str, int | str]:
"""The shape ``/_selftest`` answers with."""
return {
"command": self.command,
"outcome": self.outcome,
"queue_ms": self.queue_ms,
"dispatch_ms": self.dispatch_ms,
"handler_ms": self.handler_ms,
"send_ms": self.send_ms,
"upstream_ms": self.upstream_ms,
"own_ms": self.own_ms,
}