vercel-django-tasks 0.7.2__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.
- vercel_django_tasks-0.7.2/.gitignore +129 -0
- vercel_django_tasks-0.7.2/LICENSE +21 -0
- vercel_django_tasks-0.7.2/PKG-INFO +56 -0
- vercel_django_tasks-0.7.2/README.md +44 -0
- vercel_django_tasks-0.7.2/pyproject.toml +75 -0
- vercel_django_tasks-0.7.2/vercel/integrations/django/__init__.py +23 -0
- vercel_django_tasks-0.7.2/vercel/integrations/django/_backend.py +630 -0
- vercel_django_tasks-0.7.2/vercel/integrations/django/py.typed +1 -0
- vercel_django_tasks-0.7.2/vercel/integrations/django/version.py +3 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
|
|
5
|
+
# C extensions
|
|
6
|
+
*.so
|
|
7
|
+
|
|
8
|
+
# Distribution / packaging
|
|
9
|
+
.Python
|
|
10
|
+
build/
|
|
11
|
+
develop-eggs/
|
|
12
|
+
dist/
|
|
13
|
+
downloads/
|
|
14
|
+
eggs/
|
|
15
|
+
.eggs/
|
|
16
|
+
lib/
|
|
17
|
+
lib64/
|
|
18
|
+
parts/
|
|
19
|
+
sdist/
|
|
20
|
+
var/
|
|
21
|
+
wheels/
|
|
22
|
+
share/python-wheels/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
.installed.cfg
|
|
25
|
+
*.egg
|
|
26
|
+
MANIFEST
|
|
27
|
+
|
|
28
|
+
# PyInstallerhave
|
|
29
|
+
*.manifest
|
|
30
|
+
*.spec
|
|
31
|
+
|
|
32
|
+
# Installer logs
|
|
33
|
+
pip-log.txt
|
|
34
|
+
pip-delete-this-directory.txt
|
|
35
|
+
|
|
36
|
+
# Unit test / coverage reports
|
|
37
|
+
.benchmarks/
|
|
38
|
+
htmlcov/
|
|
39
|
+
.tox/
|
|
40
|
+
.nox/
|
|
41
|
+
.coverage
|
|
42
|
+
.coverage.*
|
|
43
|
+
.cache
|
|
44
|
+
nosetests.xml
|
|
45
|
+
coverage.xml
|
|
46
|
+
*.cover
|
|
47
|
+
*.py,cover
|
|
48
|
+
.hypothesis/
|
|
49
|
+
.pytest_cache/
|
|
50
|
+
.ruff_cache/
|
|
51
|
+
|
|
52
|
+
# Translations
|
|
53
|
+
*.mo
|
|
54
|
+
*.pot
|
|
55
|
+
|
|
56
|
+
# Scrapy
|
|
57
|
+
.scrapy
|
|
58
|
+
|
|
59
|
+
# Sphinx documentation
|
|
60
|
+
docs/_build/
|
|
61
|
+
|
|
62
|
+
# PyBuilder
|
|
63
|
+
target/
|
|
64
|
+
|
|
65
|
+
# Jupyter Notebook
|
|
66
|
+
.ipynb_checkpoints
|
|
67
|
+
|
|
68
|
+
# IPython
|
|
69
|
+
profile_default/
|
|
70
|
+
ipython_config.py
|
|
71
|
+
|
|
72
|
+
# pyenv
|
|
73
|
+
.python-version
|
|
74
|
+
|
|
75
|
+
# pipenv
|
|
76
|
+
Pipfile.lock
|
|
77
|
+
|
|
78
|
+
# poetry
|
|
79
|
+
poetry.lock
|
|
80
|
+
|
|
81
|
+
# PDM
|
|
82
|
+
pdm.lock
|
|
83
|
+
.pdm.toml
|
|
84
|
+
|
|
85
|
+
# Hatch
|
|
86
|
+
.hatch/
|
|
87
|
+
|
|
88
|
+
# pyright/mypy
|
|
89
|
+
.mypy_cache/
|
|
90
|
+
.dmypy.json
|
|
91
|
+
dmypy.json
|
|
92
|
+
|
|
93
|
+
# pyre
|
|
94
|
+
.pyre/
|
|
95
|
+
|
|
96
|
+
# pytype
|
|
97
|
+
.pytype/
|
|
98
|
+
|
|
99
|
+
# Caches
|
|
100
|
+
__pypackages__/
|
|
101
|
+
|
|
102
|
+
# Editor/project files
|
|
103
|
+
.DS_Store
|
|
104
|
+
.idea/
|
|
105
|
+
.vscode/
|
|
106
|
+
.zed/
|
|
107
|
+
*.swp
|
|
108
|
+
*.swo
|
|
109
|
+
|
|
110
|
+
# Virtual environments
|
|
111
|
+
.env
|
|
112
|
+
.venv
|
|
113
|
+
.venv.*
|
|
114
|
+
env/
|
|
115
|
+
venv/
|
|
116
|
+
**/.env
|
|
117
|
+
**/.venv
|
|
118
|
+
**/.venv.*
|
|
119
|
+
**/env/
|
|
120
|
+
**/venv/
|
|
121
|
+
ENV/
|
|
122
|
+
env.bak/
|
|
123
|
+
venv.bak/
|
|
124
|
+
# dotenv anywhere
|
|
125
|
+
**/.env
|
|
126
|
+
**/.env.*
|
|
127
|
+
**/*.env
|
|
128
|
+
|
|
129
|
+
.claude
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vercel, Inc.
|
|
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,56 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vercel-django-tasks
|
|
3
|
+
Version: 0.7.2
|
|
4
|
+
Summary: Django task backend backed by Vercel Queue Service
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: django>=6.0; python_version >= '3.12'
|
|
9
|
+
Requires-Dist: vercel-cache
|
|
10
|
+
Requires-Dist: vercel-queue>=0.6.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# vercel-django-tasks
|
|
14
|
+
|
|
15
|
+
Django task backend backed by Vercel Queue Service. The installer uses
|
|
16
|
+
`VercelQueuesBackend` as Django's default task backend when no `TASKS`
|
|
17
|
+
backends are configured and registers generated queue subscribers.
|
|
18
|
+
|
|
19
|
+
Register push subscribers during application startup:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from vercel.integrations.django import install_vercel_django_task_integration
|
|
23
|
+
|
|
24
|
+
install_vercel_django_task_integration()
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
No `TASKS` setting is required. Configure one explicitly to customize the
|
|
28
|
+
backend or to use a different backend:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
TASKS = {
|
|
32
|
+
"default": {
|
|
33
|
+
"BACKEND": "vercel.integrations.django.VercelQueuesBackend",
|
|
34
|
+
"QUEUES": ["default"],
|
|
35
|
+
"OPTIONS": {
|
|
36
|
+
"result_namespace": "django-task-results",
|
|
37
|
+
"result_ttl_seconds": 86400,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Declare the module that loads your Django application in `pyproject.toml` so
|
|
44
|
+
Vercel generates a queue subscriber function:
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
[[tool.vercel.subscribers]]
|
|
48
|
+
entrypoint = "my_project.wsgi"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
No manual queue endpoint is required.
|
|
52
|
+
|
|
53
|
+
Task result state is stored in Vercel Runtime Cache. Results are cache-backed
|
|
54
|
+
and expire according to `result_ttl_seconds`; they are not durable storage.
|
|
55
|
+
|
|
56
|
+
This package depends on Django, `vercel-queue`, and `vercel-cache`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# vercel-django-tasks
|
|
2
|
+
|
|
3
|
+
Django task backend backed by Vercel Queue Service. The installer uses
|
|
4
|
+
`VercelQueuesBackend` as Django's default task backend when no `TASKS`
|
|
5
|
+
backends are configured and registers generated queue subscribers.
|
|
6
|
+
|
|
7
|
+
Register push subscribers during application startup:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from vercel.integrations.django import install_vercel_django_task_integration
|
|
11
|
+
|
|
12
|
+
install_vercel_django_task_integration()
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
No `TASKS` setting is required. Configure one explicitly to customize the
|
|
16
|
+
backend or to use a different backend:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
TASKS = {
|
|
20
|
+
"default": {
|
|
21
|
+
"BACKEND": "vercel.integrations.django.VercelQueuesBackend",
|
|
22
|
+
"QUEUES": ["default"],
|
|
23
|
+
"OPTIONS": {
|
|
24
|
+
"result_namespace": "django-task-results",
|
|
25
|
+
"result_ttl_seconds": 86400,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Declare the module that loads your Django application in `pyproject.toml` so
|
|
32
|
+
Vercel generates a queue subscriber function:
|
|
33
|
+
|
|
34
|
+
```toml
|
|
35
|
+
[[tool.vercel.subscribers]]
|
|
36
|
+
entrypoint = "my_project.wsgi"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
No manual queue endpoint is required.
|
|
40
|
+
|
|
41
|
+
Task result state is stored in Vercel Runtime Cache. Results are cache-backed
|
|
42
|
+
and expire according to `result_ttl_seconds`; they are not durable storage.
|
|
43
|
+
|
|
44
|
+
This package depends on Django, `vercel-queue`, and `vercel-cache`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27.0,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vercel-django-tasks"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Django task backend backed by Vercel Queue Service"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE", "LICENSE.*"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"Django>=6.0; python_version >= '3.12'",
|
|
15
|
+
"vercel-cache",
|
|
16
|
+
"vercel-queue>=0.6.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.uv.sources]
|
|
20
|
+
vercel-cache = { workspace = true }
|
|
21
|
+
vercel-queue = { workspace = true }
|
|
22
|
+
|
|
23
|
+
[tool.hatch.version]
|
|
24
|
+
path = "vercel/integrations/django/version.py"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.sdist]
|
|
27
|
+
include = [
|
|
28
|
+
"/vercel/integrations/django/**/*.py",
|
|
29
|
+
"/vercel/integrations/django/py.typed",
|
|
30
|
+
"/README.md",
|
|
31
|
+
"/pyproject.toml",
|
|
32
|
+
"/LICENSE",
|
|
33
|
+
]
|
|
34
|
+
exclude = [
|
|
35
|
+
"/**/__pycache__",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
dev-mode-dirs = ["."]
|
|
40
|
+
only-include = [
|
|
41
|
+
"/vercel/integrations/django",
|
|
42
|
+
]
|
|
43
|
+
exclude = [
|
|
44
|
+
"/**/__pycache__",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
extend = "../ruff.toml"
|
|
49
|
+
|
|
50
|
+
[tool.ty.environment]
|
|
51
|
+
python-version = "3.12"
|
|
52
|
+
|
|
53
|
+
[tool.ty.rules]
|
|
54
|
+
unresolved-attribute = "ignore"
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
addopts = "--no-header --capture=tee-sys"
|
|
58
|
+
asyncio_mode = "auto"
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
|
|
61
|
+
[tool.poe]
|
|
62
|
+
include = "../../scripts/poe/poe.toml"
|
|
63
|
+
verbosity = -1
|
|
64
|
+
|
|
65
|
+
[tool.poe.tasks.test]
|
|
66
|
+
shell = """
|
|
67
|
+
if python -c 'import sys; raise SystemExit(sys.version_info < (3, 12))'; then
|
|
68
|
+
$PYTEST
|
|
69
|
+
else
|
|
70
|
+
echo "Skipping vercel-django-tasks tests: Django 6 requires Python 3.12+"
|
|
71
|
+
fi
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
[tool.poe.tasks.typecheck-mypy]
|
|
75
|
+
cmd = "$MYPY --python-version 3.12"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Django task integration for Vercel Queue Service."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 12):
|
|
6
|
+
pass
|
|
7
|
+
else: # pragma: no cover - dependency marker mirrors this gate.
|
|
8
|
+
raise RuntimeError(
|
|
9
|
+
"vercel.integrations.django requires Python 3.12 or newer because Django 6 "
|
|
10
|
+
"does not support earlier Python versions."
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from ._backend import (
|
|
14
|
+
VercelQueuesBackend,
|
|
15
|
+
install_vercel_django_task_integration,
|
|
16
|
+
)
|
|
17
|
+
from .version import __version__
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"VercelQueuesBackend",
|
|
21
|
+
"__version__",
|
|
22
|
+
"install_vercel_django_task_integration",
|
|
23
|
+
]
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypedDict, cast
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import math
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from traceback import format_exception
|
|
11
|
+
|
|
12
|
+
import vercel.queue as vqs
|
|
13
|
+
import vercel.queue.sync as vqs_sync
|
|
14
|
+
from django.conf import global_settings, settings
|
|
15
|
+
from django.tasks import DEFAULT_TASK_BACKEND_ALIAS, task_backends
|
|
16
|
+
from django.tasks.backends.base import BaseTaskBackend
|
|
17
|
+
from django.tasks.base import (
|
|
18
|
+
DEFAULT_TASK_PRIORITY,
|
|
19
|
+
Task,
|
|
20
|
+
TaskContext,
|
|
21
|
+
TaskError,
|
|
22
|
+
TaskResult,
|
|
23
|
+
TaskResultStatus,
|
|
24
|
+
)
|
|
25
|
+
from django.tasks.exceptions import TaskResultDoesNotExist
|
|
26
|
+
from django.tasks.signals import task_enqueued, task_finished, task_started
|
|
27
|
+
from django.utils import timezone as django_timezone
|
|
28
|
+
from django.utils.crypto import get_random_string
|
|
29
|
+
from django.utils.json import normalize_json
|
|
30
|
+
from django.utils.module_loading import import_string
|
|
31
|
+
from vercel.cache import RuntimeCache
|
|
32
|
+
|
|
33
|
+
from .version import __version__
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"VercelQueuesBackend",
|
|
37
|
+
"__version__",
|
|
38
|
+
"install_vercel_django_task_integration",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_CONSUMER_GROUP = "django-tasks"
|
|
42
|
+
_MAX_ATTEMPTS = 3
|
|
43
|
+
_RETRY_BACKOFF_BASE_SECONDS = 5
|
|
44
|
+
_RETRY_BACKOFF_FACTOR = 2.0
|
|
45
|
+
_MAX_RETRY_DELAY_SECONDS = 60 * 60
|
|
46
|
+
_DEFAULT_RESULT_TTL_SECONDS = 24 * 60 * 60
|
|
47
|
+
_DEFAULT_RESULT_NAMESPACE = "django-task-results"
|
|
48
|
+
_DEFAULT_TASK_BACKEND = {
|
|
49
|
+
"BACKEND": "vercel.integrations.django.VercelQueuesBackend",
|
|
50
|
+
}
|
|
51
|
+
_ENVELOPE_VERSION = 1
|
|
52
|
+
_RESULT_WRAPPER_MARKER = "__vercel_django_task_result__"
|
|
53
|
+
_RESULT_WRAPPER_VERSION = 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _TaskEnvelope(TypedDict):
|
|
57
|
+
version: int
|
|
58
|
+
task: str
|
|
59
|
+
queue: str
|
|
60
|
+
args: list[Any]
|
|
61
|
+
kwargs: dict[str, Any]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_StoredTaskRecord = dict[str, Any]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class _BackendOptions:
|
|
69
|
+
result_namespace: str = _DEFAULT_RESULT_NAMESPACE
|
|
70
|
+
result_ttl_seconds: int = _DEFAULT_RESULT_TTL_SECONDS
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def parse(cls, options: object) -> _BackendOptions:
|
|
74
|
+
if not isinstance(options, dict):
|
|
75
|
+
raise TypeError("VercelQueuesBackend OPTIONS must be a dictionary")
|
|
76
|
+
|
|
77
|
+
unknown = set(options) - {"result_namespace", "result_ttl_seconds"}
|
|
78
|
+
if unknown:
|
|
79
|
+
names = ", ".join(sorted(map(str, unknown)))
|
|
80
|
+
raise ValueError(f"Unknown VercelQueuesBackend option(s): {names}")
|
|
81
|
+
|
|
82
|
+
namespace = options.get("result_namespace", _DEFAULT_RESULT_NAMESPACE)
|
|
83
|
+
if not isinstance(namespace, str) or not namespace:
|
|
84
|
+
raise ValueError("result_namespace must be a non-empty string")
|
|
85
|
+
|
|
86
|
+
ttl = options.get("result_ttl_seconds", _DEFAULT_RESULT_TTL_SECONDS)
|
|
87
|
+
if not isinstance(ttl, int) or isinstance(ttl, bool) or ttl <= 0:
|
|
88
|
+
raise ValueError("result_ttl_seconds must be a positive integer")
|
|
89
|
+
|
|
90
|
+
return cls(result_namespace=namespace, result_ttl_seconds=ttl)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _TaskEnvelopeTransport(vqs.RawJsonTransport[_TaskEnvelope]):
|
|
94
|
+
def validate_payload(self, payload: Any) -> _TaskEnvelope:
|
|
95
|
+
return _parse_envelope(payload)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _RuntimeCacheResults:
|
|
99
|
+
def __init__(self, *, namespace: str, ttl: int) -> None:
|
|
100
|
+
self.ttl = ttl
|
|
101
|
+
self._runtime_cache = RuntimeCache(
|
|
102
|
+
namespace=str(vqs.sanitize_name(namespace)),
|
|
103
|
+
strict=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def get(self, result_id: str) -> _StoredTaskRecord | None:
|
|
107
|
+
value = self._runtime_cache.get(result_id)
|
|
108
|
+
if value is None:
|
|
109
|
+
return None
|
|
110
|
+
return _unwrap_result_record(value)
|
|
111
|
+
|
|
112
|
+
def set(self, result_id: str, record: _StoredTaskRecord) -> None:
|
|
113
|
+
self._runtime_cache.set(
|
|
114
|
+
result_id,
|
|
115
|
+
_wrap_result_record(record),
|
|
116
|
+
{"name": result_id, "ttl": self.ttl},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class _PreparedEnqueue:
|
|
122
|
+
envelope: _TaskEnvelope
|
|
123
|
+
delay: vqs.Duration | None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_registered_subscribers: dict[tuple[str, str, str], Any] = {}
|
|
127
|
+
_registration_lock = threading.RLock()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _now() -> datetime:
|
|
131
|
+
return django_timezone.now()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _json_normalize(value: Any) -> Any:
|
|
135
|
+
return normalize_json(value)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _set_result_attr(result: TaskResult, name: str, value: Any) -> None:
|
|
139
|
+
object.__setattr__(result, name, value) # noqa: PLC2801
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _exception_class_path(exc: BaseException) -> str:
|
|
143
|
+
exc_type = type(exc)
|
|
144
|
+
return f"{exc_type.__module__}.{exc_type.__qualname__}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _task_error(exc: BaseException) -> TaskError:
|
|
148
|
+
return TaskError(
|
|
149
|
+
exception_class_path=_exception_class_path(exc),
|
|
150
|
+
traceback="".join(format_exception(exc)),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _retry_delay_seconds(attempt: int) -> int:
|
|
155
|
+
delay = float(_RETRY_BACKOFF_BASE_SECONDS) * math.pow(
|
|
156
|
+
_RETRY_BACKOFF_FACTOR,
|
|
157
|
+
max(0, attempt - 1),
|
|
158
|
+
)
|
|
159
|
+
if not math.isfinite(delay):
|
|
160
|
+
return _MAX_RETRY_DELAY_SECONDS
|
|
161
|
+
return int(max(0, min(float(_MAX_RETRY_DELAY_SECONDS), delay)))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _parse_iso_datetime(value: object) -> datetime | None:
|
|
165
|
+
if value is None:
|
|
166
|
+
return None
|
|
167
|
+
if not isinstance(value, str) or not value:
|
|
168
|
+
raise TypeError("task result timestamp must be a string or null")
|
|
169
|
+
raw = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
170
|
+
try:
|
|
171
|
+
parsed = datetime.fromisoformat(raw)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise ValueError("task result timestamp is invalid") from exc
|
|
174
|
+
if parsed.tzinfo is None:
|
|
175
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
176
|
+
return parsed
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _wrap_result_record(record: _StoredTaskRecord) -> dict[str, object]:
|
|
180
|
+
return {
|
|
181
|
+
_RESULT_WRAPPER_MARKER: _RESULT_WRAPPER_VERSION,
|
|
182
|
+
"record": record,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _unwrap_result_record(value: object) -> _StoredTaskRecord:
|
|
187
|
+
if not isinstance(value, dict):
|
|
188
|
+
raise TypeError("Runtime Cache result payload is not an object")
|
|
189
|
+
if value.get(_RESULT_WRAPPER_MARKER) != _RESULT_WRAPPER_VERSION:
|
|
190
|
+
raise ValueError("Runtime Cache result payload has an unknown version")
|
|
191
|
+
record = value.get("record")
|
|
192
|
+
if not isinstance(record, dict):
|
|
193
|
+
raise TypeError("Runtime Cache result record is not an object")
|
|
194
|
+
return cast("_StoredTaskRecord", record)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_envelope(payload: Any) -> _TaskEnvelope:
|
|
198
|
+
if not isinstance(payload, dict):
|
|
199
|
+
raise TypeError("Invalid task payload: expected object")
|
|
200
|
+
if payload.get("version") != _ENVELOPE_VERSION:
|
|
201
|
+
raise ValueError("Invalid task payload: unknown envelope version")
|
|
202
|
+
task_path = payload.get("task")
|
|
203
|
+
queue = payload.get("queue")
|
|
204
|
+
args = payload.get("args")
|
|
205
|
+
kwargs = payload.get("kwargs")
|
|
206
|
+
if not isinstance(task_path, str) or not task_path:
|
|
207
|
+
raise TypeError("Invalid task payload: task must be a non-empty string")
|
|
208
|
+
if not isinstance(queue, str) or not queue:
|
|
209
|
+
raise TypeError("Invalid task payload: queue must be a non-empty string")
|
|
210
|
+
if not isinstance(args, list) or not isinstance(kwargs, dict):
|
|
211
|
+
raise TypeError("Invalid task payload: args and kwargs are required")
|
|
212
|
+
return cast("_TaskEnvelope", payload)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class VercelQueuesBackend(BaseTaskBackend):
|
|
216
|
+
task_class: type[Task] = Task
|
|
217
|
+
supports_defer = True
|
|
218
|
+
supports_async_task = True
|
|
219
|
+
supports_get_result = True
|
|
220
|
+
supports_priority = False
|
|
221
|
+
|
|
222
|
+
def __init__(self, alias: str, params: dict[str, Any]) -> None:
|
|
223
|
+
super().__init__(alias, params)
|
|
224
|
+
self._cfg = _BackendOptions.parse(self.options)
|
|
225
|
+
self._sync_queue_client: vqs_sync.QueueClient | None = None
|
|
226
|
+
self._async_queue_client: vqs.QueueClient | None = None
|
|
227
|
+
self._results = _RuntimeCacheResults(
|
|
228
|
+
namespace=self._cfg.result_namespace,
|
|
229
|
+
ttl=self._cfg.result_ttl_seconds,
|
|
230
|
+
)
|
|
231
|
+
self.worker_id = get_random_string(32)
|
|
232
|
+
|
|
233
|
+
def _topic(self, queue_name: str) -> vqs.Topic[_TaskEnvelope]:
|
|
234
|
+
return vqs.Topic[_TaskEnvelope](
|
|
235
|
+
vqs.sanitize_name(queue_name),
|
|
236
|
+
transport=_TaskEnvelopeTransport(),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _sync_client(self) -> vqs_sync.QueueClient:
|
|
240
|
+
if self._sync_queue_client is None:
|
|
241
|
+
self._sync_queue_client = vqs_sync.QueueClient()
|
|
242
|
+
return self._sync_queue_client
|
|
243
|
+
|
|
244
|
+
def _async_client(self) -> vqs.QueueClient:
|
|
245
|
+
if self._async_queue_client is None:
|
|
246
|
+
self._async_queue_client = vqs.QueueClient()
|
|
247
|
+
return self._async_queue_client
|
|
248
|
+
|
|
249
|
+
def close(self) -> None:
|
|
250
|
+
"""Clear backend-owned queue clients."""
|
|
251
|
+
self._sync_queue_client = None
|
|
252
|
+
self._async_queue_client = None
|
|
253
|
+
|
|
254
|
+
def _task_from_module_path(self, *, module_path: str, queue_name: str) -> Task:
|
|
255
|
+
imported = import_string(module_path)
|
|
256
|
+
if isinstance(imported, Task):
|
|
257
|
+
func = imported.func
|
|
258
|
+
takes_context = imported.takes_context
|
|
259
|
+
else:
|
|
260
|
+
func = imported
|
|
261
|
+
takes_context = False
|
|
262
|
+
if not callable(func):
|
|
263
|
+
raise TypeError(f"Task function is not callable: {module_path!r}")
|
|
264
|
+
return self.task_class(
|
|
265
|
+
func=func,
|
|
266
|
+
priority=DEFAULT_TASK_PRIORITY,
|
|
267
|
+
queue_name=queue_name,
|
|
268
|
+
backend=self.alias,
|
|
269
|
+
takes_context=takes_context,
|
|
270
|
+
run_after=None,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _prepare_enqueue(
|
|
274
|
+
self,
|
|
275
|
+
task: Task,
|
|
276
|
+
args: list[Any],
|
|
277
|
+
kwargs: dict[str, Any],
|
|
278
|
+
) -> _PreparedEnqueue:
|
|
279
|
+
self.validate_task(task)
|
|
280
|
+
envelope: _TaskEnvelope = {
|
|
281
|
+
"version": _ENVELOPE_VERSION,
|
|
282
|
+
"task": task.module_path,
|
|
283
|
+
"queue": task.queue_name,
|
|
284
|
+
"args": cast("list[Any]", _json_normalize(list(args))),
|
|
285
|
+
"kwargs": cast("dict[str, Any]", _json_normalize(dict(kwargs))),
|
|
286
|
+
}
|
|
287
|
+
delay: vqs.Duration | None = None
|
|
288
|
+
if task.run_after is not None:
|
|
289
|
+
seconds = (task.run_after - _now()).total_seconds()
|
|
290
|
+
if seconds > 0:
|
|
291
|
+
delay = int(seconds)
|
|
292
|
+
return _PreparedEnqueue(envelope=envelope, delay=delay)
|
|
293
|
+
|
|
294
|
+
def _finalize_enqueue(
|
|
295
|
+
self,
|
|
296
|
+
task: Task,
|
|
297
|
+
prepared: _PreparedEnqueue,
|
|
298
|
+
message_id: object,
|
|
299
|
+
) -> TaskResult:
|
|
300
|
+
if message_id is None:
|
|
301
|
+
raise RuntimeError("Vercel Queue accepted the task without returning a message id")
|
|
302
|
+
result: TaskResult = TaskResult(
|
|
303
|
+
task=task,
|
|
304
|
+
id=str(message_id),
|
|
305
|
+
status=TaskResultStatus.READY,
|
|
306
|
+
enqueued_at=_now(),
|
|
307
|
+
started_at=None,
|
|
308
|
+
last_attempted_at=None,
|
|
309
|
+
finished_at=None,
|
|
310
|
+
args=prepared.envelope["args"],
|
|
311
|
+
kwargs=prepared.envelope["kwargs"],
|
|
312
|
+
backend=self.alias,
|
|
313
|
+
errors=[],
|
|
314
|
+
worker_ids=[],
|
|
315
|
+
)
|
|
316
|
+
self._store_result(result)
|
|
317
|
+
task_enqueued.send(type(self), task_result=result)
|
|
318
|
+
return copy.deepcopy(result)
|
|
319
|
+
|
|
320
|
+
def enqueue(
|
|
321
|
+
self,
|
|
322
|
+
task: Task,
|
|
323
|
+
args: list[Any],
|
|
324
|
+
kwargs: dict[str, Any],
|
|
325
|
+
) -> TaskResult:
|
|
326
|
+
prepared = self._prepare_enqueue(task, args, kwargs)
|
|
327
|
+
message_id = self._sync_client().send(
|
|
328
|
+
self._topic(task.queue_name),
|
|
329
|
+
prepared.envelope,
|
|
330
|
+
delay=prepared.delay,
|
|
331
|
+
)
|
|
332
|
+
return self._finalize_enqueue(task, prepared, message_id)
|
|
333
|
+
|
|
334
|
+
async def aenqueue(
|
|
335
|
+
self,
|
|
336
|
+
task: Task,
|
|
337
|
+
args: list[Any],
|
|
338
|
+
kwargs: dict[str, Any],
|
|
339
|
+
) -> TaskResult:
|
|
340
|
+
prepared = self._prepare_enqueue(task, args, kwargs)
|
|
341
|
+
message_id = await self._async_client().send(
|
|
342
|
+
self._topic(task.queue_name),
|
|
343
|
+
prepared.envelope,
|
|
344
|
+
delay=prepared.delay,
|
|
345
|
+
)
|
|
346
|
+
return self._finalize_enqueue(task, prepared, message_id)
|
|
347
|
+
|
|
348
|
+
def _serialize_result(self, result: TaskResult) -> _StoredTaskRecord:
|
|
349
|
+
def _datetime(value: datetime | None) -> str | None:
|
|
350
|
+
return value.isoformat() if value is not None else None
|
|
351
|
+
|
|
352
|
+
record: _StoredTaskRecord = {
|
|
353
|
+
"version": 1,
|
|
354
|
+
"id": result.id,
|
|
355
|
+
"task": result.task.module_path,
|
|
356
|
+
"queue": result.task.queue_name,
|
|
357
|
+
"status": str(result.status),
|
|
358
|
+
"enqueued_at": _datetime(result.enqueued_at),
|
|
359
|
+
"started_at": _datetime(result.started_at),
|
|
360
|
+
"finished_at": _datetime(result.finished_at),
|
|
361
|
+
"last_attempted_at": _datetime(result.last_attempted_at),
|
|
362
|
+
"args": _json_normalize(list(result.args)),
|
|
363
|
+
"kwargs": _json_normalize(dict(result.kwargs)),
|
|
364
|
+
"worker_ids": list(result.worker_ids),
|
|
365
|
+
"errors": [
|
|
366
|
+
{
|
|
367
|
+
"exception_class_path": error.exception_class_path,
|
|
368
|
+
"traceback": error.traceback,
|
|
369
|
+
}
|
|
370
|
+
for error in result.errors
|
|
371
|
+
],
|
|
372
|
+
}
|
|
373
|
+
if result.status == TaskResultStatus.SUCCESSFUL:
|
|
374
|
+
record["return_value"] = _json_normalize(result.return_value)
|
|
375
|
+
return record
|
|
376
|
+
|
|
377
|
+
def _deserialize_result(self, record: _StoredTaskRecord) -> TaskResult:
|
|
378
|
+
required = {
|
|
379
|
+
"version",
|
|
380
|
+
"id",
|
|
381
|
+
"task",
|
|
382
|
+
"queue",
|
|
383
|
+
"status",
|
|
384
|
+
"enqueued_at",
|
|
385
|
+
"started_at",
|
|
386
|
+
"finished_at",
|
|
387
|
+
"last_attempted_at",
|
|
388
|
+
"args",
|
|
389
|
+
"kwargs",
|
|
390
|
+
"worker_ids",
|
|
391
|
+
"errors",
|
|
392
|
+
}
|
|
393
|
+
if record.get("version") != 1 or not required.issubset(record):
|
|
394
|
+
raise ValueError("Runtime Cache task result record is malformed")
|
|
395
|
+
|
|
396
|
+
result_id = record["id"]
|
|
397
|
+
module_path = record["task"]
|
|
398
|
+
queue_name = record["queue"]
|
|
399
|
+
args = record["args"]
|
|
400
|
+
kwargs = record["kwargs"]
|
|
401
|
+
worker_ids = record["worker_ids"]
|
|
402
|
+
errors = record["errors"]
|
|
403
|
+
if not all(
|
|
404
|
+
isinstance(value, str) and value for value in (result_id, module_path, queue_name)
|
|
405
|
+
):
|
|
406
|
+
raise TypeError("Runtime Cache task result identity is malformed")
|
|
407
|
+
if not isinstance(args, list) or not isinstance(kwargs, dict):
|
|
408
|
+
raise TypeError("Runtime Cache task result arguments are malformed")
|
|
409
|
+
if not isinstance(worker_ids, list) or not all(
|
|
410
|
+
isinstance(item, str) for item in worker_ids
|
|
411
|
+
):
|
|
412
|
+
raise TypeError("Runtime Cache task result worker IDs are malformed")
|
|
413
|
+
if not isinstance(errors, list) or not all(isinstance(item, dict) for item in errors):
|
|
414
|
+
raise TypeError("Runtime Cache task result errors are malformed")
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
status = TaskResultStatus(record["status"])
|
|
418
|
+
except (TypeError, ValueError) as exc:
|
|
419
|
+
raise ValueError("Runtime Cache task result status is malformed") from exc
|
|
420
|
+
|
|
421
|
+
task = self._task_from_module_path(
|
|
422
|
+
module_path=module_path,
|
|
423
|
+
queue_name=queue_name,
|
|
424
|
+
)
|
|
425
|
+
result: TaskResult = TaskResult(
|
|
426
|
+
task=task,
|
|
427
|
+
id=result_id,
|
|
428
|
+
status=status,
|
|
429
|
+
enqueued_at=_parse_iso_datetime(record["enqueued_at"]),
|
|
430
|
+
started_at=_parse_iso_datetime(record["started_at"]),
|
|
431
|
+
finished_at=_parse_iso_datetime(record["finished_at"]),
|
|
432
|
+
last_attempted_at=_parse_iso_datetime(record["last_attempted_at"]),
|
|
433
|
+
args=args,
|
|
434
|
+
kwargs=kwargs,
|
|
435
|
+
backend=self.alias,
|
|
436
|
+
errors=[
|
|
437
|
+
TaskError(
|
|
438
|
+
exception_class_path=str(error.get("exception_class_path") or ""),
|
|
439
|
+
traceback=str(error.get("traceback") or ""),
|
|
440
|
+
)
|
|
441
|
+
for error in errors
|
|
442
|
+
],
|
|
443
|
+
worker_ids=worker_ids,
|
|
444
|
+
)
|
|
445
|
+
if "return_value" in record:
|
|
446
|
+
_set_result_attr(result, "_return_value", record["return_value"])
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
def _store_result(self, result: TaskResult) -> None:
|
|
450
|
+
self._results.set(result.id, self._serialize_result(result))
|
|
451
|
+
|
|
452
|
+
def get_result(self, result_id: str) -> TaskResult:
|
|
453
|
+
try:
|
|
454
|
+
record = self._results.get(str(result_id))
|
|
455
|
+
except (ImportError, TypeError, ValueError):
|
|
456
|
+
raise TaskResultDoesNotExist(result_id) from None
|
|
457
|
+
if record is None:
|
|
458
|
+
raise TaskResultDoesNotExist(result_id)
|
|
459
|
+
try:
|
|
460
|
+
return self._deserialize_result(record)
|
|
461
|
+
except (ImportError, TypeError, ValueError):
|
|
462
|
+
raise TaskResultDoesNotExist(result_id) from None
|
|
463
|
+
|
|
464
|
+
async def aget_result(self, result_id: str) -> TaskResult:
|
|
465
|
+
return self.get_result(result_id)
|
|
466
|
+
|
|
467
|
+
def _load_or_initialize_result(
|
|
468
|
+
self,
|
|
469
|
+
*,
|
|
470
|
+
message_id: str,
|
|
471
|
+
envelope: _TaskEnvelope,
|
|
472
|
+
task: Task,
|
|
473
|
+
) -> TaskResult:
|
|
474
|
+
try:
|
|
475
|
+
result = self.get_result(message_id)
|
|
476
|
+
except TaskResultDoesNotExist:
|
|
477
|
+
result = TaskResult(
|
|
478
|
+
task=task,
|
|
479
|
+
id=message_id,
|
|
480
|
+
status=TaskResultStatus.READY,
|
|
481
|
+
enqueued_at=None,
|
|
482
|
+
started_at=None,
|
|
483
|
+
last_attempted_at=None,
|
|
484
|
+
finished_at=None,
|
|
485
|
+
args=envelope["args"],
|
|
486
|
+
kwargs=envelope["kwargs"],
|
|
487
|
+
backend=self.alias,
|
|
488
|
+
errors=[],
|
|
489
|
+
worker_ids=[],
|
|
490
|
+
)
|
|
491
|
+
_set_result_attr(result, "task", task)
|
|
492
|
+
_set_result_attr(result, "args", envelope["args"])
|
|
493
|
+
_set_result_attr(result, "kwargs", envelope["kwargs"])
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
def _start_result(self, result: TaskResult) -> None:
|
|
497
|
+
now = _now()
|
|
498
|
+
_set_result_attr(result, "status", TaskResultStatus.RUNNING)
|
|
499
|
+
if result.started_at is None:
|
|
500
|
+
_set_result_attr(result, "started_at", now)
|
|
501
|
+
_set_result_attr(result, "last_attempted_at", now)
|
|
502
|
+
result.worker_ids.append(self.worker_id)
|
|
503
|
+
self._store_result(result)
|
|
504
|
+
task_started.send(sender=type(self), task_result=result)
|
|
505
|
+
|
|
506
|
+
def _finish_result(
|
|
507
|
+
self,
|
|
508
|
+
result: TaskResult,
|
|
509
|
+
*,
|
|
510
|
+
return_value: Any = None,
|
|
511
|
+
error: BaseException | None = None,
|
|
512
|
+
) -> int | None:
|
|
513
|
+
if error is None:
|
|
514
|
+
_set_result_attr(result, "_return_value", _json_normalize(return_value))
|
|
515
|
+
_set_result_attr(result, "status", TaskResultStatus.SUCCESSFUL)
|
|
516
|
+
_set_result_attr(result, "finished_at", _now())
|
|
517
|
+
self._store_result(result)
|
|
518
|
+
task_finished.send(sender=type(self), task_result=result)
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
result.errors.append(_task_error(error))
|
|
522
|
+
attempt = len(result.worker_ids)
|
|
523
|
+
if attempt < _MAX_ATTEMPTS:
|
|
524
|
+
_set_result_attr(result, "status", TaskResultStatus.READY)
|
|
525
|
+
_set_result_attr(result, "finished_at", None)
|
|
526
|
+
self._store_result(result)
|
|
527
|
+
return _retry_delay_seconds(attempt)
|
|
528
|
+
|
|
529
|
+
_set_result_attr(result, "status", TaskResultStatus.FAILED)
|
|
530
|
+
_set_result_attr(result, "finished_at", _now())
|
|
531
|
+
self._store_result(result)
|
|
532
|
+
task_finished.send(sender=type(self), task_result=result)
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
async def _execute_message(self, message: vqs.Message[_TaskEnvelope]) -> int | None:
|
|
536
|
+
envelope = _parse_envelope(message.payload)
|
|
537
|
+
queue_name = envelope["queue"]
|
|
538
|
+
if self.queues and queue_name not in self.queues:
|
|
539
|
+
raise ValueError(f"Queue {queue_name!r} is not valid for backend {self.alias!r}")
|
|
540
|
+
task = self._task_from_module_path(
|
|
541
|
+
module_path=envelope["task"],
|
|
542
|
+
queue_name=queue_name,
|
|
543
|
+
)
|
|
544
|
+
result = self._load_or_initialize_result(
|
|
545
|
+
message_id=message.metadata.message_id,
|
|
546
|
+
envelope=envelope,
|
|
547
|
+
task=task,
|
|
548
|
+
)
|
|
549
|
+
self._start_result(result)
|
|
550
|
+
try:
|
|
551
|
+
if task.takes_context:
|
|
552
|
+
return_value = await task.acall(
|
|
553
|
+
TaskContext(task_result=result),
|
|
554
|
+
*result.args,
|
|
555
|
+
**result.kwargs,
|
|
556
|
+
)
|
|
557
|
+
else:
|
|
558
|
+
return_value = await task.acall(*result.args, **result.kwargs)
|
|
559
|
+
except Exception as exc: # noqa: BLE001
|
|
560
|
+
return self._finish_result(result, error=exc)
|
|
561
|
+
return self._finish_result(result, return_value=return_value)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _resolve_backend(alias: str) -> VercelQueuesBackend:
|
|
565
|
+
backend = task_backends[alias]
|
|
566
|
+
if not isinstance(backend, VercelQueuesBackend):
|
|
567
|
+
raise TypeError(
|
|
568
|
+
f"Backend {alias!r} is {backend.__class__.__name__}, expected VercelQueuesBackend."
|
|
569
|
+
)
|
|
570
|
+
return backend
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _register_task_queues(backend_alias: str) -> None:
|
|
574
|
+
backend = _resolve_backend(backend_alias)
|
|
575
|
+
with _registration_lock:
|
|
576
|
+
for queue_name in sorted(backend.queues):
|
|
577
|
+
topic = backend._topic(queue_name) # noqa: SLF001
|
|
578
|
+
key = (backend.alias, str(topic.name), _CONSUMER_GROUP)
|
|
579
|
+
if key in _registered_subscribers:
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
async def callback(
|
|
583
|
+
message: vqs.Message[_TaskEnvelope],
|
|
584
|
+
*,
|
|
585
|
+
_backend: VercelQueuesBackend = backend,
|
|
586
|
+
) -> None:
|
|
587
|
+
retry_after = await _backend._execute_message(message) # noqa: SLF001
|
|
588
|
+
if retry_after is not None:
|
|
589
|
+
raise vqs.RetryAfter(retry_after)
|
|
590
|
+
|
|
591
|
+
callback.__name__ = f"vercel_django_task_{backend.alias}_{topic.name}"
|
|
592
|
+
subscriber = vqs.subscribe(
|
|
593
|
+
topic=topic,
|
|
594
|
+
consumer_group=_CONSUMER_GROUP,
|
|
595
|
+
max_attempts=_MAX_ATTEMPTS,
|
|
596
|
+
)(callback)
|
|
597
|
+
_registered_subscribers[key] = subscriber
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def install_vercel_django_task_integration(
|
|
601
|
+
backend_alias: str = "default",
|
|
602
|
+
*,
|
|
603
|
+
register_queues: bool = True,
|
|
604
|
+
) -> None:
|
|
605
|
+
"""Install the default backend when needed and register its queue subscribers."""
|
|
606
|
+
_configure_default_task_backend()
|
|
607
|
+
if register_queues:
|
|
608
|
+
_register_task_queues(backend_alias)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _configure_default_task_backend() -> None:
|
|
612
|
+
global_settings.TASKS[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND)
|
|
613
|
+
if not settings.configured:
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
configured_backends = settings.TASKS
|
|
617
|
+
if settings.is_overridden("TASKS") and configured_backends:
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
configured_backends[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND)
|
|
621
|
+
task_backends.settings[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND)
|
|
622
|
+
|
|
623
|
+
connections: Any = getattr(task_backends, "_connections") # noqa: B009
|
|
624
|
+
if hasattr(connections, DEFAULT_TASK_BACKEND_ALIAS):
|
|
625
|
+
existing_backend = getattr(connections, DEFAULT_TASK_BACKEND_ALIAS)
|
|
626
|
+
if not isinstance(existing_backend, VercelQueuesBackend):
|
|
627
|
+
close = getattr(existing_backend, "close", None)
|
|
628
|
+
if close is not None:
|
|
629
|
+
close()
|
|
630
|
+
delattr(connections, DEFAULT_TASK_BACKEND_ALIAS)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|