File indexing completed on 2026-11-09 10:47:33
0001 """The comp snapshot: the computing centre room's live record
0002 (docs/COMP_SNAPSHOT.md).
0003
0004 One builder over the PanDA and swf databases, served as the cached
0005 product ``comp_snapshot:v1`` (docs/CACHED_PRODUCTS.md) by REST at
0006 ``panda/comp/snapshot.json`` and by the MCP tool ``swf_comp_snapshot``.
0007 No remote call and no LLM: the build reads tables only. A part that
0008 cannot be read is listed in ``errors`` and left out, never shown as zero.
0009 """
0010 import json
0011 import logging
0012 import re
0013 import time
0014 from datetime import datetime, timedelta, timezone as dt_timezone
0015 from zoneinfo import ZoneInfo
0016 from urllib.parse import quote, urlencode
0017
0018 from django.db import connections
0019 from django.utils import timezone
0020
0021 from monitor_app.short_names import short_name, short_text
0022 from monitor_app import error_corrections
0023 from monitor_app.panda.constants import ERROR_COMPONENTS
0024 from monitor_app.panda.queries import INCOMPLETE_REGISTRATION_OUTCOMES
0025 from monitor_app.panda.error_labels import category_label
0026 from monitor_app.snapper_errors import _classify_sql
0027
0028 logger = logging.getLogger(__name__)
0029
0030 PRODUCT_KEY = 'comp_snapshot:v1'
0031 TTL_SECONDS = 60
0032 SCHEMA = 'comp-snapshot/1'
0033 PUBLIC = 'https://epic-devcloud.org/prod'
0034
0035
0036
0037
0038
0039
0040 DEFAULT_SITES = [
0041 {'id': 'osg', 'name': 'OSG', 'execute_sites': True},
0042 {'id': 'grex', 'name': 'GREX'},
0043 {'id': 'compute-canada', 'name': 'Canada'},
0044 {'id': 'nersc', 'name': 'Perlmutter'},
0045 {'id': 'google', 'name': 'Google'},
0046 {'id': 'jlab', 'name': 'JLab'},
0047 ]
0048 INACTIVE_SITE_LABELS = {
0049 'nersc': 'No allocation',
0050 'compute-canada': 'Authentication block',
0051 'jlab': 'Cybersecurity block',
0052 }
0053
0054
0055 LIVE_SITE_LABELS = {}
0056
0057
0058 from swf_epicprod.regulation import PILOTS_PER_WORKER
0059 from swf_epicprod import perlmutter_production
0060 OSG_SUBMIT_HOST = 'osgsub01'
0061
0062
0063
0064 from monitor_app.host_reports import HARVESTER_HOSTS as HARVESTER_REPORTS
0065
0066 REPORT_STALE_SECONDS = 900
0067
0068
0069
0070 DEMAND_MAX_AGE_SECONDS = {'osgsub01-harvester': 90, 'perlmutter-harvester': None}
0071 LEVELS = ('grey', 'green', 'amber', 'red')
0072
0073 LOG_STAGEOUT_CODES = (1137, 1151, 1152)
0074
0075
0076
0077
0078 REGISTRATION_OWED = INCOMPLETE_REGISTRATION_OUTCOMES
0079
0080
0081
0082 OUTPUT_RSE = 'BNL-XRD'
0083
0084
0085 VERDICT_LEVELS = {'ok': 'green', 'attention': 'amber', 'alarm': 'red'}
0086
0087
0088
0089 LIVE_LEVELS = ('WARNING', 'ERROR', 'CRITICAL')
0090
0091
0092 TREND_HOURS = 12
0093 TREND_BIN_MINUTES = 15
0094
0095
0096 TREND_STALE_MINUTES = 15
0097
0098
0099 JLAB_FLOW_KEY = 'jlab_flow:v1'
0100 JLAB_FLOW_REREAD_BINS = 2
0101 JLAB_FLOW_BACKFILL_BINS = 4
0102
0103
0104 LIVE_WINDOW_H = 12
0105
0106
0107 LIVE_MOVE_HOURS = 3
0108
0109
0110 def _worst(levels):
0111 levels = [lv for lv in levels if lv in LEVELS]
0112 if not levels:
0113 return 'grey'
0114 real = [lv for lv in levels if lv != 'grey']
0115 return max(real, key=LEVELS.index) if real else 'grey'
0116
0117
0118 def _health(parts):
0119 return {'level': _worst(p['level'] for p in parts.values()), 'parts': parts}
0120
0121
0122 def _part(level, value=None, note=''):
0123 return {'level': level, 'value': value, 'note': note}
0124
0125
0126 def _activity_part(finished, failed):
0127 ended = finished + failed
0128 if ended == 0:
0129 return _part('grey', 0, 'no jobs ended in the last hour')
0130 if finished == 0 and failed >= 10:
0131 level = 'red'
0132 elif failed * 4 > ended:
0133 level = 'amber'
0134 else:
0135 level = 'green'
0136 return _part(level, round(failed / ended, 3),
0137 f'{finished} finished, {failed} failed in the last hour')
0138
0139
0140 def _fixed_limit(config):
0141 """Whether harvester applies the queue's nQueueLimitWorker unchanged:
0142 a launch-limits row whose ratio is unset or zero."""
0143 ratio = (config or {}).get('n_queue_limit_worker_ratio')
0144 try:
0145 return ratio is None or int(ratio) == 0
0146 except (ValueError, TypeError):
0147 return False
0148
0149
0150 def effective_waiting_limit(config, applied, now):
0151 """Use the fixed DB limit or the last applied value carried by the reporter.
0152
0153 The limit is fixed when no ratio is set or the ratio is zero: harvester
0154 then applies nQueueLimitWorker as it stands (its db_proxy
0155 get_worker_limits). The adjuster skips its limit log when the waiting
0156 queue is already full. Its timestamp records an event, not a heartbeat:
0157 freshness belongs to the enclosing report, checked separately by
0158 ``_pilot_supply_part``.
0159 """
0160 config = config or {}
0161 try:
0162 if _fixed_limit(config):
0163 return int(config['n_queue_limit_worker'])
0164 except (KeyError, ValueError, TypeError):
0165 pass
0166 try:
0167 at = datetime.fromisoformat(applied['at'].replace('Z', '+00:00'))
0168 limit = int(applied['queue_limit'])
0169 if at <= now and limit >= 0:
0170 return limit
0171 except (KeyError, ValueError, TypeError):
0172 pass
0173 return None
0174
0175
0176 def _pilot_display(pilots):
0177 """Resolve the waiting count once while preserving both raw readings."""
0178 if not isinstance(pilots, dict):
0179 return pilots
0180 schedd = pilots.get('waiting_schedd')
0181 waiting = schedd if schedd is not None else pilots.get('waiting')
0182 return dict(pilots, waiting_current=waiting,
0183 waiting_source=('schedd' if schedd is not None else
0184 'harvester' if waiting is not None else None))
0185
0186
0187 def _node_ceilings():
0188 """{queue: the most nodes it may hold}, SysConfig ``comp.node_ceilings``:
0189 Perlmutter's Event Service queue at five (Torre Wenaus, 2026-10-28)."""
0190 from monitor_app.models import SysConfig
0191 value = SysConfig.get_setting('comp.node_ceilings', {'NERSC_Perlmutter_epic_es': 5})
0192 return value if isinstance(value, dict) else {}
0193
0194
0195 def _worker_limit(cfg, queue):
0196 """The operator's active worker limit control on ``queue``
0197 (``pilot.queue.<queue>.worker_limit``), or None."""
0198 control = cfg.get(f'pilot.queue.{queue}.worker_limit') or {}
0199 if (isinstance(control, dict) and control.get('active')
0200 and isinstance(control.get('limit'), int) and control['limit'] > 0):
0201 return control
0202 return None
0203
0204
0205 def _pilot_supply_part(p, queued, started_1h, submitted_1h, workers=None, operator_limit=None):
0206 """Whether pilots keep reaching the queue (docs/COMP_SNAPSHOT.md, Pilot
0207 supply). Harvester holds new pilots back against its own count of
0208 those waiting, so a count the schedd no longer bears out stops supply
0209 while the queue reads full. ``workers`` (running, submitted) is PanDA's
0210 own worker table, read for a queue no harvester report names. A queue
0211 whose running slots fill the operator's worker limit is due no pilot:
0212 the limit is its own line (Torre Wenaus, 2026-10-28, GREX full at its
0213 300-core limit read red here)."""
0214 if not p:
0215 running, submitted = workers or (0, 0)
0216 if running or submitted:
0217 return _part('green', submitted, f'no harvester report; PanDA records {running} worker(s) '
0218 f'running and {submitted} submitted')
0219 return _part('amber' if queued else 'grey', None, 'no harvester report names the queue, '
0220 'and PanDA records no worker running or submitted')
0221 age = p.get('report_age_seconds')
0222 stale_seconds = p.get('report_stale_seconds', REPORT_STALE_SECONDS)
0223 if age is None or age > stale_seconds:
0224 return _part('amber' if queued else 'grey', None, f"harvester report {int((age or 0) // 60)} min old")
0225 if p.get('error'):
0226 return _part('amber' if queued else 'grey', None, p['error'])
0227 demand_age = p.get('demand_age_seconds')
0228 if queued and p.get('demand_max_age_seconds') is not None and (
0229 demand_age is None or demand_age > p['demand_max_age_seconds']):
0230 return _part('amber', demand_age, 'job-demand cache missing or stale')
0231 waiting, limit = p['waiting'], p.get('limit')
0232 if operator_limit and (p.get('running') or 0) >= operator_limit:
0233 return _part('green', p['running'], f"{p['running']} of the operator's limit of "
0234 f"{operator_limit} slots running: full, no pilot due")
0235 try:
0236 ceiling = int(p.get('max_workers'))
0237 except (TypeError, ValueError):
0238 ceiling = None
0239 if ceiling and (p.get('running') or 0) >= ceiling:
0240
0241
0242 return _part('green', p['running'], f"{p['running']} of harvester's {ceiling} workers "
0243 f"running: full, no pilot due")
0244 if queued and not waiting and not started_1h and not submitted_1h:
0245 return _part('red', 0, f"{queued} jobs queued, no pilot waiting, submitted "
0246 f"or started in the last hour")
0247 if limit is None:
0248 return _part('amber' if queued else 'grey', waiting, f"harvester counts {waiting} waiting; the limit "
0249 f"it applies is not reported")
0250 label = 'last applied limit' if p.get('limit_source') == 'adjuster' else 'limit'
0251 seen = f"harvester counts {waiting} waiting (its {label} {limit})"
0252 idle = p.get('waiting_schedd')
0253 if idle is not None and (p.get('schedd_age_seconds') or 0) <= stale_seconds:
0254 seen += f", the schedd holds {idle}"
0255 if waiting >= 2 * limit and idle < limit:
0256 return _part('red' if queued else 'amber', waiting - idle,
0257 f"{seen}: harvester counts pilots the schedd no longer has "
0258 f"and submits none")
0259
0260
0261 startable = idle + max(limit - waiting, 0)
0262 if startable < limit / 2:
0263 return _part('red' if queued else 'amber', startable,
0264 f"{seen}: harvester will add {max(limit - waiting, 0)}, so the "
0265 f"pool can start {startable}, under half the limit; harvester "
0266 f"has not yet seen its waiting pilots start")
0267 return _part('green', waiting, seen)
0268
0269
0270 def _aware(t):
0271 """A PanDA time (naive UTC) made aware."""
0272 return timezone.make_aware(t, dt_timezone.utc) if t is not None and timezone.is_naive(t) else t
0273
0274
0275 def _rows(sql, params=()):
0276 with connections['panda'].cursor() as cursor:
0277 cursor.execute(sql, params)
0278 return cursor.fetchall()
0279
0280
0281
0282 TASK_ENDED = ('done', 'finished', 'failed', 'broken', 'aborted', 'exhausted')
0283
0284
0285
0286 PANDA_SITE_CHARS = 36
0287
0288
0289 def _uncut_site(name):
0290 """An execute site's full name: one PanDA cut at its 36 characters is
0291 restored from the full names the short-name table knows; any other
0292 name passes unchanged."""
0293 from monitor_app.short_names import SHORT_NAMES
0294 if not name or len(name) != PANDA_SITE_CHARS:
0295 return name
0296 full = [n for n in SHORT_NAMES if len(n) > PANDA_SITE_CHARS and n.startswith(name)]
0297 return full[0] if len(full) == 1 else name
0298
0299
0300 def _task_pcs(tasks):
0301 """Each PanDA task's physics configuration as PCS records it:
0302 ``{jeditaskid: (pc label, one-line physics)}``, the line PCS's summary
0303 (process, beams, Q², generator, sample, background, requesters).
0304 ``tasks`` is a set of (jeditaskid, taskname); a task PCS links nowhere
0305 is found by the composed identity its name carries, and one PCS does
0306 not know is left out. Local reads only."""
0307 try:
0308 from pcs.models import Dataset, PandaTasks
0309 from pcs.name_tokens import logical_name_from_physical_name
0310 from pcs.physics_config import summary
0311 except ImportError:
0312 return {}
0313 related = ('physics_config__physics_tag', 'physics_config__background_tag')
0314 out = {}
0315 for link in (PandaTasks.objects.filter(jedi_task_id__in={t for t, _ in tasks})
0316 .select_related(*(f'prod_task__dataset__{r}' for r in related))):
0317 ds = link.prod_task.dataset if link.prod_task.dataset_id else None
0318 if ds is not None and ds.physics_config_id:
0319 out[link.jedi_task_id] = (ds.physics_config.label, summary(ds.physics_config))
0320 for tid, name in tasks:
0321 if tid in out:
0322 continue
0323 ds = (Dataset.objects.filter(composed_name=logical_name_from_physical_name(name or ''))
0324 .select_related(*related).first())
0325 if ds is not None and ds.physics_config_id:
0326 out[tid] = (ds.physics_config.label, summary(ds.physics_config))
0327 return out
0328
0329
0330 def _task_physics(tasks):
0331 """What each PanDA task simulates, its tags read as physics:
0332 ``{jeditaskid: one-line physics}`` (_task_pcs)."""
0333 return {tid: line for tid, (_pc, line) in _task_pcs(tasks).items()}
0334
0335
0336 def _physics_label(line):
0337 """The physics in a row's width: PCS's summary without its tail
0338 (generator, sample, requesters), the nucleus its beam species already
0339 names, and the units ('DIS Au197 eAu 9x100 GeV Q² 10–100 · beagle ...'
0340 -> 'DIS eAu 9x100 Q² 10–100')."""
0341 import re
0342 head = (line or '').split(' · ', 1)[0].replace('_', ' ').replace(' GeV', '')
0343 return re.sub(r'\b[A-Z][a-z]?\d{1,3}\s(?=e[A-Z])', '', head).strip()
0344
0345
0346 def _plan_progress():
0347 """The campaign plan's delivered and target events per physics
0348 configuration, from the stored completion record
0349 (``prod_hub_campaign_completion``) as it stands: never built here."""
0350 from monitor_app.models import CachedProduct
0351 row = CachedProduct.objects.filter(key='prod_hub_campaign_completion').values('value').first()
0352 out = {}
0353 for entry in ((row or {}).get('value') or []):
0354 for pc in entry.get('pcs') or []:
0355 if pc.get('pc'):
0356 out[pc['pc']] = {'target': pc.get('target'),
0357 'delivered_events': pc.get('delivered_events')}
0358 return out
0359
0360
0361 class _Build:
0362 def __init__(self):
0363 from monitor_app.panda.census import PANDA_SCHEMA
0364 self.S = PANDA_SCHEMA
0365 self.now = timezone.now()
0366 self.hour_ago = self.now - timedelta(hours=1)
0367 self.errors = []
0368
0369 def part(self, name, fn, fallback=None):
0370 try:
0371 return fn()
0372 except Exception as exc:
0373 logger.exception('comp snapshot: %s failed', name)
0374 self.errors.append({'part': name, 'error': f'{type(exc).__name__}: {exc}'})
0375 return fallback
0376
0377
0378
0379 def ended(self, column):
0380 """Production jobs ended in the last hour by ``column``: counts and
0381 core-hours, finished and failed."""
0382 out = {}
0383 sql = f'''
0384 SELECT {column}, jobstatus, COUNT(*),
0385 SUM(GREATEST(COALESCE(corecount, 1), 1)
0386 * EXTRACT(EPOCH FROM (endtime - starttime))) / 3600.0
0387 FROM (
0388 SELECT {column}, jobstatus, corecount, endtime, starttime FROM "{self.S}"."jobsactive4"
0389 WHERE endtime >= %s AND processingtype = 'epicproduction'
0390 AND jobstatus IN ('finished', 'failed')
0391 UNION ALL
0392 SELECT {column}, jobstatus, corecount, endtime, starttime FROM "{self.S}"."jobsarchived4"
0393 WHERE endtime >= %s AND processingtype = 'epicproduction'
0394 AND jobstatus IN ('finished', 'failed')) t
0395 GROUP BY 1, 2'''
0396 for key, status, n, core_h in _rows(sql, (self.hour_ago, self.hour_ago)):
0397 entry = out.setdefault(key or '', {'finished': 0, 'failed': 0,
0398 'finished_core_h': 0.0, 'failed_core_h': 0.0})
0399 entry[status] = int(n)
0400 entry[f'{status}_core_h'] = round(float(core_h or 0), 1)
0401 return out
0402
0403 def top_failures(self):
0404 """The last hour's failures per queue by Snapper error category,
0405 largest first: component x code, the job's first nonzero error
0406 component in ERROR_COMPONENTS order (SNAPPER_ERRORS.md), labelled
0407 from the code catalogs and read through the error-correction root
0408 (ERROR_ATTRIBUTION.md), so a label a rule marks unreliable shows
0409 its corrected reading. A failed job with no error code in any of
0410 the seven components reads as such; nothing reads "unattributed"
0411 (Torre, 2026-10-23: the OSG failures of 41039 and 41040 were DDM
0412 200, the log-name collision, and the room called them that)."""
0413 comp_case, code_case, diag_case = _classify_sql()
0414 fields = ', '.join(f'"{c["code"]}", "{c["diag"]}"' for c in ERROR_COMPONENTS)
0415 sql = f'''
0416 SELECT computingsite, component, code, MIN(diag), transexitcode, COUNT(*) FROM (
0417 SELECT computingsite, transexitcode,
0418 CASE {comp_case} ELSE 'none' END AS component,
0419 CASE {code_case} ELSE 0 END AS code,
0420 CASE {diag_case} ELSE '' END AS diag
0421 FROM (
0422 SELECT computingsite, transexitcode, {fields} FROM "{self.S}"."jobsactive4"
0423 WHERE endtime >= %s AND processingtype = 'epicproduction' AND jobstatus = 'failed'
0424 UNION ALL
0425 SELECT computingsite, transexitcode, {fields} FROM "{self.S}"."jobsarchived4"
0426 WHERE endtime >= %s AND processingtype = 'epicproduction' AND jobstatus = 'failed') j) t
0427 GROUP BY 1, 2, 3, 5'''
0428 cats = {}
0429 for site, component, code, diag, exitcode, n in _rows(sql, (self.hour_ago, self.hour_ago)):
0430 cat = cats.setdefault((site, component, int(code or 0)),
0431 {'n': 0, 'diag': diag or '', 'exits': {}})
0432 cat['n'] += int(n)
0433 if exitcode not in (None, ''):
0434 cat['exits'][str(exitcode)] = cat['exits'].get(str(exitcode), 0) + int(n)
0435 out = {}
0436 for (site, component, code), cat in cats.items():
0437 if component == 'none':
0438 label = 'failed with no error code in any component'
0439 else:
0440 label = category_label(component, code)
0441 try:
0442 rule = error_corrections.match(component, code, cat['diag'], sites=[site])
0443 if rule is not None:
0444 label = error_corrections.correction(rule, cat['exits'])['label']
0445 except Exception as e:
0446 logger.error('error-correction read failed for %s %s: %s', component, code, e)
0447 out.setdefault(site, []).append((label, cat['n']))
0448 for site in out:
0449 out[site].sort(key=lambda t: -t[1])
0450 return out
0451
0452 def production_alarms(self):
0453 """The active alarm events that concern production: a task's
0454 failure-rate alarm only when the task is a production task
0455 (processingtype epicproduction). The failure-rate alarms watch
0456 every EIC task, a user's included, and the room reports
0457 production."""
0458 alarms = _active_alarms()
0459 ids = {int(m.group(1)) for m in (TASK_ALARM_RE.match(a['title']) for a in alarms) if m}
0460 types = {}
0461 if ids:
0462 sql = f'''SELECT jeditaskid, processingtype FROM "{self.S}"."jedi_tasks"
0463 WHERE jeditaskid = ANY(%s)'''
0464 types = {int(t): p for t, p in _rows(sql, (list(ids),))}
0465 out = []
0466 for a in alarms:
0467 m = TASK_ALARM_RE.match(a['title'])
0468 if m and types.get(int(m.group(1))) != 'epicproduction':
0469 continue
0470 out.append(a)
0471 return out
0472
0473 def floor_tasks(self, site_name, alarms, site_of):
0474 """The task board (COMP_SNAPSHOT.md, Tasks): every production task
0475 with jobs running, starting or queued at the floor's queues, newest
0476 first; a task with none there is off the board."""
0477 from monitor_app.panda.census import (NOT_STARTED, RUNNING,
0478 TASK_TERMINAL_STATUSES,
0479 ungenerated_by_task)
0480 from pcs.models import Dataset
0481 from pcs.views import physics_description
0482 terminal = list(TASK_TERMINAL_STATUSES)
0483 sql = f'''
0484 SELECT a.jeditaskid, t.taskname, t.status,
0485 COALESCE(t.starttime, t.creationdate),
0486 SUM(CASE WHEN a.jobstatus = ANY(%s) THEN 1 ELSE 0 END), COUNT(*),
0487 MODE() WITHIN GROUP (ORDER BY a.computingsite),
0488 ARRAY_AGG(DISTINCT a.computingsite)
0489 FROM "{self.S}"."jobsactive4" a
0490 JOIN "{self.S}"."jedi_tasks" t ON t.jeditaskid = a.jeditaskid
0491 WHERE a.jobstatus = ANY(%s)
0492 AND t.processingtype = 'epicproduction'
0493 AND (t.status IS NULL OR NOT t.status = ANY(%s))
0494 GROUP BY a.jeditaskid, t.taskname, t.status, t.starttime, t.creationdate'''
0495
0496
0497
0498 rows = _rows(sql, (list(RUNNING), list(NOT_STARTED + RUNNING), terminal))
0499
0500
0501 ungenerated = {tid: (queue, n) for tid, (queue, n) in ungenerated_by_task().items()
0502 if n > 0}
0503 to_generate = {tid: n for tid, (_queue, n) in ungenerated.items()}
0504 waiting = sorted(set(to_generate) - {int(r[0]) for r in rows})
0505 if waiting:
0506 sql = f'''
0507 SELECT jeditaskid, taskname, status, COALESCE(starttime, creationdate),
0508 0, 0, site, NULL
0509 FROM "{self.S}"."jedi_tasks" WHERE jeditaskid = ANY(%s)'''
0510 rows = list(rows) + list(_rows(sql, (waiting,)))
0511 ids = [int(r[0]) for r in rows]
0512 files = {}
0513 if ids:
0514 sql = f'''SELECT jeditaskid, SUM(nfiles), SUM(nfilesfinished), SUM(nfilesfailed)
0515 FROM "{self.S}"."jedi_datasets"
0516 WHERE jeditaskid = ANY(%s) AND masterid IS NULL
0517 AND type IN ('input', 'pseudo_input')
0518 GROUP BY jeditaskid'''
0519 files = {int(t): (int(n or 0), int(f or 0), int(x or 0))
0520 for t, n, f, x in _rows(sql, (ids,))}
0521 alarmed = {int(m.group(1)) for m in (TASK_ALARM_RE.match(a['title']) for a in alarms) if m}
0522 names = {r[1]: re.sub(r'\.try\d+$', '', r[1]) for r in rows}
0523 datasets = {d.composed_name: d for d in
0524 Dataset.objects.filter(composed_name__in=set(names.values()))
0525 .select_related('physics_tag', 'evgen_tag')
0526 .order_by('composed_name', 'block_num').distinct('composed_name')}
0527 items = []
0528 for tid, name, status, at, running, active, queue, queues in sorted(
0529 rows, key=lambda r: r[3], reverse=True):
0530 tid, running, active = int(tid), int(running or 0), int(active or 0)
0531
0532
0533 queues = sorted(set(queues or ())
0534 | ({ungenerated[tid][0]} if tid in ungenerated else set()))
0535 if at is not None and at.tzinfo is None:
0536 at = at.replace(tzinfo=dt_timezone.utc)
0537 n, done, lost = files.get(tid, (0, 0, 0))
0538 d = datasets.get(names[name])
0539 physics = physics_description(d, concise=True) if d else ''
0540 lost_pct = 100.0 * lost / (done + lost) if done + lost else 0.0
0541 local = at.astimezone(ZoneInfo('America/New_York')) if at else None
0542 detail = (' at '.join(x for x in (physics, site_name.get(queue, queue)) if x)
0543 + f". {name}, {status} on {short_name(queue)}"
0544 + (f" since {local:%m-%d %H:%M} ET" if local else '')
0545 + f"; {done:,} of {n:,} files done, {lost:,} lost after retries"
0546 + (f" ({lost_pct:.1f}%), in alarm" if tid in alarmed else '') + '.')
0547 items.append({
0548 'id': tid, 'at': at.isoformat() if at else None,
0549 'level': ('red' if tid in alarmed else 'amber' if lost
0550 else 'green' if running else 'grey'),
0551 'title': str(tid), 'name': name,
0552 'counts': {'ungenerated': to_generate.get(tid, 0),
0553 'queued': active - running, 'running': running,
0554 'finished': done, 'failed': lost},
0555 'detail': detail,
0556 'sites': sorted({site_of[q] for q in queues if q in site_of}),
0557 'queues': queues,
0558 'link': f'{PUBLIC}/panda/tasks/{tid}/'})
0559 return {'link': f'{PUBLIC}/panda/tasks/?processingtype=epicproduction',
0560 'items': items}
0561
0562 def paused_tasks(self, site_name):
0563 """The paused pillar (COMP_SNAPSHOT.md, Paused): every production
0564 task paused in PanDA, wherever it is placed, oldest pause first.
0565 The pause time is the task's last state change."""
0566 sql = f'''SELECT t.jeditaskid, t.taskname, t.site,
0567 COALESCE(t.statechangetime, t.modificationtime),
0568 SUM(d.nfiles), SUM(d.nfilesfinished), SUM(d.nfilesfailed)
0569 FROM "{self.S}"."jedi_tasks" t
0570 LEFT JOIN "{self.S}"."jedi_datasets" d ON d.jeditaskid = t.jeditaskid
0571 AND d.masterid IS NULL AND d.type IN ('input', 'pseudo_input')
0572 WHERE t.status = 'paused' AND t.processingtype = 'epicproduction'
0573 GROUP BY 1, 2, 3, 4 ORDER BY 4, 1'''
0574 from swf_epicprod.quiet_end import quiet_task_ids
0575 quiet = quiet_task_ids()
0576 items, oldest = [], None
0577 for tid, name, queue, since, n, done, lost in _rows(sql, ()):
0578 if int(tid) in quiet:
0579 continue
0580 if since is not None and since.tzinfo is None:
0581 since = since.replace(tzinfo=dt_timezone.utc)
0582 oldest = oldest or since
0583 local = since.astimezone(ZoneInfo('America/New_York')) if since else None
0584 n, done, lost = int(n or 0), int(done or 0), int(lost or 0)
0585 where = site_name.get(queue, queue) or 'no queue'
0586 items.append({
0587 'id': int(tid), 'name': name, 'queue': queue or '',
0588 'since': since.isoformat() if since else None,
0589 'link': f'{PUBLIC}/panda/tasks/{int(tid)}/',
0590 'detail': (f"{int(tid)} paused on {where}"
0591 + (f" ({queue})" if queue and queue != where else '')
0592 + (f" since {local:%m-%d %H:%M} ET" if local else '')
0593 + f"; {done:,} of {n:,} files done, {lost:,} lost. {name}")})
0594
0595 accounts = _pause_accounts([i['id'] for i in items])
0596 for i in items:
0597 i['paused_by'], i['pause_reason'] = accounts.get(i['id'], (None, None))
0598
0599 days = max(7, (self.now - oldest).days + 1) if oldest else 7
0600 listed = ', '.join(f"{i['id']} on {site_name.get(i['queue'], i['queue'])}"
0601 + (f" since {datetime.fromisoformat(i['since']).astimezone(ZoneInfo('America/New_York')):%m-%d %H:%M} ET"
0602 if i['since'] else '')
0603 for i in items)
0604 return {'tasks': len(items),
0605 'link': f'{PUBLIC}/panda/tasks/?status=paused&processingtype=epicproduction&days={days}',
0606 'detail': (f"{len(items)} task{'s' if len(items) != 1 else ''} paused: {listed}"
0607 if items else 'no production task is paused'),
0608 'items': items}
0609
0610 def pc_arrivals(self, site_name, hours=24):
0611 """Science arriving at JLab over the last ``hours`` (24, as the
0612 completed tasks; Torre Wenaus, 2026-10-24), per physics
0613 configuration (COMP_SNAPSHOT.md, Arrivals): the RECO files the
0614 finished production jobs registered and their events, from each
0615 job's payload report, grouped by task and the task's PC. Newest
0616 arrival first. Each PC carries its physics in words and the plan's
0617 delivered and target events, read from the stored completion
0618 record without building it."""
0619 since = (self.now - timedelta(hours=hours)).astimezone(dt_timezone.utc).replace(tzinfo=None)
0620 sql = f'''SELECT j.jeditaskid, t.taskname, t.status, j.computingsite, COUNT(*),
0621 SUM(COALESCE((m.metadata::jsonb -> 'payload' -> 'outputs' -> 'reco' ->> 'events')::bigint, 0)),
0622 MIN(j.endtime), MAX(j.endtime)
0623 FROM "{self.S}"."jobsarchived4" j
0624 JOIN "{self.S}"."jedi_tasks" t ON t.jeditaskid = j.jeditaskid
0625 LEFT JOIN "{self.S}"."metatable" m ON m.pandaid = j.pandaid
0626 WHERE j.endtime >= %s AND j.processingtype = 'epicproduction'
0627 AND j.jobstatus = 'finished' AND j.jobmetrics LIKE %s
0628 GROUP BY 1, 2, 3, 4'''
0629 rows = _rows(sql, (since, '%payloadRegistration=registered%'))
0630 pcs = _task_pcs({(int(r[0]), r[1]) for r in rows})
0631 progress = _plan_progress()
0632 by_pc = {}
0633 for tid, name, status, queue, files, events, first, last in rows:
0634 pc, physics = pcs.get(int(tid), (None, ''))
0635 key = pc or f'task {int(tid)}'
0636 b = by_pc.setdefault(key, {'pc': pc, 'physics': physics, 'files': 0, 'events': 0,
0637 'tasks': set(), 'sites': set(), 'first': None, 'last': None,
0638 'running': False})
0639
0640
0641
0642 b['running'] = b['running'] or status not in TASK_ENDED
0643 b['files'] += int(files or 0)
0644 b['events'] += int(events or 0)
0645 b['tasks'].add(int(tid))
0646 b['sites'].add(site_name.get(queue, queue) or queue or '')
0647 first, last = _aware(first), _aware(last)
0648 b['first'] = min(x for x in (b['first'], first) if x) if (b['first'] or first) else None
0649 b['last'] = max(x for x in (b['last'], last) if x) if (b['last'] or last) else None
0650 et = ZoneInfo('America/New_York')
0651 items = []
0652 for key, b in by_pc.items():
0653 plan = progress.get(b['pc']) or {}
0654 delivered, target = plan.get('delivered_events'), plan.get('target')
0655
0656
0657
0658 met = bool(target) and delivered is not None and delivered >= target
0659 finished = met or not b['running']
0660 sites = ', '.join(sorted(s for s in b['sites'] if s))
0661 items.append({
0662 'pc': b['pc'] or '', 'physics': b['physics'], 'label': _physics_label(b['physics']),
0663 'state': 'finished' if finished else 'running',
0664 'files': b['files'],
0665 'events': b['events'], 'tasks': sorted(b['tasks']), 'sites': sorted(s for s in b['sites'] if s),
0666 'first_at': b['first'].isoformat() if b['first'] else None,
0667 'last_at': b['last'].isoformat() if b['last'] else None,
0668 'delivered_events': delivered, 'target_events': target,
0669 'link': (f"{PUBLIC}/pcs/config/{b['pc']}/" if b['pc']
0670 else f"{PUBLIC}/panda/tasks/{min(b['tasks'])}/"),
0671 'detail': (f"{b['pc'] or key}: {b['physics'] or 'physics not recorded in PCS'}. "
0672 f"{b['files']:,} RECO file{'s' if b['files'] != 1 else ''}, "
0673 f"{b['events']:,} events in {hours} h"
0674 + (f", at {b['last'].astimezone(et):%H:%M} ET"
0675 if b['first'] and b['first'] == b['last'] else
0676 f", {b['first'].astimezone(et):%H:%M} to {b['last'].astimezone(et):%H:%M} ET"
0677 if b['first'] and b['last'] else '')
0678 + f", from task{'s' if len(b['tasks']) != 1 else ''} "
0679 + ', '.join(str(t) for t in sorted(b['tasks']))
0680 + (f" at {sites}" if sites else '') + '.'
0681 + (f" Delivered {delivered:,} of {target:,} events ({100 * delivered / target:.0f}%)."
0682 if delivered is not None and target else '')
0683 + (' Target met.' if met else ' Finished.' if finished else ' Still running.'))})
0684 items.sort(key=lambda i: i['last_at'] or '', reverse=True)
0685 files = sum(i['files'] for i in items)
0686 events = sum(i['events'] for i in items)
0687 return {'pcs': len(items), 'files': files, 'events': events, 'window_hours': hours,
0688 'link': f'{PUBLIC}/snapper/epicprod/campaign/',
0689 'detail': (f"{len(items)} physics configuration{'s' if len(items) != 1 else ''} "
0690 f"received {files:,} RECO files, {events:,} events at JLab in {hours} h"
0691 if items else f'no science arrived at JLab in {hours} h'),
0692 'items': items}
0693
0694 def completed_tasks(self, site_name, hours=24):
0695 """The production tasks that completed in the last ``hours``
0696 (COMP_SNAPSHOT.md, Completed): done (every input file finished) or
0697 finished (some lost), newest first, the completion time the
0698 task's end time."""
0699 since = self.now - timedelta(hours=hours)
0700 sql = f'''SELECT t.jeditaskid, t.taskname, t.site, t.status,
0701 COALESCE(t.endtime, t.statechangetime),
0702 SUM(d.nfiles), SUM(d.nfilesfinished), SUM(d.nfilesfailed)
0703 FROM "{self.S}"."jedi_tasks" t
0704 LEFT JOIN "{self.S}"."jedi_datasets" d ON d.jeditaskid = t.jeditaskid
0705 AND d.masterid IS NULL AND d.type IN ('input', 'pseudo_input')
0706 WHERE t.status IN ('done', 'finished') AND t.processingtype = 'epicproduction'
0707 AND COALESCE(t.endtime, t.statechangetime) >= %s
0708 GROUP BY 1, 2, 3, 4, 5 ORDER BY 5 DESC, 1 DESC'''
0709 items = []
0710 rows = _rows(sql, (since.astimezone(dt_timezone.utc).replace(tzinfo=None),))
0711 physics = _task_physics({(int(r[0]), r[1]) for r in rows})
0712 from swf_epicprod.quiet_end import quiet_task_ids
0713 quiet = quiet_task_ids()
0714 rows = [r for r in rows if int(r[0]) not in quiet]
0715 for tid, name, queue, state, at, n, done, lost in rows:
0716 if at is not None and at.tzinfo is None:
0717 at = at.replace(tzinfo=dt_timezone.utc)
0718 local = at.astimezone(ZoneInfo('America/New_York')) if at else None
0719 n, done, lost = int(n or 0), int(done or 0), int(lost or 0)
0720 where = site_name.get(queue, queue) or 'no queue'
0721 what = physics.get(int(tid), '')
0722 items.append({
0723 'id': int(tid), 'name': name, 'queue': queue or '', 'status': state,
0724 'at': at.isoformat() if at else None, 'physics': what,
0725 'link': f'{PUBLIC}/panda/tasks/{int(tid)}/',
0726 'detail': (f"{int(tid)} {state} on {where}"
0727 + (f" ({queue})" if queue and queue != where else '')
0728 + (f" at {local:%m-%d %H:%M} ET" if local else '')
0729 + f"; {done:,} of {n:,} files done, {lost:,} lost."
0730 + (f" {what}." if what else '') + f" {name}")})
0731 return {'tasks': len(items), 'window_hours': hours,
0732 'link': f'{PUBLIC}/panda/tasks/?processingtype=epicproduction&days=1',
0733 'detail': (f"{len(items)} task{'s' if len(items) != 1 else ''} completed in {hours} h"
0734 if items else f'no production task completed in {hours} h'),
0735 'items': items}
0736
0737 def opened(self):
0738 """Harvester workers per queue in the last hour: started (a slot
0739 opened: the pilot got a slot at the site) and submitted (sent to
0740 the site's batch system or pool, not yet a slot)."""
0741 sql = f'''SELECT computingsite,
0742 SUM(CASE WHEN starttime >= %s THEN 1 ELSE 0 END),
0743 SUM(CASE WHEN submittime >= %s THEN 1 ELSE 0 END)
0744 FROM "{self.S}"."harvester_workers"
0745 WHERE starttime >= %s OR submittime >= %s GROUP BY 1'''
0746 h = self.hour_ago
0747 return {site: (int(s or 0), int(n or 0)) for site, s, n in _rows(sql, (h, h, h, h))}
0748
0749 def workers_now(self):
0750 """Harvester workers per queue now, from PanDA's own table: (running,
0751 submitted). The pilot-supply reading of a queue whose harvester
0752 sends us no report (NERSC's, run by PanDA operations)."""
0753 sql = f'''SELECT computingsite,
0754 SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END),
0755 SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END)
0756 FROM "{self.S}"."harvester_workers"
0757 WHERE status IN ('running', 'submitted') AND submittime >= %s GROUP BY 1'''
0758 return {site: (int(r or 0), int(s or 0))
0759 for site, r, s in _rows(sql, (self.now - timedelta(days=7),))}
0760
0761 def queue_status(self, names):
0762 sql = f'''SELECT panda_queue, data->>'status' FROM "{self.S}"."schedconfig_json"
0763 WHERE panda_queue = ANY(%s)'''
0764 return {q: (st or '') for q, st in _rows(sql, (list(names),))}
0765
0766 def jlab_worker_outcomes(self):
0767 """Commissioning evidence, including failures before a pilot gets a job.
0768
0769 Harvester can stamp starttime when it discovers a finished worker, so
0770 this deliberately reports statuses, not inferred starts or runtimes.
0771 """
0772 rows = _rows(f'''SELECT status, COUNT(*)
0773 FROM "{self.S}"."harvester_workers"
0774 WHERE computingsite = %s AND submittime >= %s
0775 GROUP BY status''',
0776 ('JLAB_PanDA_1', self.now - timedelta(hours=48)))
0777 counts = {status: int(n) for status, n in rows}
0778 latest = _rows(f'''SELECT workerid, status, diagmessage, submittime
0779 FROM "{self.S}"."harvester_workers"
0780 WHERE computingsite = %s ORDER BY submittime DESC LIMIT 1''',
0781 ('JLAB_PanDA_1',))
0782 last = latest[0] if latest else None
0783 detail = ', '.join(f'{n} {status}' for status, n in sorted(counts.items()))
0784 reason = f'commissioning; workers submitted in 48 h: {detail or "none"}'
0785 if last:
0786 reason += f'; latest worker {last[0]}: {last[1]}'
0787 if last[2]:
0788 reason += f' ({str(last[2])[:240]})'
0789 return {'counts_48h': counts, 'latest_worker': last[0] if last else None,
0790 'latest_submitted_at': last[3].isoformat() if last and last[3] else None,
0791 'reason': reason,
0792 'level': 'red' if counts.get('failed') or counts.get('cancelled') else 'grey'}
0793
0794 def front_decisions(self, names):
0795 from monitor_app.models import AppLog
0796 out = {}
0797 since = self.now - timedelta(hours=3)
0798 for q in names:
0799 rec = (AppLog.objects.filter(app_name='epicprod', timestamp__gte=since,
0800 extra_data__action='front_decision',
0801 extra_data__subject_key=q)
0802 .order_by('-timestamp').values_list('extra_data', flat=True).first())
0803 if rec:
0804 out[q] = rec
0805 return out
0806
0807 def osg_pool(self):
0808 from monitor_app.host_reports import latest
0809 found = latest(OSG_SUBMIT_HOST)
0810 if not found:
0811 return None
0812 record = found.get('record') or {}
0813 pool = record.get('pool') or {}
0814 return {'admitted': pool.get('slots_admitted'), 'total': pool.get('slots_total'),
0815 'age_seconds': found.get('age_seconds'),
0816 'fair_share': _fair_share(record.get('negotiators'), found.get('age_seconds'))}
0817
0818 def pilots(self):
0819 """Per queue, the pilot slots waiting at the site and running by
0820 harvester's own count, its waiting limit, and for the OSG queues
0821 the idle pilots the submit schedd holds, from the host reporters'
0822 records (no remote call)."""
0823 from monitor_app.host_reports import latest
0824 out = {}
0825 for key in HARVESTER_REPORTS:
0826 found = latest(key)
0827 if not found:
0828 continue
0829 record = found.get('record') or {}
0830 db = record.get('harvester_db') or {}
0831 now = db.get('workers_now') or {}
0832 try:
0833 demand_at = datetime.fromisoformat(db['cache_updates']['job_statistics.json']).replace(tzinfo=dt_timezone.utc)
0834 demand_age = (self.now - demand_at).total_seconds()
0835 except (KeyError, ValueError, TypeError):
0836 demand_age = None
0837
0838
0839 applied = (record.get('adjustment') or {}).get('queues') or {}
0840 limits = {r['queue']: r for r in (record.get('harvester_db') or {}).get('launch_limits', [])
0841 if isinstance(r, dict) and 'queue' in r}
0842 for q in (set(now) - {'error'}) | set(applied) | set(limits):
0843 w = now.get(q) or {}
0844 a = applied.get(q) or {}
0845 limit = effective_waiting_limit(limits.get(q), a, self.now)
0846 fixed = q in limits and _fixed_limit(limits[q])
0847 adjustment_error = ((record.get('adjustment') or {}).get('read') or {}).get('error')
0848 out[q] = {
0849 'error': db.get('error') or now.get('error') or (
0850 f'cannot read adjuster log: {adjustment_error}' if adjustment_error and not fixed else None),
0851 'demand_age_seconds': demand_age,
0852 'demand_max_age_seconds': DEMAND_MAX_AGE_SECONDS.get(key, 900),
0853 'report_stale_seconds': 180 if key == 'osgsub01-harvester' else REPORT_STALE_SECONDS,
0854
0855 'waiting': int(w.get('submitted') or 0) + int(w.get('idle') or 0),
0856 'waiting_schedd': None,
0857 'limit': limit,
0858 'limit_source': 'database' if fixed else 'adjuster',
0859 'limit_at': a.get('at'),
0860 'max_workers': (limits.get(q) or {}).get('max_workers'),
0861 'running': int(w.get('running') or 0),
0862 'per_slot': PILOTS_PER_WORKER.get(q, 1),
0863 'report': key,
0864 'report_age_seconds': found.get('age_seconds'),
0865 }
0866 found = latest(OSG_SUBMIT_HOST)
0867 idle = ((found or {}).get('record') or {}).get('idle_analysis')
0868 if isinstance(idle, dict) and 'error' not in idle:
0869
0870 for q, p in out.items():
0871 if p['report'] == f'{OSG_SUBMIT_HOST}-harvester':
0872 p['waiting_schedd'] = int((idle.get(q) or {}).get('idle') or 0)
0873 p['schedd_age_seconds'] = found.get('age_seconds')
0874 return {q: _pilot_display(p) for q, p in out.items()}
0875
0876 def execute_sites(self, queues):
0877 sql = f'''SELECT COALESCE(NULLIF(destinationsite, ''), ''), COUNT(*),
0878 SUM(GREATEST(COALESCE(corecount, 1), 1))
0879 FROM "{self.S}"."jobsactive4"
0880 WHERE jobstatus = 'running' AND computingsite = ANY(%s) GROUP BY 1'''
0881 running = {d: (int(n), int(c or 0)) for d, n, c in _rows(sql, (list(queues),))}
0882 sql = f'''
0883 SELECT COALESCE(NULLIF(destinationsite, ''), ''), jobstatus, COUNT(*),
0884 SUM(GREATEST(COALESCE(corecount, 1), 1)
0885 * EXTRACT(EPOCH FROM (endtime - starttime))) / 3600.0
0886 FROM (
0887 SELECT destinationsite, jobstatus, corecount, endtime, starttime FROM "{self.S}"."jobsactive4"
0888 WHERE endtime >= %s AND computingsite = ANY(%s) AND jobstatus IN ('finished', 'failed')
0889 UNION ALL
0890 SELECT destinationsite, jobstatus, corecount, endtime, starttime FROM "{self.S}"."jobsarchived4"
0891 WHERE endtime >= %s AND computingsite = ANY(%s) AND jobstatus IN ('finished', 'failed')) t
0892 GROUP BY 1, 2'''
0893 ended = {}
0894 for d, status, n, core_h in _rows(sql, (self.hour_ago, list(queues), self.hour_ago, list(queues))):
0895 e = ended.setdefault(d, {'finished': 0, 'failed': 0,
0896 'finished_core_h': 0.0, 'failed_core_h': 0.0})
0897 e[status] = int(n)
0898 e[f'{status}_core_h'] = round(float(core_h or 0), 1)
0899 out = []
0900 for name in sorted(set(running) | set(ended),
0901 key=lambda s: -(running.get(s, (0, 0))[0])):
0902 r_jobs, r_cores = running.get(name, (0, 0))
0903 e = ended.get(name, {'finished': 0, 'failed': 0,
0904 'finished_core_h': 0.0, 'failed_core_h': 0.0})
0905 label = _uncut_site(name) or 'not reported'
0906 out.append({
0907 'id': re.sub(r'[^a-z0-9]+', '-', label.lower()).strip('-'),
0908 'name': label,
0909 'short': short_name(label),
0910 'running': r_jobs,
0911 'finished_1h': e['finished'], 'failed_1h': e['failed'],
0912 'capacity': None, 'capacity_source': None,
0913 'in_use': r_cores,
0914 'finished_core_h_1h': e['finished_core_h'],
0915 'failed_core_h_1h': e['failed_core_h'],
0916 'health': _health({'prod_activity': _activity_part(e['finished'], e['failed'])}),
0917
0918 'links': ({'jobs': f'{PUBLIC}/panda/jobs/?execsite={quote(name)}&days=1'}
0919 if name else {}),
0920 })
0921 return out
0922
0923 def science_arrivals(self, since=None, until=None, bin_seconds=None):
0924 """Per queue and output kind, the science files the last hour's
0925 finished production jobs registered in JLab Rucio, with their bytes
0926 and events: each DID the payload report's registration lists,
0927 joined by file name to the output it reports. A clash registered
0928 under a derived name (outcome ``diverted``) is a registered file
0929 whose name matches no output, so it counts with kind ``other`` and
0930 no bytes. PanDA's own file table holds no science output, since
0931 production tasks register their outputs themselves. ``since`` and
0932 ``until`` set another window; ``bin_seconds`` keys the result by
0933 the end of the job's end-time bin (epoch seconds) in place of the
0934 queue."""
0935 key = (f'((FLOOR(EXTRACT(EPOCH FROM d.endtime) / {int(bin_seconds)}) + 1)'
0936 f' * {int(bin_seconds)})::bigint' if bin_seconds else 'd.site')
0937 sql = f'''
0938 WITH r AS (
0939 SELECT j.computingsite AS site, j.endtime, m.metadata::jsonb -> 'payload' AS p
0940 FROM "{self.S}"."metatable" m JOIN "{self.S}"."jobsarchived4" j ON j.pandaid = m.pandaid
0941 WHERE j.endtime >= %s AND j.endtime < %s
0942 AND j.processingtype = 'epicproduction' AND j.jobstatus = 'finished'
0943 AND (j.jobmetrics LIKE %s OR j.jobmetrics LIKE %s)),
0944 d AS (
0945 SELECT r.site, r.endtime, r.p, did FROM r, jsonb_array_elements_text(
0946 CASE WHEN jsonb_typeof(r.p -> 'registration' -> 'dids') = 'array'
0947 THEN r.p -> 'registration' -> 'dids' ELSE '[]'::jsonb END) did)
0948 SELECT {key}, COALESCE(o.key, 'other'), COUNT(*),
0949 SUM((o.value ->> 'bytes')::bigint), SUM((o.value ->> 'events')::bigint)
0950 FROM d LEFT JOIN LATERAL (
0951 SELECT o.key, o.value FROM jsonb_each(
0952 CASE WHEN jsonb_typeof(d.p -> 'outputs') = 'object'
0953 THEN d.p -> 'outputs' ELSE '{{}}'::jsonb END) o
0954 WHERE d.did LIKE '%%/' || (o.value ->> 'file') LIMIT 1) o ON true
0955 GROUP BY 1, 2'''
0956 out = {}
0957 for k, kind, n, nbytes, events in _rows(sql, (since or self.hour_ago, until or self.now,
0958 '%payloadRegistration=registered%',
0959 '%payloadRegistration=diverted%')):
0960 out.setdefault(int(k) if bin_seconds else (k or ''), {})[kind] = (
0961 int(n), int(nbytes or 0), int(events or 0))
0962 return out
0963
0964 def registration_outcomes(self, since=None, until=None, bin_seconds=None):
0965 """Per queue, the last hour's finished production jobs by the
0966 payload's registration outcome (``payloadRegistration``), and the
0967 end time of the newest that registered. ``since``, ``until`` and
0968 ``bin_seconds`` as for ``science_arrivals``."""
0969 key = (f'((FLOOR(EXTRACT(EPOCH FROM endtime) / {int(bin_seconds)}) + 1)'
0970 f' * {int(bin_seconds)})::bigint' if bin_seconds else 'computingsite')
0971 sql = f'''
0972 SELECT {key}, substring(jobmetrics from 'payloadRegistration=([A-Za-z_]+)'),
0973 COUNT(*), MAX(endtime)
0974 FROM "{self.S}"."jobsarchived4"
0975 WHERE endtime >= %s AND endtime < %s
0976 AND processingtype = 'epicproduction' AND jobstatus = 'finished'
0977 GROUP BY 1, 2'''
0978 out, last = {}, None
0979 for k, outcome, n, end in _rows(sql, (since or self.hour_ago, until or self.now)):
0980 out.setdefault(int(k) if bin_seconds else (k or ''), {})[outcome or ''] = int(n)
0981 if outcome == 'registered' and end is not None and (last is None or end > last):
0982 last = end
0983 return out, _aware(last)
0984
0985 def log_arrivals(self):
0986 """Per queue, the log tarballs of the last hour's ended production
0987 jobs that reached BNL Rucio, with their bytes, and the jobs that
0988 failed at log stage-out. A failed job's log is registered too
0989 (PanDA marks every file of a failed job failed, but its log is at
0990 BNL_PROD_DISK_1, checked 2026-10-22), so only a job failed at log
0991 stage-out, or one whose pilot held its log in the stage-out bucket
0992 (``logHeld``), has no log there."""
0993 stageout = (f"(j.piloterrorcode IN ({','.join(str(c) for c in LOG_STAGEOUT_CODES)}) "
0994 f"OR j.piloterrordiag ILIKE %s)")
0995 missing = f"((j.jobstatus = 'failed' AND {stageout}) OR COALESCE(j.jobmetrics, '') LIKE %s)"
0996 sql = f'''
0997 SELECT j.computingsite,
0998 SUM(CASE WHEN {missing} THEN 0 ELSE 1 END),
0999 SUM(CASE WHEN {missing} THEN 0 ELSE f.fsize END),
1000 SUM(CASE WHEN j.jobstatus = 'failed' AND {stageout} THEN 1 ELSE 0 END),
1001 MAX(CASE WHEN {missing} THEN NULL ELSE j.endtime END)
1002 FROM (
1003 SELECT pandaid, computingsite, jobstatus, jobmetrics, piloterrorcode, piloterrordiag, endtime
1004 FROM "{self.S}"."jobsarchived4"
1005 WHERE endtime >= %s AND processingtype = 'epicproduction' AND jobstatus IN ('finished', 'failed')
1006 UNION ALL
1007 SELECT pandaid, computingsite, jobstatus, jobmetrics, piloterrorcode, piloterrordiag, endtime
1008 FROM "{self.S}"."jobsactive4"
1009 WHERE endtime >= %s AND processingtype = 'epicproduction' AND jobstatus IN ('finished', 'failed')) j
1010 JOIN "{self.S}"."filestable4" f ON f.pandaid = j.pandaid AND f.type = 'log'
1011 GROUP BY 1'''
1012 diag, held = '%stage-out%', '%logHeld=%'
1013 rows = _rows(sql, (diag, held, diag, held, diag, diag, held, self.hour_ago, self.hour_ago))
1014 out, last = {}, None
1015 for site, n, nbytes, failed, end in rows:
1016 out[site or ''] = (int(n or 0), int(nbytes or 0), int(failed or 0))
1017 if end is not None and (last is None or end > last):
1018 last = end
1019 return out, _aware(last)
1020
1021 def latest_finished(self, metrics_like, negate=False):
1022 """The end time of the newest finished production job in two days
1023 whose job metrics match (or, negated, do not match) the pattern:
1024 the newest arrival when none arrived in the last hour."""
1025 last = _rows(f'''
1026 SELECT MAX(endtime) FROM "{self.S}"."jobsarchived4"
1027 WHERE endtime >= %s AND processingtype = 'epicproduction' AND jobstatus = 'finished'
1028 AND COALESCE(jobmetrics, '') {'NOT ' if negate else ''}LIKE %s''',
1029 (self.now - timedelta(days=2), metrics_like))[0][0]
1030 return _aware(last)
1031
1032 def jlab_flow(self, edges):
1033 """The JLab output flow on the trends axis (COMP_SNAPSHOT.md,
1034 Storage): per bin, the science files finished production jobs
1035 registered and their bytes, and the jobs whose registration did not
1036 complete, grouped by the job's end, through the hour's own
1037 reducers. A bin's jobs are final once archived, so the bins are
1038 kept (cached product ``jlab_flow:v1``) and each build reads again
1039 the JLAB_FLOW_REREAD_BINS bins before the newest kept one, and
1040 backfills JLAB_FLOW_BACKFILL_BINS of the window's bins not yet
1041 kept: a read of the whole window at once takes minutes. A bin not
1042 yet read is null."""
1043 from .models import CachedProduct
1044
1045 width = int((edges[1] - edges[0]).total_seconds())
1046 ends = [int(e.timestamp()) for e in edges[1:]]
1047 row = CachedProduct.objects.filter(key=JLAB_FLOW_KEY).first()
1048 kept = {int(k): v for k, v in ((row.value or {}).get('bins') or {}).items()} if row else {}
1049 first = int(edges[0].timestamp())
1050
1051
1052 if kept:
1053 spans = [(max(first, max(kept) - JLAB_FLOW_REREAD_BINS * width), ends[-1])]
1054 oldest = min(t for t in kept if t >= ends[0]) if any(t >= ends[0] for t in kept) else ends[-1]
1055 if oldest - width > first:
1056 spans.append((max(first, oldest - width - JLAB_FLOW_BACKFILL_BINS * width),
1057 oldest - width))
1058 else:
1059 spans = [(max(first, ends[-1] - JLAB_FLOW_BACKFILL_BINS * width), ends[-1])]
1060 for start, end in spans:
1061 since = datetime.fromtimestamp(start, dt_timezone.utc)
1062 until = datetime.fromtimestamp(end, dt_timezone.utc)
1063 arrivals = self.science_arrivals(since=since, until=until, bin_seconds=width)
1064 outcomes, _last = self.registration_outcomes(since=since, until=until,
1065 bin_seconds=width)
1066 for t in ends:
1067 if start < t <= end:
1068 kinds = arrivals.get(t, {}).values()
1069 kept[t] = [sum(n for n, _b, _e in kinds), sum(b for _n, b, _e in kinds),
1070 sum(outcomes.get(t, {}).get(k, 0) for k in REGISTRATION_OWED)]
1071 kept = {t: v for t, v in kept.items() if t >= ends[0]}
1072 from .cached_product import stores
1073 if stores():
1074 CachedProduct.objects.update_or_create(
1075 key=JLAB_FLOW_KEY, defaults={'value': {'bins': {str(t): v for t, v in kept.items()}},
1076 'built_at': timezone.now()})
1077 column = {t: v for t, v in kept.items()}
1078 return {
1079 'files': [column[t][0] if t in column else None for t in ends],
1080 'bytes': [column[t][1] if t in column else None for t in ends],
1081 'registration_incomplete_jobs': [column[t][2] if t in column else None for t in ends],
1082 }
1083
1084 def storage(self, site_of, edges=None):
1085 """The E1s' Rucio storage (docs/COMP_SNAPSHOT.md, Storage): what
1086 arrived in the last hour, what did not, what is still owed, the
1087 output RSE's capacity, and the health."""
1088 def site_id(queue):
1089 return site_of.get(queue) or re.sub(r'[^a-z0-9]+', '-', (queue or 'not reported').lower()).strip('-')
1090
1091
1092 arrivals = self.part('science_arrivals', self.science_arrivals, None)
1093 outcomes, last = self.part('registration_outcomes', self.registration_outcomes, (None, None))
1094 reg = {}
1095 for per in (outcomes or {}).values():
1096 for k, n in per.items():
1097 reg[k] = reg.get(k, 0) + n
1098 if outcomes is None:
1099 reg_part = _part('grey', None, 'registration outcomes not read')
1100 else:
1101 registered = reg.get('registered', 0) + reg.get('diverted', 0)
1102 unsettled = sum(reg.get(k, 0) for k in REGISTRATION_OWED)
1103 done = registered + unsettled
1104 if done == 0:
1105 reg_part = _part('grey', 0, 'no finished jobs in the last hour')
1106 else:
1107 share = unsettled / done
1108 reg_part = _part('red' if share > 0.5 else 'amber' if share > 0.05 else 'green',
1109 round(share, 3),
1110 f'{registered} registered, {unsettled} not registered (pending or unfinished)')
1111 if last is None:
1112 last = self.part('latest_registration',
1113 lambda: self.latest_finished('%payloadRegistration=registered%'), None)
1114 if last is None:
1115 arrival = _part('red', None, 'no output registered in two days')
1116 jlab_age = None
1117 else:
1118 jlab_age = max(0, int((self.now - last).total_seconds()))
1119 age_min = round(jlab_age / 60, 1)
1120 arrival = _part('green' if age_min <= 30 else 'amber' if age_min <= 120 else 'red',
1121 age_min, f'latest output registered {age_min} min ago')
1122 jlab_sites, by_kind = {}, {}
1123 for queue, kinds in (arrivals or {}).items():
1124 s = jlab_sites.setdefault(site_id(queue), {'arrived_1h': 0, 'bytes_1h': 0, 'failed_1h': 0})
1125 for kind, (n, nbytes, events) in kinds.items():
1126 s['arrived_1h'] += n
1127 s['bytes_1h'] += nbytes
1128 k = by_kind.setdefault(kind, {'files': 0, 'bytes': 0, 'events': 0})
1129 k['files'] += n
1130 k['bytes'] += nbytes
1131 k['events'] += events
1132 for queue, per in (outcomes or {}).items():
1133 owed = sum(per.get(k, 0) for k in REGISTRATION_OWED)
1134 if owed:
1135 jlab_sites.setdefault(site_id(queue), {'arrived_1h': 0, 'bytes_1h': 0,
1136 'failed_1h': 0})['failed_1h'] += owed
1137 jlab_pending = self.part('jlab_pending', _jlab_pending, None)
1138 capacity = self.part('storage_record', _storage_capacity, None)
1139 flow = self.part('jlab_flow', lambda: self.jlab_flow(edges), None) if edges else None
1140
1141
1142 logs, last_log = self.part('log_arrivals', self.log_arrivals, (None, None))
1143 if logs is None:
1144 log_part = _part('grey', None, 'log stage-out not read')
1145 log_failed = None
1146 else:
1147 log_failed = sum(f for _n, _b, f in logs.values())
1148 log_part = _part('red' if log_failed >= 50 else 'amber' if log_failed >= 5 else 'green',
1149 log_failed, f'{log_failed} jobs failed at log stage-out in the last hour')
1150 bnl_sites = {}
1151 for queue, (n, nbytes, failed) in (logs or {}).items():
1152 s = bnl_sites.setdefault(site_id(queue), {'arrived_1h': 0, 'bytes_1h': 0, 'failed_1h': 0})
1153 s['arrived_1h'] += n
1154 s['bytes_1h'] += nbytes
1155 s['failed_1h'] += failed
1156 if last_log is None:
1157 last_log = self.part('latest_log',
1158 lambda: self.latest_finished('%logHeld=%', negate=True), None)
1159 log_pending = self.part('log_pending', _log_pending, None)
1160
1161 def site_rows(sites):
1162 return [dict(site=k, **v) for k, v in
1163 sorted(sites.items(), key=lambda kv: -kv[1]['arrived_1h'])]
1164
1165 return [
1166 {'id': 'jlab-rucio', 'name': 'JLab Rucio', 'holds': 'science data',
1167 'status': 'live', 'counts': 'files',
1168 'arrived_1h': (sum(k['files'] for k in by_kind.values()) if arrivals is not None else None),
1169 'bytes_1h': (sum(k['bytes'] for k in by_kind.values()) if arrivals is not None else None),
1170 'events_1h': (sum(k['events'] for k in by_kind.values()) if arrivals is not None else None),
1171 'arrived_by_kind_1h': ({k: v['files'] for k, v in by_kind.items()}
1172 if arrivals is not None else None),
1173 'failed_1h': (sum(reg.get(k, 0) for k in REGISTRATION_OWED) if outcomes is not None else None),
1174 'pending': (jlab_pending or {}).get('pending'),
1175 'pending_parts': (jlab_pending or {}).get('parts'),
1176 'pending_at': (jlab_pending or {}).get('at'),
1177 'latest_arrival_s': jlab_age,
1178 'flow': flow,
1179 'by_site': site_rows(jlab_sites),
1180 'capacity': (capacity or {}).get('output'),
1181 'rses': (capacity or {}).get('rses') or [],
1182 'health': _health({'registration': reg_part, 'latest_arrival': arrival}),
1183 'links': {'storage': f'{PUBLIC}/panda/storage-doors/',
1184 'arrived': f'{PUBLIC}/snapper/epicprod/storage/',
1185 'failed': incomplete_registration_link(self.now),
1186 'pending': f'{PUBLIC}/pcs/stash/'}},
1187 {'id': 'bnl-rucio', 'name': 'BNL Rucio', 'holds': 'logs',
1188 'status': 'live', 'counts': 'logs',
1189 'arrived_1h': (sum(n for n, _b, _f in logs.values()) if logs is not None else None),
1190 'bytes_1h': (sum(b for _n, b, _f in logs.values()) if logs is not None else None),
1191 'events_1h': None,
1192 'arrived_by_kind_1h': None,
1193 'failed_1h': log_failed,
1194 'pending': (log_pending or {}).get('pending'),
1195 'pending_parts': (log_pending or {}).get('parts'),
1196 'pending_at': (log_pending or {}).get('at'),
1197 'latest_arrival_s': (max(0, int((self.now - last_log).total_seconds()))
1198 if last_log is not None else None),
1199 'by_site': site_rows(bnl_sites),
1200 'capacity': None,
1201 'rses': [],
1202 'health': _health({'log_stageout': log_part}),
1203 'links': {'storage': f'{PUBLIC}/panda/storage-doors/',
1204 'arrived': f'{PUBLIC}/panda/jobs/?status=finished&days=1',
1205 'failed': f'{PUBLIC}/panda/errors/?error_source=pilot&days=1',
1206 'pending': None}},
1207 ]
1208
1209
1210 def _primary_queue(queues):
1211 return max(queues, key=lambda q: (
1212 {'live': 3, 'commissioning': 2, 'off': 1}.get(q['status'], 0),
1213 q.get('running') or 0), default=None)
1214
1215
1216 def _production_display(queue, status, cfg):
1217 return status == 'live' or (
1218 queue == 'BNL_ePIC_GOOGLE_es' and status == 'commissioning'
1219 and bool(cfg.get('gke_pilots.enabled'))
1220 and int(cfg.get('gke_pilots.max_pods') or 0) > 0)
1221
1222
1223 def _sites_config():
1224 """The site groups of ``comp.sites``, each with its queues from the
1225 queue registry in the registry's order, shown with the registry's
1226 status; a registry site with no group is shown under its own id."""
1227 from monitor_app.models import SysConfig
1228 from swf_epicprod import queues as registry
1229 value = SysConfig.get_setting('comp.sites', DEFAULT_SITES)
1230 groups = [g for g in (value if isinstance(value, list) else DEFAULT_SITES)
1231 if isinstance(g, dict) and g.get('id')]
1232 by_site = registry.room_queues()
1233 out = []
1234 for g in [*groups, *({'id': s, 'name': s} for s in by_site if s not in {g['id'] for g in groups})]:
1235 site = {k: v for k, v in g.items() if k != 'queues'}
1236 site['name'] = short_name(site.get('name') or site['id'])
1237 site['queues'] = [{'id': e['queue'], 'status': registry.ROOM_STATUS.get(e['status']),
1238 'status_reason': e['reason']} for e in by_site.get(g['id'], [])]
1239 out.append(site)
1240 return out
1241
1242
1243 def build():
1244 """Build the record (docs/COMP_SNAPSHOT.md, schema 1)."""
1245 from monitor_app.models import SysConfig
1246 from monitor_app.panda.census import queue_census
1247 from swf_epicprod import queues as registry
1248
1249 t0 = time.monotonic()
1250 b = _Build()
1251 sites_cfg = _sites_config()
1252 names = [q['id'] for s in sites_cfg for q in s.get('queues', [])]
1253 cfg = SysConfig.get_config()
1254
1255 census = b.part('census', lambda: (queue_census() or {}).get('queues') or {}, {})
1256 ended = b.part('ended', lambda: b.ended('computingsite'), {})
1257 failures = b.part('failures', b.top_failures, {})
1258 opened = b.part('harvester', b.opened, {})
1259 workers = b.part('harvester_workers', b.workers_now, {})
1260 pandastatus =b.part('queue_status', lambda: b.queue_status(names), {})
1261 decisions = b.part('front', lambda: b.front_decisions(names), {})
1262 pool = b.part('osg_pool', b.osg_pool, None)
1263 pilots = b.part('pilots', b.pilots, {})
1264 drift = b.part('queue_drift', _queue_drift, [])
1265 jlab_workers = (b.part('jlab_workers', b.jlab_worker_outcomes, None)
1266 if 'JLAB_PanDA_1' in names else None)
1267
1268 sites = []
1269 for s in sites_cfg:
1270 queues = []
1271 for qc in s.get('queues', []):
1272 q = qc['id']
1273 c = census.get(q) or {}
1274 by_status = c.get('by_status') or {}
1275 e = ended.get(q) or {'finished': 0, 'failed': 0, 'finished_core_h': 0.0, 'failed_core_h': 0.0}
1276 d = decisions.get(q) or {}
1277 dc = d.get('counts') or d
1278 running = int((by_status.get('running') or {}).get('jobs', 0))
1279 running_cores = int((by_status.get('running') or {}).get('cores', running))
1280 starting = int((by_status.get('starting') or {}).get('jobs', 0))
1281 in_panda = q in pandastatus
1282 status = qc.get('status') or ('live' if in_panda else 'not_built')
1283 reason = qc.get('status_reason') or ('' if in_panda else 'not in PanDA')
1284 if q == 'JLAB_PanDA_1' and status != 'live':
1285 reason = (jlab_workers or {}).get('reason',
1286 'commissioning; worker status unavailable')
1287
1288
1289 capacity, source = None, None
1290 configured = cfg.get(f'comp.capacity.{q}')
1291 if configured:
1292 capacity, source = int(configured), 'configured'
1293 elif s.get('execute_sites') and pool and pool.get('admitted') is not None:
1294
1295 capacity, source = int(pool['admitted']), 'pool, shared'
1296 elif (c.get('calibration') or {}).get('peak_running'):
1297 capacity, source = int(c['calibration']['peak_running']), 'observed peak'
1298
1299 if status != 'live':
1300 capacity, source = None, None
1301 if status == 'live':
1302 activity = _activity_part(e['finished'], e['failed'])
1303 canary = dc.get('canary') or 'unknown'
1304 canary_part = _part({'healthy': 'green', 'failing': 'red'}.get(canary, 'grey'), canary,
1305 f'canary {canary}')
1306 top = (failures.get(q) or [])
1307 ended_n = e['finished'] + e['failed']
1308 if not ended_n:
1309 snap = _part('grey', 0, 'no jobs ended in the last hour')
1310 elif top and top[0][1] * 2 > ended_n and e['failed'] >= 10:
1311 snap = _part('red', top[0][1], f'{top[0][1]} x {top[0][0]}')
1312 elif e['failed'] * 4 > ended_n:
1313 snap = _part('amber', e['failed'], f'top: {top[0][1]} x {top[0][0]}' if top else '')
1314 else:
1315 snap = _part('green', e['failed'],
1316 f'top: {top[0][1]} x {top[0][0]}' if top else 'no failures')
1317 if q in registry.AGENT_SUPPLIED:
1318 supply = _gke_workers(cfg)[2]
1319 else:
1320 supply = _pilot_supply_part(pilots.get(q), int(c.get('not_started') or 0),
1321 *opened.get(q, (0, 0)), workers=workers.get(q),
1322 operator_limit=(_worker_limit(cfg, q) or {}).get('limit'))
1323 health = _health({'prod_activity': activity, 'canary': canary_part,
1324 'snapper_errors': snap, 'pilot_supply': supply})
1325 else:
1326 health = _health({'status': _part('grey', status, reason or status)})
1327 if q == 'JLAB_PanDA_1':
1328 health = _health({'status': _part(
1329 (jlab_workers or {}).get('level', 'amber'), status, reason)})
1330
1331 queues.append({
1332 'id': q, 'name': qc.get('name') or q,
1333 'status': status, 'status_reason': reason,
1334
1335
1336 'workers_now': (dict(zip(('running', 'submitted'), workers[q]))
1337 if workers.get(q) else None),
1338 'production_display': _production_display(q, status, cfg),
1339 'running': running, 'starting': starting,
1340 'queued': int(c.get('not_started') or 0),
1341 'ungenerated': int(c.get('ungenerated') or 0),
1342 'harvester_slots_opened_1h': opened.get(q, (0, 0))[0],
1343 'harvester_pilots_submitted_1h': opened.get(q, (0, 0))[1],
1344 'pilots': pilots.get(q),
1345 'finished_1h': e['finished'], 'failed_1h': e['failed'],
1346 'capacity': capacity, 'capacity_source': source,
1347 'in_use': running_cores,
1348 'finished_core_h_1h': e['finished_core_h'],
1349 'failed_core_h_1h': e['failed_core_h'],
1350 'front': {'state': d.get('outcome'), 'reason': d.get('reason'),
1351 'feed': dc.get('feed_switch')},
1352 'health': health,
1353 'links': {
1354 'panda_queue': f'{PUBLIC}/panda/epic-queues/{q}/',
1355 'snapper': f'{PUBLIC}/panda/errors/?site={q}',
1356
1357 'canary': f'{PUBLIC}/canary/probes/',
1358
1359
1360
1361 'running': f'{PUBLIC}/panda/jobs/?site={q}&status=running&days=2',
1362 'queued': f'{PUBLIC}/panda/jobs/?site={q}&status=queued&days=30',
1363 'slots': f'{PUBLIC}/panda/epic-queues/{q}/#workers',
1364 },
1365 })
1366 if s.get('execute_sites') and pool and pool.get('admitted') is not None:
1367 site_capacity, site_source = int(pool['admitted']), 'pool'
1368 else:
1369 caps = [q['capacity'] for q in queues if q['capacity'] is not None]
1370 site_capacity = sum(caps) if caps else None
1371 site_source = ('+'.join(sorted({q['capacity_source'] for q in queues if q['capacity_source']}))
1372 or None)
1373 site = {
1374 'id': s['id'], 'name': s['name'],
1375 'capacity': site_capacity, 'capacity_source': site_source,
1376 'in_use': sum(q['in_use'] for q in queues),
1377 'status': (_primary_queue(queues) or {}).get('status', 'off'),
1378 'health': {'level': _worst(q['health']['level'] for q in queues),
1379 'parts': {q['id']: q['health']['level'] for q in queues}},
1380
1381
1382 'links': (dict(_primary_queue(queues)['links'])
1383 if queues else {'panda_queue': None}),
1384 'queues': queues,
1385 'execute_sites': [],
1386 }
1387 if s.get('execute_sites'):
1388 site['fair_share'] = (pool or {}).get('fair_share')
1389 live = [q['id'] for q in queues if q['status'] == 'live']
1390 if live:
1391 site['execute_sites'] = b.part('execute_sites', lambda: b.execute_sites(live), [])
1392 sites.append(site)
1393
1394 site_of = {q['id']: s['id'] for s in sites_cfg for q in s.get('queues', [])}
1395 trend = b.part('trend_series', lambda: _trend_series(sites), None)
1396 storage = b.part('storage', lambda: b.storage(
1397 site_of, edges=trend['edges'] if trend else None), [])
1398
1399 ready = b.part('ready_tasks', lambda: _ready_tasks(), None)
1400 released = b.part('released', lambda: _released(b.hour_ago), None)
1401 front = {
1402 'ready_tasks': ready, 'released_1h': released,
1403 'enabled': cfg.get('front.enabled'), 'mode': cfg.get('front.mode'),
1404 'jedi_throttled': cfg.get('front.jedi_throttled'),
1405 'link': f'{PUBLIC}/panda/front/#ready',
1406 **(b.part('front_board', lambda: _front_board(ready, released), None) or {}),
1407 }
1408
1409 alarms = b.part('alarms', b.production_alarms, [])
1410 site_name = {q['id']: short_name(s.get('name') or s['id'])
1411 for s in sites_cfg for q in s.get('queues', [])}
1412 record = {
1413 'schema': SCHEMA,
1414 'observed_at': b.now.isoformat(),
1415 'units': {'capacity': 'cores', 'core_hours': 'core-hours in the last hour'},
1416 'pressure_front': front,
1417 'sites': sites,
1418 'storage': storage,
1419 'report': {'text': _report_text(front, sites, storage),
1420 'items': _drift_items(drift) + _report_items(
1421 front, sites, storage, alarms,
1422 b.part('assessment', _assessment_freshness, None)),
1423 'assessment': b.part('daily_assessment', _latest_daily_assessment, None)},
1424 'live': b.part('live', _live_records, None),
1425 'tasks': b.part('tasks', lambda: b.floor_tasks(site_name, alarms, site_of), None),
1426 'paused': b.part('paused', lambda: b.paused_tasks(site_name), None),
1427 'completed': b.part('completed', lambda: b.completed_tasks(site_name), None),
1428 'arrivals': b.part('arrivals', lambda: b.pc_arrivals(site_name), None),
1429 'trends': b.part('trends', lambda: _trends(trend), None),
1430 'errors': b.errors,
1431 }
1432 record['build_seconds'] = round(time.monotonic() - t0, 2)
1433 return record
1434
1435
1436 def _ready_tasks():
1437 with connections['default'].cursor() as cursor:
1438 cursor.execute("SELECT COUNT(*) FROM pcs_prod_task WHERE overrides ? 'queued'")
1439 return int(cursor.fetchone()[0])
1440
1441
1442 def _front_board(ready, released):
1443 """What the pressure-front pillar shows on hover and opens on click
1444 (Torre Wenaus, 2026-10-22): the ready buffer task by task, in each
1445 queue's feed order, and each production queue's latest decision, from
1446 the state the front's cycle stored (cached product ``front_state``)."""
1447 from monitor_app.models import CachedProduct
1448 from swf_epicprod.front import STATE_KEY, closed_queues
1449
1450 row = CachedProduct.objects.filter(key=STATE_KEY).first()
1451 state = (row.value if row else None) or {}
1452 closed = closed_queues()
1453 items = []
1454 for queue, entries in (state.get('backlog') or {}).items():
1455 for order, e in enumerate(entries or [], 1):
1456 items.append({
1457 'task': e.get('task'), 'queue': None if queue == 'unplaced' else queue,
1458 'order': order, 'kind': e.get('kind'), 'by_hand': bool(e.get('hand')),
1459 'priority': e.get('level'), 'jobs': e.get('rows'),
1460 'eligible': not e.get('problems') and queue not in closed,
1461 'problems': list(e.get('problems') or [])
1462 + ([f'queue closed: {closed[queue]}'] if queue in closed else []),
1463 'link': f"{PUBLIC}/pcs/tasks/{e.get('task')}/"})
1464 queues = []
1465 for name, d in (state.get('queues') or {}).items():
1466 queues.append({
1467 'queue': name, 'state': d.get('state'), 'reason': d.get('reason'),
1468 'committed_h': d.get('committed_h'), 'h_low': d.get('h_low'),
1469 'h_high': d.get('h_high'), 'running': d.get('running'),
1470 'ready_total': d.get('ready_total'), 'ready_eligible': d.get('ready_eligible'),
1471 'last_feed_age_h': d.get('last_feed_age_h'), 'closed': name in closed})
1472 lines = [f"{ready if ready is not None else '?'} tasks ready, "
1473 f"{released if released is not None else '?'} released in the last hour"]
1474 for q in queues:
1475 waiting = [i for i in items if i['queue'] == q['queue']]
1476 if not waiting and q['closed']:
1477 continue
1478 lines.append(
1479 f"{short_name(q['queue'])}: {q['state'] or '?'}"
1480 + (f", {q['committed_h']:.1f} h committed of {q['h_low']:g}-{q['h_high']:g} h"
1481 if isinstance(q['committed_h'], (int, float)) and q['h_low'] is not None else '')
1482 + (f"; {len(waiting)} ready, next {waiting[0]['task']}" if waiting else '')
1483 + (' (closed)' if q['closed'] else ''))
1484 decided = {q['queue'] for q in queues}
1485 for queue in dict.fromkeys(i['queue'] for i in items if i['queue'] not in decided):
1486 waiting = [i for i in items if i['queue'] == queue]
1487 lines.append(f"{short_name(queue) or 'no queue yet'}: {len(waiting)} ready, next {waiting[0]['task']}"
1488 + (' (queue closed, held)' if queue in closed else ''))
1489 return {'items': items, 'queues': queues, 'cycle_at': state.get('cycle_at'),
1490 'detail': '. '.join(lines) + '.'}
1491
1492
1493 def _released(since):
1494 from monitor_app.models import AppLog
1495 return AppLog.objects.filter(app_name='epicprod', timestamp__gte=since,
1496 extra_data__action='front_decision',
1497 extra_data__outcome='fed').count()
1498
1499
1500 def _fair_share_notice(share, link):
1501 """The notice shared by every display of the pool's fair-share state."""
1502 if not share or not share.get('limited'):
1503 return None
1504 pools = ' and '.join(share.get('pools') or ['Pool'])
1505 cycle = share.get('cycle_at')
1506 when = ''
1507 if cycle:
1508 try:
1509 at = datetime.fromisoformat(cycle.replace('Z', '+00:00'))
1510 when = f" ({at.astimezone(ZoneInfo('America/New_York')):%Y-%m-%d %H:%M:%S} ET)"
1511 except (TypeError, ValueError):
1512 logger.warning('Unusable fair-share cycle time: %r', cycle)
1513
1514
1515 return {
1516 'text': 'fair share limited', 'level': 'grey', 'link': link,
1517 'detail': (f'{pools}: fair-share limit reached in the last reported '
1518 f'negotiation cycle{when}. Existing pilots keep their slots; '
1519 'later cycles may grant more.'),
1520 }
1521
1522
1523 def _fair_share(negotiators, age_seconds):
1524 """Whether a pool's negotiator held our OSG submitter at its
1525 fair-share limit in its last cycle, from the submit host reporter's
1526 record (docs/OSG_SUBMIT_REPORTER.md).
1527 None when the record lacks the reading or is stale."""
1528 if (not isinstance(negotiators, dict) or not negotiators.get('negotiators')
1529 or age_seconds is None or age_seconds > REPORT_STALE_SECONDS):
1530 return None
1531 held = [n for n in negotiators['negotiators'] if n.get('share_limited')]
1532 return {'limited': bool(held),
1533 'pools': sorted({short_name(n['pool']) for n in held}),
1534 'cycle_at': max((n['cycle_at'] for n in held if n.get('cycle_at')), default=None),
1535 'unread': sorted({short_name(p) for p in negotiators.get('errors') or {}})}
1536
1537
1538 def _queue_drift():
1539 """Where a host's copy of a queue's status disagrees with the queue
1540 registry, from the host reporters' records (swf-epicprod
1541 docs/QUEUE_REGISTRY.md, The drift check)."""
1542 from monitor_app.host_reports import latest
1543 from swf_epicprod import queues as registry
1544 return registry.drift({key: latest(key) for key in registry.drift_reports()})
1545
1546
1547 def _drift_items(drift):
1548 """One red report line per disagreement: the copy must be put right."""
1549 return [{'level': 'red', 'key': f"drift:{d['queue']}:{d['copy']}",
1550 'text': (f"Queue drift, {d['queue']}: {d['copy']} holds {d['holds']}, "
1551 f"the queue registry says {d['expected']}"),
1552 'link': f'{PUBLIC}/panda/front/'} for d in drift or []]
1553
1554
1555 def _report_text(front, sites, storage):
1556 lines = []
1557 ready, released = front.get('ready_tasks'), front.get('released_1h')
1558 lines.append(f"Pressure front: {ready if ready is not None else '?'} tasks ready, "
1559 f"{released if released is not None else '?'} released in the last hour.")
1560 for s in sites:
1561 live = [q for q in s['queues'] if q['status'] == 'live']
1562 if not live:
1563 continue
1564 running = sum(q['running'] for q in live)
1565 fin = sum(q['finished_1h'] for q in live)
1566 fail = sum(q['failed_1h'] for q in live)
1567 queued = sum(q['queued'] for q in live)
1568 lines.append(f"{s['name']}: {running} running, {queued} queued; last hour "
1569 f"{fin} finished, {fail} failed ({s['health']['level']}).")
1570 for q in live:
1571 for name, p in q['health']['parts'].items():
1572 if p['level'] in ('amber', 'red'):
1573 lines.append(f" {q['id']} {name} {p['level']}: {p['note']}")
1574 off = [s['name'] for s in sites if not any(q['status'] == 'live' for q in s['queues'])]
1575 if off:
1576 lines.append(f"Off or not yet built: {', '.join(off)}.")
1577 for st in storage:
1578 for name, p in st['health']['parts'].items():
1579 if p['level'] in ('amber', 'red'):
1580 lines.append(f"{st['name']} {name} {p['level']}: {p['note']}")
1581 return '\n'.join(lines)
1582
1583
1584
1585
1586 FRONT_STATES = {
1587 'supplied': ('green', 'enough work queued, nothing to feed'),
1588 'fed': ('green', 'fed a task in its last cycle'),
1589 'awaiting_observation': ('grey', 'waiting for its last submission to activate'),
1590 'degraded': ('red', 'holds new releases, a gate is red'),
1591 'half_open': ('amber', 'trying one task after a gate cleared'),
1592 'held': ('grey', 'held'),
1593 'oversize': ('amber', 'the next task alone is too large, waiting for the operator'),
1594 'no_work': ('amber', 'no eligible task in the ready buffer'),
1595 }
1596 FRONT_REASONS = {
1597 'queue_off': 'its feed is off', 'front_off': 'the front is off',
1598 'queue_exempt': 'its own feeder supplies it',
1599 'declared': 'a declared downtime', 'would_exceed_high': 'a feed would pass the high mark',
1600 'task_cap': 'the task cap', 'job_cap': 'the job cap',
1601 'unsized_task': 'a task that cannot be sized', 'no_calibration': 'no calibration yet',
1602 'canary': 'the canary is failing', 'burn_through': 'jobs burning through',
1603 'failure_window': 'the failure rate over its threshold', 'credential': 'a credential',
1604 }
1605 PART_NAMES = {'prod_activity': 'jobs', 'canary': 'canary',
1606 'snapper_errors': 'failures', 'pilot_supply': 'pilot supply'}
1607 LEVEL_ORDER = {'red': 0, 'amber': 1, 'grey': 2, 'green': 3}
1608
1609
1610 TASK_ALARM_RE = re.compile(r'^task (\d+) \(([^)]*)\) final-failure rate ([\d.]+%) — (.*)$')
1611
1612 DUE_RE = re.compile(r'^(?:due in \d+ days?|due today|overdue by \d+ days?) '
1613 r'\((\d{4}-\d\d-\d\d)\): (.*)$')
1614
1615
1616 def _active_alarms():
1617 """The alarm engine's events not cleared (docs/alarms.md)."""
1618 from monitor_app.alarms_data import CONTEXT_NAME
1619 from monitor_app.models import Entry
1620 rows = (Entry.objects.filter(context_id=CONTEXT_NAME, kind='event',
1621 archived=False, deleted_at__isnull=True)
1622 .order_by('-timestamp_created')[:500])
1623 return [{'id': str(e.id), 'title': e.title or (e.data or {}).get('subject') or '',
1624 'severity': (e.data or {}).get('severity') or 'alarm'}
1625 for e in rows if (e.data or {}).get('clear_time') is None]
1626
1627
1628 def _assessment_freshness():
1629 """Whether the scheduled campaign assessments arrived (the System
1630 page's campaign-assessments row). The verdicts themselves are held
1631 by corun-ai, which this DB-only build does not call."""
1632 from swf_epicprod.assessment.freshness import assessment_freshness
1633 status, summary, _data = assessment_freshness()
1634 return {'status': status, 'summary': summary}
1635
1636
1637 def _latest_daily_assessment():
1638 """The newest daily campaign assessment (swf-epicprod
1639 EPICPROD_ASSESSMENTS.md), from its ``assessment_register`` record: the
1640 verdict, the verdict as a health level, and the assessment's page."""
1641 from monitor_app.models import AppLog
1642 from swf_epicprod.assessment.freshness import DAILY_KINDS
1643 rec = (AppLog.objects.filter(app_name='epicprod', extra_data__action='assessment_register',
1644 extra_data__subject_type='campaign',
1645 extra_data__assessment_kind__in=DAILY_KINDS)
1646 .order_by('-timestamp').values('timestamp', 'extra_data').first())
1647 if not rec:
1648 return None
1649 x = rec['extra_data'] or {}
1650 path = x.get('url') or x.get('report_path')
1651 verdict = x.get('verdict') or ''
1652 return {'verdict': verdict, 'level': VERDICT_LEVELS.get(verdict, 'grey'),
1653 'at': rec['timestamp'].isoformat(),
1654 'link': f'{PUBLIC}{path}' if path else None,
1655 'campaign': x.get('subject_key'), 'title': x.get('report_title'),
1656 'narration': x.get('narration')}
1657
1658
1659 def _live_move_hours():
1660 """How long a task Move stays on the epicprod-live screen, SysConfig
1661 ``comp.live.move_hours``."""
1662 from monitor_app.models import SysConfig
1663 value = SysConfig.get_setting('comp.live.move_hours', LIVE_MOVE_HOURS)
1664 if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
1665 return value
1666 logger.warning('comp.live.move_hours %r is not a number of hours; using %s',
1667 value, LIVE_MOVE_HOURS)
1668 return LIVE_MOVE_HOURS
1669
1670
1671 def _move_detail(extra, at, task_name):
1672 """A Move's detail on the screen: the task, its route, who asked and
1673 why (live_curated.task_operation_who_why), and its state unless done."""
1674 from monitor_app.live_curated import task_operation_who_why
1675 who, why = task_operation_who_why(extra, at)
1676 text = (f"{short_name(extra.get('previous_site')) or '?'} to "
1677 f"{short_name(extra.get('site')) or '?'}, by {who}")
1678 if why:
1679 text += f': {why}'
1680 if extra.get('outcome') != 'ok':
1681 text += f"; {extra.get('reason') or extra.get('outcome')}"
1682 return f'{task_name}: {text}' if task_name else text
1683
1684
1685 def _feed_text(extra):
1686 """(title, detail) for a task the front released from the ready buffer
1687 to a queue: where from and where to (Torre Wenaus, 2026-10-29: "eg from
1688 ready buffer to perlmutter"), the task by its physics and evgen tags,
1689 and why the queue took it."""
1690 from monitor_app.live_notices import task_short
1691 queue = short_name(extra.get('subject_key')) or '?'
1692 task = str(extra.get('candidate') or '')
1693 short = task_short(task)
1694 title = f'Move ready buffer to {queue}' + (f' · {short}' if short else '')
1695 why = (f"{queue} below its low mark: {extra.get('committed_h')} h committed "
1696 f"of {extra.get('h_low')} to {extra.get('h_high')} h")
1697 return title, (f'{task}: ready buffer to {queue}, by front: {why}' if task
1698 else f'ready buffer to {queue}, by front: {why}')
1699
1700
1701 def _live_records():
1702 """The epicprod-live screen: the newest warning and error records of
1703 the live stream, the logs page's live view (ACTION_STREAM.md), each
1704 with its own page, and a task Move at INFO for ``comp.live.move_hours``.
1705 The room reads the record anonymously and the logs page is behind the
1706 login wall, so the records ride here. The floor is live now: records
1707 from the window only, a node guard trip only for a node new to the
1708 blacklist, and off it a record a ``live_cleared`` record names, a
1709 failure whose action has since run ok, and a record a later one on its
1710 subject supersedes or repeats (COMP_SNAPSHOT.md, Live)."""
1711 from django.db.models import Q
1712 from monitor_app.epicprod_logging import EPICPROD_APP_NAME, live_stream_q
1713 from monitor_app.live_notices import (FAILURE_OUTCOMES, board_text, failure_key,
1714 site_node_guard_fields)
1715 from monitor_app.models import AppLog
1716 cleared = set()
1717 for ids in (AppLog.objects
1718 .filter(app_name=EPICPROD_APP_NAME,
1719 extra_data__action='live_cleared')
1720 .values_list('extra_data__cleared_ids', flat=True)):
1721 cleared.update(int(i) for i in ids or ())
1722 moves_since = timezone.now() - timedelta(hours=_live_move_hours())
1723 rows = (AppLog.objects.filter(live_stream_q())
1724 .filter(Q(levelname__in=LIVE_LEVELS)
1725 | Q(levelname='INFO', timestamp__gte=moves_since,
1726 extra_data__action='panda_task_operation',
1727 extra_data__operation='reassign')
1728 | Q(levelname='INFO', timestamp__gte=moves_since,
1729 extra_data__action='front_decision', extra_data__outcome='fed'))
1730 .exclude(id__in=cleared)
1731 .order_by('-timestamp')
1732 .values('id', 'timestamp', 'levelname', 'message', 'instance_name',
1733 'extra_data'))
1734 cutoff = timezone.now() - timedelta(hours=LIVE_WINDOW_H)
1735 ranks = None
1736 last_ok = {}
1737 shown = set()
1738 items = []
1739 for r in _pages(rows):
1740 if r['timestamp'] < cutoff:
1741 break
1742 extra = r['extra_data'] or {}
1743
1744
1745 if (extra.get('action') == 'node_guard_decision'
1746 and extra.get('old_state') not in (None, '', 'clear')):
1747 continue
1748
1749
1750 feed = extra.get('action') == 'front_decision' and extra.get('outcome') == 'fed'
1751 subject = (json.dumps(['front_decision', extra.get('candidate')]) if feed
1752 else _subject(r['instance_name'], extra))
1753 if subject in shown:
1754 continue
1755 if str(extra.get('outcome') or '') in FAILURE_OUTCOMES:
1756 key = failure_key(r['instance_name'], extra)
1757 if key not in last_ok:
1758 last_ok[key] = _last_ok(r['instance_name'], extra, key)
1759 if last_ok[key] and last_ok[key] > r['timestamp']:
1760 continue
1761 if not feed and _superseded(r['instance_name'], extra, r['timestamp']):
1762 continue
1763 shown.add(subject)
1764 if extra.get('action') == 'node_guard_decision':
1765 if ranks is None:
1766 from monitor_app.panda.node_guard import exclusion_ranks
1767 try:
1768 ranks = exclusion_ranks()
1769 except Exception:
1770 logger.exception('live screen: node guard blacklist unreadable')
1771 ranks = {}
1772 extra = site_node_guard_fields(extra, ranks)
1773 title, detail = board_text(r['message'], extra)
1774 if extra.get('action') == 'panda_task_operation' and extra.get('operation') == 'reassign':
1775 detail = _move_detail(extra, r['timestamp'], extra.get('task_name') or '')
1776 elif feed:
1777 title, detail = _feed_text(extra)
1778 items.append({'id': r['id'], 'at': r['timestamp'].isoformat(),
1779 'level': r['levelname'], 'title': title, 'detail': detail,
1780 'link': f"{PUBLIC}/logs/{r['id']}/"})
1781 return {'link': f'{PUBLIC}/logs/epicprod-live/',
1782 'numbering': 'site', 'items': items, 'hours': LIVE_WINDOW_H,
1783 'counts': _live_counts(cutoff)}
1784
1785
1786 def _live_counts(cutoff):
1787 """The live stream's records since ``cutoff`` at INFO, WARNING and
1788 ERROR, zero included, each with the logs page's list of exactly those
1789 records (COMP_SNAPSHOT.md, Live)."""
1790 from django.db.models import Count
1791 from monitor_app.epicprod_logging import live_stream_q
1792 from monitor_app.models import AppLog
1793 levels = ('INFO', 'WARNING', 'ERROR')
1794 found = dict(AppLog.objects.filter(live_stream_q(), timestamp__gte=cutoff,
1795 levelname__in=levels)
1796 .values_list('levelname').annotate(n=Count('id')))
1797 return [{'level': level, 'count': found.get(level, 0),
1798 'link': (f'{PUBLIC}/logs/?app_name=epicprod&live=1&levelname={level}'
1799 f'&hours={LIVE_WINDOW_H}')}
1800 for level in levels]
1801
1802
1803 def _coverage_gaps(scope, start):
1804 """Snapper's own record of the spans its collection missed since
1805 ``start``, ``[(gap start, gap end)]``: each gap a recovering snap closed
1806 (one whose start is unknown runs from the snap before it), and the open
1807 gap the capture cursor holds, to now."""
1808 from django.db.models import Q
1809 from snapper_ai.models import CaptureCursor, SystemSnap
1810
1811 gaps = []
1812 rows = (SystemSnap.objects.filter(scope=scope, snap_time__gte=start)
1813 .filter(Q(recovered_gap_started_at__isnull=False)
1814 | ~Q(recovered_gap_start_unknown=False))
1815 .values_list('snap_time', 'recovered_gap_started_at',
1816 'recovered_gap_start_unknown'))
1817 for snap_time, gap_start, unknown in rows:
1818 if gap_start is None or unknown is not False:
1819 before = (SystemSnap.objects.filter(scope=scope, snap_time__lt=snap_time)
1820 .order_by('-snap_time').values_list('snap_time', flat=True).first())
1821 gap_start = before or start
1822 gaps.append((gap_start, snap_time))
1823 cursor = CaptureCursor.objects.filter(scope=scope).first()
1824 if cursor is not None and cursor.coverage_gap_started_at:
1825 gaps.append((cursor.coverage_gap_started_at, timezone.now()))
1826 return sorted(gaps)
1827
1828
1829 def _trend_series(sites):
1830 """The floor's trend record (COMP_SNAPSHOT.md, Trends): the Snapper
1831 Site view's series product for the floor's live queues, with the axis
1832 of 48 whole quarter-hour bins ending at or before the product's own
1833 newest sample, and Snapper's coverage gaps over it. The task board's
1834 output flow shares the axis. None when no queue is live or the product
1835 holds no sample."""
1836 from snapper_ai.products import series_product
1837
1838 live = [(s, [q['id'] for q in s['queues'] if q['status'] != 'not_built'])
1839 for s in sites]
1840 live = [(s, queues) for s, queues in live if queues]
1841 if not live:
1842 return None
1843 product = series_product('epicprod', 'site', window='24h',
1844 selection=','.join(q for _, qs in live for q in qs))
1845 curves = product.get('curves') or {}
1846
1847 def stamps(cid):
1848 return [datetime.fromisoformat(x) for x, _ in
1849 (curves.get(cid) or {}).get('points') or []]
1850
1851
1852
1853 samples = {q: stamps(f'sjfw_{q}') for _, qs in live for q in qs}
1854 newest = max((x[-1] for x in samples.values() if x), default=None)
1855 if newest is None:
1856 return None
1857 step_s = TREND_BIN_MINUTES * 60
1858 bins = TREND_HOURS * 60 // TREND_BIN_MINUTES
1859 aligned = datetime.fromtimestamp(newest.timestamp() // step_s * step_s, dt_timezone.utc)
1860 edges = [aligned - timedelta(seconds=step_s * k) for k in range(bins, -1, -1)]
1861 gaps = [g for g in _coverage_gaps('epicprod', edges[0]) if g[1] >= edges[0]]
1862 return {'live': live, 'curves': curves, 'samples': samples, 'newest': newest,
1863 'edges': edges, 'gaps': gaps}
1864
1865
1866 def _trends(series):
1867 """The floor's trend plots (COMP_SNAPSHOT.md, Trends): per site with a
1868 live queue, on the trend record's axis, running and queued jobs at each
1869 bin's end and jobs finished and failed in the bin, summed over the
1870 site's live queues. A value is read at the queue's newest sample at or
1871 before the edge: a job state absent from that sample was zero then (the
1872 PanDA state lists only the states that hold jobs), and a sample older
1873 than TREND_STALE_MINUTES before the edge, or an edge inside a coverage
1874 gap, gives null, never a value carried forward. Finished and failed
1875 count up, so a bin's ended jobs are the difference of the count at its
1876 two ends, exact across a gap inside the bin, null where an end is
1877 unknown. The series rebases those counters by summing their nonnegative
1878 increments, so a raw counter reset reads as no increment, not as a
1879 detectable fall. A queue with no sample is null throughout."""
1880 from bisect import bisect_right
1881
1882 from monitor_app.panda.census import NOT_STARTED
1883 from snapper_ai.models import CurrentComponent
1884
1885 if series is None:
1886 return None
1887 curves, samples, edges, gaps = (series['curves'], series['samples'],
1888 series['edges'], series['gaps'])
1889 stale = timedelta(minutes=TREND_STALE_MINUTES)
1890
1891 def values(cid):
1892 return {datetime.fromisoformat(x): v for x, v in
1893 (curves.get(cid) or {}).get('points') or []}
1894
1895 def at_edges(q, cid, absent_is_zero):
1896 """The curve at each edge, from the queue's newest sample at or
1897 before it."""
1898 times = samples.get(q) or []
1899 by_time = values(cid)
1900 out = []
1901 for edge in edges:
1902 i = bisect_right(times, edge)
1903 if not i or edge - times[i - 1] > stale or any(a <= edge <= b for a, b in gaps):
1904 out.append(None)
1905 continue
1906 v = by_time.get(times[i - 1])
1907 out.append(v if v is not None else (0 if absent_is_zero else None))
1908 return out
1909
1910 def total(rows):
1911 return [None if any(v is None for v in col) else sum(col) for col in zip(*rows)]
1912
1913 def ended(counts):
1914
1915
1916
1917
1918 return [None if a is None or b is None or b < a else round(b - a)
1919 for a, b in zip(counts, counts[1:])]
1920
1921 def known_sum(xs):
1922 return None if any(v is None for v in xs) else sum(xs)
1923
1924 def num(v):
1925 return 'unknown' if v is None else f'{v:,}'
1926
1927 hour = 60 // TREND_BIN_MINUTES
1928 to = edges[-1].astimezone(ZoneInfo('America/New_York')).strftime('%H:%M')
1929 out = []
1930 for site, queues in series['live']:
1931 running = total([at_edges(q, f'sj_{q}_running', True) for q in queues])[1:]
1932 cores = total([at_edges(q, f'sjc_{q}', False) for q in queues])[1:]
1933 queued = total([at_edges(q, f'sj_{q}_{s}', True) for q in queues
1934 for s in NOT_STARTED])[1:]
1935 finished = ended(total([at_edges(q, f'sjfw_{q}', False) for q in queues]))
1936 failed = ended(total([at_edges(q, f'sjxw_{q}', False) for q in queues]))
1937 name = short_name(site['name'])
1938 detail = (f'{name} at {to} ET: {num(cores[-1])} cores in {num(running[-1])} running jobs, {num(queued[-1])} queued jobs; '
1939 f'{num(known_sum(finished[-hour:]))} finished, '
1940 f'{num(known_sum(failed[-hour:]))} failed in the hour before')
1941 out.append({'id': site['id'], 'name': name, 'running': running,
1942 'cores': cores,
1943 'queued': queued, 'finished': finished, 'failed': failed,
1944 'detail': detail})
1945 panda = CurrentComponent.objects.filter(scope='epicprod', name='panda').first()
1946
1947 def iso(x):
1948 return x.astimezone(dt_timezone.utc).isoformat() if x else None
1949
1950 return {
1951 'window_hours': TREND_HOURS, 'bin_minutes': TREND_BIN_MINUTES,
1952 't': [iso(x) for x in edges[1:]],
1953 'sampled_at': iso(series['newest']),
1954 'source_as_of': iso(panda.source_as_of) if panda else None,
1955 'gaps': [[iso(a), iso(b)] for a, b in gaps],
1956 'sites': out,
1957 'link': f'{PUBLIC}/snapper/epicprod/site/',
1958 }
1959
1960
1961 def clear_live_records(ids, reason, username, instance='mcp'):
1962 """Take records off the room's epicprod-live screen: one live_cleared
1963 action naming them and why (COMP_SNAPSHOT.md, Live). An id outside the
1964 epicprod stream is refused, never recorded."""
1965 from monitor_app.epicprod_logging import EPICPROD_APP_NAME, log_epicprod_action
1966 from monitor_app.models import AppLog
1967 ids = sorted({int(i) for i in ids or ()})
1968 reason = str(reason or '').strip()
1969 if not ids:
1970 raise ValueError('no record ids')
1971 if not reason:
1972 raise ValueError('a reason is required')
1973 found = set(AppLog.objects.filter(id__in=ids, app_name=EPICPROD_APP_NAME)
1974 .values_list('id', flat=True))
1975 unknown = [i for i in ids if i not in found]
1976 if unknown:
1977 raise ValueError(f'not epicprod records: {unknown}')
1978 summary = (f"cleared {len(ids)} record(s) from the epicprod-live screen: "
1979 f"{', '.join(str(i) for i in ids)}; {reason}")
1980 row_id = log_epicprod_action(instance, 'live_cleared', username=username,
1981 message=summary, cleared_ids=ids,
1982 reason=reason, summary=summary)
1983 if row_id is None:
1984 raise RuntimeError('the live_cleared record was not written')
1985 return {'record_id': row_id, 'cleared_ids': ids, 'reason': reason}
1986
1987
1988 def _subject(instance_name, extra):
1989 """What a live record is about: its action on its subject, or for an
1990 action with no subject (a maintenance pass), the action from its
1991 source. The screen shows one record per subject, the newest."""
1992 if extra.get('subject_key'):
1993 return json.dumps([extra.get('action'), extra.get('subject_type'),
1994 extra.get('subject_key')])
1995 return json.dumps([extra.get('action'), instance_name])
1996
1997
1998 def _superseded(instance_name, extra, at):
1999 """True when a later record of the same action on the same subject
2000 reports another outcome: a node guard decision the node has moved on
2001 from, a task operation followed by another. A later record with the
2002 same outcome (the node guard's hourly standing record) continues it."""
2003 from monitor_app.epicprod_logging import EPICPROD_APP_NAME
2004 from monitor_app.models import AppLog
2005 later = AppLog.objects.filter(app_name=EPICPROD_APP_NAME, timestamp__gt=at,
2006 extra_data__action=extra.get('action'))
2007 if extra.get('subject_key'):
2008 later = later.filter(extra_data__subject_type=extra.get('subject_type'),
2009 extra_data__subject_key=extra['subject_key'])
2010 else:
2011 later = later.filter(instance_name=instance_name)
2012 if extra.get('outcome') is not None:
2013 later = later.exclude(extra_data__outcome=extra['outcome'])
2014 return later.exists()
2015
2016
2017 def _last_ok(instance_name, extra, key):
2018 """Time of the newest ok record carrying a failure's identity
2019 (live_notices.failure_key), or None: the recovery the epicprod-live
2020 channel announces."""
2021 from monitor_app.epicprod_logging import EPICPROD_APP_NAME
2022 from monitor_app.live_notices import failure_key
2023 from monitor_app.models import AppLog
2024 oks = AppLog.objects.filter(app_name=EPICPROD_APP_NAME,
2025 instance_name=instance_name,
2026 extra_data__action=extra.get('action'),
2027 extra_data__outcome='ok')
2028 for field in ('subject_type', 'subject_key', 'source', 'operation'):
2029 if extra.get(field) is not None:
2030 oks = oks.filter(**{f'extra_data__{field}': extra[field]})
2031 for ok in _pages(oks.order_by('-timestamp').values('timestamp', 'extra_data')):
2032 if failure_key(instance_name, ok['extra_data'] or {}) == key:
2033 return ok['timestamp']
2034 return None
2035
2036
2037 def _pages(queryset, size=100):
2038 """Rows of an ordered queryset in LIMIT/OFFSET pages: each page is an
2039 index scan, where a server-side cursor plans a sort of the whole set."""
2040 start = 0
2041 while True:
2042 page = list(queryset[start:start + size])
2043 yield from page
2044 if len(page) < size:
2045 return
2046 start += size
2047
2048
2049 def _jlab_pending():
2050 """What JLab Rucio is still owed: the failover stash's entries not yet
2051 home, from the drain's own record (the cached product ``stash_state``,
2052 swf-epicprod RUCIO_FAILOVER_STASH.md), and the registrations the
2053 registrar's last pass deferred, from its action record."""
2054 from monitor_app.models import AppLog, CachedProduct
2055 with connections['default'].cursor() as cursor:
2056
2057
2058 cursor.execute(f'''
2059 SELECT value ->> 'built_at',
2060 (SELECT COUNT(*) FROM jsonb_array_elements(
2061 CASE WHEN jsonb_typeof(value -> 'entries') = 'array'
2062 THEN value -> 'entries' ELSE '[]'::jsonb END) e
2063 WHERE e ->> 'outcome' IS DISTINCT FROM 'home')
2064 FROM {CachedProduct._meta.db_table} WHERE key = 'stash_state' ''')
2065 row = cursor.fetchone()
2066 stash, stash_at = (int(row[1]), row[0]) if row else (None, None)
2067 rec = (AppLog.objects.filter(app_name='epicprod', extra_data__action='registrar',
2068 extra_data__has_key='deferred')
2069 .order_by('-timestamp').values('timestamp', 'extra_data').first())
2070 deferred = int(rec['extra_data'].get('deferred') or 0) if rec else None
2071 known = [n for n in (stash, deferred) if n is not None]
2072 times = [t for t in (stash_at, rec['timestamp'].isoformat() if rec else None) if t]
2073 return {'pending': sum(known) if known else None,
2074 'parts': {'stash': stash, 'registrar': deferred},
2075 'at': min(times) if times else None}
2076
2077
2078 def _log_pending():
2079 """The logs pilots held in the stage-out bucket that are not yet in BNL
2080 Rucio, from the hourly log-rescue pass's record: those held over its
2081 window less those it moved (swf-epicprod LOG_STAGEOUT_FALLBACK.md,
2082 Recovery)."""
2083 from monitor_app.models import AppLog
2084 rec = (AppLog.objects.filter(app_name='epicprod', extra_data__action='log_rescue',
2085 extra_data__has_key='held')
2086 .order_by('-timestamp').values('timestamp', 'extra_data').first())
2087 if not rec:
2088 return None
2089 held = int(rec['extra_data'].get('held') or 0)
2090 moved = int(rec['extra_data'].get('moved') or 0)
2091 return {'pending': max(0, held - moved), 'parts': {'held': held, 'moved': moved},
2092 'at': rec['timestamp'].isoformat()}
2093
2094
2095 def _storage_capacity():
2096 """Each JLab disk RSE's usage against the production account's limit,
2097 from the latest storage component (swf-epicprod STORAGE.md, per RSE
2098 capacity), and the production output RSE's alone."""
2099 from monitor_app.snapper_storage import _current_data
2100 data = _current_data()
2101 if not data:
2102 raise LookupError('no storage component published')
2103 rses = []
2104 for name, r in sorted((data.get('rses') or {}).items()):
2105 c = (r or {}).get('capacity') or {}
2106
2107
2108 account = c.get('account_used') is not None
2109 rses.append({'id': name.lower(), 'name': name, 'type': (r or {}).get('type'),
2110 'used_bytes': c.get('account_used') if account else c.get('used'),
2111 'limit_bytes': c.get('limit'), 'fraction': c.get('fraction'),
2112 'files': c.get('account_files') if account else c.get('files'),
2113 'as_of': c.get('as_of')})
2114 output = next(({'rse': r['name'], 'used_bytes': r['used_bytes'], 'limit_bytes': r['limit_bytes'],
2115 'fraction': r['fraction'], 'as_of': r['as_of']}
2116 for r in rses if r['name'] == OUTPUT_RSE), None)
2117 if output:
2118 output['fill'] = _storage_fill(rses)
2119 return {'output': output, 'rses': rses}
2120
2121
2122
2123
2124
2125
2126 STORE_MAJOR_SHARE, STORE_AMBER, STORE_RED = 0.10, 0.88, 0.94
2127
2128
2129 def _storage_fill(rses):
2130 """The fullest major store and its level: red, amber or green."""
2131 held = [r for r in rses if r['used_bytes'] and r['fraction'] is not None]
2132 total = sum(r['used_bytes'] for r in held)
2133 major = [r for r in held if total and r['used_bytes'] / total >= STORE_MAJOR_SHARE]
2134 if not major:
2135 return None
2136 fullest = max(major, key=lambda r: r['fraction'])
2137 level = ('red' if fullest['fraction'] >= STORE_RED
2138 else 'amber' if fullest['fraction'] >= STORE_AMBER else 'green')
2139 return {'level': level, 'rse': fullest['name'], 'fraction': fullest['fraction']}
2140
2141
2142 FEED_SWITCH_REASONS = ('queue_off', 'queue_exempt')
2143 ET = ZoneInfo('America/New_York')
2144
2145
2146 def _subject_name(site, queue, live):
2147 """How a report line names what it is about: the site's display name,
2148 or the queue's short name where the site has more than one live queue."""
2149 return (short_name(site.get('name') or site['id']) if len(live) <= 1
2150 else short_name(queue['id']))
2151
2152
2153 def _when(at):
2154 """An ISO time as the report shows it, Eastern: '10/28 06:05 ET'."""
2155 if not at:
2156 return ''
2157 try:
2158 t = datetime.fromisoformat(str(at).replace('Z', '+00:00'))
2159 except ValueError:
2160 return str(at)
2161 if t.tzinfo is None:
2162 t = t.replace(tzinfo=dt_timezone.utc)
2163 return f'{t.astimezone(ET):%m/%d %H:%M} ET'
2164
2165
2166 def _account(when, by):
2167 """' (10/28 06:05 ET, swf-1)': when and who, as far as recorded."""
2168 bits = [b for b in (_when(when), by) if b]
2169 return f" ({', '.join(bits)})" if bits else ''
2170
2171
2172 def _site_state_items(value, cfg):
2173 """Every state a site is in, as report lines with who, when and why
2174 (COMP_SNAPSHOT.md, Report; Torre Wenaus, 2026-10-28: "absolute reported
2175 accountability in why a site is in the states it is in"), from the
2176 current settings: a front feed off or exempt, a core limit, the pool's
2177 fair-share limit and each production task paused at its queues."""
2178 items = []
2179 paused = (value.get('paused') or {}).get('items') or []
2180 for site in value.get('sites') or []:
2181 live = [q for q in site.get('queues', []) if q.get('status') == 'live']
2182 name = short_name(site.get('name') or site['id'])
2183 for q in live:
2184 if q.get('front') is None:
2185 continue
2186 base = f"front.queue.{q['id']}"
2187 if cfg.get(f'{base}.feed') is True:
2188 continue
2189 subject = _subject_name(site, q, live)
2190 account = _account(cfg.get(f'{base}.feed_set_at'), cfg.get(f'{base}.feed_set_by'))
2191 exempt, off = cfg.get(f'{base}.feed_exempt'), cfg.get(f'{base}.feed_off_reason')
2192 if exempt:
2193 items.append({'level': 'grey', 'site': site['id'], 'link': f'{PUBLIC}/panda/front/',
2194 'key': f"feed_exempt:{q['id']}",
2195 'text': f'{subject} front EXEMPT{account}: {exempt}'})
2196 else:
2197 items.append({'level': 'red', 'site': site['id'], 'link': f'{PUBLIC}/panda/front/',
2198 'key': f"feed_off:{q['id']}",
2199 'text': (f'{subject} front feed OFF{account}: {off}' if off else
2200 f'{subject} front feed OFF: no reason recorded')})
2201 for control in (site.get('worker_limit') or {}).get('controls') or []:
2202 subject = _subject_name(site, {'id': control['queue']}, live)
2203 cores = (f"{control['core_limit']:,} cores" if control.get('core_limit') is not None
2204 else f"{control.get('limit')} allocations")
2205 items.append({'level': 'red', 'site': site['id'], 'key': f"limit:{control['queue']}",
2206 'link': (site.get('links') or {}).get('panda_queue'),
2207 'text': (f'{subject} core limit {cores}'
2208 f"{_account(control.get('at'), control.get('by'))}: "
2209 f"{control.get('reason') or 'no reason recorded'}")})
2210
2211
2212
2213 ceilings = _node_ceilings()
2214 for q in site.get('queues', []):
2215 w = q.get('workers_now') or {}
2216 nodes = int(w.get('running') or 0) + int(w.get('submitted') or 0)
2217 if ceilings.get(q['id']) and nodes > ceilings[q['id']]:
2218 items.append({'level': 'red', 'site': site['id'], 'key': f"nodes:{q['id']}",
2219 'link': (site.get('links') or {}).get('panda_queue'),
2220 'text': (f"{_subject_name(site, q, live)} nodes {nodes} over the "
2221 f"ceiling of {ceilings[q['id']]} ({w.get('running') or 0} "
2222 f"running, {w.get('submitted') or 0} waiting)")})
2223 notice = (site.get('fair_share') or {}).get('notice')
2224 if notice:
2225 items.append({'level': notice.get('level') or 'amber', 'site': site['id'],
2226 'key': f"fair_share:{site['id']}", 'link': notice.get('link'),
2227 'text': f"{name} {notice['text']}: {notice.get('detail') or ''}".rstrip(': ')})
2228 queues = {q['id'] for q in site.get('queues', [])}
2229 for task in paused:
2230 if task.get('queue') not in queues:
2231 continue
2232 subject = _subject_name(site, {'id': task['queue']}, live)
2233
2234
2235
2236
2237 by = task.get('paused_by')
2238 level = 'red' if by in ('front task guard', 'outside our tools') or not task.get('pause_reason') else 'grey'
2239 items.append({'level': level, 'site': site['id'], 'link': task.get('link'),
2240 'key': f"paused:{task['id']}",
2241 'text': (f"{subject} task {task['id']} paused"
2242 f"{_account(task.get('since'), task.get('paused_by'))}: "
2243 f"{task.get('pause_reason') or 'no reason recorded'}")})
2244 return items
2245
2246
2247 NUMBERS_KEY = 'prod_now_numbers'
2248 NUMBER_HOLD = timedelta(hours=1)
2249 NUMBER_SEEN_REFRESH = timedelta(minutes=5)
2250
2251
2252 def _stable_numbers(keys, now=None):
2253 """{key: (n, since)}. A Production now line's number is immutable
2254 (Torre Wenaus, 2026-10-28: "prod now numbering should be immutable"):
2255 a new line takes the next number of the PersistentState counter
2256 ``prod_now_line``, which only rises, so a number names one line and is
2257 never given to another, and the newest line has the biggest. A line
2258 keeps its number while it stands, and a cleared line's number stays
2259 held for NUMBER_HOLD, so a line that returns within it keeps it; one
2260 that returns later takes a new number. ``since`` is when the line first
2261 stood. The lines are held in the CachedProduct row NUMBERS_KEY,
2262 ``{key: {n, seen, since}}``, written only when it changes."""
2263 from django.db import transaction
2264 from monitor_app.models import CachedProduct, PersistentState
2265 now = now or timezone.now()
2266 with transaction.atomic():
2267 row, _ = CachedProduct.objects.select_for_update().get_or_create(
2268 key=NUMBERS_KEY, defaults={'value': {}})
2269 held = dict(row.value or {})
2270 changed = False
2271
2272 start = max((entry['n'] for entry in held.values()), default=0) + 1
2273 for key, entry in list(held.items()):
2274 if key not in keys and now - datetime.fromisoformat(entry['seen']) > NUMBER_HOLD:
2275 del held[key]
2276 changed = True
2277 for key in keys:
2278 if key in held:
2279 if now - datetime.fromisoformat(held[key]['seen']) > NUMBER_SEEN_REFRESH:
2280 held[key] = dict(held[key], seen=now.isoformat())
2281 changed = True
2282 if 'since' not in held[key]:
2283
2284 held[key] = dict(held[key], since=now.isoformat())
2285 changed = True
2286 continue
2287 held[key] = {'n': PersistentState.next_number('prod_now_line', start),
2288 'seen': now.isoformat(), 'since': now.isoformat()}
2289 changed = True
2290 if changed:
2291 row.value = held
2292 row.save(update_fields=['value', 'updated_at'])
2293 return {key: (held[key]['n'], held[key]['since']) for key in keys}
2294
2295
2296 def _numbered(items, sites):
2297 """The report's lines newest first, the biggest number at the top
2298 (Torre Wenaus, 2026-10-28: "reverse time order, which means I should
2299 see biggest numbers at top"), with their standing numbers
2300 (``_stable_numbers``), the colour carrying the level; each site's own lines as
2301 ``site.notices`` for its panel, and its bullet ``site.level``: the
2302 worst of its health, status and lines."""
2303 for item in items:
2304 item.setdefault('key', item.get('text') or '')
2305 try:
2306 numbers = _stable_numbers(list(dict.fromkeys(i['key'] for i in items)))
2307 except Exception:
2308 logger.exception('Production now numbers unavailable')
2309 numbers = {}
2310 for item in items:
2311 item['n'], item['since'] = numbers.get(item['key'], (None, None))
2312
2313 items = sorted(items, key=lambda i: (i['n'] is not None, i['n'] or 0), reverse=True)
2314 by_site = {}
2315 for item in items:
2316 if item.get('site'):
2317 by_site.setdefault(item['site'], []).append(
2318 {k: item.get(k) for k in ('n', 'level', 'text', 'link')})
2319 for site in sites:
2320 site['notices'] = by_site.get(site['id'], [])
2321 health = (site.get('health') or {}).get('level')
2322 if site.get('status') == 'live':
2323 site['level'] = _worst([health, site.get('status_level')]
2324 + [i['level'] for i in site['notices']])
2325 else:
2326 site['level'] = site.get('status_level') or health
2327 return items
2328
2329
2330 def _guard_reason(evidence):
2331 """The front's task guard's trip, from its pause evidence."""
2332 e = evidence or {}
2333 if e.get('trigger') == 'consecutive':
2334 return f"{e.get('consecutive_failed')} jobs failed on {e.get('run_hosts')} hosts"
2335 if e.get('trigger'):
2336 return (f"{e.get('window_failed')} of {e.get('counted')} jobs failed "
2337 f"on {e.get('window_hosts')} hosts")
2338 if e.get('failed') is not None:
2339 return f"{e['failed']} of {int(e.get('finished') or 0) + int(e['failed'])} jobs failed"
2340 return None
2341
2342
2343 def _pause_accounts(task_ids):
2344 """{jedi task id: (who, why)} from each task's latest pause made
2345 through our tools (PandaTaskOperation); PanDA records no reason."""
2346 from monitor_app.models import PandaTaskOperation
2347 out = {}
2348 if not task_ids:
2349 return out
2350 for op in (PandaTaskOperation.objects.filter(jedi_task_id__in=list(task_ids), operation='pause')
2351 .order_by('jedi_task_id', '-requested_at')):
2352 if op.jedi_task_id in out:
2353 continue
2354 evidence = op.evidence or {}
2355 if str(op.source or '').startswith('front_task_failure_guard'):
2356 out[op.jedi_task_id] = ('front task guard',
2357 _guard_reason(evidence) or evidence.get('reason'))
2358 else:
2359 out[op.jedi_task_id] = (op.requested_by or None,
2360 evidence.get('reason') or evidence.get('note'))
2361
2362
2363 for task_id in task_ids:
2364 out.setdefault(task_id, ('outside our tools',
2365 'paused directly in PanDA, which records no reason'))
2366 return out
2367
2368
2369 def _report_items(front, sites, storage, alarms, assessment):
2370 """The report as items (docs/COMP_SNAPSHOT.md, Report): what the room's
2371 floor cannot show, each one line with a level and the page to open.
2372 No per-site counts: the pillars carry those."""
2373 items = []
2374 if not front.get('enabled'):
2375 items.append({'level': 'red', 'key': 'front:off', 'text': 'Pressure front: off, nothing is fed',
2376 'link': front.get('link')})
2377 for s in sites:
2378 live = [q for q in s['queues'] if q['status'] == 'live']
2379 if not live:
2380 continue
2381 for q in live:
2382 subject = _subject_name(s, q, live)
2383 f = q.get('front') or {}
2384 level, words = FRONT_STATES.get(f.get('state'), ('grey', f.get('state') or ''))
2385
2386
2387 if (f.get('state') and f.get('state') not in ('supplied', 'fed')
2388 and f.get('reason') not in FEED_SWITCH_REASONS):
2389 reason = FRONT_REASONS.get(f.get('reason'), '')
2390 items.append({'level': level, 'site': s['id'], 'key': f"front:{q['id']}",
2391 'text': f"Front, {subject}: {words}{', ' + reason if reason else ''}",
2392 'link': front.get('link')})
2393 for name, p in q['health']['parts'].items():
2394 if p['level'] not in ('amber', 'red'):
2395 continue
2396 link = (q['links'].get('slots') if name == 'pilot_supply' else
2397 q['links'].get('canary') if name == 'canary' else
2398 q['links'].get('snapper'))
2399 items.append({'level': p['level'], 'site': s['id'], 'key': f"health:{q['id']}:{name}",
2400 'text': f"{subject} {PART_NAMES.get(name, name)}: {p['note']}",
2401 'link': link})
2402 seen = set()
2403 for a in alarms or []:
2404 title = a['title']
2405
2406
2407 task = TASK_ALARM_RE.match(title)
2408 key = ('task', task.group(1)) if task else ('title', title)
2409 if key in seen:
2410 continue
2411 seen.add(key)
2412
2413
2414
2415 ping = a.get('severity') == 'ping'
2416 due = DUE_RE.match(title) if not task else None
2417 if task:
2418 number, state, rate, name = task.groups()
2419 text = f'Final failures {rate}, task {number} ({state}): {name}'
2420 key = f'alarm:task:{number}'
2421 elif due:
2422 date, what = due.groups()
2423 text = f"{'Due' if ping else 'Overdue, due'} {date}: {what}"
2424 key = f'due:{date}:{what}'
2425 else:
2426 text = f"{'Due' if ping else 'Alarm'}: {title.replace(' — ', ': ')}"
2427 key = f'alarm:{title}'
2428 items.append({'level': 'amber' if ping else 'red', 'text': text, 'key': key,
2429 'link': f"{PUBLIC}/alarms/events/{a['id']}/"})
2430 for st in storage:
2431 for name, p in st['health']['parts'].items():
2432 if p['level'] in ('amber', 'red'):
2433 items.append({'level': p['level'], 'key': f"storage:{st['id']}:{name}",
2434 'text': f"{st['name']}: {p['note']}",
2435 'link': (st.get('links') or {}).get('storage')})
2436 if assessment:
2437 level = {'ok': 'green', 'green': 'green', 'warn': 'amber', 'warning': 'amber',
2438 'amber': 'amber', 'fail': 'red', 'error': 'red', 'red': 'red'}.get(
2439 assessment['status'], 'grey')
2440 if level in ('amber', 'red'):
2441 items.append({'level': level, 'key': 'assessment',
2442 'text': f"Campaign assessment: {assessment['summary']}",
2443 'link': f'{PUBLIC}/ai/assessments/?subject_type=campaign_task,campaign'})
2444 items.sort(key=lambda i: LEVEL_ORDER.get(i['level'], 2))
2445 return items
2446
2447
2448 def incomplete_registration_link(observed_at):
2449 """The same completion window and registration subset as the count."""
2450 if isinstance(observed_at, str):
2451 observed_at = datetime.fromisoformat(observed_at)
2452 return f'{PUBLIC}/panda/jobs/?' + urlencode({
2453 'status': 'finished', 'registration': 'incomplete', 'hours': 1,
2454 'ended_after': (observed_at - timedelta(hours=1)).isoformat(),
2455 'ended_before': observed_at.isoformat(),
2456 })
2457
2458
2459 def _dashboard_campaign():
2460 """Current campaign, from the same cached completion product as Nav."""
2461 from pcs.models import Campaign
2462 from monitor_app.cached_product import get_product
2463 from swf_epicprod.analytics.completion import completion_product_value
2464
2465 name = Campaign.objects.filter(lifecycle='current').values_list('name', flat=True).first()
2466 if not name:
2467 return None
2468 product = get_product('prod_hub_campaign_completion', completion_product_value,
2469 ttl_seconds=600)
2470 row = next((row for row in product['value'] or [] if row['campaign'] == name), {})
2471 totals = row.get('overall') or {}
2472 return {'id': name, 'delivered_since': totals.get('delivered_since'),
2473 'events': totals.get('delivered_events'), 'bytes': totals.get('bytes'),
2474 'configurations': totals.get('configurations')}
2475
2476
2477 def _gke_workers(cfg, queue='BNL_ePIC_GOOGLE_es'):
2478 """The GKE controller's fresh worker reading for its queue, its age, and
2479 the pilot-supply part it gives (swf-epicprod GKE_PILOT_FLOW.md): the
2480 controller, not Harvester, supplies this queue."""
2481 from monitor_app.models import CachedProduct
2482 from swf_epicprod.gke_pilots import CAPABILITY_KEY
2483
2484 row = CachedProduct.objects.filter(key=CAPABILITY_KEY).first()
2485 cap = (row.value or {}) if row else {}
2486 age = ((timezone.now() - row.built_at).total_seconds()
2487 if row and row.built_at else None)
2488 fresh = (age is not None and 0 <= age <= REPORT_STALE_SECONDS
2489 and cap.get('queue') == queue)
2490 workers = cap.get('workers') if fresh else None
2491 if workers is not None and (workers.get('error') or not all(
2492 isinstance(workers.get(key), int) for key in ('pending', 'running'))):
2493 workers = None
2494 latest = (workers or {}).get('latest') or {}
2495 state = 'red' if latest.get('failed') else 'green' if workers is not None else 'amber'
2496 text = (f"{workers['pending']} pilots waiting, {workers['running']} running"
2497 if workers is not None else 'pilot supply unknown')
2498 return workers, age, _part(state, None, text)
2499
2500
2501 def _google_pilot_display(site, cfg):
2502 """Supply counts for the ordinary production presentation; diagnostics stay in the controller record."""
2503 queue = next((q for q in site['queues'] if q['id'] == 'BNL_ePIC_GOOGLE_es'), None)
2504 if not queue or not queue.get('production_display'):
2505 return
2506 limit = int(cfg.get('gke_pilots.max_pods') or 0)
2507 workers, age, workers_part = _gke_workers(cfg, queue['id'])
2508 if workers is not None:
2509 pending, running = workers.get('pending'), workers.get('running')
2510 queue['pilots'] = {'report': 'gke-pilots', 'waiting': pending,
2511 'waiting_current': pending, 'waiting_source': 'gke-pilots',
2512 'running': running, 'limit': limit,
2513 'per_slot': 1, 'report_age_seconds': round(age, 1),
2514 'report_stale_seconds': REPORT_STALE_SECONDS,
2515 'limit_source': 'controller', 'error': None}
2516 else:
2517 queue['pilots'] = None
2518 status_level = 'green' if queue['status'] == 'live' else 'blue' if queue['status'] == 'commissioning' else 'grey'
2519 queue['health'] = _health({'status': _part(status_level, queue['status'], queue['status']),
2520 'gke_workers': workers_part})
2521 queue['status_reason'] = queue['status']
2522 site['health'] = {'level': _worst(q['health']['level'] for q in site['queues']),
2523 'parts': {q['id']: q['health']['level'] for q in site['queues']}}
2524 site['status_reason'] = queue['status']
2525
2526
2527 def _report_header(record):
2528 """Display-ready header blocks shared by the dashboard and room."""
2529 queues = [q for site in record.get('sites', []) for q in site.get('queues', [])]
2530
2531 def total(key):
2532 return (sum(q[key] for q in queues)
2533 if queues and all(q.get(key) is not None for q in queues) else None)
2534
2535 def number(value, divisor=1, suffix=''):
2536 return f'{int(value / divisor + 0.5):,}{suffix}' if value is not None else '—'
2537
2538 def metric(value, label, colour, link, detail):
2539 return {'value': value, 'label': label, 'colour': colour, 'link': link, 'detail': detail}
2540
2541 jobs = f'{PUBLIC}/panda/jobs/'
2542 front = f'{PUBLIC}/panda/front/#ready'
2543 campaign = record.get('campaign') or {}
2544 plan = f"{PUBLIC}/pcs/plan/?campaign={quote(campaign.get('id') or '')}"
2545 thread_count = total('processing_threads')
2546 thread_value = number(thread_count)
2547 thread_detail = 'Cores carrying payload work in running production jobs'
2548 known_threads = total('processing_known_threads')
2549 if thread_count is None and known_threads:
2550 thread_value = number(known_threads)
2551 thread_detail += '; lower bound because some jobs have no fresh payload-thread measurement'
2552 since = campaign.get('delivered_since')
2553 if since:
2554 date = datetime.fromisoformat(str(since))
2555 since = f"{date.strftime('%b')} {date.day}"
2556 return [
2557 {'id': 'computing', 'title': 'Computing now', 'link': f'{PUBLIC}/panda/compute-efficiency/',
2558 'detail': 'Compute efficiency history by site and month or week.',
2559 'metrics': [metric(thread_value, 'cores', '#edf2f1',
2560 jobs + '?status=running&days=2', thread_detail),
2561 metric(number(total('finished_cores_1h')), 'finish/1h', '#7fce95',
2562 jobs + '?status=finished&days=1', 'Core completions in the last hour')]},
2563 {'id': 'work', 'title': 'Work available', 'link': front,
2564 'detail': 'Queued jobs and input work not yet generated into jobs.',
2565 'metrics': [metric(number(total('queued')), 'queued', '#a79b8f',
2566 jobs + '?status=queued&days=30', 'Jobs waiting to start'),
2567 metric(number(total('ungenerated')), 'to generate', '#9cabb5',
2568 front, 'Input work not yet generated into jobs')]},
2569 {'id': 'campaign', 'title': 'Campaign ' + (campaign.get('id') or '—'),
2570 'subtitle': 'delivered since ' + (since or '—'), 'link': plan,
2571 'detail': 'Campaign delivery recorded in the production plan.',
2572 'metrics': [metric(number(campaign.get('events'), 1e6, 'M'), 'events', '#7fce95',
2573 plan, 'Delivered events'),
2574 metric(number(campaign.get('bytes'), 1e12), 'TB', '#7fce95',
2575 plan, 'Delivered data in decimal terabytes')],
2576 'footer': {'text': number(campaign.get('configurations')) + ' physics configs',
2577 'link': plan, 'detail': 'Physics configurations in the current campaign'}},
2578 ]
2579
2580
2581 def get_snapshot(refresh=False):
2582 """The stored record, rebuilt behind the request when older than the
2583 TTL (docs/CACHED_PRODUCTS.md)."""
2584 from monitor_app.cached_product import get_product
2585 from monitor_app.models import SysConfig
2586 from swf_epicprod.regulation import PILOTS_PER_WORKER
2587 result = get_product(PRODUCT_KEY, build, TTL_SECONDS, refresh=refresh)
2588 value = dict(result.get('value') or {})
2589
2590
2591 if value.get('live') and value['live'].get('numbering') != 'site':
2592 value['live'] = _live_records()
2593
2594 value['sites'] = [dict(site, execute_sites=[
2595 dict(item, short=short_name(item.get('name')))
2596 for item in site.get('execute_sites', [])
2597 ]) for site in value.get('sites', [])]
2598 status_by_queue = {q['id']: q for s in _sites_config() for q in s.get('queues', [])}
2599 cfg = SysConfig.get_config()
2600 for site in value['sites']:
2601 site['name'] = short_name(site.get('name') or site['id'])
2602 site['queues'] = [dict(q, pilots=_pilot_display(q.get('pilots')))
2603 for q in site.get('queues', [])]
2604 for queue in site['queues']:
2605 declared = status_by_queue.get(queue['id']) or {}
2606 if declared.get('status'):
2607 queue['status'] = declared['status']
2608 queue['production_display'] = _production_display(queue['id'], queue['status'], cfg)
2609 queue['status_reason'] = short_text(queue.get('status_reason'))
2610 queue['health'] = dict(queue['health'], parts={
2611 key: dict(part, note=short_text(part.get('note')))
2612 for key, part in queue['health']['parts'].items()})
2613 primary = _primary_queue(site['queues'])
2614 site['status'] = primary['status'] if primary else 'off'
2615 site['status_label'] = {
2616 'live': '', 'commissioning': 'COMMISSIONING', 'off': 'OFF',
2617 'not_built': 'OFF', 'not_live': 'OFF',
2618 }.get(site['status'], site['status'].replace('_', ' ').upper())
2619 if site['status'] != 'live':
2620 site['status_label'] = INACTIVE_SITE_LABELS.get(site['id'], site['status_label'])
2621 else:
2622 site['status_label'] = LIVE_SITE_LABELS.get(site['id'], '')
2623 site['status_level'] = (
2624 'grey' if site['id'] in INACTIVE_SITE_LABELS and site['status'] != 'live' else
2625 'blue' if site['status'] == 'commissioning' else
2626 None if site['status'] == 'live' else 'grey')
2627 site['production_panel'] = True
2628 site['status_reason'] = (primary or {}).get('status_reason') or site['status_label']
2629
2630
2631
2632 off = [q['id'] for q in site['queues']
2633 if cfg.get(f"front.queue.{q['id']}.feed") is False
2634 and cfg.get(f"front.queue.{q['id']}.feed_off_reason")]
2635 if site['status'] == 'live' and off:
2636 site['status_label'] = 'Pressure front OFF'
2637 site['status_level'] = 'red'
2638 site['status_reason'] = (f"{', '.join(off)}: the pressure front's feed is off; running work "
2639 f"continues. {cfg.get(f'front.queue.{off[0]}.feed_off_reason')}")
2640 if primary:
2641 site['links'] = dict(primary['links'])
2642 limits = []
2643 for queue in site['queues']:
2644 control = _worker_limit(cfg, queue['id'])
2645 if control:
2646 width = control.get('cores_per_worker') or PILOTS_PER_WORKER.get(queue['id'])
2647 core_limit = control['limit'] * width if isinstance(width, int) and width > 0 else None
2648 limits.append(dict(control, queue=queue['id'], core_limit=core_limit))
2649 site['worker_limit'] = None
2650 if limits:
2651 levels = ', '.join(f"{item['core_limit']:,}" if item['core_limit'] is not None
2652 else 'unavailable' for item in limits)
2653 detail = '\n'.join(
2654 f"{item['queue']}: {item['core_limit'] if item['core_limit'] is not None else 'unknown'} "
2655 f"core ceiling ({item['limit']:,} Harvester allocations); "
2656 f"{item.get('reason') or 'operator override'}. "
2657 'Existing allocations finish normally.' for item in limits)
2658 site['worker_limit'] = {
2659 'active': True, 'controls': limits,
2660 'notice': {'text': f'Core limit {levels}', 'level': 'red',
2661 'detail': detail,
2662 'link': (site.get('links') or {}).get('panda_queue')},
2663 }
2664 if site['id'] == 'google':
2665 site.pop('board', None)
2666 _google_pilot_display(site, cfg)
2667 if isinstance(site.get('fair_share'), dict):
2668 share = site['fair_share']
2669 site['fair_share'] = dict(share, notice=_fair_share_notice(
2670 share, (site.get('links') or {}).get('panda_queue')))
2671 from monitor_app.processing_activity import product as processing_product
2672 try:
2673 activity = processing_product()
2674 measured = activity.get('value') or {}
2675
2676
2677
2678
2679
2680 from swf_epicprod.es_closeout import setting as es_setting
2681 es_queues = set(es_setting('queues', seed=False) or ())
2682 for site in value['sites']:
2683 for queue in site['queues']:
2684 row = (measured.get('queues') or {}).get(queue['id'], {})
2685 queue['processing_threads'] = row.get('threads', 0)
2686 queue['processing_known_threads'] = row.get('known_threads', 0)
2687 queue['processing_unknown_jobs'] = row.get('unknown_jobs', 0)
2688 queue['processing_threads_at'] = row.get('threads_at')
2689 queue['event_service'] = queue['id'] in es_queues
2690 queue['events_1h'] = row.get('events_1h', 0)
2691 queue['events_unknown_jobs'] = row.get('events_unknown_jobs', 0)
2692 counts = [q['processing_threads'] for q in site['queues']]
2693 site['processing_threads'] = sum(counts) if all(n is not None for n in counts) else None
2694 for executor in site.get('execute_sites', []):
2695 matches = [r for name, r in (measured.get('executors') or {}).items()
2696 if (_uncut_site(name) or 'not reported') == executor['name']]
2697 counts = [r.get('threads') for r in matches]
2698 executor['processing_threads'] = (sum(counts) if all(n is not None for n in counts)
2699 else sum(r['known_threads'] for r in matches))
2700 value['processing_observed_at'] = measured.get('observed_at')
2701 except Exception:
2702 logger.exception('Payload processing activity unavailable')
2703 for site in value['sites']:
2704 site['processing_threads'] = None
2705 for queue in site['queues']:
2706 queue['processing_threads'] = None
2707 trends = value.get('trends')
2708 if trends:
2709 trends = dict(trends, sites=[dict(s, name=short_name(s.get('name')))
2710 for s in trends.get('sites', [])])
2711 value['trends'] = trends
2712 known = {s['id'] for s in trends.get('sites', [])}
2713 missing = [s for s in value['sites'] if s['id'] not in known]
2714 if missing:
2715 empty = [None] * len(trends.get('t') or [])
2716 value['trends'] = dict(trends, sites=[*trends.get('sites', []), *(
2717 {'id': s['id'], 'name': s['name'], 'detail': 'No trend samples yet',
2718 **{key: list(empty) for key in ('running', 'queued', 'finished', 'failed')}}
2719 for s in missing)])
2720 if value.get('observed_at'):
2721 value['storage'] = [dict(row, links=dict(row.get('links') or {},
2722 failed=incomplete_registration_link(value['observed_at'])))
2723 if row.get('id') == 'jlab-rucio' else row
2724 for row in value.get('storage', [])]
2725 from monitor_app.site_core_metrics import product as core_product, ready_capacity
2726 try:
2727 core_result = core_product()
2728 core_data = core_result.get('value') or {}
2729
2730
2731
2732
2733 core_fresh = bool(core_data.get('since'))
2734 google = core_data.get('google') or {}
2735 for site in value['sites']:
2736 for queue in site['queues']:
2737 row = (core_data.get('queues') or {}).get(queue['id'], {})
2738 for key in ('finished_cores_1h', 'failed_cores_1h', 'queued_cores',
2739 'finished_jobs_1h', 'failed_jobs_1h'):
2740 queue[key] = row.get(key, 0) if core_fresh else None
2741 queue['queued_jobs'] = row.get('queued_jobs', 0) if core_fresh else None
2742 if queue['id'] == google.get('queue'):
2743 site['node_cap'] = google['nodes']
2744 site['cores_per_node'] = google['cores_per_node']
2745 queue['queued_core_demand'] = row.get('queued_core_demand', 0)
2746 if queue['id'] == perlmutter_production.QUEUE:
2747
2748
2749
2750
2751
2752 workers_now = queue.get('workers_now')
2753 site['node_cap'] = (int(workers_now.get('running') or 0) if workers_now
2754 else int(queue.get('running') or 0))
2755 site['cores_per_node'] = int(perlmutter_production.SHAPE['slots'])
2756 trend = next((t for t in (value.get('trends') or {}).get('sites', [])
2757 if t['id'] == site['id']), None)
2758 if not trend:
2759 continue
2760 ids = [q['id'] for q in site['queues']]
2761 times = value['trends']['t']
2762 for state in ('finished', 'failed'):
2763 trend[f'{state}_cores'] = [
2764 sum((core_data.get('ended', {}).get(stamp, {}).get(q, {}).get(state, 0)) for q in ids)
2765 if core_fresh and stamp >= core_data['since'] else None for stamp in times]
2766 if not core_fresh:
2767 trend['queued_cores'] = [None] * len(times)
2768 elif site['id'] == 'google' and google:
2769 trend['queued_cores'] = [ready_capacity(n, google['nodes'], google['cores_per_node'])
2770 if n is not None else None for n in trend['queued']]
2771 elif perlmutter_production.QUEUE in ids:
2772
2773
2774
2775 lanes = int(perlmutter_production.SHAPE['slots'])
2776 trend['queued_cores'] = [n * lanes if n is not None else None
2777 for n in trend['queued']]
2778 else:
2779
2780 trend['queued_cores'] = list(trend['queued'])
2781
2782
2783 trend['live'] = {'cores': site.get('processing_threads'),
2784 'queued_cores': (sum(q.get('queued_cores') or 0 for q in site['queues'])
2785 if core_fresh else None)}
2786 if value.get('trends'):
2787 value['trends']['live_t'] = value.get('processing_observed_at') or value.get('observed_at')
2788 except Exception:
2789 logger.exception('Site core accounting unavailable')
2790 try:
2791 value['campaign'] = _dashboard_campaign()
2792 except Exception:
2793 logger.exception('Dashboard campaign summary unavailable')
2794 value['campaign'] = None
2795 value['report'] = dict(value.get('report') or {}, header=_report_header(value),
2796 text=_report_text(value.get('pressure_front') or {}, value['sites'],
2797 value.get('storage') or []))
2798
2799
2800 try:
2801 state_items = _site_state_items(value, cfg)
2802 except Exception:
2803 logger.exception('Site state lines unavailable')
2804 state_items = [{'level': 'red', 'key': 'error:state',
2805 'text': 'Site state lines unavailable: see the monitor log'}]
2806 value['report']['items'] = _numbered(
2807 [dict(item, text=short_text(item.get('text')))
2808 for item in value['report'].get('items', []) + state_items], value['sites'])
2809 value['age_seconds'] = result.get('age_seconds')
2810 value['refreshing'] = result.get('refreshing')
2811 return value