Skip to content

Commit 137ecee

Browse files
authored
Merge pull request #307 from buildingSMART/IVS-673_Premature_Progress_Completion
IVS-673: advance progress only after a task completes
2 parents d1f0424 + 77e7ad3 commit 137ecee

2 files changed

Lines changed: 114 additions & 7 deletions

File tree

backend/apps/ifc_validation/tasks/task_runner.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
from celery import shared_task, chain, chord, group
66
from celery.exceptions import SoftTimeLimitExceeded
77

8+
from django.db.models import F, Value
9+
from django.db.models.functions import Least
10+
811
from core.redis_lock import acquire_user_lock, LockError
912
from core.utils import log_execution
1013

@@ -192,11 +195,6 @@ def validation_subtask_runner(self, *args, **kwargs):
192195
else: # for testing, we're not instantiating a model
193196
invalid_blockers = []
194197

195-
# update progress
196-
increment = config.increment
197-
request.progress = min(request.progress + increment, 100)
198-
request.save()
199-
200198
# run or skip
201199
if not invalid_blockers:
202200
task.mark_as_initiated()
@@ -224,12 +222,19 @@ def validation_subtask_runner(self, *args, **kwargs):
224222
logger.exception(f"Processing failed in task {task_type}: {err}")
225223
return
226224

227-
# Handle skipped tasks
225+
# Handle skipped tasks
228226
else:
229227
reason = f"Skipped due to fail in blocking tasks: {', '.join(invalid_blockers)}"
230228
logger.debug(reason)
231229
task.mark_as_skipped(reason)
232-
230+
231+
# Advance progress only after the work is done, so a request never shows
232+
# 100% while a long-running task (e.g. instance completion) is still running.
233+
# Failed tasks returned early above. Atomic: parallel tasks increment together.
234+
ValidationRequest.objects.filter(pk=id).update(
235+
progress=Least(F("progress") + config.increment, Value(100))
236+
)
237+
233238
validation_subtask_runner.__doc__ = f"Validation task for {task_type} generated by the task_factory func."
234239
return validation_subtask_runner
235240

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from contextlib import contextmanager
2+
from unittest import mock
3+
4+
from django.test import TransactionTestCase
5+
from django.contrib.auth.models import User
6+
7+
from apps.ifc_validation_models.models import (
8+
ValidationRequest,
9+
ValidationTask,
10+
set_user_context,
11+
)
12+
13+
from ..tasks.configs import task_registry
14+
from ..tasks import schema_validation_subtask
15+
import apps.ifc_validation.tasks.task_runner as task_runner
16+
17+
18+
@contextmanager
19+
def _noop_lock(*args, **kwargs):
20+
# Replaces the redis-backed user task lock so these tests stay hermetic.
21+
yield None
22+
23+
24+
@mock.patch.object(task_runner, "acquire_user_lock", _noop_lock)
25+
class ProgressUpdateTaskTestCase(TransactionTestCase):
26+
"""Progress must advance only after a subtask's work finishes, so a request
27+
never reports 100% while a task is still running."""
28+
29+
TASK_TYPE = ValidationTask.Type.SCHEMA # increment = 10
30+
31+
def setUp(self):
32+
user, _ = User.objects.get_or_create(
33+
id=1, defaults={"username": "SYSTEM", "is_active": True}
34+
)
35+
set_user_context(user)
36+
self.config = task_registry[self.TASK_TYPE]
37+
self.increment = self.config.increment
38+
39+
def _make_request(self, progress=0):
40+
request = ValidationRequest.objects.create(
41+
file_name="valid_file.ifc", file="valid_file.ifc", size=280
42+
)
43+
request.mark_as_initiated() # progress -> 0
44+
if progress:
45+
ValidationRequest.objects.filter(pk=request.id).update(progress=progress)
46+
return request
47+
48+
def _run(self, request):
49+
schema_validation_subtask(
50+
prev_result={"is_valid": True, "reason": "test"},
51+
id=request.id,
52+
file_name=request.file_name,
53+
)
54+
55+
def test_progress_advances_only_after_task_work_completes(self):
56+
request = self._make_request()
57+
seen = {}
58+
59+
def fake_check(context):
60+
seen["during_check"] = ValidationRequest.objects.get(pk=request.id).progress
61+
return context
62+
63+
def fake_process(context):
64+
seen["during_process"] = ValidationRequest.objects.get(pk=request.id).progress
65+
return "ok"
66+
67+
with mock.patch.object(self.config, "check_program", side_effect=fake_check), \
68+
mock.patch.object(self.config, "process_results", side_effect=fake_process):
69+
self._run(request)
70+
71+
# During the task body, progress must not have advanced (the bug: it was
72+
# bumped before the work ran).
73+
self.assertEqual(seen["during_check"], 0)
74+
self.assertEqual(seen["during_process"], 0)
75+
76+
# It advances by the increment only after the task finishes.
77+
request.refresh_from_db()
78+
self.assertEqual(request.progress, self.increment)
79+
80+
def test_progress_is_clamped_to_100(self):
81+
request = self._make_request(progress=95) # 95 + 10 would overflow to 105
82+
83+
with mock.patch.object(self.config, "check_program", return_value=None), \
84+
mock.patch.object(self.config, "process_results", return_value="ok"):
85+
self._run(request)
86+
87+
request.refresh_from_db()
88+
self.assertEqual(request.progress, 100)
89+
90+
def test_failed_task_does_not_advance_progress(self):
91+
request = self._make_request()
92+
93+
with mock.patch.object(self.config, "check_program", side_effect=RuntimeError("boom")):
94+
self._run(request)
95+
96+
request.refresh_from_db()
97+
self.assertEqual(request.progress, 0)
98+
99+
task = ValidationTask.objects.filter(
100+
request_id=request.id, type=self.TASK_TYPE
101+
).first()
102+
self.assertEqual(task.status, ValidationTask.Status.FAILED)

0 commit comments

Comments
 (0)