django-database-task 0.1.0__py3-none-any.whl
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_database_task/__init__.py +27 -0
- django_database_task/admin.py +211 -0
- django_database_task/apps.py +7 -0
- django_database_task/backends.py +211 -0
- django_database_task/executor.py +181 -0
- django_database_task/locale/ja/LC_MESSAGES/django.mo +0 -0
- django_database_task/locale/ja/LC_MESSAGES/django.po +170 -0
- django_database_task/management/__init__.py +0 -0
- django_database_task/management/commands/__init__.py +0 -0
- django_database_task/management/commands/purge_completed_database_tasks.py +88 -0
- django_database_task/management/commands/run_database_tasks.py +98 -0
- django_database_task/migrations/0001_initial.py +190 -0
- django_database_task/migrations/__init__.py +0 -0
- django_database_task/models.py +120 -0
- django_database_task/urls.py +67 -0
- django_database_task/views.py +205 -0
- django_database_task-0.1.0.dist-info/METADATA +506 -0
- django_database_task-0.1.0.dist-info/RECORD +21 -0
- django_database_task-0.1.0.dist-info/WHEEL +5 -0
- django_database_task-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_database_task-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
django-database-task: A database-backed task queue backend for Django 6.0's task framework.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name):
|
|
9
|
+
"""Lazy import to avoid AppRegistryNotReady errors."""
|
|
10
|
+
if name in (
|
|
11
|
+
"fetch_task",
|
|
12
|
+
"get_pending_task_count",
|
|
13
|
+
"process_one_task",
|
|
14
|
+
"process_tasks",
|
|
15
|
+
):
|
|
16
|
+
from . import executor
|
|
17
|
+
|
|
18
|
+
return getattr(executor, name)
|
|
19
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"fetch_task",
|
|
24
|
+
"get_pending_task_count",
|
|
25
|
+
"process_one_task",
|
|
26
|
+
"process_tasks",
|
|
27
|
+
]
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from django.contrib import admin, messages
|
|
2
|
+
from django.db import transaction
|
|
3
|
+
from django.tasks import task_backends
|
|
4
|
+
from django.tasks.base import TaskResultStatus
|
|
5
|
+
from django.utils.html import format_html
|
|
6
|
+
from django.utils.translation import gettext_lazy as _
|
|
7
|
+
|
|
8
|
+
from .models import DatabaseTask
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@admin.register(DatabaseTask)
|
|
12
|
+
class DatabaseTaskAdmin(admin.ModelAdmin):
|
|
13
|
+
list_display = [
|
|
14
|
+
"id_short",
|
|
15
|
+
"task_path_short",
|
|
16
|
+
"status_badge",
|
|
17
|
+
"priority",
|
|
18
|
+
"queue_name",
|
|
19
|
+
"enqueued_at",
|
|
20
|
+
"started_at",
|
|
21
|
+
"finished_at",
|
|
22
|
+
]
|
|
23
|
+
list_filter = ["status", "queue_name", "backend_name"]
|
|
24
|
+
search_fields = ["id", "task_path"]
|
|
25
|
+
ordering = ["-created_at"]
|
|
26
|
+
readonly_fields = [
|
|
27
|
+
"id",
|
|
28
|
+
"created_at",
|
|
29
|
+
"updated_at",
|
|
30
|
+
"enqueued_at",
|
|
31
|
+
"started_at",
|
|
32
|
+
"finished_at",
|
|
33
|
+
"last_attempted_at",
|
|
34
|
+
"return_value_json",
|
|
35
|
+
"errors_json",
|
|
36
|
+
"worker_ids_json",
|
|
37
|
+
]
|
|
38
|
+
fieldsets = [
|
|
39
|
+
(
|
|
40
|
+
"Basic Information",
|
|
41
|
+
{"fields": ["id", "task_path", "backend_name", "queue_name", "priority"]},
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"Status",
|
|
45
|
+
{"fields": ["status", "run_after"]},
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
"Arguments",
|
|
49
|
+
{
|
|
50
|
+
"fields": ["args_json", "kwargs_json"],
|
|
51
|
+
"classes": ["collapse"],
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
(
|
|
55
|
+
"Execution Result",
|
|
56
|
+
{
|
|
57
|
+
"fields": ["return_value_json", "errors_json", "worker_ids_json"],
|
|
58
|
+
"classes": ["collapse"],
|
|
59
|
+
},
|
|
60
|
+
),
|
|
61
|
+
(
|
|
62
|
+
"Timestamps",
|
|
63
|
+
{
|
|
64
|
+
"fields": [
|
|
65
|
+
"enqueued_at",
|
|
66
|
+
"started_at",
|
|
67
|
+
"finished_at",
|
|
68
|
+
"last_attempted_at",
|
|
69
|
+
"created_at",
|
|
70
|
+
"updated_at",
|
|
71
|
+
],
|
|
72
|
+
"classes": ["collapse"],
|
|
73
|
+
},
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def id_short(self, obj):
|
|
78
|
+
"""Display shortened ID."""
|
|
79
|
+
return str(obj.id)[:8]
|
|
80
|
+
|
|
81
|
+
id_short.short_description = "ID"
|
|
82
|
+
|
|
83
|
+
def task_path_short(self, obj):
|
|
84
|
+
"""Display shortened task path."""
|
|
85
|
+
path = obj.task_path
|
|
86
|
+
if len(path) > 40:
|
|
87
|
+
return f"...{path[-37:]}"
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
task_path_short.short_description = "Task"
|
|
91
|
+
|
|
92
|
+
def status_badge(self, obj):
|
|
93
|
+
"""Display status as a colored badge."""
|
|
94
|
+
colors = {
|
|
95
|
+
"READY": "#6c757d",
|
|
96
|
+
"RUNNING": "#007bff",
|
|
97
|
+
"SUCCESSFUL": "#28a745",
|
|
98
|
+
"FAILED": "#dc3545",
|
|
99
|
+
}
|
|
100
|
+
color = colors.get(obj.status, "#6c757d")
|
|
101
|
+
return format_html(
|
|
102
|
+
'<span style="background-color: {}; color: white; padding: 3px 8px; '
|
|
103
|
+
'border-radius: 3px; font-size: 11px;">{}</span>',
|
|
104
|
+
color,
|
|
105
|
+
obj.status,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
status_badge.short_description = "Status"
|
|
109
|
+
|
|
110
|
+
actions = ["run_selected_tasks", "retry_failed_tasks"]
|
|
111
|
+
|
|
112
|
+
def has_add_permission(self, request):
|
|
113
|
+
"""Disable adding tasks from admin."""
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
@admin.action(description=_("Run selected tasks"))
|
|
117
|
+
def run_selected_tasks(self, request, queryset):
|
|
118
|
+
"""Execute selected tasks that are in READY status."""
|
|
119
|
+
ready_tasks = queryset.filter(status=TaskResultStatus.READY)
|
|
120
|
+
ready_count = ready_tasks.count()
|
|
121
|
+
|
|
122
|
+
if ready_count == 0:
|
|
123
|
+
self.message_user(
|
|
124
|
+
request,
|
|
125
|
+
"No tasks in READY status were selected.",
|
|
126
|
+
messages.WARNING,
|
|
127
|
+
)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
success_count = 0
|
|
131
|
+
fail_count = 0
|
|
132
|
+
|
|
133
|
+
for db_task in ready_tasks:
|
|
134
|
+
try:
|
|
135
|
+
backend = task_backends[db_task.backend_name]
|
|
136
|
+
result = backend.run_task(db_task, worker_id="admin")
|
|
137
|
+
if result.status == TaskResultStatus.SUCCESSFUL:
|
|
138
|
+
success_count += 1
|
|
139
|
+
else:
|
|
140
|
+
fail_count += 1
|
|
141
|
+
except Exception:
|
|
142
|
+
fail_count += 1
|
|
143
|
+
|
|
144
|
+
skipped_count = queryset.count() - ready_count
|
|
145
|
+
msg_parts = []
|
|
146
|
+
if success_count:
|
|
147
|
+
msg_parts.append(f"{success_count} succeeded")
|
|
148
|
+
if fail_count:
|
|
149
|
+
msg_parts.append(f"{fail_count} failed")
|
|
150
|
+
if skipped_count:
|
|
151
|
+
msg_parts.append(f"{skipped_count} skipped (not READY)")
|
|
152
|
+
|
|
153
|
+
self.message_user(
|
|
154
|
+
request,
|
|
155
|
+
f"Task execution completed: {', '.join(msg_parts)}.",
|
|
156
|
+
messages.SUCCESS if fail_count == 0 else messages.WARNING,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@admin.action(description=_("Retry failed tasks"))
|
|
160
|
+
def retry_failed_tasks(self, request, queryset):
|
|
161
|
+
"""Reset failed tasks to READY status and re-execute them."""
|
|
162
|
+
failed_tasks = queryset.filter(status=TaskResultStatus.FAILED)
|
|
163
|
+
failed_count = failed_tasks.count()
|
|
164
|
+
|
|
165
|
+
if failed_count == 0:
|
|
166
|
+
self.message_user(
|
|
167
|
+
request,
|
|
168
|
+
"No tasks in FAILED status were selected.",
|
|
169
|
+
messages.WARNING,
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
success_count = 0
|
|
174
|
+
fail_count = 0
|
|
175
|
+
|
|
176
|
+
for db_task in failed_tasks:
|
|
177
|
+
try:
|
|
178
|
+
with transaction.atomic():
|
|
179
|
+
# Reset task status to READY
|
|
180
|
+
db_task.status = TaskResultStatus.READY
|
|
181
|
+
db_task.errors_json = []
|
|
182
|
+
db_task.started_at = None
|
|
183
|
+
db_task.finished_at = None
|
|
184
|
+
db_task.return_value_json = None
|
|
185
|
+
db_task.save()
|
|
186
|
+
|
|
187
|
+
# Execute the task
|
|
188
|
+
backend = task_backends[db_task.backend_name]
|
|
189
|
+
result = backend.run_task(db_task, worker_id="admin-retry")
|
|
190
|
+
|
|
191
|
+
if result.status == TaskResultStatus.SUCCESSFUL:
|
|
192
|
+
success_count += 1
|
|
193
|
+
else:
|
|
194
|
+
fail_count += 1
|
|
195
|
+
except Exception:
|
|
196
|
+
fail_count += 1
|
|
197
|
+
|
|
198
|
+
skipped_count = queryset.count() - failed_count
|
|
199
|
+
msg_parts = []
|
|
200
|
+
if success_count:
|
|
201
|
+
msg_parts.append(f"{success_count} succeeded")
|
|
202
|
+
if fail_count:
|
|
203
|
+
msg_parts.append(f"{fail_count} failed again")
|
|
204
|
+
if skipped_count:
|
|
205
|
+
msg_parts.append(f"{skipped_count} skipped (not FAILED)")
|
|
206
|
+
|
|
207
|
+
self.message_user(
|
|
208
|
+
request,
|
|
209
|
+
f"Retry completed: {', '.join(msg_parts)}.",
|
|
210
|
+
messages.SUCCESS if fail_count == 0 else messages.WARNING,
|
|
211
|
+
)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import traceback
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from inspect import iscoroutinefunction
|
|
5
|
+
|
|
6
|
+
from django.tasks.backends.base import BaseTaskBackend
|
|
7
|
+
from django.tasks.base import Task, TaskContext, TaskError, TaskResult, TaskResultStatus
|
|
8
|
+
from django.tasks.exceptions import TaskResultDoesNotExist
|
|
9
|
+
from django.tasks.signals import task_enqueued, task_finished, task_started
|
|
10
|
+
from django.utils import timezone
|
|
11
|
+
from django.utils.json import normalize_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DatabaseTaskBackend(BaseTaskBackend):
|
|
15
|
+
"""A task backend that persists tasks in the database."""
|
|
16
|
+
|
|
17
|
+
supports_defer = True
|
|
18
|
+
supports_async_task = True
|
|
19
|
+
supports_get_result = True
|
|
20
|
+
supports_priority = True
|
|
21
|
+
|
|
22
|
+
def enqueue(self, task, args, kwargs):
|
|
23
|
+
"""Enqueue a task to the database.
|
|
24
|
+
|
|
25
|
+
Args and kwargs must be JSON-serializable. Supported types are:
|
|
26
|
+
- str, int, float, bool, None
|
|
27
|
+
- dict (with JSON-serializable keys and values)
|
|
28
|
+
- list, tuple (with JSON-serializable elements)
|
|
29
|
+
- bytes (UTF-8 decodable)
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
TypeError: If args or kwargs contain non-JSON-serializable types.
|
|
33
|
+
"""
|
|
34
|
+
from .models import DatabaseTask
|
|
35
|
+
|
|
36
|
+
self.validate_task(task)
|
|
37
|
+
|
|
38
|
+
# Normalize args and kwargs to ensure JSON serialization
|
|
39
|
+
# This will raise TypeError for unsupported types (e.g., datetime, UUID)
|
|
40
|
+
normalized_args = normalize_json(list(args))
|
|
41
|
+
normalized_kwargs = normalize_json(dict(kwargs))
|
|
42
|
+
|
|
43
|
+
now = timezone.now()
|
|
44
|
+
db_task = DatabaseTask.objects.create(
|
|
45
|
+
task_path=self._get_task_path(task),
|
|
46
|
+
queue_name=task.queue_name,
|
|
47
|
+
priority=task.priority,
|
|
48
|
+
args_json=normalized_args,
|
|
49
|
+
kwargs_json=normalized_kwargs,
|
|
50
|
+
status=TaskResultStatus.READY,
|
|
51
|
+
run_after=task.run_after,
|
|
52
|
+
enqueued_at=now,
|
|
53
|
+
backend_name=self.alias,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
task_result = self._db_task_to_result(db_task, task)
|
|
57
|
+
task_enqueued.send(sender=self.__class__, task_result=task_result)
|
|
58
|
+
|
|
59
|
+
return task_result
|
|
60
|
+
|
|
61
|
+
def get_result(self, result_id):
|
|
62
|
+
"""Retrieve a task result from the database."""
|
|
63
|
+
from .models import DatabaseTask
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
db_task = DatabaseTask.objects.get(id=result_id)
|
|
67
|
+
except DatabaseTask.DoesNotExist as e:
|
|
68
|
+
raise TaskResultDoesNotExist(result_id) from e
|
|
69
|
+
|
|
70
|
+
task = self._resolve_task(db_task.task_path)
|
|
71
|
+
return self._db_task_to_result(db_task, task)
|
|
72
|
+
|
|
73
|
+
def _get_task_path(self, task):
|
|
74
|
+
"""Get the module path of the task function."""
|
|
75
|
+
func = task.func
|
|
76
|
+
return f"{func.__module__}.{func.__qualname__}"
|
|
77
|
+
|
|
78
|
+
def _resolve_task(self, task_path):
|
|
79
|
+
"""Resolve a Task object from its module path."""
|
|
80
|
+
module_path, func_name = task_path.rsplit(".", 1)
|
|
81
|
+
module = import_module(module_path)
|
|
82
|
+
func = getattr(module, func_name)
|
|
83
|
+
if isinstance(func, Task):
|
|
84
|
+
return func
|
|
85
|
+
return func
|
|
86
|
+
|
|
87
|
+
def _db_task_to_result(self, db_task, task):
|
|
88
|
+
"""Convert a DatabaseTask model to a TaskResult."""
|
|
89
|
+
errors = [
|
|
90
|
+
TaskError(
|
|
91
|
+
exception_class_path=e.get("exception_class_path", ""),
|
|
92
|
+
traceback=e.get("traceback", ""),
|
|
93
|
+
)
|
|
94
|
+
for e in db_task.errors_json
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
result = TaskResult(
|
|
98
|
+
task=task if isinstance(task, Task) else task,
|
|
99
|
+
id=str(db_task.id),
|
|
100
|
+
status=TaskResultStatus(db_task.status),
|
|
101
|
+
enqueued_at=db_task.enqueued_at,
|
|
102
|
+
started_at=db_task.started_at,
|
|
103
|
+
finished_at=db_task.finished_at,
|
|
104
|
+
last_attempted_at=db_task.last_attempted_at,
|
|
105
|
+
args=db_task.args_json,
|
|
106
|
+
kwargs=db_task.kwargs_json,
|
|
107
|
+
backend=db_task.backend_name,
|
|
108
|
+
errors=errors,
|
|
109
|
+
worker_ids=db_task.worker_ids_json,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if db_task.return_value_json is not None:
|
|
113
|
+
object.__setattr__(result, "_return_value", db_task.return_value_json)
|
|
114
|
+
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def run_task(self, db_task, worker_id=None):
|
|
118
|
+
"""Execute a task (called from management command)."""
|
|
119
|
+
|
|
120
|
+
now = timezone.now()
|
|
121
|
+
|
|
122
|
+
# Update status to RUNNING
|
|
123
|
+
worker_ids = db_task.worker_ids_json.copy()
|
|
124
|
+
if worker_id:
|
|
125
|
+
worker_ids.append(worker_id)
|
|
126
|
+
|
|
127
|
+
db_task.status = TaskResultStatus.RUNNING
|
|
128
|
+
db_task.started_at = db_task.started_at or now
|
|
129
|
+
db_task.last_attempted_at = now
|
|
130
|
+
db_task.worker_ids_json = worker_ids
|
|
131
|
+
db_task.save(
|
|
132
|
+
update_fields=[
|
|
133
|
+
"status",
|
|
134
|
+
"started_at",
|
|
135
|
+
"last_attempted_at",
|
|
136
|
+
"worker_ids_json",
|
|
137
|
+
"updated_at",
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
task = self._resolve_task(db_task.task_path)
|
|
142
|
+
task_result = self._db_task_to_result(db_task, task)
|
|
143
|
+
task_started.send(sender=self.__class__, task_result=task_result)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
# Get task function
|
|
147
|
+
if isinstance(task, Task):
|
|
148
|
+
func = task.func
|
|
149
|
+
takes_context = task.takes_context
|
|
150
|
+
else:
|
|
151
|
+
func = task
|
|
152
|
+
takes_context = False
|
|
153
|
+
|
|
154
|
+
# Prepare arguments
|
|
155
|
+
args = db_task.args_json
|
|
156
|
+
kwargs = db_task.kwargs_json.copy()
|
|
157
|
+
|
|
158
|
+
if takes_context:
|
|
159
|
+
context = TaskContext(task_result=task_result)
|
|
160
|
+
kwargs["context"] = context
|
|
161
|
+
|
|
162
|
+
# Execute task
|
|
163
|
+
if iscoroutinefunction(func):
|
|
164
|
+
return_value = asyncio.run(func(*args, **kwargs))
|
|
165
|
+
else:
|
|
166
|
+
return_value = func(*args, **kwargs)
|
|
167
|
+
|
|
168
|
+
# Normalize return value for JSON serialization
|
|
169
|
+
# This will raise TypeError for unsupported types
|
|
170
|
+
normalized_return_value = normalize_json(return_value)
|
|
171
|
+
|
|
172
|
+
# Success
|
|
173
|
+
db_task.status = TaskResultStatus.SUCCESSFUL
|
|
174
|
+
db_task.return_value_json = normalized_return_value
|
|
175
|
+
db_task.finished_at = timezone.now()
|
|
176
|
+
db_task.save(
|
|
177
|
+
update_fields=[
|
|
178
|
+
"status",
|
|
179
|
+
"return_value_json",
|
|
180
|
+
"finished_at",
|
|
181
|
+
"updated_at",
|
|
182
|
+
]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
# Failure
|
|
187
|
+
error = TaskError(
|
|
188
|
+
exception_class_path=f"{type(e).__module__}.{type(e).__qualname__}",
|
|
189
|
+
traceback=traceback.format_exc(),
|
|
190
|
+
)
|
|
191
|
+
errors = db_task.errors_json.copy()
|
|
192
|
+
errors.append(
|
|
193
|
+
{
|
|
194
|
+
"exception_class_path": error.exception_class_path,
|
|
195
|
+
"traceback": error.traceback,
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
db_task.status = TaskResultStatus.FAILED
|
|
200
|
+
db_task.errors_json = errors
|
|
201
|
+
db_task.finished_at = timezone.now()
|
|
202
|
+
db_task.save(
|
|
203
|
+
update_fields=["status", "errors_json", "finished_at", "updated_at"]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Get final result and send signal
|
|
207
|
+
db_task.refresh_from_db()
|
|
208
|
+
final_result = self._db_task_to_result(db_task, task)
|
|
209
|
+
task_finished.send(sender=self.__class__, task_result=final_result)
|
|
210
|
+
|
|
211
|
+
return final_result
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API for executing database tasks.
|
|
3
|
+
|
|
4
|
+
This module provides functions to process tasks stored in the database
|
|
5
|
+
without using management commands.
|
|
6
|
+
|
|
7
|
+
Example usage:
|
|
8
|
+
from django_database_task import process_tasks, process_one_task
|
|
9
|
+
|
|
10
|
+
# Process a single task
|
|
11
|
+
result = process_one_task()
|
|
12
|
+
|
|
13
|
+
# Process multiple tasks
|
|
14
|
+
results = process_tasks(max_tasks=10)
|
|
15
|
+
|
|
16
|
+
# Process tasks from a specific queue
|
|
17
|
+
results = process_tasks(queue_name="emails", max_tasks=5)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import socket
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
from django.db import transaction
|
|
24
|
+
from django.db.models import Q
|
|
25
|
+
from django.tasks import task_backends
|
|
26
|
+
from django.tasks.base import TaskResultStatus
|
|
27
|
+
from django.utils import timezone
|
|
28
|
+
|
|
29
|
+
from .models import DatabaseTask
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _generate_worker_id():
|
|
33
|
+
"""Generate a unique worker ID."""
|
|
34
|
+
return f"{socket.gethostname()}-{uuid.uuid4().hex[:8]}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch_task(queue_name=None, backend_name="default"):
|
|
38
|
+
"""
|
|
39
|
+
Fetch and lock a single pending task with exclusive lock.
|
|
40
|
+
|
|
41
|
+
This function uses SELECT FOR UPDATE SKIP LOCKED to safely
|
|
42
|
+
fetch a task without conflicts in multi-worker environments.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
queue_name: Optional queue name to filter tasks.
|
|
46
|
+
backend_name: Backend name (default: "default").
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
DatabaseTask instance if a task is available, None otherwise.
|
|
50
|
+
"""
|
|
51
|
+
now = timezone.now()
|
|
52
|
+
|
|
53
|
+
with transaction.atomic():
|
|
54
|
+
queryset = DatabaseTask.objects.select_for_update(skip_locked=True).filter(
|
|
55
|
+
status=TaskResultStatus.READY,
|
|
56
|
+
backend_name=backend_name,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# run_after condition: NULL or before current time
|
|
60
|
+
queryset = queryset.filter(Q(run_after__isnull=True) | Q(run_after__lte=now))
|
|
61
|
+
|
|
62
|
+
if queue_name:
|
|
63
|
+
queryset = queryset.filter(queue_name=queue_name)
|
|
64
|
+
|
|
65
|
+
# Order by priority descending, enqueued_at ascending
|
|
66
|
+
task = queryset.order_by("-priority", "enqueued_at").first()
|
|
67
|
+
|
|
68
|
+
return task
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def process_one_task(queue_name=None, backend_name="default", worker_id=None):
|
|
72
|
+
"""
|
|
73
|
+
Fetch and execute a single pending task.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
queue_name: Optional queue name to filter tasks.
|
|
77
|
+
backend_name: Backend name (default: "default").
|
|
78
|
+
worker_id: Optional worker ID. If not provided, one will be generated.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
TaskResult if a task was processed, None if no task was available.
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
>>> from django_database_task import process_one_task
|
|
85
|
+
>>> result = process_one_task()
|
|
86
|
+
>>> if result:
|
|
87
|
+
... print(f"Processed: {result.id}, status: {result.status}")
|
|
88
|
+
... else:
|
|
89
|
+
... print("No tasks available")
|
|
90
|
+
"""
|
|
91
|
+
if worker_id is None:
|
|
92
|
+
worker_id = _generate_worker_id()
|
|
93
|
+
|
|
94
|
+
task = fetch_task(queue_name=queue_name, backend_name=backend_name)
|
|
95
|
+
|
|
96
|
+
if task is None:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
backend = task_backends[backend_name]
|
|
100
|
+
return backend.run_task(task, worker_id=worker_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def process_tasks(
|
|
104
|
+
queue_name=None,
|
|
105
|
+
backend_name="default",
|
|
106
|
+
max_tasks=0,
|
|
107
|
+
worker_id=None,
|
|
108
|
+
):
|
|
109
|
+
"""
|
|
110
|
+
Process multiple pending tasks.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
queue_name: Optional queue name to filter tasks.
|
|
114
|
+
backend_name: Backend name (default: "default").
|
|
115
|
+
max_tasks: Maximum number of tasks to process (0 = unlimited).
|
|
116
|
+
worker_id: Optional worker ID. If not provided, one will be generated.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of TaskResult objects for all processed tasks.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> from django_database_task import process_tasks
|
|
123
|
+
>>> results = process_tasks(max_tasks=10)
|
|
124
|
+
>>> print(f"Processed {len(results)} tasks")
|
|
125
|
+
>>> for result in results:
|
|
126
|
+
... print(f" {result.id}: {result.status}")
|
|
127
|
+
"""
|
|
128
|
+
if worker_id is None:
|
|
129
|
+
worker_id = _generate_worker_id()
|
|
130
|
+
|
|
131
|
+
results = []
|
|
132
|
+
tasks_processed = 0
|
|
133
|
+
|
|
134
|
+
while True:
|
|
135
|
+
result = process_one_task(
|
|
136
|
+
queue_name=queue_name,
|
|
137
|
+
backend_name=backend_name,
|
|
138
|
+
worker_id=worker_id,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if result is None:
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
results.append(result)
|
|
145
|
+
tasks_processed += 1
|
|
146
|
+
|
|
147
|
+
if max_tasks and tasks_processed >= max_tasks:
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
return results
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_pending_task_count(queue_name=None, backend_name="default"):
|
|
154
|
+
"""
|
|
155
|
+
Get the count of pending tasks.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
queue_name: Optional queue name to filter tasks.
|
|
159
|
+
backend_name: Backend name (default: "default").
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Number of pending tasks.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
>>> from django_database_task import get_pending_task_count
|
|
166
|
+
>>> count = get_pending_task_count()
|
|
167
|
+
>>> print(f"Pending tasks: {count}")
|
|
168
|
+
"""
|
|
169
|
+
now = timezone.now()
|
|
170
|
+
|
|
171
|
+
queryset = DatabaseTask.objects.filter(
|
|
172
|
+
status=TaskResultStatus.READY,
|
|
173
|
+
backend_name=backend_name,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
queryset = queryset.filter(Q(run_after__isnull=True) | Q(run_after__lte=now))
|
|
177
|
+
|
|
178
|
+
if queue_name:
|
|
179
|
+
queryset = queryset.filter(queue_name=queue_name)
|
|
180
|
+
|
|
181
|
+
return queryset.count()
|
|
Binary file
|