Skip to content

Commit 8ce1fd8

Browse files
Store task exceptions (#68)
1 parent c935dd8 commit 8ce1fd8

File tree

10 files changed

+153
-18
lines changed

10 files changed

+153
-18
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ result.refresh()
153153
assert result.status == ResultStatus.COMPLETE
154154
```
155155

156+
#### Exceptions
157+
158+
If a task raised an exception, its `.result` will be the exception raised:
159+
160+
```python
161+
assert isinstance(result.result, ValueError)
162+
```
163+
164+
As part of the serialization process for exceptions, some information, such as the traceback information, is lost. If the exception could not be serialized, the `.result` is `None`.
165+
156166
### Backend introspecting
157167

158168
Because `django-tasks` enables support for multiple different backends, those backends may not support all features, and it can be useful to determine this at runtime to ensure the chosen task queue meets the requirements, or to gracefully degrade functionality if it doesn't.

django_tasks/backends/database/management/commands/db_worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def run_task(self, db_task_result: DBTaskResult) -> None:
121121
db_task_result.task_path,
122122
ResultStatus.FAILED,
123123
)
124-
db_task_result.set_failed()
124+
db_task_result.set_failed(e)
125125

126126
# If the user tried to terminate, let them
127127
if isinstance(e, KeyboardInterrupt):

django_tasks/backends/database/models.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import uuid
23
from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar
34

@@ -9,7 +10,9 @@
910
from typing_extensions import ParamSpec
1011

1112
from django_tasks.task import DEFAULT_QUEUE_NAME, ResultStatus, Task
12-
from django_tasks.utils import retry
13+
from django_tasks.utils import exception_to_dict, retry
14+
15+
logger = logging.getLogger("django_tasks.backends.database")
1316

1417
T = TypeVar("T")
1518
P = ParamSpec("P")
@@ -141,7 +144,12 @@ def set_result(self, result: Any) -> None:
141144
self.save(update_fields=["status", "result", "finished_at"])
142145

143146
@retry()
144-
def set_failed(self) -> None:
147+
def set_failed(self, exc: BaseException) -> None:
145148
self.status = ResultStatus.FAILED
146149
self.finished_at = timezone.now()
147-
self.save(update_fields=["status", "finished_at"])
150+
try:
151+
self.result = exception_to_dict(exc)
152+
except Exception:
153+
logger.exception("Task id=%s unable to save exception", self.id)
154+
self.result = None
155+
self.save(update_fields=["status", "finished_at", "result"])

django_tasks/backends/immediate.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from inspect import iscoroutinefunction
23
from typing import TypeVar
34
from uuid import uuid4
@@ -7,10 +8,13 @@
78
from typing_extensions import ParamSpec
89

910
from django_tasks.task import ResultStatus, Task, TaskResult
10-
from django_tasks.utils import json_normalize
11+
from django_tasks.utils import exception_to_dict, json_normalize
1112

1213
from .base import BaseTaskBackend
1314

15+
logger = logging.getLogger(__name__)
16+
17+
1418
T = TypeVar("T")
1519
P = ParamSpec("P")
1620

@@ -29,16 +33,21 @@ def enqueue(
2933

3034
enqueued_at = timezone.now()
3135
started_at = timezone.now()
36+
result_id = str(uuid4())
3237
try:
3338
result = json_normalize(calling_task_func(*args, **kwargs))
3439
status = ResultStatus.COMPLETE
35-
except Exception:
36-
result = None
40+
except Exception as e:
41+
try:
42+
result = exception_to_dict(e)
43+
except Exception:
44+
logger.exception("Task id=%s unable to save exception", result_id)
45+
result = None
3746
status = ResultStatus.FAILED
3847

3948
task_result = TaskResult[T](
4049
task=task,
41-
id=str(uuid4()),
50+
id=result_id,
4251
status=status,
4352
enqueued_at=enqueued_at,
4453
started_at=started_at,

django_tasks/task.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Optional,
1212
TypeVar,
1313
Union,
14+
cast,
1415
overload,
1516
)
1617

@@ -20,6 +21,7 @@
2021
from typing_extensions import ParamSpec, Self
2122

2223
from .exceptions import ResultDoesNotExist
24+
from .utils import exception_from_dict, get_module_path
2325

2426
if TYPE_CHECKING:
2527
from .backends.base import BaseTaskBackend
@@ -147,7 +149,7 @@ def get_backend(self) -> "BaseTaskBackend":
147149

148150
@property
149151
def module_path(self) -> str:
150-
return f"{self.func.__module__}.{self.func.__qualname__}"
152+
return get_module_path(self.func)
151153

152154

153155
# Bare decorator usage
@@ -220,20 +222,26 @@ class TaskResult(Generic[T]):
220222
backend: str
221223
"""The name of the backend the task will run on"""
222224

223-
_result: Optional[T] = field(init=False, default=None)
225+
_result: Optional[Union[T, dict]] = field(init=False, default=None)
224226

225227
@property
226-
def result(self) -> T:
227-
if self.status not in [ResultStatus.COMPLETE, ResultStatus.FAILED]:
228-
raise ValueError("Task has not finished yet")
229-
230-
return self._result # type:ignore
228+
def result(self) -> Optional[Union[T, BaseException]]:
229+
if self.status == ResultStatus.COMPLETE:
230+
return cast(T, self._result)
231+
elif self.status == ResultStatus.FAILED:
232+
return (
233+
exception_from_dict(cast(dict, self._result))
234+
if self._result is not None
235+
else None
236+
)
237+
238+
raise ValueError("Task has not finished yet")
231239

232240
def get_result(self) -> Optional[T]:
233241
"""
234-
A convenience method to get the result, or None if it's not ready yet.
242+
A convenience method to get the result, or None if it's not ready yet or has failed.
235243
"""
236-
return self._result
244+
return cast(T, self.result) if self.status == ResultStatus.COMPLETE else None
237245

238246
def refresh(self) -> None:
239247
"""

django_tasks/utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from functools import wraps
66
from typing import Any, Callable, TypeVar
77

8+
from django.utils.module_loading import import_string
89
from typing_extensions import ParamSpec
910

1011
T = TypeVar("T")
@@ -64,3 +65,23 @@ def inner_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: # type:ignore[return
6465
return inner_wrapper
6566

6667
return wrapper
68+
69+
70+
def get_module_path(val: Any) -> str:
71+
return f"{val.__module__}.{val.__qualname__}"
72+
73+
74+
def exception_to_dict(exc: BaseException) -> dict:
75+
return {
76+
"exc_type": get_module_path(type(exc)),
77+
"exc_args": json_normalize(exc.args),
78+
}
79+
80+
81+
def exception_from_dict(exc_data: dict) -> BaseException:
82+
exc_class = import_string(exc_data["exc_type"])
83+
84+
if not inspect.isclass(exc_class) or not issubclass(exc_class, BaseException):
85+
raise TypeError(f"{type(exc_class)} is not an exception")
86+
87+
return exc_class(*exc_data["exc_args"])

tests/tasks.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ def failing_task() -> None:
2626
raise ValueError("This task failed")
2727

2828

29+
@task()
30+
def complex_exception() -> None:
31+
raise ValueError(ValueError("This task failed"))
32+
33+
2934
@task()
3035
def exit_task() -> None:
3136
exit(1)

tests/tests/test_database_backend.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def test_enqueue_task(self) -> None:
4444
self.assertIsNone(result.finished_at)
4545
with self.assertRaisesMessage(ValueError, "Task has not finished yet"):
4646
result.result # noqa:B018
47+
self.assertIsNone(result.get_result())
4748
self.assertEqual(result.task, task)
4849
self.assertEqual(result.args, [1])
4950
self.assertEqual(result.kwargs, {"two": 3})
@@ -58,6 +59,7 @@ async def test_enqueue_task_async(self) -> None:
5859
self.assertIsNone(result.finished_at)
5960
with self.assertRaisesMessage(ValueError, "Task has not finished yet"):
6061
result.result # noqa:B018
62+
self.assertIsNone(result.get_result())
6163
self.assertEqual(result.task, task)
6264
self.assertEqual(result.args, [])
6365
self.assertEqual(result.kwargs, {})
@@ -307,6 +309,28 @@ def test_failing_task(self) -> None:
307309
self.assertGreaterEqual(result.started_at, result.enqueued_at) # type: ignore
308310
self.assertGreaterEqual(result.finished_at, result.started_at) # type: ignore
309311
self.assertEqual(result.status, ResultStatus.FAILED)
312+
self.assertIsInstance(result.result, ValueError)
313+
314+
self.assertEqual(DBTaskResult.objects.ready().count(), 0)
315+
316+
def test_complex_exception(self) -> None:
317+
result = test_tasks.complex_exception.enqueue()
318+
self.assertEqual(DBTaskResult.objects.ready().count(), 1)
319+
320+
with self.assertNumQueries(8), self.assertLogs(
321+
"django_tasks.backends.database", level="ERROR"
322+
):
323+
self.run_worker()
324+
325+
self.assertEqual(result.status, ResultStatus.NEW)
326+
result.refresh()
327+
self.assertIsNotNone(result.started_at)
328+
self.assertIsNotNone(result.finished_at)
329+
330+
self.assertGreaterEqual(result.started_at, result.enqueued_at) # type: ignore
331+
self.assertGreaterEqual(result.finished_at, result.started_at) # type: ignore
332+
self.assertEqual(result.status, ResultStatus.FAILED)
333+
self.assertIsNone(result.result)
310334

311335
self.assertEqual(DBTaskResult.objects.ready().count(), 0)
312336

tests/tests/test_immediate_backend.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ async def test_enqueue_task_async(self) -> None:
4444
self.assertGreaterEqual(result.started_at, result.enqueued_at)
4545
self.assertGreaterEqual(result.finished_at, result.started_at)
4646
self.assertIsNone(result.result)
47+
self.assertIsNone(result.get_result())
4748
self.assertEqual(result.task, task)
4849
self.assertEqual(result.args, [])
4950
self.assertEqual(result.kwargs, {})
@@ -56,11 +57,27 @@ def test_catches_exception(self) -> None:
5657
self.assertIsNotNone(result.finished_at)
5758
self.assertGreaterEqual(result.started_at, result.enqueued_at)
5859
self.assertGreaterEqual(result.finished_at, result.started_at)
59-
self.assertIsNone(result.result)
60+
self.assertIsInstance(result.result, ValueError)
61+
self.assertIsNone(result.get_result())
6062
self.assertEqual(result.task, test_tasks.failing_task)
6163
self.assertEqual(result.args, [])
6264
self.assertEqual(result.kwargs, {})
6365

66+
def test_complex_exception(self) -> None:
67+
with self.assertLogs("django_tasks.backends.immediate", level="ERROR"):
68+
result = default_task_backend.enqueue(test_tasks.complex_exception, [], {})
69+
70+
self.assertEqual(result.status, ResultStatus.FAILED)
71+
self.assertIsNotNone(result.started_at)
72+
self.assertIsNotNone(result.finished_at)
73+
self.assertGreaterEqual(result.started_at, result.enqueued_at)
74+
self.assertGreaterEqual(result.finished_at, result.started_at)
75+
self.assertIsNone(result.result)
76+
self.assertIsNone(result.get_result())
77+
self.assertEqual(result.task, test_tasks.complex_exception)
78+
self.assertEqual(result.args, [])
79+
self.assertEqual(result.kwargs, {})
80+
6481
def test_result(self) -> None:
6582
result = default_task_backend.enqueue(
6683
test_tasks.calculate_meaning_of_life, [], {}

tests/tests/test_utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import subprocess
33
from unittest.mock import Mock
44

5+
from django.core.exceptions import ImproperlyConfigured
56
from django.test import SimpleTestCase
67

78
from django_tasks import utils
9+
from django_tasks.exceptions import InvalidTaskError
810
from tests import tasks as test_tasks
911

1012

@@ -85,3 +87,34 @@ def test_retry(self) -> None:
8587
def test_keeps_return_value(self) -> None:
8688
self.assertTrue(utils.retry()(lambda: True)())
8789
self.assertFalse(utils.retry()(lambda: False)())
90+
91+
92+
class ExceptionSerializationTestCase(SimpleTestCase):
93+
def test_serialize_exceptions(self) -> None:
94+
for exc in [
95+
ValueError(10),
96+
SyntaxError("Wrong"),
97+
ImproperlyConfigured("It's wrong"),
98+
InvalidTaskError(""),
99+
SystemExit(),
100+
]:
101+
with self.subTest(exc):
102+
data = utils.exception_to_dict(exc)
103+
self.assertEqual(utils.json_normalize(data), data)
104+
self.assertEqual(set(data.keys()), {"exc_type", "exc_args"})
105+
reconstructed = utils.exception_from_dict(data)
106+
self.assertIsInstance(reconstructed, type(exc))
107+
self.assertEqual(reconstructed.args, exc.args)
108+
109+
def test_cannot_deserialize_non_exception(self) -> None:
110+
for data in [
111+
{"exc_type": "subprocess.check_output", "exc_args": ["exit", "1"]},
112+
{"exc_type": "True", "exc_args": []},
113+
{"exc_type": "math.pi", "exc_args": []},
114+
{"exc_type": __name__, "exc_args": []},
115+
{"exc_type": utils.get_module_path(type(self)), "exc_args": []},
116+
{"exc_type": utils.get_module_path(Mock), "exc_args": []},
117+
]:
118+
with self.subTest(data):
119+
with self.assertRaises((TypeError, ImportError)):
120+
utils.exception_from_dict(data)

0 commit comments

Comments
 (0)