1212import urllib .parse
1313import urllib .request
1414from typing import Any
15+ from zoneinfo import ZoneInfo , ZoneInfoNotFoundError
1516
1617
1718DEFAULT_ACCEPT_STATUSES = {"ok" , "skipped" , "success" , "completed" , "no_action" }
@@ -42,6 +43,79 @@ def _env_bool(name: str, default: bool = False) -> bool:
4243 return value in {"1" , "true" , "yes" , "y" , "on" }
4344
4445
46+ def _parse_day_of_month_field (raw : str ) -> set [int ] | None :
47+ text = str (raw or "" ).strip ()
48+ if not text or text in {"*" , "?" }:
49+ return None
50+ days : set [int ] = set ()
51+ for part in text .split ("," ):
52+ part = part .strip ()
53+ if not part :
54+ continue
55+ step = 1
56+ if "/" in part :
57+ part , step_text = part .split ("/" , 1 )
58+ try :
59+ step = max (1 , int (step_text ))
60+ except ValueError :
61+ return None
62+ if "-" in part :
63+ start_text , end_text = part .split ("-" , 1 )
64+ try :
65+ start = int (start_text )
66+ end = int (end_text )
67+ except ValueError :
68+ return None
69+ if start > end :
70+ return None
71+ days .update (day for day in range (start , end + 1 , step ) if 1 <= day <= 31 )
72+ continue
73+ try :
74+ day = int (part )
75+ except ValueError :
76+ return None
77+ if 1 <= day <= 31 :
78+ days .add (day )
79+ return days or None
80+
81+
82+ def _runtime_target_scheduler () -> dict [str , Any ]:
83+ raw = (os .environ .get ("RUNTIME_TARGET_JSON" ) or "" ).strip ()
84+ if not raw :
85+ return {}
86+ try :
87+ payload = json .loads (raw )
88+ except json .JSONDecodeError :
89+ return {}
90+ scheduler = payload .get ("scheduler" ) if isinstance (payload , dict ) else None
91+ return scheduler if isinstance (scheduler , dict ) else {}
92+
93+
94+ def _heartbeat_skip_reason_for_schedule (now : dt .datetime ) -> str | None :
95+ scheduler = _runtime_target_scheduler ()
96+ cron = str (scheduler .get ("main_time" ) or "" ).strip ()
97+ fields = cron .split ()
98+ if len (fields ) != 5 :
99+ return None
100+ expected_days = _parse_day_of_month_field (fields [2 ])
101+ if not expected_days :
102+ return None
103+ timezone_name = str (scheduler .get ("timezone" ) or "UTC" ).strip () or "UTC"
104+ try :
105+ timezone = ZoneInfo (timezone_name )
106+ except ZoneInfoNotFoundError :
107+ timezone = dt .timezone .utc
108+ timezone_name = "UTC"
109+ local_now = now .astimezone (timezone )
110+ if local_now .day in expected_days :
111+ return None
112+ day_text = "," .join (str (day ) for day in sorted (expected_days ))
113+ return (
114+ f"runtime scheduler main_time is not due today "
115+ f"({ timezone_name } day={ local_now .day } ; expected day(s)={ day_text } )"
116+ )
117+
118+
45119def _parse_timestamp (value : Any ) -> dt .datetime | None :
46120 if not value :
47121 return None
@@ -353,7 +427,7 @@ def _send_telegram(message: str) -> bool:
353427 return ok
354428
355429
356- def main () -> int :
430+ def main (now : dt . datetime | None = None ) -> int :
357431 project = (
358432 os .environ .get ("RUNTIME_HEARTBEAT_GCP_PROJECT_ID" )
359433 or os .environ .get ("GCP_PROJECT_ID" )
@@ -365,7 +439,12 @@ def main() -> int:
365439 fail_workflow = _env_bool ("RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT" , True )
366440 required_services = _load_required_services ()
367441
368- now = dt .datetime .now (dt .timezone .utc )
442+ now = now or dt .datetime .now (dt .timezone .utc )
443+ schedule_skip_reason = _heartbeat_skip_reason_for_schedule (now )
444+ if schedule_skip_reason :
445+ print (f"Execution report heartbeat skipped for { name } : { schedule_skip_reason } " )
446+ return 0
447+
369448 since = now - dt .timedelta (hours = lookback_hours )
370449 globs = _report_globs (since , now )
371450 if not globs :
0 commit comments