aury-boot 0.0.39__py3-none-any.whl → 0.0.41__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.
- aury/boot/_version.py +2 -2
- aury/boot/application/adapter/http.py +17 -6
- aury/boot/application/app/base.py +1 -0
- aury/boot/application/app/components.py +93 -3
- aury/boot/application/config/settings.py +80 -2
- aury/boot/commands/init.py +20 -0
- aury/boot/commands/pkg.py +31 -1
- aury/boot/commands/templates/project/aury_docs/00-overview.md.tpl +1 -0
- aury/boot/commands/templates/project/aury_docs/18-monitoring-profiling.md.tpl +239 -0
- aury/boot/commands/templates/project/env_templates/monitoring.tpl +15 -0
- aury/boot/common/logging/setup.py +8 -3
- aury/boot/infrastructure/cache/redis.py +82 -16
- aury/boot/infrastructure/channel/__init__.py +2 -1
- aury/boot/infrastructure/channel/backends/__init__.py +2 -1
- aury/boot/infrastructure/channel/backends/redis_cluster.py +124 -0
- aury/boot/infrastructure/channel/backends/redis_cluster_channel.py +139 -0
- aury/boot/infrastructure/channel/base.py +2 -0
- aury/boot/infrastructure/channel/manager.py +9 -1
- aury/boot/infrastructure/clients/redis/manager.py +90 -19
- aury/boot/infrastructure/database/manager.py +6 -4
- aury/boot/infrastructure/monitoring/__init__.py +10 -2
- aury/boot/infrastructure/monitoring/alerting/notifiers/feishu.py +33 -16
- aury/boot/infrastructure/monitoring/alerting/notifiers/webhook.py +14 -13
- aury/boot/infrastructure/monitoring/profiling/__init__.py +664 -0
- aury/boot/infrastructure/scheduler/__init__.py +2 -0
- aury/boot/infrastructure/scheduler/jobstores/__init__.py +10 -0
- aury/boot/infrastructure/scheduler/jobstores/redis_cluster.py +255 -0
- aury/boot/infrastructure/scheduler/manager.py +15 -3
- aury/boot/toolkit/http/__init__.py +180 -85
- {aury_boot-0.0.39.dist-info → aury_boot-0.0.41.dist-info}/METADATA +14 -4
- {aury_boot-0.0.39.dist-info → aury_boot-0.0.41.dist-info}/RECORD +33 -27
- {aury_boot-0.0.39.dist-info → aury_boot-0.0.41.dist-info}/WHEEL +0 -0
- {aury_boot-0.0.39.dist-info → aury_boot-0.0.41.dist-info}/entry_points.txt +0 -0
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"""Redis 客户端管理器 - 命名多实例模式。
|
|
2
2
|
|
|
3
3
|
提供统一的 Redis 连接管理,支持多实例。
|
|
4
|
+
支持普通 Redis 和 Redis Cluster:
|
|
5
|
+
- redis://... - 普通 Redis
|
|
6
|
+
- redis-cluster://... - Redis Cluster
|
|
4
7
|
"""
|
|
5
8
|
|
|
6
9
|
from __future__ import annotations
|
|
7
10
|
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
8
14
|
from redis.asyncio import ConnectionPool, Redis
|
|
9
15
|
|
|
10
16
|
from aury.boot.common.logging import logger
|
|
11
17
|
|
|
12
18
|
from .config import RedisConfig
|
|
13
19
|
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from redis.asyncio.cluster import RedisCluster
|
|
22
|
+
|
|
14
23
|
|
|
15
24
|
class RedisClient:
|
|
16
25
|
"""Redis 客户端管理器(命名多实例)。
|
|
@@ -50,8 +59,9 @@ class RedisClient:
|
|
|
50
59
|
self.name = name
|
|
51
60
|
self._config: RedisConfig | None = None
|
|
52
61
|
self._pool: ConnectionPool | None = None
|
|
53
|
-
self._redis: Redis | None = None
|
|
62
|
+
self._redis: Redis | RedisCluster | None = None
|
|
54
63
|
self._initialized: bool = False
|
|
64
|
+
self._is_cluster: bool = False
|
|
55
65
|
|
|
56
66
|
@classmethod
|
|
57
67
|
def get_instance(cls, name: str = "default") -> RedisClient:
|
|
@@ -135,6 +145,10 @@ class RedisClient:
|
|
|
135
145
|
async def initialize(self) -> RedisClient:
|
|
136
146
|
"""初始化 Redis 连接。
|
|
137
147
|
|
|
148
|
+
自动检测 URL scheme:
|
|
149
|
+
- redis://... -> 普通 Redis
|
|
150
|
+
- redis-cluster://... -> Redis Cluster
|
|
151
|
+
|
|
138
152
|
Returns:
|
|
139
153
|
self: 支持链式调用
|
|
140
154
|
|
|
@@ -152,33 +166,81 @@ class RedisClient:
|
|
|
152
166
|
)
|
|
153
167
|
|
|
154
168
|
try:
|
|
155
|
-
|
|
156
|
-
self._pool = ConnectionPool.from_url(
|
|
157
|
-
self._config.url,
|
|
158
|
-
max_connections=self._config.max_connections,
|
|
159
|
-
socket_timeout=self._config.socket_timeout,
|
|
160
|
-
socket_connect_timeout=self._config.socket_connect_timeout,
|
|
161
|
-
retry_on_timeout=self._config.retry_on_timeout,
|
|
162
|
-
health_check_interval=self._config.health_check_interval,
|
|
163
|
-
decode_responses=self._config.decode_responses,
|
|
164
|
-
)
|
|
169
|
+
url = self._config.url
|
|
165
170
|
|
|
166
|
-
#
|
|
167
|
-
|
|
171
|
+
# 自动检测是否为集群模式
|
|
172
|
+
if url.startswith("redis-cluster://"):
|
|
173
|
+
await self._initialize_cluster(url)
|
|
174
|
+
else:
|
|
175
|
+
await self._initialize_standalone(url)
|
|
168
176
|
|
|
169
177
|
# 验证连接
|
|
170
178
|
await self._redis.ping()
|
|
171
179
|
|
|
172
180
|
self._initialized = True
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
logger.info(f"Redis 客户端 [{self.name}]
|
|
181
|
+
masked_url = self._mask_url(url)
|
|
182
|
+
mode = "Cluster" if self._is_cluster else "Standalone"
|
|
183
|
+
logger.info(f"Redis 客户端 [{self.name}] 初始化完成 ({mode}): {masked_url}")
|
|
176
184
|
|
|
177
185
|
return self
|
|
178
186
|
except Exception as e:
|
|
179
187
|
logger.error(f"Redis 客户端 [{self.name}] 初始化失败: {e}")
|
|
180
188
|
raise
|
|
181
189
|
|
|
190
|
+
async def _initialize_standalone(self, url: str) -> None:
|
|
191
|
+
"""初始化普通 Redis 连接。"""
|
|
192
|
+
self._pool = ConnectionPool.from_url(
|
|
193
|
+
url,
|
|
194
|
+
max_connections=self._config.max_connections,
|
|
195
|
+
socket_timeout=self._config.socket_timeout,
|
|
196
|
+
socket_connect_timeout=self._config.socket_connect_timeout,
|
|
197
|
+
retry_on_timeout=self._config.retry_on_timeout,
|
|
198
|
+
health_check_interval=self._config.health_check_interval,
|
|
199
|
+
decode_responses=self._config.decode_responses,
|
|
200
|
+
)
|
|
201
|
+
self._redis = Redis(connection_pool=self._pool)
|
|
202
|
+
self._is_cluster = False
|
|
203
|
+
|
|
204
|
+
async def _initialize_cluster(self, url: str) -> None:
|
|
205
|
+
"""初始化 Redis Cluster 连接。
|
|
206
|
+
|
|
207
|
+
支持 URL 格式:
|
|
208
|
+
- redis-cluster://password@host:port (密码在用户名位置)
|
|
209
|
+
- redis-cluster://:password@host:port (标准格式)
|
|
210
|
+
- redis-cluster://username:password@host:port (ACL 模式)
|
|
211
|
+
"""
|
|
212
|
+
from redis.asyncio.cluster import RedisCluster
|
|
213
|
+
|
|
214
|
+
parsed_url = url.replace("redis-cluster://", "redis://")
|
|
215
|
+
parsed = urlparse(parsed_url)
|
|
216
|
+
|
|
217
|
+
# 提取认证信息
|
|
218
|
+
username = parsed.username
|
|
219
|
+
password = parsed.password
|
|
220
|
+
|
|
221
|
+
# 处理 password@host 格式(密码在用户名位置)
|
|
222
|
+
if username and not password:
|
|
223
|
+
password = username
|
|
224
|
+
username = None
|
|
225
|
+
|
|
226
|
+
# 构建连接参数
|
|
227
|
+
cluster_kwargs: dict = {
|
|
228
|
+
"host": parsed.hostname or "localhost",
|
|
229
|
+
"port": parsed.port or 6379,
|
|
230
|
+
"decode_responses": self._config.decode_responses,
|
|
231
|
+
"socket_timeout": self._config.socket_timeout,
|
|
232
|
+
"socket_connect_timeout": self._config.socket_connect_timeout,
|
|
233
|
+
"retry_on_timeout": self._config.retry_on_timeout,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if username:
|
|
237
|
+
cluster_kwargs["username"] = username
|
|
238
|
+
if password:
|
|
239
|
+
cluster_kwargs["password"] = password
|
|
240
|
+
|
|
241
|
+
self._redis = RedisCluster(**cluster_kwargs)
|
|
242
|
+
self._is_cluster = True
|
|
243
|
+
|
|
182
244
|
def _mask_url(self, url: str) -> str:
|
|
183
245
|
"""URL 脱敏(隐藏密码)。"""
|
|
184
246
|
if "@" in url:
|
|
@@ -198,11 +260,16 @@ class RedisClient:
|
|
|
198
260
|
return self._initialized
|
|
199
261
|
|
|
200
262
|
@property
|
|
201
|
-
def
|
|
263
|
+
def is_cluster(self) -> bool:
|
|
264
|
+
"""检查是否为集群模式。"""
|
|
265
|
+
return self._is_cluster
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def connection(self) -> Redis | RedisCluster:
|
|
202
269
|
"""获取 Redis 连接。
|
|
203
270
|
|
|
204
271
|
Returns:
|
|
205
|
-
Redis
|
|
272
|
+
Redis 或 RedisCluster 客户端实例
|
|
206
273
|
|
|
207
274
|
Raises:
|
|
208
275
|
RuntimeError: 未初始化时调用
|
|
@@ -245,7 +312,10 @@ class RedisClient:
|
|
|
245
312
|
async def cleanup(self) -> None:
|
|
246
313
|
"""清理资源,关闭连接。"""
|
|
247
314
|
if self._redis:
|
|
248
|
-
|
|
315
|
+
if self._is_cluster:
|
|
316
|
+
await self._redis.aclose()
|
|
317
|
+
else:
|
|
318
|
+
await self._redis.close()
|
|
249
319
|
logger.info(f"Redis 客户端 [{self.name}] 已关闭")
|
|
250
320
|
|
|
251
321
|
if self._pool:
|
|
@@ -254,6 +324,7 @@ class RedisClient:
|
|
|
254
324
|
self._redis = None
|
|
255
325
|
self._pool = None
|
|
256
326
|
self._initialized = False
|
|
327
|
+
self._is_cluster = False
|
|
257
328
|
|
|
258
329
|
def __repr__(self) -> str:
|
|
259
330
|
"""字符串表示。"""
|
|
@@ -226,6 +226,9 @@ class DatabaseManager:
|
|
|
226
226
|
async def session(self) -> AsyncGenerator[AsyncSession]:
|
|
227
227
|
"""获取数据库会话(上下文管理器)。
|
|
228
228
|
|
|
229
|
+
连接校验由 pool_pre_ping=True 在引擎层自动处理,
|
|
230
|
+
无需手动检查。
|
|
231
|
+
|
|
229
232
|
Yields:
|
|
230
233
|
AsyncSession: 数据库会话
|
|
231
234
|
|
|
@@ -235,7 +238,6 @@ class DatabaseManager:
|
|
|
235
238
|
"""
|
|
236
239
|
session = self.session_factory()
|
|
237
240
|
try:
|
|
238
|
-
await self._check_session_connection(session)
|
|
239
241
|
yield session
|
|
240
242
|
except SQLAlchemyError as exc:
|
|
241
243
|
# 只捕获数据库相关异常
|
|
@@ -253,15 +255,15 @@ class DatabaseManager:
|
|
|
253
255
|
async def create_session(self) -> AsyncSession:
|
|
254
256
|
"""创建新的数据库会话(需要手动关闭)。
|
|
255
257
|
|
|
258
|
+
连接校验由 pool_pre_ping=True 在引擎层自动处理。
|
|
259
|
+
|
|
256
260
|
Returns:
|
|
257
261
|
AsyncSession: 数据库会话
|
|
258
262
|
|
|
259
263
|
注意:使用后需要手动调用 await session.close()
|
|
260
264
|
建议使用 session() 上下文管理器代替此方法。
|
|
261
265
|
"""
|
|
262
|
-
|
|
263
|
-
await self._check_session_connection(session)
|
|
264
|
-
return session
|
|
266
|
+
return self.session_factory()
|
|
265
267
|
|
|
266
268
|
async def get_session(self) -> AsyncGenerator[AsyncSession]:
|
|
267
269
|
"""FastAPI 依赖注入专用的会话获取器。
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import traceback
|
|
8
9
|
from abc import ABC, abstractmethod
|
|
9
10
|
from collections.abc import Callable
|
|
10
11
|
from functools import wraps
|
|
@@ -13,6 +14,11 @@ import time
|
|
|
13
14
|
from aury.boot.common.logging import logger
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def _format_exception_stacktrace(exc: Exception) -> str:
|
|
18
|
+
"""格式化异常堆栈为字符串。"""
|
|
19
|
+
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
20
|
+
|
|
21
|
+
|
|
16
22
|
class MonitorContext:
|
|
17
23
|
"""监控上下文。
|
|
18
24
|
|
|
@@ -262,6 +268,7 @@ async def _emit_http_exception_alert(
|
|
|
262
268
|
method=method,
|
|
263
269
|
error_type=type(exception).__name__,
|
|
264
270
|
error_message=str(exception),
|
|
271
|
+
stacktrace=_format_exception_stacktrace(exception),
|
|
265
272
|
)
|
|
266
273
|
except ImportError:
|
|
267
274
|
pass
|
|
@@ -369,8 +376,9 @@ class ErrorReporterComponent(MonitorComponent):
|
|
|
369
376
|
source="service",
|
|
370
377
|
duration=context.duration,
|
|
371
378
|
service=context.service_name,
|
|
372
|
-
|
|
373
|
-
|
|
379
|
+
error_type=type(context.exception).__name__,
|
|
380
|
+
error_message=str(context.exception),
|
|
381
|
+
stacktrace=_format_exception_stacktrace(context.exception),
|
|
374
382
|
)
|
|
375
383
|
except ImportError:
|
|
376
384
|
pass # alerting 模块未加载
|
|
@@ -11,7 +11,7 @@ import hmac
|
|
|
11
11
|
import time
|
|
12
12
|
from typing import TYPE_CHECKING, Any
|
|
13
13
|
|
|
14
|
-
import
|
|
14
|
+
import aiohttp
|
|
15
15
|
|
|
16
16
|
from aury.boot.common.logging import logger
|
|
17
17
|
|
|
@@ -118,6 +118,25 @@ class FeishuNotifier(AlertNotifier):
|
|
|
118
118
|
details.append(f"**错误信息**: {notification.metadata['error_message']}")
|
|
119
119
|
if "task_name" in notification.metadata:
|
|
120
120
|
details.append(f"**任务**: {notification.metadata['task_name']}")
|
|
121
|
+
|
|
122
|
+
# 事件循环阻塞检测专用字段
|
|
123
|
+
if "blocked_ms" in notification.metadata:
|
|
124
|
+
details.append(f"**阻塞时间**: {notification.metadata['blocked_ms']:.0f}ms")
|
|
125
|
+
if "threshold_ms" in notification.metadata:
|
|
126
|
+
details.append(f"**阈值**: {notification.metadata['threshold_ms']:.0f}ms")
|
|
127
|
+
if "total_blocks" in notification.metadata:
|
|
128
|
+
window_minutes = notification.metadata.get("window_minutes", 5)
|
|
129
|
+
details.append(f"**近{window_minutes}分钟**: {notification.metadata['total_blocks']} 次")
|
|
130
|
+
if "block_rate" in notification.metadata:
|
|
131
|
+
details.append(f"**阻塞率**: {notification.metadata['block_rate']}")
|
|
132
|
+
if "process_stats" in notification.metadata:
|
|
133
|
+
stats = notification.metadata["process_stats"]
|
|
134
|
+
if stats:
|
|
135
|
+
stats_str = f"CPU {stats.get('cpu_percent', 'N/A')}%, "
|
|
136
|
+
stats_str += f"RSS {stats.get('memory_rss_mb', 'N/A')}MB, "
|
|
137
|
+
stats_str += f"线程 {stats.get('num_threads', 'N/A')}"
|
|
138
|
+
details.append(f"**进程状态**: {stats_str}")
|
|
139
|
+
|
|
121
140
|
# SQL 和堆栈单独处理
|
|
122
141
|
if "sql" in notification.metadata:
|
|
123
142
|
sql_content = notification.metadata["sql"]
|
|
@@ -155,11 +174,10 @@ class FeishuNotifier(AlertNotifier):
|
|
|
155
174
|
"content": f"**堆栈**:\n```python\n{stacktrace_content}\n```",
|
|
156
175
|
})
|
|
157
176
|
|
|
158
|
-
#
|
|
177
|
+
# 构建卡片消息(飞书自定义机器人格式)
|
|
159
178
|
card = {
|
|
160
179
|
"msg_type": "interactive",
|
|
161
180
|
"card": {
|
|
162
|
-
"schema": "2.0",
|
|
163
181
|
"config": {
|
|
164
182
|
"wide_screen_mode": True,
|
|
165
183
|
},
|
|
@@ -170,9 +188,7 @@ class FeishuNotifier(AlertNotifier):
|
|
|
170
188
|
"content": notification.title,
|
|
171
189
|
},
|
|
172
190
|
},
|
|
173
|
-
"
|
|
174
|
-
"elements": elements,
|
|
175
|
-
},
|
|
191
|
+
"elements": elements,
|
|
176
192
|
},
|
|
177
193
|
}
|
|
178
194
|
|
|
@@ -191,16 +207,17 @@ class FeishuNotifier(AlertNotifier):
|
|
|
191
207
|
message["sign"] = self._generate_sign(timestamp)
|
|
192
208
|
|
|
193
209
|
# 发送请求
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
timeout = aiohttp.ClientTimeout(total=10)
|
|
211
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
212
|
+
async with session.post(self.webhook, json=message) as response:
|
|
213
|
+
result = await response.json()
|
|
214
|
+
|
|
215
|
+
if result.get("code") == 0 or result.get("StatusCode") == 0:
|
|
216
|
+
logger.debug(f"飞书通知发送成功: {notification.title}")
|
|
217
|
+
return True
|
|
218
|
+
else:
|
|
219
|
+
logger.error(f"飞书通知发送失败: {result}")
|
|
220
|
+
return False
|
|
204
221
|
except Exception as e:
|
|
205
222
|
logger.error(f"飞书通知发送异常: {e}")
|
|
206
223
|
return False
|
|
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from typing import TYPE_CHECKING, Any
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import aiohttp
|
|
11
11
|
|
|
12
12
|
from aury.boot.common.logging import logger
|
|
13
13
|
|
|
@@ -87,21 +87,22 @@ class WebhookNotifier(AlertNotifier):
|
|
|
87
87
|
try:
|
|
88
88
|
payload = self._build_payload(notification)
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
|
91
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
92
|
+
async with session.post(
|
|
92
93
|
self.url,
|
|
93
94
|
json=payload,
|
|
94
95
|
headers=self.headers,
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
96
|
+
) as response:
|
|
97
|
+
if response.status < 400:
|
|
98
|
+
logger.debug(f"Webhook 通知发送成功: {notification.title}")
|
|
99
|
+
return True
|
|
100
|
+
else:
|
|
101
|
+
text = await response.text()
|
|
102
|
+
logger.error(
|
|
103
|
+
f"Webhook 通知发送失败: {response.status} - {text}"
|
|
104
|
+
)
|
|
105
|
+
return False
|
|
105
106
|
except Exception as e:
|
|
106
107
|
logger.error(f"Webhook 通知发送异常: {e}")
|
|
107
108
|
return False
|