pydocket 0.9.2__tar.gz → 0.13.0b1__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 (94) hide show
  1. pydocket-0.13.0b1/.coveragerc-memory +10 -0
  2. {pydocket-0.9.2 → pydocket-0.13.0b1}/.github/workflows/chaos.yml +2 -0
  3. pydocket-0.13.0b1/.github/workflows/ci.yml +97 -0
  4. pydocket-0.13.0b1/.github/workflows/claude-code-review.yml +40 -0
  5. pydocket-0.13.0b1/.github/workflows/claude.yml +42 -0
  6. {pydocket-0.9.2 → pydocket-0.13.0b1}/.gitignore +2 -0
  7. {pydocket-0.9.2 → pydocket-0.13.0b1}/CLAUDE.md +5 -7
  8. {pydocket-0.9.2 → pydocket-0.13.0b1}/PKG-INFO +24 -4
  9. {pydocket-0.9.2 → pydocket-0.13.0b1}/README.md +14 -1
  10. {pydocket-0.9.2 → pydocket-0.13.0b1}/chaos/driver.py +36 -6
  11. {pydocket-0.9.2 → pydocket-0.13.0b1}/docs/advanced-patterns.md +132 -0
  12. {pydocket-0.9.2 → pydocket-0.13.0b1}/docs/dependencies.md +135 -40
  13. {pydocket-0.9.2 → pydocket-0.13.0b1}/docs/getting-started.md +1 -1
  14. {pydocket-0.9.2 → pydocket-0.13.0b1}/docs/testing.md +70 -0
  15. pydocket-0.13.0b1/examples/agenda_scatter.py +128 -0
  16. pydocket-0.13.0b1/examples/fastapi_background_tasks.py +204 -0
  17. pydocket-0.13.0b1/examples/local_development.py +98 -0
  18. pydocket-0.13.0b1/examples/task_progress.py +111 -0
  19. {pydocket-0.9.2 → pydocket-0.13.0b1}/pyproject.toml +27 -6
  20. pydocket-0.13.0b1/sitecustomize.py +7 -0
  21. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/__init__.py +6 -1
  22. pydocket-0.13.0b1/src/docket/agenda.py +202 -0
  23. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/annotations.py +3 -1
  24. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/cli.py +293 -22
  25. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/dependencies.py +158 -57
  26. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/docket.py +130 -149
  27. pydocket-0.13.0b1/src/docket/execution.py +1212 -0
  28. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/instrumentation.py +14 -1
  29. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/tasks.py +2 -2
  30. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/worker.py +127 -50
  31. pydocket-0.13.0b1/tests/cli/run.py +53 -0
  32. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/test_clear.py +71 -122
  33. pydocket-0.13.0b1/tests/cli/test_module.py +10 -0
  34. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/test_parsing.py +7 -0
  35. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/test_snapshot.py +137 -123
  36. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/test_striking.py +78 -108
  37. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/test_tasks.py +32 -45
  38. pydocket-0.13.0b1/tests/cli/test_url_validation.py +59 -0
  39. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/test_version.py +5 -6
  40. pydocket-0.13.0b1/tests/cli/test_watch.py +404 -0
  41. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/test_worker.py +45 -68
  42. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/test_workers.py +27 -29
  43. pydocket-0.13.0b1/tests/cli/waiting.py +133 -0
  44. pydocket-0.13.0b1/tests/conftest.py +210 -0
  45. pydocket-0.13.0b1/tests/test_agenda.py +404 -0
  46. pydocket-0.13.0b1/tests/test_dependencies.py +666 -0
  47. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_docket.py +42 -0
  48. pydocket-0.13.0b1/tests/test_execution_progress.py +835 -0
  49. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_fundamentals.py +393 -4
  50. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_instrumentation.py +115 -4
  51. pydocket-0.13.0b1/tests/test_memory_backend.py +113 -0
  52. pydocket-0.13.0b1/tests/test_results.py +429 -0
  53. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_striking.py +2 -0
  54. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_worker.py +26 -4
  55. pydocket-0.13.0b1/uv.lock +2243 -0
  56. pydocket-0.9.2/.github/workflows/ci.yml +0 -65
  57. pydocket-0.9.2/src/docket/execution.py +0 -438
  58. pydocket-0.9.2/tests/cli/conftest.py +0 -8
  59. pydocket-0.9.2/tests/cli/test_module.py +0 -22
  60. pydocket-0.9.2/tests/conftest.py +0 -180
  61. pydocket-0.9.2/tests/test_dependencies.py +0 -262
  62. pydocket-0.9.2/uv.lock +0 -1444
  63. {pydocket-0.9.2 → pydocket-0.13.0b1}/.cursor/rules/general.mdc +0 -0
  64. {pydocket-0.9.2 → pydocket-0.13.0b1}/.cursor/rules/python-style.mdc +0 -0
  65. {pydocket-0.9.2 → pydocket-0.13.0b1}/.github/codecov.yml +0 -0
  66. {pydocket-0.9.2 → pydocket-0.13.0b1}/.github/workflows/docs.yml +0 -0
  67. {pydocket-0.9.2 → pydocket-0.13.0b1}/.github/workflows/publish.yml +0 -0
  68. {pydocket-0.9.2 → pydocket-0.13.0b1}/.pre-commit-config.yaml +0 -0
  69. {pydocket-0.9.2 → pydocket-0.13.0b1}/LICENSE +0 -0
  70. {pydocket-0.9.2 → pydocket-0.13.0b1}/chaos/README.md +0 -0
  71. {pydocket-0.9.2 → pydocket-0.13.0b1}/chaos/__init__.py +0 -0
  72. {pydocket-0.9.2 → pydocket-0.13.0b1}/chaos/producer.py +0 -0
  73. {pydocket-0.9.2 → pydocket-0.13.0b1}/chaos/run +0 -0
  74. {pydocket-0.9.2 → pydocket-0.13.0b1}/chaos/tasks.py +0 -0
  75. {pydocket-0.9.2 → pydocket-0.13.0b1}/docs/api-reference.md +0 -0
  76. {pydocket-0.9.2 → pydocket-0.13.0b1}/docs/index.md +0 -0
  77. {pydocket-0.9.2 → pydocket-0.13.0b1}/docs/production.md +0 -0
  78. {pydocket-0.9.2 → pydocket-0.13.0b1}/examples/__init__.py +0 -0
  79. {pydocket-0.9.2 → pydocket-0.13.0b1}/examples/common.py +0 -0
  80. {pydocket-0.9.2 → pydocket-0.13.0b1}/examples/concurrency_control.py +0 -0
  81. {pydocket-0.9.2 → pydocket-0.13.0b1}/examples/find_and_flood.py +0 -0
  82. {pydocket-0.9.2 → pydocket-0.13.0b1}/examples/self_perpetuating.py +0 -0
  83. {pydocket-0.9.2 → pydocket-0.13.0b1}/mkdocs.yml +0 -0
  84. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/__main__.py +0 -0
  85. {pydocket-0.9.2 → pydocket-0.13.0b1}/src/docket/py.typed +0 -0
  86. {pydocket-0.9.2 → pydocket-0.13.0b1}/telemetry/.gitignore +0 -0
  87. {pydocket-0.9.2 → pydocket-0.13.0b1}/telemetry/start +0 -0
  88. {pydocket-0.9.2 → pydocket-0.13.0b1}/telemetry/stop +0 -0
  89. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/__init__.py +0 -0
  90. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/cli/__init__.py +0 -0
  91. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_concurrency_basic.py +0 -0
  92. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_concurrency_control.py +0 -0
  93. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_concurrency_refresh.py +0 -0
  94. {pydocket-0.9.2 → pydocket-0.13.0b1}/tests/test_execution.py +0 -0
