File indexing completed on 2026-11-09 10:47:24
0001
0002 """perlmutter-harvester-reporter.py — the Perlmutter harvester's record.
0003
0004 The harvester ``Perlmutter_test_1`` runs on a NERSC login node under PanDA
0005 operations' account, and its logs are in that account's scratch, which the
0006 m3763 project cannot read. What it reports to the PanDA server (its
0007 heartbeat, its workers, its worker counts and intentions, its errors and
0008 its metrics) and its queue configuration, which m3763 reads through the
0009 NERSC IRI API, make the record the other harvesters' reporters deliver
0010 (docs/HARVESTER_REPORTER.md, Perlmutter), under the host key
0011 ``perlmutter-harvester``. Read-only with respect to the harvester.
0012
0013 Runs on pandaserver02 every five minutes from the deployed release, which
0014 stores the record through ``host_reports.store``. A source that cannot be
0015 read is an error field, never dropped. Times are UTC, as in every host
0016 record.
0017
0018 cd /opt/swf-monitor/current && source ~/.env
0019 .venv/bin/python scripts/perlmutter-harvester-reporter.py [--print]
0020
0021 ``--print`` prints the record and stores nothing; a development tree
0022 always only prints (CACHED_PRODUCTS.md, Contract).
0023 """
0024 import argparse
0025 import json
0026 import os
0027 import re
0028 import sys
0029 import time
0030 from datetime import datetime, timedelta
0031
0032 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0033 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0034 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0035
0036 import django
0037 django.setup()
0038
0039 from django.db import connections
0040
0041 HOST_KEY = 'perlmutter-harvester'
0042 HARVESTER_ID = 'Perlmutter_test_1'
0043 VERSION = '1.1'
0044 SCHEMA = 'doma_panda'
0045 QUEUE_CONFIG = '/global/common/software/m3763/panda-harvester/etc/panda/panda_queueconfig.json'
0046 STATE_DIR = os.path.expanduser('~/.swf-harvester-reporter-' + HOST_KEY)
0047 TERMINAL = ('finished', 'failed', 'cancelled', 'missed')
0048
0049
0050 LIVE_DAYS = 7
0051 REASON_HOURS = 24
0052 DIALOG_HOURS = 24
0053 EXAMPLES = 3
0054 REASONS_KEEP = 25
0055 LINE_KEEP = 400
0056 LOGS = ("the harvester's logs are in PanDA operations' scratch at NERSC "
0057 "(/pscratch/sd/x/xin/panda/log), which m3763 cannot read")
0058 FETCH = ('not applicable: the Perlmutter queues run in pull mode (mapType NoJob), '
0059 'so their pilots fetch their own jobs; ' + LOGS)
0060
0061
0062 def rows(sql, params=()):
0063 with connections['panda'].cursor() as cursor:
0064 cursor.execute(sql, params)
0065 return cursor.fetchall()
0066
0067
0068 def iso(t):
0069 return t.strftime('%Y-%m-%dT%H:%M:%SZ') if t else None
0070
0071
0072 def error(exc):
0073 return {'error': '{}: {}'.format(exc.__class__.__name__, str(exc)[:LINE_KEEP])}
0074
0075
0076 def instance(now):
0077 found = rows(f'''SELECT starttime, lastupdate, hostname, sw_version, owner
0078 FROM {SCHEMA}.harvester_instances WHERE harvester_id = %s''', (HARVESTER_ID,))
0079 if not found:
0080 return {'error': f'no {HARVESTER_ID} in harvester_instances'}
0081 start, last, host, version, owner = found[0]
0082 return {'harvester_id': HARVESTER_ID, 'started_at': iso(start), 'last_heartbeat': iso(last),
0083 'heartbeat_age_s': int((now - last).total_seconds()) if last else None,
0084 'hostname': host or None, 'sw_version': version, 'owner': owner}
0085
0086
0087 def queue_config():
0088 """The queue configuration through the IRI API: each queue's launch
0089 limits, in the harvester database's names, and its queueStatus."""
0090 from swf_epicprod.nersc_iri import Client
0091 config = json.loads(Client().read(QUEUE_CONFIG))
0092 blocks = {q: b for q, b in config.items() if isinstance(b, dict)}
0093 limits = [{'queue': q, 'site': q, 'job_type': None, 'resource_type': None,
0094 'n_queue_limit_worker': b.get('nQueueLimitWorker'),
0095 'max_workers': b.get('maxWorkers'),
0096 'n_queue_limit_job': b.get('nQueueLimitJob'),
0097 'n_queue_limit_worker_ratio': b.get('nQueueLimitWorkerRatio'),
0098 'n_queue_limit_worker_min': b.get('nQueueLimitWorkerMin'),
0099 'n_queue_limit_worker_max': b.get('nQueueLimitWorkerMax'),
0100 'map_type': b.get('mapType')}
0101 for q, b in sorted(blocks.items())]
0102 return limits, {'status': {q: b.get('queueStatus') for q, b in blocks.items()}}
0103
0104
0105 def harvester_db(since, until, now, limits):
0106 """The harvester's workers as it reports them to the PanDA server, in
0107 the shape the other reporters read from the harvester's own database."""
0108 out = {'window': {'since': since.strftime('%Y-%m-%d %H:%M:%S'),
0109 'until': until.strftime('%Y-%m-%d %H:%M:%S')},
0110 'source': 'the PanDA server tables the harvester reports to'}
0111 try:
0112 live = now - timedelta(days=LIVE_DAYS)
0113 found = rows(f'''SELECT computingsite, status, COUNT(*) FROM {SCHEMA}.harvester_workers
0114 WHERE harvesterid = %s AND status <> ALL(%s) AND submittime >= %s
0115 GROUP BY 1, 2''', (HARVESTER_ID, list(TERMINAL), live))
0116 workers = {}
0117 for site, status, n in found:
0118 workers.setdefault(site or '', {})[status] = int(n)
0119 out['workers_now'] = workers
0120 except Exception as exc:
0121 out['workers_now'] = error(exc)
0122 try:
0123 found = rows(f'''SELECT computingsite, SUM(n_workers), MAX(lastupdate)
0124 FROM {SCHEMA}.harvester_worker_stats
0125 WHERE harvester_id = %s AND status = 'to_submit' GROUP BY 1''', (HARVESTER_ID,))
0126 out['workers_to_submit'] = {site: {'n': int(n or 0), 'at': iso(at)} for site, n, at in found}
0127 except Exception as exc:
0128 out['workers_to_submit'] = error(exc)
0129 if isinstance(limits, list):
0130 to_submit = out.get('workers_to_submit') if isinstance(out.get('workers_to_submit'), dict) else {}
0131 last = dict(rows(f'''SELECT computingsite, MAX(submittime) FROM {SCHEMA}.harvester_workers
0132 WHERE harvesterid = %s AND submittime >= %s GROUP BY 1''',
0133 (HARVESTER_ID, now - timedelta(days=LIVE_DAYS))))
0134 for row in limits:
0135 row['n_new_workers'] = (to_submit.get(row['queue']) or {}).get('n')
0136 row['submit_time'] = (last[row['queue']].strftime('%Y-%m-%d %H:%M:%S')
0137 if last.get(row['queue']) else None)
0138 row['job_fetch_time'] = None
0139 out['launch_limits'] = limits
0140 else:
0141 out['launch_limits'] = limits
0142 try:
0143 found = rows(f'''SELECT computingsite, COUNT(*) FROM {SCHEMA}.harvester_workers
0144 WHERE harvesterid = %s AND starttime > %s AND starttime <= %s GROUP BY 1''',
0145 (HARVESTER_ID, since, until))
0146 started = {site: int(n) for site, n in found}
0147 for site in out.get('workers_now') or {}:
0148 if site != 'error':
0149 started.setdefault(site, 0)
0150 out['workers_started'] = started
0151 except Exception as exc:
0152 out['workers_started'] = error(exc)
0153 try:
0154 found = rows(f'''SELECT computingsite, status, EXTRACT(EPOCH FROM (endtime - starttime))
0155 FROM {SCHEMA}.harvester_workers
0156 WHERE harvesterid = %s AND endtime > %s AND endtime <= %s''',
0157 (HARVESTER_ID, since, until))
0158 sites = {}
0159 for site, status, run_s in found:
0160 s = sites.setdefault(site or '', {'ended': 0, 'by_status': {}, '_runs': []})
0161 s['ended'] += 1
0162 s['by_status'][status] = s['by_status'].get(status, 0) + 1
0163 if status == 'finished' and run_s is not None and run_s >= 0:
0164 s['_runs'].append(int(run_s))
0165 for s in sites.values():
0166 runs = sorted(s.pop('_runs'))
0167 s['median_finished_run_s'] = runs[len(runs) // 2] if runs else None
0168 out['workers_ended'] = {'sites': sites, 'workers': len(found)}
0169 except Exception as exc:
0170 out['workers_ended'] = error(exc)
0171 try:
0172 out['not_finished'] = not_finished(until)
0173 except Exception as exc:
0174 out['not_finished'] = error(exc)
0175 try:
0176 found = rows(f'''SELECT DISTINCT ON (harvester_host) harvester_host, creation_time, metrics
0177 FROM {SCHEMA}.harvester_metrics WHERE harvester_id = %s
0178 ORDER BY harvester_host, creation_time DESC''', (HARVESTER_ID,))
0179 metrics = {}
0180 for host, created, raw in found:
0181 try:
0182 value = json.loads(raw) if raw else None
0183 except ValueError:
0184 value = (raw or '')[:2000]
0185 metrics[host or ''] = {'at': iso(created), 'metrics': value}
0186 out['service_metrics'] = metrics
0187 except Exception as exc:
0188 out['service_metrics'] = error(exc)
0189 out['cache_updates'] = {'error': "the harvester's job-demand cache is in its own database "
0190 "and does not reach the PanDA server"}
0191 return out
0192
0193
0194 def not_finished(until):
0195 """Per site, over the REASON_HOURS before ``until``, every worker that
0196 did not finish or finished with a nonzero batch exit, grouped by the
0197 harvester's diagnostic, as the other reporters group theirs."""
0198 found = rows(f'''SELECT workerid, batchid, computingsite, status, nativestatus, endtime,
0199 LEFT(diagmessage, 300), COALESCE(batchlog, stdout), nodeid,
0200 computingelement, nativeexitcode
0201 FROM {SCHEMA}.harvester_workers
0202 WHERE harvesterid = %s AND endtime > %s AND endtime <= %s
0203 AND (status <> 'finished' OR COALESCE(nativeexitcode, 0) <> 0)
0204 ORDER BY endtime DESC''',
0205 (HARVESTER_ID, until - timedelta(hours=REASON_HOURS), until))
0206 sites = {}
0207 for worker, batch, site, status, native, ended, diag, log, node, ce, _ in found:
0208 text = (diag or '').strip() or (native or '').strip() or status
0209 s = sites.setdefault(site or '', {'workers': 0, 'reasons': {}})
0210 s['workers'] += 1
0211 entry = s['reasons'].setdefault(f'{status} | {text}', {
0212 'status': status, 'native_status': (native or '').strip() or None,
0213 'source': 'harvester diagnostic', 'event': None, 'reason': text, 'count': 0,
0214 'last_ended': ended.strftime('%Y-%m-%d %H:%M:%S') if ended else None, 'examples': []})
0215 entry['count'] += 1
0216 if len(entry['examples']) < EXAMPLES:
0217 entry['examples'].append({'workerid': int(worker), 'batchid': batch,
0218 'ended': ended.strftime('%Y-%m-%d %H:%M:%S') if ended else None,
0219 'node': node, 'ce': ce, 'batch_log': log})
0220 for s in sites.values():
0221 reasons = sorted(s['reasons'].values(), key=lambda e: -e['count'])
0222 s['reasons'] = reasons[:REASONS_KEEP]
0223 s['reasons_dropped'] = max(0, len(reasons) - REASONS_KEEP)
0224 return {'hours': REASON_HOURS, 'sites': sites, 'workers': sum(v['workers'] for v in sites.values())}
0225
0226
0227 def submission(since, until):
0228 found = rows(f'''SELECT computingsite, COUNT(*) FROM {SCHEMA}.harvester_workers
0229 WHERE harvesterid = %s AND submittime > %s AND submittime <= %s GROUP BY 1''',
0230 (HARVESTER_ID, since, until))
0231 return {'queues': {site: {'workers_submitted': int(n)} for site, n in found},
0232 'source': 'the workers the harvester reported to the PanDA server'}
0233
0234
0235 def dialogs(now):
0236 """The errors the harvester reported to the PanDA server over the last
0237 DIALOG_HOURS, per module (count and the latest message) and per kind
0238 of message (the message with its numbers set aside), so one kind's
0239 latest line never hides another's. The page shows a queue the kinds
0240 that name it or name none of the harvester's queues."""
0241 found = rows(f'''SELECT modulename, messagelevel, creationtime, LEFT(diagmessage, {LINE_KEEP})
0242 FROM {SCHEMA}.harvester_dialogs
0243 WHERE harvester_id = %s AND creationtime > %s
0244 AND messagelevel IN ('ERROR', 'CRITICAL')
0245 ORDER BY creationtime DESC''', (HARVESTER_ID, now - timedelta(hours=DIALOG_HOURS)))
0246 modules, kinds = {}, {}
0247 for module, level, at, message in found:
0248 m = modules.setdefault(module or '', {'count': 0, 'levels': {},
0249 'last': {'at': iso(at), 'level': level, 'message': message}})
0250 m['count'] += 1
0251 m['levels'][level] = m['levels'].get(level, 0) + 1
0252 k = kinds.setdefault((module or '', re.sub(r'\d+', '#', message or '')),
0253 {'module': module or '', 'count': 0, 'levels': {}, 'first_at': None,
0254 'last': {'at': iso(at), 'level': level, 'message': message}})
0255 k['count'] += 1
0256 k['levels'][level] = k['levels'].get(level, 0) + 1
0257 k['first_at'] = iso(at)
0258 messages = sorted(kinds.values(), key=lambda k: k['last']['at'] or '', reverse=True)
0259 return {'hours': DIALOG_HOURS, 'modules': modules, 'messages': messages[:REASONS_KEEP],
0260 'messages_dropped': max(0, len(messages) - REASONS_KEEP)}
0261
0262
0263 def load_state():
0264 try:
0265 with open(os.path.join(STATE_DIR, 'state.json')) as handle:
0266 return json.load(handle)
0267 except (OSError, ValueError):
0268 return {}
0269
0270
0271 def save_state(state):
0272 os.makedirs(STATE_DIR, exist_ok=True)
0273 path = os.path.join(STATE_DIR, 'state.json')
0274 with open(path + '.new', 'w') as handle:
0275 json.dump(state, handle)
0276 os.replace(path + '.new', path)
0277
0278
0279 def collect(state):
0280 started = time.time()
0281 now = datetime.utcnow().replace(microsecond=0)
0282 last = state.get('last_run_at')
0283 since = now - timedelta(minutes=5)
0284 if last:
0285 try:
0286 since = datetime.strptime(last, '%Y-%m-%dT%H:%M:%SZ')
0287 except ValueError:
0288 pass
0289 record = {'host': HOST_KEY, 'harvester_id': HARVESTER_ID, 'collected_at': iso(now),
0290 'reporter_version': VERSION,
0291 'source': ('what the harvester reports to the PanDA server, and its queue '
0292 'configuration through the NERSC IRI API; ' + LOGS),
0293 'interval': {'since': last, 'seconds': int((now - since).total_seconds()),
0294 'first_run': not last}}
0295 for name, read in (('instance', lambda: instance(now)), ('dialogs', lambda: dialogs(now)),
0296 ('submission', lambda: submission(since, now))):
0297 try:
0298 record[name] = read()
0299 except Exception as exc:
0300 record[name] = error(exc)
0301 try:
0302 limits, record['queue_status'] = queue_config()
0303 except Exception as exc:
0304 limits = error(exc)
0305 record['queue_status'] = error(exc)
0306 record['harvester_db'] = harvester_db(since, now, now, limits)
0307 record['fetching'] = {'error': FETCH}
0308 record['adjustment'] = {'queues': {}, 'read': {'error': LOGS}}
0309 record['failed_calls'] = {'error': LOGS}
0310 record['daemons'] = {'error': LOGS}
0311 record['processes'] = {'error': "the harvester runs on a NERSC login node, whose process "
0312 "table m3763 cannot read; its heartbeat is in instance"}
0313 record['collect_seconds'] = round(time.time() - started, 2)
0314 state['last_run_at'] = record['collected_at']
0315 return record
0316
0317
0318 def main():
0319 ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
0320 ap.add_argument('--print', action='store_true', help='print the record; store nothing')
0321 args = ap.parse_args()
0322 from monitor_app.cached_product import stores
0323 from monitor_app.host_reports import RecordRefused, store
0324 if args.print or not stores():
0325 print(json.dumps(collect(load_state()), indent=1, default=str))
0326 return 0
0327 state = load_state()
0328 record = collect(state)
0329 try:
0330 at, size = store(HOST_KEY, record, by='perlmutter-harvester-reporter')
0331 except RecordRefused as exc:
0332 print(f'{iso(datetime.utcnow())} refused: {exc}')
0333 return 1
0334 save_state(state)
0335 errors = [k for k, v in record.items() if isinstance(v, dict) and v.get('error')
0336 and k not in ('fetching', 'failed_calls', 'daemons', 'processes')]
0337 print(f"{record['collected_at']} stored {size} bytes"
0338 + (f"; unread: {', '.join(errors)}" if errors else ''))
0339 return 0
0340
0341
0342 if __name__ == '__main__':
0343 sys.exit(main())