File indexing completed on 2026-11-09 10:47:23
0001
0002 """Backtest the node guard's decision over past windows (site-canary
0003 docs/NODE_GUARD.md, What the backtest measured).
0004
0005 Steps the guard's window back over ``--days`` at ``--step-h``, reads the
0006 terminal production jobs of each window from PanDA once, and runs
0007 ``canary.guard.decide_nodes`` with the live settings (SysConfig
0008 ``node_guard.*``), the attribution built from the same record over
0009 ``attribution_h`` before each window's end. Prints every trip by reason,
0010 queue and host with its windows and peak evidence; ``--reason`` limits
0011 the listing. Read-only: no record, document or action is written.
0012
0013 python scripts/node-guard-backtest.py --days 7 --reason outlier
0014 """
0015 import argparse
0016 import os
0017 import sys
0018 from collections import defaultdict
0019 from datetime import timedelta, timezone as dt_timezone
0020
0021 sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src'))
0022 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0023 import django
0024
0025 django.setup()
0026
0027 from django.db import connections
0028 from django.utils import timezone
0029
0030 from canary import guard
0031 from monitor_app.panda import node_guard
0032 from monitor_app.panda.constants import PANDA_SCHEMA
0033
0034
0035 def read_jobs(start, end):
0036 """Every terminal production job that ended in [start, end), in the
0037 window_rows shape plus the end as a datetime."""
0038 sql = f"""
0039 SELECT "computingsite", "modificationhost", "jobstatus", "jeditaskid",
0040 EXTRACT(EPOCH FROM ("endtime" - "starttime")), "endtime", "pandaid",
0041 "destinationsite", "piloterrorcode", "exeerrorcode", "transexitcode"
0042 FROM "{PANDA_SCHEMA}"."jobsarchived4"
0043 WHERE "processingtype" = 'epicproduction'
0044 AND "endtime" >= %s AND "endtime" < %s
0045 AND "jobstatus" IN ('finished', 'failed')
0046 """
0047 with connections['panda'].cursor() as cursor:
0048 cursor.execute(sql, [start.replace(tzinfo=None), end.replace(tzinfo=None)])
0049 rows = cursor.fetchall()
0050 out = []
0051 for (queue, host, status, task, seconds, ended, pandaid, dest, pilot, exe, trans) in rows:
0052 codes = (str(exe or '').strip(), str(trans or '').strip())
0053 out.append({'queue': str(queue) if queue else None, 'host': host,
0054 'jobstatus': status, 'jeditaskid': task,
0055 'duration_s': float(seconds) if seconds is not None else None,
0056 'endtime': ended.isoformat() if ended is not None else None,
0057 'ended': ended, 'pandaid': pandaid,
0058 'site': node_guard.execute_site(dest, host),
0059 'error': node_guard.error_label(pilot, exe, trans) if status == 'failed' else '',
0060 'registration': status == 'failed' and '78' in codes,
0061 'declined': status == 'failed' and '80' in codes})
0062 return out
0063
0064
0065 def main():
0066 ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
0067 ap.add_argument('--days', type=float, default=7)
0068 ap.add_argument('--step-h', type=float, default=1)
0069 ap.add_argument('--reason', default='', help='list only trips with this reason')
0070 args = ap.parse_args()
0071
0072
0073
0074 from monitor_app.models import SysConfig
0075 live = SysConfig.get_config()
0076 cfg = {k: live.get(f'node_guard.{k}', d) for k, d in node_guard.DEFAULTS.items()}
0077 decision = {k: cfg[k] for k in node_guard.DECISION_KEYS}
0078 window = timedelta(hours=float(cfg['window_h']))
0079 attribution = timedelta(hours=float(cfg['attribution_h']))
0080 now = timezone.now()
0081 first_end = now - timedelta(days=args.days)
0082 jobs = read_jobs(first_end - window - attribution, now)
0083 jobs = [j for j in jobs if j['ended'] is not None]
0084 for j in jobs:
0085 if timezone.is_naive(j['ended']):
0086 j['ended'] = timezone.make_aware(j["ended"], dt_timezone.utc)
0087 calib = node_guard.median_finished_s()
0088 print(f'{len(jobs)} terminal production jobs read; windows of {cfg["window_h"]} h '
0089 f'every {args.step_h} h over {args.days} days', file=sys.stderr)
0090
0091 trips = defaultdict(list)
0092 end = first_end
0093 while end <= now:
0094 rows = [j for j in jobs if end - window < j['ended'] <= end]
0095 elsewhere = defaultdict(lambda: defaultdict(dict))
0096 for j in jobs:
0097 if j['jobstatus'] == 'finished' and j['jeditaskid'] is not None \
0098 and end - attribution < j['ended'] <= end:
0099 host = guard.normalize_host(j['host'])
0100 prev = elsewhere[j['queue']][j['jeditaskid']].get(host)
0101 if prev is None or j['endtime'] > prev:
0102 elsewhere[j['queue']][j['jeditaskid']][host] = j['endtime']
0103 out = guard.decide_nodes(rows, calib, decision, finished_elsewhere=elsewhere)
0104 for queue, host in out['tripped']:
0105 v = out['queues'][queue]['nodes'][host]
0106 if args.reason and v['reason'] != args.reason:
0107 continue
0108 e = v['evidence']
0109 trips[(v['reason'], queue, host)].append(
0110 (timezone.localtime(end).strftime('%m-%d %H:%M'), e['jobs'], e['failed'],
0111 e['finished'], e.get('queue_others_failed_fraction'), e.get('outlier_ratio')))
0112 end += timedelta(hours=args.step_h)
0113
0114 if not trips:
0115 print('no trips')
0116 for (reason, queue, host), hits in sorted(trips.items()):
0117 peak = max(hits, key=lambda h: h[2])
0118 print(f'{reason:12} {queue:28} {host:24} windows {len(hits):3} '
0119 f'{hits[0][0]} .. {hits[-1][0]} ET peak: jobs {peak[1]} failed {peak[2]} '
0120 f'finished {peak[3]} others_failed {peak[4]} ratio {peak[5]}')
0121
0122
0123 if __name__ == '__main__':
0124 main()