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.
@@ -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,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoDatabaseTaskConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_database_task"
7
+ verbose_name = "Django Database Task"
@@ -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()