Back to home page

EIC code displayed by LXR

 
 

    


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

0001 #!/usr/bin/env python3
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  # noqa: E402
0055 django.setup()
0056 
0057 from django.db import connections  # noqa: E402
0058 
0059 from monitor_app.models import EpicProdJob  # noqa: E402
0060 from monitor_app.panda.constants import PANDA_SCHEMA  # noqa: E402
0061 
0062 # The stash: the BNL science-data RSE of the JLab catalog, its write door,
0063 # and its deterministic prefix, so a stashed file sits at the PFN its
0064 # logical name resolves to. The same values run.sh writes with
0065 # (STASH_DOOR, STASH_PREFIX).
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 # A test output (under /TEST/) registered from the stash removes itself, as
0075 # the payload canaries' do.
0076 TEST_LIFETIME_S = 7 * 86400
0077 
0078 # The JLab catalog's registrar: its identity, client, deterministic path and
0079 # one registration by logical name. Imported rather than copied, so the two
0080 # writers into the catalog of record cannot drift.
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     # Canaries included: their /TEST/ outputs stash and drain like
0109     # production's, and a canary is how the failover chain is proven
0110     # (swf-epicprod docs/RUCIO_FAILOVER_STASH.md, Sequencing).
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:                                    # noqa: BLE001
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:                                    # noqa: BLE001
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:                                    # noqa: BLE001
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     # The registrar's completion reads the replica back and answers
0223     # 'registered' only when it reads AVAILABLE at the RSE.
0224     outcome, reason = _reg.complete(jlab, rse, owes, events, proxy)
0225     if outcome == 'undelivered':
0226         # Settled at first sight: a file the storage says is absent does
0227         # not arrive later, and retrying it only repeats the report.
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:                                # noqa: BLE001
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:                                # noqa: BLE001
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:                                    # noqa: BLE001
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     # A name given by hand is tried past the attempt ceiling. A report that
0363     # carries it supplies the entry, so the registration keeps its event
0364     # count; a name no report carries is a bare entry at its path.
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     # The proxy is the fallback: the catalog takes a token from the PanDA
0375     # server, the stash door a storage token (swf_epicprod.jlab_auth).
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         # An empty stash is the good state and still worth recording: the
0388         # page must be able to say "nothing is waiting, as of this pass"
0389         # rather than "the drain has never run".
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     # The catalog of record is asked first, before any file is confirmed
0397     # at the door: a silent catalog ends the pass in seconds with the
0398     # entries kept, the page current, and nothing touched. Confirming
0399     # every stashed file first cost 30 minutes on 871 entries and no
0400     # pass completed for 14 hours (2026-09-17).
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     # The pass summary names what it did; tens of thousands of names
0415     # belong in the state file, not on one log line.
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:                                    # noqa: BLE001
0464         _log(f'WARNING: the stash state was not stored: {e}')
0465 
0466 
0467 if __name__ == '__main__':
0468     sys.exit(main())