bazis-async-background 2.2.0__py3-none-any.whl
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/contrib/async_background/__init__.py +21 -0
- bazis/contrib/async_background/_version.py +34 -0
- bazis/contrib/async_background/apps.py +23 -0
- bazis/contrib/async_background/broker.py +62 -0
- bazis/contrib/async_background/conf.py +88 -0
- bazis/contrib/async_background/management/__init__.py +14 -0
- bazis/contrib/async_background/management/commands/__init__.py +14 -0
- bazis/contrib/async_background/management/commands/kafka_consumer_multiple.py +113 -0
- bazis/contrib/async_background/management/commands/kafka_consumer_single.py +91 -0
- bazis/contrib/async_background/producer.py +132 -0
- bazis/contrib/async_background/router.py +15 -0
- bazis/contrib/async_background/routes.py +57 -0
- bazis/contrib/async_background/schemas.py +36 -0
- bazis/contrib/async_background/utils.py +137 -0
- bazis_async_background-2.2.0.dist-info/METADATA +271 -0
- bazis_async_background-2.2.0.dist-info/RECORD +18 -0
- bazis_async_background-2.2.0.dist-info/WHEEL +5 -0
- bazis_async_background-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
from importlib.metadata import version
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = version('bazis-async-background')
|
|
20
|
+
except Exception:
|
|
21
|
+
__version__ = 'dev'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '2.2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 2, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
from django.utils.translation import gettext_lazy as _
|
|
16
|
+
|
|
17
|
+
from bazis.core.utils.apps import BaseConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyncBackgroundConfig(BaseConfig):
|
|
21
|
+
name = "bazis.contrib.async_background"
|
|
22
|
+
verbose_name = _("AsyncBackground")
|
|
23
|
+
default = True
|
|
@@ -0,0 +1,62 @@
|
|
|
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 random
|
|
17
|
+
from contextlib import asynccontextmanager
|
|
18
|
+
|
|
19
|
+
from django.conf import settings
|
|
20
|
+
|
|
21
|
+
from faststream import FastStream
|
|
22
|
+
from faststream.kafka import KafkaBroker
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_brokers_by_loop_id: dict[int, KafkaBroker] = {}
|
|
26
|
+
_consumer_broker: KafkaBroker | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _new_broker() -> KafkaBroker:
|
|
30
|
+
return KafkaBroker(settings.KAFKA_BOOTSTRAP_SERVERS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_broker_for_async() -> KafkaBroker:
|
|
34
|
+
loop_id = id(asyncio.get_running_loop())
|
|
35
|
+
broker = _brokers_by_loop_id.get(loop_id)
|
|
36
|
+
if broker is None:
|
|
37
|
+
broker = _new_broker()
|
|
38
|
+
_brokers_by_loop_id[loop_id] = broker
|
|
39
|
+
return broker
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_broker_for_consumer() -> KafkaBroker:
|
|
43
|
+
global _consumer_broker
|
|
44
|
+
if _consumer_broker is None:
|
|
45
|
+
_consumer_broker = _new_broker()
|
|
46
|
+
return _consumer_broker
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@asynccontextmanager
|
|
50
|
+
async def lifespan_handler(app: FastStream | None = None):
|
|
51
|
+
stop_task = asyncio.create_task(
|
|
52
|
+
asyncio.sleep(
|
|
53
|
+
settings.KAFKA_CONSUMER_LIFETIME_SEC +
|
|
54
|
+
random.randint(0, settings.KAFKA_CONSUMER_LIFETIME_JITTER_SEC)
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
yield
|
|
58
|
+
stop_task.cancel()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_app() -> FastStream:
|
|
62
|
+
return FastStream(get_broker_for_consumer(), lifespan=lifespan_handler)
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
from pydantic import Field, computed_field
|
|
16
|
+
|
|
17
|
+
from bazis.core.utils.schemas import BazisSettings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Settings(BazisSettings):
|
|
21
|
+
"""Kafka configuration."""
|
|
22
|
+
|
|
23
|
+
KAFKA_TASKS: list[str] = Field([], description="List of Kafka tasks to run.")
|
|
24
|
+
|
|
25
|
+
KAFKA_LOG_LEVEL: str = Field(
|
|
26
|
+
default="INFO", description="Logging level for Kafka (DEBUG, INFO, WARNING...)."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
KAFKA_CONSUMER_LIFETIME_SEC: int | None = Field(
|
|
30
|
+
default=None, description="Lifetime of the Kafka consumer (in seconds). For example, 7200."
|
|
31
|
+
) # TD: Try with rolling-update.
|
|
32
|
+
|
|
33
|
+
KAFKA_CONSUMER_LIFETIME_JITTER_SEC: int | None = Field(
|
|
34
|
+
default=None,
|
|
35
|
+
description="Maximum random lifetime shift (jitter) in seconds. For example, 300.",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
KAFKA_RESPONSE_HOLD_SEC: int = Field(
|
|
39
|
+
default=86400, description="Time to hold the response for async requests (in seconds)."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
KAFKA_BOOTSTRAP_SERVERS: str | None = Field(
|
|
43
|
+
default=None, description="List of Kafka brokers separated by commas (for example, 'kafka1:9092,kafka2:9092')."
|
|
44
|
+
) # List of brokers polled to obtain the topic owner
|
|
45
|
+
|
|
46
|
+
KAFKA_TOPIC_ASYNC_REQUEST: str | None = Field(default=None, description="Kafka topic for async requests.")
|
|
47
|
+
|
|
48
|
+
KAFKA_GROUP_ID: str | None = Field(default=None, description="Kafka consumer group for this service.")
|
|
49
|
+
|
|
50
|
+
KAFKA_AUTO_OFFSET_RESET: str = Field(
|
|
51
|
+
default="earliest",
|
|
52
|
+
description="Behavior when there is no offset: earliest - from the beginning, latest - from the end.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
KAFKA_ENABLE_AUTO_COMMIT: bool = Field(
|
|
56
|
+
default=False, description="Enable automatic committing of offsets (not recommended)."
|
|
57
|
+
)
|
|
58
|
+
# If you enable automatic commits, information about messages read via poll() will be automatically
|
|
59
|
+
# committed to Kafka (moving the offset) at the interval auto.commit.interval.ms
|
|
60
|
+
# This approach helps avoid frequent commits and reduce the load on Kafka, but it is not suitable for most of our
|
|
61
|
+
# cases because:
|
|
62
|
+
# 1) When the consumer crashes, information about the fact of processing messages since the last auto.commit.interval.ms
|
|
63
|
+
# is lost, the offset for them does not have time to be moved by a commit, and after the consumer is restarted these messages will
|
|
64
|
+
# be processed again.
|
|
65
|
+
# 2) The commit that moves the offset for a message read via poll() may be sent to Kafka before the consumer
|
|
66
|
+
# actually processes this message. This creates a risk that the consumer will not be able to process the message successfully,
|
|
67
|
+
# and Kafka will have already advanced the offset for this message.
|
|
68
|
+
|
|
69
|
+
KAFKA_AUTO_COMMIT_INTERVAL_MS: int = Field(
|
|
70
|
+
default=10000, description="Interval for auto-committing the offset if enable.auto.commit is enabled."
|
|
71
|
+
) # If KAFKA_ENABLE_AUTO_COMMIT is enabled
|
|
72
|
+
|
|
73
|
+
KAFKA_PUBLISH_TIMEOUT_SEC: int = Field(
|
|
74
|
+
default=10, description="Timeout in seconds for producing a message to Kafka."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@computed_field
|
|
78
|
+
@property
|
|
79
|
+
def KAFKA_ENABLED(self) -> bool: # noqa: N802
|
|
80
|
+
return all(
|
|
81
|
+
[
|
|
82
|
+
self.KAFKA_BOOTSTRAP_SERVERS,
|
|
83
|
+
self.KAFKA_TOPIC_ASYNC_REQUEST,
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
settings = Settings()
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
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 logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from django.core.management.base import BaseCommand, CommandParser
|
|
21
|
+
|
|
22
|
+
import psutil
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
POLL_INTERVAL_SEC = 1
|
|
28
|
+
SHUTDOWN_POLL_INTERVAL_SEC = 0.1
|
|
29
|
+
SHUTDOWN_TIMEOUT_SEC = 5
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Command(BaseCommand):
|
|
33
|
+
help = "Starts Kafka consumers in separate processes."
|
|
34
|
+
|
|
35
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--consumers-count",
|
|
38
|
+
type=int,
|
|
39
|
+
default=15,
|
|
40
|
+
help="Number of processes to start (default: 15).",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--restart-delay-sec",
|
|
44
|
+
type=float,
|
|
45
|
+
default=1.0,
|
|
46
|
+
help="Delay before restarting a consumer process (default: 1.0).",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--max-restarts",
|
|
50
|
+
type=int,
|
|
51
|
+
default=None,
|
|
52
|
+
help="Maximum restarts per consumer. Omit for unlimited.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def handle(self, *args, **options) -> None:
|
|
56
|
+
"""Starts Kafka consumers and monitors their completion."""
|
|
57
|
+
logger.info("Starting Kafka consumers...")
|
|
58
|
+
consumers_count = options["consumers_count"]
|
|
59
|
+
restart_delay_sec = options["restart_delay_sec"]
|
|
60
|
+
max_restarts = options["max_restarts"]
|
|
61
|
+
processes: dict[int, psutil.Popen] = {}
|
|
62
|
+
|
|
63
|
+
def start_consumer_process(index: int) -> psutil.Popen:
|
|
64
|
+
env = os.environ.copy()
|
|
65
|
+
process = psutil.Popen(
|
|
66
|
+
[str(sys.executable), "manage.py", "kafka_consumer_single"],
|
|
67
|
+
env=env,
|
|
68
|
+
)
|
|
69
|
+
logger.info("Started consumer process %s with index %s", process.pid, index)
|
|
70
|
+
return process
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
for i in range(1, consumers_count + 1):
|
|
74
|
+
processes[i] = start_consumer_process(i)
|
|
75
|
+
|
|
76
|
+
restart_counts: dict[int, int] = {i: 0 for i in processes}
|
|
77
|
+
|
|
78
|
+
while True:
|
|
79
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
80
|
+
|
|
81
|
+
for index, process in list(processes.items()):
|
|
82
|
+
if process.poll() is not None:
|
|
83
|
+
exit_code = process.returncode
|
|
84
|
+
logger.warning(
|
|
85
|
+
"Consumer process %s (index=%s) exited with code %s",
|
|
86
|
+
process.pid,
|
|
87
|
+
index,
|
|
88
|
+
exit_code,
|
|
89
|
+
)
|
|
90
|
+
restart_counts[index] += 1
|
|
91
|
+
if max_restarts is not None and restart_counts[index] > max_restarts:
|
|
92
|
+
logger.error(
|
|
93
|
+
"Consumer %s exceeded max restarts (%s).",
|
|
94
|
+
index,
|
|
95
|
+
max_restarts,
|
|
96
|
+
)
|
|
97
|
+
continue
|
|
98
|
+
time.sleep(restart_delay_sec)
|
|
99
|
+
logger.info("Restarting consumer process with index %s", index)
|
|
100
|
+
processes[index] = start_consumer_process(index)
|
|
101
|
+
|
|
102
|
+
except KeyboardInterrupt:
|
|
103
|
+
logger.warning("Received KeyboardInterrupt. Shutting down...")
|
|
104
|
+
start_time = time.time()
|
|
105
|
+
while (time.time() - start_time) < SHUTDOWN_TIMEOUT_SEC:
|
|
106
|
+
for process in processes.values():
|
|
107
|
+
process.poll()
|
|
108
|
+
time.sleep(SHUTDOWN_POLL_INTERVAL_SEC)
|
|
109
|
+
|
|
110
|
+
for process in processes.values():
|
|
111
|
+
if process.poll() is None:
|
|
112
|
+
process.terminate()
|
|
113
|
+
logger.info("All consumer processes terminated.")
|
|
@@ -0,0 +1,91 @@
|
|
|
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 inspect
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from django.conf import settings
|
|
22
|
+
from django.core.management.base import BaseCommand
|
|
23
|
+
|
|
24
|
+
from aiokafka.errors import KafkaConnectionError
|
|
25
|
+
|
|
26
|
+
from bazis.contrib.async_background.broker import build_app
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_consumer_logger(consumer_id: int) -> logging.Logger:
|
|
33
|
+
consumer_logger = logging.getLogger(f"consumer_{consumer_id}")
|
|
34
|
+
if not consumer_logger.handlers:
|
|
35
|
+
handler = logging.StreamHandler()
|
|
36
|
+
formatter = logging.Formatter(
|
|
37
|
+
"%(asctime)s - %(levelname)s - %(name)s - [consumer_id=%(consumer_id)s] - %(message)s",
|
|
38
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
39
|
+
)
|
|
40
|
+
handler.setFormatter(formatter)
|
|
41
|
+
consumer_logger.addHandler(handler)
|
|
42
|
+
consumer_logger.setLevel(settings.KAFKA_LOG_LEVEL)
|
|
43
|
+
return consumer_logger
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_consumer(consumer_id: int) -> None:
|
|
47
|
+
from bazis.core.app import app # noqa: F401
|
|
48
|
+
from bazis.core.router import router # noqa: F401
|
|
49
|
+
|
|
50
|
+
if not settings.KAFKA_TASKS:
|
|
51
|
+
logger.warning("No Kafka tasks configured in settings.KAFKA_TASKS.")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
for task_path in settings.KAFKA_TASKS:
|
|
55
|
+
__import__(task_path)
|
|
56
|
+
|
|
57
|
+
consumer_logger = _get_consumer_logger(consumer_id)
|
|
58
|
+
consumer_logger.info("Starting consumer process", extra={"consumer_id": consumer_id})
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
broker_app = build_app()
|
|
63
|
+
result = broker_app.run()
|
|
64
|
+
if inspect.iscoroutine(result):
|
|
65
|
+
asyncio.run(result)
|
|
66
|
+
break
|
|
67
|
+
except KafkaConnectionError as err:
|
|
68
|
+
consumer_logger.warning(
|
|
69
|
+
"Kafka not ready: %s. Retrying in 1s...",
|
|
70
|
+
err,
|
|
71
|
+
extra={"consumer_id": consumer_id},
|
|
72
|
+
)
|
|
73
|
+
time.sleep(1)
|
|
74
|
+
except Exception as err:
|
|
75
|
+
consumer_logger.exception(
|
|
76
|
+
"Error while processing: %s",
|
|
77
|
+
err,
|
|
78
|
+
extra={"consumer_id": consumer_id},
|
|
79
|
+
)
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
finally:
|
|
82
|
+
consumer_logger.info("Consumer process stopped", extra={"consumer_id": consumer_id})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Command(BaseCommand):
|
|
86
|
+
help = "Starts a single Kafka consumer (one process)."
|
|
87
|
+
|
|
88
|
+
def handle(self, *args, **options) -> None:
|
|
89
|
+
"""Entry point of the Django command."""
|
|
90
|
+
logger.info("Starting a single Kafka consumer...")
|
|
91
|
+
run_consumer(consumer_id=1)
|
|
@@ -0,0 +1,132 @@
|
|
|
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 threading
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from bazis.contrib.async_background.broker import get_broker_for_async
|
|
23
|
+
from bazis.contrib.async_background.schemas import KafkaTask, TaskStatus
|
|
24
|
+
from bazis.contrib.async_background.utils import set_and_publish_status_async
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def enqueue_task_async[Payload: BaseModel](
|
|
31
|
+
*,
|
|
32
|
+
topic_name: str,
|
|
33
|
+
channel_name: str,
|
|
34
|
+
payload: Payload,
|
|
35
|
+
partition_marker: str | None = None,
|
|
36
|
+
) -> KafkaTask[Payload]:
|
|
37
|
+
task_id = str(uuid4())
|
|
38
|
+
message = KafkaTask[Payload](
|
|
39
|
+
task_id=task_id,
|
|
40
|
+
channel_name=channel_name,
|
|
41
|
+
payload=payload,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
await set_and_publish_status_async(
|
|
45
|
+
task_id=task_id,
|
|
46
|
+
channel_name=channel_name,
|
|
47
|
+
status=TaskStatus.CREATED,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
producer = _get_kafka_producer(topic_name)
|
|
52
|
+
await producer.send_one_message(
|
|
53
|
+
message=message.model_dump(),
|
|
54
|
+
partition_marker=partition_marker,
|
|
55
|
+
)
|
|
56
|
+
except Exception as err:
|
|
57
|
+
await set_and_publish_status_async(
|
|
58
|
+
task_id=task_id,
|
|
59
|
+
channel_name=channel_name,
|
|
60
|
+
status=TaskStatus.FAILED,
|
|
61
|
+
response={"error": str(err)},
|
|
62
|
+
)
|
|
63
|
+
raise
|
|
64
|
+
else:
|
|
65
|
+
await set_and_publish_status_async(
|
|
66
|
+
task_id=task_id,
|
|
67
|
+
channel_name=channel_name,
|
|
68
|
+
status=TaskStatus.PENDING,
|
|
69
|
+
)
|
|
70
|
+
return message
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _KafkaProducer:
|
|
74
|
+
"""FastStream Kafka producer with reusable connection lifecycle."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, topic_name: str) -> None:
|
|
77
|
+
self.topic_name = topic_name
|
|
78
|
+
self._start_lock = threading.Lock()
|
|
79
|
+
self._started = False
|
|
80
|
+
self._loop_id: int | None = None
|
|
81
|
+
self._broker = None
|
|
82
|
+
|
|
83
|
+
async def ensure_started(self) -> None:
|
|
84
|
+
current_loop_id = id(asyncio.get_running_loop())
|
|
85
|
+
if self._started and self._loop_id == current_loop_id:
|
|
86
|
+
return
|
|
87
|
+
with self._start_lock:
|
|
88
|
+
current_loop_id = id(asyncio.get_running_loop())
|
|
89
|
+
if self._started and self._loop_id == current_loop_id:
|
|
90
|
+
return
|
|
91
|
+
if self._started and self._loop_id != current_loop_id:
|
|
92
|
+
self._started = False
|
|
93
|
+
self._loop_id = None
|
|
94
|
+
self._broker = None
|
|
95
|
+
if self._broker is None:
|
|
96
|
+
self._broker = get_broker_for_async()
|
|
97
|
+
await self._broker.start()
|
|
98
|
+
self._started = True
|
|
99
|
+
self._loop_id = current_loop_id
|
|
100
|
+
|
|
101
|
+
async def send_one_message(
|
|
102
|
+
self,
|
|
103
|
+
message: dict,
|
|
104
|
+
partition_marker: str | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Sends a single message to Kafka."""
|
|
107
|
+
await self.ensure_started()
|
|
108
|
+
try:
|
|
109
|
+
await self._broker.publish(
|
|
110
|
+
message,
|
|
111
|
+
self.topic_name,
|
|
112
|
+
key=partition_marker.encode("utf-8") if partition_marker else None,
|
|
113
|
+
)
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.exception("Kafka publish failed.")
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_producer_cache: dict[tuple[str | None, int], _KafkaProducer] = {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_kafka_producer(topic_name: str) -> _KafkaProducer:
|
|
123
|
+
try:
|
|
124
|
+
loop_id = id(asyncio.get_running_loop())
|
|
125
|
+
except RuntimeError:
|
|
126
|
+
loop_id = None
|
|
127
|
+
cache_key = (topic_name, loop_id if loop_id is not None else threading.get_ident())
|
|
128
|
+
producer = _producer_cache.get(cache_key)
|
|
129
|
+
if producer is None:
|
|
130
|
+
producer = _KafkaProducer(topic_name)
|
|
131
|
+
_producer_cache[cache_key] = producer
|
|
132
|
+
return producer
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
from .routes import router # noqa: F401
|
|
@@ -0,0 +1,57 @@
|
|
|
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 json
|
|
16
|
+
|
|
17
|
+
from django.utils.translation import gettext_lazy as _
|
|
18
|
+
|
|
19
|
+
from fastapi import HTTPException, Request
|
|
20
|
+
|
|
21
|
+
from bazis.contrib.async_background.utils import (
|
|
22
|
+
ChannelNameError,
|
|
23
|
+
get_redis_async,
|
|
24
|
+
resolve_channel_name_async,
|
|
25
|
+
)
|
|
26
|
+
from bazis.core.errors import JsonApi401Exception, JsonApi403Exception
|
|
27
|
+
from bazis.core.routing import BazisRouter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
router = BazisRouter(tags=[_("Async requests")])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/async_background_response/{task_id}/", response_model=dict)
|
|
34
|
+
async def get_async_background_response(request: Request, task_id: str, full_response: bool = False) -> dict:
|
|
35
|
+
"""Returns the result of a background task by its identifier."""
|
|
36
|
+
try:
|
|
37
|
+
channel_name = await resolve_channel_name_async(request)
|
|
38
|
+
except ChannelNameError as err:
|
|
39
|
+
raise JsonApi401Exception from err
|
|
40
|
+
|
|
41
|
+
redis_data_raw = await get_redis_async().get(task_id)
|
|
42
|
+
if not redis_data_raw:
|
|
43
|
+
raise HTTPException(status_code=404, detail=_("Unknown task ID"))
|
|
44
|
+
try:
|
|
45
|
+
redis_data = json.loads(redis_data_raw.decode("utf-8"))
|
|
46
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as err:
|
|
47
|
+
raise HTTPException(status_code=500, detail=_("Invalid task data format in Redis")) from err
|
|
48
|
+
|
|
49
|
+
if channel_name != redis_data["channel_name"]:
|
|
50
|
+
raise JsonApi403Exception
|
|
51
|
+
|
|
52
|
+
if full_response:
|
|
53
|
+
return redis_data
|
|
54
|
+
|
|
55
|
+
response = redis_data.get("response")
|
|
56
|
+
return response if response is not None else {"status": "not ready"}
|
|
57
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TaskStatus(str, Enum):
|
|
21
|
+
"""Background task statuses."""
|
|
22
|
+
|
|
23
|
+
CREATED = "created" # The task is registered but has not yet been sent to Kafka
|
|
24
|
+
PENDING = "pending" # The message has been delivered to Kafka and is awaiting processing
|
|
25
|
+
PROCESSING = "processing" # The consumer has started processing
|
|
26
|
+
COMPLETED = "completed" # The task has completed successfully
|
|
27
|
+
FAILED = "failed" # An error occurred during execution
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KafkaTask[Payload: BaseModel](BaseModel):
|
|
31
|
+
"""Base schema for tasks processed by Kafka."""
|
|
32
|
+
|
|
33
|
+
task_id: str = Field(..., description="Background task identifier")
|
|
34
|
+
channel_name: str = Field(..., description="Channel name for status updates")
|
|
35
|
+
payload: Payload
|
|
36
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
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 json
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from django.conf import settings
|
|
20
|
+
|
|
21
|
+
from fastapi import Request
|
|
22
|
+
|
|
23
|
+
from redis import Redis
|
|
24
|
+
from redis.asyncio import Redis as AsyncRedis
|
|
25
|
+
|
|
26
|
+
from bazis.contrib.ws.utils import UserError, get_user_from_token_async
|
|
27
|
+
|
|
28
|
+
from .schemas import TaskStatus
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
redis = Redis.from_url(settings.CACHES['default']['LOCATION'])
|
|
34
|
+
_redis_async_by_loop: dict[int, AsyncRedis] = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_redis_async() -> AsyncRedis:
|
|
38
|
+
loop_id = id(asyncio.get_running_loop())
|
|
39
|
+
client = _redis_async_by_loop.get(loop_id)
|
|
40
|
+
if client is None:
|
|
41
|
+
client = AsyncRedis.from_url(settings.CACHES['default']['LOCATION'])
|
|
42
|
+
_redis_async_by_loop[loop_id] = client
|
|
43
|
+
return client
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StatusStorageError(Exception):
|
|
47
|
+
"""Error when setting or publishing status in Redis."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ChannelNameError(Exception):
|
|
51
|
+
"""Error when resolving channel name."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_and_publish_status(
|
|
55
|
+
task_id: str, channel_name: str, status: TaskStatus, response: dict | None = None
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Saves the task status in Redis and publishes a minimal status to the WS channel."""
|
|
58
|
+
try:
|
|
59
|
+
# Save the full status in Redis (for subsequent retrieval of the result by task_id)
|
|
60
|
+
redis.set(
|
|
61
|
+
task_id,
|
|
62
|
+
json.dumps(
|
|
63
|
+
{
|
|
64
|
+
"status": status.value,
|
|
65
|
+
"channel_name": channel_name,
|
|
66
|
+
"response": response,
|
|
67
|
+
},
|
|
68
|
+
ensure_ascii=False,
|
|
69
|
+
),
|
|
70
|
+
ex=settings.KAFKA_RESPONSE_HOLD_SEC,
|
|
71
|
+
)
|
|
72
|
+
except Exception as err:
|
|
73
|
+
logger.exception("Failed to set task %s in Redis", task_id)
|
|
74
|
+
raise StatusStorageError(f"Redis set failed: {err}") from err
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Prepare a lightweight payload for publication via WebSocket
|
|
78
|
+
redis.publish(
|
|
79
|
+
channel_name,
|
|
80
|
+
json.dumps(
|
|
81
|
+
{
|
|
82
|
+
"status": status.value,
|
|
83
|
+
"task_id": task_id,
|
|
84
|
+
"action": "async_bg",
|
|
85
|
+
},
|
|
86
|
+
ensure_ascii=False,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
logger.info(
|
|
91
|
+
"Published WS message for task %s with status %s to channel %s",
|
|
92
|
+
task_id,
|
|
93
|
+
status.value,
|
|
94
|
+
channel_name,
|
|
95
|
+
)
|
|
96
|
+
except Exception as err:
|
|
97
|
+
logger.exception("Failed to publish to channel for task %s", task_id)
|
|
98
|
+
raise StatusStorageError(f"Redis publish failed: {err}") from err
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def set_and_publish_status_async(
|
|
102
|
+
task_id: str, channel_name: str, status: TaskStatus, response: dict | None = None
|
|
103
|
+
) -> None:
|
|
104
|
+
from asgiref.sync import sync_to_async
|
|
105
|
+
|
|
106
|
+
await sync_to_async(set_and_publish_status)(
|
|
107
|
+
task_id,
|
|
108
|
+
channel_name,
|
|
109
|
+
status,
|
|
110
|
+
response,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _get_token_from_request(request: Request) -> str | None:
|
|
115
|
+
authorization = request.headers.get("authorization")
|
|
116
|
+
if not authorization or not authorization.lower().startswith("bearer "):
|
|
117
|
+
return None
|
|
118
|
+
return authorization.split(" ")[1].strip()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def resolve_channel_name_async(request: Request) -> str:
|
|
122
|
+
token = _get_token_from_request(request)
|
|
123
|
+
|
|
124
|
+
if token and token.count('.') == 2:
|
|
125
|
+
try:
|
|
126
|
+
user = await get_user_from_token_async(token)
|
|
127
|
+
except UserError as exc:
|
|
128
|
+
raise ChannelNameError(
|
|
129
|
+
f"Failed to get user from token: {exc.message}"
|
|
130
|
+
) from exc
|
|
131
|
+
return user.user_channel
|
|
132
|
+
elif token:
|
|
133
|
+
return token
|
|
134
|
+
else:
|
|
135
|
+
raise ChannelNameError(
|
|
136
|
+
"No valid token found in request for channel name resolution."
|
|
137
|
+
)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bazis-async-background
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Async Background core framework for Bazis.
|
|
5
|
+
Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
|
|
6
|
+
Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
|
|
7
|
+
Project-URL: Home, https://github.com/ecofuture-tech/bazis-async-background
|
|
8
|
+
Keywords: bazis,django,fastapi,async,background,kafka,framework
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Framework :: Django
|
|
17
|
+
Classifier: Framework :: FastAPI
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: bazis
|
|
21
|
+
Requires-Dist: bazis-ws
|
|
22
|
+
Requires-Dist: faststream[kafka]
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: bazis-test-utils; extra == "test"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: ruff; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# Bazis Async Background
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/bazis-async-background/)
|
|
31
|
+
[](https://pypi.org/project/bazis-async-background/)
|
|
32
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
33
|
+
|
|
34
|
+
Core background task framework for Bazis. It provides Kafka broker helpers, task schemas, status storage in Redis, and a base API to retrieve task results.
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Install the package
|
|
40
|
+
uv add bazis-async-background
|
|
41
|
+
|
|
42
|
+
# Configure environment variables / settings
|
|
43
|
+
INSTALLED_APPS='["bazis.contrib.async_background", ...]'
|
|
44
|
+
BAZIS_CONFIG_APPS='["bazis.contrib.async_background", ...]'
|
|
45
|
+
|
|
46
|
+
# Kafka settings
|
|
47
|
+
KAFKA_BOOTSTRAP_SERVERS=localhost:9093
|
|
48
|
+
KAFKA_TOPIC_ASYNC_REQUEST=my_app_background_tasks
|
|
49
|
+
KAFKA_GROUP_ID=my_app_background
|
|
50
|
+
KAFKA_TASKS='["my_app.background.tasks"]'
|
|
51
|
+
|
|
52
|
+
# Run consumer in Kubernetes
|
|
53
|
+
python manage.py kafka_consumer_single
|
|
54
|
+
|
|
55
|
+
# Run multiple consumers locally
|
|
56
|
+
python manage.py kafka_consumer_multiple --consumers-count=5
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Table of Contents
|
|
60
|
+
|
|
61
|
+
- [Description](#description)
|
|
62
|
+
- [Requirements](#requirements)
|
|
63
|
+
- [Installation](#installation)
|
|
64
|
+
- [Architecture](#architecture)
|
|
65
|
+
- [Configuration](#configuration)
|
|
66
|
+
- [Environment Variables / Settings](#environment-variables--settings)
|
|
67
|
+
- [Route Registration](#route-registration)
|
|
68
|
+
- [Usage](#usage)
|
|
69
|
+
- [Running Consumers](#running-consumers)
|
|
70
|
+
- [Examples](#examples)
|
|
71
|
+
- [License](#license)
|
|
72
|
+
- [Links](#links)
|
|
73
|
+
|
|
74
|
+
## Description
|
|
75
|
+
|
|
76
|
+
**Bazis Async Background** is a core package for running background tasks in the Bazis framework. It includes:
|
|
77
|
+
|
|
78
|
+
- **Kafka Producer** — sending tasks to Kafka queue
|
|
79
|
+
- **Kafka Consumer** — processing tasks from the queue
|
|
80
|
+
- **Redis storage** — storing task execution results
|
|
81
|
+
- **API endpoint** — retrieving results by task_id
|
|
82
|
+
|
|
83
|
+
## Requirements
|
|
84
|
+
|
|
85
|
+
- **Python**: 3.12+
|
|
86
|
+
- **bazis**: latest version
|
|
87
|
+
- **PostgreSQL**: 12+
|
|
88
|
+
- **Redis**: For storing results and caching
|
|
89
|
+
- **Kafka**: For task queue
|
|
90
|
+
|
|
91
|
+
## Installation
|
|
92
|
+
|
|
93
|
+
### Using uv (recommended)
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv add bazis-async-background
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Using pip
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install bazis-async-background
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Running Tests
|
|
106
|
+
|
|
107
|
+
Run from the project root:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
docker compose -f sample/docker-compose.test.yml up --build --exit-code-from bazis-async-background-pytest --attach bazis-async-background-pytest --attach bazis-async-background-consumer-test
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This waits for the pytest container to finish and streams logs only from the Python containers, so test completion and output are easy to follow.
|
|
114
|
+
|
|
115
|
+
## Architecture
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
┌─────────────┐
|
|
119
|
+
│ Client │
|
|
120
|
+
└──────┬──────┘
|
|
121
|
+
│ Background task request
|
|
122
|
+
▼
|
|
123
|
+
┌─────────────────────┐
|
|
124
|
+
│ API Endpoint │
|
|
125
|
+
│ (Async Background) │
|
|
126
|
+
└──────┬──────────────┘
|
|
127
|
+
│ 1. Return task_id (202)
|
|
128
|
+
│ 2. Send to Kafka
|
|
129
|
+
▼
|
|
130
|
+
┌─────────────────────┐
|
|
131
|
+
│ Kafka Topic │
|
|
132
|
+
│ (async_background) │
|
|
133
|
+
└──────┬──────────────┘
|
|
134
|
+
│
|
|
135
|
+
│ Consumer polls
|
|
136
|
+
▼
|
|
137
|
+
┌─────────────────────┐
|
|
138
|
+
│ Kafka Consumer │
|
|
139
|
+
│ (Background Worker)│
|
|
140
|
+
└──────┬──────────────┘
|
|
141
|
+
│ 3. Process task
|
|
142
|
+
│ 4. Save result to Redis
|
|
143
|
+
▼
|
|
144
|
+
┌─────────────────────┐
|
|
145
|
+
│ Redis │
|
|
146
|
+
│ (Results Store) │
|
|
147
|
+
└──────┬──────────────┘
|
|
148
|
+
│
|
|
149
|
+
│ 5. GET /async_background_response/{task_id}/
|
|
150
|
+
▼
|
|
151
|
+
┌─────────────────────┐
|
|
152
|
+
│ API Endpoint │
|
|
153
|
+
│ (Get Result) │
|
|
154
|
+
└─────────────────────┘
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Configuration
|
|
158
|
+
|
|
159
|
+
### Environment Variables / Settings
|
|
160
|
+
|
|
161
|
+
Add to your `.env` or `settings.py`:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Required settings
|
|
165
|
+
INSTALLED_APPS='["bazis.contrib.async_background", ...]'
|
|
166
|
+
BAZIS_CONFIG_APPS='["bazis.contrib.async_background", ...]'
|
|
167
|
+
KAFKA_TASKS='["my_app.background.tasks"]'
|
|
168
|
+
|
|
169
|
+
# Kafka settings
|
|
170
|
+
KAFKA_BOOTSTRAP_SERVERS=localhost:9093
|
|
171
|
+
KAFKA_TOPIC_ASYNC_REQUEST=my_app_background_tasks
|
|
172
|
+
KAFKA_GROUP_ID=my_app_background
|
|
173
|
+
|
|
174
|
+
# Optional settings
|
|
175
|
+
KAFKA_CONSUMER_LIFETIME_SEC=900 # Consumer lifetime (15 minutes)
|
|
176
|
+
KAFKA_CONSUMER_LIFETIME_JITTER_SEC=180 # Random deviation (3 minutes)
|
|
177
|
+
KAFKA_AUTO_OFFSET_RESET=latest
|
|
178
|
+
KAFKA_ENABLE_AUTO_COMMIT=true
|
|
179
|
+
KAFKA_AUTO_COMMIT_INTERVAL_MS=5000
|
|
180
|
+
KAFKA_LOG_LEVEL=INFO
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Parameters**:
|
|
184
|
+
|
|
185
|
+
- `KAFKA_TASKS` — dotted module paths imported by the consumer to register tasks
|
|
186
|
+
- `KAFKA_BOOTSTRAP_SERVERS` — Kafka broker address
|
|
187
|
+
- `KAFKA_TOPIC_ASYNC_REQUEST` — topic for async tasks
|
|
188
|
+
- `KAFKA_GROUP_ID` — consumer group
|
|
189
|
+
- `KAFKA_CONSUMER_LIFETIME_SEC` — consumer working time before restart
|
|
190
|
+
- `KAFKA_CONSUMER_LIFETIME_JITTER_SEC` — random deviation to avoid simultaneous restart
|
|
191
|
+
- `KAFKA_AUTO_OFFSET_RESET` — Kafka auto offset reset policy
|
|
192
|
+
- `KAFKA_ENABLE_AUTO_COMMIT` — Kafka auto-commit toggle
|
|
193
|
+
- `KAFKA_AUTO_COMMIT_INTERVAL_MS` — auto-commit interval in ms
|
|
194
|
+
- `KAFKA_LOG_LEVEL` — log level for consumers
|
|
195
|
+
|
|
196
|
+
### Route Registration
|
|
197
|
+
|
|
198
|
+
Add the route for getting results to your `router.py`:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from bazis.core.routing import BazisRouter
|
|
202
|
+
|
|
203
|
+
router = BazisRouter(prefix='/api/v1')
|
|
204
|
+
|
|
205
|
+
# Register background task results route
|
|
206
|
+
router.register('bazis.contrib.async_background.router')
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`
|
|
210
|
+
|
|
211
|
+
## Usage
|
|
212
|
+
|
|
213
|
+
### Running Consumers
|
|
214
|
+
|
|
215
|
+
#### For Kubernetes (one consumer per pod)
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
python manage.py kafka_consumer_single
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Runs one consumer that processes tasks from Kafka. Suitable for horizontal scaling in Kubernetes.
|
|
222
|
+
|
|
223
|
+
#### For Local Development (multiple consumers)
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
python manage.py kafka_consumer_multiple --consumers-count=5
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Runs 5 consumers in separate processes. Suitable for local development or deployment without orchestration.
|
|
230
|
+
|
|
231
|
+
**Parameters**:
|
|
232
|
+
|
|
233
|
+
- `--consumers-count` — number of consumers to run (default: 1)
|
|
234
|
+
|
|
235
|
+
## Examples
|
|
236
|
+
|
|
237
|
+
### Minimal Task Registration
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from bazis.contrib.async_background.broker import get_broker_for_consumer
|
|
241
|
+
from bazis.contrib.async_background.schemas import KafkaTask, TaskStatus
|
|
242
|
+
from bazis.contrib.async_background.utils import set_and_publish_status_async
|
|
243
|
+
from pydantic import BaseModel
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class DemoPayload(BaseModel):
|
|
247
|
+
message: str
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@get_broker_for_consumer().subscriber("my_app_background_tasks")
|
|
251
|
+
async def consumer_demo(task: KafkaTask[DemoPayload]):
|
|
252
|
+
await set_and_publish_status_async(
|
|
253
|
+
task_id=task.task_id,
|
|
254
|
+
channel_name=task.channel_name,
|
|
255
|
+
status=TaskStatus.COMPLETED,
|
|
256
|
+
response={"echo": task.payload.model_dump()},
|
|
257
|
+
)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
Apache License 2.0
|
|
263
|
+
|
|
264
|
+
See [LICENSE](LICENSE) file for details.
|
|
265
|
+
|
|
266
|
+
## Links
|
|
267
|
+
|
|
268
|
+
- [Bazis Documentation](https://github.com/ecofuture-tech/bazis) — main repository
|
|
269
|
+
- [Bazis Async Background Repository](https://github.com/ecofuture-tech/bazis-async-background) — package repository
|
|
270
|
+
- [Issue Tracker](https://github.com/ecofuture-tech/bazis-async-background/issues) — report bugs or request features
|
|
271
|
+
- [Apache Kafka](https://kafka.apache.org/) — Kafka documentation
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
bazis/contrib/async_background/__init__.py,sha256=fQ4bunv4zeO5I0gmiNFAAiswPLm2uy5XDwaIz6M6OJM,755
|
|
2
|
+
bazis/contrib/async_background/_version.py,sha256=6OGz4a0gjMGlckPyPCNiJDWyFDO-tWO8O_ZNx4ajT2Y,704
|
|
3
|
+
bazis/contrib/async_background/apps.py,sha256=obdEeKVgMdqsmKAwHVPpDzonhp9qCNM-P6GqcwPWeUs,862
|
|
4
|
+
bazis/contrib/async_background/broker.py,sha256=iHxW20sUIQJcMxLsikPwXZdvpqK-hQVJSMaPvQFtxFk,1843
|
|
5
|
+
bazis/contrib/async_background/conf.py,sha256=0jv_EcjNU7LbP3k0WynnOlEGc6qXOExZ8gxFVpao68Q,3824
|
|
6
|
+
bazis/contrib/async_background/producer.py,sha256=k7SO4KRmXVCvTluycnLUWPtUBelljnDRkZIUfTfHzik,4205
|
|
7
|
+
bazis/contrib/async_background/router.py,sha256=aQRPo_2IaqXILLAI9tQuefPUhYuS2gTdVGTcbO_MK8k,656
|
|
8
|
+
bazis/contrib/async_background/routes.py,sha256=K46h6x9SCtIuo1CTVsM0j2KIAWPP2PYajbT8zpOD4cU,2077
|
|
9
|
+
bazis/contrib/async_background/schemas.py,sha256=DMporComNAJK1TWREmYayntVjeX3v06TGzp99TDzuE8,1398
|
|
10
|
+
bazis/contrib/async_background/utils.py,sha256=1DlPfE9-odo5ssSTsc9g6E6zl3iDQMrQkfVQvDB8oWA,4249
|
|
11
|
+
bazis/contrib/async_background/management/__init__.py,sha256=7MU6gF_TkvnR3NFi-Nfci4RIacoZ6bqMjwlR6ONK8Ms,615
|
|
12
|
+
bazis/contrib/async_background/management/commands/__init__.py,sha256=7MU6gF_TkvnR3NFi-Nfci4RIacoZ6bqMjwlR6ONK8Ms,615
|
|
13
|
+
bazis/contrib/async_background/management/commands/kafka_consumer_multiple.py,sha256=j4fKuKJAb0NoZUP17RjwktHpiLtwwrvqGSxPLTowINo,4208
|
|
14
|
+
bazis/contrib/async_background/management/commands/kafka_consumer_single.py,sha256=Dupi8l7YwKRt8SvhWXCcQX8MKd0k4og_D9n59_sMvIw,3066
|
|
15
|
+
bazis_async_background-2.2.0.dist-info/METADATA,sha256=7YOxquFpncjmWHRInvS2ZRaASAA3JpI-aAj0ffD4SrI,8557
|
|
16
|
+
bazis_async_background-2.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
17
|
+
bazis_async_background-2.2.0.dist-info/top_level.txt,sha256=WgdrPZTZBMG8i_EqxA3vU5qI4ETQ_RsqKqSqsfIApHY,6
|
|
18
|
+
bazis_async_background-2.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bazis
|