Skip to content

Commit c754272

Browse files
authored
Make IBKR heartbeat scheduler aware (#145)
1 parent bbd2fb5 commit c754272

3 files changed

Lines changed: 282 additions & 4 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ jobs:
4040
RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT || 'true' }}
4141
RUNTIME_HEARTBEAT_ACCEPT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_ACCEPT_STATUSES }}
4242
RUNTIME_HEARTBEAT_REJECT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_REJECT_STATUSES }}
43+
RUNTIME_HEARTBEAT_SCHEDULER_AWARE: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_AWARE || 'true' }}
44+
RUNTIME_HEARTBEAT_SCHEDULER_LOCATION: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_LOCATION || vars.CLOUD_RUN_REGION || 'us-central1' }}
4345
CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}
4446
CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }}
4547
CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }}

scripts/execution_report_heartbeat.py

Lines changed: 204 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import urllib.parse
1313
import urllib.request
1414
from typing import Any
15+
from zoneinfo import ZoneInfo
1516

1617

1718
DEFAULT_ACCEPT_STATUSES = {"ok", "skipped", "success", "completed", "no_action"}
@@ -90,10 +91,10 @@ def _base_report_uris() -> list[str]:
9091
return unique
9192

9293

93-
def _load_required_services() -> list[str]:
94+
def _load_required_service_candidates() -> tuple[list[str], bool]:
9495
explicit_services = _split_values(os.environ.get("RUNTIME_HEARTBEAT_REQUIRED_SERVICES"))
9596
if explicit_services:
96-
return _unique_values(explicit_services)
97+
return _unique_values(explicit_services), True
9798

