feat(translate,scheduler,trace): new-key-only mode, stale PENDING protection, trace_id propagation

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
This commit is contained in:
2026-05-14 10:13:56 +03:00
parent d1695fe536
commit 8d0bc6fb93
26 changed files with 1525 additions and 19 deletions

25
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,25 @@
# #region TestSessionConfig [C:2] [TYPE Module] [SEMANTICS test, conftest, db]
# @BRIEF Shared pytest fixtures and session configuration for backend tests.
# @RELATION BINDS_TO -> [init_db]
# @PRE All test modules use sys.path patching to import from src/.
# @POST Database tables exist before test session executes.
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
@pytest.fixture(scope="session", autouse=True)
def ensure_db_tables():
"""Ensure all tables exist before test session.
This fixture runs once per test session, creating all registered
SQLAlchemy tables. Individual test modules may also create their
own in-memory engines — this guarantees the real engine schema
is initialized for tests that depend on it.
"""
from src.core.database import init_db
init_db()
# #endregion TestSessionConfig

View File

@@ -0,0 +1,177 @@
# #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

View File

@@ -0,0 +1,291 @@
# #region TestTranslateExecutorFilter [C:3] [TYPE Module] [SEMANTICS test, translate, executor, filter, new-key-only]
# @BRIEF Verify TranslationExecutor._filter_new_keys contracts — key dedup, empty-set, guard triggers.
# @RELATION BINDS_TO -> [TranslationExecutor]
# @TEST_EDGE: no_prior_run — all rows pass through when no previous run exists
# @TEST_EDGE: filter_existing_keys — rows with keys from prior run are skipped
# @TEST_EDGE: composite_keys — multiple target_key_cols produce correct dedup
# @TEST_EDGE: all_existing — zero new rows returns empty list
# @TEST_EDGE: empty_key_cols — guard triggers, all rows returned with warning
# @TEST_EDGE: null_source_data — None source_data is safely handled
# @TEST_INVARIANT: new_key_only_filter -> VERIFIED_BY: [test_no_prior_run_returns_all_rows, test_filters_existing_keys, test_composite_keys]
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
from unittest.mock import MagicMock
from src.plugins.translate.executor import TranslationExecutor
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_executor(mock_db=None):
"""Create a TranslationExecutor with a mocked DB session and config."""
db = mock_db or MagicMock()
cfg = MagicMock()
return TranslationExecutor(db, cfg, current_user="test_user")
def _make_job(source_key_cols=None, target_key_cols=None):
"""Build a MagicMock TranslationJob with key columns configured."""
job = MagicMock()
job.id = "job-test-1"
job.source_key_cols = source_key_cols or []
job.target_key_cols = target_key_cols or []
return job
def _make_rows(*source_data_dicts):
"""Build source_rows list from source_data dicts."""
rows = []
for sd in source_data_dicts:
rows.append({
"row_index": sd.get("_idx", "0"),
"source_text": sd.get("text", ""),
"source_data": sd,
})
return rows
def _make_prev_records(run_id, source_data_dicts):
"""Build MagicMock TranslationRecord list with source_data and SUCCESS status."""
recs = []
for sd in source_data_dicts:
rec = MagicMock()
rec.run_id = run_id
rec.status = "SUCCESS"
rec.source_data = sd
recs.append(rec)
return recs
def _wire_query_chain(mock_db, prev_run, prev_records):
"""Wire mock DB for _filter_new_keys: two sequential db.query() calls.
Query 1: db.query(TranslationRun).filter(a,b,c,d).order_by(desc).first() → prev_run
Query 2: db.query(TranslationRecord).filter(a,b).all() → prev_records
"""
q1 = MagicMock()
q1.filter.return_value.order_by.return_value.first.return_value = prev_run
q2 = MagicMock()
q2.filter.return_value.all.return_value = prev_records
mock_db.query.side_effect = [q1, q2]
# #region test_no_prior_run_returns_all_rows [C:1] [TYPE Function]
def test_no_prior_run_returns_all_rows():
"""When no prior TranslationRun exists, all source_rows pass through unchanged."""
executor = _make_executor()
job = _make_job(source_key_cols=["id"], target_key_cols=["id"])
rows = _make_rows(
{"id": 1, "text": "hello", "_idx": "0"},
{"id": 2, "text": "world", "_idx": "1"},
{"id": 3, "text": "test", "_idx": "2"},
)
# Mock: no prior run (query returns None)
_wire_query_chain(executor.db, prev_run=None, prev_records=[])
result = executor._filter_new_keys(job, "run-1", rows)
assert len(result) == 3
assert result == rows # identity — all pass through
# #endregion test_no_prior_run_returns_all_rows
# #region test_filters_existing_keys [C:1] [TYPE Function]
def test_filters_existing_keys():
"""Rows whose key exists in a prior run's records are excluded."""
executor = _make_executor()
job = _make_job(source_key_cols=["id"], target_key_cols=["id"])
rows = _make_rows(
{"id": 1, "text": "existing", "_idx": "0"},
{"id": 2, "text": "new", "_idx": "1"},
{"id": 3, "text": "existing", "_idx": "2"},
)
mock_prev_run = MagicMock()
mock_prev_run.id = "prev-run-1"
# Only id=1 and id=3 were previously translated successfully
prev_recs = _make_prev_records("prev-run-1", [
{"id": 1, "text": "existing"},
{"id": 3, "text": "existing"},
])
_wire_query_chain(executor.db, mock_prev_run, prev_recs)
result = executor._filter_new_keys(job, "run-1", rows)
assert len(result) == 1, f"Expected 1 new row, got {len(result)}"
assert result[0]["source_data"]["id"] == 2
# #endregion test_filters_existing_keys
# #region test_composite_keys [C:1] [TYPE Function]
def test_composite_keys():
"""Multiple target_key_cols produce correct composite key dedup."""
executor = _make_executor()
job = _make_job(target_key_cols=["schema", "table"])
rows = _make_rows(
{"schema": "public", "table": "users", "text": "a", "_idx": "0"},
{"schema": "public", "table": "orders", "text": "b", "_idx": "1"},
{"schema": "admin", "table": "users", "text": "c", "_idx": "2"},
)
mock_prev_run = MagicMock()
mock_prev_run.id = "prev-run-1"
# Only (public, users) was already translated
prev_recs = _make_prev_records("prev-run-1", [
{"schema": "public", "table": "users"},
])
_wire_query_chain(executor.db, mock_prev_run, prev_recs)
result = executor._filter_new_keys(job, "run-1", rows)
assert len(result) == 2, f"Expected 2 new rows, got {len(result)}"
result_keys = {
tuple(str(r["source_data"].get(k, "")) for k in ["schema", "table"])
for r in result
}
assert ("public", "orders") in result_keys
assert ("admin", "users") in result_keys
assert ("public", "users") not in result_keys
# #endregion test_composite_keys
# #region test_all_keys_existing_returns_empty [C:1] [TYPE Function]
def test_all_keys_existing_returns_empty():
"""When every source row's key already exists, the filtered list is empty."""
executor = _make_executor()
job = _make_job(source_key_cols=["id"])
rows = _make_rows(
{"id": 1, "text": "a", "_idx": "0"},
{"id": 2, "text": "b", "_idx": "1"},
)
mock_prev_run = MagicMock()
mock_prev_run.id = "prev-run-1"
prev_recs = _make_prev_records("prev-run-1", [
{"id": 1}, {"id": 2},
])
_wire_query_chain(executor.db, mock_prev_run, prev_recs)
result = executor._filter_new_keys(job, "run-1", rows)
assert result == [], "All keys existing should produce empty result"
# #endregion test_all_keys_existing_returns_empty
# #region test_empty_key_cols_returns_all_rows [C:1] [TYPE Function]
def test_empty_key_cols_returns_all_rows():
"""When no key columns are configured, guard triggers — all rows returned."""
executor = _make_executor()
job = _make_job(source_key_cols=[], target_key_cols=[])
rows = _make_rows(
{"id": 1, "text": "a", "_idx": "0"},
{"id": 2, "text": "b", "_idx": "1"},
)
mock_prev_run = MagicMock()
mock_prev_run.id = "prev-run-1"
_wire_query_chain(executor.db, mock_prev_run, prev_records=[])
# Even with a prior run and records, no key cols means all rows pass through
result = executor._filter_new_keys(job, "run-1", rows)
assert len(result) == 2
assert result == rows
# #endregion test_empty_key_cols_returns_all_rows
# #region test_null_source_data_handled [C:1] [TYPE Function]
def test_null_source_data_handled():
"""None source_data is safely handled with or {} fallback — no crash."""
executor = _make_executor()
job = _make_job(source_key_cols=["id"])
rows = [
{"row_index": "0", "source_text": "a", "source_data": None},
{"row_index": "1", "source_text": "b", "source_data": {"id": 2}},
]
mock_prev_run = MagicMock()
mock_prev_run.id = "prev-run-1"
# Previous run only had id=2
prev_recs = _make_prev_records("prev-run-1", [{"id": 2}])
_wire_query_chain(executor.db, mock_prev_run, prev_recs)
# Should NOT crash on None source_data
result = executor._filter_new_keys(job, "run-1", rows)
# The row with None source_data has key_tuple ("",) — NOT in existing_keys
# (existing has ("2",)). So it should pass through.
assert len(result) == 1, (
f"Expected 1 new row (None source_data treated as ''), got {len(result)}"
)
assert result[0]["source_data"] is None
# #endregion test_null_source_data_handled
# #region test_prior_run_no_records_returns_all [C:1] [TYPE Function]
def test_prior_run_no_records_returns_all():
"""Prior run exists but has no successful records — all rows treated as new."""
executor = _make_executor()
job = _make_job(source_key_cols=["id"])
rows = _make_rows(
{"id": 1, "text": "a", "_idx": "0"},
{"id": 2, "text": "b", "_idx": "1"},
)
mock_prev_run = MagicMock()
mock_prev_run.id = "prev-run-1"
_wire_query_chain(executor.db, mock_prev_run, prev_records=[])
result = executor._filter_new_keys(job, "run-1", rows)
assert len(result) == 2
assert result == rows
# #endregion test_prior_run_no_records_returns_all
# #region test_key_cols_none_returns_all [C:1] [TYPE Function]
def test_key_cols_none_returns_all():
"""When source_key_cols and target_key_cols are both None, all rows pass through."""
executor = _make_executor()
# Job with no key_cols at all (None, not empty list)
job = MagicMock()
job.id = "job-test-1"
job.source_key_cols = None
job.target_key_cols = None
rows = _make_rows(
{"id": 1, "text": "a", "_idx": "0"},
)
mock_prev_run = MagicMock()
mock_prev_run.id = "prev-run-1"
_wire_query_chain(executor.db, mock_prev_run, prev_records=[])
result = executor._filter_new_keys(job, "run-1", rows)
assert len(result) == 1
assert result == rows
# #endregion test_key_cols_none_returns_all
# #endregion TestTranslateExecutorFilter

