File indexing completed on 2026-11-09 10:47:24
0001
0002 """registrar.py — complete the registrations the payload left pending.
0003
0004 The production-ops agent's doer for the second half of Measure 2
0005 (swf-epicprod docs/RUCIO_RESILIENCE.md). A job whose output is written and
0006 uploaded but whose catalog entry could not be made no longer fails: it
0007 records the registration pending in its payload report and exits on its
0008 physics. This reads those reports and finishes the job's bookkeeping.
0009
0010 Retry lives here and only here, so a struggling catalog sees one gentle
0011 attempt per job and then a bounded number of hour-scale attempts from one
0012 process, instead of thousands of jobs retrying at once. An outage
0013 therefore produces a registration backlog that costs no compute.
0014
0015 Worklist, from two places, because a pending job now exits success:
0016 * the PanDA metatable, which holds the payload report of every finished
0017 job — the normal case;
0018 * the reports the sweeper filed on EpicProdJob for failed jobs, for a
0019 job that went pending and then died for another reason.
0020
0021 For each pending DID the file is looked for at the RSE's deterministic
0022 path. Present means the upload stood and only the catalog entry is
0023 missing: the replica is registered with the size and checksum the storage
0024 reports, attached to its dataset, and given the event count the report
0025 carries. Absent means the upload itself did not complete, which is not a
0026 pending registration but an undelivered output, and it is reported as
0027 such and left to the residual rerun rather than invented into the
0028 catalog.
0029
0030 Attempts are recorded on the job record under
0031 ``data['payload_report']['registrar']``, so a DID is retried at most once
0032 an hour and gives up after a bounded number of tries with its reason kept.
0033
0034 Django-bootstrap standalone script — also usable by hand::
0035
0036 cd /data/wenauseic/github/swf-monitor/src
0037 ../../swf-testbed/.venv/bin/python ../scripts/registrar.py \
0038 [--hours 48] [--limit N] [--dry-run]
0039
0040 The last stdout line is a JSON summary; progress and errors go to stderr.
0041 Credentials: the catalog by a token from the PanDA server, the eicprod
0042 proxy as fallback; the storage check by a storage token where the door
0043 takes one, else the proxy, else none (swf_epicprod.jlab_auth).
0044
0045 Exit codes: 0 ok · 5 no token and no usable proxy · 6 Rucio unreachable.
0046 """
0047 import argparse
0048 import importlib.util
0049 import json
0050 import os
0051 import subprocess
0052 import sys
0053 from datetime import datetime, timedelta, timezone as dt_timezone
0054
0055 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0056 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0057 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0058
0059 import django
0060 django.setup()
0061
0062 from django.db import connections
0063
0064 from monitor_app.models import EpicProdJob
0065 from monitor_app.panda.constants import PANDA_SCHEMA
0066
0067
0068
0069
0070 _spec = importlib.util.spec_from_file_location(
0071 'register_evgen_rucio', os.path.join(THIS_DIR, 'register-evgen-rucio.py'))
0072 _evgen = importlib.util.module_from_spec(_spec)
0073 _spec.loader.exec_module(_evgen)
0074
0075 RUCIO_SCOPE = _evgen.RUCIO_SCOPE
0076 EXIT_PROXY, EXIT_RUCIO = 5, 6
0077
0078
0079 RETRY_INTERVAL_S = int(os.environ.get('REGISTRAR_RETRY_INTERVAL_S', 3600))
0080 MAX_ATTEMPTS = int(os.environ.get('REGISTRAR_MAX_ATTEMPTS', 8))
0081
0082
0083 DID_NAME_MAX = 250
0084 DEFAULT_HOURS = 48
0085
0086
0087 def _log(msg):
0088 print(msg, file=sys.stderr, flush=True)
0089
0090
0091 def _pending_from_report(report):
0092 """The DIDs a payload report says are pending, or []."""
0093 registration = (report or {}).get('registration') or {}
0094 if (registration.get('outcome') or '') != 'pending':
0095 return []
0096 return [d for d in (registration.get('dids') or []) if str(d).startswith('/')]
0097
0098
0099 def _events_for(report, did):
0100 """The event count the report gives for the output named by this DID."""
0101 outputs = (report or {}).get('outputs') or {}
0102 stem = did.rsplit('/', 1)[-1]
0103 for entry in outputs.values():
0104 if isinstance(entry, dict) and entry.get('file') == stem:
0105 events = entry.get('events')
0106 return int(events) if isinstance(events, int) else None
0107 return None
0108
0109
0110 def worklist(since, limit=None):
0111 """Jobs with a pending registration: [(pandaid, report, [did, ...])]."""
0112 found, seen = [], set()
0113 sql = f"""
0114 SELECT m."pandaid", m."metadata"
0115 FROM "{PANDA_SCHEMA}"."metatable" m
0116 JOIN "{PANDA_SCHEMA}"."jobsarchived4" j ON j."pandaid" = m."pandaid"
0117 WHERE j."modificationtime" >= %s AND j."processingtype" IN ('epicproduction', 'canary')
0118 """
0119
0120
0121 try:
0122 with connections['panda'].cursor() as cursor:
0123 cursor.execute(sql, [since])
0124 for pandaid, raw in cursor.fetchall():
0125 try:
0126 metadata = json.loads(raw) if isinstance(raw, str) else raw
0127 except (ValueError, TypeError):
0128 continue
0129 report = (metadata or {}).get('payload')
0130 dids = _pending_from_report(report)
0131 if dids:
0132 seen.add(int(pandaid))
0133 found.append((int(pandaid), report, dids))
0134 except Exception as e:
0135 _log(f'ERROR: metatable worklist query failed: {e}')
0136
0137 try:
0138 for job in EpicProdJob.objects.filter(updated_at__gte=since).only(
0139 'pandaid', 'data').iterator():
0140 if int(job.pandaid) in seen:
0141 continue
0142 report = ((job.data or {}).get('payload_report') or {}).get('report')
0143 dids = _pending_from_report(report)
0144 if dids:
0145 found.append((int(job.pandaid), report, dids))
0146 except Exception as e:
0147 _log(f'ERROR: filed-report worklist query failed: {e}')
0148
0149 return found[:limit] if limit else found
0150
0151
0152 def _registrar_state(pandaid):
0153 job = EpicProdJob.objects.filter(pandaid=pandaid).only('data').first()
0154 filed = ((job.data if job else None) or {}).get('payload_report') or {}
0155 return filed.get('registrar') or {}
0156
0157
0158 def _record_attempt(pandaid, did, outcome, reason=''):
0159 """Keep the attempt on the job record. No table, no migration: the
0160 registrar's state is a few fields beside the report it acts on."""
0161 try:
0162 job, _ = EpicProdJob.objects.get_or_create(pandaid=pandaid)
0163 data = dict(job.data or {})
0164 filed = dict(data.get('payload_report') or {})
0165 state = dict(filed.get('registrar') or {})
0166 entry = dict(state.get(did) or {})
0167 entry['attempts'] = int(entry.get('attempts') or 0) + 1
0168 entry['last_attempt'] = datetime.now(dt_timezone.utc).isoformat()
0169 entry['outcome'] = outcome
0170 entry['reason'] = reason
0171 state[did] = entry
0172 filed['registrar'] = state
0173 data['payload_report'] = filed
0174 job.data = data
0175 job.save(update_fields=['data', 'updated_at'])
0176 except Exception as e:
0177 _log(f'ERROR: attempt not recorded for {pandaid} {did}: {e}')
0178
0179
0180 def _due(state, did):
0181 """Whether this DID is due an attempt now."""
0182 entry = state.get(did) or {}
0183 if entry.get('outcome') in ('registered', 'refused'):
0184 return False
0185 if int(entry.get('attempts') or 0) >= MAX_ATTEMPTS:
0186 return False
0187 last = entry.get('last_attempt')
0188 if not last:
0189 return True
0190 try:
0191 when = datetime.fromisoformat(last)
0192 except ValueError:
0193 return True
0194 return (datetime.now(dt_timezone.utc) - when).total_seconds() >= RETRY_INTERVAL_S
0195
0196
0197 _PREFIX_CACHE = {}
0198
0199
0200 def rse_pfn_prefix(client, rse):
0201 """The deterministic PFN prefix of an RSE, from its own protocol entry,
0202 so nothing here hardcodes a door. Read once per process: it is a
0203 property of the RSE, not of the file."""
0204 if rse in _PREFIX_CACHE:
0205 return _PREFIX_CACHE[rse]
0206
0207
0208 protocols = [p for p in client.get_protocols(rse)
0209 if ((p.get('domains') or {}).get('wan') or {}).get('read')]
0210 for protocol in sorted(protocols, key=lambda p: p['domains']['wan']['read']):
0211 scheme = protocol.get('scheme')
0212 host = protocol.get('hostname')
0213 if not (scheme and host):
0214 continue
0215 port = protocol.get('port')
0216 prefix = protocol.get('prefix') or '/'
0217 netloc = f'{host}:{port}' if port else host
0218 _PREFIX_CACHE[rse] = f'{scheme}://{netloc}/{prefix.lstrip("/")}'
0219 return _PREFIX_CACHE[rse]
0220 raise _evgen.DoerError(EXIT_RUCIO, f'no readable protocol on RSE {rse}')
0221
0222
0223 class StorageProbeFailed(Exception):
0224 """The storage could not say whether the file is there."""
0225
0226
0227 def stored_file(pfn, proxy):
0228 """(bytes, adler32) of the file at a PFN, or None when the storage
0229 answers that it is not there. Raises StorageProbeFailed when the
0230 storage does not answer either way: a failed probe is not an absent
0231 file, and reading it as one loses nothing but reports a loss.
0232
0233 The storage is asked, never the catalog: the question is whether the
0234 upload stood, and the catalog is the thing that could not answer.
0235 """
0236 from swf_epicprod.jlab_auth import door_access
0237 door, _, path = pfn.partition('//')[2].partition('/')
0238
0239
0240 door_url, env, _how = door_access(f"{pfn.split('//')[0]}//{door}", f'/{path}', proxy=proxy)
0241 try:
0242 stat = subprocess.run(['xrdfs', door_url, 'stat', f'/{path}'],
0243 capture_output=True, text=True, timeout=120, env=env)
0244 if stat.returncode != 0:
0245 answer = f'{stat.stdout or ""} {stat.stderr or ""}'
0246 if '[3011]' in answer or 'No such file' in answer:
0247 return None
0248 raise StorageProbeFailed(f'stat rc={stat.returncode}: {answer.strip()[:200]}')
0249 size = None
0250 for line in (stat.stdout or '').splitlines():
0251 if line.strip().startswith('Size:'):
0252 size = int(line.split(':', 1)[1].strip())
0253 check = subprocess.run(['xrdfs', door_url, 'query', 'checksum', f'/{path}'],
0254 capture_output=True, text=True, timeout=300, env=env)
0255 adler = ''
0256 if check.returncode == 0:
0257 parts = (check.stdout or '').split()
0258 if len(parts) >= 2 and parts[0].startswith('adler32'):
0259 adler = parts[1]
0260 if size is None or not adler:
0261 raise StorageProbeFailed(f'the file is there but its size or checksum did not read '
0262 f'(size {size}, checksum rc={check.returncode})')
0263 return size, adler
0264 except StorageProbeFailed:
0265 raise
0266 except Exception as e:
0267 _log(f'WARNING: storage probe failed for {pfn}: {e}')
0268 raise StorageProbeFailed(f'{type(e).__name__}: {e}') from e
0269
0270
0271 def complete(client, rse, did, events, proxy, dry_run=False):
0272 """Register one pending DID. Returns (outcome, reason)."""
0273 dataset = did.rsplit('/', 1)[0]
0274 if len(did) > DID_NAME_MAX:
0275 return 'refused', (f'the name is {len(did)} characters, past '
0276 f"Rucio's {DID_NAME_MAX}; it can never be registered")
0277
0278
0279
0280
0281 try:
0282 prefix = rse_pfn_prefix(client, rse)
0283 except Exception as e:
0284 return 'deferred', f'catalog unreachable (RSE protocols): {e}'
0285 pfn = f'{prefix.rstrip("/")}/{did.lstrip("/")}'
0286 try:
0287 found = stored_file(pfn, proxy)
0288 except StorageProbeFailed as e:
0289 return 'deferred', f'storage probe failed, presence unknown: {e}'
0290 if found is None:
0291 return 'undelivered', ('the output is not at the storage, so the upload '
0292 'did not complete; this is a residual rerun, not '
0293 'a registration')
0294 size, adler = found
0295 if dry_run:
0296 return 'would register', f'{size} bytes, adler32 {adler}'
0297
0298 entry = {'scope': RUCIO_SCOPE, 'name': did, 'bytes': size, 'adler32': adler}
0299
0300
0301
0302
0303
0304
0305 try:
0306 client.add_files_to_datasets(
0307 [{'scope': RUCIO_SCOPE, 'name': dataset, 'rse': rse, 'dids': [entry]}],
0308 ignore_duplicate=True)
0309 except Exception as e:
0310 text = str(e).lower()
0311 if 'not found' in text or 'does not exist' in text:
0312 return 'deferred', f'the output dataset {dataset} is not in the catalog: {e}'
0313 if not ('already' in text or 'duplicate' in text):
0314 return 'deferred', f'add_files_to_datasets: {e}'
0315
0316
0317
0318
0319
0320
0321 try:
0322 replicas = list(client.list_replicas(
0323 [{'scope': RUCIO_SCOPE, 'name': did}], all_states=True))
0324 except Exception as e:
0325 return 'deferred', f'registered, but the read-back failed: {e}'
0326 available = any((r.get('states') or {}).get(rse) == 'AVAILABLE' for r in replicas)
0327 if not available:
0328 state_at_rse = next(((r.get('states') or {}).get(rse) for r in replicas
0329 if (r.get('states') or {}).get(rse)), None)
0330 if state_at_rse:
0331
0332
0333
0334
0335 try:
0336 client.update_replicas_states(
0337 rse, files=[{'scope': RUCIO_SCOPE, 'name': did, 'state': 'A'}])
0338 except Exception as e:
0339 return 'deferred', f'the {state_at_rse} replica could not be made AVAILABLE: {e}'
0340 else:
0341 try:
0342 client.add_replicas(rse=rse, files=[entry], ignore_availability=True)
0343 except Exception as e:
0344 if 'already' not in str(e).lower():
0345 return 'deferred', f'add_replicas on the existing DID: {e}'
0346 try:
0347 client.attach_dids(scope=RUCIO_SCOPE, name=dataset,
0348 dids=[{'scope': RUCIO_SCOPE, 'name': did}])
0349 except Exception as e:
0350 if 'already' not in str(e).lower():
0351 return 'deferred', f'attach_dids on the existing DID: {e}'
0352 try:
0353 replicas = list(client.list_replicas(
0354 [{'scope': RUCIO_SCOPE, 'name': did}], all_states=True))
0355 except Exception as e:
0356 return 'deferred', f'registered, but the read-back failed: {e}'
0357 if not any((r.get('states') or {}).get(rse) == 'AVAILABLE' for r in replicas):
0358 return 'deferred', f'the replica at {rse} does not read AVAILABLE after registration'
0359 if events is not None:
0360 try:
0361 client.set_metadata(RUCIO_SCOPE, did, 'events', int(events))
0362 except Exception as e:
0363
0364 _log(f'WARNING: events not set on {did}: {e}')
0365 return 'registered', ''
0366
0367
0368 def main():
0369 parser = argparse.ArgumentParser()
0370 parser.add_argument('--hours', type=float, default=DEFAULT_HOURS,
0371 help='how far back to look for pending registrations')
0372 parser.add_argument('--limit', type=int, default=None)
0373 parser.add_argument('--rse', default=os.environ.get('REGISTRAR_RSE', 'BNL-XRD'),
0374 help='the RSE the payload uploaded to')
0375 parser.add_argument('--dry-run', action='store_true',
0376 help='find and probe, register nothing')
0377 args = parser.parse_args()
0378
0379 since = datetime.now(dt_timezone.utc) - timedelta(hours=args.hours)
0380 summary = {'window_hours': args.hours, 'rse': args.rse, 'jobs': 0,
0381 'registered': [], 'deferred': [], 'undelivered': [], 'refused': [],
0382 'skipped': 0, 'dry_run': bool(args.dry_run)}
0383
0384 work = worklist(since, limit=args.limit)
0385 summary['jobs'] = len(work)
0386 if not work:
0387 print(json.dumps(summary))
0388 return 0
0389
0390
0391
0392 proxy, summary['proxy'] = _evgen.resolve_proxy_or_none()
0393 try:
0394 client = _evgen.rucio_client(proxy)
0395 except _evgen.DoerError as e:
0396 summary['error'] = str(e)
0397 print(json.dumps(summary))
0398 return EXIT_PROXY if proxy is None else EXIT_RUCIO
0399 summary['catalog_auth'] = client.jlab_auth
0400
0401
0402
0403
0404
0405
0406
0407 import time as _time
0408 started = _time.monotonic()
0409 budget_s = float(os.environ.get('REGISTRAR_PASS_BUDGET_S', 1500))
0410 unreachable_streak = 0
0411 max_streak = int(os.environ.get('REGISTRAR_UNREACHABLE_STREAK', 5))
0412 stopped = ''
0413 for pandaid, report, dids in work:
0414 if stopped:
0415 break
0416 state = _registrar_state(pandaid)
0417 for did in dids:
0418 if _time.monotonic() - started > budget_s:
0419 stopped = f'pass budget of {budget_s:.0f}s reached'
0420 break
0421 if unreachable_streak >= max_streak:
0422 stopped = f'catalog unreachable {unreachable_streak} times in a row'
0423 break
0424 if not _due(state, did):
0425 summary['skipped'] += 1
0426 continue
0427 try:
0428 outcome, reason = complete(
0429 client, args.rse, did, _events_for(report, did), proxy,
0430 dry_run=args.dry_run)
0431 except Exception as e:
0432 outcome, reason = 'deferred', f'{type(e).__name__}: {e}'
0433 if outcome == 'deferred' and 'unreachable' in reason:
0434 unreachable_streak += 1
0435 else:
0436 unreachable_streak = 0
0437 _log(f'{pandaid} {did}: {outcome}{" — " + reason if reason else ""}')
0438 if not args.dry_run:
0439 _record_attempt(pandaid, did, outcome, reason)
0440 if outcome == 'registered' or outcome == 'would register':
0441 summary['registered'].append(did)
0442 elif outcome == 'undelivered':
0443 summary['undelivered'].append({'did': did, 'reason': reason})
0444 elif outcome == 'refused':
0445 summary['refused'].append({'did': did, 'reason': reason})
0446 else:
0447 summary['deferred'].append({'did': did, 'reason': reason})
0448
0449 if stopped:
0450 summary['stopped'] = stopped
0451 _log(f'pass ended early: {stopped}; the rest of the worklist waits for the next pass')
0452 print(json.dumps(summary))
0453 return 0
0454
0455
0456 if __name__ == '__main__':
0457 sys.exit(main())