From 1aad6945c79b64334aabd53feb5c3189baedb376 Mon Sep 17 00:00:00 2001 From: Alfredo Di Stasio Date: Fri, 20 Mar 2026 14:49:48 +0100 Subject: [PATCH] fix(v2-scheduler): avoid restart loops when scheduler is disabled --- .env.example | 2 + README.md | 2 + docker-compose.yml | 4 ++ scripts/scheduler.sh | 12 +++++- tests/test_scheduler_operational_safety.py | 43 ++++++++++++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/test_scheduler_operational_safety.py diff --git a/.env.example b/.env.example index ecb33a8..4537838 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,8 @@ DAILY_ORCHESTRATION_INTERVAL_SECONDS=86400 # Future optional scheduler loop settings (not enabled in base v2 runtime) SCHEDULER_ENABLED=0 SCHEDULER_INTERVAL_SECONDS=900 +# When scheduler is disabled but container is started, keep it idle (avoid restart loops) +SCHEDULER_DISABLED_SLEEP_SECONDS=300 # API safeguards (read-only API is optional) API_THROTTLE_ANON=100/hour diff --git a/README.md b/README.md index 1544817..4e86025 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,7 @@ Notes: - image: `registry.younerd.org/hoopscout/scheduler:${APP_IMAGE_TAG:-latest}` - command: `/app/scripts/scheduler.sh` - interval: `DAILY_ORCHESTRATION_INTERVAL_SECONDS` + - disabled idle interval: `SCHEDULER_DISABLED_SLEEP_SECONDS` ### Scheduler entrypoint/runtime expectations @@ -290,6 +291,7 @@ Notes: - scheduler is disabled unless: - compose `scheduler` profile is started - `SCHEDULER_ENABLED=1` +- if scheduler service is started while disabled (`SCHEDULER_ENABLED=0`), it does not exit; it enters idle sleep mode to avoid restart loops with `restart: unless-stopped` - this keeps default runtime simple while supporting daily automation ### LBA extractor assumptions and limitations (MVP) diff --git a/docker-compose.yml b/docker-compose.yml index 8ef73bf..51d8106 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,10 @@ services: dockerfile: Dockerfile env_file: - .env + environment: + SCHEDULER_ENABLED: ${SCHEDULER_ENABLED:-0} + SCHEDULER_DISABLED_SLEEP_SECONDS: ${SCHEDULER_DISABLED_SLEEP_SECONDS:-300} + DAILY_ORCHESTRATION_INTERVAL_SECONDS: ${DAILY_ORCHESTRATION_INTERVAL_SECONDS:-86400} command: /app/scripts/scheduler.sh depends_on: postgres: diff --git a/scripts/scheduler.sh b/scripts/scheduler.sh index 326faea..2cc6152 100644 --- a/scripts/scheduler.sh +++ b/scripts/scheduler.sh @@ -2,8 +2,16 @@ set -e if [ "${SCHEDULER_ENABLED:-0}" != "1" ]; then - echo "Scheduler disabled (SCHEDULER_ENABLED=${SCHEDULER_ENABLED:-0}). Exiting." - exit 0 + DISABLED_SLEEP="${SCHEDULER_DISABLED_SLEEP_SECONDS:-300}" + if [ "${DISABLED_SLEEP}" -lt 30 ]; then + echo "SCHEDULER_DISABLED_SLEEP_SECONDS must be >= 30" + exit 1 + fi + echo "Scheduler disabled (SCHEDULER_ENABLED=${SCHEDULER_ENABLED:-0}). Entering idle mode with ${DISABLED_SLEEP}s sleep." + while true; do + echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Scheduler disabled; sleeping for ${DISABLED_SLEEP}s." + sleep "${DISABLED_SLEEP}" + done fi INTERVAL="${DAILY_ORCHESTRATION_INTERVAL_SECONDS:-${SCHEDULER_INTERVAL_SECONDS:-86400}}" diff --git a/tests/test_scheduler_operational_safety.py b/tests/test_scheduler_operational_safety.py new file mode 100644 index 0000000..7bca5d2 --- /dev/null +++ b/tests/test_scheduler_operational_safety.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +import subprocess +import time +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def test_scheduler_disabled_mode_stays_alive_without_exit_loop(): + env = os.environ.copy() + env["SCHEDULER_ENABLED"] = "0" + env["SCHEDULER_DISABLED_SLEEP_SECONDS"] = "30" + + process = subprocess.Popen( + ["sh", "scripts/scheduler.sh"], + cwd=_repo_root(), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + time.sleep(1.0) + assert process.poll() is None + finally: + process.terminate() + process.wait(timeout=5) + + +def test_scheduler_compose_service_is_profile_gated(): + compose_text = (_repo_root() / "docker-compose.yml").read_text(encoding="utf-8") + assert 'profiles: ["scheduler"]' in compose_text + assert "restart: unless-stopped" in compose_text + + +def test_scheduler_script_declares_idle_disabled_behavior(): + scheduler_script = (_repo_root() / "scripts/scheduler.sh").read_text(encoding="utf-8") + assert "Entering idle mode" in scheduler_script + assert "SCHEDULER_DISABLED_SLEEP_SECONDS" in scheduler_script