pydocket 0.2.1__py3-none-any.whl → 0.3.1__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/__init__.py CHANGED
@@ -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
  ]
docket/dependencies.py CHANGED
@@ -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]:
docket/execution.py CHANGED
@@ -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)
docket/instrumentation.py CHANGED
@@ -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",
docket/worker.py CHANGED
@@ -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,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
@@ -0,0 +1,16 @@
1
+ docket/__init__.py,sha256=7oruGALDoU6W_ntF-mMxxv3FFtO970DVzj3lUgoVIiM,775
2
+ docket/__main__.py,sha256=Vkuh7aJ-Bl7QVpVbbkUksAd_hn05FiLmWbc-8kbhZQ4,34
3
+ docket/annotations.py,sha256=GZwOPtPXyeIhnsLh3TQMBnXrjtTtSmF4Ratv4vjPx8U,950
4
+ docket/cli.py,sha256=EseF0Sj7IEgd9QDC-FSbHSffvF7DNsrmDGYGgZBdJc8,19413
5
+ docket/dependencies.py,sha256=S3KqXxEF0Q2t_jO3R-kI5IIA3M-tqybtiSod2xnRO4o,4991
6
+ docket/docket.py,sha256=zva6ofTm7i5hRwAaAnNtlgIqoMPaNLqCTs2PXGka_8s,19723
7
+ docket/execution.py,sha256=PDrlAr8VzmB6JvqKO71YhXUcTcGQW7eyXrSKiTcAexE,12508
8
+ docket/instrumentation.py,sha256=bZlGA02JoJcY0J1WGm5_qXDfY0AXKr0ZLAYu67wkeKY,4611
9
+ docket/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ docket/tasks.py,sha256=RIlSM2omh-YDwVnCz6M5MtmK8T_m_s1w2OlRRxDUs6A,1437
11
+ docket/worker.py,sha256=AO5k7rn5xHLGN0L6vckQuMyt_LSHh01me_LqNtsobtc,21786
12
+ pydocket-0.3.1.dist-info/METADATA,sha256=tIHiKQjQ-to6bp437vH3-2LsUPnfUP-iG61FAcQa2GE,13092
13
+ pydocket-0.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ pydocket-0.3.1.dist-info/entry_points.txt,sha256=4WOk1nUlBsUT5O3RyMci2ImuC5XFswuopElYcLHtD5k,47
15
+ pydocket-0.3.1.dist-info/licenses/LICENSE,sha256=YuVWU_ZXO0K_k2FG8xWKe5RGxV24AhJKTvQmKfqXuyk,1087
16
+ pydocket-0.3.1.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- docket/__init__.py,sha256=GoJYpyuO6QFeBB8GNaxGGvMMuai55Eaw_8u-o1PM3hk,743
2
- docket/__main__.py,sha256=Vkuh7aJ-Bl7QVpVbbkUksAd_hn05FiLmWbc-8kbhZQ4,34
3
- docket/annotations.py,sha256=GZwOPtPXyeIhnsLh3TQMBnXrjtTtSmF4Ratv4vjPx8U,950
4
- docket/cli.py,sha256=EseF0Sj7IEgd9QDC-FSbHSffvF7DNsrmDGYGgZBdJc8,19413
5
- docket/dependencies.py,sha256=gIDwcBUhrLk7xGh0ZxdqpsnSeX-hZzGMNvUrVFfqbJI,4281
6
- docket/docket.py,sha256=zva6ofTm7i5hRwAaAnNtlgIqoMPaNLqCTs2PXGka_8s,19723
7
- docket/execution.py,sha256=ShP8MoLmxEslk2pAuhKi6KEEKbHdneyQukR9oQwXdjQ,11732
8
- docket/instrumentation.py,sha256=SUVhVFf8AX2HAfmi0HPTT_QvQezlGPJEKs_1YAmrCbA,4454
9
- docket/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- docket/tasks.py,sha256=RIlSM2omh-YDwVnCz6M5MtmK8T_m_s1w2OlRRxDUs6A,1437
11
- docket/worker.py,sha256=DH15hW8QBGHaZdOdkpH7bjYtLEydi4sGh-Ei8lEXGOo,20556
12
- pydocket-0.2.1.dist-info/METADATA,sha256=9DxwXrPzeTCOlxDGn9JUOzQN-k6OjhAJbiRPeMhcNNo,13092
13
- pydocket-0.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
- pydocket-0.2.1.dist-info/entry_points.txt,sha256=4WOk1nUlBsUT5O3RyMci2ImuC5XFswuopElYcLHtD5k,47
15
- pydocket-0.2.1.dist-info/licenses/LICENSE,sha256=YuVWU_ZXO0K_k2FG8xWKe5RGxV24AhJKTvQmKfqXuyk,1087
16
- pydocket-0.2.1.dist-info/RECORD,,