Skip to content

Commit ce43889

Browse files
Simplify exception serialization to avoid issues with complex exceptions (#121)
* Simplify exception serialization to avoid issues with complex exceptions * Raise exception when accessing exception details on unfinished task
1 parent ae77a27 commit ce43889

13 files changed

+208
-186
lines changed

README.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,22 +201,18 @@ assert result.status == ResultStatus.SUCCEEDED
201201

202202
#### Exceptions
203203

204-
If a task raised an exception, its `.exception` will be the exception raised:
204+
If a task raised an exception, its `.exception_class` will be the exception class raised:
205205

206206
```python
207-
assert isinstance(result.exception, ValueError)
207+
assert result.exception == ValueError
208208
```
209209

210-
As part of the serialization process for exceptions, some information is lost. The traceback information is reduced to a string that you can print to help debugging:
210+
Note that this is just the type of exception, and contains no other values. The traceback information is reduced to a string that you can print to help debugging:
211211

212212
```python
213213
assert isinstance(result.traceback, str)
214214
```
215215

216-
The stack frames, `globals()` and `locals()` are not available.
217-
218-
If the exception could not be serialized, the `.result` is `None`.
219-
220216
### Backend introspecting
221217

222218
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.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.1.1 on 2024-11-22 16:32
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_tasks_database", "0011_rename_complete_status"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="dbtaskresult",
14+
name="exception_class_path",
15+
field=models.TextField(default="", verbose_name="exception class path"),
16+
preserve_default=False,
17+
),
18+
migrations.AddField(
19+
model_name="dbtaskresult",
20+
name="traceback",
21+
field=models.TextField(default="", verbose_name="traceback"),
22+
preserve_default=False,
23+
),
24+
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.13 on 2024-08-23 14:38
2+
3+
from django.db import migrations, models
4+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
5+
from django.db.migrations.state import StateApps
6+
from django.db.models.functions import Coalesce
7+
8+
9+
def separate_exception_fields(
10+
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
11+
) -> None:
12+
DBTaskResult = apps.get_model("django_tasks_database", "DBTaskResult")
13+
14+
DBTaskResult.objects.using(schema_editor.connection.alias).update(
15+
exception_class_path=Coalesce(
16+
models.F("exception_data__exc_type"), models.Value("", models.JSONField())
17+
),
18+
traceback=Coalesce(
19+
models.F("exception_data__exc_traceback"),
20+
models.Value("", models.JSONField()),
21+
),
22+
)
23+
24+
25+
class Migration(migrations.Migration):
26+
dependencies = [
27+
("django_tasks_database", "0012_add_separate_exception_fields"),
28+
]
29+
30+
operations = [migrations.RunPython(separate_exception_fields)]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Generated by Django 5.1.1 on 2024-11-22 16:32
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_tasks_database", "0013_separate_exception_fields"),
9+
]
10+
11+
operations = [
12+
migrations.RemoveField(
13+
model_name="dbtaskresult",
14+
name="exception_data",
15+
),
16+
]

django_tasks/backends/database/models.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
ResultStatus,
2121
Task,
2222
)
23-
from django_tasks.utils import exception_to_dict, retry
23+
from django_tasks.utils import get_exception_traceback, get_module_path, retry
2424

2525
from .utils import normalize_uuid
2626

@@ -101,7 +101,9 @@ class DBTaskResult(GenericBase[P, T], models.Model):
101101
run_after = models.DateTimeField(_("run after"), null=True)
102102

103103
return_value = models.JSONField(_("return value"), default=None, null=True)
104-
exception_data = models.JSONField(_("exception data"), default=None, null=True)
104+
105+
exception_class_path = models.TextField(_("exception class path"))
106+
traceback = models.TextField(_("traceback"))
105107

106108
objects = DBTaskResultQuerySet.as_manager()
107109

@@ -145,7 +147,12 @@ def task(self) -> Task[P, T]:
145147
def task_result(self) -> "TaskResult[T]":
146148
from .backend import TaskResult
147149

