File indexing completed on 2026-08-12 09:36:13
0001 """Backfill cumulative terminal-outcome counters into snap history.
0002
0003 Reconstructs the panda component's version-5 cumulative terminal
0004 counters (jobs.cum, per-site cum and cum_failed_by_class) at a regular
0005 grid of historical instants, from the complete recorded job history in
0006 the PanDA database. One synthetic snap per grid instant is written
0007 with capture policy ``backfill-panda-v1`` — reconstructed evidence,
0008 explicitly distinguishable from observed snaps — carrying only the
0009 counter fields in the live publisher's envelope shape. The counters
0010 are absolute counts of terminal events with end times at or before
0011 each instant, the same origin the live publisher seeds from, so the
0012 backfilled grid and the live counter chain form one consistent record.
0013
0014 Idempotent: --apply first removes prior backfill-panda-v1 snaps for
0015 the scope, and writes only instants strictly before the earliest live
0016 snap carrying jobs.cum (or up to now when none exists yet). Dry-run
0017 default.
0018
0019 Run under the venv with the swf-monitor project on the path:
0020
0021 cd <swf-monitor>/src && source <venv>/bin/activate && source ~/.env
0022 python <swf-monitor>/scripts/backfill-panda-counters.py \\
0023 [--start 2026-07-01] [--step-hours 1] [--apply]
0024 """
0025
0026 import argparse
0027 import datetime as dt
0028 import os
0029 import sys
0030
0031 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0032
0033 import django
0034
0035 django.setup()
0036
0037 from django.db import connections
0038 from django.utils import timezone
0039
0040 from monitor_app.panda.constants import (
0041 ERROR_COMPONENTS,
0042 PANDA_SCHEMA,
0043 )
0044 from monitor_app.snapper_panda import (
0045 ASSESSMENT_POLICY_VERSION,
0046 MAX_SITES,
0047 PANDA_REGISTRATION,
0048 PUBLISHER_IDENTITY,
0049 TERMINAL_JOB_STATUSES,
0050 )
0051 from snapper_ai.models import SystemSnap
0052
0053 CAPTURE_POLICY = 'backfill-panda-v1'
0054
0055
0056 def _hourly_outcome_buckets(until):
0057 """Terminal-outcome counts per (hour bucket, site, status, failure
0058 class) over the full recorded history up to ``until``, deduplicated
0059 across the active and archived tables."""
0060 class_case = ' '.join(
0061 f'WHEN "{c["code"]}" > 0 THEN \'{c["name"]}\''
0062 for c in ERROR_COMPONENTS
0063 )
0064 err_fields = ', '.join(f'"{c["code"]}"' for c in ERROR_COMPONENTS)
0065 placeholders = ', '.join(['%s'] * len(TERMINAL_JOB_STATUSES))
0066 bounds = f'"endtime" <= %s AND "jobstatus" IN ({placeholders})'
0067 params = [until, *TERMINAL_JOB_STATUSES]
0068 sql = f"""
0069 SELECT date_trunc('hour', "endtime") AS bucket,
0070 COALESCE("computingsite", 'unknown'), "jobstatus",
0071 CASE WHEN "jobstatus" = 'failed'
0072 THEN CASE {class_case} ELSE 'other' END
0073 ELSE '' END,
0074 COUNT(*)
0075 FROM (
0076 SELECT "pandaid", "endtime", "jobstatus",
0077 "computingsite", {err_fields}
0078 FROM "{PANDA_SCHEMA}"."jobsactive4" WHERE {bounds}
0079 UNION
0080 SELECT "pandaid", "endtime", "jobstatus",
0081 "computingsite", {err_fields}
0082 FROM "{PANDA_SCHEMA}"."jobsarchived4" WHERE {bounds}
0083 ) completed
0084 GROUP BY 1, 2, 3, 4
0085 ORDER BY 1
0086 """
0087 with connections['panda'].cursor() as cursor:
0088 cursor.execute(sql, params + params)
0089 return cursor.fetchall()
0090
0091
0092 def _bump(counter, key, count):
0093 counter[key] = int(counter.get(key) or 0) + count
0094
0095
0096 def _counters_at_instants(instants, buckets):
0097 """Walk the hour buckets once, emitting deep-copied counter maps at
0098 each grid instant. Bucket times are naive UTC from the database;
0099 a bucket belongs to instant t when the whole hour ends at or
0100 before t."""
0101 scope_cum = {}
0102 site_cums = {}
0103 results = []
0104 index = 0
0105 for instant in instants:
0106 cutoff = instant.replace(tzinfo=None)
0107 while index < len(buckets):
0108 bucket, site, status, fclass, count = buckets[index]
0109 if bucket + dt.timedelta(hours=1) > cutoff:
0110 break
0111 count = int(count or 0)
0112 _bump(scope_cum, status, count)
0113 entry = site_cums.setdefault(
0114 str(site or 'unknown'), {'cum': {}, 'classes': {}})
0115 _bump(entry['cum'], status, count)
0116 if status == 'failed' and fclass:
0117 _bump(entry['classes'], fclass, count)
0118 index += 1
0119 ranked = sorted(
0120 site_cums,
0121 key=lambda name: (-sum(site_cums[name]['cum'].values()), name),
0122 )[:MAX_SITES]
0123 sites = {}
0124 for name in ranked:
0125 entry = site_cums[name]
0126 block = {'cum': dict(entry['cum'])}
0127 if entry['classes']:
0128 block['cum_failed_by_class'] = dict(entry['classes'])
0129 sites[name] = block
0130 results.append((instant, dict(scope_cum), sites))
0131 return results
0132
0133
0134 def main():
0135 parser = argparse.ArgumentParser(
0136 description='Backfill cumulative terminal-outcome counters '
0137 'into epicprod snap history.')
0138 parser.add_argument('--start', default='2026-07-01',
0139 help='first grid instant, UTC date or ISO '
0140 '(default 2026-07-01)')
0141 parser.add_argument('--step-hours', type=float, default=1.0,
0142 help='grid spacing in hours (default 1)')
0143 parser.add_argument('--apply', action='store_true',
0144 help='write the snaps (dry run without)')
0145 args = parser.parse_args()
0146
0147 start = dt.datetime.fromisoformat(args.start)
0148 if start.tzinfo is None:
0149 start = start.replace(tzinfo=dt.timezone.utc)
0150 now = timezone.now()
0151
0152 live_first = (
0153 SystemSnap.objects
0154 .filter(scope='epicprod',
0155 state__components__panda__data__jobs__has_key='cum')
0156 .exclude(capture_policy=CAPTURE_POLICY)
0157 .order_by('snap_time')
0158 .values_list('snap_time', flat=True)
0159 .first())
0160 end = live_first or now
0161 if start >= end:
0162 print(f'nothing to do: start {start} is not before end {end}')
0163 return 0
0164
0165 instants = []
0166 step = dt.timedelta(hours=args.step_hours)
0167 instant = start
0168 while instant < end:
0169 instants.append(instant)
0170 instant = instant + step
0171
0172 buckets = _hourly_outcome_buckets(end)
0173 results = _counters_at_instants(instants, buckets)
0174
0175 print(f'grid: {len(instants)} instants, {args.step_hours}h step, '
0176 f'{start.isoformat()} -> {instants[-1].isoformat()}')
0177 print(f'end boundary: '
0178 f'{"earliest live counter snap " + end.isoformat() if live_first else "now"}')
0179 print(f'hour buckets: {len(buckets)}')
0180 for instant, scope_cum, sites in results[-3:]:
0181 google = sites.get('BNL_ePIC_GOOGLE') or {}
0182 print(f' {instant.isoformat()}: scope {scope_cum} | '
0183 f'BNL_ePIC_GOOGLE {google.get("cum")} '
0184 f'{google.get("cum_failed_by_class")}')
0185
0186 if not args.apply:
0187 print('\ndry run — nothing written; --apply writes the snaps')
0188 return 0
0189
0190 removed = SystemSnap.objects.filter(
0191 scope='epicprod', capture_policy=CAPTURE_POLICY).delete()
0192 written = 0
0193 for instant, scope_cum, sites in results:
0194
0195
0196
0197 SystemSnap.objects.create(
0198 scope='epicprod',
0199 snap_time=instant + dt.timedelta(seconds=1),
0200 observed_at=now,
0201 completed_at=now,
0202 snap_schema_version=1,
0203 capture_policy=CAPTURE_POLICY,
0204 encoding='full',
0205 reasons=['backfill'],
0206 changed_components=['panda'],
0207 component_revisions={'panda': 0},
0208 registration_versions={'panda': 5},
0209 component_hashes={},
0210 state_hash='',
0211 state={'components': {'panda': {
0212 'v': 1,
0213 'data': {'jobs': {'cum': scope_cum, 'sites': sites}},
0214 'registration': PANDA_REGISTRATION,
0215 'revision': 0,
0216 'registration_version': 5,
0217 'assessed_at': instant.isoformat(),
0218 'source_as_of': instant.isoformat(),
0219 'accepted_at': now.isoformat(),
0220 'assessment_policy': ASSESSMENT_POLICY_VERSION,
0221 'publisher_identity': PUBLISHER_IDENTITY,
0222 }}},
0223 )
0224 written += 1
0225 print(f'\napplied: removed prior backfill {removed[0]}, '
0226 f'wrote {written} snaps')
0227 return 0
0228
0229
0230 if __name__ == '__main__':
0231 sys.exit(main())