Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-10-20 10:15:20

0001 """The node guard's cycle (site-canary docs/NODE_GUARD.md): every five
0002 minutes, the terminal production jobs of the window per queue and host
0003 go to the canary's decision (``canary.guard.decide_nodes``), and what
0004 comes back is recorded on the action stream and stored as the cached
0005 product ``node_guard_state``, which the Node guard page reads.
0006 
0007 The cycle runs as the production-operations agent's ``node_guard_cycle``
0008 doer (``scripts/node-guard-cycle.py``), by cron enqueue. In shadow mode
0009 a tripped node reads ``would_exclude``, in live mode ``excluded``; the
0010 record is written the same way in both. What acts is the published
0011 exclusion (NODE_GUARD.md, Actuation): every cycle the record's black
0012 holes go out as one document carrying the mode and a validity, stored
0013 as the cached product ``node_guard_exclusion``, served anonymously at
0014 ``GET /api/node-guard/exclusion/``, and put on the devcloud bucket's
0015 public pilot prefix (``pilot/node-exclusion.json``) with the worker
0016 profile in the operating account's AWS credentials, where the wrapper
0017 and the payload's landing check fetch it. A reader declines only on a
0018 live document inside its validity, so a publisher that stops leaves no
0019 exclusion standing.
0020 
0021 Settings live in SysConfig under ``node_guard.*`` and are seeded at
0022 their defaults on first read, so every knob is visible on the System
0023 page (the table in NODE_GUARD.md, Modes and settings).
0024 
0025 The verdicts go to the node record in the canary store
0026 (``canary.store.nodes.apply``): a trip opens a black hole that stays
0027 latched until it expires into half open, where one landing decides;
0028 a person may clear or pin. Decision records: one ``node_guard_decision``
0029 per change of a node's record (a black hole opened or reopened, expired
0030 into half open, cleared by a clean landing), and hourly as a heartbeat
0031 for each standing black hole; one ``node_guard_cycle`` per cycle. Every
0032 read is fenced: a failed window read or calibration is recorded as the
0033 cycle's error and the cycle goes on with what it has.
0034 """
0035 import json
0036 import logging
0037 import os
0038 from datetime import timedelta
0039 
0040 from django.db import connections
0041 from django.utils import timezone
0042 
0043 from monitor_app.panda.constants import PANDA_SCHEMA
0044 
0045 logger = logging.getLogger(__name__)
0046 
0047 STATE_KEY = 'node_guard_state'
0048 STATE_TTL_S = 24 * 3600
0049 MODES = ('shadow', 'live')
0050 # The published exclusion: the document's validity (a reader ignores an
0051 # older one) and where it goes. The bucket, key and profile follow
0052 # swf-epicprod docs/DEVCLOUD_STAGEOUT.md ยง 4; the environment overrides.
0053 EXCLUSION_KEY = 'node_guard_exclusion'
0054 EXCLUSION_VALID_S = 20 * 60
0055 EXCLUSION_BUCKET = os.environ.get('NODE_EXCLUSION_BUCKET', 'epic-devcloud-stageout')
0056 EXCLUSION_OBJECT = os.environ.get('NODE_EXCLUSION_OBJECT', 'pilot/node-exclusion.json')
0057 EXCLUSION_REGION = os.environ.get('NODE_EXCLUSION_REGION', 'us-east-1')
0058 EXCLUSION_PROFILE = os.environ.get('NODE_EXCLUSION_PROFILE', 'epic-stageout')
0059 EXCLUSION_URL = (f'https://{EXCLUSION_BUCKET}.s3.{EXCLUSION_REGION}.amazonaws.com/'
0060                  f'{EXCLUSION_OBJECT}')
0061 HEARTBEAT_S = 3600
0062 DEFAULTS = {
0063     'enabled': False,
0064     'mode': 'shadow',
0065     'queues': [],
0066     'window_h': 4,
0067     'attribution_h': 24,
0068     'min_jobs': 8,
0069     'failed_fraction': 0.8,
0070     'fast_fraction': 0.5,
0071     'fast_ratio': 0.5,
0072     'storm_nodes': 10,
0073     'storm_minutes': 5,
0074     'not_nodes': ['pandaharvester01.sdcc.bnl.gov', 'osgsub01.sdcc.bnl.gov'],
0075     'fixed_min_jobs': 5,
0076     'fixed_time_ratio': 1.2,
0077     'expiry_h': 24,
0078 }
0079 DECISION_KEYS = ('min_jobs', 'failed_fraction', 'fast_fraction', 'fast_ratio',
0080                  'storm_nodes', 'storm_minutes', 'not_nodes', 'fixed_min_jobs',
0081                  'fixed_time_ratio')
0082 TRIPPED_STATES = {'shadow': 'would_exclude', 'live': 'excluded'}
0083 # The record's status as the decision record names it, per mode.
0084 RECORD_STATES = {'black_hole': TRIPPED_STATES, 'half_open': {'shadow': 'half_open', 'live': 'half_open'},
0085                  'clear': {'shadow': 'clear', 'live': 'clear'}}
0086 
0087 
0088 def _safe(label, fn, fallback):
0089     try:
0090         return fn()
0091     except Exception as exc:  # noqa: BLE001
0092         logger.exception('node guard: %s failed', label)
0093         return fallback(exc) if callable(fallback) else fallback
0094 
0095 
0096 def setting(key, default):
0097     from monitor_app.models import SysConfig
0098     return SysConfig.get_setting(key, default)
0099 
0100 
0101 def settings():
0102     out = {}
0103     for key, default in DEFAULTS.items():
0104         out[key] = setting(f'node_guard.{key}', default)
0105     queues = out['queues']
0106     if not (isinstance(queues, (list, tuple)) and all(isinstance(q, str) for q in queues)):
0107         logger.error('node_guard.queues is not a list of queue names: %r; judging every queue', queues)
0108         out['queues'] = []
0109     out['queues'] = [q for q in out['queues'] if q]
0110     not_nodes = out['not_nodes']
0111     if not (isinstance(not_nodes, (list, tuple)) and all(isinstance(h, str) for h in not_nodes)):
0112         logger.error('node_guard.not_nodes is not a list of host names: %r; using the defaults', not_nodes)
0113         out['not_nodes'] = list(DEFAULTS['not_nodes'])
0114     out['window_h'] = max(1, int(out['window_h'] or 1))
0115     out['attribution_h'] = max(out['window_h'], int(out['attribution_h'] or 1))
0116     return out
0117 
0118 
0119 def execute_site(dest_site, host):
0120     """The actual site a job ran at: the pilot-reported glidein site when
0121     the record carries one (the OSG pool queues, since 2026-08-13), else
0122     the site the worker's domain names, else the domain itself; '' for a
0123     bare host name (monitor_app.panda.queries, the execute-site key)."""
0124     from canary.guard import normalize_host
0125     from monitor_app.panda.queries import (EXECUTE_SITE_NAMES, PRIVATE_DOMAIN_TAILS)
0126     if dest_site:
0127         return str(dest_site)
0128     h = normalize_host(host)
0129     parts = h.split('.')
0130     if len(parts) < 2 or parts[-1].lower() in PRIVATE_DOMAIN_TAILS:
0131         return ''
0132     key = '.'.join(parts[-2:]).lower()
0133     return EXECUTE_SITE_NAMES.get(key, key)
0134 
0135 
0136 def error_label(pilot, exe, trans):
0137     """One label for a failed job's error: the pilot error code when set,
0138     else the payload's exit code, else the transformation's; 'no code'
0139     when none is set."""
0140     if pilot:
0141         return f'pilot {pilot}'
0142     if exe:
0143         return f'exe {exe}'
0144     if trans:
0145         return f'trans {trans}'
0146     return 'no code'
0147 
0148 
0149 def window_rows(window_h, queues=()):
0150     """The terminal production jobs of the last ``window_h`` hours, one
0151     dict per job in the shape ``canary.guard.decide_nodes`` reads, with
0152     the actual site and the failure's error label resolved here."""
0153     sql = f"""
0154         SELECT "computingsite", "modificationhost", "jobstatus", "jeditaskid",
0155                EXTRACT(EPOCH FROM ("endtime" - "starttime")), "endtime", "pandaid",
0156                "destinationsite", "piloterrorcode", "exeerrorcode", "transexitcode"
0157         FROM "{PANDA_SCHEMA}"."jobsarchived4"
0158         WHERE "processingtype" = 'epicproduction'
0159           AND "endtime" > NOW() - INTERVAL %s
0160           AND "jobstatus" IN ('finished', 'failed')
0161     """
0162     params = [f'{window_h} hours']
0163     if queues:
0164         sql += ' AND "computingsite" = ANY(%s)'
0165         params.append(list(queues))
0166     with connections['panda'].cursor() as cursor:
0167         cursor.execute(sql, params)
0168         rows = cursor.fetchall()
0169     out = []
0170     for (queue, host, status, task, seconds, end, pandaid,
0171          dest_site, pilot, exe, trans) in rows:
0172         out.append({'queue': str(queue) if queue else None, 'host': host,
0173                     'jobstatus': status, 'jeditaskid': task,
0174                     'duration_s': float(seconds) if seconds is not None else None,
0175                     'endtime': end.isoformat() if end is not None else None,
0176                     'pandaid': pandaid,
0177                     'site': execute_site(dest_site, host),
0178                     'error': error_label(pilot, exe, trans) if status == 'failed' else ''})
0179     return out
0180 
0181 
0182 def finished_elsewhere(attribution_h, queues=()):
0183     """Per queue and task, the hosts on which the task finished over the
0184     attribution window, each with its latest finish:
0185     ``{queue: {task: {host: endtime}}}``. The guard's attribution is
0186     contemporaneous (a finish at or after the node's first failure), so
0187     the time travels with the host."""
0188     from canary.guard import normalize_host
0189     sql = f"""
0190         SELECT "computingsite", "jeditaskid", "modificationhost", MAX("endtime")
0191         FROM "{PANDA_SCHEMA}"."jobsarchived4"
0192         WHERE "processingtype" = 'epicproduction'
0193           AND "endtime" > NOW() - INTERVAL %s
0194           AND "jobstatus" = 'finished'
0195         GROUP BY 1, 2, 3
0196     """
0197     params = [f'{attribution_h} hours']
0198     if queues:
0199         sql = sql.replace('        GROUP BY 1, 2, 3', '          AND "computingsite" = ANY(%s)\n        GROUP BY 1, 2, 3')
0200         params.append(list(queues))
0201     with connections['panda'].cursor() as cursor:
0202         cursor.execute(sql, params)
0203         rows = cursor.fetchall()
0204     out = {}
0205     for queue, task, host, end in rows:
0206         if not queue or task is None:
0207             continue
0208         h = normalize_host(host)
0209         if h:
0210             out.setdefault(str(queue), {}).setdefault(task, {})[h] = (
0211                 end.isoformat() if end is not None else None)
0212     return out
0213 
0214 
0215 def median_finished_s():
0216     """Per queue, the census calibration's median finished walltime in
0217     seconds (None where the calibration has none)."""
0218     from monitor_app.panda.census import calibration
0219     calib = (calibration() or {}).get('queues') or {}
0220     out = {}
0221     for queue, c in calib.items():
0222         med_h = (c or {}).get('median_walltime_h')
0223         out[queue] = float(med_h) * 3600.0 if med_h else None
0224     return out
0225 
0226 
0227 def _last_decision(queue, host):
0228     from monitor_app.models import AppLog
0229     return (AppLog.objects.filter(app_name='epicprod',
0230                                   extra_data__action='node_guard_decision',
0231                                   extra_data__subject_key=f'{queue}/{host}')
0232             .order_by('-timestamp').values('id', 'timestamp', 'extra_data').first())
0233 
0234 
0235 def _window_counts(rows, calib, fast_ratio):
0236     """Per (queue, host): the window's finished jobs and fast failures,
0237     for the nodes the record holds that did not reach the floor."""
0238     from canary.guard import normalize_host
0239     out = {}
0240     for r in rows or ():
0241         host = normalize_host(r.get('host'))
0242         if not r.get('queue') or not host:
0243             continue
0244         c = out.setdefault((r['queue'], host), {'finished': 0, 'fast_failed': 0, 'failed': 0})
0245         if r.get('jobstatus') == 'finished':
0246             c['finished'] += 1
0247         elif r.get('jobstatus') == 'failed':
0248             c['failed'] += 1
0249             med = calib.get(r['queue'])
0250             d = r.get('duration_s')
0251             if med and d is not None and float(d) < med * fast_ratio:
0252                 c['fast_failed'] += 1
0253     return out
0254 
0255 
0256 def _readings(verdicts, rows, calib, cfg):
0257     """What the record is told this cycle: every judged node's verdict,
0258     and for every node holding a record that was not judged, its window
0259     counts (zero when it had no job)."""
0260     from canary.store.models import NodeState
0261     counts = _window_counts(rows, calib, cfg['fast_ratio'])
0262     readings = {}
0263     for queue, q in verdicts['queues'].items():
0264         for host, v in q['nodes'].items():
0265             e = v['evidence']
0266             readings[(queue, host)] = {
0267                 'tripped': v['state'] == 'tripped', 'reason': v['reason'], 'evidence': e,
0268                 'finished': e.get('finished', 0), 'fast_failed': e.get('fast_failed', 0),
0269                 'site': e.get('site', '')}
0270     for queue_name, host in (NodeState.objects.exclude(status='clear')
0271                              .values_list('queue__name', 'host')):
0272         key = (queue_name, host)
0273         if key in readings:
0274             continue
0275         c = counts.get(key, {'finished': 0, 'fast_failed': 0, 'failed': 0})
0276         readings[key] = {'tripped': False, 'reason': '', 'evidence': {},
0277                          'finished': c['finished'], 'fast_failed': c['fast_failed'], 'site': ''}
0278     return readings
0279 
0280 
0281 def exclusion_document(mode, now):
0282     """The published exclusion: the record's black holes
0283     (``canary.store.nodes.current_exclusion``) with the mode and a
0284     validity. A reader (the wrapper, the payload's landing check) declines
0285     a landing only when ``mode`` is live, ``valid_until`` has not passed,
0286     and its host (and queue, when it knows it) is listed."""
0287     from canary.store import nodes as node_record
0288     return {
0289         'generated_at': now.isoformat(),
0290         'valid_until': (now + timedelta(seconds=EXCLUSION_VALID_S)).isoformat(),
0291         'mode': mode,
0292         'nodes': node_record.current_exclusion(),
0293         'source': 'swf-monitor node guard (site-canary docs/NODE_GUARD.md)',
0294     }
0295 
0296 
0297 def publish_exclusion(document):
0298     """Put the document on the bucket's public pilot prefix, where every
0299     worker fetches; the worker profile in the operating account's AWS
0300     credentials writes it. Returns the public URL; raises on failure."""
0301     import boto3
0302     session = boto3.Session(profile_name=EXCLUSION_PROFILE, region_name=EXCLUSION_REGION)
0303     session.client('s3').put_object(
0304         Bucket=EXCLUSION_BUCKET, Key=EXCLUSION_OBJECT,
0305         Body=json.dumps(document, indent=1).encode(),
0306         ContentType='application/json', CacheControl='no-cache, max-age=0')
0307     return EXCLUSION_URL
0308 
0309 
0310 def run_cycle(*, dry_run=False, created_by='node-guard'):
0311     """One cycle. Returns (nodes, summary): the judged nodes with their
0312     verdicts as shown, and the cycle summary; writes the records and
0313     the stored state unless dry_run."""
0314     from canary import guard
0315     from monitor_app.epicprod_logging import log_epicprod_action
0316 
0317     t0 = timezone.now()
0318     errors = []
0319 
0320     def failed(label):
0321         def _f(exc):
0322             errors.append(f'{label}: {type(exc).__name__}: {exc}')
0323             return None
0324         return _f
0325 
0326     cfg = _safe('settings', settings, lambda exc: dict(DEFAULTS))
0327     enabled = bool(cfg['enabled'])
0328     mode_requested = str(cfg['mode'])
0329     mode = mode_requested
0330     if mode not in MODES:
0331         errors.append(f'node_guard.mode {mode_requested!r} is not a mode ({", ".join(MODES)}); '
0332                       f'decided as shadow')
0333         mode = 'shadow'
0334     tripped_state = TRIPPED_STATES[mode]
0335 
0336     rows = calib = elsewhere = None
0337     set_aside = {}
0338     door_aside = {}
0339     if enabled:
0340         rows = _safe('window', lambda: window_rows(cfg['window_h'], cfg['queues']),
0341                      failed('window'))
0342         if rows is not None:
0343             # Jobs that ended under a declared downtime of their queue
0344             # are set aside before judgment: a node is not a black hole
0345             # for a downtime's deaths (swf-epicprod
0346             # CONTINUOUS_PRODUCTION.md, Declared downtime). An unreadable
0347             # record sets nothing aside.
0348             from monitor_app.declared import set_aside_declared
0349             kept = _safe('declared', lambda: set_aside_declared(rows), failed('declared'))
0350             if kept is not None:
0351                 rows, set_aside = kept
0352             # And the registration failures that ended while the write
0353             # door was down: a dead door kills every node's jobs at the
0354             # same point in their run, which reads as a fixed-time kill
0355             # on each of them in turn (2026-09-22: four Perlmutter nodes
0356             # excluded for epicxrd1's expired certificate). The door
0357             # canary's own record says when a door was down
0358             # (site-canary docs/STORAGE_DOORS.md); before it has watched
0359             # a door there are no spans and nothing is set aside.
0360             from monitor_app.panda import storage_doors
0361             spans = _safe('door spans',
0362                           lambda: storage_doors.down_windows(cfg['window_h'] + 1),
0363                           failed('door spans')) or []
0364             kept = _safe('door', lambda: storage_doors.set_aside_door(rows, spans),
0365                          failed('door'))
0366             if kept is not None:
0367                 rows, door_aside = kept
0368         calib = _safe('calibration', median_finished_s, failed('calibration')) or {}
0369         elsewhere = _safe('attribution',
0370                           lambda: finished_elsewhere(cfg['attribution_h'], cfg['queues']),
0371                           failed('attribution')) or {}
0372     verdicts = None
0373     if rows is not None:
0374         verdicts = _safe('decision', lambda: guard.decide_nodes(
0375             rows, calib, {k: cfg[k] for k in DECISION_KEYS}, finished_elsewhere=elsewhere),
0376             failed('decision'))
0377     verdicts = verdicts or {'queues': {}, 'tripped': [], 'judged': 0,
0378                             'hosts': 0, 'jobs': 0, 'malformed': 0}
0379 
0380     nodes = []
0381     for queue, q in sorted(verdicts['queues'].items()):
0382         for host, v in sorted(q['nodes'].items()):
0383             tripped = v['state'] == 'tripped'
0384             nodes.append({'queue': queue, 'host': host,
0385                           'state': tripped_state if tripped else 'clear',
0386                           'reason': v['reason'], 'evidence': v['evidence'],
0387                           'recorded': False, 'log_id': None})
0388 
0389     # The record: the verdicts applied to the node map, with latch,
0390     # expiry and half open; the changes are the decisions recorded.
0391     written = 0
0392     changes, trips = [], []
0393     if enabled and rows is not None and not dry_run:
0394         from canary.store import nodes as node_record
0395         readings = _safe('readings', lambda: _readings(verdicts, rows, calib, cfg),
0396                          failed('readings'))
0397         if readings is not None:
0398             applied = _safe('record', lambda: node_record.apply(
0399                 readings, now=t0, expiry_h=float(cfg['expiry_h']), mode=mode,
0400                 username=created_by), failed('record'))
0401             if applied is not None:
0402                 changes, trips = applied
0403         by_key = {(n['queue'], n['host']): n for n in nodes}
0404         for queue, host, old, new, why in changes:
0405             state = RECORD_STATES.get(new, {}).get(mode, new)
0406             shown = by_key.get((queue, host))
0407             e = (shown or {}).get('evidence') or {}
0408             log_id = _safe(f'{queue}/{host} record', lambda: log_epicprod_action(
0409                 'node-guard', 'node_guard_decision', subject_type='panda_node',
0410                 subject_key=f'{queue}/{host}', username=created_by, outcome=state,
0411                 sublevel='normal' if new == 'black_hole' else 'low',
0412                 live_default=new == 'black_hole',
0413                 level=logging.WARNING if new == 'black_hole' else logging.INFO,
0414                 message=(f'node guard {queue} {host}: {state} ({why}), was {old}'
0415                          + (f'; {e["failed"]} of {e["jobs"]} jobs failed, {e["fast_failed"]} fast, '
0416                             f'in the last {cfg["window_h"]} h; '
0417                             f'{len(e["tasks_finished_elsewhere"])} of {len(e["tasks_failed"])} '
0418                             f'failed tasks finish on other nodes' if e else '')),
0419                 state=state, old_state=old, reason=why, mode=mode, host=host, queue=queue,
0420                 window_h=cfg['window_h'], **e), failed(f'{queue}/{host} record'))
0421             if shown is not None:
0422                 shown['recorded'] = True
0423                 shown['log_id'] = log_id
0424             written += 1
0425         # Hourly heartbeat for each standing black hole with a verdict
0426         # this cycle and no change recorded.
0427         changed = {(q, h) for q, h, _, _, _ in changes}
0428         for shown in nodes:
0429             key = (shown['queue'], shown['host'])
0430             if shown['state'] == 'clear' or key in changed:
0431                 continue
0432             last = _safe(f'{key} last decision', lambda: _last_decision(*key), None)
0433             if last is None or (t0 - last['timestamp']).total_seconds() < HEARTBEAT_S:
0434                 shown['log_id'] = (last or {}).get('id')
0435                 continue
0436             e = shown['evidence']
0437             shown['recorded'] = True
0438             shown['log_id'] = _safe(f'{key} heartbeat', lambda: log_epicprod_action(
0439                 'node-guard', 'node_guard_decision', subject_type='panda_node',
0440                 subject_key=f'{key[0]}/{key[1]}', username=created_by, outcome=shown['state'],
0441                 sublevel='low', live_default=False,
0442                 message=(f'node guard {key[0]} {key[1]}: {shown["state"]} standing; '
0443                          f'{e["failed"]} of {e["jobs"]} jobs failed in the last {cfg["window_h"]} h'),
0444                 state=shown['state'], reason=shown['reason'], mode=mode, host=key[1],
0445                 queue=key[0], window_h=cfg['window_h'], **e), failed(f'{key} heartbeat'))
0446             written += 1
0447 
0448     # The published exclusion, every cycle the guard is on: the record's
0449     # black holes as the document the wrapper and the landing check read,
0450     # stored, served, and put on the bucket. A failed publication is the
0451     # cycle's error; the last document's validity bounds what stands.
0452     exclusion = {'nodes': 0, 'published': False, 'url': ''}
0453     if enabled and not dry_run:
0454         document = _safe('exclusion document', lambda: exclusion_document(mode, t0),
0455                          failed('exclusion document'))
0456         if document is not None:
0457             from monitor_app.cached_product import get_product
0458             exclusion['nodes'] = len(document['nodes'])
0459             _safe('exclusion store', lambda: get_product(
0460                 EXCLUSION_KEY, lambda: document, ttl_seconds=STATE_TTL_S, refresh=True),
0461                 failed('exclusion store'))
0462             url = _safe('exclusion publish', lambda: publish_exclusion(document),
0463                         failed('exclusion publish'))
0464             if url:
0465                 exclusion.update(published=True, url=url)
0466 
0467     tripped_nodes = [n for n in nodes if n['state'] in TRIPPED_STATES.values()]
0468     summary = {'cycle_at': t0.isoformat(), 'mode': mode, 'mode_requested': mode_requested,
0469                'enabled': enabled, 'window_h': cfg['window_h'],
0470                'jobs': verdicts['jobs'], 'hosts': verdicts['hosts'],
0471                'judged': verdicts['judged'], 'tripped': len(tripped_nodes),
0472                'opened': len(trips), 'record_changes': len(changes),
0473                'malformed': verdicts['malformed'], 'decisions_recorded': written,
0474                'under_declaration': sum(set_aside.values()),
0475                'under_dead_door': sum(door_aside.values()),
0476                'exclusion_nodes': exclusion['nodes'],
0477                'exclusion_published': exclusion['published'],
0478                'errors': errors,
0479                'duration_s': round((timezone.now() - t0).total_seconds(), 1)}
0480     if not dry_run:
0481         queues_state = {}
0482         for queue, q in verdicts['queues'].items():
0483             queues_state[queue] = {k: v for k, v in q.items() if k != 'nodes'}
0484             queues_state[queue]['storm_nodes'] = ([h for h, v in q['nodes'].items()
0485                                                    if v['reason'] == guard.QUEUE_EVENT]
0486                                                   if q.get('queue_event') else [])
0487         state_payload = {'cycle_at': summary['cycle_at'], 'mode': mode,
0488                          'mode_requested': mode_requested, 'enabled': enabled,
0489                          'settings': {k: cfg[k] for k in DEFAULTS},
0490                          'queues': queues_state, 'nodes': nodes,
0491                          'tripped': [(n['queue'], n['host']) for n in tripped_nodes],
0492                          'under_declaration': set_aside,
0493                          'under_dead_door': door_aside,
0494                          'exclusion': exclusion,
0495                          'errors': errors, 'duration_s': summary['duration_s']}
0496         from monitor_app.cached_product import get_product
0497         _safe('state store', lambda: get_product(
0498             STATE_KEY, lambda: state_payload, ttl_seconds=STATE_TTL_S, refresh=True),
0499             failed('state store'))
0500         _safe('cycle record', lambda: log_epicprod_action(
0501             'node-guard', 'node_guard_cycle', username=created_by,
0502             outcome='error' if errors else 'ok',
0503             duration_ms=int(summary['duration_s'] * 1000),
0504             sublevel='normal' if errors else 'low', live_default=bool(errors),
0505             level=logging.ERROR if errors else logging.INFO,
0506             message=(f'node guard cycle ({mode}{"" if enabled else ", off"}): '
0507                      f'{summary["jobs"]} jobs on {summary["hosts"]} '
0508                      f'hosts, {summary["judged"]} judged, {summary["tripped"]} tripped'
0509                      + (': ' + ', '.join(f'{q} {h}' for q, h in state_payload['tripped'])
0510                         if tripped_nodes else '')
0511                      + (f'; {len(trips)} black hole{"s" if len(trips) != 1 else ""} opened'
0512                         if trips else '')
0513                      + (f'; {summary["under_declaration"]} jobs under a declared downtime set aside ('
0514                         + ', '.join(f'{q} {n}' for q, n in sorted(set_aside.items())) + ')'
0515                         if set_aside else '')
0516                      + (f'; {summary["under_dead_door"]} registration failures at a dead '
0517                         f'door set aside ('
0518                         + ', '.join(f'{q} {n}' for q, n in sorted(door_aside.items())) + ')'
0519                         if door_aside else '')
0520                      + ''.join(f'; {q} storm on {s["storm_hosts"]} hosts, the queue\'s event'
0521                                for q, s in queues_state.items() if s.get('queue_event'))
0522                      + (f'; exclusion of {exclusion["nodes"]} node'
0523                         f'{"s" if exclusion["nodes"] != 1 else ""} published ({mode})'
0524                         if exclusion['published'] else '')
0525                      + (f'; errors: {"; ".join(errors)}' if errors else '')),
0526             **{k: v for k, v in summary.items() if k != 'errors'}),
0527             failed('cycle record'))
0528     return nodes, summary