Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """Successful wall-time efficiency history (docs/COMPUTE_EFFICIENCY.md)."""
0002 from collections import defaultdict
0003 import re
0004 from datetime import date, datetime, timedelta, timezone
0005 
0006 from django.db import connections, transaction
0007 
0008 from .cached_product import get_product
0009 from .models import CachedProduct
0010 from .panda.queries import PANDA_SCHEMA, execute_site_key_sql, execute_site_label, osg_pool_queues
0011 from .short_names import short_name
0012 
0013 PRODUCT_KEY = 'compute_efficiency:v1'
0014 CURRENT_KEY = 'compute_efficiency_current:v2'
0015 START = date(2026, 5, 1)
0016 TTL = 3600
0017 INCIDENT_TASK = 40856
0018 MIN_HOURS = 50
0019 # An Event Service queue's jobs from before its move to production were its
0020 # commissioning trials, whatever their source label (Torre, 2026-10-26), and
0021 # stay out of the production sample. SysConfig
0022 # ``compute_efficiency.commissioning_until`` {queue: ISO time}.
0023 DEFAULT_COMMISSIONING_UNTIL = {
0024     'BNL_ePIC_GOOGLE_es': '2026-10-25T21:36:37+00:00',
0025     'NERSC_Perlmutter_epic_es': '2026-10-26T12:20:35+00:00',
0026 }
0027 # Trials after a queue's move to production stay out of the production
0028 # sample too (Torre Wenaus, 2026-10-29: "MARK THEM AS TRIALS"): on Perlmutter,
0029 # 28 October's return to commissioning, the gun tasks of the serialized-handoff
0030 # defect and the first tasks of the new EVGEN Event Service workflow, none of
0031 # them production runs. SysConfig ``compute_efficiency.commissioning_tasks``
0032 # [JEDI task id].
0033 DEFAULT_COMMISSIONING_TASKS = [41463, 41469, 41479, 41480]
0034 # Jobs whose loss came from a commissioning-grade defect of ours stay out of
0035 # the production sample (Torre Wenaus, 2026-10-29: "DO NOT ACCOUNT
0036 # INEFFICIENCIES ARISING TO PROD"): the four Google Event Service jobs the
0037 # broken close-out step could not close on 28-29 October. SysConfig
0038 # ``compute_efficiency.commissioning_jobs`` [PandaID].
0039 DEFAULT_COMMISSIONING_JOBS = [6341816, 6359213, 6359214, 6359215]
0040 DASHBOARD_SITES = (('osg', 'OSG', '#b48ee8'),
0041                    ('perlmutter', 'Perlmutter', '#e3b954'),
0042                    ('grex', 'GREX', '#7fce95'),
0043                    ('google', 'Google', '#6fb7f2'))
0044 # A site drawn only on some of the dashboard's plots: Google's few cores make
0045 # flat lines on the core plots, while its efficiency is meaningful (Torre,
0046 # 2026-10-27). A site absent here is drawn on all three.
0047 DASHBOARD_SITE_METRICS = {'google': ('efficiency',)}
0048 
0049 
0050 def commissioning_until():
0051     """{queue: naive UTC time} from SysConfig; malformed entries are skipped."""
0052     from .models import SysConfig
0053     value = SysConfig.get_setting('compute_efficiency.commissioning_until',
0054                                   DEFAULT_COMMISSIONING_UNTIL)
0055     out = {}
0056     for queue, stamp in (value.items() if isinstance(value, dict) else ()):
0057         try:
0058             at = datetime.fromisoformat(str(stamp))
0059         except ValueError:
0060             continue
0061         if re.fullmatch(r'[A-Za-z0-9_.-]+', str(queue)):
0062             out[queue] = (at.astimezone(timezone.utc).replace(tzinfo=None)
0063                           if at.tzinfo else at)
0064     return out
0065 
0066 
0067 def commissioning_tasks():
0068     """[JEDI task id] from SysConfig; entries that are not ids are skipped."""
0069     from .models import SysConfig
0070     value = SysConfig.get_setting('compute_efficiency.commissioning_tasks',
0071                                   DEFAULT_COMMISSIONING_TASKS)
0072     return [int(t) for t in (value if isinstance(value, list) else ())
0073             if str(t).isdigit()]
0074 
0075 
0076 # A PCS trial's PanDA task name carries its trial suffix, ``….trial`` or
0077 # ``….trial<N>``, before any physical suffix (swf-epicprod pcs/trials.py).
0078 TRIAL_NAME_PATTERN = r'\.trial[0-9]*(\.|$)'
0079 
0080 
0081 def trial_sql():
0082     """SQL true for a job or task of a trial: its JEDI task on the trial
0083     list, or a PCS trial by its name. Trials never count in a production
0084     metric (Torre Wenaus, 2026-10-29), and a production task relabelled a
0085     trial (``relabel_trial``) leaves every one through this condition."""
0086     named = (f'COALESCE(jeditaskid, 0) IN (SELECT jeditaskid FROM "{PANDA_SCHEMA}".jedi_tasks'
0087              f" WHERE taskname ~ '{TRIAL_NAME_PATTERN}')")
0088     tasks = commissioning_tasks()
0089     # integers by construction, so written in place
0090     listed = f"COALESCE(jeditaskid, 0) IN ({','.join(map(str, tasks))}) OR " if tasks else ''
0091     return f'({listed}{named})'
0092 
0093 
0094 def trial_tasks(task_ids):
0095     """The trials among these JEDI task ids, by the rule of ``trial_sql``."""
0096     ids = sorted({int(t) for t in task_ids if t})
0097     if not ids:
0098         return set()
0099     with connections['panda'].cursor() as cursor:
0100         cursor.execute(f'SELECT jeditaskid FROM "{PANDA_SCHEMA}".jedi_tasks'
0101                        ' WHERE jeditaskid = ANY(%s) AND taskname ~ %s',
0102                        [ids, TRIAL_NAME_PATTERN])
0103         named = {row[0] for row in cursor.fetchall()}
0104     return named | (set(commissioning_tasks()) & set(ids))
0105 
0106 
0107 def relabel_trial(task_ids, *, by, reason, decision_ref, production=False):
0108     """Put PanDA tasks on the trial list, or with ``production`` take them
0109     off, recording who, when and why in the action stream. A production
0110     task found to be commissioning after the fact (a defect showing that
0111     validation was incomplete) is relabelled this way; the retained
0112     histories are then rebuilt over its days (swf-epicprod
0113     scripts/trial-relabel.py). Returns (list before, list after, record)."""
0114     from django.db import transaction
0115     from .epicprod_logging import log_epicprod_action
0116     from .models import SysConfig
0117     if not str(reason or '').strip() or not str(decision_ref or '').strip():
0118         raise ValueError('a reason and a decision reference are required')
0119     ids = sorted({int(t) for t in task_ids})
0120     key = 'compute_efficiency.commissioning_tasks'
0121     with transaction.atomic():
0122         SysConfig.objects.select_for_update().get(pk=1)
0123         before = commissioning_tasks()
0124         after = (sorted(set(before) - set(ids)) if production
0125                  else sorted(set(before) | set(ids)))
0126         if after == before:
0127             return before, after, None
0128         SysConfig.update_config({key: after}, username=by)
0129         record = log_epicprod_action(
0130             'trial-relabel', 'sysconfig_edit', username=by,
0131             sublevel='high', live_default=True,
0132             subject_type='panda_task', subject_key=','.join(map(str, ids)),
0133             keys=[key], previous={key: before}, updated={key: after},
0134             reason=reason, decision_ref=decision_ref,
0135             summary=(f"PanDA task {', '.join(map(str, ids))} relabelled "
0136                      f"{'production' if production else 'trial'}: {reason}"))
0137         if record is None:
0138             raise RuntimeError('trial relabel audit failed; change rolled back')
0139     return before, after, record
0140 
0141 
0142 def commissioning_jobs():
0143     """[PandaID] from SysConfig; entries that are not ids are skipped."""
0144     from .models import SysConfig
0145     value = SysConfig.get_setting('compute_efficiency.commissioning_jobs',
0146                                   DEFAULT_COMMISSIONING_JOBS)
0147     return [int(j) for j in (value if isinstance(value, list) else ())
0148             if str(j).isdigit()]
0149 
0150 
0151 def query_days(start, end, production=False, finished_only=False):
0152     """One grouped database read; no per-job records leave the database."""
0153     pools = osg_pool_queues()
0154     key = execute_site_key_sql()
0155     fields = f'''computingsite, destinationsite AS dest_site,
0156         regexp_replace(COALESCE(modificationhost, ''), '^.*@', '') AS host,
0157         jobstatus, jeditaskid, endtime,
0158         CASE WHEN starttime IS NOT NULL AND endtime >= starttime THEN
0159           EXTRACT(EPOCH FROM (endtime-starttime)) *
0160           GREATEST(1, COALESCE(NULLIF(actualcorecount,0), NULLIF(corecount,0),1)) / 3600.0
0161         END AS hours,
0162         pandaid, piloterrorcode, starttime,
0163         COALESCE(eventservice, 0) IN ({','.join(map(str, ES_CONSUMERS))}) AS es'''
0164     where = '''endtime >= %s AND endtime < %s
0165         AND jobstatus IN ('finished','failed','closed','cancelled')'''
0166     if production:
0167         # Production used the test source label before the managed-label
0168         # transition. Keep that history without admitting canary probes.
0169         where += """ AND workinggroup='EIC'
0170             AND (prodsourcelabel='managed' OR processingtype='epicproduction')
0171             AND COALESCE(processingtype,'') <> 'canary'"""
0172         # Validated names and parsed times, so they are written in place.
0173         for queue, at in commissioning_until().items():
0174             where += (f" AND NOT (computingsite = '{queue}'"
0175                       f" AND creationtime < '{at.isoformat(sep=' ')}')")
0176         where += f' AND NOT {trial_sql()}'
0177         jobs = commissioning_jobs()
0178         if jobs:
0179             # integers by construction, so written in place
0180             where += f" AND pandaid NOT IN ({','.join(map(str, jobs))})"
0181     if finished_only:
0182         where += " AND jobstatus='finished'"
0183     # PanDA stores UTC timestamps without a timezone; datetime bounds retain
0184     # their time of day for the dashboard's exact trailing 24-hour window.
0185     def utc_bound(value):
0186         if isinstance(value, datetime):
0187             return value.astimezone(timezone.utc).replace(tzinfo=None) if value.tzinfo else value
0188         return datetime.combine(value, datetime.min.time())
0189 
0190     bounds = [utc_bound(start), utc_bound(end)]
0191     sql = f'''WITH jobs AS (
0192         SELECT {fields} FROM "{PANDA_SCHEMA}".jobsarchived4 WHERE {where}
0193           AND modificationtime >= %s
0194         UNION ALL
0195         SELECT {fields} FROM "{PANDA_SCHEMA}".jobsactive4 WHERE {where}
0196       ), located AS (
0197         SELECT *, (computingsite = ANY(%s) OR computingsite LIKE 'BNL_OSG%%') AS pool
0198         FROM jobs
0199       )
0200       SELECT endtime::date, computingsite, pool,
0201         CASE WHEN pool THEN {key} ELSE '' END AS execution,
0202         COALESCE(SUM(hours) FILTER (WHERE jobstatus='finished' AND NOT es),0),
0203         COALESCE(SUM(hours),0), COUNT(*), COUNT(*) FILTER (WHERE hours IS NULL),
0204         COALESCE(SUM(hours) FILTER (WHERE jobstatus='finished' AND jeditaskid={INCIDENT_TASK}),0),
0205         COALESCE(SUM(hours) FILTER (WHERE jeditaskid={INCIDENT_TASK}),0),
0206         COUNT(*) FILTER (WHERE jeditaskid={INCIDENT_TASK}),
0207         COUNT(*) FILTER (WHERE jeditaskid={INCIDENT_TASK} AND hours IS NULL),
0208         COUNT(*) FILTER (WHERE es AND hours IS NOT NULL),
0209         ARRAY_AGG(ARRAY[pandaid::text, jobstatus, COALESCE(piloterrorcode, 0)::text,
0210                         starttime::text, endtime::text, hours::text])
0211           FILTER (WHERE es AND hours IS NOT NULL)
0212       FROM located GROUP BY 1,2,3,4 ORDER BY 1,2,4'''
0213     with transaction.atomic(using='panda'):
0214         with connections['panda'].cursor() as cursor:
0215             cursor.execute("SET LOCAL statement_timeout = '180s'")
0216             cursor.execute(sql, bounds + [bounds[0]] + bounds + [pools])
0217             rows = cursor.fetchall()
0218     out = []
0219     for row in rows:
0220         values = [float(v or 0) for v in row[4:13]]
0221         # An Event Service job's productive core-hours are its allocation
0222         # times the share its slots spent processing events whose output
0223         # was saved, whatever PanDA's status says (docs/COMPUTE_EFFICIENCY.md,
0224         # Event Service jobs). Column 12 counts the Event Service jobs with
0225         # timings, column 13 those whose record does not measure the share;
0226         # for those PanDA's status stands, the only evidence there is.
0227         unmeasured = 0
0228         for pandaid, status, pilot_error, start, end, hours in row[13] or []:
0229             fraction = es_saved_fraction(int(pandaid), status, int(pilot_error), start, end)
0230             if fraction is None:
0231                 unmeasured += 1
0232                 fraction = 1.0 if status == 'finished' else 0.0
0233             values[0] += float(hours) * fraction
0234         values.append(float(unmeasured))
0235         out.append([str(row[0]), row[1] or 'Unknown queue', bool(row[2]), row[3], *values])
0236     return out
0237 
0238 
0239 # The Event Service job flavors that process event ranges (consumers, not
0240 # merges; panda/constants.py ES_JOB_FLAVORS).
0241 ES_CONSUMERS = (1, 3, 4, 5, 6)
0242 _es_fractions = {}
0243 
0244 
0245 def es_saved_fraction(pandaid, status, pilot_error, start, end):
0246     """The share of an ended Event Service job's slot time spent processing
0247     events whose output was saved (the job page's measured saved
0248     processing, panda/queries.py es_slot_timeline), from the harness's own
0249     record; None when the job has no record that measures it. A measured
0250     value never changes, so it is kept for the process's life."""
0251     if pandaid in _es_fractions:
0252         return _es_fractions[pandaid]
0253     from .panda.queries import es_harness_report, es_slot_timeline
0254     job = {'pandaid': pandaid, 'jobstatus': status, 'piloterrorcode': pilot_error,
0255            'starttime': start, 'endtime': end}
0256     report = es_harness_report(connections['panda'], pandaid, job=job)
0257     timeline = es_slot_timeline(job, report['raw']) if report else None
0258     fraction = (timeline or {}).get('saved_processing_fraction')
0259     if fraction is not None:
0260         fraction = min(1.0, max(0.0, float(fraction)))
0261         _es_fractions[pandaid] = fraction
0262     return fraction
0263 
0264 
0265 def merge_days(previous, recent, reread_from):
0266     """Replace entire recent days, including rows that disappeared on correction."""
0267     return [r for r in previous if r[0] < str(reread_from)] + recent
0268 
0269 
0270 def build(full=False):
0271     now = datetime.now(timezone.utc)
0272     row = CachedProduct.objects.filter(key=PRODUCT_KEY).first()
0273     previous = (row.value or {}).get('daily', []) if row else []
0274     start = START if full or not previous else max(START, now.date() - timedelta(days=7))
0275     recent = query_days(start, now.date() + timedelta(days=1), production=True)
0276     if full and previous:
0277         if not recent:
0278             raise RuntimeError('Full efficiency reconciliation returned no source history')
0279         # Retain periods that have aged out of the PanDA archive.
0280         start = max(start, date.fromisoformat(min(r[0] for r in recent)))
0281     daily = merge_days(previous, recent, start)
0282     return {'daily': daily, 'start': str(START), 'through': now.isoformat(),
0283             'reread_from': str(start), 'schema': 1}
0284 
0285 
0286 def product(refresh=False, full=False):
0287     return get_product(PRODUCT_KEY, lambda: build(full), TTL,
0288                        refresh=refresh or full, async_first_fill=True)
0289 
0290 
0291 def _hours_summary(rows):
0292     finished, ended, jobs, missing = (sum(row[i] for row in rows) for i in range(4, 8))
0293     # Rows built before the Event Service measure carry no columns 12-13.
0294     es_jobs, es_unmeasured = (int(sum(row[i] for row in rows if len(row) > i)) for i in (12, 13))
0295     return {'finished': finished, 'ended': ended, 'jobs': int(jobs), 'missing': int(missing),
0296             'es_jobs': es_jobs, 'es_unmeasured': es_unmeasured,
0297             'efficiency': 100 * finished / ended if ended >= MIN_HOURS else None}
0298 
0299 
0300 def _delivery_summary(rows, days):
0301     summary = _hours_summary(rows)
0302     measured = days > 0 and summary['jobs'] > summary['missing']
0303     return {**summary, 'days': days,
0304             'core_hours_per_day': summary['ended'] / days if measured else None,
0305             'productive_cores': summary['finished'] / (days * 24) if measured else None}
0306 
0307 
0308 def _dashboard_site(row):
0309     if row[2]:
0310         return 'osg'
0311     if row[1].startswith('NERSC_Perlmutter_'):
0312         return 'perlmutter'
0313     if row[1] == 'UM_GREX_PanDA_1':
0314         return 'grex'
0315     if row[1].startswith('BNL_ePIC_GOOGLE'):
0316         return 'google'
0317     return None
0318 
0319 
0320 def build_current():
0321     end = datetime.now(timezone.utc)
0322     start = end - timedelta(hours=24)
0323     rows = query_days(start, end, production=True)
0324     sites = {sid: _delivery_summary([row for row in rows if _dashboard_site(row) == sid], 1)
0325              for sid, _, _ in DASHBOARD_SITES}
0326     return {**_delivery_summary(rows, 1), 'sites': sites,
0327             'start': start.isoformat(), 'end': end.isoformat()}
0328 
0329 
0330 def current_product():
0331     return get_product(CURRENT_KEY, build_current, 60, async_first_fill=True)
0332 
0333 
0334 def dashboard_data(value, current):
0335     """Global averages and three site histories over the same ended-job sample."""
0336     now = datetime.now(timezone.utc)
0337     today = now.date()
0338     through = datetime.fromisoformat(value['through']) if value.get('through') else now
0339     through = min(through.astimezone(timezone.utc), now)
0340     grouped = defaultdict(list)
0341     for row in value.get('daily', []):
0342         day = date.fromisoformat(row[0])
0343         if START <= day <= today:
0344             grouped[str(day.replace(day=1))].append(row)
0345     months = []
0346     sites = [{'id': sid, 'label': label, 'color': color, 'months': [],
0347               'metrics': list(DASHBOARD_SITE_METRICS.get(sid, ()))}
0348              for sid, label, color in DASHBOARD_SITES]
0349     month = START
0350     while month <= today:
0351         following = date(month.year + (month.month == 12), month.month % 12 + 1, 1)
0352         begin = datetime.combine(month, datetime.min.time(), tzinfo=timezone.utc)
0353         end = min(datetime.combine(following, datetime.min.time(), tzinfo=timezone.utc), through)
0354         days = max(0, (end - begin).total_seconds() / 86400)
0355         period = {'period': str(month), 'label': month.strftime('%b'),
0356                   'partial': month.year == today.year and month.month == today.month}
0357         rows = grouped[str(month)]
0358         months.append({**period, **_delivery_summary(rows, days)})
0359         for site in sites:
0360             site['months'].append({**period, **_delivery_summary(
0361                 [row for row in rows if _dashboard_site(row) == site['id']], days)})
0362         month = following
0363     return {'current': current, 'months': months, 'sites': sites, 'minimum_hours': MIN_HOURS,
0364             'history_through': value.get('through')}
0365 
0366 
0367 def _queue_labels():
0368     from swf_epicprod.queues import entries
0369     from .comp_snapshot import DEFAULT_SITES
0370     from .models import SysConfig
0371     groups = SysConfig.get_setting('comp.sites', DEFAULT_SITES)
0372     names = {g['id']: short_name(g.get('name') or g['id']) for g in groups}
0373     return {q['queue']: names.get(q.get('site')) for q in entries()}
0374 
0375 
0376 def display_data(value, start, end, bucket='month', exclude=False):
0377     """Sum hours into periods, then form ratios; retain unknown timing counts."""
0378     labels = _queue_labels()
0379     totals = defaultdict(lambda: [0.0, 0.0, 0, 0])
0380     sites = {}
0381     for row in value.get('daily', []):
0382         day = date.fromisoformat(row[0])
0383         if not start <= day <= end:
0384             continue
0385         queue, pool, execution = row[1:4]
0386         # These historical test/GPU/ES queues used the same physical system.
0387         family = 'NERSC_Perlmutter_epic' if queue.startswith('NERSC_Perlmutter_') else queue
0388         name = short_name(execute_site_label(execution)) if pool else (
0389             labels.get(family) or short_name(queue))
0390         sid = ('osg:' if pool else 'queue:') + str(name)
0391         label = 'OSG · ' + str(name) if pool else str(name)
0392         period = (day.replace(day=1) if bucket == 'month' else day - timedelta(days=day.weekday()))
0393         sites[sid] = label
0394         finished, ended, jobs, missing = row[4:8]
0395         if exclude:
0396             finished -= row[8]; ended -= row[9]; jobs -= row[10]; missing -= row[11]
0397         target = totals[(sid, str(period))]
0398         for i, n in enumerate((finished, ended, jobs, missing)):
0399             target[i] += n
0400     cells = []
0401     for (sid, period), (finished, ended, jobs, missing) in totals.items():
0402         cells.append({'site': sid, 'period': period, 'finished': finished, 'ended': ended,
0403                       'jobs': int(jobs), 'missing': int(missing),
0404                       'efficiency': 100 * finished / ended if ended >= MIN_HOURS else None})
0405     return {'sites': [{'id': sid, 'label': label} for sid, label in sites.items()],
0406             'cells': cells, 'start': str(start), 'end': str(end), 'bucket': bucket,
0407             'exclude_task': INCIDENT_TASK if exclude else None, 'minimum_hours': MIN_HOURS,
0408             'through': value.get('through')}