Back to home page

EIC code displayed by LXR

 
 

    


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

0001 #!/usr/bin/env python3
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  # noqa: E402
0037 django.setup()
0038 
0039 from django.db import connections  # noqa: E402
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 # A worker row not terminal after this long is one the harvester lost
0049 # track of, not a live worker (comp_snapshot workers_now uses the same).
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:  # noqa: BLE001
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:  # noqa: BLE001
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:  # noqa: BLE001
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:  # noqa: BLE001
0170         out['workers_ended'] = error(exc)
0171     try:
0172         out['not_finished'] = not_finished(until)
0173     except Exception as exc:  # noqa: BLE001
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:  # noqa: BLE001
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:  # noqa: BLE001
0300             record[name] = error(exc)
0301     try:
0302         limits, record['queue_status'] = queue_config()
0303     except Exception as exc:  # noqa: BLE001
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())