mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
One row per command attempt, not two aggregates written only on success. command_uses holds last-use only and daily_uses counts successes; both are rate-limiter state and both are written by store.record_use, which the dispatcher calls only when a handler returns cleanly. A gate refusal and a crash returned before it, so a command that failed every time looked exactly like one nobody ran, and nothing knew which command a counted message was. command_events now takes every attempt with its outcome, its duration and the item it served; upstream_calls takes every outbound request made while serving one. The two writes stay separate on purpose -- recording a refusal as a use is what slid the cooldown window forward every time somebody was told to wait -- and test_dispatcher_outcomes asserts last_used does not move. Backfilled from journald joined against message_events, with daily_uses filling the residual so the two sources cannot double-count. Two things were wrong on the first attempt and both were found by measuring rather than by reasoning: the window has to be symmetric, because sent_at_ms comes from the sender's phone and the journal stamp from this host (194 of 299 lines landed *before* their own message, by up to 3.2 s); and matching has to be a global closest-pair assignment, because per-row greedy cannot separate two commands seconds apart. Together those took the dropped rows from 137 to zero, and recovered 418 invocations back to 29 August. Also: !analityka in the group, twelve Polish lines, naming at most three people and only ones whose display name has been seen; an Analytics tab with Nivo charts; and continuous latency measurement, which answers the open question in DOCS-latency.md -- a text send is 235 ms and an image send about 2.2 s, so the upload really is where the time goes. EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a schema bump rebuilds, and what leaving a group clears. daily_uses left the first (it is a backfill source now) and stayed in the second, and _drop_reshaped took over the repair a bump used to do for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
145 lines
3.9 KiB
Python
145 lines
3.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Building a command history to ask questions of.
|
|
|
|
Everything is arithmetic -- no randomness, no clock -- so a failure names a
|
|
row rather than a seed. The people are invented (ala, basia, celina, darek)
|
|
and so are the group ids: ``check_personal_data`` learns real names from the
|
|
live database, so a real one here would fail only on the maintainer's
|
|
machine and pass in CI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from signal_bot.analytics.backfill import midnight_ms
|
|
from signal_bot.analytics.probe import UpstreamCall
|
|
from signal_bot.analytics.uses import (
|
|
OUTCOME_OK,
|
|
SOURCE_DAILY,
|
|
SOURCE_JOURNAL,
|
|
SOURCE_LIVE,
|
|
CommandEvent,
|
|
)
|
|
from signal_bot.stats.backfill import UNKNOWN_HOUR
|
|
from signal_bot.stats.periods import weekday_of
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
from signal_bot.store import Store
|
|
|
|
GROUP = "group-a=="
|
|
OTHER = "group-b=="
|
|
|
|
ALA = "uuid-ala"
|
|
BASIA = "uuid-basia"
|
|
CELINA = "uuid-celina"
|
|
|
|
HOUR_MS = 3_600_000
|
|
|
|
|
|
def at(day: str, hour: int, minute: int = 0) -> int:
|
|
"""A Warsaw wall-clock moment on one day, in epoch milliseconds."""
|
|
return midnight_ms(day) + hour * HOUR_MS + minute * 60_000
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Shape:
|
|
"""Everything about one attempt except who ran what, and when.
|
|
|
|
Grouped rather than passed as arguments, for the reason the repository
|
|
caps arguments at seven: these are the columns a given test usually
|
|
leaves entirely alone, and a call site that names three of them reads
|
|
better than one that names all eleven.
|
|
"""
|
|
|
|
group_id: str = GROUP
|
|
outcome: str = OUTCOME_OK
|
|
source: str = SOURCE_LIVE
|
|
item_id: str | None = None
|
|
handler_ms: int | None = None
|
|
send_ms: int | None = None
|
|
error_class: str = ""
|
|
args: str = ""
|
|
calls: Sequence[UpstreamCall] = ()
|
|
|
|
|
|
# A module-level singleton, because Shape is frozen and a default argument
|
|
# that is a call is evaluated once anyway -- naming it says so.
|
|
PLAIN = Shape()
|
|
|
|
|
|
def ran(
|
|
store: Store,
|
|
*,
|
|
day: str,
|
|
hour: int = 12,
|
|
minute: int = 0,
|
|
who: str = ALA,
|
|
command: str = "cat",
|
|
shape: Shape = PLAIN,
|
|
) -> int:
|
|
"""Record one attempt and return the timestamp it was keyed on."""
|
|
invoked_at_ms = at(day, hour, minute)
|
|
store.commands.record(
|
|
CommandEvent(
|
|
group_id=shape.group_id,
|
|
user_uuid=who,
|
|
command=command,
|
|
args=shape.args,
|
|
item_id=shape.item_id,
|
|
outcome=shape.outcome,
|
|
error_class=shape.error_class,
|
|
invoked_at_ms=invoked_at_ms,
|
|
day=day,
|
|
local_hour=UNKNOWN_HOUR if shape.source == SOURCE_DAILY else hour,
|
|
weekday=weekday_of(day),
|
|
handler_ms=shape.handler_ms,
|
|
send_ms=shape.send_ms,
|
|
source=shape.source,
|
|
calls=shape.calls,
|
|
)
|
|
)
|
|
return invoked_at_ms
|
|
|
|
|
|
def backfilled(
|
|
store: Store, *, day: str, command: str = "cat", who: str = ALA, group_id: str = GROUP
|
|
) -> None:
|
|
"""One row from the source that knows a day and nothing finer."""
|
|
ran(
|
|
store,
|
|
day=day,
|
|
command=command,
|
|
who=who,
|
|
hour=0,
|
|
shape=Shape(group_id=group_id, source=SOURCE_DAILY),
|
|
)
|
|
|
|
|
|
def reconstructed(
|
|
store: Store,
|
|
*,
|
|
day: str,
|
|
hour: int,
|
|
command: str = "cat",
|
|
who: str = ALA,
|
|
group_id: str = GROUP,
|
|
) -> None:
|
|
"""One row recovered from the journal: a real hour, but no durations."""
|
|
ran(
|
|
store,
|
|
day=day,
|
|
hour=hour,
|
|
command=command,
|
|
who=who,
|
|
shape=Shape(group_id=group_id, source=SOURCE_JOURNAL),
|
|
)
|
|
|
|
|
|
def a_call(host: str = "api.example.test", status: int = 200, ms: int = 40) -> UpstreamCall:
|
|
"""One outbound request made while a command was running."""
|
|
return UpstreamCall(host=host, status=status, ms=ms)
|