Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-10-20 10:15:26

0001 """Reproduction runs: every attempt to run a crashed row again, as one
0002 read-only summary shared by the catalog, the runs page and the
0003 signature page (swf-epicprod docs/SEGFAULT_DIAGNOSIS.md, Reproduction).
0004 
0005 Two records make an attempt. The request lives on the signature
0006 (``CrashSignature.reproduction``, one entry per queue the operator
0007 asked for, written by ``segfaults.reproduce``); the execution lives in
0008 the canary store (``ProbeRun`` of kind ``payload`` tagged with the
0009 signature, written by the canary agent and brought up to date by the
0010 probe collection). A request that has not produced a run is still an
0011 attempt, and a signature-tagged run whose request mirror is stale is
0012 still an attempt; the two are joined here, one row per attempt, and
0013 never on a page read: ``attempts`` reads both stores and writes
0014 nothing, ``reconcile`` writes the join back onto the signature and is
0015 run by the canary agent after each collection.
0016 
0017 Execution and result are kept apart. The execution phase says where
0018 the job is (queued for submission, submitting, queued, running,
0019 finishing, finished, failed, cancelled, submission failed); the result
0020 says what the payload did (pending, awaiting report, crash reproduced,
0021 completed without crash, inconclusive with the reason). A finished
0022 PanDA job is not a successful payload: the canary wrapper exits 0 so
0023 the pilot keeps the report, and the result comes from the payload's
0024 own exit code in that report, or from the job digest when the job
0025 failed after the payload ran.
0026 """
0027 import logging
0028 import uuid
0029 from datetime import datetime, timedelta, timezone as dt_timezone
0030 
0031 from django.db import transaction
0032 from django.utils import timezone
0033 
0034 from .models import CrashSignature
0035 
0036 logger = logging.getLogger(__name__)
0037 
0038 REFERENCE_QUEUE = 'BNL_NPPS_GPU'
0039 CRASH_EXITS = {134, 135, 136, 139}
0040 
0041 
0042 def entry_role(entry, sig=None):
0043     """The role of a request's run: as recorded on the request (since
0044     2026-09-13), else by the legacy rule, the reference queue against
0045     everything else. ``production`` is the queue the crash happened on;
0046     ``reference`` and ``elsewhere`` are the other run of the pair."""
0047     role = (entry or {}).get('role')
0048     if role:
0049         return role
0050     return 'reference' if (entry or {}).get('queue') == REFERENCE_QUEUE else 'production'
0051 
0052 # Execution phases, in the order the page lists them; active first.
0053 PHASES = [
0054     ('queued_submission', 'Queued for submission'),
0055     ('submitting', 'Submitting'),
0056     ('queued', 'Queued'),
0057     ('running', 'Running'),
0058     ('finishing', 'Finishing'),
0059     ('finished', 'Finished'),
0060     ('failed', 'Failed'),
0061     ('cancelled', 'Cancelled'),
0062     ('submission_failed', 'Submission failed'),
0063     ('unknown', 'Unknown'),
0064 ]
0065 PHASE_LABELS = dict(PHASES)
0066 PHASE_ORDER = [p for p, _ in PHASES]
0067 ACTIVE_PHASES = {'queued_submission', 'submitting', 'queued', 'running', 'finishing'}
0068 
0069 RESULTS = [
0070     ('pending', 'Pending'),
0071     ('awaiting_report', 'Awaiting report'),
0072     ('crashed', 'Crash reproduced'),
0073     ('crash_observed', 'Crash observed'),
0074     ('completed', 'Completed without crash'),
0075     ('inconclusive', 'Inconclusive'),
0076     ('cancelled', 'Cancelled'),
0077 ]
0078 RESULT_LABELS = dict(RESULTS)
0079 RESULT_ORDER = [r for r, _ in RESULTS]
0080 # A request's outcome words once its run has ended (nothing moves them
0081 # back), and the subset that takes part in the pair settling the
0082 # signature's outcome: a cancelled run is a withdrawn request, not a
0083 # result, and counts for nothing.
0084 ENDED_OUTCOMES = ('crashed', 'completed', 'inconclusive', 'cancelled')
0085 PAIR_OUTCOMES = ('crashed', 'completed', 'inconclusive')
0086 
0087 # The house state colours (static/css/state-colors.css) a phase or
0088 # result cell takes, where its own word has no fill class.
0089 PHASE_STATE = {'queued_submission': 'waiting', 'submission_failed': 'failed'}
0090 RESULT_STATE = {'awaiting_report': 'pending', 'crashed': 'failed',
0091                 'crash_observed': 'failed',
0092                 'inconclusive': 'degraded'}
0093 
0094 # PanDA job states before the job runs; the raw state is kept beside
0095 # the phase.
0096 QUEUED_JOB_STATES = ('defined', 'waiting', 'assigned', 'activated', 'pending',
0097                      'sent', 'starting', 'throttled')
0098 FINISHING_JOB_STATES = ('holding', 'transferring', 'merging')
0099 # The tolerance the legacy join allows between a request and the run
0100 # it produced (the agent stamps the run when it dispatches).
0101 LEGACY_JOIN_TOLERANCE = timedelta(seconds=5)
0102 
0103 
0104 def new_request_id():
0105     """The immutable identity a request carries into the message and the
0106     run's data, so the join is exact from now on."""
0107     return uuid.uuid4().hex[:12]
0108 
0109 
0110 def _iso(dt):
0111     return dt.isoformat(timespec='seconds') if dt else None
0112 
0113 
0114 def _parse_dt(text):
0115     if not text:
0116         return None
0117     try:
0118         dt = datetime.fromisoformat(str(text).replace('Z', '+00:00'))
0119     except ValueError:
0120         return None
0121     if dt.tzinfo is None:
0122         dt = dt.replace(tzinfo=dt_timezone.utc)
0123     return dt
0124 
0125 
0126 # ------------------------------------------------------------------ the join
0127 
0128 def _signature_runs(keys=None):
0129     """The signature-tagged payload runs, by signature key, oldest first."""
0130     from canary.store.models import ProbeRun
0131     qs = (ProbeRun.objects.filter(data__kind='payload')
0132           .exclude(data__signature__isnull=True)
0133           .select_related('queue').order_by('submitted_at', 'id'))
0134     if keys is not None:
0135         qs = qs.filter(data__signature__in=list(keys))
0136     runs = list(qs)
0137     # Reports filed by the existing report sweep also carry fatal evidence
0138     # when the pilot's last heartbeat predates the signal. Read in bulk;
0139     # pages never fetch remote logs or mutate the canary store.
0140     from .models import EpicProdJob
0141     ids = [(r.data or {}).get('pandaid') for r in runs]
0142     reports = {j.pandaid: ((j.data or {}).get('payload_report') or {}).get('report') or {}
0143                for j in EpicProdJob.objects.filter(pandaid__in=[i for i in ids if i]).only('pandaid', 'data')}
0144     by_key = {}
0145     for run in runs:
0146         fatal = reports.get((run.data or {}).get('pandaid'), {}).get('fatal')
0147         if fatal:
0148             run.data = dict(run.data or {}, fatal=fatal)
0149         key = (run.data or {}).get('signature')
0150         if key:
0151             by_key.setdefault(key, []).append(run)
0152     return by_key
0153 
0154 
0155 def join_requests(entries, runs):
0156     """Pair a signature's request entries with its runs, deterministically
0157     and one to one. Returns ``(pairs, unmatched_runs)`` where ``pairs`` is
0158     a list of ``(entry, run_or_None)`` in the entries' order.
0159 
0160     An entry that already names its run (``run_id``) keeps it; an entry
0161     carrying a ``request_id`` takes the run stamped with it; a legacy
0162     entry without either takes the earliest run on its queue for its
0163     crashed job submitted at or after its request time, among the runs
0164     no other entry holds. Runs held by earlier entries, settled or not,
0165     are never reassigned, and a run never serves two entries.
0166     """
0167     by_id = {str(r.id): r for r in runs}
0168     by_request = {}
0169     for r in runs:
0170         rid = (r.data or {}).get('request_id')
0171         if rid:
0172             by_request.setdefault(rid, r)
0173     claimed = set()
0174     pairs = [None] * len(entries)
0175     # Exact identities first, so a legacy match never takes a run that
0176     # belongs to a later request by identity.
0177     for i, e in enumerate(entries):
0178         run = None
0179         if e.get('run_id') and str(e['run_id']) in by_id:
0180             run = by_id[str(e['run_id'])]
0181         elif e.get('request_id') and e['request_id'] in by_request:
0182             run = by_request[e['request_id']]
0183         if run is not None and run.id not in claimed:
0184             claimed.add(run.id)
0185             pairs[i] = (e, run)
0186     # Then the legacy entries, in request order.
0187     order = sorted((i for i, p in enumerate(pairs) if p is None),
0188                    key=lambda i: (_parse_dt(entries[i].get('requested_at'))
0189                                   or datetime.min.replace(tzinfo=dt_timezone.utc), i))
0190     for i in order:
0191         e = entries[i]
0192         if e.get('request_id'):
0193             # An exact identity with no run yet: the request is still in
0194             # the agent's hands (or its dispatch failed before a run was
0195             # recorded); a legacy match would be a guess.
0196             pairs[i] = (e, None)
0197             continue
0198         requested = _parse_dt(e.get('requested_at')) or datetime.min.replace(tzinfo=dt_timezone.utc)
0199         run = next((r for r in runs
0200                     if r.id not in claimed
0201                     and r.queue.name == e.get('queue')
0202                     and (r.data or {}).get('reproduction_of') == e.get('pandaid')
0203                     and not (r.data or {}).get('request_id')
0204                     and r.submitted_at >= requested - LEGACY_JOIN_TOLERANCE), None)
0205         if run is not None:
0206             claimed.add(run.id)
0207         pairs[i] = (e, run)
0208     unmatched = [r for r in runs if r.id not in claimed]
0209     return pairs, unmatched
0210 
0211 
0212 # ---------------------------------------------------------- phase and result
0213 
0214 def phase_of(run):
0215     """The execution phase of a run (None: a request with no run yet)."""
0216     if run is None:
0217         return 'queued_submission', ''
0218     d = run.data or {}
0219     st = run.status
0220     js = d.get('job_status') or ''
0221     if st == 'failed_submit':
0222         return 'submission_failed', ''
0223     if run.jeditaskid is None:
0224         return 'submitting', ''
0225     # Terminal evidence from the job wins over the run's own flag: a run
0226     # still marked submitted whose job has ended is finished or failed,
0227     # awaiting collection.
0228     if js == 'finished':
0229         return 'finished', js
0230     if js == 'failed':
0231         return 'failed', js
0232     if js in ('cancelled', 'closed'):
0233         return 'cancelled', js
0234     if st in ('collected', 'finished'):
0235         return 'finished', js
0236     if st == 'failed':
0237         task_state = d.get('task_status') or ''
0238         return ('cancelled' if task_state in ('aborted', 'broken') else 'failed'), js or task_state
0239     if js == 'running':
0240         return 'running', js
0241     if js in FINISHING_JOB_STATES:
0242         return 'finishing', js
0243     if js in QUEUED_JOB_STATES or not js:
0244         return 'queued', js
0245     return 'unknown', js
0246 
0247 
0248 def result_of(run, phase):
0249     """The reproduction result of a run given its phase: what the payload
0250     did, never what the pilot did."""
0251     if run is None:
0252         return 'pending', '', None
0253     d = run.data or {}
0254     if phase == 'submission_failed':
0255         reason = d.get('error') or (d.get('stderr') or d.get('stdout') or '').strip().splitlines()[-1:]
0256         reason = reason if isinstance(reason, str) else (reason[0] if reason else 'submission failed')
0257         return 'inconclusive', f'submission failed: {reason}'[:300], None
0258     fatal = d.get('fatal') or {}
0259     if fatal.get('signal') in (6, 7, 8, 11):
0260         names = {6: 'SIGABRT', 7: 'SIGBUS', 8: 'SIGFPE', 11: 'SIGSEGV'}
0261         reason = f"{names[fatal['signal']]} observed in {fatal.get('stage') or 'payload'}"
0262         if fatal.get('terminated'):
0263             reason += '; stalled after the signal; stage terminated by watchdog'
0264         elif phase in ACTIVE_PHASES:
0265             reason += '; execution has not ended'
0266         else:
0267             reason += '; execution ended separately'
0268         # An observed signal is evidence, not an invented process exit or
0269         # proof that the requested crashing frame was reached.
0270         return 'crash_observed', reason, d.get('payload_exit_code')
0271     if phase == 'cancelled':
0272         # A killed run is a withdrawn request, not a result.
0273         return 'cancelled', 'request withdrawn', None
0274     if phase in ACTIVE_PHASES:
0275         return 'pending', '', None
0276     if run.status == 'collected':
0277         rc = d.get('payload_exit_code')
0278         if rc in CRASH_EXITS:
0279             return 'crashed', '', rc
0280         if rc == 0:
0281             return 'completed', '', rc
0282         return 'inconclusive', f'payload exited {rc}', rc
0283     if run.status == 'finished':
0284         return 'inconclusive', d.get('collect_note') or 'finished without a payload report', None
0285     if run.status == 'failed':
0286         errors = d.get('errors') or {}
0287         parts = [f"{k} {v.get('code')}" for k, v in errors.items()] if isinstance(errors, dict) else []
0288         return 'inconclusive', 'the canary job failed' + (': ' + ', '.join(parts) if parts else ''), None
0289     # Ended, not yet collected.
0290     return 'awaiting_report', '', None
0291 
0292 
0293 # ---------------------------------------------------------------- the rows
0294 
0295 def withdrawn_phase(entry):
0296     """The phase and result of a request the agent did not run, as the
0297     agent recorded on the entry (``withdraw_request``): ``cancelled`` for
0298     a duplicate it dropped, ``submission_failed`` for a dispatch that
0299     failed before a run existed. None for a request still in the
0300     agent's hands."""
0301     withdrawn = (entry or {}).get('withdrawn')
0302     if not withdrawn or entry.get('outcome') != 'cancelled':
0303         return None
0304     if withdrawn == 'dispatch_failed':
0305         return 'submission_failed', 'inconclusive', entry.get('reason') or 'submission failed'
0306     return 'cancelled', 'cancelled', entry.get('reason') or 'request withdrawn'
0307 
0308 
0309 def _attempt(sig, entry, run, now):
0310     """One attempt row from a request entry, its run, or both."""
0311     d = (run.data or {}) if run is not None else {}
0312     phase, job_status = phase_of(run)
0313     result, reason, rc = result_of(run, phase)
0314     withdrawn = withdrawn_phase(entry) if run is None else None
0315     if withdrawn:
0316         phase, result, reason = withdrawn
0317     queue = (run.queue.name if run is not None else entry.get('queue')) or ''
0318     started = _parse_dt(d.get('started_at'))
0319     ended = _parse_dt(d.get('ended_at'))
0320     created = _parse_dt(d.get('created_at')) or (run.submitted_at if run is not None else None)
0321     wait_s = d.get('wait_s')
0322     if wait_s is None and started and created:
0323         wait_s = int((started - created).total_seconds())
0324     run_s = d.get('run_s')
0325     running_for_s = None
0326     if run_s is None and started and ended:
0327         run_s = int((ended - started).total_seconds())
0328     if started and not ended and phase in ('running', 'finishing'):
0329         running_for_s = int((now - started).total_seconds())
0330     observed = _parse_dt(d.get('observed_at')) or (run.modified_at if run is not None else None)
0331     # Why the job itself failed, when it did: the pilot's or the DDM error
0332     # the collection recorded, or the note of a collection from the digest.
0333     execution_note = ''
0334     if phase in ('failed', 'cancelled'):
0335         errors = d.get('errors') or {}
0336         parts = [f"{k} {v.get('code')}: {(v.get('diag') or '')[:80]}".rstrip(': ')
0337                  for k, v in errors.items()] if isinstance(errors, dict) else []
0338         note = d.get('collect_note') or ''
0339         if note.startswith('payload outcome from the job digest; '):
0340             note = note[len('payload outcome from the job digest; '):]
0341         execution_note = '; '.join(parts) if parts else note
0342     requested = _parse_dt(entry.get('requested_at')) if entry else None
0343     if requested is None and run is not None:
0344         requested = run.submitted_at
0345     if run is not None:
0346         attempt_id = str(run.id)
0347     else:
0348         attempt_id = f"request:{sig.key}:{entry.get('request_id') or entry.get('requested_at') or ''}:{entry.get('queue')}"
0349     return {
0350         'id': attempt_id,
0351         'run_id': str(run.id) if run is not None else '',
0352         'request_id': (entry or {}).get('request_id') or d.get('request_id') or '',
0353         'signature': sig.key,
0354         'level': sig.level,
0355         'member_of': (sig.data or {}).get('member_of', ''),
0356         'frame': (sig.trace or {}).get('frame', ''),
0357         'exit_code': sig.exit_code,
0358         'original_pandaid': (entry or {}).get('pandaid') or d.get('reproduction_of'),
0359         'queue': queue,
0360         'role': entry_role(entry) if entry else ('reference' if queue == REFERENCE_QUEUE else 'production'),
0361         'requested_at': _iso(requested),
0362         'requested_by': (entry or {}).get('requested_by') or '',
0363         'jeditaskid': run.jeditaskid if run is not None else None,
0364         'canary_pandaid': d.get('pandaid'),
0365         'phase': phase,
0366         'phase_label': PHASE_LABELS.get(phase, phase),
0367         'phase_state': PHASE_STATE.get(phase, phase),
0368         'job_status': job_status,
0369         'active': phase in ACTIVE_PHASES,
0370         'execution_note': execution_note[:200],
0371         'result': result,
0372         'result_label': RESULT_LABELS.get(result, result),
0373         'result_state': RESULT_STATE.get(result, result),
0374         'reason': reason,
0375         'payload_exit_code': rc,
0376         'payload_stage': d.get('payload_stage') or '',
0377         'events_processed': d.get('events_processed'),
0378         'created_at': _iso(created),
0379         'started_at': _iso(started),
0380         'ended_at': _iso(ended),
0381         'wait_s': wait_s,
0382         'run_s': run_s,
0383         'running_for_s': running_for_s,
0384         'mem_limit_mb': (entry or {}).get('mem_limit_mb') or d.get('mem_limit_mb'),
0385         'container': (entry or {}).get('container') or d.get('container') or '',
0386         'landed_site': d.get('landed_site') or '',
0387         'host': d.get('host') or '',
0388         'observed_at': _iso(observed),
0389         'observed_age_s': int((now - observed).total_seconds()) if observed else None,
0390         'collect_error': d.get('collect_error') or '',
0391         'request_mirror': (entry or {}).get('outcome', '') if entry else '',
0392     }
0393 
0394 
0395 def attempts(keys=None, signatures=None):
0396     """Every reproduction attempt, one row each, newest request first
0397     among the ended and the active ones ahead of them; reads the
0398     signature store and the canary store, writes nothing.
0399 
0400     ``keys`` restricts to those signatures (their own attempts; a
0401     trace-level signature's members are the caller's to add through
0402     ``member_keys``). ``signatures`` passes already loaded rows.
0403     """
0404     now = timezone.now()
0405     if signatures is None:
0406         qs = CrashSignature.objects.all()
0407         if keys is not None:
0408             qs = qs.filter(key__in=list(keys))
0409         signatures = list(qs)
0410     runs_by_key = _signature_runs(keys=[s.key for s in signatures] if keys is not None else None)
0411     rows = []
0412     for sig in signatures:
0413         entries = list(sig.reproduction or [])
0414         runs = runs_by_key.get(sig.key, [])
0415         if not entries and not runs:
0416             continue
0417         pairs, unmatched = join_requests(entries, runs)
0418         for entry, run in pairs:
0419             rows.append(_attempt(sig, entry, run, now))
0420         for run in unmatched:
0421             rows.append(_attempt(sig, None, run, now))
0422     # Active first, in request order; then the ended, newest first.
0423     active = [r for r in rows if r['active']]
0424     ended = sorted([r for r in rows if not r['active']],
0425                    key=lambda r: r['requested_at'] or '', reverse=True)
0426     return active + ended
0427 
0428 
0429 def member_keys(sig_rows):
0430     """For catalog rows: the keys whose attempts a row shows, itself plus
0431     its members for a trace-level row (``data['members']``)."""
0432     out = {}
0433     for r in sig_rows:
0434         keys = [r['key']] + list(r.get('members') or [])
0435         out[r['key']] = keys
0436     return out
0437 
0438 
0439 def counts_by_signature(rows, members=None):
0440     """Per-signature counts of active and ended attempts, each attempt
0441     counted once; a trace-level key gets its members' attempts too."""
0442     own = {}
0443     for r in rows:
0444         c = own.setdefault(r['signature'], {'active': 0, 'ended': 0, 'ids': set()})
0445         c['ids'].add(r['id'])
0446         c['active' if r['active'] else 'ended'] += 1
0447     out = {}
0448     for key, keys in (members or {}).items():
0449         seen, active, ended = set(), 0, 0
0450         for k in keys:
0451             c = own.get(k)
0452             if not c:
0453                 continue
0454             for r in rows:
0455                 if r['signature'] == k and r['id'] not in seen:
0456                     seen.add(r['id'])
0457                     if r['active']:
0458                         active += 1
0459                     else:
0460                         ended += 1
0461         out[key] = {'active': active, 'ended': ended}
0462     for key, c in own.items():
0463         out.setdefault(key, {'active': c['active'], 'ended': c['ended']})
0464     return out
0465 
0466 
0467 # Where a signature stands on reproduction, for the catalog's facet: the
0468 # settled outcomes, the runs in progress, and for the rest whether a run
0469 # can be formed at all.
0470 REPRODUCTION_STATES = [
0471     ('run_needed', 'Run needed'),
0472     ('in_progress', 'In progress'),
0473     ('not_runnable', 'Not runnable'),
0474     ('reproduced', 'Reproduced'),
0475     ('site_dependent', 'Site dependent'),
0476     ('not_reproduced', 'Not reproduced'),
0477 ]
0478 REPRODUCTION_STATE_LABELS = dict(REPRODUCTION_STATES)
0479 REPRODUCTION_STATE_ORDER = [k for k, _ in REPRODUCTION_STATES]
0480 SETTLED_OUTCOMES = ('reproduced', 'site_dependent', 'not_reproduced')
0481 
0482 
0483 def _settled(outcomes):
0484     """One settled outcome for a set of them: a crash reproduced anywhere
0485     settles the frame as reproduced."""
0486     outcomes = [o for o in outcomes if o in SETTLED_OUTCOMES]
0487     for o in SETTLED_OUTCOMES:
0488         if o in outcomes:
0489             return o
0490     return ''
0491 
0492 
0493 def reproduction_states(sig_rows, counts):
0494     """The reproduction state of every catalog row (signature_summary
0495     rows carrying ``runnable``, ``reproduction_outcome``, ``member_of``
0496     and ``members``), given the per-row attempt counts. A signature
0497     settled by its own runs, or a member of a frame one of whose members
0498     settled, is settled; a signature with an attempt still active is in
0499     progress; otherwise a run is needed where one can be formed, and the
0500     signature is not runnable where it cannot. Returns key -> state."""
0501     by_key = {r['key']: r for r in sig_rows}
0502     frame_outcome = {}
0503     for r in sig_rows:
0504         if r.get('level') == 'trace':
0505             frame_outcome[r['key']] = _settled(
0506                 [r.get('reproduction_outcome', '')]
0507                 + [by_key[m].get('reproduction_outcome', '') for m in r.get('members') or [] if m in by_key])
0508     out = {}
0509     for r in sig_rows:
0510         c = counts.get(r['key']) or {'active': 0, 'ended': 0}
0511         own = r.get('reproduction_outcome', '')
0512         settled = _settled([own])
0513         if not settled and r.get('member_of') in frame_outcome:
0514             settled = frame_outcome[r['member_of']]
0515         if not settled and r.get('level') == 'trace':
0516             settled = frame_outcome.get(r['key'], '')
0517         if c['active']:
0518             out[r['key']] = 'in_progress'
0519         elif settled:
0520             out[r['key']] = settled
0521         else:
0522             runnable = r.get('runnable') or any(
0523                 by_key[m].get('runnable') for m in r.get('members') or [] if m in by_key)
0524             out[r['key']] = 'run_needed' if runnable else 'not_runnable'
0525     return out
0526 
0527 
0528 def global_counts(rows):
0529     return {'active': sum(1 for r in rows if r['active']),
0530             'ended': sum(1 for r in rows if not r['active']),
0531             'total': len(rows)}
0532 
0533 
0534 def freshness(rows):
0535     """The latest observation among the rows, and its age."""
0536     latest = None
0537     for r in rows:
0538         t = _parse_dt(r.get('observed_at'))
0539         if t and (latest is None or t > latest):
0540             latest = t
0541     if latest is None:
0542         return {'observed_at': None, 'age_s': None}
0543     return {'observed_at': _iso(latest), 'age_s': int((timezone.now() - latest).total_seconds())}
0544 
0545 
0546 # ----------------------------------------------------------- reconciliation
0547 
0548 def _entry_outcome(row):
0549     """The request mirror's outcome word for an attempt row (the
0550     signature's own vocabulary: submitted, running, crashed, completed,
0551     inconclusive, cancelled)."""
0552     if row['result'] == 'crash_observed':
0553         # A fatal signal the watchdog ended with the stage's exit in the
0554         # crash class is the payload's crash (the trace comes from its
0555         # report); a signal with no payload exit stays evidence only.
0556         if row['active']:
0557             return 'running'
0558         return 'crashed' if row['payload_exit_code'] in CRASH_EXITS else 'inconclusive'
0559     if row['result'] in ENDED_OUTCOMES:
0560         return row['result']
0561     if row['phase'] in ('running', 'finishing', 'queued'):
0562         return 'running' if row['phase'] != 'queued' else 'submitted'
0563     if row['phase'] in ('finished', 'failed', 'cancelled'):
0564         return 'running'          # ended, report not yet collected
0565     return 'submitted'
0566 
0567 
0568 def reconcile(sig_key, queue_diagnosis=None):
0569     """Write the join back onto one signature: each request entry gets
0570     its run's identity and outcome, and the pair settles the signature's
0571     reproduction outcome (reproduced, site_dependent, not_reproduced,
0572     inconclusive) once the production run and the other run of the pair
0573     (the reference queue or a second production-class queue) have both
0574     reported. A reproduced crash with no trace on record has its trace
0575     read from the crashed run's payload report (the other run first,
0576     the reference queue being the one of known conditions), so a reproduced crash yields its
0577     trace where the campaign's did not. The diagnosis is queued once
0578     when the outcome stands as reproduced or site dependent with a trace
0579     on record and no study yet. Runs under a row lock so a request
0580     appended meanwhile is kept; the bus message goes out after the lock.
0581     Returns what changed."""
0582     from .segfaults import covering_finding, trace_from_report
0583     changed = {'entries': 0, 'outcome': None, 'trace': None, 'diagnosis': False, 'covered': None}
0584     to_queue = None
0585     with transaction.atomic():
0586         sig = CrashSignature.objects.select_for_update().filter(key=sig_key).first()
0587         if sig is None or not sig.reproduction:
0588             return changed
0589         entries = list(sig.reproduction)
0590         runs = _signature_runs(keys=[sig.key]).get(sig.key, [])
0591         pairs, _unmatched = join_requests(entries, runs)
0592         now = timezone.now()
0593         for i, (entry, run) in enumerate(pairs):
0594             if run is None:
0595                 continue
0596             row = _attempt(sig, entry, run, now)
0597             before = dict(entry)
0598             entry['run_id'] = str(run.id)
0599             entry['jedi_task_id'] = run.jeditaskid
0600             entry['canary_pandaid'] = row['canary_pandaid']
0601             outcome = _entry_outcome(row)
0602             if before.get('outcome') in ENDED_OUTCOMES and outcome in ('submitted', 'running'):
0603                 # Never move a settled entry back on missing evidence.
0604                 outcome = before['outcome']
0605             entry['outcome'] = outcome
0606             if outcome in ENDED_OUTCOMES:
0607                 entry['exit_code'] = row['payload_exit_code']
0608                 entry['minutes'] = (row['run_s'] / 60.0) if row['run_s'] else entry.get('minutes')
0609                 entry['verdict_time'] = entry.get('verdict_time') or _iso(now)
0610                 entry['reason'] = row['reason']
0611             if entry != before:
0612                 changed['entries'] += 1
0613             entries[i] = entry
0614         settled = [e for e in entries if e.get('outcome') in PAIR_OUTCOMES]
0615         # The pair: the production run against the other run, whether on
0616         # the reference queue or a second production-class queue; a
0617         # cancelled request is not in it.
0618         prod = [e for e in settled if entry_role(e) == 'production']
0619         ref = [e for e in settled if entry_role(e) != 'production']
0620         outcome = None
0621         if prod and ref:
0622             p, r = prod[-1]['outcome'], ref[-1]['outcome']
0623             if p == 'crashed' and r == 'crashed':
0624                 outcome = 'reproduced'
0625             elif p == 'crashed' and r == 'completed':
0626                 outcome = 'site_dependent'
0627             elif p == 'completed' and r == 'completed':
0628                 outcome = 'not_reproduced'
0629             elif p == 'completed' and r == 'crashed':
0630                 outcome = 'reproduced'
0631             else:
0632                 outcome = 'inconclusive'
0633         elif ref and ref[-1]['outcome'] == 'crashed':
0634             outcome = 'reproduced'
0635         if (outcome in ('reproduced', 'site_dependent')
0636                 and (sig.trace or {}).get('trace_status') != 'found'):
0637             # The crashed run's report carries the stage's log tail
0638             # (payload 0.12 and later); record_trace saves the trace and
0639             # merges the frame under the lock held here.
0640             for entry in ref[::-1] + prod[::-1]:
0641                 if entry.get('outcome') == 'crashed' and entry.get('canary_pandaid'):
0642                     if trace_from_report(sig, entry['canary_pandaid']):
0643                         changed['trace'] = entry['canary_pandaid']
0644                         # The merge under a frame entry wrote member_of on
0645                         # a fresh row instance; read it back so the
0646                         # covering finding is seen on this pass.
0647                         sig.refresh_from_db(fields=['data', 'status', 'trace'])
0648                         break
0649         data = dict(sig.data or {})
0650         fields = []
0651         if changed['entries']:
0652             sig.reproduction = entries
0653             fields.append('reproduction')
0654         if outcome and data.get('reproduction_outcome') != outcome:
0655             data['reproduction_outcome'] = outcome
0656             data['reproduction_settled_at'] = _iso(now)
0657             changed['outcome'] = outcome
0658             sig.data = data
0659             fields.append('data')
0660             if outcome in ('reproduced', 'site_dependent') and sig.status in ('reproducing', 'traced', 'new'):
0661                 sig.status = 'reproduced'
0662                 fields.append('status')
0663             elif outcome == 'not_reproduced' and sig.status == 'reproducing':
0664                 sig.status = 'not_reproduced'
0665                 fields.append('status')
0666         if (data.get('reproduction_outcome') in ('reproduced', 'site_dependent')
0667                 and (sig.trace or {}).get('trace_status') == 'found'
0668                 and not data.get('diagnosis')):
0669             # Marked before the message goes out, so a second pass never
0670             # queues the study twice; the trace may arrive after the
0671             # outcome settled, so this is checked on every pass. A frame
0672             # whose reading already exists as a finding owes no study:
0673             # the finding is the diagnosis and the signature is marked by
0674             # it, so nothing further is ever asked of it.
0675             finding = covering_finding(sig)
0676             if finding:
0677                 data['diagnosis'] = {'state': 'covered', 'finding': finding['fid'],
0678                                      'anchor': finding['anchor'], 'requested_by': 'auto:reproduced',
0679                                      'submitted_at': _iso(now)}
0680                 sig.verdict = f"covered by finding {finding['fid']}: {finding['title']}".strip(': ')
0681                 sig.status = 'diagnosed'
0682                 for f in ('verdict', 'status'):
0683                     if f not in fields:
0684                         fields.append(f)
0685                 changed['covered'] = finding['fid']
0686             else:
0687                 data['diagnosis'] = {'state': 'queued', 'requested_by': 'auto:reproduced',
0688                                      'submitted_at': _iso(now)}
0689                 to_queue = sig.key
0690                 changed['diagnosis'] = True
0691             sig.data = data
0692             if 'data' not in fields:
0693                 fields.append('data')
0694         if fields:
0695             sig.save(update_fields=fields + ['updated_at'])
0696     if to_queue and queue_diagnosis is not None:
0697         queue_diagnosis(to_queue, 'auto:reproduced')
0698     return changed
0699 
0700 
0701 def withdraw_request(sig_key, request_id, reason, failed=False):
0702     """Record on the signature that the canary agent did not run a
0703     request: dropped as a duplicate of a submission already in flight
0704     for the same row and queue, or a dispatch that failed before a run
0705     was recorded (``failed=True``). Either way the entry's outcome is
0706     ``cancelled``, a withdrawn request that counts for nothing in the
0707     pair; ``withdrawn`` says which (``duplicate`` or
0708     ``dispatch_failed``), and the runs page reads the second as a failed
0709     submission with the reason. Without this the request would read
0710     "queued for submission" forever and keep its signature open to every
0711     reconciliation pass. Nothing is written when a run already carries
0712     the request id: the run is the record then, and the reconciliation
0713     reads it. Returns what happened: ``withdrawn``, ``run exists``,
0714     ``already ended``, or ``no such request``."""
0715     with transaction.atomic():
0716         sig = CrashSignature.objects.select_for_update().filter(key=sig_key).first()
0717         if sig is None or not sig.reproduction:
0718             return 'no such request'
0719         entries = list(sig.reproduction)
0720         idx = next((i for i, e in enumerate(entries) if e.get('request_id') == request_id), None)
0721         if idx is None:
0722             return 'no such request'
0723         if entries[idx].get('outcome') in ENDED_OUTCOMES:
0724             return 'already ended'
0725         runs = _signature_runs(keys=[sig.key]).get(sig.key, [])
0726         if any((r.data or {}).get('request_id') == request_id for r in runs):
0727             return 'run exists'
0728         entry = dict(entries[idx])
0729         entry['outcome'] = 'cancelled'
0730         entry['reason'] = (reason or '')[:300]
0731         entry['withdrawn'] = 'dispatch_failed' if failed else 'duplicate'
0732         entry['verdict_time'] = _iso(timezone.now())
0733         entries[idx] = entry
0734         sig.reproduction = entries
0735         sig.save(update_fields=['reproduction', 'updated_at'])
0736     return 'withdrawn'
0737 
0738 
0739 def open_signature_keys():
0740     """The signatures with a request not yet settled, or a run of theirs
0741     not yet mirrored: what a reconciliation pass visits."""
0742     keys = set()
0743     for sig in CrashSignature.objects.exclude(reproduction=[]).exclude(reproduction__isnull=True).only('key', 'reproduction'):
0744         if any(e.get('outcome') not in ENDED_OUTCOMES
0745                for e in (sig.reproduction or [])):
0746             keys.add(sig.key)
0747     return sorted(keys)
0748 
0749 
0750 def reconcile_open(queue_diagnosis=None):
0751     """One pass over the open signatures; returns per-key changes."""
0752     out = {}
0753     for key in open_signature_keys():
0754         try:
0755             out[key] = reconcile(key, queue_diagnosis=queue_diagnosis)
0756         except Exception as e:                                # noqa: BLE001
0757             logger.error('reproduction reconcile %s failed: %s', key, e, exc_info=True)
0758             out[key] = {'error': str(e)[:300]}
0759     return out