View File

@@ -197,7 +197,7 @@ def test_list_runs_filter_status(client, mock_api_deps):
_create_test_run(session, job.id, status="FAILED")
session.commit()
response = client.get(f"/api/translate/runs?status=FAILED")
response = client.get(f"/api/translate/runs?run_status=FAILED")
assert response.status_code == 200
data = response.json()
for item in data["items"]:

View File

@@ -0,0 +1,399 @@
# #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

View File

@@ -0,0 +1,302 @@
# #region TestTranslateSchedulerGuard [C:3] [TYPE Module] [SEMANTICS test, translate, scheduler, guard, concurrency, stale, pending]
# @BRIEF Verify execute_scheduled_translation concurrency guard and stale-PENDING clearance contracts.
# @RELATION BINDS_TO -> [execute_scheduled_translation]
# @TEST_EDGE: concurrent_run_skips — active RUNNING concurrent run → function skips and logs skipped_concurrent event
# @TEST_EDGE: recent_pending_run_not_stale — recent PENDING run (30 min old) → function skips, not stale
# @TEST_EDGE: stale_pending_cleared_and_proceeds — stale PENDING run (>1h old) → cleared as FAILED, execution proceeds
# @TEST_EDGE: multiple_stale_pending_all_cleared — multiple stale PENDING runs → all cleared as FAILED, execution proceeds
# @TEST_INVARIANT: concurrency_guard -> VERIFIED_BY: [test_concurrent_run_skips, test_recent_pending_run_not_stale]
# @TEST_INVARIANT: stale_pending_clearance -> VERIFIED_BY: [test_stale_pending_cleared_and_proceeds, test_multiple_stale_pending_all_cleared]
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, 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 — concurrency guard and stale PENDING clearance
# ---------------------------------------------------------------------------
# #region test_concurrent_run_skips [C:1] [TYPE Function]
def test_concurrent_run_skips():
"""Active RUNNING concurrent run → function skips and logs skipped_concurrent event."""
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"
active_run = MagicMock()
active_run.id = "active-run-1"
active_run.job_id = "job-1"
active_run.status = "RUNNING"
q1 = MagicMock()
q1.filter.return_value.first.return_value = schedule
q2 = MagicMock()
q2.filter.return_value.order_by.return_value.first.return_value = active_run
mock_db.query.side_effect = [q1, q2]
with patch(
"src.plugins.translate.orchestrator.TranslationOrchestrator"
) as mock_orch_cls:
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",
)
mock_orch_cls.assert_not_called()
mock_event_log.log_event.assert_called_once()
event_call = mock_event_log.log_event.call_args
assert event_call.kwargs["event_type"] == "RUN_STARTED"
assert event_call.kwargs["payload"]["reason"] == "skipped_concurrent"
mock_db.close.assert_called_once()
# #endregion test_concurrent_run_skips
# #region test_recent_pending_run_not_stale [C:1] [TYPE Function]
def test_recent_pending_run_not_stale():
"""Recent PENDING run (30 min old) → function skips, not stale."""
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"
pending_run = MagicMock()
pending_run.id = "pending-run-1"
pending_run.job_id = "job-1"
pending_run.status = "PENDING"
pending_run.created_at = datetime.now(timezone.utc) - timedelta(minutes=30)
q1 = MagicMock()
q1.filter.return_value.first.return_value = schedule
q2 = MagicMock()
q2.filter.return_value.order_by.return_value.first.return_value = pending_run
mock_db.query.side_effect = [q1, q2]
with patch(
"src.plugins.translate.orchestrator.TranslationOrchestrator"
) as mock_orch_cls:
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",
)
mock_orch_cls.assert_not_called()
mock_event_log.log_event.assert_called_once()
event_call = mock_event_log.log_event.call_args
assert event_call.kwargs["event_type"] == "RUN_STARTED"
assert event_call.kwargs["payload"]["reason"] == "skipped_concurrent"
mock_db.close.assert_called_once()
# #endregion test_recent_pending_run_not_stale
# #region test_stale_pending_cleared_and_proceeds [C:1] [TYPE Function]
def test_stale_pending_cleared_and_proceeds():
"""Stale PENDING run (>1h old) → cleared as FAILED, execution proceeds."""
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"
stale_run = MagicMock()
stale_run.id = "stale-run-1"
stale_run.job_id = "job-1"
stale_run.status = "PENDING"
stale_run.created_at = datetime.now(timezone.utc) - timedelta(hours=2)
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 = stale_run
q3 = MagicMock()
q3.filter.return_value.all.return_value = [stale_run]
q4 = MagicMock()
q4.filter.return_value.order_by.return_value.first.return_value = most_recent
mock_db.query.side_effect = [q1, q2, q3, q4]
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 stale_run.status == "FAILED"
assert "Stale: concurrency deadlock" in stale_run.error_message
assert stale_run.completed_at is not None
mock_orch.start_run.assert_called_once_with(job_id="job-1", is_scheduled=True)
mock_orch.execute_run.assert_called_once()
assert mock_db.commit.call_count >= 1
mock_db.close.assert_called_once()
# #endregion test_stale_pending_cleared_and_proceeds
# #region test_multiple_stale_pending_all_cleared [C:1] [TYPE Function]
def test_multiple_stale_pending_all_cleared():
"""Multiple stale PENDING runs → all cleared as FAILED, execution proceeds."""
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"
stale_run_a = MagicMock()
stale_run_a.id = "stale-run-a"
stale_run_a.job_id = "job-1"
stale_run_a.status = "PENDING"
stale_run_a.created_at = datetime.now(timezone.utc) - timedelta(hours=5)
stale_run_b = MagicMock()
stale_run_b.id = "stale-run-b"
stale_run_b.job_id = "job-1"
stale_run_b.status = "PENDING"
stale_run_b.created_at = datetime.now(timezone.utc) - timedelta(hours=3)
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 = stale_run_a
q3 = MagicMock()
q3.filter.return_value.all.return_value = [stale_run_a, stale_run_b]
q4 = MagicMock()
q4.filter.return_value.order_by.return_value.first.return_value = most_recent
mock_db.query.side_effect = [q1, q2, q3, q4]
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",
)
for sr in [stale_run_a, stale_run_b]:
assert sr.status == "FAILED"
assert "Stale: concurrency deadlock" in sr.error_message
assert sr.completed_at is not None
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_multiple_stale_pending_all_cleared
# #endregion TestTranslateSchedulerGuard