pydocket 0.8.0__tar.gz → 0.9.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 (71) hide show
  1. {pydocket-0.8.0 → pydocket-0.9.0}/PKG-INFO +1 -1
  2. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/docket.py +151 -35
  3. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/worker.py +6 -3
  4. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_fundamentals.py +24 -3
  5. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_worker.py +78 -1
  6. {pydocket-0.8.0 → pydocket-0.9.0}/.cursor/rules/general.mdc +0 -0
  7. {pydocket-0.8.0 → pydocket-0.9.0}/.cursor/rules/python-style.mdc +0 -0
  8. {pydocket-0.8.0 → pydocket-0.9.0}/.github/codecov.yml +0 -0
  9. {pydocket-0.8.0 → pydocket-0.9.0}/.github/workflows/chaos.yml +0 -0
  10. {pydocket-0.8.0 → pydocket-0.9.0}/.github/workflows/ci.yml +0 -0
  11. {pydocket-0.8.0 → pydocket-0.9.0}/.github/workflows/docs.yml +0 -0
  12. {pydocket-0.8.0 → pydocket-0.9.0}/.github/workflows/publish.yml +0 -0
  13. {pydocket-0.8.0 → pydocket-0.9.0}/.gitignore +0 -0
  14. {pydocket-0.8.0 → pydocket-0.9.0}/.pre-commit-config.yaml +0 -0
  15. {pydocket-0.8.0 → pydocket-0.9.0}/CLAUDE.md +0 -0
  16. {pydocket-0.8.0 → pydocket-0.9.0}/LICENSE +0 -0
  17. {pydocket-0.8.0 → pydocket-0.9.0}/README.md +0 -0
  18. {pydocket-0.8.0 → pydocket-0.9.0}/chaos/README.md +0 -0
  19. {pydocket-0.8.0 → pydocket-0.9.0}/chaos/__init__.py +0 -0
  20. {pydocket-0.8.0 → pydocket-0.9.0}/chaos/driver.py +0 -0
  21. {pydocket-0.8.0 → pydocket-0.9.0}/chaos/producer.py +0 -0
  22. {pydocket-0.8.0 → pydocket-0.9.0}/chaos/run +0 -0
  23. {pydocket-0.8.0 → pydocket-0.9.0}/chaos/tasks.py +0 -0
  24. {pydocket-0.8.0 → pydocket-0.9.0}/docs/advanced-patterns.md +0 -0
  25. {pydocket-0.8.0 → pydocket-0.9.0}/docs/api-reference.md +0 -0
  26. {pydocket-0.8.0 → pydocket-0.9.0}/docs/dependencies.md +0 -0
  27. {pydocket-0.8.0 → pydocket-0.9.0}/docs/getting-started.md +0 -0
  28. {pydocket-0.8.0 → pydocket-0.9.0}/docs/index.md +0 -0
  29. {pydocket-0.8.0 → pydocket-0.9.0}/docs/production.md +0 -0
  30. {pydocket-0.8.0 → pydocket-0.9.0}/docs/testing.md +0 -0
  31. {pydocket-0.8.0 → pydocket-0.9.0}/examples/__init__.py +0 -0
  32. {pydocket-0.8.0 → pydocket-0.9.0}/examples/common.py +0 -0
  33. {pydocket-0.8.0 → pydocket-0.9.0}/examples/concurrency_control.py +0 -0
  34. {pydocket-0.8.0 → pydocket-0.9.0}/examples/find_and_flood.py +0 -0
  35. {pydocket-0.8.0 → pydocket-0.9.0}/examples/self_perpetuating.py +0 -0
  36. {pydocket-0.8.0 → pydocket-0.9.0}/mkdocs.yml +0 -0
  37. {pydocket-0.8.0 → pydocket-0.9.0}/pyproject.toml +0 -0
  38. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/__init__.py +0 -0
  39. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/__main__.py +0 -0
  40. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/annotations.py +0 -0
  41. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/cli.py +0 -0
  42. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/dependencies.py +0 -0
  43. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/execution.py +0 -0
  44. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/instrumentation.py +0 -0
  45. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/py.typed +0 -0
  46. {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/tasks.py +0 -0
  47. {pydocket-0.8.0 → pydocket-0.9.0}/telemetry/.gitignore +0 -0
  48. {pydocket-0.8.0 → pydocket-0.9.0}/telemetry/start +0 -0
  49. {pydocket-0.8.0 → pydocket-0.9.0}/telemetry/stop +0 -0
  50. {pydocket-0.8.0 → pydocket-0.9.0}/tests/__init__.py +0 -0
  51. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/__init__.py +0 -0
  52. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/conftest.py +0 -0
  53. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_clear.py +0 -0
  54. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_module.py +0 -0
  55. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_parsing.py +0 -0
  56. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_snapshot.py +0 -0
  57. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_striking.py +0 -0
  58. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_tasks.py +0 -0
  59. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_version.py +0 -0
  60. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_worker.py +0 -0
  61. {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_workers.py +0 -0
  62. {pydocket-0.8.0 → pydocket-0.9.0}/tests/conftest.py +0 -0
  63. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_concurrency_basic.py +0 -0
  64. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_concurrency_control.py +0 -0
  65. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_concurrency_refresh.py +0 -0
  66. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_dependencies.py +0 -0
  67. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_docket.py +0 -0
  68. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_execution.py +0 -0
  69. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_instrumentation.py +0 -0
  70. {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_striking.py +0 -0
  71. {pydocket-0.8.0 → pydocket-0.9.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydocket
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -16,6 +16,7 @@ from typing import (
16
16
  Mapping,
17
17
  NoReturn,
18
18
  ParamSpec,
19
+ Protocol,
19
20
  Self,
20
21
  Sequence,
21
22
  TypedDict,
@@ -27,7 +28,6 @@ from typing import (
27
28
  import redis.exceptions
28
29
  from opentelemetry import propagate, trace
29
30
  from redis.asyncio import ConnectionPool, Redis
30
- from redis.asyncio.client import Pipeline
31
31
  from uuid_extensions import uuid7
32
32
 
33
33
  from .execution import (
@@ -55,6 +55,18 @@ logger: logging.Logger = logging.getLogger(__name__)
55
55
  tracer: trace.Tracer = trace.get_tracer(__name__)
56
56
 
57
57
 
58
+ class _schedule_task(Protocol):
59
+ async def __call__(
60
+ self, keys: list[str], args: list[str | float | bytes]
61
+ ) -> str: ... # pragma: no cover
62
+
63
+
64
+ class _cancel_task(Protocol):
65
+ async def __call__(
66
+ self, keys: list[str], args: list[str]
67
+ ) -> str: ... # pragma: no cover
68
+
69
+
58
70
  P = ParamSpec("P")
59
71
  R = TypeVar("R")
60
72
 
@@ -131,6 +143,8 @@ class Docket:
131
143
 
132
144
  _monitor_strikes_task: asyncio.Task[None]
133
145
  _connection_pool: ConnectionPool
146
+ _schedule_task_script: _schedule_task | None
147
+ _cancel_task_script: _cancel_task | None
134
148
 
135
149
  def __init__(
136
150
  self,
@@ -156,6 +170,8 @@ class Docket:
156
170
  self.url = url
157
171
  self.heartbeat_interval = heartbeat_interval
158
172
  self.missed_heartbeats = missed_heartbeats
173
+ self._schedule_task_script = None
174
+ self._cancel_task_script = None
159
175
 
160
176
  @property
161
177
  def worker_group_name(self) -> str:
@@ -300,9 +316,7 @@ class Docket:
300
316
  execution = Execution(function, args, kwargs, when, key, attempt=1)
301
317
 
302
318
  async with self.redis() as redis:
303
- async with redis.pipeline() as pipeline:
304
- await self._schedule(redis, pipeline, execution, replace=False)
305
- await pipeline.execute()
319
+ await self._schedule(redis, execution, replace=False)
306
320
 
307
321
  TASKS_ADDED.add(1, {**self.labels(), **execution.general_labels()})
308
322
  TASKS_SCHEDULED.add(1, {**self.labels(), **execution.general_labels()})
@@ -361,9 +375,7 @@ class Docket:
361
375
  execution = Execution(function, args, kwargs, when, key, attempt=1)
362
376
 
363
377
  async with self.redis() as redis:
364
- async with redis.pipeline() as pipeline:
365
- await self._schedule(redis, pipeline, execution, replace=True)
366
- await pipeline.execute()
378
+ await self._schedule(redis, execution, replace=True)
367
379
 
368
380
  TASKS_REPLACED.add(1, {**self.labels(), **execution.general_labels()})
369
381
  TASKS_CANCELLED.add(1, {**self.labels(), **execution.general_labels()})
@@ -383,9 +395,7 @@ class Docket:
383
395
  },
384
396
  ):
385
397
  async with self.redis() as redis:
386
- async with redis.pipeline() as pipeline:
387
- await self._schedule(redis, pipeline, execution, replace=False)
388
- await pipeline.execute()
398
+ await self._schedule(redis, execution, replace=False)
389
399
 
390
400
  TASKS_SCHEDULED.add(1, {**self.labels(), **execution.general_labels()})
391
401
 
@@ -400,9 +410,7 @@ class Docket:
400
410
  attributes={**self.labels(), "docket.key": key},
401
411
  ):
402
412
  async with self.redis() as redis:
403
- async with redis.pipeline() as pipeline:
404
- await self._cancel(pipeline, key)
405
- await pipeline.execute()
413
+ await self._cancel(redis, key)
406
414
 
407
415
  TASKS_CANCELLED.add(1, self.labels())
408
416
 
@@ -423,10 +431,17 @@ class Docket:
423
431
  async def _schedule(
424
432
  self,
425
433
  redis: Redis,
426
- pipeline: Pipeline,
427
434
  execution: Execution,
428
435
  replace: bool = False,
429
436
  ) -> None:
437
+ """Schedule a task atomically.
438
+
439
+ Handles:
440
+ - Checking for task existence
441
+ - Cancelling existing tasks when replacing
442
+ - Adding tasks to stream (immediate) or queue (future)
443
+ - Tracking stream message IDs for later cancellation
444
+ """
430
445
  if self.strike_list.is_stricken(execution):
431
446
  logger.warning(
432
447
  "%r is stricken, skipping schedule of %r",
@@ -449,32 +464,133 @@ class Docket:
449
464
  key = execution.key
450
465
  when = execution.when
451
466
  known_task_key = self.known_task_key(key)
467
+ is_immediate = when <= datetime.now(timezone.utc)
452
468
 
469
+ # Lock per task key to prevent race conditions between concurrent operations
453
470
  async with redis.lock(f"{known_task_key}:lock", timeout=10):
454
- if replace:
455
- await self._cancel(pipeline, key)
456
- else:
457
- # if the task is already in the queue or stream, retain it
458
- if await redis.exists(known_task_key):
459
- logger.debug(
460
- "Task %r is already in the queue or stream, not scheduling",
461
- key,
462
- extra=self.labels(),
463
- )
464
- return
471
+ if self._schedule_task_script is None:
472
+ self._schedule_task_script = cast(
473
+ _schedule_task,
474
+ redis.register_script(
475
+ # KEYS: stream_key, known_key, parked_key, queue_key
476
+ # ARGV: task_key, when_timestamp, is_immediate, replace, ...message_fields
477
+ """
478
+ local stream_key = KEYS[1]
479
+ local known_key = KEYS[2]
480
+ local parked_key = KEYS[3]
481
+ local queue_key = KEYS[4]
482
+
483
+ local task_key = ARGV[1]
484
+ local when_timestamp = ARGV[2]
485
+ local is_immediate = ARGV[3] == '1'
486
+ local replace = ARGV[4] == '1'
487
+
488
+ -- Extract message fields from ARGV[5] onwards
489
+ local message = {}
490
+ for i = 5, #ARGV, 2 do
491
+ message[#message + 1] = ARGV[i] -- field name
492
+ message[#message + 1] = ARGV[i + 1] -- field value
493
+ end
494
+
495
+ -- Handle replacement: cancel existing task if needed
496
+ if replace then
497
+ local existing_message_id = redis.call('HGET', known_key, 'stream_message_id')
498
+ if existing_message_id then
499
+ redis.call('XDEL', stream_key, existing_message_id)
500
+ end
501
+ redis.call('DEL', known_key, parked_key)
502
+ redis.call('ZREM', queue_key, task_key)
503
+ else
504
+ -- Check if task already exists
505
+ if redis.call('EXISTS', known_key) == 1 then
506
+ return 'EXISTS'
507
+ end
508
+ end
509
+
510
+ if is_immediate then
511
+ -- Add to stream and store message ID for later cancellation
512
+ local message_id = redis.call('XADD', stream_key, '*', unpack(message))
513
+ redis.call('HSET', known_key, 'when', when_timestamp, 'stream_message_id', message_id)
514
+ return message_id
515
+ else
516
+ -- Add to queue with task data in parked hash
517
+ redis.call('HSET', known_key, 'when', when_timestamp)
518
+ redis.call('HSET', parked_key, unpack(message))
519
+ redis.call('ZADD', queue_key, when_timestamp, task_key)
520
+ return 'QUEUED'
521
+ end
522
+ """
523
+ ),
524
+ )
525
+ schedule_task = self._schedule_task_script
465
526
 
466
- pipeline.set(known_task_key, when.timestamp())
527
+ await schedule_task(
528
+ keys=[
529
+ self.stream_key,
530
+ known_task_key,
531
+ self.parked_task_key(key),
532
+ self.queue_key,
533
+ ],
534
+ args=[
535
+ key,
536
+ str(when.timestamp()),
537
+ "1" if is_immediate else "0",
538
+ "1" if replace else "0",
539
+ *[
540
+ item
541
+ for field, value in message.items()
542
+ for item in (field, value)
543
+ ],
544
+ ],
545
+ )
467
546
 
468
- if when <= datetime.now(timezone.utc):
469
- pipeline.xadd(self.stream_key, message) # type: ignore[arg-type]
470
- else:
471
- pipeline.hset(self.parked_task_key(key), mapping=message) # type: ignore[arg-type]
472
- pipeline.zadd(self.queue_key, {key: when.timestamp()})
547
+ async def _cancel(self, redis: Redis, key: str) -> None:
548
+ """Cancel a task atomically.
473
549
 
474
- async def _cancel(self, pipeline: Pipeline, key: str) -> None:
475
- pipeline.delete(self.known_task_key(key))
476
- pipeline.delete(self.parked_task_key(key))
477
- pipeline.zrem(self.queue_key, key)
550
+ Handles cancellation regardless of task location:
551
+ - From the stream (using stored message ID)
552
+ - From the queue (scheduled tasks)
553
+ - Cleans up all associated metadata keys
554
+ """
555
+ if self._cancel_task_script is None:
556
+ self._cancel_task_script = cast(
557
+ _cancel_task,
558
+ redis.register_script(
559
+ # KEYS: stream_key, known_key, parked_key, queue_key
560
+ # ARGV: task_key
561
+ """
562
+ local stream_key = KEYS[1]
563
+ local known_key = KEYS[2]
564
+ local parked_key = KEYS[3]
565
+ local queue_key = KEYS[4]
566
+ local task_key = ARGV[1]
567
+
568
+ -- Delete from stream if message ID exists
569
+ local message_id = redis.call('HGET', known_key, 'stream_message_id')
570
+ if message_id then
571
+ redis.call('XDEL', stream_key, message_id)
572
+ end
573
+
574
+ -- Clean up all task-related keys
575
+ redis.call('DEL', known_key, parked_key)
576
+ redis.call('ZREM', queue_key, task_key)
577
+
578
+ return 'OK'
579
+ """
580
+ ),
581
+ )
582
+ cancel_task = self._cancel_task_script
583
+
584
+ # Execute the cancellation script
585
+ await cancel_task(
586
+ keys=[
587
+ self.stream_key,
588
+ self.known_task_key(key),
589
+ self.parked_task_key(key),
590
+ self.queue_key,
591
+ ],
592
+ args=[key],
593
+ )
478
594
 
479
595
  @property
480
596
  def strike_key(self) -> str:
@@ -286,7 +286,7 @@ class Worker:
286
286
  count=available_slots,
287
287
  )
288
288
 
289
- async def start_task(message_id: RedisMessageID, message: RedisMessage) -> bool:
289
+ def start_task(message_id: RedisMessageID, message: RedisMessage) -> bool:
290
290
  function_name = message[b"function"].decode()
291
291
  if not (function := self.docket.tasks.get(function_name)):
292
292
  logger.warning(
@@ -347,7 +347,7 @@ class Worker:
347
347
  if not message: # pragma: no cover
348
348
  continue
349
349
 
350
- task_started = await start_task(message_id, message)
350
+ task_started = start_task(message_id, message)
351
351
  if not task_started:
352
352
  # Other errors - delete and ack
353
353
  await self._delete_known_task(redis, message)
@@ -406,7 +406,7 @@ class Worker:
406
406
  task[task_data[j]] = task_data[j+1]
407
407
  end
408
408
 
409
- redis.call('XADD', KEYS[2], '*',
409
+ local message_id = redis.call('XADD', KEYS[2], '*',
410
410
  'key', task['key'],
411
411
  'when', task['when'],
412
412
  'function', task['function'],
@@ -414,6 +414,9 @@ class Worker:
414
414
  'kwargs', task['kwargs'],
415
415
  'attempt', task['attempt']
416
416
  )
417
+ -- Store the message ID in the known task key
418
+ local known_key = ARGV[2] .. ":known:" .. key
419
+ redis.call('HSET', known_key, 'stream_message_id', message_id)
417
420
  redis.call('DEL', hash_key)
418
421
  due_work = due_work + 1
419
422
  end
@@ -250,10 +250,10 @@ async def test_cancelling_future_task(
250
250
  the_task.assert_not_called()
251
251
 
252
252
 
253
- async def test_cancelling_current_task_not_supported(
253
+ async def test_cancelling_immediate_task(
254
254
  docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
255
255
  ):
256
- """docket does not allow cancelling a task that is schedule now"""
256
+ """docket can cancel a task that is scheduled immediately"""
257
257
 
258
258
  execution = await docket.add(the_task, now())("a", "b", c="c")
259
259
 
@@ -261,7 +261,28 @@ async def test_cancelling_current_task_not_supported(
261
261
 
262
262
  await worker.run_until_finished()
263
263
 
264
- the_task.assert_awaited_once_with("a", "b", c="c")
264
+ the_task.assert_not_called()
265
+
266
+
267
+ async def test_cancellation_is_idempotent(
268
+ docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
269
+ ):
270
+ """Test that canceling the same task twice doesn't error."""
271
+ key = f"test-task:{uuid4()}"
272
+
273
+ # Schedule a task
274
+ later = now() + timedelta(seconds=1)
275
+ await docket.add(the_task, later, key=key)("test")
276
+
277
+ # Cancel it twice - both should succeed without error
278
+ await docket.cancel(key)
279
+ await docket.cancel(key) # Should be idempotent
280
+
281
+ # Run worker to ensure the task was actually cancelled
282
+ await worker.run_until_finished()
283
+
284
+ # Task should not have been executed since it was cancelled
285
+ the_task.assert_not_called()
265
286
 
266
287
 
267
288
  async def test_errors_are_logged(
@@ -2,8 +2,9 @@ import asyncio
2
2
  import logging
3
3
  from contextlib import asynccontextmanager
4
4
  from datetime import datetime, timedelta, timezone
5
- from typing import AsyncGenerator
5
+ from typing import AsyncGenerator, Callable
6
6
  from unittest.mock import AsyncMock, patch
7
+ from uuid import uuid4
7
8
 
8
9
  import pytest
9
10
  from redis.asyncio import Redis
@@ -1427,3 +1428,79 @@ async def test_finally_block_releases_concurrency_on_success(docket: Docket):
1427
1428
 
1428
1429
  # If both tasks completed, the finally block successfully released slots
1429
1430
  assert task_completed
1431
+
1432
+
1433
+ async def test_replacement_race_condition_stream_tasks(
1434
+ docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
1435
+ ):
1436
+ """Test that replace() properly cancels tasks already in the stream.
1437
+
1438
+ This reproduces the race condition where:
1439
+ 1. Task is scheduled for immediate execution
1440
+ 2. Scheduler moves it to stream
1441
+ 3. replace() tries to cancel but only checks queue/hash, not stream
1442
+ 4. Both original and replacement tasks execute
1443
+ """
1444
+ key = f"my-cool-task:{uuid4()}"
1445
+
1446
+ # Schedule a task immediately (will be moved to stream quickly)
1447
+ await docket.add(the_task, now(), key=key)("a", "b", c="c")
1448
+
1449
+ # Let the scheduler move the task to the stream
1450
+ # The scheduler runs every 250ms by default
1451
+ await asyncio.sleep(0.3)
1452
+
1453
+ # Now replace the task - this should cancel the one in the stream
1454
+ later = now() + timedelta(milliseconds=100)
1455
+ await docket.replace(the_task, later, key=key)("b", "c", c="d")
1456
+
1457
+ # Run the worker to completion
1458
+ await worker.run_until_finished()
1459
+
1460
+ # Should only execute the replacement task, not both
1461
+ the_task.assert_awaited_once_with("b", "c", c="d")
1462
+ assert the_task.await_count == 1, (
1463
+ f"Task was called {the_task.await_count} times, expected 1"
1464
+ )
1465
+
1466
+
1467
+ async def test_replace_task_in_queue_before_stream(
1468
+ docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
1469
+ ):
1470
+ """Test that replace() works correctly when task is still in queue."""
1471
+ key = f"my-cool-task:{uuid4()}"
1472
+
1473
+ # Schedule a task slightly in the future (stays in queue)
1474
+ soon = now() + timedelta(seconds=1)
1475
+ await docket.add(the_task, soon, key=key)("a", "b", c="c")
1476
+
1477
+ # Replace immediately (before scheduler can move it)
1478
+ later = now() + timedelta(milliseconds=100)
1479
+ await docket.replace(the_task, later, key=key)("b", "c", c="d")
1480
+
1481
+ await worker.run_until_finished()
1482
+
1483
+ # Should only execute the replacement
1484
+ the_task.assert_awaited_once_with("b", "c", c="d")
1485
+ assert the_task.await_count == 1
1486
+
1487
+
1488
+ async def test_rapid_replace_operations(
1489
+ docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
1490
+ ):
1491
+ """Test multiple rapid replace operations."""
1492
+ key = f"my-cool-task:{uuid4()}"
1493
+
1494
+ # Schedule initial task
1495
+ await docket.add(the_task, now(), key=key)("a", "b", c="c")
1496
+
1497
+ # Rapid replacements
1498
+ for i in range(5):
1499
+ when = now() + timedelta(milliseconds=50 + i * 10)
1500
+ await docket.replace(the_task, when, key=key)(f"arg{i}", b=f"b{i}")
1501
+
1502
+ await worker.run_until_finished()
1503
+
1504
+ # Should only execute the last replacement
1505
+ the_task.assert_awaited_once_with("arg4", b="b4")
1506
+ assert the_task.await_count == 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
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