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,228 @@
|
|
|
1
|
+
"""Hợp đồng chung cho mọi backend database, và phần ánh xạ entity <-> bản ghi.
|
|
2
|
+
|
|
3
|
+
Ba backend (memory / SQL / MongoDB) đều cài đúng bộ method dưới đây, nên tầng
|
|
4
|
+
`Repository` ở core không cần biết đang chạy trên cái nào.
|
|
5
|
+
|
|
6
|
+
Ánh xạ dựa hoàn toàn vào dataclass entity: tên trường thành tên cột hoặc khoá
|
|
7
|
+
document, kiểu Python thành kiểu cột. Entity vì vậy vẫn là dataclass thuần,
|
|
8
|
+
không dính ORM.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import hashlib
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from functools import cache
|
|
19
|
+
from typing import Any, Protocol, TypeVar, get_args, get_type_hints
|
|
20
|
+
|
|
21
|
+
from pymodular.core.compat import UTC, TimeoutErrors
|
|
22
|
+
|
|
23
|
+
E = TypeVar("E")
|
|
24
|
+
|
|
25
|
+
Filters = dict[str, Any]
|
|
26
|
+
Match = Callable[[Any], bool] | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MAX_INDEX_NAME = 63 # PostgreSQL cắt tên định danh ở 63 ký tự
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def index_name(prefix: str, storage: str, columns: Sequence[str]) -> str:
|
|
33
|
+
"""Tên index ổn định, không vượt giới hạn độ dài của PostgreSQL."""
|
|
34
|
+
name = f"{prefix}_{storage}_{'_'.join(columns)}"
|
|
35
|
+
if len(name) <= MAX_INDEX_NAME:
|
|
36
|
+
return name
|
|
37
|
+
digest = hashlib.sha1("_".join(columns).encode()).hexdigest()[:8]
|
|
38
|
+
return f"{prefix}_{storage[:40]}_{digest}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
42
|
+
class EntityMapping:
|
|
43
|
+
entity: type
|
|
44
|
+
storage: str # tên bảng (SQL) hoặc collection (Mongo)
|
|
45
|
+
fields: dict[str, type] # tên trường -> kiểu đã giải
|
|
46
|
+
unique: tuple[tuple[str, ...], ...] # mỗi phần tử là một cột hoặc một cụm cột
|
|
47
|
+
indexes: tuple[tuple[str, ...], ...]
|
|
48
|
+
|
|
49
|
+
def index_specs(self) -> list[tuple[str, tuple[str, ...], bool]]:
|
|
50
|
+
"""[(tên index, các cột, có unique không)] cho mọi index đã khai báo."""
|
|
51
|
+
return [
|
|
52
|
+
*((index_name("uq", self.storage, cols), cols, True) for cols in self.unique),
|
|
53
|
+
*((index_name("ix", self.storage, cols), cols, False) for cols in self.indexes),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@cache
|
|
58
|
+
def mapping_for(entity: type) -> EntityMapping:
|
|
59
|
+
if not dataclasses.is_dataclass(entity):
|
|
60
|
+
raise TypeError(f"{entity.__name__} phải là dataclass mới ánh xạ được")
|
|
61
|
+
|
|
62
|
+
hints = get_type_hints(entity)
|
|
63
|
+
fields = {f.name: hints.get(f.name, str) for f in dataclasses.fields(entity)}
|
|
64
|
+
return EntityMapping(
|
|
65
|
+
entity=entity,
|
|
66
|
+
storage=getattr(entity, "__storage_name__", f"{entity.__name__.lower()}s"),
|
|
67
|
+
fields=fields,
|
|
68
|
+
unique=tuple(getattr(entity, "__storage_unique__", ())),
|
|
69
|
+
indexes=tuple(getattr(entity, "__storage_indexes__", ())),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def to_document(obj: Any) -> dict[str, Any]:
|
|
74
|
+
"""Entity -> dict thuần (Enum thành giá trị, datetime giữ nguyên)."""
|
|
75
|
+
doc: dict[str, Any] = {}
|
|
76
|
+
for field in dataclasses.fields(obj):
|
|
77
|
+
value = getattr(obj, field.name)
|
|
78
|
+
doc[field.name] = value.value if isinstance(value, Enum) else value
|
|
79
|
+
return doc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _allows_none(declared: Any) -> bool:
|
|
83
|
+
"""Kiểu có chấp nhận None không (Optional[X] hoặc X | None)."""
|
|
84
|
+
return declared is Any or type(None) in get_args(declared)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@cache
|
|
88
|
+
def _defaults_of(entity: type) -> dict[str, Callable[[], Any]]:
|
|
89
|
+
"""Hàm sinh giá trị mặc định cho từng trường có khai báo default."""
|
|
90
|
+
out: dict[str, Callable[[], Any]] = {}
|
|
91
|
+
for f in dataclasses.fields(entity):
|
|
92
|
+
if f.default is not dataclasses.MISSING:
|
|
93
|
+
out[f.name] = lambda value=f.default: value
|
|
94
|
+
elif f.default_factory is not dataclasses.MISSING: # type: ignore[misc]
|
|
95
|
+
out[f.name] = f.default_factory # type: ignore[assignment]
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def from_document(entity: type[E], doc: dict[str, Any]) -> E:
|
|
100
|
+
"""dict -> entity, ép lại Enum/datetime và bù giá trị mặc định.
|
|
101
|
+
|
|
102
|
+
Bù mặc định là chỗ quan trọng khi schema tiến hoá: thêm một trường vào
|
|
103
|
+
entity thì bản ghi cũ chưa có giá trị (SQL trả NULL, Mongo thiếu hẳn khoá).
|
|
104
|
+
Nếu kiểu khai báo không nhận None mà entity có default, ta dùng default —
|
|
105
|
+
nhờ vậy dữ liệu cũ đọc ra vẫn hợp lệ thay vì mang None trái kiểu.
|
|
106
|
+
"""
|
|
107
|
+
mapping = mapping_for(entity)
|
|
108
|
+
defaults = _defaults_of(entity)
|
|
109
|
+
kwargs: dict[str, Any] = {}
|
|
110
|
+
|
|
111
|
+
for name, declared in mapping.fields.items():
|
|
112
|
+
value = doc.get(name)
|
|
113
|
+
|
|
114
|
+
if value is None and not _allows_none(declared) and name in defaults:
|
|
115
|
+
value = defaults[name]()
|
|
116
|
+
elif value is not None:
|
|
117
|
+
if isinstance(declared, type) and issubclass(declared, Enum):
|
|
118
|
+
value = declared(value)
|
|
119
|
+
elif declared is datetime:
|
|
120
|
+
if isinstance(value, str):
|
|
121
|
+
value = datetime.fromisoformat(value)
|
|
122
|
+
if isinstance(value, datetime) and value.tzinfo is None:
|
|
123
|
+
# SQLite và MongoDB trả datetime KHÔNG mang múi giờ (cả hai
|
|
124
|
+
# lưu theo UTC nhưng không kèm tzinfo). Gắn lại UTC ở đây để
|
|
125
|
+
# ba driver cho ra cùng một dạng, và để một response không
|
|
126
|
+
# lẫn lộn "có Z" với "không Z".
|
|
127
|
+
value = value.replace(tzinfo=UTC)
|
|
128
|
+
|
|
129
|
+
kwargs[name] = value
|
|
130
|
+
|
|
131
|
+
return entity(**kwargs) # type: ignore[call-arg]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def matches(obj: Any, filters: Filters, match: Match) -> bool:
|
|
135
|
+
"""Lọc trong Python — dùng cho backend memory và cho tham số `match=`."""
|
|
136
|
+
for key, value in filters.items():
|
|
137
|
+
if getattr(obj, key, None) != value:
|
|
138
|
+
return False
|
|
139
|
+
return match is None or match(obj)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def active_filters(filters: Filters) -> Filters:
|
|
143
|
+
"""Bỏ mọi điều kiện có giá trị None (quy ước: None = không lọc)."""
|
|
144
|
+
return {k: v for k, v in filters.items() if v is not None}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class DuplicateKeyViolation(Exception):
|
|
148
|
+
"""Vi phạm ràng buộc duy nhất, do backend không có sẵn kiểu lỗi riêng.
|
|
149
|
+
|
|
150
|
+
SQL ném IntegrityError, Mongo ném DuplicateKeyError; backend memory dùng
|
|
151
|
+
lớp này để ba đường đi cho ra cùng một kết quả HTTP 409.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, storage: str, columns: Sequence[str], values: Sequence[Any]) -> None:
|
|
155
|
+
self.storage = storage
|
|
156
|
+
self.columns = tuple(columns)
|
|
157
|
+
self.values = tuple(values)
|
|
158
|
+
pairs = ", ".join(f"{c}={v!r}" for c, v in zip(self.columns, self.values, strict=True))
|
|
159
|
+
super().__init__(f"{storage}: đã tồn tại bản ghi với {pairs}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Tên lớp lỗi "tạm thời" của các driver. Dùng tên thay vì import để file này
|
|
163
|
+
# không phụ thuộc vào thư viện của driver nào.
|
|
164
|
+
_TRANSIENT_NAMES = frozenset({
|
|
165
|
+
"ServerSelectionTimeoutError", # pymongo: chưa chọn được server
|
|
166
|
+
"AutoReconnect", # pymongo: mất kết nối, sẽ tự nối lại
|
|
167
|
+
"NetworkTimeout", # pymongo
|
|
168
|
+
"ConnectionFailure", # pymongo (lớp cha)
|
|
169
|
+
"CannotConnectNowError", # asyncpg: database đang khởi động
|
|
170
|
+
"TooManyConnectionsError", # asyncpg: hết slot, chờ chút sẽ có
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def is_transient_error(exc: BaseException) -> bool:
|
|
175
|
+
"""Lỗi này có khả năng tự hết nếu thử lại không?
|
|
176
|
+
|
|
177
|
+
Phân biệt "database chưa kịp lên" (đáng thử lại) với "sai mật khẩu / sai
|
|
178
|
+
tên database" (thử lại bao nhiêu lần cũng vậy, chỉ làm chậm lúc phát hiện
|
|
179
|
+
cấu hình sai). Duyệt cả chuỗi __cause__ vì driver hay bọc lỗi gốc lại.
|
|
180
|
+
"""
|
|
181
|
+
seen: set[int] = set()
|
|
182
|
+
current: BaseException | None = exc
|
|
183
|
+
|
|
184
|
+
while current is not None and id(current) not in seen:
|
|
185
|
+
seen.add(id(current))
|
|
186
|
+
if isinstance(current, (ConnectionError, *TimeoutErrors)):
|
|
187
|
+
return True
|
|
188
|
+
if type(current).__name__ in _TRANSIENT_NAMES:
|
|
189
|
+
return True
|
|
190
|
+
# OSError chung (host chưa phân giải được, mạng chưa lên...)
|
|
191
|
+
if isinstance(current, OSError) and not isinstance(current, (IsADirectoryError, NotADirectoryError, PermissionError)):
|
|
192
|
+
return True
|
|
193
|
+
current = current.__cause__ or current.__context__
|
|
194
|
+
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class DatabaseBackend(Protocol):
|
|
199
|
+
"""Bộ method mà mọi backend phải có."""
|
|
200
|
+
|
|
201
|
+
name: str
|
|
202
|
+
|
|
203
|
+
async def startup(self) -> None: ...
|
|
204
|
+
async def shutdown(self) -> None: ...
|
|
205
|
+
async def ping(self) -> bool: ...
|
|
206
|
+
|
|
207
|
+
async def get(self, entity: type[E], id_: str) -> E | None: ...
|
|
208
|
+
async def find(
|
|
209
|
+
self,
|
|
210
|
+
entity: type[E],
|
|
211
|
+
*,
|
|
212
|
+
filters: Filters,
|
|
213
|
+
match: Match = None,
|
|
214
|
+
order_by: str | None = None,
|
|
215
|
+
limit: int | None = None,
|
|
216
|
+
offset: int = 0,
|
|
217
|
+
) -> list[E]: ...
|
|
218
|
+
async def find_one(
|
|
219
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
220
|
+
) -> E | None: ...
|
|
221
|
+
async def count(
|
|
222
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
223
|
+
) -> int: ...
|
|
224
|
+
async def save(self, entity: type[E], obj: E) -> E: ...
|
|
225
|
+
async def delete(self, entity: type[E], id_: str) -> bool: ...
|
|
226
|
+
async def delete_where(
|
|
227
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
228
|
+
) -> int: ...
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Circuit breaker + hạn thời gian cho database.
|
|
2
|
+
|
|
3
|
+
Vấn đề khi không có nó: database chết, mỗi request vẫn đi tới tận nơi, chờ hết
|
|
4
|
+
`connect_timeout` rồi mới nhận 503. Với timeout 10 giây và 100 request/giây,
|
|
5
|
+
toàn bộ worker bị giữ chỗ chờ vô ích, và API chết theo database.
|
|
6
|
+
|
|
7
|
+
Cách chữa: đếm số lần hỏng liên tiếp. Quá ngưỡng thì "ngắt mạch" — mọi request
|
|
8
|
+
trả 503 NGAY, không chạm database. Sau `reset_seconds` cho đúng MỘT request đi
|
|
9
|
+
thử; thành công thì đóng mạch lại, hỏng thì mở tiếp.
|
|
10
|
+
|
|
11
|
+
Lớp bọc này còn áp một hạn thời gian cứng cho mọi lời gọi. Cần thiết vì
|
|
12
|
+
timeout của từng driver không phủ hết mọi tình huống: database bị đóng băng
|
|
13
|
+
giữa lúc đang trả lời thì connection vẫn "mở", `connect_timeout` không cứu
|
|
14
|
+
được, và request treo cho tới khi client bỏ cuộc. `asyncio.wait_for` ở đây chặn
|
|
15
|
+
được mọi trường hợp, bất kể driver nào bên dưới.
|
|
16
|
+
|
|
17
|
+
Ba trạng thái:
|
|
18
|
+
|
|
19
|
+
closed --(hỏng liên tiếp >= ngưỡng)--> open
|
|
20
|
+
open --(hết reset_seconds)---------> half_open
|
|
21
|
+
half_open --(1 request thành công)----> closed
|
|
22
|
+
--(1 request hỏng)-----------> open
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import time
|
|
29
|
+
from typing import Any, TypeVar
|
|
30
|
+
|
|
31
|
+
from pymodular.core.compat import StrEnum, TimeoutErrors
|
|
32
|
+
from pymodular.core.logging import get_logger
|
|
33
|
+
from pymodular.infrastructure.database.base import (
|
|
34
|
+
DatabaseBackend,
|
|
35
|
+
Filters,
|
|
36
|
+
Match,
|
|
37
|
+
is_transient_error,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
log = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
E = TypeVar("E")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CircuitState(StrEnum):
|
|
46
|
+
CLOSED = "closed"
|
|
47
|
+
OPEN = "open"
|
|
48
|
+
HALF_OPEN = "half_open"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CircuitOpenError(Exception):
|
|
52
|
+
"""Mạch đang ngắt — không thử database nữa cho tới khi hết thời gian nghỉ."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, backend: str, retry_after: float) -> None:
|
|
55
|
+
self.backend = backend
|
|
56
|
+
self.retry_after = retry_after
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"Database '{backend}' đang bị ngắt mạch, thử lại sau {retry_after:.0f}s"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CircuitBreakerBackend(DatabaseBackend):
|
|
63
|
+
"""Bọc quanh một backend thật, đếm hỏng và ngắt mạch khi cần.
|
|
64
|
+
|
|
65
|
+
Chỉ tính lỗi KẾT NỐI (`is_transient_error`). Lỗi nghiệp vụ như trùng khoá
|
|
66
|
+
không được làm mạch ngắt — đó là database đang hoạt động tốt.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
inner: DatabaseBackend,
|
|
72
|
+
*,
|
|
73
|
+
failure_threshold: int = 5,
|
|
74
|
+
reset_seconds: float = 10.0,
|
|
75
|
+
call_timeout_seconds: float = 15.0,
|
|
76
|
+
breaker_enabled: bool = True,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._inner = inner
|
|
79
|
+
self._threshold = failure_threshold
|
|
80
|
+
self._reset_seconds = reset_seconds
|
|
81
|
+
self._call_timeout = call_timeout_seconds
|
|
82
|
+
# Tắt breaker chỉ tắt phần ngắt mạch; HẠN THỜI GIAN vẫn áp dụng, vì
|
|
83
|
+
# thiếu nó thì một database bị treo sẽ giữ chỗ mọi worker vô thời hạn.
|
|
84
|
+
self._breaker_enabled = breaker_enabled
|
|
85
|
+
|
|
86
|
+
self._state = CircuitState.CLOSED
|
|
87
|
+
self._failures = 0
|
|
88
|
+
self._opened_at = 0.0
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ trạng thái
|
|
91
|
+
@property
|
|
92
|
+
def name(self) -> str:
|
|
93
|
+
return self._inner.name
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def state(self) -> CircuitState:
|
|
97
|
+
return self._state
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def stats(self) -> dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"enabled": self._breaker_enabled,
|
|
103
|
+
"state": self._state.value,
|
|
104
|
+
"failures": self._failures,
|
|
105
|
+
"threshold": self._threshold,
|
|
106
|
+
"call_timeout": self._call_timeout,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def _before_call(self) -> None:
|
|
110
|
+
if not self._breaker_enabled or self._state is CircuitState.CLOSED:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
waited = time.monotonic() - self._opened_at
|
|
114
|
+
if self._state is CircuitState.OPEN:
|
|
115
|
+
if waited < self._reset_seconds:
|
|
116
|
+
raise CircuitOpenError(self.name, self._reset_seconds - waited)
|
|
117
|
+
self._state = CircuitState.HALF_OPEN
|
|
118
|
+
log.warning("db.circuit_half_open", backend=self.name)
|
|
119
|
+
|
|
120
|
+
def _on_success(self) -> None:
|
|
121
|
+
if self._state is not CircuitState.CLOSED:
|
|
122
|
+
log.info("db.circuit_closed", backend=self.name)
|
|
123
|
+
self._state = CircuitState.CLOSED
|
|
124
|
+
self._failures = 0
|
|
125
|
+
|
|
126
|
+
def _on_failure(self, exc: BaseException) -> None:
|
|
127
|
+
if not self._breaker_enabled or not is_transient_error(exc):
|
|
128
|
+
return # lỗi nghiệp vụ: database vẫn khoẻ, không tính
|
|
129
|
+
|
|
130
|
+
self._failures += 1
|
|
131
|
+
if self._failures >= self._threshold or self._state is CircuitState.HALF_OPEN:
|
|
132
|
+
if self._state is not CircuitState.OPEN:
|
|
133
|
+
log.error(
|
|
134
|
+
"db.circuit_open",
|
|
135
|
+
backend=self.name,
|
|
136
|
+
failures=self._failures,
|
|
137
|
+
reset_in=self._reset_seconds,
|
|
138
|
+
)
|
|
139
|
+
self._state = CircuitState.OPEN
|
|
140
|
+
self._opened_at = time.monotonic()
|
|
141
|
+
|
|
142
|
+
async def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
|
143
|
+
self._before_call()
|
|
144
|
+
try:
|
|
145
|
+
result = await asyncio.wait_for(
|
|
146
|
+
getattr(self._inner, method)(*args, **kwargs), self._call_timeout
|
|
147
|
+
)
|
|
148
|
+
except TimeoutErrors as exc:
|
|
149
|
+
# TimeoutError là lỗi tạm thời, nên nó tính vào số lần hỏng và sẽ
|
|
150
|
+
# làm mạch ngắt nếu lặp lại — đúng ý đồ.
|
|
151
|
+
log.warning("db.call_timeout", backend=self.name, method=method,
|
|
152
|
+
timeout=self._call_timeout)
|
|
153
|
+
self._on_failure(exc)
|
|
154
|
+
# Ném lại bằng TimeoutError DỰNG SẴN, không phải asyncio.TimeoutError:
|
|
155
|
+
# trên 3.10 hai lớp đó khác nhau, nên `except TimeoutError` ở code
|
|
156
|
+
# người dùng sẽ trượt trên 3.10 và trúng trên 3.11. Lỗi phụ thuộc
|
|
157
|
+
# phiên bản là thứ tệ nhất một thư viện có thể để lọt ra ngoài.
|
|
158
|
+
raise TimeoutError(
|
|
159
|
+
f"{self.name}.{method} quá {self._call_timeout}s"
|
|
160
|
+
) from exc
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
self._on_failure(exc)
|
|
163
|
+
raise
|
|
164
|
+
self._on_success()
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ vòng đời
|
|
168
|
+
async def startup(self) -> None:
|
|
169
|
+
await self._inner.startup()
|
|
170
|
+
|
|
171
|
+
async def shutdown(self) -> None:
|
|
172
|
+
await self._inner.shutdown()
|
|
173
|
+
|
|
174
|
+
async def ping(self) -> bool:
|
|
175
|
+
return await self._call("ping")
|
|
176
|
+
|
|
177
|
+
def __getattr__(self, item: str) -> Any:
|
|
178
|
+
# create_schema và các method riêng của backend cụ thể đi thẳng.
|
|
179
|
+
return getattr(self._inner, item)
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------ truy vấn
|
|
182
|
+
async def get(self, entity: type[E], id_: str) -> E | None:
|
|
183
|
+
return await self._call("get", entity, id_)
|
|
184
|
+
|
|
185
|
+
async def find(self, entity: type[E], **kwargs: Any) -> list[E]:
|
|
186
|
+
return await self._call("find", entity, **kwargs)
|
|
187
|
+
|
|
188
|
+
async def find_one(
|
|
189
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
190
|
+
) -> E | None:
|
|
191
|
+
return await self._call("find_one", entity, filters=filters, match=match)
|
|
192
|
+
|
|
193
|
+
async def count(
|
|
194
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
195
|
+
) -> int:
|
|
196
|
+
return await self._call("count", entity, filters=filters, match=match)
|
|
197
|
+
|
|
198
|
+
async def save(self, entity: type[E], obj: E) -> E:
|
|
199
|
+
return await self._call("save", entity, obj)
|
|
200
|
+
|
|
201
|
+
async def delete(self, entity: type[E], id_: str) -> bool:
|
|
202
|
+
return await self._call("delete", entity, id_)
|
|
203
|
+
|
|
204
|
+
async def delete_where(
|
|
205
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
206
|
+
) -> int:
|
|
207
|
+
return await self._call("delete_where", entity, filters=filters, match=match)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Chọn backend theo cấu hình, và chỉ import thư viện của driver được chọn.
|
|
2
|
+
|
|
3
|
+
Đây là điểm mấu chốt cho yêu cầu "dùng Postgres thì không cần cài thư viện của
|
|
4
|
+
SQLite/Mongo": mọi `import sqlalchemy` / `import motor` đều nằm TRONG hàm, nên
|
|
5
|
+
chúng chỉ chạy khi driver tương ứng được chọn. Thiếu thư viện sẽ báo lỗi nói rõ
|
|
6
|
+
cần chạy lệnh make nào, thay vì ImportError lúc khởi động.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pymodular.core.config import DatabaseSettings
|
|
12
|
+
from pymodular.infrastructure.database.base import DatabaseBackend
|
|
13
|
+
|
|
14
|
+
_INSTALL_HINT = {
|
|
15
|
+
"sqlite": "pip install \'fastapi-modular[sqlite]\'",
|
|
16
|
+
"postgres": "pip install \'fastapi-modular[postgres]\'",
|
|
17
|
+
"mongodb": "pip install \'fastapi-modular[mongodb]\'",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _missing(driver: str, package: str) -> RuntimeError:
|
|
22
|
+
cach_cai = _INSTALL_HINT.get(driver, "pip install 'fastapi-modular[<driver>]'")
|
|
23
|
+
return RuntimeError(
|
|
24
|
+
f"Driver database '{driver}' cần thư viện '{package}' nhưng chưa cài. "
|
|
25
|
+
f"Chạy: {cach_cai}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _wrap(settings: DatabaseSettings, backend: DatabaseBackend) -> DatabaseBackend:
|
|
30
|
+
"""Bọc backend bằng lớp hạn-thời-gian + circuit breaker.
|
|
31
|
+
|
|
32
|
+
Luôn bọc, kể cả khi tắt circuit breaker: phần hạn thời gian là bắt buộc,
|
|
33
|
+
vì timeout của từng driver không phủ được trường hợp database treo giữa
|
|
34
|
+
chừng, và khi đó request sẽ giữ chỗ worker vô thời hạn.
|
|
35
|
+
"""
|
|
36
|
+
from pymodular.infrastructure.database.circuit import CircuitBreakerBackend
|
|
37
|
+
|
|
38
|
+
return CircuitBreakerBackend(
|
|
39
|
+
backend,
|
|
40
|
+
failure_threshold=settings.circuit_failure_threshold,
|
|
41
|
+
reset_seconds=settings.circuit_reset_seconds,
|
|
42
|
+
call_timeout_seconds=settings.query_timeout_seconds,
|
|
43
|
+
breaker_enabled=settings.circuit_breaker,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_backend(settings: DatabaseSettings) -> DatabaseBackend:
|
|
48
|
+
driver = settings.driver
|
|
49
|
+
|
|
50
|
+
if driver == "memory":
|
|
51
|
+
from pymodular.infrastructure.database.memory import MemoryBackend
|
|
52
|
+
|
|
53
|
+
# Backend memory không đi qua mạng nên không có gì để ngắt.
|
|
54
|
+
return MemoryBackend()
|
|
55
|
+
|
|
56
|
+
if driver in ("sqlite", "postgres"):
|
|
57
|
+
try:
|
|
58
|
+
from pymodular.infrastructure.database.sql import SqlBackend
|
|
59
|
+
except ModuleNotFoundError as exc:
|
|
60
|
+
raise _missing(driver, exc.name or "sqlalchemy") from exc
|
|
61
|
+
|
|
62
|
+
return _wrap(settings, SqlBackend(
|
|
63
|
+
settings.resolved_dsn,
|
|
64
|
+
echo=settings.echo,
|
|
65
|
+
schema_mode=settings.schema_mode,
|
|
66
|
+
drop_columns=settings.drop_columns,
|
|
67
|
+
pool_pre_ping=settings.pool_pre_ping,
|
|
68
|
+
pool_size=settings.pool_size,
|
|
69
|
+
max_overflow=settings.max_overflow,
|
|
70
|
+
pool_recycle_seconds=settings.pool_recycle_seconds,
|
|
71
|
+
connect_timeout_seconds=settings.connect_timeout_seconds,
|
|
72
|
+
query_timeout_seconds=settings.query_timeout_seconds,
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
if driver == "mongodb":
|
|
76
|
+
try:
|
|
77
|
+
from pymodular.infrastructure.database.mongo import MongoBackend
|
|
78
|
+
except ModuleNotFoundError as exc:
|
|
79
|
+
raise _missing(driver, exc.name or "motor") from exc
|
|
80
|
+
|
|
81
|
+
return _wrap(settings, MongoBackend(
|
|
82
|
+
settings.resolved_dsn,
|
|
83
|
+
database=settings.name,
|
|
84
|
+
connect_timeout_seconds=settings.connect_timeout_seconds,
|
|
85
|
+
query_timeout_seconds=settings.query_timeout_seconds,
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
raise RuntimeError(f"Driver database không hỗ trợ: {driver!r}")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Backend lưu trong RAM — mặc định của template, không cần cài gì thêm.
|
|
2
|
+
|
|
3
|
+
Chỉ hợp cho phát triển và test: dữ liệu mất khi restart, và mỗi worker giữ
|
|
4
|
+
một bản riêng nên KHÔNG được chạy nhiều worker với backend này.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
from pymodular.infrastructure.database.base import (
|
|
13
|
+
DatabaseBackend,
|
|
14
|
+
DuplicateKeyViolation,
|
|
15
|
+
Filters,
|
|
16
|
+
Match,
|
|
17
|
+
active_filters,
|
|
18
|
+
mapping_for,
|
|
19
|
+
matches,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
E = TypeVar("E")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MemoryBackend(DatabaseBackend):
|
|
26
|
+
name = "memory"
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._tables: dict[str, dict[str, Any]] = {}
|
|
30
|
+
|
|
31
|
+
async def startup(self) -> None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
async def shutdown(self) -> None:
|
|
35
|
+
self._tables.clear()
|
|
36
|
+
|
|
37
|
+
async def ping(self) -> bool:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
def _table(self, entity: type) -> dict[str, Any]:
|
|
41
|
+
return self._tables.setdefault(mapping_for(entity).storage, {})
|
|
42
|
+
|
|
43
|
+
def _select(self, entity: type, filters: Filters, match: Match) -> list[Any]:
|
|
44
|
+
active = active_filters(filters)
|
|
45
|
+
return [o for o in self._table(entity).values() if matches(o, active, match)]
|
|
46
|
+
|
|
47
|
+
async def get(self, entity: type[E], id_: str) -> E | None:
|
|
48
|
+
return self._table(entity).get(id_)
|
|
49
|
+
|
|
50
|
+
async def find(
|
|
51
|
+
self,
|
|
52
|
+
entity: type[E],
|
|
53
|
+
*,
|
|
54
|
+
filters: Filters,
|
|
55
|
+
match: Match = None,
|
|
56
|
+
order_by: str | None = None,
|
|
57
|
+
limit: int | None = None,
|
|
58
|
+
offset: int = 0,
|
|
59
|
+
) -> list[E]:
|
|
60
|
+
rows = self._select(entity, filters, match)
|
|
61
|
+
if order_by:
|
|
62
|
+
rows.sort(key=lambda o: getattr(o, order_by, 0))
|
|
63
|
+
rows = rows[offset:]
|
|
64
|
+
return rows[:limit] if limit is not None else rows
|
|
65
|
+
|
|
66
|
+
async def find_one(
|
|
67
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
68
|
+
) -> E | None:
|
|
69
|
+
return next(iter(self._select(entity, filters, match)), None)
|
|
70
|
+
|
|
71
|
+
async def count(
|
|
72
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
73
|
+
) -> int:
|
|
74
|
+
return len(self._select(entity, filters, match))
|
|
75
|
+
|
|
76
|
+
async def save(self, entity: type[E], obj: E) -> E:
|
|
77
|
+
self._check_unique(entity, obj)
|
|
78
|
+
if not getattr(obj, "id", None):
|
|
79
|
+
obj.id = uuid.uuid4().hex # type: ignore[attr-defined]
|
|
80
|
+
self._table(entity)[obj.id] = obj # type: ignore[attr-defined]
|
|
81
|
+
return obj
|
|
82
|
+
|
|
83
|
+
def _check_unique(self, entity: type, obj: Any) -> None:
|
|
84
|
+
"""Bắt chước ràng buộc unique của database thật.
|
|
85
|
+
|
|
86
|
+
Không có phần này thì backend memory sẽ cho ghi trùng, và test chạy
|
|
87
|
+
trên memory sẽ không bắt được lỗi mà production gặp phải.
|
|
88
|
+
"""
|
|
89
|
+
mapping = mapping_for(entity)
|
|
90
|
+
if not mapping.unique:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
own_id = getattr(obj, "id", None)
|
|
94
|
+
for columns in mapping.unique:
|
|
95
|
+
values = tuple(getattr(obj, column, None) for column in columns)
|
|
96
|
+
for other in self._table(entity).values():
|
|
97
|
+
if getattr(other, "id", None) == own_id:
|
|
98
|
+
continue
|
|
99
|
+
if tuple(getattr(other, c, None) for c in columns) == values:
|
|
100
|
+
raise DuplicateKeyViolation(mapping.storage, columns, values)
|
|
101
|
+
|
|
102
|
+
async def delete(self, entity: type[E], id_: str) -> bool:
|
|
103
|
+
return self._table(entity).pop(id_, None) is not None
|
|
104
|
+
|
|
105
|
+
async def delete_where(
|
|
106
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
107
|
+
) -> int:
|
|
108
|
+
table = self._table(entity)
|
|
109
|
+
ids = [o.id for o in self._select(entity, filters, match)]
|
|
110
|
+
for id_ in ids:
|
|
111
|
+
del table[id_]
|
|
112
|
+
return len(ids)
|