Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """Batch-layer records: capture, store, read.
0002 
0003 Every failure reason originating in the batch layer reaches PanDA cut to
0004 a column width, and the cut falls at a character count rather than at a
0005 clause, so what is lost is the end of the message, which is where batch
0006 systems put the error. The condor event log the harvester keeps carries
0007 each reason whole, is served without credentials inside the SCDF
0008 network, and is transient: the dated directories hold about eighteen
0009 days. This module captures those logs while they exist
0010 (docs/ERROR_ATTRIBUTION.md, Batch-layer records).
0011 
0012 Capture applies no filter and no classification. The body is written
0013 verbatim to the file store and a fetch that fails leaves a marker
0014 carrying its reason, so a failure is visible rather than an absence.
0015 Parsing runs against the stored copy and never over the network
0016 (monitor_app.panda.queries.condor_log_events), so a parser that is wrong
0017 costs a re-run and leaves the record intact.
0018 
0019 The store is a filesystem tree rather than a database table: the bodies
0020 are write-once, read-rarely, never queried by content, and a rolling
0021 month of them is comparable in size to the whole of swfdb, whose backup
0022 and vacuum load they would join for nothing. Layout::
0023 
0024     <root>/YYYY-MM-DD/<pandaid>.log      the body, verbatim
0025     <root>/YYYY-MM-DD/<pandaid>.error    why the fetch failed
0026     <root>/keep/<pattern>/<pandaid>.log  exemplars and unmatched logs
0027 
0028 Retention is one month, and the date directory is the unit of deletion.
0029 Anything the learning pass rescues moves under ``keep`` before its
0030 directory goes, so nothing at delete time has to decide anything.
0031 """
0032 import hashlib
0033 import json
0034 import logging
0035 import os
0036 import re
0037 import ssl
0038 import urllib.request
0039 from concurrent.futures import ThreadPoolExecutor
0040 from datetime import date, datetime, timedelta, timezone as dt_timezone
0041 
0042 from django.conf import settings
0043 from django.db import connections
0044 
0045 from .panda.constants import PANDA_SCHEMA
0046 
0047 logger = logging.getLogger(__name__)
0048 
0049 # A condor event log measures 1 to 3 KB. The cap is far above that so it
0050 # never trims a real log; a body that reaches it is recorded as capped
0051 # rather than silently shortened.
0052 FETCH_MAX_BYTES = 4 * 1024 * 1024
0053 FETCH_TIMEOUT_S = 20
0054 FETCH_WORKERS = 8
0055 RETENTION_DAYS = 30
0056 
0057 
0058 def store_root():
0059     """Where captured bodies live. Overridable for a test or a move."""
0060     return getattr(settings, 'BATCH_RECORD_ROOT',
0061                    '/data/wenauseic/swf-monitor/batch-logs')
0062 
0063 
0064 def _day_dir(root, day):
0065     return os.path.join(root, day.isoformat())
0066 
0067 
0068 def _keep_paths(root, pandaid):
0069     """Where a rescued copy of this job's log would be, under ``keep``."""
0070     keep_root = os.path.join(root, 'keep')
0071     try:
0072         buckets = os.listdir(keep_root)
0073     except OSError:
0074         return []
0075     return [os.path.join(keep_root, bucket, f'{pandaid}.log')
0076             for bucket in sorted(buckets)]
0077 
0078 
0079 def stored(pandaid, root=None):
0080     """The capture of one job, or None.
0081 
0082     Returns ``{pandaid, status, path, body, bytes, day}`` with status
0083     'captured' or 'failed'; the failed form carries the reason as its
0084     body. The date directory is not known from the PanDA id, so the
0085     month of directories is scanned, which is thirty stats.
0086     """
0087     root = root or store_root()
0088     try:
0089         days = sorted((d for d in os.listdir(root) if d[:2].isdigit()),
0090                       reverse=True)
0091     except OSError:
0092         return None
0093     for day in days:
0094         for suffix, status in (('.log', 'captured'), ('.error', 'failed')):
0095             path = os.path.join(root, day, f'{pandaid}{suffix}')
0096             if os.path.exists(path):
0097                 try:
0098                     with open(path, encoding='utf-8', errors='replace') as f:
0099                         body = f.read()
0100                 except OSError as e:                          # noqa: BLE001
0101                     logger.error('batch record unreadable at %s: %s', path, e)
0102                     return None
0103                 return {'pandaid': pandaid, 'status': status, 'path': path,
0104                         'body': body, 'bytes': len(body), 'day': day}
0105     # A rescued exemplar outlives its date directory, and a link to it must
0106     # outlive it too: the knowledge base points at these jobs by name.
0107     for path in _keep_paths(root, pandaid):
0108         if os.path.exists(path):
0109             try:
0110                 with open(path, encoding='utf-8', errors='replace') as f:
0111                     body = f.read()
0112             except OSError as e:                              # noqa: BLE001
0113                 logger.error('rescued batch record unreadable at %s: %s', path, e)
0114                 return None
0115             return {'pandaid': pandaid, 'status': 'captured', 'path': path,
0116                     'body': body, 'bytes': len(body), 'day': 'kept'}
0117     return None
0118 
0119 
0120 def first_day(root=None):
0121     """The earliest day the store holds, ISO date, or None if it is empty."""
0122     root = root or store_root()
0123     try:
0124         days = sorted(d for d in os.listdir(root) if d[:2].isdigit())
0125     except OSError:
0126         return None
0127     return days[0] if days else None
0128 
0129 
0130 def why_absent(job, has_batchlog=True):
0131     """Why a job has no captured batch record, as a sentence for a page.
0132 
0133     Capture covers failed jobs the harvester holds a batch log for, once
0134     nightly (``candidates``). So a job that did not fail was never a
0135     candidate; a failed job with no harvester batch log has nothing to
0136     capture; a failed job that ended before the store's first day
0137     predates the capture; a failed job newer than that awaits the
0138     nightly pass.
0139     """
0140     status = job.get('jobstatus') or ''
0141     if status != 'failed':
0142         return (f'Not captured: the capture covers failed jobs only, and '
0143                 f'this job is {status or "not failed"}.')
0144     if not has_batchlog:
0145         return 'Not captured: the harvester holds no batch log for this job.'
0146     start = first_day()
0147     ended = str(job.get('endtime') or job.get('modificationtime') or '')[:10]
0148     if start and ended and ended < start:
0149         return (f'Not captured: the store begins {start} and this job '
0150                 f'failed {ended}, before the capture existed; the harvester '
0151                 f'keeps the source about eighteen days.')
0152     return ('Not yet captured: the nightly pass captures the logs of the '
0153             'jobs that failed since its last run.')
0154 
0155 
0156 def _fetch(url):
0157     """The log body, or an exception. No retry: the caller runs daily."""
0158     # The SCDF log hosts serve these over TLS with a chain this host's
0159     # trust store does not carry. The content is an operational log
0160     # inside the facility and no credential travels either way.
0161     context = ssl._create_unverified_context()
0162     request = urllib.request.Request(
0163         url, headers={'User-Agent': 'swf-monitor/batch-records'})
0164     with urllib.request.urlopen(request, timeout=FETCH_TIMEOUT_S,
0165                                 context=context) as response:
0166         return response.read(FETCH_MAX_BYTES).decode('utf-8',
0167                                                      errors='replace')
0168 
0169 
0170 def candidates(since, limit=None):
0171     """Jobs whose batch record is worth having: failed and never-started
0172     jobs in the window that the harvester holds a batch log for.
0173 
0174     Returns ``[(pandaid, batchlog_url, endtime)]``. A job with no
0175     harvester row has no batch record to capture and is not a candidate.
0176     """
0177     sql = f"""
0178         SELECT j."pandaid", w."batchlog", j."endtime"
0179         FROM (
0180             SELECT "pandaid", "endtime", "modificationtime"
0181             FROM "{PANDA_SCHEMA}"."jobsactive4"
0182             WHERE "jobstatus" = 'failed' AND "modificationtime" >= %s
0183             UNION
0184             SELECT "pandaid", "endtime", "modificationtime"
0185             FROM "{PANDA_SCHEMA}"."jobsarchived4"
0186             WHERE "jobstatus" = 'failed' AND "modificationtime" >= %s
0187         ) j
0188         JOIN "{PANDA_SCHEMA}"."harvester_rel_jobs_workers" r
0189             ON r."pandaid" = j."pandaid"
0190         JOIN "{PANDA_SCHEMA}"."harvester_workers" w
0191             ON w."workerid" = r."workerid"
0192            AND w."harvesterid" = r."harvesterid"
0193         WHERE w."batchlog" IS NOT NULL AND w."batchlog" <> ''
0194         ORDER BY j."modificationtime" DESC
0195     """
0196     if limit:
0197         sql += f' LIMIT {int(limit)}'
0198     try:
0199         with connections['panda'].cursor() as cursor:
0200             cursor.execute(sql, [since, since])
0201             return cursor.fetchall()
0202     except Exception as e:                                    # noqa: BLE001
0203         logger.error('batch record candidate query failed: %s', e)
0204         return []
0205 
0206 
0207 def capture(since=None, limit=None, root=None, day=None):
0208     """Capture every uncaptured candidate's condor event log.
0209 
0210     Idempotent: a job that already has a body or a failure marker is
0211     skipped, so a re-run costs one stat per job. Returns counts of
0212     captured, failed and skipped.
0213     """
0214     root = root or store_root()
0215     day = day or date.today()
0216     since = since or (datetime.now(dt_timezone.utc) - timedelta(days=1))
0217     rows = candidates(since, limit=limit)
0218     directory = _day_dir(root, day)
0219     os.makedirs(directory, exist_ok=True)
0220 
0221     pending = [(pid, url) for pid, url, _ in rows
0222                if not stored(pid, root=root)]
0223     counts = {'candidates': len(rows), 'skipped': len(rows) - len(pending),
0224               'captured': 0, 'failed': 0}
0225 
0226     def _one(item):
0227         pandaid, url = item
0228         try:
0229             body = _fetch(url)
0230         except Exception as e:                                # noqa: BLE001
0231             # A failure is recorded, never left as an absence: an absent
0232             # file and an unreachable log are otherwise the same thing.
0233             reason = f'{e.__class__.__name__}: {e}\nsource: {url}\n'
0234             _write(os.path.join(directory, f'{pandaid}.error'), reason)
0235             return 'failed'
0236         capped = '' if len(body) < FETCH_MAX_BYTES else (
0237             f'\n[capture: body reached the {FETCH_MAX_BYTES} byte cap]\n')
0238         header = (f'# source: {url}\n'
0239                   f'# fetched: {datetime.now(dt_timezone.utc).isoformat()}\n'
0240                   f'# bytes: {len(body)}\n')
0241         _write(os.path.join(directory, f'{pandaid}.log'),
0242                header + body + capped)
0243         return 'captured'
0244 
0245     if pending:
0246         with ThreadPoolExecutor(max_workers=FETCH_WORKERS) as pool:
0247             for outcome in pool.map(_one, pending):
0248                 counts[outcome] += 1
0249     return counts
0250 
0251 
0252 def _write(path, text):
0253     """Write once, atomically, so a reader never sees a partial body."""
0254     tmp = f'{path}.part'
0255     with open(tmp, 'w', encoding='utf-8') as f:
0256         f.write(text)
0257     os.replace(tmp, path)
0258 
0259 
0260 def prune(days=RETENTION_DAYS, root=None, dry_run=False):
0261     """Remove date directories older than the retention window.
0262 
0263     The directory is the unit: anything the learning pass rescued has
0264     already moved under ``keep``, so this decides nothing about content.
0265     """
0266     root = root or store_root()
0267     cutoff = date.today() - timedelta(days=days)
0268     removed = []
0269     try:
0270         entries = os.listdir(root)
0271     except OSError as e:                                      # noqa: BLE001
0272         logger.error('batch record store unreadable at %s: %s', root, e)
0273         return removed
0274     for name in sorted(entries):
0275         if name == 'keep':
0276             continue
0277         try:
0278             when = date.fromisoformat(name)
0279         except ValueError:
0280             continue
0281         if when >= cutoff:
0282             continue
0283         path = os.path.join(root, name)
0284         if dry_run:
0285             removed.append(path)
0286             continue
0287         try:
0288             for entry in os.scandir(path):
0289                 os.unlink(entry.path)
0290             os.rmdir(path)
0291             removed.append(path)
0292         except OSError as e:                                  # noqa: BLE001
0293             logger.error('batch record prune failed for %s: %s', path, e)
0294     return removed
0295 
0296 
0297 # ── the learning pass ────────────────────────────────────────────────────
0298 # The corpus is the authority on what a condor event log contains. A pass
0299 # over it before it ages out yields the catalogue of codes that actually
0300 # occur, the taxonomy of reasons that recur, and the separation of
0301 # boilerplate from signal — and rescues one raw log per pattern, plus every
0302 # log matching no pattern, so the knowledge base always keeps the evidence
0303 # behind it (docs/ERROR_ATTRIBUTION.md, Retention and learning).
0304 
0305 KNOWLEDGE_FILE = 'knowledge.json'
0306 # A line present in most logs is the shape of the format, not the account of
0307 # a failure. Two thirds is high enough that a common failure mode does not
0308 # qualify and low enough to catch the ceremony.
0309 BOILERPLATE_SHARE = 0.66
0310 _NUM_RE = re.compile(r'\d+')
0311 _HEX_RE = re.compile(r'\b[0-9a-f]{8,}\b', re.I)
0312 _PATH_RE = re.compile(r'/[\w./@+-]{4,}')
0313 _HOST_RE = re.compile(r'\b[\w-]+(?:\.[\w-]+){2,}\b')
0314 # A condor address — the "sinful string" — carries the address, the CCB
0315 # ids, the alias and the session in one token, and every one of them is
0316 # the instance rather than the kind. Nothing inside it distinguishes two
0317 # failures, so the whole token goes.
0318 _SINFUL_RE = re.compile(r'<[^<>\s]{6,}>')
0319 # The slot a job landed in, and the glidein that made it: both name this
0320 # run only.
0321 _SLOT_RE = re.compile(r'\bslot\w*@[\w.@-]+', re.I)
0322 # A long opaque run of letters and digits — a session, a token, a
0323 # generated directory — with no word to it.
0324 _TOKEN_RE = re.compile(r'\b(?=[\w-]*\d)(?=[\w-]*[A-Za-z])[\w-]{12,}\b')
0325 
0326 
0327 def normalize(text):
0328     """The shape of a line, with what varies between jobs removed.
0329 
0330     Two failures are the same shape when their normalized text matches:
0331     hosts, paths, hexadecimal ids and numbers carry the instance, not the
0332     kind. Derived from the corpus rather than declared, so a pattern is
0333     whatever the logs repeat.
0334     """
0335     out = _SINFUL_RE.sub('<addr>', text or '')
0336     out = _SLOT_RE.sub('<slot>', out)
0337     out = _HOST_RE.sub('<host>', out)
0338     out = _PATH_RE.sub('<path>', out)
0339     out = _HEX_RE.sub('<hex>', out)
0340     out = _TOKEN_RE.sub('<token>', out)
0341     out = _NUM_RE.sub('#', out)
0342     return ' '.join(out.split())[:400]
0343 
0344 
0345 def _iter_logs(root):
0346     """(pandaid, day, path, body) for every captured body in the store."""
0347     for name in sorted(os.listdir(root)):
0348         if name == 'keep':
0349             continue
0350         day_dir = os.path.join(root, name)
0351         if not os.path.isdir(day_dir):
0352             continue
0353         for entry in sorted(os.listdir(day_dir)):
0354             if not entry.endswith('.log'):
0355                 continue
0356             path = os.path.join(day_dir, entry)
0357             try:
0358                 with open(path, errors='replace') as handle:
0359                     body = handle.read()
0360             except OSError as e:                              # noqa: BLE001
0361                 logger.error('batch record %s unreadable: %s', path, e)
0362                 continue
0363             yield entry[:-4], name, path, body
0364 
0365 
0366 def learn(root=None, rescue=True):
0367     """Mine the corpus and write the knowledge base. Returns its summary.
0368 
0369     Nothing here classifies a log at capture time; the capture stays
0370     verbatim and this is the only reader that generalizes over it, so a
0371     better pass re-runs against the same bodies.
0372     """
0373     from .panda.queries import CONDOR_EVENT_NAMES, parse_condor_log
0374     root = root or store_root()
0375     codes, patterns, line_docs = {}, {}, {}
0376     logs = 0
0377     for pandaid, day, path, body in _iter_logs(root):
0378         logs += 1
0379         seen_lines = set()
0380         for line in body.splitlines():
0381             code = line[:3]
0382             if code.isdigit() and line[3:4] == ' ' and '(' in line:
0383                 entry = codes.setdefault(code, {
0384                     'code': code,
0385                     'name': CONDOR_EVENT_NAMES.get(code, ''),
0386                     'named': code in CONDOR_EVENT_NAMES,
0387                     'count': 0, 'logs': 0, 'first_seen': day, 'last_seen': day,
0388                     'example': line.strip()[:300]})
0389                 entry['count'] += 1
0390                 entry['last_seen'] = max(entry['last_seen'], day)
0391                 entry['first_seen'] = min(entry['first_seen'], day)
0392                 if code not in seen_lines:
0393                     entry['logs'] += 1
0394                     seen_lines.add(code)
0395             shape = normalize(line.strip())
0396             if shape:
0397                 line_docs.setdefault(shape, set()).add(pandaid)
0398         events = parse_condor_log(body, source=path)
0399         for event in events.get('events') or []:
0400             shape = normalize(event['text'])
0401             if not shape:
0402                 continue
0403             pattern = patterns.setdefault(shape, {
0404                 'shape': shape, 'code': event['code'],
0405                 'event': event.get('event') or '',
0406                 'count': 0, 'jobs': [], 'first_seen': day, 'last_seen': day,
0407                 'example': event['text'][:600], 'exemplar': pandaid})
0408             pattern['count'] += 1
0409             pattern['last_seen'] = max(pattern['last_seen'], day)
0410             pattern['first_seen'] = min(pattern['first_seen'], day)
0411             if len(pattern['jobs']) < 20 and pandaid not in pattern['jobs']:
0412                 pattern['jobs'].append(pandaid)
0413 
0414     boilerplate = sorted(
0415         shape for shape, docs in line_docs.items()
0416         if logs and len(docs) / logs >= BOILERPLATE_SHARE)
0417     knowledge = {
0418         'built_at': datetime.now(dt_timezone.utc).isoformat(),
0419         'logs': logs,
0420         'codes': sorted(codes.values(), key=lambda c: -c['count']),
0421         'unnamed_codes': sorted(c['code'] for c in codes.values()
0422                                 if not c['named']),
0423         'patterns': sorted(patterns.values(), key=lambda p: -p['count']),
0424         'boilerplate': boilerplate,
0425     }
0426     if rescue:
0427         knowledge['rescued'] = _rescue(root, knowledge)
0428         knowledge['retired'] = _retire(root, knowledge)
0429     knowledge['permanent'] = _permanent_size(root)
0430     _write(os.path.join(root, KNOWLEDGE_FILE),
0431            json.dumps(knowledge, indent=2, sort_keys=True))
0432     return knowledge
0433 
0434 
0435 def knowledge(root=None):
0436     """The knowledge base as last built, or None."""
0437     path = os.path.join(root or store_root(), KNOWLEDGE_FILE)
0438     try:
0439         with open(path) as handle:
0440             return json.load(handle)
0441     except (OSError, ValueError):
0442         return None
0443 
0444 
0445 def _rescue(root, knowledge_base):
0446     """Keep one raw log per pattern, and every log matching no pattern.
0447 
0448     Retention deletes a date directory whole, so anything that must outlive
0449     it moves under ``keep`` first. One exemplar per pattern bounds the
0450     permanent set against a storm; an unmatched log is kept entire, because
0451     a novel shape is the thing the corpus cannot reconstruct later.
0452     """
0453     keep_root = os.path.join(root, 'keep')
0454     shapes = {p['shape'] for p in knowledge_base['patterns']}
0455     rescued = {'exemplars': 0, 'unmatched': 0}
0456 
0457     # One exemplar per shape, addressed by shape: a single log is often the
0458     # exemplar for several shapes, so keying this by job kept only the last
0459     # of them and left entries in the knowledge base with no evidence.
0460     bodies = {pandaid: body for pandaid, _day, _path, body in _iter_logs(root)}
0461     for pattern in knowledge_base['patterns']:
0462         body = bodies.get(pattern['exemplar'])
0463         if body is None:
0464             continue
0465         target_dir = os.path.join(keep_root, _shape_dir(pattern['shape']))
0466         target = os.path.join(target_dir, f"{pattern['exemplar']}.log")
0467         if os.path.exists(target):
0468             continue
0469         try:
0470             os.makedirs(target_dir, exist_ok=True)
0471             with open(target, 'w') as handle:
0472                 handle.write(body)
0473             rescued['exemplars'] += 1
0474         except OSError as e:                                  # noqa: BLE001
0475             logger.error('exemplar %s not kept: %s', pattern['exemplar'], e)
0476 
0477     from .panda.queries import parse_condor_log
0478     for pandaid, _day, path, body in _iter_logs(root):
0479         events = parse_condor_log(body, source=path)
0480         found = {normalize(e['text']) for e in (events.get('events') or [])}
0481         if found & shapes:
0482             continue
0483         if rescued['unmatched'] >= UNMATCHED_KEEP_PER_PASS:
0484             # A storm of a shape the parser cannot yet read is still a storm:
0485             # keep a bounded sample and count the rest, so the next pass sees
0486             # the shape without the disk paying for every instance of it.
0487             rescued['unmatched_over_cap'] = rescued.get('unmatched_over_cap', 0) + 1
0488             continue
0489         target_dir = os.path.join(keep_root, 'unmatched')
0490         target = os.path.join(target_dir, f'{pandaid}.log')
0491         if os.path.exists(target):
0492             continue
0493         try:
0494             os.makedirs(target_dir, exist_ok=True)
0495             with open(target, 'w') as handle:
0496                 handle.write(body)
0497             rescued['unmatched'] += 1
0498         except OSError as e:                                  # noqa: BLE001
0499             logger.error('unmatched log %s not kept: %s', pandaid, e)
0500     return rescued
0501 
0502 
0503 def _shape_dir(shape):
0504     """A stable directory name for a pattern, from its shape."""
0505     return hashlib.sha1(shape.encode('utf-8')).hexdigest()[:12]
0506 
0507 
0508 # What bounds the permanent set. One exemplar per shape already ties it to
0509 # the number of kinds of failure rather than the number of failures — over
0510 # the first corpus, 23 logs yielded 13 shapes, and the ratio falls as the
0511 # corpus grows. Three rules keep it that way when normalization is weak or
0512 # a novel storm arrives.
0513 UNMATCHED_KEEP_PER_PASS = 20
0514 
0515 
0516 def _retire(root, knowledge_base):
0517     """Drop kept logs whose shape the corpus no longer holds.
0518 
0519     An exemplar is evidence for an entry in the knowledge base. When the
0520     shape vocabulary improves, the shapes it replaced are no longer
0521     entries, and their exemplars are no longer evidence for anything.
0522     Retiring them is what stops a better parser from costing disk.
0523     """
0524     keep_root = os.path.join(root, 'keep')
0525     live = {_shape_dir(p['shape']) for p in knowledge_base['patterns']}
0526     live.add('unmatched')
0527     retired = {'buckets': 0, 'logs': 0}
0528     try:
0529         buckets = os.listdir(keep_root)
0530     except OSError:
0531         return retired
0532     for bucket in sorted(buckets):
0533         if bucket in live:
0534             continue
0535         path = os.path.join(keep_root, bucket)
0536         try:
0537             files = os.listdir(path)
0538             for name in files:
0539                 os.remove(os.path.join(path, name))
0540             os.rmdir(path)
0541             retired['buckets'] += 1
0542             retired['logs'] += len(files)
0543         except OSError as e:                                  # noqa: BLE001
0544             logger.error('kept bucket %s not retired: %s', path, e)
0545     return retired
0546 
0547 
0548 def _permanent_size(root):
0549     """What the permanent set costs: buckets, logs and bytes under keep."""
0550     keep_root = os.path.join(root, 'keep')
0551     buckets = logs = size = 0
0552     for dirpath, _dirnames, filenames in os.walk(keep_root):
0553         if dirpath != keep_root:
0554             buckets += 1
0555         for name in filenames:
0556             logs += 1
0557             try:
0558                 size += os.path.getsize(os.path.join(dirpath, name))
0559             except OSError:
0560                 pass
0561     return {'buckets': buckets, 'logs': logs, 'bytes': size}