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

@@ -0,0 +1,256 @@
# #region Test.AsyncJobRunner [C:3] [TYPE Module] [SEMANTICS test,async,runner,scheduler]
# @BRIEF Unit tests for AsyncJobRunner — centralized async/sync bridge.
# @RELATION BINDS_TO -> [AsyncJobRunner]
# @TEST_CONTRACT: AsyncJobRunner.run -> result | executes coroutine and returns result
# @TEST_CONTRACT: AsyncJobRunner.run_later -> None | schedules delayed coroutine execution
# @TEST_EDGE: run_propagates_exception -> exception from coroutine is re-raised
# @TEST_EDGE: run_later_executes_after_delay -> coroutine runs after specified delay
# @TEST_EDGE: run_from_background_thread -> works correctly from non-main thread
# @TEST_INVARIANT: coroutine_must_be_awaited -> VERIFIED_BY: [test_run_executes_coroutine, test_run_propagates_exception]
import asyncio
import sys
import threading
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from src.core.async_job_runner import AsyncJobRunner
# -- Helpers -----------------------------------------------------------------
async def async_return_value(value):
"""Simple async function that returns a value."""
return value
async def async_raise(exc_class, message=""):
"""Simple async function that raises an exception."""
raise exc_class(message)
async def async_delay(value, delay_seconds):
"""Async function that sleeps then returns a value."""
await asyncio.sleep(delay_seconds)
return value
# -- Fixtures ----------------------------------------------------------------
@pytest.fixture
def event_loop():
"""Create a new event loop for each test."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture
def runner(event_loop):
"""Create an AsyncJobRunner with a new event loop."""
return AsyncJobRunner(event_loop)
# -- Tests: run() ------------------------------------------------------------
# #region test_run_executes_coroutine [C:2] [TYPE Function]
# @BRIEF run() executes a coroutine and returns its result.
def test_run_executes_coroutine(runner, event_loop):
"""run() dispatches coroutine to event loop and waits for result."""
# Start the event loop in a background thread
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
result = runner.run(async_return_value(42))
assert result == 42
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_executes_coroutine
# #region test_run_returns_string [C:2] [TYPE Function]
# @BRIEF run() works with string return values.
def test_run_returns_string(runner, event_loop):
"""run() correctly returns string results."""
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
result = runner.run(async_return_value("hello"))
assert result == "hello"
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_returns_string
# #region test_run_returns_none [C:2] [TYPE Function]
# @BRIEF run() works when coroutine returns None.
def test_run_returns_none(runner, event_loop):
"""run() correctly handles None return value."""
async def async_none():
return None
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
result = runner.run(async_none())
assert result is None
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_returns_none
# #region test_run_propagates_exception [C:2] [TYPE Function]
# @BRIEF run() re-raises exceptions from the coroutine.
def test_run_propagates_exception(runner, event_loop):
"""run() re-raises exceptions from the coroutine in the calling thread."""
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
with pytest.raises(ValueError, match="boom"):
runner.run(async_raise(ValueError, "boom"))
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_propagates_exception
# #region test_run_propagates_runtime_error [C:2] [TYPE Function]
# @BRIEF run() re-raises RuntimeError from the coroutine.
def test_run_propagates_runtime_error(runner, event_loop):
"""run() re-raises RuntimeError from the coroutine."""
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
with pytest.raises(RuntimeError, match="connection lost"):
runner.run(async_raise(RuntimeError, "connection lost"))
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_propagates_runtime_error
# -- Tests: run_later() ------------------------------------------------------
# #region test_run_later_executes_after_delay [C:2] [TYPE Function]
# @BRIEF run_later() schedules a coroutine to execute after a delay.
def test_run_later_executes_after_delay(runner, event_loop):
"""run_later() dispatches coroutine after specified delay."""
called = []
async def track():
called.append(True)
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
runner.run_later(track(), delay_seconds=0.1)
time.sleep(0.3)
assert len(called) == 1, f"Expected coroutine to execute once, got {len(called)} times"
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_later_executes_after_delay
# #region test_run_later_does_not_execute_immediately [C:2] [TYPE Function]
# @BRIEF run_later() does not execute coroutine before the delay.
def test_run_later_does_not_execute_immediately(runner, event_loop):
"""run_later() does not execute coroutine before the delay expires."""
called = []
async def track():
called.append(True)
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
runner.run_later(track(), delay_seconds=0.5)
time.sleep(0.1) # Check before delay expires
assert len(called) == 0, "Coroutine should not have executed yet"
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_later_does_not_execute_immediately
# -- Tests: from background thread ------------------------------------------
# #region test_run_from_background_thread [C:2] [TYPE Function]
# @BRIEF run() works correctly when called from a non-main thread.
def test_run_from_background_thread(event_loop):
"""run() can be called from a background thread (simulating APScheduler)."""
runner = AsyncJobRunner(event_loop)
results = []
errors = []
def background_work():
try:
result = runner.run(async_return_value(99))
results.append(result)
except Exception as e:
errors.append(e)
# Start the event loop in a background thread
loop_thread = threading.Thread(target=event_loop.run_forever, daemon=True)
loop_thread.start()
try:
# Call run() from another background thread (simulating APScheduler)
work_thread = threading.Thread(target=background_work)
work_thread.start()
work_thread.join(timeout=5)
assert len(errors) == 0, f"Unexpected errors: {errors}"
assert results == [99], f"Expected [99], got {results}"
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
loop_thread.join(timeout=2)
# #endregion test_run_from_background_thread
# -- Tests: edge cases -------------------------------------------------------
# #region test_run_with_complex_return_value [C:2] [TYPE Function]
# @BRIEF run() works with complex return values (dict, list).
def test_run_with_complex_return_value(runner, event_loop):
"""run() correctly returns complex objects."""
async def async_dict():
return {"key": "value", "nested": [1, 2, 3]}
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
result = runner.run(async_dict())
assert result == {"key": "value", "nested": [1, 2, 3]}
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_with_complex_return_value
# #region test_run_later_with_zero_delay [C:2] [TYPE Function]
# @BRIEF run_later() with zero delay executes immediately.
def test_run_later_with_zero_delay(runner, event_loop):
"""run_later() with delay_seconds=0 executes immediately."""
called = []
async def track():
called.append(True)
thread = threading.Thread(target=event_loop.run_forever, daemon=True)
thread.start()
try:
runner.run_later(track(), delay_seconds=0)
time.sleep(0.2)
assert len(called) == 1, "Coroutine should have executed immediately"
finally:
event_loop.call_soon_threadsafe(event_loop.stop)
thread.join(timeout=2)
# #endregion test_run_later_with_zero_delay

View File

@@ -0,0 +1,188 @@
# #region Test.AsyncJobRunnerIntegration [C:3] [TYPE Module] [SEMANTICS test,async,runner,integration]
# @BRIEF Integration tests for AsyncJobRunner with real event loop and threading.
# @RELATION BINDS_TO -> [AsyncJobRunner]
# @TEST_CONTRACT: AsyncJobRunner.run_with_real_loop -> result | works with real asyncio event loop
# @TEST_CONTRACT: AsyncJobRunner.run_later_with_real_loop -> result | delayed execution works
# @TEST_EDGE: concurrent_runs -> multiple run() calls from different threads
# @TEST_EDGE: run_with_await -> coroutine with await works correctly
# @TEST_INVARIANT: thread_safety -> VERIFIED_BY: [test_concurrent_runs_from_multiple_threads]
import asyncio
import sys
import threading
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from src.core.async_job_runner import AsyncJobRunner
# -- Helpers -----------------------------------------------------------------
async def async_db_operation(value, delay=0.01):
"""Simulate an async DB operation."""
await asyncio.sleep(delay)
return {"result": value, "status": "ok"}
async def async_chain_operation(value):
"""Simulate chained async operations."""
result1 = await async_db_operation(value)
result2 = await async_db_operation(result1["result"] * 2)
return result2
# -- Tests: Integration with real event loop ---------------------------------
# #region test_run_with_real_event_loop [C:2] [TYPE Function]
# @BRIEF run() works with a real asyncio event loop running in a background thread.
def test_run_with_real_event_loop():
"""run() dispatches coroutine to a real event loop and waits for result."""
loop = asyncio.new_event_loop()
runner = AsyncJobRunner(loop)
def run_loop():
loop.run_forever()
loop_thread = threading.Thread(target=run_loop, daemon=True)
loop_thread.start()
try:
result = runner.run(async_db_operation("test"))
assert result == {"result": "test", "status": "ok"}
finally:
loop.call_soon_threadsafe(loop.stop)
loop_thread.join(timeout=2)
loop.close()
# #endregion test_run_with_real_event_loop
# #region test_run_later_with_real_event_loop [C:2] [TYPE Function]
# @BRIEF run_later() works with a real event loop — coroutine executes after delay.
def test_run_later_with_real_event_loop():
"""run_later() schedules coroutine on real event loop with correct timing."""
loop = asyncio.new_event_loop()
runner = AsyncJobRunner(loop)
execution_times = []
async def track_time():
execution_times.append(time.monotonic())
def run_loop():
loop.run_forever()
loop_thread = threading.Thread(target=run_loop, daemon=True)
loop_thread.start()
try:
start = time.monotonic()
runner.run_later(track_time(), delay_seconds=0.2)
time.sleep(0.5)
assert len(execution_times) == 1
elapsed = execution_times[0] - start
assert 0.15 < elapsed < 0.4, f"Expected ~0.2s delay, got {elapsed:.3f}s"
finally:
loop.call_soon_threadsafe(loop.stop)
loop_thread.join(timeout=2)
loop.close()
# #endregion test_run_later_with_real_event_loop
# #region test_concurrent_runs_from_multiple_threads [C:2] [TYPE Function]
# @BRIEF Multiple threads can call run() concurrently without deadlocks.
def test_concurrent_runs_from_multiple_threads():
"""run() is thread-safe — multiple threads can dispatch coroutines concurrently."""
loop = asyncio.new_event_loop()
runner = AsyncJobRunner(loop)
results = {}
errors = []
def worker(thread_id):
try:
result = runner.run(async_db_operation(f"thread-{thread_id}"))
results[thread_id] = result
except Exception as e:
errors.append((thread_id, e))
def run_loop():
loop.run_forever()
loop_thread = threading.Thread(target=run_loop, daemon=True)
loop_thread.start()
try:
threads = []
for i in range(5):
t = threading.Thread(target=worker, args=(i,))
threads.append(t)
t.start()
for t in threads:
t.join(timeout=10)
assert len(errors) == 0, f"Errors in threads: {errors}"
assert len(results) == 5
for i in range(5):
assert results[i]["result"] == f"thread-{i}"
finally:
loop.call_soon_threadsafe(loop.stop)
loop_thread.join(timeout=2)
loop.close()
# #endregion test_concurrent_runs_from_multiple_threads
# #region test_run_with_chained_await [C:2] [TYPE Function]
# @BRIEF run() works with coroutines that await other coroutines.
def test_run_with_chained_await():
"""run() correctly handles coroutines that internally await other coroutines."""
loop = asyncio.new_event_loop()
runner = AsyncJobRunner(loop)
def run_loop():
loop.run_forever()
loop_thread = threading.Thread(target=run_loop, daemon=True)
loop_thread.start()
try:
result = runner.run(async_chain_operation(5))
assert result == {"result": 10, "status": "ok"}
finally:
loop.call_soon_threadsafe(loop.stop)
loop_thread.join(timeout=2)
loop.close()
# #endregion test_run_with_chained_await
# #region test_run_later_multiple_tasks [C:2] [TYPE Function]
# @BRIEF Multiple run_later() calls schedule multiple coroutines correctly.
def test_run_later_multiple_tasks():
"""Multiple run_later() calls all execute their coroutines."""
loop = asyncio.new_event_loop()
runner = AsyncJobRunner(loop)
execution_order = []
async def track(name):
execution_order.append(name)
def run_loop():
loop.run_forever()
loop_thread = threading.Thread(target=run_loop, daemon=True)
loop_thread.start()
try:
runner.run_later(track("first"), delay_seconds=0.1)
runner.run_later(track("second"), delay_seconds=0.2)
runner.run_later(track("third"), delay_seconds=0.3)
time.sleep(0.5)
assert len(execution_order) == 3
assert execution_order == ["first", "second", "third"]
finally:
loop.call_soon_threadsafe(loop.stop)
loop_thread.join(timeout=2)
loop.close()
# #endregion test_run_later_multiple_tasks

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):