Back to home page

EIC code displayed by LXR

 
 

    


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

0001 #!/usr/bin/env python3
0002 """outputs-ingest.py — the production record of what a task produced.
0003 
0004 One pass over a PCS task's jobs, reading each job's payload report and
0005 posting what it delivered to the production record (swf-epicprod
0006 docs/RUCIO_RESILIENCE.md, Measure 3). Rucio holds the bytes; this record
0007 holds the truth, and it is what says whether a sample is complete.
0008 
0009 The same pass yields the registrar's worklist, because a report that says
0010 its registration is pending is exactly a row in ``pending``.
0011 
0012 Reports come from two places, since a pending job now exits success:
0013   * the PanDA metatable, which holds a finished job's report;
0014   * the copy the sweeper filed on EpicProdJob, for a failed job.
0015 
0016 Writing goes through ``POST /pcs/api/outputs/`` and nowhere else — the
0017 endpoint is the single writer of that record, keyed by task and DID, so
0018 re-running updates rather than duplicates and a re-run is always safe.
0019 
0020 Django-bootstrap standalone script — also usable by hand::
0021 
0022     cd /data/wenauseic/github/swf-monitor/src
0023     ../../swf-testbed/.venv/bin/python ../scripts/outputs-ingest.py \
0024         [--hours 48] [--task <jeditaskid>] [--dry-run]
0025 
0026 The last stdout line is a JSON summary; progress goes to stderr.
0027 Exit codes: 0 ok · 2 nothing to write · 6 the record refused a batch.
0028 """
0029 import argparse
0030 import json
0031 import os
0032 import sys
0033 from datetime import datetime, timedelta, timezone as dt_timezone
0034 
0035 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0036 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0037 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0038 
0039 import django  # noqa: E402
0040 django.setup()
0041 
0042 from django.db import connections  # noqa: E402
0043 
0044 from monitor_app.models import EpicProdJob  # noqa: E402
0045 from monitor_app.panda.constants import PANDA_SCHEMA  # noqa: E402
0046 
0047 OUTPUTS_URL = os.environ.get(
0048     'PCS_OUTPUTS_URL', 'https://localhost/swf-monitor/pcs/api/outputs/')
0049 POST_TIMEOUT_S = 120
0050 # Rows per request: the writer upserts a row at a time, so one request
0051 # carrying a 50,000-job task ran past the gateway's minute and the record
0052 # of the three tasks of 2026-09-17 was never written; a batch is a few
0053 # seconds, and the writer's keying by task and DID makes any split safe.
0054 POST_BATCH = int(os.environ.get('OUTPUTS_POST_BATCH', 2000))
0055 DEFAULT_HOURS = 48
0056 
0057 
0058 def _log(msg):
0059     print(msg, file=sys.stderr, flush=True)
0060 
0061 
0062 def pcs_tasks(since, jedi_task_id=None):
0063     """The JEDI tasks PCS owns whose jobs moved in the window.
0064 
0065     A task PCS does not own has no production record to write, and the
0066     endpoint refuses it rather than storing against a guess, so the pass
0067     does not offer it.
0068     """
0069     from pcs.models import PandaTasks
0070     rows = PandaTasks.objects.filter(jedi_task_id__isnull=False)
0071     if jedi_task_id:
0072         rows = rows.filter(jedi_task_id=jedi_task_id)
0073     ids = sorted({int(t) for t in rows.values_list('jedi_task_id', flat=True)})
0074     if jedi_task_id or not ids:
0075         return ids
0076     sql = f"""
0077         SELECT DISTINCT "jeditaskid" FROM "{PANDA_SCHEMA}"."jobsarchived4"
0078         WHERE "jeditaskid" = ANY(%s) AND "modificationtime" >= %s
0079         UNION
0080         SELECT DISTINCT "jeditaskid" FROM "{PANDA_SCHEMA}"."jobsactive4"
0081         WHERE "jeditaskid" = ANY(%s) AND "modificationtime" >= %s
0082     """
0083     try:
0084         with connections['panda'].cursor() as cursor:
0085             cursor.execute(sql, [ids, since, ids, since])
0086             return sorted({int(r[0]) for r in cursor.fetchall()})
0087     except Exception as e:                                    # noqa: BLE001
0088         _log(f'ERROR: active-task query failed: {e}')
0089         return []
0090 
0091 
0092 def reports_for_task(jedi_task_id):
0093     """(pandaid, report, reported_at) for every job of the task that has one."""
0094     found, seen = [], set()
0095     sql = f"""
0096         SELECT m."pandaid", m."metadata", j."endtime"
0097         FROM "{PANDA_SCHEMA}"."metatable" m
0098         JOIN "{PANDA_SCHEMA}"."jobsarchived4" j ON j."pandaid" = m."pandaid"
0099         WHERE j."jeditaskid" = %s
0100     """
0101     try:
0102         with connections['panda'].cursor() as cursor:
0103             cursor.execute(sql, [jedi_task_id])
0104             for pandaid, raw, endtime in cursor.fetchall():
0105                 try:
0106                     metadata = json.loads(raw) if isinstance(raw, str) else raw
0107                 except (ValueError, TypeError):
0108                     continue
0109                 report = (metadata or {}).get('payload')
0110                 if isinstance(report, dict):
0111                     seen.add(int(pandaid))
0112                     found.append((int(pandaid), report, endtime))
0113     except Exception as e:                                    # noqa: BLE001
0114         _log(f'ERROR: metatable read failed for task {jedi_task_id}: {e}')
0115 
0116     try:
0117         for job in (EpicProdJob.objects.filter(jeditaskid=jedi_task_id)
0118                     .only('pandaid', 'data', 'updated_at').iterator()):
0119             if int(job.pandaid) in seen:
0120                 continue
0121             filed = (job.data or {}).get('payload_report') or {}
0122             report = filed.get('report')
0123             if isinstance(report, dict):
0124                 found.append((int(job.pandaid), report, job.updated_at))
0125     except Exception as e:                                    # noqa: BLE001
0126         _log(f'ERROR: filed reports unreadable for task {jedi_task_id}: {e}')
0127     return found
0128 
0129 
0130 def segment_of(did):
0131     """The unit of work a file belongs to: its name without the stage.
0132 
0133     FULL and RECO of one manifest row share a stem, so grouping on it is
0134     what makes a work unit's state derivable rather than stored.
0135     """
0136     stem = did.rsplit('/', 1)[-1]
0137     for suffix in ('.eicrecon.edm4eic.root', '.edm4hep.root', '.root'):
0138         if stem.endswith(suffix):
0139             return stem[:-len(suffix)]
0140     return stem
0141 
0142 
0143 def rows_from_report(pandaid, report, reported_at):
0144     """The record rows one job's report yields, one per file.
0145 
0146     A row exists for a file the job registered, owes a registration for,
0147     or failed to register — not for a file it merely produced. The trial
0148     path, for one, makes a FULL output and never uploads it: inventing a
0149     delivery state for a file that never left the worker would put the
0150     registrar on a hunt for bytes that are not there.
0151 
0152     The outcome is per report; the state is per file, because a job can
0153     register one output and fail the other.
0154     """
0155     registration = report.get('registration') or {}
0156     outcome = (registration.get('outcome') or '').strip()
0157     outputs = {}
0158     for kind, entry in (report.get('outputs') or {}).items():
0159         if isinstance(entry, dict) and entry.get('file'):
0160             outputs[entry['file']] = (str(kind).upper(), entry)
0161 
0162     def _row(did, state, reason):
0163         stem = str(did).rsplit('/', 1)[-1]
0164         kind, entry = outputs.get(stem, ('', {}))
0165         return {
0166             'pandaid': pandaid,
0167             'did': str(did),
0168             'kind': kind or ('RECO' if 'eicrecon' in stem else 'FULL'),
0169             'segment': segment_of(str(did)),
0170             'events': entry.get('events'),
0171             'bytes': entry.get('bytes'),
0172             'status': state,
0173             'reason': reason,
0174             'reported_at': (reported_at.isoformat()
0175                             if hasattr(reported_at, 'isoformat') else None),
0176         }
0177 
0178     state = {'registered': 'delivered', 'pending': 'pending',
0179              'diverted': 'diverted'}.get(outcome, 'lost')
0180     reason = '' if state == 'delivered' else (
0181         f'the job reported registration {outcome or "not reached"}')
0182     rows = [_row(did, state, reason)
0183             for did in (registration.get('dids') or [])]
0184     # What the job named as failed is lost to this attempt whatever the
0185     # overall outcome was: the catalog has nothing for it.
0186     rows.extend(_row(did, 'lost', 'the job reported this registration failed')
0187                 for did in (registration.get('failed') or []))
0188     return rows
0189 
0190 
0191 def post_outputs(jedi_task_id, rows, token):
0192     """Send one task's rows to the single writer. Returns (ok, detail)."""
0193     import urllib.error
0194     import urllib.request
0195     import ssl
0196     body = json.dumps({'jedi_task_id': jedi_task_id, 'outputs': rows}).encode()
0197     request = urllib.request.Request(
0198         OUTPUTS_URL, data=body, method='POST',
0199         headers={'Content-Type': 'application/json',
0200                  'Authorization': f'Token {token}'})
0201     context = ssl._create_unverified_context()
0202     try:
0203         with urllib.request.urlopen(request, timeout=POST_TIMEOUT_S,
0204                                     context=context) as response:
0205             return True, json.loads(response.read().decode() or '{}')
0206     except urllib.error.HTTPError as e:
0207         return False, f'{e.code}: {e.read().decode()[:300]}'
0208     except Exception as e:                                    # noqa: BLE001
0209         return False, str(e)
0210 
0211 
0212 def main():
0213     parser = argparse.ArgumentParser()
0214     parser.add_argument('--hours', type=float, default=DEFAULT_HOURS)
0215     parser.add_argument('--task', type=int, default=None,
0216                         help='one JEDI task id, ignoring the window')
0217     parser.add_argument('--dry-run', action='store_true',
0218                         help='read and shape the rows, write nothing')
0219     args = parser.parse_args()
0220 
0221     token = os.environ.get('SWF_API_TOKEN', '')
0222     if not token and not args.dry_run:
0223         print(json.dumps({'error': 'SWF_API_TOKEN is not set'}))
0224         return 6
0225 
0226     since = datetime.now(dt_timezone.utc) - timedelta(hours=args.hours)
0227     summary = {'tasks': 0, 'jobs': 0, 'rows': 0, 'delivered': 0, 'pending': 0,
0228                'lost': 0, 'written': 0, 'refused': [], 'dry_run': args.dry_run}
0229 
0230     for jedi_task_id in pcs_tasks(since, jedi_task_id=args.task):
0231         reports = reports_for_task(jedi_task_id)
0232         if not reports:
0233             continue
0234         rows = []
0235         for pandaid, report, reported_at in reports:
0236             rows.extend(rows_from_report(pandaid, report, reported_at))
0237         if not rows:
0238             continue
0239         summary['tasks'] += 1
0240         summary['jobs'] += len(reports)
0241         summary['rows'] += len(rows)
0242         for row in rows:
0243             summary[row['status']] = summary.get(row['status'], 0) + 1
0244         _log(f'task {jedi_task_id}: {len(reports)} report(s), {len(rows)} row(s)')
0245         if args.dry_run:
0246             continue
0247         for start in range(0, len(rows), POST_BATCH):
0248             batch = rows[start:start + POST_BATCH]
0249             ok, detail = post_outputs(jedi_task_id, batch, token)
0250             if ok:
0251                 summary['written'] += (detail or {}).get('written', len(batch))
0252                 for refusal in (detail or {}).get('refused') or []:
0253                     summary['refused'].append(f'{jedi_task_id}: {refusal}')
0254             else:
0255                 summary['refused'].append(
0256                     f'{jedi_task_id} rows {start}-{start + len(batch)}: {detail}')
0257                 _log(f'ERROR: task {jedi_task_id} rows {start}-{start + len(batch)} refused: {detail}')
0258                 break
0259 
0260     print(json.dumps(summary))
0261     return 0 if summary['rows'] else 2
0262 
0263 
0264 if __name__ == '__main__':
0265     sys.exit(main())