kuu 0.0.1__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.
- kuu-0.0.1/PKG-INFO +57 -0
- kuu-0.0.1/kuu/__init__.py +29 -0
- kuu-0.0.1/kuu/_import.py +140 -0
- kuu-0.0.1/kuu/_types.py +9 -0
- kuu-0.0.1/kuu/app.py +191 -0
- kuu-0.0.1/kuu/brokers/__init__.py +3 -0
- kuu-0.0.1/kuu/brokers/base.py +32 -0
- kuu-0.0.1/kuu/brokers/memory.py +142 -0
- kuu-0.0.1/kuu/brokers/nats.py +172 -0
- kuu-0.0.1/kuu/brokers/redis.py +206 -0
- kuu-0.0.1/kuu/cli.py +127 -0
- kuu-0.0.1/kuu/context.py +21 -0
- kuu-0.0.1/kuu/events.py +60 -0
- kuu-0.0.1/kuu/exceptions.py +23 -0
- kuu-0.0.1/kuu/handle.py +53 -0
- kuu-0.0.1/kuu/message.py +34 -0
- kuu-0.0.1/kuu/middleware/__init__.py +12 -0
- kuu-0.0.1/kuu/middleware/base.py +29 -0
- kuu-0.0.1/kuu/middleware/logging.py +37 -0
- kuu-0.0.1/kuu/middleware/retry.py +36 -0
- kuu-0.0.1/kuu/middleware/timeout.py +20 -0
- kuu-0.0.1/kuu/orchestrator/__init__.py +3 -0
- kuu-0.0.1/kuu/orchestrator/_worker.py +34 -0
- kuu-0.0.1/kuu/orchestrator/main.py +157 -0
- kuu-0.0.1/kuu/outcome.py +24 -0
- kuu-0.0.1/kuu/prometheus.py +179 -0
- kuu-0.0.1/kuu/registry.py +25 -0
- kuu-0.0.1/kuu/result.py +12 -0
- kuu-0.0.1/kuu/results/__init__.py +3 -0
- kuu-0.0.1/kuu/results/base.py +58 -0
- kuu-0.0.1/kuu/results/redis.py +57 -0
- kuu-0.0.1/kuu/scheduler/__init__.py +8 -0
- kuu-0.0.1/kuu/scheduler/job.py +54 -0
- kuu-0.0.1/kuu/scheduler/scheduler.py +259 -0
- kuu-0.0.1/kuu/serializers/__init__.py +11 -0
- kuu-0.0.1/kuu/serializers/base.py +21 -0
- kuu-0.0.1/kuu/serializers/json.py +53 -0
- kuu-0.0.1/kuu/serializers/msgpack.py +28 -0
- kuu-0.0.1/kuu/serializers/pickle.py +39 -0
- kuu-0.0.1/kuu/task.py +71 -0
- kuu-0.0.1/kuu/worker.py +201 -0
- kuu-0.0.1/pyproject.toml +89 -0
- kuu-0.0.1/readme.md +14 -0
kuu-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kuu
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: lightweight async distributed task queue
|
|
5
|
+
Keywords: apscheduler,message queue,queue,schedule tasks,scheduler,scheduling,task,task queue
|
|
6
|
+
Author: Alexey Pechenin
|
|
7
|
+
Author-email: Alexey Pechenin <me@pyrorhythm.dev>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Internet
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
23
|
+
Classifier: Topic :: System :: Networking
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Dist: anyio>=4.10,<5
|
|
26
|
+
Requires-Dist: croniter>=6.2
|
|
27
|
+
Requires-Dist: orjson>=3.10
|
|
28
|
+
Requires-Dist: pydantic>=2.12,<3
|
|
29
|
+
Requires-Dist: typer>=0.24.2
|
|
30
|
+
Requires-Dist: uvloop>=0.22.1
|
|
31
|
+
Requires-Dist: watchfiles>=1.1.1
|
|
32
|
+
Requires-Dist: msgspec>=0.21 ; extra == 'msgspec'
|
|
33
|
+
Requires-Dist: nats-py>=2.7 ; extra == 'nats'
|
|
34
|
+
Requires-Dist: prometheus-client>=0.20 ; extra == 'prometheus'
|
|
35
|
+
Requires-Dist: redis>=6.0,<8 ; extra == 'redis'
|
|
36
|
+
Requires-Python: >=3.12
|
|
37
|
+
Project-URL: homepage, https://github.com/pyrorhythm/kuu
|
|
38
|
+
Provides-Extra: msgspec
|
|
39
|
+
Provides-Extra: nats
|
|
40
|
+
Provides-Extra: prometheus
|
|
41
|
+
Provides-Extra: redis
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
|
|
44
|
+
# kuu
|
|
45
|
+
|
|
46
|
+
```shell
|
|
47
|
+
uv install kuu --prerelease=allow
|
|
48
|
+
|
|
49
|
+
# extras availible: msgspec, nats, prometheus, redis
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
a native distributed queue that is rather simple and easy-to-integrate in production
|
|
53
|
+
has task queue, redis xstream, nats jetstream support, result backend, middlewares and event/signals
|
|
54
|
+
also has a built-in scheduler yk
|
|
55
|
+
|
|
56
|
+
nothing serious in this project, just got tired of taskiq's undefined behaviour and `logging.getLogger("root")`
|
|
57
|
+
maybe will build a webui for it idk
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .app import Kuu
|
|
2
|
+
from .brokers.base import Broker, Delivery
|
|
3
|
+
from .context import Context
|
|
4
|
+
from .exceptions import NotConnected, RejectErr, RetryErr, TaskError
|
|
5
|
+
from .message import Message
|
|
6
|
+
from .middleware.base import Middleware
|
|
7
|
+
from .results.base import Result, ResultBackend
|
|
8
|
+
from .serializers import JSONSerializer, Serializer
|
|
9
|
+
from .handle import TaskHandle
|
|
10
|
+
from .task import Task
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Kuu",
|
|
14
|
+
"Task",
|
|
15
|
+
"TaskHandle",
|
|
16
|
+
"Message",
|
|
17
|
+
"Context",
|
|
18
|
+
"RetryErr",
|
|
19
|
+
"RejectErr",
|
|
20
|
+
"TaskError",
|
|
21
|
+
"NotConnected",
|
|
22
|
+
"Middleware",
|
|
23
|
+
"Broker",
|
|
24
|
+
"Delivery",
|
|
25
|
+
"Result",
|
|
26
|
+
"ResultBackend",
|
|
27
|
+
"Serializer",
|
|
28
|
+
"JSONSerializer",
|
|
29
|
+
]
|
kuu-0.0.1/kuu/_import.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Generator, Sequence
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@contextmanager
|
|
16
|
+
def add_cwd_in_path() -> Generator[None]:
|
|
17
|
+
"""
|
|
18
|
+
adds current directory in python path
|
|
19
|
+
|
|
20
|
+
this context manager adds current directory in sys.path,
|
|
21
|
+
so all python files are discoverable now, without installing
|
|
22
|
+
current project
|
|
23
|
+
|
|
24
|
+
:yield none
|
|
25
|
+
"""
|
|
26
|
+
cwd = Path.cwd()
|
|
27
|
+
if str(cwd) in sys.path:
|
|
28
|
+
yield
|
|
29
|
+
else:
|
|
30
|
+
logger.debug(f"inserting {cwd} in sys.path")
|
|
31
|
+
sys.path.insert(0, str(cwd))
|
|
32
|
+
try:
|
|
33
|
+
yield
|
|
34
|
+
finally:
|
|
35
|
+
try:
|
|
36
|
+
sys.path.remove(str(cwd))
|
|
37
|
+
except ValueError:
|
|
38
|
+
logger.warning(f"cannot remove '{cwd}' from sys.path")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def import_object(object_spec: str, app_dir: str | None = None) -> Any:
|
|
42
|
+
"""
|
|
43
|
+
it parses python object spec and imports it
|
|
44
|
+
|
|
45
|
+
:param object_spec: string in format like `package.module:variable`
|
|
46
|
+
:param app_dir: directory to add in sys.path for importing
|
|
47
|
+
:raises ValueError: if spec has unknown format
|
|
48
|
+
:returns imported broker:
|
|
49
|
+
"""
|
|
50
|
+
import_spec = object_spec.split(":")
|
|
51
|
+
if len(import_spec) != 2:
|
|
52
|
+
raise ValueError("you should provide object path in `module:variable` format.")
|
|
53
|
+
with add_cwd_in_path():
|
|
54
|
+
if app_dir:
|
|
55
|
+
sys.path.insert(0, app_dir)
|
|
56
|
+
module = import_module(import_spec[0])
|
|
57
|
+
return getattr(module, import_spec[1])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def import_from_modules(modules: list[str]) -> None:
|
|
61
|
+
"""
|
|
62
|
+
import all modules from modules variable
|
|
63
|
+
|
|
64
|
+
:param modules: list of modules.
|
|
65
|
+
"""
|
|
66
|
+
for module in modules:
|
|
67
|
+
try:
|
|
68
|
+
logger.info(f"importing tasks from module {module}")
|
|
69
|
+
with add_cwd_in_path():
|
|
70
|
+
import_module(module)
|
|
71
|
+
except ImportError as err:
|
|
72
|
+
logger.warning(f"cannot import {module}. Cause:")
|
|
73
|
+
logger.exception(err)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def import_tasks(modules: list[str], pattern: str | Sequence[str], fs_discover: bool) -> None:
|
|
77
|
+
"""
|
|
78
|
+
import tasks modules
|
|
79
|
+
|
|
80
|
+
this function is used to
|
|
81
|
+
import all tasks from modules
|
|
82
|
+
|
|
83
|
+
:param modules: list of modules to import
|
|
84
|
+
:param pattern: pattern of a file if fs_discover is true.
|
|
85
|
+
:param fs_discover: if true it will try to import modules
|
|
86
|
+
from filesystem.
|
|
87
|
+
"""
|
|
88
|
+
if fs_discover:
|
|
89
|
+
if isinstance(pattern, str):
|
|
90
|
+
pattern = (pattern,)
|
|
91
|
+
discovered_modules = set()
|
|
92
|
+
for glob_pattern in pattern:
|
|
93
|
+
for path in Path().glob(glob_pattern):
|
|
94
|
+
if path.is_file():
|
|
95
|
+
if path.suffix in (".py", ".pyc", ".pyd", ".so"):
|
|
96
|
+
# remove all suffixes
|
|
97
|
+
prefix = path.name.partition(".")[0]
|
|
98
|
+
discovered_modules.add(
|
|
99
|
+
str(path.with_name(prefix)).replace(os.path.sep, ".")
|
|
100
|
+
)
|
|
101
|
+
# ignore other files
|
|
102
|
+
else:
|
|
103
|
+
discovered_modules.add(str(path).replace(os.path.sep, "."))
|
|
104
|
+
|
|
105
|
+
modules.extend(list(discovered_modules))
|
|
106
|
+
import_from_modules(modules)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def object_fqn(obj: object) -> str:
|
|
110
|
+
if hasattr(obj, "__name__"):
|
|
111
|
+
return f"{obj.__module__}.{obj.__name__}"
|
|
112
|
+
return f"{obj.__module__}.{obj.__class__.__name__}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_type_fqn(arg: Any) -> str | None:
|
|
116
|
+
_resolved_module = ""
|
|
117
|
+
try:
|
|
118
|
+
_resolved_module = arg.__module__
|
|
119
|
+
except AttributeError:
|
|
120
|
+
if arg.__class__.__name__ in __builtins__:
|
|
121
|
+
_resolved_module = "builtins"
|
|
122
|
+
|
|
123
|
+
if _resolved_module == "":
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
return _resolved_module + ":" + arg.__class__.__name__
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_type_from_fqn(_result: str | bytes | None) -> Any:
|
|
130
|
+
_imported_type = None
|
|
131
|
+
if _result is None:
|
|
132
|
+
return _imported_type
|
|
133
|
+
|
|
134
|
+
_decoded_result = _result.decode() if isinstance(_result, bytes) else _result
|
|
135
|
+
try:
|
|
136
|
+
_imported_type = import_object(_decoded_result)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
logger.warning("{}", exc)
|
|
139
|
+
|
|
140
|
+
return _imported_type
|
kuu-0.0.1/kuu/_types.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Callable, Coroutine
|
|
2
|
+
|
|
3
|
+
if TYPE_CHECKING:
|
|
4
|
+
from kuu.task import Task
|
|
5
|
+
|
|
6
|
+
type _FnAsync[**P, R] = Callable[P, Coroutine[None, None, R]]
|
|
7
|
+
type _Fn[**P, R] = Callable[P, R] | _FnAsync[P, R]
|
|
8
|
+
type _FnSingle[P, R] = Callable[[P], R]
|
|
9
|
+
type _Wrap[**P, R] = _FnSingle[_Fn[P, R], Task[P, R]]
|
kuu-0.0.1/kuu/app.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, overload
|
|
6
|
+
|
|
7
|
+
from kuu._import import object_fqn
|
|
8
|
+
from kuu._types import _Fn, _FnAsync, _Wrap
|
|
9
|
+
from kuu.brokers.base import Broker
|
|
10
|
+
from kuu.context import Context
|
|
11
|
+
from kuu.events import Events
|
|
12
|
+
from kuu.handle import TaskHandle
|
|
13
|
+
from kuu.message import Message, Payload
|
|
14
|
+
from kuu.middleware.base import Middleware, run_chain
|
|
15
|
+
from kuu.registry import Registry
|
|
16
|
+
from kuu.results.base import ResultBackend
|
|
17
|
+
from kuu.serializers.base import Serializer
|
|
18
|
+
from kuu.serializers.json import JSONSerializer
|
|
19
|
+
from kuu.task import Task
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Kuu:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
broker: Broker,
|
|
26
|
+
default_queue: str = "default",
|
|
27
|
+
middleware: list[Middleware] | None = None,
|
|
28
|
+
results: ResultBackend | None = None,
|
|
29
|
+
serializer: Serializer = JSONSerializer(),
|
|
30
|
+
result_ttl: float = 86400,
|
|
31
|
+
result_replay: bool = True,
|
|
32
|
+
result_store_errors: bool = True,
|
|
33
|
+
):
|
|
34
|
+
"""Initialize task manager
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
broker (Broker): broker implementation to handle tasks
|
|
38
|
+
default_queue (str, optional): default queue to communicate through. Defaults to "default".
|
|
39
|
+
middleware (list[Middleware] | None, optional): middlewares. Defaults to None.
|
|
40
|
+
results (ResultBackend | None, optional): result backend implementation. Defaults to None.
|
|
41
|
+
serializer (Serializer, optional): serializer implementation. Defaults to JSONSerializer().
|
|
42
|
+
result_ttl (float, optional): ttl for storing results. Defaults to 86400.
|
|
43
|
+
result_replay (bool, optional): Defaults to True.
|
|
44
|
+
result_store_errors (bool, optional): Defaults to True.
|
|
45
|
+
"""
|
|
46
|
+
self.broker = broker
|
|
47
|
+
self.results = results
|
|
48
|
+
self.serializer = serializer
|
|
49
|
+
self.default_queue = default_queue
|
|
50
|
+
self.middleware: list[Middleware] = list(middleware or [])
|
|
51
|
+
self.registry = Registry()
|
|
52
|
+
self.events = Events()
|
|
53
|
+
self.result_ttl = result_ttl
|
|
54
|
+
self.result_replay = result_replay
|
|
55
|
+
self.result_store_errors = result_store_errors
|
|
56
|
+
|
|
57
|
+
@overload
|
|
58
|
+
def task[**P, R](
|
|
59
|
+
self,
|
|
60
|
+
name: str | None = ...,
|
|
61
|
+
/,
|
|
62
|
+
queue: str | None = ...,
|
|
63
|
+
max_attempts: int = ...,
|
|
64
|
+
timeout: float | None = ...,
|
|
65
|
+
blocking: bool = ...,
|
|
66
|
+
**labels: Any,
|
|
67
|
+
) -> _Wrap[P, R]: ...
|
|
68
|
+
|
|
69
|
+
@overload
|
|
70
|
+
def task[**P, R](self, func: _Fn[P, R] = ..., /) -> Task[P, R]: ...
|
|
71
|
+
|
|
72
|
+
def task[**P, R](
|
|
73
|
+
self,
|
|
74
|
+
name_or_func: str | _Fn[P, R] | None = None,
|
|
75
|
+
/,
|
|
76
|
+
queue: str | None = None,
|
|
77
|
+
max_attempts: int = 5,
|
|
78
|
+
timeout: float | None = None,
|
|
79
|
+
blocking: bool = False,
|
|
80
|
+
**labels: Any,
|
|
81
|
+
) -> _Wrap[P, R] | Task[P, R]:
|
|
82
|
+
|
|
83
|
+
def _get_wrap(
|
|
84
|
+
_name: str | None = None,
|
|
85
|
+
):
|
|
86
|
+
def _wrap(func: _Fn[P, R]) -> Task[P, R]:
|
|
87
|
+
t: Task[P, R] = Task(
|
|
88
|
+
manager=self,
|
|
89
|
+
original_func=func,
|
|
90
|
+
task_name=_name or object_fqn(func),
|
|
91
|
+
task_queue=queue or self.default_queue,
|
|
92
|
+
task_labels=labels,
|
|
93
|
+
max_attempts=max_attempts,
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
blocking=blocking,
|
|
96
|
+
)
|
|
97
|
+
self.registry.add(t)
|
|
98
|
+
return t
|
|
99
|
+
|
|
100
|
+
return _wrap
|
|
101
|
+
|
|
102
|
+
if inspect.isfunction(name_or_func):
|
|
103
|
+
func = name_or_func
|
|
104
|
+
return _get_wrap()(func)
|
|
105
|
+
|
|
106
|
+
name = name_or_func
|
|
107
|
+
return _get_wrap(name)
|
|
108
|
+
|
|
109
|
+
def _build_message(
|
|
110
|
+
self,
|
|
111
|
+
task_name: str,
|
|
112
|
+
task: Task | None,
|
|
113
|
+
args: Payload,
|
|
114
|
+
queue: str | None,
|
|
115
|
+
not_before: datetime | None,
|
|
116
|
+
headers: dict[str, str] | None,
|
|
117
|
+
max_attempts: int | None,
|
|
118
|
+
) -> Message:
|
|
119
|
+
return Message(
|
|
120
|
+
task=task_name,
|
|
121
|
+
queue=queue or (task.task_queue if task else self.default_queue),
|
|
122
|
+
payload=args,
|
|
123
|
+
headers=headers or {},
|
|
124
|
+
max_attempts=(
|
|
125
|
+
max_attempts if max_attempts is not None else (task.max_attempts if task else 5)
|
|
126
|
+
),
|
|
127
|
+
not_before=not_before,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def _dispatch(
|
|
131
|
+
self,
|
|
132
|
+
msg: Message,
|
|
133
|
+
task: Task[Any, Any] | None,
|
|
134
|
+
not_before: datetime | None,
|
|
135
|
+
) -> None:
|
|
136
|
+
ctx = Context(app=self, message=msg, phase="enqueue", task=task)
|
|
137
|
+
|
|
138
|
+
async def _terminal(_c: Context) -> None:
|
|
139
|
+
if not_before is not None and not_before > datetime.now(timezone.utc):
|
|
140
|
+
await self.broker.schedule(msg, not_before)
|
|
141
|
+
else:
|
|
142
|
+
await self.broker.enqueue(msg)
|
|
143
|
+
await self.events.task_enqueued.send(msg)
|
|
144
|
+
|
|
145
|
+
await run_chain(ctx, self.middleware, _terminal)
|
|
146
|
+
|
|
147
|
+
def _enqueue_task[**P, Res](
|
|
148
|
+
self,
|
|
149
|
+
task: Task[P, Res],
|
|
150
|
+
queue: str | None = None,
|
|
151
|
+
not_before: datetime | None = None,
|
|
152
|
+
headers: dict[str, str] | None = None,
|
|
153
|
+
max_attempts: int | None = None,
|
|
154
|
+
) -> _FnAsync[P, TaskHandle[Res]]:
|
|
155
|
+
async def _(*args: P.args, **kwargs: P.kwargs) -> TaskHandle[Res]:
|
|
156
|
+
payload = Payload(args=args, kwargs=kwargs)
|
|
157
|
+
msg = self._build_message(
|
|
158
|
+
task.task_name,
|
|
159
|
+
task,
|
|
160
|
+
payload,
|
|
161
|
+
queue=queue,
|
|
162
|
+
not_before=not_before,
|
|
163
|
+
headers=headers,
|
|
164
|
+
max_attempts=max_attempts,
|
|
165
|
+
)
|
|
166
|
+
await self._dispatch(msg, task, not_before)
|
|
167
|
+
return TaskHandle[Res](message=msg, app=self)
|
|
168
|
+
|
|
169
|
+
return _
|
|
170
|
+
|
|
171
|
+
async def enqueue_by_name(
|
|
172
|
+
self,
|
|
173
|
+
task: str,
|
|
174
|
+
args: Payload = Payload(),
|
|
175
|
+
queue: str | None = None,
|
|
176
|
+
not_before: datetime | None = None,
|
|
177
|
+
headers: dict[str, str] | None = None,
|
|
178
|
+
max_attempts: int | None = None,
|
|
179
|
+
) -> TaskHandle[Any]:
|
|
180
|
+
t = self.registry.get(task)
|
|
181
|
+
msg = self._build_message(
|
|
182
|
+
task,
|
|
183
|
+
t,
|
|
184
|
+
args,
|
|
185
|
+
queue=queue,
|
|
186
|
+
not_before=not_before,
|
|
187
|
+
headers=headers,
|
|
188
|
+
max_attempts=max_attempts,
|
|
189
|
+
)
|
|
190
|
+
await self._dispatch(msg, t, not_before)
|
|
191
|
+
return TaskHandle[Any](message=msg, app=self)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from ..message import Message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Delivery[Receipt]:
|
|
13
|
+
message: Message
|
|
14
|
+
receipt: Receipt
|
|
15
|
+
queue: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Broker(Protocol):
|
|
19
|
+
async def connect(self) -> None: ...
|
|
20
|
+
async def close(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
async def declare(self, queue: str) -> None: ...
|
|
23
|
+
|
|
24
|
+
async def enqueue(self, msg: Message) -> None: ...
|
|
25
|
+
async def schedule(self, msg: Message, not_before: datetime) -> None: ...
|
|
26
|
+
|
|
27
|
+
def consume(self, queues: list[str], prefetch: int) -> AsyncIterator[Delivery]: ...
|
|
28
|
+
|
|
29
|
+
async def ack(self, delivery: Delivery) -> None: ...
|
|
30
|
+
async def nack(
|
|
31
|
+
self, delivery: Delivery, requeue: bool = True, delay: float | None = None
|
|
32
|
+
) -> None: ...
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import heapq
|
|
4
|
+
import itertools
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
11
|
+
|
|
12
|
+
from ..message import Message
|
|
13
|
+
from .base import Broker, Delivery
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryReceipt(NamedTuple):
|
|
17
|
+
queue: str
|
|
18
|
+
seq: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Q(NamedTuple):
|
|
22
|
+
send: MemoryObjectSendStream[Delivery[MemoryReceipt]]
|
|
23
|
+
recv: MemoryObjectReceiveStream[Delivery[MemoryReceipt]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MemoryBroker(Broker):
|
|
27
|
+
def __init__(self, buffer: int = 1024, pump_interval: float = 0.05):
|
|
28
|
+
self.buffer = buffer
|
|
29
|
+
self.pump_interval = pump_interval
|
|
30
|
+
self._queues: dict[str, _Q] = {}
|
|
31
|
+
# (run_at_ts, seq, queue, message)
|
|
32
|
+
self._scheduled: list[tuple[float, int, str, Message]] = []
|
|
33
|
+
self._sched_lock = anyio.Lock()
|
|
34
|
+
self._sched_event = anyio.Event()
|
|
35
|
+
self._seq = itertools.count()
|
|
36
|
+
self._pending: dict[tuple[str, int], Message] = {}
|
|
37
|
+
|
|
38
|
+
async def connect(self) -> None:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
async def close(self) -> None:
|
|
42
|
+
for q in self._queues.values():
|
|
43
|
+
await q.send.aclose()
|
|
44
|
+
await q.recv.aclose()
|
|
45
|
+
self._queues.clear()
|
|
46
|
+
self._scheduled.clear()
|
|
47
|
+
self._pending.clear()
|
|
48
|
+
|
|
49
|
+
async def declare(self, queue: str) -> None:
|
|
50
|
+
if queue in self._queues:
|
|
51
|
+
return
|
|
52
|
+
send, recv = anyio.create_memory_object_stream[Delivery[MemoryReceipt]](self.buffer)
|
|
53
|
+
self._queues[queue] = _Q(send, recv)
|
|
54
|
+
|
|
55
|
+
def _now(self) -> float:
|
|
56
|
+
return datetime.now(timezone.utc).timestamp()
|
|
57
|
+
|
|
58
|
+
def _ts(self, dt: datetime) -> float:
|
|
59
|
+
return dt.replace(tzinfo=timezone.utc).timestamp() if dt.tzinfo is None else dt.timestamp()
|
|
60
|
+
|
|
61
|
+
async def _push(self, queue: str, msg: Message) -> None:
|
|
62
|
+
await self.declare(queue)
|
|
63
|
+
seq = next(self._seq)
|
|
64
|
+
receipt = MemoryReceipt(queue=queue, seq=seq)
|
|
65
|
+
self._pending[(queue, seq)] = msg
|
|
66
|
+
await self._queues[queue].send.send(Delivery(message=msg, receipt=receipt, queue=queue))
|
|
67
|
+
|
|
68
|
+
async def enqueue(self, msg: Message) -> None:
|
|
69
|
+
await self._push(msg.queue, msg)
|
|
70
|
+
|
|
71
|
+
async def schedule(self, msg: Message, not_before: datetime) -> None:
|
|
72
|
+
await self.declare(msg.queue)
|
|
73
|
+
async with self._sched_lock:
|
|
74
|
+
heapq.heappush(self._scheduled, (self._ts(not_before), next(self._seq), msg.queue, msg))
|
|
75
|
+
self._sched_event.set()
|
|
76
|
+
self._sched_event = anyio.Event()
|
|
77
|
+
|
|
78
|
+
async def _pump_scheduled(self) -> None:
|
|
79
|
+
while True:
|
|
80
|
+
async with self._sched_lock:
|
|
81
|
+
now = self._now()
|
|
82
|
+
due: list[tuple[str, Message]] = []
|
|
83
|
+
while self._scheduled and self._scheduled[0][0] <= now:
|
|
84
|
+
_, _, q, m = heapq.heappop(self._scheduled)
|
|
85
|
+
due.append((q, m))
|
|
86
|
+
for q, m in due:
|
|
87
|
+
await self._push(q, m)
|
|
88
|
+
await anyio.sleep(self.pump_interval)
|
|
89
|
+
|
|
90
|
+
async def consume(
|
|
91
|
+
self, queues: list[str], prefetch: int
|
|
92
|
+
) -> AsyncIterator[Delivery[MemoryReceipt]]:
|
|
93
|
+
del prefetch # in-memory has its own buffer
|
|
94
|
+
for q in queues:
|
|
95
|
+
await self.declare(q)
|
|
96
|
+
|
|
97
|
+
send, recv = anyio.create_memory_object_stream[Delivery[MemoryReceipt]](self.buffer)
|
|
98
|
+
|
|
99
|
+
async def _forward(q: str) -> None:
|
|
100
|
+
async for delivery in self._queues[q].recv.clone():
|
|
101
|
+
await send.send(delivery)
|
|
102
|
+
|
|
103
|
+
async with anyio.create_task_group() as tg:
|
|
104
|
+
tg.start_soon(self._pump_scheduled)
|
|
105
|
+
for q in queues:
|
|
106
|
+
tg.start_soon(_forward, q)
|
|
107
|
+
try:
|
|
108
|
+
async with recv:
|
|
109
|
+
async for delivery in recv:
|
|
110
|
+
yield delivery
|
|
111
|
+
finally:
|
|
112
|
+
tg.cancel_scope.cancel()
|
|
113
|
+
|
|
114
|
+
async def ack(self, delivery: Delivery) -> None:
|
|
115
|
+
match delivery.receipt:
|
|
116
|
+
case MemoryReceipt():
|
|
117
|
+
pass
|
|
118
|
+
case _:
|
|
119
|
+
return
|
|
120
|
+
self._pending.pop((delivery.receipt.queue, delivery.receipt.seq), None)
|
|
121
|
+
|
|
122
|
+
async def nack(
|
|
123
|
+
self,
|
|
124
|
+
delivery: Delivery,
|
|
125
|
+
requeue: bool = True,
|
|
126
|
+
delay: float | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
match delivery.receipt:
|
|
129
|
+
case MemoryReceipt():
|
|
130
|
+
pass
|
|
131
|
+
case _:
|
|
132
|
+
return
|
|
133
|
+
key = (delivery.receipt.queue, delivery.receipt.seq)
|
|
134
|
+
self._pending.pop(key, None)
|
|
135
|
+
if not requeue:
|
|
136
|
+
return
|
|
137
|
+
msg = delivery.message.model_copy(update={"attempt": delivery.message.attempt + 1})
|
|
138
|
+
if delay and delay > 0:
|
|
139
|
+
when = datetime.fromtimestamp(self._now() + delay, tz=timezone.utc)
|
|
140
|
+
await self.schedule(msg, when)
|
|
141
|
+
else:
|
|
142
|
+
await self._push(delivery.receipt.queue, msg)
|