bazis-async-background 2.2.2__tar.gz → 2.3.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.
Files changed (61) hide show
  1. bazis_async_background-2.3.0/.github/workflows/build-publish.yml +20 -0
  2. bazis_async_background-2.3.0/.github/workflows/release.yml +74 -0
  3. bazis_async_background-2.3.0/.github/workflows/tests.yml +123 -0
  4. bazis_async_background-2.3.0/CLAUDE.md +64 -0
  5. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/PKG-INFO +19 -9
  6. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/README.md +13 -4
  7. bazis_async_background-2.3.0/bazis/contrib/async_background/_version.py +24 -0
  8. bazis_async_background-2.3.0/bazis/contrib/async_background/broker.py +241 -0
  9. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/conf.py +38 -0
  10. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/management/commands/kafka_consumer_single.py +6 -0
  11. bazis_async_background-2.3.0/bazis/contrib/async_background/producer.py +114 -0
  12. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/routes.py +19 -21
  13. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/schemas.py +2 -2
  14. bazis_async_background-2.3.0/bazis/contrib/async_background/utils.py +152 -0
  15. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis_async_background.egg-info/PKG-INFO +19 -9
  16. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis_async_background.egg-info/SOURCES.txt +8 -0
  17. bazis_async_background-2.3.0/bazis_async_background.egg-info/requires.txt +10 -0
  18. bazis_async_background-2.3.0/bazis_async_background.egg-info/scm_file_list.json +48 -0
  19. bazis_async_background-2.3.0/bazis_async_background.egg-info/scm_version.json +8 -0
  20. bazis_async_background-2.3.0/docs/releases/2.3.0.md +47 -0
  21. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/pyproject.toml +6 -4
  22. bazis_async_background-2.3.0/pytest.ini +6 -0
  23. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/Dockerfile +9 -8
  24. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/demo/tasks.py +2 -11
  25. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/docker-compose.test.yml +0 -38
  26. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/main.py +0 -3
  27. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/test.env +2 -0
  28. bazis_async_background-2.3.0/sample/uv.lock +1690 -0
  29. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/tests/conftest.py +20 -13
  30. bazis_async_background-2.3.0/tests/test_async_background.py +238 -0
  31. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/tests/test_demo.py +13 -4
  32. bazis_async_background-2.2.2/.github/workflows/build-publish.yml +0 -16
  33. bazis_async_background-2.2.2/bazis/contrib/async_background/_version.py +0 -34
  34. bazis_async_background-2.2.2/bazis/contrib/async_background/broker.py +0 -62
  35. bazis_async_background-2.2.2/bazis/contrib/async_background/producer.py +0 -132
  36. bazis_async_background-2.2.2/bazis/contrib/async_background/utils.py +0 -137
  37. bazis_async_background-2.2.2/bazis_async_background.egg-info/requires.txt +0 -9
  38. bazis_async_background-2.2.2/pytest.ini +0 -4
  39. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/.dockerignore +0 -0
  40. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/.github/workflows/update-licenses.yml +0 -0
  41. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/.gitignore +0 -0
  42. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/__init__.py +0 -0
  43. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/apps.py +0 -0
  44. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/management/__init__.py +0 -0
  45. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/management/commands/__init__.py +0 -0
  46. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/management/commands/kafka_consumer_multiple.py +0 -0
  47. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/router.py +0 -0
  48. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis_async_background.egg-info/dependency_links.txt +0 -0
  49. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis_async_background.egg-info/top_level.txt +0 -0
  50. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/demo/__init__.py +0 -0
  51. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/demo/router.py +0 -0
  52. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/demo/schemas.py +0 -0
  53. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/manage.py +0 -0
  54. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/project.env +0 -0
  55. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/pyproject.toml +0 -0
  56. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/__init__.py +0 -0
  57. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/router.py +0 -0
  58. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/settings.py +0 -0
  59. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/urls.py +0 -0
  60. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/wsgi.py +0 -0
  61. {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/setup.cfg +0 -0
@@ -0,0 +1,20 @@
1
+ name: Build and Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*.*.*'
7
+ # started by the Release workflow on the tag it creates: a tag pushed with the
8
+ # workflow token does not trigger the push event above
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ contents: write
13
+
14
+ jobs:
15
+ build:
16
+ if: startsWith(github.ref, 'refs/tags/v')
17
+ uses: ecofuture-tech/.github/.github/workflows/reusable-build-publish.yml@main
18
+ secrets:
19
+ PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
20
+ TEST_PYPI_TOKEN: ${{ secrets.TEST_PYPI_TOKEN }}
@@ -0,0 +1,74 @@
1
+ name: Release
2
+
3
+ # Creates the release tag vX.Y.Z on the current main commit and starts Build and Publish
4
+ # for it. Run it from the Actions tab (or through the GitHub API) on the main branch.
5
+
6
+ on:
7
+ workflow_dispatch:
8
+ inputs:
9
+ version:
10
+ description: 'Version without the "v" prefix, e.g. 2.3.0 or 2.4.0-rc1'
11
+ required: true
12
+ type: string
13
+
14
+ permissions:
15
+ contents: write
16
+ actions: write
17
+
18
+ concurrency:
19
+ group: release
20
+ cancel-in-progress: false
21
+
22
+ jobs:
23
+ release:
24
+ runs-on: ubuntu-latest
25
+ timeout-minutes: 10
26
+ if: github.ref == 'refs/heads/main'
27
+ env:
28
+ GH_TOKEN: ${{ github.token }}
29
+ VERSION: ${{ inputs.version }}
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ with:
33
+ fetch-depth: 0
34
+
35
+ - name: Validate the version
36
+ run: |
37
+ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)[0-9]+)?$ ]]; then
38
+ echo "::error::Invalid version '$VERSION', expected e.g. 2.3.0 or 2.4.0-rc1"
39
+ exit 1
40
+ fi
41
+ if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
42
+ echo "::error::Tag v$VERSION already exists"
43
+ exit 1
44
+ fi
45
+ LAST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -n 1)
46
+ echo "Last release: ${LAST_TAG:-none}, new release: v$VERSION"
47
+ if [ -n "$LAST_TAG" ] && [ "$(printf '%s\n%s\n' "${LAST_TAG#v}" "$VERSION" | sort -V | tail -n 1)" != "$VERSION" ]; then
48
+ echo "::error::Version $VERSION is not newer than $LAST_TAG"
49
+ exit 1
50
+ fi
51
+
52
+ - name: Require green tests on this commit
53
+ run: |
54
+ CONCLUSION=$(gh run list --workflow tests.yml --commit "$GITHUB_SHA" --status completed \
55
+ --json conclusion --jq 'map(.conclusion) | first // "none"')
56
+ echo "Tests on $GITHUB_SHA: $CONCLUSION"
57
+ if [ "$CONCLUSION" != "success" ]; then
58
+ echo "::error::The Tests workflow has not succeeded on $GITHUB_SHA ($CONCLUSION)"
59
+ exit 1
60
+ fi
61
+
62
+ - name: Create the tag
63
+ run: |
64
+ git config user.name 'github-actions[bot]'
65
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
66
+ git tag -a "v$VERSION" -m "${{ github.event.repository.name }} $VERSION" "$GITHUB_SHA"
67
+ git push origin "v$VERSION"
68
+
69
+ - name: Start Build and Publish
70
+ run: gh workflow run build-publish.yml --ref "v$VERSION"
71
+
72
+ - name: Summary
73
+ run: |
74
+ echo "Tagged \`v$VERSION\` at \`$GITHUB_SHA\` and started Build and Publish." >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,123 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: tests-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ lint:
18
+ runs-on: ubuntu-latest
19
+ timeout-minutes: 10
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v6
23
+ - run: uvx ruff check bazis tests
24
+
25
+ tests:
26
+ name: tests (python ${{ matrix.python }}, ${{ matrix.resolution }})
27
+ runs-on: ubuntu-latest
28
+ timeout-minutes: 30
29
+ strategy:
30
+ fail-fast: false
31
+ matrix:
32
+ python: ['3.12', '3.13', '3.14']
33
+ resolution: [highest]
34
+ include:
35
+ # the lowest versions allowed by pyproject.toml
36
+ - python: '3.12'
37
+ resolution: lowest-direct
38
+
39
+ services:
40
+ postgres:
41
+ image: postgis/postgis:16-3.4
42
+ env:
43
+ POSTGRES_USER: postgres
44
+ POSTGRES_PASSWORD: postgres
45
+ POSTGRES_DB: bazis
46
+ ports:
47
+ - 5432:5432
48
+ options: >-
49
+ --health-cmd pg_isready
50
+ --health-interval 5s
51
+ --health-timeout 5s
52
+ --health-retries 10
53
+ redis:
54
+ image: redis:7
55
+ ports:
56
+ - 6379:6379
57
+ options: >-
58
+ --health-cmd "redis-cli ping"
59
+ --health-interval 5s
60
+ --health-timeout 5s
61
+ --health-retries 10
62
+ kafka:
63
+ image: apache/kafka:3.9.1
64
+ ports:
65
+ - 9092:9092
66
+ options: >-
67
+ --health-cmd "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092"
68
+ --health-interval 10s
69
+ --health-timeout 10s
70
+ --health-retries 15
71
+ env:
72
+ BS_DEBUG: 'true'
73
+ BS_SECRET_KEY: ci-only-secret-key-that-is-long-enough-0123456789
74
+ BS_DATABASES__DEFAULT__HOST: localhost
75
+ BS_DATABASES__DEFAULT__PORT: '5432'
76
+ BS_DATABASES__DEFAULT__NAME: bazis
77
+ BS_DATABASES__DEFAULT__USER: postgres
78
+ BS_DATABASES__DEFAULT__PASSWORD: postgres
79
+ BS_CACHES__DEFAULT__LOCATION: redis://localhost:6379/1
80
+ BS_MEDIA_ROOT: /tmp/bazis/media
81
+ BS_STATIC_ROOT: /tmp/bazis/static
82
+ BS_WEBAPP_ROOT: /tmp/bazis/webapp
83
+ BS_KAFKA_BOOTSTRAP_SERVERS: localhost:9092
84
+ BS_KAFKA_TOPIC_ASYNC_BG: sample_local_async_background
85
+ BS_KAFKA_GROUP_ID: sample_local
86
+ BS_KAFKA_AUTO_OFFSET_RESET: earliest
87
+ steps:
88
+ - uses: actions/checkout@v4
89
+
90
+ - name: Install GDAL (required by the PostGIS database backend)
91
+ # a stalled Ubuntu mirror must fail the step quickly instead of hanging the job
92
+ timeout-minutes: 10
93
+ run: |
94
+ APT="sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30"
95
+ $APT update
96
+ $APT install -y --no-install-recommends gdal-bin
97
+
98
+ - uses: astral-sh/setup-uv@v6
99
+ with:
100
+ python-version: ${{ matrix.python }}
101
+ activate-environment: true
102
+
103
+ - name: Install dependencies
104
+ run: uv pip install --resolution ${{ matrix.resolution }} -e '.[test]'
105
+
106
+ - name: Check for missing migrations
107
+ working-directory: sample
108
+ run: python manage.py makemigrations --check --dry-run
109
+
110
+ - name: Start a Kafka consumer
111
+ working-directory: sample
112
+ run: |
113
+ nohup python manage.py kafka_consumer_single > "$RUNNER_TEMP/consumer.log" 2>&1 &
114
+ sleep 15
115
+ cat "$RUNNER_TEMP/consumer.log"
116
+
117
+ - name: Run tests
118
+ working-directory: sample
119
+ run: python -m pytest ../tests -o addopts="" -p no:cacheprovider
120
+
121
+ - name: Consumer log
122
+ if: failure()
123
+ run: cat "$RUNNER_TEMP/consumer.log"
@@ -0,0 +1,64 @@
1
+ # bazis-async-background
2
+
3
+ Background tasks over Kafka for Bazis (FastStream): `enqueue_task_async` stores the status of a task in
4
+ Redis and publishes it to a Kafka topic; consumer processes (`kafka_consumer_single` /
5
+ `kafka_consumer_multiple`) run the subscribers registered in the `KAFKA_TASKS` modules
6
+ (`subscriber_kwargs()` builds their arguments from the settings) and publish the statuses to
7
+ the WebSocket channel of the client (bazis-ws). `GET /async_background_response/{task_id}/`
8
+ returns the result to the owner of the channel.
9
+
10
+ The channel of a request is resolved by `resolve_channel_name_async` with the functions of
11
+ bazis-ws: an anonymous token must never address a user channel.
12
+
13
+ The package code is in `bazis/contrib/async_background`, the sample project used by the tests is in `sample/`,
14
+ the tests are in `tests/`.
15
+
16
+ ## Running the tests
17
+
18
+ The tests need PostgreSQL with PostGIS and Redis and Kafka (see `.github/workflows/tests.yml`).
19
+ Run them from the `sample` directory:
20
+
21
+ ```bash
22
+ cd sample
23
+ BS_DEBUG=true \
24
+ BS_SECRET_KEY=local-secret-key-that-is-long-enough-0123456789 \
25
+ BS_DATABASES__DEFAULT__HOST=localhost BS_DATABASES__DEFAULT__PORT=5432 \
26
+ BS_DATABASES__DEFAULT__NAME=bazis BS_DATABASES__DEFAULT__USER=postgres \
27
+ BS_DATABASES__DEFAULT__PASSWORD=postgres \
28
+ BS_CACHES__DEFAULT__LOCATION=redis://localhost:6379/1 \
29
+ BS_MEDIA_ROOT=/tmp/bazis/media BS_STATIC_ROOT=/tmp/bazis/static BS_WEBAPP_ROOT=/tmp/bazis/webapp \
30
+ BS_KAFKA_BOOTSTRAP_SERVERS=localhost:9092 BS_KAFKA_TOPIC_ASYNC_BG=sample_local_async_background \
31
+ BS_KAFKA_GROUP_ID=sample_local \
32
+ python -m pytest ../tests -o addopts="" -p no:cacheprovider
33
+ ```
34
+
35
+ The tests marked `kafka` also need a running consumer (start it first, from `sample`, with
36
+ the same variables): `python manage.py kafka_consumer_single &`. Without Kafka settings they
37
+ are skipped. A local Kafka without Docker: download the Kafka binaries and start a single
38
+ KRaft node (`bin/kafka-storage.sh format ...`, `bin/kafka-server-start.sh
39
+ config/kraft/server.properties`).
40
+
41
+ Lint: `ruff check bazis tests`. CI also runs `python manage.py makemigrations --check
42
+ --dry-run` in `sample`: commit the migrations of model changes, including the sample apps.
43
+
44
+ ## Releasing
45
+
46
+ A release is the tag `vX.Y.Z` on `main`: the Build and Publish workflow builds the package
47
+ (the version comes from the tag through setuptools-scm) and publishes it to PyPI
48
+ (pre-releases `-alphaN`/`-betaN`/`-rcN` go to Test PyPI) and creates the GitHub release.
49
+
50
+ Claude Code sessions cannot push tags. Release through the **Release** workflow instead:
51
+
52
+ 1. Make sure the changes are merged into `main` and the Tests workflow is green on the
53
+ `main` head commit (the Release workflow checks this and refuses otherwise).
54
+ 2. Add the release notes as `docs/releases/X.Y.Z.md` in the change being released.
55
+ 3. Start the workflow `release.yml` on `ref: main` with the input `version: X.Y.Z`
56
+ (GitHub API: `POST /repos/ecofuture-tech/bazis-async-background/actions/workflows/release.yml/dispatches`;
57
+ with the GitHub MCP tools: `actions_run_trigger`, method `run_workflow`).
58
+ 4. The Release run creates the annotated tag and starts Build and Publish on it. Check
59
+ that both runs succeed and that the version appears on https://pypi.org/project/bazis-async-background/.
60
+
61
+ Release the Bazis packages in dependency order: a package is tested in CI against the
62
+ versions of its Bazis dependencies published on PyPI. Pick the version by semver:
63
+ breaking changes (settings renamed or required, dependency removed, behavior changed)
64
+ bump the minor version while the project is below 3.0.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazis-async-background
3
- Version: 2.2.2
3
+ Version: 2.3.0
4
4
  Summary: Async Background core framework for Bazis.
5
5
  Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
6
6
  Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
@@ -17,11 +17,12 @@ Classifier: Framework :: Django
17
17
  Classifier: Framework :: FastAPI
18
18
  Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
- Requires-Dist: bazis
21
- Requires-Dist: bazis-ws
22
- Requires-Dist: faststream[kafka]
20
+ Requires-Dist: bazis>=2.3.1
21
+ Requires-Dist: bazis-ws>=2.3.0
22
+ Requires-Dist: faststream[kafka]>=0.6
23
+ Requires-Dist: redis>=5.0
23
24
  Provides-Extra: test
24
- Requires-Dist: bazis-test-utils; extra == "test"
25
+ Requires-Dist: bazis-test-utils>=2.3.0; extra == "test"
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: ruff; extra == "dev"
27
28
 
@@ -197,8 +198,10 @@ BS_KAFKA_TASKS='["my_app.background.tasks"]'
197
198
  - `KAFKA_CONSUMER_LIFETIME_SEC` — consumer working time before restart
198
199
  - `KAFKA_CONSUMER_LIFETIME_JITTER_SEC` — random deviation to avoid simultaneous restart
199
200
  - `KAFKA_AUTO_OFFSET_RESET` — Kafka auto offset reset policy
200
- - `KAFKA_ENABLE_AUTO_COMMIT` — Kafka auto-commit toggle
201
+ - `KAFKA_ENABLE_AUTO_COMMIT` — commit a message before processing it (FastStream `AckPolicy.ACK_FIRST`; not recommended: a crash loses the message)
202
+ - `KAFKA_ACK_POLICY` — without auto commit, when a message is committed: `reject_on_error` (default: after processing, also when it failed, so a failing message is not redelivered forever), `nack_on_error` (only after a successful processing) or `ack`
201
203
  - `KAFKA_AUTO_COMMIT_INTERVAL_MS` — auto-commit interval in ms
204
+ - `KAFKA_PUBLISH_TIMEOUT_SEC` — timeout of publishing a task
202
205
  - `KAFKA_LOG_LEVEL` — log level for consumers
203
206
 
204
207
  ### Route Registration
@@ -214,7 +217,14 @@ router = BazisRouter(prefix='/api/v1')
214
217
  router.register('bazis.contrib.async_background.router')
215
218
  ```
216
219
 
217
- This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`
220
+ This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`.
221
+
222
+ The request must carry the same `Authorization: Bearer <token>` as the request that created
223
+ the task: a user session JWT (bazis-users) or an anonymous token generated by the client
224
+ (16–128 characters `A-Z a-z 0-9 _ -`). The token determines the WebSocket channel of the
225
+ client (bazis-ws), which receives the status updates and owns the results; the results of
226
+ other channels are refused with 403. The statuses are stored in the cache database under
227
+ `async_bg:task:<task_id>` for `KAFKA_RESPONSE_HOLD_SEC`.
218
228
 
219
229
  ## Usage
220
230
 
@@ -245,7 +255,7 @@ Runs 5 consumers in separate processes. Suitable for local development or deploy
245
255
  ### Minimal Task Registration
246
256
 
247
257
  ```python
248
- from bazis.contrib.async_background.broker import get_broker_for_consumer
258
+ from bazis.contrib.async_background.broker import get_broker_for_consumer, subscriber_kwargs
249
259
  from bazis.contrib.async_background.schemas import KafkaTask, TaskStatus
250
260
  from bazis.contrib.async_background.utils import set_and_publish_status_async
251
261
  from pydantic import BaseModel
@@ -255,7 +265,7 @@ class DemoPayload(BaseModel):
255
265
  message: str
256
266
 
257
267
 
258
- @get_broker_for_consumer().subscriber("my_app_background_tasks")
268
+ @get_broker_for_consumer().subscriber("my_app_background_tasks", **subscriber_kwargs())
259
269
  async def consumer_demo(task: KafkaTask[DemoPayload]):
260
270
  await set_and_publish_status_async(
261
271
  task_id=task.task_id,
@@ -170,8 +170,10 @@ BS_KAFKA_TASKS='["my_app.background.tasks"]'
170
170
  - `KAFKA_CONSUMER_LIFETIME_SEC` — consumer working time before restart
171
171
  - `KAFKA_CONSUMER_LIFETIME_JITTER_SEC` — random deviation to avoid simultaneous restart
172
172
  - `KAFKA_AUTO_OFFSET_RESET` — Kafka auto offset reset policy
173
- - `KAFKA_ENABLE_AUTO_COMMIT` — Kafka auto-commit toggle
173
+ - `KAFKA_ENABLE_AUTO_COMMIT` — commit a message before processing it (FastStream `AckPolicy.ACK_FIRST`; not recommended: a crash loses the message)
174
+ - `KAFKA_ACK_POLICY` — without auto commit, when a message is committed: `reject_on_error` (default: after processing, also when it failed, so a failing message is not redelivered forever), `nack_on_error` (only after a successful processing) or `ack`
174
175
  - `KAFKA_AUTO_COMMIT_INTERVAL_MS` — auto-commit interval in ms
176
+ - `KAFKA_PUBLISH_TIMEOUT_SEC` — timeout of publishing a task
175
177
  - `KAFKA_LOG_LEVEL` — log level for consumers
176
178
 
177
179
  ### Route Registration
@@ -187,7 +189,14 @@ router = BazisRouter(prefix='/api/v1')
187
189
  router.register('bazis.contrib.async_background.router')
188
190
  ```
189
191
 
190
- This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`
192
+ This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`.
193
+
194
+ The request must carry the same `Authorization: Bearer <token>` as the request that created
195
+ the task: a user session JWT (bazis-users) or an anonymous token generated by the client
196
+ (16–128 characters `A-Z a-z 0-9 _ -`). The token determines the WebSocket channel of the
197
+ client (bazis-ws), which receives the status updates and owns the results; the results of
198
+ other channels are refused with 403. The statuses are stored in the cache database under
199
+ `async_bg:task:<task_id>` for `KAFKA_RESPONSE_HOLD_SEC`.
191
200
 
192
201
  ## Usage
193
202
 
@@ -218,7 +227,7 @@ Runs 5 consumers in separate processes. Suitable for local development or deploy
218
227
  ### Minimal Task Registration
219
228
 
220
229
  ```python
221
- from bazis.contrib.async_background.broker import get_broker_for_consumer
230
+ from bazis.contrib.async_background.broker import get_broker_for_consumer, subscriber_kwargs
222
231
  from bazis.contrib.async_background.schemas import KafkaTask, TaskStatus
223
232
  from bazis.contrib.async_background.utils import set_and_publish_status_async
224
233
  from pydantic import BaseModel
@@ -228,7 +237,7 @@ class DemoPayload(BaseModel):
228
237
  message: str
229
238
 
230
239
 
231
- @get_broker_for_consumer().subscriber("my_app_background_tasks")
240
+ @get_broker_for_consumer().subscriber("my_app_background_tasks", **subscriber_kwargs())
232
241
  async def consumer_demo(task: KafkaTask[DemoPayload]):
233
242
  await set_and_publish_status_async(
234
243
  task_id=task.task_id,
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
+
24
+ __commit_id__ = commit_id = 'gf5747b6d2'
@@ -0,0 +1,241 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import asyncio
16
+ import logging
17
+ import random
18
+ from contextlib import asynccontextmanager
19
+
20
+ from django.conf import settings
21
+
22
+ from aiokafka.admin import AIOKafkaAdminClient, NewTopic
23
+ from aiokafka.errors import NoError, TopicAlreadyExistsError
24
+ from faststream import AckPolicy, FastStream
25
+ from faststream.kafka import KafkaBroker
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ _brokers_by_loop: dict[asyncio.AbstractEventLoop, KafkaBroker] = {}
32
+
33
+
34
+ def drop_closed_loops(registry: dict):
35
+ """
36
+ Removes the objects of closed event loops from a per-loop registry: the objects refer to
37
+ their loop, so a weak registry would never release them.
38
+ """
39
+ for loop in [loop for loop in registry if loop.is_closed()]:
40
+ del registry[loop]
41
+ _consumer_broker: KafkaBroker | None = None
42
+
43
+
44
+ def _new_broker() -> KafkaBroker:
45
+ # the broker connects when it starts: the task modules can be imported without Kafka.
46
+ # The publication is bounded by the request timeout of the producer: cancelling it from
47
+ # outside would leave the message queued in the producer and delivered later
48
+ return KafkaBroker(
49
+ settings.KAFKA_BOOTSTRAP_SERVERS or 'localhost',
50
+ request_timeout_ms=settings.KAFKA_PUBLISH_TIMEOUT_SEC * 1000,
51
+ )
52
+
53
+
54
+ def get_broker_for_async() -> KafkaBroker:
55
+ """
56
+ Returns the (not started) publishing broker of the running event loop: a broker and
57
+ its connections cannot be shared between event loops.
58
+ """
59
+ loop = asyncio.get_running_loop()
60
+ broker = _brokers_by_loop.get(loop)
61
+ if broker is None:
62
+ drop_closed_loops(_brokers_by_loop)
63
+ broker = _new_broker()
64
+ _brokers_by_loop[loop] = broker
65
+ return broker
66
+
67
+
68
+ def get_broker_for_consumer() -> KafkaBroker:
69
+ """
70
+ Returns the broker that the subscribers of the consumer process register on.
71
+ """
72
+ global _consumer_broker
73
+ if _consumer_broker is None:
74
+ _consumer_broker = _new_broker()
75
+ return _consumer_broker
76
+
77
+
78
+ def subscriber_kwargs(**overrides) -> dict:
79
+ """
80
+ The arguments of `broker.subscriber(...)` from the KAFKA_* settings, for the installed
81
+ FastStream (`auto_commit` was replaced by `ack_policy` in FastStream 0.6)::
82
+
83
+ @get_broker_for_consumer().subscriber(settings.KAFKA_TOPIC_ASYNC_BG, **subscriber_kwargs())
84
+ async def consumer(task: KafkaTask[MyPayload]): ...
85
+ """
86
+ kwargs: dict[str, object] = {
87
+ 'auto_offset_reset': settings.KAFKA_AUTO_OFFSET_RESET,
88
+ 'auto_commit_interval_ms': settings.KAFKA_AUTO_COMMIT_INTERVAL_MS,
89
+ 'consumer_timeout_ms': settings.KAFKA_CONSUMER_TIMEOUT_MS,
90
+ 'ack_policy': (
91
+ AckPolicy.ACK_FIRST
92
+ if settings.KAFKA_ENABLE_AUTO_COMMIT
93
+ else AckPolicy(settings.KAFKA_ACK_POLICY)
94
+ ),
95
+ }
96
+ if settings.KAFKA_GROUP_ID:
97
+ kwargs['group_id'] = settings.KAFKA_GROUP_ID
98
+ if settings.KAFKA_FETCH_MIN_BYTES is not None:
99
+ kwargs['fetch_min_bytes'] = settings.KAFKA_FETCH_MIN_BYTES
100
+ if settings.KAFKA_FETCH_MAX_WAIT_MS is not None:
101
+ kwargs['fetch_max_wait_ms'] = settings.KAFKA_FETCH_MAX_WAIT_MS
102
+ kwargs.update(overrides)
103
+ return kwargs
104
+
105
+
106
+ def consumer_lifetime() -> float | None:
107
+ """
108
+ The lifetime of a consumer process in seconds (with a random jitter), or None.
109
+ """
110
+ if not settings.KAFKA_CONSUMER_LIFETIME_SEC:
111
+ return None
112
+ jitter = settings.KAFKA_CONSUMER_LIFETIME_JITTER_SEC or 0
113
+ return settings.KAFKA_CONSUMER_LIFETIME_SEC + random.randint(0, jitter)
114
+
115
+
116
+ def lifetime_hooks(app: FastStream, lifetime: float):
117
+ """
118
+ Returns the hooks that start and cancel the timer which stops the application after
119
+ its lifetime.
120
+ """
121
+ timer: dict[str, asyncio.Task] = {}
122
+
123
+ async def stop_after_lifetime():
124
+ await asyncio.sleep(lifetime)
125
+ logger.info('Kafka consumer lifetime of %s s is over, stopping.', round(lifetime))
126
+ app.exit()
127
+
128
+ async def start_lifetime_timer():
129
+ timer['task'] = asyncio.create_task(stop_after_lifetime())
130
+
131
+ async def stop_lifetime_timer():
132
+ if task := timer.get('task'):
133
+ task.cancel()
134
+
135
+ return start_lifetime_timer, stop_lifetime_timer
136
+
137
+
138
+ def build_app() -> FastStream:
139
+ """
140
+ The FastStream application of a consumer process. With KAFKA_CONSUMER_LIFETIME_SEC the
141
+ application stops after its lifetime, and kafka_consumer_multiple starts a new process.
142
+ """
143
+ app = FastStream(get_broker_for_consumer())
144
+ if lifetime := consumer_lifetime():
145
+ start_timer, stop_timer = lifetime_hooks(app, lifetime)
146
+ app.after_startup(start_timer)
147
+ app.on_shutdown(stop_timer)
148
+ return app
149
+
150
+
151
+ @asynccontextmanager
152
+ async def _get_admin_client():
153
+ admin_client = AIOKafkaAdminClient(
154
+ bootstrap_servers=settings.KAFKA_BOOTSTRAP_SERVERS,
155
+ request_timeout_ms=settings.KAFKA_ADMIN_TIMEOUT_MS,
156
+ )
157
+ started = False
158
+ try:
159
+ await admin_client.start()
160
+ started = True
161
+ yield admin_client
162
+ finally:
163
+ if started:
164
+ await admin_client.close()
165
+ else:
166
+ try:
167
+ await admin_client.close()
168
+ except Exception:
169
+ logger.debug(
170
+ "Kafka admin client close failed after failed start.",
171
+ exc_info=True,
172
+ )
173
+
174
+
175
+ async def ensure_topic_exists(
176
+ topic_name: str,
177
+ num_partitions: int | None = None,
178
+ replication_factor: int | None = None,
179
+ ) -> None:
180
+ logger.info("Ensuring Kafka topic '%s' exists...", topic_name)
181
+
182
+ resolved_num_partitions = num_partitions or settings.KAFKA_AUTO_TOPIC_NUM_PARTITIONS
183
+ resolved_replication_factor = replication_factor or settings.KAFKA_AUTO_TOPIC_REPLICATION_FACTOR
184
+
185
+ try:
186
+ async with _get_admin_client() as admin_client:
187
+ topics = await admin_client.list_topics()
188
+ if topic_name in topics:
189
+ logger.info("Kafka topic '%s' already exists.", topic_name)
190
+ return
191
+
192
+ new_topic = NewTopic(
193
+ name=topic_name,
194
+ num_partitions=resolved_num_partitions,
195
+ replication_factor=resolved_replication_factor,
196
+ )
197
+
198
+ resp = await admin_client.create_topics([new_topic])
199
+
200
+ for err in getattr(resp, "topic_errors", []):
201
+ # err[0]/err[1]/err[2] topic/error_code/message
202
+ error_code = err[1]
203
+ if error_code == NoError.errno:
204
+ continue
205
+
206
+ if error_code == TopicAlreadyExistsError.errno:
207
+ logger.info("Kafka topic '%s' already exists.", topic_name)
208
+ return
209
+
210
+ error_message = err[2] if len(err) > 2 else None
211
+ raise RuntimeError(
212
+ f"Error creating Kafka topic '{topic_name}': "
213
+ f"code={error_code} message={error_message}"
214
+ )
215
+
216
+ logger.info("Kafka topic '%s' created successfully.", topic_name)
217
+ except TopicAlreadyExistsError:
218
+ logger.info("Kafka topic '%s' already exists.", topic_name)
219
+ except Exception:
220
+ logger.exception("Error creating Kafka topic '%s'.", topic_name)
221
+ raise
222
+
223
+
224
+ async def get_topics_by_prefix(
225
+ prefix: str,
226
+ ) -> list[str]:
227
+ try:
228
+ async with _get_admin_client() as admin_client:
229
+ topics = await admin_client.list_topics()
230
+
231
+ topics = [t for t in topics if t.startswith(prefix)]
232
+ logger.info(
233
+ "Found %d topics with prefix '%s': %s",
234
+ len(topics),
235
+ prefix,
236
+ topics if topics else "none",
237
+ )
238
+ return topics
239
+ except Exception:
240
+ logger.exception("Kafka error while listing topics with prefix '%s'.", prefix)
241
+ raise