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
0032
0033
0034
0035
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
0042
0043 FAST_FAILURE_FRACTION = 0.25
0044 FAST_FAILURE_FLOOR_S = 20 * 60
0045
0046
0047
0048 CALIBRATION_MIN_JOBS = 20
0049 CALIBRATION_CACHE_KEY = 'panda:queue-census:calibration:v3'
0050 CALIBRATION_TTL_S = 3600
0051
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 }