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.
@@ -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,2 @@
1
+ include LICENSE
2
+ include README.rst
@@ -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,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class TaskQConfig(AppConfig):
5
+ name = "django_taskq"
6
+ label = "taskq"
7
+ verbose_name = "Task Queue"
8
+ default_auto_field = "django.db.models.BigAutoField"
@@ -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
@@ -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,2 @@
1
+ Django>=4.0
2
+ django-picklefield>=3.2
@@ -0,0 +1 @@
1
+ django_taskq
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ['setuptools>=40.8.0']
3
+ build-backend = 'setuptools.build_meta'
@@ -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
+
@@ -0,0 +1,3 @@
1
+ from setuptools import setup
2
+
3
+ setup()