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
0047
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
0057
0058
0059
0060
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
0065 INPUT_STAGES = ('simulation', 'reconstruction')
0066
0067
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
0130
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
0135
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
0151
0152
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
0164
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
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:
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
0311
0312
0313
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
0375
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
0420
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
0453
0454
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
0522
0523
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
0704
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
0729
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
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:
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
0940
0941
0942
0943
0944
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
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 = [], []
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
1099
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
1184
1185
1186
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
1251
1252
1253
1254 'runnable': bool((sig.configuration or {}).get('prod_task')
1255 and ((sig.data or {}).get('rows_resolved') or 0) > 0),
1256
1257
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
1280
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
1311
1312
1313
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
1342
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
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
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
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:
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
1573
1574
1575
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
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
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
1703
1704 CANARY_QUEUE = '/queue/canary.ops'
1705 CANARY_NAMESPACE = 'canary'
1706 REFERENCE_QUEUE = 'BNL_NPPS_GPU'
1707
1708
1709
1710
1711
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
1781
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:
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
1807
1808
1809
1810
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
1873
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
1888
1889
1890
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
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:
2031 logger.error('segfault %s: diagnosis not queued: %s', key, e)
2032 return False
2033
2034
2035
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
2044
2045
2046
2047
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