File indexing completed on 2026-11-09 10:47:23
0001
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
0040 django.setup()
0041
0042 from django.db import connections
0043
0044 from monitor_app.models import EpicProdJob
0045 from monitor_app.panda.constants import PANDA_SCHEMA
0046
0047 OUTPUTS_URL = os.environ.get(
0048 'PCS_OUTPUTS_URL', 'https://localhost/swf-monitor/pcs/api/outputs/')
0049 POST_TIMEOUT_S = 120
0050
0051
0052
0053
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:
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:
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:
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
0185
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:
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())