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,616 @@
|
|
|
1
|
+
"""Kết nối RabbitMQ: khai báo exchange, đăng tin, mở hàng đợi.
|
|
2
|
+
|
|
3
|
+
RabbitMQ là TUỲ CHỌN. Không cài `aio-pika` và để `APP_RABBITMQ__ENABLED=false` (mặc
|
|
4
|
+
định) thì cả lớp này nằm im: không import thư viện, không mở kết nối, không
|
|
5
|
+
route nào đổi. Giống hệt cách driver database được tách riêng.
|
|
6
|
+
|
|
7
|
+
pip install 'fastapi-modular[rabbitmq]' # cài aio-pika + ghi sẵn APP_RABBITMQ__* vào .env
|
|
8
|
+
|
|
9
|
+
Hai điều đáng nói về cách dùng kênh (channel):
|
|
10
|
+
|
|
11
|
+
1. **Kênh đăng tin tách riêng kênh nhận tin.** Một lỗi giao thức trên kênh
|
|
12
|
+
(bind vào exchange không tồn tại, ack sai) sẽ ĐÓNG cả kênh đó. Dùng chung
|
|
13
|
+
thì một lệnh publish sai làm chết luôn mọi consumer đang chạy.
|
|
14
|
+
|
|
15
|
+
2. **`connect_robust`, không phải `connect`.** Bản robust tự nối lại khi mạng
|
|
16
|
+
rớt hoặc broker restart, và khai báo lại exchange/queue/consumer/binding đã
|
|
17
|
+
đăng ký qua nó. Bản thường thì mất kết nối là mất hẳn.
|
|
18
|
+
|
|
19
|
+
Về việc tự nối lại, có ba tình huống khác nhau và phải xử lý khác nhau:
|
|
20
|
+
|
|
21
|
+
(a) Broker CHƯA LÊN lúc app khởi động -> `connect_robust` ném lỗi ngay
|
|
22
|
+
(nó chỉ tự nối lại sau khi ĐÃ
|
|
23
|
+
từng kết nối được). Lớp này tự
|
|
24
|
+
chạy vòng nối lại có backoff.
|
|
25
|
+
(b) Mất kết nối giữa chừng -> aio-pika lo, kèm khôi phục
|
|
26
|
+
exchange/queue/binding/consumer.
|
|
27
|
+
(c) Broker restart hẳn -> cũng là (b) dưới góc nhìn client.
|
|
28
|
+
|
|
29
|
+
Với (a), app KHÔNG chết theo: HTTP và WebSocket vẫn phục vụ, chỉ những thao tác
|
|
30
|
+
cần RabbitMQ mới trả 503. Không có lựa chọn nào để tắt hành vi này — một dịch
|
|
31
|
+
vụ phụ chưa sẵn sàng không bao giờ đáng để cả API nằm im, và thứ tự khởi động
|
|
32
|
+
trong docker compose hay k8s vốn không bảo đảm.
|
|
33
|
+
|
|
34
|
+
Thiếu THƯ VIỆN thì khác hẳn thiếu KẾT NỐI: đó là lỗi cấu hình, không phải sự cố
|
|
35
|
+
tạm thời, nên báo ngay lúc boot chứ không thử lại.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import asyncio
|
|
41
|
+
import contextlib
|
|
42
|
+
import json
|
|
43
|
+
import uuid
|
|
44
|
+
from collections.abc import Awaitable, Callable
|
|
45
|
+
from typing import Any
|
|
46
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
47
|
+
|
|
48
|
+
from pymodular.core.clock import utcnow
|
|
49
|
+
from pymodular.core.compat import TimeoutErrors
|
|
50
|
+
from pymodular.core.config import Settings
|
|
51
|
+
from pymodular.core.container import injectable
|
|
52
|
+
from pymodular.core.exceptions import ComponentNotEnabledError, ServiceUnavailableError
|
|
53
|
+
from pymodular.core.logging import get_logger
|
|
54
|
+
from pymodular.infrastructure.rabbitmq.metrics import rabbitmq_publish_failed, rabbitmq_published
|
|
55
|
+
|
|
56
|
+
log = get_logger(__name__)
|
|
57
|
+
|
|
58
|
+
CONTENT_TYPE = "application/json"
|
|
59
|
+
|
|
60
|
+
# Giữ đúng một chỗ, để config và cảnh báo không lệch nhau.
|
|
61
|
+
DEFAULT_URL = "amqp://guest:guest@localhost:5672/"
|
|
62
|
+
|
|
63
|
+
# Số tin broker giao trước khi consumer ack. Đây là mặc định của MỘT consumer,
|
|
64
|
+
# không phải của cả ứng dụng: giá trị đúng phụ thuộc handler chạy nhanh hay
|
|
65
|
+
# chậm, payload nặng hay nhẹ. Đè bằng @rabbitmq_subscriber(prefetch=...).
|
|
66
|
+
DEFAULT_PREFETCH = 20
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _require_aio_pika() -> Any:
|
|
70
|
+
try:
|
|
71
|
+
import aio_pika
|
|
72
|
+
except ModuleNotFoundError as exc:
|
|
73
|
+
raise ComponentNotEnabledError(
|
|
74
|
+
"APP_RABBITMQ__ENABLED=true nhưng chưa cài thư viện aio-pika. "
|
|
75
|
+
"Chạy `pip install 'fastapi-modular[rabbitmq]'`, hoặc đặt APP_RABBITMQ__ENABLED=false nếu "
|
|
76
|
+
"dự án này không dùng RabbitMQ."
|
|
77
|
+
) from exc
|
|
78
|
+
return aio_pika
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def with_heartbeat(url: str, seconds: int) -> str:
|
|
82
|
+
"""Thêm `?heartbeat=` vào DSN nếu người dùng chưa tự đặt.
|
|
83
|
+
|
|
84
|
+
Không đặt thì lấy theo mặc định của server (RabbitMQ là 60s), tức mất tới
|
|
85
|
+
~120 giây mới phát hiện được một máy chủ bị rút điện. Cho tới lúc đó, mọi
|
|
86
|
+
lệnh publish đều treo rồi hết giờ.
|
|
87
|
+
"""
|
|
88
|
+
parsed = urlparse(url)
|
|
89
|
+
params = dict(parse_qsl(parsed.query))
|
|
90
|
+
if "heartbeat" in params or seconds <= 0:
|
|
91
|
+
return url
|
|
92
|
+
params["heartbeat"] = str(seconds)
|
|
93
|
+
return urlunparse(parsed._replace(query=urlencode(params)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def safe_url(url: str) -> str:
|
|
97
|
+
"""Che mật khẩu trước khi đưa vào log."""
|
|
98
|
+
if "@" not in url:
|
|
99
|
+
return url
|
|
100
|
+
scheme, _, rest = url.partition("://")
|
|
101
|
+
credentials, _, host = rest.rpartition("@")
|
|
102
|
+
user = credentials.split(":")[0] if credentials else ""
|
|
103
|
+
return f"{scheme}://{user}:***@{host}" if user else f"{scheme}://{host}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _doc_body(body: bytes) -> Any:
|
|
107
|
+
"""Tin trong DLQ có thể sai khuôn — đó thường là lý do nó nằm ở đó."""
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(body)
|
|
110
|
+
except Exception: # noqa: BLE001
|
|
111
|
+
return body.decode("utf-8", errors="replace")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@injectable
|
|
115
|
+
class RabbitBroker:
|
|
116
|
+
def __init__(self, settings: Settings) -> None:
|
|
117
|
+
self._config = settings.rabbitmq
|
|
118
|
+
self._connection: Any = None
|
|
119
|
+
self._publish_channel: Any = None
|
|
120
|
+
self._exchanges: dict[str, Any] = {}
|
|
121
|
+
self._lock = asyncio.Lock()
|
|
122
|
+
self._ready_hooks: list[Callable[[], Awaitable[None]]] = []
|
|
123
|
+
self._supervisor: asyncio.Task[None] | None = None
|
|
124
|
+
self._closing = False
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------- vòng đời
|
|
127
|
+
@property
|
|
128
|
+
def enabled(self) -> bool:
|
|
129
|
+
return self._config.enabled
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def connected(self) -> bool:
|
|
133
|
+
"""Đang thật sự nói chuyện được với broker.
|
|
134
|
+
|
|
135
|
+
`is_closed` KHÔNG đủ: một RobustConnection đang trong vòng nối lại thì
|
|
136
|
+
chưa đóng nhưng cũng chưa dùng được. Chỉ dựa vào nó thì /health/ready
|
|
137
|
+
sẽ báo "ổn" trong lúc broker đã chết — đúng kiểu cảnh báo im lặng mà
|
|
138
|
+
readiness sinh ra để tránh.
|
|
139
|
+
"""
|
|
140
|
+
if self._connection is None or self._connection.is_closed:
|
|
141
|
+
return False
|
|
142
|
+
if getattr(self._connection, "reconnecting", False):
|
|
143
|
+
return False
|
|
144
|
+
ready = getattr(self._connection, "connected", None)
|
|
145
|
+
return bool(ready.is_set()) if ready is not None else True
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def url(self) -> str:
|
|
149
|
+
return safe_url(self._config.url)
|
|
150
|
+
|
|
151
|
+
async def startup(self) -> None:
|
|
152
|
+
"""Nối tới broker. Không nối được thì lui về chế độ nối lại ngầm."""
|
|
153
|
+
if not self._config.enabled:
|
|
154
|
+
log.debug("mq.disabled")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# Thiếu thư viện là lỗi cấu hình -> báo ngay, thử lại cũng vô ích.
|
|
158
|
+
_require_aio_pika()
|
|
159
|
+
|
|
160
|
+
# Bật RabbitMQ mà vẫn dùng URL mặc định gần như chắc chắn là quên đặt
|
|
161
|
+
# biến, hoặc đặt sai tên biến. Không kêu ở đây thì log chỉ hiện
|
|
162
|
+
# "Connection refused localhost:5672" — người đọc tưởng broker chết,
|
|
163
|
+
# trong khi thật ra app chưa từng đọc được URL họ đã điền.
|
|
164
|
+
if self._config.url == DEFAULT_URL:
|
|
165
|
+
log.warning(
|
|
166
|
+
"rabbitmq.default_url",
|
|
167
|
+
url=DEFAULT_URL,
|
|
168
|
+
hint="chưa đặt APP_RABBITMQ__URL? Kiểm tra tên biến trong .env",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self._closing = False
|
|
172
|
+
if await self._try_connect():
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
log.warning(
|
|
176
|
+
"mq.starting_degraded",
|
|
177
|
+
url=self.url,
|
|
178
|
+
hint="app vẫn chạy; sẽ nối lại ngầm cho tới khi được",
|
|
179
|
+
)
|
|
180
|
+
self._supervisor = asyncio.create_task(self._reconnect_forever(), name="mq-reconnect")
|
|
181
|
+
|
|
182
|
+
async def _try_connect(self) -> bool:
|
|
183
|
+
try:
|
|
184
|
+
await self._connect()
|
|
185
|
+
except Exception as exc: # noqa: BLE001 - mọi lỗi kết nối đều dẫn tới cùng một việc: thử lại
|
|
186
|
+
log.warning("mq.connect_failed", url=self.url, error=f"{type(exc).__name__}: {exc}")
|
|
187
|
+
return False
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
async def _connect(self) -> None:
|
|
191
|
+
"""Nối xong xuôi HẲN mới ghi vào self — hỏng giữa chừng thì dọn sạch.
|
|
192
|
+
|
|
193
|
+
Nếu gán `self._connection` ngay rồi mới mở kênh, một lỗi ở bước sau sẽ
|
|
194
|
+
để lại kết nối nửa vời: `connected` trả về True nên vòng nối lại nghĩ
|
|
195
|
+
mọi thứ ổn và bỏ cuộc, trong khi log vừa báo "degraded". Tôi đã thấy
|
|
196
|
+
đúng cảnh đó khi thử khai một exchange sai kiểu.
|
|
197
|
+
"""
|
|
198
|
+
aio_pika = _require_aio_pika()
|
|
199
|
+
connection = await aio_pika.connect_robust(
|
|
200
|
+
with_heartbeat(self._config.url, self._config.heartbeat_seconds),
|
|
201
|
+
timeout=self._config.connect_timeout_seconds,
|
|
202
|
+
reconnect_interval=self._config.reconnect_delay_seconds,
|
|
203
|
+
)
|
|
204
|
+
try:
|
|
205
|
+
publish_channel = await connection.channel(publisher_confirms=True)
|
|
206
|
+
except BaseException:
|
|
207
|
+
await connection.close()
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
self._connection = connection
|
|
211
|
+
self._publish_channel = publish_channel
|
|
212
|
+
self._exchanges.clear()
|
|
213
|
+
|
|
214
|
+
# aio-pika gọi lại khi nó tự nối lại xong. Không tự khai báo lại gì ở
|
|
215
|
+
# đây: RobustChannel/RobustQueue đã khôi phục exchange, hàng đợi,
|
|
216
|
+
# binding và consumer. Khai lại lần nữa sẽ thành hai consumer trên cùng
|
|
217
|
+
# một hàng đợi, tức mỗi tin xử lý hai lần.
|
|
218
|
+
connection.reconnect_callbacks.add(self._on_reconnect)
|
|
219
|
+
connection.close_callbacks.add(self._on_close)
|
|
220
|
+
|
|
221
|
+
log.info("mq.connected", url=self.url)
|
|
222
|
+
await self._run_hooks()
|
|
223
|
+
|
|
224
|
+
async def _reconnect_forever(self) -> None:
|
|
225
|
+
"""Vòng nối lại cho tình huống (a): chưa từng kết nối được lần nào."""
|
|
226
|
+
delay = self._config.reconnect_delay_seconds
|
|
227
|
+
while not self._closing and not self.connected:
|
|
228
|
+
await asyncio.sleep(delay)
|
|
229
|
+
if self._closing:
|
|
230
|
+
return
|
|
231
|
+
if await self._try_connect():
|
|
232
|
+
log.info("mq.recovered", url=self.url)
|
|
233
|
+
return
|
|
234
|
+
delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
|
|
235
|
+
|
|
236
|
+
def _on_reconnect(self, _sender: Any) -> Any:
|
|
237
|
+
log.info("mq.reconnected", url=self.url)
|
|
238
|
+
# Trả về coroutine: aio-pika await nếu callback là bất đồng bộ.
|
|
239
|
+
return self._run_hooks()
|
|
240
|
+
|
|
241
|
+
def _on_close(self, _sender: Any, exc: BaseException | None = None) -> None:
|
|
242
|
+
if self._closing:
|
|
243
|
+
return
|
|
244
|
+
log.warning("mq.connection_lost", url=self.url, error=str(exc) if exc else None)
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------ hook
|
|
247
|
+
def on_ready(self, hook: Callable[[], Awaitable[None]]) -> None:
|
|
248
|
+
"""Đăng ký việc cần làm lại sau MỖI lần kết nối thành công.
|
|
249
|
+
|
|
250
|
+
Hook phải chạy lại được nhiều lần mà không nhân đôi trạng thái —
|
|
251
|
+
consumer dùng nó để dựng lại phần aio-pika không biết (ví dụ hàng đợi
|
|
252
|
+
thử lại, hoặc binding sinh ra trong lúc broker đang rớt).
|
|
253
|
+
"""
|
|
254
|
+
self._ready_hooks.append(hook)
|
|
255
|
+
|
|
256
|
+
async def _run_hooks(self) -> None:
|
|
257
|
+
for hook in list(self._ready_hooks):
|
|
258
|
+
try:
|
|
259
|
+
await hook()
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
log.exception("mq.ready_hook_failed", error=str(exc))
|
|
262
|
+
|
|
263
|
+
async def shutdown(self) -> None:
|
|
264
|
+
self._closing = True
|
|
265
|
+
if self._supervisor is not None:
|
|
266
|
+
self._supervisor.cancel()
|
|
267
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
268
|
+
await self._supervisor
|
|
269
|
+
self._supervisor = None
|
|
270
|
+
if self._connection is not None and not self._connection.is_closed:
|
|
271
|
+
await self._connection.close()
|
|
272
|
+
log.info("mq.disconnected")
|
|
273
|
+
self._connection = None
|
|
274
|
+
self._publish_channel = None
|
|
275
|
+
self._exchanges.clear()
|
|
276
|
+
|
|
277
|
+
def _ready(self) -> None:
|
|
278
|
+
if not self._config.enabled:
|
|
279
|
+
raise ComponentNotEnabledError(
|
|
280
|
+
"RabbitMQ đang tắt (APP_RABBITMQ__ENABLED=false) nên không đăng tin được."
|
|
281
|
+
)
|
|
282
|
+
if not self.connected:
|
|
283
|
+
raise ServiceUnavailableError("Chưa kết nối được RabbitMQ")
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------- khai báo
|
|
286
|
+
async def exchange(self, name: str) -> Any:
|
|
287
|
+
"""Lấy (hoặc khai báo) một topic exchange. Kết quả được nhớ lại."""
|
|
288
|
+
found = self._exchanges.get(name)
|
|
289
|
+
if found is not None:
|
|
290
|
+
return found
|
|
291
|
+
|
|
292
|
+
self._ready()
|
|
293
|
+
aio_pika = _require_aio_pika()
|
|
294
|
+
async with self._lock:
|
|
295
|
+
if name not in self._exchanges:
|
|
296
|
+
self._exchanges[name] = await self._publish_channel.declare_exchange(
|
|
297
|
+
# Exchange luôn bền: nó chỉ là một bảng định tuyến, không
|
|
298
|
+
# giữ dữ liệu, mà mất nó thì mọi binding mất theo.
|
|
299
|
+
name, aio_pika.ExchangeType.TOPIC, durable=True
|
|
300
|
+
)
|
|
301
|
+
log.debug("mq.exchange_declared", exchange=name)
|
|
302
|
+
return self._exchanges[name]
|
|
303
|
+
|
|
304
|
+
async def new_channel(self, *, prefetch: int = DEFAULT_PREFETCH) -> Any:
|
|
305
|
+
"""Mở một kênh RIÊNG.
|
|
306
|
+
|
|
307
|
+
Vì sao mỗi consumer cần kênh riêng: một lỗi giao thức (khai lại hàng
|
|
308
|
+
đợi với tham số khác, ack sai số hiệu) làm RabbitMQ ĐÓNG cả kênh. Dùng
|
|
309
|
+
chung một kênh thì một consumer khai sai sẽ kéo sập mọi consumer khác
|
|
310
|
+
— đúng cảnh đã xảy ra khi tôi thử tắt/bật broker.
|
|
311
|
+
"""
|
|
312
|
+
self._ready()
|
|
313
|
+
channel = await self._connection.channel()
|
|
314
|
+
await channel.set_qos(prefetch_count=prefetch)
|
|
315
|
+
return channel
|
|
316
|
+
|
|
317
|
+
async def worker_queue(self, channel: Any, hint: str) -> Any:
|
|
318
|
+
"""Hàng đợi RIÊNG của tiến trình này: tự sinh tên, tự xoá khi ngắt.
|
|
319
|
+
|
|
320
|
+
Dùng cho fan-out: mỗi worker cần MỘT BẢN SAO của mọi tin. Nếu nhiều
|
|
321
|
+
worker cùng nghe MỘT tên hàng đợi thì RabbitMQ chia lượt cho từng
|
|
322
|
+
worker — đúng cho xử lý nền (`@rabbitmq_subscriber`), sai cho fan-out.
|
|
323
|
+
"""
|
|
324
|
+
self._ready()
|
|
325
|
+
return await channel.declare_queue(
|
|
326
|
+
name="", # để broker tự đặt tên, chắc chắn không đụng nhau
|
|
327
|
+
exclusive=True, # không ai khác nối vào được
|
|
328
|
+
auto_delete=True, # rớt kết nối là biến mất, không để rác lại
|
|
329
|
+
arguments={"x-queue-type": "classic"},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
async def durable_queue(
|
|
333
|
+
self,
|
|
334
|
+
channel: Any,
|
|
335
|
+
name: str,
|
|
336
|
+
*,
|
|
337
|
+
durable: bool = True,
|
|
338
|
+
dead_letter: bool = False,
|
|
339
|
+
auto_delete: bool = False,
|
|
340
|
+
) -> Any:
|
|
341
|
+
"""Hàng đợi BỀN cho consumer nền: nhiều worker chia nhau xử lý.
|
|
342
|
+
|
|
343
|
+
Mặc định khai ĐÚNG MỘT hàng đợi. `dead_letter=True` thì khai thêm
|
|
344
|
+
`<name>.dlq` và trỏ hàng đợi này vào đó, để tin bị `reject` có chỗ nằm
|
|
345
|
+
lại thay vì biến mất.
|
|
346
|
+
|
|
347
|
+
`auto_delete=True` thì broker XOÁ hàng đợi khi consumer cuối cùng ngắt,
|
|
348
|
+
và mọi tin còn nằm trong đó mất theo. Mặc định là giữ lại: app tắt (hoặc
|
|
349
|
+
deploy) thì tin vẫn đọng ở broker, chạy lên là xử lý tiếp.
|
|
350
|
+
"""
|
|
351
|
+
self._ready()
|
|
352
|
+
arguments: dict[str, Any] = {}
|
|
353
|
+
if dead_letter:
|
|
354
|
+
dlx = await self._declare_dead_letter(channel, name, durable=durable)
|
|
355
|
+
arguments["x-dead-letter-exchange"] = dlx
|
|
356
|
+
arguments["x-dead-letter-routing-key"] = name
|
|
357
|
+
|
|
358
|
+
return await self._declare(
|
|
359
|
+
channel,
|
|
360
|
+
name,
|
|
361
|
+
durable=durable,
|
|
362
|
+
auto_delete=auto_delete,
|
|
363
|
+
arguments=arguments or None,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
async def _declare_dead_letter(
|
|
367
|
+
self, channel: Any, queue_name: str, *, durable: bool = True
|
|
368
|
+
) -> str:
|
|
369
|
+
aio_pika = _require_aio_pika()
|
|
370
|
+
dlx_name = self._config.dead_letter_exchange
|
|
371
|
+
dlx = await channel.declare_exchange(
|
|
372
|
+
dlx_name, aio_pika.ExchangeType.DIRECT, durable=True
|
|
373
|
+
)
|
|
374
|
+
dlq = await self._declare(channel, f"{queue_name}.dlq", durable=durable)
|
|
375
|
+
await dlq.bind(dlx, routing_key=queue_name)
|
|
376
|
+
return dlx_name
|
|
377
|
+
|
|
378
|
+
async def _declare(self, channel: Any, name: str, **kwargs: Any) -> Any:
|
|
379
|
+
"""Khai báo hàng đợi, đổi lỗi khó hiểu của RabbitMQ thành lời nói người.
|
|
380
|
+
|
|
381
|
+
PRECONDITION_FAILED xảy ra khi hàng đợi đã tồn tại với tham số khác
|
|
382
|
+
(đổi thời gian thử lại, bật/tắt dead-letter...). RabbitMQ KHÔNG cho sửa
|
|
383
|
+
tham số của hàng đợi đã có — phải xoá rồi tạo lại, và thông báo gốc thì
|
|
384
|
+
không hề nói vậy.
|
|
385
|
+
"""
|
|
386
|
+
try:
|
|
387
|
+
return await channel.declare_queue(name, **kwargs)
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
if "PRECONDITION_FAILED" not in str(exc):
|
|
390
|
+
raise
|
|
391
|
+
raise ServiceUnavailableError(
|
|
392
|
+
f"Hàng đợi '{name}' đã tồn tại với tham số khác: {exc}. "
|
|
393
|
+
"RabbitMQ không cho đổi tham số của hàng đợi đã có — xoá hàng đợi cũ "
|
|
394
|
+
f"(rabbitmqctl delete_queue {name}) rồi khởi động lại, hoặc đổi tên hàng đợi."
|
|
395
|
+
) from exc
|
|
396
|
+
|
|
397
|
+
async def queue_info(self, name: str) -> dict[str, int] | None:
|
|
398
|
+
"""Số tin đang chờ và số consumer đang nghe. `None` nếu hàng đợi không có.
|
|
399
|
+
|
|
400
|
+
Hỏi bằng cách khai báo `passive` trên một kênh DÙNG-MỘT-LẦN: hàng đợi
|
|
401
|
+
không tồn tại thì RabbitMQ trả NOT_FOUND và ĐÓNG luôn kênh đó. Hỏi trên
|
|
402
|
+
kênh đang có việc thì mỗi câu hỏi hụt là một kênh chết.
|
|
403
|
+
"""
|
|
404
|
+
if not self.connected:
|
|
405
|
+
return None
|
|
406
|
+
channel = await self._connection.channel()
|
|
407
|
+
try:
|
|
408
|
+
queue = await channel.declare_queue(name, passive=True)
|
|
409
|
+
result = queue.declaration_result
|
|
410
|
+
return {
|
|
411
|
+
"messages": int(result.message_count or 0),
|
|
412
|
+
"consumers": int(result.consumer_count or 0),
|
|
413
|
+
}
|
|
414
|
+
except Exception: # noqa: BLE001 - không tồn tại, hoặc kênh vừa bị đóng
|
|
415
|
+
return None
|
|
416
|
+
finally:
|
|
417
|
+
with contextlib.suppress(Exception):
|
|
418
|
+
await channel.close()
|
|
419
|
+
|
|
420
|
+
async def queue_exists(self, name: str) -> bool:
|
|
421
|
+
return await self.queue_info(name) is not None
|
|
422
|
+
|
|
423
|
+
async def peek(self, name: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
424
|
+
"""Xem tin trong hàng đợi mà KHÔNG lấy đi — để soi `<queue>.dlq`.
|
|
425
|
+
|
|
426
|
+
Giữ tất cả tin lấy được rồi mới trả lại một lượt, chứ không trả từng
|
|
427
|
+
tin: trả ngay thì tin quay về đầu hàng đợi và lần lấy kế tiếp vớ đúng
|
|
428
|
+
nó, xem 10 tin trên hàng đợi 2 tin sẽ ra 10 dòng trùng nhau.
|
|
429
|
+
|
|
430
|
+
Đây là công cụ để NHÌN, không phải để xử lý. Tin đang nằm trong tay một
|
|
431
|
+
consumer khác thì không hiện ra ở đây, và thứ tự sau khi trả lại có thể
|
|
432
|
+
đổi.
|
|
433
|
+
"""
|
|
434
|
+
if not self.connected:
|
|
435
|
+
return []
|
|
436
|
+
channel = await self._connection.channel()
|
|
437
|
+
giu: list[Any] = []
|
|
438
|
+
found: list[dict[str, Any]] = []
|
|
439
|
+
try:
|
|
440
|
+
queue = await channel.declare_queue(name, passive=True)
|
|
441
|
+
for _ in range(max(1, limit)):
|
|
442
|
+
message = await queue.get(no_ack=False, fail=False)
|
|
443
|
+
if message is None:
|
|
444
|
+
break
|
|
445
|
+
giu.append(message)
|
|
446
|
+
found.append(
|
|
447
|
+
{
|
|
448
|
+
"message_id": message.message_id,
|
|
449
|
+
"routing_key": message.routing_key,
|
|
450
|
+
"headers": dict(message.headers or {}),
|
|
451
|
+
"body": _doc_body(message.body),
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
except Exception as exc: # noqa: BLE001 - soi hàng đợi không được thì trả rỗng
|
|
455
|
+
log.debug("mq.peek_failed", queue=name, error=f"{type(exc).__name__}: {exc}")
|
|
456
|
+
finally:
|
|
457
|
+
for message in giu:
|
|
458
|
+
with contextlib.suppress(Exception):
|
|
459
|
+
await message.nack(requeue=True)
|
|
460
|
+
with contextlib.suppress(Exception):
|
|
461
|
+
await channel.close()
|
|
462
|
+
return found
|
|
463
|
+
|
|
464
|
+
async def delete_queue(self, name: str, *, if_unused: bool = True) -> bool:
|
|
465
|
+
"""Xoá một hàng đợi. Trả về False nếu không có, hoặc còn người đang nghe.
|
|
466
|
+
|
|
467
|
+
`if_unused=True` là chốt an toàn: hàng đợi nào còn consumer thì broker
|
|
468
|
+
từ chối xoá, nên không cắt ngang worker khác đang chạy.
|
|
469
|
+
"""
|
|
470
|
+
if not self.connected:
|
|
471
|
+
return False
|
|
472
|
+
channel = await self._connection.channel()
|
|
473
|
+
try:
|
|
474
|
+
await channel.queue_delete(name, if_unused=if_unused)
|
|
475
|
+
return True
|
|
476
|
+
except Exception as exc: # noqa: BLE001 - dọn dẹp: không xoá được thì thôi
|
|
477
|
+
log.debug("mq.queue_delete_failed", queue=name, error=f"{type(exc).__name__}: {exc}")
|
|
478
|
+
return False
|
|
479
|
+
finally:
|
|
480
|
+
with contextlib.suppress(Exception):
|
|
481
|
+
await channel.close()
|
|
482
|
+
|
|
483
|
+
async def retry_queue(
|
|
484
|
+
self, channel: Any, name: str, target_queue: str, *, durable: bool = True
|
|
485
|
+
) -> Any:
|
|
486
|
+
"""Hàng đợi tạm để thử lại sau một khoảng chờ.
|
|
487
|
+
|
|
488
|
+
Mẹo quen thuộc của RabbitMQ, không cần plugin: hàng đợi này KHÔNG có ai
|
|
489
|
+
nghe. Tin nằm đó tới khi hết hạn thì "chết" và được đẩy ngược về hàng
|
|
490
|
+
đợi chính. Nhờ vậy có chờ giữa các lần thử mà không phải `sleep` trong
|
|
491
|
+
consumer — sleep sẽ giữ luôn suất prefetch và làm nghẽn cả hàng đợi.
|
|
492
|
+
|
|
493
|
+
Thời gian chờ đặt trên TỪNG TIN (`expiration`) chứ không phải trên hàng
|
|
494
|
+
đợi (`x-message-ttl`). Lý do rất thực tế: RabbitMQ KHÔNG cho khai lại
|
|
495
|
+
hàng đợi đã tồn tại với tham số khác, nên chỉ cần đổi
|
|
496
|
+
APP_RABBITMQ__CONSUMER_RETRY_DELAY_SECONDS là lần khởi động sau chết với
|
|
497
|
+
PRECONDITION_FAILED. Tôi đã dính đúng lỗi này khi chạy test với thời
|
|
498
|
+
gian chờ khác lúc chạy dev.
|
|
499
|
+
"""
|
|
500
|
+
self._ready()
|
|
501
|
+
return await self._declare(
|
|
502
|
+
channel,
|
|
503
|
+
name,
|
|
504
|
+
durable=durable,
|
|
505
|
+
arguments={
|
|
506
|
+
"x-dead-letter-exchange": "", # exchange mặc định
|
|
507
|
+
"x-dead-letter-routing-key": target_queue,
|
|
508
|
+
},
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# ------------------------------------------------------------- đăng tin
|
|
512
|
+
async def publish(
|
|
513
|
+
self,
|
|
514
|
+
exchange: str,
|
|
515
|
+
routing_key: str,
|
|
516
|
+
payload: Any = None,
|
|
517
|
+
*,
|
|
518
|
+
headers: dict[str, Any] | None = None,
|
|
519
|
+
persistent: bool = True,
|
|
520
|
+
timeout: float | None = None,
|
|
521
|
+
fire_and_forget: bool = False,
|
|
522
|
+
) -> bool:
|
|
523
|
+
"""Đăng một tin lên exchange. Trả về True nếu broker đã xác nhận.
|
|
524
|
+
|
|
525
|
+
`fire_and_forget=True` thì RabbitMQ hỏng chỉ ghi cảnh báo thay vì ném
|
|
526
|
+
lỗi — dùng cho thông báo phụ, nơi mất tin còn hơn hỏng cả request. Mặc
|
|
527
|
+
định là ném lỗi, vì im lặng nuốt tin là thứ khó lần ra nhất.
|
|
528
|
+
"""
|
|
529
|
+
aio_pika = _require_aio_pika()
|
|
530
|
+
try:
|
|
531
|
+
target = await self.exchange(exchange)
|
|
532
|
+
message = aio_pika.Message(
|
|
533
|
+
body=json.dumps(payload, ensure_ascii=False, default=str).encode(),
|
|
534
|
+
content_type=CONTENT_TYPE,
|
|
535
|
+
content_encoding="utf-8",
|
|
536
|
+
message_id=uuid.uuid4().hex,
|
|
537
|
+
timestamp=utcnow(),
|
|
538
|
+
delivery_mode=(
|
|
539
|
+
aio_pika.DeliveryMode.PERSISTENT
|
|
540
|
+
if persistent
|
|
541
|
+
else aio_pika.DeliveryMode.NOT_PERSISTENT
|
|
542
|
+
),
|
|
543
|
+
headers=headers or {},
|
|
544
|
+
)
|
|
545
|
+
await asyncio.wait_for(
|
|
546
|
+
target.publish(message, routing_key=routing_key),
|
|
547
|
+
timeout or self._config.publish_timeout_seconds,
|
|
548
|
+
)
|
|
549
|
+
except TimeoutErrors as exc:
|
|
550
|
+
# Broker chết kiểu "im lặng" (rút điện, mất mạng): kết nối vẫn mở
|
|
551
|
+
# trên giấy tờ nên lệnh gửi treo tới hết hạn. Trả 503 với đúng tên
|
|
552
|
+
# thành phần thay vì để lỗi chung chung nổi lên.
|
|
553
|
+
rabbitmq_publish_failed.inc(exchange=exchange)
|
|
554
|
+
if fire_and_forget:
|
|
555
|
+
log.warning("mq.publish_timeout", exchange=exchange, routing_key=routing_key)
|
|
556
|
+
return False
|
|
557
|
+
raise ServiceUnavailableError(
|
|
558
|
+
f"RabbitMQ không phản hồi trong {self._config.publish_timeout_seconds}s"
|
|
559
|
+
) from exc
|
|
560
|
+
except Exception as exc:
|
|
561
|
+
rabbitmq_publish_failed.inc(exchange=exchange)
|
|
562
|
+
if fire_and_forget:
|
|
563
|
+
log.warning(
|
|
564
|
+
"mq.publish_failed",
|
|
565
|
+
exchange=exchange,
|
|
566
|
+
routing_key=routing_key,
|
|
567
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
568
|
+
)
|
|
569
|
+
return False
|
|
570
|
+
raise
|
|
571
|
+
|
|
572
|
+
rabbitmq_published.inc(exchange=exchange, routing_key=routing_key)
|
|
573
|
+
log.debug("mq.published", exchange=exchange, routing_key=routing_key)
|
|
574
|
+
return True
|
|
575
|
+
|
|
576
|
+
async def publish_to_queue(
|
|
577
|
+
self,
|
|
578
|
+
queue: str,
|
|
579
|
+
body: bytes,
|
|
580
|
+
*,
|
|
581
|
+
headers: dict[str, Any] | None = None,
|
|
582
|
+
expiration: float | None = None,
|
|
583
|
+
persistent: bool = True,
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Gửi thẳng vào MỘT hàng đợi, không qua exchange nào.
|
|
586
|
+
|
|
587
|
+
Dùng exchange mặc định (tên rỗng) của AMQP: nó route theo đúng tên hàng
|
|
588
|
+
đợi. Cần cho việc hẹn xử lý lại — tin phải vào đúng `<queue>.retry`,
|
|
589
|
+
không được phát tán theo mẫu như exchange topic.
|
|
590
|
+
"""
|
|
591
|
+
aio_pika = _require_aio_pika()
|
|
592
|
+
self._ready()
|
|
593
|
+
message = aio_pika.Message(
|
|
594
|
+
body=body,
|
|
595
|
+
content_type=CONTENT_TYPE,
|
|
596
|
+
content_encoding="utf-8",
|
|
597
|
+
delivery_mode=(
|
|
598
|
+
aio_pika.DeliveryMode.PERSISTENT
|
|
599
|
+
if persistent
|
|
600
|
+
else aio_pika.DeliveryMode.NOT_PERSISTENT
|
|
601
|
+
),
|
|
602
|
+
headers=headers or {},
|
|
603
|
+
expiration=expiration,
|
|
604
|
+
)
|
|
605
|
+
await asyncio.wait_for(
|
|
606
|
+
self._publish_channel.default_exchange.publish(message, routing_key=queue),
|
|
607
|
+
self._config.publish_timeout_seconds,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
def stats(self) -> dict[str, Any]:
|
|
611
|
+
return {
|
|
612
|
+
"enabled": self._config.enabled,
|
|
613
|
+
"connected": self.connected,
|
|
614
|
+
"url": self.url if self._config.enabled else None,
|
|
615
|
+
"exchanges": sorted(self._exchanges),
|
|
616
|
+
}
|