File indexing completed on 2026-11-09 10:47:23
0001
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
0050 django.setup()
0051
0052 from django.db import connections
0053
0054 from monitor_app.panda.constants import PANDA_SCHEMA
0055
0056
0057
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
0133
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')
0136 os.environ.setdefault('X509_USER_PROXY', _op.X509_PROXY)
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:
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:
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
0176
0177
0178
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
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())