Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-09-01 09:34:26

0001 """Publish the epicprod PanDA platform-health component to Snapper.
0002 
0003 Design: docs/SNAPPER_PLATFORM.md. Each publication carries the owner's
0004 assessed reading of the platform at one instant, in five groups:
0005 
0006 - database: PanDA database connections, longest transaction, and the
0007   jobsactive4 table's health, read from the PanDA database;
0008 - heartbeats: running jobs by heartbeat age, heartbeats received and
0009   jobs started in the publication interval, the heartbeat yield
0010   against the running population's expected rate, and the same yield
0011   as a ratio of sums over a window of two heartbeat periods (the
0012   per-interval yield beats against the pilot's heartbeat phase; the
0013   window is the assessed figure);
0014 - server: one timed liveness request to the PanDA server;
0015 - server_host: the pandaserver01 reporter's record when one has been
0016   delivered (docs/PANDA_SERVER_REPORTER.md), else absent;
0017 - monitor_host: the monitor's own host and tier, measured locally.
0018 
0019 Load (jobs in flight, cores) and consequences (kills, outcomes) are
0020 not recorded here: the PanDA activity and error-state components
0021 carry them at the same cadence, and the Platform view reads them from
0022 there. Every measurement that fails records its failure in place and
0023 the publication proceeds; a source that cannot be read is an assessed
0024 unavailable state, never a silent omission.
0025 """
0026 
0027 import json
0028 import os
0029 import subprocess
0030 import time
0031 from dataclasses import dataclass
0032 import logging
0033 from datetime import datetime, timedelta
0034 from datetime import timezone as dt_timezone
0035 
0036 from django.db import connection, connections, transaction
0037 from django.utils import timezone
0038 
0039 logger = logging.getLogger(__name__)
0040 
0041 from snapper_ai.services import (
0042     ComponentUpdate,
0043     publish_component,
0044     register_component,
0045 )
0046 
0047 from .panda.constants import PANDA_SCHEMA
0048 from .snapper_panda import _bounded_site_names, _canary_queue_names
0049 
0050 PUBLISHER_IDENTITY = "swf-monitor:panda-platform"
0051 ASSESSMENT_POLICY_VERSION = "swf-panda-platform-v1"
0052 COMPONENT_NAME = "platform"
0053 SCOPE = "epicprod"
0054 
0055 MAX_SITES = 32
0056 MAX_APPS = 16
0057 MAX_SERIALIZED_BYTES = 64 * 1024
0058 # Heartbeat-age tiers in minutes: part of the recorded vocabulary (the
0059 # view's curves are named by them), so a constant, not a knob.
0060 STALENESS_TIERS_MINUTES = (30, 60, 120)
0061 # A fresh component starts its first interval this far back rather
0062 # than swallowing all recorded history into one interval.
0063 FIRST_INTERVAL_MINUTES = 5
0064 # Units and volumes measured on the monitor host.
0065 MONITOR_UNITS = {
0066     "asgi": "swf-monitor-mcp-asgi",
0067     "ops_agent": "epicprod-ops-agent",
0068     "httpd": "httpd",
0069 }
0070 
0071 # Operator-visible configuration, seeded at first read (SysConfig
0072 # sets things; no knob hides behind a code default).
0073 CONFIG_DEFAULTS = {
0074     "platform_panda_server_url": "https://pandaserver01.sdcc.bnl.gov:25443",
0075     "platform_server_timeout_seconds": 10,
0076     "platform_pandamon_url": "https://pandamon01.sdcc.bnl.gov",
0077     "platform_pandamon_timeout_seconds": 20,
0078     "platform_heartbeat_period_seconds": 1800,
0079     "platform_monitor_volumes": ["/", "/var", "/data"],
0080     "platform_reporter_stale_seconds": 900,
0081     # Assessment thresholds.
0082     "platform_yield_warn_below": 0.5,
0083     "platform_connections_warn_fraction": 0.8,
0084     "platform_latency_warn_ms": 2000,
0085     "platform_pandamon_warn_ms": 20000,
0086     "platform_stale_warn_fraction": 0.25,
0087     "platform_stale_warn_tier_minutes": 60,
0088     "platform_volume_warn_percent": 90,
0089 }
0090 
0091 PLATFORM_REGISTRATION = {
0092     "title": "PanDA platform health",
0093     "description": (
0094         "Five-minute assessed readings of the PanDA platform: database "
0095         "connections and table health, running-job heartbeat ages and "
0096         "the heartbeat yield over the publication interval, PanDA "
0097         "server liveness latency, the server host's own report when "
0098         "delivered, and the monitor host's tier. Load and consequences "
0099         "are recorded by the PanDA activity and error-state components."
0100     ),
0101     "visibility": "public",
0102     "owning_subsystem": "SWF PanDA production monitor",
0103     "assessment_policy": ASSESSMENT_POLICY_VERSION,
0104     "max_serialized_bytes": MAX_SERIALIZED_BYTES,
0105     "quantities": {
0106         "interval": {
0107             "path": "interval",
0108             "type": "object",
0109             "required": True,
0110             "kind": "window",
0111             "description": (
0112                 "The half-open interval (start, end] the interval "
0113                 "quantities (heartbeats received, starts) cover; runs "
0114                 "from the previous publication's source time."
0115             ),
0116         },
0117         "database": {
0118             "path": "database",
0119             "type": "object",
0120             "required": True,
0121             "kind": "gauge",
0122             "description": (
0123                 "PanDA database: connections total/active/idle/waiting "
0124                 "against max_connections, the longest open transaction "
0125                 "in seconds, jobsactive4 live and dead tuples and minutes "
0126                 "since its last autovacuum, and connections by "
0127                 "application and state (bounded map, remainder in "
0128                 "'other'). An 'error' key records a failed read."
0129             ),
0130         },
0131         "database_by_app": {
0132             "path": "database.by_app",
0133             "type": "object",
0134             "required": False,
0135             "kind": "bounded_map",
0136             "max_items": MAX_APPS,
0137             "description": "Connections by application name and state.",
0138         },
0139         "heartbeats": {
0140             "path": "heartbeats",
0141             "type": "object",
0142             "required": True,
0143             "kind": "assessment",
0144             "description": (
0145                 "Running jobs by heartbeat age (stale_30, stale_60, "
0146                 "stale_120: last modification older than N minutes), "
0147                 "heartbeats received and jobs started in the interval, "
0148                 "the expected heartbeat count for the running population "
0149                 "at the configured heartbeat period, the yield "
0150                 "(received over expected), and window: the yield as a "
0151                 "ratio of sums over the last two heartbeat periods "
0152                 "(seconds, intervals, received, expected, yield, and the "
0153                 "recent intervals it sums). The window yield is the "
0154                 "assessed figure; the per-interval yield beats against "
0155                 "the pilot heartbeat phase."
0156             ),
0157         },
0158         "heartbeat_sites": {
0159             "path": "heartbeats.sites",
0160             "type": "object",
0161             "required": True,
0162             "kind": "bounded_map",
0163             "max_items": MAX_SITES,
0164             "description": (
0165                 "Per-site running counts and heartbeat-age tiers. Every "
0166                 "non-test Canary queue is retained; remaining slots take "
0167                 "the sites with the most running jobs."
0168             ),
0169         },
0170         "server": {
0171             "path": "server",
0172             "type": "object",
0173             "required": True,
0174             "kind": "gauge",
0175             "description": (
0176                 "One timed liveness request to the PanDA server: "
0177                 "latency in milliseconds, HTTP status, and ok; a timeout "
0178                 "or failure is recorded with its reason."
0179             ),
0180         },
0181         "pandamon": {
0182             "path": "pandamon",
0183             "type": "object",
0184             "required": False,
0185             "kind": "gauge",
0186             "description": (
0187                 "Two timed requests to the PanDA monitor (BigPanDA) web "
0188                 "face: its front page and the harvester worker-stats "
0189                 "query the monitor's tools use; latency in milliseconds, "
0190                 "HTTP status, and ok; a timeout or failure is recorded "
0191                 "with its reason."
0192             ),
0193         },
0194         "server_host": {
0195             "path": "server_host",
0196             "type": "object",
0197             "required": False,
0198             "kind": "gauge",
0199             "description": (
0200                 "The pandaserver01 reporter's latest record (web-tier "
0201                 "request counts, daemon liveness, WSGI tier, host "
0202                 "resources), present only when one has been delivered."
0203             ),
0204         },
0205         "reporter_status": {
0206             "path": "reporter_status",
0207             "type": "string",
0208             "required": True,
0209             "kind": "assessment",
0210             "enum": ["fresh", "stale", "absent"],
0211             "description": (
0212                 "Freshness of the server-host report against the "
0213                 "configured staleness threshold."
0214             ),
0215         },
0216         "monitor_host": {
0217             "path": "monitor_host",
0218             "type": "object",
0219             "required": True,
0220             "kind": "gauge",
0221             "description": (
0222                 "The monitor host: load average, memory and swap, volume "
0223                 "use, the WSGI daemon and httpd processes, the ASGI and "
0224                 "prod-ops agent services, and the monitor database's "
0225                 "connection count."
0226             ),
0227         },
0228         "assessment": {
0229             "path": "assessment",
0230             "type": "object",
0231             "required": True,
0232             "kind": "assessment",
0233             "description": (
0234                 "Per-metric verdicts (ok, warning, unknown) against the "
0235                 "SysConfig thresholds, the thresholds applied, and the "
0236                 "overall verdict."
0237             ),
0238         },
0239     },
0240 }
0241 
0242 
0243 @dataclass(frozen=True)
0244 class PlatformPublication:
0245     registration_update: ComponentUpdate
0246     update: ComponentUpdate
0247     projection: dict
0248     observed_at: datetime
0249 
0250 
0251 def _config(key):
0252     from .models import SysConfig
0253 
0254     return SysConfig.get_setting(key, CONFIG_DEFAULTS[key])
0255 
0256 
0257 def _iso_utc(value):
0258     if value.tzinfo is not None:
0259         value = value.astimezone(dt_timezone.utc).replace(tzinfo=None)
0260     return value.isoformat(timespec="seconds") + "Z"
0261 
0262 
0263 def _naive_utc(value):
0264     """The PanDA database stores naive UTC timestamps."""
0265     return value.astimezone(dt_timezone.utc).replace(tzinfo=None)
0266 
0267 
0268 # ── Database ────────────────────────────────────────────────────────────
0269 
0270 def database_reading():
0271     """Connections, longest transaction, and jobsactive4 health from
0272     the PanDA database's own statistics views."""
0273     out = {}
0274     try:
0275         with connections["panda"].cursor() as cursor:
0276             cursor.execute(
0277                 "SELECT setting::int FROM pg_settings "
0278                 "WHERE name = 'max_connections'")
0279             out["max_connections"] = int(cursor.fetchone()[0])
0280             cursor.execute(
0281                 "SELECT COALESCE(state, ''), "
0282                 "       (wait_event IS NOT NULL AND state = 'active'), "
0283                 "       COUNT(*) "
0284                 "FROM pg_stat_activity WHERE datname = current_database() "
0285                 "GROUP BY 1, 2")
0286             total = active = idle = waiting = 0
0287             for state, is_waiting, count in cursor.fetchall():
0288                 count = int(count or 0)
0289                 total += count
0290                 if state == "active":
0291                     active += count
0292                     if is_waiting:
0293                         waiting += count
0294                 elif state.startswith("idle"):
0295                     idle += count
0296             out.update({"connections": total, "active": active,
0297                         "idle": idle, "waiting": waiting})
0298             cursor.execute(
0299                 "SELECT COALESCE(EXTRACT(EPOCH FROM MAX(now() - xact_start)), 0) "
0300                 "FROM pg_stat_activity "
0301                 "WHERE datname = current_database() AND state <> 'idle'")
0302             out["longest_transaction_s"] = round(float(cursor.fetchone()[0] or 0), 1)
0303             cursor.execute(
0304                 "SELECT n_live_tup, n_dead_tup, last_autovacuum "
0305                 "FROM pg_stat_user_tables "
0306                 "WHERE schemaname = %s AND relname = 'jobsactive4'",
0307                 [PANDA_SCHEMA])
0308             row = cursor.fetchone()
0309             if row:
0310                 live, dead, last_vacuum = row
0311                 out["jobsactive4"] = {
0312                     "live_tuples": int(live or 0),
0313                     "dead_tuples": int(dead or 0),
0314                     "minutes_since_autovacuum": (
0315                         round((timezone.now() - last_vacuum).total_seconds() / 60)
0316                         if last_vacuum is not None else None),
0317                 }
0318             cursor.execute(
0319                 "SELECT COALESCE(NULLIF(application_name, ''), 'unnamed'), "
0320                 "       COALESCE(state, ''), COUNT(*) "
0321                 "FROM pg_stat_activity WHERE datname = current_database() "
0322                 "GROUP BY 1, 2 ORDER BY 3 DESC")
0323             by_app = {}
0324             for app, state, count in cursor.fetchall():
0325                 key = f"{app}@{state}" if state else str(app)
0326                 by_app[key] = int(count or 0)
0327             if len(by_app) > MAX_APPS:
0328                 keep = dict(list(by_app.items())[:MAX_APPS - 1])
0329                 keep["other"] = sum(list(by_app.values())[MAX_APPS - 1:])
0330                 by_app = keep
0331             out["by_app"] = by_app
0332     except Exception as e:                                   # noqa: BLE001
0333         out["error"] = f"{type(e).__name__}: {e}"[:300]
0334     return out
0335 
0336 
0337 # ── Heartbeats ──────────────────────────────────────────────────────────
0338 
0339 def heartbeat_reading(mark, until, period_seconds):
0340     """Running jobs by heartbeat age (scope and per site), heartbeats
0341     received and jobs started in (mark, until], and the yield against
0342     the running population's expected heartbeat count."""
0343     out = {"tiers_minutes": list(STALENESS_TIERS_MINUTES)}
0344     until_naive = _naive_utc(until)
0345     mark_naive = _naive_utc(mark)
0346     tier_filters = ", ".join(
0347         f"COUNT(*) FILTER (WHERE \"modificationtime\" < %s)"
0348         for _ in STALENESS_TIERS_MINUTES)
0349     tier_params = [until_naive - timedelta(minutes=m)
0350                    for m in STALENESS_TIERS_MINUTES]
0351     try:
0352         with connections["panda"].cursor() as cursor:
0353             cursor.execute(
0354                 f"SELECT COALESCE(\"computingsite\", 'unknown'), COUNT(*), "
0355                 f"       {tier_filters}, "
0356                 f"       COUNT(*) FILTER (WHERE \"modificationtime\" > %s) "
0357                 f"FROM \"{PANDA_SCHEMA}\".\"jobsactive4\" "
0358                 f"WHERE \"jobstatus\" = 'running' "
0359                 f"GROUP BY 1",
0360                 tier_params + [mark_naive])
0361             per_site = {}
0362             running = received = 0
0363             stale = [0] * len(STALENESS_TIERS_MINUTES)
0364             for row in cursor.fetchall():
0365                 site = str(row[0])
0366                 count = int(row[1] or 0)
0367                 tiers = [int(v or 0) for v in row[2:2 + len(STALENESS_TIERS_MINUTES)]]
0368                 got = int(row[2 + len(STALENESS_TIERS_MINUTES)] or 0)
0369                 running += count
0370                 received += got
0371                 stale = [a + b for a, b in zip(stale, tiers)]
0372                 entry = {"running": count, "received": got}
0373                 for minutes, value in zip(STALENESS_TIERS_MINUTES, tiers):
0374                     entry[f"stale_{minutes}"] = value
0375                 per_site[site] = entry
0376             cursor.execute(
0377                 f"SELECT COUNT(*) FROM ("
0378                 f"  SELECT \"pandaid\" FROM \"{PANDA_SCHEMA}\".\"jobsactive4\" "
0379                 f"  WHERE \"starttime\" > %s AND \"starttime\" <= %s "
0380                 f"  UNION "
0381                 f"  SELECT \"pandaid\" FROM \"{PANDA_SCHEMA}\".\"jobsarchived4\" "
0382                 f"  WHERE \"starttime\" > %s AND \"starttime\" <= %s) s",
0383                 [mark_naive, until_naive, mark_naive, until_naive])
0384             started = int(cursor.fetchone()[0] or 0)
0385     except Exception as e:                                   # noqa: BLE001
0386         out["error"] = f"{type(e).__name__}: {e}"[:300]
0387         out.update({"running": 0, "received": 0, "started": 0,
0388                     "expected": 0, "yield": None, "sites": {}})
0389         for minutes in STALENESS_TIERS_MINUTES:
0390             out[f"stale_{minutes}"] = 0
0391         return out
0392 
0393     interval_seconds = max(0.0, (until - mark).total_seconds())
0394     expected = round(running * interval_seconds / max(period_seconds, 1))
0395     out.update({
0396         "running": running,
0397         "received": received,
0398         "started": started,
0399         "expected": expected,
0400         "yield": (round(min(received / expected, 9.999), 3)
0401                   if expected else None),
0402         "period_seconds": int(period_seconds),
0403     })
0404     for minutes, value in zip(STALENESS_TIERS_MINUTES, stale):
0405         out[f"stale_{minutes}"] = value
0406     catalog = _canary_queue_names()
0407     names = _bounded_site_names(
0408         per_site, catalog,
0409         lambda name: (-int((per_site.get(name) or {}).get("running") or 0),
0410                       name))
0411     sites = {}
0412     for name in names:
0413         entry = per_site.get(name)
0414         if entry is None:
0415             entry = {"running": 0, "received": 0}
0416             for minutes in STALENESS_TIERS_MINUTES:
0417                 entry[f"stale_{minutes}"] = 0
0418         sites[name] = entry
0419     out["sites"] = sites
0420     return out
0421 
0422 
0423 # ── Server ──────────────────────────────────────────────────────────────
0424 
0425 def timed_get(url, timeout_seconds, params=None):
0426     """One timed GET: latency in milliseconds, HTTP status, ok; a
0427     timeout records at the timeout value with its reason, never omitted."""
0428     import requests
0429 
0430     started = time.monotonic()
0431     try:
0432         response = requests.get(url, params=params,
0433                                 timeout=float(timeout_seconds))
0434         ms = round((time.monotonic() - started) * 1000, 1)
0435         return {"url": url, "latency_ms": ms,
0436                 "status": int(response.status_code),
0437                 "ok": response.status_code == 200,
0438                 "timeout": False}
0439     except requests.Timeout:
0440         return {"url": url, "latency_ms": round(float(timeout_seconds) * 1000, 1),
0441                 "status": None, "ok": False, "timeout": True,
0442                 "error": f"no response within {timeout_seconds}s"}
0443     except Exception as e:                                   # noqa: BLE001
0444         return {"url": url,
0445                 "latency_ms": round((time.monotonic() - started) * 1000, 1),
0446                 "status": None, "ok": False, "timeout": False,
0447                 "error": f"{type(e).__name__}: {e}"[:300]}
0448 
0449 
0450 def server_reading(base_url, timeout_seconds):
0451     """One timed request to the PanDA server's liveness endpoint."""
0452     return timed_get(f"{base_url.rstrip('/')}/api/v1/system/is_alive",
0453                      timeout_seconds)
0454 
0455 
0456 def pandamon_reading(base_url, timeout_seconds, now):
0457     """Two timed requests to the PanDA monitor (BigPanDA) web face: the
0458     front page, and the harvester worker-stats query over the last hour
0459     — the request the monitor's own tools make, so the record shows the
0460     face as its consumers meet it."""
0461     from concurrent.futures import ThreadPoolExecutor
0462 
0463     root = base_url.rstrip("/")
0464     since = now - timedelta(hours=1)
0465     # The probes run together: the reading costs one timeout at worst,
0466     # not one per probe, inside the refresh doer's own time limit.
0467     with ThreadPoolExecutor(max_workers=2) as pool:
0468         front = pool.submit(timed_get, f"{root}/", timeout_seconds)
0469         workers = pool.submit(
0470             timed_get, f"{root}/harvester/getworkerstats/", timeout_seconds,
0471             {"lastupdate_from": since.strftime("%Y-%m-%d %H:%M:%S"),
0472              "lastupdate_to": now.strftime("%Y-%m-%d %H:%M:%S")})
0473         return {"front": front.result(), "workers": workers.result()}
0474 
0475 
0476 # ── Server host (reporter) ──────────────────────────────────────────────
0477 
0478 def server_host_reading(now, stale_seconds):
0479     """The pandaserver01 reporter's latest record and its freshness.
0480     Until the reporter and its ingest exist (docs/SNAPPER_PLATFORM.md,
0481     delivery stage 3) there is no record: status 'absent'."""
0482     return None, "absent"
0483 
0484 
0485 # ── Monitor host ────────────────────────────────────────────────────────
0486 
0487 def _rss_kb(pid):
0488     try:
0489         with open(f"/proc/{pid}/status") as handle:
0490             for line in handle:
0491                 if line.startswith("VmRSS:"):
0492                     return int(line.split()[1])
0493     except OSError:
0494         return None
0495     return None
0496 
0497 
0498 def _unit_state(unit):
0499     try:
0500         result = subprocess.run(
0501             ["systemctl", "show", unit,
0502              "-p", "ActiveState,MainPID,NRestarts"],
0503             capture_output=True, text=True, timeout=10, check=False)
0504     except (OSError, subprocess.SubprocessError) as e:
0505         return {"error": f"{type(e).__name__}: {e}"[:200]}
0506     props = dict(line.split("=", 1) for line in result.stdout.splitlines()
0507                  if "=" in line)
0508     pid = int(props.get("MainPID") or 0)
0509     rss = _rss_kb(pid) if pid else None
0510     return {"active": props.get("ActiveState") == "active",
0511             "state": props.get("ActiveState") or "unknown",
0512             "restarts": int(props.get("NRestarts") or 0),
0513             "rss_mb": round(rss / 1024, 1) if rss is not None else None}
0514 
0515 
0516 def _process_scan():
0517     """httpd process count and resident memory, and the mod_wsgi
0518     daemon processes (display name 'wsgi:...') separately."""
0519     httpd = {"count": 0, "rss_mb": 0.0}
0520     wsgi = {"count": 0, "rss_mb": 0.0}
0521     for entry in os.listdir("/proc"):
0522         if not entry.isdigit():
0523             continue
0524         try:
0525             with open(f"/proc/{entry}/comm") as handle:
0526                 comm = handle.read().strip()
0527             if comm != "httpd":
0528                 continue
0529             with open(f"/proc/{entry}/cmdline", "rb") as handle:
0530                 cmdline = handle.read().replace(b"\0", b" ").decode(
0531                     "utf-8", "replace")
0532         except OSError:
0533             continue
0534         rss = _rss_kb(entry) or 0
0535         httpd["count"] += 1
0536         httpd["rss_mb"] += rss / 1024
0537         if "wsgi:" in cmdline:
0538             wsgi["count"] += 1
0539             wsgi["rss_mb"] += rss / 1024
0540     httpd["rss_mb"] = round(httpd["rss_mb"], 1)
0541     wsgi["rss_mb"] = round(wsgi["rss_mb"], 1)
0542     return httpd, wsgi
0543 
0544 
0545 def monitor_host_reading(volumes):
0546     out = {}
0547     try:
0548         with open("/proc/loadavg") as handle:
0549             parts = handle.read().split()
0550         out["load"] = {"1m": float(parts[0]), "5m": float(parts[1]),
0551                        "15m": float(parts[2])}
0552         out["cpus"] = os.cpu_count()
0553     except (OSError, ValueError, IndexError) as e:
0554         out["load_error"] = f"{type(e).__name__}: {e}"[:200]
0555     try:
0556         info = {}
0557         with open("/proc/meminfo") as handle:
0558             for line in handle:
0559                 key, _, rest = line.partition(":")
0560                 info[key] = int(rest.split()[0])
0561         total = info["MemTotal"]
0562         available = info["MemAvailable"]
0563         out["memory"] = {
0564             "total_mb": round(total / 1024),
0565             "available_mb": round(available / 1024),
0566             "used_mb": round((total - available) / 1024),
0567             "used_percent": round(100 * (total - available) / total, 1),
0568             "swap_used_mb": round((info["SwapTotal"] - info["SwapFree"]) / 1024),
0569         }
0570     except (OSError, ValueError, KeyError) as e:
0571         out["memory_error"] = f"{type(e).__name__}: {e}"[:200]
0572     vols = {}
0573     for path in volumes:
0574         try:
0575             stat = os.statvfs(path)
0576             total = stat.f_blocks * stat.f_frsize
0577             free = stat.f_bavail * stat.f_frsize
0578             vols[path] = {"used_percent": round(100 * (total - free) / total, 1)
0579                           if total else None,
0580                           "free_gb": round(free / 1e9, 1)}
0581         except OSError as e:
0582             vols[path] = {"error": f"{type(e).__name__}: {e}"[:200]}
0583     out["volumes"] = vols
0584     try:
0585         httpd, wsgi = _process_scan()
0586         out["httpd"] = httpd
0587         out["wsgi"] = wsgi
0588     except OSError as e:
0589         out["process_error"] = f"{type(e).__name__}: {e}"[:200]
0590     out["asgi"] = _unit_state(MONITOR_UNITS["asgi"])
0591     out["ops_agent"] = _unit_state(MONITOR_UNITS["ops_agent"])
0592     try:
0593         with connection.cursor() as cursor:
0594             cursor.execute(
0595                 "SELECT COUNT(*) FROM pg_stat_activity "
0596                 "WHERE datname = current_database()")
0597             out["db_connections"] = int(cursor.fetchone()[0] or 0)
0598     except Exception as e:                                   # noqa: BLE001
0599         out["db_error"] = f"{type(e).__name__}: {e}"[:200]
0600     return out
0601 
0602 
0603 # ── Assessment ──────────────────────────────────────────────────────────
0604 
0605 def assess(database, heartbeats, server, reporter_status, monitor_host,
0606            thresholds, pandamon=None):
0607     """Per-metric verdicts against the configured thresholds."""
0608     verdicts = {}
0609 
0610     def verdict(name, condition_warning, known=True):
0611         verdicts[name] = ("unknown" if not known
0612                           else "warning" if condition_warning else "ok")
0613 
0614     # The verdict reads the windowed yield: the per-interval ratio
0615     # beats against the 30-minute pilot heartbeat phase.
0616     y = (heartbeats.get("window") or {}).get("yield")
0617     if y is None:
0618         y = heartbeats.get("yield")
0619     verdict("heartbeat_yield",
0620             y is not None and y < thresholds["platform_yield_warn_below"],
0621             known=y is not None and "error" not in heartbeats)
0622     running = int(heartbeats.get("running") or 0)
0623     tier = int(thresholds["platform_stale_warn_tier_minutes"])
0624     stale = int(heartbeats.get(f"stale_{tier}") or 0)
0625     verdict("heartbeat_staleness",
0626             running > 0 and stale / running
0627             > thresholds["platform_stale_warn_fraction"],
0628             known="error" not in heartbeats)
0629     conns = database.get("connections")
0630     limit = database.get("max_connections")
0631     verdict("db_connections",
0632             bool(conns and limit) and conns / limit
0633             > thresholds["platform_connections_warn_fraction"],
0634             known="error" not in database and bool(limit))
0635     verdict("server_latency",
0636             (not server.get("ok"))
0637             or server.get("latency_ms", 0) > thresholds["platform_latency_warn_ms"],
0638             known=True)
0639     probes = list((pandamon or {}).values())
0640     verdict("pandamon_latency",
0641             any((not p.get("ok"))
0642                 or p.get("latency_ms", 0) > thresholds["platform_pandamon_warn_ms"]
0643                 for p in probes),
0644             known=bool(probes))
0645     vols = (monitor_host.get("volumes") or {})
0646     percents = [v.get("used_percent") for v in vols.values()
0647                 if isinstance(v, dict) and v.get("used_percent") is not None]
0648     verdict("monitor_volumes",
0649             any(p > thresholds["platform_volume_warn_percent"] for p in percents),
0650             known=bool(percents))
0651     verdict("monitor_services",
0652             not ((monitor_host.get("asgi") or {}).get("active")
0653                  and (monitor_host.get("ops_agent") or {}).get("active")),
0654             known=True)
0655     verdicts["reporter"] = ("ok" if reporter_status == "fresh"
0656                             else "warning" if reporter_status == "stale"
0657                             else "unknown")
0658     if any(v == "warning" for v in verdicts.values()):
0659         overall = "warning"
0660     elif all(v == "ok" for v in verdicts.values()):
0661         overall = "ok"
0662     else:
0663         overall = "ok" if any(v == "ok" for v in verdicts.values()) else "unknown"
0664     return {"overall": overall, "verdicts": verdicts,
0665             "thresholds": thresholds}
0666 
0667 
0668 # ── Projection and publication ──────────────────────────────────────────
0669 
0670 def _previous_publication():
0671     """The component's current source time and data (None, None for a
0672     fresh record)."""
0673     from snapper_ai.models import CurrentComponent
0674 
0675     row = (CurrentComponent.objects
0676            .filter(scope=SCOPE, name=COMPONENT_NAME)
0677            .values("source_as_of", "data").first())
0678     if not row:
0679         return None, None
0680     return row["source_as_of"], (row["data"] or {})
0681 
0682 
0683 WINDOW_PERIODS = 2          # the yield window, in heartbeat periods
0684 WINDOW_MAX_INTERVALS = 48   # bound on the recent-interval list
0685 
0686 
0687 def heartbeat_window(heartbeats, previous, mark, until, period_seconds):
0688     """The yield over the last WINDOW_PERIODS heartbeat periods as a
0689     ratio of sums: the intervals carried by the previous publication
0690     that end inside the window, plus this one. A ratio of sums, never
0691     a mean of ratios — the running population moves between
0692     intervals."""
0693     window_seconds = WINDOW_PERIODS * max(int(period_seconds), 1)
0694     floor = until - timedelta(seconds=window_seconds)
0695     recent = []
0696     for entry in ((previous or {}).get("window") or {}).get("recent") or []:
0697         try:
0698             end = datetime.fromisoformat(
0699                 str(entry["end"]).replace("Z", "+00:00"))
0700         except (KeyError, ValueError, TypeError) as e:
0701             logger.error("platform heartbeat window: bad recent entry "
0702                          "%r: %s", entry, e)
0703             continue
0704         if end > floor and end <= mark:
0705             recent.append({"start": entry.get("start"), "end": entry["end"],
0706                            "received": int(entry.get("received") or 0),
0707                            "expected": int(entry.get("expected") or 0)})
0708     if "error" not in heartbeats:
0709         recent.append({"start": _iso_utc(mark), "end": _iso_utc(until),
0710                        "received": int(heartbeats.get("received") or 0),
0711                        "expected": int(heartbeats.get("expected") or 0)})
0712     recent = recent[-WINDOW_MAX_INTERVALS:]
0713     received = sum(e["received"] for e in recent)
0714     expected = sum(e["expected"] for e in recent)
0715     return {
0716         "seconds": window_seconds,
0717         "periods": WINDOW_PERIODS,
0718         "intervals": len(recent),
0719         "received": received,
0720         "expected": expected,
0721         "yield": (round(min(received / expected, 9.999), 3)
0722                   if expected else None),
0723         "recent": recent,
0724     }
0725 
0726 
0727 def platform_projection(now=None, mark=None):
0728     """Build the platform projection without publishing. The interval
0729     is (mark, now]; mark defaults to the component's current source
0730     time, or a short first interval for a fresh record."""
0731     observed_at = now or timezone.now()
0732     previous_mark, previous = _previous_publication()
0733     if mark is None:
0734         mark = previous_mark
0735     if mark is None or mark >= observed_at:
0736         mark = observed_at - timedelta(minutes=FIRST_INTERVAL_MINUTES)
0737     thresholds = {key: _config(key) for key in CONFIG_DEFAULTS
0738                   if key.startswith("platform_") and (
0739                       "warn" in key)}
0740     database = database_reading()
0741     period_seconds = int(_config("platform_heartbeat_period_seconds"))
0742     heartbeats = heartbeat_reading(mark, observed_at, period_seconds)
0743     heartbeats["window"] = heartbeat_window(
0744         heartbeats, previous, mark, observed_at, period_seconds)
0745     server = server_reading(str(_config("platform_panda_server_url")),
0746                             _config("platform_server_timeout_seconds"))
0747     pandamon = pandamon_reading(str(_config("platform_pandamon_url")),
0748                                 _config("platform_pandamon_timeout_seconds"),
0749                                 observed_at)
0750     server_host, reporter_status = server_host_reading(
0751         observed_at, int(_config("platform_reporter_stale_seconds")))
0752     monitor_host = monitor_host_reading(
0753         list(_config("platform_monitor_volumes") or []))
0754     projection = {
0755         "interval": {"start": _iso_utc(mark), "end": _iso_utc(observed_at)},
0756         "database": database,
0757         "heartbeats": heartbeats,
0758         "server": server,
0759         "pandamon": pandamon,
0760         "reporter_status": reporter_status,
0761         "monitor_host": monitor_host,
0762         "assessment": assess(database, heartbeats, server, reporter_status,
0763                              monitor_host, thresholds, pandamon),
0764     }
0765     if server_host is not None:
0766         projection["server_host"] = server_host
0767     serialized = len(json.dumps(projection, separators=(",", ":"), default=str))
0768     if serialized > MAX_SERIALIZED_BYTES:
0769         raise ValueError(
0770             f"platform projection serializes to {serialized} bytes, over "
0771             f"the {MAX_SERIALIZED_BYTES} bound")
0772     return projection, observed_at
0773 
0774 
0775 def publish_platform_state() -> PlatformPublication:
0776     """Measure, assess, and atomically publish the platform component.
0777     Publication is unconditional: the gauges change every interval."""
0778     projection, observed_at = platform_projection()
0779     with transaction.atomic():
0780         registration_update = register_component(
0781             scope=SCOPE,
0782             name=COMPONENT_NAME,
0783             publisher_identity=PUBLISHER_IDENTITY,
0784             registration=PLATFORM_REGISTRATION,
0785             component_schema_version=1,
0786         )
0787         update = publish_component(
0788             scope=SCOPE,
0789             name=COMPONENT_NAME,
0790             publisher_identity=PUBLISHER_IDENTITY,
0791             data=projection,
0792             assessed_at=observed_at,
0793             source_as_of=observed_at,
0794             assessment_policy_version=ASSESSMENT_POLICY_VERSION,
0795         )
0796     return PlatformPublication(
0797         registration_update=registration_update,
0798         update=update,
0799         projection=projection,
0800         observed_at=observed_at,
0801     )
0802 
0803 
0804 def compact_platform_publication_report(publication: PlatformPublication) -> str:
0805     projection = publication.projection
0806     heartbeats = projection["heartbeats"]
0807     return json.dumps(
0808         {
0809             "scope": SCOPE,
0810             "component": COMPONENT_NAME,
0811             "revision": max(publication.update.revision,
0812                             publication.registration_update.revision),
0813             "content_changed": publication.update.content_changed,
0814             "interval": projection["interval"],
0815             "db_connections": projection["database"].get("connections"),
0816             "running": heartbeats.get("running"),
0817             "heartbeats_received": heartbeats.get("received"),
0818             "heartbeat_yield": heartbeats.get("yield"),
0819             "server_latency_ms": projection["server"].get("latency_ms"),
0820             "server_ok": projection["server"].get("ok"),
0821             "pandamon_front_ms": projection["pandamon"]["front"].get("latency_ms"),
0822             "pandamon_workers_ms": projection["pandamon"]["workers"].get("latency_ms"),
0823             "reporter_status": projection["reporter_status"],
0824             "assessment": projection["assessment"]["overall"],
0825             "observed_at": publication.observed_at.isoformat(),
0826         },
0827         indent=2,
0828         sort_keys=True,
0829     )