@@ -0,0 +1,10 @@
1
+ # Coverage configuration for memory backend testing
2
+ # CLI tests are skipped with memory:// URLs, so exclude CLI from coverage
3
+
4
+ [run]
5
+ branch = true
6
+ parallel = true
7
+ omit =
8
+ src/docket/__main__.py
9
+ src/docket/cli.py
10
+ tests/cli/test_*.py
@@ -15,6 +15,8 @@ jobs:
15
15
 
16
16
  steps:
17
17
  - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
18
20
 
19
21
  - name: Install uv and set Python version
20
22
  uses: astral-sh/setup-uv@v5
@@ -0,0 +1,97 @@
1
+ name: Docket CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ jobs:
10
+ test:
11
+ name: Test Python ${{ matrix.python-version }}, ${{ matrix.backend.name }}
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
17
+ backend:
18
+ - name: "Redis 6.2, redis-py <5"
19
+ redis-version: "6.2"
20
+ redis-py-version: ">=5,<6"
21
+ - name: "Redis 7.4, redis-py >=5"
22
+ redis-version: "7.4"
23
+ redis-py-version: ">=5"
24
+ - name: "Valkey 8.0, redis-py >=5"
25
+ redis-version: "valkey-8.0"
26
+ redis-py-version: ">=5"
27
+ - name: "Memory (in-memory backend)"
28
+ redis-version: "memory"
29
+ redis-py-version: ">=5"
30
+ include:
31
+ - python-version: "3.10"
32
+ cov-threshold: 100
33
+ pytest-args: ""
34
+ # Python 3.11 coverage reporting is unstable, so use 98% threshold
35
+ - python-version: "3.11"
36
+ cov-threshold: 98
37
+ pytest-args: ""
38
+ - python-version: "3.12"
39
+ cov-threshold: 100
40
+ pytest-args: ""
41
+ - python-version: "3.13"
42
+ cov-threshold: 100
43
+ pytest-args: ""
44
+ - python-version: "3.14"
45
+ cov-threshold: 100
46
+ pytest-args: ""
47
+ # Memory backend: CLI tests are skipped via pytest skip markers because
48
+ # CLI rejects memory:// URLs. Use separate coverage config to exclude CLI.
49
+ - backend:
50
+ name: "Memory (in-memory backend)"
51
+ redis-version: "memory"
52
+ redis-py-version: ">=5"
53
+ cov-threshold: 98 # CLI tests are excluded from coverage and some lines are only covered by CLI tests
54
+ pytest-args: "--cov-config=.coveragerc-memory"
55
+
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+
59
+ - name: Install uv and set Python version
60
+ uses: astral-sh/setup-uv@v5
61
+ with:
62
+ python-version: ${{ matrix.python-version }}
63
+ enable-cache: true
64
+ cache-dependency-glob: "pyproject.toml"
65
+
66
+ - name: Install dependencies
67
+ run: uv sync --upgrade-package 'redis${{ matrix.backend.redis-py-version }}'
68
+
69
+ - name: Run tests
70
+ env:
71
+ REDIS_VERSION: ${{ matrix.backend.redis-version }}
72
+ run: uv run pytest --cov-branch --cov-fail-under=${{ matrix.cov-threshold }} --cov-report=xml --cov-report=term-missing:skip-covered ${{ matrix.pytest-args }}
73
+
74
+ - name: Upload coverage reports to Codecov
75
+ uses: codecov/codecov-action@v5
76
+ with:
77
+ token: ${{ secrets.CODECOV_TOKEN }}
78
+ flags: python-${{ matrix.python-version }}
79
+
80
+ prek:
81
+ name: Prek checks
82
+ runs-on: ubuntu-latest
83
+ steps:
84
+ - uses: actions/checkout@v4
85
+
86
+ - name: Install uv and set Python version
87
+ uses: astral-sh/setup-uv@v5
88
+ with:
89
+ python-version: "3.10"
90
+ enable-cache: true
91
+ cache-dependency-glob: "pyproject.toml"
92
+
93
+ - name: Install dependencies
94
+ run: uv sync
95
+
96
+ - name: Run prek
97
+ uses: j178/prek-action@v1
@@ -0,0 +1,40 @@
1
+ name: Claude Code Review
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize]
6
+
7
+ jobs:
8
+ claude-review:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ pull-requests: read
13
+ issues: read
14
+ id-token: write
15
+
16
+ steps:
17
+ - name: Checkout repository
18
+ uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 1
21
+
22
+ - name: Run Claude Code Review
23
+ id: claude-review
24
+ uses: anthropics/claude-code-action@beta
25
+ with:
26
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
27
+ model: "claude-opus-4-1-20250805"
28
+
29
+ # Direct prompt for automated review (no @claude mention needed)
30
+ direct_prompt: |
31
+ Please review this pull request and provide feedback on:
32
+ - Code quality and best practices
33
+ - Potential bugs or issues
34
+ - Performance considerations
35
+ - Security concerns
36
+ - Test coverage, which must be maintained at 100% for this project
37
+
38
+ Be constructive and helpful in your feedback.
39
+
40
+ use_sticky_comment: true
@@ -0,0 +1,42 @@
1
+ name: Claude Code
2
+
3
+ on:
4
+ issue_comment:
5
+ types: [created]
6
+ pull_request_review_comment:
7
+ types: [created]
8
+ issues:
9
+ types: [opened, assigned]
10
+ pull_request_review:
11
+ types: [submitted]
12
+
13
+ jobs:
14
+ claude:
15
+ if: |
16
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
17
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
18
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
19
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20
+ runs-on: ubuntu-latest
21
+ permissions:
22
+ contents: read
23
+ pull-requests: read
24
+ issues: read
25
+ id-token: write
26
+ actions: read # Required for Claude to read CI results on PRs
27
+ steps:
28
+ - name: Checkout repository
29
+ uses: actions/checkout@v4
30
+ with:
31
+ fetch-depth: 1
32
+
33
+ - name: Run Claude Code
34
+ id: claude
35
+ uses: anthropics/claude-code-action@beta
36
+ with:
37
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
38
+
39
+ additional_permissions: |
40
+ actions: read
41
+
42
+ model: "claude-opus-4-1-20250805"
@@ -9,3 +9,5 @@ __pycache__/
9
9
  build/
