Back to home page

EIC code displayed by LXR

 
 

    


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 # How many jobs of one failure signature are read before the rest of that
0043 # signature is deleted unread. Two gives a second opinion on the reason
0044 # without turning a storm into a read loop.
0045 READ_PER_SIGNATURE = 2
0046 # The gateway holds the objects seven days; a pass covers its own hour
0047 # with generous overlap, since re-reading an object costs a GET and
0048 # missing one costs the account of a failure.
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                     # The node is what a decline is a measurement of, and it
0138                     # is on the job record even when nothing else survives.
0139                     'node': node or '',
0140                 })
0141     except Exception as e:                                    # noqa: BLE001
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:                                # noqa: BLE001
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         # A row the sweep creates knows only its own job; give it the task
0223         # so every task-keyed surface can reach it.
0224         if jeditaskid and not job.jeditaskid:
0225             job.jeditaskid = jeditaskid
0226             fields.append('jeditaskid')
0227         job.save(update_fields=fields)
0228         # A trial's report is the measurement its edition was waiting for:
0229         # the cost lands on the edition the trial proves (PCS.md, Trials).
0230         # Reported and never fatal to the filing.
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:                                # noqa: BLE001
0235             logger.error(f'payload report sweep: trial cost for {pandaid}: {e}')
0236         return True
0237     except Exception as e:                                    # noqa: BLE001
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:                                    # noqa: BLE001
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             # The job's record with its shipped closes' units joined in
0288             # (swf-epicprod swf_epicprod/es_records.py): the whole account,
0289             # however the harness divided it.
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:                                # noqa: BLE001
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:                                # noqa: BLE001
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:                                # noqa: BLE001
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:                                    # noqa: BLE001
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:                                    # noqa: BLE001
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:                                    # noqa: BLE001
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         # A canary or a reproduction is a one-off whose report is the
0421         # product (SEGFAULT_DIAGNOSIS.md, Reproduction): read it whatever
0422         # its signature's count. The bound is for storms.
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         # 'from' and 'to', which is what the gateway's index reads
0458         # (swf-remote scripts/stageout_index.py). The pass record is a
0459         # contract between two hosts, so its shape is pinned in
0460         # swf-epicprod docs/JOB_REPORTING.md rather than left to a reader.
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