refactor web pagination and add partitioned events, flap/timeout showcase agents
This commit is contained in:
@@ -58,6 +58,20 @@ def _apply_migrations(database_url: str) -> Iterator[None]:
|
||||
cfg.set_main_option("script_location", os.path.join(SERVER_ROOT, "alembic"))
|
||||
cfg.set_main_option("sqlalchemy.url", get_settings().sync_database_url)
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
# Partitions for `events` are created at runtime, not by the migration.
|
||||
async def _bootstrap() -> None:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from monlet_server.services.partitioning import ensure_event_partitions
|
||||
|
||||
eng = create_async_engine(database_url, future=True)
|
||||
try:
|
||||
await ensure_event_partitions(eng)
|
||||
finally:
|
||||
await eng.dispose()
|
||||
|
||||
asyncio.run(_bootstrap())
|
||||
yield
|
||||
|
||||
|
||||
@@ -83,7 +97,7 @@ async def _truncate_tables(engine) -> AsyncIterator[None]:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"TRUNCATE TABLE notification_outbox, incidents, events, checks, agents RESTART IDENTITY CASCADE"
|
||||
"TRUNCATE TABLE notification_outbox, incidents, events, event_ingest_dedup, checks, agents RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -9,26 +9,18 @@ from monlet_server.models import Incident
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_id_conflict_do_nothing(engine):
|
||||
async def test_event_id_dedup_conflict_do_nothing(engine):
|
||||
"""Global idempotency is enforced via event_ingest_dedup, not the partitioned events table."""
|
||||
eid = str(uuid4())
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO agents (agent_id, hostname, features, status, last_seen_at) "
|
||||
"VALUES ('a','h',jsonb_build_object('push', true, 'metrics', false),'alive', now())"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO events (event_id, agent_id, check_id, observed_at, status, exit_code, duration_ms, incident_key)"
|
||||
" VALUES (:e,'a','c', now(),'ok',0,1,'a:c')"
|
||||
),
|
||||
text("INSERT INTO event_ingest_dedup (event_id) VALUES (:e)"),
|
||||
{"e": eid},
|
||||
)
|
||||
r = await conn.execute(
|
||||
text(
|
||||
"INSERT INTO events (event_id, agent_id, check_id, observed_at, status, exit_code, duration_ms, incident_key)"
|
||||
" VALUES (:e,'a','c', now(),'ok',0,1,'a:c') ON CONFLICT (event_id) DO NOTHING RETURNING event_id"
|
||||
"INSERT INTO event_ingest_dedup (event_id) VALUES (:e) "
|
||||
"ON CONFLICT (event_id) DO NOTHING RETURNING event_id"
|
||||
),
|
||||
{"e": eid},
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from monlet_server.models import Agent, Event, Incident, NotificationOutbox
|
||||
from monlet_server.models import Agent, Check, Event, Incident, NotificationOutbox
|
||||
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
|
||||
from monlet_server.settings import reset_settings_cache
|
||||
|
||||
@@ -82,22 +82,75 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detector_prunes_bounded_history(
|
||||
app_client, auth_headers, engine, session, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("MONLET_EVENTS_RETENTION_MAX_ROWS", "2")
|
||||
async def test_detector_owns_liveness_check_and_events(app_client, auth_headers, engine, session):
|
||||
await app_client.post(
|
||||
"/api/v1/heartbeat", json=make_heartbeat("agent-flap"), headers=auth_headers
|
||||
)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
# Tick 1: alive → Check exists with status=ok, one event.
|
||||
await _tick(sm)
|
||||
session.expire_all()
|
||||
check = (
|
||||
await session.execute(
|
||||
select(Check).where(Check.agent_id == "agent-flap", Check.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert check.status == "ok"
|
||||
n_events = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.agent_id == "agent-flap", Event.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert n_events == 1
|
||||
|
||||
# Tick 2: still alive → no new event.
|
||||
await _tick(sm)
|
||||
session.expire_all()
|
||||
n_events = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.agent_id == "agent-flap", Event.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert n_events == 1
|
||||
|
||||
# Push agent into dead → transition event written, Check updates.
|
||||
await session.execute(
|
||||
update(Agent)
|
||||
.where(Agent.agent_id == "agent-flap")
|
||||
.values(last_seen_at=datetime.now(UTC) - timedelta(minutes=10))
|
||||
)
|
||||
await session.commit()
|
||||
await _tick(sm)
|
||||
session.expire_all()
|
||||
check = (
|
||||
await session.execute(
|
||||
select(Check).where(Check.agent_id == "agent-flap", Check.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert check.status == "critical"
|
||||
n_events = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.agent_id == "agent-flap", Event.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert n_events == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detector_prunes_outbox(app_client, auth_headers, engine, session, monkeypatch):
|
||||
monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "2")
|
||||
reset_settings_cache()
|
||||
try:
|
||||
await app_client.post(
|
||||
"/api/v1/heartbeat", json=make_heartbeat("agent-prune"), headers=auth_headers
|
||||
)
|
||||
events = [make_event(check_id="prune_check") for _ in range(5)]
|
||||
await app_client.post(
|
||||
"/api/v1/events",
|
||||
json={"agent_id": "agent-prune", "events": events},
|
||||
headers=auth_headers,
|
||||
)
|
||||
critical = make_event(check_id="critical_prune", status="critical", exit_code=2)
|
||||
await app_client.post(
|
||||
"/api/v1/events",
|
||||
@@ -129,7 +182,6 @@ async def test_detector_prunes_bounded_history(
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
await _tick(sm)
|
||||
|
||||
assert (await session.execute(select(func.count()).select_from(Event))).scalar_one() == 2
|
||||
terminal_outbox = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
|
||||
125
server/tests/test_partitioning.py
Normal file
125
server/tests/test_partitioning.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from monlet_server.services.partitioning import (
|
||||
_month_start,
|
||||
_partition_name,
|
||||
_shift_months,
|
||||
drop_old_event_partitions,
|
||||
ensure_event_partitions,
|
||||
)
|
||||
from monlet_server.settings import reset_settings_cache
|
||||
|
||||
|
||||
async def _partitions(engine) -> set[str]:
|
||||
async with engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT c.relname FROM pg_inherits i "
|
||||
"JOIN pg_class c ON c.oid=i.inhrelid "
|
||||
"JOIN pg_class p ON p.oid=i.inhparent "
|
||||
"WHERE p.relname='events'"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_creates_current_and_future_partitions(engine, monkeypatch):
|
||||
monkeypatch.setenv("MONLET_EVENTS_FUTURE_PARTITIONS", "2")
|
||||
reset_settings_cache()
|
||||
try:
|
||||
await ensure_event_partitions(engine)
|
||||
today = _month_start(datetime.now(UTC).date())
|
||||
for i in range(0, 3):
|
||||
assert _partition_name(_shift_months(today, i)) in await _partitions(engine)
|
||||
finally:
|
||||
reset_settings_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_old_partitions_uses_retention(engine, monkeypatch):
|
||||
monkeypatch.setenv("MONLET_EVENTS_RETENTION_MONTHS", "1")
|
||||
reset_settings_cache()
|
||||
try:
|
||||
# Create an old partition directly.
|
||||
old = date(2020, 1, 1)
|
||||
name = _partition_name(old)
|
||||
end = _shift_months(old, 1)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
f"CREATE TABLE IF NOT EXISTS {name} PARTITION OF events "
|
||||
f"FOR VALUES FROM ('{old.isoformat()} UTC') TO ('{end.isoformat()} UTC');"
|
||||
)
|
||||
)
|
||||
assert name in await _partitions(engine)
|
||||
await drop_old_event_partitions(engine)
|
||||
assert name not in await _partitions(engine)
|
||||
finally:
|
||||
reset_settings_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedup_prevents_replay_via_ingest(app_client, auth_headers, session):
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from monlet_server.models import Event
|
||||
|
||||
from ._helpers import make_event, make_heartbeat
|
||||
|
||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||
ev = make_event(status="critical", exit_code=2)
|
||||
|
||||
r1 = await app_client.post(
|
||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||
)
|
||||
assert r1.json() == {"accepted": 1, "deduplicated": 0}
|
||||
|
||||
# Replay the exact same event_id long after — dedup must block ingestion entirely,
|
||||
# never touching checks/incidents/outbox/events.
|
||||
r2 = await app_client.post(
|
||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||
)
|
||||
assert r2.json() == {"accepted": 0, "deduplicated": 1}
|
||||
|
||||
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
|
||||
assert n == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_late_dedup_does_not_keep_replay_doors_open(engine, monkeypatch):
|
||||
"""Dedup retention TTL must be at least documented agent spool window."""
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from monlet_server.models import EventIngestDedup
|
||||
from monlet_server.services.detector import _prune_history
|
||||
|
||||
monkeypatch.setenv("MONLET_EVENT_DEDUP_RETENTION_DAYS", "1")
|
||||
reset_settings_cache()
|
||||
try:
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sm() as s:
|
||||
old = datetime.now(UTC) - timedelta(days=5)
|
||||
s.add(EventIngestDedup(event_id=uuid4(), received_at=old))
|
||||
s.add(EventIngestDedup(event_id=uuid4(), received_at=datetime.now(UTC)))
|
||||
await s.commit()
|
||||
|
||||
async with sm() as s:
|
||||
await _prune_history(s)
|
||||
await s.commit()
|
||||
|
||||
async with sm() as s:
|
||||
n = (await s.execute(select(func.count()).select_from(EventIngestDedup))).scalar_one()
|
||||
assert n == 1
|
||||
finally:
|
||||
reset_settings_cache()
|
||||
Reference in New Issue
Block a user