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.
- bazis_async_background-2.3.0/.github/workflows/build-publish.yml +20 -0
- bazis_async_background-2.3.0/.github/workflows/release.yml +74 -0
- bazis_async_background-2.3.0/.github/workflows/tests.yml +123 -0
- bazis_async_background-2.3.0/CLAUDE.md +64 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/PKG-INFO +19 -9
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/README.md +13 -4
- bazis_async_background-2.3.0/bazis/contrib/async_background/_version.py +24 -0
- bazis_async_background-2.3.0/bazis/contrib/async_background/broker.py +241 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/conf.py +38 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/management/commands/kafka_consumer_single.py +6 -0
- bazis_async_background-2.3.0/bazis/contrib/async_background/producer.py +114 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/routes.py +19 -21
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/schemas.py +2 -2
- bazis_async_background-2.3.0/bazis/contrib/async_background/utils.py +152 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis_async_background.egg-info/PKG-INFO +19 -9
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis_async_background.egg-info/SOURCES.txt +8 -0
- bazis_async_background-2.3.0/bazis_async_background.egg-info/requires.txt +10 -0
- bazis_async_background-2.3.0/bazis_async_background.egg-info/scm_file_list.json +48 -0
- bazis_async_background-2.3.0/bazis_async_background.egg-info/scm_version.json +8 -0
- bazis_async_background-2.3.0/docs/releases/2.3.0.md +47 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/pyproject.toml +6 -4
- bazis_async_background-2.3.0/pytest.ini +6 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/Dockerfile +9 -8
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/demo/tasks.py +2 -11
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/docker-compose.test.yml +0 -38
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/main.py +0 -3
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/test.env +2 -0
- bazis_async_background-2.3.0/sample/uv.lock +1690 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/tests/conftest.py +20 -13
- bazis_async_background-2.3.0/tests/test_async_background.py +238 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/tests/test_demo.py +13 -4
- bazis_async_background-2.2.2/.github/workflows/build-publish.yml +0 -16
- bazis_async_background-2.2.2/bazis/contrib/async_background/_version.py +0 -34
- bazis_async_background-2.2.2/bazis/contrib/async_background/broker.py +0 -62
- bazis_async_background-2.2.2/bazis/contrib/async_background/producer.py +0 -132
- bazis_async_background-2.2.2/bazis/contrib/async_background/utils.py +0 -137
- bazis_async_background-2.2.2/bazis_async_background.egg-info/requires.txt +0 -9
- bazis_async_background-2.2.2/pytest.ini +0 -4
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/.dockerignore +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/.github/workflows/update-licenses.yml +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/.gitignore +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/__init__.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/apps.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/management/__init__.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/management/commands/__init__.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/management/commands/kafka_consumer_multiple.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis/contrib/async_background/router.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis_async_background.egg-info/dependency_links.txt +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/bazis_async_background.egg-info/top_level.txt +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/demo/__init__.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/demo/router.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/demo/schemas.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/manage.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/project.env +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/pyproject.toml +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/__init__.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/router.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/settings.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/urls.py +0 -0
- {bazis_async_background-2.2.2 → bazis_async_background-2.3.0}/sample/sample/wsgi.py +0 -0
- {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.
|
|
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` —
|
|
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` —
|
|
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
|