Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-10-20 10:15:20

0001 """The per-queue job census: the pool of not-yet-running work at each
0002 PanDA queue, in jobs and in hours at the queue's capacity, with the
0003 six-hour completion and failure counts that gate it (swf-epicprod
0004 docs/CONTINUOUS_PRODUCTION.md, The dispatcher: The pressure measure).
0005 
0006 The dispatcher's loop and the ready-queue page read one record from
0007 ``queue_census()``. The live counts read ``jobsactive4`` on every call.
0008 The calibration per queue (median and p90 finished walltime of
0009 production jobs, peak concurrent running, over the last 14 days) reads
0010 ``jobsarchived4`` and is cached for an hour: it moves slowly and its
0011 two queries cost about a second each. The six-hour window (finished,
0012 failed, fast failures) is read on every call.
0013 
0014 Hours at capacity is the depth the dispatcher regulates on: the
0015 not-started jobs of a queue times the queue's median finished walltime,
0016 divided by its running ceiling (the 14-day peak, or the current running
0017 count when that is higher). It is None where the queue has no
0018 calibration.
0019 """
0020 import logging
0021 from datetime import timedelta
0022 
0023 from django.core.cache import cache
0024 from django.db import connections
0025 from django.utils import timezone
0026 
0027 from .constants import PANDA_SCHEMA
0028 
0029 logger = logging.getLogger(__name__)
0030 
0031 # A starting job is on a slot: a job is recorded running late and works
0032 # for most of its life as starting (2026-10-05, median of the last 200
0033 # finished: 87 of 115 min at BNL_OSG_EPIC_PROD_1, 4.9 of 5.1 min at
0034 # UM_GREX_PanDA_1), so it counts as running, as in the ePIC job
0035 # throttler (swf-epicprod swf_epicprod/jedi/epic_job_throttler.py).
0036 NOT_STARTED = ('defined', 'waiting', 'assigned', 'activated', 'sent')
0037 RUNNING = ('running', 'starting')
0038 FINISHING = ('holding', 'transferring', 'merging')
0039 CALIBRATION_DAYS = 14
0040 GATE_HOURS = 6
0041 # A failure is fast when it dies in under this fraction of the queue's
0042 # median finished walltime (20 minutes where no median exists).
0043 FAST_FAILURE_FRACTION = 0.25
0044 FAST_FAILURE_FLOOR_S = 20 * 60
0045 # A queue's calibration counts from this many finished production jobs
0046 # in the window: one fast trial job read a p90 start latency of 0.04 h at
0047 # UM_GREX_PanDA_1 (2026-09-30). Below it the queue reads uncalibrated.
0048 CALIBRATION_MIN_JOBS = 20
0049 CALIBRATION_CACHE_KEY = 'panda:queue-census:calibration:v3'
0050 CALIBRATION_TTL_S = 3600
0051 # JEDI task statuses that end a task (as monitor_app.snapper_panda reads them).
0052 TASK_TERMINAL_STATUSES = ('done', 'finished', 'failed', 'broken', 'aborted',
0053                           'exhausted', 'passed')
0054 
0055 
0056 def _live_counts():
0057     """Per queue and status, the jobs in ``jobsactive4``: all jobs, their
0058     cores, and the production subset."""
0059     sql = f"""
0060         SELECT COALESCE("computingsite", 'unknown'), "jobstatus",
0061                COUNT(*),
0062                COALESCE(SUM(COALESCE("actualcorecount", "corecount", 1)), 0),
0063                COUNT(*) FILTER (WHERE "processingtype" = 'epicproduction')
0064         FROM "{PANDA_SCHEMA}"."jobsactive4"
0065         GROUP BY 1, 2
0066     """
0067     out = {}
0068     with connections['panda'].cursor() as cursor:
0069         cursor.execute(sql)
0070         for site, status, jobs, cores, prod in cursor.fetchall():
0071             out.setdefault(str(site), {})[str(status)] = {
0072                 'jobs': int(jobs or 0), 'cores': int(cores or 0),
0073                 'production': int(prod or 0)}
0074     return out
0075 
0076 
0077 def _tasks_active():
0078     """Per queue, the production tasks pinned to it that have not
0079     ended (canary and other test tasks excluded). By processing type,
0080     not VO: production tasks carry ``wlcg`` (the VO the server was
0081     commissioned under, set by the Standard Production configuration),
0082     the test and canary paths carry ``epic``; a VO filter here had read
0083     a queue with three live production tasks as none."""
0084     placeholders = ', '.join(['%s'] * len(TASK_TERMINAL_STATUSES))
0085     sql = f"""
0086         SELECT COALESCE("site", 'unknown'), COUNT(*)
0087         FROM "{PANDA_SCHEMA}"."jedi_tasks"
0088         WHERE "processingtype" = 'epicproduction'
0089           AND ("status" IS NULL OR "status" NOT IN ({placeholders}))
0090         GROUP BY 1
0091     """
0092     with connections['panda'].cursor() as cursor:
0093         cursor.execute(sql, list(TASK_TERMINAL_STATUSES))
0094         return {str(site): int(n or 0) for site, n in cursor.fetchall()}
0095 
0096 
0097 def _ungenerated():
0098     """Per queue, the rows of its non-terminal production tasks that
0099     JEDI has not generated jobs for: ``nFilesToBeUsed - nFilesUsed`` on
0100     each task's input dataset (the largest input where a task carries
0101     several, as an EVGEN task carries two pseudo inputs with one count).
0102     The work committed to a queue beyond its activated pool; the
0103     pressure front's phase-two depth counts it once the ePIC job
0104     throttler paces generation (swf-epicprod CONTINUOUS_PRODUCTION.md,
0105     Two regulators)."""
0106     placeholders = ', '.join(['%s'] * len(TASK_TERMINAL_STATUSES))
0107     sql = f"""
0108         SELECT site, SUM(remaining) FROM (
0109             SELECT COALESCE(t."site", 'unknown') AS site, t."jeditaskid",
0110                    MAX(GREATEST(COALESCE(d."nfilestobeused", 0) - COALESCE(d."nfilesused", 0), 0)) AS remaining
0111             FROM "{PANDA_SCHEMA}"."jedi_tasks" t
0112             JOIN "{PANDA_SCHEMA}"."jedi_datasets" d ON d."jeditaskid" = t."jeditaskid"
0113             WHERE t."processingtype" = 'epicproduction'
0114               AND (t."status" IS NULL OR t."status" NOT IN ({placeholders}))
0115               AND d."type" IN ('input', 'pseudo_input') AND d."masterid" IS NULL
0116             GROUP BY 1, 2
0117         ) per_task
0118         GROUP BY 1
0119     """
0120     with connections['panda'].cursor() as cursor:
0121         cursor.execute(sql, list(TASK_TERMINAL_STATUSES))
0122         return {str(site): int(n or 0) for site, n in cursor.fetchall()}
0123 
0124 
0125 def _calibration():
0126     """Per queue over the last CALIBRATION_DAYS days: the finished
0127     production jobs' median and p90 walltime in hours, their p90 start
0128     latency (creation to start) in hours and their count (the three
0129     measures None below CALIBRATION_MIN_JOBS jobs), and the peak
0130     number of concurrently running jobs of any type (a sweep over start
0131     and end times, the currently running jobs included)."""
0132     days = f"{CALIBRATION_DAYS} days"
0133     walltime_sql = f"""
0134         SELECT "computingsite", COUNT(*),
0135                PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM ("endtime" - "starttime"))),
0136                PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM ("endtime" - "starttime"))),
0137                PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM ("starttime" - "creationtime")))
0138         FROM "{PANDA_SCHEMA}"."jobsarchived4"
0139         WHERE "jobstatus" = 'finished' AND "processingtype" = 'epicproduction'
0140           AND "starttime" IS NOT NULL AND "endtime" > NOW() - INTERVAL %s
0141         GROUP BY 1
0142     """
0143     peak_sql = f"""
0144         WITH ev AS (
0145             SELECT "computingsite" AS s, "starttime" AS t, 1 AS d
0146             FROM "{PANDA_SCHEMA}"."jobsarchived4"
0147             WHERE "starttime" IS NOT NULL AND "endtime" > NOW() - INTERVAL %s
0148             UNION ALL
0149             SELECT "computingsite", "endtime", -1
0150             FROM "{PANDA_SCHEMA}"."jobsarchived4"
0151             WHERE "starttime" IS NOT NULL AND "endtime" > NOW() - INTERVAL %s
0152             UNION ALL
0153             SELECT "computingsite", "starttime", 1
0154             FROM "{PANDA_SCHEMA}"."jobsactive4"
0155             WHERE "jobstatus" IN ('running', 'starting') AND "starttime" IS NOT NULL
0156         )
0157         SELECT s, MAX(run) FROM (
0158             SELECT s, SUM(d) OVER (PARTITION BY s ORDER BY t, d ROWS UNBOUNDED PRECEDING) AS run
0159             FROM ev) x
0160         GROUP BY s
0161     """
0162     out = {}
0163     with connections['panda'].cursor() as cursor:
0164         cursor.execute(walltime_sql, [days])
0165         for site, n, med, p90, start_p90 in cursor.fetchall():
0166             if int(n or 0) < CALIBRATION_MIN_JOBS:
0167                 med = p90 = start_p90 = None
0168             out.setdefault(str(site), {}).update({
0169                 'finished_jobs': int(n or 0),
0170                 'median_walltime_h': round(float(med) / 3600.0, 3) if med is not None else None,
0171                 'p90_walltime_h': round(float(p90) / 3600.0, 3) if p90 is not None else None,
0172                 'p90_start_latency_h': (round(float(start_p90) / 3600.0, 2)
0173                                         if start_p90 is not None else None),
0174             })
0175         cursor.execute(peak_sql, [days, days])
0176         for site, peak in cursor.fetchall():
0177             out.setdefault(str(site), {})['peak_running'] = int(peak or 0)
0178     return {'built_at': timezone.now().isoformat(), 'days': CALIBRATION_DAYS,
0179             'queues': out}
0180 
0181 
0182 def calibration(refresh=False):
0183     """The cached calibration, rebuilt when absent, expired, or asked."""
0184     if not refresh:
0185         cached = cache.get(CALIBRATION_CACHE_KEY)
0186         if cached:
0187             return cached
0188     built = _calibration()
0189     cache.set(CALIBRATION_CACHE_KEY, built, CALIBRATION_TTL_S)
0190     return built
0191 
0192 
0193 def _gate_window(calib_queues):
0194     """Per queue over the last GATE_HOURS hours: finished and failed
0195     production jobs, and the failures that died fast."""
0196     rows = []
0197     with connections['panda'].cursor() as cursor:
0198         cursor.execute(f"""
0199             SELECT "computingsite", "jobstatus",
0200                    EXTRACT(EPOCH FROM ("endtime" - "starttime"))
0201             FROM "{PANDA_SCHEMA}"."jobsarchived4"
0202             WHERE "processingtype" = 'epicproduction'
0203               AND "endtime" > NOW() - INTERVAL %s
0204               AND "jobstatus" IN ('finished', 'failed')
0205         """, [f"{GATE_HOURS} hours"])
0206         rows = cursor.fetchall()
0207     out = {}
0208     for site, status, seconds in rows:
0209         site = str(site)
0210         g = out.setdefault(site, {'finished': 0, 'failed': 0, 'fast_failed': 0})
0211         if status == 'finished':
0212             g['finished'] += 1
0213             continue
0214         g['failed'] += 1
0215         med_h = (calib_queues.get(site) or {}).get('median_walltime_h')
0216         threshold = med_h * 3600.0 * FAST_FAILURE_FRACTION if med_h else FAST_FAILURE_FLOOR_S
0217         if seconds is not None and float(seconds) < threshold:
0218             g['fast_failed'] += 1
0219     return out
0220 
0221 
0222 def queue_census(refresh=False):
0223     """The census record: per queue the live pool by status, the
0224     calibration, the gate window, and the derived depth."""
0225     observed_at = timezone.now()
0226     live = _live_counts()
0227     tasks = _tasks_active()
0228     ungenerated = _ungenerated()
0229     calib = calibration(refresh=refresh)
0230     calib_queues = calib.get('queues') or {}
0231     gate = _gate_window(calib_queues)
0232     names = set(live) | set(calib_queues) | set(gate) | set(tasks)
0233     queues = {}
0234     for name in sorted(names):
0235         by_status = live.get(name, {})
0236         not_started = sum(v['jobs'] for s, v in by_status.items() if s in NOT_STARTED)
0237         not_started_prod = sum(v['production'] for s, v in by_status.items() if s in NOT_STARTED)
0238         running = sum(v['jobs'] for s, v in by_status.items() if s in RUNNING)
0239         finishing = sum(v['jobs'] for s, v in by_status.items() if s in FINISHING)
0240         c = calib_queues.get(name) or {}
0241         g = gate.get(name) or {'finished': 0, 'failed': 0, 'fast_failed': 0}
0242         peak = int(c.get('peak_running') or 0)
0243         ceiling = max(peak, running)
0244         med_h = c.get('median_walltime_h')
0245         hours = (round(not_started * med_h / ceiling, 2)
0246                  if med_h and ceiling > 0 else None)
0247         queues[name] = {
0248             'by_status': by_status,
0249             'not_started': not_started,
0250             'not_started_production': not_started_prod,
0251             'running': running,
0252             'finishing': finishing,
0253             'tasks_active': int(tasks.get(name) or 0),
0254             'ungenerated': int(ungenerated.get(name) or 0),
0255             'calibration': {
0256                 'median_walltime_h': med_h,
0257                 'p90_walltime_h': c.get('p90_walltime_h'),
0258                 'p90_start_latency_h': c.get('p90_start_latency_h'),
0259                 'finished_jobs': c.get('finished_jobs', 0),
0260                 'peak_running': peak,
0261             },
0262             'ceiling': ceiling,
0263             'hours_at_capacity': hours,
0264             'gate': {
0265                 'hours': GATE_HOURS,
0266                 'finished': g['finished'],
0267                 'failed': g['failed'],
0268                 'fast_failed': g['fast_failed'],
0269                 'useful_rate_per_h': round(g['finished'] / GATE_HOURS, 2),
0270             },
0271         }
0272     return {
0273         'observed_at': observed_at.isoformat(),
0274         'calibration_built_at': calib.get('built_at'),
0275         'calibration_days': calib.get('days', CALIBRATION_DAYS),
0276         'queues': queues,
0277     }