Skip to content

Commit 6e95d42

Browse files
authored
Harden scheduler switch guards
1 parent 6e69b65 commit 6e95d42

8 files changed

Lines changed: 154 additions & 9 deletions

.github/workflows/sync-cloud-run-env.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,8 @@ jobs:
880880
for target in plan["targets"]:
881881
service_name = str(target["service_name"]).strip()
882882
env = target.get("env") or {}
883-
timezone = str(env.get("IBKR_MARKET_TIMEZONE") or "").strip()
883+
scheduler = target.get("scheduler") or {}
884+
timezone = str(scheduler.get("timezone") or env.get("IBKR_MARKET_TIMEZONE") or "").strip()
884885
market = str(env.get("IBKR_MARKET") or "").strip().upper()
885886
if not timezone:
886887
timezone = "Asia/Hong_Kong" if market == "HK" else "America/New_York"
@@ -889,9 +890,9 @@ jobs:
889890
[
890891
service_name,
891892
timezone,
892-
configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15"),
893-
configured_time("CLOUD_SCHEDULER_PROBE_TIME", "35 9,15"),
894-
configured_time("CLOUD_SCHEDULER_PRECHECK_TIME", "45 9"),
893+
str(scheduler.get("main_time") or configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15")),
894+
str(scheduler.get("probe_time") or configured_time("CLOUD_SCHEDULER_PROBE_TIME", "35 9,15")),
895+
str(scheduler.get("precheck_time") or configured_time("CLOUD_SCHEDULER_PRECHECK_TIME", "45 9")),
895896
]
896897
)
897898
)

scripts/build_cloud_run_env_sync_plan.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ def _should_add_local_src(candidate: Path) -> bool:
100100
"IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD",
101101
"EXECUTION_REPORT_GCS_URI",
102102
)
103+
SCHEDULER_TIME_DEFAULTS = {
104+
"main_time": "45 15",
105+
"probe_time": "35 9,15",
106+
"precheck_time": "45 9",
107+
}
108+
SCHEDULER_TIME_ENV = {
109+
"main_time": "CLOUD_SCHEDULER_MAIN_TIME",
110+
"probe_time": "CLOUD_SCHEDULER_PROBE_TIME",
111+
"precheck_time": "CLOUD_SCHEDULER_PRECHECK_TIME",
112+
}
103113

104114

105115
def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]:
@@ -270,10 +280,46 @@ def _build_target_plan(
270280
"service_name": service_name,
271281
"strategy_profile": canonical_profile,
272282
"env": env_values,
283+
"scheduler": _build_scheduler_plan(
284+
target=target,
285+
defaults=defaults,
286+
env=env,
287+
env_values=env_values,
288+
per_service_mode=per_service_mode,
289+
),
273290
"remove_env_vars": sorted(set(remove_env_vars) - set(env_values)),
274291
}
275292

276293

