Backend: - MigrationPlugin.execute — remove AsyncJobRunner.run() deadlock on post-migration ID sync (2 deadlocks total: plugins/migration.py + api/routes/migration.py trigger_sync_now). Replace blocking runner.run with direct await. - MigrationPlugin.execute — fix temp file leak (dry_run=True prevented cleanup). - MigrationPlugin.execute — IdMappingService(SessionLocal()) now closed in finally. - MigrationPlugin.execute — wire IdMappingService into MigrationEngine constructor so cross-filter patching actually works instead of silently skipping. - MigrationPlugin.execute — SupersetClient.aclose() in finally to prevent httpx connection pool leak. - TaskManager — _async_tasks dict leak (add_done_callback cleanup). - JobLifecycle — CancelledError handler (tasks stuck in RUNNING after cancel). - JobLifecycle — persist_task BEFORE _broadcast_task_status (crash consistency). - JobLifecycle — wait_for_resolution/wait_for_input now have 3600s timeout. - AsyncJobRunner.run — 300s timeout on future.result() (APScheduler thread safety). Translate scheduler: - execute_scheduled_translation — replace blocking runner.run(orch.execute_run(run)) with fire-and-forget TranslationOrchestrator.execute_background(). APScheduler thread is freed in ms instead of blocking for the full translation duration. Translation runs of 10k+ rows (200+ LLM batches, hours) no longer hit the 300s runner timeout. - TranslationOrchestrator.execute_background — new static method: opens own DB session, dispatches asyncio.create_task, handles errors + notification. - Scheduler last_run_at updated at dispatch time (not after completion). Frontend: - MigrationModel.stepReady[3] now requires dryRunResult != null (was always true, allowing UI to reach step 3 without dry-run). - WizardModel.goToStep gate for step 3 uses stepReady[3]. - +page.svelte — dryRunResult no longer self-clears via reactive loop. - Progress bar step 3 indicator gate fixed for new readiness logic. Tests: - 2 new regression tests for migration sync (deadlock-free, completes cleanly). - test_migration_plugin.py — _make_mock_superset_client/_make_mock_mapping_service helpers for proper async mock behavior (aclose, sync_environment AsyncMock). - 10 scheduled-translation tests updated for fire-and-forget pattern. - MigrationModel.test.ts — step 3 invariant + goToStep block test. - All affected tests: 104 backend + 79 frontend = 183 passed.
462 lines
19 KiB
Python
462 lines
19 KiB
Python
# #region Test.TranslationScheduler [C:3] [TYPE Module] [SEMANTICS test,scheduler,cron]
|
|
# @BRIEF Tests for scheduler.py — TranslationScheduler.
|
|
# @RELATION BINDS_TO -> [TranslationScheduler]
|
|
# @TEST_EDGE: job_not_found -> ValueError
|
|
# @TEST_EDGE: schedule_not_found -> ValueError
|
|
# @TEST_EDGE: invalid_cron -> empty next_executions
|
|
|
|
import sys
|
|
import asyncio
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from src.models.translate import TranslationJob, TranslationSchedule
|
|
from src.plugins.translate.scheduler import (
|
|
TranslationScheduler,
|
|
execute_scheduled_translation,
|
|
)
|
|
from .conftest import JOB_ID, db_session
|
|
|
|
|
|
def _make_coro_runner():
|
|
"""Mock runner whose run() executes the coroutine synchronously via asyncio.run."""
|
|
r = MagicMock()
|
|
r.run.side_effect = lambda coro: asyncio.run(coro)
|
|
return r
|
|
|
|
|
|
class TestCreateSchedule:
|
|
"""TranslationScheduler.create_schedule."""
|
|
|
|
def test_creates_schedule(self, db_session):
|
|
"""Creates a schedule for a job."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock(), "test-user")
|
|
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *")
|
|
assert schedule.job_id == JOB_ID
|
|
assert schedule.cron_expression == "0 0 * * *"
|
|
assert schedule.is_active is True
|
|
assert schedule.created_by == "test-user"
|
|
|
|
def test_job_not_found(self, db_session):
|
|
"""Non-existent job raises ValueError."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
with pytest.raises(ValueError, match="not found"):
|
|
scheduler.create_schedule("nonexistent", "0 0 * * *")
|
|
|
|
def test_custom_mode(self, db_session):
|
|
"""Custom execution_mode."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
schedule = scheduler.create_schedule(
|
|
JOB_ID, "0 0 * * *", execution_mode="new_key_only"
|
|
)
|
|
assert schedule.execution_mode == "new_key_only"
|
|
|
|
|
|
class TestUpdateSchedule:
|
|
"""TranslationScheduler.update_schedule."""
|
|
|
|
def test_update_cron(self, db_session):
|
|
"""Update cron expression."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *")
|
|
updated = scheduler.update_schedule(JOB_ID, cron_expression="0 30 * * *")
|
|
assert updated.cron_expression == "0 30 * * *"
|
|
|
|
def test_update_timezone(self, db_session):
|
|
"""Update timezone."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *")
|
|
updated = scheduler.update_schedule(JOB_ID, timezone_str="Europe/Moscow")
|
|
assert updated.timezone == "Europe/Moscow"
|
|
|
|
def test_update_is_active(self, db_session):
|
|
"""Update is_active."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *")
|
|
updated = scheduler.update_schedule(JOB_ID, is_active=False)
|
|
assert updated.is_active is False
|
|
|
|
def test_update_execution_mode(self, db_session):
|
|
"""Update execution_mode."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *")
|
|
updated = scheduler.update_schedule(JOB_ID, execution_mode="new_key_only")
|
|
assert updated.execution_mode == "new_key_only"
|
|
|
|
def test_not_found(self, db_session):
|
|
"""Non-existent schedule raises ValueError."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
with pytest.raises(ValueError, match="No schedule found"):
|
|
scheduler.update_schedule(JOB_ID)
|
|
|
|
|
|
class TestDeleteSchedule:
|
|
"""TranslationScheduler.delete_schedule."""
|
|
|
|
def test_delete(self, db_session):
|
|
"""Delete existing schedule."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *")
|
|
scheduler.delete_schedule(JOB_ID)
|
|
assert db_session.query(TranslationSchedule).filter(
|
|
TranslationSchedule.job_id == JOB_ID
|
|
).first() is None
|
|
|
|
def test_not_found(self, db_session):
|
|
"""Non-existent schedule raises ValueError."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
with pytest.raises(ValueError, match="No schedule found"):
|
|
scheduler.delete_schedule(JOB_ID)
|
|
|
|
|
|
class TestSetScheduleActive:
|
|
"""TranslationScheduler.set_schedule_active."""
|
|
|
|
def test_disable(self, db_session):
|
|
"""Disable schedule."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *")
|
|
updated = scheduler.set_schedule_active(JOB_ID, False)
|
|
assert updated.is_active is False
|
|
|
|
def test_enable(self, db_session):
|
|
"""Enable schedule."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=False)
|
|
updated = scheduler.set_schedule_active(JOB_ID, True)
|
|
assert updated.is_active is True
|
|
|
|
def test_not_found(self, db_session):
|
|
"""Non-existent raises ValueError."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
with pytest.raises(ValueError, match="No schedule found"):
|
|
scheduler.set_schedule_active(JOB_ID, True)
|
|
|
|
|
|
class TestGetSchedule:
|
|
"""TranslationScheduler.get_schedule."""
|
|
|
|
def test_get(self, db_session):
|
|
"""Get existing schedule."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *")
|
|
schedule = scheduler.get_schedule(JOB_ID)
|
|
assert schedule.job_id == JOB_ID
|
|
|
|
def test_not_found(self, db_session):
|
|
"""Non-existent raises ValueError."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
with pytest.raises(ValueError, match="No schedule found"):
|
|
scheduler.get_schedule(JOB_ID)
|
|
|
|
|
|
class TestListActiveSchedules:
|
|
"""TranslationScheduler.list_active_schedules."""
|
|
|
|
def test_list_active(self, db_session):
|
|
"""Returns only active schedules."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
|
|
|
|
# Create another job with inactive schedule
|
|
job2 = TranslationJob(id="job-2", name="Job 2", status="ACTIVE",
|
|
source_dialect="en", target_dialect="fr")
|
|
db_session.add(job2)
|
|
db_session.commit()
|
|
scheduler.create_schedule("job-2", "0 0 * * *", is_active=False)
|
|
|
|
active = TranslationScheduler.list_active_schedules(db_session)
|
|
assert len(active) == 1
|
|
assert active[0].job_id == JOB_ID
|
|
|
|
|
|
class TestGetNextExecutions:
|
|
"""TranslationScheduler.get_next_executions."""
|
|
|
|
def test_valid_cron(self):
|
|
"""Valid cron returns future times."""
|
|
results = TranslationScheduler.get_next_executions("0 0 * * *", "UTC", 3)
|
|
assert len(results) == 3
|
|
for r in results:
|
|
assert isinstance(r, str)
|
|
|
|
def test_invalid_cron(self):
|
|
"""Invalid cron returns empty list."""
|
|
results = TranslationScheduler.get_next_executions("invalid-cron", "UTC", 3)
|
|
assert results == []
|
|
|
|
def test_custom_count(self, db_session):
|
|
"""Custom N returns that many results."""
|
|
results = TranslationScheduler.get_next_executions("*/5 * * * *", "UTC", 5)
|
|
assert len(results) == 5
|
|
|
|
def test_default_count(self, db_session):
|
|
"""Default n=3."""
|
|
results = TranslationScheduler.get_next_executions("*/5 * * * *", "UTC")
|
|
assert len(results) == 3
|
|
|
|
|
|
class TestExecuteScheduledTranslation:
|
|
"""execute_scheduled_translation — APScheduler job handler."""
|
|
|
|
def _make_db_session_maker(self, db_session):
|
|
def maker():
|
|
return db_session
|
|
return maker
|
|
|
|
def test_schedule_inactive(self, db_session):
|
|
"""Inactive schedule returns early."""
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=False)
|
|
db_session.commit()
|
|
|
|
maker = self._make_db_session_maker(db_session)
|
|
|
|
with patch("src.plugins.translate.scheduler.seed_trace_id"):
|
|
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
|
|
# Should return early without error
|
|
|
|
def test_schedule_not_found(self, db_session):
|
|
"""Non-existent schedule returns early."""
|
|
maker = self._make_db_session_maker(db_session)
|
|
|
|
with patch("src.plugins.translate.scheduler.seed_trace_id"):
|
|
execute_scheduled_translation("nonexistent", JOB_ID, maker, MagicMock())
|
|
# Should return early
|
|
|
|
def test_concurrent_run_skips(self, db_session):
|
|
"""Active concurrent run causes scheduled execution to be skipped."""
|
|
from src.plugins.translate.scheduler import TranslationScheduler
|
|
from src.models.translate import TranslationRun
|
|
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
|
|
db_session.commit()
|
|
|
|
# Create a PENDING run to simulate concurrency
|
|
# Use datetime.now() (naive) — SQLite stores naive datetimes
|
|
pending_run = TranslationRun(
|
|
id="concurrent-run", job_id=JOB_ID, status="PENDING",
|
|
created_at=datetime.now(),
|
|
)
|
|
db_session.add(pending_run)
|
|
db_session.commit()
|
|
|
|
maker = self._make_db_session_maker(db_session)
|
|
|
|
with (
|
|
patch("src.plugins.translate.scheduler.seed_trace_id"),
|
|
patch("src.plugins.translate.scheduler.UTC", None), # SQLite stores naive datetimes
|
|
patch("src.plugins.translate.scheduler.TranslationEventLog") as MockEventLog,
|
|
):
|
|
mock_event_log = MagicMock()
|
|
MockEventLog.return_value = mock_event_log
|
|
|
|
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
|
|
|
|
# Should have logged skipped event
|
|
mock_event_log.log_event.assert_called_once()
|
|
|
|
def test_stale_concurrent_run_cleared(self, db_session):
|
|
"""Stale PENDING run older than 1h is cleared and execution proceeds."""
|
|
from src.plugins.translate.scheduler import TranslationScheduler
|
|
from src.models.translate import TranslationRun
|
|
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
|
|
db_session.commit()
|
|
|
|
# Create a stale PENDING run (older than 1h)
|
|
# Use datetime.now() (naive) — SQLite stores naive datetimes
|
|
stale_time = datetime.now() - timedelta(hours=2)
|
|
stale_run = TranslationRun(
|
|
id="stale-run", job_id=JOB_ID, status="PENDING",
|
|
created_at=stale_time,
|
|
)
|
|
db_session.add(stale_run)
|
|
db_session.commit()
|
|
|
|
maker = self._make_db_session_maker(db_session)
|
|
|
|
# Prevent session close so we can still read stale_run after the call
|
|
original_close = db_session.close
|
|
db_session.close = MagicMock()
|
|
db_session.expire_on_commit = False
|
|
try:
|
|
with (
|
|
patch("src.plugins.translate.scheduler.seed_trace_id"),
|
|
patch("src.plugins.translate.scheduler.UTC", None), # SQLite stores naive datetimes
|
|
patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
|
|
patch("src.dependencies.get_async_job_runner", return_value=_make_coro_runner()),
|
|
):
|
|
mock_orch = MagicMock()
|
|
mock_run = MagicMock()
|
|
mock_run.id = "new-run"
|
|
mock_run.status = "COMPLETED"
|
|
mock_run.insert_status = "succeeded"
|
|
mock_orch.start_run.return_value = mock_run
|
|
MockOrch.return_value = mock_orch
|
|
|
|
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
|
|
|
|
# Stale run should be marked FAILED
|
|
assert stale_run.status == "FAILED"
|
|
assert "Stale" in stale_run.error_message
|
|
finally:
|
|
db_session.close = original_close
|
|
db_session.expire_on_commit = True
|
|
|
|
def test_execution_new_key_only(self, db_session):
|
|
"""execution_mode=new_key_only with fresh baseline."""
|
|
from src.plugins.translate.scheduler import TranslationScheduler
|
|
from src.models.translate import TranslationRun
|
|
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
schedule = scheduler.create_schedule(
|
|
JOB_ID, "0 0 * * *", is_active=True, execution_mode="new_key_only"
|
|
)
|
|
db_session.commit()
|
|
|
|
# Recent successful run (baseline not expired)
|
|
# Use datetime.now() (naive) — SQLite stores naive datetimes
|
|
recent = datetime.now()
|
|
recent_run = TranslationRun(
|
|
id="recent-run", job_id=JOB_ID, status="COMPLETED",
|
|
insert_status="succeeded", created_at=recent,
|
|
)
|
|
db_session.add(recent_run)
|
|
db_session.commit()
|
|
|
|
maker = self._make_db_session_maker(db_session)
|
|
|
|
with (
|
|
patch("src.plugins.translate.scheduler.seed_trace_id"),
|
|
patch("src.plugins.translate.scheduler.UTC", None), # SQLite stores naive datetimes
|
|
patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
|
|
patch("src.dependencies.get_async_job_runner", return_value=_make_coro_runner()),
|
|
):
|
|
mock_orch = MagicMock()
|
|
mock_run = MagicMock()
|
|
mock_run.id = "new-run"
|
|
mock_run.status = "COMPLETED"
|
|
mock_run.insert_status = "succeeded"
|
|
mock_orch.start_run.return_value = mock_run
|
|
MockOrch.return_value = mock_orch
|
|
|
|
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock(), execution_mode="new_key_only")
|
|
|
|
# Should set trigger_type to "new_key_only" because baseline is fresh
|
|
assert mock_run.trigger_type == "new_key_only"
|
|
|
|
def test_execution_baseline_expired(self, db_session):
|
|
"""Baseline older than 90d triggers full translation even in new_key_only mode."""
|
|
from src.plugins.translate.scheduler import TranslationScheduler
|
|
from src.models.translate import TranslationRun
|
|
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
schedule = scheduler.create_schedule(
|
|
JOB_ID, "0 0 * * *", is_active=True, execution_mode="new_key_only"
|
|
)
|
|
db_session.commit()
|
|
|
|
# Very old successful run (baseline expired > 90 days)
|
|
# Use datetime.now() (naive) — SQLite stores naive datetimes
|
|
old_time = datetime.now() - timedelta(days=100)
|
|
old_run = TranslationRun(
|
|
id="old-run", job_id=JOB_ID, status="COMPLETED",
|
|
insert_status="succeeded", created_at=old_time,
|
|
)
|
|
db_session.add(old_run)
|
|
db_session.commit()
|
|
|
|
maker = self._make_db_session_maker(db_session)
|
|
|
|
with (
|
|
patch("src.plugins.translate.scheduler.seed_trace_id"),
|
|
patch("src.plugins.translate.scheduler.UTC", None), # SQLite stores naive datetimes
|
|
patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
|
|
patch("src.dependencies.get_async_job_runner", return_value=_make_coro_runner()),
|
|
):
|
|
mock_orch = MagicMock()
|
|
mock_run = MagicMock()
|
|
mock_run.id = "new-run"
|
|
mock_run.status = "COMPLETED"
|
|
mock_run.insert_status = "succeeded"
|
|
mock_orch.start_run.return_value = mock_run
|
|
MockOrch.return_value = mock_orch
|
|
|
|
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock(), execution_mode="new_key_only")
|
|
|
|
# Should fall back to "scheduled" because baseline expired
|
|
assert mock_run.trigger_type == "scheduled"
|
|
|
|
def test_execution_failure_sends_notification(self, db_session):
|
|
"""Execution failure is handled by execute_background — scheduler dispatches fire-and-forget."""
|
|
from src.plugins.translate.scheduler import TranslationScheduler
|
|
from src.plugins.translate.orchestrator import TranslationOrchestrator
|
|
|
|
scheduler = TranslationScheduler(db_session, MagicMock())
|
|
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
|
|
db_session.commit()
|
|
|
|
maker = self._make_db_session_maker(db_session)
|
|
|
|
with (
|
|
patch("src.plugins.translate.scheduler.seed_trace_id"),
|
|
patch("src.plugins.translate.scheduler.UTC", None), # SQLite stores naive datetimes
|
|
patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
|
|
patch("src.dependencies.get_async_job_runner", return_value=_make_coro_runner()),
|
|
patch(
|
|
"src.plugins.translate.scheduler.NotificationService",
|
|
) as MockNotif,
|
|
patch(
|
|
"src.dependencies.get_async_job_runner",
|
|
) as MockRunner,
|
|
):
|
|
mock_orch = MagicMock()
|
|
mock_run = MagicMock()
|
|
mock_run.id = "fail-run"
|
|
mock_run.status = "PENDING"
|
|
mock_run.insert_status = None
|
|
mock_orch.start_run.return_value = mock_run
|
|
MockOrch.return_value = mock_orch
|
|
|
|
# The runner is still used for the outer notification (crash outside execution),
|
|
# but NOT for execute_run dispatch. execute_background is called instead.
|
|
mock_runner = MagicMock()
|
|
mock_runner.run.side_effect = lambda coro: asyncio.run(coro)
|
|
MockRunner.return_value = mock_runner
|
|
|
|
# Provider mock — notification is NOT sent from scheduler (moved to background task)
|
|
mock_notif = MagicMock()
|
|
mock_notif._providers = {}
|
|
MockNotif.return_value = mock_notif
|
|
|
|
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
|
|
|
|
# Scheduler dispatches via execute_background instead of calling execute_run directly
|
|
MockOrch.execute_background.assert_called_once()
|
|
call_kwargs = MockOrch.execute_background.call_args.kwargs
|
|
assert call_kwargs["run_id"] == "fail-run"
|
|
assert call_kwargs["current_user"] == "scheduler"
|
|
# Notification is NOT sent from scheduler (moved to background task)
|
|
# (schedule.last_run_at tracking verified in test_scheduled_execution_uses_runner)
|
|
|
|
def test_get_next_executions_break_on_none(self):
|
|
"""get_next_executions breaks when cron trigger returns None."""
|
|
from src.plugins.translate.scheduler import TranslationScheduler
|
|
|
|
# Use a cron that's unlikely to have many future fires
|
|
# or mock the trigger to return None after one fire
|
|
results = TranslationScheduler.get_next_executions(
|
|
"0 0 30 2 5", "UTC", 10 # Feb 30 doesn't exist, trigger may produce fewer
|
|
)
|
|
# Should not crash; returns what it can
|
|
assert isinstance(results, list)
|
|
# #endregion Test.TranslationScheduler
|