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
400 lines
13 KiB
Python
400 lines
13 KiB
Python
# #region TestTranslateSchedulerExecution [C:3] [TYPE Module] [SEMANTICS test, translate, scheduler, execution, trigger-type, baseline]
|
|
# @BRIEF Verify execute_scheduled_translation contracts — trigger_type dispatch, baseline expiry fallback.
|
|
# @RELATION BINDS_TO -> [execute_scheduled_translation]
|
|
# @TEST_EDGE: new_key_only_mode — trigger_type="new_key_only" when baseline not expired
|
|
# @TEST_EDGE: baseline_expired_fallback — trigger_type="scheduled" when baseline > 90 days old
|
|
# @TEST_EDGE: full_mode_default — trigger_type="scheduled" for execution_mode="full"
|
|
# @TEST_EDGE: inactive_schedule — function returns early without executing
|
|
# @TEST_EDGE: execution_error_handled — run marked FAILED, schedule tracking updated
|
|
# @TEST_INVARIANT: trigger_type_dispatch -> VERIFIED_BY: [test_new_key_only_mode, test_baseline_expired_fallback, test_full_mode_default]
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
from unittest.mock import MagicMock, patch
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
|
# -- Helpers -----------------------------------------------------------------
|
|
|
|
def _make_most_recent_run(created_at_delta_days=60):
|
|
"""Build a MagicMock TranslationRun with given age."""
|
|
run = MagicMock()
|
|
run.id = "prev-run-1"
|
|
run.job_id = "job-1"
|
|
run.status = "COMPLETED"
|
|
run.insert_status = "succeeded"
|
|
run.created_at = datetime.now(timezone.utc) - timedelta(days=created_at_delta_days)
|
|
return run
|
|
|
|
|
|
def _make_mock_run(run_id="run-1", job_id="job-1"):
|
|
"""Build a mock TranslationRun returned by start_run."""
|
|
run = MagicMock()
|
|
run.id = run_id
|
|
run.job_id = job_id
|
|
run.status = "PENDING"
|
|
run.trigger_type = None
|
|
run.total_records = 0
|
|
run.successful_records = 0
|
|
run.failed_records = 0
|
|
run.skipped_records = 0
|
|
run.insert_status = None
|
|
run.superset_execution_id = None
|
|
run.superset_execution_log = None
|
|
run.error_message = None
|
|
return run
|
|
|
|
|
|
# -- Tests: trigger_type dispatch and execution modes -----------------------
|
|
|
|
# #region test_new_key_only_mode [C:1] [TYPE Function]
|
|
def test_new_key_only_mode():
|
|
"""execution_mode="new_key_only" with 60-day-old prev run → trigger_type="new_key_only"."""
|
|
from src.plugins.translate.scheduler import execute_scheduled_translation
|
|
|
|
mock_db = MagicMock()
|
|
mock_session_maker = MagicMock(return_value=mock_db)
|
|
mock_config = MagicMock()
|
|
|
|
schedule = MagicMock()
|
|
schedule.id = "sched-1"
|
|
schedule.job_id = "job-1"
|
|
schedule.is_active = True
|
|
schedule.execution_mode = "new_key_only"
|
|
|
|
most_recent = _make_most_recent_run(created_at_delta_days=60) # 60 days < 90
|
|
|
|
q1 = MagicMock()
|
|
q1.filter.return_value.first.return_value = schedule
|
|
|
|
q2 = MagicMock()
|
|
q2.filter.return_value.order_by.return_value.first.return_value = None # no concurrent
|
|
|
|
q3 = MagicMock()
|
|
q3.filter.return_value.order_by.return_value.first.return_value = most_recent
|
|
|
|
mock_db.query.side_effect = [q1, q2, q3]
|
|
|
|
mock_run = _make_mock_run()
|
|
|
|
with patch(
|
|
"src.plugins.translate.orchestrator.TranslationOrchestrator"
|
|
) as mock_orch_cls:
|
|
mock_orch = MagicMock()
|
|
mock_orch.start_run.return_value = mock_run
|
|
mock_orch_cls.return_value = mock_orch
|
|
|
|
execute_scheduled_translation(
|
|
schedule_id="sched-1",
|
|
job_id="job-1",
|
|
db_session_maker=mock_session_maker,
|
|
config_manager=mock_config,
|
|
execution_mode="new_key_only",
|
|
)
|
|
|
|
assert mock_run.trigger_type == "new_key_only", (
|
|
f"Expected trigger_type='new_key_only', got '{mock_run.trigger_type}'"
|
|
)
|
|
mock_orch.start_run.assert_called_once_with(job_id="job-1", is_scheduled=True)
|
|
mock_orch.execute_run.assert_called_once()
|
|
mock_db.close.assert_called_once()
|
|
# #endregion test_new_key_only_mode
|
|
|
|
|
|
# #region test_baseline_expired_fallback [C:1] [TYPE Function]
|
|
def test_baseline_expired_fallback():
|
|
"""execution_mode="new_key_only" with >90-day-old prev run → trigger_type="scheduled" baseline fallback."""
|
|
from src.plugins.translate.scheduler import execute_scheduled_translation
|
|
|
|
mock_db = MagicMock()
|
|
mock_session_maker = MagicMock(return_value=mock_db)
|
|
mock_config = MagicMock()
|
|
|
|
schedule = MagicMock()
|
|
schedule.id = "sched-1"
|
|
schedule.job_id = "job-1"
|
|
schedule.is_active = True
|
|
schedule.execution_mode = "new_key_only"
|
|
|
|
most_recent = _make_most_recent_run(created_at_delta_days=95) # 95 days > 90
|
|
|
|
q1 = MagicMock()
|
|
q1.filter.return_value.first.return_value = schedule
|
|
|
|
q2 = MagicMock()
|
|
q2.filter.return_value.order_by.return_value.first.return_value = None
|
|
|
|
q3 = MagicMock()
|
|
q3.filter.return_value.order_by.return_value.first.return_value = most_recent
|
|
|
|
mock_db.query.side_effect = [q1, q2, q3]
|
|
|
|
mock_run = _make_mock_run()
|
|
|
|
with patch(
|
|
"src.plugins.translate.orchestrator.TranslationOrchestrator"
|
|
) as mock_orch_cls:
|
|
mock_orch = MagicMock()
|
|
mock_orch.start_run.return_value = mock_run
|
|
mock_orch_cls.return_value = mock_orch
|
|
|
|
with patch(
|
|
"src.plugins.translate.scheduler.TranslationEventLog"
|
|
) as mock_event_log_cls:
|
|
mock_event_log = MagicMock()
|
|
mock_event_log_cls.return_value = mock_event_log
|
|
|
|
execute_scheduled_translation(
|
|
schedule_id="sched-1",
|
|
job_id="job-1",
|
|
db_session_maker=mock_session_maker,
|
|
config_manager=mock_config,
|
|
execution_mode="new_key_only",
|
|
)
|
|
|
|
assert mock_run.trigger_type == "scheduled", (
|
|
f"Expected trigger_type='scheduled' (baseline expired), got '{mock_run.trigger_type}'"
|
|
)
|
|
|
|
mock_event_log.log_event.assert_called_once()
|
|
event_call = mock_event_log.log_event.call_args
|
|
assert event_call.kwargs["job_id"] == "job-1"
|
|
assert event_call.kwargs["event_type"] == "RUN_STARTED"
|
|
assert event_call.kwargs["payload"]["reason"] == "baseline_expired"
|
|
assert event_call.kwargs["payload"]["age_days"] == 95
|
|
|
|
mock_db.close.assert_called_once()
|
|
# #endregion test_baseline_expired_fallback
|
|
|
|
|
|
# #region test_full_mode_default [C:1] [TYPE Function]
|
|
def test_full_mode_default():
|
|
"""execution_mode="full" → trigger_type="scheduled" regardless of baseline age."""
|
|
from src.plugins.translate.scheduler import execute_scheduled_translation
|
|
|
|
mock_db = MagicMock()
|
|
mock_session_maker = MagicMock(return_value=mock_db)
|
|
mock_config = MagicMock()
|
|
|
|
schedule = MagicMock()
|
|
schedule.id = "sched-1"
|
|
schedule.job_id = "job-1"
|
|
schedule.is_active = True
|
|
schedule.execution_mode = "full"
|
|
|
|
most_recent = _make_most_recent_run(created_at_delta_days=30)
|
|
|
|
q1 = MagicMock()
|
|
q1.filter.return_value.first.return_value = schedule
|
|
|
|
q2 = MagicMock()
|
|
q2.filter.return_value.order_by.return_value.first.return_value = None
|
|
|
|
q3 = MagicMock()
|
|
q3.filter.return_value.order_by.return_value.first.return_value = most_recent
|
|
|
|
mock_db.query.side_effect = [q1, q2, q3]
|
|
|
|
mock_run = _make_mock_run()
|
|
|
|
with patch(
|
|
"src.plugins.translate.orchestrator.TranslationOrchestrator"
|
|
) as mock_orch_cls:
|
|
mock_orch = MagicMock()
|
|
mock_orch.start_run.return_value = mock_run
|
|
mock_orch_cls.return_value = mock_orch
|
|
|
|
execute_scheduled_translation(
|
|
schedule_id="sched-1",
|
|
job_id="job-1",
|
|
db_session_maker=mock_session_maker,
|
|
config_manager=mock_config,
|
|
execution_mode="full",
|
|
)
|
|
|
|
assert mock_run.trigger_type == "scheduled", (
|
|
f"Expected trigger_type='scheduled' for full mode, got '{mock_run.trigger_type}'"
|
|
)
|
|
mock_orch.execute_run.assert_called_once()
|
|
mock_db.close.assert_called_once()
|
|
# #endregion test_full_mode_default
|
|
|
|
|
|
# #region test_inactive_schedule_skips [C:1] [TYPE Function]
|
|
def test_inactive_schedule_skips():
|
|
"""Inactive schedule → function returns early without executing anything."""
|
|
from src.plugins.translate.scheduler import execute_scheduled_translation
|
|
|
|
mock_db = MagicMock()
|
|
mock_session_maker = MagicMock(return_value=mock_db)
|
|
mock_config = MagicMock()
|
|
|
|
schedule = MagicMock()
|
|
schedule.id = "sched-1"
|
|
schedule.job_id = "job-1"
|
|
schedule.is_active = False
|
|
|
|
q1 = MagicMock()
|
|
q1.filter.return_value.first.return_value = schedule
|
|
|
|
mock_db.query.return_value = q1
|
|
|
|
with patch(
|
|
"src.plugins.translate.orchestrator.TranslationOrchestrator"
|
|
) as mock_orch_cls:
|
|
execute_scheduled_translation(
|
|
schedule_id="sched-1",
|
|
job_id="job-1",
|
|
db_session_maker=mock_session_maker,
|
|
config_manager=mock_config,
|
|
execution_mode="full",
|
|
)
|
|
|
|
mock_orch_cls.assert_not_called()
|
|
mock_db.close.assert_called_once()
|
|
# #endregion test_inactive_schedule_skips
|
|
|
|
|
|
# #region test_schedule_not_found_skips [C:1] [TYPE Function]
|
|
def test_schedule_not_found_skips():
|
|
"""Schedule not found → function returns early."""
|
|
from src.plugins.translate.scheduler import execute_scheduled_translation
|
|
|
|
mock_db = MagicMock()
|
|
mock_session_maker = MagicMock(return_value=mock_db)
|
|
mock_config = MagicMock()
|
|
|
|
q1 = MagicMock()
|
|
q1.filter.return_value.first.return_value = None
|
|
|
|
mock_db.query.return_value = q1
|
|
|
|
with patch(
|
|
"src.plugins.translate.orchestrator.TranslationOrchestrator"
|
|
) as mock_orch_cls:
|
|
execute_scheduled_translation(
|
|
schedule_id="sched-1",
|
|
job_id="job-1",
|
|
db_session_maker=mock_session_maker,
|
|
config_manager=mock_config,
|
|
execution_mode="full",
|
|
)
|
|
|
|
mock_orch_cls.assert_not_called()
|
|
mock_db.close.assert_called_once()
|
|
# #endregion test_schedule_not_found_skips
|
|
|
|
|
|
# #region test_baseline_expired_full_mode [C:1] [TYPE Function]
|
|
def test_baseline_expired_full_mode():
|
|
"""execution_mode="full" with expired baseline → trigger_type stays 'scheduled'."""
|
|
from src.plugins.translate.scheduler import execute_scheduled_translation
|
|
|
|
mock_db = MagicMock()
|
|
mock_session_maker = MagicMock(return_value=mock_db)
|
|
mock_config = MagicMock()
|
|
|
|
schedule = MagicMock()
|
|
schedule.id = "sched-1"
|
|
schedule.job_id = "job-1"
|
|
schedule.is_active = True
|
|
schedule.execution_mode = "full"
|
|
|
|
most_recent = _make_most_recent_run(created_at_delta_days=100) # expired
|
|
|
|
q1 = MagicMock()
|
|
q1.filter.return_value.first.return_value = schedule
|
|
|
|
q2 = MagicMock()
|
|
q2.filter.return_value.order_by.return_value.first.return_value = None
|
|
|
|
q3 = MagicMock()
|
|
q3.filter.return_value.order_by.return_value.first.return_value = most_recent
|
|
|
|
mock_db.query.side_effect = [q1, q2, q3]
|
|
|
|
mock_run = _make_mock_run()
|
|
|
|
with patch(
|
|
"src.plugins.translate.orchestrator.TranslationOrchestrator"
|
|
) as mock_orch_cls:
|
|
mock_orch = MagicMock()
|
|
mock_orch.start_run.return_value = mock_run
|
|
mock_orch_cls.return_value = mock_orch
|
|
|
|
with patch(
|
|
"src.plugins.translate.scheduler.TranslationEventLog"
|
|
) as mock_event_log_cls:
|
|
mock_event_log = MagicMock()
|
|
mock_event_log_cls.return_value = mock_event_log
|
|
|
|
execute_scheduled_translation(
|
|
schedule_id="sched-1",
|
|
job_id="job-1",
|
|
db_session_maker=mock_session_maker,
|
|
config_manager=mock_config,
|
|
execution_mode="full",
|
|
)
|
|
|
|
assert mock_run.trigger_type == "scheduled"
|
|
mock_event_log.log_event.assert_called_once()
|
|
event_call = mock_event_log.log_event.call_args
|
|
assert event_call.kwargs["payload"]["reason"] == "baseline_expired"
|
|
mock_db.close.assert_called_once()
|
|
# #endregion test_baseline_expired_full_mode
|
|
|
|
|
|
# #region test_execution_error_handled [C:1] [TYPE Function]
|
|
def test_execution_error_handled():
|
|
"""execute_run raises → run is marked FAILED, schedule tracking updated, no crash."""
|
|
from src.plugins.translate.scheduler import execute_scheduled_translation
|
|
|
|
mock_db = MagicMock()
|
|
mock_session_maker = MagicMock(return_value=mock_db)
|
|
mock_config = MagicMock()
|
|
|
|
schedule = MagicMock()
|
|
schedule.id = "sched-1"
|
|
schedule.job_id = "job-1"
|
|
schedule.is_active = True
|
|
schedule.execution_mode = "full"
|
|
|
|
q1 = MagicMock()
|
|
q1.filter.return_value.first.return_value = schedule
|
|
|
|
q2 = MagicMock()
|
|
q2.filter.return_value.order_by.return_value.first.return_value = None
|
|
|
|
q3 = MagicMock()
|
|
q3.filter.return_value.order_by.return_value.first.return_value = None
|
|
|
|
mock_db.query.side_effect = [q1, q2, q3]
|
|
|
|
mock_run = _make_mock_run()
|
|
|
|
with patch(
|
|
"src.plugins.translate.orchestrator.TranslationOrchestrator"
|
|
) as mock_orch_cls:
|
|
mock_orch = MagicMock()
|
|
mock_orch.start_run.return_value = mock_run
|
|
mock_orch.execute_run.side_effect = RuntimeError("LLM timeout")
|
|
mock_orch_cls.return_value = mock_orch
|
|
|
|
execute_scheduled_translation(
|
|
schedule_id="sched-1",
|
|
job_id="job-1",
|
|
db_session_maker=mock_session_maker,
|
|
config_manager=mock_config,
|
|
execution_mode="full",
|
|
)
|
|
|
|
assert mock_run.status == "FAILED"
|
|
assert "LLM timeout" in mock_run.error_message
|
|
assert mock_run.completed_at is not None
|
|
assert schedule.last_run_at is not None
|
|
mock_db.commit.assert_called()
|
|
mock_db.close.assert_called_once()
|
|
# #endregion test_execution_error_handled
|
|
|
|
# #endregion TestTranslateSchedulerExecution
|