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
216 lines
7.6 KiB
Python
216 lines
7.6 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""SQLite persistence, including survival across a restart."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from typing import TYPE_CHECKING
|
|
|
|
from signal_bot.schema import SCHEMA_VERSION
|
|
from signal_bot.stats.events import MessageEvent
|
|
from signal_bot.store import Store, open_store
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
GROUP = "group-a=="
|
|
OTHER = "group-b=="
|
|
|
|
|
|
def event(
|
|
uuid: str = "u1",
|
|
name: str = "Alice",
|
|
at: int = 1,
|
|
day: str = "2026-08-29",
|
|
group_id: str = GROUP,
|
|
) -> MessageEvent:
|
|
"""One minimal event. ``at`` also disambiguates the primary key."""
|
|
return MessageEvent(
|
|
group_id=group_id,
|
|
uuid=uuid,
|
|
display_name=name,
|
|
sent_at_ms=at,
|
|
day=day,
|
|
local_hour=12,
|
|
)
|
|
|
|
|
|
def counted(store: Store, group_id: str) -> int:
|
|
"""How many events one group holds."""
|
|
row = store.events.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (group_id,)
|
|
).fetchone()
|
|
return int(row["n"])
|
|
|
|
|
|
def test_last_used_is_none_before_first_use(store: Store) -> None:
|
|
assert store.last_used(GROUP, "u1", "cat") is None
|
|
|
|
|
|
def test_record_use_tracks_cooldown_and_daily(store: Store) -> None:
|
|
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
|
|
assert store.last_used(GROUP, "u1", "cat") == 100.0
|
|
assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 1
|
|
store.record_use(GROUP, "u1", "cat", 200.0, "2026-08-29")
|
|
assert store.last_used(GROUP, "u1", "cat") == 200.0
|
|
assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 2
|
|
|
|
|
|
def test_daily_counter_is_per_day(store: Store) -> None:
|
|
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
|
|
assert store.uses_today(GROUP, "u1", "cat", "2026-08-30") == 0
|
|
|
|
|
|
def test_uses_today_is_per_user_and_command(store: Store) -> None:
|
|
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
|
|
assert store.uses_today(GROUP, "u2", "cat", "2026-08-29") == 0
|
|
assert store.uses_today(GROUP, "u1", "dog", "2026-08-29") == 0
|
|
|
|
|
|
def test_votes_are_deduplicated_per_person(store: Store) -> None:
|
|
store.add_vote(GROUP, "kot", "u1", 100.0)
|
|
store.add_vote(GROUP, "kot", "u1", 101.0)
|
|
store.add_vote(GROUP, "kot", "u2", 102.0)
|
|
assert store.count_votes(GROUP, "kot", 0.0) == 2
|
|
|
|
|
|
def test_votes_outside_the_window_are_ignored(store: Store) -> None:
|
|
store.add_vote(GROUP, "cmd", "u1", 100.0)
|
|
assert store.count_votes(GROUP, "cmd", 150.0) == 0
|
|
|
|
|
|
def test_clear_votes(store: Store) -> None:
|
|
store.add_vote(GROUP, "cmd", "u1", 100.0)
|
|
store.clear_votes(GROUP, "cmd")
|
|
assert store.count_votes(GROUP, "cmd", 0.0) == 0
|
|
|
|
|
|
def test_store_is_a_context_manager(tmp_path: Path) -> None:
|
|
with Store(str(tmp_path / "x.sqlite3")) as opened:
|
|
opened.events.record(event())
|
|
assert opened.meta.__self__ is opened
|
|
|
|
|
|
def test_state_is_scoped_per_group(store: Store) -> None:
|
|
"""The whole point of the group key: two groups never share a counter."""
|
|
store.events.record(event(at=1))
|
|
store.events.record(event(at=1, group_id=OTHER))
|
|
store.events.record(event(at=2, group_id=OTHER))
|
|
assert counted(store, GROUP) == 1
|
|
assert counted(store, OTHER) == 2
|
|
|
|
|
|
def test_forget_group_keeps_the_history(store: Store) -> None:
|
|
"""The reversal 0.1.0 turns on, and why it matters.
|
|
|
|
A group listing that transiently omits members makes discovery drop the
|
|
group, which calls this. Before 0.1.0 that cost a day's counter; against
|
|
a permanent history it would delete every statistic the group has.
|
|
"""
|
|
store.events.record(event())
|
|
store.events.note_person(GROUP, "u1", "Alice", "", "2026-08-29")
|
|
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
|
|
store.add_vote(GROUP, "cmd", "u1", 100.0)
|
|
store.set_meta(GROUP, "announced_version", "0.0.7")
|
|
|
|
store.forget_group(GROUP)
|
|
|
|
assert store.last_used(GROUP, "u1", "cat") is None
|
|
assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 0
|
|
assert store.count_votes(GROUP, "cmd", 0.0) == 0
|
|
assert store.meta(GROUP, "announced_version") is None
|
|
assert counted(store, GROUP) == 1
|
|
assert [p.display_name for p in store.events.people(GROUP)] == ["Alice"]
|
|
|
|
|
|
def test_rate_limits_are_scoped_per_group(store: Store) -> None:
|
|
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
|
|
assert store.last_used(OTHER, "u1", "cat") is None
|
|
assert store.uses_today(OTHER, "u1", "cat", "2026-08-29") == 0
|
|
|
|
|
|
def test_votes_are_scoped_per_group(store: Store) -> None:
|
|
store.add_vote(GROUP, "cmd", "u1", 100.0)
|
|
store.add_vote(OTHER, "cmd", "u2", 100.0)
|
|
store.clear_votes(GROUP, "cmd")
|
|
assert store.count_votes(GROUP, "cmd", 0.0) == 0
|
|
assert store.count_votes(OTHER, "cmd", 0.0) == 1
|
|
|
|
|
|
def test_a_pre_group_database_is_rebuilt(tmp_path: Path) -> None:
|
|
"""CREATE TABLE IF NOT EXISTS is a no-op against the wrong columns.
|
|
|
|
A database written before the group key would survive startup untouched
|
|
and then fail on every INSERT: a bot that boots clean and answers
|
|
nothing. PRAGMA user_version is the only part of the file that knows.
|
|
"""
|
|
db = str(tmp_path / "old.sqlite3")
|
|
legacy = sqlite3.connect(db)
|
|
legacy.execute("CREATE TABLE command_uses (uuid TEXT PRIMARY KEY, used_at REAL NOT NULL)")
|
|
legacy.execute("INSERT INTO command_uses VALUES ('u1', 7.0)")
|
|
legacy.commit()
|
|
legacy.close()
|
|
|
|
with open_store(db) as store:
|
|
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
|
|
assert store.last_used(GROUP, "u1", "cat") == 100.0
|
|
|
|
check = sqlite3.connect(db)
|
|
assert int(check.execute("PRAGMA user_version").fetchone()[0]) == SCHEMA_VERSION
|
|
check.close()
|
|
|
|
|
|
def test_a_schema_rebuild_never_drops_the_history(tmp_path: Path) -> None:
|
|
"""The second destructive path the table split governs.
|
|
|
|
forget_group is the obvious one. This is the other: a future session
|
|
bumping SCHEMA_VERSION for some cooldown table must not take years of
|
|
message history with it.
|
|
|
|
The use history now survives too, which it did not before command
|
|
analytics. daily_uses stopped being disposable the day it became the
|
|
only source covering the days that predate command_events, so it left
|
|
REBUILT_ON_BUMP. A bump can therefore no longer repair its shape --
|
|
_drop_reshaped is what took that job over.
|
|
"""
|
|
db = str(tmp_path / "bump.sqlite3")
|
|
with open_store(db) as first:
|
|
first.events.record(event())
|
|
first.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
|
|
|
|
aged = sqlite3.connect(db)
|
|
aged.execute("PRAGMA user_version = 0")
|
|
aged.commit()
|
|
aged.close()
|
|
|
|
with open_store(db) as second:
|
|
assert counted(second, GROUP) == 1
|
|
assert second.last_used(GROUP, "u1", "cat") == 100.0
|
|
assert second.uses_today(GROUP, "u1", "cat", "2026-08-29") == 1
|
|
|
|
|
|
def test_a_bump_still_clears_a_vote_in_progress(tmp_path: Path) -> None:
|
|
"""What REBUILT_ON_BUMP still holds is still rebuilt."""
|
|
db = str(tmp_path / "votes.sqlite3")
|
|
with open_store(db) as first:
|
|
first.add_vote(GROUP, "cat", "u1", 100.0)
|
|
first.set_meta(GROUP, "last_reported_day", "2026-08-29")
|
|
|
|
aged = sqlite3.connect(db)
|
|
aged.execute("PRAGMA user_version = 0")
|
|
aged.commit()
|
|
aged.close()
|
|
|
|
with open_store(db) as second:
|
|
assert second.count_votes(GROUP, "cat", 0.0) == 0
|
|
assert second.meta(GROUP, "last_reported_day") is None
|
|
|
|
|
|
def test_a_current_database_is_left_alone(tmp_path: Path) -> None:
|
|
"""Reopening must not wipe the history on every restart."""
|
|
db = str(tmp_path / "current.sqlite3")
|
|
with open_store(db) as first:
|
|
first.events.record(event())
|
|
with open_store(db) as second:
|
|
assert counted(second, GROUP) == 1
|