Skip to content

Commit 9aa591f

Browse files
Separate return value and exception attributes for task results
A partial solution for #19
1 parent 00a9bf6 commit 9aa591f

File tree

12 files changed

+190
-68
lines changed

12 files changed

+190
-68
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,11 @@ default_task_backend.get_result(result_id)
182182

183183
### Return values
184184

185-
If your task returns something, it can be retrieved from the `.result` attribute on a `TaskResult`. Accessing this property on an unfinished task (ie not `COMPLETE` or `FAILED`) will raise a `ValueError`.
185+
If your task returns something, it can be retrieved from the `.return_value` attribute on a `TaskResult`. Accessing this property on an unfinished task (ie not `COMPLETE` or `FAILED`) will raise a `ValueError`.
186186

187187
```python
188188
assert result.status == ResultStatus.COMPLETE
189-
assert result.result == 42
189+
assert result.return_value == 42
190190
```
191191

192192
If a result has been updated in the background, you can call `refresh` on it to update its values. Results obtained using `get_result` will always be up-to-date.
@@ -199,10 +199,10 @@ assert result.status == ResultStatus.COMPLETE
199199

200200
#### Exceptions
201201

202-
If a task raised an exception, its `.result` will be the exception raised:
202+
If a task raised an exception, its `.exception` will be the exception raised:
203203

204204
```python
205-
assert isinstance(result.result, ValueError)
205+
assert isinstance(result.exception, ValueError)
206206
```
207207

208208
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:

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def run_task(self, db_task_result: DBTaskResult) -> None:
130130

131131
# Setting the return and success value inside the error handling,
132132
# So errors setting it (eg JSON encode) can still be recorded
133-
db_task_result.set_result(return_value)
133+
db_task_result.set_complete(return_value)
134134
logger.info(
135135
"Task id=%s path=%s state=%s",
136136
db_task_result.id,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 4.2.13 on 2024-08-23 14:38
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_tasks_database", "0006_alter_dbtaskresult_args_kwargs_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="dbtaskresult",
14+
name="exception_data",
15+
field=models.JSONField(
16+
default=None, null=True, verbose_name="exception data"
17+
),
18+
),
19+
migrations.AddField(
20+
model_name="dbtaskresult",
21+
name="return_value",
22+
field=models.JSONField(
23+
default=None, null=True, verbose_name="return value"
24+
),
25+
),
26+
]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
from django_tasks import ResultStatus
8+
9+
10+
def separate_results_field(
11+
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
12+
) -> None:
13+
DBTaskResult = apps.get_model("django_tasks_database", "DBTaskResult")
14+
15+
# If a task completed, the result is its return value
16+
DBTaskResult.objects.using(schema_editor.connection.alias).filter(
17+
status=ResultStatus.COMPLETE
18+
).update(return_value=models.F("result"))
19+
20+
# If a task failed, the result is the exception data (or nothing)
21+
DBTaskResult.objects.using(schema_editor.connection.alias).filter(
22+
status=ResultStatus.FAILED
23+
).update(exception_data=models.F("result"))
24+
25+
26+
def merge_results_field(
27+
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
28+
) -> None:
29+
DBTaskResult = apps.get_model("django_tasks_database", "DBTaskResult")
30+
31+
# If a task completed, the result is its return value
32+
DBTaskResult.objects.using(schema_editor.connection.alias).filter(
33+
status=ResultStatus.COMPLETE
34+
).update(result=models.F("return_value"))
35+
36+
# If a task failed, the result is the exception data (or nothing)
37+
DBTaskResult.objects.using(schema_editor.connection.alias).filter(
38+
status=ResultStatus.FAILED
39+
).update(result=models.F("exception_data"))
40+
41+
42+
class Migration(migrations.Migration):
43+
dependencies = [
44+
("django_tasks_database", "0007_add_separate_results_fields"),
45+
]
46+
47+
operations = [migrations.RunPython(separate_results_field, merge_results_field)]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Generated by Django 4.2.13 on 2024-08-23 14:51
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_tasks_database", "0008_separate_results_field"),
9+
]
10+
11+
operations = [
12+
migrations.RemoveField(
13+
model_name="dbtaskresult",
14+
name="result",
15+
),
16+
]

django_tasks/backends/database/models.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ class DBTaskResult(GenericBase[P, T], models.Model):
9999

100100
run_after = models.DateTimeField(_("run after"), null=True)
101101

102-
result = models.JSONField(_("result"), default=None, null=True)
102+
return_value = models.JSONField(_("return value"), default=None, null=True)
103+
exception_data = models.JSONField(_("exception data"), default=None, null=True)
103104

104105
objects = DBTaskResultQuerySet.as_manager()
105106