10
10
  dist/
11
11
  wheels/
12
+
13
+ .coverage.*
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
6
6
 
7
7
  **Docket** (`pydocket` on PyPI) is a distributed background task system for Python functions with Redis-backed persistence. It enables scheduling both immediate and future work with comprehensive dependency injection, retry mechanisms, and fault tolerance.
8
8
 
9
- **Key Requirements**: Python 3.12+, Redis 6.2+ or Valkey 8.0+
9
+ **Key Requirements**: Python 3.10+, Redis 6.2+ or Valkey 8.0+
10
10
 
11
11
  ## Development Commands
12
12
 
@@ -34,8 +34,8 @@ ruff format
34
34
  pyright
35
35
  pyright tests
36
36
 
37
- # Run all pre-commit hooks
38
- pre-commit run --all-files
37
+ # Run all prek hooks
38
+ uv run prek run --all-files
39
39
  ```
40
40
 
41
41
  ### Development Setup
@@ -44,8 +44,8 @@ pre-commit run --all-files
44
44
  # Install development dependencies
45
45
  uv sync --group dev
46
46
 
47
- # Install pre-commit hooks
48
- pre-commit install
47
+ # Install prek hooks
48
+ uv run prek install
49
49
  ```
50
50
 
51
51
  ### Git Workflow
