File indexing completed on 2026-11-09 10:47:21
0001 """Add missing trailing entry fields to the errors entries of live snaps
0002 recorded before the current schema version.
0003
0004 The error-state component's entry rows grow at the end: the terminal
0005 status at schema version 3, the payload's transformation exit code at
0006 version 4, the core-seconds held at version 5 (docs/SNAPPER_ERRORS.md).
0007 Live snaps captured before a field joined carry rows without it. This
0008 augmentation adds every trailing field a row lacks, in place, looked up
0009 by PanDA job id from the job records with the publisher's own
0010 definitions, and recomputes the snap's component hash and state hash
0011 by the capture contract (snapper_ai capture: the sha256 of the
0012 canonical JSON of the component data, and of the components document).
0013 Every other field of the row and the snap stays as recorded; the
0014 component document carries an ``augmented`` note per field naming when
0015 it was added, how many rows received it, and how many had no job record
0016 left to read (those take the field's empty value). Overflow fold keys
0017 name no job and keep their recorded form.
0018
0019 Idempotent: rows already carrying every field are skipped, and a snap
0020 with nothing to add is not written. Dry-run default; ``--limit N``
0021 bounds an apply to the first N snaps needing the augmentation, for a
0022 trial.
0023
0024 Run under the venv with the swf-monitor project on the path:
0025
0026 cd <swf-monitor>/src && source <venv>/bin/activate && source ~/.env
0027 python <swf-monitor>/scripts/augment-errors-entries.py [--apply] [--limit N]
0028 """
0029
0030 import argparse
0031 import hashlib
0032 import json
0033 import os
0034 import sys
0035
0036 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0037
0038 import django
0039
0040 django.setup()
0041
0042 from django.db import connections, transaction
0043 from django.utils import timezone
0044
0045 from monitor_app.panda.constants import PANDA_SCHEMA
0046 from monitor_app.snapper_errors import (
0047 ENTRY_FIELDS,
0048 HELD_SQL,
0049 _exit_key,
0050 )
0051 from snapper_ai.models import SystemSnap
0052
0053 SCOPE = 'epicprod'
0054 COMPONENT = 'errors'
0055 BACKFILL_POLICY = 'backfill-errors-v1'
0056 WIDTH = len(ENTRY_FIELDS)
0057
0058 EMPTY = {'status': '', 'exitcode': '', 'held': 0}
0059
0060
0061 def _canonical_hash(value):
0062 """The capture contract's hash: sha256 of the canonical JSON."""
0063 encoded = json.dumps(
0064 value, allow_nan=False, ensure_ascii=False,
0065 separators=(',', ':'), sort_keys=True).encode('utf-8')
0066 return hashlib.sha256(encoded).hexdigest()
0067
0068
0069 def _job_fields(pandaids):
0070 """{pandaid: {field: value}} for the fields the job records supply
0071 — status, exitcode and held — over both tables. A job no longer in
0072 either table is absent from the result."""
0073 ids = sorted({int(p) for p in pandaids if p})
0074 out = {}
0075 if not ids:
0076 return out
0077 columns = (
0078 '"pandaid", "jobstatus", "transexitcode", "starttime", '
0079 '"endtime" AS "ended", "actualcorecount", "corecount"'
0080 )
0081 sql = (
0082 f'SELECT "pandaid", "jobstatus", "transexitcode", {HELD_SQL} '
0083 f'FROM (SELECT {columns} FROM "{PANDA_SCHEMA}"."jobsarchived4" '
0084 f'WHERE "pandaid" = ANY(%s) '
0085 f'UNION SELECT {columns} FROM "{PANDA_SCHEMA}"."jobsactive4" '
0086 f'WHERE "pandaid" = ANY(%s)) jobs'
0087 )
0088 with connections['panda'].cursor() as cursor:
0089 cursor.execute(sql, [ids, ids])
0090 for pandaid, status, exitcode, held in cursor.fetchall():
0091 out[int(pandaid)] = {
0092 'status': str(status or ''),
0093 'exitcode': _exit_key(exitcode),
0094 'held': int(held or 0),
0095 }
0096 return out
0097
0098
0099 def _augment(rows, jobs):
0100 """Add every missing trailing field to each short row in place.
0101 Returns {field: (rows added to, rows whose job had no record)}."""
0102 stats = {}
0103 for row in rows:
0104 if len(row) >= WIDTH:
0105 continue
0106 job = jobs.get(int(row[0] or 0))
0107 for index in range(len(row), WIDTH):
0108 field = ENTRY_FIELDS[index]
0109 added, unresolved = stats.get(field) or (0, 0)
0110 if job is None:
0111 row.append(EMPTY.get(field, ''))
0112 unresolved += 1
0113 else:
0114 row.append(job.get(field, EMPTY.get(field, '')))
0115 stats[field] = (added + 1, unresolved)
0116 return stats
0117
0118
0119 def _note(doc, stats, now_iso):
0120 """The augmented note: one entry per field added. An earlier note
0121 in the first form (a timestamp under 'exitcode' with 'rows' and
0122 'unresolved' beside it) is carried into the per-field form."""
0123 note = doc.get('augmented') or {}
0124 if isinstance(note.get('exitcode'), str):
0125 note = {'exitcode': {'at': note['exitcode'],
0126 'rows': note.get('rows', 0),
0127 'unresolved': note.get('unresolved', 0)}}
0128 for field, (added, unresolved) in stats.items():
0129 note[field] = {'at': now_iso, 'rows': added,
0130 'unresolved': unresolved}
0131 doc['augmented'] = note
0132
0133
0134 def main():
0135 parser = argparse.ArgumentParser(
0136 description='Add missing trailing entry fields to pre-current '
0137 'errors entries in live epicprod snaps.')
0138 parser.add_argument('--apply', action='store_true',
0139 help='write the augmented snaps (default: dry run)')
0140 parser.add_argument('--limit', type=int, default=0,
0141 help='with --apply, augment at most N snaps')
0142 args = parser.parse_args()
0143
0144 snaps = (SystemSnap.objects
0145 .filter(scope=SCOPE,
0146 state__components__errors__data__has_key='entries')
0147 .exclude(capture_policy=BACKFILL_POLICY)
0148 .order_by('snap_time'))
0149 now_iso = timezone.now().isoformat()
0150 cache = {}
0151 seen = to_augment = written = 0
0152 totals = {}
0153 first = last = None
0154 for snap in snaps.iterator(chunk_size=100):
0155 seen += 1
0156 doc = snap.state['components'][COMPONENT]
0157 data = doc['data']
0158 rows = data.get('entries') or []
0159 if not any(len(row) < WIDTH for row in rows):
0160 continue
0161 interval = data.get('interval') or {}
0162 key = (interval.get('start'), interval.get('end'))
0163 if key not in cache:
0164 cache[key] = _job_fields(
0165 row[0] for row in rows if len(row) < WIDTH)
0166 stats = _augment(rows, cache[key])
0167 to_augment += 1
0168 for field, (added, unresolved) in stats.items():
0169 a, u = totals.get(field) or (0, 0)
0170 totals[field] = (a + added, u + unresolved)
0171 first = first or snap.snap_time
0172 last = snap.snap_time
0173 if not args.apply or (args.limit and written >= args.limit):
0174 continue
0175 _note(doc, stats, now_iso)
0176 hashes = dict(snap.component_hashes or {})
0177 hashes[COMPONENT] = _canonical_hash(data)
0178 state_hash = _canonical_hash(snap.state['components'])
0179 with transaction.atomic():
0180 SystemSnap.objects.filter(pk=snap.pk).update(
0181 state=snap.state, component_hashes=hashes,
0182 state_hash=state_hash)
0183 written += 1
0184
0185 print(f'live snaps carrying entries: {seen}; needing augmentation: '
0186 f'{to_augment} ({first} to {last}); distinct intervals looked '
0187 f'up: {len(cache)}')
0188 for field, (added, unresolved) in totals.items():
0189 print(f' {field}: added to {added} rows; {unresolved} rows '
0190 f'without a job record (empty value)')
0191 if not args.apply:
0192 print('\ndry run — nothing written; --apply augments the snaps')
0193 return 0
0194 print(f'\napplied: augmented {written} snaps'
0195 + (f' (limit {args.limit})' if args.limit else ''))
0196 return 0
0197
0198
0199 if __name__ == '__main__':
0200 sys.exit(main())