pydocket 0.4.0__py3-none-any.whl → 0.5.0__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/cli.py CHANGED
@@ -162,6 +162,7 @@ def worker(
162
162
  "This can be specified multiple times. A task collection is any "
163
163
  "iterable of async functions."
164
164
  ),
165
+ envvar="DOCKET_TASKS",
165
166
  ),
166
167
  ] = ["docket.tasks:standard_tasks"],
167
168
  docket_: Annotated[
@@ -236,6 +237,14 @@ def worker(
236
237
  envvar="DOCKET_WORKER_MINIMUM_CHECK_INTERVAL",
237
238
  ),
238
239
  ] = timedelta(milliseconds=100),
240
+ scheduling_resolution: Annotated[
241
+ timedelta,
242
+ typer.Option(
243
+ parser=duration,
244
+ help="How frequently to check for future tasks to be scheduled",
245
+ envvar="DOCKET_WORKER_SCHEDULING_RESOLUTION",
246
+ ),
247
+ ] = timedelta(milliseconds=250),
239
248
  until_finished: Annotated[
240
249
  bool,
241
250
  typer.Option(
@@ -260,6 +269,7 @@ def worker(
260
269
  redelivery_timeout=redelivery_timeout,
261
270
  reconnection_delay=reconnection_delay,
262
271
  minimum_check_interval=minimum_check_interval,
272
+ scheduling_resolution=scheduling_resolution,
263
273
  until_finished=until_finished,
264
274
  metrics_port=metrics_port,
265
275
  tasks=tasks,
@@ -542,6 +552,18 @@ def relative_time(now: datetime, when: datetime) -> str:
542
552
 
543
553
  @app.command(help="Shows a snapshot of what's on the docket right now")
544
554
  def snapshot(
555
+ tasks: Annotated[
556
+ list[str],
557
+ typer.Option(
558
+ "--tasks",
559
+ help=(
560
+ "The dotted path of a task collection to register with the docket. "
561
+ "This can be specified multiple times. A task collection is any "
562
+ "iterable of async functions."
563
+ ),
564
+ envvar="DOCKET_TASKS",
565
+ ),
566
+ ] = ["docket.tasks:standard_tasks"],
545
567
  docket_: Annotated[
546
568
  str,
547
569
  typer.Option(
@@ -560,6 +582,9 @@ def snapshot(
560
582
  ) -> None:
561
583
  async def run() -> DocketSnapshot:
562
584
  async with Docket(name=docket_, url=url) as docket:
585
+ for task_path in tasks:
586
+ docket.register_collection(task_path)
587
+
563
588
  return await docket.snapshot()
564
589
 
565
590
  snapshot = asyncio.run(run())
docket/worker.py CHANGED
@@ -18,6 +18,7 @@ from uuid import uuid4
18
18
  import redis.exceptions
19
19
  from opentelemetry import propagate, trace
20
20
  from opentelemetry.trace import Tracer
21
+ from redis.asyncio import Redis
21
22
 
22
23
  from .docket import (
23
24
  Docket,
@@ -68,6 +69,7 @@ class Worker:
68
69
  redelivery_timeout: timedelta
69
70
  reconnection_delay: timedelta
70
71
  minimum_check_interval: timedelta
72
+ scheduling_resolution: timedelta
71
73
 
72
74
  def __init__(
73
75
  self,
@@ -77,6 +79,7 @@ class Worker:
77
79
  redelivery_timeout: timedelta = timedelta(minutes=5),
78
80
  reconnection_delay: timedelta = timedelta(seconds=5),
79
81
  minimum_check_interval: timedelta = timedelta(milliseconds=100),
82
+ scheduling_resolution: timedelta = timedelta(milliseconds=250),
80
83
  ) -> None:
81
84
  self.docket = docket
82
85
  self.name = name or f"worker:{uuid4()}"
@@ -84,6 +87,7 @@ class Worker:
84
87
  self.redelivery_timeout = redelivery_timeout
85
88
  self.reconnection_delay = reconnection_delay
86
89
  self.minimum_check_interval = minimum_check_interval
90
+ self.scheduling_resolution = scheduling_resolution
87
91
 
88
92
  async def __aenter__(self) -> Self:
89
93
  self._heartbeat_task = asyncio.create_task(self._heartbeat())
@@ -128,6 +132,7 @@ class Worker:
128
132
  redelivery_timeout: timedelta = timedelta(minutes=5),
129
133
  reconnection_delay: timedelta = timedelta(seconds=5),
130
134
  minimum_check_interval: timedelta = timedelta(milliseconds=100),
135
+ scheduling_resolution: timedelta = timedelta(milliseconds=250),
131
136
  until_finished: bool = False,
132
137
  metrics_port: int | None = None,
133
138
  tasks: list[str] = ["docket.tasks:standard_tasks"],
@@ -144,6 +149,7 @@ class Worker:
144
149
  redelivery_timeout=redelivery_timeout,
145
150
  reconnection_delay=reconnection_delay,
146
151
  minimum_check_interval=minimum_check_interval,
152
+ scheduling_resolution=scheduling_resolution,
147
153
  ) as worker:
148
154
  if until_finished:
149
155
  await worker.run_until_finished()
@@ -210,57 +216,24 @@ class Worker:
210
216
  await asyncio.sleep(self.reconnection_delay.total_seconds())
211
217
 
212
218
  async def _worker_loop(self, forever: bool = False):
213
- async with self.docket.redis() as redis:
214
- stream_due_tasks: _stream_due_tasks = cast(
215
- _stream_due_tasks,
216
- redis.register_script(
217
- # Lua script to atomically move scheduled tasks to the stream
218
- # KEYS[1]: queue key (sorted set)
219
- # KEYS[2]: stream key
220
- # ARGV[1]: current timestamp
221
- # ARGV[2]: docket name prefix
222
- """
223
- local total_work = redis.call('ZCARD', KEYS[1])
224
- local due_work = 0
225
-
226
- if total_work > 0 then
227
- local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1])
228
-
229
- for i, key in ipairs(tasks) do
230
- local hash_key = ARGV[2] .. ":" .. key
231
- local task_data = redis.call('HGETALL', hash_key)
232
-
233
- if #task_data > 0 then
234
- local task = {}
235
- for j = 1, #task_data, 2 do
236
- task[task_data[j]] = task_data[j+1]
237
- end
238
-
239
- redis.call('XADD', KEYS[2], '*',
240
- 'key', task['key'],
241
- 'when', task['when'],
242
- 'function', task['function'],
243
- 'args', task['args'],
244
- 'kwargs', task['kwargs'],
245
- 'attempt', task['attempt']
246
- )
247
- redis.call('DEL', hash_key)
248
- due_work = due_work + 1
249
- end
250
- end
251
- end
252
-
253
- if due_work > 0 then
254
- redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
255
- end
219
+ should_stop = asyncio.Event()
256
220
 
257
- return {total_work, due_work}
258
- """
259
- ),
221
+ async with self.docket.redis() as redis:
222
+ scheduler_task = asyncio.create_task(
223
+ self._scheduler_loop(redis, should_stop)
260
224
  )
261
225
 
262
226
  active_tasks: dict[asyncio.Task[None], RedisMessageID] = {}
263
227
 
228
+ async def check_for_work() -> bool:
229
+ async with redis.pipeline() as pipeline:
230
+ pipeline.xlen(self.docket.stream_key)
231
+ pipeline.zcard(self.docket.queue_key)
232
+ results: list[int] = await pipeline.execute()
233
+ stream_len = results[0]
234
+ queue_len = results[1]
235
+ return stream_len > 0 or queue_len > 0
236
+
264
237
  async def process_completed_tasks() -> None:
265
238
  completed_tasks = {task for task in active_tasks if task.done()}
266
239
  for task in completed_tasks:
@@ -280,10 +253,13 @@ class Worker:
280
253
  )
281
254
  await pipeline.execute()
282
255
 
283
- future_work, due_work = sys.maxsize, 0
256
+ has_work: bool = True
257
+
258
+ if not forever: # pragma: no branch
259
+ has_work = await check_for_work()
284
260
 
285
261
  try:
286
- while forever or future_work or active_tasks:
262
+ while forever or has_work or active_tasks:
287
263
  await process_completed_tasks()
288
264
 
289
265
  available_slots = self.concurrency - len(active_tasks)
@@ -297,28 +273,13 @@ class Worker:
297
273
  task = asyncio.create_task(self._execute(message))
298
274
  active_tasks[task] = message_id
299
275
 
300
- nonlocal available_slots, future_work
276
+ nonlocal available_slots
301
277
  available_slots -= 1
302
- future_work += 1
303
278
 
304
279
  if available_slots <= 0:
305
280
  await asyncio.sleep(self.minimum_check_interval.total_seconds())
306
281
  continue
307
282
 
308
- future_work, due_work = await stream_due_tasks(
309
- keys=[self.docket.queue_key, self.docket.stream_key],
310
- args=[datetime.now(timezone.utc).timestamp(), self.docket.name],
311
- )
312
- if due_work > 0:
313
- logger.debug(
314
- "Moved %d/%d due tasks from %s to %s",
315
- due_work,
316
- future_work,
317
- self.docket.queue_key,
318
- self.docket.stream_key,
319
- extra=self._log_context(),
320
- )
321
-
322
283
  redeliveries: RedisMessages
323
284
  _, redeliveries, *_ = await redis.xautoclaim(
324
285
  name=self.docket.stream_key,
@@ -348,10 +309,14 @@ class Worker:
348
309
  ),
349
310
  count=available_slots,
350
311
  )
312
+
351
313
  for _, messages in new_deliveries:
352
314
  for message_id, message in messages:
353
315
  start_task(message_id, message)
354
316
 
317
+ if not forever and not active_tasks and not new_deliveries:
318
+ has_work = await check_for_work()
319
+
355
320
  except asyncio.CancelledError:
356
321
  if active_tasks: # pragma: no cover
357
322
  logger.info(
@@ -364,7 +329,98 @@ class Worker:
364
329
  await asyncio.gather(*active_tasks, return_exceptions=True)
365
330
  await process_completed_tasks()
366
331
 
332
+ should_stop.set()
333
+ await scheduler_task
334
+
335
+ async def _scheduler_loop(
336
+ self,
337
+ redis: Redis,
338
+ should_stop: asyncio.Event,
339
+ ) -> None:
340
+ """Loop that moves due tasks from the queue to the stream."""
341
+
342
+ stream_due_tasks: _stream_due_tasks = cast(
343
+ _stream_due_tasks,
344
+ redis.register_script(
345
+ # Lua script to atomically move scheduled tasks to the stream
346
+ # KEYS[1]: queue key (sorted set)
347
+ # KEYS[2]: stream key
348
+ # ARGV[1]: current timestamp
349
+ # ARGV[2]: docket name prefix
350
+ """
351
+ local total_work = redis.call('ZCARD', KEYS[1])
352
+ local due_work = 0
353
+
354
+ if total_work > 0 then
355
+ local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1])
356
+
357
+ for i, key in ipairs(tasks) do
358
+ local hash_key = ARGV[2] .. ":" .. key
359
+ local task_data = redis.call('HGETALL', hash_key)
360
+
361
+ if #task_data > 0 then
362
+ local task = {}
363
+ for j = 1, #task_data, 2 do
364
+ task[task_data[j]] = task_data[j+1]
365
+ end
366
+
367
+ redis.call('XADD', KEYS[2], '*',
368
+ 'key', task['key'],
369
+ 'when', task['when'],
370
+ 'function', task['function'],
371
+ 'args', task['args'],
372
+ 'kwargs', task['kwargs'],
373
+ 'attempt', task['attempt']
374
+ )
375
+ redis.call('DEL', hash_key)
376
+ due_work = due_work + 1
377
+ end
378
+ end
379
+ end
380
+
381
+ if due_work > 0 then
382
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
383
+ end
384
+
385
+ return {total_work, due_work}
386
+ """
387
+ ),
388
+ )
389
+
390
+ total_work: int = sys.maxsize
391
+
392
+ while not should_stop.is_set() or total_work:
393
+ try:
394
+ total_work, due_work = await stream_due_tasks(
395
+ keys=[self.docket.queue_key, self.docket.stream_key],
396
+ args=[datetime.now(timezone.utc).timestamp(), self.docket.name],
397
+ )
398
+
399
+ if due_work > 0:
400
+ logger.debug(
401
+ "Moved %d/%d due tasks from %s to %s",
402
+ due_work,
403
+ total_work,
404
+ self.docket.queue_key,
405
+ self.docket.stream_key,
406
+ extra=self._log_context(),
407
+ )
408
+ except Exception: # pragma: no cover
409
+ logger.exception(
410
+ "Error in scheduler loop",
411
+ exc_info=True,
412
+ extra=self._log_context(),
413
+ )
414
+ finally:
415
+ await asyncio.sleep(self.scheduling_resolution.total_seconds())
416
+
417
+ logger.debug("Scheduler loop finished", extra=self._log_context())
418
+
367
419
  async def _execute(self, message: RedisMessage) -> None:
420
+ key = message[b"key"].decode()
421
+ async with self.docket.redis() as redis:
422
+ await redis.delete(self.docket.known_task_key(key))
423
+
368
424
  log_context: Mapping[str, str | float] = self._log_context()
369
425
 
370
426
  function_name = message[b"function"].decode()
@@ -377,9 +433,6 @@ class Worker:
377
433
 
378
434
  execution = Execution.from_message(function, message)
379
435
 
380
- async with self.docket.redis() as redis:
381
- await redis.delete(self.docket.known_task_key(execution.key))
382
-
383
436
  log_context = {**log_context, **execution.specific_labels()}
384
437
  counter_labels = {**self.labels(), **execution.general_labels()}
385
438
 
@@ -572,11 +625,10 @@ class Worker:
572
625
  pipeline.zcount(self.docket.queue_key, 0, now)
573
626
  pipeline.zcount(self.docket.queue_key, now, "+inf")
574
627
 
575
- (
576
- stream_depth,
577
- overdue_depth,
578
- schedule_depth,
579
- ) = await pipeline.execute()
628
+ results: list[int] = await pipeline.execute()
629
+ stream_depth = results[0]
630
+ overdue_depth = results[1]
631
+ schedule_depth = results[2]
580
632
 
581
633
  QUEUE_DEPTH.set(
582
634
  stream_depth + overdue_depth, self.docket.labels()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydocket
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -1,16 +1,16 @@
1
1
  docket/__init__.py,sha256=7oruGALDoU6W_ntF-mMxxv3FFtO970DVzj3lUgoVIiM,775
2
2
  docket/__main__.py,sha256=Vkuh7aJ-Bl7QVpVbbkUksAd_hn05FiLmWbc-8kbhZQ4,34
3
3
  docket/annotations.py,sha256=GZwOPtPXyeIhnsLh3TQMBnXrjtTtSmF4Ratv4vjPx8U,950
4
- docket/cli.py,sha256=EseF0Sj7IEgd9QDC-FSbHSffvF7DNsrmDGYGgZBdJc8,19413
4
+ docket/cli.py,sha256=OWql6QFthSbvRCGkIg-ufo26F48z0eCmzRXJYOdyAEc,20309
5
5
  docket/dependencies.py,sha256=S3KqXxEF0Q2t_jO3R-kI5IIA3M-tqybtiSod2xnRO4o,4991
6
6
  docket/docket.py,sha256=xjmXZtE2QY6Pet5Gv5hEJNr8d5wp3zL1GJqRbJYwnKs,20321
7
7
  docket/execution.py,sha256=PDrlAr8VzmB6JvqKO71YhXUcTcGQW7eyXrSKiTcAexE,12508
8
8
  docket/instrumentation.py,sha256=bZlGA02JoJcY0J1WGm5_qXDfY0AXKr0ZLAYu67wkeKY,4611
9
9
  docket/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  docket/tasks.py,sha256=RIlSM2omh-YDwVnCz6M5MtmK8T_m_s1w2OlRRxDUs6A,1437
11
- docket/worker.py,sha256=3gyXOMaXTBAqEMJqTbItssWtil4ZtVT32UZZoaqWxK0,22006
12
- pydocket-0.4.0.dist-info/METADATA,sha256=eZAK9MrnZBJhgH8Nc_JDWykcETUU5wszL0KbAPq8ha0,13092
13
- pydocket-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
- pydocket-0.4.0.dist-info/entry_points.txt,sha256=4WOk1nUlBsUT5O3RyMci2ImuC5XFswuopElYcLHtD5k,47
15
- pydocket-0.4.0.dist-info/licenses/LICENSE,sha256=YuVWU_ZXO0K_k2FG8xWKe5RGxV24AhJKTvQmKfqXuyk,1087
16
- pydocket-0.4.0.dist-info/RECORD,,
11
+ docket/worker.py,sha256=lPrx2G87tQDHgbIKZLsjsRQn56u-L8_8gGz4UxWQK68,23710
12
+ pydocket-0.5.0.dist-info/METADATA,sha256=Jh28apfadHchCF5Xr_YV-57COunujrhtdDEF0UbjzmU,13092
13
+ pydocket-0.5.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ pydocket-0.5.0.dist-info/entry_points.txt,sha256=4WOk1nUlBsUT5O3RyMci2ImuC5XFswuopElYcLHtD5k,47
15
+ pydocket-0.5.0.dist-info/licenses/LICENSE,sha256=YuVWU_ZXO0K_k2FG8xWKe5RGxV24AhJKTvQmKfqXuyk,1087
16
+ pydocket-0.5.0.dist-info/RECORD,,