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