signal-bot/tool/latency.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

67 lines
2.4 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Measure what one text reply costs the bot's own code, against the live bot.
tool/latency.py testbot # 20 x !pomoc, judged at 100 ms p95
tool/latency.py testbot --text '!ja' --times 50
tool/latency.py testbot --budget 50
Posts for real into the named group, which must be in SELFTEST_GROUPS -- the
same fail-closed rule as scripts/selftest.sh. The command's cooldown is
switched off for the run and put back afterwards. Exit 1 when the 95th
percentile of the bot's own time is over budget; see signal_bot/latency.py
for what "own" excludes and why.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from typing import Final
import httpx
from dotenv import load_dotenv
from signal_bot.app import HTTP_TIMEOUT_SECONDS, USER_AGENT
from signal_bot.latency import LatencyError, measure
from signal_bot.served import OWN_BUDGET_MS
EXIT_OVER_BUDGET: Final = 1
EXIT_REFUSED: Final = 2
DEFAULT_TIMES: Final = 20
async def run(group: str, text: str, times: int, budget_ms: int, base: str) -> int:
"""One measured run; the exit code is the verdict."""
async with httpx.AsyncClient(
timeout=HTTP_TIMEOUT_SECONDS, headers={"User-Agent": USER_AGENT}
) as client:
report = await measure(client, base, group, text, times, budget_ms)
sys.stdout.write(report.render() + "\n")
return 0 if report.within_budget else EXIT_OVER_BUDGET
def main() -> int:
"""Parse arguments and run one measurement."""
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("group", help="test group name; must be in SELFTEST_GROUPS")
parser.add_argument("--text", default="!pomoc", help="the command to type (default: !pomoc)")
parser.add_argument("--times", type=int, default=DEFAULT_TIMES, help="replies to sample")
parser.add_argument("--budget", type=int, default=OWN_BUDGET_MS, help="p95 own_ms allowed")
args = parser.parse_args()
load_dotenv()
base = f"http://127.0.0.1:{os.environ.get('BIND_PORT', '8137')}"
try:
return asyncio.run(run(args.group, args.text, args.times, args.budget, base))
except (LatencyError, httpx.HTTPError) as error:
sys.stderr.write(f"latency: {error}\n")
return EXIT_REFUSED
if __name__ == "__main__":
raise SystemExit(main())