@@ -58,7 +58,6 @@ pre-commit install
58
58
  ### Key Classes
59
59
 
60
60
  - **`Docket`** (`src/docket/docket.py`): Central task registry and scheduler
61
-
62
61
  - `add()`: Schedule tasks for execution
63
62
  - `replace()`: Replace existing scheduled tasks
64
63
  - `cancel()`: Cancel pending tasks
@@ -66,7 +65,6 @@ pre-commit install
66
65
  - `snapshot()`: Get current state for observability
67
66
 
68
67
  - **`Worker`** (`src/docket/worker.py`): Task execution engine
69
-
70
68
  - `run_forever()`/`run_until_finished()`: Main execution loops
71
69
  - Handles concurrency, retries, and dependency injection
72
70
  - Maintains heartbeat for liveness tracking
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydocket
3
- Version: 0.9.2
3
+ Version: 0.13.0b1
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
@@ -19,18 +19,25 @@ Classifier: Development Status :: 4 - Beta
19
19
  Classifier: License :: OSI Approved :: MIT License
20
20
  Classifier: Operating System :: OS Independent
21
21
  Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
22
24
  Classifier: Programming Language :: Python :: 3.12
23
25
  Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
24
27
  Classifier: Typing :: Typed
25
- Requires-Python: >=3.12
28
+ Requires-Python: >=3.10
26
29
  Requires-Dist: cloudpickle>=3.1.1
30
+ Requires-Dist: exceptiongroup>=1.2.0; python_version < '3.11'
31
+ Requires-Dist: fakeredis[lua]>=2.32.1
27
32
  Requires-Dist: opentelemetry-api>=1.30.0
