django-qstash 0.0.2__py3-none-any.whl → 0.0.4__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.
Potentially problematic release.
This version of django-qstash might be problematic. Click here for more details.
- django_qstash/__init__.py +1 -1
- django_qstash/exceptions.py +25 -0
- django_qstash/handlers.py +143 -0
- django_qstash/management/commands/__init__.py +0 -0
- django_qstash/management/commands/clear_stale_results.py +61 -0
- django_qstash/results/__init__.py +0 -0
- django_qstash/results/admin.py +21 -0
- django_qstash/results/apps.py +10 -0
- django_qstash/results/migrations/0001_initial.py +62 -0
- django_qstash/results/migrations/__init__.py +0 -0
- django_qstash/results/models.py +36 -0
- django_qstash/results/services.py +33 -0
- django_qstash/tasks.py +28 -16
- django_qstash/views.py +11 -114
- django_qstash-0.0.4.dist-info/METADATA +343 -0
- django_qstash-0.0.4.dist-info/RECORD +19 -0
- django_qstash-0.0.2.dist-info/METADATA +0 -95
- django_qstash-0.0.2.dist-info/RECORD +0 -8
- {django_qstash-0.0.2.dist-info → django_qstash-0.0.4.dist-info}/WHEEL +0 -0
- {django_qstash-0.0.2.dist-info → django_qstash-0.0.4.dist-info}/top_level.txt +0 -0
django_qstash/__init__.py
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WebhookError(Exception):
|
|
5
|
+
"""Base exception for webhook handling errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SignatureError(WebhookError):
|
|
11
|
+
"""Invalid or missing signature."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PayloadError(WebhookError):
|
|
17
|
+
"""Invalid payload structure or content."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TaskError(WebhookError):
|
|
23
|
+
"""Error in task execution."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.http import HttpRequest
|
|
10
|
+
from qstash import Receiver
|
|
11
|
+
|
|
12
|
+
from . import utils
|
|
13
|
+
from .exceptions import PayloadError
|
|
14
|
+
from .exceptions import SignatureError
|
|
15
|
+
from .exceptions import TaskError
|
|
16
|
+
from .results.services import store_task_result
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TaskPayload:
|
|
23
|
+
function: str
|
|
24
|
+
module: str
|
|
25
|
+
args: list
|
|
26
|
+
kwargs: dict
|
|
27
|
+
task_name: str
|
|
28
|
+
function_path: str
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, data: dict) -> TaskPayload:
|
|
32
|
+
"""Create TaskPayload from dictionary."""
|
|
33
|
+
is_valid, error = utils.validate_task_payload(data)
|
|
34
|
+
if not is_valid:
|
|
35
|
+
raise PayloadError(error)
|
|
36
|
+
|
|
37
|
+
function_path = f"{data['module']}.{data['function']}"
|
|
38
|
+
return cls(
|
|
39
|
+
function=data["function"],
|
|
40
|
+
module=data["module"],
|
|
41
|
+
args=data["args"],
|
|
42
|
+
kwargs=data["kwargs"],
|
|
43
|
+
task_name=data.get("task_name", function_path),
|
|
44
|
+
function_path=function_path,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QStashWebhook:
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.receiver = Receiver(
|
|
51
|
+
current_signing_key=settings.QSTASH_CURRENT_SIGNING_KEY,
|
|
52
|
+
next_signing_key=settings.QSTASH_NEXT_SIGNING_KEY,
|
|
53
|
+
)
|
|
54
|
+
self.force_https = getattr(settings, "DJANGO_QSTASH_FORCE_HTTPS", True)
|
|
55
|
+
|
|
56
|
+
def verify_signature(self, body: str, signature: str, url: str) -> None:
|
|
57
|
+
"""Verify QStash signature."""
|
|
58
|
+
if not signature:
|
|
59
|
+
raise SignatureError("Missing Upstash-Signature header")
|
|
60
|
+
|
|
61
|
+
if self.force_https and not url.startswith("https://"):
|
|
62
|
+
url = url.replace("http://", "https://")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
self.receiver.verify(body=body, signature=signature, url=url)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
raise SignatureError(f"Invalid signature: {e}")
|
|
68
|
+
|
|
69
|
+
def parse_payload(self, body: str) -> TaskPayload:
|
|
70
|
+
"""Parse and validate webhook payload."""
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(body)
|
|
73
|
+
return TaskPayload.from_dict(data)
|
|
74
|
+
except json.JSONDecodeError as e:
|
|
75
|
+
raise PayloadError(f"Invalid JSON payload: {e}")
|
|
76
|
+
|
|
77
|
+
def execute_task(self, payload: TaskPayload) -> Any:
|
|
78
|
+
"""Import and execute the task function."""
|
|
79
|
+
try:
|
|
80
|
+
task_func = utils.import_string(payload.function_path)
|
|
81
|
+
except ImportError as e:
|
|
82
|
+
raise TaskError(f"Could not import task function: {e}")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
if callable(task_func) and hasattr(task_func, "actual_func"):
|
|
86
|
+
return task_func.actual_func(*payload.args, **payload.kwargs)
|
|
87
|
+
return task_func(*payload.args, **payload.kwargs)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise TaskError(f"Task execution failed: {e}")
|
|
90
|
+
|
|
91
|
+
def handle_request(self, request: HttpRequest) -> tuple[dict, int]:
|
|
92
|
+
"""Process webhook request and return response data and status code."""
|
|
93
|
+
payload = None # Initialize payload as None
|
|
94
|
+
try:
|
|
95
|
+
body = request.body.decode()
|
|
96
|
+
self.verify_signature(
|
|
97
|
+
body=body,
|
|
98
|
+
signature=request.headers.get("Upstash-Signature"),
|
|
99
|
+
url=request.build_absolute_uri(),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
payload = self.parse_payload(body)
|
|
103
|
+
result = self.execute_task(payload)
|
|
104
|
+
|
|
105
|
+
store_task_result(
|
|
106
|
+
task_id=request.headers.get("Upstash-Message-Id"),
|
|
107
|
+
task_name=payload.task_name,
|
|
108
|
+
status="SUCCESS",
|
|
109
|
+
result=result,
|
|
110
|
+
args=payload.args,
|
|
111
|
+
kwargs=payload.kwargs,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"status": "success",
|
|
116
|
+
"task_name": payload.task_name,
|
|
117
|
+
"result": result if result is not None else "null",
|
|
118
|
+
}, 200
|
|
119
|
+
|
|
120
|
+
except (SignatureError, PayloadError) as e:
|
|
121
|
+
logger.exception("Authentication error: %s", str(e))
|
|
122
|
+
return {
|
|
123
|
+
"status": "error",
|
|
124
|
+
"error_type": e.__class__.__name__,
|
|
125
|
+
"error": str(e),
|
|
126
|
+
"task_name": getattr(payload, "task_name", None),
|
|
127
|
+
}, 400
|
|
128
|
+
except TaskError as e:
|
|
129
|
+
logger.exception("Task execution error: %s", str(e))
|
|
130
|
+
return {
|
|
131
|
+
"status": "error",
|
|
132
|
+
"error_type": e.__class__.__name__,
|
|
133
|
+
"error": str(e),
|
|
134
|
+
"task_name": payload.task_name if payload else None,
|
|
135
|
+
}, 500
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.exception("Unexpected error in webhook handler: %s", str(e))
|
|
138
|
+
return {
|
|
139
|
+
"status": "error",
|
|
140
|
+
"error_type": "InternalServerError",
|
|
141
|
+
"error": "An unexpected error occurred",
|
|
142
|
+
"task_name": getattr(payload, "task_name", None),
|
|
143
|
+
}, 500
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.core.management.base import BaseCommand
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
|
|
10
|
+
DJANGO_QSTASH_RESULT_TTL = getattr(settings, "DJANGO_QSTASH_RESULT_TTL", 604800)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Command(BaseCommand):
|
|
14
|
+
help = f"""Clears stale task results older than\n
|
|
15
|
+
{DJANGO_QSTASH_RESULT_TTL} seconds (settings.DJANGO_QSTASH_RESULT_TTL)"""
|
|
16
|
+
|
|
17
|
+
def add_arguments(self, parser):
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--no-input",
|
|
20
|
+
action="store_true",
|
|
21
|
+
help="Do not ask for confirmation",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--since",
|
|
25
|
+
type=int,
|
|
26
|
+
help="The number of seconds ago to clear results for",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def handle(self, *args, **options):
|
|
30
|
+
no_input = options["no_input"]
|
|
31
|
+
since = options.get("since") or DJANGO_QSTASH_RESULT_TTL
|
|
32
|
+
cutoff_date = timezone.now() - timedelta(seconds=since)
|
|
33
|
+
try:
|
|
34
|
+
TaskResult = apps.get_model("django_qstash_results", "TaskResult")
|
|
35
|
+
except LookupError:
|
|
36
|
+
self.stdout.write(
|
|
37
|
+
self.style.ERROR(
|
|
38
|
+
"Django QStash Results not installed.\nAdd `django_qstash.results` to INSTALLED_APPS and run migrations."
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
to_delete = TaskResult.objects.filter(date_done__lt=cutoff_date)
|
|
43
|
+
|
|
44
|
+
if not to_delete.exists():
|
|
45
|
+
self.stdout.write("No stale Django QStash task results found")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# use input to confirm deletion
|
|
49
|
+
self.stdout.write(
|
|
50
|
+
f"Deleting {to_delete.count()} task results older than {cutoff_date} ({DJANGO_QSTASH_RESULT_TTL} seconds)"
|
|
51
|
+
)
|
|
52
|
+
if not no_input:
|
|
53
|
+
if input("Are you sure? (y/n): ") != "y":
|
|
54
|
+
self.stdout.write("Skipping deletion")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
deleted_count, _ = to_delete.delete()
|
|
58
|
+
|
|
59
|
+
self.stdout.write(
|
|
60
|
+
self.style.SUCCESS(f"Successfully deleted {deleted_count} stale results.")
|
|
61
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
|
|
5
|
+
from .models import TaskResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@admin.register(TaskResult)
|
|
9
|
+
class TaskResultAdmin(admin.ModelAdmin):
|
|
10
|
+
readonly_fields = [
|
|
11
|
+
"task_name",
|
|
12
|
+
"status",
|
|
13
|
+
"date_done",
|
|
14
|
+
"result",
|
|
15
|
+
"traceback",
|
|
16
|
+
"args",
|
|
17
|
+
"kwargs",
|
|
18
|
+
"task_id",
|
|
19
|
+
"date_created",
|
|
20
|
+
]
|
|
21
|
+
list_display = ["task_name", "status", "date_done"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResultsConfig(AppConfig):
|
|
7
|
+
name = "django_qstash.results"
|
|
8
|
+
label = "django_qstash_results"
|
|
9
|
+
verbose_name = "django_qstash_results"
|
|
10
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Generated by Django 5.1.4 on 2024-12-30 23:31
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
import django.utils.timezone
|
|
8
|
+
from django.db import migrations
|
|
9
|
+
from django.db import models
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Migration(migrations.Migration):
|
|
13
|
+
initial = True
|
|
14
|
+
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name="TaskResult",
|
|
20
|
+
fields=[
|
|
21
|
+
(
|
|
22
|
+
"id",
|
|
23
|
+
models.UUIDField(
|
|
24
|
+
default=uuid.uuid1,
|
|
25
|
+
editable=False,
|
|
26
|
+
primary_key=True,
|
|
27
|
+
serialize=False,
|
|
28
|
+
unique=True,
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
"task_id",
|
|
33
|
+
models.CharField(db_index=True, max_length=255, unique=True),
|
|
34
|
+
),
|
|
35
|
+
("task_name", models.CharField(max_length=255)),
|
|
36
|
+
(
|
|
37
|
+
"status",
|
|
38
|
+
models.CharField(
|
|
39
|
+
choices=[
|
|
40
|
+
("PENDING", "Pending"),
|
|
41
|
+
("SUCCESS", "Success"),
|
|
42
|
+
("FAILURE", "Failure"),
|
|
43
|
+
],
|
|
44
|
+
default="PENDING",
|
|
45
|
+
max_length=50,
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
"date_created",
|
|
50
|
+
models.DateTimeField(default=django.utils.timezone.now),
|
|
51
|
+
),
|
|
52
|
+
("date_done", models.DateTimeField(null=True)),
|
|
53
|
+
("result", models.JSONField(null=True)),
|
|
54
|
+
("traceback", models.TextField(null=True)),
|
|
55
|
+
("args", models.JSONField(null=True)),
|
|
56
|
+
("kwargs", models.JSONField(null=True)),
|
|
57
|
+
],
|
|
58
|
+
options={
|
|
59
|
+
"ordering": ["-date_done"],
|
|
60
|
+
},
|
|
61
|
+
),
|
|
62
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TaskResult(models.Model):
|
|
10
|
+
id = models.UUIDField(
|
|
11
|
+
primary_key=True, default=uuid.uuid1, editable=False, unique=True
|
|
12
|
+
)
|
|
13
|
+
task_id = models.CharField(max_length=255, unique=True, db_index=True)
|
|
14
|
+
task_name = models.CharField(max_length=255)
|
|
15
|
+
status = models.CharField(
|
|
16
|
+
max_length=50,
|
|
17
|
+
choices=[
|
|
18
|
+
("PENDING", "Pending"),
|
|
19
|
+
("SUCCESS", "Success"),
|
|
20
|
+
("FAILURE", "Failure"),
|
|
21
|
+
],
|
|
22
|
+
default="PENDING",
|
|
23
|
+
)
|
|
24
|
+
date_created = models.DateTimeField(default=timezone.now)
|
|
25
|
+
date_done = models.DateTimeField(null=True)
|
|
26
|
+
result = models.JSONField(null=True)
|
|
27
|
+
traceback = models.TextField(null=True)
|
|
28
|
+
args = models.JSONField(null=True)
|
|
29
|
+
kwargs = models.JSONField(null=True)
|
|
30
|
+
|
|
31
|
+
class Meta:
|
|
32
|
+
app_label = "django_qstash_results"
|
|
33
|
+
ordering = ["-date_done"]
|
|
34
|
+
|
|
35
|
+
def __str__(self):
|
|
36
|
+
return f"{self.task_name} ({self.task_id})"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def store_task_result(
|
|
12
|
+
task_id, task_name, status, result=None, traceback=None, args=None, kwargs=None
|
|
13
|
+
):
|
|
14
|
+
"""Helper function to store task results if the results app is installed"""
|
|
15
|
+
try:
|
|
16
|
+
TaskResult = apps.get_model("django_qstash_results", "TaskResult")
|
|
17
|
+
task_result = TaskResult.objects.create(
|
|
18
|
+
task_id=task_id,
|
|
19
|
+
task_name=task_name,
|
|
20
|
+
status=status,
|
|
21
|
+
date_done=timezone.now(),
|
|
22
|
+
result=result,
|
|
23
|
+
traceback=traceback,
|
|
24
|
+
args=args,
|
|
25
|
+
kwargs=kwargs,
|
|
26
|
+
)
|
|
27
|
+
return task_result
|
|
28
|
+
except LookupError:
|
|
29
|
+
# Model isn't installed, skip storage
|
|
30
|
+
logger.debug(
|
|
31
|
+
"Django QStash Results not installed. Add `django_qstash.results` to INSTALLED_APPS and run migrations."
|
|
32
|
+
)
|
|
33
|
+
return None
|
django_qstash/tasks.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import functools
|
|
2
|
-
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Callable
|
|
3
7
|
|
|
4
8
|
from django.conf import settings
|
|
5
9
|
from django.core.exceptions import ImproperlyConfigured
|
|
@@ -11,7 +15,11 @@ DJANGO_QSTASH_WEBHOOK_PATH = getattr(
|
|
|
11
15
|
settings, "DJANGO_QSTASH_WEBHOOK_PATH", "/qstash/webhook/"
|
|
12
16
|
)
|
|
13
17
|
if not QSTASH_TOKEN or not DJANGO_QSTASH_DOMAIN:
|
|
14
|
-
|
|
18
|
+
warnings.warn(
|
|
19
|
+
"QSTASH_TOKEN and DJANGO_QSTASH_DOMAIN should be set for QStash functionality",
|
|
20
|
+
RuntimeWarning,
|
|
21
|
+
stacklevel=2,
|
|
22
|
+
)
|
|
15
23
|
|
|
16
24
|
# Initialize QStash client once
|
|
17
25
|
qstash_client = QStash(QSTASH_TOKEN)
|
|
@@ -20,11 +28,11 @@ qstash_client = QStash(QSTASH_TOKEN)
|
|
|
20
28
|
class QStashTask:
|
|
21
29
|
def __init__(
|
|
22
30
|
self,
|
|
23
|
-
func:
|
|
24
|
-
name:
|
|
25
|
-
delay_seconds:
|
|
31
|
+
func: Callable | None = None,
|
|
32
|
+
name: str | None = None,
|
|
33
|
+
delay_seconds: int | None = None,
|
|
26
34
|
deduplicated: bool = False,
|
|
27
|
-
**options:
|
|
35
|
+
**options: dict[str, Any],
|
|
28
36
|
):
|
|
29
37
|
self.func = func
|
|
30
38
|
self.name = name or (func.__name__ if func else None)
|
|
@@ -45,6 +53,10 @@ class QStashTask:
|
|
|
45
53
|
"""
|
|
46
54
|
Execute the task, either directly or via QStash based on context
|
|
47
55
|
"""
|
|
56
|
+
if not QSTASH_TOKEN or not DJANGO_QSTASH_DOMAIN:
|
|
57
|
+
raise ImproperlyConfigured(
|
|
58
|
+
"QSTASH_TOKEN and DJANGO_QSTASH_DOMAIN must be set to use django-qstash"
|
|
59
|
+
)
|
|
48
60
|
# Handle the case when the decorator is used without parameters
|
|
49
61
|
if self.func is None:
|
|
50
62
|
return self.__class__(
|
|
@@ -89,18 +101,18 @@ class QStashTask:
|
|
|
89
101
|
# Return an AsyncResult-like object for Celery compatibility
|
|
90
102
|
return AsyncResult(response.message_id)
|
|
91
103
|
|
|
92
|
-
def delay(self, *args, **kwargs) ->
|
|
104
|
+
def delay(self, *args, **kwargs) -> AsyncResult:
|
|
93
105
|
"""Celery-compatible delay() method"""
|
|
94
106
|
self._is_delayed = True
|
|
95
107
|
return self(*args, **kwargs)
|
|
96
108
|
|
|
97
109
|
def apply_async(
|
|
98
110
|
self,
|
|
99
|
-
args:
|
|
100
|
-
kwargs:
|
|
101
|
-
countdown:
|
|
102
|
-
**options:
|
|
103
|
-
) ->
|
|
111
|
+
args: tuple | None = None,
|
|
112
|
+
kwargs: dict | None = None,
|
|
113
|
+
countdown: int | None = None,
|
|
114
|
+
**options: dict[str, Any],
|
|
115
|
+
) -> AsyncResult:
|
|
104
116
|
"""Celery-compatible apply_async() method"""
|
|
105
117
|
self._is_delayed = True
|
|
106
118
|
if countdown is not None:
|
|
@@ -119,7 +131,7 @@ class AsyncResult:
|
|
|
119
131
|
def __init__(self, task_id: str):
|
|
120
132
|
self.task_id = task_id
|
|
121
133
|
|
|
122
|
-
def get(self, timeout:
|
|
134
|
+
def get(self, timeout: int | None = None) -> Any:
|
|
123
135
|
"""Simulate Celery's get() method"""
|
|
124
136
|
raise NotImplementedError("QStash doesn't support result retrieval")
|
|
125
137
|
|
|
@@ -129,10 +141,10 @@ class AsyncResult:
|
|
|
129
141
|
|
|
130
142
|
|
|
131
143
|
def shared_task(
|
|
132
|
-
func:
|
|
133
|
-
name:
|
|
144
|
+
func: Callable | None = None,
|
|
145
|
+
name: str | None = None,
|
|
134
146
|
deduplicated: bool = False,
|
|
135
|
-
**options:
|
|
147
|
+
**options: dict[str, Any],
|
|
136
148
|
) -> QStashTask:
|
|
137
149
|
"""
|
|
138
150
|
Decorator that mimics Celery's shared_task
|
django_qstash/views.py
CHANGED
|
@@ -1,124 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
|
-
import logging
|
|
3
4
|
|
|
4
|
-
from django.
|
|
5
|
-
from django.http import
|
|
6
|
-
HttpRequest,
|
|
7
|
-
HttpResponse,
|
|
8
|
-
HttpResponseBadRequest,
|
|
9
|
-
HttpResponseForbidden,
|
|
10
|
-
)
|
|
5
|
+
from django.http import HttpRequest
|
|
6
|
+
from django.http import HttpResponse
|
|
11
7
|
from django.views.decorators.csrf import csrf_exempt
|
|
12
8
|
from django.views.decorators.http import require_http_methods
|
|
13
|
-
from qstash import Receiver
|
|
14
|
-
|
|
15
|
-
from . import utils
|
|
16
|
-
|
|
17
|
-
DJANGO_QSTASH_FORCE_HTTPS = getattr(settings, "DJANGO_QSTASH_FORCE_HTTPS", True)
|
|
18
|
-
|
|
19
|
-
logger = logging.getLogger(__name__)
|
|
20
9
|
|
|
21
|
-
|
|
22
|
-
# Initialize the QStash receiver
|
|
23
|
-
receiver = Receiver(
|
|
24
|
-
current_signing_key=settings.QSTASH_CURRENT_SIGNING_KEY,
|
|
25
|
-
next_signing_key=settings.QSTASH_NEXT_SIGNING_KEY,
|
|
26
|
-
)
|
|
10
|
+
from .handlers import QStashWebhook
|
|
27
11
|
|
|
28
12
|
|
|
29
13
|
@csrf_exempt
|
|
30
14
|
@require_http_methods(["POST"])
|
|
31
15
|
def qstash_webhook_view(request: HttpRequest) -> HttpResponse:
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{
|
|
39
|
-
"function": "full.path.to.function",
|
|
40
|
-
"module": "module.path",
|
|
41
|
-
"args": [...],
|
|
42
|
-
"kwargs": {...},
|
|
43
|
-
"task_name": "optional_task_name",
|
|
44
|
-
"options": {...}
|
|
45
|
-
}
|
|
46
|
-
"""
|
|
47
|
-
try:
|
|
48
|
-
# Get the signature from headers
|
|
49
|
-
signature = request.headers.get("Upstash-Signature")
|
|
50
|
-
if not signature:
|
|
51
|
-
return HttpResponseForbidden("Missing Upstash-Signature header")
|
|
52
|
-
|
|
53
|
-
# Verify the signature using the QStash SDK
|
|
54
|
-
url = request.build_absolute_uri()
|
|
55
|
-
if DJANGO_QSTASH_FORCE_HTTPS and not url.startswith("https://"):
|
|
56
|
-
url = url.replace("http://", "https://")
|
|
57
|
-
try:
|
|
58
|
-
receiver.verify(
|
|
59
|
-
body=request.body.decode(),
|
|
60
|
-
signature=signature,
|
|
61
|
-
url=url,
|
|
62
|
-
)
|
|
63
|
-
except Exception as e:
|
|
64
|
-
logger.error(f"Signature verification failed: {e}")
|
|
65
|
-
return HttpResponseForbidden("Invalid signature")
|
|
66
|
-
|
|
67
|
-
# Parse the payload
|
|
68
|
-
try:
|
|
69
|
-
payload = json.loads(request.body.decode())
|
|
70
|
-
except json.JSONDecodeError as e:
|
|
71
|
-
logger.error(f"Failed to parse JSON payload: {e}")
|
|
72
|
-
return HttpResponseBadRequest("Invalid JSON payload")
|
|
73
|
-
|
|
74
|
-
# Validate payload structure
|
|
75
|
-
is_valid, error_message = utils.validate_task_payload(payload)
|
|
76
|
-
if not is_valid:
|
|
77
|
-
logger.error(f"Invalid payload structure: {error_message}")
|
|
78
|
-
return HttpResponseBadRequest(error_message)
|
|
79
|
-
|
|
80
|
-
# Import the function
|
|
81
|
-
try:
|
|
82
|
-
function_path = f"{payload['module']}.{payload['function']}"
|
|
83
|
-
task_func = utils.import_string(function_path)
|
|
84
|
-
except ImportError as e:
|
|
85
|
-
logger.error(f"Failed to import task function: {e}")
|
|
86
|
-
return HttpResponseBadRequest(f"Could not import task function: {e}")
|
|
87
|
-
|
|
88
|
-
# Execute the task
|
|
89
|
-
try:
|
|
90
|
-
if hasattr(task_func, "__call__") and hasattr(task_func, "actual_func"):
|
|
91
|
-
# If it's a wrapped function, call the actual function directly
|
|
92
|
-
result = task_func.actual_func(*payload["args"], **payload["kwargs"])
|
|
93
|
-
else:
|
|
94
|
-
result = task_func(*payload["args"], **payload["kwargs"])
|
|
95
|
-
|
|
96
|
-
# Prepare the response
|
|
97
|
-
response_data = {
|
|
98
|
-
"status": "success",
|
|
99
|
-
"task_name": payload.get("task_name", function_path),
|
|
100
|
-
"result": result if result is not None else "null",
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return HttpResponse(
|
|
104
|
-
json.dumps(response_data), content_type="application/json"
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
except Exception as e:
|
|
108
|
-
logger.exception(f"Task execution failed: {e}")
|
|
109
|
-
error_response = {
|
|
110
|
-
"status": "error",
|
|
111
|
-
"task_name": payload.get("task_name", function_path),
|
|
112
|
-
"error": str(e),
|
|
113
|
-
}
|
|
114
|
-
return HttpResponse(
|
|
115
|
-
json.dumps(error_response), status=500, content_type="application/json"
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
except Exception as e:
|
|
119
|
-
logger.exception(f"Unexpected error in webhook handler: {e}")
|
|
120
|
-
return HttpResponse(
|
|
121
|
-
json.dumps({"status": "error", "error": "Internal server error"}),
|
|
122
|
-
status=500,
|
|
123
|
-
content_type="application/json",
|
|
124
|
-
)
|
|
16
|
+
"""Handle QStash webhook requests."""
|
|
17
|
+
webhook = QStashWebhook()
|
|
18
|
+
response_data, status_code = webhook.handle_request(request)
|
|
19
|
+
return HttpResponse(
|
|
20
|
+
json.dumps(response_data), status=status_code, content_type="application/json"
|
|
21
|
+
)
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-qstash
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: A drop-in replacement for Celery's shared_task with Upstash QStash.
|
|
5
|
+
Author-email: Justin Mitchel <justin@codingforentrepreneurs.com>
|
|
6
|
+
Project-URL: Changelog, https://github.com/jmitchel3/django-qstash
|
|
7
|
+
Project-URL: Documentation, https://github.com/jmitchel3/django-qstash
|
|
8
|
+
Project-URL: Funding, https://github.com/jmitchel3/django-qstash
|
|
9
|
+
Project-URL: Repository, https://github.com/jmitchel3/django-qstash
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Django :: 4.2
|
|
12
|
+
Classifier: Framework :: Django :: 5.0
|
|
13
|
+
Classifier: Framework :: Django :: 5.1
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: django>=4.2
|
|
27
|
+
Requires-Dist: qstash>=2
|
|
28
|
+
Requires-Dist: requests>=2.30
|
|
29
|
+
|
|
30
|
+
> :warning: **BETA Software**: Working on being production-ready soon.
|
|
31
|
+
|
|
32
|
+
# django-qstash
|
|
33
|
+
|
|
34
|
+
_django-qstash_ is a drop-in replacement for Celery's `shared_task`.
|
|
35
|
+
|
|
36
|
+
To do this, we use:
|
|
37
|
+
|
|
38
|
+
- [Upstash QStash](https://upstash.com/docs/qstash/overall/getstarted)
|
|
39
|
+
- A single public _webhook_ to call `@shared_task` functions automatically
|
|
40
|
+
|
|
41
|
+
This allows us to:
|
|
42
|
+
|
|
43
|
+
- Focus just on Django
|
|
44
|
+
- Drop Celery
|
|
45
|
+
- Truly scale Django to zero
|
|
46
|
+
- Run background tasks through webhooks
|
|
47
|
+
- Cut costs
|
|
48
|
+
- Trigger GitHub Actions Workflows or GitLab CI/CD pipelines for handling other kinds of background tasks based on our project's code.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Table of Contents
|
|
52
|
+
|
|
53
|
+
- [django-qstash](#django-qstash)
|
|
54
|
+
- [Table of Contents](#table-of-contents)
|
|
55
|
+
- [Installation](#installation)
|
|
56
|
+
- [Using Pip](#using-pip)
|
|
57
|
+
- [Update Settings (`settings.py`)](#update-settings-settingspy)
|
|
58
|
+
- [Configure Webhook URL](#configure-webhook-url)
|
|
59
|
+
- [Required Environment Variables](#required-environment-variables)
|
|
60
|
+
- [Sample Project](#sample-project)
|
|
61
|
+
- [Dependencies](#dependencies)
|
|
62
|
+
- [Usage](#usage)
|
|
63
|
+
- [Define a Task](#define-a-task)
|
|
64
|
+
- [Regular Task Call](#regular-task-call)
|
|
65
|
+
- [Async Task](#async-task)
|
|
66
|
+
- [`.delay()`](#delay)
|
|
67
|
+
- [`.apply_async()`](#apply_async)
|
|
68
|
+
- [`.apply_async()` With Time Delay](#apply_async-with-time-delay)
|
|
69
|
+
- [JSON-ready Arguments](#json-ready-arguments)
|
|
70
|
+
- [Example Task](#example-task)
|
|
71
|
+
- [Configuration](#configuration)
|
|
72
|
+
- [Storing Task Results (Optional)](#storing-task-results-optional)
|
|
73
|
+
- [Clear Stale Results](#clear-stale-results)
|
|
74
|
+
- [Definitions](#definitions)
|
|
75
|
+
- [Motivation](#motivation)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
### Using Pip
|
|
81
|
+
```bash
|
|
82
|
+
pip install django-qstash
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Update Settings (`settings.py`)
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
INSTALLED_APPS = [
|
|
89
|
+
##...
|
|
90
|
+
"django_qstash",
|
|
91
|
+
"django_qstash.results",
|
|
92
|
+
##...
|
|
93
|
+
]
|
|
94
|
+
```
|
|
95
|
+
- `django_qstash` Includes the `@shared_task` decorator and webhook view
|
|
96
|
+
- `django_qstash.results` (Optional): Store task results in Django DB
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
### Configure Webhook URL
|
|
100
|
+
|
|
101
|
+
In your `ROOT_URLCONF` (e.g. `urls.py`), add the following:
|
|
102
|
+
```python
|
|
103
|
+
from django_qstash.views import qstash_webhook_view
|
|
104
|
+
|
|
105
|
+
urlpatterns = [
|
|
106
|
+
# ...
|
|
107
|
+
path("qstash/webhook/", qstash_webhook_view),
|
|
108
|
+
# ...
|
|
109
|
+
]
|
|
110
|
+
```
|
|
111
|
+
Be sure to use this path in your `DJANGO_QSTASH_WEBHOOK_PATH` environment variable.
|
|
112
|
+
|
|
113
|
+
### Required Environment Variables
|
|
114
|
+
|
|
115
|
+
Get your QStash token and signing keys from [Upstash](https://upstash.com/).
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
QSTASH_TOKEN = "your_token"
|
|
119
|
+
QSTASH_CURRENT_SIGNING_KEY = "your_current_signing_key"
|
|
120
|
+
QSTASH_NEXT_SIGNING_KEY = "your_next_signing_key"
|
|
121
|
+
|
|
122
|
+
# required for django-qstash
|
|
123
|
+
DJANGO_QSTASH_DOMAIN = "https://example.com"
|
|
124
|
+
DJANGO_QSTASH_WEBHOOK_PATH = "/qstash/webhook/"
|
|
125
|
+
```
|
|
126
|
+
> Review [.env.sample](.env.sample) to see all the environment variables you need to set.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
## Sample Project
|
|
130
|
+
There is a sample project in [sample_project/](sample_project/) that shows how all this is implemented.
|
|
131
|
+
|
|
132
|
+
## Dependencies
|
|
133
|
+
|
|
134
|
+
- [Python 3.10+](https://www.python.org/)
|
|
135
|
+
- [Django 5+](https://docs.djangoproject.com/)
|
|
136
|
+
- [qstash-py](https://github.com/upstash/qstash-py)
|
|
137
|
+
- [Upstash](https://upstash.com/) account
|
|
138
|
+
|
|
139
|
+
## Usage
|
|
140
|
+
|
|
141
|
+
Django-QStash revolves around the `shared_task` decorator. The goal is to be a drop-in replacement for Celery's `shared_task` decorator.
|
|
142
|
+
|
|
143
|
+
Here's how it works:
|
|
144
|
+
- Define a Task
|
|
145
|
+
- Call a Task with `.delay()` or `.apply_async()`
|
|
146
|
+
|
|
147
|
+
### Define a Task
|
|
148
|
+
```python
|
|
149
|
+
from django_qstash import shared_task
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@shared_task
|
|
153
|
+
def hello_world(name: str, age: int = None, activity: str = None):
|
|
154
|
+
if age is None:
|
|
155
|
+
print(f"Hello {name}! I see you're {activity}.")
|
|
156
|
+
return
|
|
157
|
+
print(f"Hello {name}! I see you're {activity} at {age} years old.")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
### Regular Task Call
|
|
162
|
+
Nothing special here. Just call the function like any other to verify it works.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# normal function call
|
|
166
|
+
hello_world("Tony Stark", age=40, activity="building in a cave with a box of scraps.")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Async Task
|
|
170
|
+
|
|
171
|
+
Using `.delay()` or `.apply_async()` is how you call an async task. This is modeled after Celery and it works as you'd expect.
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
#### `.delay()`
|
|
175
|
+
```python
|
|
176
|
+
hello_world.delay(
|
|
177
|
+
"Tony Stark", age=40, activity="building in a cave with a box of scraps."
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
#### `.apply_async()`
|
|
182
|
+
```python
|
|
183
|
+
hello_world.apply_async(
|
|
184
|
+
args=("Tony Stark",),
|
|
185
|
+
kwargs={"activity": "building in a cave with a box of scraps."},
|
|
186
|
+
)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### `.apply_async()` With Time Delay
|
|
190
|
+
|
|
191
|
+
Just use the `countdown` parameter to delay the task by N seconds. (always in seconds): `.apply_async(*args, **kwargs, countdown=N)`
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
# async task delayed 35 seconds
|
|
196
|
+
delay_35_seconds = 35
|
|
197
|
+
hello_world.apply_async(
|
|
198
|
+
args=("Tony Stark",),
|
|
199
|
+
kwargs={"activity": "building in a cave with a box of scraps."},
|
|
200
|
+
countdown=delay_35_seconds,
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### JSON-ready Arguments
|
|
205
|
+
|
|
206
|
+
Each argument needs to be _JSON_ serializable. The way you find out:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
import json
|
|
210
|
+
|
|
211
|
+
data = {
|
|
212
|
+
"args": ("Tony Stark",),
|
|
213
|
+
"kwargs": {"activity": "building in a cave with a box of scraps."},
|
|
214
|
+
}
|
|
215
|
+
print(json.dumps(data))
|
|
216
|
+
# no errors, you're good to go.
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Example Task
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
# from celery import shared_task
|
|
223
|
+
from django_qstash import shared_task
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@shared_task
|
|
227
|
+
def math_add_task(a, b, save_to_file=False, *args, **kwargs):
|
|
228
|
+
logger.info(f"Adding {a} and {b}")
|
|
229
|
+
if save_to_file:
|
|
230
|
+
with open("math-add-result.txt", "w") as f:
|
|
231
|
+
f.write(f"{a} + {b} = {a + b}")
|
|
232
|
+
return a + b
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
Calling:
|
|
237
|
+
```python
|
|
238
|
+
math_add_task.apply_async(args=(12, 454), save_to_file=True)
|
|
239
|
+
```
|
|
240
|
+
is the same as
|
|
241
|
+
```python
|
|
242
|
+
math_add_task.delay(12, 454, save_to_file=True)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
But if you need to delay the task, use `.apply_async()` with the `countdown` parameter.
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
five_hours = 5 * 60 * 60
|
|
249
|
+
math_add_task.apply_async(
|
|
250
|
+
args=(12, 454), kwargs={"save_to_file": True}, countdown=five_hours
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The `.delay()` method does not support a countdown parameter because it simply passes the arguments (*args, **kwargs) to the `apply_async()` method.
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
## Configuration
|
|
258
|
+
|
|
259
|
+
In Django settings, you can configure the following:
|
|
260
|
+
|
|
261
|
+
`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.com`
|
|
262
|
+
|
|
263
|
+
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
264
|
+
|
|
265
|
+
`DJANGO_QSTASH_WEBHOOK_PATH` (default:`/qstash/webhook/`): The path where QStash will send webhooks to your Django application.
|
|
266
|
+
|
|
267
|
+
`DJANGO_QSTASH_FORCE_HTTPS` (default:`True`): Whether to force HTTPS for the webhook.
|
|
268
|
+
|
|
269
|
+
`DJANGO_QSTASH_RESULT_TTL` (default:`604800`): A number of seconds after which task result data can be safely deleted. Defaults to 604800 seconds (7 days or 7 * 24 * 60 * 60).
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
## Storing Task Results (Optional)
|
|
273
|
+
|
|
274
|
+
In `django_qstash.results.models` we have the `TaskResult` model class that can be used to track async task results. These entries are created via webhooks.
|
|
275
|
+
|
|
276
|
+
To install it, just add `django_qstash.results` to your `INSTALLED_APPS` setting.
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
INSTALLED_APPS = [
|
|
280
|
+
# ...
|
|
281
|
+
"django_qstash.results",
|
|
282
|
+
# ...
|
|
283
|
+
]
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Run migrations:
|
|
287
|
+
```bash
|
|
288
|
+
python manage.py migrate
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Clear Stale Results
|
|
292
|
+
|
|
293
|
+
We recommend purging the `TaskResult` model after a certain amount of time.
|
|
294
|
+
```bash
|
|
295
|
+
python manage.py clear_stale_results
|
|
296
|
+
```
|
|
297
|
+
Args:
|
|
298
|
+
- `--since` is the number of seconds ago to clear results for. Defaults to 604800 seconds (7 days or the `DJANGO_QSTASH_RESULT_TTL` setting).
|
|
299
|
+
- `--no-input` is a flag to skip the confirmation prompt to delete the results.
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
## Definitions
|
|
304
|
+
|
|
305
|
+
- **Background Task**: A function or task that is not part of the request/response cycle.
|
|
306
|
+
- Examples include as sending an email, running a report, or updating a database.
|
|
307
|
+
- Pro: Background tasks can drastically improve the end-user experience since they can move on with their day while the task runs in the background.
|
|
308
|
+
- Con: Processes that run background tasks (like Celery) typically have to run 24/7.
|
|
309
|
+
- **Scale-to-Zero**: Depending on the amount of traffic, Django can be effectively turned off. If done right, when more traffic comes in, Django can be turned back on very quickly.
|
|
310
|
+
- **Serverless**: A cloud computing model where code runs without server management, with scaling and billing tied to usage. Often used interchangeably with "scale-to-zero".
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
## Motivation
|
|
314
|
+
|
|
315
|
+
TLDR - Celery cannot be serverless. I want serverless "Celery" so I only pay for the apps that have attention and traffic. Upstash created QStash to help solve the problem of message queues in a serverless environment. django-qstash is the goldilocks that combines the functionality of Celery with the functionality of QStash all to unlock fully serverless Django.
|
|
316
|
+
|
|
317
|
+
I run a lot of side projects with Django. Some as demos for tutorials based on my work at [@codingforentrepreneurs](https://cfe.sh/github) and some are new businesses that haven't found much traction yet.
|
|
318
|
+
|
|
319
|
+
Most web apps can benefit from async background tasks such as sending emails, running reports, or updating databases.
|
|
320
|
+
|
|
321
|
+
But how?
|
|
322
|
+
|
|
323
|
+
Traditionally, I'd reach for Celery but that can get expensive really quick. Running a lot of Django projects can add up too -- "death by a thousand cuts" if you will. A server for Django, for celery worker, for celery beat scheduler, and so on. It adds up fast.
|
|
324
|
+
|
|
325
|
+
I think serverless is the answer. Pay for what you use and scale to zero when you don't need it and scale up when you do -- all automated.
|
|
326
|
+
|
|
327
|
+
Django can be serverless and is pretty easy to do thanks to Docker and the countless hosting options and services out there. Celery cannot be serverless, at least yet.
|
|
328
|
+
|
|
329
|
+
Let's face it. Celery is a powerful tool to run async background tasks but it comes at a cost. It needs at least one server running 24/7. For best performance it needs 2 (one worker, one beat). It also needs Redis or RabbitMQ. Most background processes that are tied to web apps are not serverless; they have to "listen" for their next task.
|
|
330
|
+
|
|
331
|
+
To make Django truly scale-to-zero and serverless, we need to drop Celery.
|
|
332
|
+
|
|
333
|
+
Enter __django-qstash__.
|
|
334
|
+
|
|
335
|
+
django-qstash is designed to be a near drop-in replacement for Celery's `shared_task` decorator.
|
|
336
|
+
|
|
337
|
+
It works by leveraging Upstash QStash to deliver messages about your tasks (e.g. the function's arguments) via webhooks to your Django application. In the QStash [docs](https://upstash.com/docs/qstash/overall/getstarted), it is described as:
|
|
338
|
+
|
|
339
|
+
> QStash is a serverless messaging and scheduling solution. It fits easily into your existing workflow and allows you to build reliable systems without managing infrastructure.
|
|
340
|
+
>
|
|
341
|
+
> Instead of calling an endpoint directly, QStash acts as a middleman between you and an API to guarantee delivery, perform automatic retries on failure, and more.
|
|
342
|
+
|
|
343
|
+
django-qstash has a webhook handler that converts a QStash message to run a specific `@shared_task` function (the one that called `.delay()` or `.apply_async()`). It's easy, it's cheap, it's effective, and best of all, it unlocks the scale-to-zero potential of Django as a serverless app.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
django_qstash/__init__.py,sha256=wkPLjMVFXjwiEliBROhlszOouSwzpvgVAXU0IfHtlAw,117
|
|
2
|
+
django_qstash/exceptions.py,sha256=pH6kKRJFIVFkDHUJQ9yRWmtGdBBSXpNAwMSFuNzMgPw,392
|
|
3
|
+
django_qstash/handlers.py,sha256=mmm8TJOqV3j1rQXooNOa128gtmALXFNCAaDZ5xwIcuw,4950
|
|
4
|
+
django_qstash/tasks.py,sha256=tiBJz8BIOYRD6MN0k4_1ncKCjLlptP3BvTad9lz7YVo,5220
|
|
5
|
+
django_qstash/utils.py,sha256=wrTU30cobO2di18BNEFtKD4ih2euf7eQNpg6p6TkQ1Y,1185
|
|
6
|
+
django_qstash/views.py,sha256=H32f_jGnlwOTO0YG9znNo2b-GRYZ8TM-Wt0T62SGdXM,639
|
|
7
|
+
django_qstash/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
django_qstash/management/commands/clear_stale_results.py,sha256=stHCnvC1ER_rZXKUK-YVUgazC2q04eGIHjo_BrPSDEI,2096
|
|
9
|
+
django_qstash/results/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
django_qstash/results/admin.py,sha256=q9fn3lfn0gviMfiimYij0wBCYww7FxyrOfGPr1NvntA,434
|
|
11
|
+
django_qstash/results/apps.py,sha256=4me4cg5yeoeSJTphkHYzGMJUfGucT47FNIUMYu5gmIo,275
|
|
12
|
+
django_qstash/results/models.py,sha256=aEiAhGJOuLRtjibUw6xdQqUt3eYKLqY2as4I4QSrF5U,1047
|
|
13
|
+
django_qstash/results/services.py,sha256=HvNp5D1tQ__nz4LVUTAGxuyLl_dnlBps4pJ6E9HD2kA,991
|
|
14
|
+
django_qstash/results/migrations/0001_initial.py,sha256=A90SKgWmBf4SIJYG1Jh6-b_81Ia1zIzGj3Bfl1O4-kg,1902
|
|
15
|
+
django_qstash/results/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
django_qstash-0.0.4.dist-info/METADATA,sha256=D8RJT4Ivi3TlKjhD8hGw_YcLJvBo6r4XK6Vp7_pJ8e8,12307
|
|
17
|
+
django_qstash-0.0.4.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
18
|
+
django_qstash-0.0.4.dist-info/top_level.txt,sha256=AlV3WSK1A0ZvKuCLsINtIJhJW8zo7SEB-D3_RAjZ0hI,14
|
|
19
|
+
django_qstash-0.0.4.dist-info/RECORD,,
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: django-qstash
|
|
3
|
-
Version: 0.0.2
|
|
4
|
-
Summary: A drop-in replacement for Celery's shared_task with Upstash QStash.
|
|
5
|
-
Author-email: Justin Mitchel <justin@codingforentrepreneurs.com>
|
|
6
|
-
Project-URL: Changelog, https://github.com/jmitchel3/django-qstash
|
|
7
|
-
Project-URL: Documentation, https://github.com/jmitchel3/django-qstash
|
|
8
|
-
Project-URL: Funding, https://github.com/jmitchel3/django-qstash
|
|
9
|
-
Project-URL: Repository, https://github.com/jmitchel3/django-qstash
|
|
10
|
-
Classifier: Development Status :: 4 - Beta
|
|
11
|
-
Classifier: Framework :: Django :: 4.2
|
|
12
|
-
Classifier: Framework :: Django :: 5.0
|
|
13
|
-
Classifier: Framework :: Django :: 5.1
|
|
14
|
-
Classifier: Intended Audience :: Developers
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
-
Classifier: Natural Language :: English
|
|
17
|
-
Classifier: Operating System :: OS Independent
|
|
18
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
-
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
24
|
-
Requires-Python: >=3.10
|
|
25
|
-
Description-Content-Type: text/markdown
|
|
26
|
-
Requires-Dist: django>=4.2
|
|
27
|
-
Requires-Dist: qstash>=2
|
|
28
|
-
Requires-Dist: requests>=2.30
|
|
29
|
-
|
|
30
|
-
# Django QStash `pip install django-qstash`
|
|
31
|
-
|
|
32
|
-
A drop-in replacement for Celery's shared_task leveraging Upstash QStash for a truly serverless Django application to run background tasks asynchronously from the request/response cycle.
|
|
33
|
-
|
|
34
|
-
## Installation
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
pip install django-qstash
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Depends on:
|
|
41
|
-
|
|
42
|
-
- [Python 3.10+](https://www.python.org/)
|
|
43
|
-
- [Django 5+](https://docs.djangoproject.com/)
|
|
44
|
-
- [qstash-py](https://github.com/upstash/qstash-py)
|
|
45
|
-
|
|
46
|
-
## Usage
|
|
47
|
-
|
|
48
|
-
```python
|
|
49
|
-
# from celery import shared_task
|
|
50
|
-
from django_qstash import shared_task
|
|
51
|
-
|
|
52
|
-
@shared_task
|
|
53
|
-
def math_add_task(a, b, save_to_file=False):
|
|
54
|
-
logger.info(f"Adding {a} and {b}")
|
|
55
|
-
if save_to_file:
|
|
56
|
-
with open("math-add-result.txt", "w") as f:
|
|
57
|
-
f.write(f"{a} + {b} = {a + b}")
|
|
58
|
-
return a + b
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
```python
|
|
62
|
-
math_add_task.apply_async(args=(12, 454), save_to_file=True)
|
|
63
|
-
|
|
64
|
-
# or
|
|
65
|
-
|
|
66
|
-
math_add_task.delay(12, 454, save_to_file=True)
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
## Configuration
|
|
71
|
-
|
|
72
|
-
### Environment variables
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
```python
|
|
76
|
-
QSTASH_TOKEN="your_token"
|
|
77
|
-
QSTASH_CURRENT_SIGNING_KEY="your_current_signing_key"
|
|
78
|
-
QSTASH_NEXT_SIGNING_KEY="your_next_signing_key"
|
|
79
|
-
|
|
80
|
-
# required for django-qstash
|
|
81
|
-
DJANGO_QSTASH_DOMAIN="https://example.com"
|
|
82
|
-
DJANGO_QSTASH_WEBHOOK_PATH="/qstash/webhook/"
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.net`
|
|
88
|
-
|
|
89
|
-
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`DJANGO_QSTASH_WEBHOOK_PATH`: The path where QStash will send webhooks to your Django application. Defaults to `/qstash/webhook/`
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
`DJANGO_QSTASH_FORCE_HTTPS`: Whether to force HTTPS for the webhook. Defaults to `True`.
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
django_qstash/__init__.py,sha256=s9YTqnhDQdkNv4B-ncx_Ci8Lpkim8vmzfhpL9keFVjM,117
|
|
2
|
-
django_qstash/tasks.py,sha256=X2gFILRvUF2GFuwyAUT43Zvw7OsdFci7870VhRNQ5-M,4929
|
|
3
|
-
django_qstash/utils.py,sha256=wrTU30cobO2di18BNEFtKD4ih2euf7eQNpg6p6TkQ1Y,1185
|
|
4
|
-
django_qstash/views.py,sha256=ucxKQdBoz9Db_1wpuE3U0BBC4nPqtFv05Kb0wggrpOg,4270
|
|
5
|
-
django_qstash-0.0.2.dist-info/METADATA,sha256=ZyBvj8g4G_XyONeA-lqI6p7cEOFBupE-L6JDpJU0x_A,3079
|
|
6
|
-
django_qstash-0.0.2.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
7
|
-
django_qstash-0.0.2.dist-info/top_level.txt,sha256=AlV3WSK1A0ZvKuCLsINtIJhJW8zo7SEB-D3_RAjZ0hI,14
|
|
8
|
-
django_qstash-0.0.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|