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,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
|
+
]
|