pydocket 0.5.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pydocket might be problematic. Click here for more details.
- {pydocket-0.5.0 → pydocket-0.5.1}/PKG-INFO +1 -1
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/dependencies.py +42 -2
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/worker.py +68 -29
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_fundamentals.py +28 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/.cursor/rules/general.mdc +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/.cursor/rules/python-style.mdc +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/.github/codecov.yml +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/.github/workflows/chaos.yml +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/.github/workflows/ci.yml +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/.github/workflows/publish.yml +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/.gitignore +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/.pre-commit-config.yaml +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/LICENSE +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/README.md +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/chaos/README.md +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/chaos/__init__.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/chaos/driver.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/chaos/producer.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/chaos/run +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/chaos/tasks.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/pyproject.toml +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/__init__.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/__main__.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/annotations.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/cli.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/docket.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/execution.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/instrumentation.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/py.typed +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/src/docket/tasks.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/telemetry/.gitignore +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/telemetry/start +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/telemetry/stop +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/__init__.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/__init__.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/conftest.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_module.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_parsing.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_snapshot.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_striking.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_tasks.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_version.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_worker.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/cli/test_workers.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/conftest.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_dependencies.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_docket.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_instrumentation.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_striking.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/tests/test_worker.py +0 -0
- {pydocket-0.5.0 → pydocket-0.5.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pydocket
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
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
|
|
@@ -2,7 +2,7 @@ import abc
|
|
|
2
2
|
import inspect
|
|
3
3
|
import logging
|
|
4
4
|
from datetime import timedelta
|
|
5
|
-
from typing import Any, Awaitable, Callable, Counter, cast
|
|
5
|
+
from typing import Any, Awaitable, Callable, Counter, TypeVar, cast
|
|
6
6
|
|
|
7
7
|
from .docket import Docket
|
|
8
8
|
from .execution import Execution
|
|
@@ -130,12 +130,29 @@ class Perpetual(Dependency):
|
|
|
130
130
|
single = True
|
|
131
131
|
|
|
132
132
|
every: timedelta
|
|
133
|
+
automatic: bool
|
|
134
|
+
|
|
133
135
|
args: tuple[Any, ...]
|
|
134
136
|
kwargs: dict[str, Any]
|
|
137
|
+
|
|
135
138
|
cancelled: bool
|
|
136
139
|
|
|
137
|
-
def __init__(
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
every: timedelta = timedelta(0),
|
|
143
|
+
automatic: bool = False,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Declare a task that should be run perpetually.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
every: The target interval between task executions.
|
|
149
|
+
automatic: If set, this task will be automatically scheduled during worker
|
|
150
|
+
startup and continually through the worker's lifespan. This ensures
|
|
151
|
+
that the task will always be scheduled despite crashes and other
|
|
152
|
+
adverse conditions. Automatic tasks must not require any arguments.
|
|
153
|
+
"""
|
|
138
154
|
self.every = every
|
|
155
|
+
self.automatic = automatic
|
|
139
156
|
self.cancelled = False
|
|
140
157
|
|
|
141
158
|
def __call__(
|
|
@@ -170,6 +187,29 @@ def get_dependency_parameters(
|
|
|
170
187
|
return dependencies
|
|
171
188
|
|
|
172
189
|
|
|
190
|
+
D = TypeVar("D", bound=Dependency)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_single_dependency_parameter_of_type(
|
|
194
|
+
function: Callable[..., Awaitable[Any]], dependency_type: type[D]
|
|
195
|
+
) -> D | None:
|
|
196
|
+
assert dependency_type.single, "Dependency must be single"
|
|
197
|
+
for _, dependency in get_dependency_parameters(function).items():
|
|
198
|
+
if isinstance(dependency, dependency_type):
|
|
199
|
+
return dependency
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_single_dependency_of_type(
|
|
204
|
+
dependencies: dict[str, Dependency], dependency_type: type[D]
|
|
205
|
+
) -> D | None:
|
|
206
|
+
assert dependency_type.single, "Dependency must be single"
|
|
207
|
+
for _, dependency in dependencies.items():
|
|
208
|
+
if isinstance(dependency, dependency_type):
|
|
209
|
+
return dependency
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
173
213
|
def validate_dependencies(function: Callable[..., Awaitable[Any]]) -> None:
|
|
174
214
|
parameters = get_dependency_parameters(function)
|
|
175
215
|
|
|
@@ -6,11 +6,9 @@ from datetime import datetime, timedelta, timezone
|
|
|
6
6
|
from types import TracebackType
|
|
7
7
|
from typing import (
|
|
8
8
|
TYPE_CHECKING,
|
|
9
|
-
Any,
|
|
10
9
|
Mapping,
|
|
11
10
|
Protocol,
|
|
12
11
|
Self,
|
|
13
|
-
TypeVar,
|
|
14
12
|
cast,
|
|
15
13
|
)
|
|
16
14
|
from uuid import uuid4
|
|
@@ -53,8 +51,6 @@ tracer: Tracer = trace.get_tracer(__name__)
|
|
|
53
51
|
if TYPE_CHECKING: # pragma: no cover
|
|
54
52
|
from .dependencies import Dependency
|
|
55
53
|
|
|
56
|
-
D = TypeVar("D", bound="Dependency")
|
|
57
|
-
|
|
58
54
|
|
|
59
55
|
class _stream_due_tasks(Protocol):
|
|
60
56
|
async def __call__(
|
|
@@ -216,13 +212,17 @@ class Worker:
|
|
|
216
212
|
await asyncio.sleep(self.reconnection_delay.total_seconds())
|
|
217
213
|
|
|
218
214
|
async def _worker_loop(self, forever: bool = False):
|
|
219
|
-
|
|
215
|
+
worker_stopping = asyncio.Event()
|
|
216
|
+
|
|
217
|
+
await self._schedule_all_automatic_perpetual_tasks()
|
|
218
|
+
perpetual_scheduling_task = asyncio.create_task(
|
|
219
|
+
self._perpetual_scheduling_loop(worker_stopping)
|
|
220
|
+
)
|
|
220
221
|
|
|
221
222
|
async with self.docket.redis() as redis:
|
|
222
223
|
scheduler_task = asyncio.create_task(
|
|
223
|
-
self._scheduler_loop(redis,
|
|
224
|
+
self._scheduler_loop(redis, worker_stopping)
|
|
224
225
|
)
|
|
225
|
-
|
|
226
226
|
active_tasks: dict[asyncio.Task[None], RedisMessageID] = {}
|
|
227
227
|
|
|
228
228
|
async def check_for_work() -> bool:
|
|
@@ -329,13 +329,14 @@ class Worker:
|
|
|
329
329
|
await asyncio.gather(*active_tasks, return_exceptions=True)
|
|
330
330
|
await process_completed_tasks()
|
|
331
331
|
|
|
332
|
-
|
|
332
|
+
worker_stopping.set()
|
|
333
333
|
await scheduler_task
|
|
334
|
+
await perpetual_scheduling_task
|
|
334
335
|
|
|
335
336
|
async def _scheduler_loop(
|
|
336
337
|
self,
|
|
337
338
|
redis: Redis,
|
|
338
|
-
|
|
339
|
+
worker_stopping: asyncio.Event,
|
|
339
340
|
) -> None:
|
|
340
341
|
"""Loop that moves due tasks from the queue to the stream."""
|
|
341
342
|
|
|
@@ -389,7 +390,7 @@ class Worker:
|
|
|
389
390
|
|
|
390
391
|
total_work: int = sys.maxsize
|
|
391
392
|
|
|
392
|
-
while not
|
|
393
|
+
while not worker_stopping.is_set() or total_work:
|
|
393
394
|
try:
|
|
394
395
|
total_work, due_work = await stream_due_tasks(
|
|
395
396
|
keys=[self.docket.queue_key, self.docket.stream_key],
|
|
@@ -416,6 +417,49 @@ class Worker:
|
|
|
416
417
|
|
|
417
418
|
logger.debug("Scheduler loop finished", extra=self._log_context())
|
|
418
419
|
|
|
420
|
+
async def _perpetual_scheduling_loop(self, worker_stopping: asyncio.Event) -> None:
|
|
421
|
+
"""Loop that ensures that automatic perpetual tasks are always scheduled."""
|
|
422
|
+
|
|
423
|
+
while not worker_stopping.is_set():
|
|
424
|
+
minimum_interval = self.scheduling_resolution
|
|
425
|
+
try:
|
|
426
|
+
minimum_interval = await self._schedule_all_automatic_perpetual_tasks()
|
|
427
|
+
except Exception: # pragma: no cover
|
|
428
|
+
logger.exception(
|
|
429
|
+
"Error in perpetual scheduling loop",
|
|
430
|
+
exc_info=True,
|
|
431
|
+
extra=self._log_context(),
|
|
432
|
+
)
|
|
433
|
+
finally:
|
|
434
|
+
# Wait until just before the next time any task would need to be
|
|
435
|
+
# scheduled (one scheduling_resolution before the lowest interval)
|
|
436
|
+
interval = max(
|
|
437
|
+
minimum_interval - self.scheduling_resolution,
|
|
438
|
+
self.scheduling_resolution,
|
|
439
|
+
)
|
|
440
|
+
assert interval <= self.scheduling_resolution
|
|
441
|
+
await asyncio.sleep(interval.total_seconds())
|
|
442
|
+
|
|
443
|
+
async def _schedule_all_automatic_perpetual_tasks(self) -> timedelta:
|
|
444
|
+
from .dependencies import Perpetual, get_single_dependency_parameter_of_type
|
|
445
|
+
|
|
446
|
+
minimum_interval = self.scheduling_resolution
|
|
447
|
+
for task_function in self.docket.tasks.values():
|
|
448
|
+
perpetual = get_single_dependency_parameter_of_type(
|
|
449
|
+
task_function, Perpetual
|
|
450
|
+
)
|
|
451
|
+
if perpetual is None:
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
if not perpetual.automatic:
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
key = task_function.__name__
|
|
458
|
+
await self.docket.add(task_function, key=key)()
|
|
459
|
+
minimum_interval = min(minimum_interval, perpetual.every)
|
|
460
|
+
|
|
461
|
+
return minimum_interval
|
|
462
|
+
|
|
419
463
|
async def _execute(self, message: RedisMessage) -> None:
|
|
420
464
|
key = message[b"key"].decode()
|
|
421
465
|
async with self.docket.redis() as redis:
|
|
@@ -511,12 +555,12 @@ class Worker:
|
|
|
511
555
|
def _get_dependencies(
|
|
512
556
|
self,
|
|
513
557
|
execution: Execution,
|
|
514
|
-
) -> dict[str,
|
|
558
|
+
) -> dict[str, "Dependency"]:
|
|
515
559
|
from .dependencies import get_dependency_parameters
|
|
516
560
|
|
|
517
561
|
parameters = get_dependency_parameters(execution.function)
|
|
518
562
|
|
|
519
|
-
dependencies: dict[str,
|
|
563
|
+
dependencies: dict[str, "Dependency"] = {}
|
|
520
564
|
|
|
521
565
|
for parameter_name, dependency in parameters.items():
|
|
522
566
|
# If the argument is already provided, skip it, which allows users to call
|
|
@@ -532,16 +576,14 @@ class Worker:
|
|
|
532
576
|
async def _retry_if_requested(
|
|
533
577
|
self,
|
|
534
578
|
execution: Execution,
|
|
535
|
-
dependencies: dict[str,
|
|
579
|
+
dependencies: dict[str, "Dependency"],
|
|
536
580
|
) -> bool:
|
|
537
|
-
from .dependencies import Retry
|
|
581
|
+
from .dependencies import Retry, get_single_dependency_of_type
|
|
538
582
|
|
|
539
|
-
|
|
540
|
-
if not
|
|
583
|
+
retry = get_single_dependency_of_type(dependencies, Retry)
|
|
584
|
+
if not retry:
|
|
541
585
|
return False
|
|
542
586
|
|
|
543
|
-
retry = retries[0]
|
|
544
|
-
|
|
545
587
|
if retry.attempts is None or execution.attempt < retry.attempts:
|
|
546
588
|
execution.when = datetime.now(timezone.utc) + retry.delay
|
|
547
589
|
execution.attempt += 1
|
|
@@ -553,19 +595,16 @@ class Worker:
|
|
|
553
595
|
return False
|
|
554
596
|
|
|
555
597
|
async def _perpetuate_if_requested(
|
|
556
|
-
self,
|
|
598
|
+
self,
|
|
599
|
+
execution: Execution,
|
|
600
|
+
dependencies: dict[str, "Dependency"],
|
|
601
|
+
duration: timedelta,
|
|
557
602
|
) -> bool:
|
|
558
|
-
from .dependencies import Perpetual
|
|
559
|
-
|
|
560
|
-
perpetuals = [
|
|
561
|
-
perpetual
|
|
562
|
-
for perpetual in dependencies.values()
|
|
563
|
-
if isinstance(perpetual, Perpetual)
|
|
564
|
-
]
|
|
565
|
-
if not perpetuals:
|
|
566
|
-
return False
|
|
603
|
+
from .dependencies import Perpetual, get_single_dependency_of_type
|
|
567
604
|
|
|
568
|
-
perpetual =
|
|
605
|
+
perpetual = get_single_dependency_of_type(dependencies, Perpetual)
|
|
606
|
+
if not perpetual:
|
|
607
|
+
return False
|
|
569
608
|
|
|
570
609
|
if perpetual.cancelled:
|
|
571
610
|
return False
|
|
@@ -1037,3 +1037,31 @@ async def test_perpetual_tasks_perpetuate_even_after_errors(
|
|
|
1037
1037
|
await worker.run_at_most({execution.key: 3})
|
|
1038
1038
|
|
|
1039
1039
|
assert calls == 3
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
async def test_perpetual_tasks_can_be_automatically_scheduled(
|
|
1043
|
+
docket: Docket, worker: Worker
|
|
1044
|
+
):
|
|
1045
|
+
"""Perpetual tasks can be automatically scheduled"""
|
|
1046
|
+
|
|
1047
|
+
calls = 0
|
|
1048
|
+
|
|
1049
|
+
async def my_automatic_task(
|
|
1050
|
+
perpetual: Perpetual = Perpetual(
|
|
1051
|
+
every=timedelta(milliseconds=50), automatic=True
|
|
1052
|
+
),
|
|
1053
|
+
):
|
|
1054
|
+
assert isinstance(perpetual, Perpetual)
|
|
1055
|
+
|
|
1056
|
+
assert perpetual.every == timedelta(milliseconds=50)
|
|
1057
|
+
|
|
1058
|
+
nonlocal calls
|
|
1059
|
+
calls += 1
|
|
1060
|
+
|
|
1061
|
+
# Note we never add this task to the docket, we just register it.
|
|
1062
|
+
docket.register(my_automatic_task)
|
|
1063
|
+
|
|
1064
|
+
# The automatic key will be the task function's name
|
|
1065
|
+
await worker.run_at_most({"my_automatic_task": 3})
|
|
1066
|
+
|
|
1067
|
+
assert calls == 3
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|