28
33
  Requires-Dist: opentelemetry-exporter-prometheus>=0.51b0
29
34
  Requires-Dist: prometheus-client>=0.21.1
35
+ Requires-Dist: py-key-value-aio[memory,redis]>=0.2.8
30
36
  Requires-Dist: python-json-logger>=3.2.1
31
- Requires-Dist: redis>=4.6
37
+ Requires-Dist: redis>=5
32
38
  Requires-Dist: rich>=13.9.4
33
39
  Requires-Dist: typer>=0.15.1
40
+ Requires-Dist: typing-extensions>=4.12.0
34
41
  Requires-Dist: uuid7>=0.1.0
35
42
  Description-Content-Type: text/markdown
36
43
 
@@ -69,6 +76,7 @@ from docket import Docket, Worker
69
76
 
70
77
  async with Docket() as docket:
71
78
  async with Worker(docket) as worker:
79
+ worker.register(greet)
72
80
  await worker.run_until_finished()
73
81
  ```
74
82
 
@@ -98,7 +106,7 @@ reference](https://chrisguidry.github.io/docket/api-reference/).
98
106
  ## Installing `docket`
99
107
 
100
108
  Docket is [available on PyPI](https://pypi.org/project/pydocket/) under the package name
101
- `pydocket`. It targets Python 3.12 or above.
109
+ `pydocket`. It targets Python 3.10 or above.
102
110
 
103
111
  With [`uv`](https://docs.astral.sh/uv/):
104
112
 
@@ -119,6 +127,18 @@ pip install pydocket
119
127
  Docket requires a [Redis](http://redis.io/) server with Streams support (which was
120
128
  introduced in Redis 5.0.0). Docket is tested with Redis 6 and 7.
121
129
 
130
+ For testing without Redis, Docket includes [fakeredis](https://github.com/cunla/fakeredis-py) for in-memory operation:
131
+
132
+ ```python
133
+ from docket import Docket
134
+
135
+ async with Docket(name="my-docket", url="memory://my-docket") as docket:
136
+ # Use docket normally - all operations are in-memory
137
+ ...
138
+ ```
139
+
140
+ See [Testing with Docket](https://chrisguidry.github.io/docket/testing/#using-in-memory-backend-no-redis-required) for more details.
141
+
122
142
  # Hacking on `docket`
123
143
 
124
144
  We use [`uv`](https://docs.astral.sh/uv/) for project management, so getting set up
@@ -33,6 +33,7 @@ from docket import Docket, Worker
33
33
 
34
34
  async with Docket() as docket:
35
35
  async with Worker(docket) as worker:
36
+ worker.register(greet)
36
37
  await worker.run_until_finished()
37
38
  ```
38
39
 
