Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """The storage door canary's cycle (site-canary docs/STORAGE_DOORS.md):
0002 the doors production writes through are used — a small write, a stat, a
0003 delete, and the date on the certificate they serve — and what they
0004 answer becomes a record production operations can act on.
0005 
0006 The cycle runs as the production-operations agent's
0007 ``storage_door_cycle`` doer (``scripts/storage-door-cycle.py``), by cron
0008 enqueue every fifteen minutes; a door is probed when its last probe is
0009 older than ``storage_doors.interval_h``, which starts at one hour, so the
0010 cadence is a setting rather than a cron line. The doors come from the
0011 catalog, not from a list here: each RSE's preferred xrootd write
0012 protocol (``canary.doors.write_door``) is the door its writes go
0013 through, so an RSE added to the catalog is probed the day it exists.
0014 
0015 Nothing acts on the record automatically. The canary reports and
0016 production operations act (STORAGE_DOORS.md, Nothing acts on it
0017 automatically): a door that changes verdict raises its own action, at
0018 alarm severity when it goes down, and the cycle leaves the cached
0019 product ``storage_door_state`` for the page and the endpoint. The
0020 payload is not tied to this record; it reads its own write door's
0021 certificate itself.
0022 
0023 Settings live in SysConfig under ``storage_doors.*``, seeded at their
0024 defaults on first read so every knob is visible on the System page.
0025 """
0026 import logging
0027 import os
0028 from datetime import timedelta, timezone as dt_timezone
0029 
0030 from django.utils import timezone
0031 
0032 logger = logging.getLogger(__name__)
0033 
0034 STATE_KEY = 'storage_door_state'
0035 STATE_TTL_S = 24 * 3600
0036 MODES = ('shadow', 'live')
0037 DEFAULTS = {
0038     'enabled': False,
0039     'mode': 'shadow',
0040     'interval_h': 1,
0041     'validity_h': 3,
0042     'skip_rses': [],
0043     'probe_prefix': '/canary',
0044     'probe_name': 'storage-door-probe',
0045     'timeout_s': 30,
0046     'max_per_cycle': 4,
0047     'warn_days': 7,
0048     'catalog_url': 'https://rucio-server.jlab.org:443',
0049     'account': 'eicprod',
0050 }
0051 
0052 
0053 # The failures a dead write door causes: the payload's own registration
0054 # exit, and the pilot's label for the stat it could not make at the door.
0055 # A node is not a black hole for these while its door is down.
0056 REGISTRATION_ERRORS = ('pilot 1305', 'exe 78', 'trans 78')
0057 
0058 
0059 def down_windows(hours=24, now=None):
0060     """The spans each door was recorded down, newest verdict last, from
0061     the cycle's own verdict changes on the action stream:
0062     ``[{'rse', 'door', 'start', 'end'}]`` with ``end`` None while a door
0063     is still down. A door that has never changed verdict has no span,
0064     which is the honest answer before the canary has watched it.
0065     """
0066     from django.utils.dateparse import parse_datetime
0067     from monitor_app.models import AppLog
0068 
0069     now = now or timezone.now()
0070     since = now - timedelta(hours=hours)
0071     opened, spans = {}, []
0072     rows = (AppLog.objects
0073             .filter(app_name='epicprod', timestamp__gte=since,
0074                     extra_data__action='storage_door_verdict')
0075             .order_by('timestamp')
0076             .values_list('timestamp', 'extra_data'))
0077     # A door found down may have died at any time since the probe before,
0078     # one interval earlier: the span opens there. On 2026-10-02 BNL-XRD's
0079     # disk filled before the 10:23 probe saw it, and jobs that failed on it
0080     # at 10:21 tripped a GREX node as if the node were at fault.
0081     lead = timedelta(hours=settings()['interval_h'])
0082     for stamp, extra in rows:
0083         extra = extra or {}
0084         rse = str(extra.get('subject_key') or '')
0085         if not rse:
0086             continue
0087         verdict = str(extra.get('verdict') or '')
0088         if verdict == 'down' and rse not in opened:
0089             opened[rse] = {'rse': rse, 'door': str(extra.get('door') or ''),
0090                            'start': stamp - lead, 'end': None}
0091         elif verdict != 'down' and rse in opened:
0092             span = opened.pop(rse)
0093             span['end'] = stamp
0094             spans.append(span)
0095     spans.extend(opened.values())
0096     return spans
0097 
0098 
0099 def set_aside_door(rows, spans, parse=None):
0100     """The node guard's rows with the registration failures that ended
0101     while their write door was down set aside: a dead door is not a
0102     node's fault (site-canary docs/NODE_GUARD.md, The detector).
0103     Returns (kept, set_aside_by_queue). Pure over ``spans``.
0104 
0105     Only a failure of the registration class is set aside, and only
0106     inside a span: a node that kills jobs for its own reasons during an
0107     outage still reads as what it is.
0108     """
0109     from django.utils.dateparse import parse_datetime
0110     parse = parse or parse_datetime
0111     by_queue = {}
0112     if not spans:
0113         return list(rows), by_queue
0114     kept = []
0115     for row in rows:
0116         error = str(row.get('error') or '')
0117         when = parse(str(row.get('endtime') or '')) if row.get('endtime') else None
0118         if when is not None and when.tzinfo is None:
0119             # PanDA's times are naive UTC; the spans come from the action
0120             # stream, aware.
0121             when = when.replace(tzinfo=dt_timezone.utc)
0122         if (row.get('jobstatus') == 'failed' and error in REGISTRATION_ERRORS
0123                 and when is not None
0124                 and any(s['start'] <= when and (s['end'] is None or when <= s['end'])
0125                         for s in spans)):
0126             queue = str(row.get('queue') or '')
0127             by_queue[queue] = by_queue.get(queue, 0) + 1
0128             continue
0129         kept.append(row)
0130     return kept, by_queue
0131 
0132 
0133 def _safe(label, fn, fallback):
0134     try:
0135         return fn()
0136     except Exception as exc:  # noqa: BLE001
0137         logger.exception('storage doors: %s failed', label)
0138         return fallback(exc) if callable(fallback) else fallback
0139 
0140 
0141 def setting(key, default):
0142     from monitor_app.models import SysConfig
0143     return SysConfig.get_setting(key, default)
0144 
0145 
0146 def settings():
0147     out = {key: setting(f'storage_doors.{key}', default)
0148            for key, default in DEFAULTS.items()}
0149     skip = out['skip_rses']
0150     if not (isinstance(skip, (list, tuple)) and all(isinstance(r, str) for r in skip)):
0151         logger.error('storage_doors.skip_rses is not a list of RSE names: %r; '
0152                      'probing every RSE', skip)
0153         out['skip_rses'] = []
0154     out['interval_h'] = max(0.25, float(out['interval_h'] or 1))
0155     out['validity_h'] = max(2 * out['interval_h'], float(out['validity_h'] or 3))
0156     out['timeout_s'] = max(5, int(out['timeout_s'] or 30))
0157     out['max_per_cycle'] = max(1, int(out['max_per_cycle'] or 4))
0158     if out['mode'] not in MODES:
0159         logger.error('storage_doors.mode is not one of %s: %r; running shadow',
0160                      MODES, out['mode'])
0161         out['mode'] = 'shadow'
0162     return out
0163 
0164 
0165 def catalog_client(cfg):
0166     """The catalog, read as the production account through the proxy the
0167     ops agent holds. Raises when there is no proxy: a cycle that cannot
0168     read the catalog has no doors to probe and says so."""
0169     from rucio.client import Client
0170     proxy = os.environ.get('EVGEN_X509_PROXY', '')
0171     if not proxy or not os.path.exists(proxy):
0172         raise RuntimeError('no proxy (EVGEN_X509_PROXY) on this host; '
0173                            'the catalog was not read')
0174     base = str(cfg['catalog_url'])
0175     return Client(rucio_host=base, auth_host=base, account=str(cfg['account']),
0176                   auth_type='x509_proxy', creds={'client_proxy': proxy},
0177                   timeout=30)
0178 
0179 
0180 def catalog_doors(cfg):
0181     """{RSE: the door its writes go through} from the catalog. An RSE
0182     whose write protocols are all of a scheme the probe does not speak is
0183     carried with no door and reported as not probed."""
0184     from canary import doors as canary_doors
0185     client = catalog_client(cfg)
0186     skip = {r.strip() for r in cfg['skip_rses'] if r.strip()}
0187     out = {}
0188     for record in client.list_rses():
0189         rse = record.get('rse') if isinstance(record, dict) else str(record)
0190         if not rse or rse in skip:
0191             continue
0192         door = _safe(f'{rse} protocols',
0193                      lambda rse=rse: canary_doors.write_door(client.get_protocols(rse)),
0194                      None)
0195         out[rse] = door
0196     return out
0197 
0198 
0199 def previous_state():
0200     """The last cycle's record, or an empty one."""
0201     from monitor_app.models import CachedProduct
0202     row = CachedProduct.objects.filter(key=STATE_KEY).first()
0203     value = (row.value if row and isinstance(row.value, dict) else {}) or {}
0204     return value.get('doors') or {}
0205 
0206 
0207 def is_due(entry, interval_h, now):
0208     """Whether a door is due a probe: never probed, or last probed longer
0209     ago than the interval. Pure."""
0210     if not entry or not entry.get('probed_at'):
0211         return True
0212     from django.utils.dateparse import parse_datetime
0213     last = parse_datetime(str(entry['probed_at']))
0214     if last is None:
0215         return True
0216     if timezone.is_naive(last):
0217         last = timezone.make_aware(last, timezone.utc)
0218     return (now - last) >= timedelta(hours=interval_h)
0219 
0220 
0221 def run_cycle(*, dry_run=False, created_by='storage-doors', force=False,
0222               only=()):
0223     """One cycle: the due doors probed, the verdicts recorded, a changed
0224     verdict announced. Returns (doors, summary)."""
0225     from canary import doors as canary_doors
0226     from monitor_app.epicprod_logging import log_epicprod_action
0227 
0228     t0 = timezone.now()
0229     cfg = settings()
0230     errors = []
0231 
0232     def failed(label):
0233         def note(exc):
0234             errors.append(f'{label}: {type(exc).__name__}: {exc}')
0235             return None
0236         return note
0237 
0238     previous = previous_state()
0239     resolved = _safe('catalog', lambda: catalog_doors(cfg), failed('catalog')) or {}
0240     if only:
0241         # A look by hand at named RSEs; the cycle itself takes the catalog.
0242         wanted = {r.strip() for r in only if r.strip()}
0243         resolved = {rse: door for rse, door in resolved.items() if rse in wanted}
0244 
0245     # The most overdue doors first, and only so many in one cycle: a
0246     # door that does not answer costs its whole timeout, and eight of
0247     # those would outlast the doer. With the enqueue every fifteen
0248     # minutes, every door still gets its turn inside the hour.
0249     def overdue(item):
0250         was = previous.get(item[0]) or {}
0251         return str(was.get('probed_at') or '')
0252     ordered = sorted(resolved.items(), key=overdue)
0253 
0254     readings, state, probed = [], {}, 0
0255     for rse, door in ordered:
0256         was = previous.get(rse) or {}
0257         if not door:
0258             state[rse] = {**was, 'rse': rse, 'verdict': 'unknown',
0259                           'reason': 'no_xrootd_write_door',
0260                           'evidence': {}, 'door': '', 'probed_at': was.get('probed_at')}
0261             continue
0262         if (not (force or cfg['enabled']) or not is_due(was, cfg['interval_h'], t0)
0263                 or probed >= cfg['max_per_cycle']):
0264             state[rse] = {**was, 'rse': rse, 'door': door['door']}
0265             continue
0266         path = canary_doors.probe_path(door['prefix'], cfg['probe_prefix'],
0267                                        cfg['probe_name'])
0268         reading = _safe(f'{rse} probe', lambda door=door, path=path: canary_doors.probe_door(
0269             door['door'], path, timeout_s=cfg['timeout_s']), failed(f'{rse} probe'))
0270         if reading is None:
0271             state[rse] = {**was, 'rse': rse, 'door': door['door']}
0272             continue
0273         reading['rse'] = rse
0274         reading['expiry'] = _safe(
0275             f'{rse} certificate',
0276             lambda door=door: canary_doors.door_certificate(door['door'], cfg['timeout_s']),
0277             failed(f'{rse} certificate'))
0278         readings.append(reading)
0279         probed += 1
0280 
0281     verdicts = canary_doors.decide_doors(
0282         readings, {'warn_days': cfg['warn_days']}, t0) if readings else {}
0283     changes = []
0284     for reading in readings:
0285         rse = reading['rse']
0286         verdict = verdicts[reading['door']]
0287         was = previous.get(rse) or {}
0288         state[rse] = {
0289             'rse': rse, 'door': reading['door'], 'path': reading.get('path'),
0290             'verdict': verdict['verdict'], 'reason': verdict['reason'],
0291             'evidence': verdict['evidence'], 'probed_at': t0.isoformat(),
0292             'since': (was.get('since') if was.get('verdict') == verdict['verdict']
0293                       else t0.isoformat()),
0294         }
0295         if was.get('verdict') and was['verdict'] != verdict['verdict']:
0296             changes.append((rse, was['verdict'], verdict['verdict'], verdict))
0297         elif not was.get('verdict') and verdict['verdict'] != 'up':
0298             changes.append((rse, 'unseen', verdict['verdict'], verdict))
0299 
0300     if not dry_run:
0301         for rse, before, after, verdict in changes:
0302             evidence = verdict['evidence']
0303             severity = {'down': 'alarm', 'unknown': 'warning'}.get(after, 'info')
0304             _safe(f'{rse} verdict record', lambda rse=rse, before=before, after=after,
0305                   verdict=verdict, evidence=evidence, severity=severity: log_epicprod_action(
0306                 'storage-doors', 'storage_door_verdict',
0307                 subject_type='storage_rse', subject_key=rse, username=created_by,
0308                 outcome='error' if after == 'down' else 'ok',
0309                 sublevel='high' if after == 'down' else 'normal', live_default=True,
0310                 level=logging.ERROR if after == 'down' else logging.INFO,
0311                 message=(f'storage door {rse} {before} -> {after} '
0312                          f'({verdict["reason"]})'
0313                          + (f'; certificate {evidence["certificate"]}, '
0314                             f'{evidence["certificate_days_left"]} days'
0315                             if evidence.get('certificate') else '')
0316                          + (f'; the door said: {evidence["said"]}'
0317                             if evidence.get('said') else '')),
0318                 severity=severity, verdict=after, was=before,
0319                 reason=verdict['reason'], door=state[rse]['door'],
0320                 **{k: v for k, v in evidence.items() if k != 'said'}),
0321                 failed(f'{rse} verdict record'))
0322 
0323     down = sorted(r for r, s in state.items() if s.get('verdict') == 'down')
0324     expiring = sorted(r for r, s in state.items()
0325                       if (s.get('evidence') or {}).get('certificate_expiring'))
0326     summary = {'cycle_at': t0.isoformat(), 'max_per_cycle': cfg['max_per_cycle'], 'mode': cfg['mode'], 'enabled': cfg['enabled'],
0327                'interval_h': cfg['interval_h'], 'rses': len(state), 'probed': probed,
0328                'down': down, 'expiring': expiring, 'changes': len(changes),
0329                'errors': errors,
0330                'duration_s': round((timezone.now() - t0).total_seconds(), 1)}
0331 
0332     if not dry_run:
0333         payload = {'cycle_at': summary['cycle_at'], 'mode': cfg['mode'],
0334                    'enabled': cfg['enabled'],
0335                    'valid_until': (t0 + timedelta(hours=cfg['validity_h'])).isoformat(),
0336                    'settings': {k: cfg[k] for k in DEFAULTS},
0337                    'doors': state, 'down': down, 'expiring': expiring,
0338                    'errors': errors, 'duration_s': summary['duration_s']}
0339         from monitor_app.cached_product import get_product
0340         _safe('state store', lambda: get_product(
0341             STATE_KEY, lambda: payload, ttl_seconds=STATE_TTL_S, refresh=True),
0342             failed('state store'))
0343         _safe('cycle record', lambda: log_epicprod_action(
0344             'storage-doors', 'storage_door_cycle', username=created_by,
0345             outcome='error' if errors else 'ok',
0346             duration_ms=int(summary['duration_s'] * 1000),
0347             sublevel='normal' if (errors or down) else 'low',
0348             live_default=bool(errors or down),
0349             level=logging.ERROR if errors else logging.INFO,
0350             message=(f'storage door cycle ({cfg["mode"]}'
0351                      f'{"" if cfg["enabled"] else ", off"}): '
0352                      f'{probed} of {len(state)} doors probed'
0353                      + (f'; down: {", ".join(down)}' if down else '')
0354                      + (f'; certificate expiring: {", ".join(expiring)}' if expiring else '')
0355                      + (f'; {len(changes)} verdict change'
0356                         f'{"s" if len(changes) != 1 else ""}' if changes else '')
0357                      + (f'; errors: {"; ".join(errors)}' if errors else '')),
0358             **{k: v for k, v in summary.items() if k != 'errors'}),
0359             failed('cycle record'))
0360     return state, summary