1616
1717import dataclasses
1818import datetime
19+ from dateutil import parser
1920import json
2021import logging
2122import os
2728
2829from airflow .decorators import task
2930from airflow .exceptions import AirflowFailException
30- from airflow .operators . python import get_current_context
31+ from airflow .sensors . base import PokeReturnValue
3132from google .cloud .monitoring_v3 import types
3233import kubernetes
3334
@@ -763,12 +764,144 @@ def get_pod_creation_timestamps(
763764 return timestamps
764765
765766
767+ @task
768+ def get_pod_metadata_map (
769+ node_pool : node_pool_info , namespace : str , jobset_name : str
770+ ) -> dict [str , dict [str , str ]]:
771+ """
772+ Returns a map: {
773+ pod_name: {
774+ "timestamp": "2026-01-27T...",
775+ "instance_id": "12345..."
776+ }
777+ }
778+ """
779+ with tempfile .NamedTemporaryFile () as temp_config_file :
780+ env = os .environ .copy ()
781+ env ["KUBECONFIG" ] = temp_config_file .name
782+
783+ # This command gets: PodName, Timestamp, and NodeName
784+ get_pod_info_cmd = (
785+ f"kubectl get pods -n { namespace } --kubeconfig={ temp_config_file .name } "
786+ f"-l jobset.sigs.k8s.io/jobset-name={ jobset_name } "
787+ '-o jsonpath=\' {range .items[*]}{.metadata.name}{"\\ t"}{.metadata.creationTimestamp}{"\\ t"}{.spec.nodeName}{"\\ n"}{end}\' '
788+ )
789+
790+ cmd = " && " .join ([
791+ Command .get_credentials_command (node_pool ),
792+ get_pod_info_cmd ,
793+ ])
794+
795+ stdout = subprocess .run_exec (cmd , env = env )
796+ if not stdout or not stdout .strip ():
797+ return {}
798+
799+ metadata_map = {}
800+ for line in stdout .strip ().split ("\n " ):
801+ parts = line .split ("\t " )
802+ if len (parts ) == 3 :
803+ pod_name , ts , node_name = parts
804+
805+ # Now get the Instance ID for this specific Node
806+ get_inst_id_cmd = (
807+ f"kubectl get node { node_name } --kubeconfig={ temp_config_file .name } "
808+ "-o jsonpath='{.metadata.annotations.container\\ .googleapis\\ .com/instance_id}'"
809+ )
810+
811+ # Execute sub-command to get the ID
812+ full_inst_cmd = " && " .join (
813+ [Command .get_credentials_command (node_pool ), get_inst_id_cmd ]
814+ )
815+ inst_id = subprocess .run_exec (full_inst_cmd , env = env ).strip ()
816+
817+ metadata_map [pod_name ] = {
818+ "timestamp" : ts .strip (),
819+ "instance_id" : inst_id ,
820+ }
821+
822+ logging .info (f"Successfully mapped metadata: { metadata_map } " )
823+ return metadata_map
824+
825+
766826@task .sensor (poke_interval = 60 , timeout = 3600 , mode = "poke" )
767827def wait_for_tpu_scheduled_chips (
828+ node_pool : node_pool_info ,
829+ pod_name : str ,
830+ metadata_map : dict ,
831+ job_apply_time : TimeUtil ,
832+ ):
833+ pod_data = metadata_map .get (pod_name , {})
834+ instance_id = pod_data .get ("instance_id" )
835+
836+ now = datetime .datetime .now ()
837+ time_series = query_time_series (
838+ project_id = node_pool .project_id ,
839+ filter_str = (
840+ 'metric.type="compute.googleapis.com/instance/tpu/scheduled_chips" '
841+ f'resource.labels.instance_id="{ instance_id } "'
842+ ),
843+ start_time = job_apply_time ,
844+ end_time = TimeUtil .from_datetime (now ),
845+ )
846+
847+ if len (time_series ) > 0 :
848+ # Get the timestamp from the metric
849+ t_scheduled = str (time_series [0 ].points [0 ].interval .start_time )
850+
851+ logging .info (f"Detected scheduled_chips for { pod_name } at { t_scheduled } " )
852+
853+ # Returning PokeReturnValue signals sensor completion AND pushes xcom_value
854+ return PokeReturnValue (is_done = True , xcom_value = t_scheduled )
855+
856+ return PokeReturnValue (is_done = False , xcom_value = None )
857+
858+
859+ # @task.sensor(poke_interval=60, timeout=600, mode="poke")
860+ # def wait_for_tpu_scheduled_chips(
861+ # node_pool: node_pool_info,
862+ # instance_id: str,
863+ # job_apply_time: TimeUtil,
864+ # pod_timestamps: dict,
865+ # ) -> bool:
866+ # """
867+ # Polls for scheduled_chips and calculates latency from Pod creation.
868+ # """
869+ # now = datetime.datetime.now()
870+ # time_series = query_time_series(
871+ # project_id=node_pool.project_id,
872+ # filter_str=(
873+ # 'metric.type="compute.googleapis.com/instance/tpu/scheduled_chips" '
874+ # f'resource.labels.instance_id="{instance_id}"'
875+ # ),
876+ # start_time=job_apply_time,
877+ # end_time=TimeUtil.from_datetime(now),
878+ # )
879+
880+ # if len(time_series) > 0:
881+ # scheduled_ts_str = str(time_series[0].points[0].interval.start_time)
882+ # t_scheduled = parser.isoparse(scheduled_ts_str)
883+ # pod_ts_str = min(pod_timestamps.values())
884+ # t_pod = parser.isoparse(pod_ts_str)
885+ # latency_sec = (t_scheduled - t_pod).total_seconds()
886+ # logging.info(f"--- Latency Found for Instance {instance_id} ---")
887+ # logging.info(f"Pod Created: {t_pod} | TPU Scheduled: {t_scheduled}")
888+ # logging.info(f"Latency (seconds): {latency_sec}")
889+ # if latency_sec > 600:
890+ # return False
891+ # return True
892+
893+ # return False
894+
895+
896+ @task .sensor (poke_interval = 60 , timeout = 600 , mode = "poke" )
897+ def wait_for_tpu_metrics (
768898 node_pool : node_pool_info ,
769899 instance_id : str ,
770900 job_apply_time : TimeUtil ,
771- ) -> bool :
901+ ) -> PokeReturnValue :
902+ """
903+ Polls for the 'scheduled_chips' metric and returns the event timestamp.
904+ """
772905 now = datetime .datetime .now ()
773906 time_series = query_time_series (
774907 project_id = node_pool .project_id ,
@@ -781,38 +914,36 @@ def wait_for_tpu_scheduled_chips(
781914 )
782915
783916 if len (time_series ) > 0 :
784- # Capture the timestamp of the first metric point
785- first_ts = str (time_series [0 ].points [0 ].interval .start_time )
786- # Push to XCom so the latency task can read it
787- ti = get_current_context ()["ti" ]
788- ti .xcom_push (key = "scheduled_ts" , value = first_ts )
789- return True
790- return False
917+ # Extract the timestamp of the first data point found
918+ scheduled_ts_str = str (time_series [0 ].points [0 ].interval .start_time )
919+ logging .info (f"Metric found: { scheduled_ts_str } " )
920+ return PokeReturnValue (is_done = True , xcom_value = scheduled_ts_str )
921+
922+ return PokeReturnValue (is_done = False )
791923
792924
793925@task
794- def calculate_latency (
795- instance_id : str , pod_timestamps : dict , scheduled_ts : str
796- ):
797- """Calculates time delta between Pod Creation and TPU Scheduling."""
798- from dateutil import parser
926+ def calculate_pod_latency (pod_name : str , metadata_map : dict , scheduled_ts : str ):
927+ """Calculates the delta between Pod Creation and Metric detection."""
928+
929+ if not scheduled_ts :
930+ return None
931+
932+ # Get Pod creation time from our map
933+ pod_ts_str = metadata_map .get (pod_name , {}).get ("timestamp" )
799934
935+ t_pod = parser .isoparse (pod_ts_str )
800936 t_scheduled = parser .isoparse (scheduled_ts )
801- # Match the first available pod timestamp from the JobSet
802- first_pod_ts = list (pod_timestamps .values ())[0 ]
803- t_pod = parser .isoparse (first_pod_ts )
804937
805- diff_seconds = (t_scheduled - t_pod ).total_seconds ()
806- logging .info (f"Instance { instance_id } Latency: { diff_seconds } s" )
938+ latency_sec = (t_scheduled - t_pod ).total_seconds ()
807939
808- return {
809- "instance_id" : instance_id ,
810- "latency_seconds" : diff_seconds ,
811- "latency_minutes" : round (diff_seconds / 60 , 2 ),
812- }
940+ logging .info (f"--- Latency Analysis: { pod_name } ---" )
941+ logging .info (f"Latency: { latency_sec } s ({ latency_sec / 60 :.2f} min)" )
813942
943+ return {"pod_name" : pod_name , "latency_sec" : latency_sec }
814944
815- @task .sensor (poke_interval = 60 , timeout = 3600 , mode = "poke" )
945+
946+ @task .sensor (poke_interval = 60 , timeout = 600 , mode = "poke" )
816947def wait_for_tpu_active_chips (
817948 node_pool : node_pool_info ,
818949 instance_id : str ,
@@ -851,26 +982,16 @@ def wait_for_tpu_active_chips(
851982 return len (time_series ) > 0
852983
853984
854- @task .sensor (poke_interval = 60 , timeout = 3600 , mode = "poke" )
985+ @task .sensor (poke_interval = 60 , timeout = 600 , mode = "poke" )
855986def wait_for_tpu_utilized_chips (
856987 node_pool : node_pool_info ,
857988 instance_id : str ,
858989 job_apply_time : TimeUtil ,
990+ expected_chip_count : int ,
859991) -> bool :
860992 """
861- Polls the TPU utilized_chips metric for a specific GCE instance.
862-
863- This metric represents the number of TPU chips currently being utilized by
864- a workload. It is the best indicator that the training software (JAX/PyTorch)
865- has successfully opened the TPU device and started computation.
866-
867- Args:
868- node_pool: Configuration object with project and cluster details.
869- instance_id: The specific GCE Instance ID to monitor.
870- job_apply_time: The time when the job was applied.
871-
872- Returns:
873- True if the utilized_chips metric is found, False otherwise.
993+ Polls the TPU utilized_chips metric and verifies the count matches
994+ the expected hardware capacity and is in a HEALTHY state.
874995 """
875996 now = datetime .datetime .now ()
876997
@@ -883,14 +1004,27 @@ def wait_for_tpu_utilized_chips(
8831004 start_time = job_apply_time ,
8841005 end_time = TimeUtil .from_datetime (now ),
8851006 )
886- logging .info (
887- "TPU Utilized Chips Time series for %s: %s" , instance_id , time_series
888- )
8891007
890- return len (time_series ) > 0
1008+ if len (time_series ) > 0 :
1009+ latest_value = time_series [0 ].points [0 ].value .double_value
8911010
1011+ logging .info (
1012+ "Instance %s: Current Utilized Chips = %s (Expected = %s)" ,
1013+ instance_id ,
1014+ latest_value ,
1015+ expected_chip_count ,
1016+ )
8921017
893- @task .sensor (poke_interval = 60 , timeout = 3600 , mode = "poke" )
1018+ if latest_value >= expected_chip_count :
1019+ logging .info ("Target chip utilization reached and chips are HEALTHY." )
1020+ return True
1021+ else :
1022+ logging .info ("Chips detected but not all are utilized yet. Retrying..." )
1023+
1024+ return False
1025+
1026+
1027+ @task .sensor (poke_interval = 60 , timeout = 600 , mode = "poke" )
8941028def wait_for_tpu_chip_state (
8951029 node_pool : node_pool_info ,
8961030 instance_id : str ,
0 commit comments