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,231 @@
1
+ """Kết nối Kafka: gửi tin có xác nhận, tự nối lại, không chặn lúc khởi động.
2
+
3
+ Kafka là TUỲ CHỌN. `APP_KAFKA__ENABLED` mặc định false thì lớp này nằm im.
4
+
5
+ pip install 'fastapi-modular[kafka]' # cài aiokafka + ghi sẵn APP_KAFKA__* vào .env
6
+
7
+ Kafka khác RabbitMQ ở chỗ căn bản: tin KHÔNG mất đi sau khi được xử lý. Nó nằm
8
+ lại trong nhật ký theo thời gian giữ (retention) của topic, và mỗi nhóm
9
+ consumer có con trỏ đọc riêng. Nhờ vậy thêm một nhóm mới là đọc lại được cả
10
+ lịch sử — thứ hàng đợi không làm được. Cái giá: không có "hàng đợi thử lại",
11
+ không xoá được một tin lẻ, và thứ tự chỉ bảo đảm TRONG MỘT phân vùng.
12
+
13
+ Về tự nối lại: aiokafka tự tìm lại broker khi cụm chuyển leader hoặc rớt giữa
14
+ chừng. Thứ nó không lo là lúc khởi động mà cụm chưa lên — `producer.start()`
15
+ ném lỗi ngay. Chỗ đó lớp này chạy vòng thử lại có backoff, y như RabbitMQ, để
16
+ app vẫn phục vụ HTTP bình thường.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import json
24
+ from collections.abc import Awaitable, Callable
25
+ from typing import Any
26
+
27
+ from pymodular.core.compat import TimeoutErrors
28
+ from pymodular.core.config import Settings
29
+ from pymodular.core.container import injectable
30
+ from pymodular.core.exceptions import ComponentNotEnabledError, ServiceUnavailableError
31
+ from pymodular.core.logging import get_logger
32
+ from pymodular.infrastructure.kafka.metrics import kafka_publish_failed, kafka_published
33
+
34
+ log = get_logger(__name__)
35
+
36
+ DEFAULT_SERVERS = "localhost:9092"
37
+
38
+
39
+ def _require_aiokafka() -> Any:
40
+ try:
41
+ import aiokafka
42
+ except ModuleNotFoundError as exc:
43
+ raise ComponentNotEnabledError(
44
+ "APP_KAFKA__ENABLED=true nhưng chưa cài thư viện aiokafka. "
45
+ "Chạy `pip install 'fastapi-modular[kafka]'`, hoặc đặt APP_KAFKA__ENABLED=false nếu "
46
+ "dự án này không dùng Kafka."
47
+ ) from exc
48
+ return aiokafka
49
+
50
+
51
+ @injectable
52
+ class KafkaBroker:
53
+ def __init__(self, settings: Settings) -> None:
54
+ self._config = settings.kafka
55
+ self._producer: Any = None
56
+ self._supervisor: asyncio.Task[None] | None = None
57
+ self._closing = False
58
+ self._ready_hooks: list[Callable[[], Awaitable[None]]] = []
59
+
60
+ # ------------------------------------------------------------- vòng đời
61
+ @property
62
+ def enabled(self) -> bool:
63
+ return self._config.enabled
64
+
65
+ @property
66
+ def connected(self) -> bool:
67
+ return self._producer is not None
68
+
69
+ @property
70
+ def servers(self) -> str:
71
+ return self._config.bootstrap_servers
72
+
73
+ async def startup(self) -> None:
74
+ if not self._config.enabled:
75
+ log.debug("kafka.disabled")
76
+ return
77
+
78
+ _require_aiokafka()
79
+ self._closing = False
80
+ if await self._thu_noi():
81
+ return
82
+
83
+ log.warning(
84
+ "kafka.starting_degraded",
85
+ servers=self.servers,
86
+ hint="app vẫn chạy; sẽ nối lại ngầm cho tới khi được",
87
+ )
88
+ self._supervisor = asyncio.create_task(self._reconnect_forever(), name="kafka-reconnect")
89
+
90
+ async def _thu_noi(self) -> bool:
91
+ aiokafka = _require_aiokafka()
92
+ producer = aiokafka.AIOKafkaProducer(
93
+ bootstrap_servers=self._config.bootstrap_servers,
94
+ client_id=self._config.client_id,
95
+ acks=self._config.acks if self._config.acks != "all" else "all",
96
+ request_timeout_ms=int(self._config.request_timeout_seconds * 1000),
97
+ # Không bật idempotence mặc định: nó đòi acks=all và bản Kafka đủ
98
+ # mới; bật ngầm sẽ làm cụm cũ từ chối kết nối mà không rõ vì sao.
99
+ )
100
+ try:
101
+ await asyncio.wait_for(producer.start(), self._config.connect_timeout_seconds)
102
+ except Exception as exc: # noqa: BLE001 - mọi lỗi đều dẫn tới cùng một việc: thử lại
103
+ with contextlib.suppress(Exception):
104
+ await producer.stop()
105
+ log.warning(
106
+ "kafka.connect_failed",
107
+ servers=self.servers,
108
+ error=f"{type(exc).__name__}: {exc}",
109
+ )
110
+ return False
111
+
112
+ self._producer = producer
113
+ log.info("kafka.connected", servers=self.servers, client_id=self._config.client_id)
114
+ await self._run_hooks()
115
+ return True
116
+
117
+ async def _reconnect_forever(self) -> None:
118
+ delay = self._config.reconnect_delay_seconds
119
+ while not self._closing and self._producer is None:
120
+ await asyncio.sleep(delay)
121
+ if self._closing:
122
+ return
123
+ if await self._thu_noi():
124
+ log.info("kafka.recovered", servers=self.servers)
125
+ return
126
+ delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
127
+
128
+ def on_ready(self, hook: Callable[[], Awaitable[None]]) -> None:
129
+ """Việc cần làm sau mỗi lần nối được — consumer dùng để bật vòng đọc."""
130
+ self._ready_hooks.append(hook)
131
+
132
+ async def _run_hooks(self) -> None:
133
+ for hook in list(self._ready_hooks):
134
+ try:
135
+ await hook()
136
+ except Exception as exc:
137
+ log.exception("kafka.ready_hook_failed", error=str(exc))
138
+
139
+ async def shutdown(self) -> None:
140
+ self._closing = True
141
+ if self._supervisor is not None:
142
+ self._supervisor.cancel()
143
+ with contextlib.suppress(asyncio.CancelledError):
144
+ await self._supervisor
145
+ self._supervisor = None
146
+ if self._producer is not None:
147
+ with contextlib.suppress(Exception):
148
+ await self._producer.stop()
149
+ log.info("kafka.disconnected")
150
+ self._producer = None
151
+
152
+ def _ready(self) -> Any:
153
+ if not self._config.enabled:
154
+ raise ComponentNotEnabledError(
155
+ "Kafka đang tắt (APP_KAFKA__ENABLED=false) nên không gửi tin được."
156
+ )
157
+ if self._producer is None:
158
+ raise ServiceUnavailableError("Chưa kết nối được Kafka")
159
+ return self._producer
160
+
161
+ # ---------------------------------------------------------------- gửi
162
+ async def publish(
163
+ self,
164
+ topic: str,
165
+ payload: Any = None,
166
+ *,
167
+ key: str | None = None,
168
+ headers: dict[str, str] | None = None,
169
+ timeout: float | None = None,
170
+ fire_and_forget: bool = False,
171
+ ) -> bool:
172
+ """Gửi một tin và CHỜ cụm xác nhận. Trả về True nếu đã ghi xong.
173
+
174
+ `key` quyết định phân vùng: mọi tin cùng key rơi vào cùng một phân vùng,
175
+ nên chúng được xử lý ĐÚNG THỨ TỰ. Không có key thì tin rải đều và thứ tự
176
+ giữa chúng không còn bảo đảm gì. Quy tắc thực dụng: lấy id của thực thể
177
+ làm key (`key=order_id`) khi thứ tự có ý nghĩa.
178
+
179
+ Mức bảo đảm ghi lấy theo `APP_KAFKA__ACKS` — đây là thuộc tính của cụm,
180
+ không phải quyết định của từng lời gọi.
181
+ """
182
+ if not self._config.enabled:
183
+ raise ComponentNotEnabledError(
184
+ "Kafka đang tắt (APP_KAFKA__ENABLED=false) nên không gửi tin được."
185
+ )
186
+ if self._producer is None:
187
+ kafka_publish_failed.inc(topic=topic)
188
+ if fire_and_forget:
189
+ log.warning("kafka.publish_skipped", topic=topic, reason="chưa kết nối")
190
+ return False
191
+ raise ServiceUnavailableError("Chưa kết nối được Kafka")
192
+
193
+ body = payload if isinstance(payload, bytes) else json.dumps(
194
+ payload, ensure_ascii=False, default=str
195
+ ).encode()
196
+ try:
197
+ await asyncio.wait_for(
198
+ self._producer.send_and_wait(
199
+ topic,
200
+ value=body,
201
+ key=key.encode() if key else None,
202
+ headers=[(k, str(v).encode()) for k, v in (headers or {}).items()] or None,
203
+ ),
204
+ timeout or self._config.request_timeout_seconds,
205
+ )
206
+ except Exception as exc:
207
+ kafka_publish_failed.inc(topic=topic)
208
+ if fire_and_forget:
209
+ log.warning(
210
+ "kafka.publish_failed",
211
+ topic=topic,
212
+ error=f"{type(exc).__name__}: {exc}",
213
+ )
214
+ return False
215
+ if isinstance(exc, TimeoutErrors):
216
+ raise ServiceUnavailableError(
217
+ f"Kafka không xác nhận trong {timeout or self._config.request_timeout_seconds}s"
218
+ ) from exc
219
+ raise
220
+
221
+ kafka_published.inc(topic=topic)
222
+ log.debug("kafka.published", topic=topic, key=key)
223
+ return True
224
+
225
+ def stats(self) -> dict[str, Any]:
226
+ return {
227
+ "enabled": self._config.enabled,
228
+ "connected": self.connected,
229
+ "servers": self.servers if self._config.enabled else None,
230
+ "acks": self._config.acks,
231
+ }
@@ -0,0 +1,371 @@
1
+ """Consumer Kafka — `@kafka_subscriber`, tương đương `@rabbitmq_subscriber` của RabbitMQ.
2
+
3
+ @injectable
4
+ class DonHangConsumer:
5
+ @kafka_subscriber("don-hang", group="kho-van")
6
+ async def xu_ly(self, payload: DonHang, meta: dict) -> None:
7
+ ...
8
+
9
+ `group` là BẮT BUỘC và cố ý không tự sinh, giống `queue` bên RabbitMQ: nhóm
10
+ chính là danh tính của con trỏ đọc. Tên tự sinh sẽ đổi sau mỗi lần deploy, và
11
+ mỗi lần deploy sẽ đọc lại từ đầu (hoặc bỏ qua sạch phần cũ, tuỳ
12
+ `auto_offset_reset`).
13
+
14
+ Ba điều khác RabbitMQ, phải nắm trước khi dùng:
15
+
16
+ 1. **Thử lại làm ĐỨNG phân vùng.** Kafka không cho ack lẻ từng tin: con trỏ đọc
17
+ chỉ tiến lên. Nên thử lại tin thứ 5 nghĩa là tin thứ 6, 7, 8... phải chờ.
18
+ Đó là cái giá của việc giữ đúng thứ tự, và cũng là lý do `retry_delay` ở đây
19
+ nên nhỏ hơn nhiều so với bên RabbitMQ.
20
+
21
+ 2. **Không xoá được một tin.** Tin lỗi được sao sang topic `<topic>.dlt` rồi con
22
+ trỏ đi tiếp; bản gốc vẫn nằm trong nhật ký cho tới khi hết hạn giữ.
23
+
24
+ 3. **Số worker chạy song song bị chặn bởi số phân vùng.** Topic một phân vùng
25
+ thì chạy mười worker cũng chỉ một worker có việc.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import contextlib
32
+ import inspect
33
+ import json
34
+ from collections.abc import Callable
35
+ from dataclasses import dataclass
36
+ from typing import Any, get_type_hints
37
+
38
+ from pydantic import BaseModel, ValidationError
39
+
40
+ from pymodular.core.config import Settings
41
+ from pymodular.core.container import _REGISTRY, container, injectable, request_scope
42
+ from pymodular.core.context import new_request_id, reset_request_id, set_request_id
43
+ from pymodular.core.logging import get_logger
44
+ from pymodular.infrastructure.kafka.broker import KafkaBroker, _require_aiokafka
45
+ from pymodular.infrastructure.kafka.metrics import (
46
+ kafka_consume_failed,
47
+ kafka_consumed,
48
+ kafka_dead_lettered,
49
+ )
50
+
51
+ log = get_logger(__name__)
52
+
53
+ _SPEC_ATTR = "__kafka_subscriber__"
54
+
55
+
56
+ class PermanentMessageError(Exception):
57
+ """Tin này sai vĩnh viễn — bỏ qua mọi lượt thử, đẩy thẳng sang `<topic>.dlt`."""
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class KafkaSpec:
62
+ topic: str
63
+ group: str
64
+ auto_offset_reset: str = "latest"
65
+ max_retries: int = 3
66
+ retry_delay: float = 1.0
67
+ dead_letter: bool = True
68
+ cls: type | None = None
69
+ fn: Callable | None = None
70
+ model: type[BaseModel] | None = None
71
+ wants_meta: bool = False
72
+
73
+ @property
74
+ def label(self) -> str:
75
+ return f"{self.cls.__name__}.{self.fn.__name__}" if self.cls and self.fn else self.topic
76
+
77
+ @property
78
+ def dlt(self) -> str:
79
+ return f"{self.topic}.dlt"
80
+
81
+
82
+ def kafka_subscriber(
83
+ topic: str,
84
+ *,
85
+ group: str,
86
+ auto_offset_reset: str = "latest",
87
+ max_retries: int = 3,
88
+ retry_delay: float = 1.0,
89
+ dead_letter: bool = True,
90
+ ) -> Callable[[Callable], Callable]:
91
+ """Gắn method vào một topic Kafka, đọc dưới danh nghĩa nhóm `group`.
92
+
93
+ group BẮT BUỘC — danh tính con trỏ đọc. Nhiều worker cùng
94
+ group thì CHIA NHAU phân vùng; khác group thì mỗi
95
+ bên nhận đủ một bản sao của mọi tin.
96
+ auto_offset_reset nhóm MỚI (chưa có con trỏ) bắt đầu từ đâu:
97
+ "latest" = chỉ tin phát sinh từ giờ trở đi (mặc
98
+ định, an toàn) | "earliest" = đọc lại từ đầu nhật
99
+ ký, có thể là hàng triệu tin. Nhóm đã có con trỏ thì
100
+ tham số này KHÔNG có tác dụng.
101
+ max_retries thử lại mấy lần trước khi bỏ sang <topic>.dlt.
102
+ Nhớ: thử lại làm đứng cả phân vùng.
103
+ retry_delay chờ giữa các lần thử (giây). Để nhỏ.
104
+ dead_letter False = tin lỗi bị BỎ QUA hẳn, con trỏ vẫn đi tiếp.
105
+ """
106
+ if not group:
107
+ raise ValueError("kafka_subscriber cần `group` — xem docstring")
108
+ if auto_offset_reset not in ("latest", "earliest"):
109
+ raise ValueError("auto_offset_reset phải là 'latest' hoặc 'earliest'")
110
+
111
+ def decorate(fn: Callable) -> Callable:
112
+ if not inspect.iscoroutinefunction(fn):
113
+ raise RuntimeError(f"{fn.__name__} phải là `async def`")
114
+ setattr(
115
+ fn,
116
+ _SPEC_ATTR,
117
+ KafkaSpec(
118
+ topic=topic,
119
+ group=group,
120
+ auto_offset_reset=auto_offset_reset,
121
+ max_retries=max_retries,
122
+ retry_delay=retry_delay,
123
+ dead_letter=dead_letter,
124
+ ),
125
+ )
126
+ return fn
127
+
128
+ return decorate
129
+
130
+
131
+ def discover_kafka_subscribers() -> list[KafkaSpec]:
132
+ """Quét mọi provider đã đăng ký để tìm method mang @kafka_subscriber."""
133
+ found: list[KafkaSpec] = []
134
+ for cls in _REGISTRY.values():
135
+ for fn in vars(cls).values():
136
+ spec: KafkaSpec | None = getattr(fn, _SPEC_ATTR, None)
137
+ if spec is None:
138
+ continue
139
+
140
+ params = list(inspect.signature(fn).parameters.values())[1:]
141
+ if not params or len(params) > 2:
142
+ raise RuntimeError(
143
+ f"{cls.__name__}.{fn.__name__}: chữ ký phải là "
144
+ "(self, payload) hoặc (self, payload, meta)"
145
+ )
146
+
147
+ hints = get_type_hints(fn)
148
+ annotation = hints.get(params[0].name)
149
+ model = (
150
+ annotation
151
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel)
152
+ else None
153
+ )
154
+ found.append(
155
+ KafkaSpec(
156
+ topic=spec.topic,
157
+ group=spec.group,
158
+ auto_offset_reset=spec.auto_offset_reset,
159
+ max_retries=spec.max_retries,
160
+ retry_delay=spec.retry_delay,
161
+ dead_letter=spec.dead_letter,
162
+ cls=cls,
163
+ fn=fn,
164
+ model=model,
165
+ wants_meta=len(params) == 2,
166
+ )
167
+ )
168
+ return sorted(found, key=lambda s: (s.group, s.topic))
169
+
170
+
171
+ @injectable
172
+ class KafkaRunner:
173
+ """Một AIOKafkaConsumer cho mỗi @kafka_subscriber, mỗi cái một task."""
174
+
175
+ def __init__(self, broker: KafkaBroker, settings: Settings) -> None:
176
+ self._broker = broker
177
+ self._config = settings.kafka
178
+ self._specs: list[KafkaSpec] = []
179
+ self._tasks: dict[str, asyncio.Task[None]] = {}
180
+ self._closing = False
181
+
182
+ async def startup(self) -> None:
183
+ if not self._config.enabled:
184
+ return
185
+ self._specs = discover_kafka_subscribers()
186
+ if not self._specs:
187
+ return
188
+
189
+ self._closing = False
190
+ self._broker.on_ready(self._setup)
191
+ if self._broker.connected:
192
+ await self._setup()
193
+
194
+ async def _setup(self) -> None:
195
+ """Idempotent: gọi lại bao nhiêu lần cũng không sinh consumer trùng."""
196
+ for spec in self._specs:
197
+ khoa = f"{spec.group}:{spec.topic}"
198
+ cu = self._tasks.get(khoa)
199
+ if cu is not None and not cu.done():
200
+ continue
201
+ self._tasks[khoa] = asyncio.create_task(self._vong_doc(spec), name=f"kafka-{khoa}")
202
+
203
+ async def _vong_doc(self, spec: KafkaSpec) -> None:
204
+ """Đọc mãi. Consumer chết vì bất cứ lý do gì thì dựng lại, có backoff.
205
+
206
+ Mỗi spec một consumer RIÊNG: dùng chung một consumer cho nhiều nhóm là
207
+ không thể (nhóm gắn với con trỏ đọc), và một topic khai sai không được
208
+ phép kéo các topic khác chết theo.
209
+ """
210
+ aiokafka = _require_aiokafka()
211
+ delay = self._config.reconnect_delay_seconds
212
+ while not self._closing:
213
+ consumer = aiokafka.AIOKafkaConsumer(
214
+ spec.topic,
215
+ bootstrap_servers=self._config.bootstrap_servers,
216
+ group_id=spec.group,
217
+ client_id=self._config.client_id,
218
+ auto_offset_reset=spec.auto_offset_reset,
219
+ # Tự commit theo đồng hồ sẽ commit cả những tin CHƯA xử lý xong
220
+ # — tiến trình chết đúng lúc đó là mất tin. Commit tay sau khi
221
+ # handler xong cho ngữ nghĩa "ít nhất một lần".
222
+ enable_auto_commit=False,
223
+ )
224
+ try:
225
+ await consumer.start()
226
+ log.info(
227
+ "kafka.consumer_started",
228
+ handler=spec.label,
229
+ topic=spec.topic,
230
+ group=spec.group,
231
+ tu=spec.auto_offset_reset,
232
+ )
233
+ delay = self._config.reconnect_delay_seconds
234
+ async for message in consumer:
235
+ if self._closing:
236
+ break
237
+ await self._xu_ly(spec, message)
238
+ await consumer.commit()
239
+ except asyncio.CancelledError:
240
+ raise
241
+ except Exception as exc: # noqa: BLE001 - hỏng kiểu gì cũng dựng lại
242
+ if self._closing:
243
+ return
244
+ log.warning(
245
+ "kafka.consumer_lost",
246
+ handler=spec.label,
247
+ error=f"{type(exc).__name__}: {exc}",
248
+ retry=delay,
249
+ )
250
+ finally:
251
+ with contextlib.suppress(Exception):
252
+ await consumer.stop()
253
+
254
+ if self._closing:
255
+ return
256
+ await asyncio.sleep(delay)
257
+ delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
258
+
259
+ async def _xu_ly(self, spec: KafkaSpec, message: Any) -> None:
260
+ """Chạy handler, thử lại tại chỗ, hết lượt thì sang <topic>.dlt.
261
+
262
+ Hàm này KHÔNG được ném lỗi ra ngoài: ném là vòng đọc đứt và cả phân
263
+ vùng dừng lại vì một tin hỏng.
264
+ """
265
+ for lan in range(1, spec.max_retries + 2):
266
+ try:
267
+ await self._goi(spec, message, lan)
268
+ except PermanentMessageError as exc:
269
+ log.error("kafka.permanent_error", handler=spec.label, error=str(exc))
270
+ await self._bo_sang_dlt(spec, message, exc)
271
+ return
272
+ except Exception as exc:
273
+ kafka_consume_failed.inc(topic=spec.topic)
274
+ log.exception(
275
+ "kafka.handler_failed",
276
+ handler=spec.label,
277
+ topic=spec.topic,
278
+ partition=message.partition,
279
+ offset=message.offset,
280
+ attempt=lan,
281
+ error=str(exc),
282
+ )
283
+ if lan > spec.max_retries:
284
+ await self._bo_sang_dlt(spec, message, exc)
285
+ return
286
+ await asyncio.sleep(spec.retry_delay)
287
+ else:
288
+ kafka_consumed.inc(topic=spec.topic)
289
+ return
290
+
291
+ async def _goi(self, spec: KafkaSpec, message: Any, lan: int) -> None:
292
+ token = set_request_id(new_request_id())
293
+ try:
294
+ async with request_scope():
295
+ payload: Any = json.loads(message.value)
296
+ if spec.model is not None:
297
+ try:
298
+ payload = spec.model.model_validate(payload)
299
+ except ValidationError as exc:
300
+ raise PermanentMessageError(f"Payload không hợp lệ: {exc}") from exc
301
+
302
+ instance = container.resolve(spec.cls) # type: ignore[arg-type]
303
+ if spec.wants_meta:
304
+ meta = {
305
+ "topic": message.topic,
306
+ "partition": message.partition,
307
+ "offset": message.offset,
308
+ "key": message.key.decode() if message.key else None,
309
+ "timestamp": message.timestamp,
310
+ "attempt": lan,
311
+ }
312
+ await spec.fn(instance, payload, meta) # type: ignore[misc]
313
+ else:
314
+ await spec.fn(instance, payload) # type: ignore[misc]
315
+ finally:
316
+ reset_request_id(token)
317
+
318
+ async def _bo_sang_dlt(self, spec: KafkaSpec, message: Any, error: BaseException) -> None:
319
+ kafka_dead_lettered.inc(topic=spec.topic)
320
+ if not spec.dead_letter:
321
+ log.error(
322
+ "kafka.message_dropped",
323
+ handler=spec.label,
324
+ offset=message.offset,
325
+ hint="consumer khai dead_letter=False nên tin bị bỏ qua, không lưu lại",
326
+ )
327
+ return
328
+
329
+ try:
330
+ await self._broker.publish(
331
+ spec.dlt,
332
+ json.loads(message.value),
333
+ key=message.key.decode() if message.key else None,
334
+ headers={
335
+ "x-original-topic": message.topic,
336
+ "x-original-partition": str(message.partition),
337
+ "x-original-offset": str(message.offset),
338
+ "x-error": f"{type(error).__name__}: {error}"[:500],
339
+ },
340
+ fire_and_forget=True,
341
+ )
342
+ log.error("kafka.dead_lettered", handler=spec.label, topic=spec.dlt)
343
+ except Exception as exc: # không cứu được thì cũng đừng chặn phân vùng
344
+ log.exception("kafka.dlt_publish_failed", handler=spec.label, error=str(exc))
345
+
346
+ async def shutdown(self) -> None:
347
+ self._closing = True
348
+ for task in self._tasks.values():
349
+ task.cancel()
350
+ for task in self._tasks.values():
351
+ with contextlib.suppress(asyncio.CancelledError, Exception):
352
+ await task
353
+ self._tasks.clear()
354
+
355
+ def stats(self) -> dict[str, Any]:
356
+ return {
357
+ "consumers": [
358
+ {
359
+ "handler": spec.label,
360
+ "topic": spec.topic,
361
+ "group": spec.group,
362
+ "retries": spec.max_retries,
363
+ "dead_letter": spec.dlt if spec.dead_letter else None,
364
+ "running": (
365
+ (task := self._tasks.get(f"{spec.group}:{spec.topic}")) is not None
366
+ and not task.done()
367
+ ),
368
+ }
369
+ for spec in self._specs
370
+ ]
371
+ }
@@ -0,0 +1,17 @@
1
+ """Số đo của lớp Kafka."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pymodular.core.metrics import Counter, registry
6
+
7
+ kafka_published = registry.register(Counter("kafka_published_total", "Số tin đã gửi lên Kafka"))
8
+ kafka_publish_failed = registry.register(
9
+ Counter("kafka_publish_failed_total", "Số lần gửi tin thất bại")
10
+ )
11
+ kafka_consumed = registry.register(Counter("kafka_consumed_total", "Số tin đã xử lý xong"))
12
+ kafka_consume_failed = registry.register(
13
+ Counter("kafka_consume_failed_total", "Số lần handler xử lý lỗi (tính cả lần thử lại)")
14
+ )
15
+ kafka_dead_lettered = registry.register(
16
+ Counter("kafka_dead_lettered_total", "Số tin bị đẩy sang topic chết")
17
+ )
@@ -0,0 +1,35 @@
1
+ """Lớp MQTT — TUỲ CHỌN, và độc lập với mọi thứ khác.
2
+
3
+ pip install 'fastapi-modular[mqtt]' # cài aiomqtt + ghi sẵn APP_MQTT__* vào .env
4
+
5
+ Dùng cho thiết bị IoT: giao thức nhẹ, giữ kết nối lâu, chịu được mạng chập
6
+ chờn. Không dùng thì để `APP_MQTT__ENABLED=false` (mặc định).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pymodular.infrastructure.mqtt.client import MqttClient
12
+ from pymodular.infrastructure.mqtt.consumers import (
13
+ MqttRunner,
14
+ discover_mqtt_subscribers,
15
+ mqtt_subscriber,
16
+ )
17
+ from pymodular.infrastructure.mqtt.patterns import (
18
+ covers,
19
+ matches,
20
+ narrow_filters,
21
+ validate_topic,
22
+ validate_topic_filter,
23
+ )
24
+
25
+ __all__ = [
26
+ "MqttClient",
27
+ "MqttRunner",
28
+ "covers",
29
+ "discover_mqtt_subscribers",
30
+ "matches",
31
+ "mqtt_subscriber",
32
+ "narrow_filters",
33
+ "validate_topic",
34
+ "validate_topic_filter",
35
+ ]