Back to home page

EIC code displayed by LXR

 
 

    


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     # A pool no collector answers for: its reading is the sample a
0038     # worker took on the node (squeue and sinfo, before the container
0039     # started) and the payload carried in its report, the newest one
0040     # among the finished jobs of the pool's queues (swf-epicprod
0041     # docs/NERSC_PERLMUTTER.md, the pool sample). The queues are the
0042     # pool's by name; nothing else runs there.
0043     'nersc-perlmutter': {'label': 'NERSC Perlmutter, allocation m3763',
0044                          'source': 'sample',
0045                          'queue_prefix': 'NERSC_Perlmutter'},
0046 }
0047 
0048 # The newest sample a pool's jobs carried, looked for over this window
0049 # and served as a cached product: the page never reads the PanDA record.
0050 SAMPLE_WINDOW_DAYS = 3
0051 SAMPLE_TTL_S = 300
0052 SAMPLE_SCAN_LIMIT = 200
0053 
0054 # How long a queue's pool attribution is kept before the jobs are read
0055 # again. Where a queue's workers run changes when a site is added or a
0056 # queue is repointed, which is a matter of days, not minutes.
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         # No job of the pool's queues has carried a sample in the window:
0126         # a fact the page states. The read succeeded and found none, which
0127         # is not the empty-in-place-of-missing case the contract forbids.
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             # Attributed by queue name (queue_prefix), not by domain.
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             # The OSG pool reports slots by site rather than by machine;
0210             # its own reading of the pool is what identifies it, and the
0211             # site names are not node domains, so the OSG pool is matched
0212             # by the queues the submit host itself says it submits.
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     # Only jobs that actually ran name a worker node: a job that never
0230     # started carries the harvester host instead, and those rows would
0231     # put every queue in the pool the harvester happens to sit in.
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:                                    # noqa: BLE001
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             # A strong majority, so a stray node of another facility does
0260             # not carry a queue into a pool it does not run in.
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         # The OSG submit host counts the slots its queue's requirements
0302         # admit, not the pool's claimed share: the pool is not ours and
0303         # the collector does not answer state to us. What it does know
0304         # is its own workers, which is the queue ahead that we made.
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