pydocket 0.6.1__tar.gz → 0.6.3__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.
Potentially problematic release.
This version of pydocket might be problematic. Click here for more details.
- {pydocket-0.6.1 → pydocket-0.6.3}/PKG-INFO +2 -1
- {pydocket-0.6.1 → pydocket-0.6.3}/examples/common.py +0 -2
- {pydocket-0.6.1 → pydocket-0.6.3}/examples/find_and_flood.py +3 -1
- pydocket-0.6.3/examples/self_perpetuating.py +36 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/pyproject.toml +4 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/__init__.py +11 -9
- pydocket-0.6.3/src/docket/__main__.py +3 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/cli.py +8 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/dependencies.py +48 -1
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/docket.py +2 -2
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/execution.py +48 -4
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/worker.py +63 -35
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_dependencies.py +48 -1
- pydocket-0.6.3/tests/test_execution.py +63 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_fundamentals.py +152 -2
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_worker.py +28 -6
- {pydocket-0.6.1 → pydocket-0.6.3}/uv.lock +11 -0
- pydocket-0.6.1/src/docket/__main__.py +0 -3
- {pydocket-0.6.1 → pydocket-0.6.3}/.cursor/rules/general.mdc +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/.cursor/rules/python-style.mdc +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/.github/codecov.yml +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/.github/workflows/chaos.yml +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/.github/workflows/ci.yml +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/.github/workflows/publish.yml +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/.gitignore +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/.pre-commit-config.yaml +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/LICENSE +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/README.md +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/chaos/README.md +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/chaos/__init__.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/chaos/driver.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/chaos/producer.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/chaos/run +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/chaos/tasks.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/examples/__init__.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/annotations.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/instrumentation.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/py.typed +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/src/docket/tasks.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/telemetry/.gitignore +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/telemetry/start +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/telemetry/stop +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/__init__.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/__init__.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/conftest.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_module.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_parsing.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_snapshot.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_striking.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_tasks.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_version.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_worker.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/cli/test_workers.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/conftest.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_docket.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_instrumentation.py +0 -0
- {pydocket-0.6.1 → pydocket-0.6.3}/tests/test_striking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pydocket
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: A distributed background task system for Python functions
|
|
5
5
|
Project-URL: Homepage, https://github.com/chrisguidry/docket
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/chrisguidry/docket/issues
|
|
@@ -31,6 +31,7 @@ Requires-Dist: python-json-logger>=3.2.1
|
|
|
31
31
|
Requires-Dist: redis>=4.6
|
|
32
32
|
Requires-Dist: rich>=13.9.4
|
|
33
33
|
Requires-Dist: typer>=0.15.1
|
|
34
|
+
Requires-Dist: uuid7>=0.1.0
|
|
34
35
|
Description-Content-Type: text/markdown
|
|
35
36
|
|
|
36
37
|
Docket is a distributed background task system for Python functions with a focus
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import random
|
|
2
3
|
from datetime import timedelta
|
|
3
4
|
from logging import Logger, LoggerAdapter
|
|
4
5
|
from typing import Annotated
|
|
@@ -16,7 +17,7 @@ async def find(
|
|
|
16
17
|
perpetual: Perpetual = Perpetual(every=timedelta(seconds=3), automatic=True),
|
|
17
18
|
) -> None:
|
|
18
19
|
for i in range(1, 10 + 1):
|
|
19
|
-
await docket.add(flood
|
|
20
|
+
await docket.add(flood)(i)
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
async def flood(
|
|
@@ -24,6 +25,7 @@ async def flood(
|
|
|
24
25
|
logger: LoggerAdapter[Logger] = TaskLogger(),
|
|
25
26
|
) -> None:
|
|
26
27
|
logger.info("Working on %s", item)
|
|
28
|
+
await asyncio.sleep(random.uniform(0.5, 2))
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
tasks = [find, flood]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from logging import Logger, LoggerAdapter
|
|
5
|
+
|
|
6
|
+
from docket import Docket
|
|
7
|
+
from docket.dependencies import CurrentDocket, Perpetual, TaskLogger
|
|
8
|
+
|
|
9
|
+
from .common import run_example_workers
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def now() -> datetime:
|
|
13
|
+
return datetime.now(timezone.utc)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def bouncey(
|
|
17
|
+
docket: Docket = CurrentDocket(),
|
|
18
|
+
logger: LoggerAdapter[Logger] = TaskLogger(),
|
|
19
|
+
perpetual: Perpetual = Perpetual(every=timedelta(seconds=3), automatic=True),
|
|
20
|
+
) -> None:
|
|
21
|
+
seconds = random.randint(1, 10)
|
|
22
|
+
perpetual.every = timedelta(seconds=seconds)
|
|
23
|
+
logger.info("See you in %s seconds at %s", seconds, now() + perpetual.every)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
tasks = [bouncey]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
asyncio.run(
|
|
31
|
+
run_example_workers(
|
|
32
|
+
workers=3,
|
|
33
|
+
concurrency=8,
|
|
34
|
+
tasks="examples.self_perpetuating:tasks",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
@@ -28,6 +28,7 @@ dependencies = [
|
|
|
28
28
|
"redis>=4.6",
|
|
29
29
|
"rich>=13.9.4",
|
|
30
30
|
"typer>=0.15.1",
|
|
31
|
+
"uuid7>=0.1.0",
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
[dependency-groups]
|
|
@@ -64,6 +65,9 @@ source = "vcs"
|
|
|
64
65
|
[tool.hatch.build.targets.wheel]
|
|
65
66
|
packages = ["src/docket"]
|
|
66
67
|
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
target-version = "py312"
|
|
70
|
+
|
|
67
71
|
[tool.pytest.ini_options]
|
|
68
72
|
addopts = [
|
|
69
73
|
"--numprocesses=logical",
|
|
@@ -17,6 +17,7 @@ from .dependencies import (
|
|
|
17
17
|
ExponentialRetry,
|
|
18
18
|
Perpetual,
|
|
19
19
|
Retry,
|
|
20
|
+
TaskArgument,
|
|
20
21
|
TaskKey,
|
|
21
22
|
TaskLogger,
|
|
22
23
|
Timeout,
|
|
@@ -26,19 +27,20 @@ from .execution import Execution
|
|
|
26
27
|
from .worker import Worker
|
|
27
28
|
|
|
28
29
|
__all__ = [
|
|
29
|
-
"
|
|
30
|
-
"Worker",
|
|
31
|
-
"Execution",
|
|
30
|
+
"__version__",
|
|
32
31
|
"CurrentDocket",
|
|
33
|
-
"CurrentWorker",
|
|
34
32
|
"CurrentExecution",
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
33
|
+
"CurrentWorker",
|
|
34
|
+
"Depends",
|
|
35
|
+
"Docket",
|
|
36
|
+
"Execution",
|
|
38
37
|
"ExponentialRetry",
|
|
39
38
|
"Logged",
|
|
40
39
|
"Perpetual",
|
|
40
|
+
"Retry",
|
|
41
|
+
"TaskArgument",
|
|
42
|
+
"TaskKey",
|
|
43
|
+
"TaskLogger",
|
|
41
44
|
"Timeout",
|
|
42
|
-
"
|
|
43
|
-
"__version__",
|
|
45
|
+
"Worker",
|
|
44
46
|
]
|
|
@@ -245,6 +245,13 @@ def worker(
|
|
|
245
245
|
envvar="DOCKET_WORKER_SCHEDULING_RESOLUTION",
|
|
246
246
|
),
|
|
247
247
|
] = timedelta(milliseconds=250),
|
|
248
|
+
schedule_automatic_tasks: Annotated[
|
|
249
|
+
bool,
|
|
250
|
+
typer.Option(
|
|
251
|
+
"--schedule-automatic-tasks",
|
|
252
|
+
help="Schedule automatic tasks",
|
|
253
|
+
),
|
|
254
|
+
] = True,
|
|
248
255
|
until_finished: Annotated[
|
|
249
256
|
bool,
|
|
250
257
|
typer.Option(
|
|
@@ -270,6 +277,7 @@ def worker(
|
|
|
270
277
|
reconnection_delay=reconnection_delay,
|
|
271
278
|
minimum_check_interval=minimum_check_interval,
|
|
272
279
|
scheduling_resolution=scheduling_resolution,
|
|
280
|
+
schedule_automatic_tasks=schedule_automatic_tasks,
|
|
273
281
|
until_finished=until_finished,
|
|
274
282
|
metrics_port=metrics_port,
|
|
275
283
|
tasks=tasks,
|
|
@@ -79,6 +79,29 @@ def TaskKey() -> str:
|
|
|
79
79
|
return cast(str, _TaskKey())
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
class _TaskArgument(Dependency):
|
|
83
|
+
parameter: str | None
|
|
84
|
+
optional: bool
|
|
85
|
+
|
|
86
|
+
def __init__(self, parameter: str | None = None, optional: bool = False) -> None:
|
|
87
|
+
self.parameter = parameter
|
|
88
|
+
self.optional = optional
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> Any:
|
|
91
|
+
assert self.parameter is not None
|
|
92
|
+
execution = self.execution.get()
|
|
93
|
+
try:
|
|
94
|
+
return execution.get_argument(self.parameter)
|
|
95
|
+
except KeyError:
|
|
96
|
+
if self.optional:
|
|
97
|
+
return None
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def TaskArgument(parameter: str | None = None, optional: bool = False) -> Any:
|
|
102
|
+
return cast(Any, _TaskArgument(parameter, optional))
|
|
103
|
+
|
|
104
|
+
|
|
82
105
|
class _TaskLogger(Dependency):
|
|
83
106
|
async def __aenter__(self) -> logging.LoggerAdapter[logging.Logger]:
|
|
84
107
|
execution = self.execution.get()
|
|
@@ -275,6 +298,11 @@ class _Depends(Dependency, Generic[R]):
|
|
|
275
298
|
parameters = get_dependency_parameters(function)
|
|
276
299
|
|
|
277
300
|
for parameter, dependency in parameters.items():
|
|
301
|
+
# Special case for TaskArguments, they are "magical" and infer the parameter
|
|
302
|
+
# they refer to from the parameter name (unless otherwise specified)
|
|
303
|
+
if isinstance(dependency, _TaskArgument) and not dependency.parameter:
|
|
304
|
+
dependency.parameter = parameter
|
|
305
|
+
|
|
278
306
|
arguments[parameter] = await stack.enter_async_context(dependency)
|
|
279
307
|
|
|
280
308
|
return arguments
|
|
@@ -338,6 +366,12 @@ def validate_dependencies(function: TaskFunction) -> None:
|
|
|
338
366
|
)
|
|
339
367
|
|
|
340
368
|
|
|
369
|
+
class FailedDependency:
|
|
370
|
+
def __init__(self, parameter: str, error: Exception) -> None:
|
|
371
|
+
self.parameter = parameter
|
|
372
|
+
self.error = error
|
|
373
|
+
|
|
374
|
+
|
|
341
375
|
@asynccontextmanager
|
|
342
376
|
async def resolved_dependencies(
|
|
343
377
|
worker: "Worker", execution: Execution
|
|
@@ -361,6 +395,19 @@ async def resolved_dependencies(
|
|
|
361
395
|
arguments[parameter] = kwargs[parameter]
|
|
362
396
|
continue
|
|
363
397
|
|
|
364
|
-
|
|
398
|
+
# Special case for TaskArguments, they are "magical" and infer the parameter
|
|
399
|
+
# they refer to from the parameter name (unless otherwise specified). At
|
|
400
|
+
# the top-level task function call, it doesn't make sense to specify one
|
|
401
|
+
# _without_ a parameter name, so we'll call that a failed dependency.
|
|
402
|
+
if isinstance(dependency, _TaskArgument) and not dependency.parameter:
|
|
403
|
+
arguments[parameter] = FailedDependency(
|
|
404
|
+
parameter, ValueError("No parameter name specified")
|
|
405
|
+
)
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
arguments[parameter] = await stack.enter_async_context(dependency)
|
|
410
|
+
except Exception as error:
|
|
411
|
+
arguments[parameter] = FailedDependency(parameter, error)
|
|
365
412
|
|
|
366
413
|
yield arguments
|
|
@@ -23,12 +23,12 @@ from typing import (
|
|
|
23
23
|
cast,
|
|
24
24
|
overload,
|
|
25
25
|
)
|
|
26
|
-
from uuid import uuid4
|
|
27
26
|
|
|
28
27
|
import redis.exceptions
|
|
29
28
|
from opentelemetry import propagate, trace
|
|
30
29
|
from redis.asyncio import ConnectionPool, Redis
|
|
31
30
|
from redis.asyncio.client import Pipeline
|
|
31
|
+
from uuid_extensions import uuid7
|
|
32
32
|
|
|
33
33
|
from .execution import (
|
|
34
34
|
Execution,
|
|
@@ -254,7 +254,7 @@ class Docket:
|
|
|
254
254
|
when = datetime.now(timezone.utc)
|
|
255
255
|
|
|
256
256
|
if key is None:
|
|
257
|
-
key =
|
|
257
|
+
key = str(uuid7())
|
|
258
258
|
|
|
259
259
|
async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
|
|
260
260
|
execution = Execution(function, args, kwargs, when, key, attempt=1)
|
|
@@ -3,15 +3,23 @@ import enum
|
|
|
3
3
|
import inspect
|
|
4
4
|
import logging
|
|
5
5
|
from datetime import datetime
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
Awaitable,
|
|
9
|
+
Callable,
|
|
10
|
+
Hashable,
|
|
11
|
+
Literal,
|
|
12
|
+
Mapping,
|
|
13
|
+
Self,
|
|
14
|
+
cast,
|
|
15
|
+
)
|
|
7
16
|
|
|
8
17
|
import cloudpickle # type: ignore[import]
|
|
9
|
-
|
|
10
|
-
from opentelemetry import trace, propagate
|
|
11
18
|
import opentelemetry.context
|
|
19
|
+
from opentelemetry import propagate, trace
|
|
12
20
|
|
|
13
21
|
from .annotations import Logged
|
|
14
|
-
from
|
|
22
|
+
from .instrumentation import message_getter
|
|
15
23
|
|
|
16
24
|
logger: logging.Logger = logging.getLogger(__name__)
|
|
17
25
|
|
|
@@ -83,6 +91,11 @@ class Execution:
|
|
|
83
91
|
"docket.attempt": self.attempt,
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
def get_argument(self, parameter: str) -> Any:
|
|
95
|
+
signature = get_signature(self.function)
|
|
96
|
+
bound_args = signature.bind(*self.args, **self.kwargs)
|
|
97
|
+
return bound_args.arguments[parameter]
|
|
98
|
+
|
|
86
99
|
def call_repr(self) -> str:
|
|
87
100
|
arguments: list[str] = []
|
|
88
101
|
function_name = self.function.__name__
|
|
@@ -112,6 +125,37 @@ class Execution:
|
|
|
112
125
|
return [trace.Link(initiating_context)] if initiating_context.is_valid else []
|
|
113
126
|
|
|
114
127
|
|
|
128
|
+
def compact_signature(signature: inspect.Signature) -> str:
|
|
129
|
+
from .dependencies import Dependency
|
|
130
|
+
|
|
131
|
+
parameters: list[str] = []
|
|
132
|
+
dependencies: int = 0
|
|
133
|
+
|
|
134
|
+
for parameter in signature.parameters.values():
|
|
135
|
+
if isinstance(parameter.default, Dependency):
|
|
136
|
+
dependencies += 1
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
parameter_definition = parameter.name
|
|
140
|
+
if parameter.annotation is not parameter.empty:
|
|
141
|
+
annotation = parameter.annotation
|
|
142
|
+
if hasattr(annotation, "__origin__"):
|
|
143
|
+
annotation = annotation.__args__[0]
|
|
144
|
+
|
|
145
|
+
type_name = getattr(annotation, "__name__", str(annotation))
|
|
146
|
+
parameter_definition = f"{parameter.name}: {type_name}"
|
|
147
|
+
|
|
148
|
+
if parameter.default is not parameter.empty:
|
|
149
|
+
parameter_definition = f"{parameter_definition} = {parameter.default!r}"
|
|
150
|
+
|
|
151
|
+
parameters.append(parameter_definition)
|
|
152
|
+
|
|
153
|
+
if dependencies > 0:
|
|
154
|
+
parameters.append("...")
|
|
155
|
+
|
|
156
|
+
return ", ".join(parameters)
|
|
157
|
+
|
|
158
|
+
|
|
115
159
|
class Operator(enum.StrEnum):
|
|
116
160
|
EQUAL = "=="
|
|
117
161
|
NOT_EQUAL = "!="
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
3
5
|
import sys
|
|
4
6
|
import time
|
|
5
7
|
from datetime import datetime, timedelta, timezone
|
|
@@ -11,17 +13,15 @@ from typing import (
|
|
|
11
13
|
Self,
|
|
12
14
|
cast,
|
|
13
15
|
)
|
|
14
|
-
from uuid import uuid4
|
|
15
16
|
|
|
16
17
|
from opentelemetry import trace
|
|
17
18
|
from opentelemetry.trace import Tracer
|
|
18
19
|
from redis.asyncio import Redis
|
|
19
20
|
from redis.exceptions import ConnectionError, LockError
|
|
20
21
|
|
|
21
|
-
from docket.execution import get_signature
|
|
22
|
-
|
|
23
22
|
from .dependencies import (
|
|
24
23
|
Dependency,
|
|
24
|
+
FailedDependency,
|
|
25
25
|
Perpetual,
|
|
26
26
|
Retry,
|
|
27
27
|
Timeout,
|
|
@@ -36,6 +36,7 @@ from .docket import (
|
|
|
36
36
|
RedisMessageID,
|
|
37
37
|
RedisReadGroupResponse,
|
|
38
38
|
)
|
|
39
|
+
from .execution import compact_signature, get_signature
|
|
39
40
|
from .instrumentation import (
|
|
40
41
|
QUEUE_DEPTH,
|
|
41
42
|
REDIS_DISRUPTIONS,
|
|
@@ -71,6 +72,7 @@ class Worker:
|
|
|
71
72
|
reconnection_delay: timedelta
|
|
72
73
|
minimum_check_interval: timedelta
|
|
73
74
|
scheduling_resolution: timedelta
|
|
75
|
+
schedule_automatic_tasks: bool
|
|
74
76
|
|
|
75
77
|
def __init__(
|
|
76
78
|
self,
|
|
@@ -81,14 +83,16 @@ class Worker:
|
|
|
81
83
|
reconnection_delay: timedelta = timedelta(seconds=5),
|
|
82
84
|
minimum_check_interval: timedelta = timedelta(milliseconds=250),
|
|
83
85
|
scheduling_resolution: timedelta = timedelta(milliseconds=250),
|
|
86
|
+
schedule_automatic_tasks: bool = True,
|
|
84
87
|
) -> None:
|
|
85
88
|
self.docket = docket
|
|
86
|
-
self.name = name or f"
|
|
89
|
+
self.name = name or f"{socket.gethostname()}#{os.getpid()}"
|
|
87
90
|
self.concurrency = concurrency
|
|
88
91
|
self.redelivery_timeout = redelivery_timeout
|
|
89
92
|
self.reconnection_delay = reconnection_delay
|
|
90
93
|
self.minimum_check_interval = minimum_check_interval
|
|
91
94
|
self.scheduling_resolution = scheduling_resolution
|
|
95
|
+
self.schedule_automatic_tasks = schedule_automatic_tasks
|
|
92
96
|
|
|
93
97
|
async def __aenter__(self) -> Self:
|
|
94
98
|
self._heartbeat_task = asyncio.create_task(self._heartbeat())
|
|
@@ -134,6 +138,7 @@ class Worker:
|
|
|
134
138
|
reconnection_delay: timedelta = timedelta(seconds=5),
|
|
135
139
|
minimum_check_interval: timedelta = timedelta(milliseconds=100),
|
|
136
140
|
scheduling_resolution: timedelta = timedelta(milliseconds=250),
|
|
141
|
+
schedule_automatic_tasks: bool = True,
|
|
137
142
|
until_finished: bool = False,
|
|
138
143
|
metrics_port: int | None = None,
|
|
139
144
|
tasks: list[str] = ["docket.tasks:standard_tasks"],
|
|
@@ -151,6 +156,7 @@ class Worker:
|
|
|
151
156
|
reconnection_delay=reconnection_delay,
|
|
152
157
|
minimum_check_interval=minimum_check_interval,
|
|
153
158
|
scheduling_resolution=scheduling_resolution,
|
|
159
|
+
schedule_automatic_tasks=schedule_automatic_tasks,
|
|
154
160
|
) as worker:
|
|
155
161
|
if until_finished:
|
|
156
162
|
await worker.run_until_finished()
|
|
@@ -199,10 +205,7 @@ class Worker:
|
|
|
199
205
|
self._execution_counts = {}
|
|
200
206
|
|
|
201
207
|
async def _run(self, forever: bool = False) -> None:
|
|
202
|
-
|
|
203
|
-
for task_name, task in self.docket.tasks.items():
|
|
204
|
-
signature = get_signature(task)
|
|
205
|
-
logger.info("* %s%s", task_name, signature)
|
|
208
|
+
self._startup_log()
|
|
206
209
|
|
|
207
210
|
while True:
|
|
208
211
|
try:
|
|
@@ -220,7 +223,8 @@ class Worker:
|
|
|
220
223
|
async def _worker_loop(self, redis: Redis, forever: bool = False):
|
|
221
224
|
worker_stopping = asyncio.Event()
|
|
222
225
|
|
|
223
|
-
|
|
226
|
+
if self.schedule_automatic_tasks:
|
|
227
|
+
await self._schedule_all_automatic_perpetual_tasks()
|
|
224
228
|
|
|
225
229
|
scheduler_task = asyncio.create_task(
|
|
226
230
|
self._scheduler_loop(redis, worker_stopping)
|
|
@@ -499,6 +503,8 @@ class Worker:
|
|
|
499
503
|
arrow = "↬" if execution.attempt > 1 else "↪"
|
|
500
504
|
logger.info("%s [%s] %s", arrow, ms(punctuality), call, extra=log_context)
|
|
501
505
|
|
|
506
|
+
dependencies: dict[str, Dependency] = {}
|
|
507
|
+
|
|
502
508
|
with tracer.start_as_current_span(
|
|
503
509
|
execution.function.__name__,
|
|
504
510
|
kind=trace.SpanKind.CONSUMER,
|
|
@@ -509,17 +515,34 @@ class Worker:
|
|
|
509
515
|
},
|
|
510
516
|
links=execution.incoming_span_links(),
|
|
511
517
|
):
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
518
|
+
try:
|
|
519
|
+
async with resolved_dependencies(self, execution) as dependencies:
|
|
520
|
+
# Preemptively reschedule the perpetual task for the future, or clear
|
|
521
|
+
# the known task key for this task
|
|
522
|
+
rescheduled = await self._perpetuate_if_requested(
|
|
523
|
+
execution, dependencies
|
|
524
|
+
)
|
|
525
|
+
if not rescheduled:
|
|
526
|
+
async with self.docket.redis() as redis:
|
|
527
|
+
await self._delete_known_task(redis, execution)
|
|
528
|
+
|
|
529
|
+
dependency_failures = {
|
|
530
|
+
k: v
|
|
531
|
+
for k, v in dependencies.items()
|
|
532
|
+
if isinstance(v, FailedDependency)
|
|
533
|
+
}
|
|
534
|
+
if dependency_failures:
|
|
535
|
+
raise ExceptionGroup(
|
|
536
|
+
(
|
|
537
|
+
"Failed to resolve dependencies for parameter(s): "
|
|
538
|
+
+ ", ".join(dependency_failures.keys())
|
|
539
|
+
),
|
|
540
|
+
[
|
|
541
|
+
dependency.error
|
|
542
|
+
for dependency in dependency_failures.values()
|
|
543
|
+
],
|
|
544
|
+
)
|
|
521
545
|
|
|
522
|
-
try:
|
|
523
546
|
if timeout := get_single_dependency_of_type(dependencies, Timeout):
|
|
524
547
|
await self._run_function_with_timeout(
|
|
525
548
|
execution, dependencies, timeout
|
|
@@ -544,24 +567,24 @@ class Worker:
|
|
|
544
567
|
logger.info(
|
|
545
568
|
"%s [%s] %s", arrow, ms(duration), call, extra=log_context
|
|
546
569
|
)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
retried = await self._retry_if_requested(execution, dependencies)
|
|
552
|
-
if not retried:
|
|
553
|
-
retried = await self._perpetuate_if_requested(
|
|
554
|
-
execution, dependencies, timedelta(seconds=duration)
|
|
555
|
-
)
|
|
570
|
+
except Exception:
|
|
571
|
+
duration = log_context["duration"] = time.time() - start
|
|
572
|
+
TASKS_FAILED.add(1, counter_labels)
|
|
556
573
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
574
|
+
retried = await self._retry_if_requested(execution, dependencies)
|
|
575
|
+
if not retried:
|
|
576
|
+
retried = await self._perpetuate_if_requested(
|
|
577
|
+
execution, dependencies, timedelta(seconds=duration)
|
|
560
578
|
)
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
579
|
+
|
|
580
|
+
arrow = "↫" if retried else "↩"
|
|
581
|
+
logger.exception(
|
|
582
|
+
"%s [%s] %s", arrow, ms(duration), call, extra=log_context
|
|
583
|
+
)
|
|
584
|
+
finally:
|
|
585
|
+
TASKS_RUNNING.add(-1, counter_labels)
|
|
586
|
+
TASKS_COMPLETED.add(1, counter_labels)
|
|
587
|
+
TASK_DURATION.record(duration, counter_labels)
|
|
565
588
|
|
|
566
589
|
async def _run_function_with_timeout(
|
|
567
590
|
self,
|
|
@@ -641,6 +664,11 @@ class Worker:
|
|
|
641
664
|
|
|
642
665
|
return True
|
|
643
666
|
|
|
667
|
+
def _startup_log(self) -> None:
|
|
668
|
+
logger.info("Starting worker %r with the following tasks:", self.name)
|
|
669
|
+
for task_name, task in self.docket.tasks.items():
|
|
670
|
+
logger.info("* %s(%s)", task_name, compact_signature(get_signature(task)))
|
|
671
|
+
|
|
644
672
|
@property
|
|
645
673
|
def workers_set(self) -> str:
|
|
646
674
|
return self.docket.workers_set
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
2
4
|
|
|
3
5
|
from docket import CurrentDocket, CurrentWorker, Docket, Worker
|
|
4
|
-
from docket.dependencies import Retry
|
|
6
|
+
from docket.dependencies import Depends, Retry, TaskArgument
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
async def test_dependencies_may_be_duplicated(docket: Docket, worker: Worker):
|
|
@@ -91,3 +93,48 @@ async def test_user_provide_retries_are_used(docket: Docket, worker: Worker):
|
|
|
91
93
|
await worker.run_until_finished()
|
|
92
94
|
|
|
93
95
|
assert calls == 2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_dependencies_error_for_missing_task_argument(
|
|
99
|
+
docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
|
|
100
|
+
):
|
|
101
|
+
"""A task will fail when asking for a missing task argument"""
|
|
102
|
+
|
|
103
|
+
async def dependency_one(nope: list[str] = TaskArgument()) -> list[str]:
|
|
104
|
+
raise NotImplementedError("This should not be called") # pragma: no cover
|
|
105
|
+
|
|
106
|
+
async def dependent_task(
|
|
107
|
+
a: list[str],
|
|
108
|
+
b: list[str] = TaskArgument("a"),
|
|
109
|
+
c: list[str] = Depends(dependency_one),
|
|
110
|
+
) -> None:
|
|
111
|
+
raise NotImplementedError("This should not be called") # pragma: no cover
|
|
112
|
+
|
|
113
|
+
await docket.add(dependent_task)(a=["hello", "world"])
|
|
114
|
+
|
|
115
|
+
await worker.run_until_finished()
|
|
116
|
+
|
|
117
|
+
with caplog.at_level(logging.ERROR):
|
|
118
|
+
await worker.run_until_finished()
|
|
119
|
+
|
|
120
|
+
assert "Failed to resolve dependencies for parameter(s): c" in caplog.text
|
|
121
|
+
assert "ExceptionGroup" in caplog.text
|
|
122
|
+
assert "KeyError: 'nope'" in caplog.text
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_a_task_argument_cannot_ask_for_itself(
|
|
126
|
+
docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
|
|
127
|
+
):
|
|
128
|
+
"""A task argument cannot ask for itself"""
|
|
129
|
+
|
|
130
|
+
# This task would be nonsense, because it's asking for itself.
|
|
131
|
+
async def dependent_task(a: list[str] = TaskArgument()) -> None:
|
|
132
|
+
raise NotImplementedError("This should not be called") # pragma: no cover
|
|
133
|
+
|
|
134
|
+
await docket.add(dependent_task)()
|
|
135
|
+
|
|
136
|
+
with caplog.at_level(logging.ERROR):
|
|
137
|
+
await worker.run_until_finished()
|
|
138
|
+
|
|
139
|
+
assert "Failed to resolve dependencies for parameter(s): a" in caplog.text
|
|
140
|
+
assert "ValueError: No parameter name specified" in caplog.text
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from docket import Docket, Worker
|
|
6
|
+
from docket.annotations import Logged
|
|
7
|
+
from docket.dependencies import CurrentDocket, CurrentWorker, Depends
|
|
8
|
+
from docket.execution import TaskFunction, compact_signature, get_signature
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def no_args() -> None: ... # pragma: no cover
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def one_arg(a: str) -> None: ... # pragma: no cover
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def two_args(a: str, b: str) -> None: ... # pragma: no cover
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def optional_args(a: str, b: str, c: str = "c") -> None: ... # pragma: no cover
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def logged_args(
|
|
24
|
+
a: Annotated[str, Logged()],
|
|
25
|
+
b: Annotated[str, Logged()] = "foo",
|
|
26
|
+
) -> None: ... # pragma: no cover
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def a_dependency() -> str: ... # pragma: no cover
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def dependencies(
|
|
33
|
+
a: str,
|
|
34
|
+
b: int = 42,
|
|
35
|
+
c: str = Depends(a_dependency),
|
|
36
|
+
docket: Docket = CurrentDocket(),
|
|
37
|
+
worker: Worker = CurrentWorker(),
|
|
38
|
+
) -> None: ... # pragma: no cover
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def only_dependencies(
|
|
42
|
+
a: str = Depends(a_dependency),
|
|
43
|
+
docket: Docket = CurrentDocket(),
|
|
44
|
+
worker: Worker = CurrentWorker(),
|
|
45
|
+
) -> None: ... # pragma: no cover
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"function, expected",
|
|
50
|
+
[
|
|
51
|
+
(no_args, ""),
|
|
52
|
+
(one_arg, "a: str"),
|
|
53
|
+
(two_args, "a: str, b: str"),
|
|
54
|
+
(optional_args, "a: str, b: str, c: str = 'c'"),
|
|
55
|
+
(logged_args, "a: str, b: str = 'foo'"),
|
|
56
|
+
(dependencies, "a: str, b: int = 42, ..."),
|
|
57
|
+
(only_dependencies, "..."),
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
async def test_compact_signature(
|
|
61
|
+
docket: Docket, worker: Worker, function: TaskFunction, expected: str
|
|
62
|
+
):
|
|
63
|
+
assert compact_signature(get_signature(function)) == expected
|
|
@@ -27,6 +27,7 @@ from docket import (
|
|
|
27
27
|
Logged,
|
|
28
28
|
Perpetual,
|
|
29
29
|
Retry,
|
|
30
|
+
TaskArgument,
|
|
30
31
|
TaskKey,
|
|
31
32
|
TaskLogger,
|
|
32
33
|
Timeout,
|
|
@@ -150,10 +151,10 @@ async def test_rescheduling_by_name(
|
|
|
150
151
|
|
|
151
152
|
key = f"my-cool-task:{uuid4()}"
|
|
152
153
|
|
|
153
|
-
soon = now() + timedelta(milliseconds=
|
|
154
|
+
soon = now() + timedelta(milliseconds=100)
|
|
154
155
|
await docket.add(the_task, soon, key=key)("a", "b", c="c")
|
|
155
156
|
|
|
156
|
-
later = now() + timedelta(milliseconds=
|
|
157
|
+
later = now() + timedelta(milliseconds=200)
|
|
157
158
|
await docket.replace("the_task", later, key=key)("b", "c", c="d")
|
|
158
159
|
|
|
159
160
|
await worker.run_until_finished()
|
|
@@ -1383,3 +1384,152 @@ async def test_dependencies_can_ask_for_docket_dependencies(
|
|
|
1383
1384
|
await docket.add(dependent_task)()
|
|
1384
1385
|
|
|
1385
1386
|
await worker.run_until_finished()
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
async def test_dependency_failures_are_task_failures(
|
|
1390
|
+
docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
|
|
1391
|
+
):
|
|
1392
|
+
"""A task dependency failure will cause the task to fail"""
|
|
1393
|
+
|
|
1394
|
+
called: bool = False
|
|
1395
|
+
|
|
1396
|
+
async def dependency_one() -> str:
|
|
1397
|
+
raise ValueError("this one is bad")
|
|
1398
|
+
|
|
1399
|
+
async def dependency_two() -> str:
|
|
1400
|
+
raise ValueError("and so is this one")
|
|
1401
|
+
|
|
1402
|
+
async def dependent_task(
|
|
1403
|
+
a: str = Depends(dependency_one),
|
|
1404
|
+
b: str = Depends(dependency_two),
|
|
1405
|
+
) -> None:
|
|
1406
|
+
nonlocal called
|
|
1407
|
+
called = True # pragma: no cover
|
|
1408
|
+
|
|
1409
|
+
await docket.add(dependent_task)()
|
|
1410
|
+
|
|
1411
|
+
with caplog.at_level(logging.ERROR):
|
|
1412
|
+
await worker.run_until_finished()
|
|
1413
|
+
|
|
1414
|
+
assert not called
|
|
1415
|
+
|
|
1416
|
+
assert "Failed to resolve dependencies for parameter(s): a, b" in caplog.text
|
|
1417
|
+
assert "ValueError: this one is bad" in caplog.text
|
|
1418
|
+
assert "ValueError: and so is this one" in caplog.text
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
async def test_contextual_dependency_before_failures_are_task_failures(
|
|
1422
|
+
docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
|
|
1423
|
+
):
|
|
1424
|
+
"""A contextual task dependency failure will cause the task to fail"""
|
|
1425
|
+
|
|
1426
|
+
called: int = 0
|
|
1427
|
+
|
|
1428
|
+
@asynccontextmanager
|
|
1429
|
+
async def dependency_before() -> AsyncGenerator[str, None]:
|
|
1430
|
+
raise ValueError("this one is bad")
|
|
1431
|
+
yield "this won't be used" # pragma: no cover
|
|
1432
|
+
|
|
1433
|
+
async def dependent_task(
|
|
1434
|
+
a: str = Depends(dependency_before),
|
|
1435
|
+
) -> None:
|
|
1436
|
+
nonlocal called
|
|
1437
|
+
called += 1 # pragma: no cover
|
|
1438
|
+
|
|
1439
|
+
await docket.add(dependent_task)()
|
|
1440
|
+
|
|
1441
|
+
with caplog.at_level(logging.ERROR):
|
|
1442
|
+
await worker.run_until_finished()
|
|
1443
|
+
|
|
1444
|
+
assert not called
|
|
1445
|
+
|
|
1446
|
+
assert "Failed to resolve dependencies for parameter(s): a" in caplog.text
|
|
1447
|
+
assert "ValueError: this one is bad" in caplog.text
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
async def test_contextual_dependency_after_failures_are_task_failures(
|
|
1451
|
+
docket: Docket, worker: Worker, caplog: pytest.LogCaptureFixture
|
|
1452
|
+
):
|
|
1453
|
+
"""A contextual task dependency failure will cause the task to fail"""
|
|
1454
|
+
|
|
1455
|
+
called: int = 0
|
|
1456
|
+
|
|
1457
|
+
@asynccontextmanager
|
|
1458
|
+
async def dependency_after() -> AsyncGenerator[str, None]:
|
|
1459
|
+
yield "this will be used"
|
|
1460
|
+
raise ValueError("this one is bad")
|
|
1461
|
+
|
|
1462
|
+
async def dependent_task(
|
|
1463
|
+
a: str = Depends(dependency_after),
|
|
1464
|
+
) -> None:
|
|
1465
|
+
assert a == "this will be used"
|
|
1466
|
+
|
|
1467
|
+
nonlocal called
|
|
1468
|
+
called += 1
|
|
1469
|
+
|
|
1470
|
+
await docket.add(dependent_task)()
|
|
1471
|
+
|
|
1472
|
+
with caplog.at_level(logging.ERROR):
|
|
1473
|
+
await worker.run_until_finished()
|
|
1474
|
+
|
|
1475
|
+
assert called == 1
|
|
1476
|
+
|
|
1477
|
+
assert "ValueError: this one is bad" in caplog.text
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
async def test_dependencies_can_ask_for_task_arguments(docket: Docket, worker: Worker):
|
|
1481
|
+
"""A task dependency can ask for a task argument"""
|
|
1482
|
+
|
|
1483
|
+
called = 0
|
|
1484
|
+
|
|
1485
|
+
async def dependency_one(a: list[str] = TaskArgument()) -> list[str]:
|
|
1486
|
+
return a
|
|
1487
|
+
|
|
1488
|
+
async def dependency_two(another_name: list[str] = TaskArgument("a")) -> list[str]:
|
|
1489
|
+
return another_name
|
|
1490
|
+
|
|
1491
|
+
async def dependent_task(
|
|
1492
|
+
a: list[str],
|
|
1493
|
+
b: list[str] = TaskArgument("a"),
|
|
1494
|
+
c: list[str] = Depends(dependency_one),
|
|
1495
|
+
d: list[str] = Depends(dependency_two),
|
|
1496
|
+
) -> None:
|
|
1497
|
+
assert a is b
|
|
1498
|
+
assert a is c
|
|
1499
|
+
assert a is d
|
|
1500
|
+
|
|
1501
|
+
nonlocal called
|
|
1502
|
+
called += 1
|
|
1503
|
+
|
|
1504
|
+
await docket.add(dependent_task)(a=["hello", "world"])
|
|
1505
|
+
|
|
1506
|
+
await worker.run_until_finished()
|
|
1507
|
+
|
|
1508
|
+
assert called == 1
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
async def test_task_arguments_may_be_optional(docket: Docket, worker: Worker):
|
|
1512
|
+
"""A task dependency can ask for a task argument optionally"""
|
|
1513
|
+
|
|
1514
|
+
called = 0
|
|
1515
|
+
|
|
1516
|
+
async def dependency_one(
|
|
1517
|
+
a: list[str] | None = TaskArgument(optional=True),
|
|
1518
|
+
) -> list[str] | None:
|
|
1519
|
+
return a
|
|
1520
|
+
|
|
1521
|
+
async def dependent_task(
|
|
1522
|
+
not_a: list[str],
|
|
1523
|
+
b: list[str] | None = Depends(dependency_one),
|
|
1524
|
+
) -> None:
|
|
1525
|
+
assert not_a == ["hello", "world"]
|
|
1526
|
+
assert b is None
|
|
1527
|
+
|
|
1528
|
+
nonlocal called
|
|
1529
|
+
called += 1
|
|
1530
|
+
|
|
1531
|
+
await docket.add(dependent_task)(not_a=["hello", "world"])
|
|
1532
|
+
|
|
1533
|
+
await worker.run_until_finished()
|
|
1534
|
+
|
|
1535
|
+
assert called == 1
|
|
@@ -273,14 +273,14 @@ async def test_worker_announcements(
|
|
|
273
273
|
docket.register(the_task)
|
|
274
274
|
docket.register(another_task)
|
|
275
275
|
|
|
276
|
-
async with Worker(docket) as worker_a:
|
|
276
|
+
async with Worker(docket, name="worker-a") as worker_a:
|
|
277
277
|
await asyncio.sleep(heartbeat.total_seconds() * 5)
|
|
278
278
|
|
|
279
279
|
workers = await docket.workers()
|
|
280
280
|
assert len(workers) == 1
|
|
281
281
|
assert worker_a.name in {w.name for w in workers}
|
|
282
282
|
|
|
283
|
-
async with Worker(docket) as worker_b:
|
|
283
|
+
async with Worker(docket, name="worker-b") as worker_b:
|
|
284
284
|
await asyncio.sleep(heartbeat.total_seconds() * 5)
|
|
285
285
|
|
|
286
286
|
workers = await docket.workers()
|
|
@@ -315,14 +315,14 @@ async def test_task_announcements(
|
|
|
315
315
|
|
|
316
316
|
docket.register(the_task)
|
|
317
317
|
docket.register(another_task)
|
|
318
|
-
async with Worker(docket) as worker_a:
|
|
318
|
+
async with Worker(docket, name="worker-a") as worker_a:
|
|
319
319
|
await asyncio.sleep(heartbeat.total_seconds() * 5)
|
|
320
320
|
|
|
321
321
|
workers = await docket.task_workers("the_task")
|
|
322
322
|
assert len(workers) == 1
|
|
323
323
|
assert worker_a.name in {w.name for w in workers}
|
|
324
324
|
|
|
325
|
-
async with Worker(docket) as worker_b:
|
|
325
|
+
async with Worker(docket, name="worker-b") as worker_b:
|
|
326
326
|
await asyncio.sleep(heartbeat.total_seconds() * 5)
|
|
327
327
|
|
|
328
328
|
workers = await docket.task_workers("the_task")
|
|
@@ -422,13 +422,13 @@ async def test_perpetual_tasks_are_scheduled_close_to_target_time(
|
|
|
422
422
|
assert len(timestamps) == 8
|
|
423
423
|
|
|
424
424
|
intervals = [next - previous for previous, next in zip(timestamps, timestamps[1:])]
|
|
425
|
-
|
|
425
|
+
average = sum(intervals, timedelta(0)) / len(intervals)
|
|
426
426
|
|
|
427
427
|
debug = ", ".join([f"{i.total_seconds() * 1000:.2f}ms" for i in intervals])
|
|
428
428
|
|
|
429
429
|
# It's not reliable to assert the maximum duration on different machine setups, but
|
|
430
430
|
# we'll make sure that the minimum is observed (within 5ms), which is the guarantee
|
|
431
|
-
assert
|
|
431
|
+
assert average >= timedelta(milliseconds=50), debug
|
|
432
432
|
|
|
433
433
|
|
|
434
434
|
async def test_worker_can_exit_from_perpetual_tasks_that_queue_further_tasks(
|
|
@@ -491,3 +491,25 @@ def test_formatting_durations():
|
|
|
491
491
|
assert ms(1000.000) == " 1000s "
|
|
492
492
|
assert ms(10000.00) == " 10000s "
|
|
493
493
|
assert ms(100000.0) == "100000s "
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
async def test_worker_can_be_told_to_skip_automatic_tasks(docket: Docket):
|
|
497
|
+
"""A worker can be told to skip automatic tasks"""
|
|
498
|
+
|
|
499
|
+
called = False
|
|
500
|
+
|
|
501
|
+
async def perpetual_task(
|
|
502
|
+
perpetual: Perpetual = Perpetual(
|
|
503
|
+
every=timedelta(milliseconds=50), automatic=True
|
|
504
|
+
),
|
|
505
|
+
):
|
|
506
|
+
nonlocal called
|
|
507
|
+
called = True # pragma: no cover
|
|
508
|
+
|
|
509
|
+
docket.register(perpetual_task)
|
|
510
|
+
|
|
511
|
+
# Without the flag, this would hang because the task would always be scheduled
|
|
512
|
+
async with Worker(docket, schedule_automatic_tasks=False) as worker:
|
|
513
|
+
await worker.run_until_finished()
|
|
514
|
+
|
|
515
|
+
assert not called
|
|
@@ -723,6 +723,7 @@ dependencies = [
|
|
|
723
723
|
{ name = "redis" },
|
|
724
724
|
{ name = "rich" },
|
|
725
725
|
{ name = "typer" },
|
|
726
|
+
{ name = "uuid7" },
|
|
726
727
|
]
|
|
727
728
|
|
|
728
729
|
[package.dev-dependencies]
|
|
@@ -756,6 +757,7 @@ requires-dist = [
|
|
|
756
757
|
{ name = "redis", specifier = ">=4.6" },
|
|
757
758
|
{ name = "rich", specifier = ">=13.9.4" },
|
|
758
759
|
{ name = "typer", specifier = ">=0.15.1" },
|
|
760
|
+
{ name = "uuid7", specifier = ">=0.1.0" },
|
|
759
761
|
]
|
|
760
762
|
|
|
761
763
|
[package.metadata.requires-dev]
|
|
@@ -1029,6 +1031,15 @@ wheels = [
|
|
|
1029
1031
|
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
|
|
1030
1032
|
]
|
|
1031
1033
|
|
|
1034
|
+
[[package]]
|
|
1035
|
+
name = "uuid7"
|
|
1036
|
+
version = "0.1.0"
|
|
1037
|
+
source = { registry = "https://pypi.org/simple" }
|
|
1038
|
+
sdist = { url = "https://files.pythonhosted.org/packages/5c/19/7472bd526591e2192926247109dbf78692e709d3e56775792fec877a7720/uuid7-0.1.0.tar.gz", hash = "sha256:8c57aa32ee7456d3cc68c95c4530bc571646defac01895cfc73545449894a63c", size = 14052 }
|
|
1039
|
+
wheels = [
|
|
1040
|
+
{ url = "https://files.pythonhosted.org/packages/b5/77/8852f89a91453956582a85024d80ad96f30a41fed4c2b3dce0c9f12ecc7e/uuid7-0.1.0-py2.py3-none-any.whl", hash = "sha256:5e259bb63c8cb4aded5927ff41b444a80d0c7124e8a0ced7cf44efa1f5cccf61", size = 7477 },
|
|
1041
|
+
]
|
|
1042
|
+
|
|
1032
1043
|
[[package]]
|
|
1033
1044
|
name = "virtualenv"
|
|
1034
1045
|
version = "20.29.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|