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
+101 -1
View File
@@ -330,6 +330,104 @@ async def test_one_failing_account_does_not_stop_the_sweep(
assert done == [str(accounts[0][1]), str(accounts[2][1])]
@pytest.mark.asyncio
async def test_a_dead_engine_never_marks_the_account_bootstrapped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An engine that refuses the FIRST message must not look like a clean run.
This shipped: failure was signalled by negating the count taught so far, so
a refusal on message one returned `-0` — which is `0`, not negative. The
caller read that as "learned nothing, fine", marked the account done, and
the hourly sweep then skipped it forever. The account most in need of a warm
start was the one guaranteed never to get one.
"""
marked: list[object] = []
async def _settings(_u: object) -> policy.Settings:
return SETTINGS
class _Kv:
async def get(self, _k: str, _d: object = None) -> list[str]:
return []
async def set(self, _k: str, v: object) -> None:
marked.append(v)
class _M:
async def sample(self, *_a: object, **kw: object) -> list[uuid.UUID]:
n = 50 if kw.get("special_use") == "junk" else 50
return [uuid.uuid4() for _ in range(n)]
async def raw(self, *_a: object, **_k: object) -> bytes:
return b"x"
class _Fake:
messages = _M()
def kv(self, _u: object) -> _Kv:
return _Kv()
async def publish(self, *_a: object, **_k: object) -> None:
"""Present so this test fails on its assertion, not on a bare fake."""
async def _dead(_raw: bytes, **_kw: object) -> bool:
return False # every learn refused, from the very first one
monkeypatch.setattr(policy, "load_settings", _settings)
monkeypatch.setattr(jobs, "_SDK", _Fake())
monkeypatch.setattr(jobs.rspamd, "learn", _dead)
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
assert marked == [], "marked the account bootstrapped after learning nothing"
@pytest.mark.asyncio
async def test_an_engine_that_dies_midway_also_leaves_the_account_unmarked(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The case the original sentinel did handle — keep it handled."""
marked: list[object] = []
calls = {"n": 0}
async def _settings(_u: object) -> policy.Settings:
return SETTINGS
class _Kv:
async def get(self, _k: str, _d: object = None) -> list[str]:
return []
async def set(self, _k: str, v: object) -> None:
marked.append(v)
class _M:
async def sample(self, *_a: object, **_kw: object) -> list[uuid.UUID]:
return [uuid.uuid4() for _ in range(50)]
async def raw(self, *_a: object, **_k: object) -> bytes:
return b"x"
class _Fake:
messages = _M()
def kv(self, _u: object) -> _Kv:
return _Kv()
async def publish(self, *_a: object, **_k: object) -> None:
"""As above."""
async def _dies(_raw: bytes, **_kw: object) -> bool:
calls["n"] += 1
return calls["n"] <= 5
monkeypatch.setattr(policy, "load_settings", _settings)
monkeypatch.setattr(jobs, "_SDK", _Fake())
monkeypatch.setattr(jobs.rspamd, "learn", _dies)
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
assert marked == [], "marked the account bootstrapped after a partial run"
def test_compose_gives_the_app_the_controller_password() -> None:
"""The learning loop needs the controller password; classification does not.
@@ -354,7 +452,9 @@ def test_compose_gives_the_app_the_controller_password() -> None:
assert anchor in text, f"{rel}: shared env block moved; update this test"
start = text.index(anchor)
# The shared block runs to the next top-level key.
block = text[start : text.index("\nservices:", start) if "\nservices:" in text[start:] else len(text)]
tail = text[start:]
end = start + (tail.index("\nservices:") if "\nservices:" in tail else len(tail))
block = text[start:end]
assert "RSPAMD_CONTROLLER_PASSWORD" in block, (
f"{rel}: the app containers do not receive RSPAMD_CONTROLLER_PASSWORD — "
"antispam will classify but never learn"