django-taskq 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django-taskq-1.0.0/LICENSE +21 -0
- django-taskq-1.0.0/MANIFEST.in +2 -0
- django-taskq-1.0.0/PKG-INFO +82 -0
- django-taskq-1.0.0/README.rst +59 -0
- django-taskq-1.0.0/django_taskq/__init__.py +0 -0
- django-taskq-1.0.0/django_taskq/admin.py +84 -0
- django-taskq-1.0.0/django_taskq/apps.py +8 -0
- django-taskq-1.0.0/django_taskq/celery.py +123 -0
- django-taskq-1.0.0/django_taskq/management/__init__.py +0 -0
- django-taskq-1.0.0/django_taskq/management/commands/__init__.py +0 -0
- django-taskq-1.0.0/django_taskq/management/commands/taskq.py +71 -0
- django-taskq-1.0.0/django_taskq/migrations/0001_initial.py +103 -0
- django-taskq-1.0.0/django_taskq/migrations/__init__.py +0 -0
- django-taskq-1.0.0/django_taskq/models.py +208 -0
- django-taskq-1.0.0/django_taskq/tests.py +157 -0
- django-taskq-1.0.0/django_taskq.egg-info/PKG-INFO +82 -0
- django-taskq-1.0.0/django_taskq.egg-info/SOURCES.txt +22 -0
- django-taskq-1.0.0/django_taskq.egg-info/dependency_links.txt +1 -0
- django-taskq-1.0.0/django_taskq.egg-info/requires.txt +2 -0
- django-taskq-1.0.0/django_taskq.egg-info/top_level.txt +1 -0
- django-taskq-1.0.0/pyproject.toml +3 -0
- django-taskq-1.0.0/setup.cfg +35 -0
- django-taskq-1.0.0/setup.py +3 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Aivars Kalvāns
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-taskq
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A simple Celery-like database-backed task queue for Django
|
|
5
|
+
Home-page: https://github.com/aivarsk/django-taskq
|
|
6
|
+
Author: Aivars Kalvans
|
|
7
|
+
Author-email: aivars.kalvans@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: Django :: 4.0
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
|
|
24
|
+
Django database task queue
|
|
25
|
+
|
|
26
|
+
Django Transaction Task Queue
|
|
27
|
+
=============================
|
|
28
|
+
|
|
29
|
+
A short and simple Celery replacement for my Django projects.
|
|
30
|
+
|
|
31
|
+
* *Database is the only backend.* The task is a simple Django model, it uses the same transaction and connection as other models. No more ``transaction.on_commit`` hooks to schedule tasks.
|
|
32
|
+
* *Tasks do not produce any results*, there is no ``get`` or ``join`` and there is no "result backend". This is not a distrubuted await. If you need to store results, pass a unique key into the task and store the result in some DIY model.
|
|
33
|
+
* *No workflows and chaining of jobs.*
|
|
34
|
+
* *ETA (estimated time of arrival) is a first-class citizen.* It does not depend on whether backends support the feature or not.
|
|
35
|
+
* *The worker is a single-threaded process*, you start several of those to scale. I have seen too many issues with autoscaling workers, worker processes killed by OS, workers stuck: simple is better.
|
|
36
|
+
* *No prefetching or visibility timeouts*. The worker picks the first available task and processes it.
|
|
37
|
+
* *Dead letter queue* built in. You get access to failed tasks and can retry them from Django admin.
|
|
38
|
+
* *Django admin for monitoring.* You can view pending tasks, future, failed, and "dirty" (crashed in the middle of work).
|
|
39
|
+
* *Easy to get the metrics* from Django shell and export to your favorite monitoring tool
|
|
40
|
+
* *Task records are removed after successful execution*. Unlike Celery SQLAlchemy's backend, records are removed so you don't have to care about archiving. It also keeps the table small, properly indexed and efficient.
|
|
41
|
+
|
|
42
|
+
Celery API
|
|
43
|
+
----------
|
|
44
|
+
|
|
45
|
+
The main API is the Celery API (``shared_task``) with ``delay``, ``apply_async`` and ``s``. Just to make switching between implementations easier.
|
|
46
|
+
|
|
47
|
+
.. code-block:: python
|
|
48
|
+
|
|
49
|
+
from django_taskq.celery import shared_task
|
|
50
|
+
|
|
51
|
+
@shared_task(autoretry_for(MyException,), retry_kwargs={"max_retries": 10, "countdown": 5})
|
|
52
|
+
def my_task(foo, bar=None):
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
.. code-block:: python
|
|
56
|
+
|
|
57
|
+
my_task.delay(1,bar=2)
|
|
58
|
+
my_task.appy_async((1,2))
|
|
59
|
+
my_task.s(1,2).apply_async()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Internals
|
|
63
|
+
---------
|
|
64
|
+
|
|
65
|
+
Adding a new task to the queue is just creating a new instance of the Task model.
|
|
66
|
+
|
|
67
|
+
Executing a task is a bit more expensive:
|
|
68
|
+
# A task is picked up from a queue and the state is updated to "started" within a single transaction.
|
|
69
|
+
# Python code is executed, a background thread updates "alive at" field every second ("a liveness probe").
|
|
70
|
+
# Successful tasks are deleted from the table. Failed tasks are marked as such and retried (based on configuration).
|
|
71
|
+
|
|
72
|
+
This is a bit more expensive than necessary but:
|
|
73
|
+
* we can recognize running tasks - the task is "started" and the record is updated in the last couple seconds.
|
|
74
|
+
* we can recognize "dirty" tasks that got killed or lost database connection in the middle - the task is "started" and the record has not been updated for a while.
|
|
75
|
+
|
|
76
|
+
In an ideal world tasks should be idempotent but things happen and I prefer to know which tasks crashed and double-check if some cleanup is necessary.
|
|
77
|
+
|
|
78
|
+
Performance
|
|
79
|
+
-----------
|
|
80
|
+
|
|
81
|
+
A single process can execute around 150 dummy tasks per second which is more than enough. After years of struggling with Celery, correctness and observability are more important.
|
|
82
|
+
On the other hand, to handle more "tasks" you probably want to store many events not tasks and have a single task that processes them in batches.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Django database task queue
|
|
2
|
+
|
|
3
|
+
Django Transaction Task Queue
|
|
4
|
+
=============================
|
|
5
|
+
|
|
6
|
+
A short and simple Celery replacement for my Django projects.
|
|
7
|
+
|
|
8
|
+
* *Database is the only backend.* The task is a simple Django model, it uses the same transaction and connection as other models. No more ``transaction.on_commit`` hooks to schedule tasks.
|
|
9
|
+
* *Tasks do not produce any results*, there is no ``get`` or ``join`` and there is no "result backend". This is not a distrubuted await. If you need to store results, pass a unique key into the task and store the result in some DIY model.
|
|
10
|
+
* *No workflows and chaining of jobs.*
|
|
11
|
+
* *ETA (estimated time of arrival) is a first-class citizen.* It does not depend on whether backends support the feature or not.
|
|
12
|
+
* *The worker is a single-threaded process*, you start several of those to scale. I have seen too many issues with autoscaling workers, worker processes killed by OS, workers stuck: simple is better.
|
|
13
|
+
* *No prefetching or visibility timeouts*. The worker picks the first available task and processes it.
|
|
14
|
+
* *Dead letter queue* built in. You get access to failed tasks and can retry them from Django admin.
|
|
15
|
+
* *Django admin for monitoring.* You can view pending tasks, future, failed, and "dirty" (crashed in the middle of work).
|
|
16
|
+
* *Easy to get the metrics* from Django shell and export to your favorite monitoring tool
|
|
17
|
+
* *Task records are removed after successful execution*. Unlike Celery SQLAlchemy's backend, records are removed so you don't have to care about archiving. It also keeps the table small, properly indexed and efficient.
|
|
18
|
+
|
|
19
|
+
Celery API
|
|
20
|
+
----------
|
|
21
|
+
|
|
22
|
+
The main API is the Celery API (``shared_task``) with ``delay``, ``apply_async`` and ``s``. Just to make switching between implementations easier.
|
|
23
|
+
|
|
24
|
+
.. code-block:: python
|
|
25
|
+
|
|
26
|
+
from django_taskq.celery import shared_task
|
|
27
|
+
|
|
28
|
+
@shared_task(autoretry_for(MyException,), retry_kwargs={"max_retries": 10, "countdown": 5})
|
|
29
|
+
def my_task(foo, bar=None):
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
.. code-block:: python
|
|
33
|
+
|
|
34
|
+
my_task.delay(1,bar=2)
|
|
35
|
+
my_task.appy_async((1,2))
|
|
36
|
+
my_task.s(1,2).apply_async()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
Internals
|
|
40
|
+
---------
|
|
41
|
+
|
|
42
|
+
Adding a new task to the queue is just creating a new instance of the Task model.
|
|
43
|
+
|
|
44
|
+
Executing a task is a bit more expensive:
|
|
45
|
+
# A task is picked up from a queue and the state is updated to "started" within a single transaction.
|
|
46
|
+
# Python code is executed, a background thread updates "alive at" field every second ("a liveness probe").
|
|
47
|
+
# Successful tasks are deleted from the table. Failed tasks are marked as such and retried (based on configuration).
|
|
48
|
+
|
|
49
|
+
This is a bit more expensive than necessary but:
|
|
50
|
+
* we can recognize running tasks - the task is "started" and the record is updated in the last couple seconds.
|
|
51
|
+
* we can recognize "dirty" tasks that got killed or lost database connection in the middle - the task is "started" and the record has not been updated for a while.
|
|
52
|
+
|
|
53
|
+
In an ideal world tasks should be idempotent but things happen and I prefer to know which tasks crashed and double-check if some cleanup is necessary.
|
|
54
|
+
|
|
55
|
+
Performance
|
|
56
|
+
-----------
|
|
57
|
+
|
|
58
|
+
A single process can execute around 150 dummy tasks per second which is more than enough. After years of struggling with Celery, correctness and observability are more important.
|
|
59
|
+
On the other hand, to handle more "tasks" you probably want to store many events not tasks and have a single task that processes them in batches.
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from django.contrib import admin, messages
|
|
2
|
+
|
|
3
|
+
from django_taskq.models import (ActiveTask, DirtyTask, FailedTask, FutureTask,
|
|
4
|
+
PendingTask)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@admin.register(PendingTask, ActiveTask, FutureTask)
|
|
8
|
+
class PendingTaskAdmin(admin.ModelAdmin):
|
|
9
|
+
fields = ("id", "execute_at", "queue", "repr", "created_at")
|
|
10
|
+
list_display = ("id", "execute_at", "queue", "func", "args", "kwargs")
|
|
11
|
+
readonly_fields = (
|
|
12
|
+
"id",
|
|
13
|
+
"execute_at",
|
|
14
|
+
"queue",
|
|
15
|
+
"func",
|
|
16
|
+
"args",
|
|
17
|
+
"kwargs",
|
|
18
|
+
"created_at",
|
|
19
|
+
)
|
|
20
|
+
list_filter = (
|
|
21
|
+
"queue",
|
|
22
|
+
"func",
|
|
23
|
+
("execute_at", admin.DateFieldListFilter),
|
|
24
|
+
("created_at", admin.DateFieldListFilter),
|
|
25
|
+
("started_at", admin.DateFieldListFilter),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def has_add_permission(self, request, obj=None):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
def has_delete_permission(self, request, obj=None):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@admin.register(DirtyTask, FailedTask)
|
|
36
|
+
class RestartableTaskAdmin(admin.ModelAdmin):
|
|
37
|
+
fields = (
|
|
38
|
+
"id",
|
|
39
|
+
"execute_at",
|
|
40
|
+
"queue",
|
|
41
|
+
"func",
|
|
42
|
+
"args",
|
|
43
|
+
"kwargs",
|
|
44
|
+
"retries",
|
|
45
|
+
"created_at",
|
|
46
|
+
"alive_at",
|
|
47
|
+
"traceback",
|
|
48
|
+
)
|
|
49
|
+
list_display = ("id", "execute_at", "queue", "repr", "retries")
|
|
50
|
+
readonly_fields = (
|
|
51
|
+
"id",
|
|
52
|
+
"execute_at",
|
|
53
|
+
"func",
|
|
54
|
+
"retries",
|
|
55
|
+
"created_at",
|
|
56
|
+
"alive_at",
|
|
57
|
+
"traceback",
|
|
58
|
+
)
|
|
59
|
+
list_filter = (
|
|
60
|
+
"queue",
|
|
61
|
+
"func",
|
|
62
|
+
("execute_at", admin.DateFieldListFilter),
|
|
63
|
+
("created_at", admin.DateFieldListFilter),
|
|
64
|
+
("alive_at", admin.DateFieldListFilter),
|
|
65
|
+
)
|
|
66
|
+
actions = ("force_retry",)
|
|
67
|
+
|
|
68
|
+
def has_add_permission(self, request, obj=None):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def has_delete_permission(self, request, obj=None):
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
@admin.action(description="Retry selected tasks")
|
|
75
|
+
def force_retry(self, request, queryset):
|
|
76
|
+
count = 0
|
|
77
|
+
for task in queryset.iterator():
|
|
78
|
+
count += 1
|
|
79
|
+
task.retry()
|
|
80
|
+
self.message_user(
|
|
81
|
+
request,
|
|
82
|
+
f"{count} task(s) will be retried",
|
|
83
|
+
messages.SUCCESS,
|
|
84
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import inspect
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Union
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.utils import timezone
|
|
8
|
+
|
|
9
|
+
from django_taskq.models import Retry, Task
|
|
10
|
+
|
|
11
|
+
__all__ = ["shared_task", "Ignore", "Retry"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _apply_async(
|
|
15
|
+
func,
|
|
16
|
+
args: tuple = None,
|
|
17
|
+
kwargs: dict = None,
|
|
18
|
+
countdown: float = None,
|
|
19
|
+
eta: datetime.datetime = None,
|
|
20
|
+
expires: Union[float, datetime.datetime] = None,
|
|
21
|
+
queue: str = None,
|
|
22
|
+
):
|
|
23
|
+
if countdown:
|
|
24
|
+
eta = timezone.now() + timezone.timedelta(seconds=int(countdown))
|
|
25
|
+
if expires and isinstance(expires, (int, float)):
|
|
26
|
+
expires = timezone.now() + timezone.timedelta(seconds=int(expires))
|
|
27
|
+
|
|
28
|
+
task = Task(
|
|
29
|
+
queue=queue or Task.DEFAULTQ,
|
|
30
|
+
func=".".join((inspect.getmodule(func).__name__, func.__name__)),
|
|
31
|
+
args=args or (),
|
|
32
|
+
kwargs=kwargs or {},
|
|
33
|
+
execute_at=eta,
|
|
34
|
+
expires_at=expires,
|
|
35
|
+
)
|
|
36
|
+
if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False):
|
|
37
|
+
task.execute()
|
|
38
|
+
else:
|
|
39
|
+
task.save()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _retry(
|
|
43
|
+
args=None,
|
|
44
|
+
kwargs=None,
|
|
45
|
+
exc=None,
|
|
46
|
+
throw=True,
|
|
47
|
+
eta=None,
|
|
48
|
+
countdown=None,
|
|
49
|
+
max_retries=None,
|
|
50
|
+
**options
|
|
51
|
+
):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Signature:
|
|
56
|
+
def __init__(self, func, args, kwargs):
|
|
57
|
+
self.func = func
|
|
58
|
+
self.args = args
|
|
59
|
+
self.kwargs = kwargs
|
|
60
|
+
self.options = {}
|
|
61
|
+
|
|
62
|
+
def set(self, **kwargs):
|
|
63
|
+
self.options.update(kwargs)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def delay(self):
|
|
67
|
+
_apply_async(self.func, self.args, self.kwargs, **self.options)
|
|
68
|
+
|
|
69
|
+
def apply_async(self, **kwargs):
|
|
70
|
+
self.options.update(kwargs)
|
|
71
|
+
_apply_async(self.func, self.args, self.kwargs, **self.options)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _retry(exc=None, eta=None, countdown=None, max_retries=None):
|
|
75
|
+
if not eta:
|
|
76
|
+
if countdown is None:
|
|
77
|
+
countdown = 3 * 60
|
|
78
|
+
eta = timezone.now() + timezone.timedelta(seconds=int(countdown))
|
|
79
|
+
raise Retry(exc=exc, execute_at=eta, max_retries=max_retries)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _wrap_autoretry(func, **options):
|
|
83
|
+
autoretry_for = tuple(options.get("autoretry_for", ()))
|
|
84
|
+
dont_autoretry_for = tuple(options.get("dont_autoretry_for", ()))
|
|
85
|
+
retry_kwargs = options.get("retry_kwargs", {})
|
|
86
|
+
max_retries = retry_kwargs.get("max_retries", 3)
|
|
87
|
+
countdown = retry_kwargs.get("countdown", 3 * 60)
|
|
88
|
+
eta = timezone.now() + timezone.timedelta(seconds=int(countdown))
|
|
89
|
+
|
|
90
|
+
if autoretry_for:
|
|
91
|
+
|
|
92
|
+
@wraps(func)
|
|
93
|
+
def run(*args, **kwargs):
|
|
94
|
+
try:
|
|
95
|
+
return func(*args, **kwargs)
|
|
96
|
+
except Retry:
|
|
97
|
+
raise
|
|
98
|
+
except dont_autoretry_for:
|
|
99
|
+
raise
|
|
100
|
+
except autoretry_for as exc:
|
|
101
|
+
raise Retry(exc=exc, execute_at=eta, max_retries=max_retries)
|
|
102
|
+
|
|
103
|
+
return run
|
|
104
|
+
|
|
105
|
+
return func
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def shared_task(*args, **kwargs):
|
|
109
|
+
def create_shared_task(**options):
|
|
110
|
+
def run(func):
|
|
111
|
+
func.delay = lambda *args, **kwargs: _apply_async(func, args, kwargs)
|
|
112
|
+
func.apply_async = lambda *args, **kwargs: _apply_async(
|
|
113
|
+
func, *args, **kwargs
|
|
114
|
+
)
|
|
115
|
+
func.s = lambda *args, **kwargs: Signature(func, args, kwargs)
|
|
116
|
+
func.retry = lambda *args, **kwargs: _retry(*args, **kwargs)
|
|
117
|
+
return _wrap_autoretry(func, **options)
|
|
118
|
+
|
|
119
|
+
return run
|
|
120
|
+
|
|
121
|
+
if len(args) and callable(args[0]):
|
|
122
|
+
return create_shared_task(**kwargs)(args[0])
|
|
123
|
+
return create_shared_task(*args, **kwargs)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from django.core.management.base import BaseCommand
|
|
5
|
+
|
|
6
|
+
from django_taskq.models import Retry, Task
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Heartbeat(threading.Thread):
|
|
10
|
+
def __init__(self, command):
|
|
11
|
+
super().__init__()
|
|
12
|
+
self.finished = threading.Event()
|
|
13
|
+
self.command = command
|
|
14
|
+
|
|
15
|
+
def run(self):
|
|
16
|
+
previous = None
|
|
17
|
+
while not self.finished.is_set():
|
|
18
|
+
self.finished.wait(1)
|
|
19
|
+
current = self.command.task_id
|
|
20
|
+
|
|
21
|
+
# Skip the first second update
|
|
22
|
+
# TODO: make this smarter
|
|
23
|
+
if current and current == previous:
|
|
24
|
+
Task.alive(current)
|
|
25
|
+
self.command.stdout.write(
|
|
26
|
+
self.command.style.SUCCESS(f"Task({current}) alive")
|
|
27
|
+
)
|
|
28
|
+
previous = current
|
|
29
|
+
|
|
30
|
+
def cancel(self):
|
|
31
|
+
self.finished.set()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Command(BaseCommand):
|
|
35
|
+
help = "Process tasks from a queue specified by -Q or 'default'"
|
|
36
|
+
task_id = None
|
|
37
|
+
|
|
38
|
+
def add_arguments(self, parser):
|
|
39
|
+
parser.add_argument("-Q", action="store", dest="queue_name", help="Queue name")
|
|
40
|
+
|
|
41
|
+
def handle(self, *_, **options):
|
|
42
|
+
heartbeat = Heartbeat(self)
|
|
43
|
+
heartbeat.start()
|
|
44
|
+
try:
|
|
45
|
+
while True:
|
|
46
|
+
task = Task.next_task(queue=options.get("queue_name"))
|
|
47
|
+
if not task:
|
|
48
|
+
self.stdout.write(self.style.SUCCESS("No new tasks"))
|
|
49
|
+
time.sleep(1)
|
|
50
|
+
else:
|
|
51
|
+
self._execute_one(task)
|
|
52
|
+
finally:
|
|
53
|
+
heartbeat.cancel()
|
|
54
|
+
|
|
55
|
+
def _execute_one(self, task):
|
|
56
|
+
self.stdout.write(self.style.SUCCESS(f"Processing Task({task.pk}) {task.repr}"))
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
self.task_id = task.id
|
|
60
|
+
task.execute()
|
|
61
|
+
|
|
62
|
+
self.stdout.write(self.style.SUCCESS(f"Completed Task({task.pk})"))
|
|
63
|
+
task.delete()
|
|
64
|
+
except Retry as retry:
|
|
65
|
+
self.stdout.write(self.style.ERROR(f"Failed Task({task.pk}), will retry"))
|
|
66
|
+
task.retry(retry)
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
self.stdout.write(self.style.ERROR(f"Failed Task({task.pk}): {exc!r}"))
|
|
69
|
+
task.fail(exc)
|
|
70
|
+
finally:
|
|
71
|
+
self.task_id = None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Generated by Django 5.1 on 2024-08-13 19:30
|
|
2
|
+
|
|
3
|
+
import picklefield.fields
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name="Task",
|
|
16
|
+
fields=[
|
|
17
|
+
(
|
|
18
|
+
"id",
|
|
19
|
+
models.BigAutoField(
|
|
20
|
+
auto_created=True,
|
|
21
|
+
primary_key=True,
|
|
22
|
+
serialize=False,
|
|
23
|
+
verbose_name="ID",
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
("queue", models.CharField(max_length=64)),
|
|
27
|
+
("func", models.CharField(max_length=256)),
|
|
28
|
+
("args", picklefield.fields.PickledObjectField(editable=False)),
|
|
29
|
+
("kwargs", picklefield.fields.PickledObjectField(editable=False)),
|
|
30
|
+
("repr", models.CharField(max_length=512)),
|
|
31
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
32
|
+
("execute_at", models.DateTimeField(auto_now_add=True)),
|
|
33
|
+
("expires_at", models.DateTimeField(blank=True, null=True)),
|
|
34
|
+
(
|
|
35
|
+
"alive_at",
|
|
36
|
+
models.DateTimeField(blank=True, editable=False, null=True),
|
|
37
|
+
),
|
|
38
|
+
("retries", models.PositiveIntegerField(default=0, editable=False)),
|
|
39
|
+
("traceback", models.TextField(blank=True, editable=False, null=True)),
|
|
40
|
+
("started", models.BooleanField(default=False, editable=False)),
|
|
41
|
+
("failed", models.BooleanField(default=False, editable=False)),
|
|
42
|
+
],
|
|
43
|
+
options={
|
|
44
|
+
"indexes": [
|
|
45
|
+
models.Index(
|
|
46
|
+
condition=models.Q(("failed", False), ("started", False)),
|
|
47
|
+
fields=["execute_at", "queue"],
|
|
48
|
+
name="taskq_execute_at_queue_idx",
|
|
49
|
+
)
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
),
|
|
53
|
+
migrations.CreateModel(
|
|
54
|
+
name="ActiveTask",
|
|
55
|
+
fields=[],
|
|
56
|
+
options={
|
|
57
|
+
"proxy": True,
|
|
58
|
+
"indexes": [],
|
|
59
|
+
"constraints": [],
|
|
60
|
+
},
|
|
61
|
+
bases=("taskq.task",),
|
|
62
|
+
),
|
|
63
|
+
migrations.CreateModel(
|
|
64
|
+
name="DirtyTask",
|
|
65
|
+
fields=[],
|
|
66
|
+
options={
|
|
67
|
+
"proxy": True,
|
|
68
|
+
"indexes": [],
|
|
69
|
+
"constraints": [],
|
|
70
|
+
},
|
|
71
|
+
bases=("taskq.task",),
|
|
72
|
+
),
|
|
73
|
+
migrations.CreateModel(
|
|
74
|
+
name="FailedTask",
|
|
75
|
+
fields=[],
|
|
76
|
+
options={
|
|
77
|
+
"proxy": True,
|
|
78
|
+
"indexes": [],
|
|
79
|
+
"constraints": [],
|
|
80
|
+
},
|
|
81
|
+
bases=("taskq.task",),
|
|
82
|
+
),
|
|
83
|
+
migrations.CreateModel(
|
|
84
|
+
name="FutureTask",
|
|
85
|
+
fields=[],
|
|
86
|
+
options={
|
|
87
|
+
"proxy": True,
|
|
88
|
+
"indexes": [],
|
|
89
|
+
"constraints": [],
|
|
90
|
+
},
|
|
91
|
+
bases=("taskq.task",),
|
|
92
|
+
),
|
|
93
|
+
migrations.CreateModel(
|
|
94
|
+
name="PendingTask",
|
|
95
|
+
fields=[],
|
|
96
|
+
options={
|
|
97
|
+
"proxy": True,
|
|
98
|
+
"indexes": [],
|
|
99
|
+
"constraints": [],
|
|
100
|
+
},
|
|
101
|
+
bases=("taskq.task",),
|
|
102
|
+
),
|
|
103
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
from django.db import models, transaction
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
from picklefield.fields import PickledObjectField
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MaxRetriesExceededError(Exception): ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Retry(Exception):
|
|
13
|
+
exc = None
|
|
14
|
+
execute_at = None
|
|
15
|
+
max_retries = None
|
|
16
|
+
|
|
17
|
+
def __init__(self, exc=None, execute_at=None, max_retries=None):
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.exc = exc
|
|
20
|
+
self.execute_at = execute_at
|
|
21
|
+
self.max_retries = max_retries
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Task(models.Model):
|
|
25
|
+
DEFAULTQ = "default"
|
|
26
|
+
queue = models.CharField(max_length=64)
|
|
27
|
+
|
|
28
|
+
func = models.CharField(max_length=256)
|
|
29
|
+
args = PickledObjectField()
|
|
30
|
+
kwargs = PickledObjectField()
|
|
31
|
+
repr = models.CharField(max_length=512)
|
|
32
|
+
|
|
33
|
+
created_at = models.DateTimeField(auto_now_add=True, editable=False)
|
|
34
|
+
execute_at = models.DateTimeField(auto_now_add=True)
|
|
35
|
+
expires_at = models.DateTimeField(null=True, blank=True)
|
|
36
|
+
alive_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
37
|
+
|
|
38
|
+
retries = models.PositiveIntegerField(default=0, editable=False)
|
|
39
|
+
traceback = models.TextField(null=True, blank=True, editable=False)
|
|
40
|
+
|
|
41
|
+
started = models.BooleanField(default=False, editable=False)
|
|
42
|
+
failed = models.BooleanField(default=False, editable=False)
|
|
43
|
+
|
|
44
|
+
def save(self, *args, **kwargs):
|
|
45
|
+
strargs = [str(arg) for arg in self.args]
|
|
46
|
+
strargs += [str(key) + "=" + str(value) for key, value in self.kwargs.items()]
|
|
47
|
+
self.repr = f"{self.func}({', '.join(strargs)})"
|
|
48
|
+
super().save(*args, **kwargs)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def next_task(cls, queue=None):
|
|
52
|
+
queue = queue or "default"
|
|
53
|
+
while True:
|
|
54
|
+
with transaction.atomic():
|
|
55
|
+
task = (
|
|
56
|
+
cls.objects.select_for_update(skip_locked=True)
|
|
57
|
+
.filter(
|
|
58
|
+
failed=False,
|
|
59
|
+
started=False,
|
|
60
|
+
execute_at__lte=timezone.now(),
|
|
61
|
+
queue=queue,
|
|
62
|
+
)
|
|
63
|
+
.order_by("execute_at")
|
|
64
|
+
.first()
|
|
65
|
+
)
|
|
66
|
+
if not task:
|
|
67
|
+
return task
|
|
68
|
+
|
|
69
|
+
if task.expires_at and task.expires_at <= timezone.now():
|
|
70
|
+
task.delete()
|
|
71
|
+
else:
|
|
72
|
+
task.started = True
|
|
73
|
+
task.alive_at = timezone.now()
|
|
74
|
+
task.save(update_fields=["started", "alive_at"])
|
|
75
|
+
return task
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def alive(cls, task_id):
|
|
79
|
+
cls.obects.filter(pk=task_id).update(alive_at=timezone.now())
|
|
80
|
+
|
|
81
|
+
def fail(self, exc):
|
|
82
|
+
self.started = False
|
|
83
|
+
self.failed = True
|
|
84
|
+
self.traceback = traceback.format_exception(exc)
|
|
85
|
+
self.save(update_fields=["started", "failed", "traceback"])
|
|
86
|
+
|
|
87
|
+
def retry(self, retry_info: Retry):
|
|
88
|
+
|
|
89
|
+
self.retries += 1
|
|
90
|
+
if retry_info.max_retries is not None and self.retries > retry_info.max_retries:
|
|
91
|
+
if retry_info.exc:
|
|
92
|
+
self.fail(retry_info.exc)
|
|
93
|
+
else:
|
|
94
|
+
self.fail(MaxRetriesExceededError())
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
self.started = False
|
|
98
|
+
self.failed = False
|
|
99
|
+
if retry_info.exc:
|
|
100
|
+
self.traceback = traceback.format_exception(retry_info.exc)
|
|
101
|
+
else:
|
|
102
|
+
self.traceback = None
|
|
103
|
+
self.execute_at = retry_info.execute_at
|
|
104
|
+
self.save(
|
|
105
|
+
update_fields=["started", "failed", "traceback", "retries", "execute_at"]
|
|
106
|
+
)
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def execute(self):
|
|
110
|
+
last_dot = self.func.rindex(".")
|
|
111
|
+
mod = self.func[:last_dot]
|
|
112
|
+
func = self.func[last_dot + 1 :]
|
|
113
|
+
|
|
114
|
+
f = getattr(__import__(mod, fromlist=(func,)), func)
|
|
115
|
+
f(*self.args, **self.kwargs)
|
|
116
|
+
|
|
117
|
+
class Meta:
|
|
118
|
+
indexes = [
|
|
119
|
+
models.Index(
|
|
120
|
+
name="taskq_execute_at_queue_idx",
|
|
121
|
+
fields=("execute_at", "queue"),
|
|
122
|
+
condition=models.Q(failed=False, started=False),
|
|
123
|
+
)
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PendingTaskManager(models.Manager):
|
|
128
|
+
def get_queryset(self):
|
|
129
|
+
return (
|
|
130
|
+
super()
|
|
131
|
+
.get_queryset()
|
|
132
|
+
.filter(failed=False, started=False, execute_at__lte=timezone.now())
|
|
133
|
+
.order_by("execute_at")
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PendingTask(Task):
|
|
138
|
+
objects = PendingTaskManager()
|
|
139
|
+
|
|
140
|
+
class Meta:
|
|
141
|
+
proxy = True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class FutureTaskManager(models.Manager):
|
|
145
|
+
def get_queryset(self):
|
|
146
|
+
return (
|
|
147
|
+
super()
|
|
148
|
+
.get_queryset()
|
|
149
|
+
.filter(failed=False, started=False, execute_at__gt=timezone.now())
|
|
150
|
+
.order_by("execute_at")
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class FutureTask(Task):
|
|
155
|
+
objects = FutureTaskManager()
|
|
156
|
+
|
|
157
|
+
class Meta:
|
|
158
|
+
proxy = True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ActiveTaskManager(models.Manager):
|
|
162
|
+
def get_queryset(self):
|
|
163
|
+
return (
|
|
164
|
+
super()
|
|
165
|
+
.get_queryset()
|
|
166
|
+
.filter(
|
|
167
|
+
started=True,
|
|
168
|
+
alive_at__gt=timezone.now() - datetime.timedelta(seconds=3),
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ActiveTask(Task):
|
|
174
|
+
objects = ActiveTaskManager()
|
|
175
|
+
|
|
176
|
+
class Meta:
|
|
177
|
+
proxy = True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class DirtyTaskManager(models.Manager):
|
|
181
|
+
def get_queryset(self):
|
|
182
|
+
return (
|
|
183
|
+
super()
|
|
184
|
+
.get_queryset()
|
|
185
|
+
.filter(
|
|
186
|
+
started=True,
|
|
187
|
+
alive_at__lte=timezone.now() - datetime.timedelta(seconds=3),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class DirtyTask(Task):
|
|
193
|
+
objects = DirtyTaskManager()
|
|
194
|
+
|
|
195
|
+
class Meta:
|
|
196
|
+
proxy = True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class FailedTaskManager(models.Manager):
|
|
200
|
+
def get_queryset(self):
|
|
201
|
+
return super().get_queryset().filter(failed=True)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class FailedTask(Task):
|
|
205
|
+
objects = FailedTaskManager()
|
|
206
|
+
|
|
207
|
+
class Meta:
|
|
208
|
+
proxy = True
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from unittest import TestCase
|
|
3
|
+
|
|
4
|
+
from django.utils import timezone
|
|
5
|
+
|
|
6
|
+
from django_taskq.celery import shared_task
|
|
7
|
+
from django_taskq.models import Retry, Task
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TaskTestCase(TestCase):
|
|
11
|
+
def test_repr_is_created_on_save(self):
|
|
12
|
+
task = Task.objects.create(func="foo", args=(1, 2), kwargs={"a": 3, "b": 4})
|
|
13
|
+
self.assertEqual(task.repr, "foo(1, 2, a=3, b=4)")
|
|
14
|
+
|
|
15
|
+
def test_datetime_parameter_can_be_serialized(self):
|
|
16
|
+
timestamp = datetime.datetime.now()
|
|
17
|
+
task = Task.objects.create(func="foo", args=(timestamp,), kwargs={})
|
|
18
|
+
task.refresh_from_db()
|
|
19
|
+
self.assertEqual(task.args[0], timestamp)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@shared_task
|
|
23
|
+
def dummy(a, *, b=None):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@shared_task
|
|
28
|
+
def failing_task():
|
|
29
|
+
raise KeyError()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@shared_task(autoretry_for=(ValueError,))
|
|
33
|
+
def retry_task():
|
|
34
|
+
raise ValueError()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@shared_task
|
|
38
|
+
def self_retry_task():
|
|
39
|
+
self_retry_task.retry()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@shared_task(autoretry_for=(ValueError,))
|
|
43
|
+
def retry_count():
|
|
44
|
+
raise ValueError()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@shared_task(
|
|
48
|
+
autoretry_for=(ValueError,), retry_kwargs={"max_retries": 2, "countdown": 5}
|
|
49
|
+
)
|
|
50
|
+
def retry_params():
|
|
51
|
+
raise ValueError()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CeleryInterfaceTestCase(TestCase):
|
|
55
|
+
def test_shared_task_delay(self):
|
|
56
|
+
dummy.delay(1, 2)
|
|
57
|
+
|
|
58
|
+
task = Task.objects.last()
|
|
59
|
+
self.assertEqual(task.repr, "django_taskq.tests.dummy(1, 2)")
|
|
60
|
+
|
|
61
|
+
def test_shared_task_apply_async(self):
|
|
62
|
+
dummy.apply_async(args=(1, 2))
|
|
63
|
+
|
|
64
|
+
task = Task.objects.last()
|
|
65
|
+
self.assertEqual(task.repr, "django_taskq.tests.dummy(1, 2)")
|
|
66
|
+
|
|
67
|
+
def test_shared_task_signature_delay(self):
|
|
68
|
+
dummy.s(1, 2).delay()
|
|
69
|
+
|
|
70
|
+
task = Task.objects.last()
|
|
71
|
+
self.assertEqual(task.repr, "django_taskq.tests.dummy(1, 2)")
|
|
72
|
+
|
|
73
|
+
def test_shared_task_signature_apply_async(self):
|
|
74
|
+
dummy.s(1, 2).apply_async()
|
|
75
|
+
|
|
76
|
+
task = Task.objects.last()
|
|
77
|
+
self.assertEqual(task.repr, "django_taskq.tests.dummy(1, 2)")
|
|
78
|
+
|
|
79
|
+
def test_shared_task_fail(self):
|
|
80
|
+
failing_task.delay()
|
|
81
|
+
|
|
82
|
+
task = Task.objects.last()
|
|
83
|
+
with self.assertRaises(KeyError):
|
|
84
|
+
task.execute()
|
|
85
|
+
|
|
86
|
+
def test_shared_task_retry(self):
|
|
87
|
+
retry_task.delay()
|
|
88
|
+
|
|
89
|
+
task = Task.objects.last()
|
|
90
|
+
with self.assertRaises(Retry):
|
|
91
|
+
task.execute()
|
|
92
|
+
|
|
93
|
+
def test_shared_task_self_retry(self):
|
|
94
|
+
self_retry_task.delay()
|
|
95
|
+
|
|
96
|
+
task = Task.objects.last()
|
|
97
|
+
with self.assertRaises(Retry):
|
|
98
|
+
task.execute()
|
|
99
|
+
|
|
100
|
+
def test_shared_task_self_retry_counter(self):
|
|
101
|
+
retry_count.delay()
|
|
102
|
+
|
|
103
|
+
task = Task.objects.last()
|
|
104
|
+
task.execute_at = timezone.now()
|
|
105
|
+
with self.assertRaises(Retry) as exc_info:
|
|
106
|
+
task.execute()
|
|
107
|
+
task.retry(exc_info.exception)
|
|
108
|
+
self.assertFalse(task.failed)
|
|
109
|
+
self.assertAlmostEqual(
|
|
110
|
+
(task.execute_at - timezone.now()).seconds, 3 * 60, delta=1
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
task.execute_at = timezone.now()
|
|
114
|
+
with self.assertRaises(Retry) as exc_info:
|
|
115
|
+
task.execute()
|
|
116
|
+
task.retry(exc_info.exception)
|
|
117
|
+
self.assertFalse(task.failed)
|
|
118
|
+
self.assertAlmostEqual(
|
|
119
|
+
(task.execute_at - timezone.now()).seconds, 3 * 60, delta=1
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
task.execute_at = timezone.now()
|
|
123
|
+
with self.assertRaises(Retry) as exc_info:
|
|
124
|
+
task.execute()
|
|
125
|
+
task.retry(exc_info.exception)
|
|
126
|
+
self.assertFalse(task.failed)
|
|
127
|
+
self.assertAlmostEqual(
|
|
128
|
+
(task.execute_at - timezone.now()).seconds, 3 * 60, delta=1
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
with self.assertRaises(Retry) as exc_info:
|
|
132
|
+
task.execute()
|
|
133
|
+
task.retry(exc_info.exception)
|
|
134
|
+
self.assertTrue(task.failed)
|
|
135
|
+
|
|
136
|
+
def test_shared_task_self_retry_params(self):
|
|
137
|
+
retry_params.delay()
|
|
138
|
+
|
|
139
|
+
task = Task.objects.last()
|
|
140
|
+
task.execute_at = timezone.now()
|
|
141
|
+
with self.assertRaises(Retry) as exc_info:
|
|
142
|
+
task.execute()
|
|
143
|
+
task.retry(exc_info.exception)
|
|
144
|
+
self.assertFalse(task.failed)
|
|
145
|
+
self.assertAlmostEqual((task.execute_at - timezone.now()).seconds, 5, delta=1)
|
|
146
|
+
|
|
147
|
+
task.execute_at = timezone.now()
|
|
148
|
+
with self.assertRaises(Retry) as exc_info:
|
|
149
|
+
task.execute()
|
|
150
|
+
task.retry(exc_info.exception)
|
|
151
|
+
self.assertFalse(task.failed)
|
|
152
|
+
self.assertAlmostEqual((task.execute_at - timezone.now()).seconds, 5, delta=1)
|
|
153
|
+
|
|
154
|
+
with self.assertRaises(Retry) as exc_info:
|
|
155
|
+
task.execute()
|
|
156
|
+
task.retry(exc_info.exception)
|
|
157
|
+
self.assertTrue(task.failed)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-taskq
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A simple Celery-like database-backed task queue for Django
|
|
5
|
+
Home-page: https://github.com/aivarsk/django-taskq
|
|
6
|
+
Author: Aivars Kalvans
|
|
7
|
+
Author-email: aivars.kalvans@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: Django :: 4.0
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
|
|
24
|
+
Django database task queue
|
|
25
|
+
|
|
26
|
+
Django Transaction Task Queue
|
|
27
|
+
=============================
|
|
28
|
+
|
|
29
|
+
A short and simple Celery replacement for my Django projects.
|
|
30
|
+
|
|
31
|
+
* *Database is the only backend.* The task is a simple Django model, it uses the same transaction and connection as other models. No more ``transaction.on_commit`` hooks to schedule tasks.
|
|
32
|
+
* *Tasks do not produce any results*, there is no ``get`` or ``join`` and there is no "result backend". This is not a distrubuted await. If you need to store results, pass a unique key into the task and store the result in some DIY model.
|
|
33
|
+
* *No workflows and chaining of jobs.*
|
|
34
|
+
* *ETA (estimated time of arrival) is a first-class citizen.* It does not depend on whether backends support the feature or not.
|
|
35
|
+
* *The worker is a single-threaded process*, you start several of those to scale. I have seen too many issues with autoscaling workers, worker processes killed by OS, workers stuck: simple is better.
|
|
36
|
+
* *No prefetching or visibility timeouts*. The worker picks the first available task and processes it.
|
|
37
|
+
* *Dead letter queue* built in. You get access to failed tasks and can retry them from Django admin.
|
|
38
|
+
* *Django admin for monitoring.* You can view pending tasks, future, failed, and "dirty" (crashed in the middle of work).
|
|
39
|
+
* *Easy to get the metrics* from Django shell and export to your favorite monitoring tool
|
|
40
|
+
* *Task records are removed after successful execution*. Unlike Celery SQLAlchemy's backend, records are removed so you don't have to care about archiving. It also keeps the table small, properly indexed and efficient.
|
|
41
|
+
|
|
42
|
+
Celery API
|
|
43
|
+
----------
|
|
44
|
+
|
|
45
|
+
The main API is the Celery API (``shared_task``) with ``delay``, ``apply_async`` and ``s``. Just to make switching between implementations easier.
|
|
46
|
+
|
|
47
|
+
.. code-block:: python
|
|
48
|
+
|
|
49
|
+
from django_taskq.celery import shared_task
|
|
50
|
+
|
|
51
|
+
@shared_task(autoretry_for(MyException,), retry_kwargs={"max_retries": 10, "countdown": 5})
|
|
52
|
+
def my_task(foo, bar=None):
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
.. code-block:: python
|
|
56
|
+
|
|
57
|
+
my_task.delay(1,bar=2)
|
|
58
|
+
my_task.appy_async((1,2))
|
|
59
|
+
my_task.s(1,2).apply_async()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Internals
|
|
63
|
+
---------
|
|
64
|
+
|
|
65
|
+
Adding a new task to the queue is just creating a new instance of the Task model.
|
|
66
|
+
|
|
67
|
+
Executing a task is a bit more expensive:
|
|
68
|
+
# A task is picked up from a queue and the state is updated to "started" within a single transaction.
|
|
69
|
+
# Python code is executed, a background thread updates "alive at" field every second ("a liveness probe").
|
|
70
|
+
# Successful tasks are deleted from the table. Failed tasks are marked as such and retried (based on configuration).
|
|
71
|
+
|
|
72
|
+
This is a bit more expensive than necessary but:
|
|
73
|
+
* we can recognize running tasks - the task is "started" and the record is updated in the last couple seconds.
|
|
74
|
+
* we can recognize "dirty" tasks that got killed or lost database connection in the middle - the task is "started" and the record has not been updated for a while.
|
|
75
|
+
|
|
76
|
+
In an ideal world tasks should be idempotent but things happen and I prefer to know which tasks crashed and double-check if some cleanup is necessary.
|
|
77
|
+
|
|
78
|
+
Performance
|
|
79
|
+
-----------
|
|
80
|
+
|
|
81
|
+
A single process can execute around 150 dummy tasks per second which is more than enough. After years of struggling with Celery, correctness and observability are more important.
|
|
82
|
+
On the other hand, to handle more "tasks" you probably want to store many events not tasks and have a single task that processes them in batches.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.rst
|
|
4
|
+
pyproject.toml
|
|
5
|
+
setup.cfg
|
|
6
|
+
setup.py
|
|
7
|
+
django_taskq/__init__.py
|
|
8
|
+
django_taskq/admin.py
|
|
9
|
+
django_taskq/apps.py
|
|
10
|
+
django_taskq/celery.py
|
|
11
|
+
django_taskq/models.py
|
|
12
|
+
django_taskq/tests.py
|
|
13
|
+
django_taskq.egg-info/PKG-INFO
|
|
14
|
+
django_taskq.egg-info/SOURCES.txt
|
|
15
|
+
django_taskq.egg-info/dependency_links.txt
|
|
16
|
+
django_taskq.egg-info/requires.txt
|
|
17
|
+
django_taskq.egg-info/top_level.txt
|
|
18
|
+
django_taskq/management/__init__.py
|
|
19
|
+
django_taskq/management/commands/__init__.py
|
|
20
|
+
django_taskq/management/commands/taskq.py
|
|
21
|
+
django_taskq/migrations/0001_initial.py
|
|
22
|
+
django_taskq/migrations/__init__.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_taskq
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[metadata]
|
|
2
|
+
name = django-taskq
|
|
3
|
+
version = 1.0.0
|
|
4
|
+
description = A simple Celery-like database-backed task queue for Django
|
|
5
|
+
long_description = file: README.rst
|
|
6
|
+
url = https://github.com/aivarsk/django-taskq
|
|
7
|
+
author = Aivars Kalvans
|
|
8
|
+
author_email = aivars.kalvans@gmail.com
|
|
9
|
+
license = MIT
|
|
10
|
+
classifiers =
|
|
11
|
+
Environment :: Web Environment
|
|
12
|
+
Framework :: Django
|
|
13
|
+
Framework :: Django :: 4.0
|
|
14
|
+
Intended Audience :: Developers
|
|
15
|
+
License :: OSI Approved :: MIT License
|
|
16
|
+
Operating System :: OS Independent
|
|
17
|
+
Programming Language :: Python
|
|
18
|
+
Programming Language :: Python :: 3
|
|
19
|
+
Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Programming Language :: Python :: 3.10
|
|
21
|
+
Programming Language :: Python :: 3.11
|
|
22
|
+
Programming Language :: Python :: 3.12
|
|
23
|
+
|
|
24
|
+
[options]
|
|
25
|
+
include_package_data = true
|
|
26
|
+
packages = find:
|
|
27
|
+
python_requires = >=3.10
|
|
28
|
+
install_requires =
|
|
29
|
+
Django >= 4.0
|
|
30
|
+
django-picklefield >= 3.2
|
|
31
|
+
|
|
32
|
+
[egg_info]
|
|
33
|
+
tag_build =
|
|
34
|
+
tag_date = 0
|
|
35
|
+
|