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,450 @@
1
+ """Consumer nền — tương đương `@EventPattern` của NestJS microservices.
2
+
3
+ @injectable
4
+ class AlertConsumer:
5
+ def __init__(self, service: AlertService) -> None:
6
+ self._service = service
7
+
8
+ @rabbitmq_subscriber("events", "alert.created", queue="alert-mailer")
9
+ async def gui_mail(self, payload: AlertCreated) -> None:
10
+ await self._service.notify(payload.id)
11
+
12
+ Hàng đợi ở đây BỀN và có TÊN, nên nhiều worker CHIA NHAU xử lý — mỗi tin đúng
13
+ một worker làm. Đó là ngữ nghĩa đúng cho việc phải làm một lần: gửi mail, ghi
14
+ sổ, gọi dịch vụ ngoài.
15
+
16
+ Cần ngược lại (MỌI worker đều nhận một bản sao) thì đừng dùng `@rabbitmq_subscriber`;
17
+ tự mở hàng đợi bằng `broker.worker_queue(...)`. Đảo hai thứ này là lỗi kinh
18
+ điển: hàng đợi riêng cho việc làm-một-lần thì mỗi tin bị xử lý N lần.
19
+
20
+ Mặc định mọc ra ĐÚNG MỘT hàng đợi. Handler ném lỗi thì tin bị bỏ và có log —
21
+ không thử lại, không giữ lại. Muốn chắc hơn thì tự bật:
22
+
23
+ @rabbitmq_subscriber(..., max_retries=3) # thêm <queue>.retry
24
+ @rabbitmq_subscriber(..., max_retries=3, dead_letter=True) # thêm cả <queue>.dlq
25
+
26
+ Khi đã bật, handler ném lỗi sẽ đi đường này:
27
+
28
+ lần 1..N -> đẩy sang `<queue>.retry` (hàng đợi có TTL, hết hạn thì tin tự
29
+ quay về hàng đợi chính) rồi ack bản gốc
30
+ quá N lần -> reject; có dead_letter thì tin rơi vào `<queue>.dlq`, không thì bỏ
31
+
32
+ Cố ý KHÔNG dùng `requeue=True`: tin hỏng vĩnh viễn (dữ liệu sai, bug) sẽ quay
33
+ vòng liên tục, ăn hết CPU và che lấp mọi tin khác. Đó là cách phổ biến nhất để
34
+ làm sập một hệ thống hàng đợi.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import inspect
40
+ import json
41
+ from collections.abc import Callable
42
+ from dataclasses import dataclass
43
+ from typing import Any, get_type_hints
44
+
45
+ from pydantic import BaseModel, ValidationError
46
+
47
+ from pymodular.core.config import Settings
48
+ from pymodular.core.container import _REGISTRY, container, injectable, request_scope
49
+ from pymodular.core.context import new_request_id, reset_request_id, set_request_id
50
+ from pymodular.core.logging import get_logger
51
+ from pymodular.infrastructure.rabbitmq.broker import DEFAULT_PREFETCH, RabbitBroker
52
+ from pymodular.infrastructure.rabbitmq.metrics import (
53
+ rabbitmq_consume_failed,
54
+ rabbitmq_consumed,
55
+ rabbitmq_dead_lettered,
56
+ rabbitmq_retried,
57
+ )
58
+ from pymodular.infrastructure.rabbitmq.patterns import validate_pattern
59
+
60
+ log = get_logger(__name__)
61
+
62
+ _SPEC_ATTR = "__rabbitmq_subscriber__"
63
+ ATTEMPT_HEADER = "x-attempt"
64
+
65
+
66
+ class PermanentMessageError(Exception):
67
+ """Tin này sai vĩnh viễn — thử lại vô ích, cho đi thẳng vào DLQ.
68
+
69
+ Ném từ handler khi biết chắc thử lại không giúp gì: payload sai khuôn,
70
+ tham chiếu tới bản ghi đã bị xoá, phiên bản sự kiện không hỗ trợ.
71
+ """
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class RabbitmqSpec:
76
+ exchange: str
77
+ routing_key: str
78
+ queue: str
79
+ max_retries: int = 0
80
+ retry_delay: float = 10.0
81
+ dead_letter: bool = False
82
+ durable: bool = True
83
+ auto_delete: bool = False
84
+ prefetch: int = DEFAULT_PREFETCH
85
+ cls: type | None = None
86
+ fn: Callable | None = None
87
+ model: type[BaseModel] | None = None
88
+ wants_meta: bool = False
89
+
90
+ @property
91
+ def label(self) -> str:
92
+ return f"{self.cls.__name__}.{self.fn.__name__}" if self.cls and self.fn else self.queue
93
+
94
+
95
+ def rabbitmq_subscriber(
96
+ exchange: str,
97
+ routing_key: str,
98
+ *,
99
+ queue: str,
100
+ max_retries: int = 0,
101
+ retry_delay: float = 10.0,
102
+ dead_letter: bool = False,
103
+ durable: bool = True,
104
+ auto_delete: bool = False,
105
+ prefetch: int = DEFAULT_PREFETCH,
106
+ ) -> Callable[[Callable], Callable]:
107
+ """Gắn method vào một hàng đợi bền nghe `routing_key` trên `exchange`.
108
+
109
+ `queue` là BẮT BUỘC và cố ý không tự sinh: tên hàng đợi là danh tính của
110
+ nhóm consumer. Tên tự sinh sẽ đổi sau mỗi lần deploy, khiến hàng đợi cũ ở
111
+ lại broker và tin đọng trong đó vĩnh viễn.
112
+
113
+ Mặc định mọc ra ĐÚNG MỘT hàng đợi trên broker, không có gì thêm. Handler ném
114
+ lỗi thì tin bị bỏ, kèm log `mq.message_dropped`.
115
+
116
+ Hai hàng đợi phụ chỉ xuất hiện khi bạn tự bật:
117
+
118
+ max_retries=3 -> thêm <queue>.retry (chỗ tin nằm chờ giữa hai lần thử)
119
+ dead_letter=True -> thêm <queue>.dlq (chỗ tin nằm lại sau khi bỏ cuộc)
120
+
121
+ Bật khi tin đáng tiền: đơn hàng, thanh toán, gửi mail. Để nguyên mặc định
122
+ cho loại mất cũng không sao: số đo, nhịp tim, log.
123
+
124
+ Mọi tham số ở đây là quyết định của RIÊNG consumer này, nên khai ngay tại
125
+ chỗ chứ không phải trong .env — "gửi mail thử lại 5 lần, cách nhau 60 giây"
126
+ và "ghi log không thử lại" là hai câu chuyện khác nhau, một biến môi trường
127
+ chung không nói được cả hai:
128
+
129
+ max_retries số lần thử lại; 0 (mặc định) = hỏng là bỏ ngay
130
+ retry_delay chờ bao lâu giữa các lần (giây); chỉ dùng khi max_retries > 0
131
+ dead_letter True = giữ tin hỏng lại ở <queue>.dlq để xem
132
+ durable hàng đợi sống sót qua restart broker
133
+ auto_delete xoá hàng đợi khi consumer cuối cùng ngắt
134
+ prefetch số tin nhận trước khi ack — handler chậm thì để nhỏ
135
+
136
+ `auto_delete` mặc định False, tức GIỮ LẠI hàng đợi khi app tắt. Đó là điều
137
+ người ta muốn gần như mọi lúc: deploy, restart, app chết — tin gửi trong lúc
138
+ đó vẫn nằm ở broker, app lên là xử lý tiếp. Đặt True thì broker xoá hàng đợi
139
+ ngay khi consumer cuối cùng rời đi, kèm mọi tin còn nằm trong đó; sau đó tin
140
+ nào khớp routing_key cũng rơi vào hư không cho tới lần khởi động sau.
141
+
142
+ Chỉ hợp lý cho tin chỉ có giá trị lúc này: theo dõi trực tiếp, đo đạc, log
143
+ tạm. Thường đi cùng `durable=False`.
144
+
145
+ Dọn dẹp là TRỌN GÓI: `<queue>.retry` và `<queue>.dlq` cũng bị xoá theo. Bản
146
+ thân `auto_delete` của AMQP không làm nổi việc đó — nó chỉ kích hoạt khi
147
+ consumer CUỐI CÙNG rời đi, mà hai hàng đợi phụ thì chẳng có ai nghe bao giờ,
148
+ nên chúng sẽ nằm lại broker vĩnh viễn. Khung tự xoá chúng lúc tắt, sau khi
149
+ xác nhận hàng đợi chính đã biến mất (tức không còn worker nào khác đang
150
+ nghe) — xem `RabbitmqRunner._don_hang_doi_phu`.
151
+ """
152
+ validate_pattern(routing_key)
153
+
154
+ def decorate(fn: Callable) -> Callable:
155
+ if not inspect.iscoroutinefunction(fn):
156
+ raise RuntimeError(f"{fn.__name__} phải là `async def`")
157
+ setattr(
158
+ fn,
159
+ _SPEC_ATTR,
160
+ RabbitmqSpec(
161
+ exchange=exchange,
162
+ routing_key=routing_key,
163
+ queue=queue,
164
+ max_retries=max_retries,
165
+ retry_delay=retry_delay,
166
+ dead_letter=dead_letter,
167
+ durable=durable,
168
+ auto_delete=auto_delete,
169
+ prefetch=prefetch,
170
+ ),
171
+ )
172
+ return fn
173
+
174
+ return decorate
175
+
176
+
177
+ def discover_rabbitmq_subscribers() -> list[RabbitmqSpec]:
178
+ """Quét mọi provider đã đăng ký để tìm method mang @rabbitmq_subscriber.
179
+
180
+ Không cần decorator riêng ở cấp class: bất kỳ class @injectable nào cũng
181
+ chứa consumer được, giống như service thường.
182
+ """
183
+ found: list[RabbitmqSpec] = []
184
+ for cls in _REGISTRY.values():
185
+ for fn in vars(cls).values():
186
+ spec: RabbitmqSpec | None = getattr(fn, _SPEC_ATTR, None)
187
+ if spec is None:
188
+ continue
189
+
190
+ params = list(inspect.signature(fn).parameters.values())[1:]
191
+ if not params or len(params) > 2:
192
+ raise RuntimeError(
193
+ f"{cls.__name__}.{fn.__name__}: chữ ký phải là "
194
+ "(self, payload) hoặc (self, payload, meta)"
195
+ )
196
+
197
+ hints = get_type_hints(fn)
198
+ annotation = hints.get(params[0].name)
199
+ model = (
200
+ annotation
201
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel)
202
+ else None
203
+ )
204
+ found.append(
205
+ RabbitmqSpec(
206
+ exchange=spec.exchange,
207
+ routing_key=spec.routing_key,
208
+ queue=spec.queue,
209
+ max_retries=spec.max_retries,
210
+ retry_delay=spec.retry_delay,
211
+ dead_letter=spec.dead_letter,
212
+ durable=spec.durable,
213
+ auto_delete=spec.auto_delete,
214
+ prefetch=spec.prefetch,
215
+ cls=cls,
216
+ fn=fn,
217
+ model=model,
218
+ wants_meta=len(params) == 2,
219
+ )
220
+ )
221
+ return sorted(found, key=lambda s: (s.queue, s.routing_key))
222
+
223
+
224
+ @injectable
225
+ class RabbitmqRunner:
226
+ """Dựng hàng đợi và bật consumer cho mọi @rabbitmq_subscriber tìm được."""
227
+
228
+ def __init__(self, broker: RabbitBroker, settings: Settings) -> None:
229
+ self._broker = broker
230
+ self._config = settings.rabbitmq
231
+ self._specs: list[RabbitmqSpec] = []
232
+ self._started: dict[str, Any] = {} # queue -> (queue, consumer tag, spec)
233
+
234
+ async def startup(self) -> None:
235
+ if not self._config.enabled:
236
+ return
237
+
238
+ self._specs = discover_rabbitmq_subscribers()
239
+ if not self._specs:
240
+ return
241
+
242
+ # Chạy lại sau MỖI lần kết nối được — kể cả lần đầu tiên xảy ra muộn vì
243
+ # broker chưa lên lúc app khởi động.
244
+ self._broker.on_ready(self._setup)
245
+ if self._broker.connected:
246
+ await self._setup()
247
+
248
+ async def _setup(self) -> None:
249
+ """Idempotent: gọi lại bao nhiêu lần cũng không sinh consumer trùng."""
250
+ for spec in self._specs:
251
+ if spec.queue in self._started:
252
+ # aio-pika đã tự khôi phục hàng đợi và consumer sau khi nối
253
+ # lại. Gọi consume() lần nữa sẽ thành hai consumer trên cùng
254
+ # hàng đợi, tức mỗi tin xử lý hai lần.
255
+ continue
256
+ try:
257
+ # Kênh riêng cho từng consumer: khai báo hỏng (hàng đợi đã tồn
258
+ # tại với tham số khác) làm RabbitMQ đóng cả kênh, dùng chung
259
+ # thì một consumer sai sẽ giết mọi consumer còn lại.
260
+ channel = await self._broker.new_channel(prefetch=spec.prefetch)
261
+ queue = await self._broker.durable_queue(
262
+ channel,
263
+ spec.queue,
264
+ durable=spec.durable,
265
+ dead_letter=spec.dead_letter,
266
+ auto_delete=spec.auto_delete,
267
+ )
268
+ # Chỉ tạo hàng đợi chờ khi thật sự có thử lại. Không kiểm tra
269
+ # thì broker mọc thêm một hàng đợi không bao giờ có tin nào.
270
+ if spec.max_retries > 0:
271
+ await self._broker.retry_queue(
272
+ channel, f"{spec.queue}.retry", spec.queue, durable=spec.durable
273
+ )
274
+ await queue.bind(
275
+ await self._broker.exchange(spec.exchange), routing_key=spec.routing_key
276
+ )
277
+ tag = await queue.consume(self._make_callback(spec))
278
+ self._started[spec.queue] = (queue, tag, spec)
279
+ log.info(
280
+ "mq.consumer_started",
281
+ handler=spec.label,
282
+ queue=spec.queue,
283
+ exchange=spec.exchange,
284
+ routing_key=spec.routing_key,
285
+ retries=spec.max_retries,
286
+ dead_letter=spec.dead_letter,
287
+ )
288
+ except Exception as exc:
289
+ # Một consumer hỏng không được chặn các consumer còn lại.
290
+ log.exception("mq.consumer_start_failed", handler=spec.label, error=str(exc))
291
+
292
+ def _make_callback(self, spec: RabbitmqSpec) -> Callable:
293
+ max_retries = spec.max_retries
294
+
295
+ async def callback(message: Any) -> None:
296
+ attempt = int((message.headers or {}).get(ATTEMPT_HEADER, 0)) + 1
297
+ token = set_request_id(new_request_id())
298
+ try:
299
+ async with request_scope():
300
+ await self._invoke(spec, message, attempt)
301
+ except Exception as exc:
302
+ # Bắt mọi lỗi: quyết định thử lại hay cho vào DLQ nằm ngay dưới.
303
+ rabbitmq_consume_failed.inc(queue=spec.queue)
304
+ log.exception(
305
+ "mq.handler_failed",
306
+ handler=spec.label,
307
+ queue=spec.queue,
308
+ routing_key=message.routing_key,
309
+ attempt=attempt,
310
+ error=str(exc),
311
+ )
312
+ await self._on_failure(spec, message, attempt, max_retries, exc)
313
+ else:
314
+ await message.ack()
315
+ rabbitmq_consumed.inc(queue=spec.queue)
316
+ finally:
317
+ reset_request_id(token)
318
+
319
+ return callback
320
+
321
+ async def _invoke(self, spec: RabbitmqSpec, message: Any, attempt: int) -> None:
322
+ payload: Any = json.loads(message.body)
323
+ if spec.model is not None:
324
+ try:
325
+ payload = spec.model.model_validate(payload)
326
+ except ValidationError as exc:
327
+ # Sai khuôn thì thử lại bao nhiêu lần cũng vẫn sai; ném lỗi để
328
+ # tin đi thẳng vào DLQ ở lần đầu.
329
+ raise PermanentMessageError(f"Payload không hợp lệ: {exc}") from exc
330
+
331
+ instance = container.resolve(spec.cls) # type: ignore[arg-type]
332
+ if spec.wants_meta:
333
+ meta = {
334
+ "exchange": spec.exchange,
335
+ "routing_key": message.routing_key,
336
+ "message_id": message.message_id,
337
+ "attempt": attempt,
338
+ "redelivered": bool(message.redelivered),
339
+ }
340
+ await spec.fn(instance, payload, meta) # type: ignore[misc]
341
+ else:
342
+ await spec.fn(instance, payload) # type: ignore[misc]
343
+
344
+ async def _on_failure(
345
+ self,
346
+ spec: RabbitmqSpec,
347
+ message: Any,
348
+ attempt: int,
349
+ max_retries: int,
350
+ error: BaseException,
351
+ ) -> None:
352
+ # Lỗi vĩnh viễn thì bỏ qua mọi lần thử còn lại — thử lại chỉ tốn thời
353
+ # gian và làm nhiễu log.
354
+ if isinstance(error, PermanentMessageError) or attempt > max_retries:
355
+ # reject(requeue=False): có dead_letter thì RabbitMQ đẩy sang dlx ->
356
+ # <queue>.dlq; không có thì tin bị vứt bỏ hẳn.
357
+ await message.reject(requeue=False)
358
+ rabbitmq_dead_lettered.inc(queue=spec.queue)
359
+ if spec.dead_letter:
360
+ log.error(
361
+ "mq.dead_lettered",
362
+ handler=spec.label,
363
+ queue=f"{spec.queue}.dlq",
364
+ attempt=attempt,
365
+ )
366
+ else:
367
+ log.error(
368
+ "mq.message_dropped",
369
+ handler=spec.label,
370
+ attempt=attempt,
371
+ hint="tin bị bỏ, không lưu lại. Thêm max_retries=... để thử lại, "
372
+ "dead_letter=True để giữ tin ở <queue>.dlq",
373
+ )
374
+ return
375
+
376
+ try:
377
+ await self._broker.publish_to_queue(
378
+ f"{spec.queue}.retry",
379
+ message.body,
380
+ headers={**(message.headers or {}), ATTEMPT_HEADER: attempt},
381
+ expiration=spec.retry_delay,
382
+ persistent=spec.durable,
383
+ )
384
+ except Exception as exc: # noqa: BLE001 - không hẹn lại được thì để RabbitMQ giao lại
385
+ log.warning("mq.retry_publish_failed", handler=spec.label, error=str(exc))
386
+ await message.nack(requeue=True)
387
+ return
388
+
389
+ await message.ack()
390
+ rabbitmq_retried.inc(queue=spec.queue)
391
+ log.warning(
392
+ "mq.retry_scheduled",
393
+ handler=spec.label,
394
+ attempt=attempt,
395
+ delay=spec.retry_delay,
396
+ )
397
+
398
+ async def shutdown(self) -> None:
399
+ for queue_name, (queue, tag, spec) in list(self._started.items()):
400
+ if not self._broker.connected:
401
+ break
402
+ try:
403
+ await queue.cancel(tag)
404
+ except Exception as exc: # noqa: BLE001 - đang tắt
405
+ log.debug("mq.consumer_cancel_failed", queue=queue_name, error=str(exc))
406
+ continue
407
+ if spec.auto_delete:
408
+ await self._don_hang_doi_phu(spec)
409
+ self._started.clear()
410
+
411
+ async def _don_hang_doi_phu(self, spec: RabbitmqSpec) -> None:
412
+ """Xoá `<queue>.retry` và `<queue>.dlq` khi hàng đợi chính đã tự xoá.
413
+
414
+ Phải hỏi lại broker chứ không suy đoán: nhiều worker cùng nghe một hàng
415
+ đợi thì `auto_delete` chỉ kích hoạt lúc worker CUỐI CÙNG ngắt. Worker
416
+ đầu tiên tắt mà đã dọn thì nó cướp mất hàng đợi thử lại của những worker
417
+ còn đang chạy — tin lỗi của họ sẽ đi vào hư không (exchange mặc định
418
+ không tìm thấy hàng đợi thì bỏ tin, không báo gì).
419
+ """
420
+ if await self._broker.queue_exists(spec.queue):
421
+ log.debug("mq.auto_delete_hoan", queue=spec.queue, hint="còn worker khác đang nghe")
422
+ return
423
+
424
+ phu = [f"{spec.queue}.retry"] if spec.max_retries > 0 else []
425
+ if spec.dead_letter:
426
+ phu.append(f"{spec.queue}.dlq")
427
+ for ten in phu:
428
+ # if_unused=True: hàng đợi phụ vốn không ai nghe, nhưng để broker
429
+ # tự chốt vẫn hơn là tự tin.
430
+ if await self._broker.delete_queue(ten, if_unused=True):
431
+ log.info("mq.queue_deleted", queue=ten, handler=spec.label)
432
+
433
+ def stats(self) -> dict[str, Any]:
434
+ return {
435
+ "consumers": [
436
+ {
437
+ "handler": spec.label,
438
+ "queue": spec.queue,
439
+ "exchange": spec.exchange,
440
+ "routing_key": spec.routing_key,
441
+ "retries": spec.max_retries,
442
+ "retry_delay": spec.retry_delay,
443
+ "dead_letter": spec.dead_letter,
444
+ "durable": spec.durable,
445
+ "auto_delete": spec.auto_delete,
446
+ "running": spec.queue in self._started,
447
+ }
448
+ for spec in self._specs
449
+ ]
450
+ }
@@ -0,0 +1,34 @@
1
+ """Số đo của lớp nhắn tin.
2
+
3
+ Khai ở đây chứ không phải trong `core/metrics.py`: lõi không cần biết dự án có
4
+ dùng hàng đợi hay không. Registry là thứ dùng chung, ai có số đo thì tự đăng ký
5
+ vào — thêm một transport mới (Kafka, MQTT...) cũng chỉ việc thêm file, không
6
+ phải sửa lõi.
7
+
8
+ Nhãn `routing_key` chỉ dùng cho tin ĐĂNG ĐI, nơi tập giá trị do code quyết
9
+ định. Phía nhận dùng nhãn `queue`: mẫu "#" có thể nhận về vô số key khác nhau
10
+ và sẽ làm nổ số chuỗi số đo của Prometheus.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pymodular.core.metrics import Counter, registry
16
+
17
+ rabbitmq_published = registry.register(
18
+ Counter("rabbitmq_published_total", "Số tin đã đăng lên RabbitMQ")
19
+ )
20
+ rabbitmq_publish_failed = registry.register(
21
+ Counter("rabbitmq_publish_failed_total", "Số lần đăng tin thất bại")
22
+ )
23
+ rabbitmq_consumed = registry.register(
24
+ Counter("rabbitmq_consumed_total", "Số tin consumer đã xử lý xong")
25
+ )
26
+ rabbitmq_consume_failed = registry.register(
27
+ Counter("rabbitmq_consume_failed_total", "Số tin consumer xử lý lỗi")
28
+ )
29
+ rabbitmq_retried = registry.register(
30
+ Counter("rabbitmq_retried_total", "Số tin được hẹn xử lý lại")
31
+ )
32
+ rabbitmq_dead_lettered = registry.register(
33
+ Counter("rabbitmq_dead_lettered_total", "Số tin bị đẩy sang hàng đợi chết")
34
+ )
@@ -0,0 +1,64 @@
1
+ """Kiểm tra routing key và mẫu topic trước khi gửi sang RabbitMQ.
2
+
3
+ Chuỗi rác đi thẳng vào lệnh bind hoặc publish sẽ làm hỏng kênh AMQP, kéo theo
4
+ mọi thứ khác đang dùng chung kênh đó. Chặn sớm ở đây rẻ hơn nhiều.
5
+
6
+ Luật topic của AMQP:
7
+
8
+ routing key : các "từ" ngăn bởi dấu chấm — "alert.created.hanoi"
9
+ * : khớp ĐÚNG MỘT từ
10
+ # : khớp KHÔNG hoặc NHIỀU từ
11
+
12
+ alert.* khớp alert.created, không khớp alert.created.hanoi
13
+ alert.# khớp alert, alert.created, alert.created.hanoi
14
+ *.created.* khớp alert.created.hanoi
15
+ # khớp mọi thứ
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ from pymodular.core.exceptions import BadRequestError
23
+
24
+ MAX_PATTERN_LENGTH = 255
25
+
26
+ # Ký tự hợp lệ trong một từ của routing key. Cố ý hẹp: tên có dấu cách hay
27
+ # dấu ngoặc sẽ khiến việc đọc log và đặt binding thành cực hình.
28
+ _WORD = re.compile(r"[A-Za-z0-9_\-]+")
29
+
30
+
31
+ def validate_pattern(pattern: str) -> str:
32
+ """Kiểm tra mẫu do CLIENT gửi lên. Ném BadRequestError nếu sai khuôn.
33
+
34
+ Mẫu đi thẳng vào lệnh bind của RabbitMQ nên không được nhận bừa: chuỗi rác
35
+ sẽ làm hỏng kênh AMQP, kéo theo mọi kết nối khác đang dùng chung kênh đó.
36
+ """
37
+ pattern = pattern.strip()
38
+ if not pattern:
39
+ raise BadRequestError("Mẫu routing key không được để trống")
40
+ if len(pattern) > MAX_PATTERN_LENGTH:
41
+ raise BadRequestError(f"Mẫu dài quá {MAX_PATTERN_LENGTH} ký tự")
42
+
43
+ for word in pattern.split("."):
44
+ if word in ("*", "#"):
45
+ continue
46
+ if not _WORD.fullmatch(word):
47
+ raise BadRequestError(
48
+ f"Từ '{word}' không hợp lệ trong mẫu '{pattern}'. "
49
+ "Chỉ dùng chữ, số, _ và -, ngăn nhau bởi dấu chấm; * cho một từ, # cho nhiều từ."
50
+ )
51
+ return pattern
52
+
53
+
54
+ def validate_routing_key(routing_key: str) -> str:
55
+ """Như trên nhưng cho routing key thật: không được chứa * hay #."""
56
+ routing_key = routing_key.strip()
57
+ if not routing_key:
58
+ raise BadRequestError("Routing key không được để trống")
59
+ if len(routing_key) > MAX_PATTERN_LENGTH:
60
+ raise BadRequestError(f"Routing key dài quá {MAX_PATTERN_LENGTH} ký tự")
61
+ for word in routing_key.split("."):
62
+ if not _WORD.fullmatch(word):
63
+ raise BadRequestError(f"Từ '{word}' không hợp lệ trong routing key '{routing_key}'")
64
+ return routing_key
@@ -0,0 +1,31 @@
1
+ """Lớp Redis — TUỲ CHỌN, và độc lập với mọi thứ khác.
2
+
3
+ pip install 'fastapi-modular[redis]' # cài thư viện + ghi sẵn APP_REDIS__* vào .env
4
+
5
+ Hai việc lớp này làm:
6
+
7
+ cache / khoá-giá trị / đếm RedisClient.get, set, incr, cached, delete_prefix
8
+ phát tin tới mọi worker RedisClient.publish + @redis_subscriber
9
+
10
+ Không dùng thì để `APP_REDIS__ENABLED=false` (mặc định) — không phải cài thư
11
+ viện, không phải sửa dòng code nào.
12
+
13
+ Lưu ý: adapter Redis của WebSocket (`APP_WS__ADAPTER=redis`) là một thứ KHÁC,
14
+ có cấu hình riêng, và không cần lớp này bật lên mới chạy được.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pymodular.infrastructure.redis.client import RedisClient
20
+ from pymodular.infrastructure.redis.pubsub import (
21
+ RedisRunner,
22
+ discover_redis_subscribers,
23
+ redis_subscriber,
24
+ )
25
+
26
+ __all__ = [
27
+ "RedisClient",
28
+ "RedisRunner",
29
+ "discover_redis_subscribers",
30
+ "redis_subscriber",
31
+ ]