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

@@ -360,89 +360,6 @@ async def test_sync_environment_deletes_stale_mappings(db_session):
# #endregion test_sync_environment_deletes_stale_mappings
# #region test_start_scheduler_basic [C:2] [TYPE Function]
# @RELATION BINDS_TO ->[TestMappingService]
@patch("src.core.mapping_service.seed_trace_id")
@patch("src.core.mapping_service.BackgroundScheduler")
def test_start_scheduler_basic(mock_scheduler_cls, mock_seed, db_session):
"""start_scheduler adds job and starts scheduler."""
mock_scheduler = MagicMock()
mock_scheduler_cls.return_value = mock_scheduler
mock_scheduler.running = False
service = IdMappingService(db_session)
mock_factory = MagicMock(return_value=MagicMock())
service.start_scheduler("0 */5 * * *", ["env1", "env2"], mock_factory)
mock_scheduler.add_job.assert_called_once()
mock_scheduler.start.assert_called_once()
# #endregion test_start_scheduler_basic
# #region test_start_scheduler_sync_all_called [C:2] [TYPE Function]
# @RELATION BINDS_TO ->[TestMappingService]
def test_start_scheduler_sync_all_called(db_session):
"""sync_all inner function executed via scheduler.add_job (lines 75-80)."""
mock_scheduler = MagicMock()
mock_scheduler.running = False
captured_fn = [None]
def _capture_job(fn, *args, **kwargs):
captured_fn[0] = fn
return MagicMock(id="sync-job")
mock_scheduler.add_job.side_effect = _capture_job
mock_factory = MagicMock(return_value=MagicMock())
with patch("src.core.mapping_service.seed_trace_id") as mock_seed, \
patch("src.core.mapping_service.BackgroundScheduler", return_value=mock_scheduler):
service = IdMappingService(db_session)
with patch.object(service, 'sync_environment', return_value=None) as mock_sync:
service.start_scheduler("0 */5 * * *", ["env1", "env2"], mock_factory)
# Call the captured sync_all
captured_fn[0]()
mock_seed.assert_called_once()
assert mock_factory.call_count == 2
mock_factory.assert_any_call("env1")
mock_factory.assert_any_call("env2")
assert mock_sync.call_count == 2
# #endregion test_start_scheduler_sync_all_called
# #region test_start_scheduler_update_existing [C:2] [TYPE Function]
# @RELATION BINDS_TO ->[TestMappingService]
@patch("src.core.mapping_service.seed_trace_id")
@patch("src.core.mapping_service.BackgroundScheduler")
def test_start_scheduler_update_existing(mock_scheduler_cls, mock_seed, db_session):
"""start_scheduler with existing job → updates, doesn't re-start scheduler."""
mock_scheduler = MagicMock()
mock_scheduler_cls.return_value = mock_scheduler
mock_scheduler.running = False # Not running initially
service = IdMappingService(db_session)
mock_factory = MagicMock(return_value=MagicMock())
service.start_scheduler("0 */5 * * *", ["env1"], mock_factory)
# First call should start the scheduler (was not running)
mock_scheduler.start.assert_called_once()
# Second call with existing job — scheduler already running
mock_scheduler.running = True
service.start_scheduler("0 */10 * * *", ["env1"], mock_factory)
mock_scheduler.remove_job.assert_called_once() # called because _sync_job exists
assert mock_scheduler.add_job.call_count == 2
# #endregion test_start_scheduler_update_existing
# #region test_get_remote_id_value_error [C:2] [TYPE Function]
# @RELATION BINDS_TO ->[TestMappingService]
def test_get_remote_id_value_error(db_session):