pydocket 0.3.2__tar.gz → 0.4.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.

Files changed (51) hide show
  1. {pydocket-0.3.2 → pydocket-0.4.0}/PKG-INFO +1 -1
  2. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/docket.py +23 -11
  3. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/worker.py +3 -0
  4. {pydocket-0.3.2 → pydocket-0.4.0}/tests/test_fundamentals.py +92 -11
  5. {pydocket-0.3.2 → pydocket-0.4.0}/tests/test_worker.py +1 -1
  6. {pydocket-0.3.2 → pydocket-0.4.0}/.cursor/rules/general.mdc +0 -0
  7. {pydocket-0.3.2 → pydocket-0.4.0}/.cursor/rules/python-style.mdc +0 -0
  8. {pydocket-0.3.2 → pydocket-0.4.0}/.github/codecov.yml +0 -0
  9. {pydocket-0.3.2 → pydocket-0.4.0}/.github/workflows/chaos.yml +0 -0
  10. {pydocket-0.3.2 → pydocket-0.4.0}/.github/workflows/ci.yml +0 -0
  11. {pydocket-0.3.2 → pydocket-0.4.0}/.github/workflows/publish.yml +0 -0
  12. {pydocket-0.3.2 → pydocket-0.4.0}/.gitignore +0 -0
  13. {pydocket-0.3.2 → pydocket-0.4.0}/.pre-commit-config.yaml +0 -0
  14. {pydocket-0.3.2 → pydocket-0.4.0}/LICENSE +0 -0
  15. {pydocket-0.3.2 → pydocket-0.4.0}/README.md +0 -0
  16. {pydocket-0.3.2 → pydocket-0.4.0}/chaos/README.md +0 -0
  17. {pydocket-0.3.2 → pydocket-0.4.0}/chaos/__init__.py +0 -0
  18. {pydocket-0.3.2 → pydocket-0.4.0}/chaos/driver.py +0 -0
  19. {pydocket-0.3.2 → pydocket-0.4.0}/chaos/producer.py +0 -0
  20. {pydocket-0.3.2 → pydocket-0.4.0}/chaos/run +0 -0
  21. {pydocket-0.3.2 → pydocket-0.4.0}/chaos/tasks.py +0 -0
  22. {pydocket-0.3.2 → pydocket-0.4.0}/pyproject.toml +0 -0
  23. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/__init__.py +0 -0
  24. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/__main__.py +0 -0
  25. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/annotations.py +0 -0
  26. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/cli.py +0 -0
  27. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/dependencies.py +0 -0
  28. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/execution.py +0 -0
  29. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/instrumentation.py +0 -0
  30. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/py.typed +0 -0
  31. {pydocket-0.3.2 → pydocket-0.4.0}/src/docket/tasks.py +0 -0
  32. {pydocket-0.3.2 → pydocket-0.4.0}/telemetry/.gitignore +0 -0
  33. {pydocket-0.3.2 → pydocket-0.4.0}/telemetry/start +0 -0
  34. {pydocket-0.3.2 → pydocket-0.4.0}/telemetry/stop +0 -0
  35. {pydocket-0.3.2 → pydocket-0.4.0}/tests/__init__.py +0 -0
  36. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/__init__.py +0 -0
  37. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/conftest.py +0 -0
  38. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/test_module.py +0 -0
  39. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/test_parsing.py +0 -0
  40. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/test_snapshot.py +0 -0
  41. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/test_striking.py +0 -0
  42. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/test_tasks.py +0 -0
  43. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/test_version.py +0 -0
  44. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/test_worker.py +0 -0
  45. {pydocket-0.3.2 → pydocket-0.4.0}/tests/cli/test_workers.py +0 -0
  46. {pydocket-0.3.2 → pydocket-0.4.0}/tests/conftest.py +0 -0
  47. {pydocket-0.3.2 → pydocket-0.4.0}/tests/test_dependencies.py +0 -0
  48. {pydocket-0.3.2 → pydocket-0.4.0}/tests/test_docket.py +0 -0
  49. {pydocket-0.3.2 → pydocket-0.4.0}/tests/test_instrumentation.py +0 -0
  50. {pydocket-0.3.2 → pydocket-0.4.0}/tests/test_striking.py +0 -0
  51. {pydocket-0.3.2 → pydocket-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydocket
3
- Version: 0.3.2
3
+ Version: 0.4.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
@@ -132,6 +132,9 @@ class Docket:
132
132
  - "redis://user:password@localhost:6379/0?ssl=true"
133
133
  - "rediss://localhost:6379/0"
134
134
  - "unix:///path/to/redis.sock"
135
+ heartbeat_interval: How often workers send heartbeat messages to the docket.
136
+ missed_heartbeats: How many heartbeats a worker can miss before it is
137
+ considered dead.
135
138
  """
136
139
  self.name = name
137
140
  self.url = url
@@ -305,6 +308,9 @@ class Docket:
305
308
  def stream_key(self) -> str:
306
309
  return f"{self.name}:stream"
307
310
 
311
+ def known_task_key(self, key: str) -> str:
312
+ return f"{self.name}:known:{key}"
313
+
308
314
  def parked_task_key(self, key: str) -> str:
309
315
  return f"{self.name}:{key}"
310
316
 
@@ -340,30 +346,36 @@ class Docket:
340
346
  when = execution.when
341
347
 
342
348
  async with self.redis() as redis:
343
- # if the task is already in the queue, retain it
344
- if await redis.zscore(self.queue_key, key) is not None:
349
+ # if the task is already in the queue or stream, retain it
350
+ if await redis.exists(self.known_task_key(key)):
351
+ logger.debug(
352
+ "Task %r is already in the queue or stream, skipping schedule",
353
+ key,
354
+ extra=self.labels(),
355
+ )
345
356
  return
346
357
 
347
- if when <= datetime.now(timezone.utc):
348
- await redis.xadd(self.stream_key, message) # type: ignore[arg-type]
349
- else:
350
- async with redis.pipeline() as pipe:
358
+ async with redis.pipeline() as pipe:
359
+ pipe.set(self.known_task_key(key), when.timestamp())
360
+
361
+ if when <= datetime.now(timezone.utc):
362
+ pipe.xadd(self.stream_key, message) # type: ignore[arg-type]
363
+ else:
351
364
  pipe.hset(self.parked_task_key(key), mapping=message) # type: ignore[arg-type]
352
365
  pipe.zadd(self.queue_key, {key: when.timestamp()})
353
- await pipe.execute()
366
+
367
+ await pipe.execute()
354
368
 
355
369
  TASKS_SCHEDULED.add(1, {**self.labels(), **execution.general_labels()})
356
370
 
357
371
  async def cancel(self, key: str) -> None:
358
372
  with tracer.start_as_current_span(
359
373
  "docket.cancel",
360
- attributes={
361
- **self.labels(),
362
- "docket.key": key,
363
- },
374
+ attributes={**self.labels(), "docket.key": key},
364
375
  ):
365
376
  async with self.redis() as redis:
366
377
  async with redis.pipeline() as pipe:
378
+ pipe.delete(self.known_task_key(key))
367
379
  pipe.delete(self.parked_task_key(key))
368
380
  pipe.zrem(self.queue_key, key)
369
381
  await pipe.execute()
@@ -377,6 +377,9 @@ class Worker:
377
377
 
378
378
  execution = Execution.from_message(function, message)
379
379
 
380
+ async with self.docket.redis() as redis:
381
+ await redis.delete(self.docket.known_task_key(execution.key))
382
+
380
383
  log_context = {**log_context, **execution.specific_labels()}
381
384
  counter_labels = {**self.labels(), **execution.general_labels()}
382
385
 
@@ -79,7 +79,7 @@ async def test_scheduled_execution(
79
79
  assert when <= now()
80
80
 
81
81
 
82
- async def test_adding_is_itempotent(
82
+ async def test_adding_is_idempotent(
83
83
  docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
84
84
  ):
85
85
  """docket should allow for rescheduling a task for later"""
@@ -159,6 +159,77 @@ async def test_rescheduling_by_name(
159
159
  assert later <= now()
160
160
 
161
161
 
162
+ async def test_task_keys_are_idempotent_in_the_future(
163
+ docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
164
+ ):
165
+ """docket should only allow one task with the same key to be scheduled or due"""
166
+
167
+ key = f"my-cool-task:{uuid4()}"
168
+
169
+ soon = now() + timedelta(milliseconds=10)
170
+ await docket.add(the_task, when=soon, key=key)("a", "b", c="c")
171
+ await docket.add(the_task, when=now(), key=key)("d", "e", c="f")
172
+
173
+ await worker.run_until_finished()
174
+
175
+ the_task.assert_awaited_once_with("a", "b", c="c")
176
+ the_task.reset_mock()
177
+
178
+ # It should be fine to run it afterward
179
+ await docket.add(the_task, key=key)("d", "e", c="f")
180
+
181
+ await worker.run_until_finished()
182
+
183
+ the_task.assert_awaited_once_with("d", "e", c="f")
184
+
185
+
186
+ async def test_task_keys_are_idempotent_between_the_future_and_present(
187
+ docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
188
+ ):
189
+ """docket should only allow one task with the same key to be scheduled or due"""
190
+
191
+ key = f"my-cool-task:{uuid4()}"
192
+
193
+ soon = now() + timedelta(milliseconds=10)
194
+ await docket.add(the_task, when=now(), key=key)("a", "b", c="c")
195
+ await docket.add(the_task, when=soon, key=key)("d", "e", c="f")
196
+
197
+ await worker.run_until_finished()
198
+
199
+ the_task.assert_awaited_once_with("a", "b", c="c")
200
+ the_task.reset_mock()
201
+
202
+ # It should be fine to run it afterward
203
+ await docket.add(the_task, key=key)("d", "e", c="f")
204
+
205
+ await worker.run_until_finished()
206
+
207
+ the_task.assert_awaited_once_with("d", "e", c="f")
208
+
209
+
210
+ async def test_task_keys_are_idempotent_in_the_present(
211
+ docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
212
+ ):
213
+ """docket should only allow one task with the same key to be scheduled or due"""
214
+
215
+ key = f"my-cool-task:{uuid4()}"
216
+
217
+ await docket.add(the_task, when=now(), key=key)("a", "b", c="c")
218
+ await docket.add(the_task, when=now(), key=key)("d", "e", c="f")
219
+
220
+ await worker.run_until_finished()
221
+
222
+ the_task.assert_awaited_once_with("a", "b", c="c")
223
+ the_task.reset_mock()
224
+
225
+ # It should be fine to run it afterward
226
+ await docket.add(the_task, key=key)("d", "e", c="f")
227
+
228
+ await worker.run_until_finished()
229
+
230
+ the_task.assert_awaited_once_with("d", "e", c="f")
231
+
232
+
162
233
  async def test_cancelling_future_task(
163
234
  docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
164
235
  ):
@@ -696,7 +767,8 @@ async def test_striking_entire_parameters(
696
767
  call(customer_id="123", order_id="456"),
697
768
  call(customer_id="456", order_id="789"),
698
769
  # customer_id == 789 is stricken
699
- ]
770
+ ],
771
+ any_order=True,
700
772
  )
701
773
  the_task.reset_mock()
702
774
 
@@ -705,7 +777,8 @@ async def test_striking_entire_parameters(
705
777
  [
706
778
  call(customer_id="456", order_id="012"),
707
779
  # customer_id == 789 is stricken
708
- ]
780
+ ],
781
+ any_order=True,
709
782
  )
710
783
  another_task.reset_mock()
711
784
 
@@ -725,7 +798,8 @@ async def test_striking_entire_parameters(
725
798
  # customer_id == 123 is stricken
726
799
  call(customer_id="456", order_id="789"),
727
800
  # customer_id == 789 is stricken
728
- ]
801
+ ],
802
+ any_order=True,
729
803
  )
730
804
  the_task.reset_mock()
731
805
 
@@ -734,7 +808,8 @@ async def test_striking_entire_parameters(
734
808
  [
735
809
  call(customer_id="456", order_id="012"),
736
810
  # customer_id == 789 is stricken
737
- ]
811
+ ],
812
+ any_order=True,
738
813
  )
739
814
  another_task.reset_mock()
740
815
 
@@ -754,7 +829,8 @@ async def test_striking_entire_parameters(
754
829
  call(customer_id="123", order_id="456"),
755
830
  call(customer_id="456", order_id="789"),
756
831
  # customer_id == 789 is still stricken
757
- ]
832
+ ],
833
+ any_order=True,
758
834
  )
759
835
 
760
836
  assert another_task.call_count == 1
@@ -762,7 +838,8 @@ async def test_striking_entire_parameters(
762
838
  [
763
839
  call(customer_id="456", order_id="012"),
764
840
  # customer_id == 789 is still stricken
765
- ]
841
+ ],
842
+ any_order=True,
766
843
  )
767
844
 
768
845
 
@@ -787,7 +864,8 @@ async def test_striking_tasks_for_specific_parameters(
787
864
  # b <= 2 is stricken, so b=1 is out
788
865
  # b <= 2 is stricken, so b=2 is out
789
866
  call("a", b=3),
790
- ]
867
+ ],
868
+ any_order=True,
791
869
  )
792
870
  the_task.reset_mock()
793
871
 
@@ -797,7 +875,8 @@ async def test_striking_tasks_for_specific_parameters(
797
875
  call("d", b=1),
798
876
  call("d", b=2),
799
877
  call("d", b=3),
800
- ]
878
+ ],
879
+ any_order=True,
801
880
  )
802
881
  another_task.reset_mock()
803
882
 
@@ -818,7 +897,8 @@ async def test_striking_tasks_for_specific_parameters(
818
897
  call("a", b=1),
819
898
  call("a", b=2),
820
899
  call("a", b=3),
821
- ]
900
+ ],
901
+ any_order=True,
822
902
  )
823
903
 
824
904
  assert another_task.call_count == 3
@@ -827,7 +907,8 @@ async def test_striking_tasks_for_specific_parameters(
827
907
  call("d", b=1),
828
908
  call("d", b=2),
829
909
  call("d", b=3),
830
- ]
910
+ ],
911
+ any_order=True,
831
912
  )
832
913
 
833
914
 
@@ -388,7 +388,7 @@ async def test_perpetual_tasks_are_scheduled_close_to_target_time(
388
388
  average = total / len(intervals)
389
389
 
390
390
  # even with a variable duration, Docket attempts to schedule them equally
391
- assert timedelta(milliseconds=45) <= average <= timedelta(milliseconds=70)
391
+ assert timedelta(milliseconds=45) <= average <= timedelta(milliseconds=75)
392
392
 
393
393
 
394
394
  async def test_worker_can_exit_from_perpetual_tasks_that_queue_further_tasks(
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