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
0050
0051
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:
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
0106
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:
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
0159
0160
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:
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:
0231
0232
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:
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:
0293 logger.error('batch record prune failed for %s: %s', path, e)
0294 return removed
0295
0296
0297
0298
0299
0300
0301
0302
0303
0304
0305 KNOWLEDGE_FILE = 'knowledge.json'
0306
0307
0308
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
0315
0316
0317
0318 _SINFUL_RE = re.compile(r'<[^<>\s]{6,}>')
0319
0320
0321 _SLOT_RE = re.compile(r'\bslot\w*@[\w.@-]+', re.I)
0322
0323
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:
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
0458
0459
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:
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
0485
0486
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:
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
0509
0510
0511
0512
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:
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}