9899
services = []
99100
for name in (
@@ -127,7 +128,36 @@ def _load_required_services() -> list[str]:
127128
except json.JSONDecodeError:
128129
pass
129130

130-
return _unique_values(services)
131+
return _unique_values(services), False
132+
133+
134+
def _load_required_services(
135+
*,
136+
project: str | None = None,
137+
since: dt.datetime | None = None,
138+
now: dt.datetime | None = None,
139+
) -> list[str]:
140+
services, explicit = _load_required_service_candidates()
141+
if explicit or not services:
142+
return services
143+
if not _env_bool("RUNTIME_HEARTBEAT_SCHEDULER_AWARE", True):
144+
return services
145+
if since is None or now is None:
146+
return services
147+
try:
148+
return _filter_scheduler_due_services(
149+
services,
150+
project=project,
151+
since=since,
152+
now=now,
153+
)
154+
except RuntimeError as exc:
155+
print(
156+
f"Unable to resolve Cloud Scheduler-backed heartbeat services: {exc}; "
157+
"falling back to all configured services.",
158+
file=sys.stderr,
159+
)
160+
return services
131161

132162

133163
def _unique_values(values: list[str]) -> list[str]:
@@ -140,6 +170,176 @@ def _unique_values(values: list[str]) -> list[str]:
140170
return unique
141171

142172

173+
def _scheduler_location() -> str:
174+
return (
175+
os.environ.get("RUNTIME_HEARTBEAT_SCHEDULER_LOCATION")
176+
or os.environ.get("CLOUD_RUN_REGION")
177+
or "us-central1"
178+
)
179+
180+
181+
def _list_scheduler_jobs(*, project: str | None) -> list[dict[str, Any]]:
182+
command = [
183+
"gcloud",
184+
"scheduler",
185+
"jobs",
186+
"list",
187+
"--location",
188+
_scheduler_location(),
189+
"--format=json",
190+
]
191+
if project:
192+
command.extend(["--project", project])
193+
result = _run_gcloud(command)
194+
if result.returncode != 0:
195+
detail = (result.stderr or result.stdout or "").strip()
196+
raise RuntimeError(detail or "gcloud scheduler jobs list failed")
197+
if not result.stdout.strip():
198+
return []
199+
try:
200+
payload = json.loads(result.stdout)
201+
except json.JSONDecodeError as exc:
202+
raise RuntimeError(f"gcloud scheduler jobs list returned invalid JSON: {exc}") from exc
203+
return payload if isinstance(payload, list) else []
204+
205+
206+
def _scheduler_job_targets_strategy_run(job: dict[str, Any], service: str) -> bool:
207+
if str(job.get("state") or "").strip().upper() not in {"", "ENABLED"}:
208+
return False
209+
uri = str((job.get("httpTarget") or {}).get("uri") or "").strip()
210+
if not uri:
211+
return False
212+
parsed = urllib.parse.urlparse(uri)
213+
path = parsed.path or "/"
214+
if path != "/":
215+
return False
216+
service_text = str(service or "").strip().lower()
217+
return bool(service_text and service_text in parsed.netloc.lower())
218+
219+
220+
def _cron_token_value(token: str, *, names: dict[str, int] | None = None) -> int:
221+
normalized = token.strip().lower()
222+
if names and normalized in names:
223+
return names[normalized]
224+
return int(normalized)
225+
226+
227+
def _cron_field_values(
228+
field: str,
229+
*,
230+
minimum: int,
231+
maximum: int,
232+
names: dict[str, int] | None = None,
233+
) -> set[int] | None:
234+
text = str(field or "").strip().lower()
235+
if text in {"", "*"}:
236+
return None
237+
values: set[int] = set()
238+
for raw_part in text.split(","):
239+
part = raw_part.strip()
240+
if not part:
241+
continue
242+
base, raw_step = part, "1"
243+
if "/" in part:
244+
base, raw_step = part.split("/", 1)
245+
step = max(1, int(raw_step))
246+
if base == "*":
247+
start, end = minimum, maximum
248+
elif "-" in base:
249+
raw_start, raw_end = base.split("-", 1)
250+
start = _cron_token_value(raw_start, names=names)
251+
end = _cron_token_value(raw_end, names=names)
252+
else:
253+
start = end = _cron_token_value(base, names=names)
254+
for value in range(start, end + 1, step):
255+
if minimum <= value <= maximum:
256+
values.add(value)
257+
elif maximum == 6 and value == 7:
258+
values.add(0)
259+
return values
260+
261+
262+
def _cron_matches(schedule: str, value: dt.datetime) -> bool:
263+
fields = str(schedule or "").split()
264+
if len(fields) != 5:
265+
return False
266+
minute, hour, day_of_month, month, day_of_week = fields
267+
dow_names = {
268+
"sun": 0,
269+
"mon": 1,
270+
"tue": 2,
271+
"wed": 3,
272+
"thu": 4,
273+
"fri": 5,
274+
"sat": 6,
275+
}
276+
minute_values = _cron_field_values(minute, minimum=0, maximum=59)
277+
hour_values = _cron_field_values(hour, minimum=0, maximum=23)
278+
dom_values = _cron_field_values(day_of_month, minimum=1, maximum=31)
279+
month_values = _cron_field_values(month, minimum=1, maximum=12)
280+
dow_values = _cron_field_values(day_of_week, minimum=0, maximum=6, names=dow_names)
281+
if minute_values is not None and value.minute not in minute_values:
282+
return False
283+
if hour_values is not None and value.hour not in hour_values:
284+
return False
285+
if month_values is not None and value.month not in month_values:
286+
return False
287+
288+
dom_matches = dom_values is None or value.day in dom_values
289+
cron_weekday = value.isoweekday() % 7
290+
dow_matches = dow_values is None or cron_weekday in dow_values
291+
if dom_values is not None and dow_values is not None:
292+
return dom_matches or dow_matches
293+
return dom_matches and dow_matches
294+
295+
296+
def _scheduler_job_due_between(
297+
job: dict[str, Any],
298+
*,
299+
since: dt.datetime,
300+
now: dt.datetime,
301+
) -> bool:
302+
schedule = str(job.get("schedule") or "").strip()
303+
if not schedule:
304+
return False
305+
try:
306+
timezone = ZoneInfo(str(job.get("timeZone") or "UTC"))
307+
except Exception: # noqa: BLE001
308+
timezone = dt.timezone.utc
309+
310+
since_utc = since.astimezone(dt.timezone.utc)
311+
now_utc = now.astimezone(dt.timezone.utc)
312+
cursor = since_utc.replace(second=0, microsecond=0)
313+
if cursor < since_utc:
314+
cursor += dt.timedelta(minutes=1)
315+
while cursor <= now_utc:
316+
if _cron_matches(schedule, cursor.astimezone(timezone)):
317+
return True
318+
cursor += dt.timedelta(minutes=1)
319+
return False
320+
321+
322+
def _filter_scheduler_due_services(
323+
services: list[str],
324+
*,
325+
project: str | None,
326+
since: dt.datetime,
327+
now: dt.datetime,
328+
) -> list[str]:
329+
jobs = _list_scheduler_jobs(project=project)
330+
due_services = []
331+
for service in services:
332+
service_jobs = [
333+
job for job in jobs if _scheduler_job_targets_strategy_run(job, service)
334+
]
335+
if not service_jobs or any(
336+
_scheduler_job_due_between(job, since=since, now=now)
337+
for job in service_jobs
338+
):
339+
due_services.append(service)
340+
return due_services
341+
342+
143343
def _report_globs(since: dt.datetime, now: dt.datetime) -> list[str]:
144344
explicit = _split_values(os.environ.get("RUNTIME_HEARTBEAT_GCS_GLOBS"))
145345
if explicit:
@@ -332,10 +532,10 @@ def main() -> int:
332532
lookback_hours = float(os.environ.get("RUNTIME_HEARTBEAT_LOOKBACK_HOURS") or "36")
333533
max_reports = int(os.environ.get("RUNTIME_HEARTBEAT_MAX_REPORTS_TO_READ") or "20")
334534
fail_workflow = _env_bool("RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT", True)
335-
required_services = _load_required_services()
336535

337536
now = dt.datetime.now(dt.timezone.utc)
338537
since = now - dt.timedelta(hours=lookback_hours)
538+
required_services = _load_required_services(project=project, since=since, now=now)
339539
globs = _report_globs(since, now)
340540
if not globs:
341541
raise SystemExit("No heartbeat GCS report URI configured")

tests/test_execution_report_heartbeat.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import datetime as dt
34
import json
45

56
from scripts import execution_report_heartbeat as heartbeat
@@ -38,3 +39,78 @@ def test_required_services_fall_back_to_cloud_run_targets(monkeypatch):
3839
)
3940

