Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """The segfault catalog: crash signatures from the crash inventory.
0002 
0003 swf-epicprod docs/SEGFAULT_DIAGNOSIS.md. The inventory
0004 (scripts/segfault-inventory.py) writes one EpicProdJob row per payload
0005 crash; this module groups them into CrashSignature rows at record level
0006 (exit code x PanDA task), reads the class from the record, and serves
0007 the catalog to the page, the MCP tools and the REST endpoints from
0008 swfdb alone. Nothing here reaches the PanDA database except
0009 ``task_totals``, which the nightly builder calls; no page render path
0010 does.
0011 
0012 Class reading, thresholds in SysConfig (``segfault_class_*``):
0013 
0014 - ``abort``: exit 134, whatever the rate;
0015 - ``configuration_dead``: crash rate at or above ``dead_rate``;
0016 - ``storm``: rate at or above ``storm_rate`` and the interquartile
0017   range of time to death under ``storm_iqr`` of the median;
0018 - ``sparse``: rate under ``sparse_rate``;
0019 - ``mixed`` otherwise, a class to look at rather than a verdict.
0020 
0021 Jobs killed for their memory are the ``memory`` class (SEGFAULT_DIAGNOSIS.md,
0022 Memory kills): one signature per task and stage, built by
0023 ``build_memory_signatures`` from aggregate queries on the PanDA record
0024 with no row per job, and a few representative jobs written by the
0025 inventory as EpicProdJob rows of phase ``payload_memory``.
0026 """
0027 import json
0028 import logging
0029 import re
0030 from collections import defaultdict
0031 from datetime import datetime, timezone as dt_timezone
0032 
0033 from django.db import connections
0034 from django.utils import timezone
0035 
0036 from .epicprod_inventory import TRACE_MAX_FRAMES
0037 from .models import CrashSignature, EpicProdJob, SysConfig
0038 
0039 logger = logging.getLogger(__name__)
0040 
0041 PHASE = 'payload_crash'
0042 MEMORY_PHASE = 'payload_memory'
0043 MEMORY_CLASS = 'memory'
0044 INPUT_PHASE = 'payload_input'
0045 INPUT_CLASS = 'input'
0046 # An Event Service unit that crashed inside a finished job
0047 # (SEGFAULT_DIAGNOSIS.md, Event Service units).
0048 ES_CLASS = 'es_unit'
0049 SIGNAL_NAMES = {6: 'SIGABRT', 7: 'SIGBUS', 8: 'SIGFPE', 11: 'SIGSEGV'}
0050 CLASS_LABELS = {
0051     'storm': 'Storm', 'configuration_dead': 'Config dead',
0052     'sparse': 'Sparse', 'abort': 'Abort', 'mixed': 'Mixed',
0053     MEMORY_CLASS: 'Memory', INPUT_CLASS: 'Bad input', ES_CLASS: 'ES unit',
0054 }
0055 STAGE_LABELS = {'simulation': 'simu', 'reconstruction': 'reco'}
0056 # What a signature records, the Kind facet of the catalog and the runs
0057 # page: a segfault (the payload died on a signal it raised itself:
0058 # SIGSEGV, SIGABRT, SIGBUS, SIGFPE), a himem job (killed from outside
0059 # for its memory) or bad input (a stage that stopped on an error it
0060 # reported, exit 1; SEGFAULT_DIAGNOSIS.md, Bad input).
0061 KIND_SEGFAULT, KIND_HIMEM, KIND_INPUT = 'segfault', 'himem', 'badinput'
0062 KIND_LABELS = {KIND_SEGFAULT: 'Segfault', KIND_HIMEM: 'Himem', KIND_INPUT: 'Bad input'}
0063 KIND_ORDER = [KIND_SEGFAULT, KIND_HIMEM, KIND_INPUT]
0064 # The stages whose reported stop is a bad-input failure.
0065 INPUT_STAGES = ('simulation', 'reconstruction')
0066 # How a memory kill removed the job, read from the pilot error, with the
0067 # conventional status of the signal it received (128 plus the signal).
0068 MEMORY_MECHANISMS = {
0069     'cgroup': {'label': 'memory cgroup kill', 'exit_code': 137, 'signal': 9},
0070     'eviction': {'label': 'batch eviction (SIGTERM)', 'exit_code': 143, 'signal': 15},
0071     'sigkill': {'label': 'SIGKILL above the request', 'exit_code': 137, 'signal': 9},
0072 }
0073 
0074 
0075 def is_memory(sig):
0076     return getattr(sig, 'class_hint', '') == MEMORY_CLASS
0077 
0078 
0079 def is_input(sig):
0080     return getattr(sig, 'class_hint', '') == INPUT_CLASS
0081 
0082 
0083 def sig_phase(sig):
0084     """The EpicProdJob phase of a signature's jobs."""
0085     return MEMORY_PHASE if is_memory(sig) else INPUT_PHASE if is_input(sig) else PHASE
0086 
0087 
0088 def stage_label(stage):
0089     """The short stage a key carries: simu, reco, another stage's own
0090     name, or '' for a crash whose stage the record does not know."""
0091     stage = str(stage or '')
0092     return STAGE_LABELS.get(stage, stage)
0093 
0094 
0095 def record_key(exit_code, jeditaskid, stage=''):
0096     """A record-level key: exit code by task, and by stage when the task's
0097     crashes split by stage (SEGFAULT_DIAGNOSIS.md, Signature record)."""
0098     key = f'exit{int(exit_code)}:task{int(jeditaskid)}'
0099     return f'{key}:{stage}' if stage else key
0100 
0101 
0102 def memory_key(jeditaskid, stage=''):
0103     """A memory signature's key: the task, and the stage when the task's
0104     kills split by stage."""
0105     key = f'memory:task{int(jeditaskid)}'
0106     return f'{key}:{stage}' if stage else key
0107 
0108 
0109 def input_key(jeditaskid, stage=''):
0110     """A bad-input signature's key: the task, and the stage when the
0111     task's failures split by stage."""
0112     key = f'input:task{int(jeditaskid)}'
0113     return f'{key}:{stage}' if stage else key
0114 
0115 
0116 def job_filter(sig):
0117     """The Q that selects a signature's own crashed jobs: its tasks, its
0118     exit code, and its stage where the entry is a stage entry; for a
0119     trace-level entry, its members' filters joined."""
0120     from django.db.models import Q
0121     if sig.level == 'trace':
0122         members = CrashSignature.objects.filter(key__in=list((sig.data or {}).get('members') or []))
0123         q = Q(pk__in=[])
0124         for m in members:
0125             q |= job_filter(m)
0126         return q
0127     task_ids = [t.get('jeditaskid') for t in (sig.tasks or []) if t.get('jeditaskid')]
0128     if is_es_unit(sig):
0129         # An Event Service unit signature's jobs are the jobs whose filed
0130         # records carry its crashed units.
0131         return Q(pandaid__in=sorted({c.get('pandaid') for u in ((sig.data or {}).get('units') or [])
0132                                      for c in u.get('crashes') or [] if c.get('pandaid')}))
0133     if is_memory(sig) or is_input(sig):
0134         # A memory or bad-input signature's jobs are its representatives,
0135         # whatever status the failure left.
0136         q = Q(phase=sig_phase(sig), jeditaskid__in=task_ids)
0137     else:
0138         q = Q(phase=PHASE, jeditaskid__in=task_ids, data__crash__exit_code=sig.exit_code)
0139     stage = (sig.data or {}).get('stage')
0140     if (sig.data or {}).get('split'):
0141         if stage:
0142             q &= Q(data__crash__stage__in=[k for k, v in STAGE_LABELS.items() if v == stage] + [stage])
0143         else:
0144             q &= (Q(data__crash__stage='') | Q(data__crash__stage__isnull=True))
0145     return q
0146 CLASS_ORDER = ['storm', 'configuration_dead', 'mixed', 'sparse', 'abort', ES_CLASS, MEMORY_CLASS,
0147                INPUT_CLASS]
0148 STATUS_LABELS = dict(CrashSignature.STATUSES)
0149 
0150 # Fields the record pass owns; everything else on a signature (status,
0151 # verdict, trace, reproduction, assessments, package) belongs to the
0152 # later stages and survives a rebuild.
0153 RECORD_FIELDS = ['level', 'exit_code', 'signal', 'class_hint', 'tasks',
0154                  'configuration', 'sites', 'crashes', 'rate', 'minutes_p10',
0155                  'minutes_p50', 'first_seen', 'last_seen', 'rows_lost',
0156                  'events_lost', 'data', 'updated_at']
0157 
0158 THRESHOLD_DEFAULTS = {
0159     'segfault_class_dead_rate': 0.95,
0160     'segfault_class_storm_rate': 0.20,
0161     'segfault_class_storm_iqr': 0.20,
0162     'segfault_class_sparse_rate': 0.05,
0163     # A SIGTERM eviction or a SIGKILL is a memory kill when the job's peak
0164     # resident memory reached this fraction of its request.
0165     'segfault_memory_rss_fraction': 0.9,
0166 }
0167 
0168 
0169 def thresholds():
0170     """The class thresholds, each seeded into SysConfig at its default on
0171     first read, so every knob is on the System page."""
0172     out = {}
0173     for key, default in THRESHOLD_DEFAULTS.items():
0174         value = SysConfig.get_setting(key, default)
0175         try:
0176             out[key] = float(value)
0177         except (TypeError, ValueError):
0178             logger.error('SysConfig %s=%r is not a number; using %s',
0179                          key, value, default)
0180             out[key] = default
0181     return out
0182 
0183 
0184 def _percentile(values, p):
0185     if not values:
0186         return None
0187     values = sorted(values)
0188     if len(values) == 1:
0189         return values[0]
0190     pos = (len(values) - 1) * p
0191     lo, hi = int(pos), min(int(pos) + 1, len(values) - 1)
0192     return round(values[lo] + (values[hi] - values[lo]) * (pos - lo), 1)
0193 
0194 
0195 def classify(exit_code, rate, minutes, th):
0196     """The class reading from the record: exit code, crash rate and the
0197     spread of time to death."""
0198     if exit_code == 134:
0199         return 'abort'
0200     if rate is None:
0201         return 'mixed'
0202     if rate >= th['segfault_class_dead_rate']:
0203         return 'configuration_dead'
0204     p25, p50, p75 = (_percentile(minutes, 0.25), _percentile(minutes, 0.5),
0205                      _percentile(minutes, 0.75))
0206     if (rate >= th['segfault_class_storm_rate'] and p50
0207             and (p75 - p25) < th['segfault_class_storm_iqr'] * p50):
0208         return 'storm'
0209     if rate < th['segfault_class_sparse_rate']:
0210         return 'sparse'
0211     return 'mixed'
0212 
0213 
0214 # ------------------------------------------------------------- the record
0215 
0216 def task_totals(jeditaskids):
0217     """{jeditaskid: {'finished': n, 'failed': n}} over the tasks' whole
0218     life from the PanDA record; the builder's call, never a page's."""
0219     from .panda.constants import PANDA_SCHEMA
0220     ids = sorted({int(t) for t in jeditaskids if t})
0221     if not ids:
0222         return {}
0223     out = defaultdict(lambda: {'finished': 0, 'failed': 0})
0224     with connections['panda'].cursor() as cur:
0225         cur.execute(
0226             f"""SELECT "jeditaskid", "jobstatus", count(*)
0227                 FROM "{PANDA_SCHEMA}"."jobsarchived4"
0228                 WHERE "jeditaskid" = ANY(%s) AND "jobstatus" IN ('finished', 'failed')
0229                 GROUP BY 1, 2""", [ids])
0230         for tid, status, n in cur.fetchall():
0231             out[int(tid)][status] = int(n)
0232     return dict(out)
0233 
0234 
0235 def _configuration(prod_task, payload_version=''):
0236     """The configuration block of a task's signature."""
0237     if prod_task is None:
0238         return {}
0239     dataset = getattr(prod_task, 'dataset', None)
0240     config = getattr(prod_task, 'prod_config', None)
0241     campaign = getattr(prod_task, 'campaign', None) or getattr(dataset, 'campaign', None)
0242     physics = getattr(dataset, 'physics_tag', None)
0243     return {
0244         'prod_task': prod_task.name,
0245         'campaign': getattr(campaign, 'name', '') if campaign else '',
0246         'composed_name': getattr(dataset, 'composed_name', '') if dataset else '',
0247         'process': getattr(physics, 'name', '') if physics else '',
0248         'prod_config_id': getattr(config, 'id', None) if config else None,
0249         'prod_config': getattr(config, 'name', '') if config else '',
0250         'container_image': getattr(config, 'container_image', '') if config else '',
0251         'detector_version': getattr(dataset, 'detector_version', '') if dataset else '',
0252         'payload_version': payload_version or '',
0253     }
0254 
0255 
0256 def _iso(dt):
0257     return dt.isoformat(timespec='seconds') if dt else None
0258 
0259 
0260 def _parse_dt(text):
0261     if not text:
0262         return None
0263     try:
0264         dt = datetime.fromisoformat(text)
0265     except ValueError:
0266         return None
0267     return dt if dt.tzinfo else dt.replace(tzinfo=dt_timezone.utc)
0268 
0269 
0270 def _row_key(row):
0271     file_col = row.get('file') or ''
0272     head, _, stem = file_col.rpartition('/')
0273     return head, stem, f"{int(row.get('ichunk') or 0):04d}"
0274 
0275 
0276 def _rows_lost(prod_task, rows):
0277     """(rows_lost, events_lost, note) for the crashed rows of a
0278     configuration: those with no delivered RECO output in any attempt,
0279     read through the delivery lookup the residual rerun uses. A lookup
0280     that cannot be made is reported, never guessed."""
0281     if prod_task is None:
0282         return None, None, 'no PCS task'
0283     if not rows:
0284         return None, None, 'rows unresolved'
0285     try:
0286         from pcs.commands import _delivered_row_keys
0287         keys, dids, _arrival = _delivered_row_keys(prod_task)
0288     except Exception as e:                                    # noqa: BLE001
0289         logger.error('segfault rows_lost %s: delivery lookup failed: %s',
0290                      prod_task.name, e)
0291         return None, None, f'delivery lookup failed: {str(e)[:120]}'
0292     if keys is None:
0293         return None, None, 'no recorded RECO outputs'
0294     lost = [r for r in rows.values() if _row_key(r) not in keys]
0295     return (len(lost), sum(int(r.get('nevents') or 0) for r in lost),
0296             f'{len(dids)} RECO dataset(s) checked')
0297 
0298 
0299 def build_record_signatures(jeditaskids=None, rows_lost=True,
0300                             rows_lost_max_tasks=50):
0301     """Group the crash inventory into record-level signatures and upsert
0302     them. ``jeditaskids`` limits the pass to those tasks (the nightly
0303     top-up); None rebuilds every task in the inventory. Returns a
0304     summary dict."""
0305     from pcs.models import PandaTasks
0306     th = thresholds()
0307     qs = EpicProdJob.objects.filter(phase=PHASE)
0308     if jeditaskids:
0309         qs = qs.filter(jeditaskid__in=[int(t) for t in jeditaskids])
0310     # A task whose crashes carry two or more known stages splits by
0311     # stage: one entry per stage, the stage on the key, and the crashes
0312     # with no stage under the plain key (SEGFAULT_DIAGNOSIS.md, Signature
0313     # record). Every other task keeps its plain key.
0314     stages_by_task = defaultdict(set)
0315     for job in qs.only('jeditaskid', 'data').iterator(chunk_size=5000):
0316         crash = (job.data or {}).get('crash') or {}
0317         label = stage_label(crash.get('stage'))
0318         if label:
0319             stages_by_task[(int(crash.get('exit_code') or 0),
0320                             int(job.jeditaskid) if job.jeditaskid else 0)].add(label)
0321     split_tasks = {k for k, v in stages_by_task.items() if len(v) >= 2}
0322     groups = {}
0323     for job in qs.only('pandaid', 'jeditaskid', 'prod_task_id', 'seq_number',
0324                        'data').iterator(chunk_size=5000):
0325         crash = (job.data or {}).get('crash') or {}
0326         exit_code = int(crash.get('exit_code') or 0)
0327         tid = int(job.jeditaskid) if job.jeditaskid else 0
0328         stage = stage_label(crash.get('stage')) if (exit_code, tid) in split_tasks else ''
0329         g = groups.get((exit_code, tid, stage))
0330         if g is None:
0331             g = groups[(exit_code, tid, stage)] = {
0332                 'exit_code': exit_code, 'jeditaskid': tid, 'stage': stage,
0333                 'split': (exit_code, tid) in split_tasks,
0334                 'taskname': crash.get('taskname') or '',
0335                 'prod_task_id': job.prod_task_id, 'crashes': 0,
0336                 'minutes': [], 'sites': defaultdict(lambda: {'crashes': 0, 'hosts': set()}),
0337                 'first': None, 'last': None, 'rows': {}, 'unresolved_rows': 0,
0338                 'stages': defaultdict(int), 'payload_version': '',
0339             }
0340         g['crashes'] += 1
0341         if crash.get('minutes') is not None:
0342             g['minutes'].append(float(crash['minutes']))
0343         site = g['sites'][crash.get('computingsite') or 'unknown']
0344         site['crashes'] += 1
0345         if crash.get('modificationhost'):
0346             site['hosts'].add(crash['modificationhost'])
0347         seen = _parse_dt(crash.get('endtime') or crash.get('modificationtime'))
0348         if seen:
0349             g['first'] = seen if not g['first'] or seen < g['first'] else g['first']
0350             g['last'] = seen if not g['last'] or seen > g['last'] else g['last']
0351         if crash.get('row'):
0352             g['rows'][job.seq_number] = crash['row']
0353         else:
0354             g['unresolved_rows'] += 1
0355         if crash.get('stage'):
0356             g['stages'][crash['stage']] += 1
0357         digest = crash.get('digest') or {}
0358         if digest.get('version') and not g['payload_version']:
0359             g['payload_version'] = digest['version']
0360         if crash.get('container') and not g.get('container'):
0361             g['container'] = crash['container']
0362 
0363     totals = task_totals([g['jeditaskid'] for g in groups.values()])
0364     prod_tasks = {}
0365     payload_versions = {}
0366     for pt in PandaTasks.objects.filter(
0367             jedi_task_id__in=[g['jeditaskid'] for g in groups.values()]
0368     ).select_related('prod_task', 'prod_task__dataset', 'prod_task__prod_config',
0369                      'prod_task__dataset__physics_tag', 'prod_task__campaign',
0370                      'prod_task__dataset__campaign'):
0371         prod_tasks[pt.jedi_task_id] = pt.prod_task
0372         payload_versions[pt.jedi_task_id] = (pt.metadata or {}).get('payload_version', '')
0373 
0374     # Loss is a delivery lookup per configuration; the largest signatures
0375     # first, bounded per pass.
0376     lost_budget = rows_lost_max_tasks if rows_lost else 0
0377     summary = {'signatures': 0, 'created': 0, 'classes': defaultdict(int),
0378                'rows_lost_checked': 0}
0379     now = timezone.now()
0380     split_keys = defaultdict(list)
0381     for (exit_code, tid, stage) in groups:
0382         if (exit_code, tid) in split_tasks:
0383             split_keys[(exit_code, tid)].append(record_key(exit_code, tid, stage))
0384     for (exit_code, tid, stage), g in sorted(groups.items(),
0385                                              key=lambda kv: -kv[1]['crashes']):
0386         tot = totals.get(tid, {'finished': 0, 'failed': 0})
0387         denom = tot['finished'] + tot['failed']
0388         rate = g['crashes'] / denom if denom else None
0389         class_hint = classify(exit_code, rate, g['minutes'], th)
0390         prod_task = prod_tasks.get(tid)
0391         key = record_key(exit_code, tid, stage)
0392         sig = CrashSignature.objects.filter(key=key).first()
0393         created = sig is None
0394         if created:
0395             sig = CrashSignature(key=key, status='new')
0396             if g['split'] and stage:
0397                 _inherit_from_plain(sig, exit_code, tid, stage)
0398         lost_note = (sig.data or {}).get('rows_lost_note') or 'not checked'
0399         lost, events_lost = sig.rows_lost, sig.events_lost
0400         if lost_budget > 0 and prod_task is not None and g['rows']:
0401             lost, events_lost, lost_note = _rows_lost(prod_task, g['rows'])
0402             lost_budget -= 1
0403             summary['rows_lost_checked'] += 1
0404         elif prod_task is None:
0405             lost, events_lost, lost_note = None, None, 'no PCS task'
0406         elif not g['rows']:
0407             lost, events_lost, lost_note = None, None, 'rows unresolved'
0408         sig.exit_code = exit_code
0409         sig.signal = exit_code - 128
0410         sig.class_hint = class_hint
0411         sig.tasks = [{
0412             'jeditaskid': tid, 'taskname': g['taskname'],
0413             'crashes': g['crashes'], 'finished': tot['finished'],
0414             'failed': tot['failed'], 'first_seen': _iso(g['first']),
0415             'last_seen': _iso(g['last']),
0416         }]
0417         sig.configuration = _configuration(prod_task, payload_versions.get(tid, '')
0418                                            or g['payload_version'])
0419         # The image the crashed task ran, from its PanDA task parameters:
0420         # what a reproduction runs, whatever the configuration says now.
0421         sig.configuration['container_image_ran'] = g.get('container', '')
0422         sig.sites = sorted(
0423             ({'site': s, 'crashes': v['crashes'], 'hosts': len(v['hosts'])}
0424              for s, v in g['sites'].items()),
0425             key=lambda s: -s['crashes'])
0426         sig.crashes = g['crashes']
0427         sig.rate = round(rate, 4) if rate is not None else None
0428         sig.minutes_p10 = _percentile(g['minutes'], 0.10)
0429         sig.minutes_p50 = _percentile(g['minutes'], 0.50)
0430         sig.first_seen, sig.last_seen = g['first'], g['last']
0431         sig.rows_lost, sig.events_lost = lost, events_lost
0432         data = dict(sig.data or {})
0433         data.update({
0434             'hosts': len({h for v in g['sites'].values() for h in v['hosts']}),
0435             'rows_resolved': len(g['rows']),
0436             'rows_unresolved': g['unresolved_rows'],
0437             'minutes_p25': _percentile(g['minutes'], 0.25),
0438             'minutes_p75': _percentile(g['minutes'], 0.75),
0439             'stages': dict(g['stages']),
0440             'rows_lost_note': lost_note,
0441             'record_built_at': _iso(now),
0442             'stage': stage,
0443             'split': g['split'],
0444             'split_keys': sorted(split_keys.get((exit_code, tid), [])),
0445         })
0446         sig.data = data
0447         sig.updated_at = now
0448         sig.save()
0449         summary['signatures'] += 1
0450         summary['created'] += int(created)
0451         summary['classes'][class_hint] += 1
0452     # A split task's plain entry that holds no crash any more (every job
0453     # carries a stage) is retired: its fields have moved to the stage
0454     # entries; the findings that named it name the stage entries.
0455     retired = 0
0456     for (exit_code, tid) in split_tasks:
0457         if (exit_code, tid, '') in groups:
0458             continue
0459         plain = CrashSignature.objects.filter(key=record_key(exit_code, tid)).first()
0460         if plain is not None:
0461             _retire_plain(plain, sorted(split_keys[(exit_code, tid)]))
0462             retired += 1
0463     summary['retired'] = retired
0464     summary['classes'] = dict(summary['classes'])
0465     return summary
0466 
0467 
0468 def _inherit_from_plain(sig, exit_code, tid, stage):
0469     """A new stage entry takes from the task's plain entry what belongs
0470     to its stage: the trace when the trace's stage is this one; the
0471     reproductions of jobs whose stage is this one, with the outcome, the
0472     verdict, the diagnosis marking, the assessments and the package that
0473     followed them."""
0474     plain = CrashSignature.objects.filter(key=record_key(exit_code, tid)).first()
0475     if plain is None:
0476         return
0477     pdata = plain.data or {}
0478     trace = plain.trace or {}
0479     if trace.get('trace_status') and stage_label(trace.get('stage')) == stage:
0480         sig.trace = dict(trace)
0481         sig.status = 'traced' if trace.get('trace_status') == 'found' else 'new'
0482     repro = []
0483     for entry in (plain.reproduction or []):
0484         job = EpicProdJob.objects.filter(pandaid=entry.get('pandaid'), phase=PHASE).only('data').first()
0485         job_stage = stage_label(((job.data if job else None) or {}).get('crash', {}).get('stage'))
0486         if job_stage == stage:
0487             repro.append(entry)
0488     if repro:
0489         sig.reproduction = repro
0490         sig.verdict = plain.verdict
0491         sig.status = plain.status if plain.status not in ('new', 'digging', 'traced') else sig.status
0492         sig.assessment_ids = list(plain.assessment_ids or [])
0493         sig.package = dict(plain.package or {})
0494         data = dict(sig.data or {})
0495         for k in ('reproduction_outcome', 'reproduction_settled_at', 'diagnosis'):
0496             if k in pdata:
0497                 data[k] = pdata[k]
0498         sig.data = data
0499 
0500 
0501 def _retire_plain(plain, stage_keys):
0502     """Retire a split task's plain entry: leave its frame group, hand its
0503     name in the findings to the stage entries, delete it."""
0504     for e in finding_entries():
0505         edata = e.data or {}
0506         keys = [str(k) for k in (edata.get('signatures') or [e.name])]
0507         if plain.key not in keys:
0508             continue
0509         fstage = stage_label(edata.get('stage'))
0510         take = [k for k in stage_keys if not fstage or k.endswith(':' + fstage)] or stage_keys
0511         new_keys = [k for k in keys if k != plain.key] + [k for k in take if k not in keys]
0512         set_finding(e.name, {'signatures': new_keys}, changed_by='inventory:split')
0513     frame_key_ = (plain.data or {}).get('member_of')
0514     plain.delete()
0515     if frame_key_:
0516         frame = CrashSignature.objects.filter(key=frame_key_).first()
0517         if frame is not None:
0518             remerge_frame(frame)
0519 
0520 
0521 # ------------------------------------------------------------ memory kills
0522 
0523 # The payload stage a job's digest names, and its minutes from start to end.
0524 MEMORY_STAGE_SQL = """substring(j."jobmetrics" from 'payloadStage=([A-Za-z_]+)')"""
0525 MEMORY_MINUTES_SQL = """extract(epoch FROM (j."endtime" - j."starttime")) / 60.0"""
0526 MEMORY_MECHANISM_SQL = """CASE WHEN j."piloterrorcode" = 1212 THEN 'cgroup'
0527                               WHEN j."piloterrorcode" = 1201 THEN 'eviction'
0528                               ELSE 'sigkill' END"""
0529 
0530 
0531 def memory_kill_sql():
0532     """The condition selecting a memory kill among failed jobs (alias
0533     ``j``) and its parameters (SEGFAULT_DIAGNOSIS.md, Memory kills): a
0534     cgroup kill; a SIGTERM eviction, or a SIGKILL under another pilot
0535     label, whose peak resident memory reached the SysConfig fraction of
0536     the request. ``maxrss`` is kB in the record, ``minramcount`` MB."""
0537     frac = thresholds()['segfault_memory_rss_fraction']
0538     sql = """((j."piloterrorcode" = 1212 AND j."piloterrordiag" ILIKE '%%out of memory%%')
0539               OR (j."piloterrorcode" = 1201 AND j."piloterrordiag" ILIKE '%%SIGTERM%%'
0540                   AND j."maxrss" >= %s * j."minramcount" * 1024)
0541               OR (j."transexitcode" = '137' AND j."piloterrorcode" <> 1212
0542                   AND j."maxrss" >= %s * j."minramcount" * 1024))"""
0543     return sql, [frac, frac]
0544 
0545 
0546 def _panda_rows(sql, params):
0547     with connections['panda'].cursor() as cur:
0548         cur.execute(sql, params)
0549         cols = [c[0] for c in cur.description]
0550         return [dict(zip(cols, r)) for r in cur.fetchall()]
0551 
0552 
0553 def memory_tasks(since):
0554     """The production tasks with a memory kill recorded after ``since``;
0555     the builder's call, never a page's."""
0556     from .panda.constants import PANDA_SCHEMA
0557     cond, params = memory_kill_sql()
0558     rows = _panda_rows(
0559         f"""SELECT DISTINCT j."jeditaskid" FROM "{PANDA_SCHEMA}"."jobsarchived4" j
0560             JOIN "{PANDA_SCHEMA}"."jedi_tasks" t ON t."jeditaskid" = j."jeditaskid"
0561             WHERE j."jobstatus" = 'failed' AND j."modificationtime" > %s
0562               AND t."processingtype" = 'epicproduction' AND {cond}""",
0563         [since] + params)
0564     return sorted(int(r['jeditaskid']) for r in rows)
0565 
0566 
0567 def _memory_aggregate(tids, by_stage, by_site=False):
0568     """The memory kills of the tasks over their whole life, grouped by
0569     task, by stage when asked, and by queue and removal when asked."""
0570     from .panda.constants import PANDA_SCHEMA
0571     if not tids:
0572         return []
0573     cond, params = memory_kill_sql()
0574     stage = MEMORY_STAGE_SQL if by_stage else "''"
0575     site_cols = (f', j."computingsite" AS site, {MEMORY_MECHANISM_SQL} AS mechanism'
0576                  if by_site else '')
0577     group = '1, 2' + (', 3, 4' if by_site else '')
0578     return _panda_rows(
0579         f"""SELECT j."jeditaskid", coalesce({stage}, '') AS stage{site_cols},
0580                    count(*) AS kills, count(DISTINCT j."modificationhost") AS hosts,
0581                    percentile_cont(0.10) WITHIN GROUP (ORDER BY {MEMORY_MINUTES_SQL}) AS p10,
0582                    percentile_cont(0.25) WITHIN GROUP (ORDER BY {MEMORY_MINUTES_SQL}) AS p25,
0583                    percentile_cont(0.50) WITHIN GROUP (ORDER BY {MEMORY_MINUTES_SQL}) AS p50,
0584                    percentile_cont(0.75) WITHIN GROUP (ORDER BY {MEMORY_MINUTES_SQL}) AS p75,
0585                    percentile_cont(0.50) WITHIN GROUP (ORDER BY j."maxrss") AS rss_p50,
0586                    max(j."maxrss") AS rss_max,
0587                    percentile_cont(0.50) WITHIN GROUP (ORDER BY j."maxpss") AS pss_p50,
0588                    min(j."minramcount") AS req_min, max(j."minramcount") AS req_max,
0589                    min(j."endtime") AS first, max(j."endtime") AS last,
0590                    max(t."taskname") AS taskname
0591             FROM "{PANDA_SCHEMA}"."jobsarchived4" j
0592             JOIN "{PANDA_SCHEMA}"."jedi_tasks" t ON t."jeditaskid" = j."jeditaskid"
0593             WHERE j."jeditaskid" = ANY(%s) AND j."jobstatus" = 'failed' AND {cond}
0594             GROUP BY {group}""",
0595         [list(tids)] + params)
0596 
0597 
0598 def _mb(kb):
0599     return round(float(kb) / 1024.0) if kb is not None else None
0600 
0601 
0602 def _min(value):
0603     return round(float(value), 1) if value is not None else None
0604 
0605 
0606 def _utc(dt):
0607     if dt is None:
0608         return None
0609     return dt if dt.tzinfo else dt.replace(tzinfo=dt_timezone.utc)
0610 
0611 
0612 def build_memory_signatures(jeditaskids):
0613     """The memory signatures of the tasks, recomputed over each task's
0614     life from the PanDA record and upserted: one per task, or one per
0615     stage when the task's kills carry two or more known stages (the kills
0616     of no recorded stage under the plain key), as the record-level crash
0617     entries split. Representatives are the inventory's (it writes them
0618     and records them on the signature); everything a reproduction wrote
0619     survives. Returns a summary with the keys built."""
0620     import re
0621     from pcs.manifests import _taskparams
0622     from pcs.models import PandaTasks
0623     tids = sorted({int(t) for t in jeditaskids if t})
0624     summary = {'signatures': 0, 'created': 0, 'retired': 0, 'keys': []}
0625     if not tids:
0626         return summary
0627     staged = _memory_aggregate(tids, by_stage=True)
0628     stages_by_task = defaultdict(set)
0629     for r in staged:
0630         if r['stage']:
0631             stages_by_task[int(r['jeditaskid'])].add(stage_label(r['stage']))
0632     split = {tid for tid, s in stages_by_task.items() if len(s) >= 2}
0633     whole = [t for t in tids if t not in split]
0634     groups = {}
0635     for r in staged:
0636         if int(r['jeditaskid']) in split:
0637             groups[(int(r['jeditaskid']), stage_label(r['stage']))] = r
0638     for r in _memory_aggregate(whole, by_stage=False):
0639         groups[(int(r['jeditaskid']), '')] = r
0640     stage_counts = defaultdict(dict)
0641     for r in staged:
0642         if r['stage']:
0643             stage_counts[int(r['jeditaskid'])][r['stage']] = int(r['kills'])
0644     sites = defaultdict(list)
0645     for r in _memory_aggregate(sorted(split), by_stage=True, by_site=True):
0646         sites[(int(r['jeditaskid']), stage_label(r['stage']))].append(r)
0647     for r in _memory_aggregate(whole, by_stage=False, by_site=True):
0648         sites[(int(r['jeditaskid']), '')].append(r)
0649 
0650     totals = task_totals(tids)
0651     prod_tasks, payload_versions = {}, {}
0652     for pt in PandaTasks.objects.filter(jedi_task_id__in=tids).select_related(
0653             'prod_task', 'prod_task__dataset', 'prod_task__prod_config',
0654             'prod_task__dataset__physics_tag', 'prod_task__campaign',
0655             'prod_task__dataset__campaign'):
0656         prod_tasks[pt.jedi_task_id] = pt.prod_task
0657         payload_versions[pt.jedi_task_id] = (pt.metadata or {}).get('payload_version', '')
0658     split_keys = defaultdict(list)
0659     for (tid, stage) in groups:
0660         if tid in split:
0661             split_keys[tid].append(memory_key(tid, stage))
0662     now = timezone.now()
0663     for (tid, stage), g in sorted(groups.items(), key=lambda kv: -int(kv[1]['kills'])):
0664         key = memory_key(tid, stage)
0665         sig = CrashSignature.objects.filter(key=key).first()
0666         created = sig is None
0667         if created:
0668             sig = CrashSignature(key=key, status='new')
0669         site_rows = sorted(sites.get((tid, stage), []), key=lambda s: -int(s['kills']))
0670         mechanisms = defaultdict(int)
0671         for s in site_rows:
0672             mechanisms[s['mechanism']] += int(s['kills'])
0673         dominant = max(mechanisms.items(), key=lambda kv: kv[1])[0] if mechanisms else 'cgroup'
0674         kills = int(g['kills'])
0675         tot = totals.get(tid, {'finished': 0, 'failed': 0})
0676         denom = tot['finished'] + tot['failed']
0677         sig.level = 'record'
0678         sig.class_hint = MEMORY_CLASS
0679         sig.exit_code = MEMORY_MECHANISMS[dominant]['exit_code']
0680         sig.signal = MEMORY_MECHANISMS[dominant]['signal']
0681         first, last = _utc(g['first']), _utc(g['last'])
0682         sig.tasks = [{'jeditaskid': tid, 'taskname': g['taskname'] or '', 'crashes': kills,
0683                       'finished': tot['finished'], 'failed': tot['failed'],
0684                       'first_seen': _iso(first), 'last_seen': _iso(last)}]
0685         sig.configuration = _configuration(prod_tasks.get(tid), payload_versions.get(tid, ''))
0686         m = re.search(r'"container_name":\s*"([^"]+)"', _taskparams(tid) or '')
0687         sig.configuration['container_image_ran'] = m.group(1) if m else ''
0688         sig.sites = [{'site': s['site'], 'crashes': int(s['kills']), 'hosts': int(s['hosts']),
0689                       'mechanism': s['mechanism'],
0690                       'request_mb': int(s['req_max']) if s['req_max'] is not None else None,
0691                       'rss_p50_mb': _mb(s['rss_p50']), 'rss_max_mb': _mb(s['rss_max'])}
0692                      for s in site_rows]
0693         sig.crashes = kills
0694         sig.rate = round(kills / denom, 4) if denom else None
0695         sig.minutes_p10, sig.minutes_p50 = _min(g['p10']), _min(g['p50'])
0696         sig.first_seen, sig.last_seen = first, last
0697         sig.rows_lost, sig.events_lost = None, None
0698         data = dict(sig.data or {})
0699         data.update({
0700             'kind': MEMORY_CLASS,
0701             'hosts': int(g['hosts']),
0702             'minutes_p25': _min(g['p25']), 'minutes_p75': _min(g['p75']),
0703             # A stage entry holds its stage; a split task's plain entry the
0704             # kills of no recorded stage; a whole task its one stage, if any.
0705             'stages': ({g['stage']: kills} if stage else {} if tid in split
0706                        else dict(stage_counts.get(tid, {}))),
0707             'memory': {
0708                 'request_mb': [int(g['req_min']) if g['req_min'] is not None else None,
0709                                int(g['req_max']) if g['req_max'] is not None else None],
0710                 'rss_p50_mb': _mb(g['rss_p50']), 'rss_max_mb': _mb(g['rss_max']),
0711                 'pss_p50_mb': _mb(g['pss_p50']),
0712                 'mechanisms': dict(mechanisms), 'dominant': dominant,
0713             },
0714             'rows_lost_note': 'not computed for memory kills: the killed rows are not kept per job',
0715             'record_built_at': _iso(now),
0716             'stage': stage,
0717             'split': tid in split,
0718             'split_keys': sorted(split_keys.get(tid, [])),
0719         })
0720         data.setdefault('representatives', [])
0721         data.setdefault('rows_resolved', 0)
0722         sig.data = data
0723         sig.updated_at = now
0724         sig.save()
0725         summary['signatures'] += 1
0726         summary['created'] += int(created)
0727         summary['keys'].append(key)
0728     # A split task whose kills all carry a stage leaves no plain entry; one
0729     # left from before the split goes unless a reproduction is recorded on it.
0730     for tid in split:
0731         if (tid, '') in groups:
0732             continue
0733         plain = CrashSignature.objects.filter(key=memory_key(tid)).first()
0734         if plain is None:
0735             continue
0736         if plain.reproduction:
0737             logger.info('memory signature %s kept: its task now splits by stage, '
0738                         'but a reproduction is recorded on it', plain.key)
0739             continue
0740         plain.delete()
0741         summary['retired'] += 1
0742     return summary
0743 
0744 
0745 def input_fail_sql():
0746     """The condition selecting a bad-input failure among failed jobs
0747     (alias ``j``) and its parameters (SEGFAULT_DIAGNOSIS.md, Bad input):
0748     payload exit 1 with the simulation or reconstruction stage named as
0749     the stage that failed."""
0750     sql = f"""(j."transexitcode" = '1'
0751               AND j."jobmetrics" LIKE '%%payloadStageStatus=fail%%'
0752               AND {MEMORY_STAGE_SQL} = ANY(%s))"""
0753     return sql, [list(INPUT_STAGES)]
0754 
0755 
0756 def input_tasks(since):
0757     """The production tasks with a bad-input failure recorded after
0758     ``since``; the builder's call, never a page's."""
0759     from .panda.constants import PANDA_SCHEMA
0760     cond, params = input_fail_sql()
0761     rows = _panda_rows(
0762         f"""SELECT DISTINCT j."jeditaskid" FROM "{PANDA_SCHEMA}"."jobsarchived4" j
0763             JOIN "{PANDA_SCHEMA}"."jedi_tasks" t ON t."jeditaskid" = j."jeditaskid"
0764             WHERE j."jobstatus" = 'failed' AND j."modificationtime" > %s
0765               AND t."processingtype" = 'epicproduction' AND {cond}""",
0766         [since] + params)
0767     return sorted(int(r['jeditaskid']) for r in rows)
0768 
0769 
0770 def _input_aggregate(tids, by_stage, by_site=False):
0771     """The bad-input failures of the tasks over their whole life, grouped
0772     by task, by stage when asked, and by queue when asked."""
0773     from .panda.constants import PANDA_SCHEMA
0774     if not tids:
0775         return []
0776     cond, params = input_fail_sql()
0777     stage = MEMORY_STAGE_SQL if by_stage else "''"
0778     site_col = ', j."computingsite" AS site' if by_site else ''
0779     group = '1, 2' + (', 3' if by_site else '')
0780     return _panda_rows(
0781         f"""SELECT j."jeditaskid", coalesce({stage}, '') AS stage{site_col},
0782                    count(*) AS failures, count(DISTINCT j."modificationhost") AS hosts,
0783                    percentile_cont(0.10) WITHIN GROUP (ORDER BY {MEMORY_MINUTES_SQL}) AS p10,
0784                    percentile_cont(0.25) WITHIN GROUP (ORDER BY {MEMORY_MINUTES_SQL}) AS p25,
0785                    percentile_cont(0.50) WITHIN GROUP (ORDER BY {MEMORY_MINUTES_SQL}) AS p50,
0786                    percentile_cont(0.75) WITHIN GROUP (ORDER BY {MEMORY_MINUTES_SQL}) AS p75,
0787                    min(j."endtime") AS first, max(j."endtime") AS last,
0788                    max(t."taskname") AS taskname
0789             FROM "{PANDA_SCHEMA}"."jobsarchived4" j
0790             JOIN "{PANDA_SCHEMA}"."jedi_tasks" t ON t."jeditaskid" = j."jeditaskid"
0791             WHERE j."jeditaskid" = ANY(%s) AND j."jobstatus" = 'failed' AND {cond}
0792             GROUP BY {group}""",
0793         [list(tids)] + params)
0794 
0795 
0796 def build_input_signatures(jeditaskids):
0797     """The bad-input signatures of the tasks, recomputed over each task's
0798     life from the PanDA record and upserted, split by stage as the memory
0799     signatures are. Representatives and the reading are the inventory's;
0800     everything a later stage wrote survives. Returns a summary with the
0801     keys built."""
0802     import re
0803     from pcs.manifests import _taskparams
0804     from pcs.models import PandaTasks
0805     tids = sorted({int(t) for t in jeditaskids if t})
0806     summary = {'signatures': 0, 'created': 0, 'retired': 0, 'keys': []}
0807     if not tids:
0808         return summary
0809     staged = _input_aggregate(tids, by_stage=True)
0810     stages_by_task = defaultdict(set)
0811     for r in staged:
0812         if r['stage']:
0813             stages_by_task[int(r['jeditaskid'])].add(stage_label(r['stage']))
0814     split = {tid for tid, s in stages_by_task.items() if len(s) >= 2}
0815     groups = {}
0816     for r in staged:
0817         tid = int(r['jeditaskid'])
0818         groups[(tid, stage_label(r['stage']) if tid in split else '')] = r
0819     for r in _input_aggregate([t for t in tids if t not in split], by_stage=False):
0820         groups[(int(r['jeditaskid']), '')] = r
0821     stage_counts = defaultdict(dict)
0822     for r in staged:
0823         if r['stage']:
0824             stage_counts[int(r['jeditaskid'])][r['stage']] = int(r['failures'])
0825     sites = defaultdict(list)
0826     for r in _input_aggregate(sorted(split), by_stage=True, by_site=True):
0827         sites[(int(r['jeditaskid']), stage_label(r['stage']))].append(r)
0828     for r in _input_aggregate([t for t in tids if t not in split], by_stage=False, by_site=True):
0829         sites[(int(r['jeditaskid']), '')].append(r)
0830 
0831     totals = task_totals(tids)
0832     prod_tasks, payload_versions = {}, {}
0833     for pt in PandaTasks.objects.filter(jedi_task_id__in=tids).select_related(
0834             'prod_task', 'prod_task__dataset', 'prod_task__prod_config',
0835             'prod_task__dataset__physics_tag', 'prod_task__campaign',
0836             'prod_task__dataset__campaign'):
0837         prod_tasks[pt.jedi_task_id] = pt.prod_task
0838         payload_versions[pt.jedi_task_id] = (pt.metadata or {}).get('payload_version', '')
0839     split_keys = defaultdict(list)
0840     for (tid, stage) in groups:
0841         if tid in split:
0842             split_keys[tid].append(input_key(tid, stage))
0843     now = timezone.now()
0844     for (tid, stage), g in sorted(groups.items(), key=lambda kv: -int(kv[1]['failures'])):
0845         key = input_key(tid, stage)
0846         sig = CrashSignature.objects.filter(key=key).first()
0847         created = sig is None
0848         if created:
0849             sig = CrashSignature(key=key, status='new')
0850         failures = int(g['failures'])
0851         tot = totals.get(tid, {'finished': 0, 'failed': 0})
0852         denom = tot['finished'] + tot['failed']
0853         sig.level = 'record'
0854         sig.class_hint = INPUT_CLASS
0855         sig.exit_code, sig.signal = 1, 0  # no signal: the stage reported its error
0856         first, last = _utc(g['first']), _utc(g['last'])
0857         sig.tasks = [{'jeditaskid': tid, 'taskname': g['taskname'] or '', 'crashes': failures,
0858                       'finished': tot['finished'], 'failed': tot['failed'],
0859                       'first_seen': _iso(first), 'last_seen': _iso(last)}]
0860         sig.configuration = _configuration(prod_tasks.get(tid), payload_versions.get(tid, ''))
0861         m = re.search(r'"container_name":\s*"([^"]+)"', _taskparams(tid) or '')
0862         sig.configuration['container_image_ran'] = m.group(1) if m else ''
0863         sig.sites = [{'site': s['site'], 'crashes': int(s['failures']), 'hosts': int(s['hosts'])}
0864                      for s in sorted(sites.get((tid, stage), []), key=lambda s: -int(s['failures']))]
0865         sig.crashes = failures
0866         sig.rate = round(failures / denom, 4) if denom else None
0867         sig.minutes_p10, sig.minutes_p50 = _min(g['p10']), _min(g['p50'])
0868         sig.first_seen, sig.last_seen = first, last
0869         sig.rows_lost, sig.events_lost = None, None
0870         data = dict(sig.data or {})
0871         data.update({
0872             'kind': INPUT_CLASS,
0873             'hosts': int(g['hosts']),
0874             'minutes_p25': _min(g['p25']), 'minutes_p75': _min(g['p75']),
0875             'stages': ({g['stage']: failures} if stage else {} if tid in split
0876                        else dict(stage_counts.get(tid, {}))),
0877             'rows_lost_note': 'not computed for bad-input failures: the failed rows are not kept per job',
0878             'record_built_at': _iso(now),
0879             'stage': stage,
0880             'split': tid in split,
0881             'split_keys': sorted(split_keys.get(tid, [])),
0882         })
0883         data.setdefault('input', {})
0884         data.setdefault('representatives', [])
0885         data.setdefault('rows_resolved', 0)
0886         sig.data = data
0887         sig.updated_at = now
0888         sig.save()
0889         summary['signatures'] += 1
0890         summary['created'] += int(created)
0891         summary['keys'].append(key)
0892     return summary
0893 
0894 
0895 def _fatal_lines(text):
0896     """The FATAL lines a stage's program wrote, whitespace collapsed, in
0897     order: DDSim and Geant4 write them as '<component> FATAL +++ <text>'."""
0898     import re
0899     out = []
0900     for line in (text or '').splitlines():
0901         if re.search(r'\bFATAL\b', line):
0902             out.append(' '.join(line.split())[:300])
0903     return out
0904 
0905 
0906 def read_input_fatal(sig, fetch=None):
0907     """Give a bad-input signature its reading when it has none: the FATAL
0908     lines of the first representative whose payload log holds any, read
0909     through the PanDA monitor's file browser (as the job study reads it).
0910     ``fetch(pandaid)`` returns the payload.stdout text, for tests. Returns
0911     True when a reading was recorded (SEGFAULT_DIAGNOSIS.md, Bad input)."""
0912     data = dict(sig.data or {})
0913     reading = dict(data.get('input') or {})
0914     if reading.get('fatal'):
0915         return False
0916     if fetch is None:
0917         from askpanda_atlas.log_analysis_impl import _fetch_log_text
0918         from decouple import config
0919         base_url = config('PANDA_BASE_URL', default='https://pandamon01.sdcc.bnl.gov')
0920 
0921         def fetch(pid):
0922             return _fetch_log_text(pid, 'payload.stdout', base_url, timeout=30)
0923     for pid in data.get('representatives') or []:
0924         try:
0925             lines = _fatal_lines(fetch(pid))
0926         except Exception as e:                                # noqa: BLE001
0927             logger.warning('bad-input reading of %s from job %s failed: %s', sig.key, pid, e)
0928             continue
0929         if lines:
0930             reading.update({'fatal': lines[0], 'fatal_lines': lines[:5],
0931                             'read_from': pid, 'read_at': _iso(timezone.now())})
0932             data['input'] = reading
0933             sig.data = data
0934             sig.save(update_fields=['data', 'updated_at'])
0935             return True
0936     return False
0937 
0938 
0939 # ------------------------------------------------- Event Service units
0940 
0941 # A unit of an Event Service job that crashed fails alone inside a job
0942 # that ends finished; its crash is read from the job's filed harness
0943 # record (EpicProdJob.data['es_record'], JOB_REPORTING.md) by the forms
0944 # its message takes (SEGFAULT_DIAGNOSIS.md, Event Service units).
0945 ES_RESIDENT_DEATH = 'Resident simulation exited without a reply'
0946 ES_RESIDENT_SIGNAL_RE = re.compile(r'Resident simulation died on signal (\d+)')
0947 ES_PAYLOAD_EXIT_RE = re.compile(r'^payload exit (\d+)')
0948 ES_STAGE_RE = re.compile(r'\bstage: \S+ (\w+) fail')
0949 ES_SLOT_RE = re.compile(r'^slot exited -(\d+) with the unit in flight')
0950 # A run.sh unit whose payload stopped in its resident simulation request.
0951 ES_RESIDENT_REQUEST = 'resident_sim.py" request'
0952 CRASH_SIGNALS = (6, 7, 8, 11)
0953 
0954 
0955 def is_es_unit(sig):
0956     return getattr(sig, 'class_hint', '') == ES_CLASS
0957 
0958 
0959 def es_unit_key(jeditaskid, stage=''):
0960     """An Event Service unit signature's key: the task and the stage."""
0961     key = f'esunit:task{int(jeditaskid)}'
0962     return f'{key}:{stage}' if stage else key
0963 
0964 
0965 def es_request(unit):
0966     """The request a unit sent its simulation: input LFN and path, the
0967     zero-based first event, the event count and the request seed. The
0968     four fields that identify it are None where the record lacks them."""
0969     sim = unit.get('simulation') or {}
0970     rng = unit.get('range') or {}
0971     lfn = ''
0972     try:
0973         lfn = (json.loads(sim.get('source_id') or '{}') or {}).get('LFN') or ''
0974     except (TypeError, ValueError):
0975         lfn = ''
0976     first = sim.get('first_event')
0977     if first is None and rng.get('startEvent') is not None:
0978         first = int(rng['startEvent']) - 1
0979     events = sim.get('events') or unit.get('events')
0980     seed = sim.get('seed')
0981     return {'lfn': lfn or (sim.get('input_file') or '').rpartition('/')[2],
0982             'input_file': sim.get('input_file') or '',
0983             'first_event': int(first) if first is not None else None,
0984             'events': int(events) if events else None,
0985             'seed': int(seed) if seed not in (None, '') else None}
0986 
0987 
0988 def _request_id(req):
0989     return (req['lfn'], req['first_event'], req['events'], req['seed'])
0990 
0991 
0992 def es_crash(unit):
0993     """(stage, signal, form) when the unit's message is a crash form
0994     itself, else None. The signal is 0 where the record does not name it;
0995     a run.sh unit stopped in its resident request is a crash only as the
0996     repeat of a recorded crash request (``es_crash_units``)."""
0997     message = unit.get('message') or ''
0998     m = ES_RESIDENT_SIGNAL_RE.search(message)
0999     if m:
1000         return 'simulation', int(m.group(1)), 'resident_signal'
1001     if ES_RESIDENT_DEATH in message:
1002         return 'simulation', 0, 'resident_death'
1003     m = ES_PAYLOAD_EXIT_RE.match(message)
1004     if m and int(m.group(1)) in CRASH_EXITS:
1005         stage = ES_STAGE_RE.search(message)
1006         return (stage.group(1) if stage else ''), int(m.group(1)) - 128, 'payload_exit'
1007     m = ES_SLOT_RE.match(message)
1008     if m and int(m.group(1)) in CRASH_SIGNALS:
1009         return '', int(m.group(1)), 'slot_signal'
1010     return None
1011 
1012 
1013 def es_crash_units(record):
1014     """The crashed units of one filed record (``record['failed']``) as
1015     (unit, stage, signal, form), and the request ids of its crash forms;
1016     the repeats are added by the caller against the task's crash ids."""
1017     out = []
1018     for unit in record.get('failed') or []:
1019         crash = es_crash(unit)
1020         if crash:
1021             out.append((unit, *crash))
1022     return out
1023 
1024 
1025 def _es_records(where, params):
1026     """(pandaid, jeditaskid, record) for the filed Event Service records
1027     matching ``where``, each record cut in SQL to its failed units and
1028     its unit counts: a record carries every done unit (tens of thousands
1029     on a whole node), which the intake never reads."""
1030     from django.db import connection
1031     sql = f"""SELECT pandaid, jeditaskid,
1032                      data->'es_record'->'record'->'failed',
1033                      data->'es_record'->'record'->>'done_count',
1034                      data->'es_record'->'record'->>'failed_count',
1035                      jsonb_array_length(COALESCE(data->'es_record'->'record'->'done', '[]'::jsonb))
1036               FROM swf_epicprod_jobs
1037               WHERE data ? 'es_record' AND jeditaskid IS NOT NULL AND {where}
1038               ORDER BY pandaid"""
1039     with connection.cursor() as cur:
1040         cur.execute(sql, params)
1041         for pandaid, tid, failed, done_count, failed_count, done_len in cur.fetchall():
1042             if isinstance(failed, str):
1043                 failed = json.loads(failed)
1044             failed = failed if isinstance(failed, list) else []
1045             yield int(pandaid), int(tid), {
1046                 'failed': failed,
1047                 'done_count': int(done_count) if done_count not in (None, '') else int(done_len or 0),
1048                 'failed_count': int(failed_count) if failed_count not in (None, '') else len(failed)}
1049 
1050 
1051 def es_tasks(since):
1052     """The tasks with a crashed unit among the Event Service jobs whose
1053     record was filed or updated since ``since`` (swfdb only)."""
1054     return sorted({tid for _p, tid, record in _es_records('updated_at >= %s', [since])
1055                    if es_crash_units(record)})
1056 
1057 
1058 def _from_epoch(value):
1059     try:
1060         return datetime.fromtimestamp(float(value), tz=dt_timezone.utc)
1061     except (TypeError, ValueError):
1062         return None
1063 
1064 
1065 def build_es_signatures(jeditaskids):
1066     """The Event Service unit signatures of the tasks, rebuilt from every
1067     filed record of each task and upserted, one per task and stage; what
1068     a later stage wrote (status, trace, reproductions, verdict) survives.
1069     Returns a summary with the keys built."""
1070     from pcs.models import PandaTasks
1071     tids = sorted({int(t) for t in jeditaskids if t})
1072     summary = {'signatures': 0, 'created': 0, 'units': 0, 'keys': []}
1073     if not tids:
1074         return summary
1075     prod_tasks, payload_versions, task_names, task_sites = {}, {}, {}, {}
1076     for pt in PandaTasks.objects.filter(jedi_task_id__in=tids).select_related(
1077             'prod_task', 'prod_task__dataset', 'prod_task__prod_config',
1078             'prod_task__dataset__physics_tag', 'prod_task__campaign',
1079             'prod_task__dataset__campaign'):
1080         prod_tasks[pt.jedi_task_id] = pt.prod_task
1081         payload_versions[pt.jedi_task_id] = (pt.metadata or {}).get('payload_version', '')
1082         task_names[pt.jedi_task_id] = pt.task_name
1083         task_sites[pt.jedi_task_id] = pt.site
1084     now = timezone.now()
1085     for tid in tids:
1086         units_run, jobs_filed = 0, 0
1087         crashes, deferred = [], []      # (pandaid, unit, stage, signal, form)
1088         for pandaid, _tid, record in _es_records('jeditaskid = %s', [tid]):
1089             jobs_filed += 1
1090             units_run += record['done_count'] + record['failed_count']
1091             found = es_crash_units(record)
1092             crashed = {id(c[0]) for c in found}
1093             for unit, stage, signal, form in found:
1094                 crashes.append((pandaid, unit, stage, signal, form))
1095             for unit in record['failed']:
1096                 if id(unit) not in crashed and ES_RESIDENT_REQUEST in (unit.get('message') or ''):
1097                     deferred.append((pandaid, unit))
1098         # A run.sh unit stopped in its resident request on a request the
1099         # task's records already show crashing is the same crash again.
1100         known = {_request_id(es_request(u)): (stage, signal) for _p, u, stage, signal, _f in crashes}
1101         for pandaid, unit in deferred:
1102             hit = known.get(_request_id(es_request(unit)))
1103             if hit and None not in _request_id(es_request(unit)):
1104                 crashes.append((pandaid, unit, 'simulation', hit[1], 'resident_repeat'))
1105         groups = defaultdict(list)
1106         for c in crashes:
1107             groups[stage_label(c[2])].append(c)
1108         for stage, members in groups.items():
1109             key = es_unit_key(tid, stage)
1110             sig = CrashSignature.objects.filter(key=key).first()
1111             created = sig is None
1112             if created:
1113                 sig = CrashSignature(key=key, status='new')
1114             requests = {}
1115             signals = defaultdict(int)
1116             seconds, ended = [], []
1117             for pandaid, unit, _stage, signal, form in members:
1118                 req = es_request(unit)
1119                 rid = _request_id(req)
1120                 entry = requests.setdefault(rid, dict(req, crashes=[]))
1121                 at = _from_epoch(unit.get('ended_at'))
1122                 entry['crashes'].append({
1123                     'pandaid': pandaid, 'unit_id': unit.get('unit_id') or '',
1124                     'slot': unit.get('slot'), 'form': form, 'signal': signal,
1125                     'seconds': unit.get('wall_s'), 'ended_at': _iso(at),
1126                     'message': (unit.get('message') or '')[:300]})
1127                 if signal:
1128                     signals[signal] += 1
1129                 if unit.get('wall_s') is not None:
1130                     seconds.append(float(unit['wall_s']))
1131                 if at:
1132                     ended.append(at)
1133             units = sorted(requests.values(), key=lambda r: (-len(r['crashes']), r['lfn'],
1134                                                                r['first_event'] or 0))
1135             for r in units:
1136                 r['passes'] = len({c['pandaid'] for c in r['crashes']})
1137             signal = max(signals, key=signals.get) if signals else (sig.signal or 0)
1138             sig.level = 'record'
1139             sig.class_hint = ES_CLASS
1140             sig.signal = signal
1141             sig.exit_code = 128 + signal if signal else 0
1142             first, last = (min(ended), max(ended)) if ended else (None, None)
1143             sig.tasks = [{'jeditaskid': tid, 'taskname': task_names.get(tid, ''),
1144                           'crashes': len(members), 'units': units_run, 'jobs': jobs_filed,
1145                           'first_seen': _iso(first), 'last_seen': _iso(last)}]
1146             sig.configuration = _configuration(prod_tasks.get(tid), payload_versions.get(tid, ''))
1147             site = task_sites.get(tid) or ''
1148             sig.sites = ([{'site': site, 'crashes': len(members),
1149                            'hosts': None}] if site else [])
1150             sig.crashes = len(members)
1151             sig.rate = round(len(members) / units_run, 6) if units_run else None
1152             sig.minutes_p10 = _min(_percentile(seconds, 0.1) / 60.0) if seconds else None
1153             sig.minutes_p50 = _min(_percentile(seconds, 0.5) / 60.0) if seconds else None
1154             sig.first_seen, sig.last_seen = first, last
1155             sig.rows_lost, sig.events_lost = None, None
1156             data = dict(sig.data or {})
1157             data.update({
1158                 'kind': ES_CLASS,
1159                 'units': units,
1160                 'requests': len(units),
1161                 'stages': {_stage_name(stage): len(members)} if stage else {},
1162                 'rows_lost_note': ("an Event Service unit's events go back to the file; the loss is "
1163                                    "what no pass delivered, read from the task's delivery"),
1164                 'record_built_at': _iso(now),
1165                 'stage': stage,
1166                 'split': False,
1167             })
1168             sig.data = data
1169             sig.updated_at = now
1170             sig.save()
1171             summary['signatures'] += 1
1172             summary['created'] += int(created)
1173             summary['units'] += len(members)
1174             summary['keys'].append(key)
1175     return summary
1176 
1177 
1178 def _stage_name(label):
1179     """The stage a short label stands for (simu -> simulation)."""
1180     return next((k for k, v in STAGE_LABELS.items() if v == label), label)
1181 
1182 
1183 # ---------------------------------------------------------- the reading
1184 
1185 # The campaign images all live at one cvmfs path; the version is the
1186 # information. Another image keeps its name.
1187 IMAGE_PREFIX = '/cvmfs/singularity.opensciencegrid.org/eicweb/eic_xl:'
1188 
1189 
1190 def _image_label(image):
1191     if image.startswith(IMAGE_PREFIX):
1192         return image[len(IMAGE_PREFIX):]
1193     return image.rpartition('/')[2]
1194 
1195 
1196 def signature_summary(sig):
1197     """The list-row form of a signature, as the page and the tools show it."""
1198     memory = (sig.data or {}).get('memory') or {} if is_memory(sig) else {}
1199     if memory:
1200         mechanism = MEMORY_MECHANISMS.get(memory.get('dominant') or '', {})
1201         signal_name = f"killed for memory ({mechanism.get('label', 'removed')})"
1202     elif is_input(sig):
1203         fatal = ((sig.data or {}).get('input') or {}).get('fatal') or ''
1204         signal_name = f'stopped on its error (exit 1){": " + fatal if fatal else ""}'
1205     elif is_es_unit(sig) and not sig.signal:
1206         signal_name = 'died during its request, signal not recorded'
1207     else:
1208         signal_name = SIGNAL_NAMES.get(sig.signal, f'signal {sig.signal}')
1209     return {
1210         'key': sig.key,
1211         'kind': (KIND_HIMEM if is_memory(sig) else KIND_INPUT if is_input(sig)
1212                  else KIND_SEGFAULT),
1213         'level': sig.level,
1214         'exit_code': sig.exit_code,
1215         'signal': sig.signal,
1216         'signal_name': signal_name,
1217         'memory': memory,
1218         'memory_measured': (sig.data or {}).get('memory_measured') or {},
1219         'class': sig.class_hint,
1220         'class_label': CLASS_LABELS.get(sig.class_hint, sig.class_hint),
1221         'tasks': len(sig.tasks or []),
1222         'task_ids': [t.get('jeditaskid') for t in (sig.tasks or [])],
1223         'task_names': [t.get('taskname') for t in (sig.tasks or [])],
1224         'configuration': sig.configuration or {},
1225         'image': _image_label((sig.configuration or {}).get('container_image_ran')
1226                               or (sig.configuration or {}).get('container_image') or ''),
1227         'crashes': sig.crashes,
1228         'rate': sig.rate,
1229         'rate_pct': ((f'{100 * sig.rate:.2g}%' if is_es_unit(sig) else f'{100 * sig.rate:.1f}%')
1230                      if sig.rate is not None else ''),
1231         'minutes_p10': sig.minutes_p10,
1232         'minutes_p50': sig.minutes_p50,
1233         'sites': sig.sites or [],
1234         'site_names': [s.get('site') for s in (sig.sites or [])],
1235         'hosts': (sig.data or {}).get('hosts'),
1236         'rows_lost': sig.rows_lost,
1237         'events_lost': sig.events_lost,
1238         'rows_lost_note': (sig.data or {}).get('rows_lost_note', ''),
1239         'stages': {STAGE_LABELS.get(k, k): v
1240                    for k, v in ((sig.data or {}).get('stages') or {}).items()},
1241         'status': sig.status,
1242         'status_label': STATUS_LABELS.get(sig.status, sig.status),
1243         'trace_status': (sig.trace or {}).get('trace_status', 'unknown'),
1244         'frame': (sig.trace or {}).get('frame', ''),
1245         'member_of': (sig.data or {}).get('member_of', ''),
1246         'members': list((sig.data or {}).get('members') or []),
1247         'stage': (sig.data or {}).get('stage', ''),
1248         'split_keys': [k for k in ((sig.data or {}).get('split_keys') or []) if k != sig.key],
1249         'left_frame': (sig.data or {}).get('left_frame') or {},
1250         # A reproduction can be formed when a crashed job has its manifest
1251         # row and a PCS task to run under (crashed_run); both are read at
1252         # the nightly record build, so a row resolved during the day counts
1253         # from the next build.
1254         'runnable': bool((sig.configuration or {}).get('prod_task')
1255                          and ((sig.data or {}).get('rows_resolved') or 0) > 0),
1256         # An Event Service unit replays on npps0 by its request, not as a
1257         # payload canary of a row (SEGFAULT_DIAGNOSIS.md, Event Service units).
1258         'es_unit': is_es_unit(sig),
1259         'es_requests': (sig.data or {}).get('requests') if is_es_unit(sig) else None,
1260         'reproduction_outcome': (sig.data or {}).get('reproduction_outcome', ''),
1261         'finding_anchor': '',
1262         'first_seen': _iso(sig.first_seen),
1263         'last_seen': _iso(sig.last_seen),
1264         'updated_at': _iso(sig.updated_at),
1265     }
1266 
1267 
1268 def catalog(status=None, class_hint=None, limit=None):
1269     """The catalog rows, crashes descending, with the class counts."""
1270     qs = CrashSignature.objects.all()
1271     if status:
1272         qs = qs.filter(status=status)
1273     if class_hint:
1274         qs = qs.filter(class_hint=class_hint)
1275     qs = qs.order_by('-crashes', 'key')
1276     if limit:
1277         qs = qs[:int(limit)]
1278     rows = [signature_summary(s) for s in qs]
1279     # The curated finding each frame has, by signature key or by the
1280     # trace-level entry a member belongs to.
1281     anchors = finding_anchors()
1282     for r in rows:
1283         r['finding_anchor'] = anchors.get(r['key']) or anchors.get(r['member_of'] or '') or ''
1284     counts = defaultdict(int)
1285     for s in CrashSignature.objects.values_list('class_hint', flat=True):
1286         counts[s] += 1
1287     return rows, dict(counts)
1288 
1289 
1290 def signature_detail(key, jobs_limit=200):
1291     """One signature with its crashed jobs, or None."""
1292     sig = CrashSignature.objects.filter(key=key).first()
1293     if sig is None:
1294         return None
1295     detail = signature_summary(sig)
1296     anchors = finding_anchors()
1297     detail['finding_anchor'] = anchors.get(sig.key) or anchors.get(detail['member_of'] or '') or ''
1298     detail.update({
1299         'tasks_detail': sig.tasks or [],
1300         'trace': sig.trace or {},
1301         'reproduction': sig.reproduction or [],
1302         'reproduction_outcome': (sig.data or {}).get('reproduction_outcome', ''),
1303         'verdict': sig.verdict,
1304         'assessment_ids': sig.assessment_ids or [],
1305         'package': sig.package or {},
1306         'data': sig.data or {},
1307         'local_repro': local_repro(sig),
1308         'es_units': ((sig.data or {}).get('units') or []) if is_es_unit(sig) else [],
1309     })
1310     # The reproduction attempts, execution and result apart, from the
1311     # shared read-only summary (monitor_app/reproductions.py); a
1312     # trace-level signature shows its members' attempts too. Nothing is
1313     # reconciled here: the canary agent does that after each collection.
1314     from .reproductions import attempts as _attempts, global_counts
1315     keys = [sig.key] + list((sig.data or {}).get('members') or [])
1316     detail['attempts'] = _attempts(keys=keys)
1317     detail['attempt_counts'] = global_counts(detail['attempts'])
1318     task_ids = [t.get('jeditaskid') for t in (sig.tasks or []) if t.get('jeditaskid')]
1319     jobs = []
1320     if task_ids or sig.level == 'trace':
1321         qs = (EpicProdJob.objects.filter(job_filter(sig))
1322               .only('pandaid', 'jeditaskid', 'seq_number', 'data')
1323               .order_by('-pandaid'))
1324         detail['jobs_total'] = qs.count()
1325         for job in qs[:jobs_limit]:
1326             crash = (job.data or {}).get('crash') or {}
1327             row = crash.get('row') or {}
1328             jobs.append({
1329                 'pandaid': job.pandaid,
1330                 'jeditaskid': job.jeditaskid,
1331                 'seq': job.seq_number,
1332                 'row': (f"{row.get('file', '').rpartition('/')[2]} "
1333                         f"chunk {row.get('ichunk')} ({row.get('nevents')} ev)"
1334                         if row else ''),
1335                 'site': crash.get('computingsite', ''),
1336                 'host': crash.get('modificationhost', ''),
1337                 'minutes': crash.get('minutes'),
1338                 'maxrss_mb': crash.get('maxrss_mb'),
1339                 'request_mb': crash.get('request_mb'),
1340                 'stage': STAGE_LABELS.get(crash.get('stage', ''), crash.get('stage', '')),
1341                 # PanDA's times are naive UTC; stated with the zone so the
1342                 # house formatter shows them in Eastern.
1343                 'endtime': _iso(_parse_dt(crash.get('endtime'))),
1344             })
1345     detail['jobs'] = jobs
1346     return detail
1347 
1348 
1349 def signature_for_job(pandaid):
1350     """The signature a crashed job belongs to, for the job page's card,
1351     or None. A memory signature's representative names its memory
1352     signature."""
1353     job = EpicProdJob.objects.filter(pandaid=pandaid, phase__in=(PHASE, MEMORY_PHASE, INPUT_PHASE)).only(
1354         'jeditaskid', 'phase', 'data').first()
1355     if job is None:
1356         # An Event Service job whose filed record carries a crashed unit.
1357         job = EpicProdJob.objects.filter(pandaid=pandaid, data__has_key='es_record').only(
1358             'jeditaskid').first()
1359         if job is None or not job.jeditaskid:
1360             return None
1361         from django.db.models import Q
1362         base = es_unit_key(job.jeditaskid)
1363         for sig in CrashSignature.objects.filter(Q(key=base) | Q(key__startswith=base + ':'),
1364                                                  class_hint=ES_CLASS):
1365             if any(c.get('pandaid') == int(pandaid) for u in ((sig.data or {}).get('units') or [])
1366                    for c in u.get('crashes') or []):
1367                 return signature_summary(sig)
1368         return None
1369     if not job.jeditaskid:
1370         return None
1371     crash = (job.data or {}).get('crash') or {}
1372     if job.phase == MEMORY_PHASE:
1373         sig = (CrashSignature.objects.filter(
1374                    key=memory_key(job.jeditaskid, stage_label(crash.get('stage')))).first()
1375                or CrashSignature.objects.filter(key=memory_key(job.jeditaskid)).first())
1376         return signature_summary(sig) if sig else None
1377     if job.phase == INPUT_PHASE:
1378         sig = (CrashSignature.objects.filter(
1379                    key=input_key(job.jeditaskid, stage_label(crash.get('stage')))).first()
1380                or CrashSignature.objects.filter(key=input_key(job.jeditaskid)).first())
1381         return signature_summary(sig) if sig else None
1382     exit_code = crash.get('exit_code')
1383     if not exit_code:
1384         return None
1385     sig = CrashSignature.objects.filter(
1386         key=record_key(exit_code, job.jeditaskid, stage_label(crash.get('stage')))).first()
1387     if sig is None:
1388         sig = CrashSignature.objects.filter(key=record_key(exit_code, job.jeditaskid)).first()
1389     if sig is None:
1390         # A trace-level merge keeps the record entry as a member.
1391         sig = CrashSignature.objects.filter(
1392             tasks__contains=[{'jeditaskid': int(job.jeditaskid)}],
1393             exit_code=int(exit_code)).first()
1394     return signature_summary(sig) if sig else None
1395 
1396 
1397 # ---------------------------------------------------------------- the dig
1398 
1399 def representative(sig, runnable=False):
1400     """The crashed job the dig reads for a signature: the one with the
1401     median time to death among its jobs, per ERROR_ATTRIBUTION.md's one
1402     representative per signature. With ``runnable``, the median among the
1403     jobs a reproduction can run (a resolved manifest row and a PCS task),
1404     since a merged signature can hold legacy attempts without a manifest
1405     record beside PCS attempts with one. None when no job qualifies."""
1406     task_ids = [t.get('jeditaskid') for t in (sig.tasks or []) if t.get('jeditaskid')]
1407     if not task_ids:
1408         return None
1409     timed = []
1410     qs = (EpicProdJob.objects.filter(job_filter(sig))
1411           .only('pandaid', 'jeditaskid', 'data'))
1412     if runnable:
1413         qs = qs.filter(prod_task__isnull=False, data__crash__row__isnull=False)
1414     for job in qs:
1415         crash = (job.data or {}).get('crash') or {}
1416         if runnable and not crash.get('row'):
1417             continue
1418         timed.append((crash.get('minutes') if crash.get('minutes') is not None else -1,
1419                       job.pandaid, job.jeditaskid))
1420     if not timed:
1421         return None
1422     timed.sort()
1423     _minutes, pandaid, jeditaskid = timed[len(timed) // 2]
1424     return pandaid, jeditaskid
1425 
1426 
1427 def resolve_log_did(pandaid, jeditaskid):
1428     """(scope, lfn) of the job's log tarball from the PanDA record: the
1429     job's own file row while filestable4 holds it, else the task's log
1430     dataset contents row, whose LFN carries a ``$JEDITASKID`` placeholder.
1431     The builder's and the dig's call, never a page's."""
1432     from .panda.constants import PANDA_SCHEMA
1433     with connections['panda'].cursor() as cur:
1434         cur.execute(
1435             f"""SELECT "scope", "lfn" FROM "{PANDA_SCHEMA}"."filestable4"
1436                 WHERE "pandaid" = %s AND "type" = 'log'""", [int(pandaid)])
1437         row = cur.fetchone()
1438         if row and row[1]:
1439             return row[0] or 'group.EIC', row[1]
1440         cur.execute(
1441             f"""SELECT c."lfn" FROM "{PANDA_SCHEMA}"."jedi_dataset_contents" c
1442                 JOIN "{PANDA_SCHEMA}"."jedi_datasets" d
1443                   ON d."datasetid" = c."datasetid" AND d."jeditaskid" = c."jeditaskid"
1444                 WHERE c."jeditaskid" = %s AND c."pandaid" = %s AND d."type" = 'log'""",
1445             [int(jeditaskid), int(pandaid)])
1446         row = cur.fetchone()
1447     if row and row[0]:
1448         return 'group.EIC', row[0].replace('$JEDITASKID', str(int(jeditaskid)))
1449     return None, None
1450 
1451 
1452 def reproduction_entry(sig, pandaid):
1453     """The signature's reproduction request whose canary job is
1454     ``pandaid``, or None. A reproduction's job is not in the crash
1455     inventory (the canary wrapper exits 0 to keep the report), so the
1456     dig reaches it through this record."""
1457     for entry in sig.reproduction or []:
1458         if entry.get('canary_pandaid') and int(entry['canary_pandaid']) == int(pandaid):
1459             return entry
1460     return None
1461 
1462 
1463 def report_note(pandaid):
1464     """(note, source) of a job's payload report: the sweep's filing on
1465     the job's inventory row (``payload_report``), else the PanDA
1466     metatable, where the pilot lifts jobReport.json for a job that
1467     finished at the server (a reproduction on the reference queue
1468     carries no log dataset, so its report reaches the record there and
1469     nowhere else). ('', '') when the job has none. The dig's and the
1470     reconciliation's call, never a page's."""
1471     import json
1472     from .panda.constants import PANDA_SCHEMA
1473     job = EpicProdJob.objects.filter(pandaid=int(pandaid)).only('data').first()
1474     filed = str(((((job.data if job else None) or {}).get('payload_report') or {})
1475                  .get('report') or {}).get('note') or '')
1476     if filed.startswith('crash:'):
1477         return filed, 'payload_report'
1478     try:
1479         with connections['panda'].cursor() as cur:
1480             cur.execute(f'SELECT "metadata" FROM "{PANDA_SCHEMA}"."metatable" WHERE "pandaid" = %s',
1481                         [int(pandaid)])
1482             row = cur.fetchone()
1483     except Exception as e:                                    # noqa: BLE001
1484         logger.error('job %s: metatable read failed: %s', pandaid, e)
1485         return filed, 'payload_report' if filed else ''
1486     meta = None
1487     if row and row[0]:
1488         try:
1489             meta = json.loads(row[0]) if isinstance(row[0], str) else row[0]
1490         except (ValueError, TypeError) as e:
1491             logger.error('job %s: metatable metadata unparsable: %s', pandaid, e)
1492     payload = meta.get('payload') if isinstance(meta, dict) else None
1493     note = str((payload or {}).get('note') or '') if isinstance(payload, dict) else ''
1494     if note:
1495         return note, 'metatable'
1496     return filed, 'payload_report' if filed else ''
1497 
1498 
1499 def trace_from_report(sig, pandaid):
1500     """Record on the signature the trace read from a job's payload report
1501     when its note is a crash capture (payload 0.12 and later put the
1502     crashing stage's log tail there, SEGFAULT_DIAGNOSIS.md, Traces going
1503     forward): the dig's first source, and the reconciliation's for a
1504     reproduced crash. Returns the dig summary, or None when the report
1505     carries no crash note or no frame (the dig then falls back to the
1506     log tarball)."""
1507     from .epicprod_inventory import trace_extract
1508     note, source = report_note(pandaid)
1509     if not note.startswith('crash:'):
1510         return None
1511     result = trace_extract([note])
1512     if result.get('trace_status') != 'found':
1513         return None
1514     merged = record_trace(sig, result, pandaid, '')
1515     logger.info('segfault %s: trace read from the %s payload report of job %s',
1516                 sig.key, source, pandaid)
1517     return {'key': sig.key, 'pandaid': pandaid, 'trace_status': 'found', 'source': source,
1518             'program': result.get('program'), 'stage': result.get('stage'),
1519             'frame': result.get('frame'), 'library': result.get('library'),
1520             'events_processed': result.get('events_processed'), 'merged_into': merged}
1521 
1522 
1523 def frame_key(exit_code, program, frame):
1524     import hashlib
1525     digest = hashlib.sha256(f'{program}|{frame}'.encode()).hexdigest()[:12]
1526     return f'exit{exit_code}:frame:{digest}'
1527 
1528 
1529 def record_trace(sig, result, pandaid, reason=''):
1530     """Write a dig's outcome on the signature: the trace when found, the
1531     status ``absent`` or ``log_unavailable`` with its reason otherwise;
1532     then merge into a trace-level entry when another record-level entry
1533     shares the crashing frame. Returns the trace-level key or None."""
1534     trace = dict(sig.trace or {})
1535     trace.update({
1536         'trace_status': result.get('trace_status', 'log_unavailable'),
1537         'program': result.get('program', ''),
1538         'stage': result.get('stage', ''),
1539         'frame': result.get('frame', ''),
1540         'library': result.get('library', ''),
1541         'frames': [f"{f['n']}: {f['function']}" + (f"  [{f['library']}]" if f.get('library') else '')
1542                    for f in (result.get('frames') or [])][:TRACE_MAX_FRAMES],
1543         'context': result.get('context') or [],
1544         'events_processed': result.get('events_processed'),
1545         'form': result.get('form', ''),
1546         'source_pandaid': pandaid,
1547         'reason': reason,
1548         'dug_at': _iso(timezone.now()),
1549     })
1550     sig.trace = trace
1551     if trace['trace_status'] == 'found' and sig.status in ('new', 'digging'):
1552         sig.status = 'traced'
1553     elif sig.status == 'digging':
1554         sig.status = 'new'
1555     sig.save(update_fields=['trace', 'status', 'updated_at'])
1556     if trace['trace_status'] != 'found' or sig.level != 'record':
1557         return None
1558     return merge_trace_level(sig)
1559 
1560 
1561 def merge_trace_level(sig):
1562     """Record-level entries sharing a crashing frame become members of one
1563     trace-level entry (key exit<code>:frame:<sha>), whose counts are the
1564     members' sums; the members stay in the catalog at record level."""
1565     frame, program = (sig.trace or {}).get('frame'), (sig.trace or {}).get('program')
1566     if not frame:
1567         return None
1568     key = frame_key(sig.exit_code, program, frame)
1569     candidates = [s for s in CrashSignature.objects.filter(
1570         level='record', exit_code=sig.exit_code, trace__frame=frame,
1571         trace__program=program)]
1572     # A member whose reproduction settled it under another finding than
1573     # the frame's is that finding's crash, whatever frame its one trace
1574     # read (task 38864: the trace of one row's reconstruction crash, the
1575     # reproduced rows the dRICH simulation crash); it leaves the frame.
1576     merged = CrashSignature.objects.filter(key=key).first()
1577     frame_finding = covering_finding(merged) if merged is not None else None
1578     frame_fid = (frame_finding or {}).get('fid')
1579     members, left = [], []
1580     for m in candidates:
1581         diag = (m.data or {}).get('diagnosis') or {}
1582         if (diag.get('state') == 'covered' and diag.get('finding')
1583                 and frame_fid and diag['finding'] != frame_fid):
1584             left.append(m)
1585         else:
1586             members.append(m)
1587     for m in left:
1588         mdata = dict(m.data or {})
1589         if mdata.get('member_of') == key:
1590             mdata['member_of'] = ''
1591             mdata['left_frame'] = {'key': key, 'finding': (m.data or {}).get('diagnosis', {}).get('finding')}
1592             m.data = mdata
1593             m.save(update_fields=['data', 'updated_at'])
1594     if len(members) < 2:
1595         if merged is not None and left:
1596             # the frame survives with what remains, or goes with its last member
1597             if len(members) == 1:
1598                 _refresh_frame(merged, members)
1599                 return key
1600             merged.delete()
1601         return None
1602     created = merged is None
1603     if created:
1604         merged = CrashSignature(key=key, level='trace', status='traced',
1605                                 exit_code=sig.exit_code, signal=sig.signal)
1606     _refresh_frame(merged, members, sig)
1607     return key
1608 
1609 
1610 def remerge_frame(frame):
1611     """Recompute a trace-level entry from its members as they stand now
1612     (after a split, a retirement, or a member leaving for another
1613     finding). Returns the key, or None when the frame is gone."""
1614     members = [m for m in CrashSignature.objects.filter(
1615         key__in=list((frame.data or {}).get('members') or []))]
1616     if not members:
1617         frame.delete()
1618         return None
1619     return merge_trace_level(members[0])
1620 
1621 
1622 def _refresh_frame(merged, members, sig=None):
1623     """The frame entry's counts, tasks, sites and stages from its members."""
1624     sig = sig or members[0]
1625     key = merged.key
1626     tasks, sites, minutes = [], defaultdict(lambda: {'crashes': 0, 'hosts': 0}), []
1627     for m in members:
1628         tasks.extend(m.tasks or [])
1629         for s in (m.sites or []):
1630             sites[s['site']]['crashes'] += s.get('crashes') or 0
1631             # An Event Service member names no hosts (its units' records do not).
1632             sites[s['site']]['hosts'] += s.get('hosts') or 0
1633     merged.tasks = tasks
1634     merged.sites = sorted(({'site': k, **v} for k, v in sites.items()),
1635                           key=lambda s: -s['crashes'])
1636     merged.crashes = sum(m.crashes for m in members)
1637     rates = [m.rate for m in members if m.rate is not None]
1638     merged.rate = round(sum(rates) / len(rates), 4) if rates else None
1639     p10 = [m.minutes_p10 for m in members if m.minutes_p10 is not None]
1640     p50 = [m.minutes_p50 for m in members if m.minutes_p50 is not None]
1641     merged.minutes_p10 = round(sum(p10) / len(p10), 1) if p10 else None
1642     merged.minutes_p50 = round(sum(p50) / len(p50), 1) if p50 else None
1643     firsts = [m.first_seen for m in members if m.first_seen]
1644     lasts = [m.last_seen for m in members if m.last_seen]
1645     merged.first_seen = min(firsts) if firsts else None
1646     merged.last_seen = max(lasts) if lasts else None
1647     lost = [m.rows_lost for m in members if m.rows_lost is not None]
1648     merged.rows_lost = sum(lost) if lost else None
1649     ev = [m.events_lost for m in members if m.events_lost is not None]
1650     merged.events_lost = sum(ev) if ev else None
1651     classes = defaultdict(int)
1652     for m in members:
1653         classes[m.class_hint] += m.crashes
1654     merged.class_hint = max(classes.items(), key=lambda kv: kv[1])[0]
1655     merged.configuration = dict(sig.configuration or {})
1656     prod_tasks = sorted({(m.configuration or {}).get('prod_task', '') for m in members} - {''})
1657     merged.configuration['prod_task'] = (
1658         prod_tasks[0] if len(prod_tasks) == 1
1659         else f'{len(prod_tasks)} configurations' if prod_tasks else '')
1660     merged.configuration['prod_tasks'] = prod_tasks
1661     if not merged.trace:
1662         merged.trace = dict(sig.trace)
1663     data = dict(merged.data or {})
1664     data['members'] = sorted(m.key for m in members)
1665     data['hosts'] = sum((m.data or {}).get('hosts') or 0 for m in members)
1666     data['stages'] = {}
1667     for m in members:
1668         for stage, n in ((m.data or {}).get('stages') or {}).items():
1669             data['stages'][stage] = data['stages'].get(stage, 0) + n
1670     merged.data = data
1671     merged.save()
1672     for m in members:
1673         mdata = dict(m.data or {})
1674         if mdata.get('member_of') != key:
1675             mdata['member_of'] = key
1676             m.data = mdata
1677             m.save(update_fields=['data', 'updated_at'])
1678     return key
1679 
1680 
1681 def remerge_frames():
1682     """Every trace-level entry recomputed from its members: the nightly
1683     pass after the record build. Returns the keys kept and gone."""
1684     kept, gone = [], []
1685     for frame in list(CrashSignature.objects.filter(level='trace')):
1686         if remerge_frame(frame):
1687             kept.append(frame.key)
1688         else:
1689             gone.append(frame.key)
1690     return {'kept': kept, 'gone': gone}
1691 
1692 
1693 def dig_candidates(limit):
1694     """Record-level signatures never dug, largest first, for the automatic
1695     dig: at most ``limit`` a night so a storm is not a thousand fetches."""
1696     qs = (CrashSignature.objects.filter(level='record')
1697           .exclude(class_hint__in=(MEMORY_CLASS, ES_CLASS))
1698           .exclude(trace__has_key='trace_status').order_by('-crashes'))
1699     return list(qs[:limit])
1700 
1701 
1702 # --------------------------------------------------------- reproduction
1703 
1704 CANARY_QUEUE = '/queue/canary.ops'
1705 CANARY_NAMESPACE = 'canary'
1706 REFERENCE_QUEUE = 'BNL_NPPS_GPU'
1707 # The second run of a reproduction is a second production-class queue
1708 # (SEGFAULT_DIAGNOSIS.md, Reproduction, 2026-09-13): the crash-or-not
1709 # verdict and the trace come from any queue since payload 0.14, and the
1710 # reference queue's one slot is asked for only when the reading needs
1711 # the host (a memory ceiling, a core dump, a stall watched live).
1712 ELSEWHERE_QUEUE = 'UM_GREX_PanDA_1'
1713 ELSEWHERE_FROM = {'UM_GREX_PanDA_1': 'BNL_OSG_PanDA_1'}
1714 CRASH_EXITS = {134, 135, 136, 139}
1715 
1716 
1717 def elsewhere_queue(origin):
1718     """The default second queue for a crash that happened on ``origin``."""
1719     return ELSEWHERE_FROM.get(origin or '', ELSEWHERE_QUEUE)
1720 
1721 
1722 def run_role(queue, origin):
1723     """The role a run plays in a reproduction: ``production`` on the queue
1724     the crash happened on, ``reference`` on the reference queue,
1725     ``elsewhere`` on any other queue. The last two settle the outcome the
1726     same way against the production run."""
1727     if queue == REFERENCE_QUEUE:
1728         return 'reference'
1729     if origin and queue == origin:
1730         return 'production'
1731     return 'elsewhere'
1732 
1733 
1734 def crashed_run(sig, pandaid=None):
1735     """The crashed job a reproduction runs (the representative unless
1736     named) with its crash record, its manifest row and the row as manifest
1737     text. Raises ValueError with the reason when the run cannot be formed."""
1738     if is_es_unit(sig):
1739         raise ValueError('an Event Service unit is replayed on npps0 by its request: '
1740                          f'scripts/segfault-es-reproduce.py --key {sig.key} '
1741                          '(SEGFAULT_DIAGNOSIS.md, Event Service units)')
1742     if pandaid is None:
1743         rep = representative(sig, runnable=True) or representative(sig)
1744         if rep is None:
1745             raise ValueError('no crashed job on record')
1746         pandaid = rep[0]
1747     job = EpicProdJob.objects.filter(pandaid=int(pandaid), phase=sig_phase(sig)).select_related(
1748         'prod_task').first()
1749     if job is None:
1750         raise ValueError(f'job {pandaid} is not '
1751                          + ("one of the signature's representatives" if is_memory(sig)
1752                             else 'in the crash inventory'))
1753     crash = (job.data or {}).get('crash') or {}
1754     if job.prod_task is None:
1755         raise ValueError(f'job {pandaid} has no PCS task; move its task to PCS first '
1756                          f'(EPICPROD_RETRIES.md, Move this task to PCS)')
1757     row = crash.get('row')
1758     if not row:
1759         raise ValueError(f"job {pandaid}'s manifest row is unresolved; the attempt has "
1760                          f'no manifest record')
1761     row_text = f"{row['file']},{row['ext']},{row['nevents']},{int(row['ichunk']):04d}"
1762     return job, crash, row, row_text
1763 
1764 
1765 def repro_environment(prod_task):
1766     """The payload environment of a run outside PanDA: the task's own
1767     (pcs.commands._evgen_env) with registration, the copies and the log
1768     upload off, so the run leaves its outputs in the working directory and
1769     touches no catalog."""
1770     from pcs.commands import _evgen_env
1771     env = _evgen_env(prod_task)
1772     env.update({'USERUCIO': 'false', 'COPYRECO': 'false', 'COPYFULL': 'false',
1773                 'COPYLOG': 'false'})
1774     return env
1775 
1776 
1777 PAYLOAD_REPO = 'https://github.com/BNLNPPS/swf-epicprod'
1778 PAYLOAD_PATH = 'swf_epicprod/payload/run.sh'
1779 PAYLOAD_VERSION_FILE = 'swf_epicprod/payload/VERSION'
1780 # The JLab door and base the payload streams EVGEN input from (run.sh,
1781 # XRDRURL and XRDRBASE defaults).
1782 INPUT_DOOR = 'root://dtn2304.jlab.org:8443//jlab-osdf-ro/eic/EPIC/volatile'
1783 
1784 
1785 def local_repro(sig, pandaid=None):
1786     """The crashed job's run as a shell fragment for a reproduction by hand:
1787     the image, the payload environment, the input, and the payload's
1788     invocation with the row's arguments as the dispatcher passes them
1789     (evgen_job_dispatcher.run_row: EVGEN/<file>, extension, events, chunk).
1790     The representative job unless one is named. Returns {'error': reason}
1791     when the run cannot be formed."""
1792     try:
1793         job, crash, row, row_text = crashed_run(sig, pandaid)
1794     except ValueError as e:
1795         return {'error': str(e)}
1796     try:
1797         env = repro_environment(job.prod_task)
1798     except Exception as e:                                    # noqa: BLE001
1799         logger.error('segfault %s: payload environment of task %s failed: %s',
1800                      sig.key, job.prod_task.name, e)
1801         return {'error': f'payload environment of {job.prod_task.name}: {e}'}
1802     container = crash.get('container') or ''
1803     if not container:
1804         cfg = job.prod_task.prod_config
1805         container = getattr(cfg, 'container_image', '') if cfg else ''
1806     # The payload the job ran, from its attempt's submission record; a
1807     # version was set by exactly one commit (the one that wrote it into
1808     # payload/VERSION), which the clone resolves and checks out. An attempt
1809     # without one is a legacy attempt: it ran the production team's script,
1810     # not this payload.
1811     from pcs.models import PandaTasks
1812     attempt = PandaTasks.objects.filter(jedi_task_id=job.jeditaskid).only('metadata').first()
1813     payload_version = str(((attempt.metadata if attempt else None) or {}).get('payload_version') or '')
1814     ichunk = int(row['ichunk'])
1815     nevents = int(row['nevents'])
1816     input_url = f"{INPUT_DOOR}/EVGEN/{row['file']}.{row['ext']}"
1817     if is_memory(sig):
1818         mechanism = MEMORY_MECHANISMS.get(crash.get('mechanism') or '', {})
1819         what = (f"killed for memory ({mechanism.get('label', 'removed')}) in "
1820                 f"{crash.get('stage') or 'the payload'} at {crash.get('maxrss_mb')} MB peak RSS "
1821                 f"against a {crash.get('request_mb')} MB request")
1822     else:
1823         signal_name = SIGNAL_NAMES.get(crash.get('signal'), crash.get('signal'))
1824         what = f"the payload died on {signal_name} (exit {crash.get('exit_code')})"
1825     lines = [
1826         f"# PanDA job {job.pandaid} (task {job.jeditaskid}, {job.prod_task.name}): "
1827         f"{what} after {crash.get('minutes')} min at {crash.get('computingsite')}",
1828         f"# Image: {container or '(not on record)'}",
1829         "# Inside the image (eic-shell, or: apptainer exec <image> bash), in an empty directory:",
1830         f"git clone {PAYLOAD_REPO}",
1831     ]
1832     if payload_version:
1833         lines += [
1834             f"# The payload the job ran, {payload_version}: the commit that set that version",
1835             f"git -C swf-epicprod checkout $(git -C swf-epicprod log -S'{payload_version}' "
1836             f"--format=%H -- {PAYLOAD_VERSION_FILE} | tail -1)",
1837         ]
1838     else:
1839         lines += [
1840             "# No payload version on the attempt's record: a legacy attempt ran the production",
1841             "# team's script (eic/job_submission_condor), not this payload; the current payload",
1842             "# runs the same row through the same stages in the same image.",
1843         ]
1844     lines += ["cat > environment-manifest.sh <<'EOF'"]
1845     lines += [f'export {k}={v}' for k, v in env.items()]
1846     lines += [
1847         'EOF',
1848         f"swf-epicprod/{PAYLOAD_PATH} EVGEN/{row['file']} {row['ext']} {nevents} {ichunk:04d}",
1849         f"# Input (public read): {input_url}",
1850         f"# Manifest row {ichunk + 1}: {nevents} events after skipping {ichunk} x {nevents}, "
1851         f"seed {ichunk + 1}; outputs stay in the working directory, nothing is registered.",
1852     ]
1853     if is_memory(sig):
1854         lines.append("# Each stage runs under prmon: payload-report.json gives every stage's peak "
1855                      "memory and its growth, the prmon series beside the stage logs the curve.")
1856     return {'pandaid': job.pandaid, 'jeditaskid': job.jeditaskid,
1857             'task': job.prod_task.name, 'container': container, 'row_text': row_text,
1858             'payload_version': payload_version, 'input_url': input_url,
1859             'environment': env, 'fragment': '\n'.join(lines)}
1860 
1861 
1862 def reproduction_plan(sig, pandaid=None):
1863     """What a reproduction of the signature runs: the crashed job (the
1864     representative unless named), its row, its PCS task, its production
1865     queue and that queue's memory. Raises ValueError with the reason when
1866     the run cannot be formed."""
1867     job, crash, row, row_text = crashed_run(sig, pandaid)
1868     pandaid = job.pandaid
1869     site = crash.get('computingsite') or ''
1870     maxrss_mb = None
1871     if site:
1872         # The queue's memory from schedconfig (the PanDA record, a table
1873         # read, no remote service).
1874         from .panda.queries import get_queue
1875         cfg = (get_queue(site) or {}).get('queue') or {}
1876         raw = cfg.get('maxrss')
1877         try:
1878             maxrss_mb = int(raw) if raw else None
1879         except (TypeError, ValueError):
1880             maxrss_mb = None
1881     plan = {'pandaid': int(pandaid), 'task': job.prod_task.name, 'row_text': row_text,
1882             'container': crash.get('container') or '',
1883             'production_queue': site, 'elsewhere_queue': elsewhere_queue(site),
1884             'reference_queue': REFERENCE_QUEUE,
1885             'maxrss_mb': maxrss_mb, 'job_maxrss_mb': crash.get('maxrss_mb'),
1886             'memory': is_memory(sig), 'request_mb': crash.get('request_mb')}
1887     # A crash is settled by the production queue and a second production-
1888     # class queue; a memory kill by the production queue at the task's own
1889     # request and the reference queue with no limit, where the row runs to
1890     # its end and is measured (SEGFAULT_DIAGNOSIS.md, Memory kills).
1891     plan['default_queues'] = [q for q in (site, REFERENCE_QUEUE if plan['memory']
1892                                           else plan['elsewhere_queue']) if q]
1893     return plan
1894 
1895 
1896 def _append_reproduction(key, entry):
1897     """Append one request entry to the signature under a row lock, so a
1898     request recorded meanwhile by another writer is kept."""
1899     from django.db import transaction
1900     with transaction.atomic():
1901         sig = CrashSignature.objects.select_for_update().get(key=key)
1902         sig.reproduction = list(sig.reproduction or []) + [entry]
1903         fields = ['reproduction', 'updated_at']
1904         if sig.status in ('new', 'traced', 'digging'):
1905             sig.status = 'reproducing'
1906             fields.append('status')
1907         sig.save(update_fields=fields)
1908         return sig
1909 
1910 
1911 def reproduce(sig, pandaid, queues, mem_limits, username):
1912     """Queue one payload canary per queue to the canary agent for the
1913     crashed job's row, and record each request on the signature as it is
1914     sent, with the request identity the run will carry
1915     (monitor_app/reproductions.py). ``mem_limits`` maps queue -> MB or
1916     None. Returns the entries added; a send that fails after an earlier
1917     one succeeded leaves the earlier request on record and raises."""
1918     import json
1919     from .activemq_connection import ActiveMQConnectionManager
1920     from .reproductions import new_request_id
1921     plan = reproduction_plan(sig, pandaid)
1922     entries = []
1923     now = _iso(timezone.now())
1924     for queue in queues:
1925         request_id = new_request_id()
1926         msg = {'msg_type': 'payload_canary', 'namespace': CANARY_NAMESPACE,
1927                'task': plan['task'], 'queue': queue, 'row_text': plan['row_text'],
1928                'signature': sig.key, 'pandaid': plan['pandaid'],
1929                'request_id': request_id,
1930                'created_by': username or 'segfault_reproduce'}
1931         if plan.get('container'):
1932             msg['container'] = plan['container']
1933         limit = mem_limits.get(queue)
1934         if limit:
1935             msg['mem_limit_mb'] = int(limit)
1936         sent = ActiveMQConnectionManager().send_message(CANARY_QUEUE, json.dumps(msg))
1937         if not sent:
1938             raise RuntimeError(
1939                 'the canary agent queue could not be reached'
1940                 + (f" (the request for {', '.join(e['queue'] for e in entries)} was sent)"
1941                    if entries else ''))
1942         entry = {'request_id': request_id, 'run_id': None,
1943                  'pandaid': plan['pandaid'], 'queue': queue,
1944                  'role': run_role(queue, plan['production_queue']),
1945                  'row_text': plan['row_text'], 'mem_limit_mb': limit,
1946                  'container': plan.get('container') or '',
1947                  'requested_at': now, 'requested_by': username,
1948                  'outcome': 'submitted', 'jedi_task_id': None,
1949                  'canary_pandaid': None, 'minutes': None,
1950                  'verdict_time': None, 'exit_code': None}
1951         saved = _append_reproduction(sig.key, entry)
1952         sig.reproduction, sig.status = saved.reproduction, saved.status
1953         entries.append(entry)
1954     return entries
1955 
1956 
1957 def reproduction_refresh(sig):
1958     """Reconcile one signature's reproduction requests with their runs
1959     (monitor_app/reproductions.py): the canary agent's step after each
1960     collection, never a page read. Kept under its name for the callers
1961     that have it; returns True when anything changed."""
1962     from .reproductions import reconcile
1963     changed = reconcile(sig.key, queue_diagnosis=queue_diagnosis)
1964     sig.refresh_from_db()
1965     return bool(changed.get('entries') or changed.get('outcome'))
1966 
1967 
1968 def queue_traced_studies(limit, requested_by='auto:traced'):
1969     """The nightly automatic study: a traced signature that no finding
1970     reads and no study has been asked of is studied once, largest first,
1971     at most ``limit`` a night, without waiting for a reproduction (a
1972     reproduced one is queued by the reconciliation). Each is marked
1973     queued before its message goes out, so a later pass never asks
1974     twice; a frame a finding already reads is marked covered instead,
1975     every night, outside the cap (no study, nothing to bound).
1976     Returns {'queued': [keys], 'covered': [keys]}."""
1977     from django.db import transaction
1978     out = {'queued': [], 'covered': []}
1979     candidates = (CrashSignature.objects.filter(level='record')
1980                   .exclude(status__in=('diagnosed', 'handed_off', 'fixed', 'accepted'))
1981                   .order_by('-crashes'))
1982     now = _iso(timezone.now())
1983     for sig in candidates:
1984         if (sig.trace or {}).get('trace_status') != 'found' or (sig.data or {}).get('diagnosis'):
1985             continue
1986         with transaction.atomic():
1987             sig = CrashSignature.objects.select_for_update().get(pk=sig.pk)
1988             data = dict(sig.data or {})
1989             if data.get('diagnosis'):
1990                 continue
1991             finding = covering_finding(sig)
1992             if not finding and len(out['queued']) >= limit:
1993                 continue
1994             if finding:
1995                 data['diagnosis'] = {'state': 'covered', 'finding': finding['fid'],
1996                                      'anchor': finding['anchor'], 'requested_by': requested_by,
1997                                      'submitted_at': now}
1998                 sig.verdict = f"covered by finding {finding['fid']}: {finding['title']}".strip(': ')
1999                 sig.status = 'diagnosed'
2000                 sig.data = data
2001                 sig.save(update_fields=['data', 'verdict', 'status', 'updated_at'])
2002                 out['covered'].append(sig.key)
2003                 continue
2004             data['diagnosis'] = {'state': 'queued', 'requested_by': requested_by,
2005                                  'submitted_at': now}
2006             sig.data = data
2007             sig.save(update_fields=['data', 'updated_at'])
2008         if queue_diagnosis(sig.key, requested_by):
2009             out['queued'].append(sig.key)
2010         else:
2011             # The bus refused: unmark, so the next night asks again.
2012             CrashSignature.objects.filter(pk=sig.pk).update(
2013                 data={k: v for k, v in (sig.data or {}).items() if k != 'diagnosis'})
2014     return out
2015 
2016 
2017 def queue_diagnosis(key, requested_by):
2018     """Queue a signature's LLM study to the ops agent; reported, never
2019     raised (a page read must not fail on the bus)."""
2020     import json
2021     from .activemq_connection import ActiveMQConnectionManager
2022     try:
2023         sent = ActiveMQConnectionManager().send_message(
2024             '/queue/epicprod.ops',
2025             json.dumps({'msg_type': 'segfault_diagnose', 'namespace': 'prodops',
2026                         'key': key, 'requested_by': requested_by}))
2027         if not sent:
2028             logger.error('segfault %s: diagnosis not queued, bus unreachable', key)
2029         return bool(sent)
2030     except Exception as e:                                    # noqa: BLE001
2031         logger.error('segfault %s: diagnosis not queued: %s', key, e)
2032         return False
2033 
2034 
2035 # --------------------------------------------------------------- findings
2036 
2037 FINDING_KIND = 'finding'
2038 FINDING_CONTEXT = 'segfault'
2039 FINDING_FIELDS = ('date', 'frame', 'stage', 'title', 'class', 'action', 'standing',
2040                   'fix', 'notes', 'sources', 'signatures', 'model_reading',
2041                   'detector_components', 'detector_point', 'detector_geometry')
2042 
2043 # Where in the detector a finding lies, for the ePIC detector model: DD4hep
2044 # top-level detector names from the epic compact files (BeamPipeB0),
2045 # optionally with a volume path below (BeamPipeB0/v_pipe_7); a point in
2046 # DD4hep global coordinates, millimetres, any axis unknown left null; the
2047 # geometry they refer to (e.g. 'epic 26.07.3 epic_craterlake').
2048 _DETECTOR_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9_]*(/[A-Za-z0-9_]+)*$')
2049 
2050 
2051 def _clean_detector_fields(fields):
2052     """Check and normalize the detector fields of a finding write in place;
2053     ValueError names the first bad value."""
2054     if 'detector_components' in fields:
2055         names = fields['detector_components'] or []
2056         if isinstance(names, str):
2057             names = [names]
2058         names = [str(n).strip() for n in names if str(n).strip()]
2059         bad = [n for n in names if not _DETECTOR_NAME.match(n)]
2060         if bad:
2061             raise ValueError(f'detector_components: {bad[0]!r} is not a DD4hep detector '
2062                              'name, optionally with a volume path (BeamPipeB0/v_pipe_7)')
2063         fields['detector_components'] = names
2064     if 'detector_point' in fields:
2065         point = fields['detector_point']
2066         if point in (None, {}):
2067             fields['detector_point'] = None
2068         else:
2069             if not isinstance(point, dict) or set(point) - {'x', 'y', 'z'}:
2070                 raise ValueError('detector_point: {x, y, z} in millimetres, DD4hep global '
2071                                  'coordinates, an unknown axis null')
2072             clean = {}
2073             for axis in ('x', 'y', 'z'):
2074                 value = point.get(axis)
2075                 if value is not None:
2076                     try:
2077                         value = float(value)
2078                     except (TypeError, ValueError):
2079                         raise ValueError(f'detector_point: {axis} is not a number of millimetres')
2080                 clean[axis] = value
2081             if all(v is None for v in clean.values()):
2082                 raise ValueError('detector_point: no axis given')
2083             fields['detector_point'] = clean
2084     if 'detector_geometry' in fields:
2085         fields['detector_geometry'] = str(fields['detector_geometry'] or '').strip()
2086 
2087 
2088 def detector_where(f):
2089     """The detector fields as one line for the findings page."""
2090     parts = []
2091     names = f.get('detector_components') or []
2092     if names:
2093         parts.append(', '.join(names))
2094     point = f.get('detector_point') or {}
2095     if point:
2096         known = [f'{axis} {point[axis]:g} mm' for axis in ('x', 'y', 'z')
2097                  if point.get(axis) is not None]
2098         unknown = [axis for axis in ('x', 'y', 'z') if point.get(axis) is None]
2099         parts.append('point ' + ', '.join(known)
2100                      + (f" ({', '.join(unknown)} not recorded)" if unknown else ''))
2101     line = '; '.join(parts)
2102     if line and f.get('detector_geometry'):
2103         line += f" (geometry {f['detector_geometry']})"
2104     return line
2105 
2106 
2107 def _finding_context():
2108     from .models import EntryContext
2109     ctx, _ = EntryContext.objects.get_or_create(
2110         name=FINDING_CONTEXT,
2111         defaults={'title': 'Segfault findings',
2112                   'description': 'The curated reading of the segfault catalog, one '
2113                                  'entry per crashing frame (SEGFAULT_DIAGNOSIS.md, Findings).'})
2114     return ctx
2115 
2116 
2117 def finding_entries():
2118     """The findings as stored: Entry rows of kind finding in the segfault
2119     context, not archived, newest first by serial (the order they were
2120     written; a finding without a serial yet sorts by its creation time)."""
2121     from .models import Entry
2122     rows = list(Entry.objects.filter(kind=FINDING_KIND, context_id=FINDING_CONTEXT,
2123                                      archived=False, deleted_at__isnull=True))
2124     rows.sort(key=lambda e: (int((e.data or {}).get('serial') or 0), e.timestamp_created), reverse=True)
2125     return rows
2126 
2127 
2128 def finding_id(serial):
2129     """The finding's permanent id as shown: f-8."""
2130     return f'f-{int(serial)}' if serial else ''
2131 
2132 
2133 def _next_finding_serial(ctx):
2134     """The next free serial of the findings store, under the context row's
2135     lock so two writers never draw the same number. Serials are assigned
2136     once, at creation, and never reused: a finding keeps its id whatever
2137     is written or retired around it."""
2138     from .models import Entry, EntryContext
2139     EntryContext.objects.select_for_update().get(pk=ctx.pk)
2140     taken = [int((e.data or {}).get('serial') or 0)
2141              for e in Entry.objects.filter(kind=FINDING_KIND, context=ctx).only('data')]
2142     return max(taken, default=0) + 1
2143 
2144 
2145 def set_finding(name, fields, changed_by):
2146     """Create or update the finding named ``name`` (the frame's catalog key,
2147     the trace-level entry's where one exists) with ``fields`` (FINDING_FIELDS
2148     plus ``what``, the reading, as the entry's content). A new finding draws
2149     the next permanent serial (``data['serial']``, shown as f-n). Every
2150     substantive change leaves an EntryVersion stamped with ``changed_by``.
2151     Returns the Entry and whether it was created."""
2152     from django.db import transaction
2153     from .models import Entry
2154     from .signals import set_changed_by
2155     fields = dict(fields)
2156     _clean_detector_fields(fields)
2157     set_changed_by(changed_by or 'unknown')
2158     with transaction.atomic():
2159         ctx = _finding_context()
2160         entry = Entry.objects.filter(kind=FINDING_KIND, context=ctx, name=name).first()
2161         created = entry is None
2162         if created:
2163             entry = Entry(kind=FINDING_KIND, context=ctx, name=name, status='open')
2164         data = dict(entry.data or {})
2165         for key in FINDING_FIELDS:
2166             if key in fields:
2167                 data[key] = fields[key]
2168         data.setdefault('signatures', [name])
2169         if not data.get('serial'):
2170             data['serial'] = _next_finding_serial(ctx)
2171         data['updated_by'] = changed_by
2172         entry.data = data
2173         if 'title' in fields:
2174             entry.title = str(fields['title'] or '')
2175         if 'what' in fields:
2176             entry.content = str(fields['what'] or '')
2177         if 'standing' in fields:
2178             entry.status = str(fields['standing'] or 'open')
2179         entry.timestamp_modified = __import__('time').time()
2180         entry.save()
2181     log_finding = __import__('monitor_app.epicprod_logging', fromlist=['log_epicprod_action']).log_epicprod_action
2182     log_finding('web', 'segfault_finding_set', subject_type='crash_signature', subject_key=name,
2183                 username=changed_by, sublevel='normal', live_default=True,
2184                 message=f"segfault finding {'created' if created else 'updated'}: {name}: "
2185                         f"{entry.title}"[:300], created=int(created))
2186     return entry, created
2187 
2188 
2189 def assign_finding_serials(changed_by):
2190     """Give every finding without a serial one, in creation order, so the
2191     findings written before serials existed are numbered as they were
2192     made. Returns [(name, serial)] assigned. Idempotent."""
2193     from django.db import transaction
2194     from .models import Entry
2195     from .signals import set_changed_by
2196     set_changed_by(changed_by or 'unknown')
2197     assigned = []
2198     with transaction.atomic():
2199         ctx = _finding_context()
2200         rows = [e for e in Entry.objects.filter(kind=FINDING_KIND, context=ctx)
2201                 if not (e.data or {}).get('serial')]
2202         rows.sort(key=lambda e: (e.timestamp_created, e.id))
2203         for e in rows:
2204             data = dict(e.data or {})
2205             data['serial'] = _next_finding_serial(ctx)
2206             e.data = data
2207             e.save()
2208             assigned.append((e.name, data['serial']))
2209     return assigned
2210 
2211 
2212 def findings(*, name=None, version=None, include_history=False):
2213     """The findings joined live to the catalog: each entry with the
2214     signatures it names, their crashes, tasks, configurations and loss.
2215     Optional history is read from immutable EntryVersion snapshots. A
2216     requested version must belong to the named, active segfault finding.
2217     Returns (entries, error); raises ValueError for an unknown version."""
2218     from .models import EntryVersion
2219     current = [e for e in finding_entries() if name is None or e.name == name]
2220     history = {}
2221     if include_history:
2222         for v in EntryVersion.objects.filter(entry_id__in=[e.id for e in current]).order_by('-version_num').values(
2223                 'entry_id', 'version_num', 'timestamp', 'changed_by'):
2224             v['replaced_at'] = _iso(datetime.fromtimestamp(v['timestamp'], tz=dt_timezone.utc))
2225             history.setdefault(v['entry_id'], []).append(v)
2226     snapshot = None
2227     if version is not None:
2228         if name is None or not current:
2229             raise ValueError('No such finding version')
2230         snapshot = EntryVersion.objects.filter(entry=current[0], version_num=version).first()
2231         if snapshot is None:
2232             raise ValueError('No such finding version')
2233     entries = []
2234     for i, e in enumerate(current):
2235         source = snapshot if snapshot is not None else e
2236         f = source.data or {}
2237         keys = [str(k) for k in (f.get('signatures') or [e.name])]
2238         sigs = {s.key: s for s in CrashSignature.objects.filter(key__in=keys)}
2239         rows, crashes, prod_tasks, rows_lost, events_lost = [], 0, set(), 0, 0
2240         for key in keys:
2241             s = sigs.get(key)
2242             if s is None:
2243                 rows.append({'key': key, 'missing': True})
2244                 continue
2245             rows.append(signature_summary(s))
2246             if s.level == 'trace' or len(keys) == 1:
2247                 crashes = max(crashes, s.crashes)
2248                 cfg = s.configuration or {}
2249                 for t in cfg.get('prod_tasks') or ([cfg.get('prod_task')] if cfg.get('prod_task') else []):
2250                     prod_tasks.add(t)
2251                 rows_lost += s.rows_lost or 0
2252                 events_lost += s.events_lost or 0
2253         serial = (e.data or {}).get('serial') or 0
2254         entries.append({
2255             'n': i + 1, 'id': e.id, 'name': e.name,
2256             'serial': serial, 'fid': finding_id(serial),
2257             'anchor': (keys[0] if keys else e.name).replace(':', '-'),
2258             'date': str(f.get('date') or ''), 'frame': f.get('frame', ''),
2259             'stage': f.get('stage', ''), 'title': source.title, 'what': (source.content or '').strip(),
2260             'sources': f.get('sources') or [], 'class': f.get('class', ''),
2261             'action': (f.get('action') or '').strip(),
2262             'standing': f.get('standing', '') if snapshot is not None else e.status or f.get('standing', ''),
2263             'fix': f.get('fix', ''), 'notes': (f.get('notes') or '').strip(),
2264             'model_reading': bool(f.get('model_reading')),
2265             'detector_components': f.get('detector_components') or [],
2266             'detector_point': f.get('detector_point') or None,
2267             'detector_geometry': f.get('detector_geometry') or '',
2268             'detector_where': detector_where(f),
2269             'updated_by': f.get('updated_by', ''), 'updated_at': _iso(
2270                 datetime.fromtimestamp(snapshot.timestamp if snapshot is not None else e.timestamp_modified,
2271                                        tz=dt_timezone.utc)),
2272             'signatures': rows, 'crashes': crashes,
2273             'tasks': len({t for r in rows for t in (r.get('task_ids') or [])}),
2274             'configurations': sorted(prod_tasks), 'rows_lost': rows_lost, 'events_lost': events_lost,
2275         })
2276         if include_history:
2277             current_keys = (e.data or {}).get('signatures') or [e.name]
2278             entries[-1].update({
2279                 'versions': history.get(e.id, []),
2280                 'version': snapshot.version_num if snapshot is not None else None,
2281                 'replaced_by': snapshot.changed_by if snapshot is not None else '',
2282                 'current_anchor': str(current_keys[0]).replace(':', '-'),
2283             })
2284     return entries, ''
2285 
2286 
2287 def finding_failure_rules():
2288     """Findings scoped to their task, failure mechanism and payload stage."""
2289     out = defaultdict(list)
2290     for entry in finding_entries():
2291         fid = finding_id((entry.data or {}).get('serial')) or entry.name
2292         keys = (entry.data or {}).get('signatures') or [entry.name]
2293         signatures = list(CrashSignature.objects.filter(key__in=keys))
2294         for sig in list(signatures):
2295             if sig.level == 'trace':
2296                 signatures.extend(CrashSignature.objects.filter(
2297                     key__in=(sig.data or {}).get('members', [])))
2298         for sig in signatures:
2299             if sig.level == 'trace':
2300                 continue
2301             for task in sig.tasks or []:
2302                 if task.get('jeditaskid'):
2303                     out[int(task['jeditaskid'])].append({
2304                         'fid': fid, 'memory': is_memory(sig), 'exit_code': sig.exit_code,
2305                         'stage': stage_label((sig.data or {}).get('stage'))})
2306     return dict(out)
2307 
2308 
2309 def matching_failure_finding(rules, *, memory, exit_code, stage):
2310     """A finding never exempts another failure mode of the same task."""
2311     for rule in rules:
2312         if rule['stage'] and rule['stage'] != stage_label(stage):
2313             continue
2314         if rule['memory']:
2315             if memory:
2316                 return rule['fid']
2317         elif not memory and str(exit_code) == str(rule['exit_code']):
2318             return rule['fid']
2319     return None
2320 
2321 
2322 def finding_task_ids():
2323     """{jeditaskid: finding id} for every PanDA task whose failures a
2324     finding explains: the tasks of each catalog signature a finding names
2325     (a record-level key names its task, memory:task40856:simu included).
2326     The pressure front's failure window leaves these tasks out, since a
2327     task's own crashes or memory say nothing about the queue it ran on
2328     (swf-epicprod CONTINUOUS_PRODUCTION.md, The tripwire)."""
2329     out = {}
2330     for e in finding_entries():
2331         fid = finding_id((e.data or {}).get('serial')) or e.name
2332         keys = [str(k) for k in ((e.data or {}).get('signatures') or [e.name])]
2333         for sig in CrashSignature.objects.filter(key__in=keys).only('key', 'tasks'):
2334             for t in sig.tasks or []:
2335                 if t.get('jeditaskid'):
2336                     out.setdefault(int(t['jeditaskid']), fid)
2337         for key in keys:
2338             m = re.search(r':task(\d+)(?::|$)', key)
2339             if m:
2340                 out.setdefault(int(m.group(1)), fid)
2341     return out
2342 
2343 
2344 def finding_anchors():
2345     """{signature key: findings-page anchor} for every signature a finding
2346     names."""
2347     out = {}
2348     for e in finding_entries():
2349         keys = [str(k) for k in ((e.data or {}).get('signatures') or [e.name])]
2350         anchor = (keys[0] if keys else e.name).replace(':', '-')
2351         for k in keys:
2352             out[k] = anchor
2353     return out
2354 
2355 
2356 def covering_finding(sig):
2357     """The finding that names this signature or the frame entry it merged
2358     under: the reading of its crash exists, so no study is owed and the
2359     signature is marked diagnosed by that finding once its reproduction
2360     settles. {'fid', 'title', 'anchor'} or None."""
2361     member_of = (sig.data or {}).get('member_of') or ''
2362     for e in finding_entries():
2363         keys = [str(k) for k in ((e.data or {}).get('signatures') or [e.name])]
2364         if sig.key in keys or (member_of and member_of in keys):
2365             return {'fid': finding_id((e.data or {}).get('serial')), 'title': e.title or '',
2366                     'anchor': (keys[0] if keys else e.name).replace(':', '-')}
2367     return None