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
0020
0021
0022
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
0028
0029
0030
0031
0032
0033 DEFAULT_COMMISSIONING_TASKS = [41463, 41469, 41479, 41480]
0034
0035
0036
0037
0038
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
0045
0046
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
0077
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
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
0168
0169 where += """ AND workinggroup='EIC'
0170 AND (prodsourcelabel='managed' OR processingtype='epicproduction')
0171 AND COALESCE(processingtype,'') <> 'canary'"""
0172
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
0180 where += f" AND pandaid NOT IN ({','.join(map(str, jobs))})"
0181 if finished_only:
0182 where += " AND jobstatus='finished'"
0183
0184
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
0222
0223
0224
0225
0226
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
0240
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
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
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
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')}