588 lines
20 KiB
Python
588 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from modelforge_api.domain.observability import (
|
|
AlertAction,
|
|
AlertState,
|
|
MaintenanceWindowCreate,
|
|
MetricDefinition,
|
|
MetricRegistry,
|
|
SLOPolicyCreate,
|
|
SLOState,
|
|
TelemetryType,
|
|
metrics,
|
|
)
|
|
from modelforge_api.persistence.models import (
|
|
Accelerator,
|
|
AcceleratorTelemetryLatest,
|
|
AlertHistoryEvent,
|
|
AlertRuleRevision,
|
|
Base,
|
|
CapacityAggregate,
|
|
CapacitySnapshot,
|
|
ComputeNode,
|
|
GatewayRequest,
|
|
OperationalAlert,
|
|
OperationalIncident,
|
|
ServiceLevelIndicator,
|
|
SLOPolicyRevision,
|
|
StorageRoot,
|
|
StorageVolumeState,
|
|
)
|
|
from modelforge_api.services.observability import ObservabilityService
|
|
from modelforge_api.settings import Settings
|
|
|
|
|
|
@pytest.fixture
|
|
def session() -> Session:
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
with Session(engine) as value:
|
|
yield value
|
|
|
|
|
|
def service(session: Session) -> ObservabilityService:
|
|
return ObservabilityService(
|
|
session,
|
|
Settings(observability_monitor_enabled=False),
|
|
actor="m14-test",
|
|
)
|
|
|
|
|
|
def production_node(session: Session, *, liveness: str = "offline") -> ComputeNode:
|
|
node = ComputeNode(
|
|
key=f"m14-node-{uuid.uuid4()}",
|
|
hostname="gpu_node.example.test",
|
|
display_name="GPU Node",
|
|
identity_source="node_agent",
|
|
status="active",
|
|
inventory={},
|
|
enabled=True,
|
|
production_eligible=True,
|
|
lab_eligible=True,
|
|
benchmark_eligible=True,
|
|
liveness_state=liveness,
|
|
last_heartbeat_at=datetime.now(UTC),
|
|
total_ram_bytes=64 * 1024**3,
|
|
)
|
|
session.add(node)
|
|
session.flush()
|
|
return node
|
|
|
|
|
|
def test_metric_contract_rejects_unbounded_labels_and_exports_histogram() -> None:
|
|
registry = MetricRegistry()
|
|
registry.define(
|
|
MetricDefinition(
|
|
name="modelforge_test_duration_seconds",
|
|
type=TelemetryType.HISTOGRAM,
|
|
help="Bounded test duration",
|
|
labels=("route_class",),
|
|
)
|
|
)
|
|
registry.observe("modelforge_test_duration_seconds", {"route_class": "gateway"}, 0.25)
|
|
|
|
output = registry.render()
|
|
|
|
assert "# TYPE modelforge_test_duration_seconds histogram" in output
|
|
assert 'modelforge_test_duration_seconds_bucket{le="0.25",route_class="gateway"} 1' in output
|
|
assert 'modelforge_test_duration_seconds_bucket{le="+Inf",route_class="gateway"} 1' in output
|
|
assert 'modelforge_test_duration_seconds_sum{route_class="gateway"} 0.25' in output
|
|
with pytest.raises(ValueError, match="bounded and non-sensitive"):
|
|
registry.define(
|
|
MetricDefinition(
|
|
name="modelforge_invalid_total",
|
|
type=TelemetryType.COUNTER,
|
|
help="Invalid metric",
|
|
labels=("request_id",),
|
|
)
|
|
)
|
|
counter = MetricRegistry()
|
|
counter.define(
|
|
MetricDefinition(
|
|
name="modelforge_test_requests_total",
|
|
type=TelemetryType.COUNTER,
|
|
help="Bounded request outcomes",
|
|
labels=("status",),
|
|
)
|
|
)
|
|
counter.increment("modelforge_test_requests_total", {"status": "ok"}, 2)
|
|
assert counter.samples("modelforge_test_requests_total") == [({"status": "ok"}, 2)]
|
|
with pytest.raises(ValueError, match="requires labels"):
|
|
counter.increment("modelforge_test_requests_total", {"payload": "secret"})
|
|
|
|
|
|
def test_defaults_are_typed_versioned_and_lab_has_no_error_budget(session: Session) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
|
|
assert session.scalar(select(func.count()).select_from(ServiceLevelIndicator)) == 11
|
|
assert session.scalar(select(func.count()).select_from(SLOPolicyRevision)) == 9
|
|
# 12 M14 operational rules plus the five M15 recovery rules.
|
|
assert session.scalar(select(func.count()).select_from(AlertRuleRevision)) == 17
|
|
recovery_rules = {
|
|
item.key: item.alert_type
|
|
for item in session.scalars(
|
|
select(AlertRuleRevision).where(AlertRuleRevision.key.like("recovery.%"))
|
|
)
|
|
}
|
|
assert recovery_rules == {
|
|
"recovery.backup-stale": "BACKUP_STALE",
|
|
"recovery.backup-failed": "BACKUP_FAILED",
|
|
"recovery.backup-verification-failed": "BACKUP_VERIFICATION_FAILED",
|
|
"recovery.restore-failed": "RESTORE_FAILED",
|
|
"recovery.readiness-degraded": "RECOVERY_READINESS_DEGRADED",
|
|
}
|
|
|
|
original = session.scalar(
|
|
select(SLOPolicyRevision).where(SLOPolicyRevision.key == "gateway.production")
|
|
)
|
|
assert original is not None
|
|
revised = subject.create_policy(
|
|
SLOPolicyCreate(
|
|
key=original.key,
|
|
sli_definition_id=original.sli_definition_id,
|
|
objective=0.995,
|
|
rolling_window_seconds=original.rolling_window_seconds,
|
|
minimum_sample_count=original.minimum_sample_count,
|
|
severity=original.severity,
|
|
environment="PRODUCTION",
|
|
effective_from=datetime.now(UTC),
|
|
rationale="A deliberate test revision preserving immutable history.",
|
|
created_by="test-operator",
|
|
)
|
|
)
|
|
session.refresh(original)
|
|
|
|
assert revised.revision == 2
|
|
assert revised.active is True
|
|
assert original.active is False
|
|
assert original.fingerprint != revised.fingerprint
|
|
|
|
evaluations = subject.evaluate_slos(datetime.now(UTC) + timedelta(seconds=1))
|
|
vision = next(item for item in evaluations if item.policy_key == "vision.embedding.lab")
|
|
production = next(item for item in evaluations if item.policy_key == "gateway.production")
|
|
assert vision.state is SLOState.INSUFFICIENT_DATA
|
|
assert vision.allowed_bad is None
|
|
assert vision.remaining_bad is None
|
|
assert production.allowed_bad is not None
|
|
|
|
|
|
def test_slo_evaluation_tracks_population_latency_and_burn_rates(session: Session) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
now = datetime.now(UTC)
|
|
for index in range(20):
|
|
completed = index != 19
|
|
session.add(
|
|
GatewayRequest(
|
|
request_id=uuid.uuid4(),
|
|
capability_key="rag.embedding",
|
|
capability_version=1,
|
|
status="completed" if completed else "failed",
|
|
priority="interactive",
|
|
input_sha256=f"{index:064x}",
|
|
input_count=1,
|
|
total_latency_ms=1500.0 if index == 18 else (100.0 if completed else None),
|
|
failure_code=None if completed else "RUNTIME_FAILED",
|
|
decision_evidence={"source": "m14-test"},
|
|
created_at=now - timedelta(seconds=index),
|
|
finished_at=now - timedelta(seconds=index) if completed else None,
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
results = subject.evaluate_slos(now + timedelta(seconds=1))
|
|
success = next(
|
|
item for item in results if item.policy_key == "rag.embedding.production.success"
|
|
)
|
|
latency = next(
|
|
item for item in results if item.policy_key == "rag.embedding.production.latency"
|
|
)
|
|
|
|
assert success.sample_count == 20
|
|
assert success.bad_count == 1
|
|
assert success.state is SLOState.BREACHED
|
|
assert success.long_burn_rate == pytest.approx(5.0)
|
|
assert latency.sample_count == 19
|
|
assert latency.bad_count == 1
|
|
assert latency.observed_value == pytest.approx(240.0)
|
|
assert latency.short_burn_rate == pytest.approx((1 / 19) / 0.05)
|
|
|
|
|
|
def test_slo_window_at_risk_stale_and_budget_recovery(session: Session) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
now = datetime.now(UTC)
|
|
node = production_node(session, liveness="online")
|
|
node.last_heartbeat_at = now - timedelta(seconds=100)
|
|
for index in range(100):
|
|
completed = index < 98
|
|
session.add(
|
|
GatewayRequest(
|
|
request_id=uuid.uuid4(),
|
|
capability_key="other.capability",
|
|
capability_version=1,
|
|
status="completed" if completed else "failed",
|
|
priority="interactive",
|
|
input_sha256=f"{index + 1000:064x}",
|
|
input_count=1,
|
|
total_latency_ms=100 if completed else None,
|
|
failure_code=None if completed else "CONTROLLED_FAILURE",
|
|
decision_evidence={},
|
|
created_at=now - timedelta(minutes=index),
|
|
)
|
|
)
|
|
session.add(
|
|
GatewayRequest(
|
|
request_id=uuid.uuid4(),
|
|
capability_key="other.capability",
|
|
capability_version=1,
|
|
status="failed",
|
|
priority="interactive",
|
|
input_sha256="f" * 64,
|
|
input_count=1,
|
|
failure_code="OLD_FAILURE",
|
|
decision_evidence={},
|
|
created_at=now - timedelta(days=2),
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
first = subject.evaluate_slos(now)
|
|
gateway = next(item for item in first if item.policy_key == "gateway.production")
|
|
freshness = next(item for item in first if item.policy_key == "gpu_node.production.freshness")
|
|
assert gateway.state is SLOState.AT_RISK
|
|
assert gateway.sample_count == 100
|
|
assert gateway.bad_count == 2
|
|
assert gateway.remaining_bad == 0
|
|
assert gateway.long_burn_rate == pytest.approx(2.0)
|
|
assert freshness.state is SLOState.STALE
|
|
|
|
recovered_at = now + timedelta(hours=25)
|
|
node.last_heartbeat_at = recovered_at
|
|
for index in range(20):
|
|
session.add(
|
|
GatewayRequest(
|
|
request_id=uuid.uuid4(),
|
|
capability_key="other.capability",
|
|
capability_version=1,
|
|
status="completed",
|
|
priority="interactive",
|
|
input_sha256=f"{index + 2000:064x}",
|
|
input_count=1,
|
|
total_latency_ms=100,
|
|
decision_evidence={},
|
|
created_at=recovered_at - timedelta(seconds=index),
|
|
)
|
|
)
|
|
session.commit()
|
|
recovered = subject.evaluate_slos(recovered_at)
|
|
gateway_recovered = next(item for item in recovered if item.policy_key == "gateway.production")
|
|
assert gateway_recovered.state is SLOState.HEALTHY
|
|
assert gateway_recovered.bad_count == 0
|
|
assert gateway_recovered.remaining_bad == pytest.approx(0.2)
|
|
|
|
|
|
def test_alert_pending_firing_acknowledged_resolved_and_correlated(session: Session) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
node = production_node(session)
|
|
session.commit()
|
|
start = datetime.now(UTC) - timedelta(seconds=30)
|
|
|
|
first = next(
|
|
item for item in subject.evaluate_alerts(start) if item.alert_type == "NODE_OFFLINE"
|
|
)
|
|
assert first.state is AlertState.PENDING
|
|
firing = next(
|
|
item
|
|
for item in subject.evaluate_alerts(start + timedelta(seconds=11))
|
|
if item.id == first.id
|
|
)
|
|
assert firing.state is AlertState.FIRING
|
|
assert firing.occurrence_count == 2
|
|
assert session.scalar(select(func.count()).select_from(OperationalIncident)) == 1
|
|
|
|
acknowledged = subject.acknowledge(
|
|
firing.id, AlertAction(actor="on-call", reason="Investigating node connectivity")
|
|
)
|
|
assert acknowledged.state is AlertState.ACKNOWLEDGED
|
|
node.liveness_state = "online"
|
|
session.commit()
|
|
resolved = next(
|
|
item
|
|
for item in subject.evaluate_alerts(datetime.now(UTC) + timedelta(seconds=1))
|
|
if item.id == first.id
|
|
)
|
|
assert resolved.state is AlertState.RESOLVED
|
|
history = list(
|
|
session.scalars(
|
|
select(AlertHistoryEvent)
|
|
.where(AlertHistoryEvent.alert_id == first.id)
|
|
.order_by(AlertHistoryEvent.occurred_at)
|
|
)
|
|
)
|
|
assert [item.to_state for item in history] == [
|
|
"PENDING",
|
|
"FIRING",
|
|
"ACKNOWLEDGED",
|
|
"RESOLVED",
|
|
]
|
|
incident = session.scalar(select(OperationalIncident))
|
|
assert incident is not None and incident.state == "RESOLVED"
|
|
|
|
|
|
def test_capability_rules_separate_lab_from_production(session: Session) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
production_rule = session.scalar(
|
|
select(AlertRuleRevision).where(AlertRuleRevision.key == "capability.unavailable")
|
|
)
|
|
lab_rule = session.scalar(
|
|
select(AlertRuleRevision).where(AlertRuleRevision.key == "capability.lab-unavailable")
|
|
)
|
|
|
|
assert production_rule is not None
|
|
assert production_rule.condition == {"production_only": True}
|
|
assert lab_rule is not None
|
|
assert lab_rule.condition == {"lab_only": True}
|
|
|
|
|
|
def test_overview_reports_observability_persistence_degradation(session: Session) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
metrics.gauge("modelforge_observability_degraded", {}, 1)
|
|
try:
|
|
assert subject.overview().status == "OBSERVABILITY_DEGRADED"
|
|
finally:
|
|
metrics.gauge("modelforge_observability_degraded", {}, 0)
|
|
|
|
|
|
def test_maintenance_window_suppresses_matching_alert(session: Session) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
node = production_node(session)
|
|
session.commit()
|
|
now = datetime.now(UTC)
|
|
subject.create_maintenance_window(
|
|
MaintenanceWindowCreate(
|
|
name="GPU Node maintenance rehearsal",
|
|
starts_at=now - timedelta(minutes=1),
|
|
ends_at=now + timedelta(minutes=10),
|
|
matcher={"rule_key": "node.offline", "subject_ref": str(node.id)},
|
|
reason="M14 deterministic suppression verification",
|
|
created_by="test-operator",
|
|
)
|
|
)
|
|
|
|
alert = next(item for item in subject.evaluate_alerts(now) if item.alert_type == "NODE_OFFLINE")
|
|
|
|
assert alert.state is AlertState.SUPPRESSED
|
|
assert alert.suppressed_until is not None
|
|
assert alert.suppressed_until.replace(tzinfo=UTC) == now + timedelta(minutes=10)
|
|
assert session.scalar(select(func.count()).select_from(OperationalIncident)) == 0
|
|
|
|
|
|
def test_alert_cooldown_reuses_fingerprint_and_storage_threshold_fires(
|
|
session: Session,
|
|
) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
node = production_node(session)
|
|
storage = StorageRoot(
|
|
compute_node_id=node.id,
|
|
name="models",
|
|
purpose="model_artifacts",
|
|
path="D:/models",
|
|
status="ready",
|
|
writable=True,
|
|
capacity_bytes=100 * 1024**3,
|
|
free_bytes=12 * 1024**3,
|
|
reserve_bytes=10 * 1024**3,
|
|
reserve_percent=10,
|
|
validation_details={},
|
|
)
|
|
session.add(storage)
|
|
session.commit()
|
|
start = datetime.now(UTC) - timedelta(seconds=30)
|
|
first = subject.evaluate_alerts(start)
|
|
node_alert = next(item for item in first if item.alert_type == "NODE_OFFLINE")
|
|
storage_alert = next(item for item in first if item.alert_type == "STORAGE_LOW")
|
|
assert storage_alert.state is AlertState.FIRING
|
|
firing = next(
|
|
item
|
|
for item in subject.evaluate_alerts(start + timedelta(seconds=11))
|
|
if item.id == node_alert.id
|
|
)
|
|
assert firing.state is AlertState.FIRING
|
|
node.liveness_state = "online"
|
|
storage.free_bytes = 90 * 1024**3
|
|
session.commit()
|
|
resolved = next(
|
|
item
|
|
for item in subject.evaluate_alerts(start + timedelta(seconds=20))
|
|
if item.id == node_alert.id
|
|
)
|
|
node.liveness_state = "offline"
|
|
session.commit()
|
|
during_cooldown = next(
|
|
item
|
|
for item in subject.evaluate_alerts(start + timedelta(seconds=30))
|
|
if item.id == node_alert.id
|
|
)
|
|
assert during_cooldown.state is AlertState.RESOLVED
|
|
recurred = next(
|
|
item
|
|
for item in subject.evaluate_alerts(start + timedelta(seconds=321))
|
|
if item.id == node_alert.id
|
|
)
|
|
assert recurred.state is AlertState.PENDING
|
|
assert recurred.fingerprint == resolved.fingerprint
|
|
|
|
|
|
def test_incident_groups_evidenced_downstream_only(session: Session) -> None:
|
|
subject = service(session)
|
|
subject.ensure_defaults()
|
|
node = production_node(session)
|
|
session.commit()
|
|
now = datetime.now(UTC) - timedelta(seconds=20)
|
|
subject.evaluate_alerts(now)
|
|
node_alert = next(
|
|
item
|
|
for item in subject.evaluate_alerts(now + timedelta(seconds=11))
|
|
if item.alert_type == "NODE_OFFLINE"
|
|
)
|
|
capability_rule = session.scalar(
|
|
select(AlertRuleRevision).where(AlertRuleRevision.key == "capability.unavailable")
|
|
)
|
|
assert capability_rule is not None
|
|
downstream = OperationalAlert(
|
|
rule_id=capability_rule.id,
|
|
fingerprint="d" * 64,
|
|
alert_type="CAPABILITY_UNAVAILABLE",
|
|
severity="CRITICAL",
|
|
state="FIRING",
|
|
source="capability_deployment",
|
|
subject_type="capability",
|
|
subject_ref="rag.embedding@1",
|
|
summary="Stable capability cannot serve",
|
|
details={},
|
|
first_seen_at=now,
|
|
last_seen_at=now,
|
|
firing_at=now,
|
|
occurrence_count=1,
|
|
)
|
|
unrelated = OperationalAlert(
|
|
rule_id=capability_rule.id,
|
|
fingerprint="e" * 64,
|
|
alert_type="RUNTIME_CRASH_LOOP",
|
|
severity="CRITICAL",
|
|
state="FIRING",
|
|
source="serving_jobs",
|
|
subject_type="service",
|
|
subject_ref="runtime",
|
|
summary="Runtime failures",
|
|
details={},
|
|
first_seen_at=now,
|
|
last_seen_at=now,
|
|
firing_at=now,
|
|
occurrence_count=1,
|
|
)
|
|
session.add_all((downstream, unrelated))
|
|
session.flush()
|
|
subject._correlate_incident(downstream, {"details": {"node_id": str(node.id)}}, now)
|
|
subject._correlate_incident(unrelated, {"details": {}}, now)
|
|
session.commit()
|
|
root = session.get(OperationalAlert, node_alert.id)
|
|
assert root is not None
|
|
assert downstream.incident_id == root.incident_id
|
|
assert unrelated.incident_id is None
|
|
assert session.scalar(select(func.count()).select_from(OperationalIncident)) == 1
|
|
|
|
|
|
def test_capacity_trends_forecast_refusal_and_retention(session: Session) -> None:
|
|
subject = service(session)
|
|
node = production_node(session, liveness="online")
|
|
accelerator = Accelerator(
|
|
compute_node_id=node.id,
|
|
device_index=0,
|
|
device_uuid="GPU-M14",
|
|
name="Test GPU",
|
|
total_vram_bytes=16 * 1024**3,
|
|
status="active",
|
|
capabilities={},
|
|
)
|
|
session.add(accelerator)
|
|
session.flush()
|
|
telemetry = AcceleratorTelemetryLatest(
|
|
accelerator_id=accelerator.id,
|
|
used_vram_bytes=4 * 1024**3,
|
|
free_vram_bytes=12 * 1024**3,
|
|
availability={},
|
|
observed_at=datetime.now(UTC),
|
|
received_at=datetime.now(UTC),
|
|
)
|
|
storage = StorageVolumeState(
|
|
compute_node_id=node.id,
|
|
purpose="model_artifacts",
|
|
path="D:/models",
|
|
total_bytes=2 * 1024**4,
|
|
used_bytes=1024**4,
|
|
free_bytes=1024**4,
|
|
availability={},
|
|
observed_at=datetime.now(UTC),
|
|
received_at=datetime.now(UTC),
|
|
)
|
|
session.add_all((telemetry, storage))
|
|
session.commit()
|
|
base = datetime.now(UTC) - timedelta(hours=2)
|
|
|
|
for index in range(12):
|
|
observed = base + timedelta(minutes=index * 10)
|
|
telemetry.used_vram_bytes = (4 * 1024**3) + index * 1024**2
|
|
telemetry.received_at = observed
|
|
telemetry.observed_at = observed
|
|
storage.free_bytes = (1024**4) - index * 1024**3
|
|
storage.observed_at = observed
|
|
storage.received_at = observed
|
|
session.commit()
|
|
subject.collect_capacity(observed)
|
|
|
|
trend = subject.capacity_trend(node.id, hours=4)
|
|
assert trend.status == "AVAILABLE"
|
|
assert trend.sample_count == 12
|
|
assert trend.metrics["gpu_schedulable_p95"] is not None
|
|
assert trend.metrics["external_gpu_max"] is not None
|
|
assert trend.forecast["status"] == "AVAILABLE"
|
|
assert trend.forecast["storage_change_bytes_per_day"] < 0
|
|
|
|
snapshots = list(
|
|
session.scalars(select(CapacitySnapshot).order_by(CapacitySnapshot.observed_at))
|
|
)
|
|
for item in snapshots[1:]:
|
|
session.delete(item)
|
|
session.commit()
|
|
refused = subject.capacity_trend(node.id, hours=4)
|
|
assert refused.status == "INSUFFICIENT_DATA"
|
|
assert refused.forecast == {"status": "INSUFFICIENT_DATA"}
|
|
|
|
old = snapshots[0]
|
|
old.observed_at = datetime.now(UTC) - timedelta(days=8)
|
|
session.add(old)
|
|
session.commit()
|
|
retention = subject.apply_retention(datetime.now(UTC))
|
|
assert retention == {
|
|
"aggregates_created": 1,
|
|
"raw_deleted": 1,
|
|
"aggregates_deleted": 0,
|
|
}
|
|
assert session.scalar(select(func.count()).select_from(CapacityAggregate)) == 1
|
|
assert session.scalar(select(func.count()).select_from(CapacitySnapshot)) == 0
|