fix(core): centralize async/sync bridge for APScheduler scheduled jobs
Create AsyncJobRunner — centralized bridge between APScheduler (BackgroundScheduler, sync thread pool) and async coroutines. Fixes: - P0: execute_run() called without await from APScheduler thread, causing coroutine to be silently discarded (root cause: no translation history) - P0: get_async_job_runner() deadlock when called from APScheduler thread pool without running event loop - P1: ID mismatch in disable_schedule/delete_schedule routes (job_id passed instead of schedule_id) - P1: asyncio.run() in APScheduler callbacks incompatible with running event loop - Delete unused llm_analysis/scheduler.py (not used in production) Changes: core/async_job_runner.py — new: AsyncJobRunner class core/scheduler.py — use runner.run()/run_later() translate/scheduler.py — use runner.run() for execute_run mapping_service.py — remove unused BackgroundScheduler dependencies.py — add get_async_job_runner() DI app.py — init runner in lifespan api/routes/migration.py — use runner.run() _schedule_routes.py — fix ID mismatch plugins/migration.py — use runner.run() llm_analysis/scheduler.py — delete (unused) tests: 151 new/updated tests, all passing
This commit is contained in:
@@ -1,136 +0,0 @@
|
||||
# #region Test.LLMAnalysisScheduler [C:3] [TYPE Module] [SEMANTICS test, llm, analysis, scheduler, cron]
|
||||
# @BRIEF Tests for llm_analysis/scheduler.py — _parse_cron, schedule_dashboard_validation.
|
||||
# @RELATION BINDS_TO -> [LLMAnalysisScheduler]
|
||||
# @TEST_CONTRACT: _parse_cron -> dict | cron expression parsing
|
||||
# @TEST_CONTRACT: schedule_dashboard_validation -> None | schedules a validation job
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.llm_analysis.scheduler import _parse_cron, schedule_dashboard_validation
|
||||
|
||||
|
||||
class TestParseCron:
|
||||
"""_parse_cron — basic cron expression parser."""
|
||||
|
||||
def test_standard_5_part_cron(self):
|
||||
"""Standard 5-part cron expression parsed correctly."""
|
||||
result = _parse_cron("0 8 * * 1-5")
|
||||
assert result == {
|
||||
"minute": "0",
|
||||
"hour": "8",
|
||||
"day": "*",
|
||||
"month": "*",
|
||||
"day_of_week": "1-5",
|
||||
}
|
||||
|
||||
def test_daily_at_midnight(self):
|
||||
"""Daily at midnight."""
|
||||
result = _parse_cron("0 0 * * *")
|
||||
assert result["hour"] == "0"
|
||||
assert result["minute"] == "0"
|
||||
|
||||
def test_every_hour(self):
|
||||
"""Every hour at minute 0."""
|
||||
result = _parse_cron("0 * * * *")
|
||||
assert result["minute"] == "0"
|
||||
assert result["hour"] == "*"
|
||||
|
||||
def test_invalid_parts_count(self):
|
||||
"""Less or more than 5 parts returns empty dict."""
|
||||
assert _parse_cron("0 8 * *") == {}
|
||||
assert _parse_cron("0 8 * * 1-5 extra") == {}
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Empty string returns empty dict."""
|
||||
assert _parse_cron("") == {}
|
||||
|
||||
def test_step_values(self):
|
||||
"""Step values like */15 parsed correctly."""
|
||||
result = _parse_cron("*/15 * * * *")
|
||||
assert result["minute"] == "*/15"
|
||||
|
||||
|
||||
class TestScheduleDashboardValidation:
|
||||
"""schedule_dashboard_validation — schedules recurring validation."""
|
||||
|
||||
def test_schedule_with_params(self):
|
||||
"""Scheduler and task manager called with correct args."""
|
||||
mock_scheduler = MagicMock()
|
||||
mock_task_manager = MagicMock()
|
||||
|
||||
with patch("src.plugins.llm_analysis.scheduler.get_scheduler_service", return_value=mock_scheduler), \
|
||||
patch("src.plugins.llm_analysis.scheduler.get_task_manager", return_value=mock_task_manager):
|
||||
|
||||
schedule_dashboard_validation(
|
||||
dashboard_id="dash-1",
|
||||
cron_expression="0 6 * * *",
|
||||
params={"threshold": 0.8},
|
||||
)
|
||||
|
||||
# Scheduler should have added a cron job
|
||||
mock_scheduler.add_job.assert_called_once()
|
||||
call_args = mock_scheduler.add_job.call_args
|
||||
assert call_args[0][1] == "cron" # trigger type
|
||||
assert call_args[1]["id"] == "llm_val_dash-1"
|
||||
assert call_args[1]["replace_existing"] is True
|
||||
assert call_args[1]["minute"] == "0"
|
||||
assert call_args[1]["hour"] == "6"
|
||||
|
||||
def test_schedule_creates_task_on_execution(self):
|
||||
"""The scheduled job function creates a task when executed."""
|
||||
mock_scheduler = MagicMock()
|
||||
mock_task_manager = AsyncMock()
|
||||
|
||||
with patch("src.plugins.llm_analysis.scheduler.get_scheduler_service", return_value=mock_scheduler), \
|
||||
patch("src.plugins.llm_analysis.scheduler.get_task_manager", return_value=mock_task_manager):
|
||||
|
||||
schedule_dashboard_validation(
|
||||
dashboard_id="dash-1",
|
||||
cron_expression="0 6 * * *",
|
||||
params={"threshold": 0.8},
|
||||
)
|
||||
|
||||
# Get the job function and call it
|
||||
job_func = mock_scheduler.add_job.call_args[0][0]
|
||||
import asyncio
|
||||
asyncio.run(job_func())
|
||||
|
||||
mock_task_manager.create_task.assert_called_once_with(
|
||||
plugin_id="llm_dashboard_validation",
|
||||
params={"dashboard_id": "dash-1", "threshold": 0.8},
|
||||
)
|
||||
|
||||
def test_schedule_without_params(self):
|
||||
"""Schedule without extra params still works."""
|
||||
mock_scheduler = MagicMock()
|
||||
mock_task_manager = AsyncMock()
|
||||
|
||||
with patch("src.plugins.llm_analysis.scheduler.get_scheduler_service", return_value=mock_scheduler), \
|
||||
patch("src.plugins.llm_analysis.scheduler.get_task_manager", return_value=mock_task_manager):
|
||||
|
||||
schedule_dashboard_validation(
|
||||
dashboard_id="dash-2",
|
||||
cron_expression="0 0 * * *",
|
||||
params={},
|
||||
)
|
||||
|
||||
job_func = mock_scheduler.add_job.call_args[0][0]
|
||||
import asyncio
|
||||
asyncio.run(job_func())
|
||||
|
||||
mock_task_manager.create_task.assert_called_once_with(
|
||||
plugin_id="llm_dashboard_validation",
|
||||
params={"dashboard_id": "dash-2"},
|
||||
)
|
||||
# #endregion Test.LLMAnalysisScheduler
|
||||
@@ -402,6 +402,9 @@ class TestExecuteScheduledTranslation:
|
||||
patch(
|
||||
"src.plugins.translate.scheduler.NotificationService",
|
||||
) as MockNotif,
|
||||
patch(
|
||||
"src.dependencies.get_async_job_runner",
|
||||
) as MockRunner,
|
||||
):
|
||||
mock_orch = MagicMock()
|
||||
mock_run = MagicMock()
|
||||
@@ -412,7 +415,12 @@ class TestExecuteScheduledTranslation:
|
||||
mock_orch.execute_run.side_effect = RuntimeError("LLM call failed")
|
||||
MockOrch.return_value = mock_orch
|
||||
|
||||
# Provider mock — send must return a real coroutine for asyncio.run()
|
||||
# Mock the runner to raise the exception from execute_run
|
||||
mock_runner = MagicMock()
|
||||
mock_runner.run.side_effect = RuntimeError("LLM call failed")
|
||||
MockRunner.return_value = mock_runner
|
||||
|
||||
# Provider mock — send must return a real coroutine for runner.run()
|
||||
async def _dummy_send(*args, **kwargs):
|
||||
pass
|
||||
mock_provider = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user