Back to home page

EIC code displayed by LXR

 
 

    


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

0001 #!/usr/bin/env python3
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  # noqa: E402
0060 django.setup()
0061 
0062 from django.db import connections  # noqa: E402
0063 
0064 from monitor_app.models import EpicProdJob  # noqa: E402
0065 from monitor_app.panda.constants import PANDA_SCHEMA  # noqa: E402
0066 
0067 # The credential and the client are the EVGEN registration doer's, so the
0068 # two writers into the JLab catalog cannot drift on identity, proxy renewal
0069 # or account checking. Imported rather than copied for that reason.
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 # One attempt an hour, and a DID that has refused eight times is a case for
0078 # a person rather than a ninth attempt.
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 # Rucio's limit on a data identifier name: a longer one is refused on
0082 # every attempt, so it is recorded refused and never retried.
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     # Canaries included: a canary's pending registration completes like
0120     # production's, which is how the failover chain is proven.
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:                                    # noqa: BLE001
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:                                    # noqa: BLE001
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:                                    # noqa: BLE001
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     # A protocol with no WAN read priority (null, or Rucio's 0) is not a read
0207     # door: BNL-XRD's third-party-copy davs entry carries read: null.
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     # A storage token where the door takes one, else the proxy, else none
0239     # (BNL-XRD's read door reads without a credential).
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:                                    # noqa: BLE001
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     # No read before the write: the registration call ignores a duplicate,
0278     # so an entry already registered costs one call that changes nothing,
0279     # where a read first cost every entry a call (the catalog answers
0280     # about one call a second under load, 2026-09-18).
0281     try:
0282         prefix = rse_pfn_prefix(client, rse)
0283     except Exception as e:                                    # noqa: BLE001
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     # One call registers the replica and attaches the file to its dataset,
0300     # committed together (the payload's own registration since 0.19.1, the
0301     # pilot's stage-out call). The dataset exists by contract
0302     # (RUCIO_REGISTRATION_CONTRACT.md § 2); one that does not is reported,
0303     # never created here: three calls a file (add_replicas, add_dataset,
0304     # attach_dids) were two too many against a catalog this size.
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:                                    # noqa: BLE001
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     # Read back. A DID the job's own upload attempt had registered before
0316     # the catalog failed it (the temporary replica since tombstoned) exists
0317     # with no replica; the one call above skips such a DID as a duplicate
0318     # and adds nothing, so the replica is added to the existing DID and the
0319     # attachment made explicitly (1,699 of the first 16,000 stashed files,
0320     # 2026-09-18).
0321     try:
0322         replicas = list(client.list_replicas(
0323             [{'scope': RUCIO_SCOPE, 'name': did}], all_states=True))
0324     except Exception as e:                                    # noqa: BLE001
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             # The job's own failed upload left the replica row UNAVAILABLE
0332             # and tombstoned (register_to_rucio's cleanup of a COPYING
0333             # replica); the file at that path is the stash. The row is
0334             # made AVAILABLE, which the dataset's rule then locks.
0335             try:
0336                 client.update_replicas_states(
0337                     rse, files=[{'scope': RUCIO_SCOPE, 'name': did, 'state': 'A'}])
0338             except Exception as e:                            # noqa: BLE001
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:                            # noqa: BLE001
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:                                # noqa: BLE001
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:                                # noqa: BLE001
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:                                # noqa: BLE001
0363             # The registration stands; the count is reported, never invented.
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     # The proxy is the fallback: the catalog takes a token from the PanDA
0391     # server, and the storage check a storage token or no credential.
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     # One pass is bounded in time (the agent's timeout is the ceiling) and
0402     # in consecutive catalog failures: a catalog that answers nothing is
0403     # noted once and the rest of the worklist waits for the next pass
0404     # rather than being asked a thousand times. Nothing here raises past
0405     # an entry: a crash records no attempt and defers nothing (the passes
0406     # of 2026-09-17 died on the first 503, every hour, for 14 hours).
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:                            # noqa: BLE001
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())