pydocket 0.2.1__tar.gz → 0.3.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.

Files changed (51) hide show
  1. {pydocket-0.2.1 → pydocket-0.3.1}/.pre-commit-config.yaml +2 -2
  2. {pydocket-0.2.1 → pydocket-0.3.1}/PKG-INFO +1 -1
  3. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/__init__.py +2 -0
  4. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/dependencies.py +28 -0
  5. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/execution.py +16 -1
  6. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/instrumentation.py +6 -0
  7. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/worker.py +55 -15
  8. {pydocket-0.2.1 → pydocket-0.3.1}/tests/conftest.py +7 -0
  9. {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_fundamentals.py +102 -0
  10. {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_worker.py +78 -0
  11. {pydocket-0.2.1 → pydocket-0.3.1}/.cursor/rules/general.mdc +0 -0
  12. {pydocket-0.2.1 → pydocket-0.3.1}/.cursor/rules/python-style.mdc +0 -0
  13. {pydocket-0.2.1 → pydocket-0.3.1}/.github/codecov.yml +0 -0
  14. {pydocket-0.2.1 → pydocket-0.3.1}/.github/workflows/chaos.yml +0 -0
  15. {pydocket-0.2.1 → pydocket-0.3.1}/.github/workflows/ci.yml +0 -0
  16. {pydocket-0.2.1 → pydocket-0.3.1}/.github/workflows/publish.yml +0 -0
  17. {pydocket-0.2.1 → pydocket-0.3.1}/.gitignore +0 -0
  18. {pydocket-0.2.1 → pydocket-0.3.1}/LICENSE +0 -0
  19. {pydocket-0.2.1 → pydocket-0.3.1}/README.md +0 -0
  20. {pydocket-0.2.1 → pydocket-0.3.1}/chaos/README.md +0 -0
  21. {pydocket-0.2.1 → pydocket-0.3.1}/chaos/__init__.py +0 -0
  22. {pydocket-0.2.1 → pydocket-0.3.1}/chaos/driver.py +0 -0
  23. {pydocket-0.2.1 → pydocket-0.3.1}/chaos/producer.py +0 -0
  24. {pydocket-0.2.1 → pydocket-0.3.1}/chaos/run +0 -0
  25. {pydocket-0.2.1 → pydocket-0.3.1}/chaos/tasks.py +0 -0
  26. {pydocket-0.2.1 → pydocket-0.3.1}/pyproject.toml +0 -0
  27. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/__main__.py +0 -0
  28. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/annotations.py +0 -0
  29. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/cli.py +0 -0
  30. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/docket.py +0 -0
  31. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/py.typed +0 -0
  32. {pydocket-0.2.1 → pydocket-0.3.1}/src/docket/tasks.py +0 -0
  33. {pydocket-0.2.1 → pydocket-0.3.1}/telemetry/.gitignore +0 -0
  34. {pydocket-0.2.1 → pydocket-0.3.1}/telemetry/start +0 -0
  35. {pydocket-0.2.1 → pydocket-0.3.1}/telemetry/stop +0 -0
  36. {pydocket-0.2.1 → pydocket-0.3.1}/tests/__init__.py +0 -0
  37. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/__init__.py +0 -0
  38. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/conftest.py +0 -0
  39. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_module.py +0 -0
  40. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_parsing.py +0 -0
  41. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_snapshot.py +0 -0
  42. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_striking.py +0 -0
  43. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_tasks.py +0 -0
  44. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_version.py +0 -0
  45. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_worker.py +0 -0
  46. {pydocket-0.2.1 → pydocket-0.3.1}/tests/cli/test_workers.py +0 -0
  47. {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_dependencies.py +0 -0
  48. {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_docket.py +0 -0
  49. {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_instrumentation.py +0 -0
  50. {pydocket-0.2.1 → pydocket-0.3.1}/tests/test_striking.py +0 -0
  51. {pydocket-0.2.1 → pydocket-0.3.1}/uv.lock +0 -0
@@ -24,13 +24,13 @@ repos:
24
24
  hooks:
25
25
  - id: pyright
26
26
  name: pyright (docket package)
27
- entry: uv run pyright --verifytypes docket --ignoreexternal
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: uv run pyright tests
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.2.1
3
+ Version: 0.3.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
@@ -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]:
@@ -183,18 +183,33 @@ TaskStrikes = dict[str, ParameterStrikes]
183
183
  class StrikeList:
184
184
  task_strikes: TaskStrikes
185
185
  parameter_strikes: ParameterStrikes
186
+ _conditions: list[Callable[[Execution], bool]]
186
187
 
187
188
  def __init__(self) -> None:
188
189
  self.task_strikes = {}
189
190
  self.parameter_strikes = {}
191
+ self._conditions = [self._matches_task_or_parameter_strike]
192
+
193
+ def add_condition(self, condition: Callable[[Execution], bool]) -> None:
194
+ """Adds a temporary condition that indicates an execution is stricken."""
195
+ self._conditions.insert(0, condition)
196
+
197
+ def remove_condition(self, condition: Callable[[Execution], bool]) -> None:
198
+ """Adds a temporary condition that indicates an execution is stricken."""
199
+ assert condition is not self._matches_task_or_parameter_strike
200
+ self._conditions.remove(condition)
190
201
 
191
202
  def is_stricken(self, execution: Execution) -> bool:
192
203
  """
193
- Checks if an execution is stricken based on task name or parameter values.
204
+ Checks if an execution is stricken based on task, parameter, or temporary
205
+ conditions.
194
206
 
195
207
  Returns:
196
208
  bool: True if the execution is stricken, False otherwise.
197
209
  """
210
+ return any(condition(execution) for condition in self._conditions)
211
+
212
+ def _matches_task_or_parameter_strike(self, execution: Execution) -> bool:
198
213
  function_name = execution.function.__name__
199
214
 
200
215
  # Check if the entire task is stricken (without parameter conditions)
@@ -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",
@@ -7,7 +7,6 @@ from types import TracebackType
7
7
  from typing import (
8
8
  TYPE_CHECKING,
9
9
  Any,
10
- Callable,
11
10
  Mapping,
12
11
  Protocol,
13
12
  Self,
@@ -36,6 +35,7 @@ from .instrumentation import (
36
35
  TASK_PUNCTUALITY,
37
36
  TASKS_COMPLETED,
38
37
  TASKS_FAILED,
38
+ TASKS_PERPETUATED,
39
39
  TASKS_RETRIED,
40
40
  TASKS_RUNNING,
41
41
  TASKS_STARTED,
@@ -68,7 +68,6 @@ class Worker:
68
68
  redelivery_timeout: timedelta
69
69
  reconnection_delay: timedelta
70
70
  minimum_check_interval: timedelta
71
- _strike_conditions: list[Callable[[Execution], bool]] = []
72
71
 
73
72
  def __init__(
74
73
  self,
@@ -86,13 +85,9 @@ class Worker:
86
85
  self.reconnection_delay = reconnection_delay
87
86
  self.minimum_check_interval = minimum_check_interval
88
87
 
89
- self._strike_conditions = [
90
- docket.strike_list.is_stricken,
91
- ]
92
-
93
88
  async def __aenter__(self) -> Self:
94
89
  self._heartbeat_task = asyncio.create_task(self._heartbeat())
95
-
90
+ self._execution_counts = {}
96
91
  return self
97
92
 
98
93
  async def __aexit__(
@@ -101,6 +96,8 @@ class Worker:
101
96
  exc_value: BaseException | None,
102
97
  traceback: TracebackType | None,
103
98
  ) -> None:
99
+ del self._execution_counts
100
+
104
101
  self._heartbeat_task.cancel()
105
102
  try:
106
103
  await self._heartbeat_task
@@ -161,6 +158,8 @@ class Worker:
161
158
  """Run the worker indefinitely."""
162
159
  return await self._run(forever=True) # pragma: no cover
163
160
 
161
+ _execution_counts: dict[str, int]
162
+
164
163
  async def run_at_most(self, iterations_by_key: Mapping[str, int]) -> None:
165
164
  """
166
165
  Run the worker until there are no more tasks to process, but limit specified
@@ -172,23 +171,25 @@ class Worker:
172
171
  Args:
173
172
  iterations_by_key: Maps task keys to their maximum allowed executions
174
173
  """
175
- execution_counts: dict[str, int] = {key: 0 for key in iterations_by_key}
174
+ self._execution_counts = {key: 0 for key in iterations_by_key}
176
175
 
177
176
  def has_reached_max_iterations(execution: Execution) -> bool:
178
- if execution.key not in iterations_by_key:
177
+ key = execution.key
178
+
179
+ if key not in iterations_by_key:
179
180
  return False
180
181
 
181
- if execution_counts[execution.key] >= iterations_by_key[execution.key]:
182
+ if self._execution_counts[key] >= iterations_by_key[key]:
182
183
  return True
183
184
 
184
- execution_counts[execution.key] += 1
185
185
  return False
186
186
 
187
- self._strike_conditions.insert(0, has_reached_max_iterations)
187
+ self.docket.strike_list.add_condition(has_reached_max_iterations)
188
188
  try:
189
189
  await self.run_until_finished()
190
190
  finally:
191
- self._strike_conditions.remove(has_reached_max_iterations)
191
+ self.docket.strike_list.remove_condition(has_reached_max_iterations)
192
+ self._execution_counts = {}
192
193
 
193
194
  async def _run(self, forever: bool = False) -> None:
194
195
  logger.info("Starting worker %r with the following tasks:", self.name)
@@ -379,12 +380,15 @@ class Worker:
379
380
  arrow = "↬" if execution.attempt > 1 else "↪"
380
381
  call = execution.call_repr()
381
382
 
382
- if any(condition(execution) for condition in self._strike_conditions):
383
+ if self.docket.strike_list.is_stricken(execution):
383
384
  arrow = "🗙"
384
385
  logger.warning("%s %s", arrow, call, extra=log_context)
385
386
  TASKS_STRICKEN.add(1, counter_labels | {"docket.where": "worker"})
386
387
  return
387
388
 
389
+ if execution.key in self._execution_counts:
390
+ self._execution_counts[execution.key] += 1
391
+
388
392
  dependencies = self._get_dependencies(execution)
389
393
 
390
394
  context = propagate.extract(message, getter=message_getter)
@@ -424,12 +428,20 @@ class Worker:
424
428
  TASKS_SUCCEEDED.add(1, counter_labels)
425
429
  duration = datetime.now(timezone.utc) - start
426
430
  log_context["duration"] = duration.total_seconds()
427
- logger.info("%s [%s] %s", "↩", duration, call, extra=log_context)
431
+ rescheduled = await self._perpetuate_if_requested(
432
+ execution, dependencies, duration
433
+ )
434
+ arrow = "↫" if rescheduled else "↩"
435
+ logger.info("%s [%s] %s", arrow, duration, call, extra=log_context)
428
436
  except Exception:
429
437
  TASKS_FAILED.add(1, counter_labels)
430
438
  duration = datetime.now(timezone.utc) - start
431
439
  log_context["duration"] = duration.total_seconds()
432
440
  retried = await self._retry_if_requested(execution, dependencies)
441
+ if not retried:
442
+ retried = await self._perpetuate_if_requested(
443
+ execution, dependencies, duration
444
+ )
433
445
  arrow = "↫" if retried else "↩"
434
446
  logger.exception("%s [%s] %s", arrow, duration, call, extra=log_context)
435
447
  finally:
@@ -481,6 +493,34 @@ class Worker:
481
493
 
482
494
  return False
483
495
 
496
+ async def _perpetuate_if_requested(
497
+ self, execution: Execution, dependencies: dict[str, Any], duration: timedelta
498
+ ) -> bool:
499
+ from .dependencies import Perpetual
500
+
501
+ perpetuals = [
502
+ perpetual
503
+ for perpetual in dependencies.values()
504
+ if isinstance(perpetual, Perpetual)
505
+ ]
506
+ if not perpetuals:
507
+ return False
508
+
509
+ perpetual = perpetuals[0]
510
+
511
+ if perpetual.cancelled:
512
+ return False
513
+
514
+ now = datetime.now(timezone.utc)
515
+ execution.when = max(now, now + perpetual.every - duration)
516
+ execution.args = perpetual.args
517
+ execution.kwargs = perpetual.kwargs
518
+
519
+ await self.docket.schedule(execution)
520
+
521
+ TASKS_PERPETUATED.add(1, {**self.labels(), **execution.specific_labels()})
522
+ return True
523
+
484
524
  @property
485
525
  def workers_set(self) -> str:
486
526
  return self.docket.workers_set
@@ -1,4 +1,5 @@
1
1
  import fcntl
2
+ import logging
2
3
  import os
3
4
  import socket
4
5
  import time
@@ -20,6 +21,12 @@ from docket import Docket, Worker
20
21
  REDIS_VERSION = os.environ.get("REDIS_VERSION", "7.4")
21
22
 
22
23
 
24
+ @pytest.fixture(autouse=True)
25
+ def log_level(caplog: pytest.LogCaptureFixture) -> Generator[None, None, None]:
26
+ with caplog.at_level(logging.DEBUG):
27
+ yield
28
+
29
+
23
30
  @pytest.fixture
24
31
  def now() -> Callable[[], datetime]:
25
32
  return partial(datetime.now, timezone.utc)
@@ -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,80 @@ 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
417
+
418
+
419
+ async def test_worker_can_exit_from_long_horizon_perpetual_tasks(
420
+ docket: Docket, worker: Worker
421
+ ):
422
+ """A worker can exit in a timely manner from a perpetual task that has a long
423
+ horizon because it is stricken on both execution and rescheduling"""
424
+ calls: int = 0
425
+
426
+ async def perpetual_task(
427
+ a: str,
428
+ b: int,
429
+ perpetual: Perpetual = Perpetual(every=timedelta(weeks=37)),
430
+ ):
431
+ nonlocal calls
432
+ calls += 1
433
+
434
+ await docket.add(perpetual_task, key="my-key")(a="a", b=2)
435
+
436
+ await worker.run_at_most({"my-key": 1})
437
+
438
+ assert calls == 1
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