@@ -147,7 +148,8 @@ def task_result(self) -> "TaskResult[T]":
147148
backend=self.backend_name,
148149
)
149150

150-
result._result = self.result
151+
result._return_value = self.return_value
152+
result._exception_data = self.exception_data
151153

152154
return result
153155

@@ -161,19 +163,25 @@ def claim(self) -> None:
161163
self.save(update_fields=["status", "started_at"])
162164

163165
@retry()
164-
def set_result(self, result: Any) -> None:
166+
def set_complete(self, return_value: Any) -> None:
165167
self.status = ResultStatus.COMPLETE
166168
self.finished_at = timezone.now()
167-
self.result = result
168-
self.save(update_fields=["status", "result", "finished_at"])
169+
self.return_value = return_value
170+
self.exception_data = None
171+
self.save(
172+
update_fields=["status", "return_value", "finished_at", "exception_data"]
173+
)
169174

170175
@retry()
171176
def set_failed(self, exc: BaseException) -> None:
172177
self.status = ResultStatus.FAILED
173178
self.finished_at = timezone.now()
174179
try:
175-
self.result = exception_to_dict(exc)
180+
self.exception_data = exception_to_dict(exc)
176181
except Exception:
177182
logger.exception("Task id=%s unable to save exception", self.id)
178-
self.result = None
179-
self.save(update_fields=["status", "finished_at", "result"])
183+
self.exception_data = None
184+
self.return_value = None
185+
self.save(
186+
update_fields=["status", "finished_at", "exception_data", "return_value"]
187+
)

django_tasks/backends/immediate.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,15 @@ def _execute_task(self, task_result: TaskResult) -> None:
3636

