Skip to content

Commit db204a0

Browse files
committed
Respect runtime target scheduler in heartbeat
1 parent 398afed commit db204a0

3 files changed

Lines changed: 320 additions & 3 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ jobs:
4848
RUNTIME_HEARTBEAT_SCHEDULER_AWARE: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_AWARE || 'true' }}
4949
RUNTIME_HEARTBEAT_SCHEDULER_LOCATION: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_LOCATION || vars.CLOUD_RUN_REGION || 'us-central1' }}
5050
RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }}
51+
RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}
5152
CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}
5253
CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }}
5354
CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }}

scripts/execution_report_heartbeat.py

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import urllib.parse
1313
import urllib.request
1414
from typing import Any
15-
from zoneinfo import ZoneInfo
15+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
1616

1717

1818
DEFAULT_ACCEPT_STATUSES = {"ok", "skipped", "success", "completed", "no_action"}
@@ -104,6 +104,160 @@ def _target_service_values(target: dict[str, Any], runtime_target: dict[str, Any
104104
return []
105105

106106

107+
def _parse_schedule_day_of_month_field(raw: str) -> set[int] | None:
108+
text = str(raw or "").strip()
109+
if not text or text in {"*", "?"}:
110+
return None
111+
days: set[int] = set()
112+
for part in text.split(","):
113+
part = part.strip()
114+
if not part:
115+
continue
116+
step = 1
117+
if "/" in part:
118+
part, step_text = part.split("/", 1)
119+
try:
120+
step = max(1, int(step_text))
121+
except ValueError:
122+
return None
123+
if "-" in part:
124+
start_text, end_text = part.split("-", 1)
125+
try:
126+
start = int(start_text)
127+
end = int(end_text)
128+
except ValueError:
129+
return None
130+
if start > end:
131+
return None
132+
days.update(day for day in range(start, end + 1, step) if 1 <= day <= 31)
133+
continue
134+
try:
135+
day = int(part)
136+
except ValueError:
137+
return None
138+
if 1 <= day <= 31:
139+
days.add(day)
140+
return days or None
141+
142+
143+
def _scheduler_window_status(
144+
scheduler: dict[str, Any],
145+
*,
146+
since: dt.datetime,
147+
now: dt.datetime,
148+
) -> tuple[bool, str] | None:
149+
cron = str(scheduler.get("main_time") or "").strip()
150+
fields = cron.split()
151+
if len(fields) != 5:
152+
return None
153+
expected_days = _parse_schedule_day_of_month_field(fields[2])
154+
if not expected_days:
155+
return None
156+
timezone_name = str(scheduler.get("timezone") or "UTC").strip() or "UTC"
157+
try:
158+
timezone = ZoneInfo(timezone_name)
159+
except ZoneInfoNotFoundError:
160+
timezone = dt.timezone.utc
161+
timezone_name = "UTC"
162+
local_since_date = since.astimezone(timezone).date()
163+
local_now_date = now.astimezone(timezone).date()
164+
cursor = local_since_date
165+
due = False
166+
while cursor <= local_now_date:
167+
if cursor.day in expected_days:
168+
due = True
169+
break
170+
cursor += dt.timedelta(days=1)
171+
day_text = ",".join(str(day) for day in sorted(expected_days))
172+
reason = (
173+
f"{timezone_name} date_window={local_since_date.isoformat()}.."
174+
f"{local_now_date.isoformat()}; expected day(s)={day_text}"
175+
)
176+
return due, reason
177+
178+
179+
def _target_scheduler(target: dict[str, Any], runtime_target: dict[str, Any]) -> dict[str, Any]:
180+
for source in (runtime_target, target):
181+
scheduler = source.get("scheduler") if isinstance(source, dict) else None
182+
if isinstance(scheduler, dict):
183+
return scheduler
184+
return {}
185+
186+
187+
def _runtime_target_payload() -> dict[str, Any]:
188+
raw_runtime_target = (os.environ.get("RUNTIME_TARGET_JSON") or "").strip()
189+
if not raw_runtime_target:
190+
return {}
191+
try:
192+
runtime_target = json.loads(raw_runtime_target)
193+
except json.JSONDecodeError:
194+
return {}
195+
return runtime_target if isinstance(runtime_target, dict) else {}
196+
197+
198+
def _runtime_target_enabled() -> bool:
199+
value: Any = os.environ.get("RUNTIME_TARGET_ENABLED")
200+
if value is None:
201+
value = _runtime_target_payload().get("runtime_target_enabled")
202+
return _enabled_value(value, default=True)
203+
204+
205+
def _runtime_target_schedule_candidates() -> list[tuple[str, dict[str, Any]]]:
206+
candidates: list[tuple[str, dict[str, Any]]] = []
207+
raw_targets = (os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "").strip()
208+
if raw_targets:
209+
try:
210+
payload = json.loads(raw_targets)
211+
except json.JSONDecodeError:
212+
payload = {}
213+
targets = payload.get("targets") if isinstance(payload, dict) else payload
214+
if isinstance(targets, list):
215+
for target in targets:
216+
if not isinstance(target, dict):
217+
continue
218+
runtime_target = _target_runtime_target(target)
219+
if not _target_matches_expected_scope(target, runtime_target):
220+
continue
221+
if not _target_enabled(target, runtime_target):
222+
continue
223+
scheduler = _target_scheduler(target, runtime_target)
224+
if not scheduler:
225+
continue
226+
services = _target_service_values(target, runtime_target) or [
227+
str(runtime_target.get("service_name") or "runtime_target")
228+
]
229+
candidates.extend((service, scheduler) for service in services)
230+
if isinstance(targets, list):
231+
return candidates
232+
233+
runtime_target = _runtime_target_payload()
234+
if not runtime_target:
235+
return []
236+
scheduler = _target_scheduler(runtime_target, runtime_target)
237+
if not scheduler:
238+
return []
239+
service = str(runtime_target.get("service_name") or "runtime_target")
240+
return [(service, scheduler)]
241+
242+
243+
def _runtime_target_scheduler_skip_reason(since: dt.datetime, now: dt.datetime) -> str | None:
244+
candidates = _runtime_target_schedule_candidates()
245+
if not candidates:
246+
return None
247+
reasons: list[str] = []
248+
for service, scheduler in candidates:
249+
status = _scheduler_window_status(scheduler, since=since, now=now)
250+
if status is None:
251+
return None
252+
due, reason = status
253+
if due:
254+
return None
255+
reasons.append(f"{service}: {reason}")
256+
if not reasons:
257+
return None
258+
return "runtime target scheduler main_time is not due today (" + "; ".join(reasons) + ")"
259+
260+
107261
def _parse_timestamp(value: Any) -> dt.datetime | None:
108262
if not value:
109263
return None
@@ -737,7 +891,7 @@ def main(now: dt.datetime | None = None) -> int:
737891
or os.environ.get("GOOGLE_CLOUD_PROJECT")
738892
)
739893
name = os.environ.get("RUNTIME_HEARTBEAT_NAME") or os.environ.get("GITHUB_REPOSITORY") or "runtime"
740-
if not _env_bool("RUNTIME_TARGET_ENABLED", True):
894+
if not _runtime_target_enabled():
741895
print(f"Execution report heartbeat skipped for {name}: runtime target is disabled")
742896
return 0
743897
lookback_hours = float(os.environ.get("RUNTIME_HEARTBEAT_LOOKBACK_HOURS") or "36")
@@ -749,6 +903,11 @@ def main(now: dt.datetime | None = None) -> int:
749903
now = now.replace(tzinfo=dt.timezone.utc)
750904
now = now.astimezone(dt.timezone.utc)
751905
since = now - dt.timedelta(hours=lookback_hours)
906+
runtime_target_skip_reason = _runtime_target_scheduler_skip_reason(since, now)
907+
if runtime_target_skip_reason:
908+
print(f"Execution report heartbeat skipped for {name}: {runtime_target_skip_reason}")
909+
return 0
910+
752911
required_services, scheduler_skip_reason, _scheduler_checked = _resolve_required_services(
753912
project=project,
754913
since=since,

tests/test_execution_report_heartbeat.py

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,30 @@
33
import subprocess
44
import datetime as dt
55
import json
6+
import os
67

78
import pytest
89

910
from scripts import execution_report_heartbeat as heartbeat
1011

1112

13+
def _clear_runtime_env(monkeypatch):
14+
for name in list(os.environ):
15+
if name.startswith("RUNTIME_HEARTBEAT_") or name in {
16+
"CLOUD_RUN_SERVICE",
17+
"CLOUD_RUN_SERVICES",
18+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
19+
"EXECUTION_REPORT_GCS_URI",
20+
"FIRSTRADE_GCS_STATE_BUCKET",
21+
"FIRSTRADE_STATE_PREFIX",
22+
"GCP_PROJECT_ID",
23+
"GOOGLE_CLOUD_PROJECT",
24+
"RUNTIME_TARGET_ENABLED",
25+
"RUNTIME_TARGET_JSON",
26+
}:
27+
monkeypatch.delenv(name, raising=False)
28+
29+
1230
def test_explicit_required_services_override_target_derived_services(monkeypatch):
1331
monkeypatch.setenv("RUNTIME_HEARTBEAT_REQUIRED_SERVICES", "svc-daily-a,svc-daily-b")
1432
monkeypatch.setenv(
@@ -319,6 +337,7 @@ def test_main_skips_when_no_scheduler_main_job_is_due(monkeypatch, capsys):
319337

320338

321339
def test_main_skips_when_runtime_target_is_disabled(monkeypatch, capsys):
340+
_clear_runtime_env(monkeypatch)
322341
monkeypatch.setenv("RUNTIME_HEARTBEAT_NAME", "Disabled runtime")
323342
monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false")
324343
monkeypatch.setattr(
@@ -334,6 +353,145 @@ def test_main_skips_when_runtime_target_is_disabled(monkeypatch, capsys):
334353
assert "Execution report heartbeat skipped for Disabled runtime" in output
335354
assert "runtime target is disabled" in output
336355

356+
357+
def test_main_skips_when_runtime_target_json_is_disabled(monkeypatch, capsys):
358+
_clear_runtime_env(monkeypatch)
359+
monkeypatch.setenv("RUNTIME_HEARTBEAT_NAME", "Disabled runtime")
360+
monkeypatch.setenv(
361+
"RUNTIME_TARGET_JSON",
362+
json.dumps({"runtime_target_enabled": False}),
363+
)
364+
monkeypatch.setattr(
365+
heartbeat,
366+
"_list_gcs_objects",
367+
lambda *_args, **_kwargs: pytest.fail("GCS should not be queried for disabled targets"),
368+
)
369+
370+
result = heartbeat.main(now=dt.datetime(2026, 6, 20, 23, 10, tzinfo=dt.timezone.utc))
371+
372+
assert result == 0
373+
output = capsys.readouterr().out
374+
assert "Execution report heartbeat skipped for Disabled runtime" in output
375+
assert "runtime target is disabled" in output
376+
377+
378+
def test_main_skips_outside_runtime_target_scheduler_day_for_scoped_target(
379+
monkeypatch,
380+
capsys,
381+
):
382+
_clear_runtime_env(monkeypatch)
383+
monkeypatch.setenv("RUNTIME_HEARTBEAT_NAME", "IBKR monthly runtime")
384+
monkeypatch.setenv("RUNTIME_HEARTBEAT_ACCOUNT_SCOPE", "live-monthly")
385+
monkeypatch.setenv(
386+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
387+
json.dumps(
388+
{
389+
"targets": [
390+
{
391+
"service": "interactive-brokers-quant-live-daily-service",
392+
"account_scope": "live-daily",
393+
"runtime_target": {
394+
"scheduler": {
395+
"timezone": "America/New_York",
396+
"main_time": "45 15 * * *",
397+
}
398+
},
399+
},
400+
{
401+
"service": "interactive-brokers-quant-live-monthly-service",
402+
"account_scope": "live-monthly",
403+
"runtime_target": {
404+
"scheduler": {
405+
"timezone": "America/New_York",
406+
"main_time": "45 15 1-7 * *",
407+
}
408+
},
409+
},
410+
]
411+
}
412+
),
413+
)
414+
monkeypatch.setattr(
415+
heartbeat,
416+
"_list_gcs_objects",
417+
lambda *_args, **_kwargs: pytest.fail("GCS should not be queried outside scheduler window"),
418+
)
419+
420+
result = heartbeat.main(now=dt.datetime(2026, 6, 20, 23, 10, tzinfo=dt.timezone.utc))
421+
422+
assert result == 0
423+
output = capsys.readouterr().out
424+
assert "Execution report heartbeat skipped for IBKR monthly runtime" in output
425+
assert "interactive-brokers-quant-live-monthly-service" in output
426+
assert "expected day(s)=1,2,3,4,5,6,7" in output
427+
428+
429+
def test_runtime_target_scheduler_does_not_skip_when_any_active_target_runs_daily(
430+
monkeypatch,
431+
):
432+
_clear_runtime_env(monkeypatch)
433+
monkeypatch.setenv(
434+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
435+
json.dumps(
436+
{
437+
"targets": [
438+
{
439+
"service": "interactive-brokers-quant-live-daily-service",
440+
"runtime_target": {
441+
"scheduler": {
442+
"timezone": "America/New_York",
443+
"main_time": "45 15 * * *",
444+
}
445+
},
446+
},
447+
{
448+
"service": "interactive-brokers-quant-live-monthly-service",
449+
"runtime_target": {
450+
"scheduler": {
451+
"timezone": "America/New_York",
452+
"main_time": "45 15 1-7 * *",
453+
}
454+
},
455+
},
456+
]
457+
}
458+
),
459+
)
460+
461+
now = dt.datetime(2026, 6, 20, 23, 10, tzinfo=dt.timezone.utc)
462+
reason = heartbeat._runtime_target_scheduler_skip_reason(
463+
now - dt.timedelta(hours=36),
464+
now,
465+
)
466+
467+
assert reason is None
468+
469+
470+
def test_runtime_target_scheduler_does_not_skip_when_lookback_includes_scheduler_day(
471+
monkeypatch,
472+
):
473+
_clear_runtime_env(monkeypatch)
474+
monkeypatch.setenv(
475+
"RUNTIME_TARGET_JSON",
476+
json.dumps(
477+
{
478+
"service_name": "interactive-brokers-quant-live-monthly-service",
479+
"scheduler": {
480+
"timezone": "America/New_York",
481+
"main_time": "45 15 1-7 * *",
482+
},
483+
}
484+
),
485+
)
486+
487+
reason = heartbeat._runtime_target_scheduler_skip_reason(
488+
dt.datetime(2026, 6, 7, 20, 0, tzinfo=dt.timezone.utc),
489+
dt.datetime(2026, 6, 8, 20, 0, tzinfo=dt.timezone.utc),
490+
)
491+
492+
assert reason is None
493+
494+
337495
def test_telegram_token_falls_back_to_secret_manager(monkeypatch):
338496
monkeypatch.delenv("TELEGRAM_TOKEN", raising=False)
339497
monkeypatch.delenv("TG_TOKEN", raising=False)
@@ -359,4 +517,3 @@ def fake_run_gcloud(command):
359517
"--project",
360518
"interactivebrokersquant",
361519
]
362-

0 commit comments

Comments
 (0)