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,362 @@
|
|
|
1
|
+
"""Kết nối Redis: cache, khoá/giá trị, đếm, và đăng tin lên kênh pub/sub.
|
|
2
|
+
|
|
3
|
+
Redis là TUỲ CHỌN. `APP_REDIS__ENABLED` mặc định false thì lớp này nằm im:
|
|
4
|
+
không import thư viện, không mở kết nối, không đổi hành vi nào.
|
|
5
|
+
|
|
6
|
+
pip install 'fastapi-modular[redis]' # cài thư viện + ghi sẵn APP_REDIS__* vào .env
|
|
7
|
+
|
|
8
|
+
Lớp này ĐỘC LẬP với adapter Redis của WebSocket (`APP_WS__ADAPTER=redis`). Hai
|
|
9
|
+
thứ dùng chung một loại server nhưng không dùng chung cấu hình, và tắt cái này
|
|
10
|
+
không ảnh hưởng cái kia — giống hệt cách RabbitMQ tách khỏi WebSocket.
|
|
11
|
+
|
|
12
|
+
Về tự nối lại: redis-py giữ một pool và tự mở lại connection ở LỆNH KẾ TIẾP khi
|
|
13
|
+
đứt, nên không cần vòng nối lại cho tình huống mất mạng giữa chừng. Thứ nó
|
|
14
|
+
KHÔNG lo là lúc khởi động mà Redis chưa lên — chỗ đó lớp này tự chạy vòng thử
|
|
15
|
+
lại có backoff, y như RabbitMQ, để app vẫn phục vụ HTTP bình thường.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import contextlib
|
|
22
|
+
import json
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlparse, urlunparse
|
|
26
|
+
|
|
27
|
+
from pymodular.core.config import Settings
|
|
28
|
+
from pymodular.core.container import injectable
|
|
29
|
+
from pymodular.core.exceptions import ComponentNotEnabledError, ServiceUnavailableError
|
|
30
|
+
from pymodular.core.logging import get_logger
|
|
31
|
+
from pymodular.infrastructure.redis.metrics import (
|
|
32
|
+
redis_error,
|
|
33
|
+
redis_hit,
|
|
34
|
+
redis_miss,
|
|
35
|
+
redis_published,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
log = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
DEFAULT_URL = "redis://localhost:6379/0"
|
|
41
|
+
|
|
42
|
+
# Giá trị "không có gì" phải phân biệt được với None đã lưu thật.
|
|
43
|
+
_KHONG_CO = object()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _require_redis() -> Any:
|
|
47
|
+
try:
|
|
48
|
+
import redis.asyncio as redis_asyncio
|
|
49
|
+
except ModuleNotFoundError as exc:
|
|
50
|
+
raise ComponentNotEnabledError(
|
|
51
|
+
"APP_REDIS__ENABLED=true nhưng chưa cài thư viện redis. "
|
|
52
|
+
"Chạy `pip install 'fastapi-modular[redis]'`, hoặc đặt APP_REDIS__ENABLED=false nếu "
|
|
53
|
+
"dự án này không dùng Redis."
|
|
54
|
+
) from exc
|
|
55
|
+
return redis_asyncio
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def safe_url(url: str) -> str:
|
|
59
|
+
"""Che mật khẩu trước khi đưa vào log."""
|
|
60
|
+
parsed = urlparse(url)
|
|
61
|
+
if not parsed.password:
|
|
62
|
+
return url
|
|
63
|
+
netloc = f"{parsed.username or ''}:***@{parsed.hostname}"
|
|
64
|
+
if parsed.port:
|
|
65
|
+
netloc += f":{parsed.port}"
|
|
66
|
+
return urlunparse(parsed._replace(netloc=netloc))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@injectable
|
|
70
|
+
class RedisClient:
|
|
71
|
+
def __init__(self, settings: Settings) -> None:
|
|
72
|
+
self._config = settings.redis
|
|
73
|
+
self._client: Any = None
|
|
74
|
+
self._healthy = False
|
|
75
|
+
self._supervisor: asyncio.Task[None] | None = None
|
|
76
|
+
self._closing = False
|
|
77
|
+
self._ready_hooks: list[Callable[[], Awaitable[None]]] = []
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------- vòng đời
|
|
80
|
+
@property
|
|
81
|
+
def enabled(self) -> bool:
|
|
82
|
+
return self._config.enabled
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def connected(self) -> bool:
|
|
86
|
+
return self._client is not None and self._healthy
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def url(self) -> str:
|
|
90
|
+
return safe_url(self._config.url)
|
|
91
|
+
|
|
92
|
+
def key(self, name: str) -> str:
|
|
93
|
+
"""Khoá thật sự gửi xuống Redis, đã ghép `key_prefix`."""
|
|
94
|
+
return f"{self._config.key_prefix}{name}"
|
|
95
|
+
|
|
96
|
+
async def startup(self) -> None:
|
|
97
|
+
if not self._config.enabled:
|
|
98
|
+
log.debug("redis.disabled")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
redis_asyncio = _require_redis()
|
|
102
|
+
if self._config.url == DEFAULT_URL:
|
|
103
|
+
log.info("redis.default_url", url=DEFAULT_URL, hint="chưa đặt APP_REDIS__URL?")
|
|
104
|
+
|
|
105
|
+
self._closing = False
|
|
106
|
+
self._client = redis_asyncio.from_url(
|
|
107
|
+
self._config.url,
|
|
108
|
+
socket_connect_timeout=self._config.connect_timeout_seconds,
|
|
109
|
+
socket_timeout=self._config.command_timeout_seconds,
|
|
110
|
+
decode_responses=True,
|
|
111
|
+
health_check_interval=30,
|
|
112
|
+
)
|
|
113
|
+
if await self._thu_ping():
|
|
114
|
+
log.info("redis.connected", url=self.url)
|
|
115
|
+
await self._run_hooks()
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
log.warning(
|
|
119
|
+
"redis.starting_degraded",
|
|
120
|
+
url=self.url,
|
|
121
|
+
hint="app vẫn chạy; sẽ nối lại ngầm cho tới khi được",
|
|
122
|
+
)
|
|
123
|
+
self._supervisor = asyncio.create_task(self._reconnect_forever(), name="redis-reconnect")
|
|
124
|
+
|
|
125
|
+
async def _thu_ping(self) -> bool:
|
|
126
|
+
try:
|
|
127
|
+
await self._client.ping()
|
|
128
|
+
except Exception as exc: # noqa: BLE001 - mọi lỗi đều dẫn tới cùng một việc: thử lại
|
|
129
|
+
log.warning("redis.connect_failed", url=self.url, error=f"{type(exc).__name__}: {exc}")
|
|
130
|
+
self._healthy = False
|
|
131
|
+
return False
|
|
132
|
+
self._healthy = True
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
async def _reconnect_forever(self) -> None:
|
|
136
|
+
delay = self._config.reconnect_delay_seconds
|
|
137
|
+
while not self._closing and not self._healthy:
|
|
138
|
+
await asyncio.sleep(delay)
|
|
139
|
+
if self._closing:
|
|
140
|
+
return
|
|
141
|
+
if await self._thu_ping():
|
|
142
|
+
log.info("redis.recovered", url=self.url)
|
|
143
|
+
await self._run_hooks()
|
|
144
|
+
return
|
|
145
|
+
delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
|
|
146
|
+
|
|
147
|
+
def on_ready(self, hook: Callable[[], Awaitable[None]]) -> None:
|
|
148
|
+
"""Việc cần làm lại sau mỗi lần nối được — pub/sub dùng để đăng ký lại kênh."""
|
|
149
|
+
self._ready_hooks.append(hook)
|
|
150
|
+
|
|
151
|
+
async def _run_hooks(self) -> None:
|
|
152
|
+
for hook in list(self._ready_hooks):
|
|
153
|
+
try:
|
|
154
|
+
await hook()
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
log.exception("redis.ready_hook_failed", error=str(exc))
|
|
157
|
+
|
|
158
|
+
async def shutdown(self) -> None:
|
|
159
|
+
self._closing = True
|
|
160
|
+
if self._supervisor is not None:
|
|
161
|
+
self._supervisor.cancel()
|
|
162
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
163
|
+
await self._supervisor
|
|
164
|
+
self._supervisor = None
|
|
165
|
+
if self._client is not None:
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
await self._client.aclose()
|
|
168
|
+
log.info("redis.disconnected")
|
|
169
|
+
self._client = None
|
|
170
|
+
self._healthy = False
|
|
171
|
+
|
|
172
|
+
def _ready(self) -> Any:
|
|
173
|
+
if not self._config.enabled:
|
|
174
|
+
raise ComponentNotEnabledError(
|
|
175
|
+
"Redis đang tắt (APP_REDIS__ENABLED=false) nên không dùng được."
|
|
176
|
+
)
|
|
177
|
+
if self._client is None:
|
|
178
|
+
raise ServiceUnavailableError("Chưa kết nối được Redis")
|
|
179
|
+
return self._client
|
|
180
|
+
|
|
181
|
+
async def _goi(self, ten: str, lam: Callable[[Any], Awaitable[Any]]) -> Any:
|
|
182
|
+
"""Chạy một lệnh, đổi lỗi thư viện thành lỗi của khung.
|
|
183
|
+
|
|
184
|
+
Đánh dấu mất kết nối khi lệnh hỏng: redis-py sẽ tự mở lại ở lệnh kế
|
|
185
|
+
tiếp, nhưng /health/ready cần biết ngay là đang có vấn đề.
|
|
186
|
+
"""
|
|
187
|
+
client = self._ready()
|
|
188
|
+
try:
|
|
189
|
+
ket_qua = await lam(client)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
redis_error.inc(command=ten)
|
|
192
|
+
self._healthy = False
|
|
193
|
+
raise ServiceUnavailableError(f"Redis lỗi khi chạy {ten}: {exc}") from exc
|
|
194
|
+
self._healthy = True
|
|
195
|
+
return ket_qua
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------- khoá/giá trị
|
|
198
|
+
async def get(self, key: str, default: Any = None) -> Any:
|
|
199
|
+
"""Đọc một khoá. Giá trị được giải mã JSON; không có khoá thì trả `default`.
|
|
200
|
+
|
|
201
|
+
Lỗi kết nối thì NÉM lỗi chứ không lặng lẽ trả `default` — trả về mặc
|
|
202
|
+
định khi Redis chết là cách biến một sự cố hạ tầng thành dữ liệu sai.
|
|
203
|
+
Cần "hỏng thì bỏ qua" thì dùng `cached()`.
|
|
204
|
+
"""
|
|
205
|
+
raw = await self._goi("get", lambda c: c.get(self.key(key)))
|
|
206
|
+
return default if raw is None else _giai_ma(raw)
|
|
207
|
+
|
|
208
|
+
async def set(
|
|
209
|
+
self,
|
|
210
|
+
key: str,
|
|
211
|
+
value: Any,
|
|
212
|
+
*,
|
|
213
|
+
ttl: float | None = None,
|
|
214
|
+
if_not_exists: bool = False,
|
|
215
|
+
) -> bool:
|
|
216
|
+
"""Ghi một khoá. Trả về False khi `if_not_exists=True` mà khoá đã có.
|
|
217
|
+
|
|
218
|
+
`ttl=None` nghĩa là KHÔNG BAO GIỜ HẾT HẠN. Với dữ liệu cache thì hầu
|
|
219
|
+
như luôn nên đặt một con số: khoá không hạn chỉ có thể bị xoá bằng tay,
|
|
220
|
+
và Redis đầy RAM là cả hệ thống dừng.
|
|
221
|
+
"""
|
|
222
|
+
return bool(
|
|
223
|
+
await self._goi(
|
|
224
|
+
"set",
|
|
225
|
+
lambda c: c.set(
|
|
226
|
+
self.key(key),
|
|
227
|
+
json.dumps(value, ensure_ascii=False, default=str),
|
|
228
|
+
ex=int(ttl) if ttl else None,
|
|
229
|
+
nx=if_not_exists or None,
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def delete(self, *keys: str) -> int:
|
|
235
|
+
"""Xoá một hoặc nhiều khoá. Trả về số khoá thật sự bị xoá."""
|
|
236
|
+
if not keys:
|
|
237
|
+
return 0
|
|
238
|
+
return int(await self._goi("delete", lambda c: c.delete(*(self.key(k) for k in keys))))
|
|
239
|
+
|
|
240
|
+
async def delete_prefix(self, prefix: str) -> int:
|
|
241
|
+
"""Xoá mọi khoá bắt đầu bằng `prefix` — dùng để dọn một nhóm cache.
|
|
242
|
+
|
|
243
|
+
Duyệt bằng SCAN chứ không phải KEYS: KEYS quét toàn bộ không gian khoá
|
|
244
|
+
trong MỘT lệnh và khoá chặt server suốt lúc đó, trên Redis lớn là đủ để
|
|
245
|
+
làm cả ứng dụng đứng hình.
|
|
246
|
+
"""
|
|
247
|
+
client = self._ready()
|
|
248
|
+
xoa = 0
|
|
249
|
+
try:
|
|
250
|
+
lo: list[str] = []
|
|
251
|
+
async for found in client.scan_iter(match=f"{self.key(prefix)}*", count=500):
|
|
252
|
+
lo.append(found)
|
|
253
|
+
if len(lo) >= 500:
|
|
254
|
+
xoa += int(await client.delete(*lo))
|
|
255
|
+
lo.clear()
|
|
256
|
+
if lo:
|
|
257
|
+
xoa += int(await client.delete(*lo))
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
redis_error.inc(command="scan")
|
|
260
|
+
self._healthy = False
|
|
261
|
+
raise ServiceUnavailableError(f"Redis lỗi khi quét khoá: {exc}") from exc
|
|
262
|
+
self._healthy = True
|
|
263
|
+
return xoa
|
|
264
|
+
|
|
265
|
+
async def exists(self, key: str) -> bool:
|
|
266
|
+
return bool(await self._goi("exists", lambda c: c.exists(self.key(key))))
|
|
267
|
+
|
|
268
|
+
async def ttl(self, key: str) -> int | None:
|
|
269
|
+
"""Còn sống bao nhiêu giây. `None` = không hết hạn, hoặc khoá không tồn tại."""
|
|
270
|
+
con = int(await self._goi("ttl", lambda c: c.ttl(self.key(key))))
|
|
271
|
+
return None if con < 0 else con
|
|
272
|
+
|
|
273
|
+
async def incr(self, key: str, amount: int = 1, *, ttl: float | None = None) -> int:
|
|
274
|
+
"""Cộng dồn nguyên tử. Trả về giá trị sau khi cộng.
|
|
275
|
+
|
|
276
|
+
Nguyên tử ở đây là điểm mấu chốt: đọc-rồi-ghi từ nhiều worker sẽ đếm
|
|
277
|
+
thiếu, còn INCR thì không bao giờ. Dùng cho đếm lượt xem, siết tần suất.
|
|
278
|
+
|
|
279
|
+
`ttl` chỉ được đặt ở lần cộng ĐẦU TIÊN (lúc khoá vừa sinh ra), nên cửa
|
|
280
|
+
sổ đếm không bị gia hạn vô hạn mỗi lần có thêm một lượt.
|
|
281
|
+
"""
|
|
282
|
+
async def lam(c: Any) -> Any:
|
|
283
|
+
moi = await c.incrby(self.key(key), amount)
|
|
284
|
+
if ttl and moi == amount:
|
|
285
|
+
await c.expire(self.key(key), int(ttl))
|
|
286
|
+
return moi
|
|
287
|
+
|
|
288
|
+
return int(await self._goi("incr", lam))
|
|
289
|
+
|
|
290
|
+
# -------------------------------------------------------------- cache
|
|
291
|
+
async def cached(
|
|
292
|
+
self,
|
|
293
|
+
key: str,
|
|
294
|
+
factory: Callable[[], Awaitable[Any]],
|
|
295
|
+
*,
|
|
296
|
+
ttl: float = 60.0,
|
|
297
|
+
) -> Any:
|
|
298
|
+
"""Đọc cache, trượt thì gọi `factory()` rồi ghi lại.
|
|
299
|
+
|
|
300
|
+
Đây là hàm DUY NHẤT trong lớp này CHỊU HỎNG: Redis chết thì nó ghi
|
|
301
|
+
cảnh báo và gọi thẳng `factory()`, request vẫn xong. Đúng vì cache chỉ
|
|
302
|
+
để nhanh hơn — mất cache là chậm đi, không phải sai đi. Mọi hàm khác
|
|
303
|
+
vẫn ném lỗi, vì ở đó Redis là nguồn dữ liệu chứ không phải bộ đệm.
|
|
304
|
+
"""
|
|
305
|
+
try:
|
|
306
|
+
raw = await self._goi("get", lambda c: c.get(self.key(key)))
|
|
307
|
+
except Exception as exc: # noqa: BLE001 - cache hỏng thì đi đường vòng
|
|
308
|
+
log.warning("redis.cache_bypass", key=key, error=str(exc))
|
|
309
|
+
return await factory()
|
|
310
|
+
|
|
311
|
+
if raw is not None:
|
|
312
|
+
redis_hit.inc(key=key)
|
|
313
|
+
return _giai_ma(raw)
|
|
314
|
+
|
|
315
|
+
redis_miss.inc(key=key)
|
|
316
|
+
gia_tri = await factory()
|
|
317
|
+
try:
|
|
318
|
+
await self.set(key, gia_tri, ttl=ttl)
|
|
319
|
+
except Exception as exc: # noqa: BLE001 - tính xong rồi, ghi cache hỏng không sao
|
|
320
|
+
log.warning("redis.cache_write_failed", key=key, error=str(exc))
|
|
321
|
+
return gia_tri
|
|
322
|
+
|
|
323
|
+
# -------------------------------------------------------------- pub/sub
|
|
324
|
+
async def publish(self, channel: str, payload: Any = None) -> int:
|
|
325
|
+
"""Phát một tin lên kênh. Trả về SỐ NGƯỜI NGHE đã nhận.
|
|
326
|
+
|
|
327
|
+
Trả về 0 nghĩa là lúc này không ai nghe, và tin đó **mất luôn**. Redis
|
|
328
|
+
pub/sub không lưu gì, không ack, không thử lại. Cần tin không được mất
|
|
329
|
+
thì dùng RabbitMQ hoặc Kafka, không phải chỗ này.
|
|
330
|
+
"""
|
|
331
|
+
so = int(
|
|
332
|
+
await self._goi(
|
|
333
|
+
"publish",
|
|
334
|
+
lambda c: c.publish(
|
|
335
|
+
self.key(channel), json.dumps(payload, ensure_ascii=False, default=str)
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
redis_published.inc(channel=channel)
|
|
340
|
+
if so == 0:
|
|
341
|
+
log.debug("redis.publish_no_listener", channel=channel)
|
|
342
|
+
return so
|
|
343
|
+
|
|
344
|
+
def raw(self) -> Any:
|
|
345
|
+
"""Client redis-py thô, cho những lệnh khung chưa bọc (ZSET, stream...)."""
|
|
346
|
+
return self._ready()
|
|
347
|
+
|
|
348
|
+
def stats(self) -> dict[str, Any]:
|
|
349
|
+
return {
|
|
350
|
+
"enabled": self._config.enabled,
|
|
351
|
+
"connected": self.connected,
|
|
352
|
+
"url": self.url if self._config.enabled else None,
|
|
353
|
+
"key_prefix": self._config.key_prefix or None,
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _giai_ma(raw: str) -> Any:
|
|
358
|
+
"""Giá trị do người khác ghi vào có thể không phải JSON — trả nguyên chuỗi."""
|
|
359
|
+
try:
|
|
360
|
+
return json.loads(raw)
|
|
361
|
+
except (TypeError, ValueError):
|
|
362
|
+
return raw
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Số đo của lớp Redis. Đăng ký vào registry chung, lõi không cần biết gì."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pymodular.core.metrics import Counter, registry
|
|
6
|
+
|
|
7
|
+
redis_hit = registry.register(Counter("redis_cache_hit_total", "Số lần đọc trúng cache"))
|
|
8
|
+
redis_miss = registry.register(Counter("redis_cache_miss_total", "Số lần đọc trượt cache"))
|
|
9
|
+
redis_error = registry.register(
|
|
10
|
+
Counter("redis_error_total", "Số lệnh Redis lỗi (kể cả lúc cache tự bỏ qua)")
|
|
11
|
+
)
|
|
12
|
+
redis_published = registry.register(
|
|
13
|
+
Counter("redis_published_total", "Số tin đã đăng lên kênh pub/sub")
|
|
14
|
+
)
|
|
15
|
+
redis_received = registry.register(
|
|
16
|
+
Counter("redis_received_total", "Số tin nhận được từ kênh pub/sub")
|
|
17
|
+
)
|
|
18
|
+
redis_handler_failed = registry.register(
|
|
19
|
+
Counter("redis_handler_failed_total", "Số tin handler xử lý lỗi")
|
|
20
|
+
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Nhận tin từ kênh Redis pub/sub — tương đương `@rabbitmq_subscriber` của RabbitMQ.
|
|
2
|
+
|
|
3
|
+
@injectable
|
|
4
|
+
class GiaCaListener:
|
|
5
|
+
@redis_subscriber("gia.*")
|
|
6
|
+
async def doi_gia(self, payload: dict, meta: dict) -> None:
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
Khác RabbitMQ ở một điểm phải nhớ: **pub/sub của Redis không lưu gì cả**. Tin
|
|
10
|
+
phát ra lúc không ai nghe là mất luôn — không hàng đợi, không ack, không thử
|
|
11
|
+
lại, không hàng đợi chết. Nó là loa phát thanh, không phải hộp thư.
|
|
12
|
+
|
|
13
|
+
Vì vậy `@redis_subscriber` cố ý KHÔNG có `max_retries` hay `dead_letter`: bịa
|
|
14
|
+
ra một cơ chế thử lại ở phía client sẽ khiến người dùng tưởng tin được bảo đảm,
|
|
15
|
+
trong khi tin đã mất từ lúc mạng chớp. Tin không được phép mất thì dùng
|
|
16
|
+
RabbitMQ (hàng đợi bền) hoặc Kafka (nhật ký đọc lại được).
|
|
17
|
+
|
|
18
|
+
Ngược lại, mọi worker đang nghe đều nhận MỘT BẢN SAO — đúng thứ cần cho cập
|
|
19
|
+
nhật thời gian thực, xoá cache đồng loạt, thông báo nội bộ.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import contextlib
|
|
26
|
+
import inspect
|
|
27
|
+
import json
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, get_type_hints
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, ValidationError
|
|
33
|
+
|
|
34
|
+
from pymodular.core.config import Settings
|
|
35
|
+
from pymodular.core.container import _REGISTRY, container, injectable, request_scope
|
|
36
|
+
from pymodular.core.context import new_request_id, reset_request_id, set_request_id
|
|
37
|
+
from pymodular.core.logging import get_logger
|
|
38
|
+
from pymodular.infrastructure.redis.client import RedisClient
|
|
39
|
+
from pymodular.infrastructure.redis.metrics import (
|
|
40
|
+
redis_handler_failed,
|
|
41
|
+
redis_received,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
log = get_logger(__name__)
|
|
45
|
+
|
|
46
|
+
_SPEC_ATTR = "__redis_subscriber__"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class RedisSpec:
|
|
51
|
+
channel: str
|
|
52
|
+
cls: type | None = None
|
|
53
|
+
fn: Callable | None = None
|
|
54
|
+
model: type[BaseModel] | None = None
|
|
55
|
+
wants_meta: bool = False
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_pattern(self) -> bool:
|
|
59
|
+
"""Có ký tự đại diện thì phải dùng PSUBSCRIBE thay vì SUBSCRIBE."""
|
|
60
|
+
return any(ch in self.channel for ch in "*?[")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def label(self) -> str:
|
|
64
|
+
return f"{self.cls.__name__}.{self.fn.__name__}" if self.cls and self.fn else self.channel
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def redis_subscriber(channel: str) -> Callable[[Callable], Callable]:
|
|
68
|
+
"""Gắn method vào một kênh Redis.
|
|
69
|
+
|
|
70
|
+
Tham số duy nhất là tên kênh — và đó là chủ ý. Redis pub/sub không có hàng
|
|
71
|
+
đợi để mà bền, không có ack để mà thử lại, nên không có gì khác để chỉnh.
|
|
72
|
+
|
|
73
|
+
Kênh có `*` hoặc `?` thì tự chuyển sang PSUBSCRIBE: "gia.*" nhận mọi kênh
|
|
74
|
+
bắt đầu bằng "gia.". Không có `*` thì khớp đúng tên.
|
|
75
|
+
|
|
76
|
+
`key_prefix` trong cấu hình được ghép vào tên kênh y như với khoá, nên hai
|
|
77
|
+
ứng dụng dùng chung một Redis không nghe nhầm của nhau.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def decorate(fn: Callable) -> Callable:
|
|
81
|
+
if not inspect.iscoroutinefunction(fn):
|
|
82
|
+
raise RuntimeError(f"{fn.__name__} phải là `async def`")
|
|
83
|
+
setattr(fn, _SPEC_ATTR, RedisSpec(channel=channel))
|
|
84
|
+
return fn
|
|
85
|
+
|
|
86
|
+
return decorate
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def discover_redis_subscribers() -> list[RedisSpec]:
|
|
90
|
+
"""Quét mọi provider đã đăng ký để tìm method mang @redis_subscriber."""
|
|
91
|
+
found: list[RedisSpec] = []
|
|
92
|
+
for cls in _REGISTRY.values():
|
|
93
|
+
for fn in vars(cls).values():
|
|
94
|
+
spec: RedisSpec | None = getattr(fn, _SPEC_ATTR, None)
|
|
95
|
+
if spec is None:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
params = list(inspect.signature(fn).parameters.values())[1:]
|
|
99
|
+
if not params or len(params) > 2:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
f"{cls.__name__}.{fn.__name__}: chữ ký phải là "
|
|
102
|
+
"(self, payload) hoặc (self, payload, meta)"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
hints = get_type_hints(fn)
|
|
106
|
+
annotation = hints.get(params[0].name)
|
|
107
|
+
model = (
|
|
108
|
+
annotation
|
|
109
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel)
|
|
110
|
+
else None
|
|
111
|
+
)
|
|
112
|
+
found.append(
|
|
113
|
+
RedisSpec(
|
|
114
|
+
channel=spec.channel,
|
|
115
|
+
cls=cls,
|
|
116
|
+
fn=fn,
|
|
117
|
+
model=model,
|
|
118
|
+
wants_meta=len(params) == 2,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return sorted(found, key=lambda s: s.channel)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@injectable
|
|
125
|
+
class RedisRunner:
|
|
126
|
+
"""Một kết nối pub/sub duy nhất, phục vụ mọi @redis_subscriber."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, client: RedisClient, settings: Settings) -> None:
|
|
129
|
+
self._client = client
|
|
130
|
+
self._config = settings.redis
|
|
131
|
+
self._specs: list[RedisSpec] = []
|
|
132
|
+
self._pubsub: Any = None
|
|
133
|
+
self._task: asyncio.Task[None] | None = None
|
|
134
|
+
self._closing = False
|
|
135
|
+
|
|
136
|
+
async def startup(self) -> None:
|
|
137
|
+
if not self._config.enabled:
|
|
138
|
+
return
|
|
139
|
+
self._specs = discover_redis_subscribers()
|
|
140
|
+
if not self._specs:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
self._closing = False
|
|
144
|
+
# Chạy lại sau MỖI lần nối được — kể cả lần đầu xảy ra muộn vì Redis
|
|
145
|
+
# chưa lên lúc app khởi động.
|
|
146
|
+
self._client.on_ready(self._setup)
|
|
147
|
+
if self._client.connected:
|
|
148
|
+
await self._setup()
|
|
149
|
+
|
|
150
|
+
async def _setup(self) -> None:
|
|
151
|
+
if self._task is not None and not self._task.done():
|
|
152
|
+
return # đã có vòng đọc đang chạy, đừng nhân đôi
|
|
153
|
+
self._task = asyncio.create_task(self._vong_doc(), name="redis-pubsub")
|
|
154
|
+
|
|
155
|
+
async def _vong_doc(self) -> None:
|
|
156
|
+
"""Đăng ký kênh rồi đọc mãi. Đứt thì tự đăng ký lại từ đầu.
|
|
157
|
+
|
|
158
|
+
Vòng nối lại nằm ở đây chứ không dựa vào redis-py: pool của nó tự mở
|
|
159
|
+
lại connection cho LỆNH kế tiếp, nhưng một pubsub đứt thì mất luôn danh
|
|
160
|
+
sách kênh đã đăng ký — đọc tiếp sẽ không bao giờ có tin nào nữa.
|
|
161
|
+
"""
|
|
162
|
+
delay = self._config.reconnect_delay_seconds
|
|
163
|
+
while not self._closing:
|
|
164
|
+
try:
|
|
165
|
+
await self._nghe()
|
|
166
|
+
delay = self._config.reconnect_delay_seconds
|
|
167
|
+
except asyncio.CancelledError:
|
|
168
|
+
raise
|
|
169
|
+
except Exception as exc: # noqa: BLE001 - đứt kiểu gì cũng đăng ký lại
|
|
170
|
+
if self._closing:
|
|
171
|
+
return
|
|
172
|
+
log.warning("redis.pubsub_lost", error=f"{type(exc).__name__}: {exc}", retry=delay)
|
|
173
|
+
await asyncio.sleep(delay)
|
|
174
|
+
delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
|
|
175
|
+
|
|
176
|
+
async def _nghe(self) -> None:
|
|
177
|
+
pubsub = self._client.raw().pubsub(ignore_subscribe_messages=True)
|
|
178
|
+
self._pubsub = pubsub
|
|
179
|
+
try:
|
|
180
|
+
kenh = [s for s in self._specs if not s.is_pattern]
|
|
181
|
+
mau = [s for s in self._specs if s.is_pattern]
|
|
182
|
+
if kenh:
|
|
183
|
+
await pubsub.subscribe(*{self._client.key(s.channel) for s in kenh})
|
|
184
|
+
if mau:
|
|
185
|
+
await pubsub.psubscribe(*{self._client.key(s.channel) for s in mau})
|
|
186
|
+
log.info(
|
|
187
|
+
"redis.pubsub_started",
|
|
188
|
+
channels=sorted({s.channel for s in kenh}),
|
|
189
|
+
patterns=sorted({s.channel for s in mau}),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async for message in pubsub.listen():
|
|
193
|
+
if self._closing:
|
|
194
|
+
return
|
|
195
|
+
if message.get("type") not in ("message", "pmessage"):
|
|
196
|
+
continue
|
|
197
|
+
await self._giao(message)
|
|
198
|
+
finally:
|
|
199
|
+
with contextlib.suppress(Exception):
|
|
200
|
+
await pubsub.aclose()
|
|
201
|
+
self._pubsub = None
|
|
202
|
+
|
|
203
|
+
async def _giao(self, message: dict) -> None:
|
|
204
|
+
kenh = str(message["channel"])
|
|
205
|
+
mau = message.get("pattern")
|
|
206
|
+
for spec in self._specs:
|
|
207
|
+
dich = self._client.key(spec.channel)
|
|
208
|
+
khop = dich == str(mau) if mau else dich == kenh
|
|
209
|
+
if khop:
|
|
210
|
+
await self._chay(spec, kenh, message["data"])
|
|
211
|
+
|
|
212
|
+
async def _chay(self, spec: RedisSpec, kenh: str, raw: Any) -> None:
|
|
213
|
+
redis_received.inc(channel=spec.channel)
|
|
214
|
+
token = set_request_id(new_request_id())
|
|
215
|
+
try:
|
|
216
|
+
async with request_scope():
|
|
217
|
+
payload: Any = json.loads(raw)
|
|
218
|
+
if spec.model is not None:
|
|
219
|
+
try:
|
|
220
|
+
payload = spec.model.model_validate(payload)
|
|
221
|
+
except ValidationError as exc:
|
|
222
|
+
# Không có DLQ để mà đẩy vào: ghi log rồi bỏ tin. Đây
|
|
223
|
+
# là cái giá của pub/sub, nói thẳng ra hơn là giấu đi.
|
|
224
|
+
log.error("redis.payload_invalid", handler=spec.label, error=str(exc))
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
instance = container.resolve(spec.cls) # type: ignore[arg-type]
|
|
228
|
+
if spec.wants_meta:
|
|
229
|
+
meta = {"channel": kenh, "pattern": spec.channel if spec.is_pattern else None}
|
|
230
|
+
await spec.fn(instance, payload, meta) # type: ignore[misc]
|
|
231
|
+
else:
|
|
232
|
+
await spec.fn(instance, payload) # type: ignore[misc]
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
# Một handler hỏng không được làm đứt vòng đọc của mọi handler khác.
|
|
235
|
+
redis_handler_failed.inc(channel=spec.channel)
|
|
236
|
+
log.exception("redis.handler_failed", handler=spec.label, error=str(exc))
|
|
237
|
+
finally:
|
|
238
|
+
reset_request_id(token)
|
|
239
|
+
|
|
240
|
+
async def shutdown(self) -> None:
|
|
241
|
+
self._closing = True
|
|
242
|
+
if self._pubsub is not None:
|
|
243
|
+
with contextlib.suppress(Exception):
|
|
244
|
+
await self._pubsub.aclose()
|
|
245
|
+
if self._task is not None:
|
|
246
|
+
self._task.cancel()
|
|
247
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
248
|
+
await self._task
|
|
249
|
+
self._task = None
|
|
250
|
+
|
|
251
|
+
def stats(self) -> dict[str, Any]:
|
|
252
|
+
return {
|
|
253
|
+
"listeners": [
|
|
254
|
+
{
|
|
255
|
+
"handler": spec.label,
|
|
256
|
+
"channel": spec.channel,
|
|
257
|
+
"pattern": spec.is_pattern,
|
|
258
|
+
}
|
|
259
|
+
for spec in self._specs
|
|
260
|
+
],
|
|
261
|
+
"running": self._task is not None and not self._task.done(),
|
|
262
|
+
}
|
|
File without changes
|