3737
task_result.started_at = timezone.now()
3838
try:
39-
task_result._result = json_normalize(
39+
task_result._return_value = json_normalize(
4040
calling_task_func(*task_result.args, **task_result.kwargs)
4141
)
4242
except BaseException as e:
4343
task_result.finished_at = timezone.now()
4444
try:
45-
task_result._result = exception_to_dict(e)
45+
task_result._exception_data = exception_to_dict(e)
4646
except Exception:
4747
logger.exception("Task id=%s unable to save exception", task_result.id)
48-
task_result._result = None
4948

5049
# Use `.exception` to integrate with error monitoring tools (eg Sentry)
5150
logger.exception(

django_tasks/task.py

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@
3333
MAX_PRIORITY = 100
3434
DEFAULT_PRIORITY = 0
3535

36+
TASK_REFRESH_ATTRS = {
37+
"_exception_data",
38+
"_return_value",
39+
"finished_at",
40+
"started_at",
41+
"status",
42+
}
43+
3644

3745
class ResultStatus(TextChoices):
3846
NEW = ("NEW", _("New"))
@@ -239,59 +247,58 @@ class TaskResult(Generic[T]):
239247
backend: str
240248
"""The name of the backend the task will run on"""
241249

242-
_result: Optional[Union[T, SerializedExceptionDict]] = field(
243-
init=False, default=None
244-
)
250+
_return_value: Optional[T] = field(init=False, default=None)
251+
_exception_data: Optional[SerializedExceptionDict] = field(init=False, default=None)
245252

246253
@property
247-
def result(self) -> Optional[Union[T, BaseException]]:
248-
if self.status == ResultStatus.COMPLETE:
249-
return cast(T, self._result)
250-
elif self.status == ResultStatus.FAILED:
251-
return (
252-
exception_from_dict(cast(SerializedExceptionDict, self._result))
253-
if self._result is not None
254-
else None
255-
)
256-
257-
raise ValueError("Task has not finished yet")
254+
def exception(self) -> Optional[BaseException]:
255+
return (
256+
exception_from_dict(cast(SerializedExceptionDict, self._exception_data))
257+
if self.status == ResultStatus.FAILED and self._exception_data is not None
258+
else None
259+
)
258260

259261
@property
260262
def traceback(self) -> Optional[str]:
261263
"""
262264
Return the string representation of the traceback of the task if it failed
263265
"""
264-
if self.status == ResultStatus.FAILED and self._result is not None:
265-
return cast(SerializedExceptionDict, self._result)["exc_traceback"]
266-
267-
return None
266+
return (
267+
cast(SerializedExceptionDict, self._exception_data)["exc_traceback"]
268+
if self.status == ResultStatus.FAILED and self._exception_data is not None
269+
else None
270+
)
268271

269-
def get_result(self) -> Optional[T]:
272+
@property
273+
def return_value(self) -> Optional[T]:
270274
"""
271-
A convenience method to get the result, or None if it's not ready yet or has failed.
275+
Get the return value of the task.
276+
277+
If the task didn't complete successfully, an exception is raised.
278+
This is to distinguish against the task returning None.
272279
"""
273-
return cast(T, self.result) if self.status == ResultStatus.COMPLETE else None
280+
if self.status == ResultStatus.FAILED:
281+
raise ValueError("Task failed")
282+
283+
elif self.status != ResultStatus.COMPLETE:
284+
raise ValueError("Task has not finished yet")
285+
286+
return cast(T, self._return_value)
274287

275288
def refresh(self) -> None:
276289
"""
277290
Reload the cached task data from the task store
278291
"""
279292
refreshed_task = self.task.get_backend().get_result(self.id)
280293

281-
# status, started_at, finished_at and result are the only refreshable attributes
282-
self.status = refreshed_task.status
283-
self.started_at = refreshed_task.started_at
284-
self.finished_at = refreshed_task.finished_at
285-
self._result = refreshed_task._result
294+
for attr in TASK_REFRESH_ATTRS:
295+
setattr(self, attr, getattr(refreshed_task, attr))
286296

287297
async def arefresh(self) -> None:
288298
"""
289299
Reload the cached task data from the task store
290300
"""
291301
refreshed_task = await self.task.get_backend().aget_result(self.id)
292302

293-
# status, started_at, finished_at and result are the only refreshable attributes
294-
self.status = refreshed_task.status
295-
self.started_at = refreshed_task.started_at
296-
self.finished_at = refreshed_task.finished_at
297-
self._result = refreshed_task._result
303+
for attr in TASK_REFRESH_ATTRS:
304+
setattr(self, attr, getattr(refreshed_task, attr))

tests/tests/test_database_backend.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ def test_enqueue_task(self) -> None:
6969
self.assertIsNone(result.started_at)
7070
self.assertIsNone(result.finished_at)
7171
with self.assertRaisesMessage(ValueError, "Task has not finished yet"):
72-
result.result # noqa:B018
73-
self.assertIsNone(result.get_result())
72+
result.return_value # noqa:B018
7473
self.assertEqual(result.task, task)
7574
self.assertEqual(result.args, [1])
7675
self.assertEqual(result.kwargs, {"two": 3})
@@ -84,8 +83,7 @@ async def test_enqueue_task_async(self) -> None:
8483
self.assertIsNone(result.started_at)
8584
self.assertIsNone(result.finished_at)
8685
with self.assertRaisesMessage(ValueError, "Task has not finished yet"):
87-
result.result # noqa:B018
88-
self.assertIsNone(result.get_result())
86+
result.return_value # noqa:B018
8987
self.assertEqual(result.task, task)
9088
self.assertEqual(result.args, [])
9189
self.assertEqual(result.kwargs, {})
@@ -438,7 +436,7 @@ def test_failing_task(self) -> None:
438436
self.assertGreaterEqual(result.finished_at, result.started_at) # type: ignore
439437
self.assertEqual(result.status, ResultStatus.FAILED)
440438

441-
self.assertIsInstance(result.result, ValueError)
439+
self.assertIsInstance(result.exception, ValueError)
442440
assert result.traceback # So that mypy knows the next line is allowed
443441
self.assertTrue(
444442
result.traceback.endswith(
@@ -466,7 +464,7 @@ def test_complex_exception(self) -> None:
466464
self.assertGreaterEqual(result.finished_at, result.started_at) # type: ignore
467465
self.assertEqual(result.status, ResultStatus.FAILED)
468466

469-
self.assertIsNone(result.result)
467+
self.assertIsNone(result.exception)
470468
self.assertIsNone(result.traceback)
471469

472470
self.assertEqual(DBTaskResult.objects.ready().count(), 0)

tests/tests/test_dummy_backend.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_enqueue_task(self) -> None:
3131
self.assertIsNone(result.started_at)
3232
self.assertIsNone(result.finished_at)
3333
with self.assertRaisesMessage(ValueError, "Task has not finished yet"):
34-
result.result # noqa:B018
34+
result.return_value # noqa:B018
3535
self.assertEqual(result.task, task)
3636
self.assertEqual(result.args, [1])
3737
self.assertEqual(result.kwargs, {"two": 3})
@@ -47,7 +47,7 @@ async def test_enqueue_task_async(self) -> None:
4747
self.assertIsNone(result.started_at)
4848
self.assertIsNone(result.finished_at)
4949
with self.assertRaisesMessage(ValueError, "Task has not finished yet"):
50-
result.result # noqa:B018
50+
result.return_value # noqa:B018
5151
self.assertEqual(result.task, task)
5252
self.assertEqual(result.args, [])
5353
self.assertEqual(result.kwargs, {})

0 commit comments

Comments
 (0)