File indexing completed on 2026-11-09 10:47:25
0001
0002 """segfault-inventory.py — the crash-class inventory from the PanDA record.
0003
0004 Stage 1 of swf-epicprod docs/SEGFAULT_DIAGNOSIS.md. A payload crash reaches
0005 the record as the payload's exit code: 128 plus the signal, stored on every
0006 failed job as ``transexitcode`` (134 SIGABRT, 135 SIGBUS, 136 SIGFPE, 139
0007 SIGSEGV), under whatever pilot label the pilot last read from stderr. This
0008 reads those jobs from ``jobsarchived4`` for a window and writes one
0009 ``EpicProdJob`` row per crashed job, ``phase = 'payload_crash'``, with the
0010 crash record under ``data['crash']``: exit code and signal, site and host,
0011 time to death, memory, the pilot label as stored, the task name, the
0012 sequence number and the manifest row it ran, and the stage from the
0013 payload digest when the job carried one. The row comes from the attempt's
0014 manifest record; an attempt with no record (a legacy attempt whose
0015 sandbox was purged before the keepalive existed, the storm tasks among
0016 them) has its manifest established the way the residual rerun
0017 establishes it (``pcs.manifests.attempt_manifest``): the sandbox while
0018 the PanDA cache holds it, else the reconstruction from the definition's
0019 per-file event totals and the attempt's row count, verified against the
0020 delivered outputs and stored on the attempt as such, so the pass runs
0021 once per attempt and every later read is the record's.
0022
0023 The record, as verified 2026-09-11 on one PCS job (2723039) and one storm
0024 job (1768411):
0025
0026 * ``jobsarchived4`` holds the whole campaign (rows back to 2025-09);
0027 ``doma_pandaarch.jobsarchived`` holds nothing in the window, so the
0028 live table is read alone.
0029 * The job's manifest row is its ``pseudo_input`` file in ``filestable4``
0030 (dataset ``seq_number``, LFN = the row), and that table is purged after
0031 about 30 days (6,874 of 116,334 crashed jobs still had one), as is
0032 ``jobparamstable``. Beyond the purge the row comes from the tables
0033 JEDI keeps with the task: ``jedi_job_retry_history`` links every
0034 retried job to its successor, and the ``seq_number`` dataset's
0035 contents row carries the row's last attempt, so a job's row is the
0036 row of the last job in its retry chain (116,245 of 116,334 resolve
0037 this way; chains run to depth 9). The log LFN's six-digit serial is
0038 JEDI's job counter, not the row (task 39623: 4 rows, serials to 11),
0039 and is not used.
0040 * The payload digest in ``jobmetrics`` (``payloadStage=...``) exists on
0041 payload 0.11 jobs only, so the stage is mostly unknown for the
0042 campaign to date; the dig (stage 3) supplies it.
0043
0044 Nothing on an existing row outside the crash fields is touched: the
0045 payload report the sweep files under ``data['payload_report']`` and the
0046 registrar's notes stay as they are.
0047
0048 After the crash pass, the memory pass (SEGFAULT_DIAGNOSIS.md, Memory
0049 kills) finds the production tasks with a memory kill in the window,
0050 rebuilds their memory signatures over their whole life from aggregate
0051 queries (``monitor_app.segfaults.build_memory_signatures``; no row per
0052 killed job), and gives each signature that lacks them three
0053 representative jobs, ``phase = 'payload_memory'``: the shortest, median
0054 and longest time to kill among the kills whose manifest row resolves.
0055 ``--memory-only --since 2026-07-01`` is its back-fill.
0056
0057 After the bad-input pass, the Event Service pass (SEGFAULT_DIAGNOSIS.md,
0058 Event Service units) reads from swfdb the filed harness records of the
0059 Event Service jobs updated in the window and rebuilds, over every filed
0060 record of each task with a crashed unit, its ``esunit`` signatures
0061 (``monitor_app.segfaults.build_es_signatures``). ``--es-only --since
0062 2026-09-01`` is its back-fill.
0063
0064 Django-bootstrap standalone script — usable by hand and as the nightly
0065 chain step::
0066
0067 cd /data/wenauseic/github/swf-monitor/src
0068 ../../swf-testbed/.venv/bin/python ../scripts/segfault-inventory.py \\
0069 --since 2026-07-01 # the campaign back-fill
0070 ... --days 3 # the nightly top-up (overlap intended)
0071 ... --check --since 2026-07-01 # the record's count against swfdb's
0072
0073 Every run records one ``segfault_inventory`` action on the epicprod action
0074 stream with the window and the counts; a failure records outcome ``error``
0075 and exits non-zero. The last stdout line is a JSON summary; progress and
0076 errors go to stderr.
0077 """
0078 import argparse
0079 import json
0080 import logging
0081 import os
0082 import re
0083 import sys
0084 import time
0085 from datetime import datetime, timedelta, timezone as dt_timezone
0086
0087 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0088 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0089 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0090
0091 import django
0092 django.setup()
0093
0094 from django.db import connections
0095 from django.utils import timezone
0096
0097 from monitor_app.epicprod_inventory import _prod_task_for_jeditaskid
0098 from monitor_app.epicprod_logging import log_epicprod_action
0099 from monitor_app.models import CrashSignature, EpicProdJob
0100 from monitor_app.panda.constants import PANDA_SCHEMA
0101 from pcs.manifests import expand, record_of
0102 from pcs.models import PandaTasks
0103
0104 CRASH_EXITS = ('134', '135', '136', '139')
0105 SIGNAL_NAMES = {6: 'SIGABRT', 7: 'SIGBUS', 8: 'SIGFPE', 11: 'SIGSEGV'}
0106 BATCH = 1000
0107 PHASE = 'payload_crash'
0108 MEMORY_PHASE = 'payload_memory'
0109 INPUT_PHASE = 'payload_input'
0110 MEMORY_REPRESENTATIVES = 3
0111
0112
0113 MEMORY_CANDIDATES = 10
0114
0115 JOB_FIELDS = (
0116 'pandaid', 'jeditaskid', 'jobname', 'computingsite', 'modificationhost',
0117 'transexitcode', 'piloterrorcode', 'piloterrordiag', 'starttime',
0118 'endtime', 'modificationtime', 'maxrss', 'maxpss', 'cpuconsumptiontime',
0119 'attemptnr', 'jobmetrics',
0120 )
0121
0122 DISPATCHER_SEQ_RE = re.compile(r'evgen_job_dispatcher\.py(?:%20|\s)+(\d+)')
0123 DIGEST_RE = re.compile(r'payload(Stage|StageStatus|Trail|Events|Version|Exit)=(\S+)')
0124
0125 log = logging.getLogger('segfault-inventory')
0126
0127
0128 def _panda(sql, params=None):
0129 with connections['panda'].cursor() as cur:
0130 cur.execute(sql, params or [])
0131 cols = [c[0] for c in cur.description]
0132 return [dict(zip(cols, r)) for r in cur.fetchall()]
0133
0134
0135 def _window(args):
0136 """(since, until) in UTC from --since or --days; until is open."""
0137 if args.since:
0138 since = datetime.strptime(args.since, '%Y-%m-%d').replace(tzinfo=dt_timezone.utc)
0139 else:
0140 since = datetime.now(dt_timezone.utc) - timedelta(days=args.days)
0141 return since
0142
0143
0144
0145
0146
0147
0148
0149
0150
0151 PRODUCTION_PROCESSING_TYPE = 'epicproduction'
0152
0153
0154 def crashed_jobs(since, limit=None):
0155 """Crash-class failed jobs of production tasks in the window, oldest
0156 first, with the task name."""
0157 fields = ', '.join(f'j."{f}"' for f in JOB_FIELDS)
0158 sql = f"""
0159 SELECT {fields}, t."taskname"
0160 FROM "{PANDA_SCHEMA}"."jobsarchived4" j
0161 JOIN "{PANDA_SCHEMA}"."jedi_tasks" t ON t."jeditaskid" = j."jeditaskid"
0162 WHERE j."jobstatus" = 'failed'
0163 AND j."transexitcode" = ANY(%s)
0164 AND j."modificationtime" > %s
0165 AND t."processingtype" = %s
0166 ORDER BY j."modificationtime", j."pandaid"
0167 """
0168 params = [list(CRASH_EXITS), since, PRODUCTION_PROCESSING_TYPE]
0169 if limit:
0170 sql += ' LIMIT %s'
0171 params.append(int(limit))
0172 return _panda(sql, params)
0173
0174
0175 def record_count(since):
0176 """The appendix count: crash-class failed jobs of production tasks in
0177 the window."""
0178 rows = _panda(
0179 f"""SELECT count(*) AS n FROM "{PANDA_SCHEMA}"."jobsarchived4" j
0180 JOIN "{PANDA_SCHEMA}"."jedi_tasks" t ON t."jeditaskid" = j."jeditaskid"
0181 WHERE j."jobstatus" = 'failed' AND j."transexitcode" = ANY(%s)
0182 AND j."modificationtime" > %s AND t."processingtype" = %s""",
0183 [list(CRASH_EXITS), since, PRODUCTION_PROCESSING_TYPE])
0184 return int(rows[0]['n'])
0185
0186
0187 def seq_numbers_from_files(pandaids):
0188 """{pandaid: seq} from the jobs' own pseudo_input file rows, for jobs
0189 still inside filestable4's retention."""
0190 if not pandaids:
0191 return {}
0192 rows = _panda(
0193 f"""SELECT "pandaid", "lfn" FROM "{PANDA_SCHEMA}"."filestable4"
0194 WHERE "pandaid" = ANY(%s) AND "type" = 'pseudo_input' AND "lfn" <> 'pseudo_lfn'""",
0195 [list(pandaids)])
0196 return {int(r['pandaid']): int(r['lfn']) for r in rows if str(r['lfn']).isdigit()}
0197
0198
0199 class RetryChains:
0200 """The row of a job through its retry chain, per task: the last job in
0201 the chain is the pandaid on the row's ``seq_number`` contents entry.
0202 Both tables are fetched once per task."""
0203
0204 def __init__(self):
0205 self._next = {}
0206 self._last_seq = {}
0207
0208 def _load(self, tid):
0209 nxt = {}
0210 for r in _panda(
0211 f"""SELECT "oldpandaid", "newpandaid" FROM "{PANDA_SCHEMA}"."jedi_job_retry_history"
0212 WHERE "jeditaskid" = %s AND "relationtype" = 'retry'""", [tid]):
0213 nxt[int(r['oldpandaid'])] = int(r['newpandaid'])
0214 last = {}
0215 for r in _panda(
0216 f"""SELECT c."pandaid", c."lfn"
0217 FROM "{PANDA_SCHEMA}"."jedi_dataset_contents" c
0218 JOIN "{PANDA_SCHEMA}"."jedi_datasets" d
0219 ON d."datasetid" = c."datasetid" AND d."jeditaskid" = c."jeditaskid"
0220 WHERE c."jeditaskid" = %s AND d."type" = 'pseudo_input'
0221 AND d."datasetname" = 'seq_number' AND c."pandaid" IS NOT NULL""", [tid]):
0222 if str(r['lfn']).isdigit():
0223 last[int(r['pandaid'])] = int(r['lfn'])
0224 self._next[tid], self._last_seq[tid] = nxt, last
0225
0226 def seq(self, tid, pandaid):
0227 if tid not in self._next:
0228 self._load(tid)
0229 nxt, last = self._next[tid], self._last_seq[tid]
0230 cur, hops = pandaid, 0
0231 while cur not in last and cur in nxt and hops < 100:
0232 cur, hops = nxt[cur], hops + 1
0233 return last.get(cur)
0234
0235
0236 def seq_numbers_from_params(pandaids):
0237 """{pandaid: seq} from the dispatcher argument in the job parameters,
0238 for jobs whose parameters are still in the live table."""
0239 if not pandaids:
0240 return {}
0241 rows = _panda(
0242 f"""SELECT "pandaid", "jobparameters" FROM "{PANDA_SCHEMA}"."jobparamstable"
0243 WHERE "pandaid" = ANY(%s)""", [list(pandaids)])
0244 out = {}
0245 for r in rows:
0246 text = r['jobparameters'] or ''
0247 if not isinstance(text, str):
0248 text = text.read()
0249 m = DISPATCHER_SEQ_RE.search(text)
0250 if m:
0251 out[int(r['pandaid'])] = int(m.group(1))
0252 return out
0253
0254
0255
0256
0257 CONTAINER_RE = re.compile(r'"container_name":\s*"([^"]+)"')
0258
0259
0260 class ManifestRows:
0261 """The manifest rows of an attempt from its record on PandaTasks, by
0262 JEDI task id, fetched once per task; established and recorded when
0263 no record exists (``_establish``); None with the reason kept when it
0264 cannot be. Also the container image the task ran, from its PanDA
0265 task parameters."""
0266
0267 def __init__(self, establish=True):
0268 self._rows = {}
0269 self._tasks = {}
0270 self._containers = {}
0271 self._establish_manifests = establish
0272 self.established = {}
0273 self.unavailable = {}
0274
0275 def _establish(self, pt):
0276 """The rows of an attempt with no manifest record, established as
0277 the residual rerun establishes them (pcs/manifests.py,
0278 ``attempt_manifest``): the sandbox while the PanDA cache holds it,
0279 else the reconstruction from the definition's per-file totals and
0280 the attempt's row count, verified against the delivered outputs
0281 (one JLab Rucio listing per task) and stored on the attempt as
0282 such. None, with the reason kept, when it cannot be: no PCS task,
0283 no recorded outputs to verify against, an attempt cut by hand."""
0284 from pcs.commands import _delivered_row_keys
0285 from pcs.manifests import ManifestUnavailable, attempt_manifest
0286 tid = int(pt.jedi_task_id)
0287 task = pt.prod_task
0288 try:
0289 delivered = _delivered_row_keys(task)
0290 if delivered[0] is None:
0291 self.unavailable[tid] = 'no recorded RECO outputs to verify a reconstruction against'
0292 return None
0293 rows, info = attempt_manifest(task, pt, delivered_keys=delivered[0])
0294 except ManifestUnavailable as e:
0295 self.unavailable[tid] = str(e)[:300]
0296 return None
0297 except Exception as e:
0298 log.error('task %s: manifest establishment failed: %s', tid, e)
0299 self.unavailable[tid] = f'establishment failed: {e}'[:300]
0300 return None
0301 self.established[tid] = str(info.get('source') or '')
0302 log.info('task %s: manifest %s, %d rows', tid, info.get('source'), len(rows))
0303 return [(f, e, int(n), int(c)) for f, e, n, c in rows]
0304
0305 def container(self, jeditaskid):
0306 """The image the task ran (``container_name`` in its task
0307 parameters), '' when the parameters carry none."""
0308 if not jeditaskid:
0309 return ''
0310 if jeditaskid not in self._containers:
0311 from pcs.manifests import _taskparams
0312 m = CONTAINER_RE.search(_taskparams(jeditaskid) or '')
0313 self._containers[jeditaskid] = m.group(1) if m else ''
0314 return self._containers[jeditaskid]
0315
0316 def prod_task(self, jeditaskid):
0317 if jeditaskid not in self._tasks:
0318 self._tasks[jeditaskid] = _prod_task_for_jeditaskid(jeditaskid)
0319 return self._tasks[jeditaskid]
0320
0321 def row(self, jeditaskid, seq):
0322 if not jeditaskid or not seq:
0323 return None
0324 if jeditaskid not in self._rows:
0325 pt = (PandaTasks.objects.filter(jedi_task_id=int(jeditaskid))
0326 .select_related('prod_task', 'prod_task__dataset').first())
0327 rec = record_of(pt) if pt else None
0328 rows = expand(rec) if rec else None
0329 if rows is None and pt is not None and self._establish_manifests:
0330 rows = self._establish(pt)
0331 elif rows is None and pt is None:
0332 self.unavailable[int(jeditaskid)] = 'no PCS task association'
0333 self._rows[jeditaskid] = rows
0334 rows = self._rows[jeditaskid]
0335 if rows is None or seq < 1 or seq > len(rows):
0336 return None
0337 f, e, n, c = rows[seq - 1]
0338 return {'file': f, 'ext': e, 'nevents': n, 'ichunk': c}
0339
0340
0341 def _minutes(start, end):
0342 if not start or not end:
0343 return None
0344 return round((end - start).total_seconds() / 60.0, 1)
0345
0346
0347 def _digest(jobmetrics):
0348 """The payload digest fields in the job metrics, when the job carried one."""
0349 out = {}
0350 for key, value in DIGEST_RE.findall(jobmetrics or ''):
0351 out[key.lower()] = value
0352 return out
0353
0354
0355 def _summary(stage, signal, minutes, site):
0356 what = stage or 'payload'
0357 name = SIGNAL_NAMES.get(signal, f'signal {signal}')
0358 when = f' after {minutes:g} min' if minutes is not None else ''
0359 return f'{what} died on {name} ({signal}){when} at {site or "unknown site"}'
0360
0361
0362 def _num(value):
0363 """A numeric column as a JSON number: PanDA's numerics arrive as Decimal."""
0364 if value is None or value == '':
0365 return None
0366 try:
0367 f = float(value)
0368 except (TypeError, ValueError):
0369 return None
0370 return int(f) if f.is_integer() else f
0371
0372
0373 def crash_record(j, seq, row, seq_source):
0374 exit_code = int(j['transexitcode'])
0375 signal = exit_code - 128
0376 digest = _digest(j.get('jobmetrics'))
0377 stage = digest.get('stage') or ''
0378 minutes = _minutes(j.get('starttime'), j.get('endtime'))
0379 maxrss = _num(j.get('maxrss'))
0380 maxpss = _num(j.get('maxpss'))
0381 return {
0382 'exit_code': exit_code,
0383 'signal': signal,
0384 'computingsite': j.get('computingsite') or '',
0385 'modificationhost': j.get('modificationhost') or '',
0386 'starttime': j['starttime'].isoformat() if j.get('starttime') else None,
0387 'endtime': j['endtime'].isoformat() if j.get('endtime') else None,
0388 'modificationtime': j['modificationtime'].isoformat() if j.get('modificationtime') else None,
0389 'minutes': minutes,
0390 'maxrss_mb': round(maxrss / 1024.0, 1) if maxrss else None,
0391 'maxpss_mb': round(maxpss / 1024.0, 1) if maxpss else None,
0392 'cpuconsumptiontime': _num(j.get('cpuconsumptiontime')),
0393 'piloterrorcode': _num(j.get('piloterrorcode')),
0394 'piloterrordiag': (j.get('piloterrordiag') or '')[:300],
0395 'taskname': j.get('taskname') or '',
0396 'jobname': j.get('jobname') or '',
0397 'attemptnr': _num(j.get('attemptnr')),
0398 'seq_source': seq_source,
0399 'row': row,
0400 'stage': stage,
0401 'digest': digest or None,
0402 'trace_status': 'unknown',
0403 'recorded_at': datetime.now(dt_timezone.utc).isoformat(timespec='seconds'),
0404 }, stage, signal, minutes
0405
0406
0407
0408
0409 def write_batch(jobs, manifests, chains, dry_run, counts):
0410 pandaids = [int(j['pandaid']) for j in jobs]
0411 from_files = seq_numbers_from_files(pandaids)
0412 missing = [p for p in pandaids if p not in from_files]
0413 from_params = seq_numbers_from_params(missing) if missing else {}
0414 existing = {r.pandaid: r for r in EpicProdJob.objects.filter(pandaid__in=pandaids)}
0415 now = timezone.now()
0416 new, changed = [], []
0417 for j in jobs:
0418 pid = int(j['pandaid'])
0419 tid = int(j['jeditaskid']) if j.get('jeditaskid') else None
0420 seq, source = None, 'none'
0421 if pid in from_files:
0422 seq, source = from_files[pid], 'filestable4'
0423 elif tid:
0424 seq = chains.seq(tid, pid)
0425 if seq is not None:
0426 source = 'retry_chain'
0427 if seq is None and pid in from_params:
0428 seq, source = from_params[pid], 'jobparamstable'
0429 row = manifests.row(tid, seq)
0430 if seq is None:
0431 counts['seq_unresolved'] += 1
0432 if row is None:
0433 counts['rows_unresolved'] += 1
0434 crash, stage, signal, minutes = crash_record(j, seq, row, source)
0435 crash['container'] = manifests.container(tid) if tid else ''
0436 obj = existing.get(pid)
0437 if obj is None:
0438 obj = EpicProdJob(pandaid=pid)
0439 new.append(obj)
0440 else:
0441 changed.append(obj)
0442 obj.jeditaskid = tid
0443 obj.prod_task = manifests.prod_task(tid) if tid else None
0444 obj.seq_number = seq
0445 obj.job_index = seq - 1 if seq else None
0446 obj.status = 'failed'
0447 obj.phase = PHASE
0448 obj.failure_summary = _summary(stage, signal, minutes, j.get('computingsite'))
0449 data = dict(obj.data or {})
0450 data['crash'] = crash
0451 obj.data = data
0452 obj.last_refreshed_at = now
0453 obj.updated_at = now
0454 counts['jobs_seen'] += len(jobs)
0455 counts['rows_added'] += len(new)
0456 counts['rows_updated'] += len(changed)
0457 counts['tasks'].update(int(j['jeditaskid']) for j in jobs if j.get('jeditaskid'))
0458 if dry_run:
0459 return
0460 if new:
0461 EpicProdJob.objects.bulk_create(new, batch_size=BATCH)
0462 if changed:
0463 EpicProdJob.objects.bulk_update(
0464 changed,
0465 ['jeditaskid', 'prod_task', 'seq_number', 'job_index', 'status',
0466 'phase', 'failure_summary', 'data', 'last_refreshed_at', 'updated_at'],
0467 batch_size=BATCH)
0468
0469
0470
0471
0472 MEMORY_JOB_FIELDS = JOB_FIELDS + ('minramcount', 'maxvmem')
0473
0474
0475 def memory_record(j, seq, row, seq_source):
0476 """A memory-killed job's record, in the crash record's shape so the
0477 reproduction, the local fragment and the package read it unchanged."""
0478 digest = _digest(j.get('jobmetrics'))
0479 stage = digest.get('stage') or ''
0480 minutes = _minutes(j.get('starttime'), j.get('endtime'))
0481 code = int(_num(j.get('piloterrorcode')) or 0)
0482 mechanism = 'cgroup' if code == 1212 else 'eviction' if code == 1201 else 'sigkill'
0483 maxrss, maxpss, maxvmem = _num(j.get('maxrss')), _num(j.get('maxpss')), _num(j.get('maxvmem'))
0484 exit_text = str(j.get('transexitcode') or '')
0485 return {
0486 'kind': 'memory',
0487 'mechanism': mechanism,
0488 'exit_code': int(exit_text) if exit_text.isdigit() else None,
0489 'signal': None,
0490 'request_mb': _num(j.get('minramcount')),
0491 'computingsite': j.get('computingsite') or '',
0492 'modificationhost': j.get('modificationhost') or '',
0493 'starttime': j['starttime'].isoformat() if j.get('starttime') else None,
0494 'endtime': j['endtime'].isoformat() if j.get('endtime') else None,
0495 'modificationtime': j['modificationtime'].isoformat() if j.get('modificationtime') else None,
0496 'minutes': minutes,
0497 'maxrss_mb': round(maxrss / 1024.0, 1) if maxrss else None,
0498 'maxpss_mb': round(maxpss / 1024.0, 1) if maxpss else None,
0499 'maxvmem_mb': round(maxvmem / 1024.0, 1) if maxvmem else None,
0500 'cpuconsumptiontime': _num(j.get('cpuconsumptiontime')),
0501 'piloterrorcode': code or None,
0502 'piloterrordiag': (j.get('piloterrordiag') or '')[:300],
0503 'taskname': j.get('taskname') or '',
0504 'jobname': j.get('jobname') or '',
0505 'attemptnr': _num(j.get('attemptnr')),
0506 'seq_source': seq_source,
0507 'row': row,
0508 'stage': stage,
0509 'digest': digest or None,
0510 'recorded_at': datetime.now(dt_timezone.utc).isoformat(timespec='seconds'),
0511 }
0512
0513
0514 def _memory_summary(rec):
0515 from monitor_app.segfaults import MEMORY_MECHANISMS
0516 label = MEMORY_MECHANISMS.get(rec['mechanism'], {}).get('label', 'removed')
0517 when = f" after {rec['minutes']:g} min" if rec.get('minutes') is not None else ''
0518 return (f"{rec.get('stage') or 'payload'} killed for memory ({label}) at "
0519 f"{rec.get('maxrss_mb')} MB of a {rec.get('request_mb')} MB request{when} "
0520 f"at {rec.get('computingsite') or 'unknown site'}")
0521
0522
0523 def _input_summary(rec):
0524 when = f" after {rec['minutes']:g} min" if rec.get('minutes') is not None else ''
0525 return (f"{rec.get('stage') or 'payload'} stopped on its error (exit 1){when} "
0526 f"at {rec.get('computingsite') or 'unknown site'}")
0527
0528
0529 def memory_candidates(sig):
0530 """The pandaids of a memory or bad-input signature's failures, by time
0531 to failure."""
0532 from monitor_app.segfaults import (MEMORY_MINUTES_SQL, MEMORY_STAGE_SQL, STAGE_LABELS,
0533 input_fail_sql, is_input, memory_kill_sql)
0534 tid = int((sig.tasks or [{}])[0].get('jeditaskid') or 0)
0535 if not tid:
0536 return []
0537 cond, params = input_fail_sql() if is_input(sig) else memory_kill_sql()
0538 stage = (sig.data or {}).get('stage') or ''
0539 stage_cond, stage_params = '', []
0540 if (sig.data or {}).get('split'):
0541 if stage:
0542 stage_cond = f' AND {MEMORY_STAGE_SQL} = ANY(%s)'
0543 stage_params = [[k for k, v in STAGE_LABELS.items() if v == stage] + [stage]]
0544 else:
0545 stage_cond = f' AND {MEMORY_STAGE_SQL} IS NULL'
0546 rows = _panda(
0547 f"""SELECT j."pandaid", {MEMORY_MINUTES_SQL} AS minutes
0548 FROM "{PANDA_SCHEMA}"."jobsarchived4" j
0549 WHERE j."jeditaskid" = %s AND j."jobstatus" = 'failed' AND {cond}{stage_cond}
0550 ORDER BY minutes NULLS LAST, j."pandaid" """,
0551 [tid] + params + stage_params)
0552 return [int(r['pandaid']) for r in rows]
0553
0554
0555 def _anchored(ids, anchor, step, taken):
0556 """Up to MEMORY_CANDIDATES ids walking from ``anchor`` by ``step``,
0557 none already taken."""
0558 out, i = [], anchor
0559 while 0 <= i < len(ids) and len(out) < MEMORY_CANDIDATES:
0560 if ids[i] not in taken:
0561 out.append(ids[i])
0562 i += step
0563 return out
0564
0565
0566 def memory_representatives(sig, manifests, chains, counts):
0567 """Give a memory signature its representatives when it has fewer than
0568 MEMORY_REPRESENTATIVES: the shortest, median and longest time to kill
0569 among the kills whose manifest row resolves, written as EpicProdJob
0570 rows of phase payload_memory and named on the signature. Kept once
0571 chosen, so a reproduction requested against one stays valid."""
0572 from monitor_app.segfaults import is_input, job_filter
0573 bad_input = is_input(sig)
0574 have = list(EpicProdJob.objects.filter(job_filter(sig)).values_list('pandaid', flat=True))
0575 if len(have) >= MEMORY_REPRESENTATIVES:
0576 return 0
0577 ids = memory_candidates(sig)
0578 if not ids:
0579 return 0
0580 taken = set(have)
0581 anchors = [(0, 1), (len(ids) // 2, 1), (len(ids) - 1, -1)]
0582 pool = []
0583 for anchor, step in anchors:
0584 pool.append(_anchored(ids, anchor, step, taken))
0585 flat = sorted({p for group in pool for p in group})
0586 fields = ', '.join(f'j."{f}"' for f in MEMORY_JOB_FIELDS)
0587 jobs = {int(r['pandaid']): r for r in _panda(
0588 f"""SELECT {fields}, t."taskname" FROM "{PANDA_SCHEMA}"."jobsarchived4" j
0589 JOIN "{PANDA_SCHEMA}"."jedi_tasks" t ON t."jeditaskid" = j."jeditaskid"
0590 WHERE j."pandaid" = ANY(%s)""", [flat])}
0591 from_files = seq_numbers_from_files(flat)
0592 missing = [p for p in flat if p not in from_files]
0593 from_params = seq_numbers_from_params(missing) if missing else {}
0594 now = timezone.now()
0595 written = []
0596 for group in pool:
0597 if len(have) + len(written) >= MEMORY_REPRESENTATIVES:
0598 break
0599 for pid in group:
0600 if pid in taken or pid not in jobs:
0601 continue
0602 j = jobs[pid]
0603 tid = int(j['jeditaskid'])
0604 seq, source = None, 'none'
0605 if pid in from_files:
0606 seq, source = from_files[pid], 'filestable4'
0607 else:
0608 seq = chains.seq(tid, pid)
0609 if seq is not None:
0610 source = 'retry_chain'
0611 if seq is None and pid in from_params:
0612 seq, source = from_params[pid], 'jobparamstable'
0613 row = manifests.row(tid, seq)
0614 if row is None:
0615 counts['memory_rows_unresolved'] += 1
0616 continue
0617 rec = memory_record(j, seq, row, source)
0618 rec['container'] = manifests.container(tid)
0619 if bad_input:
0620 rec['kind'], rec['mechanism'] = 'input', 'stage_error'
0621 obj = EpicProdJob.objects.filter(pandaid=pid).first() or EpicProdJob(pandaid=pid)
0622 obj.jeditaskid = tid
0623 obj.prod_task = manifests.prod_task(tid)
0624 obj.seq_number = seq
0625 obj.job_index = seq - 1
0626 obj.status = 'failed'
0627 obj.phase = INPUT_PHASE if bad_input else MEMORY_PHASE
0628 obj.failure_summary = _input_summary(rec) if bad_input else _memory_summary(rec)
0629 data = dict(obj.data or {})
0630 data['crash'] = rec
0631 obj.data = data
0632 obj.last_refreshed_at = now
0633 obj.updated_at = now
0634 obj.save()
0635 taken.add(pid)
0636 written.append(pid)
0637 break
0638 if written:
0639 data = dict(sig.data or {})
0640 reps = list(dict.fromkeys(list(have) + written))
0641 data['representatives'] = reps
0642 data['rows_resolved'] = len(reps)
0643 sig.data = data
0644 sig.save(update_fields=['data', 'updated_at'])
0645 return len(written)
0646
0647
0648 def memory_pass(since, manifests, chains, counts):
0649 """The memory signatures of the tasks with a memory kill since
0650 ``since``, rebuilt over their life, and their representatives."""
0651 from monitor_app.segfaults import build_memory_signatures, memory_tasks
0652 tids = memory_tasks(since)
0653 log.info('memory pass: %d tasks with a memory kill since %s', len(tids),
0654 since.isoformat(timespec='seconds'))
0655 result = build_memory_signatures(tids)
0656 reps = 0
0657 for key in result['keys']:
0658 sig = CrashSignature.objects.filter(key=key).first()
0659 if sig is not None:
0660 reps += memory_representatives(sig, manifests, chains, counts)
0661 log.info(' %d memory signatures (%d new, %d retired), %d representatives written',
0662 result['signatures'], result['created'], result['retired'], reps)
0663 return {'memory_tasks': len(tids), 'memory_signatures': result['signatures'],
0664 'memory_signatures_new': result['created'],
0665 'memory_signatures_retired': result['retired'],
0666 'memory_representatives': reps,
0667 'memory_rows_unresolved': counts['memory_rows_unresolved']}
0668
0669
0670 def input_pass(since, manifests, chains, counts):
0671 """The bad-input signatures of the tasks with such a failure since
0672 ``since``, rebuilt over their life, and their representatives
0673 (SEGFAULT_DIAGNOSIS.md, Bad input)."""
0674 from monitor_app.segfaults import build_input_signatures, input_tasks, read_input_fatal
0675 tids = input_tasks(since)
0676 log.info('input pass: %d tasks with a bad-input failure since %s', len(tids),
0677 since.isoformat(timespec='seconds'))
0678 before = counts['memory_rows_unresolved']
0679 result = build_input_signatures(tids)
0680 reps = readings = 0
0681 for key in result['keys']:
0682 sig = CrashSignature.objects.filter(key=key).first()
0683 if sig is not None:
0684 reps += memory_representatives(sig, manifests, chains, counts)
0685 sig.refresh_from_db()
0686 readings += int(read_input_fatal(sig))
0687 log.info(' %d bad-input signatures (%d new), %d representatives written, %d read',
0688 result['signatures'], result['created'], reps, readings)
0689 return {'input_tasks': len(tids), 'input_signatures': result['signatures'],
0690 'input_signatures_new': result['created'],
0691 'input_representatives': reps, 'input_readings': readings,
0692 'input_rows_unresolved': counts['memory_rows_unresolved'] - before}
0693
0694
0695 def es_pass(since):
0696 """The Event Service unit signatures of the tasks with a crashed unit
0697 among the records filed since ``since``, rebuilt over all of each
0698 task's filed records (SEGFAULT_DIAGNOSIS.md, Event Service units).
0699 Reads swfdb alone."""
0700 from monitor_app.segfaults import build_es_signatures, es_tasks
0701 tids = es_tasks(since)
0702 log.info('Event Service pass: %d tasks with a crashed unit since %s', len(tids),
0703 since.isoformat(timespec='seconds'))
0704 result = build_es_signatures(tids)
0705 log.info(' %d Event Service unit signatures (%d new), %d unit crashes',
0706 result['signatures'], result['created'], result['units'])
0707 return {'es_tasks': len(tids), 'es_signatures': result['signatures'],
0708 'es_signatures_new': result['created'], 'es_unit_crashes': result['units']}
0709
0710
0711 def run(args):
0712 since = _window(args)
0713 t0 = time.monotonic()
0714 counts = {'jobs_seen': 0, 'rows_added': 0, 'rows_updated': 0,
0715 'seq_unresolved': 0, 'rows_unresolved': 0, 'tasks': set(),
0716 'memory_rows_unresolved': 0}
0717 log.info('window since %s%s', since.isoformat(timespec='seconds'),
0718 ' (dry run)' if args.dry_run else '')
0719 if args.memory_only:
0720 manifests = ManifestRows(establish=not args.no_manifest_establish)
0721 summary = {'since': since.isoformat(timespec='seconds'), 'memory_only': True}
0722 summary.update(memory_pass(since, manifests, RetryChains(), counts))
0723 summary['seconds'] = round(time.monotonic() - t0, 1)
0724 return summary, t0
0725 if args.input_only:
0726 manifests = ManifestRows(establish=not args.no_manifest_establish)
0727 summary = {'since': since.isoformat(timespec='seconds'), 'input_only': True}
0728 summary.update(input_pass(since, manifests, RetryChains(), counts))
0729 summary['seconds'] = round(time.monotonic() - t0, 1)
0730 return summary, t0
0731 if args.es_only:
0732 summary = {'since': since.isoformat(timespec='seconds'), 'es_only': True}
0733 summary.update(es_pass(since))
0734 summary['seconds'] = round(time.monotonic() - t0, 1)
0735 return summary, t0
0736 jobs = crashed_jobs(since, args.limit)
0737 log.info('%d crash-class jobs in the record', len(jobs))
0738 manifests = ManifestRows(establish=not args.dry_run and not args.no_manifest_establish)
0739 chains = RetryChains()
0740 for i in range(0, len(jobs), BATCH):
0741 write_batch(jobs[i:i + BATCH], manifests, chains, args.dry_run, counts)
0742 log.info(' %d/%d written', min(i + BATCH, len(jobs)), len(jobs))
0743 for tid, reason in sorted(manifests.unavailable.items()):
0744 log.info('task %s: manifest unavailable: %s', tid, reason)
0745 summary = {
0746 'since': since.isoformat(timespec='seconds'),
0747 'jobs_seen': counts['jobs_seen'],
0748 'rows_added': counts['rows_added'],
0749 'rows_updated': counts['rows_updated'],
0750 'seq_unresolved': counts['seq_unresolved'],
0751 'rows_unresolved': counts['rows_unresolved'],
0752 'tasks': len(counts['tasks']),
0753 'manifests_established': len(manifests.established),
0754 'manifests_unavailable': len(manifests.unavailable),
0755 'manifests': {'established': manifests.established,
0756 'unavailable': manifests.unavailable},
0757 'dry_run': bool(args.dry_run),
0758 }
0759 if not args.dry_run and not args.no_signatures:
0760 summary.update(signature_pass(sorted(counts['tasks']), args))
0761 if not args.dry_run and not args.no_memory:
0762 summary.update(memory_pass(since, manifests, chains, counts))
0763 if not args.dry_run and not args.no_input:
0764 summary.update(input_pass(since, manifests, chains, counts))
0765 if not args.dry_run and not args.no_es:
0766 summary.update(es_pass(since))
0767 summary['seconds'] = round(time.monotonic() - t0, 1)
0768 return summary, t0
0769
0770
0771 def signature_pass(jeditaskids, args):
0772 """The record-level signatures of the tasks touched (or of every task
0773 in the inventory when ``jeditaskids`` is None)."""
0774 from monitor_app.segfaults import build_record_signatures, remerge_frames
0775 log.info('signatures for %s', f'{len(jeditaskids)} tasks' if jeditaskids else 'every task')
0776 result = build_record_signatures(
0777 jeditaskids=jeditaskids, rows_lost=not args.no_rows_lost,
0778 rows_lost_max_tasks=args.rows_lost_max_tasks)
0779 log.info(' %d signatures (%d new, %d retired): %s', result['signatures'],
0780 result['created'], result.get('retired', 0), result['classes'])
0781
0782
0783
0784 frames = remerge_frames()
0785 log.info(' frames: %d kept, %d gone', len(frames['kept']), len(frames['gone']))
0786 return {'signatures': result['signatures'],
0787 'signatures_new': result['created'],
0788 'signatures_retired': result.get('retired', 0),
0789 'signature_classes': result['classes'],
0790 'rows_lost_checked': result['rows_lost_checked'],
0791 'frames_kept': len(frames['kept']), 'frames_gone': len(frames['gone'])}
0792
0793
0794 def check(args):
0795 """The record's crash-class count for the window against swfdb's rows."""
0796 since = _window(args)
0797 in_record = record_count(since)
0798 in_swfdb = EpicProdJob.objects.filter(
0799 phase=PHASE, data__crash__modificationtime__gt=since.replace(tzinfo=None).isoformat()).count()
0800 in_swfdb_any = EpicProdJob.objects.filter(phase=PHASE).count()
0801 return {'since': since.isoformat(timespec='seconds'),
0802 'record': in_record, 'swfdb_in_window': in_swfdb,
0803 'swfdb_total': in_swfdb_any, 'match': in_record == in_swfdb}
0804
0805
0806 def main():
0807 ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
0808 win = ap.add_mutually_exclusive_group()
0809 win.add_argument('--since', help='window start, YYYY-MM-DD (UTC)')
0810 win.add_argument('--days', type=int, default=3,
0811 help='window length in days back from now (default 3)')
0812 ap.add_argument('--limit', type=int, help='at most this many jobs (a trial run)')
0813 ap.add_argument('--dry-run', action='store_true', help='read and count, write nothing')
0814 ap.add_argument('--check', action='store_true',
0815 help="compare the record's count for the window with swfdb's rows")
0816 ap.add_argument('--signatures', action='store_true',
0817 help='rebuild the signatures of every task in the inventory, '
0818 'no job pass')
0819 ap.add_argument('--no-signatures', action='store_true',
0820 help='the job pass only')
0821 ap.add_argument('--no-memory', action='store_true',
0822 help='skip the memory pass')
0823 ap.add_argument('--memory-only', action='store_true',
0824 help='the memory pass alone over the window (its back-fill)')
0825 ap.add_argument('--no-input', action='store_true',
0826 help='skip the bad-input pass')
0827 ap.add_argument('--input-only', action='store_true',
0828 help='the bad-input pass alone over the window (its back-fill)')
0829 ap.add_argument('--no-es', action='store_true',
0830 help='skip the Event Service unit pass')
0831 ap.add_argument('--es-only', action='store_true',
0832 help='the Event Service unit pass alone over the window (its back-fill)')
0833 ap.add_argument('--no-rows-lost', action='store_true',
0834 help='skip the delivery lookup behind rows_lost')
0835 ap.add_argument('--no-manifest-establish', action='store_true',
0836 help='read manifest records only; establish none for attempts without one')
0837 ap.add_argument('--rows-lost-max-tasks', type=int, default=50,
0838 help='delivery lookups per pass, largest signatures first (default 50)')
0839 ap.add_argument('--instance', default='segfault-inventory',
0840 help="the action's instance name ('catalog-sync' under the chain)")
0841 ap.add_argument('--created-by', default='', help='the action record\'s username')
0842 ap.add_argument('--no-action', action='store_true',
0843 help='record no action (the ops agent records it under the chain)')
0844 args = ap.parse_args()
0845 logging.basicConfig(level=logging.INFO, stream=sys.stderr,
0846 format='%(asctime)s %(name)s %(levelname)s %(message)s')
0847 if args.check:
0848 result = check(args)
0849 print(json.dumps(result))
0850 return 0 if result['match'] else 3
0851 t0 = time.monotonic()
0852 try:
0853 if args.signatures:
0854 summary = signature_pass(None, args)
0855 summary['seconds'] = round(time.monotonic() - t0, 1)
0856 else:
0857 summary, t0 = run(args)
0858 except Exception as e:
0859 log.exception('segfault inventory failed')
0860 if not args.no_action:
0861 log_epicprod_action(
0862 args.instance, 'segfault_inventory', outcome='error',
0863 duration_ms=int((time.monotonic() - t0) * 1000),
0864 username=args.created_by, sublevel='normal', live_default=True,
0865 level=logging.ERROR, message=f'segfault inventory failed: {str(e)[:200]}',
0866 reason=str(e)[:300])
0867 print(json.dumps({'error': str(e)[:300]}))
0868 return 1
0869 if not args.dry_run and not args.no_action:
0870 memory = (f"{summary.get('memory_signatures', 0)} memory signatures "
0871 f"({summary.get('memory_signatures_new', 0)} new) over "
0872 f"{summary.get('memory_tasks', 0)} tasks, "
0873 f"{summary.get('memory_representatives', 0)} representatives written"
0874 if 'memory_signatures' in summary else '')
0875 bad_input = (f"{summary.get('input_signatures', 0)} bad-input signatures "
0876 f"({summary.get('input_signatures_new', 0)} new) over "
0877 f"{summary.get('input_tasks', 0)} tasks, "
0878 f"{summary.get('input_representatives', 0)} representatives written"
0879 if 'input_signatures' in summary else '')
0880 es_units = (f"{summary.get('es_signatures', 0)} Event Service unit signatures "
0881 f"({summary.get('es_signatures_new', 0)} new) over "
0882 f"{summary.get('es_tasks', 0)} tasks, "
0883 f"{summary.get('es_unit_crashes', 0)} unit crashes"
0884 if 'es_signatures' in summary else '')
0885 log_epicprod_action(
0886 args.instance, 'segfault_inventory', outcome='ok',
0887 duration_ms=int((time.monotonic() - t0) * 1000),
0888 username=args.created_by, sublevel='normal', live_default=True,
0889 message=(f"segfault signatures rebuilt: {summary.get('signatures')} "
0890 f"({summary.get('signatures_new')} new) {summary.get('signature_classes')}"
0891 if args.signatures else
0892 f"segfault inventory since {summary['since'][:10]}: {memory}"
0893 if args.memory_only else
0894 f"segfault inventory since {summary['since'][:10]}: {bad_input}"
0895 if args.input_only else
0896 f"segfault inventory since {summary['since'][:10]}: {es_units}"
0897 if args.es_only else
0898 f"segfault inventory since {summary['since'][:10]}: "
0899 f"{summary['jobs_seen']} crashed jobs, {summary['rows_added']} rows added, "
0900 f"{summary['rows_updated']} updated, {summary['rows_unresolved']} rows "
0901 f"unresolved, {summary['tasks']} tasks, "
0902 f"{summary.get('manifests_established', 0)} manifests established "
0903 f"({summary.get('manifests_unavailable', 0)} unavailable), "
0904 f"{summary.get('signatures', 0)} signatures "
0905 f"({summary.get('signatures_new', 0)} new)"
0906 + (f"; {memory}" if memory else '')
0907 + (f"; {bad_input}" if bad_input else '')
0908 + (f"; {es_units}" if es_units else '')),
0909 **{k: v for k, v in summary.items() if isinstance(v, int) and not isinstance(v, bool)})
0910 print(json.dumps(summary))
0911 return 0
0912
0913
0914 if __name__ == '__main__':
0915 sys.exit(main())