pydocket 0.2.1__tar.gz → 0.3.0__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.2.1 → pydocket-0.3.0}/.pre-commit-config.yaml +2 -2
- {pydocket-0.2.1 → pydocket-0.3.0}/PKG-INFO +1 -1
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/__init__.py +2 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/dependencies.py +28 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/instrumentation.py +6 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/worker.py +38 -1
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_fundamentals.py +102 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_worker.py +56 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/.cursor/rules/general.mdc +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/.cursor/rules/python-style.mdc +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/.github/codecov.yml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/.github/workflows/chaos.yml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/.github/workflows/ci.yml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/.github/workflows/publish.yml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/.gitignore +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/LICENSE +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/README.md +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/chaos/README.md +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/chaos/__init__.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/chaos/driver.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/chaos/producer.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/chaos/run +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/chaos/tasks.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/pyproject.toml +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/__main__.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/annotations.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/cli.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/docket.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/execution.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/py.typed +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/src/docket/tasks.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/telemetry/.gitignore +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/telemetry/start +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/telemetry/stop +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/__init__.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/__init__.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/conftest.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_module.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_parsing.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_snapshot.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_striking.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_tasks.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_version.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_worker.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/cli/test_workers.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/conftest.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_dependencies.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_docket.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_instrumentation.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/tests/test_striking.py +0 -0
- {pydocket-0.2.1 → pydocket-0.3.0}/uv.lock +0 -0
|
@@ -24,13 +24,13 @@ repos:
|
|
|
24
24
|
hooks:
|
|
25
25
|
- id: pyright
|
|
26
26
|
name: pyright (docket package)
|
|
27
|
-
entry:
|
|
27
|
+
entry: pyright --verifytypes docket --ignoreexternal
|
|
28
28
|
language: system
|
|
29
29
|
types: [python]
|
|
30
30
|
pass_filenames: false
|
|
31
31
|
- id: pyright
|
|
32
32
|
name: pyright (source and tests)
|
|
33
|
-
entry:
|
|
33
|
+
entry: pyright tests
|
|
34
34
|
language: system
|
|
35
35
|
types: [python]
|
|
36
36
|
pass_filenames: false
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pydocket
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -14,6 +14,7 @@ from .dependencies import (
|
|
|
14
14
|
CurrentExecution,
|
|
15
15
|
CurrentWorker,
|
|
16
16
|
ExponentialRetry,
|
|
17
|
+
Perpetual,
|
|
17
18
|
Retry,
|
|
18
19
|
TaskKey,
|
|
19
20
|
TaskLogger,
|
|
@@ -34,5 +35,6 @@ __all__ = [
|
|
|
34
35
|
"Retry",
|
|
35
36
|
"ExponentialRetry",
|
|
36
37
|
"Logged",
|
|
38
|
+
"Perpetual",
|
|
37
39
|
"__version__",
|
|
38
40
|
]
|
|
@@ -126,6 +126,34 @@ class ExponentialRetry(Retry):
|
|
|
126
126
|
return retry
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
class Perpetual(Dependency):
|
|
130
|
+
single = True
|
|
131
|
+
|
|
132
|
+
every: timedelta
|
|
133
|
+
args: tuple[Any, ...]
|
|
134
|
+
kwargs: dict[str, Any]
|
|
135
|
+
cancelled: bool
|
|
136
|
+
|
|
137
|
+
def __init__(self, every: timedelta = timedelta(0)) -> None:
|
|
138
|
+
self.every = every
|
|
139
|
+
self.cancelled = False
|
|
140
|
+
|
|
141
|
+
def __call__(
|
|
142
|
+
self, docket: Docket, worker: Worker, execution: Execution
|
|
143
|
+
) -> "Perpetual":
|
|
144
|
+
perpetual = Perpetual(every=self.every)
|
|
145
|
+
perpetual.args = execution.args
|
|
146
|
+
perpetual.kwargs = execution.kwargs
|
|
147
|
+
return perpetual
|
|
148
|
+
|
|
149
|
+
def cancel(self) -> None:
|
|
150
|
+
self.cancelled = True
|
|
151
|
+
|
|
152
|
+
def perpetuate(self, *args: Any, **kwargs: Any) -> None:
|
|
153
|
+
self.args = args
|
|
154
|
+
self.kwargs = kwargs
|
|
155
|
+
|
|
156
|
+
|
|
129
157
|
def get_dependency_parameters(
|
|
130
158
|
function: Callable[..., Awaitable[Any]],
|
|
131
159
|
) -> dict[str, Dependency]:
|
|
@@ -70,6 +70,12 @@ TASKS_RETRIED = meter.create_counter(
|
|
|
70
70
|
unit="1",
|
|
71
71
|
)
|
|
72
72
|
|
|
73
|
+
TASKS_PERPETUATED = meter.create_counter(
|
|
74
|
+
"docket_tasks_perpetuated",
|
|
75
|
+
description="How many tasks that have been self-perpetuated",
|
|
76
|
+
unit="1",
|
|
77
|
+
)
|
|
78
|
+
|
|
73
79
|
TASK_DURATION = meter.create_histogram(
|
|
74
80
|
"docket_task_duration",
|
|
75
81
|
description="How long tasks take to complete",
|
|
@@ -36,6 +36,7 @@ from .instrumentation import (
|
|
|
36
36
|
TASK_PUNCTUALITY,
|
|
37
37
|
TASKS_COMPLETED,
|
|
38
38
|
TASKS_FAILED,
|
|
39
|
+
TASKS_PERPETUATED,
|
|
39
40
|
TASKS_RETRIED,
|
|
40
41
|
TASKS_RUNNING,
|
|
41
42
|
TASKS_STARTED,
|
|
@@ -424,12 +425,20 @@ class Worker:
|
|
|
424
425
|
TASKS_SUCCEEDED.add(1, counter_labels)
|
|
425
426
|
duration = datetime.now(timezone.utc) - start
|
|
426
427
|
log_context["duration"] = duration.total_seconds()
|
|
427
|
-
|
|
428
|
+
rescheduled = await self._perpetuate_if_requested(
|
|
429
|
+
execution, dependencies, duration
|
|
430
|
+
)
|
|
431
|
+
arrow = "↫" if rescheduled else "↩"
|
|
432
|
+
logger.info("%s [%s] %s", arrow, duration, call, extra=log_context)
|
|
428
433
|
except Exception:
|
|
429
434
|
TASKS_FAILED.add(1, counter_labels)
|
|
430
435
|
duration = datetime.now(timezone.utc) - start
|
|
431
436
|
log_context["duration"] = duration.total_seconds()
|
|
432
437
|
retried = await self._retry_if_requested(execution, dependencies)
|
|
438
|
+
if not retried:
|
|
439
|
+
retried = await self._perpetuate_if_requested(
|
|
440
|
+
execution, dependencies, duration
|
|
441
|
+
)
|
|
433
442
|
arrow = "↫" if retried else "↩"
|
|
434
443
|
logger.exception("%s [%s] %s", arrow, duration, call, extra=log_context)
|
|
435
444
|
finally:
|
|
@@ -481,6 +490,34 @@ class Worker:
|
|
|
481
490
|
|
|
482
491
|
return False
|
|
483
492
|
|
|
493
|
+
async def _perpetuate_if_requested(
|
|
494
|
+
self, execution: Execution, dependencies: dict[str, Any], duration: timedelta
|
|
495
|
+
) -> bool:
|
|
496
|
+
from .dependencies import Perpetual
|
|
497
|
+
|
|
498
|
+
perpetuals = [
|
|
499
|
+
perpetual
|
|
500
|
+
for perpetual in dependencies.values()
|
|
501
|
+
if isinstance(perpetual, Perpetual)
|
|
502
|
+
]
|
|
503
|
+
if not perpetuals:
|
|
504
|
+
return False
|
|
505
|
+
|
|
506
|
+
perpetual = perpetuals[0]
|
|
507
|
+
|
|
508
|
+
if perpetual.cancelled:
|
|
509
|
+
return False
|
|
510
|
+
|
|
511
|
+
now = datetime.now(timezone.utc)
|
|
512
|
+
execution.when = max(now, now + perpetual.every - duration)
|
|
513
|
+
execution.args = perpetual.args
|
|
514
|
+
execution.kwargs = perpetual.kwargs
|
|
515
|
+
|
|
516
|
+
await self.docket.schedule(execution)
|
|
517
|
+
|
|
518
|
+
TASKS_PERPETUATED.add(1, {**self.labels(), **execution.specific_labels()})
|
|
519
|
+
return True
|
|
520
|
+
|
|
484
521
|
@property
|
|
485
522
|
def workers_set(self) -> str:
|
|
486
523
|
return self.docket.workers_set
|
|
@@ -22,6 +22,7 @@ from docket import (
|
|
|
22
22
|
Execution,
|
|
23
23
|
ExponentialRetry,
|
|
24
24
|
Logged,
|
|
25
|
+
Perpetual,
|
|
25
26
|
Retry,
|
|
26
27
|
TaskKey,
|
|
27
28
|
TaskLogger,
|
|
@@ -854,3 +855,104 @@ async def test_adding_task_with_unbindable_arguments(
|
|
|
854
855
|
await worker.run_until_finished()
|
|
855
856
|
|
|
856
857
|
assert "got an unexpected keyword argument 'd'" in caplog.text
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
async def test_perpetual_tasks(docket: Docket, worker: Worker):
|
|
861
|
+
"""Perpetual tasks should reschedule themselves forever"""
|
|
862
|
+
|
|
863
|
+
calls = 0
|
|
864
|
+
|
|
865
|
+
async def perpetual_task(
|
|
866
|
+
a: str,
|
|
867
|
+
b: int,
|
|
868
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
869
|
+
):
|
|
870
|
+
assert a == "a"
|
|
871
|
+
assert b == 2
|
|
872
|
+
|
|
873
|
+
assert isinstance(perpetual, Perpetual)
|
|
874
|
+
|
|
875
|
+
assert perpetual.every == timedelta(milliseconds=50)
|
|
876
|
+
|
|
877
|
+
nonlocal calls
|
|
878
|
+
calls += 1
|
|
879
|
+
|
|
880
|
+
execution = await docket.add(perpetual_task)(a="a", b=2)
|
|
881
|
+
|
|
882
|
+
await worker.run_at_most({execution.key: 3})
|
|
883
|
+
|
|
884
|
+
assert calls == 3
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
async def test_perpetual_tasks_can_cancel_themselves(docket: Docket, worker: Worker):
|
|
888
|
+
"""A perpetual task can request its own cancellation"""
|
|
889
|
+
calls = 0
|
|
890
|
+
|
|
891
|
+
async def perpetual_task(
|
|
892
|
+
a: str,
|
|
893
|
+
b: int,
|
|
894
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
895
|
+
):
|
|
896
|
+
assert a == "a"
|
|
897
|
+
assert b == 2
|
|
898
|
+
|
|
899
|
+
assert isinstance(perpetual, Perpetual)
|
|
900
|
+
|
|
901
|
+
assert perpetual.every == timedelta(milliseconds=50)
|
|
902
|
+
|
|
903
|
+
nonlocal calls
|
|
904
|
+
calls += 1
|
|
905
|
+
|
|
906
|
+
if calls == 3:
|
|
907
|
+
perpetual.cancel()
|
|
908
|
+
|
|
909
|
+
await docket.add(perpetual_task)(a="a", b=2)
|
|
910
|
+
|
|
911
|
+
await worker.run_until_finished()
|
|
912
|
+
|
|
913
|
+
assert calls == 3
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
async def test_perpetual_tasks_can_change_their_parameters(
|
|
917
|
+
docket: Docket, worker: Worker
|
|
918
|
+
):
|
|
919
|
+
"""Perpetual tasks may change their parameters each time"""
|
|
920
|
+
arguments: list[tuple[str, int]] = []
|
|
921
|
+
|
|
922
|
+
async def perpetual_task(
|
|
923
|
+
a: str,
|
|
924
|
+
b: int,
|
|
925
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
926
|
+
):
|
|
927
|
+
arguments.append((a, b))
|
|
928
|
+
perpetual.perpetuate(a + "a", b=b + 1)
|
|
929
|
+
|
|
930
|
+
execution = await docket.add(perpetual_task)(a="a", b=1)
|
|
931
|
+
|
|
932
|
+
await worker.run_at_most({execution.key: 3})
|
|
933
|
+
|
|
934
|
+
assert len(arguments) == 3
|
|
935
|
+
assert arguments == [("a", 1), ("aa", 2), ("aaa", 3)]
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
async def test_perpetual_tasks_perpetuate_even_after_errors(
|
|
939
|
+
docket: Docket, worker: Worker
|
|
940
|
+
):
|
|
941
|
+
"""Perpetual tasks may change their parameters each time"""
|
|
942
|
+
calls = 0
|
|
943
|
+
|
|
944
|
+
async def perpetual_task(
|
|
945
|
+
a: str,
|
|
946
|
+
b: int,
|
|
947
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
948
|
+
):
|
|
949
|
+
nonlocal calls
|
|
950
|
+
calls += 1
|
|
951
|
+
|
|
952
|
+
raise ValueError("woops!")
|
|
953
|
+
|
|
954
|
+
execution = await docket.add(perpetual_task)(a="a", b=1)
|
|
955
|
+
|
|
956
|
+
await worker.run_at_most({execution.key: 3})
|
|
957
|
+
|
|
958
|
+
assert calls == 3
|
|
@@ -11,6 +11,7 @@ import redis.exceptions
|
|
|
11
11
|
from redis.asyncio import Redis
|
|
12
12
|
|
|
13
13
|
from docket import CurrentWorker, Docket, Worker
|
|
14
|
+
from docket.dependencies import CurrentDocket, Perpetual
|
|
14
15
|
from docket.docket import RedisMessage
|
|
15
16
|
from docket.tasks import standard_tasks
|
|
16
17
|
|
|
@@ -358,3 +359,58 @@ async def test_worker_recovers_from_redis_errors(
|
|
|
358
359
|
assert worker_info.last_seen > error_time, (
|
|
359
360
|
"Worker should have sent heartbeats after the Redis error"
|
|
360
361
|
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def test_perpetual_tasks_are_scheduled_close_to_target_time(
|
|
365
|
+
docket: Docket, worker: Worker
|
|
366
|
+
):
|
|
367
|
+
"""A perpetual task is scheduled as close to the target period as possible"""
|
|
368
|
+
timestamps: list[datetime] = []
|
|
369
|
+
|
|
370
|
+
async def perpetual_task(
|
|
371
|
+
a: str,
|
|
372
|
+
b: int,
|
|
373
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
374
|
+
):
|
|
375
|
+
timestamps.append(datetime.now(timezone.utc))
|
|
376
|
+
|
|
377
|
+
if len(timestamps) % 2 == 0:
|
|
378
|
+
await asyncio.sleep(0.05)
|
|
379
|
+
|
|
380
|
+
await docket.add(perpetual_task, key="my-key")(a="a", b=2)
|
|
381
|
+
|
|
382
|
+
await worker.run_at_most({"my-key": 8})
|
|
383
|
+
|
|
384
|
+
assert len(timestamps) == 8
|
|
385
|
+
|
|
386
|
+
intervals = [next - previous for previous, next in zip(timestamps, timestamps[1:])]
|
|
387
|
+
total = timedelta(seconds=sum(i.total_seconds() for i in intervals))
|
|
388
|
+
average = total / len(intervals)
|
|
389
|
+
|
|
390
|
+
# even with a variable duration, Docket attempts to schedule them equally
|
|
391
|
+
assert timedelta(milliseconds=45) <= average <= timedelta(milliseconds=70)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def test_worker_can_exit_from_perpetual_tasks_that_queue_further_tasks(
|
|
395
|
+
docket: Docket, worker: Worker
|
|
396
|
+
):
|
|
397
|
+
"""A worker can exit if it's processing a perpetual task that queues more tasks"""
|
|
398
|
+
|
|
399
|
+
inner_calls = 0
|
|
400
|
+
|
|
401
|
+
async def inner_task():
|
|
402
|
+
nonlocal inner_calls
|
|
403
|
+
inner_calls += 1
|
|
404
|
+
|
|
405
|
+
async def perpetual_task(
|
|
406
|
+
docket: Docket = CurrentDocket(),
|
|
407
|
+
perpetual: Perpetual = Perpetual(every=timedelta(milliseconds=50)),
|
|
408
|
+
):
|
|
409
|
+
await docket.add(inner_task)()
|
|
410
|
+
await docket.add(inner_task)()
|
|
411
|
+
|
|
412
|
+
execution = await docket.add(perpetual_task)()
|
|
413
|
+
|
|
414
|
+
await worker.run_at_most({execution.key: 3})
|
|
415
|
+
|
|
416
|
+
assert inner_calls == 6
|
|
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
|