294+
def _build_scheduler_plan(
295+
*,
296+
target: Mapping[str, object],
297+
defaults: Mapping[str, object],
298+
env: Mapping[str, str],
299+
env_values: Mapping[str, str],
300+
per_service_mode: bool,
301+
) -> dict[str, str]:
302+
market = str(env_values.get("IBKR_MARKET") or "").strip().upper()
303+
timezone = str(env_values.get("IBKR_MARKET_TIMEZONE") or "").strip()
304+
if not timezone:
305+
timezone = "Asia/Hong_Kong" if market == "HK" else "America/New_York"
306+
307+
scheduler = {"timezone": timezone}
308+
for key, env_name in SCHEDULER_TIME_ENV.items():
309+
scheduler[key] = (
310+
_target_env_value(
311+
target,
312+
defaults,
313+
env,
314+
env_name,
315+
per_service_mode=per_service_mode,
316+
allow_shared_fallback=True,
317+
)
318+
or SCHEDULER_TIME_DEFAULTS[key]
319+
)
320+
return scheduler
321+
322+
277323
def _validate_profile_inputs(
278324
*,
279325
service_name: str,

scripts/cloud_run_runtime_guard.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ def _load_services() -> list[str]:
8585
return unique
8686

8787

88+
def _scheduler_job_pattern_for_services(services: list[str]) -> str:
89+
candidates: list[str] = []
90+
for service in services:
91+
service_name = str(service or "").strip()
92+
if not service_name:
93+
continue
94+
candidates.append(service_name)
95+
if service_name.endswith("-service"):
96+
candidates.append(service_name.removesuffix("-service"))
97+
unique = list(dict.fromkeys(candidates))
98+
return "|".join(re.escape(candidate) for candidate in unique)
99+
100+
88101
def _run_gcloud_logging(project: str, log_filter: str, limit: int) -> list[dict[str, Any]]:
89102
command = [
90103
"gcloud",
@@ -214,7 +227,6 @@ def main() -> int:
214227
require_success = _env_bool("RUNTIME_GUARD_REQUIRE_SUCCESS", False)
215228
fail_workflow = _env_bool("RUNTIME_GUARD_FAIL_WORKFLOW_ON_ALERT", True)
216229
check_scheduler = _env_bool("RUNTIME_GUARD_CHECK_SCHEDULER", True)
217-
scheduler_pattern = os.environ.get("RUNTIME_GUARD_SCHEDULER_JOB_PATTERN") or ""
218230

219231
since = (
220232
dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=lookback_minutes)
@@ -230,6 +242,10 @@ def main() -> int:
230242
except RuntimeError as exc:
231243
services = []
232244
issues.append(f"service configuration error: {exc}")
245+
scheduler_pattern = (
246+
os.environ.get("RUNTIME_GUARD_SCHEDULER_JOB_PATTERN")
247+
or _scheduler_job_pattern_for_services(services)
248+
)
233249

234250
for service in services:
235251
log_filter = (

scripts/execution_report_heartbeat.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,12 +259,24 @@ def _describe_scheduler_jobs_for_services(
259259
) -> list[dict[str, Any]]:
260260
jobs = []
261261
for service in services:
262-
job = _describe_scheduler_job(f"{service}-scheduler", project=project)
263-
if job:
264-
jobs.append(job)
262+
for job_name in _scheduler_job_name_candidates(service):
263+
job = _describe_scheduler_job(job_name, project=project)
264+
if job:
265+
jobs.append(job)
266+
break
265267
return jobs
266268

267269

270+
def _scheduler_job_name_candidates(service: str) -> list[str]:
271+
service_name = str(service or "").strip()
272+
if not service_name:
273+
return []
274+
candidates = [f"{service_name}-scheduler"]
275+
if service_name.endswith("-service"):
276+
candidates.append(f"{service_name.removesuffix('-service')}-scheduler")
277+
return _unique_values(candidates)
278+
279+
268280
def _scheduler_job_targets_strategy_run(job: dict[str, Any], service: str) -> bool:
269281
if str(job.get("state") or "").strip().upper() not in {"", "ENABLED"}:
270282
return False
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
import re
4+
5+
from scripts import cloud_run_runtime_guard as guard
6+
7+
8+
def test_scheduler_job_pattern_includes_service_alias():
9+
pattern = guard._scheduler_job_pattern_for_services(
10+
["interactive-brokers-live-u1599-tqqq-service"]
11+
)
12+
13+
assert re.search(pattern, "interactive-brokers-live-u1599-tqqq-service-scheduler")
14+
assert re.search(pattern, "interactive-brokers-live-u1599-tqqq-scheduler")
15+
assert not re.search(pattern, "interactive-brokers-live-u1660-soxl-scheduler")

tests/test_execution_report_heartbeat.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,46 @@ def test_scheduler_aware_required_services_fall_back_to_named_scheduler_describe
150150
assert scheduler_checked is True
151151

152152

153+
def test_scheduler_aware_named_fallback_uses_service_alias(monkeypatch):
154+
monkeypatch.delenv("RUNTIME_HEARTBEAT_REQUIRED_SERVICES", raising=False)
155+
monkeypatch.setenv("CLOUD_RUN_SERVICE", "interactive-brokers-live-u1599-tqqq-service")
156+
monkeypatch.setattr(
157+
heartbeat,
158+
"_list_scheduler_jobs",
159+
lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("cloudscheduler.jobs.list denied")),
160+
)
161+
requested_job_names = []
162+
163+
def fake_describe_scheduler_job(job_name, **_kwargs):
164+
requested_job_names.append(job_name)
165+
if job_name != "interactive-brokers-live-u1599-tqqq-scheduler":
166+
return None
167+
return {
168+
"state": "ENABLED",
169+
"schedule": "45 15 26 * *",
170+
"timeZone": "America/New_York",
171+
"httpTarget": {
172+
"uri": "https://interactive-brokers-live-u1599-tqqq-service.example.run.app/"
173+
},
174+
}
175+
176+
monkeypatch.setattr(heartbeat, "_describe_scheduler_job", fake_describe_scheduler_job)
177+
178+
required, skip_reason, scheduler_checked = heartbeat._resolve_required_services(
179+
project="project-1",
180+
since=dt.datetime(2026, 6, 10, 0, 0, tzinfo=dt.timezone.utc),
181+
now=dt.datetime(2026, 6, 10, 2, 0, tzinfo=dt.timezone.utc),
182+
)
183+
184+
assert requested_job_names == [
185+
"interactive-brokers-live-u1599-tqqq-service-scheduler",
186+
"interactive-brokers-live-u1599-tqqq-scheduler",
187+
]
188+
assert required == []
189+
assert skip_reason and "no configured Cloud Scheduler main job was due" in skip_reason
190+
assert scheduler_checked is True
191+
192+
153193
def test_main_skips_when_no_scheduler_main_job_is_due(monkeypatch, capsys):
154194
monkeypatch.delenv("RUNTIME_HEARTBEAT_REQUIRED_SERVICES", raising=False)
155195
monkeypatch.setenv("GCP_PROJECT_ID", "interactivebrokersquant")

tests/test_runtime_config_support.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,12 +899,14 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets():
899899
"IBKR_MARKET_DATA_SYMBOL_SUFFIX": ".HK",
900900
"IBKR_MARKET_EXCHANGE": "SEHK",
901901
"IBKR_MARKET_TIMEZONE": "Asia/Hong_Kong",
902+
"cloud_scheduler_probe_time": "40 9,15",
902903
"EXECUTION_REPORT_GCS_URI": "gs://runtime/execution-reports",
903904
},
904905
"targets": [
905906
{
906907
"service": "interactive-brokers-live-slot-a-service",
907908
"account_group": "live-slot-a",
909+
"cloud_scheduler_main_time": "10 16",
908910
"runtime_target": json.loads(
909911
runtime_target_json(
910912
"tqqq_growth_income",
@@ -968,6 +970,12 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets():
968970
assert slot_a["env"]["IBKR_MARKET"] == "HK"
969971
assert slot_a["env"]["IBKR_MARKET_CURRENCY"] == "HKD"
970972
assert slot_a["env"]["IBKR_MARKET_EXCHANGE"] == "SEHK"
973+
assert slot_a["scheduler"] == {
974+
"timezone": "Asia/Hong_Kong",
975+
"main_time": "10 16",
976+
"probe_time": "40 9,15",
977+
"precheck_time": "45 9",
978+
}
971979
assert "IBKR_FEATURE_SNAPSHOT_PATH" not in slot_a["env"]
972980
assert "IBKR_FEATURE_SNAPSHOT_PATH" in slot_a["remove_env_vars"]
973981
assert "gs://stale-paper/snapshot.csv" not in json.dumps(slot_a)
@@ -977,6 +985,12 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets():
977985

978986
assert u7654_mega["env"]["ACCOUNT_GROUP"] == "live-u7654-mega"
979987
assert u7654_mega["env"]["STRATEGY_PROFILE"] == "mega_cap_leader_rotation_top50_balanced"
988+
assert u7654_mega["scheduler"] == {
989+
"timezone": "Asia/Hong_Kong",
990+
"main_time": "45 15",
991+
"probe_time": "40 9,15",
992+
"precheck_time": "45 9",
993+
}
980994
assert u7654_mega["env"]["IBKR_FEATURE_SNAPSHOT_PATH"] == "gs://runtime/mega/snapshot.csv"
981995
assert (
982996
u7654_mega["env"]["IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"]

tests/test_sync_cloud_run_env_workflow.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ grep -Fq -- '--remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")' "$workfl
8888
grep -Fq -- '--update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")' "$workflow_file"
8989
grep -Fq 'Sync Cloud Scheduler schedule' "$workflow_file"
9090
grep -Fq 'scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}"' "$workflow_file"
91-
grep -Fq 'timezone = str(env.get("IBKR_MARKET_TIMEZONE") or "").strip()' "$workflow_file"
91+
grep -Fq 'scheduler = target.get("scheduler") or {}' "$workflow_file"
92+
grep -Fq 'timezone = str(scheduler.get("timezone") or env.get("IBKR_MARKET_TIMEZONE") or "").strip()' "$workflow_file"
9293
grep -Fq 'timezone = "Asia/Hong_Kong" if market == "HK" else "America/New_York"' "$workflow_file"
9394
grep -Fq 'configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15")' "$workflow_file"
9495
grep -Fq 'configured_time("CLOUD_SCHEDULER_PROBE_TIME", "35 9,15")' "$workflow_file"

0 commit comments

Comments
 (0)