pydocket 0.0.1__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 pydocket might be problematic. Click here for more details.
- docket/__init__.py +24 -0
- docket/__main__.py +3 -0
- docket/cli.py +23 -0
- docket/dependencies.py +77 -0
- docket/docket.py +178 -0
- docket/execution.py +47 -0
- docket/py.typed +0 -0
- docket/worker.py +244 -0
- pydocket-0.0.1.dist-info/METADATA +31 -0
- pydocket-0.0.1.dist-info/RECORD +13 -0
- pydocket-0.0.1.dist-info/WHEEL +4 -0
- pydocket-0.0.1.dist-info/entry_points.txt +2 -0
- pydocket-0.0.1.dist-info/licenses/LICENSE +9 -0
docket/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
docket - A distributed background task system for Python functions.
|
|
3
|
+
|
|
4
|
+
docket focuses on scheduling future work as seamlessly and efficiently as immediate work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
|
|
9
|
+
__version__ = version("pydocket")
|
|
10
|
+
|
|
11
|
+
from .dependencies import CurrentDocket, CurrentWorker, Retry
|
|
12
|
+
from .docket import Docket
|
|
13
|
+
from .execution import Execution
|
|
14
|
+
from .worker import Worker
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Docket",
|
|
18
|
+
"Worker",
|
|
19
|
+
"Execution",
|
|
20
|
+
"CurrentDocket",
|
|
21
|
+
"CurrentWorker",
|
|
22
|
+
"Retry",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
docket/__main__.py
ADDED
docket/cli.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from docket import __version__
|
|
4
|
+
|
|
5
|
+
app: typer.Typer = typer.Typer(
|
|
6
|
+
help="Docket - A distributed background task system for Python functions",
|
|
7
|
+
add_completion=True,
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command(
|
|
13
|
+
help="Start a worker to process tasks",
|
|
14
|
+
)
|
|
15
|
+
def worker() -> None:
|
|
16
|
+
print("TODO: start the worker")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command(
|
|
20
|
+
help="Print the version of Docket",
|
|
21
|
+
)
|
|
22
|
+
def version() -> None:
|
|
23
|
+
print(__version__)
|
docket/dependencies.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import inspect
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Any, Awaitable, Callable, Counter, cast
|
|
5
|
+
|
|
6
|
+
from .docket import Docket
|
|
7
|
+
from .execution import Execution
|
|
8
|
+
from .worker import Worker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Dependency(abc.ABC):
|
|
12
|
+
single: bool = False
|
|
13
|
+
|
|
14
|
+
@abc.abstractmethod
|
|
15
|
+
def __call__(
|
|
16
|
+
self, docket: Docket, worker: Worker, execution: Execution
|
|
17
|
+
) -> Any: ... # pragma: no cover
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _CurrentWorker(Dependency):
|
|
21
|
+
def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> Worker:
|
|
22
|
+
return worker
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def CurrentWorker() -> Worker:
|
|
26
|
+
return cast(Worker, _CurrentWorker())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _CurrentDocket(Dependency):
|
|
30
|
+
def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> Docket:
|
|
31
|
+
return docket
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def CurrentDocket() -> Docket:
|
|
35
|
+
return cast(Docket, _CurrentDocket())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Retry(Dependency):
|
|
39
|
+
single: bool = True
|
|
40
|
+
|
|
41
|
+
def __init__(self, attempts: int = 1, delay: timedelta = timedelta(0)) -> None:
|
|
42
|
+
self.attempts = attempts
|
|
43
|
+
self.delay = delay
|
|
44
|
+
self.attempt = 1
|
|
45
|
+
|
|
46
|
+
def __call__(self, docket: Docket, worker: Worker, execution: Execution) -> "Retry":
|
|
47
|
+
retry = Retry(attempts=self.attempts, delay=self.delay)
|
|
48
|
+
retry.attempt = execution.attempt
|
|
49
|
+
return retry
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_dependency_parameters(
|
|
53
|
+
function: Callable[..., Awaitable[Any]],
|
|
54
|
+
) -> dict[str, Dependency]:
|
|
55
|
+
dependencies: dict[str, Any] = {}
|
|
56
|
+
|
|
57
|
+
signature = inspect.signature(function)
|
|
58
|
+
|
|
59
|
+
for param_name, param in signature.parameters.items():
|
|
60
|
+
if not isinstance(param.default, Dependency):
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
dependencies[param_name] = param.default
|
|
64
|
+
|
|
65
|
+
return dependencies
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_dependencies(function: Callable[..., Awaitable[Any]]) -> None:
|
|
69
|
+
parameters = get_dependency_parameters(function)
|
|
70
|
+
|
|
71
|
+
counts = Counter(type(dependency) for dependency in parameters.values())
|
|
72
|
+
|
|
73
|
+
for dependency_type, count in counts.items():
|
|
74
|
+
if dependency_type.single and count > 1:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Only one {dependency_type.__name__} dependency is allowed per task"
|
|
77
|
+
)
|
docket/docket.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import (
|
|
5
|
+
Any,
|
|
6
|
+
AsyncGenerator,
|
|
7
|
+
Awaitable,
|
|
8
|
+
Callable,
|
|
9
|
+
ParamSpec,
|
|
10
|
+
Self,
|
|
11
|
+
TypeVar,
|
|
12
|
+
overload,
|
|
13
|
+
)
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from redis.asyncio import Redis
|
|
17
|
+
|
|
18
|
+
from .execution import Execution
|
|
19
|
+
|
|
20
|
+
P = ParamSpec("P")
|
|
21
|
+
R = TypeVar("R")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Docket:
|
|
25
|
+
tasks: dict[str, Callable[..., Awaitable[Any]]]
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
name: str = "docket",
|
|
30
|
+
host: str = "localhost",
|
|
31
|
+
port: int = 6379,
|
|
32
|
+
db: int = 0,
|
|
33
|
+
password: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.name = name
|
|
36
|
+
self.host = host
|
|
37
|
+
self.port = port
|
|
38
|
+
self.db = db
|
|
39
|
+
self.password = password
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self) -> Self:
|
|
42
|
+
self.tasks = {}
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
async def __aexit__(
|
|
46
|
+
self,
|
|
47
|
+
exc_type: type[BaseException] | None,
|
|
48
|
+
exc_value: BaseException | None,
|
|
49
|
+
traceback: TracebackType | None,
|
|
50
|
+
) -> None:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@asynccontextmanager
|
|
54
|
+
async def redis(self) -> AsyncGenerator[Redis, None]:
|
|
55
|
+
async with Redis(
|
|
56
|
+
host=self.host,
|
|
57
|
+
port=self.port,
|
|
58
|
+
db=self.db,
|
|
59
|
+
password=self.password,
|
|
60
|
+
single_connection_client=True,
|
|
61
|
+
) as redis:
|
|
62
|
+
yield redis
|
|
63
|
+
|
|
64
|
+
def register(self, function: Callable[..., Awaitable[Any]]) -> None:
|
|
65
|
+
from .dependencies import validate_dependencies
|
|
66
|
+
|
|
67
|
+
validate_dependencies(function)
|
|
68
|
+
|
|
69
|
+
self.tasks[function.__name__] = function
|
|
70
|
+
|
|
71
|
+
@overload
|
|
72
|
+
def add(
|
|
73
|
+
self,
|
|
74
|
+
function: Callable[P, Awaitable[R]],
|
|
75
|
+
when: datetime | None = None,
|
|
76
|
+
key: str | None = None,
|
|
77
|
+
) -> Callable[P, Awaitable[Execution]]: ... # pragma: no cover
|
|
78
|
+
|
|
79
|
+
@overload
|
|
80
|
+
def add(
|
|
81
|
+
self,
|
|
82
|
+
function: str,
|
|
83
|
+
when: datetime | None = None,
|
|
84
|
+
key: str | None = None,
|
|
85
|
+
) -> Callable[..., Awaitable[Execution]]: ... # pragma: no cover
|
|
86
|
+
|
|
87
|
+
def add(
|
|
88
|
+
self,
|
|
89
|
+
function: Callable[P, Awaitable[R]] | str,
|
|
90
|
+
when: datetime | None = None,
|
|
91
|
+
key: str | None = None,
|
|
92
|
+
) -> Callable[..., Awaitable[Execution]]:
|
|
93
|
+
if isinstance(function, str):
|
|
94
|
+
function = self.tasks[function]
|
|
95
|
+
else:
|
|
96
|
+
self.register(function)
|
|
97
|
+
|
|
98
|
+
if when is None:
|
|
99
|
+
when = datetime.now(timezone.utc)
|
|
100
|
+
|
|
101
|
+
if key is None:
|
|
102
|
+
key = f"{function.__name__}:{uuid4()}"
|
|
103
|
+
|
|
104
|
+
async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
|
|
105
|
+
execution = Execution(function, args, kwargs, when, key, attempt=1)
|
|
106
|
+
await self.schedule(execution)
|
|
107
|
+
return execution
|
|
108
|
+
|
|
109
|
+
return scheduler
|
|
110
|
+
|
|
111
|
+
@overload
|
|
112
|
+
def replace(
|
|
113
|
+
self,
|
|
114
|
+
function: Callable[P, Awaitable[R]],
|
|
115
|
+
when: datetime,
|
|
116
|
+
key: str,
|
|
117
|
+
) -> Callable[P, Awaitable[Execution]]: ... # pragma: no cover
|
|
118
|
+
|
|
119
|
+
@overload
|
|
120
|
+
def replace(
|
|
121
|
+
self,
|
|
122
|
+
function: str,
|
|
123
|
+
when: datetime,
|
|
124
|
+
key: str,
|
|
125
|
+
) -> Callable[..., Awaitable[Execution]]: ... # pragma: no cover
|
|
126
|
+
|
|
127
|
+
def replace(
|
|
128
|
+
self,
|
|
129
|
+
function: Callable[P, Awaitable[R]] | str,
|
|
130
|
+
when: datetime,
|
|
131
|
+
key: str,
|
|
132
|
+
) -> Callable[..., Awaitable[Execution]]:
|
|
133
|
+
if isinstance(function, str):
|
|
134
|
+
function = self.tasks[function]
|
|
135
|
+
|
|
136
|
+
async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
|
|
137
|
+
execution = Execution(function, args, kwargs, when, key, attempt=1)
|
|
138
|
+
await self.cancel(key)
|
|
139
|
+
await self.schedule(execution)
|
|
140
|
+
return execution
|
|
141
|
+
|
|
142
|
+
return scheduler
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def queue_key(self) -> str:
|
|
146
|
+
return f"{self.name}:queue"
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def stream_key(self) -> str:
|
|
150
|
+
return f"{self.name}:stream"
|
|
151
|
+
|
|
152
|
+
def parked_task_key(self, key: str) -> str:
|
|
153
|
+
return f"{self.name}:{key}"
|
|
154
|
+
|
|
155
|
+
async def schedule(self, execution: Execution) -> None:
|
|
156
|
+
message: dict[bytes, bytes] = execution.as_message()
|
|
157
|
+
key = execution.key
|
|
158
|
+
when = execution.when
|
|
159
|
+
|
|
160
|
+
async with self.redis() as redis:
|
|
161
|
+
# if the task is already in the queue, retain it
|
|
162
|
+
if await redis.zscore(self.queue_key, key) is not None:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if when <= datetime.now(timezone.utc):
|
|
166
|
+
await redis.xadd(self.stream_key, message)
|
|
167
|
+
else:
|
|
168
|
+
async with redis.pipeline() as pipe:
|
|
169
|
+
pipe.hset(self.parked_task_key(key), mapping=message)
|
|
170
|
+
pipe.zadd(self.queue_key, {key: when.timestamp()})
|
|
171
|
+
await pipe.execute()
|
|
172
|
+
|
|
173
|
+
async def cancel(self, key: str) -> None:
|
|
174
|
+
async with self.redis() as redis:
|
|
175
|
+
async with redis.pipeline() as pipe:
|
|
176
|
+
pipe.delete(self.parked_task_key(key))
|
|
177
|
+
pipe.zrem(self.queue_key, key)
|
|
178
|
+
await pipe.execute()
|
docket/execution.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Awaitable, Callable, Self
|
|
3
|
+
|
|
4
|
+
import cloudpickle
|
|
5
|
+
|
|
6
|
+
Message = dict[bytes, bytes]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Execution:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
function: Callable[..., Awaitable[Any]],
|
|
13
|
+
args: tuple[Any, ...],
|
|
14
|
+
kwargs: dict[str, Any],
|
|
15
|
+
when: datetime,
|
|
16
|
+
key: str,
|
|
17
|
+
attempt: int,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.function = function
|
|
20
|
+
self.args = args
|
|
21
|
+
self.kwargs = kwargs
|
|
22
|
+
self.when = when
|
|
23
|
+
self.key = key
|
|
24
|
+
self.attempt = attempt
|
|
25
|
+
|
|
26
|
+
def as_message(self) -> Message:
|
|
27
|
+
return {
|
|
28
|
+
b"key": self.key.encode(),
|
|
29
|
+
b"when": self.when.isoformat().encode(),
|
|
30
|
+
b"function": self.function.__name__.encode(),
|
|
31
|
+
b"args": cloudpickle.dumps(self.args),
|
|
32
|
+
b"kwargs": cloudpickle.dumps(self.kwargs),
|
|
33
|
+
b"attempt": str(self.attempt).encode(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_message(
|
|
38
|
+
cls, function: Callable[..., Awaitable[Any]], message: Message
|
|
39
|
+
) -> Self:
|
|
40
|
+
return cls(
|
|
41
|
+
function=function,
|
|
42
|
+
args=cloudpickle.loads(message[b"args"]),
|
|
43
|
+
kwargs=cloudpickle.loads(message[b"kwargs"]),
|
|
44
|
+
when=datetime.fromisoformat(message[b"when"].decode()),
|
|
45
|
+
key=message[b"key"].decode(),
|
|
46
|
+
attempt=int(message[b"attempt"].decode()),
|
|
47
|
+
)
|
docket/py.typed
ADDED
|
File without changes
|
docket/worker.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Protocol, Self, Sequence, TypeVar, cast
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from redis import RedisError
|
|
9
|
+
|
|
10
|
+
from .docket import Docket, Execution
|
|
11
|
+
|
|
12
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
RedisStreamID = bytes
|
|
15
|
+
RedisMessageID = bytes
|
|
16
|
+
RedisMessage = dict[bytes, bytes]
|
|
17
|
+
RedisStream = tuple[RedisStreamID, Sequence[tuple[RedisMessageID, RedisMessage]]]
|
|
18
|
+
RedisReadGroupResponse = Sequence[RedisStream]
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
from .dependencies import Dependency
|
|
22
|
+
|
|
23
|
+
D = TypeVar("D", bound="Dependency")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _stream_due_tasks(Protocol):
|
|
27
|
+
async def __call__(
|
|
28
|
+
self, keys: list[str], args: list[str | float]
|
|
29
|
+
) -> tuple[int, int]: ... # pragma: no cover
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Worker:
|
|
33
|
+
name: str
|
|
34
|
+
docket: Docket
|
|
35
|
+
|
|
36
|
+
prefetch_count: int = 10
|
|
37
|
+
|
|
38
|
+
def __init__(self, docket: Docket) -> None:
|
|
39
|
+
self.name = f"worker:{uuid4()}"
|
|
40
|
+
self.docket = docket
|
|
41
|
+
|
|
42
|
+
async def __aenter__(self) -> Self:
|
|
43
|
+
async with self.docket.redis() as redis:
|
|
44
|
+
try:
|
|
45
|
+
await redis.xgroup_create(
|
|
46
|
+
groupname=self.consumer_group_name,
|
|
47
|
+
name=self.docket.stream_key,
|
|
48
|
+
id="0-0",
|
|
49
|
+
mkstream=True,
|
|
50
|
+
)
|
|
51
|
+
except RedisError as e:
|
|
52
|
+
assert "BUSYGROUP" in repr(e)
|
|
53
|
+
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
async def __aexit__(
|
|
57
|
+
self,
|
|
58
|
+
exc_type: type[BaseException] | None,
|
|
59
|
+
exc_value: BaseException | None,
|
|
60
|
+
traceback: TracebackType | None,
|
|
61
|
+
) -> None:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def consumer_group_name(self) -> str:
|
|
66
|
+
return "docket"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def _log_context(self) -> dict[str, str]:
|
|
70
|
+
return {
|
|
71
|
+
"queue_key": self.docket.queue_key,
|
|
72
|
+
"stream_key": self.docket.stream_key,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async def run_until_current(self) -> None:
|
|
76
|
+
async with self.docket.redis() as redis:
|
|
77
|
+
stream_due_tasks: _stream_due_tasks = cast(
|
|
78
|
+
_stream_due_tasks,
|
|
79
|
+
redis.register_script(
|
|
80
|
+
# Lua script to atomically move scheduled tasks to the stream
|
|
81
|
+
# KEYS[1]: queue key (sorted set)
|
|
82
|
+
# KEYS[2]: stream key
|
|
83
|
+
# ARGV[1]: current timestamp
|
|
84
|
+
# ARGV[2]: docket name prefix
|
|
85
|
+
"""
|
|
86
|
+
local total_work = redis.call('ZCARD', KEYS[1])
|
|
87
|
+
local due_work = 0
|
|
88
|
+
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1])
|
|
89
|
+
|
|
90
|
+
for i, key in ipairs(tasks) do
|
|
91
|
+
local hash_key = ARGV[2] .. ":" .. key
|
|
92
|
+
local task_data = redis.call('HGETALL', hash_key)
|
|
93
|
+
|
|
94
|
+
if #task_data > 0 then
|
|
95
|
+
local task = {}
|
|
96
|
+
for j = 1, #task_data, 2 do
|
|
97
|
+
task[task_data[j]] = task_data[j+1]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
redis.call('XADD', KEYS[2], '*',
|
|
101
|
+
'key', task['key'],
|
|
102
|
+
'when', task['when'],
|
|
103
|
+
'function', task['function'],
|
|
104
|
+
'args', task['args'],
|
|
105
|
+
'kwargs', task['kwargs'],
|
|
106
|
+
'attempt', task['attempt']
|
|
107
|
+
)
|
|
108
|
+
redis.call('DEL', hash_key)
|
|
109
|
+
due_work = due_work + 1
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if due_work > 0 then
|
|
114
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
return {total_work, due_work}
|
|
118
|
+
"""
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
total_work, due_work = sys.maxsize, 0
|
|
123
|
+
while total_work:
|
|
124
|
+
now = datetime.now(timezone.utc)
|
|
125
|
+
total_work, due_work = await stream_due_tasks(
|
|
126
|
+
keys=[self.docket.queue_key, self.docket.stream_key],
|
|
127
|
+
args=[now.timestamp(), self.docket.name],
|
|
128
|
+
)
|
|
129
|
+
logger.info(
|
|
130
|
+
"Moved %d/%d due tasks from %s to %s",
|
|
131
|
+
due_work,
|
|
132
|
+
total_work,
|
|
133
|
+
self.docket.queue_key,
|
|
134
|
+
self.docket.stream_key,
|
|
135
|
+
extra=self._log_context,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
response: RedisReadGroupResponse = await redis.xreadgroup(
|
|
139
|
+
groupname=self.consumer_group_name,
|
|
140
|
+
consumername=self.name,
|
|
141
|
+
streams={self.docket.stream_key: ">"},
|
|
142
|
+
count=self.prefetch_count,
|
|
143
|
+
block=10,
|
|
144
|
+
)
|
|
145
|
+
for _, messages in response:
|
|
146
|
+
for message_id, message in messages:
|
|
147
|
+
await self._execute(message)
|
|
148
|
+
|
|
149
|
+
# When executing a task, there's always a chance that it was
|
|
150
|
+
# either retried or it scheduled another task, so let's give
|
|
151
|
+
# ourselves one more iteration of the loop to handle that.
|
|
152
|
+
total_work += 1
|
|
153
|
+
|
|
154
|
+
async with redis.pipeline() as pipe:
|
|
155
|
+
pipe.xack(
|
|
156
|
+
self.docket.stream_key,
|
|
157
|
+
self.consumer_group_name,
|
|
158
|
+
message_id,
|
|
159
|
+
)
|
|
160
|
+
pipe.xdel(
|
|
161
|
+
self.docket.stream_key,
|
|
162
|
+
message_id,
|
|
163
|
+
)
|
|
164
|
+
await pipe.execute()
|
|
165
|
+
|
|
166
|
+
async def _execute(self, message: RedisMessage) -> None:
|
|
167
|
+
execution = Execution.from_message(
|
|
168
|
+
self.docket.tasks[message[b"function"].decode()],
|
|
169
|
+
message,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
logger.info(
|
|
173
|
+
"Executing task %s with args %s and kwargs %s",
|
|
174
|
+
execution.key,
|
|
175
|
+
execution.args,
|
|
176
|
+
execution.kwargs,
|
|
177
|
+
extra={
|
|
178
|
+
**self._log_context,
|
|
179
|
+
"function": execution.function.__name__,
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
dependencies = self._get_dependencies(execution)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
await execution.function(
|
|
187
|
+
*execution.args,
|
|
188
|
+
**{
|
|
189
|
+
**execution.kwargs,
|
|
190
|
+
**dependencies,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
except Exception:
|
|
194
|
+
logger.exception(
|
|
195
|
+
"Error executing task %s",
|
|
196
|
+
execution.key,
|
|
197
|
+
extra=self._log_context,
|
|
198
|
+
)
|
|
199
|
+
await self._retry_if_requested(execution, dependencies)
|
|
200
|
+
|
|
201
|
+
def _get_dependencies(
|
|
202
|
+
self,
|
|
203
|
+
execution: Execution,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
from .dependencies import get_dependency_parameters
|
|
206
|
+
|
|
207
|
+
parameters = get_dependency_parameters(execution.function)
|
|
208
|
+
|
|
209
|
+
dependencies: dict[str, Any] = {}
|
|
210
|
+
|
|
211
|
+
for param_name, dependency in parameters.items():
|
|
212
|
+
# If the argument is already provided, skip it, which allows users to call
|
|
213
|
+
# the function directly with the arguments they want.
|
|
214
|
+
if param_name in execution.kwargs:
|
|
215
|
+
dependencies[param_name] = execution.kwargs[param_name]
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
dependencies[param_name] = dependency(self.docket, self, execution)
|
|
219
|
+
|
|
220
|
+
return dependencies
|
|
221
|
+
|
|
222
|
+
async def _retry_if_requested(
|
|
223
|
+
self,
|
|
224
|
+
execution: Execution,
|
|
225
|
+
dependencies: dict[str, Any],
|
|
226
|
+
) -> None:
|
|
227
|
+
from .dependencies import Retry
|
|
228
|
+
|
|
229
|
+
retries = [retry for retry in dependencies.values() if isinstance(retry, Retry)]
|
|
230
|
+
if not retries:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
retry = retries[0]
|
|
234
|
+
|
|
235
|
+
if execution.attempt < retry.attempts:
|
|
236
|
+
execution.when = datetime.now(timezone.utc) + retry.delay
|
|
237
|
+
execution.attempt += 1
|
|
238
|
+
await self.docket.schedule(execution)
|
|
239
|
+
else:
|
|
240
|
+
logger.error(
|
|
241
|
+
"Task %s failed after %d attempts",
|
|
242
|
+
execution.key,
|
|
243
|
+
retry.attempts,
|
|
244
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydocket
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A distributed background task system for Python functions
|
|
5
|
+
Project-URL: Homepage, https://github.com/chrisguidry/docket
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/chrisguidry/docket/issues
|
|
7
|
+
Author-email: Chris Guidry <guid@omg.lol>
|
|
8
|
+
License: # Released under MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 Chris Guidry.
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Classifier: Development Status :: 4 - Beta
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Requires-Dist: cloudpickle>=3.1.1
|
|
26
|
+
Requires-Dist: redis>=5.2.1
|
|
27
|
+
Requires-Dist: typer>=0.15.1
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
docket is a distributed background task system for Python functions with a focus
|
|
31
|
+
on the scheduling of future work as seamlessly and efficiency as immediate work.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
docket/__init__.py,sha256=0gzwWxJcDqU7nEZ4rHrWYk7WqEgW4Mz77E7rbjpyCHw,526
|
|
2
|
+
docket/__main__.py,sha256=Vkuh7aJ-Bl7QVpVbbkUksAd_hn05FiLmWbc-8kbhZQ4,34
|
|
3
|
+
docket/cli.py,sha256=ty8CirvLDvvOuOs7MHW0SYMjmbQq9rqUgPCq-HqW_6g,434
|
|
4
|
+
docket/dependencies.py,sha256=yd_sIv3y69Czo_DyHh3aNtLJGFOMjg8jjJ701QN04-Q,2124
|
|
5
|
+
docket/docket.py,sha256=sIgSvPUX4HG4EN_OxPSN7xCGu7DZkk5oZsuBVDp5XcU,4942
|
|
6
|
+
docket/execution.py,sha256=3HT1GOeg76RMILiq06bpDZITHrqjVQ_j9FU6fuY4jqw,1375
|
|
7
|
+
docket/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
docket/worker.py,sha256=N-nftCveJELKnICp1lgmzG5-r1QCYkGRxi8FcTjrLSc,8204
|
|
9
|
+
pydocket-0.0.1.dist-info/METADATA,sha256=IYrB8lUbawu5I9Buh6uVJ68kJh47GUfph3F1wxxN78w,2084
|
|
10
|
+
pydocket-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
11
|
+
pydocket-0.0.1.dist-info/entry_points.txt,sha256=4WOk1nUlBsUT5O3RyMci2ImuC5XFswuopElYcLHtD5k,47
|
|
12
|
+
pydocket-0.0.1.dist-info/licenses/LICENSE,sha256=YuVWU_ZXO0K_k2FG8xWKe5RGxV24AhJKTvQmKfqXuyk,1087
|
|
13
|
+
pydocket-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Released under MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Chris Guidry.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|