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
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Writing the command history: one row per attempt, and what it cost.
|
|
|
|
Shares the ``Store`` connection exactly as ``EventStore`` does, so a command
|
|
and the message that triggered it are committed by the same connection in
|
|
the same order they happened.
|
|
|
|
The column set is pinned by ``test_command_events`` for the reason
|
|
``message_events``' is: this table sits beside a public repository that
|
|
leaked real names once, and a column added quietly is how the next one would
|
|
arrive. Nothing here holds free text. ``command`` is a registry name,
|
|
``args`` is a resolved token, ``item_id`` is a public identifier and
|
|
``error_class`` is an exception's class name.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
if TYPE_CHECKING:
|
|
import sqlite3
|
|
from collections.abc import Sequence
|
|
|
|
from signal_bot.analytics.probe import UpstreamCall
|
|
|
|
OUTCOME_OK: Final = "ok"
|
|
OUTCOME_COOLDOWN: Final = "refused_cooldown"
|
|
OUTCOME_DAILY: Final = "refused_daily"
|
|
OUTCOME_VOTE: Final = "refused_vote"
|
|
OUTCOME_ERROR: Final = "error"
|
|
OUTCOME_UNKNOWN: Final = "unknown_word"
|
|
|
|
SOURCE_LIVE: Final = "live"
|
|
SOURCE_JOURNAL: Final = "journald"
|
|
SOURCE_DAILY: Final = "daily_uses"
|
|
|
|
# What ``args`` holds when a command took arguments that resolve to nothing
|
|
# nameable. Never the words themselves: a user-typed argument column is the
|
|
# exact shape that put real names in this repository once.
|
|
ARGS_PRESENT: Final = "+args"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CommandEvent:
|
|
"""One attempt to run one command, whether or not it ran."""
|
|
|
|
group_id: str
|
|
user_uuid: str
|
|
command: str
|
|
outcome: str
|
|
invoked_at_ms: int
|
|
day: str
|
|
local_hour: int
|
|
weekday: int
|
|
args: str = ""
|
|
item_id: str | None = None
|
|
error_class: str = ""
|
|
# None rather than 0: a backfilled row has no duration, and zero
|
|
# milliseconds is a lie that reads as data and would drag a p50 down.
|
|
# The lesson is already written into backfill.UNKNOWN_HOUR's comment.
|
|
handler_ms: int | None = None
|
|
send_ms: int | None = None
|
|
source: str = SOURCE_LIVE
|
|
calls: Sequence[UpstreamCall] = field(default_factory=tuple)
|
|
|
|
|
|
class CommandEventStore:
|
|
"""Reads and writes the command history. Shares the Store's connection."""
|
|
|
|
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
"""Wrap an open connection whose schema has already been applied."""
|
|
self._conn = conn
|
|
|
|
@property
|
|
def connection(self) -> sqlite3.Connection:
|
|
"""The shared connection, for the query modules in this package."""
|
|
return self._conn
|
|
|
|
def record(self, event: CommandEvent) -> None:
|
|
"""Append one attempt and the upstream calls it made.
|
|
|
|
``ON CONFLICT DO NOTHING`` against the natural key is what makes a
|
|
signal-cli redelivery harmless, the same property ``message_events``
|
|
gets from its primary key -- ``invoked_at_ms`` is the envelope's own
|
|
timestamp, so a replayed frame collides with the row it already
|
|
wrote instead of adding a second one.
|
|
|
|
It is also what makes the backfill re-runnable, which is why the
|
|
backfill needs no delete-then-rebuild pass and cannot lose rows to
|
|
a journal that has since been vacuumed.
|
|
"""
|
|
self._conn.execute(
|
|
"""
|
|
INSERT INTO command_events (
|
|
group_id, user_uuid, command, args, item_id, outcome,
|
|
error_class, invoked_at_ms, day, local_hour, weekday,
|
|
handler_ms, send_ms, source
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(group_id, user_uuid, command, invoked_at_ms, source)
|
|
DO NOTHING
|
|
""",
|
|
(
|
|
event.group_id,
|
|
event.user_uuid,
|
|
event.command,
|
|
event.args,
|
|
event.item_id,
|
|
event.outcome,
|
|
event.error_class,
|
|
event.invoked_at_ms,
|
|
event.day,
|
|
event.local_hour,
|
|
event.weekday,
|
|
event.handler_ms,
|
|
event.send_ms,
|
|
event.source,
|
|
),
|
|
)
|
|
self._record_calls(event)
|
|
self._conn.commit()
|
|
|
|
def _record_calls(self, event: CommandEvent) -> None:
|
|
"""Append the upstream calls one invocation made.
|
|
|
|
Keyed on (group, timestamp, seq) rather than on the parent's rowid.
|
|
``lastrowid`` is unusable here: ``ON CONFLICT DO NOTHING`` leaves it
|
|
holding whatever the previous successful insert returned, so a
|
|
replayed frame would file its calls under an unrelated invocation.
|
|
"""
|
|
for seq, call in enumerate(event.calls):
|
|
self._conn.execute(
|
|
"""
|
|
INSERT INTO upstream_calls (
|
|
group_id, invoked_at_ms, command, host, status,
|
|
duration_ms, seq
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(group_id, invoked_at_ms, seq) DO NOTHING
|
|
""",
|
|
(
|
|
event.group_id,
|
|
event.invoked_at_ms,
|
|
event.command,
|
|
call.host,
|
|
call.status,
|
|
call.ms,
|
|
seq,
|
|
),
|
|
)
|