signal-bot/signal_bot/analytics/backfill.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
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
2026-08-31 23:19:51 +02:00

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")