148-
result = TaskResult[T](
150+
try:
151+
exception_class = import_string(self.exception_class_path)
152+
except ImportError:
153+
exception_class = None
154+
155+
task_result = TaskResult[T](
149156
db_result=self,
150157
task=self.task,
151158
id=normalize_uuid(self.id),
@@ -158,10 +165,10 @@ def task_result(self) -> "TaskResult[T]":
158165
backend=self.backend_name,
159166
)
160167

161-
object.__setattr__(result, "_return_value", self.return_value)
162-
object.__setattr__(result, "_exception_data", self.exception_data)
168+
object.__setattr__(task_result, "_exception_class", exception_class)
169+
object.__setattr__(task_result, "_traceback", self.traceback or None)
163170

164-
return result
171+
return task_result
165172

166173
@retry(backoff_delay=0)
167174
def claim(self) -> None:
@@ -177,21 +184,31 @@ def set_succeeded(self, return_value: Any) -> None:
177184
self.status = ResultStatus.SUCCEEDED
178185
self.finished_at = timezone.now()
179186
self.return_value = return_value
180-
self.exception_data = None
187+
self.exception_class_path = ""
188+
self.traceback = ""
181189
self.save(
182-
update_fields=["status", "return_value", "finished_at", "exception_data"]
190+
update_fields=[
191+
"status",
192+
"return_value",
193+
"finished_at",
194+
"exception_class_path",
195+
"traceback",
196+
]
183197
)
184198

185199
@retry()
186200
def set_failed(self, exc: BaseException) -> None:
187201
self.status = ResultStatus.FAILED
188202
self.finished_at = timezone.now()
189-
try:
190-
self.exception_data = exception_to_dict(exc)
191-
except Exception:
192-
logger.exception("Task id=%s unable to save exception", self.id)
193-
self.exception_data = None
203+
self.exception_class_path = get_module_path(type(exc))
204+
self.traceback = get_exception_traceback(exc)
194205
self.return_value = None
195206
self.save(
196-
update_fields=["status", "finished_at", "exception_data", "return_value"]
207+
update_fields=[
208+
"status",
209+
"return_value",
210+
"finished_at",
211+
"exception_class_path",
212+
"traceback",
213+
]
197214
)

django_tasks/backends/immediate.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from django_tasks.signals import task_enqueued, task_finished
1212
from django_tasks.task import ResultStatus, Task, TaskResult
13-
from django_tasks.utils import exception_to_dict, get_random_id, json_normalize
13+
from django_tasks.utils import get_exception_traceback, get_random_id, json_normalize
1414

1515
from .base import BaseTaskBackend
1616

@@ -52,10 +52,9 @@ def _execute_task(self, task_result: TaskResult) -> None:
5252
raise
5353

5454
object.__setattr__(task_result, "finished_at", timezone.now())
55-
try:
56-
object.__setattr__(task_result, "_exception_data", exception_to_dict(e))
57-
except Exception:
58-
logger.exception("Task id=%s unable to save exception", task_result.id)
55+
56+
object.__setattr__(task_result, "_traceback", get_exception_traceback(e))
57+
object.__setattr__(task_result, "_exception_class", type(e))
5958

6059
object.__setattr__(task_result, "status", ResultStatus.FAILED)
6160

django_tasks/task.py

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
Dict,
99
Generic,
1010
Optional,
11+
Type,
1112
TypeVar,
1213
Union,
1314
cast,
@@ -22,8 +23,6 @@
2223

2324
from .exceptions import ResultDoesNotExist
2425
from .utils import (
25-
SerializedExceptionDict,
26-
exception_from_dict,
2726
get_module_path,
2827
json_normalize,
2928
)
@@ -38,7 +37,8 @@
3837
DEFAULT_PRIORITY = 0
3938

4039
TASK_REFRESH_ATTRS = {
41-
"_exception_data",
40+
"_exception_class",
41+
"_traceback",
4242
"_return_value",
4343
"finished_at",
4444
"started_at",
@@ -255,27 +255,10 @@ class TaskResult(Generic[T]):
255255
backend: str
256256
"""The name of the backend the task will run on"""
257257

258-
_return_value: Optional[T] = field(init=False, default=None)
259-
_exception_data: Optional[SerializedExceptionDict] = field(init=False, default=None)
260-
261-
@property
262-
def exception(self) -> Optional[BaseException]:
263-
return (
264-
exception_from_dict(cast(SerializedExceptionDict, self._exception_data))
265-
if self.status == ResultStatus.FAILED and self._exception_data is not None
266-
else None
267-
)
258+
_exception_class: Optional[Type[BaseException]] = field(init=False, default=None)
259+
_traceback: Optional[str] = field(init=False, default=None)
268260

269-
@property
270-
def traceback(self) -> Optional[str]:
271-
"""
272-
Return the string representation of the traceback of the task if it failed
273-
"""
274-
return (
275-
cast(SerializedExceptionDict, self._exception_data)["exc_traceback"]
276-
if self.status == ResultStatus.FAILED and self._exception_data is not None
277-
else None
278-
)
261+
_return_value: Optional[T] = field(init=False, default=None)
279262

280263
@property
281264
def return_value(self) -> Optional[T]:
@@ -285,14 +268,32 @@ def return_value(self) -> Optional[T]:
285268
If the task didn't succeed, an exception is raised.
286269
This is to distinguish against the task returning None.
287270
"""
288-
if self.status == ResultStatus.FAILED:
289-
raise ValueError("Task failed")
290-
291-
elif self.status != ResultStatus.SUCCEEDED:
271+
if not self.is_finished:
292272
raise ValueError("Task has not finished yet")
293273

294274
return cast(T, self._return_value)
295275

276+
@property
277+
def exception_class(self) -> Optional[Type[BaseException]]:
278+
"""The exception raised by the task function"""
279+
if not self.is_finished:
280+
raise ValueError("Task has not finished yet")
281+
282+
return self._exception_class
283+
284+
@property
285+
def traceback(self) -> Optional[str]:
286+
"""The traceback of the exception if the task failed"""
287+
if not self.is_finished:
288+
raise ValueError("Task has not finished yet")
289+
290+
return self._traceback
291+
292+
@property
293+
def is_finished(self) -> bool:
294+
"""Has the task finished?"""
295+
return self.status in {ResultStatus.FAILED, ResultStatus.SUCCEEDED}
296+
296297
def refresh(self) -> None:
297298
"""
298299
Reload the cached task data from the task store

django_tasks/utils.py

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,11 @@
44
import time
55
from functools import wraps
66
from traceback import format_exception
7-
from typing import Any, Callable, List, TypedDict, TypeVar
7+
from typing import Any, Callable, TypeVar
88

99
from django.utils.crypto import RANDOM_STRING_CHARS
10-
from django.utils.module_loading import import_string
1110
from typing_extensions import ParamSpec
1211

13-
14-
class SerializedExceptionDict(TypedDict):
15-
"""Type for the dictionary holding exception informations in task result
16-
17-
The task result either stores the result of the task, or the serialized exception
18-
information required to reconstitute part of the exception for debugging.
19-
"""
20-
21-
exc_type: str
22-
exc_args: List[Any]
23-
exc_traceback: str
24-
25-
2612
T = TypeVar("T")
2713
P = ParamSpec("P")
2814

@@ -74,21 +60,8 @@ def get_module_path(val: Any) -> str:
7460
return f"{val.__module__}.{val.__qualname__}"
7561

7662

77-
def exception_to_dict(exc: BaseException) -> SerializedExceptionDict:
78-
return {
79-
"exc_type": get_module_path(type(exc)),
80-
"exc_args": json_normalize(exc.args),
81-
"exc_traceback": "".join(format_exception(type(exc), exc, exc.__traceback__)),
82-
}
83-
84-
85-
def exception_from_dict(exc_data: SerializedExceptionDict) -> BaseException:
86-
exc_class = import_string(exc_data["exc_type"])
87-
88-
if not inspect.isclass(exc_class) or not issubclass(exc_class, BaseException):
89-
raise TypeError(f"{type(exc_class)} is not an exception")
90-
91-
return exc_class(*exc_data["exc_args"])
63+
def get_exception_traceback(exc: BaseException) -> str:
64+
return "".join(format_exception(type(exc), exc, exc.__traceback__))
9265

9366

9467
def get_random_id() -> str:

0 commit comments

Comments
 (0)