Back to home page

EIC code displayed by LXR

 
 

    


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

0001 #!/usr/bin/env python3
0002 """log-rescue.py — move logs held in the stage-out bucket to their datasets.
0003 
0004 A pilot whose log transfer failed holds the log in the devcloud stage-out
0005 bucket under ``logs/<site>/<log dataset>/<lfn>`` and reports ``logHeld=s3`` in
0006 the job metrics (swf-epicprod docs/LOG_STAGEOUT_FALLBACK.md). Objects there
0007 expire after seven days. This pass puts each held log where the job meant it
0008 to go: the queue's log RSE, under its own name and GUID, attached to the log
0009 dataset in BNL Rucio.
0010 
0011 One pass:
0012 
0013 1. Find the held logs: jobs of the window whose metrics carry ``logHeld=s3``,
0014    with their log file from the PanDA file table.
0015 2. Skip a log BNL Rucio already holds a replica of.
0016 3. Fetch the object from the bucket and upload it to the queue's log RSE
0017    (``astorages.pl`` of the queue's schedconfig), registering it under the
0018    job's GUID and attaching it to the log dataset. A closed log dataset is
0019    reopened first, as the retry pass does.
0020 
0021 A log that will not move keeps its state entry and is tried on later passes,
0022 a bounded number of times. Objects are not deleted; their expiry does that.
0023 A queue whose log storage is the bucket itself (BNL_NPPS_GPU) has nothing to
0024 move.
0025 
0026 Django-bootstrap standalone script; also usable by hand::
0027 
0028     cd /data/wenauseic/github/swf-monitor/src
0029     ../../swf-testbed/.venv/bin/python ../scripts/log-rescue.py [--hours 168] [--pandaid N] [--dry-run]
0030 
0031 The last stdout line is a JSON summary; progress goes to stderr.
0032 Exit codes: 0 ok · 5 no usable credential.
0033 """
0034 import argparse
0035 import importlib.util
0036 import json
0037 import os
0038 import subprocess
0039 import sys
0040 import tempfile
0041 import time
0042 import zlib
0043 from datetime import datetime, timedelta, timezone as dt_timezone
0044 
0045 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0046 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0047 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0048 
0049 import django  # noqa: E402
0050 django.setup()
0051 
0052 from django.db import connections  # noqa: E402
0053 
0054 from monitor_app.panda.constants import PANDA_SCHEMA  # noqa: E402
0055 
0056 # The BNL Rucio identity and connection of the retry pass, imported rather
0057 # than copied so the two writers into the log datasets cannot drift.
0058 _spec = importlib.util.spec_from_file_location(
0059     'panda_task_operation', os.path.join(THIS_DIR, 'panda-task-operation.py'))
0060 _op = importlib.util.module_from_spec(_spec)
0061 _spec.loader.exec_module(_op)
0062 
0063 BUCKET = os.environ.get('EPICPROD_STAGEOUT_BUCKET', 'epic-devcloud-stageout')
0064 REGION = os.environ.get('EPICPROD_STAGEOUT_REGION', 'us-east-1')
0065 PROFILE = os.environ.get('EPICPROD_STAGEOUT_AWS_PROFILE', 'epic-stageout')
0066 BUCKET_STORAGE = 'DEV_CLOUD_S3'
0067 DEFAULT_HOURS = 168
0068 STATE_PATH = os.environ.get('LOG_RESCUE_STATE',
0069                             '/data/wenauseic/swf-delivery/log-rescue-state.json')
0070 MAX_ATTEMPTS = int(os.environ.get('LOG_RESCUE_MAX_ATTEMPTS', 12))
0071 
0072 
0073 def _log(msg):
0074     print(msg, file=sys.stderr, flush=True)
0075 
0076 
0077 def held_logs(since, pandaid=None):
0078     """[(pandaid, site, jeditaskid, lfn, guid, dataset, scope)] of held logs."""
0079     found = []
0080     for table in ('jobsarchived4', 'jobsactive4'):
0081         sql = f"""
0082             SELECT j."pandaid", j."computingsite", j."jeditaskid",
0083                    f."lfn", f."guid", f."dataset", f."scope"
0084             FROM "{PANDA_SCHEMA}"."{table}" j
0085             JOIN "{PANDA_SCHEMA}"."filestable4" f
0086               ON f."pandaid" = j."pandaid" AND f."type" = 'log'
0087             WHERE j."modificationtime" >= %s AND j."jobmetrics" LIKE %s
0088         """
0089         args = [since, '%logHeld=s3%']
0090         if pandaid:
0091             sql += ' AND j."pandaid" = %s'
0092             args.append(int(pandaid))
0093         with connections['panda'].cursor() as cursor:
0094             cursor.execute(sql, args)
0095             found.extend(cursor.fetchall())
0096     return found
0097 
0098 
0099 def log_storage(site, cache={}):
0100     """The RSE a queue's pilots write logs to: astorages.pl of its schedconfig."""
0101     if site not in cache:
0102         sql = f'SELECT "data" FROM "{PANDA_SCHEMA}"."schedconfig_json" WHERE "panda_queue" = %s'
0103         with connections['panda'].cursor() as cursor:
0104             cursor.execute(sql, [site])
0105             row = cursor.fetchone()
0106         data = row[0] if row else {}
0107         if isinstance(data, str):
0108             data = json.loads(data)
0109         storages = (data or {}).get('astorages') or {}
0110         names = storages.get('pl') or storages.get('write_lan') or storages.get('w') or []
0111         cache[site] = names[0] if names else ''
0112     return cache[site]
0113 
0114 
0115 def load_state():
0116     try:
0117         with open(STATE_PATH) as fp:
0118             return json.load(fp)
0119     except (OSError, ValueError):
0120         return {}
0121 
0122 
0123 def save_state(state):
0124     os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
0125     tmp = STATE_PATH + '.tmp'
0126     with open(tmp, 'w') as fp:
0127         json.dump(state, fp, indent=1, sort_keys=True)
0128     os.replace(tmp, STATE_PATH)
0129 
0130 
0131 def rucio_clients():
0132     # The upload client reads a configuration file even when handed a client;
0133     # the ePIC client configuration on CVMFS is the one the pilots use.
0134     os.environ.setdefault('RUCIO_CONFIG', '/cvmfs/eic.opensciencegrid.org/rucio-clients/rucio.cfg')
0135     os.environ.setdefault('X509_CERT_DIR', '/etc/grid-security/certificates')  # the configuration's ca_cert
0136     os.environ.setdefault('X509_USER_PROXY', _op.X509_PROXY)  # the upload path reads the proxy from here
0137     from rucio.client import Client
0138     from rucio.client.uploadclient import UploadClient
0139     client = Client(rucio_host=_op.RUCIO_URL, auth_host=_op.RUCIO_URL, account=_op.RUCIO_ACCOUNT,
0140                     auth_type='x509_proxy', creds={'client_proxy': _op.X509_PROXY},
0141                     ca_cert=None, vo=_op.RUCIO_VO)
0142     client.whoami()
0143     return client, UploadClient(_client=client)
0144 
0145 
0146 def catalog_entry(client, scope, lfn):
0147     """The file's replica record, or None when the catalog has no such file.
0148 
0149     PanDA registers the log of a failed job at the queue's log RSE, AVAILABLE
0150     with zero bytes and no checksum, whether or not any file was written: a
0151     ghost. A record with bytes and a checksum is a real replica.
0152     """
0153     try:
0154         for rep in client.list_replicas([{'scope': scope, 'name': lfn}], schemes=['root', 'davs']):
0155             return rep
0156     except Exception as exc:  # DataIdentifierNotFound: not yet registered
0157         if 'not found' not in str(exc).lower():
0158             raise
0159     return None
0160 
0161 
0162 class Occupied(Exception):
0163     """The ghost's path already holds a file: a log of another attempt under
0164     the same name (PanDA reuses a log name across a job's attempts)."""
0165 
0166 
0167 def fill_ghost(client, scope, lfn, rep, path):
0168     """Write the log to the path its ghost replica names and record its size and checksum."""
0169     pfns = [p for pfns in (rep.get('rses') or {}).values() for p in pfns]
0170     pfn = next((p for p in pfns if p.startswith('root://')), None)
0171     if pfn is None and pfns:  # a davs door on an xrootd port speaks root too
0172         pfn = 'root://' + pfns[0].split('://', 1)[1]
0173     if pfn is None:
0174         raise RuntimeError(f'ghost {scope}:{lfn} names no replica path')
0175     # A JLab door takes a storage token for this one path, create only (a
0176     # log is never overwritten), else the JLab production proxy, not the BNL
0177     # one: its TLS refuses a proxy file readable by others, as the agent's
0178     # BNL copy is (the payload-log fetch routes the same way).
0179     env = dict(os.environ)
0180     if '.jlab.org' in pfn.split('/')[2]:
0181         from swf_epicprod.jlab_auth import door_access
0182         door, _, door_path = pfn.partition('//')[2].partition('/')
0183         url, env, how = door_access(f"{pfn.split('//')[0]}//{door}", f'/{door_path}',
0184                                     verbs=('storage.create',),
0185                                     proxy=os.environ.get('EVGEN_X509_PROXY') or None)
0186         if how == 'none':
0187             raise RuntimeError(f'the JLab door at {pfn} gave no storage token '
0188                                'and EVGEN_X509_PROXY is not set')
0189         pfn = f"{url}//{door_path.lstrip('/')}"
0190     proc = subprocess.run(['xrdcp', '--nopbar', path, pfn], capture_output=True, text=True,
0191                           timeout=600, env=env)
0192     if proc.returncode != 0 and 'file exists' in (proc.stderr or proc.stdout).lower():
0193         raise Occupied(f'{pfn} already holds a file; another attempt wrote this log name, '
0194                        'and it is never overwritten. The held copy stays in the bucket until it expires.')
0195     if proc.returncode != 0:
0196         raise RuntimeError(f'xrdcp to {pfn} failed: {(proc.stderr or proc.stdout).strip()[:300]}')
0197     size = os.path.getsize(path)
0198     with open(path, 'rb') as fp:
0199         adler = f'{zlib.adler32(fp.read()) & 0xffffffff:08x}'
0200     note = ''
0201     for key, value in (('bytes', size), ('adler32', adler)):
0202         try:
0203             client.set_metadata(scope=scope, name=lfn, key=key, value=value)
0204         except Exception as exc:
0205             note += f'; {key} not recorded: {type(exc).__name__}: {str(exc)[:120]}'
0206     return f'filled ghost at {pfn} ({size} B, ad:{adler}){note}'
0207 
0208 
0209 def ensure_open(client, scope, dataset):
0210     meta = client.get_metadata(scope=scope, name=dataset)
0211     if meta.get('is_open') is False:
0212         client.set_status(scope=scope, name=dataset, open=True)
0213         client.set_metadata(scope=scope, name=dataset, key='lifetime',
0214                             value=_op.REOPENED_LIFETIME_DAYS * 86400)
0215         _log(f'reopened closed log dataset {scope}:{dataset}')
0216 
0217 
0218 def rescue(client, uploader, s3, entry, dry_run):
0219     pandaid, site, jeditaskid, lfn, guid, dataset, scope = entry
0220     rse = log_storage(site)
0221     key = f'logs/{site}/{dataset}/{lfn}'
0222     if not rse or rse == BUCKET_STORAGE:
0223         return 'bucket', f'{site} keeps its logs in the bucket'
0224     rep = catalog_entry(client, scope, lfn)
0225     if rep and rep.get('bytes') and rep.get('adler32'):
0226         return 'done', f'{scope}:{lfn} already at an RSE'
0227     if dry_run:
0228         what = 'fill the ghost replica of' if rep else f'move to {rse} and attach to {scope}:{dataset}'
0229         return 'dry_run', f'would {what} {key}'
0230     with tempfile.TemporaryDirectory(prefix='log-rescue-') as tmp:
0231         path = os.path.join(tmp, lfn)
0232         s3.download_file(BUCKET, key, path)
0233         if rep:
0234             try:
0235                 return 'done', fill_ghost(client, scope, lfn, rep, path)
0236             except Occupied as exc:
0237                 return 'occupied', str(exc)
0238         ensure_open(client, scope, dataset)
0239         if gfal_only(client, rse):
0240             try:
0241                 return 'done', upload_xrootd(client, rse, scope, lfn, guid, dataset, path)
0242             except Occupied as exc:
0243                 return 'occupied', str(exc)
0244         uploader.upload([{'path': path, 'rse': rse, 'did_scope': scope, 'did_name': lfn,
0245                           'guid': guid, 'dataset_scope': scope, 'dataset_name': dataset,
0246                           'register_after_upload': True}])
0247     return 'done', f'{key} -> {rse}, attached to {scope}:{dataset}'
0248 
0249 
0250 def gfal_only(client, rse):
0251     """True when gfal2 is not installed here and every write protocol of the
0252     RSE is gfal's (BNL_PROD_DISK_1, GREX's log RSE, lists davs and root, both
0253     gfal): the upload client cannot write there, and the log goes by xrdcp
0254     instead (``upload_xrootd``). gfal2 has no build for the agent's Python."""
0255     try:
0256         import gfal2  # noqa: F401
0257         return False
0258     except ImportError:
0259         pass
0260     writers = [p for p in client.get_protocols(rse)
0261                if any((p['domains'].get(d) or {}).get('write') for d in ('lan', 'wan'))]
0262     return bool(writers) and all(p['impl'].startswith('rucio.rse.protocols.gfal') for p in writers)
0263 
0264 
0265 def upload_xrootd(client, rse, scope, lfn, guid, dataset, path):
0266     """The upload client's write and registration, by xrdcp: the log is
0267     written to its deterministic root path at the RSE, never over a file
0268     there, then registered there under the job's GUID with its size and
0269     checksum and attached to its log dataset."""
0270     pfn = next(iter(client.lfns2pfns(rse, [f'{scope}:{lfn}'], scheme='root',
0271                                      operation='write').values()))
0272     proc = subprocess.run(['xrdcp', '--nopbar', path, pfn], capture_output=True, text=True,
0273                           timeout=600)
0274     if proc.returncode != 0 and 'file exists' in (proc.stderr or proc.stdout).lower():
0275         raise Occupied(f'{pfn} already holds a file; another attempt wrote this log name, '
0276                        'and it is never overwritten. The held copy stays in the bucket until it expires.')
0277     if proc.returncode != 0:
0278         raise RuntimeError(f'xrdcp to {pfn} failed: {(proc.stderr or proc.stdout).strip()[:300]}')
0279     size = os.path.getsize(path)
0280     with open(path, 'rb') as fp:
0281         adler = f'{zlib.adler32(fp.read()) & 0xffffffff:08x}'
0282     client.add_replicas(rse=rse, files=[{'scope': scope, 'name': lfn, 'bytes': size,
0283                                          'adler32': adler,
0284                                          'meta': {'guid': str(guid).replace('-', '')}}])
0285     client.attach_dids(scope=scope, name=dataset, dids=[{'scope': scope, 'name': lfn}])
0286     return f'{pfn} by xrdcp ({size} B, ad:{adler}), attached to {scope}:{dataset}'
0287 
0288 
0289 def main():
0290     parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
0291     parser.add_argument('--hours', type=int, default=DEFAULT_HOURS)
0292     parser.add_argument('--pandaid', type=int)
0293     parser.add_argument('--dry-run', action='store_true')
0294     args = parser.parse_args()
0295 
0296     since = datetime.now(dt_timezone.utc) - timedelta(hours=args.hours)
0297     entries = held_logs(since.replace(tzinfo=None), args.pandaid)
0298     summary = {'held': len(entries), 'done': 0, 'bucket': 0, 'occupied': 0, 'failed': [], 'skipped': 0}
0299     if not entries:
0300         print(json.dumps(summary))
0301         return 0
0302 
0303     try:
0304         import boto3
0305         s3 = boto3.Session(profile_name=PROFILE).client('s3', region_name=REGION)
0306         client, uploader = rucio_clients()
0307     except Exception as exc:
0308         _log(f'log-rescue: no usable credential: {exc}')
0309         summary['error'] = f'credential: {exc}'
0310         print(json.dumps(summary))
0311         return 5
0312 
0313     state = load_state()
0314     for entry in entries:
0315         pandaid = str(entry[0])
0316         record = state.get(pandaid, {})
0317         if record.get('outcome') in ('done', 'bucket', 'occupied'):
0318             summary[record['outcome']] += 1
0319             continue
0320         if record.get('attempts', 0) >= MAX_ATTEMPTS:
0321             summary['skipped'] += 1
0322             continue
0323         try:
0324             outcome, detail = rescue(client, uploader, s3, entry, args.dry_run)
0325         except Exception as exc:
0326             outcome, detail = 'failed', f'{type(exc).__name__}: {exc}'
0327         _log(f'log-rescue: {pandaid} {outcome}: {detail}')
0328         if outcome == 'failed':
0329             summary['failed'].append(f'{pandaid}: {detail}'[:300])
0330             state[pandaid] = {'outcome': 'failed', 'attempts': record.get('attempts', 0) + 1,
0331                               'reason': detail[:500], 'at': int(time.time())}
0332         elif outcome != 'dry_run':
0333             summary[outcome] += 1
0334             state[pandaid] = {'outcome': outcome, 'detail': detail[:500], 'at': int(time.time())}
0335     if not args.dry_run:
0336         save_state(state)
0337     print(json.dumps(summary))
0338     return 0
0339 
0340 
0341 if __name__ == '__main__':
0342     sys.exit(main())