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:
2026-06-17 16:22:30 +03:00
parent f32a9e9648
commit 4726fd110d
17 changed files with 969 additions and 374 deletions

View File

@@ -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

View File

@@ -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()