File indexing completed on 2026-11-09 10:47:21
0001
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
0123
0124
0125
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
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
0185
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:
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 '?'
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
0212
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
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:
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:
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:
0266 state['failed'][f] = f'set_metadata: {e}'
0267 failed += 1
0268
0269
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:
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:
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:]))