Skip to content

Commit 0197f2c

Browse files
authored
Merge pull request #309 from buildingSMART/IVS-750_Report_Workflow_Failure_Reason
IVS-750 report workflow failure reason
2 parents 137ecee + 92f0cb6 commit 0197f2c

2 files changed

Lines changed: 140 additions & 1 deletion

File tree

backend/apps/ifc_validation/tasks/task_runner.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,19 @@ def on_workflow_failed(self, *args, **kwargs):
159159

160160
# update status
161161
id = args[1]
162-
reason = f"Processing failed: args={args} kwargs={kwargs}"
163162
request = ValidationRequest.objects.get(pk=id)
163+
164+
# Both error callbacks can fire for one failure; skip if already finalized so the
165+
# failure emails aren't sent twice.
166+
if request.status == ValidationRequest.Status.FAILED:
167+
logger.debug(f"Request {id} already marked FAILED; skipping duplicate failure handling.")
168+
return
169+
170+
# Record a readable reason rather than the raw celery args/kwargs (full detail is logged above).
171+
exc = next((a for a in args if isinstance(a, BaseException)), None)
172+
reason = "Validation failed before any check could run (workflow-level error)."
173+
if exc:
174+
reason += f" Error: {exc}"
164175
request.mark_as_failed(reason)
165176

166177
# queue sending email
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""
2+
Workflow-level failure handling in on_workflow_failed.
3+
4+
Fixes two defects without fabricating a synthetic task:
5+
1. it could fire twice (link_error + chord on_error), double-sending failure
6+
emails -> idempotency guard;
7+
2. it dumped raw celery args/kwargs into status_reason -> readable reason instead.
8+
9+
The failed request keeps zero tasks by design: the failure is explained on the
10+
request itself rather than by inventing a task that never ran.
11+
"""
12+
13+
from unittest.mock import patch
14+
15+
from django.test import TestCase
16+
from django.contrib.auth.models import User
17+
18+
from apps.ifc_validation_models.models import (
19+
set_user_context,
20+
ValidationRequest,
21+
ValidationTask,
22+
)
23+
24+
from apps.ifc_validation.tasks.task_runner import (
25+
on_workflow_failed,
26+
on_workflow_completed,
27+
)
28+
29+
30+
class OrphanFailedRequestElegantTestCase(TestCase):
31+
32+
@staticmethod
33+
def _set_user_context():
34+
user, _ = User.objects.get_or_create(
35+
id=1, defaults={'username': 'SYSTEM', 'is_active': True},
36+
)
37+
set_user_context(user)
38+
return user
39+
40+
def _make_request(self):
41+
user = self._set_user_context()
42+
request = ValidationRequest.objects.create(
43+
file_name='orphan_file.ifc', file='orphan_file.ifc', size=123,
44+
)
45+
request.mark_as_initiated()
46+
if request.created_by_id is None:
47+
request.created_by = user
48+
request.save()
49+
return request
50+
51+
# --- the failure is now FAILED with a clean reason, and no raw arg dump ---
52+
@patch('apps.ifc_validation.tasks.task_runner.send_failure_admin_email_task')
53+
@patch('apps.ifc_validation.tasks.task_runner.send_failure_email_task')
54+
def test_orphan_failure_sets_clean_reason(self, mock_user_email, mock_admin_email):
55+
request = self._make_request()
56+
57+
result = on_workflow_failed.apply(args=[None, request.id], kwargs={})
58+
if result.failed():
59+
self.fail(f"on_workflow_failed raised: {result.traceback}")
60+
61+
request.refresh_from_db()
62+
63+
self.assertEqual(request.status, ValidationRequest.Status.FAILED)
64+
self.assertTrue(
65+
request.status_reason.startswith("Validation failed before any check could run"),
66+
f"reason should be human-readable, got: {request.status_reason!r}",
67+
)
68+
# No raw celery dump leaked into the user-visible reason.
69+
self.assertNotIn("args=", request.status_reason)
70+
self.assertNotIn("kwargs=", request.status_reason)
71+
# Option B intentionally does NOT create a synthetic task.
72+
self.assertEqual(request.tasks.count(), 0)
73+
mock_user_email.delay.assert_called_once()
74+
mock_admin_email.delay.assert_called_once()
75+
76+
# --- firing twice no longer double-sends emails ---
77+
@patch('apps.ifc_validation.tasks.task_runner.send_failure_admin_email_task')
78+
@patch('apps.ifc_validation.tasks.task_runner.send_failure_email_task')
79+
def test_double_fire_is_idempotent(self, mock_user_email, mock_admin_email):
80+
request = self._make_request()
81+
82+
first = on_workflow_failed.apply(args=[None, request.id], kwargs={})
83+
second = on_workflow_failed.apply(args=[None, request.id], kwargs={})
84+
for r in (first, second):
85+
if r.failed():
86+
self.fail(f"on_workflow_failed raised: {r.traceback}")
87+
88+
request.refresh_from_db()
89+
self.assertEqual(request.status, ValidationRequest.Status.FAILED)
90+
self.assertEqual(mock_user_email.delay.call_count, 1,
91+
"failure email must be sent exactly once across both firings")
92+
self.assertEqual(mock_admin_email.delay.call_count, 1,
93+
"admin failure email must be sent exactly once across both firings")
94+
95+
# --- when an exception is available it is surfaced in the reason ---
96+
@patch('apps.ifc_validation.tasks.task_runner.send_failure_admin_email_task')
97+
@patch('apps.ifc_validation.tasks.task_runner.send_failure_email_task')
98+
def test_reason_includes_exception_when_present(self, mock_user_email, mock_admin_email):
99+
request = self._make_request()
100+
101+
# error callbacks may carry the original exception; id is still args[1].
102+
result = on_workflow_failed.apply(args=[ValueError("boom in syntax"), request.id], kwargs={})
103+
if result.failed():
104+
self.fail(f"on_workflow_failed raised: {result.traceback}")
105+
106+
request.refresh_from_db()
107+
self.assertIn("boom in syntax", request.status_reason)
108+
109+
# --- regression: the in-workflow failure path still has its relating task ---
110+
@patch('apps.ifc_validation.tasks.task_runner.send_failure_admin_email_task')
111+
@patch('apps.ifc_validation.tasks.task_runner.send_failure_email_task')
112+
def test_completed_failure_path_has_relating_task(self, mock_user_email, mock_admin_email):
113+
request = self._make_request()
114+
ValidationTask.objects.create(
115+
request=request,
116+
type=ValidationTask.Type.SYNTAX,
117+
status=ValidationTask.Status.FAILED,
118+
)
119+
120+
result = on_workflow_completed.apply(args=[None], kwargs={'id': request.id})
121+
if result.failed():
122+
self.fail(f"on_workflow_completed raised: {result.traceback}")
123+
124+
request.refresh_from_db()
125+
self.assertEqual(request.status, ValidationRequest.Status.FAILED)
126+
self.assertEqual(request.tasks.count(), 1)
127+
mock_user_email.delay.assert_called_once()
128+
mock_admin_email.delay.assert_called_once()

0 commit comments

Comments
 (0)