File indexing completed on 2026-11-09 10:47:35
0001 """The JLab Rucio catalog component (docs/SNAPPER_CATALOG.md): what the
0002 production jobs asked of the catalog of record, what they got, and how
0003 the catalog was answering, published every five minutes beside the
0004 platform component on the System-status refresh.
0005
0006 Four groups:
0007
0008 - probe: timed from this host, a TLS handshake with the Rucio server,
0009 its /ping, and one authenticated light read; a timeout is recorded
0010 at the timeout value with its reason, never omitted.
0011 - registrations: over the half-open interval since the previous
0012 publication, every production job that ended, read from the digest
0013 the payload writes into the PanDA job-metrics string on every job
0014 whatever its end (payloadRegistration, payloadExit, payloadVersion);
0015 counts by outcome, the failed split by payload exit code, per queue.
0016 - latency: the registration stage's wall seconds over the interval's
0017 jobs whose payload report is at hand (the metatable for finished
0018 jobs, the swept reports for failed ones); count, median, p90.
0019 - registrar: pending registrations owed over the trailing day, from
0020 the same digest.
0021
0022 Publication follows the platform component: every interval, since the
0023 probe is a gauge that moves every time.
0024 """
0025 import json
0026 import logging
0027 import re
0028 import socket
0029 import ssl
0030 import statistics
0031 import time
0032 from dataclasses import dataclass
0033 from datetime import datetime, timedelta
0034 from datetime import timezone as dt_timezone
0035 from urllib.parse import urlparse
0036
0037 from django.db import connections, transaction
0038 from django.utils import timezone
0039
0040 from snapper_ai.services import (
0041 ComponentUpdate,
0042 publish_component,
0043 register_component,
0044 )
0045
0046 from .panda.constants import PANDA_SCHEMA
0047
0048 logger = logging.getLogger(__name__)
0049
0050 PUBLISHER_IDENTITY = "swf-monitor:jlab-catalog"
0051 ASSESSMENT_POLICY_VERSION = "swf-jlab-catalog-v1"
0052 COMPONENT_SCHEMA_VERSION = 1
0053 COMPONENT_NAME = "catalog"
0054 SCOPE = "epicprod"
0055 MAX_QUEUES = 32
0056 FIRST_INTERVAL_MINUTES = 5
0057 REGISTRAR_WINDOW_HOURS = 24
0058
0059
0060
0061
0062
0063 OUTCOMES = ("registered", "pending", "diverted", "unfinished", "failed", "not_reached", "none")
0064
0065 CONFIG_DEFAULTS = {
0066 "catalog_rucio_url": "https://rucio-server.jlab.org:443",
0067 "catalog_probe_timeout_s": 10,
0068 "catalog_probe_did": "epic:/RECO/26.07.1/epic_craterlake/DIS/DJANGOH4.6.10-2.0/CC/Rad/eMinus-pPlus/9x275/q2_3000to9000",
0069 "catalog_probe_slow_ms": 2000,
0070 "catalog_failed_share": 0.10,
0071 "catalog_failed_floor": 20,
0072 "catalog_pending_old_hours": 6,
0073 }
0074
0075 CATALOG_REGISTRATION = {
0076 "title": "JLab Rucio catalog",
0077 "description": (
0078 "Five-minute readings of the JLab Rucio catalog of record: a timed "
0079 "probe from the monitor host (TLS, /ping, one authenticated read), "
0080 "the interval's production jobs by registration outcome from the "
0081 "payload digest every job carries (registered, pending, failed by "
0082 "payload exit code, not reached), the registration stage's wall "
0083 "time from the payload reports at hand, and the pending "
0084 "registrations owed over the trailing day."
0085 ),
0086 "visibility": "public",
0087 "owning_subsystem": "SWF PanDA production monitor",
0088 "assessment_policy": ASSESSMENT_POLICY_VERSION,
0089 "quantities": {
0090 "interval": {
0091 "path": "interval", "type": "object", "required": True, "kind": "window",
0092 "description": "The half-open interval (start, end] the registrations and latency groups cover.",
0093 },
0094 "probe": {
0095 "path": "probe", "type": "object", "required": True, "kind": "gauge",
0096 "description": (
0097 "Timed from the monitor host: tls (handshake), ping (GET /ping), "
0098 "read (one authenticated DID metadata read), each {latency_ms, ok, "
0099 "timeout, error}; a timeout is recorded at the timeout value."
0100 ),
0101 },
0102 "registrations": {
0103 "path": "registrations", "type": "object", "required": True, "kind": "interval",
0104 "description": (
0105 "Production jobs ended in the interval by the digest's registration "
0106 "outcome (registered, pending, diverted, start, failed, not_reached, "
0107 "none), the failed split by payload exit code, per queue (bounded), "
0108 "and the total; from jobmetrics on jobsactive4 and jobsarchived4."
0109 ),
0110 },
0111 "latency": {
0112 "path": "latency", "type": "object", "required": True, "kind": "interval",
0113 "description": (
0114 "The registration stage's wall seconds over the interval's jobs whose "
0115 "payload report is at hand: count, median_s, p90_s, and the same for "
0116 "the registered jobs alone; absent counts read as no report."
0117 ),
0118 },
0119 "registrar": {
0120 "path": "registrar", "type": "object", "required": True, "kind": "gauge",
0121 "description": "Pending registrations owed over the trailing day and the oldest pending job's age in hours, from the digest.",
0122 },
0123 "assessment": {
0124 "path": "assessment", "type": "object", "required": True, "kind": "assessment",
0125 "description": "Verdicts on the probe, the failed share of registrations and the pending backlog, with the thresholds, and the overall.",
0126 },
0127 },
0128 }
0129
0130
0131 @dataclass
0132 class CatalogPublication:
0133 registration_update: ComponentUpdate
0134 update: ComponentUpdate
0135 projection: dict
0136 observed_at: datetime
0137
0138
0139 def _config(key):
0140 from .models import SysConfig
0141
0142 return SysConfig.get_setting(key, CONFIG_DEFAULTS[key])
0143
0144
0145 def _iso_utc(value):
0146 if value.tzinfo is not None:
0147 value = value.astimezone(dt_timezone.utc).replace(tzinfo=None)
0148 return value.isoformat(timespec="seconds") + "Z"
0149
0150
0151 def _naive_utc(value):
0152 """The PanDA database stores naive UTC timestamps."""
0153 return value.astimezone(dt_timezone.utc).replace(tzinfo=None)
0154
0155
0156
0157
0158 def _timed(label, fn, timeout_seconds):
0159 started = time.monotonic()
0160 try:
0161 detail = fn()
0162 ms = round((time.monotonic() - started) * 1000, 1)
0163 return {"latency_ms": ms, "ok": True, "timeout": False, **(detail or {})}
0164 except (socket.timeout, TimeoutError) as e:
0165 return {"latency_ms": round(float(timeout_seconds) * 1000, 1), "ok": False,
0166 "timeout": True, "error": f"no response within {timeout_seconds}s ({e})"[:200]}
0167 except Exception as e:
0168 elapsed = round((time.monotonic() - started) * 1000, 1)
0169 is_timeout = "timed out" in str(e).lower() or "timeout" in type(e).__name__.lower()
0170 return {"latency_ms": round(float(timeout_seconds) * 1000, 1) if is_timeout else elapsed,
0171 "ok": False, "timeout": is_timeout,
0172 "error": f"{type(e).__name__}: {e}"[:200]}
0173
0174
0175 def probe_reading(rucio_url, timeout_seconds, did):
0176 """The three timings against the catalog, from this host."""
0177 parsed = urlparse(rucio_url if "://" in rucio_url else f"https://{rucio_url}")
0178 host = parsed.hostname or rucio_url
0179 port = parsed.port or 443
0180
0181 def tls():
0182 with socket.create_connection((host, port), timeout=float(timeout_seconds)) as raw:
0183 with ssl.create_default_context().wrap_socket(raw, server_hostname=host) as s:
0184 s.settimeout(float(timeout_seconds))
0185 return {"peer": host}
0186
0187 def ping():
0188 import requests
0189 r = requests.get(f"{parsed.scheme or 'https'}://{host}:{port}/ping",
0190 timeout=float(timeout_seconds))
0191 if r.status_code != 200:
0192 raise RuntimeError(f"HTTP {r.status_code}")
0193 return {"status": r.status_code}
0194
0195 def read():
0196
0197
0198
0199 import os
0200 from swf_epicprod.jlab_auth import rucio_client
0201 scope, name = did.split(":", 1)
0202 base = f"{parsed.scheme or 'https'}://{host}:{port}"
0203 client = rucio_client(account="eicprod", host=base,
0204 proxy=os.environ.get("EVGEN_X509_PROXY") or None,
0205 timeout=float(timeout_seconds))
0206 meta = client.get_metadata(scope, name)
0207 return {"did": did, "found": bool(meta), "auth": client.jlab_auth}
0208
0209 return {"host": host, "port": port, "timeout_s": float(timeout_seconds),
0210 "tls": _timed("tls", tls, timeout_seconds),
0211 "ping": _timed("ping", ping, timeout_seconds),
0212 "read": _timed("read", read, timeout_seconds)}
0213
0214
0215
0216
0217 _REG_RE = re.compile(r"payloadRegistration=([A-Za-z_]+)")
0218 _EXIT_RE = re.compile(r"payloadExit=(-?\d+)")
0219 _VERSION_RE = re.compile(r"payloadVersion=([0-9.]+)")
0220
0221
0222 def parse_digest(jobmetrics):
0223 """(outcome, exit code or None, payload version or '') from a job's
0224 metrics string; ('none', None, '') when the digest is absent."""
0225 text = jobmetrics or ""
0226 m = _REG_RE.search(text)
0227 outcome = m.group(1) if m else "none"
0228 if outcome not in OUTCOMES:
0229 outcome = "none" if outcome in ("", "None") else outcome
0230 e = _EXIT_RE.search(text)
0231 v = _VERSION_RE.search(text)
0232 return outcome, (int(e.group(1)) if e else None), (v.group(1) if v else "")
0233
0234
0235 def _ended_jobs(mark, until):
0236 """(computingsite, jobstatus, jobmetrics) of production jobs that
0237 ended in (mark, until], from both job tables."""
0238 rows = []
0239 sql = (f'SELECT "computingsite", "jobstatus", "jobmetrics" FROM "{PANDA_SCHEMA}"."{{table}}" '
0240 f'WHERE "processingtype" = %s AND "jobstatus" IN (%s, %s) '
0241 f'AND "endtime" > %s AND "endtime" <= %s')
0242 with connections["panda"].cursor() as cur:
0243 for table in ("jobsactive4", "jobsarchived4"):
0244 cur.execute(sql.format(table=table),
0245 ["epicproduction", "finished", "failed", _naive_utc(mark), _naive_utc(until)])
0246 rows.extend(cur.fetchall())
0247 return rows
0248
0249
0250 def registrations_from_rows(rows):
0251 """The registrations group from (site, status, jobmetrics) rows."""
0252 by_outcome = {o: 0 for o in OUTCOMES}
0253 failed_by_exit = {}
0254 by_queue = {}
0255 versions = {}
0256 for site, status, metrics in rows:
0257 outcome, exit_code, version = parse_digest(metrics)
0258 if outcome not in by_outcome:
0259 by_outcome[outcome] = 0
0260 by_outcome[outcome] += 1
0261 if outcome == "failed":
0262 key = str(exit_code if exit_code is not None else "unknown")
0263 failed_by_exit[key] = failed_by_exit.get(key, 0) + 1
0264 if version:
0265 versions[version] = versions.get(version, 0) + 1
0266 q = by_queue.setdefault(site or "unknown", {o: 0 for o in OUTCOMES})
0267 q[outcome] = q.get(outcome, 0) + 1
0268 if len(by_queue) > MAX_QUEUES:
0269 ranked = sorted(by_queue.items(), key=lambda kv: -sum(kv[1].values()))
0270 kept = dict(ranked[:MAX_QUEUES])
0271 other = {o: 0 for o in OUTCOMES}
0272 for _, counts in ranked[MAX_QUEUES:]:
0273 for o, n in counts.items():
0274 other[o] = other.get(o, 0) + n
0275 kept["other"] = other
0276 by_queue = kept
0277 total = sum(by_outcome.values())
0278 attempted = total - by_outcome.get("not_reached", 0) - by_outcome.get("none", 0)
0279 failed = by_outcome.get("failed", 0)
0280 return {
0281 "jobs": total,
0282 "attempted": attempted,
0283 "by_outcome": by_outcome,
0284 "failed_by_exit": dict(sorted(failed_by_exit.items())),
0285 "failed_share": round(failed / attempted, 4) if attempted else None,
0286 "by_queue": by_queue,
0287 "payload_versions": dict(sorted(versions.items())),
0288 }
0289
0290
0291 def registrations_reading(mark, until):
0292 try:
0293 return registrations_from_rows(_ended_jobs(mark, until))
0294 except Exception as e:
0295 logger.error("catalog registrations read failed: %s", e)
0296 return {"error": f"{type(e).__name__}: {e}"[:300]}
0297
0298
0299
0300
0301 def _registration_wall(report):
0302 """The registration stage's wall seconds and outcome from a payload
0303 report, or (None, '') when the report has none."""
0304 stages = ((report or {}).get("stages") or {})
0305 reg = stages.get("registration") or {}
0306 wall = reg.get("wall_s")
0307 outcome = ((report or {}).get("registration") or {}).get("outcome") or reg.get("status") or ""
0308 try:
0309 return (float(wall) if wall is not None else None), str(outcome)
0310 except (TypeError, ValueError):
0311 return None, str(outcome)
0312
0313
0314 def _latency_rows(mark, until):
0315 """[(wall_s, outcome)] over the interval's jobs with a report: the
0316 metatable for finished jobs, the swept reports for failed ones."""
0317 out = []
0318 sql = (f'SELECT m."metadata" FROM "{PANDA_SCHEMA}"."metatable" m '
0319 f'JOIN "{PANDA_SCHEMA}"."jobsarchived4" j ON j."pandaid" = m."pandaid" '
0320 f'WHERE j."processingtype" = %s AND j."endtime" > %s AND j."endtime" <= %s')
0321 with connections["panda"].cursor() as cur:
0322 cur.execute(sql, ["epicproduction", _naive_utc(mark), _naive_utc(until)])
0323 for (raw,) in cur.fetchall():
0324 try:
0325 meta = json.loads(raw) if isinstance(raw, str) else raw
0326 except (ValueError, TypeError):
0327 continue
0328 wall, outcome = _registration_wall((meta or {}).get("payload"))
0329 if wall is not None:
0330 out.append((wall, outcome))
0331 from .models import EpicProdJob
0332 for job in (EpicProdJob.objects.filter(updated_at__gt=mark, updated_at__lte=until + timedelta(hours=2))
0333 .only("data").iterator()):
0334 report = ((job.data or {}).get("payload_report") or {}).get("report")
0335 wall, outcome = _registration_wall(report)
0336 if wall is not None:
0337 out.append((wall, outcome))
0338 return out
0339
0340
0341 def latency_from_rows(rows):
0342 def stats(values):
0343 if not values:
0344 return {"count": 0, "median_s": None, "p90_s": None}
0345 values = sorted(values)
0346 p90 = values[min(len(values) - 1, int(round(0.9 * (len(values) - 1))))]
0347 return {"count": len(values), "median_s": round(statistics.median(values), 1),
0348 "p90_s": round(p90, 1), "max_s": round(values[-1], 1)}
0349 return {"all": stats([w for w, _ in rows]),
0350 "registered": stats([w for w, o in rows if o == "registered"])}
0351
0352
0353 def latency_reading(mark, until):
0354 try:
0355 return latency_from_rows(_latency_rows(mark, until))
0356 except Exception as e:
0357 logger.error("catalog latency read failed: %s", e)
0358 return {"error": f"{type(e).__name__}: {e}"[:300]}
0359
0360
0361
0362
0363 def registrar_reading(until):
0364 """Pending registrations owed over the trailing day, from the
0365 digest, with the oldest pending job's age."""
0366 since = until - timedelta(hours=REGISTRAR_WINDOW_HOURS)
0367 sql = (f'SELECT "endtime" FROM "{PANDA_SCHEMA}"."{{table}}" '
0368 f'WHERE "processingtype" = %s AND "endtime" > %s AND "endtime" <= %s '
0369 f'AND "jobmetrics" LIKE %s')
0370 try:
0371 ends = []
0372 with connections["panda"].cursor() as cur:
0373 for table in ("jobsactive4", "jobsarchived4"):
0374 cur.execute(sql.format(table=table),
0375 ["epicproduction", _naive_utc(since), _naive_utc(until),
0376 "%payloadRegistration=pending%"])
0377 ends.extend(r[0] for r in cur.fetchall() if r[0] is not None)
0378 oldest_h = None
0379 if ends:
0380 oldest = min(ends)
0381 if oldest.tzinfo is None:
0382 oldest = oldest.replace(tzinfo=dt_timezone.utc)
0383 oldest_h = round((until - oldest).total_seconds() / 3600, 1)
0384 return {"window_hours": REGISTRAR_WINDOW_HOURS, "pending": len(ends),
0385 "oldest_pending_h": oldest_h}
0386 except Exception as e:
0387 logger.error("catalog registrar read failed: %s", e)
0388 return {"error": f"{type(e).__name__}: {e}"[:300]}
0389
0390
0391
0392
0393 def assess(probe, registrations, registrar, thresholds):
0394 verdicts = {}
0395
0396 def verdict(name, warning, known=True):
0397 verdicts[name] = "warning" if (known and warning) else ("ok" if known else "unknown")
0398
0399 slow_ms = float(thresholds["catalog_probe_slow_ms"])
0400 probe_known = bool(probe) and "error" not in probe
0401 probe_bad = probe_known and any(
0402 (not (probe.get(k) or {}).get("ok")) or float((probe.get(k) or {}).get("latency_ms") or 0) > slow_ms
0403 for k in ("tls", "ping", "read"))
0404 verdict("probe", probe_bad, probe_known)
0405 reg_known = bool(registrations) and "error" not in registrations
0406 share = (registrations or {}).get("failed_share")
0407 attempted = int((registrations or {}).get("attempted") or 0)
0408 verdict("failed_share",
0409 reg_known and share is not None and attempted >= int(thresholds["catalog_failed_floor"])
0410 and share >= float(thresholds["catalog_failed_share"]),
0411 reg_known)
0412 backlog_known = bool(registrar) and "error" not in registrar
0413 oldest = (registrar or {}).get("oldest_pending_h")
0414 verdict("pending_backlog",
0415 backlog_known and oldest is not None and oldest >= float(thresholds["catalog_pending_old_hours"]),
0416 backlog_known)
0417 overall = ("warning" if "warning" in verdicts.values()
0418 else "ok" if all(v == "ok" for v in verdicts.values()) else "unknown")
0419 return {"overall": overall, "verdicts": verdicts, "thresholds": thresholds}
0420
0421
0422
0423
0424 def _previous_publication():
0425 from snapper_ai.models import CurrentComponent
0426
0427 row = (CurrentComponent.objects.filter(scope=SCOPE, name=COMPONENT_NAME)
0428 .values("source_as_of").first())
0429 return row["source_as_of"] if row else None
0430
0431
0432 def catalog_projection(now=None, mark=None, probe=True):
0433 observed_at = now or timezone.now()
0434 if mark is None:
0435 mark = _previous_publication()
0436 if mark is None or mark >= observed_at:
0437 mark = observed_at - timedelta(minutes=FIRST_INTERVAL_MINUTES)
0438 thresholds = {k: _config(k) for k in ("catalog_probe_timeout_s", "catalog_probe_slow_ms",
0439 "catalog_failed_share", "catalog_failed_floor",
0440 "catalog_pending_old_hours")}
0441 probe_reading_ = (probe_reading(str(_config("catalog_rucio_url")),
0442 _config("catalog_probe_timeout_s"),
0443 str(_config("catalog_probe_did")))
0444 if probe else {"error": "not probed"})
0445 registrations = registrations_reading(mark, observed_at)
0446 latency = latency_reading(mark, observed_at)
0447 registrar = registrar_reading(observed_at)
0448 projection = {
0449 "interval": {"start": _iso_utc(mark), "end": _iso_utc(observed_at)},
0450 "probe": probe_reading_,
0451 "registrations": registrations,
0452 "latency": latency,
0453 "registrar": registrar,
0454 "assessment": assess(probe_reading_, registrations, registrar, thresholds),
0455 }
0456 return projection, observed_at
0457
0458
0459 def publish_catalog_state() -> CatalogPublication:
0460 """Measure, assess, and atomically publish the catalog component."""
0461 projection, observed_at = catalog_projection()
0462 with transaction.atomic():
0463 registration_update = register_component(
0464 scope=SCOPE, name=COMPONENT_NAME, publisher_identity=PUBLISHER_IDENTITY,
0465 registration=CATALOG_REGISTRATION,
0466 component_schema_version=COMPONENT_SCHEMA_VERSION)
0467 update = publish_component(
0468 scope=SCOPE, name=COMPONENT_NAME, publisher_identity=PUBLISHER_IDENTITY,
0469 data=projection, assessed_at=observed_at, source_as_of=observed_at,
0470 assessment_policy_version=ASSESSMENT_POLICY_VERSION)
0471 return CatalogPublication(registration_update=registration_update, update=update,
0472 projection=projection, observed_at=observed_at)
0473
0474
0475 def compact_catalog_publication_report(publication: CatalogPublication) -> str:
0476 p = publication.projection
0477 probe = p.get("probe") or {}
0478 reg = p.get("registrations") or {}
0479 by = reg.get("by_outcome") or {}
0480 timings = " ".join(f"{k} {((probe.get(k) or {}).get('latency_ms'))}ms"
0481 + ("" if (probe.get(k) or {}).get("ok") else "!")
0482 for k in ("tls", "ping", "read")) if "error" not in probe else probe["error"]
0483 return (f"catalog: {p['assessment']['overall']}; probe {timings}; "
0484 f"registrations {reg.get('jobs', '?')} jobs: registered {by.get('registered', 0)}, "
0485 f"pending {by.get('pending', 0)}, failed {by.get('failed', 0)} {reg.get('failed_by_exit') or ''}; "
0486 f"pending owed {((p.get('registrar') or {}).get('pending'))}")