Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """Publish the epicprod error-state component to Snapper.
0002 
0003 Design: docs/SNAPPER_ERRORS.md. Each publication records the error
0004 events of one interval: every job that ended faulty within
0005 (previous publication, now], as bounded entry rows of
0006 (pandaid, jeditaskid, category, endtime, status). The status is the
0007 job's terminal state (failed, cancelled, closed) — the system's own
0008 disposition semantics: closed marks jobs the server disposed of for
0009 workflow reasons, by design not actual errors. A job reports errors
0010 once, upon completion, so each failed job appears in exactly one
0011 interval. Counts over any period are sums of entry counts over the
0012 intervals it spans; per-task readings filter the same entries by task
0013 id. An interval whose entry count exceeds the bound keeps a
0014 representative subset and folds the exact remainder into
0015 per-category-and-status overflow counts, so aggregate counts never
0016 lose a job.
0017 """
0018 
0019 import json
0020 from dataclasses import dataclass
0021 from datetime import datetime, timedelta
0022 from datetime import timezone as dt_timezone
0023 
0024 from django.db import connections, transaction
0025 from django.utils import timezone
0026 
0027 from snapper_ai.services import (
0028     ComponentUpdate,
0029     publish_component,
0030     register_component,
0031     report_component_unchanged,
0032 )
0033 
0034 from .panda.constants import (
0035     ERROR_COMPONENTS,
0036     FAULTY_STATUSES,
0037     PANDA_SCHEMA,
0038 )
0039 
0040 PUBLISHER_IDENTITY = "swf-monitor:panda-errors"
0041 ASSESSMENT_POLICY_VERSION = "swf-panda-errors-v3"
0042 COMPONENT_NAME = "errors"
0043 SCOPE = "epicprod"
0044 
0045 ENTRY_FIELDS = ["pandaid", "jeditaskid", "category", "endtime", "status"]
0046 # The event time of a faulty job: its end time, except that a
0047 # lost-heartbeat failure (dispatcher code 100) records the last
0048 # heartbeat as the end time and the failure instant as the modification
0049 # time — the failure instant is the event.
0050 EVENT_TIME_SQL = (
0051     'CASE WHEN "jobdispatchererrorcode" = 100 '
0052     'THEN "modificationtime" ELSE "endtime" END'
0053 )
0054 MAX_ENTRIES = 2000
0055 MAX_PATTERNS = 20
0056 MAX_PATTERN_TASKS = 8
0057 MAX_PATTERN_SITES = 6
0058 PATTERN_DIAG_CHARS = 160
0059 # A fresh component (or one whose record was reset) starts its first
0060 # interval this far back rather than swallowing all recorded history
0061 # into one interval; earlier history is the backfill's to write.
0062 FIRST_INTERVAL_MINUTES = 5
0063 MAX_SERIALIZED_BYTES = 160 * 1024
0064 
0065 ERRORS_REGISTRATION = {
0066     "title": "Curated epicprod PanDA error state",
0067     "description": (
0068         "Per-interval PanDA job error events: each publication covers "
0069         "one interval and records every job that ended faulty within "
0070         "it, as entry rows of (pandaid, jeditaskid, category, "
0071         "endtime, status). The category is 'component:code', "
0072         "classified by the first nonzero error component in the "
0073         "standard order, so one job lands in one category. The status "
0074         "is the job's terminal state (failed, cancelled, closed); "
0075         "closed marks jobs the server disposed of for workflow "
0076         "reasons, by design not actual errors. Counts over any period "
0077         "are sums of entry counts over the intervals it spans."
0078     ),
0079     "visibility": "public",
0080     "owning_subsystem": "SWF PanDA production monitor",
0081     "assessment_policy": ASSESSMENT_POLICY_VERSION,
0082     "max_serialized_bytes": MAX_SERIALIZED_BYTES,
0083     "quantities": {
0084         "interval": {
0085             "path": "interval",
0086             "type": "object",
0087             "required": True,
0088             "kind": "window",
0089             "description": (
0090                 "The half-open accrual interval (start, end] this "
0091                 "publication covers. Live intervals run from the "
0092                 "previous publication's source time to this one's; "
0093                 "backfilled intervals are grid-aligned."
0094             ),
0095         },
0096         "entries": {
0097             "path": "entries",
0098             "type": "array",
0099             "required": True,
0100             "kind": "interval_events",
0101             "max_items": MAX_ENTRIES,
0102             "description": (
0103                 "One row per job that ended faulty in the interval, as "
0104                 "arrays in ENTRY_FIELDS order: pandaid, jeditaskid, "
0105                 "category 'component:code', event time, and terminal "
0106                 "status. The event time is the job's end time, except "
0107                 "for lost-heartbeat failures (dispatcher 100), whose "
0108                 "recorded end time is the last heartbeat: their event "
0109                 "time is the failure instant (the modification time). "
0110                 "Each failed job appears in exactly one "
0111                 "interval. When the interval exceeds the entry bound, "
0112                 "entries keep the earliest rows and 'overflow' carries "
0113                 "the exact remainder."
0114             ),
0115         },
0116         "overflow": {
0117             "path": "overflow",
0118             "type": "object",
0119             "required": False,
0120             "kind": "fold_remainder",
0121             "description": (
0122                 "Absent normally. In an interval exceeding the entry "
0123                 "bound: 'total' and 'by_category' counts of the rows "
0124                 "not listed in entries, keyed 'component:code@status' "
0125                 "so status-resolved aggregate counts never lose a job."
0126             ),
0127         },
0128     },
0129     "entry_fields": ENTRY_FIELDS,
0130     "event_sources": [
0131         {
0132             "name": "panda-job-errors",
0133             "resolver": "swf-panda-errors-history",
0134             "owner": "ePIC PanDA production",
0135             "event_kind": "panda-job-errors",
0136             "event_time_field": "endtime",
0137             "visibility": "public",
0138         }
0139     ],
0140 }
0141 
0142 
0143 @dataclass(frozen=True)
0144 class ErrorsPublication:
0145     registration_update: ComponentUpdate
0146     update: ComponentUpdate
0147     projection: dict
0148     observed_at: datetime
0149 
0150 
0151 def _classify_sql():
0152     """CASE expressions classifying a job by its first nonzero error
0153     component in ERROR_COMPONENTS order - the classified-mode
0154     convention of the error summary, so one job lands in one
0155     category."""
0156     comp_case = " ".join(
0157         f'WHEN "{c["code"]}" > 0 THEN \'{c["name"]}\''
0158         for c in ERROR_COMPONENTS
0159     )
0160     code_case = " ".join(
0161         f'WHEN "{c["code"]}" > 0 THEN "{c["code"]}"'
0162         for c in ERROR_COMPONENTS
0163     )
0164     diag_case = " ".join(
0165         f'WHEN "{c["code"]}" > 0 THEN "{c["diag"]}"'
0166         for c in ERROR_COMPONENTS
0167     )
0168     return comp_case, code_case, diag_case
0169 
0170 
0171 def _faulty_union(mark, until, diags=False, sites=False):
0172     """Bounds SQL and parameters for the deduplicated union of faulty
0173     jobs ending in (mark, until] across the active and archived
0174     tables. diags=True adds the diagnostic text columns for pattern
0175     aggregation; sites=True adds the computing site."""
0176     err_fields = ", ".join(f'"{c["code"]}"' for c in ERROR_COMPONENTS)
0177     if diags:
0178         err_fields += ", " + ", ".join(
0179             f'"{c["diag"]}"' for c in ERROR_COMPONENTS)
0180     if sites:
0181         err_fields += ', "computingsite"'
0182     status_placeholders = ", ".join(["%s"] * len(FAULTY_STATUSES))
0183     any_nonzero = " OR ".join(
0184         f'"{c["code"]}" > 0' for c in ERROR_COMPONENTS
0185     )
0186     # The event time is when the job ended faulty. For a lost-heartbeat
0187     # failure the record's end time is the LAST HEARTBEAT (the Watcher's
0188     # convention); the failure instant is the modification time, so
0189     # that is the event time for those jobs — otherwise a kill storm
0190     # lands on the plots hours before it happened.
0191     bounds = (
0192         f"{EVENT_TIME_SQL} > %s AND {EVENT_TIME_SQL} <= %s "
0193         f'AND "jobstatus" IN ({status_placeholders}) '
0194         f"AND ({any_nonzero})"
0195     )
0196     params = [mark, until, *FAULTY_STATUSES]
0197     union = f"""
0198         SELECT "pandaid", "jeditaskid", {EVENT_TIME_SQL} AS "endtime",
0199                "jobstatus", {err_fields}
0200         FROM "{PANDA_SCHEMA}"."jobsactive4" WHERE {bounds}
0201         UNION
0202         SELECT "pandaid", "jeditaskid", {EVENT_TIME_SQL} AS "endtime",
0203                "jobstatus", {err_fields}
0204         FROM "{PANDA_SCHEMA}"."jobsarchived4" WHERE {bounds}
0205     """
0206     return union, params + params
0207 
0208 
0209 def error_axes(mark, until, taskids=None, statuses=None):
0210     """Totals per category, per task, and per site for the faulty
0211     jobs ending in (mark, until], from one scan (GROUPING SETS) —
0212     the concentration facts behind the breakdown's attribution
0213     reading: whether the errors concentrate in one condition, one
0214     task, or one site, or spread. taskids optionally restricts to a
0215     task list; statuses to a terminal-state list."""
0216     comp_case, code_case, _ = _classify_sql()
0217     union, params = _faulty_union(mark, until, sites=True)
0218     conditions = []
0219     if taskids:
0220         placeholders = ", ".join(["%s"] * len(taskids))
0221         conditions.append(f'"jeditaskid" IN ({placeholders})')
0222         params = params + [int(t) for t in taskids]
0223     status_list = _known_statuses(statuses)
0224     if status_list:
0225         placeholders = ", ".join(["%s"] * len(status_list))
0226         conditions.append(f'"jobstatus" IN ({placeholders})')
0227         params = params + status_list
0228     where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
0229     sql = f"""
0230         SELECT CASE {comp_case} ELSE 'other' END AS comp,
0231                CASE {code_case} ELSE 0 END AS code,
0232                "jeditaskid",
0233                COALESCE("computingsite", 'unknown') AS site,
0234                COUNT(*)
0235         FROM ({union}) faulty
0236         {where}
0237         GROUP BY GROUPING SETS ((1, 2), (3), (4))
0238     """
0239     categories = {}
0240     tasks = {}
0241     sites = {}
0242     with connections["panda"].cursor() as cursor:
0243         cursor.execute(sql, params)
0244         for comp, code, taskid, site, count in cursor.fetchall():
0245             count = int(count or 0)
0246             if comp is not None:
0247                 categories[_category_key(comp, code)] = count
0248             elif taskid is not None:
0249                 tasks[int(taskid)] = count
0250             elif site is not None:
0251                 sites[str(site)] = count
0252     return {"categories": categories, "tasks": tasks, "sites": sites}
0253 
0254 
0255 def _known_statuses(statuses):
0256     """The restriction list for a terminal-state selection: the given
0257     statuses that live job-record queries can express. Synthetic
0258     presentation states (e.g. 'unrecorded' for pre-status entry rows)
0259     have no job-record equivalent and drop out; an empty or full
0260     selection means no restriction."""
0261     if not statuses:
0262         return []
0263     known = [s for s in statuses if s in FAULTY_STATUSES]
0264     if len(known) == len(FAULTY_STATUSES):
0265         return []
0266     return known
0267 
0268 
0269 def error_patterns(mark, until, taskid=None, statuses=None):
0270     """Top diagnostic patterns among faulty jobs ending in
0271     (mark, until], optionally restricted to one task and/or a
0272     terminal-state list: category, sample diagnostic, count,
0273     representative PanDA job id, and affected task ids, most frequent
0274     first. Digit runs collapse in the pattern grouping so job-specific
0275     paths, ids, and line numbers merge into one pattern; the sample
0276     shown is one member's raw text. Aggregated live from the job
0277     records — the errors view's breakdown calls this at cut time
0278     (docs/SNAPPER_ERRORS.md)."""
0279     comp_case, code_case, diag_case = _classify_sql()
0280     union, params = _faulty_union(mark, until, diags=True, sites=True)
0281     conditions = []
0282     if taskid is not None:
0283         conditions.append('"jeditaskid" = %s')
0284         params = params + [int(taskid)]
0285     status_list = _known_statuses(statuses)
0286     if status_list:
0287         placeholders = ", ".join(["%s"] * len(status_list))
0288         conditions.append(f'"jobstatus" IN ({placeholders})')
0289         params = params + status_list
0290     where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
0291     sql = f"""
0292         SELECT CASE {comp_case} ELSE 'other' END AS comp,
0293                CASE {code_case} ELSE 0 END AS code,
0294                COALESCE(LEFT(regexp_replace(
0295                    CASE {diag_case} ELSE '' END,
0296                    '[0-9]+', '#', 'g'), {PATTERN_DIAG_CHARS}), '')
0297                    AS diag_pattern,
0298                MIN(COALESCE(LEFT(CASE {diag_case} ELSE '' END,
0299                                  {PATTERN_DIAG_CHARS}), '')) AS diag,
0300                COUNT(*) AS count,
0301                MAX("pandaid") AS representative_pandaid,
0302                array_agg(DISTINCT "jeditaskid") AS taskids,
0303                array_agg(DISTINCT COALESCE("computingsite", 'unknown'))
0304                    AS sites
0305         FROM ({union}) faulty
0306         {where}
0307         GROUP BY 1, 2, 3
0308         ORDER BY 5 DESC, 1, 2
0309     """
0310     with connections["panda"].cursor() as cursor:
0311         cursor.execute(sql, params)
0312         return cursor.fetchall()
0313 
0314 
0315 def _entry_rows(mark, until):
0316     """(pandaid, jeditaskid, category, endtime, status) for faulty
0317     jobs ending in (mark, until], in endtime order."""
0318     comp_case, code_case, _ = _classify_sql()
0319     union, params = _faulty_union(mark, until)
0320     sql = f"""
0321         SELECT "pandaid", "jeditaskid",
0322                CASE {comp_case} ELSE 'other' END,
0323                CASE {code_case} ELSE 0 END,
0324                "endtime", "jobstatus"
0325         FROM ({union}) faulty
0326         ORDER BY "endtime", "pandaid"
0327     """
0328     with connections["panda"].cursor() as cursor:
0329         cursor.execute(sql, params)
0330         return cursor.fetchall()
0331 
0332 
0333 def _previous_source_time():
0334     """The interval start for the next publication. Source time and
0335     content advance atomically in publication, so intervals tile with
0336     no gap or double count across cycles."""
0337     from snapper_ai.models import CurrentComponent
0338 
0339     row = (CurrentComponent.objects
0340            .filter(scope=SCOPE, name=COMPONENT_NAME)
0341            .values("source_as_of").first())
0342     return row["source_as_of"] if row else None
0343 
0344 
0345 def _iso_utc(value):
0346     """ISO-8601 Z-suffixed UTC. Naive values are UTC by convention
0347     (the PanDA database stores naive UTC end times)."""
0348     if value.tzinfo is not None:
0349         value = value.astimezone(dt_timezone.utc).replace(tzinfo=None)
0350     return value.isoformat() + "Z"
0351 
0352 
0353 def _category_key(comp, code):
0354     return f"{comp}:{int(code or 0)}"
0355 
0356 
0357 def errors_projection(now=None, mark=None):
0358     """Build the one-interval error projection without publishing.
0359 
0360     The interval is (mark, now]. When mark is not given it comes from
0361     the component's current source time; a fresh record starts with a
0362     short first interval rather than swallowing all recorded history
0363     (earlier history is the backfill's to write).
0364     """
0365     observed_at = now or timezone.now()
0366     if mark is None:
0367         mark = _previous_source_time()
0368     if mark is None:
0369         mark = observed_at - timedelta(minutes=FIRST_INTERVAL_MINUTES)
0370 
0371     entries = []
0372     overflow_total = 0
0373     overflow_categories = {}
0374     for pandaid, taskid, comp, code, endtime, status in _entry_rows(
0375             mark, observed_at):
0376         category = _category_key(comp, code)
0377         if len(entries) < MAX_ENTRIES:
0378             entries.append([
0379                 int(pandaid or 0),
0380                 int(taskid or 0),
0381                 category,
0382                 _iso_utc(endtime),
0383                 str(status or ''),
0384             ])
0385         else:
0386             overflow_total += 1
0387             fold_key = f"{category}@{status or ''}"
0388             overflow_categories[fold_key] = (
0389                 overflow_categories.get(fold_key) or 0) + 1
0390 
0391     projection = {
0392         "interval": {"start": _iso_utc(mark), "end": _iso_utc(observed_at)},
0393         "entries": entries,
0394     }
0395     if overflow_total:
0396         projection["overflow"] = {
0397             "total": overflow_total, "by_category": overflow_categories}
0398     serialized = len(json.dumps(projection, separators=(",", ":")))
0399     if serialized > MAX_SERIALIZED_BYTES:
0400         raise ValueError(
0401             f"errors projection serializes to {serialized} bytes, over "
0402             f"the {MAX_SERIALIZED_BYTES} bound"
0403         )
0404     return projection, observed_at
0405 
0406 
0407 def _previous_data_is_v3():
0408     """Whether the component's current content already has the
0409     status-bearing interval-entries shape. Until it does, a quiet
0410     interval still publishes, so the shape transition lands as real
0411     content."""
0412     from snapper_ai.models import CurrentComponent
0413 
0414     row = (CurrentComponent.objects
0415            .filter(scope=SCOPE, name=COMPONENT_NAME)
0416            .values("data").first())
0417     if not (row and isinstance(row["data"], dict)
0418             and "entries" in row["data"]):
0419         return False
0420     rows = row["data"]["entries"]
0421     return not rows or len(rows[0]) >= len(ENTRY_FIELDS)
0422 
0423 
0424 def publish_errors_state() -> ErrorsPublication:
0425     """Query, curate, and atomically publish the error-state component.
0426 
0427     An interval with no errors is affirmed unchanged with its source
0428     time advanced, so quiet periods write no snaps while the next
0429     errorful interval still starts where the record left off.
0430     """
0431     projection, observed_at = errors_projection()
0432     quiet = (not projection["entries"] and not projection.get("overflow")
0433              and _previous_data_is_v3())
0434     with transaction.atomic():
0435         # Registration first: reconciliation is idempotent, and the
0436         # publication validates against the registration on record —
0437         # publishing first would validate new-shape data against a
0438         # superseded definition and fail.
0439         registration_update = register_component(
0440             scope=SCOPE,
0441             name=COMPONENT_NAME,
0442             publisher_identity=PUBLISHER_IDENTITY,
0443             registration=ERRORS_REGISTRATION,
0444             component_schema_version=3,
0445         )
0446         if quiet:
0447             update = report_component_unchanged(
0448                 scope=SCOPE,
0449                 name=COMPONENT_NAME,
0450                 publisher_identity=PUBLISHER_IDENTITY,
0451                 assessed_at=observed_at,
0452                 source_as_of=observed_at,
0453                 assessment_policy_version=ASSESSMENT_POLICY_VERSION,
0454             )
0455         else:
0456             update = publish_component(
0457                 scope=SCOPE,
0458                 name=COMPONENT_NAME,
0459                 publisher_identity=PUBLISHER_IDENTITY,
0460                 data=projection,
0461                 assessed_at=observed_at,
0462                 source_as_of=observed_at,
0463                 assessment_policy_version=ASSESSMENT_POLICY_VERSION,
0464             )
0465     return ErrorsPublication(
0466         registration_update=registration_update,
0467         update=update,
0468         projection=projection,
0469         observed_at=observed_at,
0470     )
0471 
0472 
0473 def compact_errors_publication_report(publication: ErrorsPublication) -> str:
0474     projection = publication.projection
0475     overflow = projection.get("overflow") or {}
0476     return json.dumps(
0477         {
0478             "scope": SCOPE,
0479             "component": COMPONENT_NAME,
0480             "revision": max(
0481                 publication.update.revision,
0482                 publication.registration_update.revision,
0483             ),
0484             "content_changed": publication.update.content_changed,
0485             "interval": projection["interval"],
0486             "entries": len(projection["entries"]),
0487             "overflow_total": int(overflow.get("total") or 0),
0488             "observed_at": publication.observed_at.isoformat(),
0489         },
0490         indent=2,
0491         sort_keys=True,
0492     )