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 """register-evgen-rucio.py — register one EVGEN input directory in JLab Rucio.
0003 
0004 The production-ops agent's doer for the EVGEN inputs page's "Register in
0005 Rucio" action (swf-epicprod docs/EPICPROD_EVGEN_INPUTS.md § Registration).
0006 Given an EVGEN path (``/EVGEN/<class>/...``) it lists the files under it on
0007 the JLab production door, takes each file's size and adler32 from the door
0008 (``xrdfs query checksum`` — the server computes it; no bytes are read), and
0009 registers them as ``epic:/EVGEN/...`` datasets at RSE ``EIC-XRD`` under the
0010 ``eicprod`` account: one dataset per directory holding files, one replica
0011 per file with its PFN on the door, files attached to their dataset. The
0012 registration contract is the production team's reference
0013 (eic/simulation_campaign_hepmc3 PR #100: ``scripts/calculate_checksum_xrd.sh``
0014 and ``scripts/register_from_checksum_listing.py``). Re-running is safe:
0015 existing datasets, replicas, and attachments are kept and counted.
0016 
0017 Credential: tokens from the PanDA server (swf_epicprod.jlab_auth): a storage
0018 token scoped to the directory being registered for the door, and a rucio
0019 token for the catalog. The JLab ``eicprod`` x509 proxy is the fallback for
0020 both while it lasts (it cannot be renewed past 2026-10-28).
0021 ``EVGEN_X509_PROXY`` names the agent's private copy (mode 0600; the same
0022 file the submission doer ships in the sandbox). ``EVGEN_X509_PROXY_SOURCE``,
0023 when set, names the renewal source — the production team's proxy drop — and
0024 a source proxy that outlives the private copy is copied over it before use.
0025 
0026 No silent failures: a file whose checksum the door does not return makes the
0027 run incomplete and nothing is registered (exit 4); a dataset whose Rucio file
0028 list disagrees with the door listing after registration exits 7. The last
0029 stdout line is a JSON summary the agent relays to the page and the action
0030 stream; progress and errors go to stderr.
0031 
0032 Usage::
0033 
0034     register-evgen-rucio.py --path /EVGEN/BACKGROUNDS/BEAMGAS/proton/pythia8.306-1.0/100GeV
0035     register-evgen-rucio.py --path ... --dry-run    # list + checksum only, no Rucio write
0036     register-evgen-rucio.py --path ... --events-only  # count events, record on the DIDs
0037 
0038 Exit codes: 0 ok · 2 bad path · 3 listing failed or empty · 4 checksum
0039 incomplete · 5 proxy unusable · 6 Rucio error · 7 verification mismatch.
0040 """
0041 import argparse
0042 import calendar
0043 import json
0044 import os
0045 import re
0046 import subprocess
0047 import sys
0048 import time
0049 from collections import defaultdict
0050 from concurrent.futures import ThreadPoolExecutor
0051 
0052 # The JLab production door and the filesystem root the EVGEN tree hangs
0053 # from. DID name = door path minus XRD_BASE; PFN = door + '/' + door path
0054 # (the '//' after the port is the xrootd absolute-path form).
0055 XRD_DOOR = os.environ.get('EVGEN_XRD_DOOR', 'root://dtn-rucio.jlab.org:1094')
0056 XRD_BASE = '/volatile/eic/EPIC'
0057 RUCIO_HOST = os.environ.get('JLAB_RUCIO_URL', 'https://rucio-server.jlab.org:443')
0058 RUCIO_ACCOUNT = os.environ.get('EVGEN_RUCIO_ACCOUNT', 'eicprod')
0059 RUCIO_SCOPE = 'epic'
0060 RUCIO_RSE = os.environ.get('EVGEN_RUCIO_RSE', 'EIC-XRD')
0061 CHECKSUM_WORKERS = int(os.environ.get('EVGEN_CHECKSUM_WORKERS', '4'))
0062 LISTING_TIMEOUT = 600
0063 CHECKSUM_TIMEOUT = 120
0064 BATCH_SIZE = 500
0065 PATH_OK = re.compile(r'^/EVGEN/[A-Za-z0-9._=+\-/]+$')
0066 # Event counting (--events-only): the entry count of the HepMC3 tree, read
0067 # from the file's tree header through the door; no event bytes move.
0068 EVENTS_WORKERS = int(os.environ.get('EVGEN_EVENTS_WORKERS', '4'))
0069 EVENTS_TIMEOUT = int(os.environ.get('EVGEN_EVENTS_TIMEOUT', '120'))   # per file
0070 EVENTS_TREE = os.environ.get('EVGEN_EVENTS_TREE', 'hepmc3_tree')
0071 
0072 EXIT_BAD_PATH, EXIT_LISTING, EXIT_CHECKSUM, EXIT_PROXY, EXIT_RUCIO, EXIT_VERIFY = 2, 3, 4, 5, 6, 7
0073 EXIT_EVENTS = 8
0074 
0075 
0076 def _log(msg):
0077     print(msg, file=sys.stderr, flush=True)
0078 
0079 
0080 class DoerError(Exception):
0081     def __init__(self, code, msg):
0082         super().__init__(msg)
0083         self.code = code
0084 
0085 
0086 # -- credential ---------------------------------------------------------------
0087 
0088 def _x509_not_after(path):
0089     """Expiry epoch of a proxy file via openssl (the credential checker's
0090     method)."""
0091     out = subprocess.run(['openssl', 'x509', '-enddate', '-noout', '-in', path],
0092                          capture_output=True, text=True, timeout=15)
0093     if out.returncode != 0:
0094         raise RuntimeError((out.stderr or out.stdout).strip())
0095     not_after = out.stdout.strip().split('=', 1)[1]
0096     return float(calendar.timegm(time.strptime(not_after, '%b %d %H:%M:%S %Y %Z')))
0097 
0098 
0099 def resolve_proxy():
0100     """The proxy to use: the private copy, refreshed from the renewal source
0101     when the source outlives it. Returns (path, summary)."""
0102     home = os.environ.get('EVGEN_X509_PROXY', '')
0103     source = os.environ.get('EVGEN_X509_PROXY_SOURCE', '')
0104     if not home:
0105         raise DoerError(EXIT_PROXY, 'EVGEN_X509_PROXY is not set')
0106     home_exp = None
0107     if os.path.exists(home):
0108         try:
0109             home_exp = _x509_not_after(home)
0110         except Exception as e:  # noqa: BLE001
0111             _log(f'WARNING: private proxy {home} unreadable: {e}')
0112     refreshed = False
0113     if source:
0114         try:
0115             src_exp = _x509_not_after(source)
0116         except Exception as e:  # noqa: BLE001
0117             # The source is a convenience for renewal, not the credential of
0118             # record: report and carry on with the private copy.
0119             _log(f'WARNING: proxy source {source} unusable: {e}')
0120         else:
0121             if src_exp > time.time() and (home_exp is None or src_exp > home_exp + 60):
0122                 data = open(source, 'rb').read()
0123                 # Written in place: the credential directory is not writable
0124                 # by the agent, the file is.
0125                 fd = os.open(home, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
0126                 try:
0127                     os.write(fd, data)
0128                 finally:
0129                     os.close(fd)
0130                 os.chmod(home, 0o600)
0131                 _log(f'proxy refreshed from {source} '
0132                      f'(expires {time.strftime("%Y-%m-%d", time.gmtime(src_exp))})')
0133                 home_exp = src_exp
0134                 refreshed = True
0135     if home_exp is None:
0136         raise DoerError(EXIT_PROXY, f'no usable proxy at {home}')
0137     days_left = (home_exp - time.time()) / 86400.0
0138     if days_left <= 0:
0139         raise DoerError(EXIT_PROXY, f'proxy {home} expired '
0140                         f'{time.strftime("%Y-%m-%d", time.gmtime(home_exp))}')
0141     return home, {'path': home, 'days_left': round(days_left, 1),
0142                   'refreshed_from_source': refreshed}
0143 
0144 
0145 # -- door ---------------------------------------------------------------------
0146 
0147 def door_credential(scope, proxy):
0148     """(door URL, environment, how) for xrootd work under the door path
0149     ``scope``: a storage token for that path, else the proxy
0150     (swf_epicprod.jlab_auth.door_access). The token is cached for the run."""
0151     from swf_epicprod.jlab_auth import door_access
0152     return door_access(XRD_DOOR, scope, proxy=proxy)
0153 
0154 
0155 def _xrdfs(args, scope, proxy, timeout):
0156     door, env, _how = door_credential(scope, proxy)
0157     return subprocess.run(['xrdfs', door] + args, capture_output=True,
0158                           text=True, timeout=timeout, env=env)
0159 
0160 
0161 # The EVGEN input the payload reads (EPICPROD_EVGEN_INPUTS.md): only these
0162 # files are registered. Other files beside them in a directory, such as the
0163 # ASCII ``.hepmc`` a sample was converted from, are listed and left out.
0164 EVGEN_SUFFIX = '.hepmc3.tree.root'
0165 # Files set aside by the production team live in a subdirectory of this
0166 # name, at any depth, and are never registered.
0167 OBSOLETE_DIR = 'obsolete'
0168 
0169 
0170 def list_files(evgen_path, proxy):
0171     """Every EVGEN file under the path on the door: [(door_path, bytes)],
0172     and the files left out: NFS silly-rename remnants (``.nfs*``), files
0173     that are not ``.hepmc3.tree.root``, and files under a directory named
0174     ``obsolete``, each counted."""
0175     door_path = XRD_BASE + evgen_path
0176     try:
0177         p = _xrdfs(['ls', '-l', '-R', door_path], door_path, proxy, LISTING_TIMEOUT)
0178     except subprocess.TimeoutExpired:
0179         raise DoerError(EXIT_LISTING, f'listing timed out after {LISTING_TIMEOUT}s')
0180     if p.returncode != 0:
0181         reason = (p.stderr or p.stdout).strip().splitlines()
0182         raise DoerError(EXIT_LISTING, 'listing failed: '
0183                         + (reason[-1] if reason else f'rc={p.returncode}'))
0184     files, skipped, other, obsolete = [], [], [], []
0185     for line in (p.stdout or '').splitlines():
0186         parts = line.split(maxsplit=6)
0187         if len(parts) < 7:
0188             continue
0189         flags, _owner, _group, size, _date, _time, path = parts
0190         if flags.startswith('d'):
0191             continue
0192         if OBSOLETE_DIR in path[len(door_path):].split('/')[:-1]:
0193             obsolete.append(path)
0194             continue
0195         if os.path.basename(path).startswith('.nfs'):
0196             skipped.append(path)
0197             continue
0198         if not path.endswith(EVGEN_SUFFIX):
0199             other.append(path)
0200             continue
0201         try:
0202             files.append((path, int(size)))
0203         except ValueError:
0204             raise DoerError(EXIT_LISTING, f'unparseable listing line: {line!r}')
0205     if not files:
0206         raise DoerError(EXIT_LISTING, f'no {EVGEN_SUFFIX} files under {door_path}'
0207                         + (f' ({len(other)} other files)' if other else '')
0208                         + (f' ({len(obsolete)} under {OBSOLETE_DIR}/)' if obsolete else ''))
0209     return files, skipped, other, obsolete
0210 
0211 
0212 def checksums(files, proxy, scope):
0213     """adler32 per file from the door, under the door path ``scope``;
0214     returns ({path: adler32}, [(path, why)])."""
0215     def one(path):
0216         try:
0217             p = _xrdfs(['query', 'checksum', path], scope, proxy, CHECKSUM_TIMEOUT)
0218         except subprocess.TimeoutExpired:
0219             return path, None, f'timed out after {CHECKSUM_TIMEOUT}s'
0220         parts = (p.stdout or '').split()
0221         if p.returncode == 0 and len(parts) == 2 and parts[0] == 'adler32':
0222             return path, parts[1], ''
0223         why = (p.stderr or p.stdout).strip().splitlines()
0224         return path, None, why[-1] if why else f'rc={p.returncode}'
0225 
0226     ok, failed = {}, []
0227     with ThreadPoolExecutor(max_workers=CHECKSUM_WORKERS) as pool:
0228         for path, adler, why in pool.map(one, [f[0] for f in files]):
0229             if adler:
0230                 ok[path] = adler
0231             else:
0232                 failed.append((path, why))
0233     return ok, failed
0234 
0235 
0236 # -- Rucio --------------------------------------------------------------------
0237 
0238 def plan_datasets(files, adlers):
0239     """Group files into datasets: one per directory holding files, named by
0240     the DID convention (door path minus XRD_BASE).
0241 
0242     The PFN is the RSE prefix followed by the DID name, which carries its
0243     own leading slash: the deterministic mapping the RSE expects yields
0244     ``/volatile/eic/EPIC//EVGEN/...``, and add_replicas refuses a PFN that
0245     differs from it by so much as that slash."""
0246     datasets = defaultdict(list)
0247     for door_path, size in files:
0248         name = door_path[len(XRD_BASE):]
0249         datasets[os.path.dirname(name)].append({
0250             'scope': RUCIO_SCOPE, 'name': name, 'bytes': size,
0251             'adler32': adlers[door_path] if door_path in adlers else '',
0252             'pfn': f'{XRD_DOOR}/{XRD_BASE}/{name}'})
0253     return dict(sorted(datasets.items()))
0254 
0255 
0256 def rucio_client(proxy):
0257     """The Rucio client as the production account: by a token from the
0258     PanDA server, else by the proxy (swf_epicprod.jlab_auth); refuses a
0259     credential that maps to any other account. ``proxy`` may be None once
0260     the proxy is gone; the token is then the only credential."""
0261     from rucio.common.exception import RucioException
0262     from swf_epicprod.jlab_auth import rucio_client as jlab_rucio_client
0263     try:
0264         client = jlab_rucio_client(account=RUCIO_ACCOUNT, host=RUCIO_HOST, proxy=proxy)
0265         who = client.whoami()
0266     except (RucioException, RuntimeError) as e:
0267         raise DoerError(EXIT_RUCIO, f'Rucio auth failed: {e}')
0268     if who.get('account') != RUCIO_ACCOUNT:
0269         raise DoerError(EXIT_RUCIO, f'{client.jlab_auth} maps to account '
0270                         f'{who.get("account")!r}, not {RUCIO_ACCOUNT!r}')
0271     _log(f'JLab Rucio as {RUCIO_ACCOUNT} by {client.jlab_auth}')
0272     return client
0273 
0274 
0275 def resolve_proxy_or_none():
0276     """resolve_proxy's (path, summary), or (None, {'error': reason}) when
0277     there is no usable proxy: for passes whose catalog work a token does
0278     alone, the proxy is only the fallback."""
0279     try:
0280         return resolve_proxy()
0281     except DoerError as e:
0282         return None, {'error': str(e)}
0283 
0284 
0285 def count_events(datasets, proxy, scope, write=True):
0286     """Event count per file, read through the door as the entry count of
0287     the HepMC3 tree (the tree header carries it; no event bytes move), then
0288     recorded as Rucio's ``events`` attribute on each file DID and, once
0289     every file of a dataset is counted, on the dataset. Returns one record
0290     per dataset; a file that cannot be counted is reported by name and
0291     leaves its dataset's total unset — never a partial total presented as
0292     the whole. ``write=False`` counts and reports without touching Rucio.
0293     uproot's xrootd client reads the door's credential for ``scope`` from
0294     this process's environment, which takes exactly the door's: the token
0295     over the root:// PFN as it is (uproot knows no roots://; the client
0296     turns on TLS for the token), else the proxy. A proxy left beside the
0297     token breaks the client's TLS (the robot's copy is group-readable), so
0298     the robot proxy stays the PanDA sign-in under its own name."""
0299     import uproot
0300     from concurrent.futures import TimeoutError as FutureTimeout
0301     from rucio.common.exception import RucioException
0302 
0303     client = rucio_client(proxy) if write else None
0304     _door, env, how = door_credential(scope, proxy)
0305     if os.environ.get('X509_USER_PROXY'):
0306         os.environ.setdefault('EPICPROD_PANDA_LOGIN_PROXY', os.environ['X509_USER_PROXY'])
0307     for key in ('XrdSecPROTOCOL', 'BEARER_TOKEN', 'X509_USER_PROXY', 'X509_CERT_DIR'):
0308         if env.get(key):
0309             os.environ[key] = env[key]
0310         else:
0311             os.environ.pop(key, None)
0312     _log(f'door credential: {how}')
0313 
0314     class _NoWrite:
0315         def set_metadata(self, *_args, **_kwargs):
0316             return True
0317 
0318     def _one(entry):
0319         if not entry['name'].endswith('.root'):
0320             return entry['name'], None, 'not a ROOT file'
0321         try:
0322             with uproot.open(entry['pfn']) as fh:
0323                 if EVENTS_TREE not in fh:
0324                     return entry['name'], None, f'no {EVENTS_TREE} in file'
0325                 return entry['name'], int(fh[EVENTS_TREE].num_entries), ''
0326         except Exception as e:                              # noqa: BLE001
0327             return entry['name'], None, f'{type(e).__name__}: {e}'
0328 
0329     client = client or _NoWrite()
0330     result = []
0331     for ds_name, entries in datasets.items():
0332         rec = {'did': f'{RUCIO_SCOPE}:{ds_name}', 'files': len(entries),
0333                'counted': 0, 'events': None, 'failed': []}
0334         counts = {}
0335         with ThreadPoolExecutor(max_workers=EVENTS_WORKERS) as pool:
0336             futures = {pool.submit(_one, e): e for e in entries}
0337             for fut, entry in futures.items():
0338                 try:
0339                     name, n, why = fut.result(timeout=EVENTS_TIMEOUT)
0340                 except FutureTimeout:
0341                     name, n, why = entry['name'], None, f'timed out after {EVENTS_TIMEOUT}s'
0342                 if n is None:
0343                     rec['failed'].append({'file': name, 'reason': why})
0344                 else:
0345                     counts[name] = n
0346         for name, n in counts.items():
0347             try:
0348                 client.set_metadata(RUCIO_SCOPE, name, 'events', n)
0349                 rec['counted'] += 1
0350             except RucioException as e:
0351                 rec['failed'].append({'file': name, 'reason': f'set_metadata: {e}'})
0352         if rec['counted'] == len(entries):
0353             # Rucio derives a dataset's events from its files and refuses a
0354             # direct write on the dataset; read the derived value back and
0355             # hold it to the sum.
0356             total = sum(counts.values())
0357             if not write:
0358                 rec['events'] = total
0359             else:
0360                 try:
0361                     derived = client.get_metadata(RUCIO_SCOPE, ds_name).get('events')
0362                 except RucioException as e:
0363                     derived = None
0364                     rec['failed'].append({'file': ds_name,
0365                                           'reason': f'get_metadata: {e}'})
0366                 if derived == total:
0367                     rec['events'] = total
0368                 elif derived is not None or not rec['failed']:
0369                     rec['failed'].append({
0370                         'file': ds_name,
0371                         'reason': f'dataset events {derived} differs from the '
0372                                   f'sum of its files {total}'})
0373         rec['failed'] = rec['failed'][:20]
0374         _log(f'  {rec["did"]}: {rec["counted"]}/{rec["files"]} files counted, '
0375              f'events {rec["events"] if rec["events"] is not None else "unset"}'
0376              + (f', {len(rec["failed"])} failed' if rec['failed'] else ''))
0377         result.append(rec)
0378     return result
0379 
0380 
0381 def register(datasets, proxy):
0382     from rucio.common.exception import (DataIdentifierAlreadyExists,
0383                                         DuplicateContent, FileAlreadyExists,
0384                                         RucioException)
0385     client = rucio_client(proxy)
0386 
0387     result = []
0388     for ds_name, entries in datasets.items():
0389         rec = {'did': f'{RUCIO_SCOPE}:{ds_name}', 'files': len(entries),
0390                'bytes': sum(e['bytes'] for e in entries),
0391                'created': False, 'replicas_new': 0, 'attached_new': 0}
0392         try:
0393             client.add_dataset(scope=RUCIO_SCOPE, name=ds_name)
0394             rec['created'] = True
0395         except DataIdentifierAlreadyExists:
0396             pass
0397         except RucioException as e:
0398             raise DoerError(EXIT_RUCIO, f'add_dataset {ds_name}: {e}')
0399         # Replicas, then attachment, in batches; a batch refused because one
0400         # member already exists is retried per file so the rest still land.
0401         for start in range(0, len(entries), BATCH_SIZE):
0402             batch = entries[start:start + BATCH_SIZE]
0403             try:
0404                 client.add_replicas(rse=RUCIO_RSE, files=batch, ignore_availability=True)
0405                 rec['replicas_new'] += len(batch)
0406             except FileAlreadyExists:
0407                 for entry in batch:
0408                     try:
0409                         client.add_replicas(rse=RUCIO_RSE, files=[entry],
0410                                             ignore_availability=True)
0411                         rec['replicas_new'] += 1
0412                     except FileAlreadyExists:
0413                         pass
0414                     except RucioException as e:
0415                         raise DoerError(EXIT_RUCIO, f'add_replicas {entry["name"]}: {e}')
0416             except RucioException as e:
0417                 raise DoerError(EXIT_RUCIO, f'add_replicas batch in {ds_name}: {e}')
0418         dids = [{'scope': e['scope'], 'name': e['name']} for e in entries]
0419         for start in range(0, len(dids), BATCH_SIZE):
0420             batch = dids[start:start + BATCH_SIZE]
0421             try:
0422                 client.attach_dids(scope=RUCIO_SCOPE, name=ds_name, dids=batch)
0423                 rec['attached_new'] += len(batch)
0424             except (DuplicateContent, FileAlreadyExists):
0425                 for did in batch:
0426                     try:
0427                         client.attach_dids(scope=RUCIO_SCOPE, name=ds_name, dids=[did])
0428                         rec['attached_new'] += 1
0429                     except (DuplicateContent, FileAlreadyExists):
0430                         pass
0431                     except RucioException as e:
0432                         raise DoerError(EXIT_RUCIO, f'attach {did["name"]}: {e}')
0433             except RucioException as e:
0434                 raise DoerError(EXIT_RUCIO, f'attach_dids batch to {ds_name}: {e}')
0435         # Verify against the catalog: every listed file is now content of
0436         # its dataset.
0437         try:
0438             in_rucio = {f['name'] for f in client.list_files(RUCIO_SCOPE, ds_name)}
0439         except RucioException as e:
0440             raise DoerError(EXIT_RUCIO, f'list_files {ds_name}: {e}')
0441         missing = [e['name'] for e in entries if e['name'] not in in_rucio]
0442         if missing:
0443             raise DoerError(EXIT_VERIFY, f'{len(missing)} of {len(entries)} files not '
0444                             f'in {rec["did"]} after registration, e.g. {missing[0]}')
0445         rec['rucio_files'] = len(in_rucio)
0446         _log(f'  {rec["did"]}: {rec["files"]} files, {rec["bytes"]} bytes, '
0447              f'{"created" if rec["created"] else "existed"}, '
0448              f'{rec["replicas_new"]} new replicas, {rec["attached_new"]} newly attached, '
0449              f'{rec["rucio_files"]} files in Rucio')
0450         result.append(rec)
0451     return result
0452 
0453 
0454 # -- main ---------------------------------------------------------------------
0455 
0456 def main(argv):
0457     ap = argparse.ArgumentParser(description='Register one EVGEN directory in JLab Rucio.')
0458     ap.add_argument('--path', required=True, help='EVGEN path, e.g. /EVGEN/DIS/...')
0459     ap.add_argument('--dry-run', action='store_true',
0460                     help='list and checksum only; write nothing to Rucio')
0461     ap.add_argument('--events-only', action='store_true',
0462                     help='count events per file through the door and record '
0463                          'them on the registered DIDs; no registration')
0464     args = ap.parse_args(argv[1:])
0465 
0466     evgen_path = args.path.strip().rstrip('/')
0467     summary = {'ok': False, 'path': evgen_path, 'dry_run': bool(args.dry_run)}
0468     t0 = time.monotonic()
0469     try:
0470         if not PATH_OK.match(evgen_path) or '..' in evgen_path or '//' in evgen_path \
0471                 or len([s for s in evgen_path.split('/') if s]) < 3:
0472             raise DoerError(EXIT_BAD_PATH, f'not an EVGEN path: {evgen_path!r}')
0473         proxy, summary['proxy'] = resolve_proxy_or_none()
0474         _log(f'proxy {proxy}: {summary["proxy"]["days_left"]} days left' if proxy
0475              else f'no proxy ({summary["proxy"]["error"]}): tokens only')
0476         scope = XRD_BASE + evgen_path
0477         summary['door_credential'] = door_credential(scope, proxy)[2]
0478         _log(f'door credential: {summary["door_credential"]}')
0479 
0480         files, skipped, other, obsolete = list_files(evgen_path, proxy)
0481         summary.update(files=len(files), bytes=sum(s for _, s in files),
0482                        skipped=len(skipped), not_evgen=len(other), obsolete=len(obsolete))
0483         _log(f'{len(files)} files, {summary["bytes"]} bytes under {XRD_BASE}{evgen_path}'
0484              + (f' ({len(skipped)} .nfs remnants skipped)' if skipped else '')
0485              + (f' ({len(other)} files not {EVGEN_SUFFIX} left out)' if other else '')
0486              + (f' ({len(obsolete)} files under {OBSOLETE_DIR}/ left out)' if obsolete else ''))
0487 
0488         if args.events_only:
0489             # The registration's second step: counts recorded on the DIDs
0490             # the first step registered. Checksums are not needed here.
0491             datasets = plan_datasets(files, defaultdict(str))
0492             summary['datasets'] = count_events(datasets, proxy, scope,
0493                                                write=not args.dry_run)
0494             failed_files = sum(len(d['failed']) for d in summary['datasets'])
0495             summary['events'] = (sum(d['events'] for d in summary['datasets'])
0496                                  if all(d['events'] is not None
0497                                         for d in summary['datasets']) else None)
0498             if failed_files:
0499                 raise DoerError(EXIT_EVENTS, f'{failed_files} file(s) not counted; '
0500                                 f'dataset totals left unset where a file is missing')
0501             summary['ok'] = True
0502             summary['seconds'] = round(time.monotonic() - t0, 1)
0503             print(json.dumps(summary), flush=True)
0504             return 0
0505 
0506         adlers, failed = checksums(files, proxy, scope)
0507         if failed:
0508             summary['checksum_failed'] = [{'file': f, 'reason': why} for f, why in failed[:20]]
0509             for f, why in failed[:20]:
0510                 _log(f'  CHECKSUM FAILED {f}: {why}')
0511             raise DoerError(EXIT_CHECKSUM, f'{len(failed)} of {len(files)} checksums '
0512                             f'not returned by the door; nothing registered')
0513         _log(f'{len(adlers)} checksums from the door in {time.monotonic() - t0:.0f}s')
0514 
0515         datasets = plan_datasets(files, adlers)
0516         summary['datasets'] = [{'did': f'{RUCIO_SCOPE}:{n}', 'files': len(e),
0517                                 'bytes': sum(x['bytes'] for x in e)}
0518                                for n, e in datasets.items()]
0519         if args.dry_run:
0520             for d in summary['datasets']:
0521                 _log(f'  would register {d["did"]}: {d["files"]} files, {d["bytes"]} bytes')
0522             summary['ok'] = True
0523         else:
0524             summary['datasets'] = register(datasets, proxy)
0525             summary['ok'] = True
0526     except DoerError as e:
0527         summary['error'] = str(e)
0528         summary['exit_code'] = e.code
0529         _log(f'ERROR: {e}')
0530     except Exception as e:  # noqa: BLE001 — the agent needs a reason, not a trace
0531         summary['error'] = f'{type(e).__name__}: {e}'
0532         summary['exit_code'] = 1
0533         _log(f'ERROR: {summary["error"]}')
0534     summary['seconds'] = round(time.monotonic() - t0, 1)
0535     print(json.dumps(summary), flush=True)
0536     return 0 if summary['ok'] else summary['exit_code']
0537 
0538 
0539 if __name__ == '__main__':
0540     sys.exit(main(sys.argv))