File indexing completed on 2026-08-12 09:36:14
0001 """Terminalize RunState rows abandoned by their writer — one-time repair.
0002
0003 Two strata of stale state, both repaired here. Rows whose Run record
0004 carries an end_time — the pre-transition-processor era, when RunState
0005 kept its launch state forever — become ended/completed at the run's
0006 true end. Rows whose run was announced but never started or ended (no
0007 Run end_time, non-terminal state, stale beyond --hours) — the residue
0008 of crashed launchers — become abandoned/failed at their last
0009 transition. The runner now terminalizes on every exit path, and the
0010 stale-state System check names any future survivor; this command is
0011 repair, never a schedule. Dry run by default; --apply writes.
0012 """
0013
0014 from datetime import timedelta
0015
0016 from django.core.management.base import BaseCommand
0017 from django.utils import timezone
0018
0019 from monitor_app.models import Run, RunState
0020
0021
0022 TERMINAL_STATES = ('ended', 'expired', 'abandoned')
0023
0024
0025 class Command(BaseCommand):
0026 help = ("Mark RunState rows ended when their Run has an end_time. "
0027 "Dry run unless --apply is given.")
0028
0029 def add_arguments(self, parser):
0030 parser.add_argument(
0031 '--apply', action='store_true',
0032 help='Write the repairs (default is a dry-run listing).')
0033 parser.add_argument(
0034 '--hours', type=float, default=12,
0035 help='staleness threshold for never-ended runs')
0036
0037 def handle(self, *args, **options):
0038 ended_runs = dict(
0039 Run.objects.filter(end_time__isnull=False)
0040 .values_list('run_number', 'end_time'))
0041 cutoff = timezone.now() - timedelta(hours=options['hours'])
0042 candidates = []
0043 abandoned = []
0044 for row in RunState.objects.exclude(
0045 state__in=TERMINAL_STATES).order_by('run_number'):
0046 if row.run_number in ended_runs:
0047 candidates.append(row)
0048 elif row.state_changed_at < cutoff:
0049 abandoned.append(row)
0050 if not candidates and not abandoned:
0051 self.stdout.write('No stuck RunState rows found.')
0052 return
0053
0054 for row in candidates:
0055 self.stdout.write(
0056 f"run {row.run_number}: {row.state}/{row.substate or '-'} "
0057 f"({row.phase}) since {row.state_changed_at:%Y-%m-%d %H:%M} "
0058 f"— run ended {ended_runs[row.run_number]:%Y-%m-%d %H:%M}")
0059 for row in abandoned:
0060 self.stdout.write(
0061 f"run {row.run_number}: {row.state}/{row.substate or '-'} "
0062 f"({row.phase}) since {row.state_changed_at:%Y-%m-%d %H:%M} "
0063 f"— announced, never ended -> abandoned")
0064 self.stdout.write(
0065 f'{len(candidates)} ended-run row(s), '
0066 f'{len(abandoned)} never-ended row(s).')
0067
0068 if not options['apply']:
0069 self.stdout.write('Dry run — nothing written. '
0070 'Re-run with --apply to repair.')
0071 return
0072
0073 for row in candidates:
0074 row.state = 'ended'
0075 row.substate = None
0076 row.phase = 'completed'
0077 row.state_changed_at = ended_runs[row.run_number]
0078 row.save(update_fields=[
0079 'state', 'substate', 'phase', 'state_changed_at',
0080 'updated_at'])
0081 for row in abandoned:
0082 row.state = 'abandoned'
0083 row.substate = None
0084 row.phase = 'failed'
0085 row.save(update_fields=[
0086 'state', 'substate', 'phase', 'updated_at'])
0087 self.stdout.write(self.style.SUCCESS(
0088 f'Repaired {len(candidates) + len(abandoned)} '
0089 'RunState row(s).'))
0090
0091
0092
0093 from monitor_app.snapper_datataking import publish_datataking_state
0094 publish_datataking_state()
0095 self.stdout.write('Datataking component republished.')