Antispam: a dead engine marked the account bootstrapped anyway

`_teach` signalled failure by negating the count taught so far. That
works for every case but the one that matters: when the FIRST message is
refused nothing has been taught yet, and `-0` is `0` — not negative. The
caller's `< 0` test then read a total engine outage as a clean run of
zero, recorded `_bootstrapped` for the account, and the hourly sweep
skipped it from then on. The account most in need of a warm start was
the one guaranteed never to get one.

Which is precisely what happened alongside the missing controller
password: every learn was refused 401, and each account was quietly
retired on its first sweep.

Return None for "the engine stopped accepting these" so failure cannot
be confused with a count, and decline to mark an account whose samples
were all unreadable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-21 22:22:28 +02:00
co-authored by Claude Opus 5
parent 059fa0ade3
commit 31edd4015e
2 changed files with 120 additions and 5 deletions
@@ -51,7 +51,15 @@ MAX_HAM_RATIO = 3
_DONE_KEY = "_bootstrapped"
async def _teach(user_id: uuid.UUID, ids: list[uuid.UUID], *, spam: bool) -> int:
async def _teach(user_id: uuid.UUID, ids: list[uuid.UUID], *, spam: bool) -> int | None:
"""Number of messages learned, or None if the engine stopped accepting them.
None rather than a negative count: the count was once negated to signal
failure, which works for every case except the one that matters. If the
FIRST message is refused, nothing has been taught yet, `-0` is `0`, and the
caller's `< 0` test reads a total engine outage as a clean run of zero — then
marks the account bootstrapped, so it is never retried.
"""
taught = 0
for message_id in ids:
raw = await _SDK.messages.raw(user_id, message_id)
@@ -60,7 +68,7 @@ async def _teach(user_id: uuid.UUID, ids: list[uuid.UUID], *, spam: bool) -> int
if not await rspamd.learn(raw, user_key=str(user_id), spam=spam):
# Engine gone. Stop rather than grind through hundreds of failures;
# the account stays unmarked, so a later run picks it up again.
return -taught
return None
taught += 1
await asyncio.sleep(PAUSE_SECONDS)
return taught
@@ -106,17 +114,24 @@ async def bootstrap_account(
ham_ids = ham_ids[: len(spam_ids) * MAX_HAM_RATIO]
spam_taught = await _teach(uid, spam_ids, spam=True)
if spam_taught < 0:
if spam_taught is None:
logger.warning(
"antispam bootstrap for %s stopped: engine unavailable", account_id
)
return
ham_taught = await _teach(uid, ham_ids, spam=False)
if ham_taught < 0:
if ham_taught is None:
logger.warning(
"antispam bootstrap for %s stopped: engine unavailable", account_id
)
return
if spam_taught == 0:
# Every sample was unreadable. Nothing was learned, so do not record a
# warm start that did not happen.
logger.warning(
"antispam bootstrap for %s: no samples could be read", account_id
)
return
await _SDK.kv(uid).set(_DONE_KEY, [*done, account_id])
logger.info(