Skip to content

Commit a7cc4e3

Browse files
committed
fix: change workflow solution to avoid missing last job trigger for analysis full update
1 parent cec6644 commit a7cc4e3

2 files changed

Lines changed: 90 additions & 40 deletions

File tree

src/sum_impact_assessment/services/full_impact_refresh_service.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from __future__ import annotations
55

66
import asyncio
7+
import threading
8+
import time
79
from datetime import datetime, timedelta
810
from typing import Any, Dict, List
911

@@ -69,14 +71,11 @@ def build_initial_dispatch_state(plan: List[Dict[str, Any]]) -> List[Dict[str, A
6971

7072
def dispatch_full_refresh_sync(parent_run_id: str, plan: List[Dict[str, Any]]) -> None:
7173
"""
72-
Run the async dispatcher from a sync entrypoint compatible with BackgroundTasks.
73-
"""
74-
asyncio.run(dispatch_full_refresh(parent_run_id, plan))
75-
74+
Create child job runs for the configured plan and launch each execution in a dedicated thread.
7675
77-
async def dispatch_full_refresh(parent_run_id: str, plan: List[Dict[str, Any]]) -> None:
78-
"""
79-
Create child job runs for the configured plan and launch each execution asynchronously.
76+
Threads are independent of the event loop lifetime, ensuring every dispatched job is started —
77+
including the last one in the sequence (which was previously vulnerable to asyncio task
78+
cancellation when the loop closed before the task had a turn to run).
8079
"""
8180
dispatch_failures = 0
8281
dispatch_count = len(plan)
@@ -122,14 +121,20 @@ async def dispatch_full_refresh(parent_run_id: str, plan: List[Dict[str, Any]])
122121
},
123122
)
124123

125-
asyncio.create_task(
126-
asyncio.to_thread(
127-
execute_job_in_background,
128-
job_name,
129-
child_job_run_id,
130-
params,
131-
)
124+
logger.info(
125+
"Child job thread spawned",
126+
extra={
127+
"parent_run_id": parent_run_id,
128+
"child_run_id": child_job_run_id,
129+
"job_name": plan_item["job_name"],
130+
"sequence": sequence,
131+
},
132132
)
133+
threading.Thread(
134+
target=execute_job_in_background,
135+
args=(job_name, child_job_run_id, params),
136+
daemon=False,
137+
).start()
133138
except Exception as error:
134139
dispatch_failures += 1
135140
logger.error(
@@ -154,7 +159,7 @@ async def dispatch_full_refresh(parent_run_id: str, plan: List[Dict[str, Any]])
154159
)
155160

156161
if index < dispatch_count - 1:
157-
await asyncio.sleep(settings.REFRESH_DISPATCH_INTERVAL_SECONDS)
162+
time.sleep(settings.REFRESH_DISPATCH_INTERVAL_SECONDS)
158163

159164
with get_db_session() as db:
160165
job_repository = JobRepository(db)
@@ -187,6 +192,13 @@ async def dispatch_full_refresh(parent_run_id: str, plan: List[Dict[str, Any]])
187192
)
188193

189194

