File indexing completed on 2026-08-12 09:36:18
0001 """Apply run lifecycle messages to RunState — the E0-E1 state write side.
0002
0003 The E0-E1 state machine (swf-testbed docs/e0-e1-state-machine.md) stamps
0004 every run lifecycle message with the state and substate in effect, and
0005 prescribes concurrent recording in the testbed database. The workflow
0006 runner creates the initial RunState row at launch; this module advances
0007 it from the messages as the monitor's ActiveMQ processor records them —
0008 one consumer, one truth. Without it every namespace lane on the Snapper
0009 Time history shows its launch-time state forever.
0010
0011 Vocabulary: state/substate copy the message stamps verbatim (beam/
0012 not_ready, run/physics, run/standby). The terminal marker follows the
0013 established fast-processing convention the read side already renders:
0014 state 'ended', phase 'completed'. The lane active check accepts both
0015 'run' (stamped) and 'running' (fast-processing legacy).
0016 """
0017
0018 import logging
0019
0020 from django.utils import timezone
0021
0022 logger = logging.getLogger(__name__)
0023
0024 RUN_LIFECYCLE_TYPES = (
0025 'run_imminent', 'start_run', 'pause_run', 'resume_run', 'end_run',
0026 )
0027
0028
0029 def apply_run_lifecycle_message(data) -> bool:
0030 """Advance RunState from one run lifecycle message; True if applied."""
0031 from .models import RunState
0032
0033 msg_type = data.get('msg_type')
0034 if msg_type not in RUN_LIFECYCLE_TYPES:
0035 return False
0036 try:
0037 run_number = int(str(data.get('run_id')))
0038 except (TypeError, ValueError):
0039 logger.error(
0040 "run lifecycle message %s carries no usable run_id: %r",
0041 msg_type, data.get('run_id'))
0042 return False
0043
0044 now = timezone.now()
0045
0046
0047 row, created = RunState.objects.get_or_create(
0048 run_number=run_number,
0049 defaults={
0050 'phase': 'initializing',
0051 'state': 'imminent',
0052 'substate': 'preparing',
0053 'target_worker_count': 0,
0054 'state_changed_at': now,
0055 'metadata': {},
0056 },
0057 )
0058
0059 previous = (row.phase, row.state, row.substate)
0060 if msg_type == 'end_run':
0061 row.state = 'ended'
0062 row.substate = None
0063 row.phase = 'completed'
0064 else:
0065 stamped_state = data.get('state')
0066 if stamped_state:
0067 row.state = str(stamped_state)
0068 stamped_substate = data.get('substate')
0069 if stamped_substate:
0070 row.substate = str(stamped_substate)
0071 if msg_type in ('start_run', 'pause_run', 'resume_run'):
0072 row.phase = 'physics'
0073 row.state_changed_at = now
0074
0075
0076
0077 metadata = row.metadata if isinstance(row.metadata, dict) else {}
0078 execution_id = data.get('execution_id')
0079 if execution_id and not metadata.get('execution_id'):
0080 metadata['execution_id'] = execution_id
0081 row.metadata = metadata
0082
0083 row.save(update_fields=[
0084 'phase', 'state', 'substate', 'state_changed_at', 'metadata',
0085 'updated_at',
0086 ])
0087 logger.info(
0088 "RunState %s: %s -> %s/%s (%s)%s",
0089 run_number, msg_type, row.state, row.substate or '-', row.phase,
0090 ' [row created]' if created else '')
0091
0092
0093
0094
0095 if created or (row.phase, row.state, row.substate) != previous:
0096 try:
0097 from .snapper_datataking import publish_datataking_state
0098 publish_datataking_state()
0099 except Exception:
0100 logger.exception(
0101 "datataking republish failed after RunState %s %s",
0102 run_number, msg_type)
0103 return True