File indexing completed on 2026-11-09 10:47:38
0001 """The queue's own workers in the batch pool: how many wait, how long.
0002
0003 The pool reading (``pools.py``) says how full somebody's batch pool is;
0004 this reading says what that costs us at one queue: the harvester's
0005 workers submitted there and not yet started, the oldest of them, and
0006 how long the ones that did start waited. It is read from the
0007 harvester's worker table in the PanDA database, which every queue has
0008 whether or not its pool is one we can see, so it is the half of the
0009 wait question that never goes dark.
0010 """
0011 import logging
0012 from datetime import timedelta
0013 from datetime import timezone as dt_timezone
0014
0015 from django.db import connections
0016 from django.utils import timezone
0017
0018 from .cached_product import get_product
0019 from .panda.constants import PANDA_SCHEMA
0020
0021 logger = logging.getLogger(__name__)
0022
0023
0024
0025 TTL_S = 120
0026 WINDOW_H = 24
0027
0028
0029 def _reading(queue_name):
0030 """Build the reading for one queue."""
0031 window = timezone.now() - timedelta(hours=WINDOW_H)
0032 out = {'queue': queue_name, 'window_hours': WINDOW_H}
0033 sql_now = f"""
0034 SELECT "status", count(*), min("submittime"), max("ncore")
0035 FROM "{PANDA_SCHEMA}"."harvester_workers"
0036 WHERE "computingsite" = %s
0037 AND "status" IN ('submitted', 'running')
0038 GROUP BY "status"
0039 """
0040
0041
0042 sql_started = f"""
0043 SELECT count(*),
0044 percentile_cont(0.5) WITHIN GROUP
0045 (ORDER BY extract(epoch FROM ("starttime" - "submittime"))),
0046 percentile_cont(0.9) WITHIN GROUP
0047 (ORDER BY extract(epoch FROM ("starttime" - "submittime"))),
0048 max("starttime"),
0049 sum(CASE WHEN "starttime" > now() - interval '1 hour' THEN 1 ELSE 0 END)
0050 FROM "{PANDA_SCHEMA}"."harvester_workers"
0051 WHERE "computingsite" = %s
0052 AND "starttime" IS NOT NULL
0053 AND "starttime" > %s
0054 """
0055 sql_ended = f"""
0056 SELECT "status", count(*)
0057 FROM "{PANDA_SCHEMA}"."harvester_workers"
0058 WHERE "computingsite" = %s
0059 AND "endtime" IS NOT NULL
0060 AND "endtime" > %s
0061 GROUP BY "status"
0062 """
0063 with connections['panda'].cursor() as cursor:
0064 cursor.execute(sql_now, [queue_name])
0065 now_rows = {status: (count, oldest, ncore)
0066 for status, count, oldest, ncore in cursor.fetchall()}
0067 cursor.execute(sql_started, [queue_name, window])
0068 started, wait_p50, wait_p90, last_start, started_last_hour = cursor.fetchone()
0069 cursor.execute(sql_ended, [queue_name, window])
0070 ended = {status: count for status, count in cursor.fetchall()}
0071
0072 pending, oldest, ncore = now_rows.get('submitted', (0, None, None))
0073 running, _, ncore_running = now_rows.get('running', (0, None, None))
0074 out['pending'] = pending
0075 out['running'] = running
0076 out['cores_per_worker'] = ncore or ncore_running
0077
0078 oldest = oldest.replace(tzinfo=dt_timezone.utc) if oldest else None
0079 last_start = last_start.replace(tzinfo=dt_timezone.utc) if last_start else None
0080 out['oldest_pending_at'] = oldest.isoformat() if oldest else None
0081 out['oldest_pending_s'] = (
0082 (timezone.now() - oldest).total_seconds() if oldest else None)
0083 out['started'] = started or 0
0084 out['started_last_hour'] = started_last_hour or 0
0085 out['wait_p50_s'] = float(wait_p50) if wait_p50 is not None else None
0086 out['wait_p90_s'] = float(wait_p90) if wait_p90 is not None else None
0087 out['last_start_at'] = last_start.isoformat() if last_start else None
0088 out['ended'] = ended
0089 return out
0090
0091
0092 def worker_reading(queue_name, refresh=False):
0093 """One queue's worker reading with its build time, or None on failure.
0094
0095 Returns the reading with ``built_at`` and ``age_seconds`` from the
0096 product store; an absent harvester record reads as zero workers,
0097 which is a fact about the queue, not a failure.
0098 """
0099 key = 'harvester_workers:v1:{}'.format(queue_name)
0100 try:
0101 product = get_product(key, lambda: _reading(queue_name),
0102 ttl_seconds=TTL_S, refresh=refresh)
0103 except Exception as e:
0104 logger.error('worker reading failed for %s: %s', queue_name, e)
0105 return {'queue': queue_name, 'error': str(e)}
0106 value = dict((product or {}).get('value') or {})
0107 value['built_at'] = (product or {}).get('built_at')
0108 value['age_seconds'] = (product or {}).get('age_seconds')
0109 return value
0110
0111
0112
0113
0114 FINAL_STATES = ('finished', 'failed', 'cancelled', 'missed')
0115
0116
0117 STALE_DAYS = 7
0118 MAX_HOURS = 168
0119
0120
0121 def worker_counts(site=None, hours=1):
0122 """Harvester workers (pilots) across the ePIC queues, read directly
0123 from PanDA's ``harvester_workers`` table.
0124
0125 Two readings are kept apart, because confusing them has misled before
0126 (BigPanDA's tally of workers that ended was read as jobs, 25-fold, on
0127 10/6 and 10/7):
0128
0129 - ``now``: the workers that exist at this moment, by state (a
0130 ``submitted`` worker waits in the batch system, a ``running`` one
0131 runs), by queue and resource type, with their cores;
0132 - ``window``: what happened in the last ``hours``: workers submitted,
0133 started, and ended, the ended ones by final state. A ``finished``
0134 worker is a batch job that ended normally; it says nothing about
0135 whether the PanDA jobs bound to it succeeded.
0136
0137 ``site`` is a queue name (computingsite), a SQL LIKE pattern when it
0138 contains ``%``. Raises on a failed read.
0139 """
0140 hours = max(1, min(int(hours or 1), MAX_HOURS))
0141 now = timezone.now()
0142 cutoff = now - timedelta(hours=hours)
0143 stale = now - timedelta(days=STALE_DAYS)
0144 site_sql, site_args = '', []
0145 if site:
0146 site_sql = (' AND "computingsite" LIKE %s' if '%' in site
0147 else ' AND "computingsite" = %s')
0148 site_args = [site]
0149 final = ', '.join(f"'{s}'" for s in FINAL_STATES)
0150 table = f'"{PANDA_SCHEMA}"."harvester_workers"'
0151 with connections['panda'].cursor() as cursor:
0152 cursor.execute(
0153 f'SELECT "computingsite", "status", "resourcetype", count(*), '
0154 f'coalesce(sum("ncore"), 0) FROM {table} '
0155 f'WHERE "status" NOT IN ({final}) AND "lastupdate" > %s{site_sql} '
0156 f'GROUP BY 1, 2, 3', [stale] + site_args)
0157 present = cursor.fetchall()
0158 cursor.execute(
0159 f'SELECT "computingsite", count(*) FROM {table} '
0160 f'WHERE "submittime" > %s{site_sql} GROUP BY 1', [cutoff] + site_args)
0161 submitted = dict(cursor.fetchall())
0162 cursor.execute(
0163 f'SELECT "computingsite", count(*) FROM {table} '
0164 f'WHERE "starttime" > %s{site_sql} GROUP BY 1', [cutoff] + site_args)
0165 started = dict(cursor.fetchall())
0166 cursor.execute(
0167 f'SELECT "computingsite", "status", "resourcetype", count(*), '
0168 f'coalesce(sum("njobs"), 0) FROM {table} '
0169 f'WHERE "endtime" > %s AND "status" IN ({final}){site_sql} '
0170 f'GROUP BY 1, 2, 3', [cutoff] + site_args)
0171 ended = cursor.fetchall()
0172
0173 def tally(rows):
0174 by_status, by_site, by_resourcetype = {}, {}, {}
0175 for computingsite, status, resourcetype, count, _ in rows:
0176 by_status[status] = by_status.get(status, 0) + count
0177 per_site = by_site.setdefault(computingsite, {})
0178 per_site[status] = per_site.get(status, 0) + count
0179 rt = resourcetype or 'unset'
0180 by_resourcetype[rt] = by_resourcetype.get(rt, 0) + count
0181 return by_status, by_site, by_resourcetype
0182
0183 now_status, now_site, now_rt = tally(present)
0184 cores = {}
0185 for _, status, _, _, ncore in present:
0186 cores[status] = cores.get(status, 0) + int(ncore)
0187 ended_status, ended_site, ended_rt = tally(ended)
0188 return {
0189 'now': {
0190 'workers': sum(now_status.values()),
0191 'by_status': now_status,
0192 'cores_by_status': cores,
0193 'by_site': now_site,
0194 'by_resourcetype': now_rt,
0195 },
0196 'window': {
0197 'hours': hours,
0198 'from': cutoff.isoformat(),
0199 'to': now.isoformat(),
0200 'submitted': sum(submitted.values()),
0201 'started': sum(started.values()),
0202 'ended': sum(ended_status.values()),
0203 'ended_by_status': ended_status,
0204 'jobs_bound_to_ended': sum(int(r[4]) for r in ended),
0205 'by_site': {
0206 s: {'submitted': submitted.get(s, 0), 'started': started.get(s, 0),
0207 'ended': ended_site.get(s, {})}
0208 for s in sorted(set(submitted) | set(started) | set(ended_site))},
0209 'ended_by_resourcetype': ended_rt,
0210 },
0211 }
0212
0213
0214
0215
0216
0217 ALLOCATION_LIVE_TTL_S = 120
0218 ALLOCATION_KEPT_TTL_S = 10 * 365 * 86400
0219
0220
0221 def _jsonable_timeline(tl):
0222 """The timeline with the worker's timestamps as ISO strings (UTC)."""
0223 worker = dict(tl.get('worker') or {})
0224 for key, value in list(worker.items()):
0225 if hasattr(value, 'isoformat'):
0226 if value.tzinfo is None:
0227 value = value.replace(tzinfo=dt_timezone.utc)
0228 worker[key] = value.isoformat()
0229 return dict(tl, worker=worker or None)
0230
0231
0232 def allocation_product(harvesterid, workerid, refresh=False):
0233 """One allocation's occupancy timeline (panda.queries.allocation_timeline)
0234 from the product store, ``allocation:v1:<harvester>:<worker>``.
0235
0236 A live allocation rebuilds every two minutes; an ended one is kept and
0237 served without rebuilding. A rebuild never replaces a record that holds
0238 the worker's batch start and end with one that has lost them. Raises
0239 when the allocation cannot be read or ran no job."""
0240 from .models import CachedProduct
0241 from .panda.queries import allocation_timeline
0242
0243 key = 'allocation:v1:{}:{}'.format(harvesterid, int(workerid))
0244 row = CachedProduct.objects.filter(key=key).first()
0245 stored = row.value if row is not None and row.built_at is not None else None
0246
0247 def build():
0248 tl = allocation_timeline(harvesterid, workerid)
0249 if tl is None:
0250 raise ValueError(f'no PanDA job is linked to worker {workerid} of harvester {harvesterid}')
0251 if tl.get('error'):
0252 raise RuntimeError(tl['error'])
0253 if (tl.get('bounds_from_jobs') and isinstance(stored, dict)
0254 and not stored.get('bounds_from_jobs')):
0255 return stored
0256 return _jsonable_timeline(tl)
0257
0258 kept = isinstance(stored, dict) and not stored.get('live')
0259 ttl = ALLOCATION_KEPT_TTL_S if kept else ALLOCATION_LIVE_TTL_S
0260 product = get_product(key, build, ttl_seconds=ttl, refresh=refresh)
0261 return product
0262