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
0047
0048
0049
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
0060
0061
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
0187
0188
0189
0190
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
0436
0437
0438
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 )