Back to home page

EIC code displayed by LXR

 
 

    


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 # Workers change by the minute; the page serves the last reading and
0024 # rebuilds behind it.
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     # Workers that started in the window, with what they waited: the
0041     # interval from the harvester's submission to the batch start.
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     # PanDA DB timestamps are naive UTC.
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:                                    # noqa: BLE001
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 # A worker in one of these states has ended; any other state is a worker
0113 # that exists now (waiting to start, or running).
0114 FINAL_STATES = ('finished', 'failed', 'cancelled', 'missed')
0115 # A worker that has not ended is still updated by its harvester; rows not
0116 # updated for this long are left out of the present count as abandoned.
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 # An allocation's occupancy timeline is kept as it is first built: the
0215 # harvester's worker record ages out of PanDA after three months, and the
0216 # plot of a slot must not lose its batch start and end with it.
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