File indexing completed on 2026-08-12 09:36:18
0001 """Publish testbed workflow activity to Snapper (Phase 6 component).
0002
0003 The testbed's workflow layer — executions launched per namespace, and
0004 the STF prompt-processing PanDA tasks they submit (processingtype
0005 stfprocessing) — becomes a contracted Snapper component, so the Time
0006 history shows workflow activity as curves alongside the datataking
0007 lanes: executions in flight, and STF task counts split by target site,
0008 which is the decision box's site assignment made visible.
0009 """
0010
0011 import json
0012 from dataclasses import dataclass
0013 from datetime import timedelta
0014
0015 from django.db import connections, transaction
0016 from django.utils import timezone
0017
0018 from snapper_ai.services import ComponentUpdate, publish_component, register_component
0019
0020 from .panda.constants import PANDA_SCHEMA
0021 from .workflow_models import WorkflowExecution
0022
0023
0024 PUBLISHER_IDENTITY = "swf-monitor:workflow-activity"
0025 ASSESSMENT_POLICY_VERSION = "swf-workflow-activity-v1"
0026 MAX_MAP_ITEMS = 32
0027 MAX_SERIALIZED_BYTES = 32 * 1024
0028 STF_PROCESSING_TYPE = "stfprocessing"
0029
0030
0031 TASK_TERMINAL_STATUSES = (
0032 "done", "finished", "failed", "aborted", "broken", "exhausted",
0033 )
0034
0035 WORKFLOW_REGISTRATION = {
0036 "title": "Testbed workflow activity",
0037 "description": (
0038 "Workflow executions and their STF prompt-processing PanDA tasks "
0039 "(processingtype stfprocessing), with task counts split by target "
0040 "site — the decision box's site assignment as recorded state."
0041 ),
0042 "visibility": "public",
0043 "owning_subsystem": "SWF testbed workflow layer",
0044 "assessment_policy": ASSESSMENT_POLICY_VERSION,
0045 "max_serialized_bytes": MAX_SERIALIZED_BYTES,
0046 "quantities": {
0047 "executions_active": {
0048 "path": "executions.active",
0049 "type": "integer",
0050 "required": True,
0051 "kind": "gauge",
0052 "description": "Workflow executions currently in running status.",
0053 },
0054 "executions_started_24h": {
0055 "path": "executions.started_24h",
0056 "type": "integer",
0057 "required": True,
0058 "kind": "window_count",
0059 "description": "Workflow executions started in the trailing 24 hours.",
0060 },
0061 "executions_by_workflow": {
0062 "path": "executions.by_workflow",
0063 "type": "object",
0064 "required": True,
0065 "kind": "bounded_map",
0066 "max_items": MAX_MAP_ITEMS,
0067 "description": (
0068 "Trailing-24-hour execution starts by workflow name."
0069 ),
0070 },
0071 "stf_tasks_in_flight": {
0072 "path": "stf_tasks.in_flight_total",
0073 "type": "integer",
0074 "required": True,
0075 "kind": "gauge",
0076 "description": "STF processing PanDA tasks in nonterminal states.",
0077 },
0078 "stf_tasks_by_site_status": {
0079 "path": "stf_tasks.by_site_status",
0080 "type": "object",
0081 "required": True,
0082 "kind": "bounded_map",
0083 "max_items": MAX_MAP_ITEMS,
0084 "description": (
0085 "In-flight STF processing task counts keyed site/status — "
0086 "the decision box's per-site assignment."
0087 ),
0088 },
0089 "stf_tasks_modified_24h": {
0090 "path": "stf_tasks.modified_24h",
0091 "type": "object",
0092 "required": True,
0093 "kind": "bounded_map",
0094 "max_items": MAX_MAP_ITEMS,
0095 "description": (
0096 "Trailing-24-hour STF processing task counts keyed "
0097 "site/status, terminal states included."
0098 ),
0099 },
0100 },
0101 }
0102
0103
0104 @dataclass(frozen=True)
0105 class WorkflowPublication:
0106 registration_update: ComponentUpdate
0107 update: ComponentUpdate
0108 projection: dict
0109
0110
0111 def _bounded_map(pairs, label) -> dict:
0112 ordered = sorted(pairs.items(), key=lambda item: (-item[1], item[0]))
0113 if len(ordered) > MAX_MAP_ITEMS:
0114 kept = ordered[: MAX_MAP_ITEMS - 1]
0115 other = sum(count for _, count in ordered[MAX_MAP_ITEMS - 1:])
0116 ordered = kept + [(f"other {label}", other)]
0117 return dict(ordered)
0118
0119
0120 def _execution_activity(now) -> dict:
0121 active = WorkflowExecution.objects.filter(status="running").count()
0122 day_ago = now - timedelta(hours=24)
0123 started = (
0124 WorkflowExecution.objects.filter(start_time__gte=day_ago)
0125 .values_list("workflow_definition__workflow_name", flat=True)
0126 )
0127 by_workflow: dict[str, int] = {}
0128 for name in started:
0129 key = str(name or "unknown")
0130 by_workflow[key] = by_workflow.get(key, 0) + 1
0131 return {
0132 "active": active,
0133 "started_24h": sum(by_workflow.values()),
0134 "by_workflow": _bounded_map(by_workflow, "workflows"),
0135 }
0136
0137
0138 def _stf_task_counts(where_sql: str, params) -> dict[str, int]:
0139 sql = f"""
0140 SELECT COALESCE("site", 'unknown'),
0141 COALESCE("status", 'unknown'),
0142 COUNT(*)
0143 FROM "{PANDA_SCHEMA}"."jedi_tasks"
0144 WHERE "processingtype" = %s AND ({where_sql})
0145 GROUP BY 1, 2
0146 """
0147 with connections["panda"].cursor() as cursor:
0148 cursor.execute(sql, [STF_PROCESSING_TYPE, *params])
0149 return {
0150 f"{site}/{status}": int(count or 0)
0151 for site, status, count in cursor.fetchall()
0152 }
0153
0154
0155 def _stf_task_activity(now) -> dict:
0156 placeholders = ", ".join(["%s"] * len(TASK_TERMINAL_STATUSES))
0157 in_flight = _stf_task_counts(
0158 f'"status" IS NULL OR "status" NOT IN ({placeholders})',
0159 list(TASK_TERMINAL_STATUSES))
0160 modified = _stf_task_counts(
0161 '"modificationtime" >= %s', [now - timedelta(hours=24)])
0162 return {
0163 "in_flight_total": sum(in_flight.values()),
0164 "by_site_status": _bounded_map(in_flight, "site/status"),
0165 "modified_24h": _bounded_map(modified, "site/status"),
0166 }
0167
0168
0169 def workflow_projection(now=None) -> dict:
0170 now = now or timezone.now()
0171 return {
0172 "executions": _execution_activity(now),
0173 "stf_tasks": _stf_task_activity(now),
0174 }
0175
0176
0177 def publish_workflow_activity() -> WorkflowPublication:
0178 """Query and atomically publish testbed workflow activity."""
0179 projection = workflow_projection()
0180 assessed_at = timezone.now()
0181 with transaction.atomic():
0182 registration_update = register_component(
0183 scope="testbed",
0184 name="workflow",
0185 publisher_identity=PUBLISHER_IDENTITY,
0186 registration=WORKFLOW_REGISTRATION,
0187 component_schema_version=1,
0188 )
0189 update = publish_component(
0190 scope="testbed",
0191 name="workflow",
0192 publisher_identity=PUBLISHER_IDENTITY,
0193 data=projection,
0194 assessed_at=assessed_at,
0195 assessment_policy_version=ASSESSMENT_POLICY_VERSION,
0196 )
0197 return WorkflowPublication(
0198 registration_update=registration_update,
0199 update=update,
0200 projection=projection,
0201 )
0202
0203
0204 def compact_workflow_publication_report(
0205 publication: WorkflowPublication,
0206 ) -> str:
0207 """Render a bounded operator-facing publication report."""
0208 executions = publication.projection["executions"]
0209 stf_tasks = publication.projection["stf_tasks"]
0210 return json.dumps({
0211 "revision": publication.update.revision,
0212 "content_changed": publication.update.content_changed,
0213 "executions_active": executions["active"],
0214 "executions_started_24h": executions["started_24h"],
0215 "stf_tasks_in_flight": stf_tasks["in_flight_total"],
0216 }, indent=2, sort_keys=True)