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.
Files changed (33) hide show
  1. aury/boot/_version.py +2 -2
  2. aury/boot/application/adapter/http.py +17 -6
  3. aury/boot/application/app/base.py +1 -0
  4. aury/boot/application/app/components.py +93 -3
  5. aury/boot/application/config/settings.py +80 -2
  6. aury/boot/commands/init.py +20 -0
  7. aury/boot/commands/pkg.py +31 -1
  8. aury/boot/commands/templates/project/aury_docs/00-overview.md.tpl +1 -0
  9. aury/boot/commands/templates/project/aury_docs/18-monitoring-profiling.md.tpl +239 -0
  10. aury/boot/commands/templates/project/env_templates/monitoring.tpl +15 -0
  11. aury/boot/common/logging/setup.py +8 -3
  12. aury/boot/infrastructure/cache/redis.py +82 -16
  13. aury/boot/infrastructure/channel/__init__.py +2 -1
  14. aury/boot/infrastructure/channel/backends/__init__.py +2 -1
  15. aury/boot/infrastructure/channel/backends/redis_cluster.py +124 -0
  16. aury/boot/infrastructure/channel/backends/redis_cluster_channel.py +139 -0
  17. aury/boot/infrastructure/channel/base.py +2 -0
  18. aury/boot/infrastructure/channel/manager.py +9 -1
  19. aury/boot/infrastructure/clients/redis/manager.py +90 -19
  20. aury/boot/infrastructure/database/manager.py +6 -4
  21. aury/boot/infrastructure/monitoring/__init__.py +10 -2
  22. aury/boot/infrastructure/monitoring/alerting/notifiers/feishu.py +33 -16
  23. aury/boot/infrastructure/monitoring/alerting/notifiers/webhook.py +14 -13
  24. aury/boot/infrastructure/monitoring/profiling/__init__.py +664 -0
  25. aury/boot/infrastructure/scheduler/__init__.py +2 -0
  26. aury/boot/infrastructure/scheduler/jobstores/__init__.py +10 -0
  27. aury/boot/infrastructure/scheduler/jobstores/redis_cluster.py +255 -0
  28. aury/boot/infrastructure/scheduler/manager.py +15 -3
  29. aury/boot/toolkit/http/__init__.py +180 -85
  30. {aury_boot-0.0.39.dist-info → aury_boot-0.0.41.dist-info}/METADATA +14 -4
  31. {aury_boot-0.0.39.dist-info → aury_boot-0.0.41.dist-info}/RECORD +33 -27
  32. {aury_boot-0.0.39.dist-info → aury_boot-0.0.41.dist-info}/WHEEL +0 -0
  33. {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
- # 创建 Redis 客户端
167
- self._redis = Redis(connection_pool=self._pool)
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
- masked_url = self._mask_url(self._config.url)
175
- logger.info(f"Redis 客户端 [{self.name}] 初始化完成: {masked_url}")
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 connection(self) -> Redis:
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: 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
- await self._redis.close()
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
- session = self.session_factory()
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
- exception_type=type(context.exception).__name__,
373
- exception_message=str(context.exception),
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 httpx
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
- # 构建 JSON 2.0 卡片消息
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
- "body": {
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
- async with httpx.AsyncClient(timeout=10) as client:
195
- response = await client.post(self.webhook, json=message)
196
- result = response.json()
197
-
198
- if result.get("code") == 0 or result.get("StatusCode") == 0:
199
- logger.debug(f"飞书通知发送成功: {notification.title}")
200
- return True
201
- else:
202
- logger.error(f"飞书通知发送失败: {result}")
203
- return False
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 httpx
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
- async with httpx.AsyncClient(timeout=self.timeout) as client:
91
- response = await client.post(
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
- if response.is_success:
98
- logger.debug(f"Webhook 通知发送成功: {notification.title}")
99
- return True
100
- else:
101
- logger.error(
102
- f"Webhook 通知发送失败: {response.status_code} - {response.text}"
103
- )
104
- return False
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