Skip to content

Commit 1ba5145

Browse files
committed
Respect runtime target state in heartbeat
1 parent 3bb4505 commit 1ba5145

3 files changed

Lines changed: 95 additions & 9 deletions

File tree

.github/workflows/execution-report-heartbeat.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ jobs:
4242
RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT || 'true' }}
4343
RUNTIME_HEARTBEAT_ACCEPT_STAGES: ${{ vars.RUNTIME_HEARTBEAT_ACCEPT_STAGES }}
4444
RUNTIME_HEARTBEAT_REJECT_STAGES: ${{ vars.RUNTIME_HEARTBEAT_REJECT_STAGES }}
45+
RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }}
4546
RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}
4647
FIRSTRADE_GCS_STATE_BUCKET: ${{ vars.FIRSTRADE_GCS_STATE_BUCKET }}
4748
FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }}

scripts/execution_report_heartbeat.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ def _env_bool(name: str, default: bool = False) -> bool:
4343
return value in {"1", "true", "yes", "y", "on"}
4444

4545

46+
def _enabled_value(value: Any, *, default: bool = True) -> bool:
47+
if value is None:
48+
return default
49+
text = str(value).strip().lower()
50+
if not text:
51+
return default
52+
if text in {"0", "false", "no", "n", "off"}:
53+
return False
54+
return True
55+
56+
4657
def _parse_day_of_month_field(raw: str) -> set[int] | None:
4758
text = str(raw or "").strip()
4859
if not text or text in {"*", "?"}:
@@ -79,19 +90,31 @@ def _parse_day_of_month_field(raw: str) -> set[int] | None:
7990
return days or None
8091

8192

82-
def _runtime_target_scheduler() -> dict[str, Any]:
93+
def _runtime_target_payload() -> dict[str, Any]:
8394
raw = (os.environ.get("RUNTIME_TARGET_JSON") or "").strip()
8495
if not raw:
8596
return {}
8697
try:
8798
payload = json.loads(raw)
8899
except json.JSONDecodeError:
89100
return {}
101+
return payload if isinstance(payload, dict) else {}
102+
103+
104+
def _runtime_target_enabled() -> bool:
105+
value: Any = os.environ.get("RUNTIME_TARGET_ENABLED")
106+
if value is None:
107+
value = _runtime_target_payload().get("runtime_target_enabled")
108+
return _enabled_value(value, default=True)
109+
110+
111+
def _runtime_target_scheduler() -> dict[str, Any]:
112+
payload = _runtime_target_payload()
90113
scheduler = payload.get("scheduler") if isinstance(payload, dict) else None
91114
return scheduler if isinstance(scheduler, dict) else {}
92115

93116

94-
def _heartbeat_skip_reason_for_schedule(now: dt.datetime) -> str | None:
117+
def _heartbeat_skip_reason_for_schedule(since: dt.datetime, now: dt.datetime) -> str | None:
95118
scheduler = _runtime_target_scheduler()
96119
cron = str(scheduler.get("main_time") or "").strip()
97120
fields = cron.split()
@@ -106,13 +129,18 @@ def _heartbeat_skip_reason_for_schedule(now: dt.datetime) -> str | None:
106129
except ZoneInfoNotFoundError:
107130
timezone = dt.timezone.utc
108131
timezone_name = "UTC"
109-
local_now = now.astimezone(timezone)
110-
if local_now.day in expected_days:
111-
return None
132+
local_since_date = since.astimezone(timezone).date()
133+
local_now_date = now.astimezone(timezone).date()
134+
cursor = local_since_date
135+
while cursor <= local_now_date:
136+
if cursor.day in expected_days:
137+
return None
138+
cursor += dt.timedelta(days=1)
112139
day_text = ",".join(str(day) for day in sorted(expected_days))
113140
return (
114141
f"runtime scheduler main_time is not due today "
115-
f"({timezone_name} day={local_now.day}; expected day(s)={day_text})"
142+
f"({timezone_name} date_window={local_since_date.isoformat()}.."
143+
f"{local_now_date.isoformat()}; expected day(s)={day_text})"
116144
)
117145

118146

@@ -434,18 +462,24 @@ def main(now: dt.datetime | None = None) -> int:
434462
or os.environ.get("GOOGLE_CLOUD_PROJECT")
435463
)
436464
name = os.environ.get("RUNTIME_HEARTBEAT_NAME") or os.environ.get("GITHUB_REPOSITORY") or "runtime"
465+
if not _runtime_target_enabled():
466+
print(f"Execution report heartbeat skipped for {name}: runtime target is disabled")
467+
return 0
437468
lookback_hours = float(os.environ.get("RUNTIME_HEARTBEAT_LOOKBACK_HOURS") or "36")
438469
max_reports = int(os.environ.get("RUNTIME_HEARTBEAT_MAX_REPORTS_TO_READ") or "20")
439470
fail_workflow = _env_bool("RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT", True)
440471
required_services = _load_required_services()
441472

