Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-09 10:47:25

0001 #!/usr/bin/env python3
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  # noqa: E402
0092 django.setup()
0093 
0094 from django.db import connections  # noqa: E402
0095 from django.utils import timezone  # noqa: E402
0096 
0097 from monitor_app.epicprod_inventory import _prod_task_for_jeditaskid  # noqa: E402
0098 from monitor_app.epicprod_logging import log_epicprod_action  # noqa: E402
0099 from monitor_app.models import CrashSignature, EpicProdJob  # noqa: E402
0100 from monitor_app.panda.constants import PANDA_SCHEMA  # noqa: E402
0101 from pcs.manifests import expand, record_of  # noqa: E402
0102 from pcs.models import PandaTasks  # noqa: E402
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 # Candidates tried around each anchor (shortest, median, longest) for a
0112 # representative whose manifest row resolves.
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 # The dispatcher exec in the job parameters: evgen_job_dispatcher.py <seq> <task>
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 # ------------------------------------------------------------- the record
0145 
0146 # The catalog is production's: only jobs of production tasks (processing
0147 # type epicproduction, the campaign's payload) are crash-class jobs here.
0148 # The testbed's streams (fast and prompt processing, EICFast) crash on
0149 # their own terms and never enter the production record (Torre,
0150 # 2026-09-13).
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 = {}      # jeditaskid -> {oldpandaid: newpandaid}
0206         self._last_seq = {}  # jeditaskid -> {pandaid of last attempt: 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 # ----------------------------------------------------------- the reading
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 = {}     # jeditaskid -> how the manifest was established
0273         self.unavailable = {}     # jeditaskid -> why it could not be
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:                                # noqa: BLE001
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 # ------------------------------------------------------------ the writing
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 # ------------------------------------------------------------ memory kills
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     # The frame entries recomputed from their members as they stand: a
0782     # split, a retirement, or a member that a reproduction settled under
0783     # another finding changes what a frame counts.
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())