mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
151 lines
5.6 KiB
Python
151 lines
5.6 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Hold the bot's own share of a reply to its budget, against the live bot.
|
|
|
|
``DOCS-latency.md`` measured a reply from the outside and found the bot's
|
|
code was a rounding error next to the container and the upstream fetch. That
|
|
is a claim about one measurement of one command; this is the check that keeps
|
|
it true. It types one text command at the bot through ``/_selftest`` many
|
|
times over, reads the ``served`` timings each answer carries (``served.py``),
|
|
and fails if the 95th percentile of ``own_ms`` -- everything that is not a
|
|
send or an upstream request -- is over ``OWN_BUDGET_MS``.
|
|
|
|
The self-test is always the same person, so the second attempt would be
|
|
refused by the cooldown. The command's cooldown is overridden to zero for the
|
|
run through the same switch the management page uses, and the row is put
|
|
back exactly as it was, refusal or crash included.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
import httpx
|
|
|
|
from signal_bot.served import OWN_BUDGET_MS
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
MS_PER_SECOND: Final = 1000
|
|
# What the report prints and what the budget is held against, in that order.
|
|
COLUMNS: Final = ("own_ms", "queue_ms", "dispatch_ms", "handler_ms", "send_ms", "round_trip_ms")
|
|
PERCENTILES: Final = (50, 95)
|
|
|
|
|
|
class LatencyError(RuntimeError):
|
|
"""The run could not measure what it was asked to."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Sample:
|
|
"""One reply's timings plus how long the whole self-test call took."""
|
|
|
|
served: dict[str, Any]
|
|
round_trip_ms: int
|
|
|
|
def __getitem__(self, column: str) -> int:
|
|
"""One column of the report, whichever side of the wire it was measured on."""
|
|
if column == "round_trip_ms":
|
|
return self.round_trip_ms
|
|
return int(self.served[column])
|
|
|
|
|
|
def percentile(values: Sequence[int], p: int) -> int:
|
|
"""Nearest-rank percentile: the value at least ``p``% of the samples sit at or under."""
|
|
ordered = sorted(values)
|
|
rank = max(1, math.ceil(p / 100 * len(ordered)))
|
|
return ordered[rank - 1]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Report:
|
|
"""Every sample of one run, and the verdict."""
|
|
|
|
command: str
|
|
samples: tuple[Sample, ...]
|
|
budget_ms: int = OWN_BUDGET_MS
|
|
|
|
def at(self, column: str, p: int) -> int:
|
|
"""One percentile of one column."""
|
|
return percentile([sample[column] for sample in self.samples], p)
|
|
|
|
@property
|
|
def within_budget(self) -> bool:
|
|
"""Whether the bot's own p95 is at or under the budget."""
|
|
return self.at("own_ms", 95) <= self.budget_ms
|
|
|
|
def render(self) -> str:
|
|
"""A table a human reads once and a CI log keeps."""
|
|
lines = [f"{self.command}: {len(self.samples)} replies"]
|
|
lines.extend(
|
|
f" {column:<14}" + " ".join(f"p{p}={self.at(column, p):>6} ms" for p in PERCENTILES)
|
|
for column in COLUMNS
|
|
)
|
|
verdict = "within" if self.within_budget else "OVER"
|
|
lines.append(f" own_ms p95 {verdict} the {self.budget_ms} ms budget")
|
|
return "\n".join(lines)
|
|
|
|
|
|
async def _selftest(client: httpx.AsyncClient, base: str, group: str, text: str) -> Sample:
|
|
started = time.perf_counter()
|
|
response = await client.post(f"{base}/_selftest", json={"group": group, "text": text})
|
|
round_trip_ms = round((time.perf_counter() - started) * MS_PER_SECOND)
|
|
body = response.json()
|
|
if response.status_code != httpx.codes.OK or body.get("status") != "delivered":
|
|
message = f"self-test refused: {body.get('reason', response.text)}"
|
|
raise LatencyError(message)
|
|
if "served" not in body:
|
|
message = f"{text!r} is not a command the bot ran; nothing to time"
|
|
raise LatencyError(message)
|
|
return Sample(served=body["served"], round_trip_ms=round_trip_ms)
|
|
|
|
|
|
async def _setting(client: httpx.AsyncClient, base: str, name: str) -> dict[str, Any]:
|
|
"""The command's switch row as it stands, so it can be put back."""
|
|
response = await client.get(f"{base}/_commands")
|
|
response.raise_for_status()
|
|
for row in response.json()["commands"]:
|
|
if row["name"] == name:
|
|
return {
|
|
"enabled": row["enabled"],
|
|
"disabled_until": row["disabled_until"],
|
|
"cooldown_seconds": row["cooldown_seconds"],
|
|
}
|
|
message = f"the bot ran {name!r} but does not list it"
|
|
raise LatencyError(message)
|
|
|
|
|
|
async def _put_setting(
|
|
client: httpx.AsyncClient, base: str, name: str, setting: dict[str, Any]
|
|
) -> None:
|
|
response = await client.put(f"{base}/_commands/{name}", json=setting)
|
|
response.raise_for_status()
|
|
|
|
|
|
async def measure(
|
|
client: httpx.AsyncClient,
|
|
base: str,
|
|
group: str,
|
|
text: str,
|
|
times: int,
|
|
budget_ms: int = OWN_BUDGET_MS,
|
|
) -> Report:
|
|
"""Type ``text`` at the bot ``times`` times and report what each reply cost.
|
|
|
|
Sequential on purpose: the point is what one reply costs when nothing
|
|
else is going on, and a burst would measure the queue behind ourselves.
|
|
"""
|
|
first = await _selftest(client, base, group, text)
|
|
command = str(first.served["command"])
|
|
original = await _setting(client, base, command)
|
|
await _put_setting(client, base, command, {**original, "cooldown_seconds": 0})
|
|
samples = [first]
|
|
try:
|
|
samples.extend([await _selftest(client, base, group, text) for _ in range(times - 1)])
|
|
finally:
|
|
await _put_setting(client, base, command, original)
|
|
return Report(command=command, samples=tuple(samples), budget_ms=budget_ms)
|