pydocket 0.6.2__py3-none-any.whl → 0.6.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pydocket might be problematic. Click here for more details.
- docket/__main__.py +1 -1
- docket/annotations.py +23 -1
- docket/cli.py +10 -0
- docket/dependencies.py +173 -10
- docket/docket.py +107 -7
- docket/execution.py +43 -4
- docket/instrumentation.py +30 -4
- docket/worker.py +56 -35
- pydocket-0.6.4.dist-info/METADATA +139 -0
- pydocket-0.6.4.dist-info/RECORD +16 -0
- pydocket-0.6.2.dist-info/METADATA +0 -388
- pydocket-0.6.2.dist-info/RECORD +0 -16
- {pydocket-0.6.2.dist-info → pydocket-0.6.4.dist-info}/WHEEL +0 -0
- {pydocket-0.6.2.dist-info → pydocket-0.6.4.dist-info}/entry_points.txt +0 -0
- {pydocket-0.6.2.dist-info → pydocket-0.6.4.dist-info}/licenses/LICENSE +0 -0
docket/__main__.py
CHANGED
docket/annotations.py
CHANGED
|
@@ -34,7 +34,29 @@ class Annotation(abc.ABC):
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class Logged(Annotation):
|
|
37
|
-
"""Instructs docket to include arguments to this parameter in the log.
|
|
37
|
+
"""Instructs docket to include arguments to this parameter in the log.
|
|
38
|
+
|
|
39
|
+
If `length_only` is `True`, only the length of the argument will be included in
|
|
40
|
+
the log.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
@task
|
|
46
|
+
def setup_new_customer(
|
|
47
|
+
customer_id: Annotated[int, Logged],
|
|
48
|
+
addresses: Annotated[list[Address], Logged(length_only=True)],
|
|
49
|
+
password: str,
|
|
50
|
+
) -> None:
|
|
51
|
+
...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
In the logs, you's see the task referenced as:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
setup_new_customer(customer_id=123, addresses[len 2], password=...)
|
|
58
|
+
```
|
|
59
|
+
"""
|
|
38
60
|
|
|
39
61
|
length_only: bool = False
|
|
40
62
|
|
docket/cli.py
CHANGED
|
@@ -259,11 +259,20 @@ def worker(
|
|
|
259
259
|
help="Exit after the current docket is finished",
|
|
260
260
|
),
|
|
261
261
|
] = False,
|
|
262
|
+
healthcheck_port: Annotated[
|
|
263
|
+
int | None,
|
|
264
|
+
typer.Option(
|
|
265
|
+
"--healthcheck-port",
|
|
266
|
+
help="The port to serve a healthcheck on",
|
|
267
|
+
envvar="DOCKET_WORKER_HEALTHCHECK_PORT",
|
|
268
|
+
),
|
|
269
|
+
] = None,
|
|
262
270
|
metrics_port: Annotated[
|
|
263
271
|
int | None,
|
|
264
272
|
typer.Option(
|
|
265
273
|
"--metrics-port",
|
|
266
274
|
help="The port to serve Prometheus metrics on",
|
|
275
|
+
envvar="DOCKET_WORKER_METRICS_PORT",
|
|
267
276
|
),
|
|
268
277
|
] = None,
|
|
269
278
|
) -> None:
|
|
@@ -279,6 +288,7 @@ def worker(
|
|
|
279
288
|
scheduling_resolution=scheduling_resolution,
|
|
280
289
|
schedule_automatic_tasks=schedule_automatic_tasks,
|
|
281
290
|
until_finished=until_finished,
|
|
291
|
+
healthcheck_port=healthcheck_port,
|
|
282
292
|
metrics_port=metrics_port,
|
|
283
293
|
tasks=tasks,
|
|
284
294
|
)
|
docket/dependencies.py
CHANGED
|
@@ -49,6 +49,16 @@ class _CurrentWorker(Dependency):
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def CurrentWorker() -> "Worker":
|
|
52
|
+
"""A dependency to access the current Worker.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
@task
|
|
58
|
+
async def my_task(worker: Worker = CurrentWorker()) -> None:
|
|
59
|
+
assert isinstance(worker, Worker)
|
|
60
|
+
```
|
|
61
|
+
"""
|
|
52
62
|
return cast("Worker", _CurrentWorker())
|
|
53
63
|
|
|
54
64
|
|
|
@@ -58,6 +68,16 @@ class _CurrentDocket(Dependency):
|
|
|
58
68
|
|
|
59
69
|
|
|
60
70
|
def CurrentDocket() -> Docket:
|
|
71
|
+
"""A dependency to access the current Docket.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
@task
|
|
77
|
+
async def my_task(docket: Docket = CurrentDocket()) -> None:
|
|
78
|
+
assert isinstance(docket, Docket)
|
|
79
|
+
```
|
|
80
|
+
"""
|
|
61
81
|
return cast(Docket, _CurrentDocket())
|
|
62
82
|
|
|
63
83
|
|
|
@@ -67,6 +87,16 @@ class _CurrentExecution(Dependency):
|
|
|
67
87
|
|
|
68
88
|
|
|
69
89
|
def CurrentExecution() -> Execution:
|
|
90
|
+
"""A dependency to access the current Execution.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
@task
|
|
96
|
+
async def my_task(execution: Execution = CurrentExecution()) -> None:
|
|
97
|
+
assert isinstance(execution, Execution)
|
|
98
|
+
```
|
|
99
|
+
"""
|
|
70
100
|
return cast(Execution, _CurrentExecution())
|
|
71
101
|
|
|
72
102
|
|
|
@@ -76,23 +106,56 @@ class _TaskKey(Dependency):
|
|
|
76
106
|
|
|
77
107
|
|
|
78
108
|
def TaskKey() -> str:
|
|
109
|
+
"""A dependency to access the key of the currently executing task.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
@task
|
|
115
|
+
async def my_task(key: str = TaskKey()) -> None:
|
|
116
|
+
assert isinstance(key, str)
|
|
117
|
+
```
|
|
118
|
+
"""
|
|
79
119
|
return cast(str, _TaskKey())
|
|
80
120
|
|
|
81
121
|
|
|
82
122
|
class _TaskArgument(Dependency):
|
|
83
123
|
parameter: str | None
|
|
124
|
+
optional: bool
|
|
84
125
|
|
|
85
|
-
def __init__(self, parameter: str | None = None) -> None:
|
|
126
|
+
def __init__(self, parameter: str | None = None, optional: bool = False) -> None:
|
|
86
127
|
self.parameter = parameter
|
|
128
|
+
self.optional = optional
|
|
87
129
|
|
|
88
130
|
async def __aenter__(self) -> Any:
|
|
89
131
|
assert self.parameter is not None
|
|
90
132
|
execution = self.execution.get()
|
|
91
|
-
|
|
133
|
+
try:
|
|
134
|
+
return execution.get_argument(self.parameter)
|
|
135
|
+
except KeyError:
|
|
136
|
+
if self.optional:
|
|
137
|
+
return None
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def TaskArgument(parameter: str | None = None, optional: bool = False) -> Any:
|
|
142
|
+
"""A dependency to access a argument of the currently executing task. This is
|
|
143
|
+
often useful in dependency functions so they can access the arguments of the
|
|
144
|
+
task they are injected into.
|
|
145
|
+
|
|
146
|
+
Example:
|
|
92
147
|
|
|
148
|
+
```python
|
|
149
|
+
async def customer_name(customer_id: int = TaskArgument()) -> str:
|
|
150
|
+
...look up the customer's name by ID...
|
|
151
|
+
return "John Doe"
|
|
93
152
|
|
|
94
|
-
|
|
95
|
-
|
|
153
|
+
@task
|
|
154
|
+
async def greet_customer(customer_id: int, name: str = Depends(customer_name)) -> None:
|
|
155
|
+
print(f"Hello, {name}!")
|
|
156
|
+
```
|
|
157
|
+
"""
|
|
158
|
+
return cast(Any, _TaskArgument(parameter, optional))
|
|
96
159
|
|
|
97
160
|
|
|
98
161
|
class _TaskLogger(Dependency):
|
|
@@ -110,15 +173,45 @@ class _TaskLogger(Dependency):
|
|
|
110
173
|
|
|
111
174
|
|
|
112
175
|
def TaskLogger() -> logging.LoggerAdapter[logging.Logger]:
|
|
176
|
+
"""A dependency to access a logger for the currently executing task. The logger
|
|
177
|
+
will automatically inject contextual information such as the worker and docket
|
|
178
|
+
name, the task key, and the current execution attempt number.
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
@task
|
|
184
|
+
async def my_task(logger: LoggerAdapter[Logger] = TaskLogger()) -> None:
|
|
185
|
+
logger.info("Hello, world!")
|
|
186
|
+
```
|
|
187
|
+
"""
|
|
113
188
|
return cast(logging.LoggerAdapter[logging.Logger], _TaskLogger())
|
|
114
189
|
|
|
115
190
|
|
|
116
191
|
class Retry(Dependency):
|
|
192
|
+
"""Configures linear retries for a task. You can specify the total number of
|
|
193
|
+
attempts (or `None` to retry indefinitely), and the delay between attempts.
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
@task
|
|
199
|
+
async def my_task(retry: Retry = Retry(attempts=3)) -> None:
|
|
200
|
+
...
|
|
201
|
+
```
|
|
202
|
+
"""
|
|
203
|
+
|
|
117
204
|
single: bool = True
|
|
118
205
|
|
|
119
206
|
def __init__(
|
|
120
207
|
self, attempts: int | None = 1, delay: timedelta = timedelta(0)
|
|
121
208
|
) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Args:
|
|
211
|
+
attempts: The total number of attempts to make. If `None`, the task will
|
|
212
|
+
be retried indefinitely.
|
|
213
|
+
delay: The delay between attempts.
|
|
214
|
+
"""
|
|
122
215
|
self.attempts = attempts
|
|
123
216
|
self.delay = delay
|
|
124
217
|
self.attempt = 1
|
|
@@ -131,14 +224,32 @@ class Retry(Dependency):
|
|
|
131
224
|
|
|
132
225
|
|
|
133
226
|
class ExponentialRetry(Retry):
|
|
134
|
-
|
|
227
|
+
"""Configures exponential retries for a task. You can specify the total number
|
|
228
|
+
of attempts (or `None` to retry indefinitely), and the minimum and maximum delays
|
|
229
|
+
between attempts.
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
@task
|
|
235
|
+
async def my_task(retry: ExponentialRetry = ExponentialRetry(attempts=3)) -> None:
|
|
236
|
+
...
|
|
237
|
+
```
|
|
238
|
+
"""
|
|
135
239
|
|
|
136
240
|
def __init__(
|
|
137
241
|
self,
|
|
138
|
-
attempts: int = 1,
|
|
242
|
+
attempts: int | None = 1,
|
|
139
243
|
minimum_delay: timedelta = timedelta(seconds=1),
|
|
140
244
|
maximum_delay: timedelta = timedelta(seconds=64),
|
|
141
245
|
) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Args:
|
|
248
|
+
attempts: The total number of attempts to make. If `None`, the task will
|
|
249
|
+
be retried indefinitely.
|
|
250
|
+
minimum_delay: The minimum delay between attempts.
|
|
251
|
+
maximum_delay: The maximum delay between attempts.
|
|
252
|
+
"""
|
|
142
253
|
super().__init__(attempts=attempts, delay=minimum_delay)
|
|
143
254
|
self.minimum_delay = minimum_delay
|
|
144
255
|
self.maximum_delay = maximum_delay
|
|
@@ -166,6 +277,19 @@ class ExponentialRetry(Retry):
|
|
|
166
277
|
|
|
167
278
|
|
|
168
279
|
class Perpetual(Dependency):
|
|
280
|
+
"""Declare a task that should be run perpetually. Perpetual tasks are automatically
|
|
281
|
+
rescheduled for the future after they finish (whether they succeed or fail). A
|
|
282
|
+
perpetual task can be scheduled at worker startup with the `automatic=True`.
|
|
283
|
+
|
|
284
|
+
Example:
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
@task
|
|
288
|
+
async def my_task(perpetual: Perpetual = Perpetual()) -> None:
|
|
289
|
+
...
|
|
290
|
+
```
|
|
291
|
+
"""
|
|
292
|
+
|
|
169
293
|
single = True
|
|
170
294
|
|
|
171
295
|
every: timedelta
|
|
@@ -181,8 +305,7 @@ class Perpetual(Dependency):
|
|
|
181
305
|
every: timedelta = timedelta(0),
|
|
182
306
|
automatic: bool = False,
|
|
183
307
|
) -> None:
|
|
184
|
-
"""
|
|
185
|
-
|
|
308
|
+
"""
|
|
186
309
|
Args:
|
|
187
310
|
every: The target interval between task executions.
|
|
188
311
|
automatic: If set, this task will be automatically scheduled during worker
|
|
@@ -210,13 +333,29 @@ class Perpetual(Dependency):
|
|
|
210
333
|
|
|
211
334
|
|
|
212
335
|
class Timeout(Dependency):
|
|
213
|
-
|
|
336
|
+
"""Configures a timeout for a task. You can specify the base timeout, and the
|
|
337
|
+
task will be cancelled if it exceeds this duration. The timeout may be extended
|
|
338
|
+
within the context of a single running task.
|
|
214
339
|
|
|
215
|
-
|
|
340
|
+
Example:
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
@task
|
|
344
|
+
async def my_task(timeout: Timeout = Timeout(timedelta(seconds=10))) -> None:
|
|
345
|
+
...
|
|
346
|
+
```
|
|
347
|
+
"""
|
|
216
348
|
|
|
349
|
+
single: bool = True
|
|
350
|
+
|
|
351
|
+
base: timedelta
|
|
217
352
|
_deadline: float
|
|
218
353
|
|
|
219
354
|
def __init__(self, base: timedelta) -> None:
|
|
355
|
+
"""
|
|
356
|
+
Args:
|
|
357
|
+
base: The base timeout duration.
|
|
358
|
+
"""
|
|
220
359
|
self.base = base
|
|
221
360
|
|
|
222
361
|
async def __aenter__(self) -> "Timeout":
|
|
@@ -231,9 +370,16 @@ class Timeout(Dependency):
|
|
|
231
370
|
return time.monotonic() >= self._deadline
|
|
232
371
|
|
|
233
372
|
def remaining(self) -> timedelta:
|
|
373
|
+
"""Get the remaining time until the timeout expires."""
|
|
234
374
|
return timedelta(seconds=self._deadline - time.monotonic())
|
|
235
375
|
|
|
236
376
|
def extend(self, by: timedelta | None = None) -> None:
|
|
377
|
+
"""Extend the timeout by a given duration. If no duration is provided, the
|
|
378
|
+
base timeout will be used.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
by: The duration to extend the timeout by.
|
|
382
|
+
"""
|
|
237
383
|
if by is None:
|
|
238
384
|
by = self.base
|
|
239
385
|
self._deadline += by.total_seconds()
|
|
@@ -321,6 +467,23 @@ class _Depends(Dependency, Generic[R]):
|
|
|
321
467
|
|
|
322
468
|
|
|
323
469
|
def Depends(dependency: DependencyFunction[R]) -> R:
|
|
470
|
+
"""Include a user-defined function as a dependency. Dependencies may either return
|
|
471
|
+
a value or an async context manager. If it returns a context manager, the
|
|
472
|
+
dependency will be entered and exited around the task, giving an opportunity to
|
|
473
|
+
control the lifetime of a resource, like a database connection.
|
|
474
|
+
|
|
475
|
+
Example:
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
|
|
479
|
+
async def my_dependency() -> str:
|
|
480
|
+
return "Hello, world!"
|
|
481
|
+
|
|
482
|
+
@task async def my_task(dependency: str = Depends(my_dependency)) -> None:
|
|
483
|
+
print(dependency)
|
|
484
|
+
|
|
485
|
+
```
|
|
486
|
+
"""
|
|
324
487
|
return cast(R, _Depends(dependency))
|
|
325
488
|
|
|
326
489
|
|
docket/docket.py
CHANGED
|
@@ -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,
|
|
@@ -112,6 +112,20 @@ class DocketSnapshot:
|
|
|
112
112
|
|
|
113
113
|
|
|
114
114
|
class Docket:
|
|
115
|
+
"""A Docket represents a collection of tasks that may be scheduled for later
|
|
116
|
+
execution. With a Docket, you can add, replace, and cancel tasks.
|
|
117
|
+
Example:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
@task
|
|
121
|
+
async def my_task(greeting: str, recipient: str) -> None:
|
|
122
|
+
print(f"{greeting}, {recipient}!")
|
|
123
|
+
|
|
124
|
+
async with Docket() as docket:
|
|
125
|
+
docket.add(my_task)("Hello", recipient="world")
|
|
126
|
+
```
|
|
127
|
+
"""
|
|
128
|
+
|
|
115
129
|
tasks: dict[str, TaskFunction]
|
|
116
130
|
strike_list: StrikeList
|
|
117
131
|
|
|
@@ -199,6 +213,11 @@ class Docket:
|
|
|
199
213
|
await asyncio.shield(r.__aexit__(None, None, None))
|
|
200
214
|
|
|
201
215
|
def register(self, function: TaskFunction) -> None:
|
|
216
|
+
"""Register a task with the Docket.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
function: The task to register.
|
|
220
|
+
"""
|
|
202
221
|
from .dependencies import validate_dependencies
|
|
203
222
|
|
|
204
223
|
validate_dependencies(function)
|
|
@@ -229,7 +248,14 @@ class Docket:
|
|
|
229
248
|
function: Callable[P, Awaitable[R]],
|
|
230
249
|
when: datetime | None = None,
|
|
231
250
|
key: str | None = None,
|
|
232
|
-
) -> Callable[P, Awaitable[Execution]]:
|
|
251
|
+
) -> Callable[P, Awaitable[Execution]]:
|
|
252
|
+
"""Add a task to the Docket.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
function: The task function to add.
|
|
256
|
+
when: The time to schedule the task.
|
|
257
|
+
key: The key to schedule the task under.
|
|
258
|
+
"""
|
|
233
259
|
|
|
234
260
|
@overload
|
|
235
261
|
def add(
|
|
@@ -237,7 +263,14 @@ class Docket:
|
|
|
237
263
|
function: str,
|
|
238
264
|
when: datetime | None = None,
|
|
239
265
|
key: str | None = None,
|
|
240
|
-
) -> Callable[..., Awaitable[Execution]]:
|
|
266
|
+
) -> Callable[..., Awaitable[Execution]]:
|
|
267
|
+
"""Add a task to the Docket.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
function: The name of a task to add.
|
|
271
|
+
when: The time to schedule the task.
|
|
272
|
+
key: The key to schedule the task under.
|
|
273
|
+
"""
|
|
241
274
|
|
|
242
275
|
def add(
|
|
243
276
|
self,
|
|
@@ -245,6 +278,13 @@ class Docket:
|
|
|
245
278
|
when: datetime | None = None,
|
|
246
279
|
key: str | None = None,
|
|
247
280
|
) -> Callable[..., Awaitable[Execution]]:
|
|
281
|
+
"""Add a task to the Docket.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
function: The task to add.
|
|
285
|
+
when: The time to schedule the task.
|
|
286
|
+
key: The key to schedule the task under.
|
|
287
|
+
"""
|
|
248
288
|
if isinstance(function, str):
|
|
249
289
|
function = self.tasks[function]
|
|
250
290
|
else:
|
|
@@ -254,7 +294,7 @@ class Docket:
|
|
|
254
294
|
when = datetime.now(timezone.utc)
|
|
255
295
|
|
|
256
296
|
if key is None:
|
|
257
|
-
key =
|
|
297
|
+
key = str(uuid7())
|
|
258
298
|
|
|
259
299
|
async def scheduler(*args: P.args, **kwargs: P.kwargs) -> Execution:
|
|
260
300
|
execution = Execution(function, args, kwargs, when, key, attempt=1)
|
|
@@ -277,7 +317,14 @@ class Docket:
|
|
|
277
317
|
function: Callable[P, Awaitable[R]],
|
|
278
318
|
when: datetime,
|
|
279
319
|
key: str,
|
|
280
|
-
) -> Callable[P, Awaitable[Execution]]:
|
|
320
|
+
) -> Callable[P, Awaitable[Execution]]:
|
|
321
|
+
"""Replace a previously scheduled task on the Docket.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
function: The task function to replace.
|
|
325
|
+
when: The time to schedule the task.
|
|
326
|
+
key: The key to schedule the task under.
|
|
327
|
+
"""
|
|
281
328
|
|
|
282
329
|
@overload
|
|
283
330
|
def replace(
|
|
@@ -285,7 +332,14 @@ class Docket:
|
|
|
285
332
|
function: str,
|
|
286
333
|
when: datetime,
|
|
287
334
|
key: str,
|
|
288
|
-
) -> Callable[..., Awaitable[Execution]]:
|
|
335
|
+
) -> Callable[..., Awaitable[Execution]]:
|
|
336
|
+
"""Replace a previously scheduled task on the Docket.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
function: The name of a task to replace.
|
|
340
|
+
when: The time to schedule the task.
|
|
341
|
+
key: The key to schedule the task under.
|
|
342
|
+
"""
|
|
289
343
|
|
|
290
344
|
def replace(
|
|
291
345
|
self,
|
|
@@ -293,6 +347,13 @@ class Docket:
|
|
|
293
347
|
when: datetime,
|
|
294
348
|
key: str,
|
|
295
349
|
) -> Callable[..., Awaitable[Execution]]:
|
|
350
|
+
"""Replace a previously scheduled task on the Docket.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
function: The task to replace.
|
|
354
|
+
when: The time to schedule the task.
|
|
355
|
+
key: The key to schedule the task under.
|
|
356
|
+
"""
|
|
296
357
|
if isinstance(function, str):
|
|
297
358
|
function = self.tasks[function]
|
|
298
359
|
|
|
@@ -329,6 +390,11 @@ class Docket:
|
|
|
329
390
|
TASKS_SCHEDULED.add(1, {**self.labels(), **execution.general_labels()})
|
|
330
391
|
|
|
331
392
|
async def cancel(self, key: str) -> None:
|
|
393
|
+
"""Cancel a previously scheduled task on the Docket.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
key: The key of the task to cancel.
|
|
397
|
+
"""
|
|
332
398
|
with tracer.start_as_current_span(
|
|
333
399
|
"docket.cancel",
|
|
334
400
|
attributes={**self.labels(), "docket.key": key},
|
|
@@ -421,6 +487,14 @@ class Docket:
|
|
|
421
487
|
operator: Operator | LiteralOperator = "==",
|
|
422
488
|
value: Hashable | None = None,
|
|
423
489
|
) -> None:
|
|
490
|
+
"""Strike a task from the Docket.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
function: The task to strike.
|
|
494
|
+
parameter: The parameter to strike on.
|
|
495
|
+
operator: The operator to use.
|
|
496
|
+
value: The value to strike on.
|
|
497
|
+
"""
|
|
424
498
|
if not isinstance(function, (str, type(None))):
|
|
425
499
|
function = function.__name__
|
|
426
500
|
|
|
@@ -436,6 +510,14 @@ class Docket:
|
|
|
436
510
|
operator: Operator | LiteralOperator = "==",
|
|
437
511
|
value: Hashable | None = None,
|
|
438
512
|
) -> None:
|
|
513
|
+
"""Restore a previously stricken task to the Docket.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
function: The task to restore.
|
|
517
|
+
parameter: The parameter to restore on.
|
|
518
|
+
operator: The operator to use.
|
|
519
|
+
value: The value to restore on.
|
|
520
|
+
"""
|
|
439
521
|
if not isinstance(function, (str, type(None))):
|
|
440
522
|
function = function.__name__
|
|
441
523
|
|
|
@@ -501,6 +583,12 @@ class Docket:
|
|
|
501
583
|
await asyncio.sleep(1)
|
|
502
584
|
|
|
503
585
|
async def snapshot(self) -> DocketSnapshot:
|
|
586
|
+
"""Get a snapshot of the Docket, including which tasks are scheduled or currently
|
|
587
|
+
running, as well as which workers are active.
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
A snapshot of the Docket.
|
|
591
|
+
"""
|
|
504
592
|
running: list[RunningExecution] = []
|
|
505
593
|
future: list[Execution] = []
|
|
506
594
|
|
|
@@ -585,6 +673,11 @@ class Docket:
|
|
|
585
673
|
return f"{self.name}:task-workers:{task_name}"
|
|
586
674
|
|
|
587
675
|
async def workers(self) -> Collection[WorkerInfo]:
|
|
676
|
+
"""Get a list of all workers that have sent heartbeats to the Docket.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
A list of all workers that have sent heartbeats to the Docket.
|
|
680
|
+
"""
|
|
588
681
|
workers: list[WorkerInfo] = []
|
|
589
682
|
|
|
590
683
|
oldest = datetime.now(timezone.utc).timestamp() - (
|
|
@@ -615,8 +708,15 @@ class Docket:
|
|
|
615
708
|
return workers
|
|
616
709
|
|
|
617
710
|
async def task_workers(self, task_name: str) -> Collection[WorkerInfo]:
|
|
618
|
-
|
|
711
|
+
"""Get a list of all workers that are able to execute a given task.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
task_name: The name of the task.
|
|
619
715
|
|
|
716
|
+
Returns:
|
|
717
|
+
A list of all workers that are able to execute the given task.
|
|
718
|
+
"""
|
|
719
|
+
workers: list[WorkerInfo] = []
|
|
620
720
|
oldest = datetime.now(timezone.utc).timestamp() - (
|
|
621
721
|
self.heartbeat_interval.total_seconds() * self.missed_heartbeats
|
|
622
722
|
)
|
docket/execution.py
CHANGED
|
@@ -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
|
|
|
@@ -117,6 +125,37 @@ class Execution:
|
|
|
117
125
|
return [trace.Link(initiating_context)] if initiating_context.is_valid else []
|
|
118
126
|
|
|
119
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
|
+
|
|
120
159
|
class Operator(enum.StrEnum):
|
|
121
160
|
EQUAL = "=="
|
|
122
161
|
NOT_EQUAL = "!="
|
docket/instrumentation.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import threading
|
|
2
1
|
from contextlib import contextmanager
|
|
2
|
+
from threading import Thread
|
|
3
3
|
from typing import Generator, cast
|
|
4
4
|
|
|
5
5
|
from opentelemetry import metrics
|
|
@@ -145,6 +145,34 @@ message_getter: MessageGetter = MessageGetter()
|
|
|
145
145
|
message_setter: MessageSetter = MessageSetter()
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
@contextmanager
|
|
149
|
+
def healthcheck_server(
|
|
150
|
+
host: str = "0.0.0.0", port: int | None = None
|
|
151
|
+
) -> Generator[None, None, None]:
|
|
152
|
+
if port is None:
|
|
153
|
+
yield
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
157
|
+
|
|
158
|
+
class HealthcheckHandler(BaseHTTPRequestHandler):
|
|
159
|
+
def do_GET(self):
|
|
160
|
+
self.send_response(200)
|
|
161
|
+
self.send_header("Content-type", "text/plain")
|
|
162
|
+
self.end_headers()
|
|
163
|
+
self.wfile.write(b"OK")
|
|
164
|
+
|
|
165
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
166
|
+
# Suppress access logs from the webserver
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
server = HTTPServer((host, port), HealthcheckHandler)
|
|
170
|
+
with server:
|
|
171
|
+
Thread(target=server.serve_forever, daemon=True).start()
|
|
172
|
+
|
|
173
|
+
yield
|
|
174
|
+
|
|
175
|
+
|
|
148
176
|
@contextmanager
|
|
149
177
|
def metrics_server(
|
|
150
178
|
host: str = "0.0.0.0", port: int | None = None
|
|
@@ -173,8 +201,6 @@ def metrics_server(
|
|
|
173
201
|
handler_class=_SilentHandler,
|
|
174
202
|
)
|
|
175
203
|
with server:
|
|
176
|
-
|
|
177
|
-
t.daemon = True
|
|
178
|
-
t.start()
|
|
204
|
+
Thread(target=server.serve_forever, daemon=True).start()
|
|
179
205
|
|
|
180
206
|
yield
|