File indexing completed on 2026-11-09 10:47:25
0001
0002 """stash-drain.py — register stashed outputs where they lie.
0003
0004 The registrar of the failover stash (swf-epicprod
0005 docs/RUCIO_FAILOVER_STASH.md). A job whose output JLab would not take
0006 writes it to the BNL science-data RSE, BNL-XRD, at the path Rucio's
0007 deterministic naming gives its logical name, and records what it
0008 stashed. The file is therefore already home; what the catalog of record
0009 is owed is the replica row. This pass supplies it when JLab answers.
0010
0011 One pass:
0012
0013 1. Read what the jobs stashed, from the payload reports — the PanDA
0014 metatable for a finished job, the swept copy for a failed one.
0015 2. Confirm each file at the door, with its size and checksum.
0016 3. Probe JLab. If it does not answer, the pass ends without touching
0017 anything: a stash that cannot be registered is a backlog, which is
0018 the point of having one.
0019 4. Register each file in the JLab catalog by logical name at the stash
0020 RSE, with its event count, through the registrar's own registration;
0021 verify the replica reads AVAILABLE. Nothing is copied and nothing is
0022 removed: the file stays where it is, a replica like any other, and a
0023 later move is Rucio's to make.
0024
0025 Retries live here and nowhere else. A file that will not register keeps
0026 its entry and is tried again on later passes, once an hour and a bounded
0027 number of times, with its reason kept in the drain's state file.
0028
0029 Django-bootstrap standalone script — also usable by hand::
0030
0031 cd /data/wenauseic/github/swf-monitor/src
0032 ../../swf-testbed/.venv/bin/python ../scripts/stash-drain.py \
0033 [--hours 168] [--limit N] [--entry /TEST/stash-probe/x.txt] [--dry-run]
0034
0035 ``--entry`` names a logical file already at its deterministic path on the
0036 stash RSE and registers it as a stash entry would be: the hand test of
0037 the path, and the way to register a stashed file no report names.
0038
0039 The last stdout line is a JSON summary; progress goes to stderr.
0040 Exit codes: 0 ok · 5 no usable credential.
0041 """
0042 import argparse
0043 import importlib.util
0044 import json
0045 import os
0046 import subprocess
0047 import sys
0048 from datetime import datetime, timedelta, timezone as dt_timezone
0049
0050 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0051 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0052 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0053
0054 import django
0055 django.setup()
0056
0057 from django.db import connections
0058
0059 from monitor_app.models import EpicProdJob
0060 from monitor_app.panda.constants import PANDA_SCHEMA
0061
0062
0063
0064
0065
0066 STASH_RSE = os.environ.get('STASH_RSE', 'BNL-XRD')
0067 STASH_DOOR = os.environ.get('STASH_DOOR', 'root://epicxrd1.sdcc.bnl.gov:1094')
0068 STASH_PREFIX = os.environ.get('STASH_PREFIX', '/eic/EPIC')
0069 DEFAULT_HOURS = 168
0070 STATE_PATH = os.environ.get('STASH_DRAIN_STATE',
0071 '/data/wenauseic/swf-delivery/stash-drain-state.json')
0072 RETRY_INTERVAL_S = int(os.environ.get('STASH_RETRY_INTERVAL_S', 3600))
0073 MAX_ATTEMPTS = int(os.environ.get('STASH_MAX_ATTEMPTS', 8))
0074
0075
0076 TEST_LIFETIME_S = 7 * 86400
0077
0078
0079
0080
0081 _spec = importlib.util.spec_from_file_location(
0082 'registrar', os.path.join(THIS_DIR, 'registrar.py'))
0083 _reg = importlib.util.module_from_spec(_spec)
0084 _spec.loader.exec_module(_reg)
0085 JLAB_SCOPE = _reg.RUCIO_SCOPE
0086
0087
0088 def _log(msg):
0089 print(msg, file=sys.stderr, flush=True)
0090
0091
0092 def stash_path(owes):
0093 """The path at the stash door of a logical name: the RSE's deterministic
0094 prefix and the name."""
0095 return '/' + '/'.join(p for p in f'{STASH_PREFIX}/{owes}'.split('/') if p)
0096
0097
0098 def stashed_entries(since, limit=None):
0099 """What jobs said they stashed: [(pandaid, entry, report)] from the
0100 reports."""
0101 found, seen = [], set()
0102 sql = f"""
0103 SELECT m."pandaid", m."metadata"
0104 FROM "{PANDA_SCHEMA}"."metatable" m
0105 JOIN "{PANDA_SCHEMA}"."jobsarchived4" j ON j."pandaid" = m."pandaid"
0106 WHERE j."modificationtime" >= %s AND j."processingtype" IN ('epicproduction', 'canary')
0107 """
0108
0109
0110
0111 try:
0112 with connections['panda'].cursor() as cursor:
0113 cursor.execute(sql, [since])
0114 for pandaid, raw in cursor.fetchall():
0115 try:
0116 metadata = json.loads(raw) if isinstance(raw, str) else raw
0117 except (ValueError, TypeError):
0118 continue
0119 report = (metadata or {}).get('payload') or {}
0120 for entry in report.get('stash') or []:
0121 seen.add(int(pandaid))
0122 found.append((int(pandaid), entry, report))
0123 except Exception as e:
0124 _log(f'ERROR: metatable read failed: {e}')
0125
0126 try:
0127 for job in (EpicProdJob.objects.filter(updated_at__gte=since)
0128 .only('pandaid', 'data').iterator()):
0129 if int(job.pandaid) in seen:
0130 continue
0131 report = ((job.data or {}).get('payload_report') or {}).get('report') or {}
0132 for entry in report.get('stash') or []:
0133 found.append((int(job.pandaid), entry, report))
0134 except Exception as e:
0135 _log(f'ERROR: filed reports unreadable: {e}')
0136 return found[:limit] if limit else found
0137
0138
0139 def hand_entry(owes):
0140 """A stash entry for a logical name given by hand: the file is expected
0141 at its deterministic path on the stash RSE."""
0142 owes = '/' + owes.lstrip('/')
0143 return (0, {'stashed_as': owes, 'path': stash_path(owes), 'owes': owes,
0144 'reason': 'registered by hand from the stash', 'by_hand': True}, None)
0145
0146
0147 def stored_at(door, path, proxy):
0148 """(bytes, adler32) of a file at a door, or None when it is not there.
0149 A storage token where the door takes one, else the proxy, else none."""
0150 from swf_epicprod.jlab_auth import door_access
0151 door, env, _how = door_access(door, path, proxy=proxy)
0152 try:
0153 stat = subprocess.run(['xrdfs', door, 'stat', path],
0154 capture_output=True, text=True, timeout=120, env=env)
0155 if stat.returncode != 0:
0156 return None
0157 size = None
0158 for line in (stat.stdout or '').splitlines():
0159 if line.strip().startswith('Size:'):
0160 size = int(line.split(':', 1)[1].strip())
0161 check = subprocess.run(['xrdfs', door, 'query', 'checksum', path],
0162 capture_output=True, text=True, timeout=300, env=env)
0163 adler = ''
0164 if check.returncode == 0:
0165 parts = (check.stdout or '').split()
0166 if len(parts) >= 2 and parts[0].startswith('adler32'):
0167 adler = parts[1]
0168 return (size, adler) if size is not None and adler else None
0169 except Exception as e:
0170 _log(f'WARNING: storage probe failed for {path}: {e}')
0171 return None
0172
0173
0174 def load_state():
0175 """The drain's own record of every entry it has tried to register:
0176 attempts, last attempt, outcome, reason. A file beside the storage
0177 store, no table."""
0178 try:
0179 with open(STATE_PATH) as handle:
0180 return json.load(handle) or {}
0181 except FileNotFoundError:
0182 return {}
0183 except (OSError, ValueError) as e:
0184 _log(f'WARNING: the drain state at {STATE_PATH} is unreadable: {e}')
0185 return {}
0186
0187
0188 def save_state(state):
0189 try:
0190 tmp = STATE_PATH + '.tmp'
0191 with open(tmp, 'w') as handle:
0192 json.dump(state, handle, indent=1, sort_keys=True)
0193 os.replace(tmp, STATE_PATH)
0194 except OSError as e:
0195 _log(f'ERROR: the drain state was not written to {STATE_PATH}: {e}')
0196
0197
0198 def due(entry_state, by_hand=False):
0199 """Whether this entry is due an attempt now: never after it is home,
0200 once an hour otherwise, and a bounded number of times unless an
0201 operator names it by hand."""
0202 if entry_state.get('outcome') in ('home', 'missing'):
0203 return False
0204 if not by_hand and int(entry_state.get('attempts') or 0) >= MAX_ATTEMPTS:
0205 return False
0206 last = entry_state.get('last_attempt')
0207 if not last:
0208 return True
0209 try:
0210 when = datetime.fromisoformat(last)
0211 except ValueError:
0212 return True
0213 return (datetime.now(dt_timezone.utc) - when).total_seconds() >= RETRY_INTERVAL_S
0214
0215
0216 def register_in_place(entry, events, jlab, proxy, rse):
0217 """One stashed file registered by logical name at the RSE it lies on,
0218 the registrar's way, and verified AVAILABLE. Returns ('home', ''),
0219 ('missing', reason) when the storage answers that the file is not
0220 there, or ('failed', reason)."""
0221 owes = entry['owes']
0222
0223
0224 outcome, reason = _reg.complete(jlab, rse, owes, events, proxy)
0225 if outcome == 'undelivered':
0226
0227
0228 return 'missing', reason
0229 if outcome != 'registered':
0230 return 'failed', f'registration {outcome}: {reason}'
0231 if owes.startswith('/TEST/'):
0232 try:
0233 jlab.set_metadata(JLAB_SCOPE, owes, 'lifetime', TEST_LIFETIME_S)
0234 except Exception as e:
0235 _log(f'WARNING: lifetime not set on the test output {owes}: {e}')
0236 return 'home', ''
0237
0238
0239 def register_all(entries, state, rse, summary, proxy, dry_run=False):
0240 """Every due entry registered where it lies, a few at a time, inside
0241 a pass budget, with the state saved as it goes.
0242
0243 The registrar's completion (``_reg.complete``) confirms the file at
0244 the door itself (stat and checksum), so nothing is confirmed twice.
0245 Workers each hold their own catalog client; the state is written
0246 after every outcome under a lock, so a pass cut short by the agent's
0247 timeout keeps everything it registered (a pass that saved only at
0248 its end lost its work to the timeout, 2026-09-18). What the budget
0249 leaves waits for the next hourly pass, which skips what went home."""
0250 import threading
0251 import time as _time
0252 from concurrent.futures import ThreadPoolExecutor
0253
0254 evgen = _reg._evgen
0255 workers = max(1, int(os.environ.get('STASH_WORKERS', 8)))
0256 budget_s = float(os.environ.get('STASH_PASS_BUDGET_S', 1500))
0257 started = _time.monotonic()
0258 now = datetime.now(dt_timezone.utc).isoformat()
0259 lock = threading.Lock()
0260 local = threading.local()
0261
0262 work = []
0263 for pandaid, entry, report in entries:
0264 name = entry['stashed_as']
0265 entry_state = state.setdefault(name, {})
0266 if not due(entry_state, by_hand=entry.get('by_hand', False)):
0267 if entry_state.get('outcome') != 'home':
0268 summary['deferred'].append({'stashed_as': name, 'owes': entry['owes'],
0269 'reason': entry_state.get('reason', '')})
0270 continue
0271 events = entry.get('events')
0272 if events is None and report is not None:
0273 events = _reg._events_for(report, entry.get('owes', ''))
0274 work.append((pandaid, entry, events))
0275 summary['due'] = len(work)
0276 if dry_run:
0277 summary['home'].extend(e['owes'] for _p, e, _ev in work)
0278 return
0279
0280 def client():
0281 if not hasattr(local, 'jlab'):
0282 local.jlab = evgen.rucio_client(proxy)
0283 return local.jlab
0284
0285 saved = [0]
0286
0287 def one(item):
0288 pandaid, entry, events = item
0289 name = entry['stashed_as']
0290 if _time.monotonic() - started > budget_s:
0291 return name, 'budget', ''
0292 try:
0293 outcome, reason = register_in_place(entry, events, client(), proxy, rse)
0294 except Exception as e:
0295 outcome, reason = 'failed', f'{type(e).__name__}: {e}'
0296 with lock:
0297 entry_state = state.setdefault(name, {})
0298 entry_state['attempts'] = int(entry_state.get('attempts') or 0) + 1
0299 entry_state['last_attempt'] = now
0300 entry_state['outcome'] = outcome
0301 entry_state['reason'] = reason
0302 entry_state['owes'] = entry['owes']
0303 if outcome == 'home':
0304 entry_state['home_at'] = now
0305 summary['moved'] += 1
0306 summary['home'].append(entry['owes'])
0307 else:
0308 summary['failed'].append(f'{name}: {reason}')
0309 if outcome == 'missing':
0310 entry_state['missing_at'] = now
0311 summary['missing_at_stash'] += 1
0312 saved[0] += 1
0313 if saved[0] % 20 == 0:
0314 save_state(state)
0315 _log(f"{pandaid} {name} at {rse}: {outcome}{' — ' + reason if reason else ''}")
0316 return name, outcome, reason
0317
0318 left = 0
0319 try:
0320 with ThreadPoolExecutor(max_workers=workers) as pool:
0321 for name, outcome, _reason in pool.map(one, work):
0322 if outcome == 'budget':
0323 left += 1
0324 finally:
0325 save_state(state)
0326 if left:
0327 summary['deferred'].append({'stashed_as': f'{left} entries', 'owes': '',
0328 'reason': f'pass budget of {budget_s:.0f}s reached before they were tried'})
0329 _log(f'pass budget reached: {left} due entries wait for the next pass')
0330
0331
0332 def jlab_reachable():
0333 """Whether the catalog of record is answering at all."""
0334 try:
0335 from pcs.services import _jlab_rucio_auth
0336 return bool(_jlab_rucio_auth(timeout=20))
0337 except Exception as e:
0338 _log(f'JLab probe failed: {e}')
0339 return False
0340
0341
0342 def main():
0343 parser = argparse.ArgumentParser()
0344 parser.add_argument('--hours', type=float, default=DEFAULT_HOURS)
0345 parser.add_argument('--limit', type=int, default=None)
0346 parser.add_argument('--rse', default=STASH_RSE,
0347 help='the stash RSE, where the files lie and are registered')
0348 parser.add_argument('--entry', action='append', default=[],
0349 help='a logical name already at its deterministic path '
0350 'on the stash RSE, registered as a stash entry')
0351 parser.add_argument('--dry-run', action='store_true',
0352 help='read and probe; register nothing')
0353 args = parser.parse_args()
0354
0355 since = datetime.now(dt_timezone.utc) - timedelta(hours=args.hours)
0356 summary = {'entries': 0, 'catalogued': 0, 'missing_at_stash': 0,
0357 'jlab_reachable': None, 'rse': args.rse, 'moved': 0, 'home': [],
0358 'deferred': [], 'failed': [], 'dry_run': bool(args.dry_run)}
0359 state = load_state()
0360
0361 entries = list(stashed_entries(since, limit=args.limit))
0362
0363
0364
0365 named = {'/' + owes.lstrip('/') for owes in args.entry}
0366 for _pandaid, entry, _report in entries:
0367 if entry.get('owes') in named:
0368 entry['by_hand'] = True
0369 reported = {entry.get('owes') for _pandaid, entry, _report in entries}
0370 entries += [hand_entry(owes) for owes in sorted(named - reported)]
0371 summary['entries'] = len(entries)
0372
0373 evgen = _reg._evgen
0374
0375
0376 proxy, _ = evgen.resolve_proxy_or_none()
0377 try:
0378 evgen.rucio_client(proxy)
0379 except evgen.DoerError as e:
0380 summary['failed'].append(f'no usable credential: {e}')
0381 if not args.dry_run:
0382 store_state(summary, entries, state)
0383 print(json.dumps(summary))
0384 return 5
0385
0386 if not entries:
0387
0388
0389
0390 summary['jlab_reachable'] = jlab_reachable()
0391 if not args.dry_run:
0392 store_state(summary, [], state)
0393 print(json.dumps(summary))
0394 return 0
0395
0396
0397
0398
0399
0400
0401 summary['jlab_reachable'] = jlab_reachable()
0402 if not summary['jlab_reachable']:
0403 _log('JLab is not answering; the stash keeps its entries for a later pass')
0404 if not args.dry_run:
0405 store_state(summary, entries, state)
0406 print(json.dumps(summary))
0407 return 0
0408
0409 register_all(entries, state, args.rse, summary, proxy, dry_run=args.dry_run)
0410
0411 if not args.dry_run:
0412 save_state(state)
0413 store_state(summary, entries, state)
0414
0415
0416 for key in ('home', 'failed', 'deferred'):
0417 summary[f'{key}_count'] = len(summary[key])
0418 summary[key] = summary[key][:50]
0419 print(json.dumps(summary))
0420 return 0
0421
0422
0423 def store_state(summary, entries, state):
0424 """Keep the pass and what is stashed where a page can read it.
0425
0426 The page shows what the stash holds, what it owes, and how far each
0427 entry got; reading the catalog to render that would be a remote call
0428 in a render, so the drain leaves its own account behind instead.
0429 """
0430 from monitor_app.cached_product import get_product
0431 rows = []
0432 for pandaid, entry, _report in entries:
0433 name = entry.get('stashed_as', '')
0434 entry_state = state.get(name) or {}
0435 rows.append({
0436 'pandaid': pandaid,
0437 'stashed_as': name,
0438 'path': entry.get('path', ''),
0439 'owes': entry.get('owes', ''),
0440 'reason': entry.get('reason', ''),
0441 'outcome': entry_state.get('outcome', ''),
0442 'attempts': int(entry_state.get('attempts') or 0),
0443 'last_attempt': entry_state.get('last_attempt', ''),
0444 'last_error': entry_state.get('reason', '') if entry_state.get('outcome') != 'home' else '',
0445 })
0446 payload = {
0447 'built_at': datetime.now(dt_timezone.utc).isoformat(),
0448 'rse': STASH_RSE,
0449 'door': STASH_DOOR,
0450 'destination_rse': summary.get('rse', STASH_RSE),
0451 'jlab_reachable': summary.get('jlab_reachable'),
0452 'entries': rows,
0453 'catalogued': summary.get('catalogued', 0),
0454 'missing_at_stash': summary.get('missing_at_stash', 0),
0455 'moved': summary.get('moved', 0),
0456 'home': summary.get('home', []),
0457 'deferred': len(summary.get('deferred', [])),
0458 'failed': summary.get('failed', []),
0459 }
0460 try:
0461 get_product('stash_state', lambda: payload,
0462 ttl_seconds=24 * 3600, refresh=True)
0463 except Exception as e:
0464 _log(f'WARNING: the stash state was not stored: {e}')
0465
0466
0467 if __name__ == '__main__':
0468 sys.exit(main())