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
0051
0052
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
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:
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
0344
0345
0346
0347
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
0353
0354
0355
0356
0357
0358
0359
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
0390
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
0426
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
0449
0450
0451
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