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.
Files changed (69) hide show
  1. fastapi_modular-0.1.0.dist-info/METADATA +377 -0
  2. fastapi_modular-0.1.0.dist-info/RECORD +69 -0
  3. fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
  4. fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
  5. fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. pymodular/__init__.py +74 -0
  7. pymodular/cli/__init__.py +0 -0
  8. pymodular/cli/clean.py +39 -0
  9. pymodular/cli/configure_env.py +569 -0
  10. pymodular/cli/cong_cu.py +111 -0
  11. pymodular/cli/info.py +62 -0
  12. pymodular/cli/install.py +83 -0
  13. pymodular/cli/main.py +247 -0
  14. pymodular/cli/new_module.py +492 -0
  15. pymodular/cli/new_project.py +471 -0
  16. pymodular/cli/serve.py +59 -0
  17. pymodular/core/__init__.py +0 -0
  18. pymodular/core/clock.py +15 -0
  19. pymodular/core/compat.py +39 -0
  20. pymodular/core/config.py +495 -0
  21. pymodular/core/container.py +354 -0
  22. pymodular/core/context.py +78 -0
  23. pymodular/core/controller.py +208 -0
  24. pymodular/core/error_handlers.py +272 -0
  25. pymodular/core/exceptions.py +104 -0
  26. pymodular/core/guards.py +117 -0
  27. pymodular/core/lifespan.py +150 -0
  28. pymodular/core/logging.py +88 -0
  29. pymodular/core/metrics.py +190 -0
  30. pymodular/core/schemas.py +105 -0
  31. pymodular/core/websocket/__init__.py +31 -0
  32. pymodular/core/websocket/adapter.py +192 -0
  33. pymodular/core/websocket/gateway.py +735 -0
  34. pymodular/core/websocket/namespace.py +148 -0
  35. pymodular/core/websocket/protocol.py +157 -0
  36. pymodular/core/websocket/server.py +175 -0
  37. pymodular/core/websocket/socket.py +241 -0
  38. pymodular/discovery.py +180 -0
  39. pymodular/factory.py +126 -0
  40. pymodular/infrastructure/__init__.py +1 -0
  41. pymodular/infrastructure/database/__init__.py +8 -0
  42. pymodular/infrastructure/database/base.py +228 -0
  43. pymodular/infrastructure/database/circuit.py +207 -0
  44. pymodular/infrastructure/database/factory.py +88 -0
  45. pymodular/infrastructure/database/memory.py +112 -0
  46. pymodular/infrastructure/database/mongo.py +186 -0
  47. pymodular/infrastructure/database/repository.py +188 -0
  48. pymodular/infrastructure/database/sql.py +520 -0
  49. pymodular/infrastructure/kafka/__init__.py +26 -0
  50. pymodular/infrastructure/kafka/broker.py +231 -0
  51. pymodular/infrastructure/kafka/consumers.py +371 -0
  52. pymodular/infrastructure/kafka/metrics.py +17 -0
  53. pymodular/infrastructure/mqtt/__init__.py +35 -0
  54. pymodular/infrastructure/mqtt/client.py +292 -0
  55. pymodular/infrastructure/mqtt/consumers.py +219 -0
  56. pymodular/infrastructure/mqtt/metrics.py +17 -0
  57. pymodular/infrastructure/mqtt/patterns.py +116 -0
  58. pymodular/infrastructure/rabbitmq/__init__.py +33 -0
  59. pymodular/infrastructure/rabbitmq/broker.py +616 -0
  60. pymodular/infrastructure/rabbitmq/consumers.py +450 -0
  61. pymodular/infrastructure/rabbitmq/metrics.py +34 -0
  62. pymodular/infrastructure/rabbitmq/patterns.py +64 -0
  63. pymodular/infrastructure/redis/__init__.py +31 -0
  64. pymodular/infrastructure/redis/client.py +362 -0
  65. pymodular/infrastructure/redis/metrics.py +20 -0
  66. pymodular/infrastructure/redis/pubsub.py +262 -0
  67. pymodular/middleware/__init__.py +0 -0
  68. pymodular/middleware/request_context.py +164 -0
  69. 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
+ }