Skip to content

Commit f5eba33

Browse files
committed
Ensure return value serialization errors are marked as task failures
1 parent d917cdf commit f5eba33

File tree

5 files changed

+81
-5
lines changed

5 files changed

+81
-5
lines changed

django_tasks/backends/rq.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.db import transaction
1111
from django.utils.functional import cached_property
1212
from redis.client import Redis
13+
from rq.defaults import UNSERIALIZABLE_RETURN_VALUE_PAYLOAD
1314
from rq.exceptions import NoSuchJobError
1415
from rq.job import Callback, JobStatus
1516
from rq.job import Job as BaseJob
@@ -116,9 +117,6 @@ def task_result(self) -> TaskResult:
116117

117118
exception_classes = self.meta.get("_django_tasks_exceptions", []).copy()
118119

119-
if self.worker_name and task_result.status == ResultStatus.RUNNING:
120-
task_result.worker_ids.append(self.worker_name)
121-
122120
rq_results = self.results()
123121

124122
for rq_result in rq_results:
@@ -135,11 +133,30 @@ def task_result(self) -> TaskResult:
135133
)
136134
)
137135

136+
if self.worker_name and task_result.status == ResultStatus.RUNNING:
137+
task_result.worker_ids.append(self.worker_name)
138+
138139
if rq_results:
139-
object.__setattr__(task_result, "_return_value", rq_results[0].return_value)
140140
object.__setattr__(
141-
task_result, "last_attempted_at", rq_results[0].created_at
141+
task_result, "_return_value", rq_results[-1].return_value
142142
)
143+
object.__setattr__(
144+
task_result, "last_attempted_at", rq_results[-1].created_at
145+
)
146+
147+
# If the return value couldn't be serialized, a specific string is saved instead.
148+
if task_result._return_value == UNSERIALIZABLE_RETURN_VALUE_PAYLOAD:
149+
# In these cases, the task should be marked as failed instead
150+
object.__setattr__(task_result, "status", ResultStatus.FAILED)
151+
152+
task_result.errors.append(
153+
TaskError(
154+
exception_class_path=get_module_path(Exception),
155+
traceback=task_result._return_value, # The traceback isn't stored, so this is the best we can do
156+
)
157+
)
158+
159+
object.__setattr__(task_result, "_return_value", None)
143160

144161
return task_result
145162

tests/tasks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ def complex_exception() -> None:
4444
raise ValueError(ValueError("This task failed"))
4545

4646

47+
@task()
48+
def complex_return_value() -> Any:
49+
# Return something which isn't JSON serializable nor picklable
50+
return lambda: True
51+
52+
4753
@task()
4854
def exit_task() -> None:
4955
exit(1)

tests/tests/test_database_backend.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,24 @@ def test_complex_exception(self) -> None:
636636

637637
self.assertEqual(DBTaskResult.objects.ready().count(), 0)
638638

639+
def test_complex_return_value(self) -> None:
640+
result = test_tasks.complex_return_value.enqueue()
641+
642+
self.run_worker()
643+
644+
result.refresh()
645+
646+
self.assertEqual(result.status, ResultStatus.FAILED)
647+
self.assertIsNotNone(result.started_at)
648+
self.assertIsNotNone(result.last_attempted_at)
649+
self.assertIsNotNone(result.finished_at)
650+
self.assertGreaterEqual(result.started_at, result.enqueued_at) # type:ignore[arg-type,misc]
651+
self.assertGreaterEqual(result.finished_at, result.started_at) # type:ignore[arg-type,misc]
652+
653+
self.assertIsNone(result._return_value)
654+
self.assertEqual(result.errors[0].exception_class, TypeError)
655+
self.assertIn("is not JSON serializable", result.errors[0].traceback)
656+
639657
def test_doesnt_process_different_backend(self) -> None:
640658
result = test_tasks.failing_task_value_error.enqueue()
641659

tests/tests/test_immediate_backend.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,21 @@ def test_complex_exception(self) -> None:
137137
self.assertEqual(result.args, [])
138138
self.assertEqual(result.kwargs, {})
139139

140+
def test_complex_return_value(self) -> None:
141+
with self.assertLogs("django_tasks", level="ERROR"):
142+
result = test_tasks.complex_return_value.enqueue()
143+
144+
self.assertEqual(result.status, ResultStatus.FAILED)
145+
self.assertIsNotNone(result.started_at)
146+
self.assertIsNotNone(result.last_attempted_at)
147+
self.assertIsNotNone(result.finished_at)
148+
self.assertGreaterEqual(result.started_at, result.enqueued_at) # type:ignore[arg-type,misc]
149+
self.assertGreaterEqual(result.finished_at, result.started_at) # type:ignore[arg-type,misc]
150+
151+
self.assertIsNone(result._return_value)
152+
self.assertEqual(result.errors[0].exception_class, TypeError)
153+
self.assertIn("is not JSON serializable", result.errors[0].traceback)
154+
140155
def test_result(self) -> None:
141156
result = default_task_backend.enqueue(
142157
test_tasks.calculate_meaning_of_life, [], {}

tests/tests/test_rq_backend.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.test import TransactionTestCase, modify_settings, override_settings
1212
from django.urls import reverse
1313
from fakeredis import FakeRedis, FakeStrictRedis
14+
from rq.defaults import UNSERIALIZABLE_RETURN_VALUE_PAYLOAD
1415
from rq.timeouts import TimerDeathPenalty
1516

1617
from django_tasks import ResultStatus, Task, default_task_backend, tasks
@@ -199,6 +200,25 @@ def test_complex_exception(self) -> None:
199200
self.assertEqual(result.kwargs, {})
200201
self.assertEqual(result.attempts, 1)
201202

203+
def test_complex_return_value(self) -> None:
204+
result = test_tasks.complex_return_value.enqueue()
205+
206+
with self.assertLogs("django_tasks", "DEBUG"):
207+
self.run_worker()
208+
209+
result.refresh()
210+
211+
self.assertEqual(result.status, ResultStatus.FAILED)
212+
self.assertIsNotNone(result.started_at)
213+
self.assertIsNotNone(result.last_attempted_at)
214+
self.assertIsNotNone(result.finished_at)
215+
self.assertGreaterEqual(result.started_at, result.enqueued_at) # type:ignore[arg-type,misc]
216+
self.assertGreaterEqual(result.finished_at, result.started_at) # type:ignore[arg-type,misc]
217+
218+
self.assertIsNone(result._return_value)
219+
self.assertEqual(result.errors[0].exception_class, Exception)
220+
self.assertIn(UNSERIALIZABLE_RETURN_VALUE_PAYLOAD, result.errors[0].traceback)
221+
202222
def test_get_result(self) -> None:
203223
result = default_task_backend.enqueue(test_tasks.noop_task, [], {})
204224

0 commit comments

Comments
 (0)