File indexing completed on 2026-11-09 10:47:22
0001
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
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
0052 TERMINAL = ('finished', 'failed', 'cancelled', 'missed')
0053
0054
0055 MAX_CONDOR_LOGS = 300
0056 CONDOR_LOG_TAIL = 256 * 1024
0057 EXAMPLES = 3
0058 REASONS_KEEP = 25
0059
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
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
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
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
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
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:
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
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
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:
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:
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:
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:
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:
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:
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
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
0679
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
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
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
0769
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
0778
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
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
0832
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:
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
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())