fastapi-modular 0.1.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.
- fastapi_modular-0.1.0.dist-info/METADATA +377 -0
- fastapi_modular-0.1.0.dist-info/RECORD +69 -0
- fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
- fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
- pymodular/__init__.py +74 -0
- pymodular/cli/__init__.py +0 -0
- pymodular/cli/clean.py +39 -0
- pymodular/cli/configure_env.py +569 -0
- pymodular/cli/cong_cu.py +111 -0
- pymodular/cli/info.py +62 -0
- pymodular/cli/install.py +83 -0
- pymodular/cli/main.py +247 -0
- pymodular/cli/new_module.py +492 -0
- pymodular/cli/new_project.py +471 -0
- pymodular/cli/serve.py +59 -0
- pymodular/core/__init__.py +0 -0
- pymodular/core/clock.py +15 -0
- pymodular/core/compat.py +39 -0
- pymodular/core/config.py +495 -0
- pymodular/core/container.py +354 -0
- pymodular/core/context.py +78 -0
- pymodular/core/controller.py +208 -0
- pymodular/core/error_handlers.py +272 -0
- pymodular/core/exceptions.py +104 -0
- pymodular/core/guards.py +117 -0
- pymodular/core/lifespan.py +150 -0
- pymodular/core/logging.py +88 -0
- pymodular/core/metrics.py +190 -0
- pymodular/core/schemas.py +105 -0
- pymodular/core/websocket/__init__.py +31 -0
- pymodular/core/websocket/adapter.py +192 -0
- pymodular/core/websocket/gateway.py +735 -0
- pymodular/core/websocket/namespace.py +148 -0
- pymodular/core/websocket/protocol.py +157 -0
- pymodular/core/websocket/server.py +175 -0
- pymodular/core/websocket/socket.py +241 -0
- pymodular/discovery.py +180 -0
- pymodular/factory.py +126 -0
- pymodular/infrastructure/__init__.py +1 -0
- pymodular/infrastructure/database/__init__.py +8 -0
- pymodular/infrastructure/database/base.py +228 -0
- pymodular/infrastructure/database/circuit.py +207 -0
- pymodular/infrastructure/database/factory.py +88 -0
- pymodular/infrastructure/database/memory.py +112 -0
- pymodular/infrastructure/database/mongo.py +186 -0
- pymodular/infrastructure/database/repository.py +188 -0
- pymodular/infrastructure/database/sql.py +520 -0
- pymodular/infrastructure/kafka/__init__.py +26 -0
- pymodular/infrastructure/kafka/broker.py +231 -0
- pymodular/infrastructure/kafka/consumers.py +371 -0
- pymodular/infrastructure/kafka/metrics.py +17 -0
- pymodular/infrastructure/mqtt/__init__.py +35 -0
- pymodular/infrastructure/mqtt/client.py +292 -0
- pymodular/infrastructure/mqtt/consumers.py +219 -0
- pymodular/infrastructure/mqtt/metrics.py +17 -0
- pymodular/infrastructure/mqtt/patterns.py +116 -0
- pymodular/infrastructure/rabbitmq/__init__.py +33 -0
- pymodular/infrastructure/rabbitmq/broker.py +616 -0
- pymodular/infrastructure/rabbitmq/consumers.py +450 -0
- pymodular/infrastructure/rabbitmq/metrics.py +34 -0
- pymodular/infrastructure/rabbitmq/patterns.py +64 -0
- pymodular/infrastructure/redis/__init__.py +31 -0
- pymodular/infrastructure/redis/client.py +362 -0
- pymodular/infrastructure/redis/metrics.py +20 -0
- pymodular/infrastructure/redis/pubsub.py +262 -0
- pymodular/middleware/__init__.py +0 -0
- pymodular/middleware/request_context.py +164 -0
- pymodular/py.typed +0 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Kết nối Kafka: gửi tin có xác nhận, tự nối lại, không chặn lúc khởi động.
|
|
2
|
+
|
|
3
|
+
Kafka là TUỲ CHỌN. `APP_KAFKA__ENABLED` mặc định false thì lớp này nằm im.
|
|
4
|
+
|
|
5
|
+
pip install 'fastapi-modular[kafka]' # cài aiokafka + ghi sẵn APP_KAFKA__* vào .env
|
|
6
|
+
|
|
7
|
+
Kafka khác RabbitMQ ở chỗ căn bản: tin KHÔNG mất đi sau khi được xử lý. Nó nằm
|
|
8
|
+
lại trong nhật ký theo thời gian giữ (retention) của topic, và mỗi nhóm
|
|
9
|
+
consumer có con trỏ đọc riêng. Nhờ vậy thêm một nhóm mới là đọc lại được cả
|
|
10
|
+
lịch sử — thứ hàng đợi không làm được. Cái giá: không có "hàng đợi thử lại",
|
|
11
|
+
không xoá được một tin lẻ, và thứ tự chỉ bảo đảm TRONG MỘT phân vùng.
|
|
12
|
+
|
|
13
|
+
Về tự nối lại: aiokafka tự tìm lại broker khi cụm chuyển leader hoặc rớt giữa
|
|
14
|
+
chừng. Thứ nó không lo là lúc khởi động mà cụm chưa lên — `producer.start()`
|
|
15
|
+
ném lỗi ngay. Chỗ đó lớp này chạy vòng thử lại có backoff, y như RabbitMQ, để
|
|
16
|
+
app vẫn phục vụ HTTP bình thường.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
from collections.abc import Awaitable, Callable
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pymodular.core.compat import TimeoutErrors
|
|
28
|
+
from pymodular.core.config import Settings
|
|
29
|
+
from pymodular.core.container import injectable
|
|
30
|
+
from pymodular.core.exceptions import ComponentNotEnabledError, ServiceUnavailableError
|
|
31
|
+
from pymodular.core.logging import get_logger
|
|
32
|
+
from pymodular.infrastructure.kafka.metrics import kafka_publish_failed, kafka_published
|
|
33
|
+
|
|
34
|
+
log = get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
DEFAULT_SERVERS = "localhost:9092"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _require_aiokafka() -> Any:
|
|
40
|
+
try:
|
|
41
|
+
import aiokafka
|
|
42
|
+
except ModuleNotFoundError as exc:
|
|
43
|
+
raise ComponentNotEnabledError(
|
|
44
|
+
"APP_KAFKA__ENABLED=true nhưng chưa cài thư viện aiokafka. "
|
|
45
|
+
"Chạy `pip install 'fastapi-modular[kafka]'`, hoặc đặt APP_KAFKA__ENABLED=false nếu "
|
|
46
|
+
"dự án này không dùng Kafka."
|
|
47
|
+
) from exc
|
|
48
|
+
return aiokafka
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@injectable
|
|
52
|
+
class KafkaBroker:
|
|
53
|
+
def __init__(self, settings: Settings) -> None:
|
|
54
|
+
self._config = settings.kafka
|
|
55
|
+
self._producer: Any = None
|
|
56
|
+
self._supervisor: asyncio.Task[None] | None = None
|
|
57
|
+
self._closing = False
|
|
58
|
+
self._ready_hooks: list[Callable[[], Awaitable[None]]] = []
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------- vòng đời
|
|
61
|
+
@property
|
|
62
|
+
def enabled(self) -> bool:
|
|
63
|
+
return self._config.enabled
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def connected(self) -> bool:
|
|
67
|
+
return self._producer is not None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def servers(self) -> str:
|
|
71
|
+
return self._config.bootstrap_servers
|
|
72
|
+
|
|
73
|
+
async def startup(self) -> None:
|
|
74
|
+
if not self._config.enabled:
|
|
75
|
+
log.debug("kafka.disabled")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
_require_aiokafka()
|
|
79
|
+
self._closing = False
|
|
80
|
+
if await self._thu_noi():
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
log.warning(
|
|
84
|
+
"kafka.starting_degraded",
|
|
85
|
+
servers=self.servers,
|
|
86
|
+
hint="app vẫn chạy; sẽ nối lại ngầm cho tới khi được",
|
|
87
|
+
)
|
|
88
|
+
self._supervisor = asyncio.create_task(self._reconnect_forever(), name="kafka-reconnect")
|
|
89
|
+
|
|
90
|
+
async def _thu_noi(self) -> bool:
|
|
91
|
+
aiokafka = _require_aiokafka()
|
|
92
|
+
producer = aiokafka.AIOKafkaProducer(
|
|
93
|
+
bootstrap_servers=self._config.bootstrap_servers,
|
|
94
|
+
client_id=self._config.client_id,
|
|
95
|
+
acks=self._config.acks if self._config.acks != "all" else "all",
|
|
96
|
+
request_timeout_ms=int(self._config.request_timeout_seconds * 1000),
|
|
97
|
+
# Không bật idempotence mặc định: nó đòi acks=all và bản Kafka đủ
|
|
98
|
+
# mới; bật ngầm sẽ làm cụm cũ từ chối kết nối mà không rõ vì sao.
|
|
99
|
+
)
|
|
100
|
+
try:
|
|
101
|
+
await asyncio.wait_for(producer.start(), self._config.connect_timeout_seconds)
|
|
102
|
+
except Exception as exc: # noqa: BLE001 - mọi lỗi đều dẫn tới cùng một việc: thử lại
|
|
103
|
+
with contextlib.suppress(Exception):
|
|
104
|
+
await producer.stop()
|
|
105
|
+
log.warning(
|
|
106
|
+
"kafka.connect_failed",
|
|
107
|
+
servers=self.servers,
|
|
108
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
109
|
+
)
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
self._producer = producer
|
|
113
|
+
log.info("kafka.connected", servers=self.servers, client_id=self._config.client_id)
|
|
114
|
+
await self._run_hooks()
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
async def _reconnect_forever(self) -> None:
|
|
118
|
+
delay = self._config.reconnect_delay_seconds
|
|
119
|
+
while not self._closing and self._producer is None:
|
|
120
|
+
await asyncio.sleep(delay)
|
|
121
|
+
if self._closing:
|
|
122
|
+
return
|
|
123
|
+
if await self._thu_noi():
|
|
124
|
+
log.info("kafka.recovered", servers=self.servers)
|
|
125
|
+
return
|
|
126
|
+
delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
|
|
127
|
+
|
|
128
|
+
def on_ready(self, hook: Callable[[], Awaitable[None]]) -> None:
|
|
129
|
+
"""Việc cần làm sau mỗi lần nối được — consumer dùng để bật vòng đọc."""
|
|
130
|
+
self._ready_hooks.append(hook)
|
|
131
|
+
|
|
132
|
+
async def _run_hooks(self) -> None:
|
|
133
|
+
for hook in list(self._ready_hooks):
|
|
134
|
+
try:
|
|
135
|
+
await hook()
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
log.exception("kafka.ready_hook_failed", error=str(exc))
|
|
138
|
+
|
|
139
|
+
async def shutdown(self) -> None:
|
|
140
|
+
self._closing = True
|
|
141
|
+
if self._supervisor is not None:
|
|
142
|
+
self._supervisor.cancel()
|
|
143
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
144
|
+
await self._supervisor
|
|
145
|
+
self._supervisor = None
|
|
146
|
+
if self._producer is not None:
|
|
147
|
+
with contextlib.suppress(Exception):
|
|
148
|
+
await self._producer.stop()
|
|
149
|
+
log.info("kafka.disconnected")
|
|
150
|
+
self._producer = None
|
|
151
|
+
|
|
152
|
+
def _ready(self) -> Any:
|
|
153
|
+
if not self._config.enabled:
|
|
154
|
+
raise ComponentNotEnabledError(
|
|
155
|
+
"Kafka đang tắt (APP_KAFKA__ENABLED=false) nên không gửi tin được."
|
|
156
|
+
)
|
|
157
|
+
if self._producer is None:
|
|
158
|
+
raise ServiceUnavailableError("Chưa kết nối được Kafka")
|
|
159
|
+
return self._producer
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------- gửi
|
|
162
|
+
async def publish(
|
|
163
|
+
self,
|
|
164
|
+
topic: str,
|
|
165
|
+
payload: Any = None,
|
|
166
|
+
*,
|
|
167
|
+
key: str | None = None,
|
|
168
|
+
headers: dict[str, str] | None = None,
|
|
169
|
+
timeout: float | None = None,
|
|
170
|
+
fire_and_forget: bool = False,
|
|
171
|
+
) -> bool:
|
|
172
|
+
"""Gửi một tin và CHỜ cụm xác nhận. Trả về True nếu đã ghi xong.
|
|
173
|
+
|
|
174
|
+
`key` quyết định phân vùng: mọi tin cùng key rơi vào cùng một phân vùng,
|
|
175
|
+
nên chúng được xử lý ĐÚNG THỨ TỰ. Không có key thì tin rải đều và thứ tự
|
|
176
|
+
giữa chúng không còn bảo đảm gì. Quy tắc thực dụng: lấy id của thực thể
|
|
177
|
+
làm key (`key=order_id`) khi thứ tự có ý nghĩa.
|
|
178
|
+
|
|
179
|
+
Mức bảo đảm ghi lấy theo `APP_KAFKA__ACKS` — đây là thuộc tính của cụm,
|
|
180
|
+
không phải quyết định của từng lời gọi.
|
|
181
|
+
"""
|
|
182
|
+
if not self._config.enabled:
|
|
183
|
+
raise ComponentNotEnabledError(
|
|
184
|
+
"Kafka đang tắt (APP_KAFKA__ENABLED=false) nên không gửi tin được."
|
|
185
|
+
)
|
|
186
|
+
if self._producer is None:
|
|
187
|
+
kafka_publish_failed.inc(topic=topic)
|
|
188
|
+
if fire_and_forget:
|
|
189
|
+
log.warning("kafka.publish_skipped", topic=topic, reason="chưa kết nối")
|
|
190
|
+
return False
|
|
191
|
+
raise ServiceUnavailableError("Chưa kết nối được Kafka")
|
|
192
|
+
|
|
193
|
+
body = payload if isinstance(payload, bytes) else json.dumps(
|
|
194
|
+
payload, ensure_ascii=False, default=str
|
|
195
|
+
).encode()
|
|
196
|
+
try:
|
|
197
|
+
await asyncio.wait_for(
|
|
198
|
+
self._producer.send_and_wait(
|
|
199
|
+
topic,
|
|
200
|
+
value=body,
|
|
201
|
+
key=key.encode() if key else None,
|
|
202
|
+
headers=[(k, str(v).encode()) for k, v in (headers or {}).items()] or None,
|
|
203
|
+
),
|
|
204
|
+
timeout or self._config.request_timeout_seconds,
|
|
205
|
+
)
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
kafka_publish_failed.inc(topic=topic)
|
|
208
|
+
if fire_and_forget:
|
|
209
|
+
log.warning(
|
|
210
|
+
"kafka.publish_failed",
|
|
211
|
+
topic=topic,
|
|
212
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
213
|
+
)
|
|
214
|
+
return False
|
|
215
|
+
if isinstance(exc, TimeoutErrors):
|
|
216
|
+
raise ServiceUnavailableError(
|
|
217
|
+
f"Kafka không xác nhận trong {timeout or self._config.request_timeout_seconds}s"
|
|
218
|
+
) from exc
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
kafka_published.inc(topic=topic)
|
|
222
|
+
log.debug("kafka.published", topic=topic, key=key)
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
def stats(self) -> dict[str, Any]:
|
|
226
|
+
return {
|
|
227
|
+
"enabled": self._config.enabled,
|
|
228
|
+
"connected": self.connected,
|
|
229
|
+
"servers": self.servers if self._config.enabled else None,
|
|
230
|
+
"acks": self._config.acks,
|
|
231
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Consumer Kafka — `@kafka_subscriber`, tương đương `@rabbitmq_subscriber` của RabbitMQ.
|
|
2
|
+
|
|
3
|
+
@injectable
|
|
4
|
+
class DonHangConsumer:
|
|
5
|
+
@kafka_subscriber("don-hang", group="kho-van")
|
|
6
|
+
async def xu_ly(self, payload: DonHang, meta: dict) -> None:
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
`group` là BẮT BUỘC và cố ý không tự sinh, giống `queue` bên RabbitMQ: nhóm
|
|
10
|
+
chính là danh tính của con trỏ đọc. Tên tự sinh sẽ đổi sau mỗi lần deploy, và
|
|
11
|
+
mỗi lần deploy sẽ đọc lại từ đầu (hoặc bỏ qua sạch phần cũ, tuỳ
|
|
12
|
+
`auto_offset_reset`).
|
|
13
|
+
|
|
14
|
+
Ba điều khác RabbitMQ, phải nắm trước khi dùng:
|
|
15
|
+
|
|
16
|
+
1. **Thử lại làm ĐỨNG phân vùng.** Kafka không cho ack lẻ từng tin: con trỏ đọc
|
|
17
|
+
chỉ tiến lên. Nên thử lại tin thứ 5 nghĩa là tin thứ 6, 7, 8... phải chờ.
|
|
18
|
+
Đó là cái giá của việc giữ đúng thứ tự, và cũng là lý do `retry_delay` ở đây
|
|
19
|
+
nên nhỏ hơn nhiều so với bên RabbitMQ.
|
|
20
|
+
|
|
21
|
+
2. **Không xoá được một tin.** Tin lỗi được sao sang topic `<topic>.dlt` rồi con
|
|
22
|
+
trỏ đi tiếp; bản gốc vẫn nằm trong nhật ký cho tới khi hết hạn giữ.
|
|
23
|
+
|
|
24
|
+
3. **Số worker chạy song song bị chặn bởi số phân vùng.** Topic một phân vùng
|
|
25
|
+
thì chạy mười worker cũng chỉ một worker có việc.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import contextlib
|
|
32
|
+
import inspect
|
|
33
|
+
import json
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Any, get_type_hints
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, ValidationError
|
|
39
|
+
|
|
40
|
+
from pymodular.core.config import Settings
|
|
41
|
+
from pymodular.core.container import _REGISTRY, container, injectable, request_scope
|
|
42
|
+
from pymodular.core.context import new_request_id, reset_request_id, set_request_id
|
|
43
|
+
from pymodular.core.logging import get_logger
|
|
44
|
+
from pymodular.infrastructure.kafka.broker import KafkaBroker, _require_aiokafka
|
|
45
|
+
from pymodular.infrastructure.kafka.metrics import (
|
|
46
|
+
kafka_consume_failed,
|
|
47
|
+
kafka_consumed,
|
|
48
|
+
kafka_dead_lettered,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
log = get_logger(__name__)
|
|
52
|
+
|
|
53
|
+
_SPEC_ATTR = "__kafka_subscriber__"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PermanentMessageError(Exception):
|
|
57
|
+
"""Tin này sai vĩnh viễn — bỏ qua mọi lượt thử, đẩy thẳng sang `<topic>.dlt`."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class KafkaSpec:
|
|
62
|
+
topic: str
|
|
63
|
+
group: str
|
|
64
|
+
auto_offset_reset: str = "latest"
|
|
65
|
+
max_retries: int = 3
|
|
66
|
+
retry_delay: float = 1.0
|
|
67
|
+
dead_letter: bool = True
|
|
68
|
+
cls: type | None = None
|
|
69
|
+
fn: Callable | None = None
|
|
70
|
+
model: type[BaseModel] | None = None
|
|
71
|
+
wants_meta: bool = False
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def label(self) -> str:
|
|
75
|
+
return f"{self.cls.__name__}.{self.fn.__name__}" if self.cls and self.fn else self.topic
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def dlt(self) -> str:
|
|
79
|
+
return f"{self.topic}.dlt"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def kafka_subscriber(
|
|
83
|
+
topic: str,
|
|
84
|
+
*,
|
|
85
|
+
group: str,
|
|
86
|
+
auto_offset_reset: str = "latest",
|
|
87
|
+
max_retries: int = 3,
|
|
88
|
+
retry_delay: float = 1.0,
|
|
89
|
+
dead_letter: bool = True,
|
|
90
|
+
) -> Callable[[Callable], Callable]:
|
|
91
|
+
"""Gắn method vào một topic Kafka, đọc dưới danh nghĩa nhóm `group`.
|
|
92
|
+
|
|
93
|
+
group BẮT BUỘC — danh tính con trỏ đọc. Nhiều worker cùng
|
|
94
|
+
group thì CHIA NHAU phân vùng; khác group thì mỗi
|
|
95
|
+
bên nhận đủ một bản sao của mọi tin.
|
|
96
|
+
auto_offset_reset nhóm MỚI (chưa có con trỏ) bắt đầu từ đâu:
|
|
97
|
+
"latest" = chỉ tin phát sinh từ giờ trở đi (mặc
|
|
98
|
+
định, an toàn) | "earliest" = đọc lại từ đầu nhật
|
|
99
|
+
ký, có thể là hàng triệu tin. Nhóm đã có con trỏ thì
|
|
100
|
+
tham số này KHÔNG có tác dụng.
|
|
101
|
+
max_retries thử lại mấy lần trước khi bỏ sang <topic>.dlt.
|
|
102
|
+
Nhớ: thử lại làm đứng cả phân vùng.
|
|
103
|
+
retry_delay chờ giữa các lần thử (giây). Để nhỏ.
|
|
104
|
+
dead_letter False = tin lỗi bị BỎ QUA hẳn, con trỏ vẫn đi tiếp.
|
|
105
|
+
"""
|
|
106
|
+
if not group:
|
|
107
|
+
raise ValueError("kafka_subscriber cần `group` — xem docstring")
|
|
108
|
+
if auto_offset_reset not in ("latest", "earliest"):
|
|
109
|
+
raise ValueError("auto_offset_reset phải là 'latest' hoặc 'earliest'")
|
|
110
|
+
|
|
111
|
+
def decorate(fn: Callable) -> Callable:
|
|
112
|
+
if not inspect.iscoroutinefunction(fn):
|
|
113
|
+
raise RuntimeError(f"{fn.__name__} phải là `async def`")
|
|
114
|
+
setattr(
|
|
115
|
+
fn,
|
|
116
|
+
_SPEC_ATTR,
|
|
117
|
+
KafkaSpec(
|
|
118
|
+
topic=topic,
|
|
119
|
+
group=group,
|
|
120
|
+
auto_offset_reset=auto_offset_reset,
|
|
121
|
+
max_retries=max_retries,
|
|
122
|
+
retry_delay=retry_delay,
|
|
123
|
+
dead_letter=dead_letter,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
return fn
|
|
127
|
+
|
|
128
|
+
return decorate
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def discover_kafka_subscribers() -> list[KafkaSpec]:
|
|
132
|
+
"""Quét mọi provider đã đăng ký để tìm method mang @kafka_subscriber."""
|
|
133
|
+
found: list[KafkaSpec] = []
|
|
134
|
+
for cls in _REGISTRY.values():
|
|
135
|
+
for fn in vars(cls).values():
|
|
136
|
+
spec: KafkaSpec | None = getattr(fn, _SPEC_ATTR, None)
|
|
137
|
+
if spec is None:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
params = list(inspect.signature(fn).parameters.values())[1:]
|
|
141
|
+
if not params or len(params) > 2:
|
|
142
|
+
raise RuntimeError(
|
|
143
|
+
f"{cls.__name__}.{fn.__name__}: chữ ký phải là "
|
|
144
|
+
"(self, payload) hoặc (self, payload, meta)"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
hints = get_type_hints(fn)
|
|
148
|
+
annotation = hints.get(params[0].name)
|
|
149
|
+
model = (
|
|
150
|
+
annotation
|
|
151
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel)
|
|
152
|
+
else None
|
|
153
|
+
)
|
|
154
|
+
found.append(
|
|
155
|
+
KafkaSpec(
|
|
156
|
+
topic=spec.topic,
|
|
157
|
+
group=spec.group,
|
|
158
|
+
auto_offset_reset=spec.auto_offset_reset,
|
|
159
|
+
max_retries=spec.max_retries,
|
|
160
|
+
retry_delay=spec.retry_delay,
|
|
161
|
+
dead_letter=spec.dead_letter,
|
|
162
|
+
cls=cls,
|
|
163
|
+
fn=fn,
|
|
164
|
+
model=model,
|
|
165
|
+
wants_meta=len(params) == 2,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return sorted(found, key=lambda s: (s.group, s.topic))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@injectable
|
|
172
|
+
class KafkaRunner:
|
|
173
|
+
"""Một AIOKafkaConsumer cho mỗi @kafka_subscriber, mỗi cái một task."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, broker: KafkaBroker, settings: Settings) -> None:
|
|
176
|
+
self._broker = broker
|
|
177
|
+
self._config = settings.kafka
|
|
178
|
+
self._specs: list[KafkaSpec] = []
|
|
179
|
+
self._tasks: dict[str, asyncio.Task[None]] = {}
|
|
180
|
+
self._closing = False
|
|
181
|
+
|
|
182
|
+
async def startup(self) -> None:
|
|
183
|
+
if not self._config.enabled:
|
|
184
|
+
return
|
|
185
|
+
self._specs = discover_kafka_subscribers()
|
|
186
|
+
if not self._specs:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
self._closing = False
|
|
190
|
+
self._broker.on_ready(self._setup)
|
|
191
|
+
if self._broker.connected:
|
|
192
|
+
await self._setup()
|
|
193
|
+
|
|
194
|
+
async def _setup(self) -> None:
|
|
195
|
+
"""Idempotent: gọi lại bao nhiêu lần cũng không sinh consumer trùng."""
|
|
196
|
+
for spec in self._specs:
|
|
197
|
+
khoa = f"{spec.group}:{spec.topic}"
|
|
198
|
+
cu = self._tasks.get(khoa)
|
|
199
|
+
if cu is not None and not cu.done():
|
|
200
|
+
continue
|
|
201
|
+
self._tasks[khoa] = asyncio.create_task(self._vong_doc(spec), name=f"kafka-{khoa}")
|
|
202
|
+
|
|
203
|
+
async def _vong_doc(self, spec: KafkaSpec) -> None:
|
|
204
|
+
"""Đọc mãi. Consumer chết vì bất cứ lý do gì thì dựng lại, có backoff.
|
|
205
|
+
|
|
206
|
+
Mỗi spec một consumer RIÊNG: dùng chung một consumer cho nhiều nhóm là
|
|
207
|
+
không thể (nhóm gắn với con trỏ đọc), và một topic khai sai không được
|
|
208
|
+
phép kéo các topic khác chết theo.
|
|
209
|
+
"""
|
|
210
|
+
aiokafka = _require_aiokafka()
|
|
211
|
+
delay = self._config.reconnect_delay_seconds
|
|
212
|
+
while not self._closing:
|
|
213
|
+
consumer = aiokafka.AIOKafkaConsumer(
|
|
214
|
+
spec.topic,
|
|
215
|
+
bootstrap_servers=self._config.bootstrap_servers,
|
|
216
|
+
group_id=spec.group,
|
|
217
|
+
client_id=self._config.client_id,
|
|
218
|
+
auto_offset_reset=spec.auto_offset_reset,
|
|
219
|
+
# Tự commit theo đồng hồ sẽ commit cả những tin CHƯA xử lý xong
|
|
220
|
+
# — tiến trình chết đúng lúc đó là mất tin. Commit tay sau khi
|
|
221
|
+
# handler xong cho ngữ nghĩa "ít nhất một lần".
|
|
222
|
+
enable_auto_commit=False,
|
|
223
|
+
)
|
|
224
|
+
try:
|
|
225
|
+
await consumer.start()
|
|
226
|
+
log.info(
|
|
227
|
+
"kafka.consumer_started",
|
|
228
|
+
handler=spec.label,
|
|
229
|
+
topic=spec.topic,
|
|
230
|
+
group=spec.group,
|
|
231
|
+
tu=spec.auto_offset_reset,
|
|
232
|
+
)
|
|
233
|
+
delay = self._config.reconnect_delay_seconds
|
|
234
|
+
async for message in consumer:
|
|
235
|
+
if self._closing:
|
|
236
|
+
break
|
|
237
|
+
await self._xu_ly(spec, message)
|
|
238
|
+
await consumer.commit()
|
|
239
|
+
except asyncio.CancelledError:
|
|
240
|
+
raise
|
|
241
|
+
except Exception as exc: # noqa: BLE001 - hỏng kiểu gì cũng dựng lại
|
|
242
|
+
if self._closing:
|
|
243
|
+
return
|
|
244
|
+
log.warning(
|
|
245
|
+
"kafka.consumer_lost",
|
|
246
|
+
handler=spec.label,
|
|
247
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
248
|
+
retry=delay,
|
|
249
|
+
)
|
|
250
|
+
finally:
|
|
251
|
+
with contextlib.suppress(Exception):
|
|
252
|
+
await consumer.stop()
|
|
253
|
+
|
|
254
|
+
if self._closing:
|
|
255
|
+
return
|
|
256
|
+
await asyncio.sleep(delay)
|
|
257
|
+
delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
|
|
258
|
+
|
|
259
|
+
async def _xu_ly(self, spec: KafkaSpec, message: Any) -> None:
|
|
260
|
+
"""Chạy handler, thử lại tại chỗ, hết lượt thì sang <topic>.dlt.
|
|
261
|
+
|
|
262
|
+
Hàm này KHÔNG được ném lỗi ra ngoài: ném là vòng đọc đứt và cả phân
|
|
263
|
+
vùng dừng lại vì một tin hỏng.
|
|
264
|
+
"""
|
|
265
|
+
for lan in range(1, spec.max_retries + 2):
|
|
266
|
+
try:
|
|
267
|
+
await self._goi(spec, message, lan)
|
|
268
|
+
except PermanentMessageError as exc:
|
|
269
|
+
log.error("kafka.permanent_error", handler=spec.label, error=str(exc))
|
|
270
|
+
await self._bo_sang_dlt(spec, message, exc)
|
|
271
|
+
return
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
kafka_consume_failed.inc(topic=spec.topic)
|
|
274
|
+
log.exception(
|
|
275
|
+
"kafka.handler_failed",
|
|
276
|
+
handler=spec.label,
|
|
277
|
+
topic=spec.topic,
|
|
278
|
+
partition=message.partition,
|
|
279
|
+
offset=message.offset,
|
|
280
|
+
attempt=lan,
|
|
281
|
+
error=str(exc),
|
|
282
|
+
)
|
|
283
|
+
if lan > spec.max_retries:
|
|
284
|
+
await self._bo_sang_dlt(spec, message, exc)
|
|
285
|
+
return
|
|
286
|
+
await asyncio.sleep(spec.retry_delay)
|
|
287
|
+
else:
|
|
288
|
+
kafka_consumed.inc(topic=spec.topic)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
async def _goi(self, spec: KafkaSpec, message: Any, lan: int) -> None:
|
|
292
|
+
token = set_request_id(new_request_id())
|
|
293
|
+
try:
|
|
294
|
+
async with request_scope():
|
|
295
|
+
payload: Any = json.loads(message.value)
|
|
296
|
+
if spec.model is not None:
|
|
297
|
+
try:
|
|
298
|
+
payload = spec.model.model_validate(payload)
|
|
299
|
+
except ValidationError as exc:
|
|
300
|
+
raise PermanentMessageError(f"Payload không hợp lệ: {exc}") from exc
|
|
301
|
+
|
|
302
|
+
instance = container.resolve(spec.cls) # type: ignore[arg-type]
|
|
303
|
+
if spec.wants_meta:
|
|
304
|
+
meta = {
|
|
305
|
+
"topic": message.topic,
|
|
306
|
+
"partition": message.partition,
|
|
307
|
+
"offset": message.offset,
|
|
308
|
+
"key": message.key.decode() if message.key else None,
|
|
309
|
+
"timestamp": message.timestamp,
|
|
310
|
+
"attempt": lan,
|
|
311
|
+
}
|
|
312
|
+
await spec.fn(instance, payload, meta) # type: ignore[misc]
|
|
313
|
+
else:
|
|
314
|
+
await spec.fn(instance, payload) # type: ignore[misc]
|
|
315
|
+
finally:
|
|
316
|
+
reset_request_id(token)
|
|
317
|
+
|
|
318
|
+
async def _bo_sang_dlt(self, spec: KafkaSpec, message: Any, error: BaseException) -> None:
|
|
319
|
+
kafka_dead_lettered.inc(topic=spec.topic)
|
|
320
|
+
if not spec.dead_letter:
|
|
321
|
+
log.error(
|
|
322
|
+
"kafka.message_dropped",
|
|
323
|
+
handler=spec.label,
|
|
324
|
+
offset=message.offset,
|
|
325
|
+
hint="consumer khai dead_letter=False nên tin bị bỏ qua, không lưu lại",
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
await self._broker.publish(
|
|
331
|
+
spec.dlt,
|
|
332
|
+
json.loads(message.value),
|
|
333
|
+
key=message.key.decode() if message.key else None,
|
|
334
|
+
headers={
|
|
335
|
+
"x-original-topic": message.topic,
|
|
336
|
+
"x-original-partition": str(message.partition),
|
|
337
|
+
"x-original-offset": str(message.offset),
|
|
338
|
+
"x-error": f"{type(error).__name__}: {error}"[:500],
|
|
339
|
+
},
|
|
340
|
+
fire_and_forget=True,
|
|
341
|
+
)
|
|
342
|
+
log.error("kafka.dead_lettered", handler=spec.label, topic=spec.dlt)
|
|
343
|
+
except Exception as exc: # không cứu được thì cũng đừng chặn phân vùng
|
|
344
|
+
log.exception("kafka.dlt_publish_failed", handler=spec.label, error=str(exc))
|
|
345
|
+
|
|
346
|
+
async def shutdown(self) -> None:
|
|
347
|
+
self._closing = True
|
|
348
|
+
for task in self._tasks.values():
|
|
349
|
+
task.cancel()
|
|
350
|
+
for task in self._tasks.values():
|
|
351
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
352
|
+
await task
|
|
353
|
+
self._tasks.clear()
|
|
354
|
+
|
|
355
|
+
def stats(self) -> dict[str, Any]:
|
|
356
|
+
return {
|
|
357
|
+
"consumers": [
|
|
358
|
+
{
|
|
359
|
+
"handler": spec.label,
|
|
360
|
+
"topic": spec.topic,
|
|
361
|
+
"group": spec.group,
|
|
362
|
+
"retries": spec.max_retries,
|
|
363
|
+
"dead_letter": spec.dlt if spec.dead_letter else None,
|
|
364
|
+
"running": (
|
|
365
|
+
(task := self._tasks.get(f"{spec.group}:{spec.topic}")) is not None
|
|
366
|
+
and not task.done()
|
|
367
|
+
),
|
|
368
|
+
}
|
|
369
|
+
for spec in self._specs
|
|
370
|
+
]
|
|
371
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Số đo của lớp Kafka."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pymodular.core.metrics import Counter, registry
|
|
6
|
+
|
|
7
|
+
kafka_published = registry.register(Counter("kafka_published_total", "Số tin đã gửi lên Kafka"))
|
|
8
|
+
kafka_publish_failed = registry.register(
|
|
9
|
+
Counter("kafka_publish_failed_total", "Số lần gửi tin thất bại")
|
|
10
|
+
)
|
|
11
|
+
kafka_consumed = registry.register(Counter("kafka_consumed_total", "Số tin đã xử lý xong"))
|
|
12
|
+
kafka_consume_failed = registry.register(
|
|
13
|
+
Counter("kafka_consume_failed_total", "Số lần handler xử lý lỗi (tính cả lần thử lại)")
|
|
14
|
+
)
|
|
15
|
+
kafka_dead_lettered = registry.register(
|
|
16
|
+
Counter("kafka_dead_lettered_total", "Số tin bị đẩy sang topic chết")
|
|
17
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Lớp MQTT — TUỲ CHỌN, và độc lập với mọi thứ khác.
|
|
2
|
+
|
|
3
|
+
pip install 'fastapi-modular[mqtt]' # cài aiomqtt + ghi sẵn APP_MQTT__* vào .env
|
|
4
|
+
|
|
5
|
+
Dùng cho thiết bị IoT: giao thức nhẹ, giữ kết nối lâu, chịu được mạng chập
|
|
6
|
+
chờn. Không dùng thì để `APP_MQTT__ENABLED=false` (mặc định).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pymodular.infrastructure.mqtt.client import MqttClient
|
|
12
|
+
from pymodular.infrastructure.mqtt.consumers import (
|
|
13
|
+
MqttRunner,
|
|
14
|
+
discover_mqtt_subscribers,
|
|
15
|
+
mqtt_subscriber,
|
|
16
|
+
)
|
|
17
|
+
from pymodular.infrastructure.mqtt.patterns import (
|
|
18
|
+
covers,
|
|
19
|
+
matches,
|
|
20
|
+
narrow_filters,
|
|
21
|
+
validate_topic,
|
|
22
|
+
validate_topic_filter,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"MqttClient",
|
|
27
|
+
"MqttRunner",
|
|
28
|
+
"covers",
|
|
29
|
+
"discover_mqtt_subscribers",
|
|
30
|
+
"matches",
|
|
31
|
+
"mqtt_subscriber",
|
|
32
|
+
"narrow_filters",
|
|
33
|
+
"validate_topic",
|
|
34
|
+
"validate_topic_filter",
|
|
35
|
+
]
|