Back to home page

EIC code displayed by LXR

 
 

    


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

0001 #!/usr/bin/env python3
0002 """backfill-produced-events.py — event counts for produced FULL and RECO
0003 files registered before the payload counted them
0004 (swf-epicprod docs/RUCIO_REGISTRATION_CONTRACT.md, the produced rows'
0005 backfill).
0006 
0007 The produced datasets are the RECO and FULL datasets of the campaign
0008 snapshots (``$SWF_TMP_DIR/rucio-snapshots/current-*.json``). For each,
0009 the files are listed from JLab Rucio with their ``events`` attribute;
0010 a file without one is read at its disk replica (BNL-XRD, epicxrd1
0011 inside SCDF, per Rucio's replica record; a tape-only file is left
0012 out) as the entry count of the podio ``events`` tree (the tree header
0013 carries it; no event bytes move), and the count is written as the
0014 file's ``events`` attribute, as the payload writes it for new files. A
0015 dataset's derived total is read back once every file of it is counted,
0016 and held to the sum. A file that cannot be counted is reported by name
0017 and left as it is; nothing is invented.
0018 
0019 Modes:
0020   --scan            list the files without a count and stop (Rucio reads only)
0021   (default)         count and report, write nothing (door reads)
0022   --apply           count and write the attribute
0023 
0024 The pass is resumable: its state file records every file done, and a
0025 later run skips them; ``--limit-datasets N`` bounds a run. Runs under the
0026 production operations agent's environment (``EVGEN_X509_PROXY``,
0027 ``EVGEN_XRD_DOOR``); ``--env-file`` loads a systemd-style KEY=VALUE file
0028 when run by hand. Progress and errors go to stderr; the last stdout
0029 line is a JSON summary; each run logs one ``produced_events_backfill``
0030 action.
0031 
0032 Usage:
0033     backfill-produced-events.py --scan [--env-file /opt/swf-monitor/config/env/production.env]
0034     backfill-produced-events.py --apply [--limit-datasets N] [--workers 4] [--env-file ...]
0035 """
0036 import argparse
0037 import glob
0038 import importlib.util
0039 import json
0040 import logging
0041 import os
0042 import sys
0043 import time
0044 from concurrent.futures import ThreadPoolExecutor
0045 from concurrent.futures import TimeoutError as FutureTimeout
0046 
0047 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0048 SWF_TMP_DIR = os.environ.get('SWF_TMP_DIR', '/data/swf-tmp')
0049 SNAPSHOT_GLOB = os.path.join(SWF_TMP_DIR, 'rucio-snapshots', 'current-*.json')
0050 STATE_PATH = os.path.join(SWF_TMP_DIR, 'produced-events-backfill', 'state.json')
0051 EVENTS_TREE = 'events'
0052 PRODUCED_PREFIXES = ('/RECO/', '/FULL/')
0053 
0054 log = logging.getLogger('backfill-produced-events')
0055 
0056 
0057 def _doer():
0058     """The EVGEN registration doer's module, for its Rucio client, scope
0059     and door constants (the same account, door and mapping)."""
0060     spec = importlib.util.spec_from_file_location(
0061         'register_evgen_rucio', os.path.join(THIS_DIR, 'register-evgen-rucio.py'))
0062     mod = importlib.util.module_from_spec(spec)
0063     spec.loader.exec_module(mod)
0064     return mod
0065 
0066 
0067 def load_env_file(path):
0068     for line in open(path):
0069         line = line.strip()
0070         if line and not line.startswith('#') and '=' in line:
0071             key, value = line.split('=', 1)
0072             os.environ.setdefault(key, value.strip().strip('"'))
0073 
0074 
0075 def produced_datasets():
0076     """The produced dataset names across every campaign snapshot, newest
0077     snapshot of a dataset winning, sorted."""
0078     names = {}
0079     for path in sorted(glob.glob(SNAPSHOT_GLOB)):
0080         snap = json.load(open(path))
0081         for camp in (snap.get('campaigns') or {}).values():
0082             for ds in camp.get('datasets') or []:
0083                 name = str(ds.get('did') or '').split(':', 1)[-1]
0084                 if name.startswith(PRODUCED_PREFIXES):
0085                     names[name] = os.path.basename(path)
0086     return sorted(names)
0087 
0088 
0089 def load_state():
0090     try:
0091         return json.load(open(STATE_PATH))
0092     except (OSError, ValueError):
0093         return {'done': {}, 'failed': {}}
0094 
0095 
0096 def save_state(state):
0097     os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
0098     tmp = STATE_PATH + '.tmp'
0099     with open(tmp, 'w') as fh:
0100         json.dump(state, fh)
0101     os.replace(tmp, STATE_PATH)
0102 
0103 
0104 def files_without_count(client, scope, name):
0105     """(files lacking events, files in all) of one dataset from Rucio."""
0106     missing, total = [], 0
0107     for f in client.list_files(scope, name):
0108         total += 1
0109         if f.get('events') in (None, 0) and str(f.get('name', '')).endswith('.root'):
0110             missing.append(f['name'])
0111     return missing, total
0112 
0113 
0114 def count_one(pfn, timeout):
0115     import uproot
0116     with uproot.open(pfn, timeout=timeout) as fh:
0117         if EVENTS_TREE not in fh:
0118             return None, f'no {EVENTS_TREE} tree'
0119         return int(fh[EVENTS_TREE].num_entries), ''
0120 
0121 
0122 # Where a produced file is read from: its disk replica, never tape. The
0123 # campaign's outputs sit on BNL-XRD (epicxrd1, inside SCDF, read directly
0124 # from this host) with a tape copy at JLAB-TAPE-SE; the JLab door holds
0125 # the EVGEN inputs, not these.
0126 TAPE_RSES = {'JLAB-TAPE-SE'}
0127 RSE_PREFERENCE = ('BNL-XRD', 'EIC-XRD')
0128 
0129 
0130 def replica_pfns(client, scope, name, wanted):
0131     """{file name: pfn} for the wanted files of a dataset, from Rucio's
0132     replica record, a disk replica preferred in RSE_PREFERENCE order and
0133     a root:// PFN over any other; a file with only a tape replica is
0134     left out (reported by the caller)."""
0135     out = {}
0136     wanted = set(wanted)
0137     for rep in client.list_replicas([{'scope': scope, 'name': name}]):
0138         fname = rep.get('name')
0139         if fname not in wanted:
0140             continue
0141         rses = {rse: pfns for rse, pfns in (rep.get('rses') or {}).items()
0142                 if pfns and rse not in TAPE_RSES}
0143         ordered = [r for r in RSE_PREFERENCE if r in rses] + [r for r in rses if r not in RSE_PREFERENCE]
0144         for rse in ordered:
0145             pfns = rses[rse]
0146             pfn = next((p for p in pfns if p.startswith('root://')), pfns[0])
0147             out[fname] = pfn
0148             break
0149     return out
0150 
0151 
0152 def main(argv):
0153     ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
0154     ap.add_argument('--scan', action='store_true', help='list files without a count; no door reads')
0155     ap.add_argument('--apply', action='store_true', help='write the counts to Rucio')
0156     ap.add_argument('--limit-datasets', type=int, default=0, help='datasets to count in this run (0 = all)')
0157     ap.add_argument('--campaign', help='count one campaign only, by version prefix, e.g. 26.07 (takes 26.07.0, .1 and .2)')
0158     ap.add_argument('--dataset', help='one dataset name only (a check run; skips the snapshot listing)')
0159     ap.add_argument('--workers', type=int, default=int(os.environ.get('EVGEN_EVENTS_WORKERS', '4')))
0160     ap.add_argument('--timeout', type=int, default=int(os.environ.get('EVGEN_EVENTS_TIMEOUT', '120')), help='seconds per file')
0161     ap.add_argument('--env-file', help='KEY=VALUE file to load (the agent environment)')
0162     ap.add_argument('--created-by', default='', help="the action record's username")
0163     args = ap.parse_args(argv)
0164     logging.basicConfig(level=logging.INFO, stream=sys.stderr,
0165                         format='%(asctime)s %(name)s %(levelname)s %(message)s')
0166     if args.env_file:
0167         load_env_file(args.env_file)
0168     proxy = os.environ.get('EVGEN_X509_PROXY')
0169     if not proxy or not os.path.exists(proxy):
0170         print(json.dumps({'error': 'EVGEN_X509_PROXY is not set or missing'}))
0171         return 2
0172     doer = _doer()
0173     client = doer.rucio_client(proxy)
0174     # The robot proxy stays the PanDA sign-in for tokens (swf_epicprod.jlab_auth).
0175     if os.environ.get('X509_USER_PROXY'):
0176         os.environ.setdefault('EPICPROD_PANDA_LOGIN_PROXY', os.environ['X509_USER_PROXY'])
0177     os.environ['X509_USER_PROXY'] = proxy
0178     scope, door, base = doer.RUCIO_SCOPE, doer.XRD_DOOR, doer.XRD_BASE
0179     t0 = time.monotonic()
0180     state = load_state()
0181     names = [args.dataset] if args.dataset else produced_datasets()
0182     log.info('%d produced datasets across the snapshots', len(names))
0183 
0184     # The worklist: every file of a produced dataset without a count,
0185     # less what an earlier run already counted or gave up on.
0186     todo = {}
0187     files_total = 0
0188     for i, name in enumerate(names, 1):
0189         try:
0190             missing, total = files_without_count(client, scope, name)
0191         except Exception as e:                                # noqa: BLE001
0192             log.error('%s: listing failed: %s', name, e)
0193             continue
0194         files_total += total
0195         missing = [f for f in missing if f not in state['done']]
0196         if missing:
0197             todo[name] = missing
0198         if i % 100 == 0:
0199             log.info('  listed %d/%d datasets, %d files without a count so far',
0200                      i, len(names), sum(len(v) for v in todo.values()))
0201     n_missing = sum(len(v) for v in todo.values())
0202     by_campaign = {}
0203     for name, missing in todo.items():
0204         camp = name.split('/')[2] if name.count('/') >= 2 else '?'   # /RECO/<version>/...
0205         by_campaign[camp] = by_campaign.get(camp, 0) + len(missing)
0206     summary = {'datasets': len(names), 'files': files_total, 'datasets_without_counts': len(todo),
0207                'files_without_count': n_missing, 'by_campaign': dict(sorted(by_campaign.items())),
0208                'mode': 'scan' if args.scan else ('apply' if args.apply else 'dry')}
0209     log.info('%d files without a count in %d datasets (of %d files in %d datasets)',
0210              n_missing, len(todo), files_total, len(names))
0211     # The worklist on disk: what the scan found, for the counting runs and
0212     # for reading the size of the job without listing again.
0213     os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
0214     with open(os.path.join(os.path.dirname(STATE_PATH), 'worklist.json'), 'w') as fh:
0215         json.dump({'scanned_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
0216                    'by_campaign': summary['by_campaign'],
0217                    'datasets': {k: len(v) for k, v in todo.items()}}, fh)
0218     if args.scan:
0219         summary['seconds'] = round(time.monotonic() - t0, 1)
0220         print(json.dumps(summary))
0221         return 0
0222     if args.campaign:
0223         # A version prefix: 26.07 takes 26.07.0, 26.07.1 and 26.07.2.
0224         todo = {k: v for k, v in todo.items() if k.split('/')[2].startswith(args.campaign)}
0225         log.info('campaign %s: %d files in %d datasets', args.campaign, sum(len(v) for v in todo.values()), len(todo))
0226 
0227     counted = written = failed = 0
0228     datasets_done = 0
0229     for name, missing in todo.items():
0230         if args.limit_datasets and datasets_done >= args.limit_datasets:
0231             break
0232         counts, errors = {}, {}
0233         try:
0234             pfns = replica_pfns(client, scope, name, missing)
0235         except Exception as e:                                # noqa: BLE001
0236             log.error('%s: replica listing failed: %s', name, e)
0237             continue
0238         for f in missing:
0239             if f not in pfns:
0240                 errors[f] = 'no disk replica (tape only, or none)'
0241         with ThreadPoolExecutor(max_workers=args.workers) as pool:
0242             futures = {pool.submit(count_one, pfns[f], args.timeout): f for f in missing if f in pfns}
0243             for fut, f in futures.items():
0244                 try:
0245                     n, why = fut.result(timeout=args.timeout + 30)
0246                 except FutureTimeout:
0247                     n, why = None, f'timed out after {args.timeout}s'
0248                 except Exception as e:                        # noqa: BLE001
0249                     n, why = None, f'{type(e).__name__}: {e}'
0250                 if n is None:
0251                     errors[f] = why
0252                 else:
0253                     counts[f] = n
0254         counted += len(counts)
0255         failed += len(errors)
0256         for f, why in errors.items():
0257             state['failed'][f] = why
0258             log.warning('%s: %s', f, why)
0259         if args.apply:
0260             for f, n in counts.items():
0261                 try:
0262                     client.set_metadata(scope, f, 'events', n)
0263                     state['done'][f] = n
0264                     written += 1
0265                 except Exception as e:                        # noqa: BLE001
0266                     state['failed'][f] = f'set_metadata: {e}'
0267                     failed += 1
0268             # The dataset's derived total, read back once every file carries
0269             # a count, and held to the sum.
0270             if not errors and not any(f in state['failed'] for f in missing):
0271                 try:
0272                     meta = client.get_metadata(scope, name)
0273                     total = sum(counts.values()) + sum(
0274                         int(f.get('events') or 0) for f in client.list_files(scope, name)
0275                         if f['name'] not in counts)
0276                     if meta.get('events') not in (None, total):
0277                         log.warning('%s: derived events %s differs from the files\' sum %s',
0278                                     name, meta.get('events'), total)
0279                 except Exception as e:                        # noqa: BLE001
0280                     log.warning('%s: dataset read-back failed: %s', name, e)
0281             save_state(state)
0282         datasets_done += 1
0283         log.info('%s: %d counted, %d failed%s (%d/%d datasets)', name, len(counts), len(errors),
0284                  ', written' if args.apply else '', datasets_done, len(todo))
0285     summary.update({'datasets_done': datasets_done, 'counted': counted, 'written': written,
0286                     'failed': failed, 'remaining_files': n_missing - counted,
0287                     'seconds': round(time.monotonic() - t0, 1)})
0288     try:
0289         sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0290         os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0291         import django
0292         django.setup()
0293         from monitor_app.epicprod_logging import log_epicprod_action
0294         log_epicprod_action(
0295             args.created_by or 'backfill-produced-events', 'produced_events_backfill',
0296             outcome='ok' if not failed else 'warning', sublevel='normal', live_default=False,
0297             username=args.created_by, duration_ms=int((time.monotonic() - t0) * 1000),
0298             message=(f"produced event-count backfill ({summary['mode']}): {counted} files counted, "
0299                      f"{written} written, {failed} failed, {summary['remaining_files']} left "
0300                      f"of {n_missing} without a count in {len(todo)} datasets"),
0301             **{k: v for k, v in summary.items() if isinstance(v, int)})
0302     except Exception as e:                                    # noqa: BLE001
0303         log.error('action record failed: %s', e)
0304     print(json.dumps(summary))
0305     return 0 if not failed else 1
0306 
0307 
0308 if __name__ == '__main__':
0309     sys.exit(main(sys.argv[1:]))