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
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
0081
0082
0083
0084 ENDED_OUTCOMES = ('crashed', 'completed', 'inconclusive', 'cancelled')
0085 PAIR_OUTCOMES = ('crashed', 'completed', 'inconclusive')
0086
0087
0088
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
0095
0096 QUEUED_JOB_STATES = ('defined', 'waiting', 'assigned', 'activated', 'pending',
0097 'sent', 'starting', 'throttled')
0098 FINISHING_JOB_STATES = ('holding', 'transferring', 'merging')
0099
0100
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
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
0138
0139
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
0176
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
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
0194
0195
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
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
0226
0227
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
0269
0270 return 'crash_observed', reason, d.get('payload_exit_code')
0271 if phase == 'cancelled':
0272
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
0290 return 'awaiting_report', '', None
0291
0292
0293
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
0332
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
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
0468
0469
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
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
0554
0555
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'
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
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
0616
0617
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
0638
0639
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
0645
0646
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
0670
0671
0672
0673
0674
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:
0757 logger.error('reproduction reconcile %s failed: %s', key, e, exc_info=True)
0758 out[key] = {'error': str(e)[:300]}
0759 return out