4041
assert heartbeat._load_required_services() == ["svc-a", "svc-b"]
42+
43+
44+
def test_scheduler_aware_required_services_only_include_due_main_schedulers(monkeypatch):
45+
monkeypatch.delenv("RUNTIME_HEARTBEAT_REQUIRED_SERVICES", raising=False)
46+
monkeypatch.setenv(
47+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
48+
json.dumps(
49+
{
50+
"targets": [
51+
{"service": "svc-daily"},
52+
{"service": "svc-monthly"},
53+
]
54+
}
55+
),
56+
)
57+
monkeypatch.setattr(
58+
heartbeat,
59+
"_list_scheduler_jobs",
60+
lambda **_kwargs: [
61+
{
62+
"state": "ENABLED",
63+
"schedule": "45 15 * * 1-5",
64+
"timeZone": "America/New_York",
65+
"httpTarget": {"uri": "https://svc-daily.example.run.app/"},
66+
},
67+
{
68+
"state": "ENABLED",
69+
"schedule": "45 15 26 * *",
70+
"timeZone": "America/New_York",
71+
"httpTarget": {"uri": "https://svc-monthly.example.run.app/"},
72+
},
73+
{
74+
"state": "ENABLED",
75+
"schedule": "35 9,15 25-30 * *",
76+
"timeZone": "America/New_York",
77+
"httpTarget": {"uri": "https://svc-monthly.example.run.app/probe"},
78+
},
79+
],
80+
)
81+
82+
required = heartbeat._load_required_services(
83+
project="project-1",
84+
since=dt.datetime(2026, 6, 5, 0, 0, tzinfo=dt.timezone.utc),
85+
now=dt.datetime(2026, 6, 6, 2, 0, tzinfo=dt.timezone.utc),
86+
)
87+
88+
assert required == ["svc-daily"]
89+
90+
91+
def test_scheduler_aware_required_services_include_monthly_service_when_due(monkeypatch):
92+
monkeypatch.delenv("RUNTIME_HEARTBEAT_REQUIRED_SERVICES", raising=False)
93+
monkeypatch.setenv(
94+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
95+
json.dumps({"targets": [{"service": "svc-monthly"}]}),
96+
)
97+
monkeypatch.setattr(
98+
heartbeat,
99+
"_list_scheduler_jobs",
100+
lambda **_kwargs: [
101+
{
102+
"state": "ENABLED",
103+
"schedule": "45 15 26 * *",
104+
"timeZone": "America/New_York",
105+
"httpTarget": {"uri": "https://svc-monthly.example.run.app/"},
106+
},
107+
],
108+
)
109+
110+
required = heartbeat._load_required_services(
111+
project="project-1",
112+
since=dt.datetime(2026, 6, 26, 19, 0, tzinfo=dt.timezone.utc),
113+
now=dt.datetime(2026, 6, 26, 20, 0, tzinfo=dt.timezone.utc),
114+
)
115+
116+
assert required == ["svc-monthly"]

0 commit comments

Comments
 (0)