442473
now = now or dt.datetime.now(dt.timezone.utc)
443-
schedule_skip_reason = _heartbeat_skip_reason_for_schedule(now)
474+
if now.tzinfo is None:
475+
now = now.replace(tzinfo=dt.timezone.utc)
476+
now = now.astimezone(dt.timezone.utc)
477+
since = now - dt.timedelta(hours=lookback_hours)
478+
schedule_skip_reason = _heartbeat_skip_reason_for_schedule(since, now)
444479
if schedule_skip_reason:
445480
print(f"Execution report heartbeat skipped for {name}: {schedule_skip_reason}")
446481
return 0
447482

448-
since = now - dt.timedelta(hours=lookback_hours)
449483
globs = _report_globs(since, now)
450484
if not globs:
451485
raise SystemExit("No heartbeat GCS report URI configured")

tests/test_execution_report_heartbeat.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,41 @@ def fake_run_gcloud(command):
5353
]
5454

5555

56+
def test_heartbeat_skips_when_runtime_target_is_disabled(monkeypatch, capsys):
57+
monkeypatch.setenv("RUNTIME_HEARTBEAT_NAME", "Firstrade disabled runtime")
58+
monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false")
59+
monkeypatch.setattr(
60+
heartbeat,
61+
"_list_gcs_objects",
62+
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("GCS should not be queried")),
63+
)
64+
65+
result = heartbeat.main(now=dt.datetime(2026, 6, 20, 23, 10, tzinfo=dt.timezone.utc))
66+
67+
assert result == 0
68+
output = capsys.readouterr().out
69+
assert "Execution report heartbeat skipped for Firstrade disabled runtime" in output
70+
assert "runtime target is disabled" in output
71+
72+
73+
def test_heartbeat_skips_when_runtime_target_json_is_disabled(monkeypatch, capsys):
74+
monkeypatch.delenv("RUNTIME_TARGET_ENABLED", raising=False)
75+
monkeypatch.setenv("RUNTIME_HEARTBEAT_NAME", "Firstrade disabled runtime")
76+
monkeypatch.setenv("RUNTIME_TARGET_JSON", '{"runtime_target_enabled":false}')
77+
monkeypatch.setattr(
78+
heartbeat,
79+
"_list_gcs_objects",
80+
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("GCS should not be queried")),
81+
)
82+
83+
result = heartbeat.main(now=dt.datetime(2026, 6, 20, 23, 10, tzinfo=dt.timezone.utc))
84+
85+
assert result == 0
86+
output = capsys.readouterr().out
87+
assert "Execution report heartbeat skipped for Firstrade disabled runtime" in output
88+
assert "runtime target is disabled" in output
89+
90+
5691

5792
def test_heartbeat_skips_outside_runtime_target_scheduler_day(monkeypatch, capsys):
5893
monkeypatch.setenv("RUNTIME_HEARTBEAT_NAME", "Firstrade monthly runtime")
@@ -79,9 +114,25 @@ def test_heartbeat_does_not_skip_inside_runtime_target_scheduler_day(monkeypatch
79114
"RUNTIME_TARGET_JSON",
80115
'{"scheduler":{"timezone":"America/New_York","main_time":"45 15 25-28 * *"}}',
81116
)
117+
now = dt.datetime(2026, 6, 25, 23, 10, tzinfo=dt.timezone.utc)
118+
119+
reason = heartbeat._heartbeat_skip_reason_for_schedule(
120+
now - dt.timedelta(hours=36),
121+
now,
122+
)
123+
124+
assert reason is None
125+
126+
127+
def test_heartbeat_does_not_skip_when_lookback_includes_scheduler_day(monkeypatch):
128+
monkeypatch.setenv(
129+
"RUNTIME_TARGET_JSON",
130+
'{"scheduler":{"timezone":"America/New_York","main_time":"45 15 25-28 * *"}}',
131+
)
82132

83133
reason = heartbeat._heartbeat_skip_reason_for_schedule(
84-
dt.datetime(2026, 6, 25, 23, 10, tzinfo=dt.timezone.utc)
134+
dt.datetime(2026, 6, 28, 20, 0, tzinfo=dt.timezone.utc),
135+
dt.datetime(2026, 6, 29, 20, 0, tzinfo=dt.timezone.utc),
85136
)
86137

87138
assert reason is None

0 commit comments

Comments
 (0)