@@ -62,7 +63,7 @@ reference](https://chrisguidry.github.io/docket/api-reference/).
62
63
  ## Installing `docket`
63
64
 
64
65
  Docket is [available on PyPI](https://pypi.org/project/pydocket/) under the package name
65
- `pydocket`. It targets Python 3.12 or above.
66
+ `pydocket`. It targets Python 3.10 or above.
66
67
 
67
68
  With [`uv`](https://docs.astral.sh/uv/):
68
69
 
@@ -83,6 +84,18 @@ pip install pydocket
83
84
  Docket requires a [Redis](http://redis.io/) server with Streams support (which was
84
85
  introduced in Redis 5.0.0). Docket is tested with Redis 6 and 7.
85
86
 
87
+ For testing without Redis, Docket includes [fakeredis](https://github.com/cunla/fakeredis-py) for in-memory operation:
88
+
89
+ ```python
90
+ from docket import Docket
91
+
92
+ async with Docket(name="my-docket", url="memory://my-docket") as docket:
93
+ # Use docket normally - all operations are in-memory
94
+ ...
95
+ ```
96
+
97
+ See [Testing with Docket](https://chrisguidry.github.io/docket/testing/#using-in-memory-backend-no-redis-required) for more details.
98
+
86
99
  # Hacking on `docket`
87
100
 
88
101
  We use [`uv`](https://docs.astral.sh/uv/) for project management, so getting set up
@@ -73,7 +73,20 @@ async def main(
73
73
  tasks: int = 20000,
74
74
  producers: int = 5,
75
75
  workers: int = 10,
76
+ base_version: str | None = None,
76
77
  ):
78
+ if base_version is None:
79
+ process = await asyncio.create_subprocess_exec(
80
+ "git",
81
+ "describe",
82
+ "--tags",
83
+ "--abbrev=0",
84
+ stdout=subprocess.PIPE,
85
+ stderr=subprocess.PIPE,
86
+ )
87
+ stdout, _ = await process.communicate()
88
+ base_version = stdout.decode("utf-8").strip()
89
+
77
90
  async with (
78
91
  run_redis("7.4.2") as (redis_url, redis_container),
79
92
  Docket(
@@ -105,11 +118,17 @@ async def main(
105
118
  )
106
119
 
107
120
  async def spawn_producer() -> Process:
121
+ docket_version = base_version if random.random() < 0.5 else "main"
122
+ base_command = ["uv", "run"]
123
+ if docket_version != "main":
124
+ logger.info("Using pydocket %s for producer", docket_version)
125
+ base_command.extend(["--with", f"pydocket=={docket_version}"])
126
+ else:
127
+ logger.info("Using main pydocket for producer")
128
+
129
+ command = [*base_command, "-m", "chaos.producer", str(tasks_per_producer)]
108
130
  return await asyncio.create_subprocess_exec(
109
- *python_entrypoint(),
110
- "-m",
111
- "chaos.producer",
112
- str(tasks_per_producer),
131
+ *command,
113
132
  env=environment | {"OTEL_SERVICE_NAME": "chaos-producer"},
114
133
  stdout=subprocess.DEVNULL,
115
134
  stderr=subprocess.DEVNULL,
@@ -122,8 +141,16 @@ async def main(
122
141
  logger.info("Spawning %d workers...", workers)
123
142
 
124
143
  async def spawn_worker() -> Process:
125
- return await asyncio.create_subprocess_exec(
126
- *python_entrypoint(),
144
+ docket_version = base_version if random.random() < 0.5 else "main"
145
+ base_command = ["uv", "run"]
146
+ if docket_version != "main":
147
+ logger.info("Using pydocket %s for worker", docket_version)
148
+ base_command.extend(["--with", f"pydocket=={docket_version}"])
149
+ else:
150
+ logger.info("Using main pydocket for worker")
151
+
152
+ command = [
153
+ *base_command,
127
154
  "-m",
128
155
  "docket",
129
156
  "worker",
@@ -135,6 +162,9 @@ async def main(
135
162
  "chaos.tasks:chaos_tasks",
136
163
  "--redelivery-timeout",
137
164
  "5s",
165
+ ]
166
+ return await asyncio.create_subprocess_exec(
167
+ *command,
138
168
  env=environment | {"OTEL_SERVICE_NAME": "chaos-worker"},
139
169
  stdout=subprocess.DEVNULL,
140
170
  stderr=subprocess.DEVNULL,
@@ -140,6 +140,138 @@ async def process_single_order(order_id: int) -> None:
140
140
 
141
141
  This pattern separates discovery (finding work) from execution (doing work), allowing for better load distribution and fault isolation. The perpetual task stays lightweight and fast, while the actual work is distributed across many workers.
142
142
 
143
+ ## Task Scattering with Agenda
144
+
145
+ For "find-and-flood" workloads, you often want to distribute a batch of tasks over time rather than scheduling them all immediately. The `Agenda` class collects related tasks and scatters them evenly across a time window.
146
+
147
+ ### Basic Scattering
148
+
149
+ ```python
150
+ from datetime import timedelta
151
+ from docket import Agenda, Docket
152
+
153
+ async def process_item(item_id: int) -> None:
154
+ await perform_expensive_operation(item_id)
155
+ await update_database(item_id)
156
+
157
+ async with Docket() as docket:
158
+ # Build an agenda of tasks
159
+ agenda = Agenda()
160
+ for item_id in range(1, 101): # 100 items to process
161
+ agenda.add(process_item)(item_id)
162
+
163
+ # Scatter them evenly over 50 minutes to avoid overwhelming the system
164
+ executions = await agenda.scatter(docket, over=timedelta(minutes=50))
165
+ print(f"Scheduled {len(executions)} tasks over 50 minutes")
166
+ ```
167
+
168
+ Tasks are distributed evenly across the time window. For 100 tasks over 50 minutes, they'll be scheduled approximately 30 seconds apart.
169
+
170
+ ### Jitter for Thundering Herd Prevention
171
+
172
+ Add random jitter to prevent multiple processes from scheduling identical work at exactly the same times:
173
+
174
+ ```python
175
+ # Scatter with ±30 second jitter around each scheduled time
176
+ await agenda.scatter(
177
+ docket,
178
+ over=timedelta(minutes=50),
179
+ jitter=timedelta(seconds=30)
180
+ )
181
+ ```
182
+
183
+ ### Future Scatter Windows
184
+
185
+ Schedule the entire batch to start at a specific time in the future:
186
+
187
+ ```python
188
+ from datetime import datetime, timezone
189
+
190
+ # Start scattering in 2 hours, spread over 30 minutes
191
+ start_time = datetime.now(timezone.utc) + timedelta(hours=2)
192
+ await agenda.scatter(
193
+ docket,
194
+ start=start_time,
195
+ over=timedelta(minutes=30)
196
+ )
197
+ ```
198
+
199
+ ### Mixed Task Types
200
+
201
+ Agendas can contain different types of tasks:
202
+
203
+ ```python
204
+ async def send_email(user_id: str, template: str) -> None:
205
+ await email_service.send(user_id, template)
206
+
207
+ async def update_analytics(event_data: dict[str, str]) -> None:
208
+ await analytics_service.track(event_data)
209
+
210
+ # Create a mixed agenda
211
+ agenda = Agenda()
212
+ agenda.add(process_item)(item_id=1001)
213
+ agenda.add(send_email)("user123", "welcome")
214
+ agenda.add(update_analytics)({"event": "signup", "user": "user123"})
215
+ agenda.add(process_item)(item_id=1002)
216
+
217
+ # All tasks will be scattered in the order they were added
218
+ await agenda.scatter(docket, over=timedelta(minutes=10))
219
+ ```
220
+
221
+ ### Single Task Positioning
222
+
223
+ When scattering a single task, it's positioned at the midpoint of the time window:
224
+
225
+ ```python
226
+ agenda = Agenda()
227
+ agenda.add(process_item)(item_id=42)
228
+
229
+ # This task will be scheduled 5 minutes from now (middle of 10-minute window)
230
+ await agenda.scatter(docket, over=timedelta(minutes=10))
231
+ ```
232
+
233
+ ### Agenda Reusability
234
+
235
+ Agendas can be reused for multiple scatter operations:
236
+
237
+ ```python
238
+ # Create a reusable template
239
+ daily_cleanup_agenda = Agenda()
240
+ daily_cleanup_agenda.add(cleanup_temp_files)()
241
+ daily_cleanup_agenda.add(compress_old_logs)()
242
+ daily_cleanup_agenda.add(update_metrics)()
243
+
244
+ # Use it multiple times with different timing
245
+ await daily_cleanup_agenda.scatter(docket, over=timedelta(hours=1))
246
+
247
+ # Later, scatter the same tasks over a different window
248
+ tomorrow = datetime.now(timezone.utc) + timedelta(days=1)
249
+ await daily_cleanup_agenda.scatter(
250
+ docket,
251
+ start=tomorrow,
252
+ over=timedelta(minutes=30)
253
+ )
254
+ ```
255
+
256
+ ### Failure Behavior
257
+
258
+ Keep in mind that, if an error occurs during scheduling, some tasks may have already been scheduled successfully:
259
+
260
+ ```python
261
+ agenda = Agenda()
262
+ agenda.add(valid_task)("arg1")
263
+ agenda.add(valid_task)("arg2")
264
+ agenda.add("nonexistent_task")("arg3") # This will cause an error
265
+ agenda.add(valid_task)("arg4")
266
+
267
+ try:
268
+ await agenda.scatter(docket, over=timedelta(minutes=10))
269
+ except KeyError:
270
+ # The first two tasks were scheduled successfully
271
+ # The error prevented the fourth task from being scheduled
272
+ pass
273
+ ```
274
+
143
275
  ## Striking and Restoring Tasks
144
276
 
145
277
  Striking allows you to temporarily disable tasks without redeploying code. This is invaluable for incident response, gradual rollouts, or handling problematic customers.