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.
- {pydocket-0.8.0 → pydocket-0.9.0}/PKG-INFO +1 -1
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/docket.py +151 -35
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/worker.py +6 -3
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_fundamentals.py +24 -3
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_worker.py +78 -1
- {pydocket-0.8.0 → pydocket-0.9.0}/.cursor/rules/general.mdc +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/.cursor/rules/python-style.mdc +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/.github/codecov.yml +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/.github/workflows/chaos.yml +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/.github/workflows/ci.yml +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/.github/workflows/docs.yml +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/.github/workflows/publish.yml +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/.gitignore +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/.pre-commit-config.yaml +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/CLAUDE.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/LICENSE +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/README.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/chaos/README.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/chaos/__init__.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/chaos/driver.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/chaos/producer.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/chaos/run +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/chaos/tasks.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/docs/advanced-patterns.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/docs/api-reference.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/docs/dependencies.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/docs/getting-started.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/docs/index.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/docs/production.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/docs/testing.md +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/examples/__init__.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/examples/common.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/examples/concurrency_control.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/examples/find_and_flood.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/examples/self_perpetuating.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/mkdocs.yml +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/pyproject.toml +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/__init__.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/__main__.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/annotations.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/cli.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/dependencies.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/execution.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/instrumentation.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/py.typed +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/src/docket/tasks.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/telemetry/.gitignore +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/telemetry/start +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/telemetry/stop +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/__init__.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/__init__.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/conftest.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_clear.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_module.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_parsing.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_snapshot.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_striking.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_tasks.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_version.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_worker.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/cli/test_workers.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/conftest.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_concurrency_basic.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_concurrency_control.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_concurrency_refresh.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_dependencies.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_docket.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_execution.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_instrumentation.py +0 -0
- {pydocket-0.8.0 → pydocket-0.9.0}/tests/test_striking.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
"
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
253
|
+
async def test_cancelling_immediate_task(
|
|
254
254
|
docket: Docket, worker: Worker, the_task: AsyncMock, now: Callable[[], datetime]
|
|
255
255
|
):
|
|
256
|
-
"""docket
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|