126 lines
4.2 KiB
Python
126 lines
4.2 KiB
Python
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()
|