Files
Spoolman/tests/test_metrics.py
Donkie a35331e50a Stop exporting archived and deleted spools in /metrics
The metrics refresh only ever set label sets, never removed them, so a
spool that was archived or deleted (or a deleted filament, or a renamed
filament's old info labels) kept being exported with its last values
until the server restarted.

Each refresh now drops the label sets the database no longer has. The
current values are set before the stale ones are removed rather than
clearing the gauges first, because /metrics is served from a worker
thread and a scrape landing mid-refresh would otherwise see live series
briefly disappear.
2026-10-10 14:32:47 +02:00

71 lines
3.0 KiB
Python

"""Tests for the Prometheus metrics refresh dropping spools and filaments that are gone."""
from datetime import datetime, timezone
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.pool import StaticPool
from spoolman.database import models
from spoolman.prometheus.metrics import filament_metrics, registry, spool_metrics
async def refresh(engine: AsyncEngine) -> None:
"""Run one metrics refresh in a fresh session, the way the scheduler does."""
async with AsyncSession(engine) as db:
await filament_metrics(db)
await spool_metrics(db)
def used(spool_id: int, filament_id: int) -> float | None:
"""Return a spool's exported used weight, or None if it is not exported."""
labels = {"spool_id": str(spool_id), "filament_id": str(filament_id)}
return registry.get_sample_value("spoolman_spool_weight_used", labels)
def density(filament_id: int) -> float | None:
"""Return a filament's exported density, or None if it is not exported."""
return registry.get_sample_value("spoolman_filament_density", {"filament_id": str(filament_id)})
def info(filament_id: int, name: str) -> float | None:
"""Return a vendorless filament's exported info sample, or None if it is not exported."""
labels = {"filament_id": str(filament_id), "vendor": "-", "name": name, "material": "None", "color": "None"}
return registry.get_sample_value("spoolman_filament_info", labels)
@pytest.mark.asyncio
async def test_refresh_drops_archived_and_deleted() -> None:
"""Archived and deleted spools, and deleted filaments, stop being exported on the next refresh."""
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
async with engine.begin() as conn:
await conn.run_sync(models.Base.metadata.create_all)
now = datetime(2026, 1, 1, tzinfo=timezone.utc).replace(tzinfo=None) # stored naive, as UTC
async with AsyncSession(engine, expire_on_commit=False) as db:
kept = models.Filament(registered=now, name="Kept", density=1.24, diameter=1.75)
gone = models.Filament(registered=now, name="Gone", density=1.27, diameter=1.75)
spools = [models.Spool(filament=kept, registered=now, used_weight=w) for w in (10, 20, 30)]
db.add_all([kept, gone, *spools])
await db.commit()
await refresh(engine)
assert [used(s.id, kept.id) for s in spools] == [10, 20, 30]
assert density(gone.id) == 1.27
assert info(gone.id, "Gone") == 1
async with AsyncSession(engine) as db:
(await db.get(models.Spool, spools[1].id)).archived = True
await db.delete(await db.get(models.Spool, spools[2].id))
await db.delete(await db.get(models.Filament, gone.id))
await db.commit()
await refresh(engine)
assert [used(s.id, kept.id) for s in spools] == [10, None, None]
assert density(kept.id) == 1.24
assert density(gone.id) is None
assert info(kept.id, "Kept") == 1
assert info(gone.id, "Gone") is None
await engine.dispose()