Skip to content

Commit b30eacd

Browse files
committed
Simplify exception serialization to avoid issues with complex exceptions
1 parent ae77a27 commit b30eacd

13 files changed

+127
-183
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
7+
8+
def separate_exception_fields(
9+
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
10+
) -> None:
11+
DBTaskResult = apps.get_model("django_tasks_database", "DBTaskResult")
12+
13+
DBTaskResult.objects.using(schema_editor.connection.alias).update(
14+
exception_class_path=models.F("exception_data__exc_type"),
15+
traceback=models.F("exception_data__exc_traceback"),
16+
)
17+
18+
19+
class Migration(migrations.Migration):
20+
dependencies = [
21+
("django_tasks_database", "0012_add_separate_exception_fields"),
22+
]
23+
24+
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: 30 additions & 16 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+
return TaskResult[T](
149156
db_result=self,
150157
task=self.task,
151158
id=normalize_uuid(self.id),
@@ -156,13 +163,10 @@ def task_result(self) -> "TaskResult[T]":
156163
args=self.args_kwargs["args"],
157164
kwargs=self.args_kwargs["kwargs"],
158165
backend=self.backend_name,
166+
exception_class=exception_class,
167+
traceback=self.traceback,
159168
)
160169

161-
object.__setattr__(result, "_return_value", self.return_value)
162-
object.__setattr__(result, "_exception_data", self.exception_data)
163-
164-
return result
165-
166170
@retry(backoff_delay=0)
167171
def claim(self) -> None:
168172
"""
@@ -177,21 +181,31 @@ def set_succeeded(self, return_value: Any) -> None:
177181
self.status = ResultStatus.SUCCEEDED
178182
self.finished_at = timezone.now()
179183
self.return_value = return_value
180-
self.exception_data = None
184+
self.exception_class_path = ""
185+
self.traceback = ""
181186
self.save(
182-
update_fields=["status", "return_value", "finished_at", "exception_data"]
187+
update_fields=[
188+
"status",
189+
"return_value",
190+
"finished_at",
191+
"exception_class_path",
192+
"traceback",
193+
]
183194
)
184195

185196
@retry()
186197
def set_failed(self, exc: BaseException) -> None:
187198
self.status = ResultStatus.FAILED
188199
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
200+
self.exception_class_path = get_module_path(type(exc))
201+
self.traceback = get_exception_traceback(exc)
194202
self.return_value = None
195203
self.save(
196-
update_fields=["status", "finished_at", "exception_data", "return_value"]
204+
update_fields=[
205+
"status",
206+
"return_value",
207+
"finished_at",
208+
"exception_class_path",
209+
"traceback",
210+
]
197211
)

django_tasks/backends/dummy.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ def enqueue(
4747
args=args,
4848
kwargs=kwargs,
4949
backend=self.alias,
50+
exception_class=None,
51+
traceback=None,
5052
)
5153

5254
if self._get_enqueue_on_commit_for_task(task) is not False:

django_tasks/backends/immediate.py

Lines changed: 6 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

@@ -81,6 +80,8 @@ def enqueue(
8180
args=args,
8281
kwargs=kwargs,
8382
backend=self.alias,
83+
exception_class=None,
84+
traceback=None,
8485
)
8586

8687
if self._get_enqueue_on_commit_for_task(task) is not False:

django_tasks/task.py

Lines changed: 8 additions & 22 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,13 @@ 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)
258+
exception_class: Optional[Type[BaseException]]
259+
"""The exception raised by the task function"""
260260

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-
)
261+
traceback: Optional[str]
262+
"""The traceback of the exception if the task failed"""
268263

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-
)
264+
_return_value: Optional[T] = field(init=False, default=None)
279265

280266
@property
281267
def return_value(self) -> Optional[T]:

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:

tests/tests/test_database_backend.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,7 @@ def test_failing_task(self) -> None:
476476
self.assertGreaterEqual(result.finished_at, result.started_at) # type: ignore
477477
self.assertEqual(result.status, ResultStatus.FAILED)
478478

479-
self.assertIsInstance(result.exception, ValueError)
479+
self.assertEqual(result.exception_class, ValueError)
480480
assert result.traceback # So that mypy knows the next line is allowed
481481
self.assertTrue(
482482
result.traceback.endswith(
@@ -490,9 +490,7 @@ def test_complex_exception(self) -> None:
490490
result = test_tasks.complex_exception.enqueue()
491491
self.assertEqual(DBTaskResult.objects.ready().count(), 1)
492492

493-
with self.assertNumQueries(
494-
9 if connection.vendor == "mysql" else 8
495-
), self.assertLogs("django_tasks.backends.database", level="ERROR"):
493+
with self.assertNumQueries(9 if connection.vendor == "mysql" else 8):
496494
self.run_worker()
497495

498496
self.assertEqual(result.status, ResultStatus.NEW)
@@ -504,8 +502,8 @@ def test_complex_exception(self) -> None:
504502
self.assertGreaterEqual(result.finished_at, result.started_at) # type: ignore
505503
self.assertEqual(result.status, ResultStatus.FAILED)
506504

507-
self.assertIsNone(result.exception)
508-
self.assertIsNone(result.traceback)
505+
self.assertEqual(result.exception_class, ValueError)
506+
self.assertIn('ValueError(ValueError("This task failed"))', result.traceback) # type: ignore[arg-type]
509507

510508
self.assertEqual(DBTaskResult.objects.ready().count(), 0)
511509

@@ -1311,7 +1309,7 @@ def test_repeat_ctrl_c(self) -> None:
13111309

13121310
result.refresh()
13131311
self.assertEqual(result.status, ResultStatus.FAILED)
1314-
self.assertIsInstance(result.exception, SystemExit)
1312+
self.assertEqual(result.exception_class, SystemExit)
13151313

13161314
@skipIf(sys.platform == "win32", "Windows doesn't support SIGKILL")
13171315
def test_kill(self) -> None:
@@ -1349,7 +1347,7 @@ def test_system_exit_task(self) -> None:
13491347

13501348
result.refresh()
13511349
self.assertEqual(result.status, ResultStatus.FAILED)
1352-
self.assertIsInstance(result.exception, SystemExit)
1350+
self.assertEqual(result.exception_class, SystemExit)
13531351

13541352
def test_keyboard_interrupt_task(self) -> None:
13551353
result = test_tasks.failing_task_keyboard_interrupt.enqueue()
@@ -1361,7 +1359,7 @@ def test_keyboard_interrupt_task(self) -> None:
13611359

13621360
result.refresh()
13631361
self.assertEqual(result.status, ResultStatus.FAILED)
1364-
self.assertIsInstance(result.exception, KeyboardInterrupt)
1362+
self.assertEqual(result.exception_class, KeyboardInterrupt)
13651363

13661364
def test_multiple_workers(self) -> None:
13671365
results = [test_tasks.sleep_for.enqueue(0.1) for _ in range(10)]

0 commit comments

Comments
 (0)