File indexing completed on 2026-11-09 10:47:34
0001 """Batch pools behind the PanDA queues: how full, and how deep the queue.
0002
0003 A PanDA queue's workers wait in somebody's batch pool, and the wait is
0004 decided there rather than in PanDA: what share of the pool is claimed,
0005 and how many jobs are already idle ahead of ours. Two pools are visible
0006 to us and are read by reporters, never in a request path:
0007
0008 - ``bnl-scdf``, the SCDF shared pool, read from the monitor host by
0009 ``scripts/pool-reporter.py`` (docs/POOL_REPORTER.md);
0010 - ``osg``, the pool the OSG submit host sees, carried in the osgsub01
0011 reporter's record (docs/OSG_SUBMIT_REPORTER.md).
0012
0013 Which pool a queue draws on is not declared anywhere: it is derived from
0014 the queue's own finished jobs. Every job record names the worker node it
0015 ran on, and a node's domain says whose pool it was in, so the mapping is
0016 evidence rather than a table someone maintains.
0017 """
0018 import json
0019 import logging
0020 from collections import Counter
0021 from datetime import datetime
0022
0023 from django.utils import timezone
0024
0025 from .cached_product import get_product
0026 from .host_reports import latest
0027
0028 logger = logging.getLogger(__name__)
0029
0030 POOLS = {
0031 'bnl-scdf': {'label': 'SCDF shared pool',
0032 'source': 'report',
0033 'report_key': 'bnl-scdf'},
0034 'osg': {'label': 'OSG pool',
0035 'source': 'osgsub01',
0036 'report_key': 'osgsub01'},
0037
0038
0039
0040
0041
0042
0043 'nersc-perlmutter': {'label': 'NERSC Perlmutter, allocation m3763',
0044 'source': 'sample',
0045 'queue_prefix': 'NERSC_Perlmutter'},
0046 }
0047
0048
0049
0050 SAMPLE_WINDOW_DAYS = 3
0051 SAMPLE_TTL_S = 300
0052 SAMPLE_SCAN_LIMIT = 200
0053
0054
0055
0056
0057 ATTRIBUTION_TTL_S = 6 * 3600
0058 ATTRIBUTION_KEY = 'queue_pool_attribution:v1'
0059
0060
0061 def domain_of(machine):
0062 """The pool-identifying part of a worker node name.
0063
0064 Job records carry ``slot1_14@spool1499.sdcc.bnl.gov``; the pool
0065 reporter carries the machine names its collector advertises. The
0066 domain is what the two have in common. A bare name contributes its
0067 leading letters, which groups the OSG pool's ``compute34`` names.
0068 """
0069 name = (machine or '').split('@')[-1].strip().lower()
0070 if not name:
0071 return ''
0072 if '.' in name:
0073 return name.split('.', 1)[1]
0074 return name.rstrip('0123456789') or name
0075
0076
0077 def _sample_pool(queue_name):
0078 """The sample-fed pool a queue belongs to by name, or None."""
0079 for pool, spec in POOLS.items():
0080 prefix = spec.get('queue_prefix')
0081 if prefix and (queue_name or '').startswith(prefix):
0082 return pool
0083 return None
0084
0085
0086 def _newest_sample(pool):
0087 """The newest pool sample among the finished jobs of the pool's
0088 queues in the window: the sample, and the job that carried it. The
0089 payload writes the sample under ``payload.pool`` of the job report
0090 the pilot ships as job metadata; the text match keeps the scan to
0091 rows that carry one."""
0092 from django.db import connections
0093 from .panda.constants import PANDA_SCHEMA
0094
0095 spec = POOLS[pool]
0096 sql = """
0097 SELECT j."pandaid", j."computingsite", j."modificationtime", m."metadata"
0098 FROM "{schema}"."jobsarchived4" j
0099 JOIN "{schema}"."metatable" m ON m."pandaid" = j."pandaid"
0100 WHERE j."computingsite" LIKE %s
0101 AND j."jobstatus" = 'finished'
0102 AND j."modificationtime" > now() - interval '{days} days'
0103 AND m."metadata" LIKE '%%pool-sample/%%'
0104 ORDER BY j."modificationtime" DESC
0105 LIMIT %s
0106 """.format(schema=PANDA_SCHEMA, days=int(SAMPLE_WINDOW_DAYS))
0107 best = None
0108 with connections['panda'].cursor() as cursor:
0109 cursor.execute(sql, [spec['queue_prefix'] + '%', SAMPLE_SCAN_LIMIT])
0110 for pandaid, queue, modtime, raw in cursor.fetchall():
0111 try:
0112 metadata = json.loads(raw) if isinstance(raw, str) else raw
0113 except (ValueError, TypeError):
0114 continue
0115 sample = ((metadata or {}).get('payload') or {}).get('pool') \
0116 if isinstance(metadata, dict) else None
0117 if not isinstance(sample, dict) or 'taken_at' not in sample:
0118 continue
0119 taken = sample.get('taken_at')
0120 if best is None or str(taken) > str(best['taken_at']):
0121 best = {'taken_at': taken, 'sample': sample,
0122 'pandaid': int(pandaid), 'queue': queue,
0123 'job_modified': modtime.isoformat() if modtime else None}
0124 if best is None:
0125
0126
0127
0128 return {'sample': None, 'searched_days': SAMPLE_WINDOW_DAYS,
0129 'searched_queues': spec['queue_prefix'] + '*'}
0130 return best
0131
0132
0133 def _sample_product(pool):
0134 """The pool's newest sample as a cached product."""
0135 return get_product('pool_sample:v1:{}'.format(pool),
0136 lambda: _newest_sample(pool),
0137 ttl_seconds=SAMPLE_TTL_S, async_first_fill=True)
0138
0139
0140 def _sample_reading(pool, spec):
0141 """The reading of a sample-fed pool in the shape the pages render."""
0142 product = _sample_product(pool)
0143 value = (product or {}).get('value') or {}
0144 sample = value.get('sample')
0145 if not sample:
0146 if value.get('searched_days'):
0147 reason = ('no finished job of the {} queues has carried a pool sample '
0148 'in the last {} days').format(value['searched_queues'],
0149 value['searched_days'])
0150 else:
0151 reason = 'the sample record is building; reload shortly'
0152 return {'pool': pool, 'label': spec['label'], 'error': reason,
0153 'absent': True, 'reported_at': None, 'age_seconds': None,
0154 'slots_unit': 'nodes'}
0155 taken_at = None
0156 try:
0157 taken_at = datetime.fromisoformat(str(value['taken_at']).replace('Z', '+00:00'))
0158 except (ValueError, TypeError, KeyError):
0159 pass
0160 out = {'pool': pool, 'label': spec['label'],
0161 'reported_at': taken_at,
0162 'age_seconds': ((timezone.now() - taken_at).total_seconds()
0163 if taken_at else None),
0164 'sampled_by': {'pandaid': value.get('pandaid'),
0165 'queue': value.get('queue'),
0166 'host': sample.get('host')},
0167 'slots_unit': 'nodes'}
0168 errors = sample.get('errors') or []
0169 if sample.get('error'):
0170 errors = [sample['error']] + list(errors)
0171 nodes = sample.get('partition_nodes') or {}
0172 total = nodes.get('total')
0173 allocated = (nodes.get('allocated') or 0) + (nodes.get('mixed') or 0)
0174 if total:
0175 out['slots'] = {'total': total, 'claimed': allocated,
0176 'unclaimed': nodes.get('idle') or 0}
0177 out['claimed_fraction'] = allocated / total
0178 machine = sample.get('machine') or {}
0179 if machine:
0180 out['queue'] = {'idle': machine.get('pending_jobs'),
0181 'running': machine.get('running_jobs'),
0182 'idle_nodes': machine.get('pending_nodes'),
0183 'running_nodes': machine.get('running_nodes'),
0184 'scope': 'the whole machine, partition {}'.format(
0185 sample.get('partition') or '?')}
0186 ours = sample.get('ours') or {}
0187 if ours:
0188 out['ours'] = {'idle': ours.get('pending_jobs'),
0189 'running': ours.get('running_jobs'),
0190 'oldest_pending_s': ours.get('oldest_pending_s'),
0191 'account': sample.get('account')}
0192 if errors:
0193 out['error'] = '; '.join(str(e) for e in errors)
0194 out['collector'] = 'squeue and sinfo on {}, carried by job {}'.format(
0195 sample.get('host') or '?', value.get('pandaid') or '?')
0196 return out
0197
0198
0199 def _pool_domains():
0200 """The machine domains each readable pool advertises."""
0201 out = {}
0202 for pool, spec in POOLS.items():
0203 if spec.get('source') == 'sample':
0204
0205 continue
0206 record = (latest(spec['report_key']) or {}).get('record') or {}
0207 if pool == 'osg':
0208 by_site = ((record.get('pool') or {}).get('slots_by_site')) or {}
0209
0210
0211
0212
0213 out[pool] = set()
0214 out[pool + ':queues'] = set((record.get('queues') or {}).keys())
0215 del by_site
0216 continue
0217 domains = ((record.get('reading') or {}).get('machine_domains')) or {}
0218 out[pool] = set(domains)
0219 return out
0220
0221
0222 def _attribution():
0223 """Queue to pool, from the worker nodes the queue's jobs ran on."""
0224 from django.db import connections
0225 from .panda.constants import PANDA_SCHEMA
0226
0227 domains = _pool_domains()
0228 osg_queues = domains.get('osg:queues') or set()
0229
0230
0231
0232 sql = """
0233 SELECT "computingsite", "modificationhost", count(*)
0234 FROM "{}"."jobsarchived4"
0235 WHERE "modificationtime" > now() - interval '2 days'
0236 AND "modificationhost" IS NOT NULL
0237 AND "starttime" IS NOT NULL
0238 GROUP BY 1, 2
0239 """.format(PANDA_SCHEMA)
0240 seen = {}
0241 try:
0242 with connections['panda'].cursor() as cursor:
0243 cursor.execute(sql)
0244 for site, host, count in cursor.fetchall():
0245 seen.setdefault(site, Counter())[domain_of(host)] += count
0246 except Exception as e:
0247 logger.error("queue pool attribution query failed: %s", e)
0248 return {'queues': {}, 'error': str(e)}
0249
0250 out = {}
0251 for site, counts in seen.items():
0252 if site in osg_queues:
0253 out[site] = {'pool': 'osg', 'evidence': 'the submit host submits it'}
0254 continue
0255 for pool, pool_domains in domains.items():
0256 if pool.endswith(':queues') or not pool_domains:
0257 continue
0258 hit = sum(n for d, n in counts.items() if d in pool_domains)
0259
0260
0261 if hit and hit >= 0.8 * sum(counts.values()):
0262 top = counts.most_common(1)[0][0]
0263 out[site] = {'pool': pool,
0264 'evidence': 'its workers ran on {}'.format(top)}
0265 break
0266 return {'queues': out}
0267
0268
0269 def queue_pools():
0270 """The queue-to-pool map, from the cached product."""
0271 product = get_product(ATTRIBUTION_KEY, _attribution,
0272 ttl_seconds=ATTRIBUTION_TTL_S,
0273 async_first_fill=True)
0274 return ((product or {}).get('value') or {}).get('queues') or {}
0275
0276
0277 def pool_reading(pool):
0278 """One pool's latest reading with its age, or None.
0279
0280 Returns ``{pool, label, slots, claimed_fraction, queue, age_seconds,
0281 error}``. A pool whose reporter has never run returns None; a pool
0282 whose reading carried an error keeps the error, because a stated
0283 failure and an absent reading are different facts.
0284 """
0285 spec = POOLS.get(pool)
0286 if not spec:
0287 return None
0288 if spec.get('source') == 'sample':
0289 return _sample_reading(pool, spec)
0290 report = latest(spec['report_key'])
0291 if not report:
0292 return None
0293 record = report.get('record') or {}
0294 out = {'pool': pool, 'label': spec['label'],
0295 'age_seconds': report.get('age_seconds'),
0296 'reported_at': report.get('reported_at')}
0297 if pool == 'osg':
0298 block = record.get('pool') or {}
0299 schedd = (record.get('schedd') or {}).get('workers') or {}
0300 total = block.get('slots_total')
0301
0302
0303
0304
0305 out['slots'] = {'total': total, 'admitted': block.get('slots_admitted')}
0306 out['queue'] = {'idle': schedd.get('idle'), 'running': schedd.get('running'),
0307 'held': schedd.get('held'), 'scope': 'our workers'}
0308 if block.get('error'):
0309 out['error'] = block['error']
0310 return out
0311 reading = record.get('reading') or {}
0312 if reading.get('error'):
0313 out['error'] = reading['error']
0314 return out
0315 out['slots'] = reading.get('slots') or {}
0316 out['cores'] = reading.get('cores') or {}
0317 out['claimed_fraction'] = reading.get('claimed_fraction')
0318 queue = dict(reading.get('queue') or {})
0319 queue['scope'] = 'the whole pool'
0320 out['queue'] = queue
0321 out['collector'] = reading.get('collector')
0322 return out
0323
0324
0325 def readings_by_queue(queue_names):
0326 """Pool readings keyed by queue name, for the queues asked about."""
0327 mapping = queue_pools()
0328 cache = {}
0329 out = {}
0330 for name in queue_names:
0331 entry = mapping.get(name)
0332 if not entry:
0333 sample_pool = _sample_pool(name)
0334 if not sample_pool:
0335 continue
0336 entry = {'pool': sample_pool,
0337 'evidence': 'its name; the pool is read by its own jobs'}
0338 pool = entry.get('pool')
0339 if pool not in cache:
0340 cache[pool] = pool_reading(pool)
0341 reading = cache[pool]
0342 if reading:
0343 out[name] = dict(reading, evidence=entry.get('evidence'))
0344 return out