Back to home page

EIC code displayed by LXR

 
 

    


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 # The digest's registration outcomes as the payload writes them
0060 # (payload_report.registration_record), in the order the view stacks
0061 # them. 'unfinished' is a job that died inside its registration stage or
0062 # stashed its output at BNL without closing the stage.
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 # ── Probe ──────────────────────────────────────────────────────────────
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:                                        # noqa: BLE001
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         # The production account by a token from the PanDA server, else
0197         # through the proxy the ops agent holds (swf_epicprod.jlab_auth,
0198         # the registrar's credential).
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 # ── Registrations from the digest ──────────────────────────────────────
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:                                        # noqa: BLE001
0295         logger.error("catalog registrations read failed: %s", e)
0296         return {"error": f"{type(e).__name__}: {e}"[:300]}
0297 
0298 
0299 # ── Latency from the payload reports ───────────────────────────────────
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:                                        # noqa: BLE001
0357         logger.error("catalog latency read failed: %s", e)
0358         return {"error": f"{type(e).__name__}: {e}"[:300]}
0359 
0360 
0361 # ── Registrar backlog ──────────────────────────────────────────────────
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:                                        # noqa: BLE001
0387         logger.error("catalog registrar read failed: %s", e)
0388         return {"error": f"{type(e).__name__}: {e}"[:300]}
0389 
0390 
0391 # ── Assessment ─────────────────────────────────────────────────────────
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 # ── Projection and publication ─────────────────────────────────────────
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'))}")