signal-bot/signal_bot/registry_holder.py
Krzysztof kuhy Rudnicki 0717df646a
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Rebuild the command table on demand, and survive a tag whose file is gone
The registry used to be built once, inline in build_bot, so every change to
the data behind it -- a person tagged in the web UI, an alias, soon a command
switched off from the management page -- meant restarting the unit. That is
cheap and lossless, but it is a deploy step somebody has to remember, and a
21:37 report can land in the middle of it.

Now the registration sequence lives in wiring.build_registry, and
RegistryHolder runs it again on POST /_reload: the new table is built whole
and swapped in with one assignment, never patched in place, so a duplicate
trigger is found before anything the dispatcher reads has changed. Bad data
(ConfigError, DuplicateTriggerError) keeps the previous table and answers
409 with the reason; a programming error still surfaces as a 500.
scripts/reload.sh wraps it, with --code for the restart a Python change
needs, and an optional command to type in testbot so "reloaded" means
"answered". No Python hot-reload on purpose.

The loader change is what makes deploying this safe: two rows in the live
tags.json name files that are no longer in media/, and load_pack refused
them, so the next restart would have looped on ConfigError. A stale tag is
drift in a file a page edits mid-session, not a broken album -- it is now
skipped with a warning naming it. A tag that reaches outside the album is
still refused. pack_load.py split at the 250-line cap; pack_tags.py is the
half whose checks are not all fatal.

Proven offline in scripts/e2e.sh: !delta is unknown, a fifth person is
tagged into the synthetic pack, scripts/reload.sh runs, and the same
process posts delta's photo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 10:14:39 +02:00

78 lines
3.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The one mutable box the command table lives in, so data changes need no restart.
``PackHolder`` made a *photo* visible the moment it was stored, and said out
loud what it would never do: add a command. A new person in the tagger, a
new alias, a command switched off from the management page -- every one of
those changes the registry itself, and until now every one of them meant
restarting the unit. Restarting is cheap and lossless (signal-cli replays
what arrived meanwhile), but it is a deploy step somebody has to remember,
and a 21:37 report can land in the middle of it.
So the registry is rebuilt, whole, from the same builder that made it at
startup, and swapped in one assignment. Never patched in place: a table
that is edited command by command can be observed half-edited, and a
duplicate trigger found on the third command of five leaves the first two
already live. Building a fresh table first means ``DuplicateTriggerError``
and ``ConfigError`` are raised before anything the dispatcher reads has
changed.
``reload`` never raises on bad *data*. The builder reads files people edit
by hand and pages write mid-session, and the only safe answer to a file
that does not load is the table that was already answering. A programming
error is not data and is not swallowed: it reaches the caller, which for
``POST /_reload`` means a 500 and a traceback in the journal.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from signal_bot.groups import ConfigError
from signal_bot.registry import DuplicateTriggerError
if TYPE_CHECKING:
from collections.abc import Callable
from signal_bot.registry import CommandRegistry
logger = logging.getLogger(__name__)
# A `type` alias is evaluated lazily, so the TYPE_CHECKING-only names are fine.
type Builder = Callable[[], CommandRegistry]
class RegistryHolder:
"""A mutable reference to the command table the dispatcher reads through."""
def __init__(self, build: Builder) -> None:
"""Build the first table now, loudly.
Startup is the one time a bad table must *not* be survived: there is
no previous one to fall back on, and ``Restart=always`` looping on a
ConfigError is a clearer signal than a bot that answers nothing.
"""
self._build = build
self.registry = build()
self.last_error = ""
def reload(self) -> bool:
"""Rebuild the table from whatever is on disk now and publish it.
Returns:
True when the new table is live, False when the old one is still
being served because the data no longer builds -- and then
``last_error`` says why, for whoever asked.
"""
try:
fresh = self._build()
except (ConfigError, DuplicateTriggerError) as error:
self.last_error = str(error)
logger.exception("command registry no longer builds; keeping the previous one")
return False
self.registry = fresh
self.last_error = ""
logger.info("command registry rebuilt: %d commands", len(fresh.commands))
return True