Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """The curated epicprod-live page (ACTION_STREAM.md, Curated view).
0002 
0003 The dashboard's epicprod-live title opens the live stream's last day as a
0004 reader wants it: one line per event in six groups, newest first in each,
0005 with who and why. Machinery and the step records behind an event another
0006 line reports are left out and counted, never dropped silently (Torre
0007 Wenaus, 2026-10-28, asking for considered curation instead of the raw
0008 list). An action no rule names is shown under Findings and decisions
0009 until it is given its place here.
0010 """
0011 import logging
0012 from datetime import timedelta
0013 
0014 from django.utils import timezone
0015 
0016 from monitor_app.live_notices import (FAILURE_OUTCOMES, MAINTENANCE, OPERATION_LABELS,
0017                                       ROUTINE_SUCCESSES, _node_guard_name, board_text,
0018                                       failure_key, node_guard_text, site_node_guard_fields)
0019 from monitor_app.short_names import short_name
0020 
0021 logger = logging.getLogger(__name__)
0022 
0023 WINDOW_H = 24
0024 MAX_WINDOW_H = 168
0025 GROUPS = (
0026     ('problems', 'Problems',
0027      'Warnings and errors, one line per subject: the newest, how often it recurred, '
0028      'and whether it has recovered.'),
0029     ('findings', 'Findings and decisions',
0030      'Findings, settings, limits and other decisions, with who and why.'),
0031     ('tasks', 'Task operations',
0032      "Submissions, pauses, resumes, finishes and moves, with who and why; a task's "
0033      'moves share one line.'),
0034     ('feeds', 'Feeds', "Work released to a site by the front or the site's own feeder."),
0035     ('node_guard', 'Node guard', 'Nodes newly excluded.'),
0036     ('assessments', 'Assessments', 'Assessments registered, with their verdicts.'),
0037 )
0038 PROBLEM_LEVELS = frozenset({'WARNING', 'ERROR', 'CRITICAL'})
0039 # Machinery: routine passes, bookkeeping and the EVGEN intake's own steps.
0040 MACHINERY = MAINTENANCE | ROUTINE_SUCCESSES | frozenset({
0041     'gke_pilot_cycle', 'live_watch', 'es_closeout', 'front_cycle', 'rucio_arrivals',
0042     'segfault_finding_set', 'segfault_diagnosis', 'segfault_diagnosis_triggered',
0043     'evgen_register', 'evgen_register_request', 'evgen_events',
0044     'dataset_expected_events_set', 'registered_sample_intake', 'task_evgen_input',
0045 })
0046 # Steps behind an event another line reports: the front's idle move and the
0047 # task guard's pause request are the Move and Pause lines' why.
0048 STEPS = frozenset({'idle_move', 'task_failure_guard', 'live_cleared'})
0049 # Informational states Production now already carries: OSG's fair-share
0050 # hold is its normal mode of operation (Torre Wenaus, 2026-10-28).
0051 INFORMATIONAL = frozenset({'osg_throttle'})
0052 TASK_ACTIONS = frozenset({'panda_task_operation', 'evgen_task_submit'})
0053 # The feeders, the outcome that releases work, and the feeder's name.
0054 FEEDERS = {'front_decision': ('fed', 'Front'),
0055            'google_production': ('submitted', 'Google feeder'),
0056            'perlmutter_production': ('submitted', 'Perlmutter feeder')}
0057 AUTOMATION = {'front_cron': 'front', 'node_guard_cron': 'node guard',
0058               'osgsub01-reporter': 'OSG submit reporter'}
0059 ASSESSMENT_KINDS = {'daily': 'Daily', 'weekly': 'Weekly', 'live_watch': 'Live watch'}
0060 
0061 
0062 def group_of(levelname, extra):
0063     """The group a live record is shown in, or None when it is left out."""
0064     action = str(extra.get('action') or '')
0065     outcome = str(extra.get('outcome') or '')
0066     if action in INFORMATIONAL:
0067         return None
0068     if action == 'assessment_register':
0069         return 'assessments'
0070     if action == 'production_finding':
0071         return 'findings'
0072     if action == 'node_guard_decision':
0073         # New exclusions only: a re-trip from half open is a node already
0074         # on the blacklist, a shadow suggestion excludes nothing.
0075         new = outcome == 'excluded' and extra.get('old_state') in (None, '', 'clear')
0076         return 'node_guard' if new else None
0077     if action in TASK_ACTIONS:
0078         return 'tasks'
0079     if action in FEEDERS and outcome == FEEDERS[action][0]:
0080         return 'feeds'
0081     if action in STEPS and outcome not in FAILURE_OUTCOMES:
0082         return None
0083     if levelname in PROBLEM_LEVELS:
0084         return 'problems'
0085     if action in MACHINERY or action in STEPS or action in FEEDERS:
0086         return None
0087     return 'findings'
0088 
0089 
0090 def curated_live(hours=WINDOW_H):
0091     """The page's record: the window, the groups with their lines, and the
0092     counts of live records read and left out."""
0093     from monitor_app.comp_snapshot import _pages
0094     from monitor_app.epicprod_logging import live_stream_q
0095     from monitor_app.models import AppLog
0096 
0097     hours = max(1, min(int(hours), MAX_WINDOW_H))
0098     since = timezone.now() - timedelta(hours=hours)
0099     rows = list(_pages(AppLog.objects.filter(live_stream_q(), timestamp__gte=since)
0100                        .order_by('-timestamp')
0101                        .values('id', 'timestamp', 'levelname', 'message',
0102                                'instance_name', 'extra_data')))
0103     sorted_rows = {key: [] for key, _, _ in GROUPS}
0104     left_out = 0
0105     for r in rows:
0106         r['extra_data'] = r['extra_data'] or {}
0107         group = group_of(r['levelname'], r['extra_data'])
0108         if group is None:
0109             left_out += 1
0110         else:
0111             sorted_rows[group].append(r)
0112     people = _people(rows)
0113     builders = {'problems': _problem_lines, 'findings': _plain_lines,
0114                 'tasks': _task_lines, 'feeds': _feed_lines,
0115                 'node_guard': _node_guard_lines, 'assessments': _assessment_lines}
0116     groups = []
0117     for key, title, description in GROUPS:
0118         try:
0119             lines = builders[key](sorted_rows[key], rows, people)
0120         except Exception:
0121             # One group's failure must not take the page down: its records
0122             # are shown plainly and the failure is logged.
0123             logger.exception('curated live page: group %s failed', key)
0124             lines = _plain_lines(sorted_rows[key], rows, people)
0125         groups.append({'key': key, 'title': title, 'description': description,
0126                        'records': len(sorted_rows[key]), 'lines': lines})
0127     return {'hours': hours, 'since': since, 'records': len(rows),
0128             'left_out': left_out, 'groups': groups}
0129 
0130 
0131 def _people(rows):
0132     """{username: full name} for the people named in the window."""
0133     from django.contrib.auth import get_user_model
0134     names = {str(r['extra_data'].get('username') or '') for r in rows} - {''}
0135     return {u.username: u.get_full_name() for u in
0136             get_user_model().objects.filter(username__in=names) if u.get_full_name()}
0137 
0138 
0139 def _who(username, people):
0140     u = str(username or '')
0141     if u in AUTOMATION:
0142         return AUTOMATION[u]
0143     if u == 'cron' or u.endswith('_cron'):
0144         return 'cron'
0145     return people.get(u) or u
0146 
0147 
0148 def _line(r, people, title=None, detail=None, level=None, who=None, note=''):
0149     t, d = board_text(r['message'], r['extra_data'])
0150     return {'id': r['id'], 'at': r['timestamp'], 'level': level or r['levelname'],
0151             'title': _plain(title or t), 'detail': _plain(detail or d), 'note': _plain(note),
0152             'who': who if who is not None else _who(r['extra_data'].get('username'), people)}
0153 
0154 
0155 def _plain_lines(group_rows, rows, people):
0156     return [_line(r, people) for r in group_rows]
0157 
0158 
0159 def _problem_lines(group_rows, rows, people):
0160     """One line per subject, the newest record, as the dashboard's panel
0161     keeps them (comp_snapshot._subject), noting the repeats and whether the
0162     failure has recovered, ended or been answered."""
0163     from monitor_app.comp_snapshot import _last_ok, _subject, _superseded
0164     from monitor_app.models import AppLog
0165     cleared = {}
0166     for c in (AppLog.objects.filter(app_name='epicprod', extra_data__action='live_cleared',
0167                                     timestamp__gte=group_rows[-1]['timestamp'])
0168               .values_list('extra_data', flat=True) if group_rows else ()):
0169         for i in (c or {}).get('cleared_ids') or ():
0170             cleared[int(i)] = str((c or {}).get('reason') or '')
0171     subjects = {}
0172     for r in group_rows:
0173         subjects.setdefault(_subject(r['instance_name'], r['extra_data']), []).append(r)
0174     lines = []
0175     for same in subjects.values():
0176         r, extra = same[0], same[0]['extra_data']
0177         notes = []
0178         if len(same) > 1:
0179             notes.append(f"{len(same)} times since {_at(same[-1]['timestamp'])}")
0180         level = None
0181         outcome = str(extra.get('outcome') or '')
0182         if r['id'] in cleared:
0183             notes.append(f"answered: {cleared[r['id']]}")
0184             level = 'CLEARED'
0185         elif outcome in FAILURE_OUTCOMES:
0186             ok = _last_ok(r['instance_name'], extra, failure_key(r['instance_name'], extra))
0187             if ok and ok > r['timestamp']:
0188                 notes.append(f'recovered {_at(ok)}')
0189                 level = 'CLEARED'
0190         elif _superseded(r['instance_name'], extra, r['timestamp']):
0191             notes.append('ended')
0192             level = 'CLEARED'
0193         lines.append(_line(r, people, level=level, note='; '.join(notes)))
0194     return lines
0195 
0196 
0197 def _operation_who_why(op, username, people, idle):
0198     """(who, why) for a task operation from its operation record
0199     (PandaTaskOperation): the front's task guard and its trip, the front's
0200     idle move (``idle``: its ``idle_move`` record by operation id), a person
0201     and their reason; the action record's ``username`` when it has none."""
0202     if op is None:
0203         return _who(username, people), ''
0204     evidence = op.evidence or {}
0205     source = str(op.source or '')
0206     if source.startswith('front_task_failure_guard'):
0207         from monitor_app.comp_snapshot import _guard_reason
0208         return 'front task guard', _guard_reason(evidence) or ''
0209     if source == 'front_idle_move':
0210         move = idle.get(str(op.id)) or {}
0211         why = f"{short_name(move.get('to') or evidence.get('site'))} idle"
0212         if move.get('rows') and move.get('from'):
0213             why += f"; {int(move['rows']):,} unstarted jobs waiting at {short_name(move['from'])}"
0214         return 'front', why
0215     return (_who(op.requested_by, people),
0216             str(evidence.get('reason') or evidence.get('note') or ''))
0217 
0218 
0219 def task_operation_who_why(extra, at):
0220     """(who, why) for the task operation one action record reports, read as
0221     the curated page reads it; the dashboard's epicprod-live panel uses it
0222     for a Move (COMP_SNAPSHOT.md, Live)."""
0223     from monitor_app.epicprod_logging import EPICPROD_APP_NAME
0224     from monitor_app.models import AppLog, PandaTaskOperation
0225     task = str(extra.get('subject_key') or '')
0226     op = (PandaTaskOperation.objects
0227           .filter(jedi_task_id=int(task), operation=str(extra.get('operation') or ''),
0228                   requested_at__lte=at)
0229           .order_by('-requested_at').first() if task.isdigit() else None)
0230     idle = {}
0231     if op is not None and str(op.source or '') == 'front_idle_move':
0232         move = (AppLog.objects.filter(app_name=EPICPROD_APP_NAME,
0233                                       extra_data__action='idle_move',
0234                                       extra_data__operation_id=str(op.id))
0235                 .values_list('extra_data', flat=True).first())
0236         if move:
0237             idle[str(op.id)] = move
0238     names = {str(extra.get('username') or ''), str(getattr(op, 'requested_by', '') or '')}
0239     people = _people([{'extra_data': {'username': n}} for n in names - {''}])
0240     return _operation_who_why(op, extra.get('username'), people, idle)
0241 
0242 
0243 def _task_lines(group_rows, rows, people):
0244     """A line per task operation with who and why from its operation record
0245     (PandaTaskOperation), the pending and final records of one operation
0246     shown once at their final outcome; a task's moves on one line."""
0247     from monitor_app.models import PandaTaskOperation
0248     task_ids = set()
0249     for r in group_rows:
0250         x = r['extra_data']
0251         task_ids.update(str(t) for t in x.get('jedi_task_ids') or ())
0252         if x.get('subject_type') == 'panda_task' and x.get('subject_key'):
0253             task_ids.add(str(x['subject_key']))
0254     ops = {}
0255     since = (group_rows[-1]['timestamp'] - timedelta(hours=1)) if group_rows else None
0256     for op in (PandaTaskOperation.objects.filter(
0257             jedi_task_id__in=[int(t) for t in task_ids if t.isdigit()],
0258             requested_at__gte=since).order_by('requested_at') if task_ids else ()):
0259         ops.setdefault((str(op.jedi_task_id), op.operation), []).append(op)
0260     idle = {str(r['extra_data'].get('operation_id')): r['extra_data']
0261             for r in rows if r['extra_data'].get('action') == 'idle_move'}
0262 
0263     def operation_of(task, operation, at):
0264         found = None
0265         for op in ops.get((task, operation), ()):
0266             if op.requested_at <= at:
0267                 found = op
0268         return found
0269 
0270     shown, moves, lines = set(), {}, []
0271     for r in group_rows:
0272         x = r['extra_data']
0273         if x.get('action') == 'evgen_task_submit':
0274             task = x.get('jedi_task_id')
0275             lines.append(_line(r, people, title=f"Submit · task {task}" if task else 'Submit',
0276                                detail=str(x.get('subject_key') or r['message'])))
0277             continue
0278         operation = str(x.get('operation') or '')
0279         tasks = [str(t) for t in x.get('jedi_task_ids') or ()] or [str(x.get('subject_key'))]
0280         op = operation_of(tasks[0], operation, r['timestamp'])
0281         key = op.id if op is not None else r['id']
0282         if key in shown:
0283             continue  # the pending record of an operation shown at its final outcome
0284         shown.add(key)
0285         who, why = _operation_who_why(op, x.get('username'), people, idle)
0286         if operation == 'reassign' and len(tasks) == 1:
0287             moves.setdefault(tasks[0], []).append((r, who))
0288             continue
0289         label = OPERATION_LABELS.get(operation, operation.capitalize() or 'Operation')
0290         title = (f"{label} · tasks {', '.join(tasks)}" if len(tasks) > 1
0291                  else f'{label} · task {tasks[0]}')
0292         name = x.get('task_name') or ''
0293         status = '' if x.get('outcome') == 'ok' else str(x.get('reason') or x.get('outcome') or '')
0294         detail = '; '.join(p for p in (why, status) if p)
0295         lines.append(_line(r, people, title=title, who=who,
0296                            detail=f'{name}: {detail}' if name and detail else (name or detail
0297                                                                                or r['message'])))
0298     for task, made in moves.items():
0299         r = made[0][0]
0300         steps = []
0301         for m, who in reversed(made):
0302             mx = m['extra_data']
0303             step = (f"{short_name(mx.get('previous_site')) or '?'} to "
0304                     f"{short_name(mx.get('site')) or '?'} {_at(m['timestamp'])}, {who}")
0305             if mx.get('outcome') != 'ok':
0306                 step += f" ({mx.get('reason') or mx.get('outcome')})"
0307             steps.append(step)
0308         worst = max((m['levelname'] for m, _ in made), key=_level_rank)
0309         title = f'Move ×{len(made)} · task {task}' if len(made) > 1 else f'Move · task {task}'
0310         name = r['extra_data'].get('task_name') or ''
0311         lines.append(_line(r, people, title=title, level=worst,
0312                            who=', '.join(dict.fromkeys(w for _, w in made)),
0313                            detail=(f'{name}: ' if name else '') + '; '.join(steps)))
0314     lines.sort(key=lambda line: line['at'], reverse=True)
0315     return lines
0316 
0317 
0318 def _feed_lines(group_rows, rows, people):
0319     lines = []
0320     for r in group_rows:
0321         x = r['extra_data']
0322         label = FEEDERS[x['action']][1]
0323         queue = x.get('queue') or x.get('subject_key')
0324         if x['action'] == 'front_decision':
0325             title = f'Front · {short_name(queue)}' if queue else label
0326             detail, who = str(x.get('candidate') or r['message']), 'front'
0327         else:
0328             events = x.get('events')
0329             title, who = label, label
0330             detail = (f"{x.get('task') or ''}: {x.get('jobs')} jobs"
0331                       + (f', {int(events):,} events' if events else ''))
0332         lines.append(_line(r, people, title=title, detail=detail, who=who))
0333     return lines
0334 
0335 
0336 def _node_guard_lines(group_rows, rows, people):
0337     ranks = {}
0338     if group_rows:
0339         from monitor_app.panda.node_guard import exclusion_ranks
0340         try:
0341             ranks = exclusion_ranks()
0342         except Exception:
0343             logger.exception('curated live page: node guard blacklist unreadable')
0344     lines = []
0345     for r in group_rows:
0346         fields = site_node_guard_fields(r['extra_data'], ranks)
0347         lines.append(_line(r, people, title=_node_guard_name(fields),
0348                            detail=node_guard_text(fields), who='node guard'))
0349     return lines
0350 
0351 
0352 def _assessment_lines(group_rows, rows, people):
0353     lines = []
0354     for r in group_rows:
0355         x = r['extra_data']
0356         kind = str(x.get('assessment_kind') or x.get('subject_type') or '')
0357         verdict = str(x.get('verdict') or '').replace('_', ' ')
0358         report = str(x.get('report_title') or x.get('subject_key') or '')
0359         if verdict and verdict not in report.replace('_', ' '):
0360             report += f': {verdict}'
0361         line = _line(r, people,
0362                      title=f"Assessment · {ASSESSMENT_KINDS.get(kind, kind.replace('_', ' '))}",
0363                      detail=report,
0364                      level={'warning': 'WARNING', 'error': 'ERROR', 'critical': 'ERROR'}
0365                      .get(str(x.get('severity') or ''), r['levelname']))
0366         line['path'] = x.get('report_path') or x.get('url')
0367         lines.append(line)
0368     return lines
0369 
0370 
0371 def _plain(text):
0372     """Page text without the long dash some records carry."""
0373     return str(text or '').replace(' — ', ', ').replace('—', ', ')
0374 
0375 
0376 def _level_rank(level):
0377     return {'CRITICAL': 3, 'ERROR': 2, 'WARNING': 1}.get(level, 0)
0378 
0379 
0380 def _at(ts):
0381     """'10-28 07:05', Eastern Time."""
0382     return timezone.localtime(ts).strftime('%m-%d %H:%M')