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
183 lines
7.2 KiB
Python
183 lines
7.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Recovering the command history that predates the table holding it.
|
|
|
|
Two sources, and they overlap, which is the whole difficulty. Summing them
|
|
would roughly double every historical count and nothing would fail.
|
|
|
|
* **journald** knows *which* command ran and *when*, to the second, but has
|
|
no group and no uuid -- ``dispatcher.py`` logs a display name because the
|
|
line was written to be read by a human.
|
|
* **daily_uses** knows the group, the uuid, the command and the day, and
|
|
counts them correctly, but has no times of day at all.
|
|
|
|
So the join runs over ``message_events``, which already holds the group, the
|
|
uuid, the real hour and the display name of every counted command -- and
|
|
lacks only the command's *name*, which is exactly what journald has. Each
|
|
matched row is a whole truthful record. ``daily_uses`` then fills the
|
|
**residual**: for each (group, user, command, day), whatever its count says
|
|
happened beyond what the join could account for. Nothing is counted twice.
|
|
|
|
Insert-only, always. There is no delete-then-rebuild pass, because journald
|
|
has no retention configuration and will rotate: a second run after the log
|
|
has aged out would delete rows it could no longer replace. Re-runnability
|
|
comes from the natural key and ``ON CONFLICT DO NOTHING``.
|
|
|
|
One inaccuracy, stated rather than hidden. ``dispatcher.py`` logs "running
|
|
command X" *before* the handler runs, so a journald row proves the command
|
|
was entered, not that it finished. A handler that then crashed, or a
|
|
``daily_limit`` or ``vote_gated`` refusal -- neither of which logged
|
|
anything -- is imported as ``ok`` because nothing in the record says
|
|
otherwise. Measured against the live database on 2026-08-31 the gap is about
|
|
4%: 293 reconstructed invocations against 388 successes in ``daily_uses``,
|
|
overshooting by roughly fifteen rows across all commands. Every such row
|
|
carries ``source = 'journald'``, so the UI can and does say where it came
|
|
from; only the ``live`` rows have outcomes that were actually observed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from signal_bot.analytics.backfill_join import Tally, from_journal
|
|
from signal_bot.analytics.uses import (
|
|
OUTCOME_OK,
|
|
SOURCE_DAILY,
|
|
SOURCE_LIVE,
|
|
CommandEvent,
|
|
)
|
|
from signal_bot.stats.backfill import UNKNOWN_HOUR
|
|
from signal_bot.stats.periods import MS_PER_SECOND, WARSAW, weekday_of
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
from signal_bot.analytics.journal import JournalLine
|
|
from signal_bot.store import Store
|
|
|
|
# Recorded per group once a pass has run, in the same place and for the same
|
|
# reason stats/backfill.py records its own marker.
|
|
BACKFILL_KEY: Final = "command_backfill"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Report:
|
|
"""What one pass did, and what it refused to guess at."""
|
|
|
|
journal_rows: int = 0
|
|
daily_rows: int = 0
|
|
unmatched: int = 0
|
|
ambiguous: int = 0
|
|
groups: int = 0
|
|
|
|
def lines(self) -> tuple[str, ...]:
|
|
"""The summary a human reads. Never names anybody."""
|
|
return (
|
|
f"journald rows: {self.journal_rows}",
|
|
f"daily_uses rows: {self.daily_rows}",
|
|
f"dropped, no match: {self.unmatched}",
|
|
f"dropped, ambiguous: {self.ambiguous}",
|
|
f"groups marked: {self.groups}",
|
|
)
|
|
|
|
|
|
def midnight_ms(day: str) -> int:
|
|
"""The first millisecond of a Warsaw calendar day.
|
|
|
|
Built from the date rather than by subtracting 86_400_000 from anything:
|
|
Warsaw days are not all 24 hours, and the arithmetic spelling lands an
|
|
hour out twice a year.
|
|
"""
|
|
start = dt.datetime.combine(dt.date.fromisoformat(day), dt.time.min, tzinfo=WARSAW)
|
|
return int(start.timestamp() * MS_PER_SECOND)
|
|
|
|
|
|
def live_floor(store: Store) -> int:
|
|
"""The earliest live row, or "no live rows yet".
|
|
|
|
Every backfilled row must be strictly older than this. Without the
|
|
cutoff, a second pass run after live recording had started would import
|
|
journald lines describing invocations that already exist as ``live``
|
|
rows -- with a different source, so the natural key would not stop them.
|
|
"""
|
|
row = store.commands.connection.execute(
|
|
"SELECT MIN(invoked_at_ms) AS floor FROM command_events WHERE source = ?",
|
|
(SOURCE_LIVE,),
|
|
).fetchone()
|
|
floor = row["floor"]
|
|
# No live rows yet means nothing is off limits.
|
|
return int(floor) if floor is not None else 1 << 62
|
|
|
|
|
|
def _residual(store: Store, tally: Tally) -> list[CommandEvent]:
|
|
"""Fill in whatever daily_uses counted that the join could not explain.
|
|
|
|
``uses`` is the truth about how many times something ran that day. The
|
|
join accounts for some of them exactly; this adds the remainder at day
|
|
granularity, with no hour and no durations, so the total is right even
|
|
where the detail is missing.
|
|
"""
|
|
events: list[CommandEvent] = []
|
|
rows = store.commands.connection.execute(
|
|
"SELECT group_id, user_uuid, command, day, uses FROM daily_uses ORDER BY day, command"
|
|
).fetchall()
|
|
for row in rows:
|
|
key = (str(row["group_id"]), str(row["user_uuid"]), str(row["command"]), str(row["day"]))
|
|
remaining = int(row["uses"]) - tally.matched[key]
|
|
day = str(row["day"])
|
|
base = midnight_ms(day)
|
|
events.extend(
|
|
CommandEvent(
|
|
group_id=key[0],
|
|
user_uuid=key[1],
|
|
command=key[2],
|
|
outcome=OUTCOME_OK,
|
|
# Deterministic, so a second pass produces the identical key
|
|
# and inserts nothing. The same trick, for the same reason,
|
|
# as seed_from_counter's sent_at_ms=index + 1.
|
|
invoked_at_ms=base + index,
|
|
day=day,
|
|
local_hour=UNKNOWN_HOUR,
|
|
weekday=weekday_of(day),
|
|
source=SOURCE_DAILY,
|
|
)
|
|
for index in range(max(0, remaining))
|
|
)
|
|
return events
|
|
|
|
|
|
def plan(store: Store, lines: Iterable[JournalLine]) -> tuple[Report, list[CommandEvent]]:
|
|
"""Work out what would be imported, writing nothing.
|
|
|
|
Separate from ``apply`` so that ``--dry-run`` reports the real numbers
|
|
rather than an estimate of them. Rolling back would not have worked:
|
|
``CommandEventStore.record`` commits per row, exactly as ``EventStore``
|
|
does, so by the time a caller could roll back there is nothing left to
|
|
roll back.
|
|
"""
|
|
floor = live_floor(store)
|
|
tally = from_journal(store, lines, floor)
|
|
residual = _residual(store, tally)
|
|
events = [*tally.events, *residual]
|
|
return (
|
|
Report(
|
|
journal_rows=len(tally.events),
|
|
daily_rows=len(residual),
|
|
unmatched=tally.unmatched,
|
|
ambiguous=tally.ambiguous,
|
|
groups=len({event.group_id for event in events}),
|
|
),
|
|
events,
|
|
)
|
|
|
|
|
|
def apply(store: Store, events: Iterable[CommandEvent]) -> None:
|
|
"""Write a planned import. Insert-only; a repeat run inserts nothing."""
|
|
groups = set()
|
|
for event in events:
|
|
store.commands.record(event)
|
|
groups.add(event.group_id)
|
|
for group_id in sorted(groups):
|
|
store.set_meta(group_id, BACKFILL_KEY, "done")
|