fix(alerts): Steward's own outage no longer reads as every agent going silent (#5441)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m26s
CI / unit (push) Successful in 1m31s
CI / publish (push) Skipped

After Steward or its database was down, every agent's age and every
series' last sample were old, so all agent-offline rules fired and all
rules went no-data at once, clearing a minute later. With per-account
alert email that would have mailed everyone a dozen notices per restart.

A periodic job now ticks a Watch (steward/core/watch.py) once it has
reached the database; the Watch says since when runs have been unbroken
(process start, or the end of a gap over 120 s). seconds_since_seen and
the no-data sweep count silence from no earlier than that, so a source
must stay quiet while Steward is watching before it counts as silent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:45:47 -04:00
co-authored by Claude Opus 5.5
parent 5a44afe86e
commit a9bf4b7f16
7 changed files with 183 additions and 6 deletions
+13
View File
@@ -60,6 +60,19 @@ def test_silent_since(last_ago, interval, watch_ago, silent_after):
assert got == (last or watch) + timedelta(seconds=silent_after)
def test_steward_s_own_outage_is_not_the_series_silence():
"""#5441: a series last sampled 18 minutes ago, while Steward has only been
back for 2 minutes, is not silent yet; once Steward has watched it for
three of its gaps with nothing arriving, it is, from then."""
last = NOW - timedelta(minutes=18)
watch = NOW - timedelta(days=1)
assert silent_since(last, 60, watch, NOW, awake_since=NOW - timedelta(minutes=2)) is None
awake = NOW - timedelta(minutes=6)
assert silent_since(last, 60, watch, NOW, awake_since=awake) == awake + timedelta(seconds=300)
# A sample since Steward came back is what counts, when there is one.
assert silent_since(NOW - timedelta(minutes=1), 60, watch, NOW, awake_since=awake) is None
class _Session:
def __init__(self):
self.added = []
+32
View File
@@ -0,0 +1,32 @@
"""Since when Steward has been watching without a break (#5441): a job's
Watch starts at its first run, holds across runs a minute apart, and starts
again after a gap long enough to mean Steward was not watching."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
from steward.core.watch import GAP_S, Watch, watch
T0 = datetime(2026, 10, 9, 2, 0, tzinfo=timezone.utc)
def test_the_first_run_starts_the_watch_and_steady_runs_keep_it():
w = Watch()
assert w.tick(T0) == T0
for minute in range(1, 30):
assert w.tick(T0 + timedelta(minutes=minute)) == T0
def test_a_gap_longer_than_two_missed_runs_starts_it_again():
w = Watch()
w.tick(T0)
assert w.tick(T0 + timedelta(seconds=GAP_S)) == T0 # just within
back = T0 + timedelta(seconds=2 * GAP_S + 1)
assert w.tick(back) == back # Steward was away
assert w.tick(back + timedelta(minutes=1)) == back
def test_each_job_has_its_own_watch_on_its_own_app():
a, b = NS(), NS()
assert watch(a, "x") is watch(a, "x")
assert watch(a, "x") is not watch(a, "y")
assert watch(a, "x") is not watch(b, "x")
+61
View File
@@ -26,6 +26,7 @@ def test_silence_is_marked_once_and_cleared_by_data(app):
from sqlalchemy import select, update
from steward.alerts import service as alert_svc
from steward.core import alerts
from steward.core.watch import watch
from steward.models.alerts import AlertEvent, AlertRule, AlertState, AlertStateEnum
from steward.models.metrics import PluginMetric
from steward.models.users import User, UserRole
@@ -77,6 +78,9 @@ def test_silence_is_marked_once_and_cleared_by_data(app):
assert st.sample_interval_s == pytest.approx(60)
assert st.last_sample_at == now - timedelta(minutes=18)
# Steward has been watching all along (#5441 covers a fresh start).
w = watch(app, "alert_silence")
w.since, w.last = now - timedelta(days=1), datetime.now(timezone.utc)
await alerts.check_silence(app)
await alerts.check_silence(app) # already marked: nothing more
await asyncio.sleep(0)
@@ -123,3 +127,60 @@ def test_silence_is_marked_once_and_cleared_by_data(app):
with patch.object(alerts, "_dispatch_notification", fake_dispatch):
asyncio.run(_go())
def test_a_series_quiet_only_while_steward_was_down_is_not_silent(app):
"""#5441: Steward comes back to a series last sampled 18 minutes ago. Its
outage is not the series' silence, so nothing is marked; once Steward has
watched for three of the series' gaps with nothing arriving, it is."""
from sqlalchemy import select
from steward.alerts import service as alert_svc
from steward.core import alerts
from steward.core.watch import watch
from steward.models.alerts import AlertEvent, AlertState
from steward.models.users import User, UserRole
sent = []
async def fake_dispatch(app_, notif_type, rule, value, event_id, note=None):
sent.append((rule.id, notif_type))
async def _go():
tag = uuid.uuid4().hex[:8]
res = f"back-{tag}"
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as s:
async with s.begin():
user = User(username=f"bk-{tag}", email=f"bk-{tag}@test", password_hash="x",
role=UserRole.admin)
s.add(user)
await s.flush()
fields = await alert_svc.rule_fields(
s, name=f"back {tag}", source_module="host_agent", resource_name=res,
metric_name="cpu_pct", operator=">", threshold=90, notify_no_data=True)
rule_id = (await alert_svc.add_rule(s, fields, created_by=user.id)).id
await s.flush()
st = (await s.execute(select(AlertState).where(AlertState.rule_id == rule_id))).scalar_one()
st.last_sample_at, st.sample_interval_s = now - timedelta(minutes=18), 60.0
async def marks():
async with app.db_sessionmaker() as s:
st = (await s.execute(select(AlertState).where(AlertState.rule_id == rule_id))).scalar_one()
n = len((await s.execute(select(AlertEvent).where(AlertEvent.rule_id == rule_id))).all())
return st.no_data_since, n
await alerts.check_silence(app) # the first run since Steward started
await asyncio.sleep(0)
assert await marks() == (None, 0) and (rule_id, "no_data") not in sent
# Six quiet minutes later (as if), Steward has watched long enough.
w = watch(app, "alert_silence")
w.since = now - timedelta(minutes=6)
await alerts.check_silence(app)
await asyncio.sleep(0)
assert await marks() == (w.since + timedelta(seconds=300), 1)
assert sent.count((rule_id, "no_data")) == 1
with patch.object(alerts, "_dispatch_notification", fake_dispatch):
asyncio.run(_go())
@@ -39,3 +39,14 @@ def test_a_clock_ahead_of_ours_reads_as_just_seen():
def test_empty_input():
assert seconds_since_seen([], NAMES, NOW) == []
def test_steward_s_own_outage_is_not_the_agent_s_silence():
"""#5441: agents that last reported 10 minutes ago, while Steward has only
been back for a minute, are a minute old; an agent heard since then keeps
its own age."""
back = NOW - timedelta(minutes=1)
regs = [FakeReg("h1", NOW - timedelta(minutes=10), NOW - timedelta(days=9)),
FakeReg("h2", NOW - timedelta(seconds=20), NOW - timedelta(days=9)),
FakeReg("h3", None, NOW - timedelta(days=9))]
assert seconds_since_seen(regs, NAMES, NOW, back) == [("nas", 60.0), ("web", 20.0), ("db", 60.0)]