Back to home page

EIC code displayed by LXR

 
 

    


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

0001 #!/usr/bin/env python3
0002 """harvester-reporter.py — how the harvester's queues get work.
0003 
0004 Runs on a harvester host (pandaharvester01, osgsub01) and posts one record
0005 per run to swf-monitor (docs/HARVESTER_REPORTER.md): per queue, the
0006 harvester's job fetches since the last run (asked, got, the outcome, the
0007 last attempt), the workers it submitted and for which jobs, the worker
0008 counts the submitter last read, the waiting-worker limit the worker
0009 adjuster last applied; every failed call to the PanDA server by
0010 daemon, call and error class; the age of every harvester log; the
0011 harvester's process count; and, from the harvester's own database and its
0012 condor logs, its launch limits per queue, its workers now, the workers
0013 that ended in the interval with the batch system's reason for each that
0014 did not finish, and its service metrics.
0015 
0016 Standard library only, written to the host's Python 3.6. Every source that
0017 cannot be read is delivered as an error field rather than dropped. The logs
0018 are read from the position the previous run left, so each record covers
0019 exactly the lines appended since it; a rotated log restarts from its
0020 beginning and says so. Log times are UTC and delivered as such.
0021 
0022 Usage::
0023 
0024     harvester-reporter.py                 # collect and post
0025     harvester-reporter.py --print         # collect and print, post nothing
0026     harvester-reporter.py --report-as osgsub01-harvester   # the record's host key
0027 
0028 Configuration, from the environment or an environment file named by
0029 --env (mode 600): SWF_MONITOR_URL, SWF_REPORT_TOKEN.
0030 """
0031 import argparse
0032 import ast
0033 import configparser
0034 import glob
0035 import hashlib
0036 import json
0037 import os
0038 import re
0039 import socket
0040 import ssl
0041 import subprocess
0042 import sys
0043 import time
0044 from datetime import datetime, timedelta
0045 
0046 # The record's host key; --report-as overrides it.
0047 HOST = socket.gethostname().split('.')[0]
0048 LOG_DIR = '/var/log/harvester'
0049 HARVESTER_CFG = '/opt/harvester/etc/panda/panda_harvester.cfg'
0050 QUEUE_CONFIG = '/opt/harvester/etc/panda/panda_queueconfig.json'
0051 # Worker states the harvester leaves; every other state is a live worker.
0052 TERMINAL = ('finished', 'failed', 'cancelled', 'missed')
0053 # Condor logs read per run (the workers that did not finish, newest first),
0054 # the bytes read from the end of each, and the examples kept per reason.
0055 MAX_CONDOR_LOGS = 300
0056 CONDOR_LOG_TAIL = 256 * 1024
0057 EXAMPLES = 3
0058 REASONS_KEEP = 25
0059 # The window over which the workers that did not finish are reported.
0060 REASON_HOURS = 24
0061 DB_TIMEOUT_S = 60
0062 FETCHER_LOG = os.path.join(LOG_DIR, 'panda-job_fetcher.log')
0063 SUBMITTER_LOG = os.path.join(LOG_DIR, 'panda-submitter.log')
0064 ADJUSTER_LOG = os.path.join(LOG_DIR, 'panda-worker_adjuster.log')
0065 CALL_LOGS = (('communicator', os.path.join(LOG_DIR, 'panda-communicator.log')),
0066              ('propagator', os.path.join(LOG_DIR, 'panda-propagator.log')))
0067 DEFAULT_STATE = os.path.expanduser('~/.swf-harvester-reporter')
0068 POST_TIMEOUT_S = 30
0069 BUFFER_KEEP = 48
0070 # A run reads at most this much of one log; more is skipped and flagged.
0071 MAX_READ_BYTES = 64 * 1024 * 1024
0072 RECENT_PUSHES = 50
0073 LINE_KEEP = 400
0074 
0075 STAMP = r'^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d),\d+ '
0076 GOT_RE = re.compile(STAMP + r'panda\.log\.job_fetcher: \w+\s+run <queueName=([^>]+)> '
0077                     r'got (\d+) jobs for prodSourceLabel=(\S+) rtype=\S+ with (.*)$')
0078 GETTING_RE = re.compile(STAMP + r'panda\.log\.job_fetcher: \w+\s+run <queueName=([^>]+)> '
0079                         r'getting (\d+) jobs')
0080 SUBMITTED_RE = re.compile(STAMP + r'panda\.log\.submitter: \w+\s+run <[^>]*queue=([^ >]+)[^>]*> '
0081                           r'submitted a workerID=(\d+) for PandaID=(\d+)')
0082 STATUS_RE = re.compile(STAMP + r'panda\.log\.submitter: \w+\s+run <[^>]*queue=([^ >]+)[^>]*> '
0083                        r'workers status: (\{.*\})')
0084 CHUNKS_RE = re.compile(STAMP + r'panda\.log\.submitter: \w+\s+run <[^>]*queue=([^ >]+)[^>]*> '
0085                        r'got (\d+) job chunks')
0086 LIMIT_RE = re.compile(STAMP + r'panda\.log\.worker_adjuster: \w+\s+define_num_workers <site=([^>]+)> '
0087                       r'limiting max_queued_workers to min\(n_activated=(\d+), queue_limit=(\d+)\)')
0088 FAILED_RE = re.compile(STAMP + r'panda\.log\.(\w+): \w+\s+(\w+).*?failed to POST with '
0089                        r"<class '([\w.]+)'>")
0090 CLASS_RE = re.compile(r"<class '([\w.]+)'>:?(.*)")
0091 URL_RE = re.compile(r'with url: (/[\w/]+)')
0092 
0093 
0094 def now_iso():
0095     return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
0096 
0097 
0098 def utc(stamp):
0099     return stamp.replace(' ', 'T') + 'Z'
0100 
0101 
0102 # ── Log tailing from the previous run's position ─────────────────────────
0103 
0104 def read_since(path, positions, first_run):
0105     """The lines appended to ``path`` since the last run, and how the
0106     read went. ``positions`` is the state map, updated in place. On the
0107     first run nothing is counted: the position is set to the end so the
0108     next run covers a known interval. A new inode or a shorter file is a
0109     rotation: the read restarts from the file's beginning."""
0110     info = {'bytes': 0, 'rotated': False, 'truncated': False}
0111     try:
0112         st = os.stat(path)
0113     except OSError as exc:
0114         info['error'] = 'cannot stat: {}'.format(exc)
0115         return [], info
0116     prev = positions.get(path) or {}
0117     offset = prev.get('offset', 0)
0118     if first_run or not prev:
0119         positions[path] = {'inode': st.st_ino, 'offset': st.st_size}
0120         info['first_run'] = True
0121         return [], info
0122     if prev.get('inode') != st.st_ino or offset > st.st_size:
0123         info['rotated'] = True
0124         offset = 0
0125     to_read = st.st_size - offset
0126     if to_read > MAX_READ_BYTES:
0127         info['truncated'] = True
0128         offset = st.st_size - MAX_READ_BYTES
0129         to_read = MAX_READ_BYTES
0130     lines = []
0131     try:
0132         with open(path, 'rb') as handle:
0133             handle.seek(offset)
0134             chunk = handle.read(to_read)
0135         # A partial final line belongs to the next run.
0136         end = chunk.rfind(b'\n') + 1
0137         lines = chunk[:end].decode('utf-8', 'replace').splitlines()
0138         info['bytes'] = end
0139         positions[path] = {'inode': st.st_ino, 'offset': offset + end}
0140     except OSError as exc:
0141         info['error'] = 'cannot read: {}'.format(exc)
0142     return lines, info
0143 
0144 
0145 # ── The functions ────────────────────────────────────────────────────────
0146 
0147 def outcome_of(text):
0148     """(outcome, error_class) of a fetch line's tail: ``OK  : took 4.5 sec``,
0149     ``No jobs in PanDA  : took ...``, or ``failed to POST with <class ...>``."""
0150     text = text.strip()
0151     if text.startswith('failed to POST with'):
0152         found = CLASS_RE.search(text)
0153         cls = found.group(1).rsplit('.', 1)[-1] if found else 'unknown'
0154         return text[:LINE_KEEP], cls
0155     return text.split(' : took')[0].strip(), None
0156 
0157 
0158 def fetching(lines, info):
0159     queues = {}
0160     for line in lines:
0161         got = GOT_RE.match(line)
0162         if got:
0163             stamp, queue, n, label, tail = got.groups()
0164             q = queues.setdefault(queue, {'attempts': 0, 'jobs_got': 0, 'failed': 0,
0165                                           'errors': {}, 'asked': 0})
0166             outcome, cls = outcome_of(tail)
0167             q['attempts'] += 1
0168             q['jobs_got'] += int(n)
0169             if cls:
0170                 q['failed'] += 1
0171                 q['errors'][cls] = q['errors'].get(cls, 0) + 1
0172             q['last'] = {'at': utc(stamp), 'label': label, 'got': int(n),
0173                          'asked': q.get('_asking'), 'outcome': outcome,
0174                          'error_class': cls}
0175             if int(n) > 0:
0176                 q['last_got_jobs_at'] = utc(stamp)
0177             continue
0178         asking = GETTING_RE.match(line)
0179         if asking:
0180             q = queues.setdefault(asking.group(2), {'attempts': 0, 'jobs_got': 0, 'failed': 0,
0181                                                     'errors': {}, 'asked': 0})
0182             q['_asking'] = int(asking.group(3))
0183             q['asked'] += int(asking.group(3))
0184     for q in queues.values():
0185         q.pop('_asking', None)
0186     return {'read': info, 'queues': queues}
0187 
0188 
0189 def submission(lines, info):
0190     queues, pushes = {}, []
0191     for line in lines:
0192         hit = SUBMITTED_RE.match(line)
0193         if hit:
0194             stamp, queue, worker, pandaid = hit.groups()
0195             q = queues.setdefault(queue, {'workers_submitted': 0})
0196             q['workers_submitted'] += 1
0197             pushes.append({'at': utc(stamp), 'queue': queue,
0198                            'workerid': int(worker), 'pandaid': int(pandaid)})
0199             continue
0200         hit = STATUS_RE.match(line)
0201         if hit:
0202             stamp, queue, raw = hit.groups()
0203             q = queues.setdefault(queue, {'workers_submitted': 0})
0204             try:
0205                 q['workers_status'] = ast.literal_eval(raw)
0206             except (ValueError, SyntaxError):
0207                 q['workers_status'] = {'unparsed': raw[:LINE_KEEP]}
0208             q['workers_status_at'] = utc(stamp)
0209             continue
0210         hit = CHUNKS_RE.match(line)
0211         if hit:
0212             stamp, queue, n = hit.groups()
0213             q = queues.setdefault(queue, {'workers_submitted': 0})
0214             q['job_chunks'] = int(n)
0215             q['job_chunks_at'] = utc(stamp)
0216     return {'read': info, 'queues': queues, 'recent_pushes': pushes[-RECENT_PUSHES:],
0217             'pushes_in_interval': len(pushes)}
0218 
0219 
0220 def adjustment(lines, info, last):
0221     """Per queue, the waiting-worker limit the worker adjuster last
0222     applied, with when it logged it. Harvester computes it from its running
0223     workers (nQueueLimitWorkerRatio, Min, Max), so pq_table's
0224     nQueueLimitWorker is only its cap. The adjuster logs it only in some
0225     cycles (on osgsub01 on 2026-10-22, in bursts half an hour apart), so
0226     the last value seen is kept in ``last``, the state map, and delivered
0227     with its time."""
0228     queues = last
0229     for line in lines:
0230         hit = LIMIT_RE.match(line)
0231         if hit:
0232             stamp, queue, activated, limit = hit.groups()
0233             queues[queue] = {'queue_limit': int(limit), 'n_activated': int(activated),
0234                              'at': utc(stamp)}
0235     return {'read': info, 'queues': queues}
0236 
0237 
0238 def failed_calls(positions, first_run):
0239     out = {}
0240     for daemon, path in CALL_LOGS:
0241         lines, info = read_since(path, positions, first_run)
0242         counts, last = {}, None
0243         for line in lines:
0244             hit = FAILED_RE.match(line)
0245             if not hit:
0246                 continue
0247             stamp, _logger, call, cls = hit.groups()
0248             # The PanDA endpoint names the call better than the logging method.
0249             endpoint = URL_RE.search(line)
0250             key = '{} {}'.format(endpoint.group(1) if endpoint else call, cls.rsplit('.', 1)[-1])
0251             counts[key] = counts.get(key, 0) + 1
0252             last = {'at': utc(stamp), 'line': line[:LINE_KEEP]}
0253         out[daemon] = {'read': info, 'failed': sum(counts.values()),
0254                        'by_call_and_class': counts, 'last': last}
0255     return out
0256 
0257 
0258 def daemons():
0259     out, now = {}, time.time()
0260     for path in sorted(glob.glob(os.path.join(LOG_DIR, 'panda-*.log'))):
0261         try:
0262             st = os.stat(path)
0263         except OSError as exc:
0264             out[os.path.basename(path)] = {'error': str(exc)}
0265             continue
0266         out[os.path.basename(path)] = {'age_s': int(now - st.st_mtime), 'bytes': st.st_size}
0267     return out
0268 
0269 
0270 def processes():
0271     try:
0272         done = subprocess.run(['ps', '-eo', 'args'], stdout=subprocess.PIPE,
0273                               stderr=subprocess.PIPE, universal_newlines=True, timeout=30)
0274     except Exception as exc:                                  # noqa: BLE001
0275         return {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0276     rows = [r for r in done.stdout.splitlines() if 'pandaharvester' in r and 'harvester-reporter' not in r]
0277     return {'harvester': len(rows)}
0278 
0279 
0280 # ── The harvester's database and condor logs ────────────────────────────
0281 
0282 def db_conf():
0283     """The harvester's database settings from its configuration file."""
0284     parser = configparser.RawConfigParser(strict=False)
0285     if not parser.read(HARVESTER_CFG):
0286         raise RuntimeError('cannot read {}'.format(HARVESTER_CFG))
0287     section = parser['db']
0288     return {key: (section.get(key) or '').strip()
0289             for key in ('engine', 'host', 'port', 'user', 'password', 'schema')}
0290 
0291 
0292 UNESCAPE_RE = re.compile(r'\\(.)')
0293 UNESCAPE = {'n': '\n', 't': '\t', '0': '\0', '\\': '\\'}
0294 
0295 
0296 def query(conf, sql):
0297     """Rows of ``sql`` from the harvester's MariaDB through the mysql client
0298     (standard library only), NULL as None. Raises with the client's error."""
0299     env = dict(os.environ, MYSQL_PWD=conf['password'])
0300     cmd = ['mysql', '--batch', '--skip-column-names', '-u', conf['user'],
0301            '-h', conf['host'] or 'localhost', '-P', conf['port'] or '3306',
0302            conf['schema'], '-e', sql]
0303     done = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
0304                           universal_newlines=True, timeout=DB_TIMEOUT_S, env=env)
0305     if done.returncode != 0:
0306         raise RuntimeError((done.stderr or 'mysql exit {}'.format(done.returncode)).strip()[:LINE_KEEP])
0307     rows = []
0308     for line in done.stdout.splitlines():
0309         rows.append([None if cell == 'NULL' else
0310                      UNESCAPE_RE.sub(lambda m: UNESCAPE.get(m.group(1), m.group(1)), cell)
0311                      for cell in line.split('\t')])
0312     return rows
0313 
0314 
0315 def pilot_dispatch(template):
0316     """Dispatch capability from the actual submit template, never queue history."""
0317     result = {'mode': 'unknown', 'images': [], 'source': template or ''}
0318     try:
0319         with open(template) as handle:
0320             text = handle.read()
0321     except (OSError, TypeError) as exc:
0322         result['error'] = 'pilot template unreadable: {}'.format(exc)
0323         return result
0324     result['sha256'] = hashlib.sha256(text.encode()).hexdigest()
0325     images = re.findall(r'(?m)^\+EpicprodNativeImage\s*=\s*"([^"\n]+)"', text)
0326     images += re.findall(r'(?:^|[\s"])EPICPROD_NATIVE_IMAGE=([^\s"]+)', text)
0327     if images:
0328         images += re.findall(r'(?m)^\+SingularityImage\s*=\s*"([^"\n]+)"', text)
0329     images = sorted(set(images))
0330     mode = re.search(r'(?m)^\+EpicprodNativeMode\s*=\s*"([^"\n]+)"', text)
0331     if mode and mode.group(1) == 'per-job':
0332         try:
0333             if ('EPICPROD_NATIVE_DISPATCH=per-job' not in text
0334                     or 'EPICPROD_NATIVE_NESTING=on' not in text):
0335                 raise ValueError('per-job mode lacks dispatch or payload-launch configuration')
0336             inputs = re.search(r'(?m)^transfer_input_files\s*=\s*(.*)$', text)
0337             launchers = [p.strip() for p in inputs.group(1).split(',')
0338                          if os.path.basename(p.strip()) == 'epic-native-pilot.sh'] if inputs else []
0339             if len(launchers) != 1:
0340                 raise ValueError('per-job launcher is not uniquely established')
0341             with open(launchers[0]) as handle:
0342                 launcher = handle.read()
0343             if 'EPICPROD_NATIVE_DISPATCH' not in launcher or 'unset PILOT_CONTAINER_NAME' not in launcher:
0344                 raise ValueError('launcher does not implement per-job dispatch')
0345             result.update(mode='any', images=[], bootstrap_images=images,
0346                           launcher_sha256=hashlib.sha256(launcher.encode()).hexdigest())
0347         except (OSError, ValueError) as exc:
0348             result['error'] = 'per-job dispatch unavailable: {}'.format(exc)
0349         return result
0350     if images:
0351         if len(images) != 1 or any(c in images[0] for c in '{}$'):
0352             result['error'] = 'pilot image binding is conflicting or unresolved'
0353         else:
0354             result.update(mode='bound', images=images)
0355             allocation = re.search(r'(?m)^\+EpicprodImagePolicyFile\s*=\s*"([^"\n]+)"', text)
0356             if allocation:
0357                 try:
0358                     with open(allocation.group(1)) as handle:
0359                         policy = json.load(handle)
0360                     state_path = os.path.join(os.path.dirname(allocation.group(1)), 'allocation-state.json')
0361                     with open(state_path) as handle:
0362                         state = json.load(handle)
0363                     until = datetime.strptime(state['valid_until'], '%Y-%m-%dT%H:%M:%SZ')
0364                     if (state.get('mode') != 'active' or until <= datetime.utcnow()
0365                             or state.get('images') != policy['images']
0366                             or state.get('queue') != policy['queue']):
0367                         raise ValueError('image allocator state is stale or disagrees with policy')
0368                     result.update(images=policy['images'], allocation_policy=allocation.group(1),
0369                                   allocation_observed_at=state['observed_at'])
0370                 except (OSError, ValueError, KeyError, TypeError) as exc:
0371                     result.update(mode='unknown', error='image allocator unavailable: {}'.format(exc))
0372         return result
0373     executable = re.search(r'(?m)^executable\s*=\s*(\S+)\s*$', text)
0374     wrapper = re.search(r'EPICPROD_STANDARD_WRAPPER=([^\s"]+)', text)
0375     legacy = ('bnlpanda.runpilot2-wrapper.sh', 'epicprod-multipilot.sh')
0376     if (executable and os.path.basename(executable.group(1)) in legacy
0377             and (not wrapper or os.path.basename(wrapper.group(1)) == legacy[0])
0378             and 'EpicprodNative' not in text and 'EPICPROD_NATIVE_' not in text):
0379         result['mode'] = 'any'
0380     else:
0381         result['error'] = 'pilot launcher image capability is not established'
0382     return result
0383 
0384 
0385 def queue_status():
0386     """Each queue's ``queueStatus`` in the harvester queue configuration, a
0387     copy the queue registry checks (swf-epicprod docs/QUEUE_REGISTRY.md);
0388     None where an entry sets none."""
0389     try:
0390         with open(QUEUE_CONFIG) as handle:
0391             config = json.load(handle)
0392     except (OSError, ValueError) as exc:
0393         return {'error': 'cannot read {}: {}'.format(QUEUE_CONFIG, exc)}
0394     blocks = {q: b for q, b in config.items() if isinstance(b, dict)}
0395     return {'status': {q: b.get('queueStatus') for q, b in blocks.items()},
0396             'pilot_dispatch': {q: pilot_dispatch((b.get('submitter') or {}).get('templateFile'))
0397                                for q, b in blocks.items()}}
0398 
0399 
0400 def log_roots():
0401     """(logBaseURL, logDir) pairs from the queue configuration: a worker's
0402     log URL under a base URL is the file under that directory here."""
0403     try:
0404         with open(QUEUE_CONFIG) as handle:
0405             config = json.load(handle)
0406     except (OSError, ValueError):
0407         return []
0408     pairs = set()
0409 
0410     def walk(node):
0411         if isinstance(node, dict):
0412             if node.get('logBaseURL') and node.get('logDir'):
0413                 pairs.add((node['logBaseURL'].rstrip('/'), node['logDir'].rstrip('/')))
0414             for value in node.values():
0415                 walk(value)
0416         elif isinstance(node, list):
0417             for value in node:
0418                 walk(value)
0419     walk(config)
0420     return sorted(pairs, key=lambda p: -len(p[0]))
0421 
0422 
0423 def local_log(url, roots):
0424     if not url:
0425         return None
0426     for base, directory in roots:
0427         if url.startswith(base):
0428             return directory + '/' + url[len(base):].lstrip('/')
0429     return None
0430 
0431 
0432 EVENT_RE = re.compile(r'^(\d{3}) \(([\d.]+)\) (\S+ \S+) (.*)$')
0433 # The events that end a worker or explain why it did not run on.
0434 REASON_EVENTS = {'005', '007', '009', '012', '021', '022', '024'}
0435 
0436 
0437 def condor_reason(path):
0438     """The batch system's own account of how the worker ended: the last
0439     terminal or hold event of its condor event log, with its detail lines
0440     (``Job was aborted. removed by SYSTEM_PERIODIC_REMOVE due to ...``)."""
0441     try:
0442         size = os.path.getsize(path)
0443         with open(path, 'rb') as handle:
0444             handle.seek(max(0, size - CONDOR_LOG_TAIL))
0445             text = handle.read().decode('utf-8', 'replace')
0446     except OSError as exc:
0447         return None, 'cannot read: {}'.format(exc)
0448     last, current = None, None
0449     for line in text.splitlines():
0450         hit = EVENT_RE.match(line)
0451         if hit:
0452             current = {'code': hit.group(1), 'at': hit.group(3), 'text': [hit.group(4).strip()]}
0453             if current['code'] in REASON_EVENTS:
0454                 last = current
0455             continue
0456         if line.startswith('...'):
0457             current = None
0458         elif current is not None and line.strip():
0459             current['text'].append(line.strip())
0460     if last is None:
0461         return None, None
0462     reason = ' '.join(last['text'])
0463     return {'code': last['code'], 'at': last['at'],
0464             'reason': re.sub(r'\s+', ' ', reason)[:LINE_KEEP]}, None
0465 
0466 
0467 def median(values):
0468     values = sorted(v for v in values if v is not None)
0469     if not values:
0470         return None
0471     mid = len(values) // 2
0472     return values[mid] if len(values) % 2 else (values[mid - 1] + values[mid]) / 2.0
0473 
0474 
0475 def harvester_db(since, until, cache, admission_cache=None):
0476     """The harvester's launch limits per queue, its workers now, the workers
0477     that ended in (since, until], the workers that did not finish over the
0478     last REASON_HOURS with the batch system's reason for each, and its
0479     latest service metrics. Each part that cannot be read is an error
0480     field."""
0481     out = {'window': {'since': since, 'until': until}}
0482     try:
0483         conf = db_conf()
0484     except Exception as exc:                                  # noqa: BLE001
0485         return dict(out, error='{}: {}'.format(exc.__class__.__name__, exc))
0486 
0487     try:
0488         cols = ('queue', 'site', 'job_type', 'resource_type', 'n_queue_limit_worker',
0489                 'max_workers', 'n_new_workers', 'n_queue_limit_job', 'job_fetch_time', 'submit_time',
0490                 'n_queue_limit_worker_ratio', 'n_queue_limit_worker_min', 'n_queue_limit_worker_max')
0491         rows = query(conf, 'SELECT queueName, siteName, jobType, resourceType, nQueueLimitWorker, '
0492                            'maxWorkers, nNewWorkers, nQueueLimitJob, jobFetchTime, submitTime, '
0493                            'nQueueLimitWorkerRatio, nQueueLimitWorkerMin, nQueueLimitWorkerMax FROM pq_table')
0494         out['launch_limits'] = [dict(zip(cols, r)) for r in rows]
0495     except Exception as exc:                                  # noqa: BLE001
0496         out['launch_limits'] = {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0497 
0498     try:
0499         rows = query(conf, "SELECT computingSite, status, COUNT(*) FROM work_table "
0500                            "WHERE status NOT IN ('{}') GROUP BY 1, 2".format("','".join(TERMINAL)))
0501         now = {}
0502         for site, status, n in rows:
0503             now.setdefault(site or '', {})[status] = int(n)
0504         out['workers_now'] = now
0505     except Exception as exc:                                  # noqa: BLE001
0506         out['workers_now'] = {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0507 
0508     try:
0509         rows = query(conf, "SELECT computingSite, COUNT(*) FROM work_table "
0510                            "WHERE startTime > '{}' AND startTime <= '{}' GROUP BY 1".format(since, until))
0511         out['workers_started'] = {site: int(n) for site, n in rows}
0512         for site in out.get('workers_now', {}):
0513             out['workers_started'].setdefault(site, 0)
0514     except Exception as exc:
0515         out['workers_started'] = {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0516 
0517     try:
0518         rows = query(conf, "SELECT mainKey, lastUpdate FROM cache_table WHERE mainKey IN "
0519                            "('job_statistics.json','job_statistics_new.json','panda_queues.json')")
0520         out['cache_updates'] = dict(rows)
0521     except Exception as exc:
0522         out['cache_updates'] = {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0523 
0524     try:
0525         out['workers_ended'] = workers_ended(conf, since, until)
0526     except Exception as exc:                                  # noqa: BLE001
0527         out['workers_ended'] = {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0528 
0529     try:
0530         out['admission_declines'] = admission_declines(conf, until, admission_cache if admission_cache is not None else {})
0531     except Exception as exc:
0532         out['admission_declines'] = {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0533 
0534     try:
0535         out['not_finished'] = not_finished(conf, until, cache)
0536     except Exception as exc:                                  # noqa: BLE001
0537         out['not_finished'] = {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0538 
0539     try:
0540         rows = query(conf, 'SELECT hostName, creationTime, LEFT(metrics, 2000) FROM sm_table '
0541                            'ORDER BY creationTime DESC LIMIT 5')
0542         metrics = {}
0543         for host, created, raw in rows:
0544             if host in metrics:
0545                 continue
0546             try:
0547                 value = json.loads(raw) if raw else None
0548             except ValueError:
0549                 value = raw
0550             metrics[host or ''] = {'at': created, 'metrics': value}
0551         out['service_metrics'] = metrics
0552     except Exception as exc:                                  # noqa: BLE001
0553         out['service_metrics'] = {'error': '{}: {}'.format(exc.__class__.__name__, exc)}
0554     return out
0555 
0556 
0557 def admission_evidence(batch_log, roots):
0558     """Bounded local startup evidence. No remote fetch and no secrets copied."""
0559     path = local_log(batch_log, roots)
0560     if not path:
0561         return {}
0562     stem = os.path.splitext(path)[0]
0563     texts = {}
0564     for suffix in ('.err', '.out', '.log'):
0565         try:
0566             with open(stem + suffix, 'rb') as handle:
0567                 handle.seek(max(0, os.path.getsize(stem + suffix) - 16384))
0568                 texts[suffix] = handle.read().decode('utf-8', 'replace')
0569         except OSError:
0570             texts[suffix] = ''
0571     err, out, event = texts['.err'], texts['.out'], texts['.log']
0572     budget = re.search(r'glidein life (-?\d+) s is under the (\d+) s a job needs; no job taken', err)
0573     if not budget:
0574         return {}
0575     site = re.search(r'execution queue=(.*?); minimum=', err)
0576     host = re.search(r'ePIC native pilot: image .*?, host ([^, ]+)', out)
0577     slot = re.search(r'Message from starter on (\S+):', event)
0578     if not slot:
0579         slot = re.search(r'SlotName:\s*(\S+)', event)
0580     busy = re.search(r'TimeSlotBusy \(s\)\s*:\s*(\d+)', event)
0581     execute = re.search(r'TimeExecute \(s\)\s*:\s*(\d+)', event)
0582     return {'reason': 'Insufficient remaining glidein lifetime; no job taken',
0583             'remaining_s': int(budget.group(1)), 'required_s': int(budget.group(2)),
0584             'execution_queue': site.group(1) if site else None,
0585             'node': host.group(1) if host else None,
0586             'slot': slot.group(1) if slot else None,
0587             'busy_s': int(busy.group(1)) if busy else None,
0588             'execute_s': int(execute.group(1)) if execute else None}
0589 
0590 
0591 def admission_declines(conf, until, cache):
0592     """Native OSG pre-job exit-73 refusals over one hour, including repeat slots."""
0593     floor = (datetime.strptime(until, '%Y-%m-%d %H:%M:%S')
0594              - timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')
0595     rows = query(conf, ("SELECT workerID, batchID, computingSite, startTime, endTime, "
0596         "JSON_UNQUOTE(JSON_EXTRACT(workAttributes, '$.batchLog')) FROM work_table "
0597         "WHERE computingSite='BNL_OSG_PanDA_1' AND nativeExitCode=73 "
0598         "AND startTime>'{}' AND startTime<='{}' ORDER BY endTime DESC").format(floor, until))
0599     roots = log_roots()
0600     groups, samples, seen, reads = {}, [], set(), 0
0601     busy_total, execute_total, measured = 0, 0, 0
0602     for worker, batch, queue, started, ended, batch_log in rows:
0603         seen.add(worker)
0604         evidence = cache.get(worker)
0605         if evidence is None and reads < 4000:
0606             reads += 1
0607             evidence = admission_evidence(batch_log, roots)
0608             if evidence:
0609                 cache[worker] = evidence
0610         evidence = evidence or {}
0611         key = (evidence.get('execution_queue') or 'unknown', evidence.get('node') or 'unknown',
0612                evidence.get('slot') or 'unknown', evidence.get('required_s'))
0613         group = groups.setdefault(key, {'execution_queue': key[0], 'node': key[1], 'slot': key[2],
0614             'count': 0, 'remaining_min_s': None, 'remaining_max_s': None,
0615             'required_s': evidence.get('required_s'), 'batch_log': batch_log})
0616         group['count'] += 1
0617         remaining = evidence.get('remaining_s')
0618         if remaining is not None:
0619             group['remaining_min_s'] = remaining if group['remaining_min_s'] is None else min(remaining, group['remaining_min_s'])
0620             group['remaining_max_s'] = remaining if group['remaining_max_s'] is None else max(remaining, group['remaining_max_s'])
0621         if evidence.get('busy_s') is not None:
0622             measured += 1
0623             busy_total += evidence['busy_s']
0624             execute_total += evidence.get('execute_s') or 0
0625         if len(samples) < 20:
0626             samples.append(dict(evidence, workerid=int(worker), batchid=batch,
0627                                 queue=queue, started=started, ended=ended, batch_log=batch_log))
0628     for worker in list(cache):
0629         if worker not in seen:
0630             del cache[worker]
0631     ranked = sorted((row for row in groups.values() if row['required_s'] is not None),
0632                     key=lambda row: -row['count'])
0633     return {'hours': 1, 'since': floor, 'until': until, 'declined': len(rows),
0634             'classified': sum(row['count'] for row in ranked if row['required_s'] is not None),
0635             'repeated_slot_starts': sum(max(0, row['count']-1) for row in ranked if row['slot'] != 'unknown'),
0636             'busy_s': busy_total, 'execute_s': execute_total, 'busy_measured_workers': measured,
0637             'placements': ranked[:60], 'placements_total': len(ranked), 'samples': samples}
0638 
0639 
0640 def workers_ended(conf, since, until):
0641     """Per site, the workers that ended in the interval, by status, and
0642     the median run of the finished ones."""
0643     rows = query(conf, (
0644         "SELECT computingSite, status, TIMESTAMPDIFF(SECOND, startTime, endTime) "
0645         "FROM work_table WHERE endTime > '{}' AND endTime <= '{}'"
0646     ).format(since, until))
0647     sites = {}
0648     for site, status, run_s in rows:
0649         s = sites.setdefault(site or '', {'ended': 0, 'by_status': {}, '_runs': []})
0650         s['ended'] += 1
0651         s['by_status'][status] = s['by_status'].get(status, 0) + 1
0652         # A worker removed before it ran has no run; the median is of finished workers.
0653         if status == 'finished' and run_s is not None and int(run_s) >= 0:
0654             s['_runs'].append(int(run_s))
0655     for s in sites.values():
0656         s['median_finished_run_s'] = median(s.pop('_runs'))
0657     return {'sites': sites, 'workers': len(rows)}
0658 
0659 
0660 def not_finished(conf, until, cache):
0661     """Per site, over the REASON_HOURS before ``until``: every worker that
0662     did not finish, or finished with a nonzero batch exit, grouped by the
0663     batch system's reason, read from its condor log, else the harvester's
0664     diagnostic. ``cache`` (the reporter's state, workerID to reason) keeps
0665     a log read once; entries older than the window are dropped."""
0666     floor = (datetime.strptime(until, '%Y-%m-%d %H:%M:%S')
0667              - timedelta(hours=REASON_HOURS)).strftime('%Y-%m-%d %H:%M:%S')
0668     rows = query(conf, (
0669         "SELECT workerID, batchID, computingSite, status, nativeStatus, endTime, "
0670         "LEFT(diagMessage, 300), JSON_UNQUOTE(JSON_EXTRACT(workAttributes, '$.batchLog')), "
0671         "nodeID, computingElement, nativeExitCode FROM work_table "
0672         "WHERE endTime > '{}' AND endTime <= '{}' AND (status <> 'finished' "
0673         "OR (nativeExitCode IS NOT NULL AND nativeExitCode <> 0)) ORDER BY endTime DESC"
0674     ).format(floor, until))
0675     roots = log_roots()
0676     sites, logs_read, log_errors, seen = {}, 0, 0, set()
0677     for worker, batch, site, status, native, ended, diag, batch_log, node, ce, exit_code in rows:
0678         # Native OSG exit 73 is the launcher's deliberate pre-job refusal.
0679         # It belongs in admission telemetry, below the operational error feed.
0680         if (site == 'BNL_OSG_PanDA_1' and str(exit_code) == '73') or (
0681                 status == 'cancelled' and str(exit_code) in ('143', '-15')):
0682             continue
0683         seen.add(worker)
0684         reason = cache.get(worker)
0685         if reason is None:
0686             path = local_log(batch_log, roots)
0687             found = None
0688             if path and logs_read < MAX_CONDOR_LOGS:
0689                 logs_read += 1
0690                 found, error = condor_reason(path)
0691                 if error:
0692                     log_errors += 1
0693             if found or logs_read < MAX_CONDOR_LOGS:
0694                 # A log not read for the run's cap is tried again next run.
0695                 reason = {'text': (found or {}).get('reason'), 'event': (found or {}).get('code')}
0696                 cache[worker] = reason
0697         text = ((reason or {}).get('text') or (diag or '').strip() or native or status)
0698         s = sites.setdefault(site or '', {'workers': 0, 'reasons': {}})
0699         s['workers'] += 1
0700         key = '{} | {}'.format(status, text)
0701         entry = s['reasons'].setdefault(key, {
0702             'status': status, 'native_status': native,
0703             'source': 'condor log' if (reason or {}).get('text') else 'harvester diagnostic',
0704             'event': (reason or {}).get('event'), 'reason': text, 'count': 0,
0705             'last_ended': ended, 'examples': []})
0706         entry['count'] += 1
0707         if len(entry['examples']) < EXAMPLES:
0708             entry['examples'].append({'workerid': int(worker), 'batchid': batch, 'ended': ended,
0709                                       'node': node, 'ce': ce, 'batch_log': batch_log})
0710     for worker in [w for w in cache if w not in seen]:
0711         del cache[worker]
0712     for s in sites.values():
0713         reasons = sorted(s['reasons'].values(), key=lambda e: -e['count'])
0714         s['reasons'] = reasons[:REASONS_KEEP]
0715         s['reasons_dropped'] = max(0, len(reasons) - REASONS_KEEP)
0716     return {'hours': REASON_HOURS, 'sites': sites, 'workers': sum(v['workers'] for v in sites.values()),
0717             'condor_logs_read': logs_read, 'condor_log_errors': log_errors}
0718 
0719 
0720 def disk_health():
0721     """Local service volumes, before log or scheduler writes run out of room."""
0722     volumes = {}
0723     for mount in ('/', '/var', '/data', '/tmp'):
0724         try:
0725             st = os.statvfs(mount)
0726             total = st.f_blocks * st.f_frsize
0727             free = st.f_bavail * st.f_frsize
0728             volumes[mount] = {
0729                 'device': os.stat(mount).st_dev,
0730                 'total_bytes': total, 'free_bytes': free,
0731                 'used_percent': round(100.0 * (total - free) / total, 1)
0732                 if total else None}
0733         except FileNotFoundError as exc:
0734             # /data is a separate service volume only on hosts that have it.
0735             if mount != '/data':
0736                 volumes[mount] = {'error': str(exc)}
0737         except OSError as exc:
0738             volumes[mount] = {'error': str(exc)}
0739     return {'volumes': volumes}
0740 
0741 
0742 def collect(state):
0743     """The record this run delivers; ``state`` is advanced in place."""
0744     started = time.time()
0745     record = {'host': HOST, 'collected_at': now_iso(), 'reporter_version': '1.8',
0746               'health': disk_health()}
0747     positions = state.setdefault('positions', {})
0748     last_run = state.get('last_run_at')
0749     first_run = not last_run
0750     record['interval'] = {'since': last_run, 'seconds': None, 'first_run': first_run}
0751     if last_run:
0752         try:
0753             since = datetime.strptime(last_run, '%Y-%m-%dT%H:%M:%SZ')
0754             record['interval']['seconds'] = int((datetime.utcnow() - since).total_seconds())
0755         except ValueError:
0756             record['interval']['error'] = 'unparsable last_run_at'
0757 
0758     lines, info = read_since(FETCHER_LOG, positions, first_run)
0759     record['fetching'] = fetching(lines, info)
0760     lines, info = read_since(SUBMITTER_LOG, positions, first_run)
0761     record['submission'] = submission(lines, info)
0762     lines, info = read_since(ADJUSTER_LOG, positions, first_run)
0763     record['adjustment'] = adjustment(lines, info, state.setdefault('adjustment', {}))
0764     record['failed_calls'] = failed_calls(positions, first_run)
0765     record['daemons'] = daemons()
0766     record['processes'] = processes()
0767     record['queue_status'] = queue_status()
0768     # The database window: since the previous run, or the last five
0769     # minutes on a first run. Harvester times are UTC.
0770     until = datetime.utcnow()
0771     since = until - timedelta(minutes=5)
0772     if last_run:
0773         try:
0774             since = datetime.strptime(last_run, '%Y-%m-%dT%H:%M:%SZ')
0775         except ValueError:
0776             pass
0777     # The reason cache rides the state; JSON keys are strings, as are the
0778     # worker IDs the mysql client returns.
0779     cache = state.setdefault('reasons', {})
0780     record['harvester_db'] = harvester_db(since.strftime('%Y-%m-%d %H:%M:%S'),
0781                                           until.strftime('%Y-%m-%d %H:%M:%S'), cache, state.setdefault('admission', {}))
0782     record['collect_seconds'] = round(time.time() - started, 2)
0783     state['last_run_at'] = record['collected_at']
0784     return record
0785 
0786 
0787 # ── Delivery (the house pattern: env file, post, buffer) ─────────────────
0788 
0789 def load_state(state_dir):
0790     path = os.path.join(state_dir, 'state.json')
0791     try:
0792         with open(path) as handle:
0793             return json.load(handle)
0794     except (OSError, ValueError):
0795         return {}
0796 
0797 
0798 def save_state(state_dir, state):
0799     try:
0800         os.makedirs(state_dir, exist_ok=True)
0801         path = os.path.join(state_dir, 'state.json')
0802         with open(path + '.tmp', 'w') as handle:
0803             json.dump(state, handle)
0804         os.replace(path + '.tmp', path)
0805     except OSError as exc:
0806         print('could not save state: {}'.format(exc), file=sys.stderr)
0807 
0808 
0809 def load_env(path):
0810     """Read KEY=value lines from an environment file, if there is one."""
0811     if not path or not os.path.exists(path):
0812         return
0813     with open(path) as handle:
0814         for line in handle:
0815             line = line.strip()
0816             if not line or line.startswith('#') or '=' not in line:
0817                 continue
0818             key, value = line.split('=', 1)
0819             os.environ.setdefault(key.strip(), value.strip().strip('"\''))
0820 
0821 
0822 def post(record, url, token):
0823     """Deliver one record. Returns None on success, the reason on failure."""
0824     import urllib.request
0825     body = json.dumps(record).encode('utf-8')
0826     endpoint = '{}/api/host-reports/{}/'.format(url.rstrip('/'), HOST)
0827     request = urllib.request.Request(
0828         endpoint, data=body, method='POST',
0829         headers={'Content-Type': 'application/json',
0830                  'Authorization': 'Token {}'.format(token)})
0831     # The monitor is inside the facility and serves a certificate this
0832     # host's trust store may not carry; the token is the authentication.
0833     context = ssl._create_unverified_context()
0834     try:
0835         with urllib.request.urlopen(request, timeout=POST_TIMEOUT_S,
0836                                     context=context) as response:
0837             response.read()
0838         return None
0839     except Exception as exc:                                  # noqa: BLE001
0840         return '{}: {}'.format(exc.__class__.__name__, exc)
0841 
0842 
0843 def buffered(state_dir):
0844     """Records held from earlier runs the monitor could not take."""
0845     try:
0846         names = sorted(os.listdir(state_dir))
0847     except OSError:
0848         return []
0849     return [os.path.join(state_dir, n) for n in names
0850             if n.startswith('record-') and n.endswith('.json')]
0851 
0852 
0853 def buffer_record(state_dir, record):
0854     """Hold a record for the next run, keeping the buffer bounded."""
0855     try:
0856         os.makedirs(state_dir, exist_ok=True)
0857         path = os.path.join(state_dir, 'record-{}.json'.format(
0858             record['collected_at'].replace(':', '')))
0859         with open(path, 'w') as handle:
0860             json.dump(record, handle)
0861         held = buffered(state_dir)
0862         for stale in held[:-BUFFER_KEEP]:
0863             os.unlink(stale)
0864     except OSError as exc:
0865         print('could not buffer the record: {}'.format(exc), file=sys.stderr)
0866 
0867 
0868 def main():
0869     global HOST
0870     parser = argparse.ArgumentParser()
0871     parser.add_argument('--print', dest='show', action='store_true',
0872                         help='print the record, post nothing, leave the state untouched')
0873     parser.add_argument('--env', default=os.path.expanduser('~/.swf-harvester-reporter.env'))
0874     parser.add_argument('--report-as', default=HOST,
0875                         help='the host key the record is stored under (default: this host)')
0876     parser.add_argument('--state', default=None,
0877                         help='state directory (default: one per host key; the home is shared across hosts)')
0878     args = parser.parse_args()
0879     HOST = args.report_as
0880     if args.state is None:
0881         args.state = DEFAULT_STATE if HOST == 'pandaharvester01' else '{}-{}'.format(DEFAULT_STATE, HOST)
0882 
0883     state = load_state(args.state)
0884     record = collect(state)
0885     if args.show:
0886         print(json.dumps(record, indent=2, sort_keys=True))
0887         return 0
0888     save_state(args.state, state)
0889 
0890     load_env(args.env)
0891     url = os.environ.get('SWF_MONITOR_URL')
0892     token = os.environ.get('SWF_REPORT_TOKEN')
0893     if not url or not token:
0894         print('SWF_MONITOR_URL and SWF_REPORT_TOKEN are required '
0895               '(environment or --env file)', file=sys.stderr)
0896         buffer_record(args.state, record)
0897         return 2
0898 
0899     # The backlog first, oldest first, so a reader sees the sequence.
0900     for path in buffered(args.state):
0901         try:
0902             with open(path) as handle:
0903                 held = json.load(handle)
0904         except (OSError, ValueError):
0905             os.unlink(path)
0906             continue
0907         if post(held, url, token) is None:
0908             os.unlink(path)
0909         else:
0910             break
0911 
0912     failure = post(record, url, token)
0913     if failure:
0914         print('post failed, record buffered: {}'.format(failure), file=sys.stderr)
0915         buffer_record(args.state, record)
0916         return 1
0917     fetch = (record.get('fetching') or {}).get('queues') or {}
0918     print('reported: {} queue(s), {} fetch attempt(s), {} failed, {} worker(s) submitted'.format(
0919         len(fetch), sum(q['attempts'] for q in fetch.values()),
0920         sum(q['failed'] for q in fetch.values()),
0921         (record.get('submission') or {}).get('pushes_in_interval')))
0922     return 0
0923 
0924 
0925 if __name__ == '__main__':
0926     sys.exit(main())