Translations: - FR-045: new-key-only execution mode — translate only rows with unseen keys - Compare source row keys against TranslationRecord.source_data from last succeeded run - Baseline expired (>90 days) fallback to full mode with baseline_expired event - run_noop early return when zero new keys (skip LLM + SQL) Scheduler reliability: - Stale PENDING protection: runs older than 1h auto-marked FAILED, no longer block schedule - load_schedules() reloads active translation schedules from DB on restart - add_translation_job/remove_translation_job register/unregister with APScheduler - execution_mode column on translation_schedules with additive DB migration Automation view: - GET /settings/automation/translation-schedules endpoint - Translation schedules displayed on Automation page below validation policies Trace propagation: - seed_trace_id() in all background entry points: TaskManager._run_task/_flusher_loop, SchedulerService._trigger_backup, websocket_endpoint, IdMappingService, all standalone scripts Migrations: - dictionary_entries: origin_run_id, origin_row_key, origin_user_id - translation_schedules: execution_mode Tests: - 3 new test modules (22 tests): core_scheduler, executor_filter, scheduler_execution+guard - Fix pre-existing translate test isolation (conftest.py) - Fix test_list_runs_filter_status parameter
178 lines
6.3 KiB
Python
178 lines
6.3 KiB
Python
# #region TestCoreScheduler [C:3] [TYPE Module] [SEMANTICS test, scheduler, apscheduler, load, reload]
|
|
# @BRIEF Verify SchedulerService load_schedules and translation job add/remove contracts.
|
|
# @RELATION BINDS_TO -> [SchedulerService]
|
|
# @TEST_EDGE: load_schedules_reloads — jobs survive remove_all_jobs when re-registered from DB
|
|
# @TEST_EDGE: add_remove_translation_job — job correctly registered and removed from APScheduler
|
|
# @TEST_INVARIANT: translation_jobs_persist_across_reload -> VERIFIED_BY: [test_load_schedules_reloads_translation_schedules]
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
from datetime import datetime, timezone
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers — fresh scheduler & mock config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_mock_config_manager(backup_env_id="env1", backup_cron="0 3 * * *"):
|
|
"""Build a mock ConfigManager returning a single backup-enabled environment."""
|
|
mgr = MagicMock()
|
|
mock_env = MagicMock()
|
|
mock_env.id = backup_env_id
|
|
mock_env.backup_schedule = MagicMock()
|
|
mock_env.backup_schedule.enabled = True
|
|
mock_env.backup_schedule.cron_expression = backup_cron
|
|
mock_config = MagicMock()
|
|
mock_config.environments = [mock_env]
|
|
mgr.get_config.return_value = mock_config
|
|
return mgr
|
|
|
|
|
|
def _make_mock_task_manager():
|
|
"""Build a mock TaskManager returning no active tasks."""
|
|
tm = MagicMock()
|
|
tm.get_tasks.return_value = []
|
|
return tm
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture helpers — start/stop a real BackgroundScheduler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _start_scheduler():
|
|
"""Create and start a BackgroundScheduler. Returns the scheduler instance."""
|
|
sched = BackgroundScheduler()
|
|
sched.start()
|
|
return sched
|
|
|
|
|
|
def _stop_scheduler(sched):
|
|
"""Shutdown a BackgroundScheduler if running."""
|
|
if sched.running:
|
|
sched.shutdown(wait=False)
|
|
|
|
|
|
# #region test_load_schedules_reloads_translation_schedules [C:1] [TYPE Function]
|
|
def test_load_schedules_reloads_translation_schedules():
|
|
"""Verify load_schedules re-registers translation jobs after remove_all_jobs."""
|
|
from src.core.scheduler import SchedulerService
|
|
|
|
sched = _start_scheduler()
|
|
try:
|
|
cfg = _make_mock_config_manager()
|
|
tm = _make_mock_task_manager()
|
|
|
|
svc = SchedulerService.__new__(SchedulerService)
|
|
svc.task_manager = tm
|
|
svc.config_manager = cfg
|
|
svc.scheduler = sched
|
|
|
|
# Pre-register a translation job (simulates what happens before reload)
|
|
svc.add_translation_job(
|
|
schedule_id="sched-1",
|
|
job_id="job-1",
|
|
cron_expression="0 2 * * *",
|
|
)
|
|
|
|
# Verify it exists BEFORE reload
|
|
job_before = sched.get_job("translate_sched-1")
|
|
assert job_before is not None, "Translation job should exist before reload"
|
|
|
|
# Mock the DB query that load_schedules uses internally
|
|
mock_schedule = MagicMock()
|
|
mock_schedule.id = "sched-1"
|
|
mock_schedule.job_id = "job-1"
|
|
mock_schedule.cron_expression = "0 2 * * *"
|
|
mock_schedule.timezone = "UTC"
|
|
mock_schedule.execution_mode = "full"
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.all.return_value = [mock_schedule]
|
|
|
|
with patch("src.core.scheduler.SessionLocal", return_value=mock_db):
|
|
svc.load_schedules()
|
|
|
|
# After reload, the translation job should STILL exist
|
|
job_after = sched.get_job("translate_sched-1")
|
|
assert job_after is not None, (
|
|
"Translation job should survive load_schedules (re-registered from DB)"
|
|
)
|
|
|
|
# Backup job should also exist (added from config)
|
|
backup_job = sched.get_job("backup_env1")
|
|
assert backup_job is not None, "Backup job should be registered by load_schedules"
|
|
finally:
|
|
_stop_scheduler(sched)
|
|
# #endregion test_load_schedules_reloads_translation_schedules
|
|
|
|
|
|
# #region test_add_and_remove_translation_job [C:1] [TYPE Function]
|
|
def test_add_and_remove_translation_job():
|
|
"""Verify add_translation_job registers and remove_translation_job removes."""
|
|
from src.core.scheduler import SchedulerService
|
|
|
|
sched = _start_scheduler()
|
|
try:
|
|
cfg = _make_mock_config_manager()
|
|
tm = _make_mock_task_manager()
|
|
|
|
svc = SchedulerService.__new__(SchedulerService)
|
|
svc.task_manager = tm
|
|
svc.config_manager = cfg
|
|
svc.scheduler = sched
|
|
|
|
# Should NOT exist before add
|
|
assert sched.get_job("translate_sched-x") is None
|
|
|
|
svc.add_translation_job(
|
|
schedule_id="sched-x",
|
|
job_id="job-x",
|
|
cron_expression="*/5 * * * *",
|
|
)
|
|
|
|
# Should exist after add
|
|
job = sched.get_job("translate_sched-x")
|
|
assert job is not None, "Translation job should be registered after add"
|
|
assert job.id == "translate_sched-x"
|
|
|
|
svc.remove_translation_job("sched-x")
|
|
|
|
# Should NOT exist after remove
|
|
assert sched.get_job("translate_sched-x") is None, (
|
|
"Translation job should be removed after remove_translation_job"
|
|
)
|
|
finally:
|
|
_stop_scheduler(sched)
|
|
# #endregion test_add_and_remove_translation_job
|
|
|
|
|
|
# #region test_remove_nonexistent_translation_job_no_error [C:1] [TYPE Function]
|
|
def test_remove_nonexistent_translation_job_no_error():
|
|
"""Verify remove_translation_job handles non-existent job silently."""
|
|
from src.core.scheduler import SchedulerService
|
|
|
|
sched = _start_scheduler()
|
|
try:
|
|
cfg = _make_mock_config_manager()
|
|
tm = _make_mock_task_manager()
|
|
|
|
svc = SchedulerService.__new__(SchedulerService)
|
|
svc.task_manager = tm
|
|
svc.config_manager = cfg
|
|
svc.scheduler = sched
|
|
|
|
# Should not raise — silent ignore
|
|
svc.remove_translation_job("nonexistent-sched")
|
|
finally:
|
|
_stop_scheduler(sched)
|
|
# #endregion test_remove_nonexistent_translation_job_no_error
|
|
|
|
|
|
# #endregion TestCoreScheduler
|