195+
async def dispatch_full_refresh(parent_run_id: str, plan: List[Dict[str, Any]]) -> None:
196+
"""
197+
Async wrapper around dispatch_full_refresh_sync for use from async contexts.
198+
"""
199+
await asyncio.to_thread(dispatch_full_refresh_sync, parent_run_id, plan)
200+
201+
190202
def build_trigger_response(plan: List[Dict[str, Any]], parent_run_id: str, started_at: datetime) -> FullImpactRefreshTriggerResponse:
191203
"""
192204
Build the trigger response for the newly accepted refresh run.

tests/test_services/test_full_impact_refresh_service.py

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,15 @@ def test_build_initial_dispatch_state_sets_pending_status():
4141
assert all(item["job_run_id"] is None for item in dispatch_state)
4242

4343

44-
@pytest.mark.anyio
45-
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.asyncio.create_task")
46-
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.asyncio.to_thread", new_callable=Mock)
47-
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.asyncio.sleep")
44+
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.threading.Thread")
45+
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.time.sleep")
4846
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.JobRepository")
4947
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.get_db_session")
50-
async def test_dispatch_full_refresh_dispatches_all_children(
48+
def test_dispatch_full_refresh_sync_dispatches_all_children(
5149
mock_get_db_session,
5250
mock_job_repository_class,
5351
mock_sleep,
54-
mock_to_thread,
55-
mock_create_task,
52+
mock_thread_class,
5653
):
5754
"""Dispatching the full refresh should create all child job runs and mark the parent successful."""
5855
started_at = datetime(2026, 5, 12, 10, 0, 0)
@@ -66,14 +63,13 @@ async def test_dispatch_full_refresh_dispatches_all_children(
6663
repo_instance = Mock()
6764
repo_instance.create_job_run.side_effect = child_job_runs
6865
mock_job_repository_class.return_value = repo_instance
69-
mock_to_thread.return_value = Mock()
7066

7167
db_context = Mock()
7268
db_context.__enter__ = Mock(return_value=Mock())
7369
db_context.__exit__ = Mock(return_value=None)
7470
mock_get_db_session.return_value = db_context
7571

76-
await dispatch_full_refresh("parent-1", plan)
72+
dispatch_full_refresh_sync("parent-1", plan)
7773

7874
assert repo_instance.create_job_run.call_count == 7
7975
assert repo_instance.update_dispatched_job.call_count == 7
@@ -84,27 +80,69 @@ async def test_dispatch_full_refresh_dispatches_all_children(
8480
)
8581
final_call = repo_instance.update_job_status.call_args_list[-1]
8682
assert final_call.kwargs["status"] == JobStatusEnum.SUCCESS
87-
assert mock_create_task.call_count == 7
88-
assert mock_sleep.await_count == 6
89-
first_dispatch = mock_to_thread.call_args_list[0]
90-
assert first_dispatch.args[2] == "child-0"
83+
assert mock_thread_class.call_count == 7
84+
assert mock_thread_class.return_value.start.call_count == 7
85+
assert mock_sleep.call_count == 6 # no trailing sleep after the last job
86+
first_thread_call = mock_thread_class.call_args_list[0]
87+
assert first_thread_call.kwargs["args"][1] == "child-0"
9188
first_update = repo_instance.update_dispatched_job.call_args_list[0]
9289
assert first_update.args[2]["job_run_id"] == "child-0"
9390
assert first_update.args[2]["scheduled_at"] == "2026-05-12T10:00:00"
9491

9592

96-
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.asyncio.run")
97-
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.dispatch_full_refresh", new_callable=Mock)
98-
def test_dispatch_full_refresh_sync_uses_asyncio_run(
99-
mock_dispatch_full_refresh,
100-
mock_asyncio_run,
93+
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.threading.Thread")
94+
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.time.sleep")
95+
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.JobRepository")
96+
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.get_db_session")
97+
def test_dispatch_full_refresh_sync_all_seven_children_are_started(
98+
mock_get_db_session,
99+
mock_job_repository_class,
100+
mock_sleep,
101+
mock_thread_class,
101102
):
102-
"""The sync wrapper should delegate the coroutine through asyncio.run."""
103-
plan = [{"job_name": "kpi_measures_analysis"}]
104-
mock_coroutine = object()
105-
mock_dispatch_full_refresh.return_value = mock_coroutine
103+
"""Regression: all 7 configured jobs must be passed to a started Thread — including the last one.
106104
107-
dispatch_full_refresh_sync("parent-1", plan)
105+
Previously the last child (mcda_analysis_qualitative_nsm_providers) was never executed because
106+
asyncio.create_task was used without awaiting, and asyncio.run() closed the loop before the
107+
task had a chance to run in Docker environments with CPU contention.
108+
"""
109+
started_at = datetime(2026, 5, 12, 10, 0, 0)
110+
plan = build_dispatch_plan(started_at)
111+
112+
child_job_runs = [
113+
Mock(id=f"child-{i}", created_at=datetime(2026, 5, 12, 10, 0, i))
114+
for i in range(len(plan))
115+
]
116+
117+
repo_instance = Mock()
118+
repo_instance.create_job_run.side_effect = child_job_runs
119+
mock_job_repository_class.return_value = repo_instance
120+
121+
db_context = Mock()
122+
db_context.__enter__ = Mock(return_value=Mock())
123+
db_context.__exit__ = Mock(return_value=None)
124+
mock_get_db_session.return_value = db_context
125+
126+
dispatch_full_refresh_sync("parent-reg", plan)
127+
128+
thread_child_ids = [
129+
call.kwargs["args"][1]
130+
for call in mock_thread_class.call_args_list
131+
]
132+
expected_child_ids = [f"child-{i}" for i in range(7)]
133+
assert thread_child_ids == expected_child_ids, (
134+
f"Expected all 7 children to be started, got: {thread_child_ids}"
135+
)
136+
137+
138+
@pytest.mark.anyio
139+
@patch("src.sum_impact_assessment.services.full_impact_refresh_service.dispatch_full_refresh_sync")
140+
async def test_dispatch_full_refresh_delegates_to_sync(
141+
mock_dispatch_full_refresh_sync,
142+
):
143+
"""The async wrapper should delegate to dispatch_full_refresh_sync."""
144+
plan = build_dispatch_plan(datetime(2026, 5, 12, 10, 0, 0))
145+
146+
await dispatch_full_refresh("parent-1", plan)
108147

109-
mock_dispatch_full_refresh.assert_called_once_with("parent-1", plan)
110-
mock_asyncio_run.assert_called_once_with(mock_coroutine)
148+
mock_dispatch_full_refresh_sync.assert_called_once_with("parent-1", plan)

0 commit comments

Comments
 (0)