Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-09 10:47:34

0001 """Payload parallelism of running production jobs, separate from allocation."""
0002 import re
0003 from collections import defaultdict
0004 from datetime import datetime, timezone
0005 
0006 from django.db import connections, transaction
0007 
0008 from .cached_product import get_product
0009 from .models import CachedProduct, EpicProdJob
0010 from .panda.queries import PANDA_SCHEMA
0011 
0012 KEY = 'processing_activity:v1'
0013 TTL = 60
0014 ES_STALE_SECONDS = 600
0015 ES_EVENTS_WINDOW_S = 3600
0016 
0017 
0018 def payload_threads(corecount, actualcorecount, eventservice, metrics, record, now):
0019     """ES busy work lanes, or the native payload's selected thread width.
0020 
0021     Each ES work lane runs one single-threaded payload. Native multicore
0022     allocations need the chosen payloadThreads report; allocation alone
0023     cannot establish that the payload uses those cores.
0024     """
0025     if eventservice:
0026         if not isinstance(record, dict) or not record.get('written_at'):
0027             return None
0028         if record.get('ended_at'):
0029             return 0
0030         age = now - float(record['written_at'])
0031         if not 0 <= age <= ES_STALE_SECONDS or 'in_flight' not in record:
0032             return None
0033         return len({unit['slot'] for unit in record['in_flight'] if 'slot' in unit})
0034     chosen = re.search(r'(?:^|\s)payloadThreads=(\d+)(?:\s|$)', metrics or '')
0035     if chosen:
0036         return int(chosen[1])
0037     # A native one-core job launches one processing thread. Do not turn an
0038     # unmeasured multicore reservation into an equivalent thread count.
0039     if int(corecount or 1) == 1 and int(actualcorecount or 1) == 1:
0040         return 1
0041     return None
0042 
0043 
0044 def closed_events_since(record, since):
0045     """The events in an Event Service harness record's closes that stood
0046     (registered their output) at or after ``since``, epoch seconds: the
0047     queue's production in that window, since an ES job finishes once in
0048     hours and its work arrives close by close."""
0049     if not isinstance(record, dict):
0050         return 0
0051     return sum(int(c.get('events') or 0) for c in record.get('closes') or ()
0052                if isinstance(c, dict) and c.get('ok') and float(c.get('ended_at') or 0) >= since)
0053 
0054 
0055 def build():
0056     now = datetime.now(timezone.utc)
0057     sql = f'''SELECT pandaid, computingsite, COALESCE(destinationsite,''),
0058                      corecount, actualcorecount, eventservice, jobmetrics
0059               FROM "{PANDA_SCHEMA}".jobsactive4
0060               WHERE jobstatus='running' AND workinggroup='EIC'
0061                 AND (prodsourcelabel='managed' OR processingtype='epicproduction')
0062                 AND COALESCE(processingtype,'') <> 'canary' '''
0063     with transaction.atomic(using='panda'):
0064         with connections['panda'].cursor() as cursor:
0065             cursor.execute("SET LOCAL statement_timeout = '20s'")
0066             cursor.execute(sql)
0067             rows = cursor.fetchall()
0068     records = dict(EpicProdJob.objects.filter(pandaid__in=[r[0] for r in rows if r[5]])
0069                    .values_list('pandaid', 'data'))
0070     def empty():
0071         return {'known_threads': 0, 'jobs': 0, 'unknown_jobs': 0,
0072                 'events_1h': 0, 'events_unknown_jobs': 0}
0073     queues, executors = defaultdict(empty), defaultdict(empty)
0074     for pid, queue, destination, cores, actual, es, metrics in rows:
0075         record = ((records.get(pid) or {}).get('es_record') or {}).get('record')
0076         threads = payload_threads(cores, actual, es, metrics, record, now.timestamp())
0077         for bucket in (queues[queue], executors[destination] if queue.startswith('BNL_OSG') else None):
0078             if bucket is None:
0079                 continue
0080             bucket['jobs'] += 1
0081             bucket['unknown_jobs'] += threads is None
0082             bucket['known_threads'] += threads or 0
0083     # Event Service events in the last hour, from the closes that stood in the
0084     # records of the queue's ES jobs, running or ended within the hour. A job
0085     # with no record yet is counted as unknown, never as none.
0086     since = now.timestamp() - ES_EVENTS_WINDOW_S
0087     ended_sql = f'''SELECT pandaid, computingsite FROM "{PANDA_SCHEMA}".jobsarchived4
0088                     WHERE endtime >= %s AND workinggroup='EIC' AND COALESCE(eventservice, 0) <> 0
0089                       AND (prodsourcelabel='managed' OR processingtype='epicproduction')
0090                       AND COALESCE(processingtype,'') <> 'canary' '''
0091     with transaction.atomic(using='panda'):
0092         with connections['panda'].cursor() as cursor:
0093             cursor.execute("SET LOCAL statement_timeout = '20s'")
0094             cursor.execute(ended_sql, [datetime.fromtimestamp(since, timezone.utc).replace(tzinfo=None)])
0095             ended = cursor.fetchall()
0096     es_jobs = [(pid, queue) for pid, queue, _d, _c, _a, es, _m in rows if es] + list(ended)
0097     missing = [pid for pid, _q in es_jobs if pid not in records]
0098     if missing:
0099         records.update(EpicProdJob.objects.filter(pandaid__in=missing).values_list('pandaid', 'data'))
0100     for pid, queue in es_jobs:
0101         record = ((records.get(pid) or {}).get('es_record') or {}).get('record')
0102         if isinstance(record, dict):
0103             queues[queue]['events_1h'] += closed_events_since(record, since)
0104         else:
0105             queues[queue]['events_unknown_jobs'] += 1
0106     # A count is never blanked. While any job of a queue or execution site is
0107     # unmeasured (an Event Service job before its first shipped record, a
0108     # multicore job without its thread report), the last complete count
0109     # stands until a complete one replaces it, and never below the part now
0110     # measured; with no complete count yet, the measured part.
0111     previous = (CachedProduct.objects.filter(key=KEY)
0112                 .values_list('value', flat=True).first()) or {}
0113     for scope, buckets in (('queues', queues), ('executors', executors)):
0114         held = previous.get(scope) or {}
0115         for name, bucket in buckets.items():
0116             if not bucket['unknown_jobs']:
0117                 bucket['threads'], bucket['threads_at'] = bucket['known_threads'], now.isoformat()
0118                 continue
0119             last = held.get(name) or {}
0120             if (last.get('threads') is not None and last.get('threads_at')
0121                     and last['threads'] > bucket['known_threads']):
0122                 bucket['threads'], bucket['threads_at'] = last['threads'], last['threads_at']
0123             else:
0124                 bucket['threads'], bucket['threads_at'] = bucket['known_threads'], None
0125     return {'queues': dict(queues), 'executors': dict(executors), 'observed_at': now.isoformat()}
0126 
0127 
0128 def product(refresh=False):
0129     return get_product(KEY, build, TTL, refresh=refresh, async_first_fill=True)