Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-09 10:47:23

0001 #!/usr/bin/env python
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  # noqa: E402
0024 
0025 django.setup()
0026 
0027 from django.db import connections  # noqa: E402
0028 from django.utils import timezone  # noqa: E402
0029 
0030 from canary import guard  # noqa: E402
0031 from monitor_app.panda import node_guard  # noqa: E402
0032 from monitor_app.panda.constants import PANDA_SCHEMA  # noqa: E402
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     # The live settings read without seeding (get_setting writes a missing
0073     # key); a key not yet in SysConfig takes its code default.
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()