File indexing completed on 2026-11-09 10:47:34
0001 """Payload reports: sweep the reports of failed jobs, file them, delete them.
0002
0003 A production job writes its own account of itself as objects to S3 while
0004 it runs, because PanDA keeps job metadata for finished jobs only and the
0005 full account of a job that fails otherwise dies with the worker
0006 (swf-epicprod docs/JOB_REPORTING.md). A successful job's objects expire
0007 unread. A failed job's are worth something only until what they say has
0008 been taken, and this module takes it: read, file beside the job record,
0009 delete.
0010
0011 The sweep is selective by design. A storm produces thousands of objects
0012 that say one thing, and the payload digest PanDA already carries for
0013 every failed job gives the signature before anything is read, so a
0014 bounded number per distinct signature is read and the rest are deleted
0015 unread. Reading a storm one object at a time buys no understanding.
0016
0017 The store belongs to the gateway, so the sweep may delete only what it
0018 reports, and it reports every pass — the empty pass and the failed pass
0019 included, because a pass that found nothing is the liveness signal and a
0020 pass that errored is what the gateway's growth guard needs to hear. The
0021 record posts after the deletes land, so the gateway's index never
0022 records as gone something still in the bucket. Only 202 means delivered.
0023
0024 Filing goes to ``EpicProdJob.data['payload_report']``: the job record
0025 already exists for the production inventory and carries a JSON field, so
0026 a report needs no table of its own.
0027 """
0028 import json
0029 import logging
0030 import os
0031 import urllib.error
0032 import urllib.request
0033 from datetime import datetime, timezone as dt_timezone
0034
0035 from django.db import connections
0036
0037 from .models import EpicProdJob, PersistentState
0038 from .panda.constants import PANDA_SCHEMA
0039
0040 logger = logging.getLogger(__name__)
0041
0042
0043
0044
0045 READ_PER_SIGNATURE = 2
0046
0047
0048
0049 DEFAULT_HOURS = 6
0050 SWEEPER_ENV = os.path.expanduser('~/.epic-report-sweeper.env')
0051 INGEST_ENV = os.path.expanduser('~/.epic-sweeper-ingest.env')
0052 BACKLOG_KEY = 'payload_report_sweep_backlog'
0053 POST_TIMEOUT_S = 30
0054
0055
0056 def _read_env_file(path):
0057 """The key=value lines of a credential file, or {} when it is absent.
0058
0059 A missing file turns the sweep off rather than failing it: that is
0060 how the channel is disabled on a host that should not sweep.
0061 """
0062 values = {}
0063 try:
0064 with open(path) as handle:
0065 for line in handle:
0066 line = line.strip()
0067 if not line or line.startswith('#') or '=' not in line:
0068 continue
0069 key, _, value = line.partition('=')
0070 values[key.strip()] = value.strip().strip('"').strip("'")
0071 except OSError as e:
0072 logger.error(f'payload report sweep: cannot read {path}: {e}')
0073 return values
0074
0075
0076 def sweeper_client():
0077 """(s3 client, bucket, prefix) from the sweeper credential, or None.
0078
0079 The credential is list, get and delete under the reports prefix and
0080 nothing else; it is not the write credential the jobs carry.
0081 """
0082 env = _read_env_file(SWEEPER_ENV)
0083 bucket = env.get('REPORT_SWEEP_BUCKET')
0084 key_id = env.get('REPORT_SWEEP_ACCESS_KEY_ID')
0085 secret = env.get('REPORT_SWEEP_SECRET_ACCESS_KEY')
0086 if not (bucket and key_id and secret):
0087 logger.error(
0088 f'payload report sweep: {SWEEPER_ENV} carries no usable credential; '
0089 f'the sweep is off on this host')
0090 return None
0091 try:
0092 import boto3
0093 except ImportError as e:
0094 logger.error(f'payload report sweep: boto3 unavailable: {e}')
0095 return None
0096 client = boto3.client(
0097 's3', region_name=env.get('REPORT_SWEEP_REGION') or 'us-east-1',
0098 aws_access_key_id=key_id, aws_secret_access_key=secret)
0099 prefix = (env.get('REPORT_SWEEP_PREFIX') or 'reports/').strip('/') + '/'
0100 return client, bucket, prefix
0101
0102
0103 def candidates(since, limit=None):
0104 """Failed jobs in the window, with what names their failure signature.
0105
0106 Returns ``[{pandaid, site, transexitcode, piloterrorcode, digest}]``.
0107 The digest is the payload's own trail, which rides the job metrics
0108 string and survives a failed job where metadata does not.
0109 """
0110 sql = f"""
0111 SELECT "pandaid", "computingsite", "transexitcode", "piloterrorcode",
0112 "jobmetrics", "jeditaskid", "modificationhost", "processingtype"
0113 FROM "{PANDA_SCHEMA}"."jobsactive4"
0114 WHERE "jobstatus" = 'failed' AND "modificationtime" >= %s
0115 UNION
0116 SELECT "pandaid", "computingsite", "transexitcode", "piloterrorcode",
0117 "jobmetrics", "jeditaskid", "modificationhost", "processingtype"
0118 FROM "{PANDA_SCHEMA}"."jobsarchived4"
0119 WHERE "jobstatus" = 'failed' AND "modificationtime" >= %s
0120 """
0121 if limit:
0122 sql += f' LIMIT {int(limit)}'
0123 rows = []
0124 try:
0125 with connections['panda'].cursor() as cursor:
0126 cursor.execute(sql, [since, since])
0127 for (pandaid, site, transexit, piloterr, metrics, jeditaskid,
0128 node, processingtype) in cursor.fetchall():
0129 rows.append({
0130 'pandaid': int(pandaid),
0131 'processingtype': processingtype or '',
0132 'site': site or '',
0133 'transexitcode': str(transexit or ''),
0134 'piloterrorcode': int(piloterr or 0),
0135 'digest': _digest(metrics),
0136 'jeditaskid': int(jeditaskid) if jeditaskid else None,
0137
0138
0139 'node': node or '',
0140 })
0141 except Exception as e:
0142 logger.error(f'payload report sweep: candidate query failed: {e}')
0143 return rows
0144
0145
0146 def _digest(jobmetrics):
0147 """The payload trail carried in the job metrics string, or ''."""
0148 for token in str(jobmetrics or '').split():
0149 if token.startswith('payloadTrail='):
0150 return token.partition('=')[2]
0151 return ''
0152
0153
0154 def signature(job):
0155 """What makes two failures the same for the purpose of reading one.
0156
0157 Site, the payload's exit code, the pilot's error code and the trail:
0158 a storm repeats all four, and two failures that differ in any of them
0159 are worth reading separately.
0160 """
0161 return (job['site'], job['transexitcode'], job['piloterrorcode'],
0162 job['digest'])
0163
0164
0165 def job_objects(client, bucket, prefix, pandaid):
0166 """The keys of one job's report objects, in the order it wrote them."""
0167 keys = []
0168 token = None
0169 while True:
0170 kwargs = {'Bucket': bucket, 'Prefix': f'{prefix}{pandaid}/'}
0171 if token:
0172 kwargs['ContinuationToken'] = token
0173 response = client.list_objects_v2(**kwargs)
0174 keys.extend(item['Key'] for item in response.get('Contents') or [])
0175 token = response.get('NextContinuationToken')
0176 if not token:
0177 break
0178
0179 def order(key):
0180 stem = key.rsplit('/', 1)[-1].rsplit('.', 1)[0]
0181 return int(stem) if stem.isdigit() else -1
0182
0183 return sorted(keys, key=order)
0184
0185
0186 def read_report(client, bucket, keys):
0187 """The job's own account, from the last object that parses.
0188
0189 The payload rewrites its report at every stage end, so the last
0190 object carries the whole run; an earlier one is read only when the
0191 last is truncated or unparseable.
0192 """
0193 for key in reversed(keys):
0194 try:
0195 body = client.get_object(Bucket=bucket, Key=key)['Body'].read()
0196 return json.loads(body.decode('utf-8')), key
0197 except Exception as e:
0198 logger.error(f'payload report sweep: {key} unreadable: {e}')
0199 return None, ''
0200
0201
0202 def file_report(pandaid, report, source_key, jeditaskid=None, node=''):
0203 """File a job's report beside its job record. Returns True when stored.
0204
0205 The report lands under ``data['payload_report']`` with the object it
0206 came from and the node that ran the job, so the filed copy names its
0207 source and can be read per node — which is what a landing decline is a
0208 measurement of. Nothing else on the record is touched: phase and
0209 failure_summary belong to the inventory's own diagnosis.
0210 """
0211 try:
0212 job, created = EpicProdJob.objects.get_or_create(pandaid=pandaid)
0213 data = dict(job.data or {})
0214 data['payload_report'] = {
0215 'report': report,
0216 'source_key': source_key,
0217 'node': node or '',
0218 'filed_at': datetime.now(dt_timezone.utc).isoformat(),
0219 }
0220 job.data = data
0221 fields = ['data', 'updated_at']
0222
0223
0224 if jeditaskid and not job.jeditaskid:
0225 job.jeditaskid = jeditaskid
0226 fields.append('jeditaskid')
0227 job.save(update_fields=fields)
0228
0229
0230
0231 try:
0232 from pcs.services import record_trial_cost
0233 record_trial_cost(pandaid, report, jeditaskid or job.jeditaskid)
0234 except Exception as e:
0235 logger.error(f'payload report sweep: trial cost for {pandaid}: {e}')
0236 return True
0237 except Exception as e:
0238 logger.error(f'payload report sweep: cannot file {pandaid}: {e}')
0239 return False
0240
0241
0242 def es_record_candidates(since, running_only=False):
0243 """Ended Event Service jobs, or only running jobs when requested.
0244
0245 Whatever their status: a preempted fine-grained job whose heartbeat
0246 runs out is archived finished when some of its ranges were credited
0247 (panda-server ``archiveJob``, fine-grained branch), so the failed-job
0248 candidates would never reach its record.
0249 """
0250 if running_only:
0251 sql = f'''SELECT "pandaid", "jeditaskid" FROM "{PANDA_SCHEMA}"."jobsactive4"
0252 WHERE "eventservice" > 0 AND "jobstatus" = 'running' '''
0253 params = []
0254 else:
0255 sql = f"""
0256 SELECT "pandaid", "jeditaskid" FROM "{PANDA_SCHEMA}"."jobsactive4"
0257 WHERE "eventservice" IS NOT NULL AND "eventservice" > 0
0258 AND "jobstatus" IN ('finished', 'failed', 'closed', 'cancelled')
0259 AND "modificationtime" >= %s
0260 UNION
0261 SELECT "pandaid", "jeditaskid" FROM "{PANDA_SCHEMA}"."jobsarchived4"
0262 WHERE "eventservice" IS NOT NULL AND "eventservice" > 0
0263 AND "modificationtime" >= %s
0264 """
0265 params = [since, since]
0266 try:
0267 with connections['panda'].cursor() as cursor:
0268 cursor.execute(sql, params)
0269 return [(int(p), int(j) if j else None) for p, j in cursor.fetchall()]
0270 except Exception as e:
0271 logger.error(f'payload report sweep: Event Service candidate query failed: {e}')
0272 return []
0273
0274
0275 def file_es_records(client, bucket, prefix, since, dry_run=False, running_only=False):
0276 """File each ended Event Service job's shipped record (``es.json``, the
0277 harness's record as it stood at its last write, swf-epicprod
0278 payload/es/es_record.py) under ``EpicProdJob.data['es_record']``. It is
0279 the only account of a preempted job, whose node took the final report
0280 with it; the job page draws the job from it when the metadata has none.
0281 Read before the failed-job pass deletes a job's objects; returns the
0282 job ids filed."""
0283 filed = []
0284 for pandaid, jeditaskid in es_record_candidates(since, running_only=running_only):
0285 key = f'{prefix}{pandaid}/es.json'
0286 try:
0287
0288
0289
0290 from swf_epicprod import es_records
0291 record = es_records.load(client, bucket, prefix, pandaid)
0292 if (not isinstance(record, dict) or record.get('kind') != 'es_record'
0293 or str(record.get('pandaid')) != str(pandaid)):
0294 raise ValueError('The harness record does not match the requested job')
0295 except Exception as e:
0296 if 'NoSuchKey' not in str(e) and 'Not Found' not in str(e):
0297 logger.error(f'payload report sweep: {key} unreadable: {e}')
0298 continue
0299 if dry_run:
0300 filed.append(pandaid)
0301 continue
0302 try:
0303 job, _ = EpicProdJob.objects.get_or_create(pandaid=pandaid)
0304 data = dict(job.data or {})
0305 data['es_record'] = {'record': record, 'source_key': key,
0306 'filed_at': datetime.now(dt_timezone.utc).isoformat()}
0307 job.data = data
0308 fields = ['data', 'updated_at']
0309 if jeditaskid and not job.jeditaskid:
0310 job.jeditaskid = jeditaskid
0311 fields.append('jeditaskid')
0312 job.save(update_fields=fields)
0313 filed.append(pandaid)
0314 except Exception as e:
0315 logger.error(f'payload report sweep: cannot file the Event Service record of {pandaid}: {e}')
0316 return filed
0317
0318
0319 def refresh_running_es_records():
0320 """File running jobs' snapshots without changing their PanDA state."""
0321 store = sweeper_client()
0322 if store is None:
0323 raise RuntimeError('The Event Service report reader credential is unavailable')
0324 return file_es_records(*store, since=None, running_only=True)
0325
0326
0327 def delete_keys(client, bucket, keys):
0328 """Delete objects, returning those actually gone."""
0329 gone = []
0330 for chunk_start in range(0, len(keys), 1000):
0331 chunk = keys[chunk_start:chunk_start + 1000]
0332 try:
0333 response = client.delete_objects(
0334 Bucket=bucket,
0335 Delete={'Objects': [{'Key': k} for k in chunk], 'Quiet': False})
0336 gone.extend(item['Key'] for item in response.get('Deleted') or [])
0337 for err in response.get('Errors') or []:
0338 logger.error(f"payload report sweep: delete {err.get('Key')} "
0339 f"refused: {err.get('Message')}")
0340 except Exception as e:
0341 logger.error(f'payload report sweep: delete failed: {e}')
0342 return gone
0343
0344
0345 def post_pass(record):
0346 """Post the pass record to the gateway. Returns (delivered, status, reason).
0347
0348 Only 202 means the record reached the gateway's spool, and a 202 is
0349 never re-posted. A 503, a timeout or any other 5xx leaves the record
0350 undelivered and it is carried to the next pass. A 400 or a 413 will
0351 never succeed unchanged, so it is raised rather than retried.
0352 """
0353 env = _read_env_file(INGEST_ENV)
0354 url = env.get('SWEEP_INGEST_URL')
0355 token = env.get('SWEEP_INGEST_TOKEN')
0356 if not (url and token):
0357 return False, 0, f'{INGEST_ENV} carries no endpoint and token'
0358 body = json.dumps(record).encode('utf-8')
0359 request = urllib.request.Request(
0360 url, data=body, method='POST',
0361 headers={'Content-Type': 'application/json',
0362 'Authorization': f'Bearer {token}'})
0363 try:
0364 with urllib.request.urlopen(request, timeout=POST_TIMEOUT_S) as response:
0365 status = response.getcode()
0366 if status == 202:
0367 return True, status, ''
0368 return False, status, f'gateway answered {status}, not 202'
0369 except urllib.error.HTTPError as e:
0370 reason = f'gateway answered {e.code}'
0371 if e.code in (400, 413):
0372 logger.error(f'payload report sweep: {reason}; the record cannot '
0373 f'succeed unchanged and is not re-posted')
0374 elif e.code in (401, 403):
0375 logger.error(f'payload report sweep: {reason}; the sweeper token '
0376 f'is missing or not the sweeper account')
0377 return False, e.code, reason
0378 except Exception as e:
0379 return False, 0, f'pass record not delivered: {e}'
0380
0381
0382 def _backlog():
0383 """Pass records the gateway has not accepted yet."""
0384 try:
0385 return (PersistentState.get_state() or {}).get(BACKLOG_KEY) or []
0386 except Exception as e:
0387 logger.error(f'payload report sweep: backlog unreadable: {e}')
0388 return []
0389
0390
0391 def _set_backlog(records):
0392 try:
0393 PersistentState.update_state({BACKLOG_KEY: records})
0394 except Exception as e:
0395 logger.error(f'payload report sweep: backlog not stored: {e}')
0396
0397
0398 def sweep(since, limit=None, per_signature=READ_PER_SIGNATURE, dry_run=False):
0399 """One pass: read a bounded sample per signature, file it, delete the rest.
0400
0401 Returns the pass record, which is also what was posted. A pass that
0402 found nothing still posts, because the empty pass is what separates a
0403 quiet week from a dead sweeper.
0404 """
0405 started = datetime.now(dt_timezone.utc)
0406 opened = sweeper_client()
0407 if opened is None:
0408 return {'outcome': 'failed', 'reason': 'no sweeper credential',
0409 'window': {'from': since.isoformat(),
0410 'to': started.isoformat()},
0411 'filed': [], 'deleted_read': [], 'deleted_unread': []}
0412 client, bucket, prefix = opened
0413
0414 es_filed = file_es_records(client, bucket, prefix, since, dry_run=dry_run)
0415 jobs = candidates(since, limit=limit)
0416 seen, to_read, to_drop = {}, [], []
0417 for job in jobs:
0418 key = signature(job)
0419 seen[key] = seen.get(key, 0) + 1
0420
0421
0422
0423 if job.get('processingtype') == 'canary':
0424 to_read.append(job)
0425 continue
0426 (to_read if seen[key] <= per_signature else to_drop).append(job)
0427
0428 filed, deleted_read, deleted_unread, errors = [], [], [], []
0429 for job in to_read:
0430 keys = job_objects(client, bucket, prefix, job['pandaid'])
0431 if not keys:
0432 continue
0433 report, source_key = read_report(client, bucket, keys)
0434 if report is None:
0435 errors.append(f"{job['pandaid']}: no readable report object")
0436 continue
0437 if dry_run:
0438 filed.append(job['pandaid'])
0439 continue
0440 if file_report(job['pandaid'], report, source_key,
0441 jeditaskid=job.get('jeditaskid'), node=job.get('node')):
0442 filed.append(job['pandaid'])
0443 deleted_read.extend(delete_keys(client, bucket, keys))
0444 else:
0445 errors.append(f"{job['pandaid']}: filing failed, objects kept")
0446
0447 for job in to_drop:
0448 keys = job_objects(client, bucket, prefix, job['pandaid'])
0449 if keys and not dry_run:
0450 deleted_unread.extend(delete_keys(client, bucket, keys))
0451 elif keys:
0452 deleted_unread.extend(keys)
0453
0454 record = {
0455 'outcome': 'ok' if not errors else 'partial',
0456 'reason': '; '.join(errors[:20]),
0457
0458
0459
0460
0461 'window': {'from': since.isoformat(), 'to': started.isoformat()},
0462 'signatures': len(seen),
0463 'candidates': len(jobs),
0464 'filed': filed,
0465 'es_records_filed': es_filed,
0466 'deleted_read': deleted_read,
0467 'deleted_unread': deleted_unread,
0468 'dry_run': bool(dry_run),
0469 }
0470 if dry_run:
0471 return record
0472
0473 pending = _backlog() + [record]
0474 undelivered = []
0475 this_pass_delivered = False
0476 for entry in pending:
0477 delivered, status, reason = post_pass(entry)
0478 if delivered:
0479 if entry is record:
0480 this_pass_delivered = True
0481 continue
0482 if status in (400, 413):
0483 logger.error(f'payload report sweep: dropping a record the gateway '
0484 f'will never accept: {reason}')
0485 continue
0486 undelivered.append(entry)
0487 logger.error(f'payload report sweep: {reason}; carried to the next pass')
0488 _set_backlog(undelivered)
0489 record['delivered'] = this_pass_delivered
0490 return record