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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/bazis-async-background.svg)](https://pypi.org/project/bazis-async-background/)
31
+ [![Python Versions](https://img.shields.io/pypi/pyversions/bazis-async-background.svg)](https://pypi.org/project/bazis-async-background/)
32
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+