jettask 0.2.23__py3-none-any.whl → 0.2.24__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.
- jettask/__init__.py +2 -0
- jettask/cli.py +12 -8
- jettask/config/lua_scripts.py +37 -0
- jettask/config/nacos_config.py +1 -1
- jettask/core/app.py +313 -340
- jettask/core/container.py +4 -4
- jettask/{persistence → core}/namespace.py +93 -27
- jettask/core/task.py +16 -9
- jettask/core/unified_manager_base.py +136 -26
- jettask/db/__init__.py +67 -0
- jettask/db/base.py +137 -0
- jettask/{utils/db_connector.py → db/connector.py} +130 -26
- jettask/db/models/__init__.py +16 -0
- jettask/db/models/scheduled_task.py +196 -0
- jettask/db/models/task.py +77 -0
- jettask/db/models/task_run.py +85 -0
- jettask/executor/__init__.py +0 -15
- jettask/executor/core.py +76 -31
- jettask/executor/process_entry.py +29 -114
- jettask/executor/task_executor.py +4 -0
- jettask/messaging/event_pool.py +928 -685
- jettask/messaging/scanner.py +30 -0
- jettask/persistence/__init__.py +28 -103
- jettask/persistence/buffer.py +170 -0
- jettask/persistence/consumer.py +330 -249
- jettask/persistence/manager.py +304 -0
- jettask/persistence/persistence.py +391 -0
- jettask/scheduler/__init__.py +15 -3
- jettask/scheduler/{task_crud.py → database.py} +61 -57
- jettask/scheduler/loader.py +2 -2
- jettask/scheduler/{scheduler_coordinator.py → manager.py} +23 -6
- jettask/scheduler/models.py +14 -10
- jettask/scheduler/schedule.py +166 -0
- jettask/scheduler/scheduler.py +12 -11
- jettask/schemas/__init__.py +50 -1
- jettask/schemas/backlog.py +43 -6
- jettask/schemas/namespace.py +70 -19
- jettask/schemas/queue.py +19 -3
- jettask/schemas/responses.py +493 -0
- jettask/task/__init__.py +0 -2
- jettask/task/router.py +3 -0
- jettask/test_connection_monitor.py +1 -1
- jettask/utils/__init__.py +7 -5
- jettask/utils/db_init.py +8 -4
- jettask/utils/namespace_dep.py +167 -0
- jettask/utils/queue_matcher.py +186 -0
- jettask/utils/rate_limit/concurrency_limiter.py +7 -1
- jettask/utils/stream_backlog.py +1 -1
- jettask/webui/__init__.py +0 -1
- jettask/webui/api/__init__.py +4 -4
- jettask/webui/api/alerts.py +806 -71
- jettask/webui/api/example_refactored.py +400 -0
- jettask/webui/api/namespaces.py +390 -45
- jettask/webui/api/overview.py +300 -54
- jettask/webui/api/queues.py +971 -267
- jettask/webui/api/scheduled.py +1249 -56
- jettask/webui/api/settings.py +129 -7
- jettask/webui/api/workers.py +442 -0
- jettask/webui/app.py +46 -2329
- jettask/webui/middleware/__init__.py +6 -0
- jettask/webui/middleware/namespace_middleware.py +135 -0
- jettask/webui/services/__init__.py +146 -0
- jettask/webui/services/heartbeat_service.py +251 -0
- jettask/webui/services/overview_service.py +60 -51
- jettask/webui/services/queue_monitor_service.py +426 -0
- jettask/webui/services/redis_monitor_service.py +87 -0
- jettask/webui/services/settings_service.py +174 -111
- jettask/webui/services/task_monitor_service.py +222 -0
- jettask/webui/services/timeline_pg_service.py +452 -0
- jettask/webui/services/timeline_service.py +189 -0
- jettask/webui/services/worker_monitor_service.py +467 -0
- jettask/webui/utils/__init__.py +11 -0
- jettask/webui/utils/time_utils.py +122 -0
- jettask/worker/lifecycle.py +8 -2
- {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/METADATA +1 -1
- jettask-0.2.24.dist-info/RECORD +142 -0
- jettask/executor/executor.py +0 -338
- jettask/persistence/backlog_monitor.py +0 -567
- jettask/persistence/base.py +0 -2334
- jettask/persistence/db_manager.py +0 -516
- jettask/persistence/maintenance.py +0 -81
- jettask/persistence/message_consumer.py +0 -259
- jettask/persistence/models.py +0 -49
- jettask/persistence/offline_recovery.py +0 -196
- jettask/persistence/queue_discovery.py +0 -215
- jettask/persistence/task_persistence.py +0 -218
- jettask/persistence/task_updater.py +0 -583
- jettask/scheduler/add_execution_count.sql +0 -11
- jettask/scheduler/add_priority_field.sql +0 -26
- jettask/scheduler/add_scheduler_id.sql +0 -25
- jettask/scheduler/add_scheduler_id_index.sql +0 -10
- jettask/scheduler/make_scheduler_id_required.sql +0 -28
- jettask/scheduler/migrate_interval_seconds.sql +0 -9
- jettask/scheduler/performance_optimization.sql +0 -45
- jettask/scheduler/run_scheduler.py +0 -186
- jettask/scheduler/schema.sql +0 -84
- jettask/task/task_executor.py +0 -318
- jettask/webui/api/analytics.py +0 -323
- jettask/webui/config.py +0 -90
- jettask/webui/models/__init__.py +0 -3
- jettask/webui/models/namespace.py +0 -63
- jettask/webui/namespace_manager/__init__.py +0 -10
- jettask/webui/namespace_manager/multi.py +0 -593
- jettask/webui/namespace_manager/unified.py +0 -193
- jettask/webui/run.py +0 -46
- jettask-0.2.23.dist-info/RECORD +0 -145
- {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/WHEEL +0 -0
- {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/entry_points.txt +0 -0
- {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/licenses/LICENSE +0 -0
- {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/top_level.txt +0 -0
jettask/persistence/base.py
DELETED
@@ -1,2334 +0,0 @@
|
|
1
|
-
"""
|
2
|
-
独立的数据访问模块,不依赖 integrated_gradio_app.py
|
3
|
-
"""
|
4
|
-
import os
|
5
|
-
import asyncio
|
6
|
-
import json
|
7
|
-
import logging
|
8
|
-
import time
|
9
|
-
from datetime import datetime, timedelta, timezone
|
10
|
-
from typing import Dict, List, Optional, Tuple
|
11
|
-
import redis.asyncio as redis
|
12
|
-
from sqlalchemy import text, bindparam
|
13
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
14
|
-
|
15
|
-
# 导入统一的数据库连接工具
|
16
|
-
from ..utils.db_connector import (
|
17
|
-
get_dual_mode_async_redis_client,
|
18
|
-
get_pg_engine_and_factory
|
19
|
-
)
|
20
|
-
|
21
|
-
# 设置日志
|
22
|
-
logger = logging.getLogger(__name__)
|
23
|
-
|
24
|
-
|
25
|
-
class RedisConfig:
|
26
|
-
"""Redis配置"""
|
27
|
-
def __init__(self, host='localhost', port=6379, db=0, password=None):
|
28
|
-
self.host = host
|
29
|
-
self.port = port
|
30
|
-
self.db = db
|
31
|
-
self.password = password
|
32
|
-
|
33
|
-
@classmethod
|
34
|
-
def from_env(cls):
|
35
|
-
import os
|
36
|
-
return cls(
|
37
|
-
host=os.getenv('REDIS_HOST', 'localhost'),
|
38
|
-
port=int(os.getenv('REDIS_PORT', 6379)),
|
39
|
-
db=int(os.getenv('REDIS_DB', 0)),
|
40
|
-
password=os.getenv('REDIS_PASSWORD')
|
41
|
-
)
|
42
|
-
|
43
|
-
|
44
|
-
class PostgreSQLConfig:
|
45
|
-
"""PostgreSQL配置"""
|
46
|
-
def __init__(self, host='localhost', port=5432, user='postgres', password='', database='jettask'):
|
47
|
-
self.host = host
|
48
|
-
self.port = port
|
49
|
-
self.user = user
|
50
|
-
self.password = password
|
51
|
-
self.database = database
|
52
|
-
|
53
|
-
@property
|
54
|
-
def dsn(self):
|
55
|
-
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
|
56
|
-
|
57
|
-
@classmethod
|
58
|
-
def from_env(cls):
|
59
|
-
import os
|
60
|
-
return cls(
|
61
|
-
host=os.getenv('POSTGRES_HOST', 'localhost'),
|
62
|
-
port=int(os.getenv('POSTGRES_PORT', 5432)),
|
63
|
-
user=os.getenv('POSTGRES_USER', 'jettask'),
|
64
|
-
password=os.getenv('POSTGRES_PASSWORD', '123456'),
|
65
|
-
database=os.getenv('POSTGRES_DB', 'jettask')
|
66
|
-
)
|
67
|
-
|
68
|
-
|
69
|
-
class JetTaskDataAccess:
|
70
|
-
"""JetTask数据访问类(使用统一的数据库连接工具)"""
|
71
|
-
|
72
|
-
def __init__(self):
|
73
|
-
self.redis_config = RedisConfig.from_env()
|
74
|
-
self.pg_config = PostgreSQLConfig.from_env()
|
75
|
-
# Redis前缀可以从环境变量配置,默认为 "jettask"
|
76
|
-
self.redis_prefix = os.environ.get('JETTASK_REDIS_PREFIX', 'jettask')
|
77
|
-
|
78
|
-
# 使用全局单例连接池
|
79
|
-
self._text_redis_client: Optional[redis.Redis] = None
|
80
|
-
self._binary_redis_client: Optional[redis.Redis] = None
|
81
|
-
|
82
|
-
# PostgreSQL 相关
|
83
|
-
self.async_engine = None
|
84
|
-
self.AsyncSessionLocal = None
|
85
|
-
|
86
|
-
async def initialize(self):
|
87
|
-
"""初始化数据库连接(使用全局单例)"""
|
88
|
-
try:
|
89
|
-
# 构建 Redis URL
|
90
|
-
redis_url = f"redis://"
|
91
|
-
if self.redis_config.password:
|
92
|
-
redis_url += f":{self.redis_config.password}@"
|
93
|
-
redis_url += f"{self.redis_config.host}:{self.redis_config.port}/{self.redis_config.db}"
|
94
|
-
|
95
|
-
# 构建 PostgreSQL 配置
|
96
|
-
pg_config = {
|
97
|
-
'host': self.pg_config.host,
|
98
|
-
'port': self.pg_config.port,
|
99
|
-
'user': self.pg_config.user,
|
100
|
-
'password': self.pg_config.password,
|
101
|
-
'database': self.pg_config.database
|
102
|
-
}
|
103
|
-
|
104
|
-
# 初始化 PostgreSQL 连接(使用全局单例)
|
105
|
-
self.async_engine, self.AsyncSessionLocal = get_pg_engine_and_factory(
|
106
|
-
config=pg_config,
|
107
|
-
pool_size=10,
|
108
|
-
max_overflow=5,
|
109
|
-
pool_pre_ping=True,
|
110
|
-
echo=False
|
111
|
-
)
|
112
|
-
|
113
|
-
# 初始化 Redis 连接(使用全局单例,双模式)
|
114
|
-
self._text_redis_client, self._binary_redis_client = get_dual_mode_async_redis_client(
|
115
|
-
redis_url=redis_url,
|
116
|
-
max_connections=50,
|
117
|
-
socket_keepalive=True,
|
118
|
-
socket_connect_timeout=5,
|
119
|
-
retry_on_timeout=True
|
120
|
-
)
|
121
|
-
|
122
|
-
logger.info("数据库连接初始化成功")
|
123
|
-
|
124
|
-
except Exception as e:
|
125
|
-
logger.error(f"数据库连接初始化失败: {e}")
|
126
|
-
raise
|
127
|
-
|
128
|
-
def get_session(self):
|
129
|
-
"""获取数据库会话(作为上下文管理器)"""
|
130
|
-
return self.AsyncSessionLocal()
|
131
|
-
|
132
|
-
async def close(self):
|
133
|
-
"""关闭数据库连接(由于使用全局单例,这里只重置状态)"""
|
134
|
-
# 注意:连接池由全局单例管理,这里只清理引用
|
135
|
-
self._text_redis_client = None
|
136
|
-
self._binary_redis_client = None
|
137
|
-
self.async_engine = None
|
138
|
-
self.AsyncSessionLocal = None
|
139
|
-
|
140
|
-
async def get_redis_client(self):
|
141
|
-
"""获取 Redis 客户端(使用全局单例)"""
|
142
|
-
if not self._text_redis_client:
|
143
|
-
raise RuntimeError("Redis client not initialized")
|
144
|
-
return self._text_redis_client
|
145
|
-
|
146
|
-
async def get_binary_redis_client(self):
|
147
|
-
"""获取二进制 Redis 客户端(用于Stream操作,使用全局单例)"""
|
148
|
-
if not self._binary_redis_client:
|
149
|
-
raise RuntimeError("Binary Redis client not initialized")
|
150
|
-
return self._binary_redis_client
|
151
|
-
|
152
|
-
async def fetch_queues_data(self) -> List[Dict]:
|
153
|
-
"""获取队列数据(基于Redis Stream)"""
|
154
|
-
try:
|
155
|
-
redis_client = await self.get_redis_client()
|
156
|
-
binary_redis_client = await self.get_binary_redis_client() # 用于Stream操作
|
157
|
-
|
158
|
-
# 获取所有Stream类型的队列 - JetTask使用 jettask:QUEUE:队列名 格式
|
159
|
-
all_keys = await redis_client.keys(f"{self.redis_prefix}:QUEUE:*")
|
160
|
-
queues_data = []
|
161
|
-
queue_names = set()
|
162
|
-
|
163
|
-
for key in all_keys:
|
164
|
-
# 检查是否是Stream类型
|
165
|
-
key_type = await redis_client.type(key)
|
166
|
-
if key_type == 'stream':
|
167
|
-
# 解析队列名称 - 格式: jettask:QUEUE:队列名
|
168
|
-
parts = key.split(':')
|
169
|
-
if len(parts) >= 3 and parts[0] == self.redis_prefix and parts[1] == 'QUEUE':
|
170
|
-
queue_name = ':'.join(parts[2:]) # 支持带冒号的队列名
|
171
|
-
queue_names.add(queue_name)
|
172
|
-
|
173
|
-
# 获取每个队列的详细信息
|
174
|
-
for queue_name in queue_names:
|
175
|
-
stream_key = f"{self.redis_prefix}:QUEUE:{queue_name}"
|
176
|
-
|
177
|
-
try:
|
178
|
-
# 使用二进制客户端获取Stream信息
|
179
|
-
stream_info = await binary_redis_client.xinfo_stream(stream_key)
|
180
|
-
# 直接提取需要的字段(字符串键)
|
181
|
-
stream_length = stream_info.get('length', 0)
|
182
|
-
|
183
|
-
# 获取消费者组信息
|
184
|
-
groups_info = []
|
185
|
-
try:
|
186
|
-
groups_info_raw = await binary_redis_client.xinfo_groups(stream_key)
|
187
|
-
for group in groups_info_raw:
|
188
|
-
group_name = group.get('name', '')
|
189
|
-
if isinstance(group_name, bytes):
|
190
|
-
group_name = group_name.decode('utf-8')
|
191
|
-
groups_info.append({
|
192
|
-
'name': group_name,
|
193
|
-
'pending': group.get('pending', 0)
|
194
|
-
})
|
195
|
-
except:
|
196
|
-
pass
|
197
|
-
|
198
|
-
pending_count = 0
|
199
|
-
processing_count = 0
|
200
|
-
|
201
|
-
# 统计各消费者组的待处理消息
|
202
|
-
for group in groups_info:
|
203
|
-
pending_count += group.get('pending', 0)
|
204
|
-
|
205
|
-
# 获取消费者信息
|
206
|
-
if group.get('name'):
|
207
|
-
try:
|
208
|
-
consumers = await binary_redis_client.xinfo_consumers(
|
209
|
-
stream_key,
|
210
|
-
group['name']
|
211
|
-
)
|
212
|
-
for consumer in consumers:
|
213
|
-
processing_count += consumer.get('pending', 0)
|
214
|
-
except:
|
215
|
-
pass
|
216
|
-
|
217
|
-
# Stream的长度即为总消息数
|
218
|
-
total_messages = stream_length if 'stream_length' in locals() else 0
|
219
|
-
|
220
|
-
# 完成的消息数 = 总消息数 - 待处理 - 处理中
|
221
|
-
completed_count = max(0, total_messages - pending_count - processing_count)
|
222
|
-
|
223
|
-
queues_data.append({
|
224
|
-
'队列名称': queue_name,
|
225
|
-
'待处理': pending_count,
|
226
|
-
'处理中': processing_count,
|
227
|
-
'已完成': completed_count,
|
228
|
-
'失败': 0, # Stream中没有直接的失败计数
|
229
|
-
'总计': total_messages
|
230
|
-
})
|
231
|
-
|
232
|
-
except Exception as e:
|
233
|
-
logger.warning(f"获取队列 {queue_name} 信息失败: {e}")
|
234
|
-
# 如果获取详细信息失败,至少返回队列名称
|
235
|
-
queues_data.append({
|
236
|
-
'队列名称': queue_name,
|
237
|
-
'待处理': 0,
|
238
|
-
'处理中': 0,
|
239
|
-
'已完成': 0,
|
240
|
-
'失败': 0,
|
241
|
-
'总计': 0
|
242
|
-
})
|
243
|
-
|
244
|
-
await redis_client.close()
|
245
|
-
await binary_redis_client.close()
|
246
|
-
return sorted(queues_data, key=lambda x: x['队列名称'])
|
247
|
-
|
248
|
-
except Exception as e:
|
249
|
-
logger.error(f"获取队列数据失败: {e}")
|
250
|
-
return []
|
251
|
-
|
252
|
-
async def fetch_queue_details(self, start_time: datetime = None, end_time: datetime = None,
|
253
|
-
time_range_minutes: int = None, queues: List[str] = None) -> List[Dict]:
|
254
|
-
"""获取队列详细信息,包含消费速度、在线workers等
|
255
|
-
|
256
|
-
Args:
|
257
|
-
start_time: 开始时间(优先使用)
|
258
|
-
end_time: 结束时间(优先使用)
|
259
|
-
time_range_minutes: 时间范围(分钟),仅在没有指定start_time/end_time时使用
|
260
|
-
queues: 要筛选的队列列表,如果为None则返回所有队列
|
261
|
-
"""
|
262
|
-
# 确定时间范围
|
263
|
-
if start_time and end_time:
|
264
|
-
# 使用指定的时间范围
|
265
|
-
query_start_time = start_time
|
266
|
-
query_end_time = end_time
|
267
|
-
elif time_range_minutes:
|
268
|
-
# 向后兼容:使用最近N分钟
|
269
|
-
query_end_time = datetime.now(timezone.utc)
|
270
|
-
query_start_time = query_end_time - timedelta(minutes=time_range_minutes)
|
271
|
-
else:
|
272
|
-
# 默认最近15分钟
|
273
|
-
query_end_time = datetime.now(timezone.utc)
|
274
|
-
query_start_time = query_end_time - timedelta(minutes=15)
|
275
|
-
|
276
|
-
try:
|
277
|
-
redis_client = await self.get_redis_client()
|
278
|
-
|
279
|
-
# 获取所有队列名称
|
280
|
-
all_keys = await redis_client.keys(f"{self.redis_prefix}:QUEUE:*")
|
281
|
-
queue_details = []
|
282
|
-
|
283
|
-
for key in all_keys:
|
284
|
-
# 检查是否是Stream类型
|
285
|
-
key_type = await redis_client.type(key)
|
286
|
-
if key_type == 'stream':
|
287
|
-
# 解析队列名称
|
288
|
-
parts = key.split(':')
|
289
|
-
if len(parts) >= 3 and parts[0] == self.redis_prefix and parts[1] == 'QUEUE':
|
290
|
-
queue_name = ':'.join(parts[2:])
|
291
|
-
|
292
|
-
# 如果指定了队列筛选,检查当前队列是否在筛选列表中
|
293
|
-
if queues and queue_name not in queues:
|
294
|
-
continue
|
295
|
-
|
296
|
-
# 获取活跃的workers数量
|
297
|
-
active_workers = 0
|
298
|
-
try:
|
299
|
-
worker_keys = await redis_client.keys(f"{self.redis_prefix}:WORKER:*")
|
300
|
-
for worker_key in worker_keys:
|
301
|
-
worker_info = await redis_client.hgetall(worker_key)
|
302
|
-
if worker_info:
|
303
|
-
last_heartbeat = worker_info.get('last_heartbeat')
|
304
|
-
if last_heartbeat:
|
305
|
-
try:
|
306
|
-
heartbeat_time = float(last_heartbeat)
|
307
|
-
if time.time() - heartbeat_time < 60:
|
308
|
-
worker_queues = worker_info.get('queues', '')
|
309
|
-
if queue_name in worker_queues:
|
310
|
-
active_workers += 1
|
311
|
-
except:
|
312
|
-
pass
|
313
|
-
except:
|
314
|
-
pass
|
315
|
-
|
316
|
-
# 从PostgreSQL获取队列统计信息
|
317
|
-
total_messages = 0
|
318
|
-
visible_messages = 0
|
319
|
-
completed_count = 0
|
320
|
-
failed_count = 0
|
321
|
-
consumption_rate = 0
|
322
|
-
success_rate = 0
|
323
|
-
|
324
|
-
if self.AsyncSessionLocal:
|
325
|
-
try:
|
326
|
-
async with self.AsyncSessionLocal() as session:
|
327
|
-
# 获取指定时间范围的所有统计数据
|
328
|
-
query = text("""
|
329
|
-
SELECT
|
330
|
-
COUNT(*) as total,
|
331
|
-
COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending_count,
|
332
|
-
COUNT(CASE WHEN status = 'success' THEN 1 END) as completed,
|
333
|
-
COUNT(CASE WHEN status = 'error' THEN 1 END) as failed
|
334
|
-
FROM tasks
|
335
|
-
WHERE queue = :queue_name
|
336
|
-
AND created_at >= :start_time
|
337
|
-
AND created_at <= :end_time
|
338
|
-
""")
|
339
|
-
result = await session.execute(query, {
|
340
|
-
'queue_name': queue_name,
|
341
|
-
'start_time': query_start_time,
|
342
|
-
'end_time': query_end_time
|
343
|
-
})
|
344
|
-
row = result.first()
|
345
|
-
if row:
|
346
|
-
total_messages = row.total or 0
|
347
|
-
visible_messages = row.pending_count or 0
|
348
|
-
completed_count = row.completed or 0
|
349
|
-
failed_count = row.failed or 0
|
350
|
-
|
351
|
-
# 计算消费速度(任务/分钟)
|
352
|
-
time_diff_minutes = (query_end_time - query_start_time).total_seconds() / 60
|
353
|
-
if time_diff_minutes > 0:
|
354
|
-
consumption_rate = round(total_messages / time_diff_minutes, 2)
|
355
|
-
|
356
|
-
# 计算成功率
|
357
|
-
if total_messages > 0:
|
358
|
-
success_rate = round((completed_count / total_messages) * 100, 2)
|
359
|
-
except Exception as e:
|
360
|
-
logger.warning(f"获取队列 {queue_name} 统计信息失败: {e}")
|
361
|
-
|
362
|
-
# 队列状态
|
363
|
-
queue_status = 'active' if total_messages > 0 or active_workers > 0 else 'idle'
|
364
|
-
|
365
|
-
queue_details.append({
|
366
|
-
'queue_name': queue_name,
|
367
|
-
'message_count': total_messages, # 总消息数量(基于时间范围)
|
368
|
-
'visible_messages': visible_messages, # 可见消息(基于时间范围,status='pending')
|
369
|
-
'invisible_messages': 0, # 不可见消息(现在设为0,不从Redis获取)
|
370
|
-
'completed': completed_count, # 成功数(基于时间范围,status='success')
|
371
|
-
'failed': failed_count, # 失败数(基于时间范围,status='error')
|
372
|
-
'consumption_rate': consumption_rate, # 消费速度(任务/分钟)
|
373
|
-
'success_rate': success_rate, # 成功率(百分比)
|
374
|
-
'active_workers': active_workers, # 在线workers
|
375
|
-
'queue_status': queue_status # 队列状态
|
376
|
-
})
|
377
|
-
|
378
|
-
await redis_client.close()
|
379
|
-
return sorted(queue_details, key=lambda x: x['queue_name'])
|
380
|
-
|
381
|
-
except Exception as e:
|
382
|
-
logger.error(f"获取队列详细信息失败: {e}")
|
383
|
-
return []
|
384
|
-
|
385
|
-
async def get_latest_task_time(self, queue_name: str) -> Optional[datetime]:
|
386
|
-
"""获取队列的最新任务时间"""
|
387
|
-
try:
|
388
|
-
if not self.AsyncSessionLocal:
|
389
|
-
await self.initialize()
|
390
|
-
|
391
|
-
async with self.AsyncSessionLocal() as session:
|
392
|
-
query = text("""
|
393
|
-
SELECT MAX(created_at) as latest_time
|
394
|
-
FROM tasks
|
395
|
-
WHERE queue = :queue_name
|
396
|
-
""")
|
397
|
-
|
398
|
-
result = await session.execute(query, {'queue_name': queue_name})
|
399
|
-
row = result.fetchone()
|
400
|
-
|
401
|
-
if row and row.latest_time:
|
402
|
-
return row.latest_time
|
403
|
-
return None
|
404
|
-
|
405
|
-
except Exception as e:
|
406
|
-
logger.error(f"获取最新任务时间失败: {e}")
|
407
|
-
return None
|
408
|
-
|
409
|
-
async def fetch_task_details(self, task_id: str, consumer_group: Optional[str] = None) -> Optional[Dict]:
|
410
|
-
"""获取单个任务的详细数据(包括task_data、result和error_message)
|
411
|
-
|
412
|
-
Args:
|
413
|
-
task_id: 任务ID (stream_id)
|
414
|
-
consumer_group: 消费者组名称(可选,用于精确定位)
|
415
|
-
"""
|
416
|
-
try:
|
417
|
-
if not self.AsyncSessionLocal:
|
418
|
-
await self.initialize()
|
419
|
-
|
420
|
-
async with self.AsyncSessionLocal() as session:
|
421
|
-
# 根据是否提供consumer_group来调整查询
|
422
|
-
if consumer_group:
|
423
|
-
# 如果提供了consumer_group,精确查询特定消费组的执行结果
|
424
|
-
query = text("""
|
425
|
-
SELECT
|
426
|
-
t.stream_id as id,
|
427
|
-
t.payload as task_data,
|
428
|
-
tr.consumer_group,
|
429
|
-
tr.result,
|
430
|
-
tr.error_message
|
431
|
-
FROM tasks t
|
432
|
-
LEFT JOIN task_runs tr ON t.stream_id = tr.stream_id
|
433
|
-
AND tr.consumer_group = :consumer_group
|
434
|
-
WHERE t.stream_id = :task_id
|
435
|
-
LIMIT 1
|
436
|
-
""")
|
437
|
-
params = {'task_id': task_id, 'consumer_group': consumer_group}
|
438
|
-
else:
|
439
|
-
# 如果没有提供consumer_group,返回第一个找到的结果(向后兼容)
|
440
|
-
query = text("""
|
441
|
-
SELECT
|
442
|
-
t.stream_id as id,
|
443
|
-
t.payload as task_data,
|
444
|
-
tr.consumer_group,
|
445
|
-
tr.result,
|
446
|
-
tr.error_message
|
447
|
-
FROM tasks t
|
448
|
-
LEFT JOIN task_runs tr ON t.stream_id = tr.stream_id
|
449
|
-
WHERE t.stream_id = :task_id
|
450
|
-
ORDER BY tr.updated_at DESC NULLS LAST
|
451
|
-
LIMIT 1
|
452
|
-
""")
|
453
|
-
params = {'task_id': task_id}
|
454
|
-
|
455
|
-
result = await session.execute(query, params)
|
456
|
-
row = result.fetchone()
|
457
|
-
|
458
|
-
if row:
|
459
|
-
return {
|
460
|
-
'id': row.id,
|
461
|
-
'task_data': row.task_data,
|
462
|
-
'consumer_group': row.consumer_group if hasattr(row, 'consumer_group') else None,
|
463
|
-
'result': row.result,
|
464
|
-
'error_message': row.error_message
|
465
|
-
}
|
466
|
-
return None
|
467
|
-
|
468
|
-
except Exception as e:
|
469
|
-
logger.error(f"获取任务详细数据失败: {e}")
|
470
|
-
return None
|
471
|
-
|
472
|
-
async def fetch_queue_flow_rates(self,
|
473
|
-
queue_name: str,
|
474
|
-
start_time: datetime,
|
475
|
-
end_time: datetime,
|
476
|
-
filters: List[Dict] = None) -> Tuple[List[Dict], str]:
|
477
|
-
"""获取队列的三种流量速率:入队、开始执行、完成
|
478
|
-
|
479
|
-
Args:
|
480
|
-
queue_name: 队列名称
|
481
|
-
start_time: 开始时间
|
482
|
-
end_time: 结束时间
|
483
|
-
filters: 筛选条件列表,与fetch_tasks_with_filters的格式相同
|
484
|
-
"""
|
485
|
-
try:
|
486
|
-
if not self.AsyncSessionLocal:
|
487
|
-
await self.initialize()
|
488
|
-
print(f'{filters=}')
|
489
|
-
async with self.AsyncSessionLocal() as session:
|
490
|
-
# 动态计算时间间隔,目标是生成约200个时间点
|
491
|
-
TARGET_POINTS = 200
|
492
|
-
duration = (end_time - start_time).total_seconds()
|
493
|
-
|
494
|
-
# 计算理想的间隔秒数
|
495
|
-
ideal_interval_seconds = duration / TARGET_POINTS
|
496
|
-
print(f'{duration=} {TARGET_POINTS=} {ideal_interval_seconds=}')
|
497
|
-
# 将间隔秒数规范化到合理的值(与fetch_queue_timeline_data保持一致)
|
498
|
-
if ideal_interval_seconds <= 1:
|
499
|
-
interval_seconds = 1
|
500
|
-
interval = '1 seconds'
|
501
|
-
granularity = 'second'
|
502
|
-
elif ideal_interval_seconds <= 5:
|
503
|
-
interval_seconds = 5
|
504
|
-
interval = '5 seconds'
|
505
|
-
granularity = 'second'
|
506
|
-
elif ideal_interval_seconds <= 10:
|
507
|
-
interval_seconds = 10
|
508
|
-
interval = '10 seconds'
|
509
|
-
granularity = 'second'
|
510
|
-
elif ideal_interval_seconds <= 30:
|
511
|
-
interval_seconds = 30
|
512
|
-
interval = '30 seconds'
|
513
|
-
granularity = 'second'
|
514
|
-
elif ideal_interval_seconds <= 60:
|
515
|
-
interval_seconds = 60
|
516
|
-
interval = '1 minute'
|
517
|
-
granularity = 'minute'
|
518
|
-
elif ideal_interval_seconds <= 120:
|
519
|
-
interval_seconds = 120
|
520
|
-
interval = '2 minutes'
|
521
|
-
granularity = 'minute'
|
522
|
-
elif ideal_interval_seconds <= 300:
|
523
|
-
interval_seconds = 300
|
524
|
-
interval = '5 minutes'
|
525
|
-
granularity = 'minute'
|
526
|
-
elif ideal_interval_seconds <= 600:
|
527
|
-
interval_seconds = 600
|
528
|
-
interval = '10 minutes'
|
529
|
-
granularity = 'minute'
|
530
|
-
elif ideal_interval_seconds <= 900:
|
531
|
-
interval_seconds = 900
|
532
|
-
interval = '15 minutes'
|
533
|
-
granularity = 'minute'
|
534
|
-
elif ideal_interval_seconds <= 1800:
|
535
|
-
interval_seconds = 1800
|
536
|
-
interval = '30 minutes'
|
537
|
-
granularity = 'minute'
|
538
|
-
elif ideal_interval_seconds <= 3600:
|
539
|
-
interval_seconds = 3600
|
540
|
-
interval = '1 hour'
|
541
|
-
granularity = 'hour'
|
542
|
-
elif ideal_interval_seconds <= 7200:
|
543
|
-
interval_seconds = 7200
|
544
|
-
interval = '2 hours'
|
545
|
-
granularity = 'hour'
|
546
|
-
elif ideal_interval_seconds <= 14400:
|
547
|
-
interval_seconds = 14400
|
548
|
-
interval = '4 hours'
|
549
|
-
granularity = 'hour'
|
550
|
-
elif ideal_interval_seconds <= 21600:
|
551
|
-
interval_seconds = 21600
|
552
|
-
interval = '6 hours'
|
553
|
-
granularity = 'hour'
|
554
|
-
elif ideal_interval_seconds <= 43200:
|
555
|
-
interval_seconds = 43200
|
556
|
-
interval = '12 hours'
|
557
|
-
granularity = 'hour'
|
558
|
-
else:
|
559
|
-
interval_seconds = 86400
|
560
|
-
interval = '1 day'
|
561
|
-
granularity = 'day'
|
562
|
-
|
563
|
-
# 重新计算实际点数
|
564
|
-
actual_points = int(duration / interval_seconds) + 1
|
565
|
-
logger.info(f"使用时间间隔: {interval_seconds}秒 ({interval}), 预计生成 {actual_points} 个时间点")
|
566
|
-
|
567
|
-
# 根据粒度确定 date_trunc 的单位
|
568
|
-
if granularity == 'second':
|
569
|
-
trunc_unit = 'second'
|
570
|
-
elif granularity == 'minute':
|
571
|
-
trunc_unit = 'minute'
|
572
|
-
elif granularity == 'hour':
|
573
|
-
trunc_unit = 'hour'
|
574
|
-
else: # day
|
575
|
-
trunc_unit = 'day'
|
576
|
-
|
577
|
-
# 构建筛选条件的WHERE子句
|
578
|
-
# 分别为tasks表和task_runs表构建条件
|
579
|
-
filter_conditions_enqueue = [] # 用于enqueued_rate(只有tasks表)
|
580
|
-
filter_conditions_complete = [] # 用于completed_rate和failed_count(有join)
|
581
|
-
filter_params = {}
|
582
|
-
has_status_filter = False
|
583
|
-
status_filter_value = None
|
584
|
-
|
585
|
-
if filters:
|
586
|
-
for idx, filter_item in enumerate(filters):
|
587
|
-
# 跳过被禁用的筛选条件
|
588
|
-
if filter_item.get('enabled') == False:
|
589
|
-
continue
|
590
|
-
|
591
|
-
field = filter_item.get('field')
|
592
|
-
operator = filter_item.get('operator')
|
593
|
-
value = filter_item.get('value')
|
594
|
-
|
595
|
-
if not field or not operator:
|
596
|
-
continue
|
597
|
-
|
598
|
-
# 检查是否有status筛选
|
599
|
-
if field == 'status' and operator == 'eq':
|
600
|
-
has_status_filter = True
|
601
|
-
status_filter_value = value
|
602
|
-
|
603
|
-
# 判断字段属于哪个表
|
604
|
-
# task_runs表独有的字段
|
605
|
-
task_runs_only_fields = ['task_name', 'consumer_group', 'worker_id', 'duration_ms',
|
606
|
-
'retry_count', 'error_message', 'result', 'start_time',
|
607
|
-
'end_time', 'consumer_name']
|
608
|
-
# tasks表和task_runs表都有的字段
|
609
|
-
both_tables_fields = ['status']
|
610
|
-
# tasks表独有的字段
|
611
|
-
tasks_only_fields = ['stream_id', 'queue', 'namespace', 'scheduled_task_id',
|
612
|
-
'payload', 'priority', 'created_at', 'source', 'metadata']
|
613
|
-
|
614
|
-
param_name = f'filter_{idx}_value'
|
615
|
-
|
616
|
-
if field in task_runs_only_fields:
|
617
|
-
# 只在task_runs表中的字段,只能用于completed_rate和failed_count查询
|
618
|
-
# enqueued_rate查询不支持这些字段
|
619
|
-
|
620
|
-
# 特殊处理空值判断
|
621
|
-
if operator in ['is_null', 'is_not_null']:
|
622
|
-
if operator == 'is_null':
|
623
|
-
filter_conditions_complete.append(f"tr.{field} IS NULL")
|
624
|
-
else:
|
625
|
-
filter_conditions_complete.append(f"tr.{field} IS NOT NULL")
|
626
|
-
else:
|
627
|
-
op_map = {
|
628
|
-
'eq': '=',
|
629
|
-
'ne': '!=',
|
630
|
-
'contains': 'LIKE',
|
631
|
-
'starts_with': 'LIKE',
|
632
|
-
'ends_with': 'LIKE'
|
633
|
-
}
|
634
|
-
sql_op = op_map.get(operator, '=')
|
635
|
-
|
636
|
-
if operator == 'contains':
|
637
|
-
filter_params[param_name] = f'%{value}%'
|
638
|
-
elif operator == 'starts_with':
|
639
|
-
filter_params[param_name] = f'{value}%'
|
640
|
-
elif operator == 'ends_with':
|
641
|
-
filter_params[param_name] = f'%{value}'
|
642
|
-
else:
|
643
|
-
filter_params[param_name] = value
|
644
|
-
|
645
|
-
filter_conditions_complete.append(f"tr.{field} {sql_op} :{param_name}")
|
646
|
-
|
647
|
-
elif field == 'status':
|
648
|
-
# status字段在两个表中都存在,需要特殊处理
|
649
|
-
# tasks表中没有status字段,task_runs表中有
|
650
|
-
# 对于enqueued_rate,不应用status筛选
|
651
|
-
# 对于completed_rate和failed_count,应用到tr.status
|
652
|
-
|
653
|
-
if operator in ['is_null', 'is_not_null']:
|
654
|
-
if operator == 'is_null':
|
655
|
-
filter_conditions_complete.append(f"tr.{field} IS NULL")
|
656
|
-
else:
|
657
|
-
filter_conditions_complete.append(f"tr.{field} IS NOT NULL")
|
658
|
-
else:
|
659
|
-
op_map = {
|
660
|
-
'eq': '=',
|
661
|
-
'ne': '!=',
|
662
|
-
'contains': 'LIKE',
|
663
|
-
'starts_with': 'LIKE',
|
664
|
-
'ends_with': 'LIKE'
|
665
|
-
}
|
666
|
-
sql_op = op_map.get(operator, '=')
|
667
|
-
|
668
|
-
if operator == 'contains':
|
669
|
-
filter_params[param_name] = f'%{value}%'
|
670
|
-
elif operator == 'starts_with':
|
671
|
-
filter_params[param_name] = f'{value}%'
|
672
|
-
elif operator == 'ends_with':
|
673
|
-
filter_params[param_name] = f'%{value}'
|
674
|
-
else:
|
675
|
-
filter_params[param_name] = value
|
676
|
-
|
677
|
-
filter_conditions_complete.append(f"tr.{field} {sql_op} :{param_name}")
|
678
|
-
|
679
|
-
elif field == 'id':
|
680
|
-
# id字段特殊处理,对应tasks表的stream_id
|
681
|
-
if operator in ['is_null', 'is_not_null']:
|
682
|
-
if operator == 'is_null':
|
683
|
-
filter_conditions_enqueue.append(f"stream_id IS NULL")
|
684
|
-
filter_conditions_complete.append(f"t.stream_id IS NULL")
|
685
|
-
else:
|
686
|
-
filter_conditions_enqueue.append(f"stream_id IS NOT NULL")
|
687
|
-
filter_conditions_complete.append(f"t.stream_id IS NOT NULL")
|
688
|
-
else:
|
689
|
-
op_map = {
|
690
|
-
'eq': '=',
|
691
|
-
'ne': '!=',
|
692
|
-
'contains': 'LIKE',
|
693
|
-
'starts_with': 'LIKE',
|
694
|
-
'ends_with': 'LIKE'
|
695
|
-
}
|
696
|
-
sql_op = op_map.get(operator, '=')
|
697
|
-
|
698
|
-
if operator == 'contains':
|
699
|
-
filter_params[param_name] = f'%{value}%'
|
700
|
-
elif operator == 'starts_with':
|
701
|
-
filter_params[param_name] = f'{value}%'
|
702
|
-
elif operator == 'ends_with':
|
703
|
-
filter_params[param_name] = f'%{value}'
|
704
|
-
else:
|
705
|
-
filter_params[param_name] = value
|
706
|
-
|
707
|
-
filter_conditions_enqueue.append(f"stream_id {sql_op} :{param_name}")
|
708
|
-
filter_conditions_complete.append(f"t.stream_id {sql_op} :{param_name}")
|
709
|
-
|
710
|
-
elif field == 'scheduled_task_id':
|
711
|
-
# scheduled_task_id字段特殊处理,数据库中是TEXT类型,需要转换
|
712
|
-
if operator in ['is_null', 'is_not_null']:
|
713
|
-
if operator == 'is_null':
|
714
|
-
filter_conditions_enqueue.append(f"scheduled_task_id IS NULL")
|
715
|
-
filter_conditions_complete.append(f"t.scheduled_task_id IS NULL")
|
716
|
-
else:
|
717
|
-
filter_conditions_enqueue.append(f"scheduled_task_id IS NOT NULL")
|
718
|
-
filter_conditions_complete.append(f"t.scheduled_task_id IS NOT NULL")
|
719
|
-
else:
|
720
|
-
op_map = {
|
721
|
-
'eq': '=',
|
722
|
-
'ne': '!=',
|
723
|
-
'contains': 'LIKE',
|
724
|
-
'starts_with': 'LIKE',
|
725
|
-
'ends_with': 'LIKE'
|
726
|
-
}
|
727
|
-
sql_op = op_map.get(operator, '=')
|
728
|
-
|
729
|
-
# 将值转换为字符串
|
730
|
-
if operator == 'contains':
|
731
|
-
filter_params[param_name] = f'%{str(value)}%'
|
732
|
-
elif operator == 'starts_with':
|
733
|
-
filter_params[param_name] = f'{str(value)}%'
|
734
|
-
elif operator == 'ends_with':
|
735
|
-
filter_params[param_name] = f'%{str(value)}'
|
736
|
-
else:
|
737
|
-
filter_params[param_name] = str(value)
|
738
|
-
|
739
|
-
filter_conditions_enqueue.append(f"scheduled_task_id {sql_op} :{param_name}")
|
740
|
-
filter_conditions_complete.append(f"t.scheduled_task_id {sql_op} :{param_name}")
|
741
|
-
|
742
|
-
else:
|
743
|
-
# 其他字段默认属于tasks表
|
744
|
-
# 特殊处理空值判断
|
745
|
-
if operator in ['is_null', 'is_not_null']:
|
746
|
-
if operator == 'is_null':
|
747
|
-
filter_conditions_enqueue.append(f"{field} IS NULL")
|
748
|
-
filter_conditions_complete.append(f"t.{field} IS NULL")
|
749
|
-
else:
|
750
|
-
filter_conditions_enqueue.append(f"{field} IS NOT NULL")
|
751
|
-
filter_conditions_complete.append(f"t.{field} IS NOT NULL")
|
752
|
-
else:
|
753
|
-
# 处理其他操作符
|
754
|
-
op_map = {
|
755
|
-
'eq': '=',
|
756
|
-
'ne': '!=',
|
757
|
-
'contains': 'LIKE',
|
758
|
-
'starts_with': 'LIKE',
|
759
|
-
'ends_with': 'LIKE'
|
760
|
-
}
|
761
|
-
sql_op = op_map.get(operator, '=')
|
762
|
-
|
763
|
-
if operator == 'contains':
|
764
|
-
filter_params[param_name] = f'%{value}%'
|
765
|
-
elif operator == 'starts_with':
|
766
|
-
filter_params[param_name] = f'{value}%'
|
767
|
-
elif operator == 'ends_with':
|
768
|
-
filter_params[param_name] = f'%{value}'
|
769
|
-
else:
|
770
|
-
filter_params[param_name] = value
|
771
|
-
|
772
|
-
filter_conditions_enqueue.append(f"{field} {sql_op} :{param_name}")
|
773
|
-
filter_conditions_complete.append(f"t.{field} {sql_op} :{param_name}")
|
774
|
-
|
775
|
-
# 构建额外的WHERE条件
|
776
|
-
extra_where_enqueue = ""
|
777
|
-
extra_where_complete = ""
|
778
|
-
if filter_conditions_enqueue:
|
779
|
-
extra_where_enqueue = " AND " + " AND ".join(filter_conditions_enqueue)
|
780
|
-
if filter_conditions_complete:
|
781
|
-
extra_where_complete = " AND " + " AND ".join(filter_conditions_complete)
|
782
|
-
|
783
|
-
# SQL查询:获取入队速率、完成速率和失败数
|
784
|
-
# 重要:时间桶对齐到固定边界(如整5秒、整分钟),确保聚合区间稳定
|
785
|
-
query = text(f"""
|
786
|
-
WITH time_series AS (
|
787
|
-
-- 生成对齐到固定边界的时间序列
|
788
|
-
-- 结束时间需要加一个间隔,确保包含所有在end_time之前的数据
|
789
|
-
SELECT generate_series(
|
790
|
-
to_timestamp(FLOOR(EXTRACT(epoch FROM CAST(:start_time AS timestamptz)) / {interval_seconds}) * {interval_seconds}),
|
791
|
-
to_timestamp(CEILING(EXTRACT(epoch FROM CAST(:end_time AS timestamptz)) / {interval_seconds}) * {interval_seconds} + {interval_seconds}),
|
792
|
-
CAST(:interval AS interval)
|
793
|
-
) AS time_bucket
|
794
|
-
),
|
795
|
-
enqueued_rate AS (
|
796
|
-
SELECT
|
797
|
-
-- 对齐到固定的时间边界
|
798
|
-
to_timestamp(
|
799
|
-
FLOOR(EXTRACT(epoch FROM created_at) / {interval_seconds}) * {interval_seconds}
|
800
|
-
) AS time_bucket,
|
801
|
-
COUNT(*) AS count
|
802
|
-
FROM tasks
|
803
|
-
WHERE (queue = :queue_name OR queue LIKE :queue_pattern)
|
804
|
-
AND created_at >= :start_time
|
805
|
-
AND created_at <= :end_time
|
806
|
-
{extra_where_enqueue}
|
807
|
-
GROUP BY 1
|
808
|
-
),
|
809
|
-
completed_rate AS (
|
810
|
-
SELECT
|
811
|
-
-- 对齐到固定的时间边界
|
812
|
-
to_timestamp(
|
813
|
-
FLOOR(EXTRACT(epoch FROM tr.end_time) / {interval_seconds}) * {interval_seconds}
|
814
|
-
) AS time_bucket,
|
815
|
-
COUNT(*) AS count
|
816
|
-
FROM tasks t
|
817
|
-
JOIN task_runs tr ON t.stream_id = tr.stream_id
|
818
|
-
WHERE (t.queue = :queue_name OR t.queue LIKE :queue_pattern)
|
819
|
-
AND tr.end_time >= :start_time
|
820
|
-
AND tr.end_time <= :end_time
|
821
|
-
AND tr.status = 'success'
|
822
|
-
{extra_where_complete}
|
823
|
-
GROUP BY 1
|
824
|
-
),
|
825
|
-
failed_count AS (
|
826
|
-
SELECT
|
827
|
-
-- 对齐到固定的时间边界
|
828
|
-
to_timestamp(
|
829
|
-
FLOOR(EXTRACT(epoch FROM tr.end_time) / {interval_seconds}) * {interval_seconds}
|
830
|
-
) AS time_bucket,
|
831
|
-
COUNT(*) AS count
|
832
|
-
FROM tasks t
|
833
|
-
JOIN task_runs tr ON t.stream_id = tr.stream_id
|
834
|
-
WHERE (t.queue = :queue_name OR t.queue LIKE :queue_pattern)
|
835
|
-
AND tr.end_time >= :start_time
|
836
|
-
AND tr.end_time <= :end_time
|
837
|
-
AND tr.status IN ('failed', 'error')
|
838
|
-
{extra_where_complete}
|
839
|
-
GROUP BY 1
|
840
|
-
)
|
841
|
-
SELECT
|
842
|
-
ts.time_bucket,
|
843
|
-
COALESCE(e.count, 0) AS enqueued,
|
844
|
-
COALESCE(c.count, 0) AS completed,
|
845
|
-
COALESCE(f.count, 0) AS failed
|
846
|
-
FROM time_series ts
|
847
|
-
LEFT JOIN enqueued_rate e ON ts.time_bucket = e.time_bucket
|
848
|
-
LEFT JOIN completed_rate c ON ts.time_bucket = c.time_bucket
|
849
|
-
LEFT JOIN failed_count f ON ts.time_bucket = f.time_bucket
|
850
|
-
ORDER BY ts.time_bucket
|
851
|
-
""")
|
852
|
-
|
853
|
-
# 合并参数
|
854
|
-
params = {
|
855
|
-
'queue_name': queue_name,
|
856
|
-
'queue_pattern': f'{queue_name}:%', # 匹配所有优先级队列
|
857
|
-
'start_time': start_time,
|
858
|
-
'end_time': end_time,
|
859
|
-
'interval': interval
|
860
|
-
}
|
861
|
-
params.update(filter_params)
|
862
|
-
|
863
|
-
logger.info(f"执行查询 - 队列: {queue_name}, 时间范围: {start_time} 到 {end_time}, 间隔: {interval}, 筛选条件: {len(filter_conditions_enqueue) + len(filter_conditions_complete)} 个")
|
864
|
-
|
865
|
-
result = await session.execute(query, params)
|
866
|
-
|
867
|
-
rows = result.fetchall()
|
868
|
-
logger.info(f"查询返回 {len(rows)} 行数据")
|
869
|
-
|
870
|
-
# 转换为前端需要的格式
|
871
|
-
data = []
|
872
|
-
total_enqueued = 0
|
873
|
-
total_completed = 0
|
874
|
-
total_failed = 0
|
875
|
-
end_index = len(rows) - 1
|
876
|
-
|
877
|
-
# 根据status筛选决定显示什么指标
|
878
|
-
if has_status_filter:
|
879
|
-
# 有status筛选时,需要特殊处理
|
880
|
-
for idx, row in enumerate(rows):
|
881
|
-
time_point = row.time_bucket.isoformat()
|
882
|
-
|
883
|
-
# 累计统计
|
884
|
-
total_enqueued += row.enqueued
|
885
|
-
|
886
|
-
# 添加入队速率数据点(蓝色)
|
887
|
-
data.append({
|
888
|
-
'time': time_point,
|
889
|
-
'value': row.enqueued or None if idx > 0 and end_index != idx else row.enqueued,
|
890
|
-
'metric': '入队速率'
|
891
|
-
})
|
892
|
-
|
893
|
-
# 根据筛选的状态决定是否显示完成速率和失败数
|
894
|
-
if status_filter_value == 'success':
|
895
|
-
# 筛选成功任务时,显示完成速率,不显示失败数
|
896
|
-
total_completed += row.completed
|
897
|
-
data.append({
|
898
|
-
'time': time_point,
|
899
|
-
'value': row.completed or None if idx > 0 and end_index != idx else row.completed,
|
900
|
-
'metric': '完成速率'
|
901
|
-
})
|
902
|
-
data.append({
|
903
|
-
'time': time_point,
|
904
|
-
'value': None,
|
905
|
-
'metric': '失败数'
|
906
|
-
})
|
907
|
-
elif status_filter_value == 'error':
|
908
|
-
# 筛选失败任务时,不显示完成速率,显示失败数
|
909
|
-
total_failed += row.failed
|
910
|
-
data.append({
|
911
|
-
'time': time_point,
|
912
|
-
'value': None,
|
913
|
-
'metric': '完成速率'
|
914
|
-
})
|
915
|
-
data.append({
|
916
|
-
'time': time_point,
|
917
|
-
'value': row.failed or None if idx > 0 and end_index != idx else row.failed,
|
918
|
-
'metric': '失败数'
|
919
|
-
})
|
920
|
-
else:
|
921
|
-
# 其他状态(running, pending, rejected等),不显示完成速率和失败数
|
922
|
-
data.append({
|
923
|
-
'time': time_point,
|
924
|
-
'value': None,
|
925
|
-
'metric': '完成速率'
|
926
|
-
})
|
927
|
-
data.append({
|
928
|
-
'time': time_point,
|
929
|
-
'value': None,
|
930
|
-
'metric': '失败数'
|
931
|
-
})
|
932
|
-
else:
|
933
|
-
# 默认或其他状态筛选:显示标准指标
|
934
|
-
for idx, row in enumerate(rows):
|
935
|
-
time_point = row.time_bucket.isoformat()
|
936
|
-
|
937
|
-
# 累计统计
|
938
|
-
total_enqueued += row.enqueued
|
939
|
-
total_completed += row.completed
|
940
|
-
total_failed += row.failed
|
941
|
-
|
942
|
-
# 添加入队速率数据点(蓝色)
|
943
|
-
data.append({
|
944
|
-
'time': time_point,
|
945
|
-
'value': row.enqueued or None if idx > 0 and end_index != idx else row.enqueued,
|
946
|
-
'metric': '入队速率'
|
947
|
-
})
|
948
|
-
# 添加完成速率数据点(绿色)
|
949
|
-
data.append({
|
950
|
-
'time': time_point,
|
951
|
-
'value': row.completed or None if idx > 0 and end_index != idx else row.completed,
|
952
|
-
'metric': '完成速率'
|
953
|
-
})
|
954
|
-
# 添加失败数数据点(红色)
|
955
|
-
data.append({
|
956
|
-
'time': time_point,
|
957
|
-
'value': row.failed or None if idx > 0 and end_index != idx else row.failed,
|
958
|
-
'metric': '失败数'
|
959
|
-
})
|
960
|
-
|
961
|
-
# 调试日志:每10个点输出一次
|
962
|
-
if idx % 10 == 0 or idx == len(rows) - 1:
|
963
|
-
logger.debug(f"Row {idx}: time={time_point}, enqueued={row.enqueued}, completed={row.completed}, failed={row.failed}")
|
964
|
-
|
965
|
-
logger.info(f"数据汇总 - 总入队: {total_enqueued}, 总完成: {total_completed}, 总失败: {total_failed}")
|
966
|
-
|
967
|
-
return data, granularity
|
968
|
-
|
969
|
-
except Exception as e:
|
970
|
-
logger.error(f"获取队列流量速率失败: {e}")
|
971
|
-
import traceback
|
972
|
-
traceback.print_exc()
|
973
|
-
raise
|
974
|
-
|
975
|
-
async def fetch_queue_timeline_data(self,
|
976
|
-
queues: List[str],
|
977
|
-
start_time: datetime,
|
978
|
-
end_time: datetime,
|
979
|
-
filters: List[Dict] = None) -> List[Dict]:
|
980
|
-
"""获取队列时间线数据 - 优化版本,使用generate_series生成完整时间序列
|
981
|
-
|
982
|
-
Args:
|
983
|
-
queues: 队列名称列表
|
984
|
-
start_time: 开始时间
|
985
|
-
end_time: 结束时间
|
986
|
-
filters: 筛选条件列表,与fetch_tasks_with_filters的格式相同
|
987
|
-
"""
|
988
|
-
try:
|
989
|
-
if not self.AsyncSessionLocal:
|
990
|
-
await self.initialize()
|
991
|
-
|
992
|
-
async with self.AsyncSessionLocal() as session:
|
993
|
-
# 构建队列名称列表字符串
|
994
|
-
queue_names_str = "', '".join(queues)
|
995
|
-
|
996
|
-
# 动态计算时间间隔,目标是生成约200个时间点
|
997
|
-
TARGET_POINTS = 200
|
998
|
-
duration = (end_time - start_time).total_seconds()
|
999
|
-
|
1000
|
-
# 计算理想的间隔秒数
|
1001
|
-
ideal_interval_seconds = duration / TARGET_POINTS
|
1002
|
-
|
1003
|
-
# 将间隔秒数规范化到合理的值
|
1004
|
-
if ideal_interval_seconds <= 1:
|
1005
|
-
interval_seconds = 1
|
1006
|
-
interval = '1 seconds'
|
1007
|
-
trunc_unit = 'second'
|
1008
|
-
elif ideal_interval_seconds <= 5:
|
1009
|
-
interval_seconds = 5
|
1010
|
-
interval = '5 seconds'
|
1011
|
-
trunc_unit = 'second'
|
1012
|
-
elif ideal_interval_seconds <= 10:
|
1013
|
-
interval_seconds = 10
|
1014
|
-
interval = '10 seconds'
|
1015
|
-
trunc_unit = 'second'
|
1016
|
-
elif ideal_interval_seconds <= 30:
|
1017
|
-
interval_seconds = 30
|
1018
|
-
interval = '30 seconds'
|
1019
|
-
trunc_unit = 'second'
|
1020
|
-
elif ideal_interval_seconds <= 60:
|
1021
|
-
interval_seconds = 60
|
1022
|
-
interval = '1 minute'
|
1023
|
-
trunc_unit = 'minute'
|
1024
|
-
elif ideal_interval_seconds <= 120:
|
1025
|
-
interval_seconds = 120
|
1026
|
-
interval = '2 minutes'
|
1027
|
-
trunc_unit = 'minute'
|
1028
|
-
elif ideal_interval_seconds <= 300:
|
1029
|
-
interval_seconds = 300
|
1030
|
-
interval = '5 minutes'
|
1031
|
-
trunc_unit = 'minute'
|
1032
|
-
elif ideal_interval_seconds <= 600:
|
1033
|
-
interval_seconds = 600
|
1034
|
-
interval = '10 minutes'
|
1035
|
-
trunc_unit = 'minute'
|
1036
|
-
elif ideal_interval_seconds <= 900:
|
1037
|
-
interval_seconds = 900
|
1038
|
-
interval = '15 minutes'
|
1039
|
-
trunc_unit = 'minute'
|
1040
|
-
elif ideal_interval_seconds <= 1800:
|
1041
|
-
interval_seconds = 1800
|
1042
|
-
interval = '30 minutes'
|
1043
|
-
trunc_unit = 'minute'
|
1044
|
-
elif ideal_interval_seconds <= 3600:
|
1045
|
-
interval_seconds = 3600
|
1046
|
-
interval = '1 hour'
|
1047
|
-
trunc_unit = 'hour'
|
1048
|
-
elif ideal_interval_seconds <= 7200:
|
1049
|
-
interval_seconds = 7200
|
1050
|
-
interval = '2 hours'
|
1051
|
-
trunc_unit = 'hour'
|
1052
|
-
elif ideal_interval_seconds <= 14400:
|
1053
|
-
interval_seconds = 14400
|
1054
|
-
interval = '4 hours'
|
1055
|
-
trunc_unit = 'hour'
|
1056
|
-
elif ideal_interval_seconds <= 21600:
|
1057
|
-
interval_seconds = 21600
|
1058
|
-
interval = '6 hours'
|
1059
|
-
trunc_unit = 'hour'
|
1060
|
-
elif ideal_interval_seconds <= 43200:
|
1061
|
-
interval_seconds = 43200
|
1062
|
-
interval = '12 hours'
|
1063
|
-
trunc_unit = 'hour'
|
1064
|
-
else:
|
1065
|
-
interval_seconds = 86400
|
1066
|
-
interval = '1 day'
|
1067
|
-
trunc_unit = 'day'
|
1068
|
-
|
1069
|
-
# 重新计算实际点数
|
1070
|
-
actual_points = int(duration / interval_seconds) + 1
|
1071
|
-
logger.info(f"使用时间间隔: {interval_seconds}秒 ({interval}), 预计生成 {actual_points} 个时间点")
|
1072
|
-
|
1073
|
-
# 构建筛选条件的WHERE子句
|
1074
|
-
filter_conditions = []
|
1075
|
-
filter_params = {}
|
1076
|
-
|
1077
|
-
if filters:
|
1078
|
-
for idx, filter_item in enumerate(filters):
|
1079
|
-
# 跳过被禁用的筛选条件
|
1080
|
-
if filter_item.get('enabled') == False:
|
1081
|
-
continue
|
1082
|
-
|
1083
|
-
field = filter_item.get('field')
|
1084
|
-
operator = filter_item.get('operator')
|
1085
|
-
value = filter_item.get('value')
|
1086
|
-
|
1087
|
-
if not field or not operator:
|
1088
|
-
continue
|
1089
|
-
|
1090
|
-
# 特殊处理空值判断
|
1091
|
-
if operator in ['is_null', 'is_not_null']:
|
1092
|
-
if operator == 'is_null':
|
1093
|
-
filter_conditions.append(f"{field} IS NULL")
|
1094
|
-
else:
|
1095
|
-
filter_conditions.append(f"{field} IS NOT NULL")
|
1096
|
-
# 处理IN和NOT IN操作符
|
1097
|
-
elif operator in ['in', 'not_in']:
|
1098
|
-
param_name = f'filter_{idx}_value'
|
1099
|
-
if isinstance(value, list):
|
1100
|
-
values_str = "', '".join(str(v) for v in value)
|
1101
|
-
if operator == 'in':
|
1102
|
-
filter_conditions.append(f"{field} IN ('{values_str}')")
|
1103
|
-
else:
|
1104
|
-
filter_conditions.append(f"{field} NOT IN ('{values_str}')")
|
1105
|
-
else:
|
1106
|
-
if operator == 'in':
|
1107
|
-
filter_conditions.append(f"{field} = :{param_name}")
|
1108
|
-
else:
|
1109
|
-
filter_conditions.append(f"{field} != :{param_name}")
|
1110
|
-
filter_params[param_name] = value
|
1111
|
-
# 处理包含操作符
|
1112
|
-
elif operator == 'contains':
|
1113
|
-
param_name = f'filter_{idx}_value'
|
1114
|
-
# 特殊处理JSON字段
|
1115
|
-
if field in ['task_data', 'result']:
|
1116
|
-
filter_conditions.append(f"{field}::text LIKE :{param_name}")
|
1117
|
-
else:
|
1118
|
-
filter_conditions.append(f"{field} LIKE :{param_name}")
|
1119
|
-
filter_params[param_name] = f'%{value}%'
|
1120
|
-
# 处理JSON相关操作符
|
1121
|
-
elif operator == 'json_key_exists':
|
1122
|
-
# 检查JSON中是否存在指定的键
|
1123
|
-
if field in ['task_data', 'result']:
|
1124
|
-
param_name = f'filter_{idx}_value'
|
1125
|
-
filter_conditions.append(f"{field} ? :{param_name}")
|
1126
|
-
filter_params[param_name] = value
|
1127
|
-
elif operator == 'json_path_value':
|
1128
|
-
# 使用JSON路径查询
|
1129
|
-
if field in ['task_data', 'result'] and '=' in value:
|
1130
|
-
import re
|
1131
|
-
path, val = value.split('=', 1)
|
1132
|
-
path = path.strip()
|
1133
|
-
val = val.strip()
|
1134
|
-
if path.startswith('$.'):
|
1135
|
-
path = path[2:]
|
1136
|
-
path_parts = path.split('.')
|
1137
|
-
# 验证路径安全性
|
1138
|
-
if all(re.match(r'^[a-zA-Z0-9_]+$', part) for part in path_parts):
|
1139
|
-
param_name = f'filter_{idx}_value'
|
1140
|
-
if len(path_parts) == 1:
|
1141
|
-
filter_conditions.append(f"{field}->>'{path_parts[0]}' = :{param_name}")
|
1142
|
-
else:
|
1143
|
-
path_str = '{' + ','.join(path_parts) + '}'
|
1144
|
-
filter_conditions.append(f"{field}#>>'{path_str}' = :{param_name}")
|
1145
|
-
filter_params[param_name] = val.strip('"').strip("'")
|
1146
|
-
elif operator == 'starts_with':
|
1147
|
-
param_name = f'filter_{idx}_value'
|
1148
|
-
filter_conditions.append(f"{field} LIKE :{param_name}")
|
1149
|
-
filter_params[param_name] = f'{value}%'
|
1150
|
-
elif operator == 'ends_with':
|
1151
|
-
param_name = f'filter_{idx}_value'
|
1152
|
-
filter_conditions.append(f"{field} LIKE :{param_name}")
|
1153
|
-
filter_params[param_name] = f'%{value}'
|
1154
|
-
# 处理标准比较操作符
|
1155
|
-
else:
|
1156
|
-
param_name = f'filter_{idx}_value'
|
1157
|
-
op_map = {
|
1158
|
-
'eq': '=',
|
1159
|
-
'ne': '!=',
|
1160
|
-
'gt': '>',
|
1161
|
-
'lt': '<',
|
1162
|
-
'gte': '>=',
|
1163
|
-
'lte': '<='
|
1164
|
-
}
|
1165
|
-
sql_op = op_map.get(operator, '=')
|
1166
|
-
filter_conditions.append(f"{field} {sql_op} :{param_name}")
|
1167
|
-
filter_params[param_name] = value
|
1168
|
-
|
1169
|
-
# 构建额外的WHERE条件
|
1170
|
-
extra_where = ""
|
1171
|
-
if filter_conditions:
|
1172
|
-
extra_where = " AND " + " AND ".join(filter_conditions)
|
1173
|
-
|
1174
|
-
# 优化的SQL查询 - 使用generate_series和CROSS JOIN生成完整的时间序列
|
1175
|
-
# 重要:时间桶对齐到固定边界,而不是基于start_time
|
1176
|
-
query = text(f"""
|
1177
|
-
WITH time_series AS (
|
1178
|
-
-- 生成对齐到固定边界的时间序列
|
1179
|
-
-- 先计算对齐后的起始和结束时间
|
1180
|
-
SELECT generate_series(
|
1181
|
-
to_timestamp(FLOOR(EXTRACT(epoch FROM CAST(:start_time AS timestamptz)) / {interval_seconds}) * {interval_seconds}),
|
1182
|
-
to_timestamp(CEILING(EXTRACT(epoch FROM CAST(:end_time AS timestamptz)) / {interval_seconds}) * {interval_seconds} + {interval_seconds}),
|
1183
|
-
CAST(:interval AS interval)
|
1184
|
-
) AS time_bucket
|
1185
|
-
),
|
1186
|
-
queue_list AS (
|
1187
|
-
SELECT UNNEST(ARRAY['{queue_names_str}']) AS queue_name
|
1188
|
-
),
|
1189
|
-
queue_data AS (
|
1190
|
-
SELECT
|
1191
|
-
-- 对齐到固定的时间边界
|
1192
|
-
-- 例如:5秒间隔会对齐到 00:00, 00:05, 00:10...
|
1193
|
-
to_timestamp(
|
1194
|
-
FLOOR(EXTRACT(epoch FROM created_at) / {interval_seconds}) * {interval_seconds}
|
1195
|
-
) AS time_bucket,
|
1196
|
-
queue AS queue_name,
|
1197
|
-
COUNT(*) as task_count
|
1198
|
-
FROM tasks
|
1199
|
-
WHERE queue IN ('{queue_names_str}')
|
1200
|
-
AND created_at >= :start_time
|
1201
|
-
AND created_at <= :end_time
|
1202
|
-
{extra_where}
|
1203
|
-
GROUP BY 1, 2
|
1204
|
-
)
|
1205
|
-
SELECT
|
1206
|
-
ts.time_bucket,
|
1207
|
-
ql.queue_name,
|
1208
|
-
COALESCE(qd.task_count, 0) as value
|
1209
|
-
FROM time_series ts
|
1210
|
-
CROSS JOIN queue_list ql
|
1211
|
-
LEFT JOIN queue_data qd
|
1212
|
-
ON ts.time_bucket = qd.time_bucket
|
1213
|
-
AND ql.queue_name = qd.queue_name
|
1214
|
-
ORDER BY ts.time_bucket, ql.queue_name
|
1215
|
-
""")
|
1216
|
-
|
1217
|
-
# 合并参数
|
1218
|
-
query_params = {
|
1219
|
-
'start_time': start_time,
|
1220
|
-
'end_time': end_time,
|
1221
|
-
'interval': interval
|
1222
|
-
}
|
1223
|
-
query_params.update(filter_params)
|
1224
|
-
|
1225
|
-
result = await session.execute(query, query_params)
|
1226
|
-
|
1227
|
-
# 直接转换结果为前端需要的格式
|
1228
|
-
timeline_data = []
|
1229
|
-
prev_time = None
|
1230
|
-
time_index = 0
|
1231
|
-
result_data = list(result)
|
1232
|
-
end_index = len(result_data) - 1
|
1233
|
-
for idx, row in enumerate(result_data):
|
1234
|
-
time_str = row.time_bucket.isoformat()
|
1235
|
-
|
1236
|
-
# 跟踪时间索引(用于决定是否将0值显示为None)
|
1237
|
-
if prev_time != time_str:
|
1238
|
-
time_index += 1
|
1239
|
-
prev_time = time_str
|
1240
|
-
|
1241
|
-
# 第一个时间点显示0,后续时间点如果是0则显示为None(用于图表美观)
|
1242
|
-
value = row.value
|
1243
|
-
if time_index > 1 and value == 0 and idx != end_index:
|
1244
|
-
value = None
|
1245
|
-
timeline_data.append({
|
1246
|
-
'time': time_str,
|
1247
|
-
'queue': row.queue_name,
|
1248
|
-
'value': value
|
1249
|
-
})
|
1250
|
-
|
1251
|
-
logger.info(f"生成了 {len(timeline_data)} 个数据点")
|
1252
|
-
return timeline_data
|
1253
|
-
|
1254
|
-
except Exception as e:
|
1255
|
-
logger.error(f"获取队列时间线数据失败: {e}")
|
1256
|
-
return []
|
1257
|
-
|
1258
|
-
|
1259
|
-
async def fetch_global_stats(self) -> Dict:
|
1260
|
-
"""获取全局统计信息"""
|
1261
|
-
try:
|
1262
|
-
redis_client = await self.get_redis_client()
|
1263
|
-
|
1264
|
-
# 获取所有队列的统计信息
|
1265
|
-
queues_data = await self.fetch_queues_data()
|
1266
|
-
|
1267
|
-
total_pending = sum(q['待处理'] for q in queues_data)
|
1268
|
-
total_processing = sum(q['处理中'] for q in queues_data)
|
1269
|
-
total_completed = sum(q['已完成'] for q in queues_data)
|
1270
|
-
total_failed = sum(q['失败'] for q in queues_data)
|
1271
|
-
|
1272
|
-
# 获取活跃worker数量
|
1273
|
-
worker_keys = await redis_client.keys(f"{self.redis_prefix}:worker:*")
|
1274
|
-
active_workers = len(worker_keys)
|
1275
|
-
|
1276
|
-
await redis_client.close()
|
1277
|
-
|
1278
|
-
return {
|
1279
|
-
'total_queues': len(queues_data),
|
1280
|
-
'total_pending': total_pending,
|
1281
|
-
'total_processing': total_processing,
|
1282
|
-
'total_completed': total_completed,
|
1283
|
-
'total_failed': total_failed,
|
1284
|
-
'active_workers': active_workers,
|
1285
|
-
'timestamp': datetime.now(timezone.utc).isoformat()
|
1286
|
-
}
|
1287
|
-
|
1288
|
-
except Exception as e:
|
1289
|
-
logger.error(f"获取全局统计信息失败: {e}")
|
1290
|
-
return {}
|
1291
|
-
|
1292
|
-
async def fetch_tasks_with_filters(self,
|
1293
|
-
queue_name: str,
|
1294
|
-
page: int = 1,
|
1295
|
-
page_size: int = 20,
|
1296
|
-
filters: List[Dict] = None,
|
1297
|
-
start_time: Optional[datetime] = None,
|
1298
|
-
end_time: Optional[datetime] = None) -> Dict:
|
1299
|
-
"""获取带灵活筛选条件的任务列表
|
1300
|
-
|
1301
|
-
Args:
|
1302
|
-
queue_name: 队列名称
|
1303
|
-
page: 页码
|
1304
|
-
page_size: 每页大小
|
1305
|
-
filters: 筛选条件列表,每个条件包含:
|
1306
|
-
- field: 字段名 (task_id, status, worker_id, created_at, etc.)
|
1307
|
-
- operator: 操作符 (eq, ne, gt, lt, gte, lte, in, not_in, contains)
|
1308
|
-
- value: 比较值
|
1309
|
-
"""
|
1310
|
-
try:
|
1311
|
-
if not self.AsyncSessionLocal:
|
1312
|
-
await self.initialize()
|
1313
|
-
|
1314
|
-
async with self.AsyncSessionLocal() as session:
|
1315
|
-
# 构建基础查询
|
1316
|
-
query_parts = []
|
1317
|
-
params = {'queue_name': queue_name}
|
1318
|
-
|
1319
|
-
# 基础条件
|
1320
|
-
query_parts.append("queue = :queue_name")
|
1321
|
-
|
1322
|
-
# 添加时间范围筛选
|
1323
|
-
if start_time:
|
1324
|
-
query_parts.append("created_at >= :start_time")
|
1325
|
-
params['start_time'] = start_time
|
1326
|
-
if end_time:
|
1327
|
-
query_parts.append("created_at <= :end_time")
|
1328
|
-
params['end_time'] = end_time
|
1329
|
-
|
1330
|
-
# 构建动态筛选条件
|
1331
|
-
if filters:
|
1332
|
-
for idx, filter_item in enumerate(filters):
|
1333
|
-
# 跳过被禁用的筛选条件
|
1334
|
-
if filter_item.get('enabled') == False:
|
1335
|
-
continue
|
1336
|
-
|
1337
|
-
field = filter_item.get('field')
|
1338
|
-
operator = filter_item.get('operator')
|
1339
|
-
value = filter_item.get('value')
|
1340
|
-
|
1341
|
-
if not field or not operator or value is None:
|
1342
|
-
continue
|
1343
|
-
|
1344
|
-
param_name = f"filter_{idx}"
|
1345
|
-
|
1346
|
-
# 根据操作符构建SQL条件
|
1347
|
-
if operator == 'eq':
|
1348
|
-
query_parts.append(f"{field} = :{param_name}")
|
1349
|
-
params[param_name] = value
|
1350
|
-
elif operator == 'ne':
|
1351
|
-
query_parts.append(f"{field} != :{param_name}")
|
1352
|
-
params[param_name] = value
|
1353
|
-
elif operator == 'gt':
|
1354
|
-
query_parts.append(f"{field} > :{param_name}")
|
1355
|
-
params[param_name] = value
|
1356
|
-
elif operator == 'lt':
|
1357
|
-
query_parts.append(f"{field} < :{param_name}")
|
1358
|
-
params[param_name] = value
|
1359
|
-
elif operator == 'gte':
|
1360
|
-
query_parts.append(f"{field} >= :{param_name}")
|
1361
|
-
params[param_name] = value
|
1362
|
-
elif operator == 'lte':
|
1363
|
-
query_parts.append(f"{field} <= :{param_name}")
|
1364
|
-
params[param_name] = value
|
1365
|
-
elif operator == 'in':
|
1366
|
-
# 处理IN操作符
|
1367
|
-
if isinstance(value, str):
|
1368
|
-
value = value.split(',')
|
1369
|
-
in_params = []
|
1370
|
-
for i, v in enumerate(value):
|
1371
|
-
in_param_name = f"{param_name}_{i}"
|
1372
|
-
in_params.append(f":{in_param_name}")
|
1373
|
-
params[in_param_name] = v.strip() if isinstance(v, str) else v
|
1374
|
-
query_parts.append(f"{field} IN ({','.join(in_params)})")
|
1375
|
-
elif operator == 'not_in':
|
1376
|
-
# 处理NOT IN操作符
|
1377
|
-
if isinstance(value, str):
|
1378
|
-
value = value.split(',')
|
1379
|
-
not_in_params = []
|
1380
|
-
for i, v in enumerate(value):
|
1381
|
-
not_in_param_name = f"{param_name}_{i}"
|
1382
|
-
not_in_params.append(f":{not_in_param_name}")
|
1383
|
-
params[not_in_param_name] = v.strip() if isinstance(v, str) else v
|
1384
|
-
query_parts.append(f"{field} NOT IN ({','.join(not_in_params)})")
|
1385
|
-
elif operator == 'contains':
|
1386
|
-
# 特殊处理JSON字段的搜索
|
1387
|
-
if field in ['task_data', 'result']:
|
1388
|
-
# 对JSON字段使用JSONB的文本搜索
|
1389
|
-
query_parts.append(f"{field}::text LIKE :{param_name}")
|
1390
|
-
params[param_name] = f"%{value}%"
|
1391
|
-
else:
|
1392
|
-
query_parts.append(f"{field} LIKE :{param_name}")
|
1393
|
-
params[param_name] = f"%{value}%"
|
1394
|
-
elif operator == 'json_key_exists':
|
1395
|
-
# 检查JSON中是否存在指定的键
|
1396
|
-
if field in ['task_data', 'result']:
|
1397
|
-
query_parts.append(f"{field} ? :{param_name}")
|
1398
|
-
params[param_name] = value
|
1399
|
-
elif operator == 'json_key_value':
|
1400
|
-
# 检查JSON中指定键的值
|
1401
|
-
if field in ['task_data', 'result'] and '=' in value:
|
1402
|
-
key, val = value.split('=', 1)
|
1403
|
-
key = key.strip()
|
1404
|
-
val = val.strip()
|
1405
|
-
# 注意:PostgreSQL的 ->> 操作符的键名不能使用参数绑定,必须直接嵌入SQL
|
1406
|
-
# 为了安全,对键名进行验证
|
1407
|
-
import re
|
1408
|
-
if not re.match(r'^[a-zA-Z0-9_]+$', key):
|
1409
|
-
continue # 跳过无效的键名
|
1410
|
-
|
1411
|
-
# 尝试解析值的类型
|
1412
|
-
if val.lower() in ['true', 'false']:
|
1413
|
-
# 布尔值
|
1414
|
-
query_parts.append(f"({field}->'{key}')::boolean = :{param_name}_val")
|
1415
|
-
params[f'{param_name}_val'] = val.lower() == 'true'
|
1416
|
-
elif val.isdigit() or (val.startswith('-') and val[1:].isdigit()):
|
1417
|
-
# 整数
|
1418
|
-
query_parts.append(f"({field}->'{key}')::text = :{param_name}_val")
|
1419
|
-
params[f'{param_name}_val'] = val
|
1420
|
-
else:
|
1421
|
-
# 字符串 - 使用 ->> 操作符获取文本值
|
1422
|
-
query_parts.append(f"{field}->>'{key}' = :{param_name}_val")
|
1423
|
-
params[f'{param_name}_val'] = val.strip('"').strip("'")
|
1424
|
-
elif operator == 'json_path_value':
|
1425
|
-
# 使用JSON路径查询
|
1426
|
-
if field in ['task_data', 'result'] and '=' in value:
|
1427
|
-
path, val = value.split('=', 1)
|
1428
|
-
path = path.strip()
|
1429
|
-
val = val.strip()
|
1430
|
-
|
1431
|
-
# 处理路径格式
|
1432
|
-
if path.startswith('$.'):
|
1433
|
-
path = path[2:] # 移除 $.
|
1434
|
-
path_parts = path.split('.')
|
1435
|
-
|
1436
|
-
# 验证路径部分的安全性
|
1437
|
-
import re
|
1438
|
-
if not all(re.match(r'^[a-zA-Z0-9_]+$', part) for part in path_parts):
|
1439
|
-
continue # 跳过无效的路径
|
1440
|
-
|
1441
|
-
# 构建JSONB路径查询
|
1442
|
-
if len(path_parts) == 1:
|
1443
|
-
# 单层路径,同json_key_value处理
|
1444
|
-
query_parts.append(f"{field}->>'{path_parts[0]}' = :{param_name}_val")
|
1445
|
-
else:
|
1446
|
-
# 多层路径,使用 #>> 操作符
|
1447
|
-
path_str = '{' + ','.join(path_parts) + '}'
|
1448
|
-
query_parts.append(f"{field}#>>'{path_str}' = :{param_name}_val")
|
1449
|
-
|
1450
|
-
# 处理值
|
1451
|
-
params[f'{param_name}_val'] = val.strip('"').strip("'")
|
1452
|
-
elif operator == 'starts_with':
|
1453
|
-
query_parts.append(f"{field} LIKE :{param_name}")
|
1454
|
-
params[param_name] = f"{value}%"
|
1455
|
-
elif operator == 'ends_with':
|
1456
|
-
query_parts.append(f"{field} LIKE :{param_name}")
|
1457
|
-
params[param_name] = f"%{value}"
|
1458
|
-
elif operator == 'is_null':
|
1459
|
-
query_parts.append(f"{field} IS NULL")
|
1460
|
-
elif operator == 'is_not_null':
|
1461
|
-
query_parts.append(f"{field} IS NOT NULL")
|
1462
|
-
|
1463
|
-
# 构建WHERE子句
|
1464
|
-
where_clause = " AND ".join(query_parts)
|
1465
|
-
|
1466
|
-
# 计算总数
|
1467
|
-
count_query = text(f"""
|
1468
|
-
SELECT COUNT(*) as total
|
1469
|
-
FROM tasks
|
1470
|
-
WHERE {where_clause}
|
1471
|
-
""")
|
1472
|
-
|
1473
|
-
count_result = await session.execute(count_query, params)
|
1474
|
-
total = count_result.scalar() or 0
|
1475
|
-
|
1476
|
-
# 获取分页数据(默认不包含task_data、result和error_message以提高性能)
|
1477
|
-
offset = (page - 1) * page_size
|
1478
|
-
data_query = text(f"""
|
1479
|
-
SELECT
|
1480
|
-
id,
|
1481
|
-
queue AS queue_name,
|
1482
|
-
task_name,
|
1483
|
-
status,
|
1484
|
-
worker_id,
|
1485
|
-
created_at,
|
1486
|
-
started_at,
|
1487
|
-
completed_at,
|
1488
|
-
retry_count,
|
1489
|
-
priority,
|
1490
|
-
max_retry,
|
1491
|
-
metadata,
|
1492
|
-
duration,
|
1493
|
-
EXTRACT(epoch FROM (
|
1494
|
-
CASE
|
1495
|
-
WHEN completed_at IS NOT NULL THEN completed_at - started_at
|
1496
|
-
WHEN started_at IS NOT NULL THEN NOW() - started_at
|
1497
|
-
ELSE NULL
|
1498
|
-
END
|
1499
|
-
)) as execution_time
|
1500
|
-
FROM tasks
|
1501
|
-
WHERE {where_clause}
|
1502
|
-
ORDER BY created_at DESC
|
1503
|
-
LIMIT :limit OFFSET :offset
|
1504
|
-
""")
|
1505
|
-
|
1506
|
-
params['limit'] = page_size
|
1507
|
-
params['offset'] = offset
|
1508
|
-
|
1509
|
-
result = await session.execute(data_query, params)
|
1510
|
-
rows = result.fetchall()
|
1511
|
-
|
1512
|
-
# 转换为字典格式
|
1513
|
-
tasks = []
|
1514
|
-
for row in rows:
|
1515
|
-
tasks.append({
|
1516
|
-
'id': row.id,
|
1517
|
-
'queue_name': row.queue_name,
|
1518
|
-
'task_name': row.task_name,
|
1519
|
-
'status': row.status,
|
1520
|
-
'worker_id': row.worker_id,
|
1521
|
-
'created_at': row.created_at.isoformat() if row.created_at else None,
|
1522
|
-
'started_at': row.started_at.isoformat() if row.started_at else None,
|
1523
|
-
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
|
1524
|
-
'execution_time': round(row.execution_time, 5) if row.execution_time else None,
|
1525
|
-
'duration': round(row.duration, 5) if row.duration else None,
|
1526
|
-
'retry_count': row.retry_count,
|
1527
|
-
'priority': row.priority,
|
1528
|
-
'max_retry': row.max_retry
|
1529
|
-
})
|
1530
|
-
|
1531
|
-
return {
|
1532
|
-
'success': True,
|
1533
|
-
'data': tasks,
|
1534
|
-
'total': total,
|
1535
|
-
'page': page,
|
1536
|
-
'page_size': page_size
|
1537
|
-
}
|
1538
|
-
|
1539
|
-
except Exception as e:
|
1540
|
-
logger.error(f"获取任务列表失败: {e}")
|
1541
|
-
import traceback
|
1542
|
-
traceback.print_exc()
|
1543
|
-
return {
|
1544
|
-
'success': False,
|
1545
|
-
'data': [],
|
1546
|
-
'total': 0,
|
1547
|
-
'page': page,
|
1548
|
-
'page_size': page_size,
|
1549
|
-
'error': str(e)
|
1550
|
-
}
|
1551
|
-
|
1552
|
-
# ============= 定时任务相关方法 =============
|
1553
|
-
|
1554
|
-
async def get_scheduled_tasks_statistics(self, session, namespace):
|
1555
|
-
"""获取定时任务统计数据"""
|
1556
|
-
try:
|
1557
|
-
from datetime import datetime, timezone, timedelta
|
1558
|
-
|
1559
|
-
# 获取今天的开始时间(UTC)
|
1560
|
-
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
1561
|
-
|
1562
|
-
# 查询统计数据
|
1563
|
-
# 今日执行次数:统计今天所有定时任务触发生成的tasks记录数
|
1564
|
-
# 成功率:统计今天成功完成的任务占总执行任务的百分比
|
1565
|
-
query = text("""
|
1566
|
-
WITH stats AS (
|
1567
|
-
SELECT
|
1568
|
-
COUNT(*) as total,
|
1569
|
-
COUNT(CASE WHEN enabled = true THEN 1 END) as active
|
1570
|
-
FROM scheduled_tasks
|
1571
|
-
WHERE namespace = :namespace
|
1572
|
-
),
|
1573
|
-
today_tasks AS (
|
1574
|
-
SELECT
|
1575
|
-
COUNT(DISTINCT t.stream_id) as today_count,
|
1576
|
-
COUNT(DISTINCT CASE WHEN tr.status = 'success' THEN t.stream_id END) as success_count
|
1577
|
-
FROM tasks t
|
1578
|
-
LEFT JOIN task_runs tr ON t.stream_id = tr.stream_id
|
1579
|
-
WHERE t.created_at >= :today_start
|
1580
|
-
AND t.scheduled_task_id IS NOT NULL
|
1581
|
-
AND t.namespace = :namespace
|
1582
|
-
)
|
1583
|
-
SELECT
|
1584
|
-
stats.total,
|
1585
|
-
stats.active,
|
1586
|
-
COALESCE(today_tasks.today_count, 0) as today_executions,
|
1587
|
-
CASE
|
1588
|
-
WHEN today_tasks.today_count > 0
|
1589
|
-
THEN ROUND(today_tasks.success_count::numeric * 100.0 / today_tasks.today_count::numeric, 1)
|
1590
|
-
ELSE 0
|
1591
|
-
END as success_rate
|
1592
|
-
FROM stats, today_tasks
|
1593
|
-
""")
|
1594
|
-
|
1595
|
-
result = await session.execute(query, {
|
1596
|
-
'today_start': today_start,
|
1597
|
-
'namespace': namespace
|
1598
|
-
})
|
1599
|
-
row = result.first()
|
1600
|
-
|
1601
|
-
if row:
|
1602
|
-
return {
|
1603
|
-
'total': row.total or 0,
|
1604
|
-
'active': row.active or 0,
|
1605
|
-
'todayExecutions': int(row.today_executions or 0),
|
1606
|
-
'successRate': float(row.success_rate or 0)
|
1607
|
-
}
|
1608
|
-
|
1609
|
-
return {
|
1610
|
-
'total': 0,
|
1611
|
-
'active': 0,
|
1612
|
-
'todayExecutions': 0,
|
1613
|
-
'successRate': 0
|
1614
|
-
}
|
1615
|
-
|
1616
|
-
except Exception as e:
|
1617
|
-
logger.error(f"获取定时任务统计失败: {e}")
|
1618
|
-
raise
|
1619
|
-
|
1620
|
-
async def fetch_scheduled_tasks(self,
|
1621
|
-
session,
|
1622
|
-
page: int = 1,
|
1623
|
-
page_size: int = 20,
|
1624
|
-
search: Optional[str] = None,
|
1625
|
-
is_active: Optional[bool] = None,
|
1626
|
-
filters: Optional[List[Dict]] = None,
|
1627
|
-
time_range: Optional[str] = None,
|
1628
|
-
start_time: Optional[str] = None,
|
1629
|
-
end_time: Optional[str] = None) -> tuple:
|
1630
|
-
"""获取定时任务列表"""
|
1631
|
-
try:
|
1632
|
-
# 构建查询条件
|
1633
|
-
where_conditions = []
|
1634
|
-
params = {}
|
1635
|
-
|
1636
|
-
if search:
|
1637
|
-
where_conditions.append("(task_name ILIKE :search OR description ILIKE :search)")
|
1638
|
-
params['search'] = f"%{search}%"
|
1639
|
-
|
1640
|
-
if is_active is not None:
|
1641
|
-
where_conditions.append("enabled = :is_active")
|
1642
|
-
params['is_active'] = is_active
|
1643
|
-
|
1644
|
-
# 处理时间范围筛选 - 针对下次执行时间
|
1645
|
-
if time_range or (start_time and end_time):
|
1646
|
-
from datetime import datetime, timedelta
|
1647
|
-
import dateutil.parser
|
1648
|
-
import pytz
|
1649
|
-
|
1650
|
-
if start_time and end_time:
|
1651
|
-
# 使用自定义时间范围
|
1652
|
-
params['start_time'] = dateutil.parser.parse(start_time)
|
1653
|
-
params['end_time'] = dateutil.parser.parse(end_time)
|
1654
|
-
else:
|
1655
|
-
# 根据预设时间范围计算
|
1656
|
-
# 使用UTC时间,因为数据库中的next_run_time是UTC时区
|
1657
|
-
now = datetime.now(pytz.UTC)
|
1658
|
-
time_ranges = {
|
1659
|
-
'1h': timedelta(hours=1),
|
1660
|
-
'6h': timedelta(hours=6),
|
1661
|
-
'24h': timedelta(hours=24),
|
1662
|
-
'7d': timedelta(days=7),
|
1663
|
-
'30d': timedelta(days=30)
|
1664
|
-
}
|
1665
|
-
delta = time_ranges.get(time_range, timedelta(hours=24))
|
1666
|
-
# 从现在开始到未来的时间范围
|
1667
|
-
params['start_time'] = now
|
1668
|
-
params['end_time'] = now + delta
|
1669
|
-
|
1670
|
-
# 筛选下次执行时间在指定范围内的任务
|
1671
|
-
where_conditions.append("next_run_time IS NOT NULL AND next_run_time BETWEEN :start_time AND :end_time")
|
1672
|
-
|
1673
|
-
# 处理高级筛选条件
|
1674
|
-
if filters:
|
1675
|
-
for idx, filter_item in enumerate(filters):
|
1676
|
-
if not filter_item.get('enabled', True):
|
1677
|
-
continue
|
1678
|
-
|
1679
|
-
field = filter_item.get('field')
|
1680
|
-
operator = filter_item.get('operator')
|
1681
|
-
value = filter_item.get('value')
|
1682
|
-
|
1683
|
-
# 映射字段名
|
1684
|
-
field_map = {
|
1685
|
-
'id': 'id',
|
1686
|
-
'scheduler_id': 'scheduler_id',
|
1687
|
-
'name': 'task_name',
|
1688
|
-
'queue_name': 'queue_name',
|
1689
|
-
'schedule_type': 'task_type',
|
1690
|
-
'is_active': 'enabled',
|
1691
|
-
'description': 'description',
|
1692
|
-
'last_run': 'last_run_time',
|
1693
|
-
'next_run': 'next_run_time',
|
1694
|
-
'created_at': 'created_at',
|
1695
|
-
'task_data': 'task_kwargs', # 任务参数存储在task_kwargs字段
|
1696
|
-
'tags': 'tags',
|
1697
|
-
'metadata': 'metadata',
|
1698
|
-
}
|
1699
|
-
|
1700
|
-
db_field = field_map.get(field, field)
|
1701
|
-
|
1702
|
-
# 处理不同的操作符
|
1703
|
-
if operator == 'is_null':
|
1704
|
-
where_conditions.append(f"{db_field} IS NULL")
|
1705
|
-
elif operator == 'is_not_null':
|
1706
|
-
where_conditions.append(f"{db_field} IS NOT NULL")
|
1707
|
-
elif operator in ['eq', 'ne', 'gt', 'lt', 'gte', 'lte']:
|
1708
|
-
op_map = {
|
1709
|
-
'eq': '=',
|
1710
|
-
'ne': '!=',
|
1711
|
-
'gt': '>',
|
1712
|
-
'lt': '<',
|
1713
|
-
'gte': '>=',
|
1714
|
-
'lte': '<='
|
1715
|
-
}
|
1716
|
-
param_name = f'filter_{idx}_value'
|
1717
|
-
where_conditions.append(f"{db_field} {op_map[operator]} :{param_name}")
|
1718
|
-
params[param_name] = value
|
1719
|
-
elif operator == 'contains':
|
1720
|
-
param_name = f'filter_{idx}_value'
|
1721
|
-
# 对于JSON字段,需要转换为文本进行搜索
|
1722
|
-
if db_field in ['task_kwargs', 'tags', 'metadata']:
|
1723
|
-
where_conditions.append(f"{db_field}::text ILIKE :{param_name}")
|
1724
|
-
else:
|
1725
|
-
where_conditions.append(f"{db_field} ILIKE :{param_name}")
|
1726
|
-
params[param_name] = f'%{value}%'
|
1727
|
-
elif operator == 'starts_with':
|
1728
|
-
param_name = f'filter_{idx}_value'
|
1729
|
-
where_conditions.append(f"{db_field} ILIKE :{param_name}")
|
1730
|
-
params[param_name] = f'{value}%'
|
1731
|
-
elif operator == 'ends_with':
|
1732
|
-
param_name = f'filter_{idx}_value'
|
1733
|
-
where_conditions.append(f"{db_field} ILIKE :{param_name}")
|
1734
|
-
params[param_name] = f'%{value}'
|
1735
|
-
elif operator in ['in', 'not_in']:
|
1736
|
-
if isinstance(value, list):
|
1737
|
-
placeholders = []
|
1738
|
-
for i, v in enumerate(value):
|
1739
|
-
param_name = f'filter_{idx}_value_{i}'
|
1740
|
-
placeholders.append(f':{param_name}')
|
1741
|
-
params[param_name] = v
|
1742
|
-
op = 'IN' if operator == 'in' else 'NOT IN'
|
1743
|
-
where_conditions.append(f"{db_field} {op} ({','.join(placeholders)})")
|
1744
|
-
elif operator == 'json_key_exists' and db_field in ['task_kwargs', 'tags', 'metadata']:
|
1745
|
-
# JSON字段键存在检查
|
1746
|
-
param_name = f'filter_{idx}_value'
|
1747
|
-
where_conditions.append(f"{db_field}::jsonb ? :{param_name}")
|
1748
|
-
params[param_name] = value
|
1749
|
-
elif operator == 'json_path_value' and db_field in ['task_kwargs', 'tags', 'metadata']:
|
1750
|
-
# JSON路径值匹配 - 使用更简单的路径操作符
|
1751
|
-
if '=' in value:
|
1752
|
-
path, val = value.split('=', 1)
|
1753
|
-
path = path.strip()
|
1754
|
-
val = val.strip().strip('"').strip("'")
|
1755
|
-
|
1756
|
-
# 处理JSON路径
|
1757
|
-
if path.startswith('$.'):
|
1758
|
-
path = path[2:] # 移除 $.
|
1759
|
-
|
1760
|
-
# 特殊处理task_kwargs字段:
|
1761
|
-
# 前端显示的是task_data.kwargs.xxx,但数据库中task_kwargs直接存储的就是kwargs的内容
|
1762
|
-
# 所以需要移除kwargs.前缀
|
1763
|
-
if db_field == 'task_kwargs':
|
1764
|
-
if path.startswith('kwargs.'):
|
1765
|
-
path = path[7:] # 移除 'kwargs.' 前缀
|
1766
|
-
elif path.startswith('args.'):
|
1767
|
-
# args存储在task_args字段,这里不处理
|
1768
|
-
continue
|
1769
|
-
|
1770
|
-
# 分割路径
|
1771
|
-
path_parts = path.split('.')
|
1772
|
-
param_name = f'filter_{idx}_value'
|
1773
|
-
|
1774
|
-
if len(path_parts) == 1:
|
1775
|
-
# 单层路径:使用 ->> 操作符
|
1776
|
-
where_conditions.append(f"{db_field}::jsonb->>'{path_parts[0]}' = :{param_name}")
|
1777
|
-
else:
|
1778
|
-
# 多层路径:使用 #>> 操作符
|
1779
|
-
path_str = '{' + ','.join(path_parts) + '}'
|
1780
|
-
where_conditions.append(f"{db_field}::jsonb#>>'{path_str}' = :{param_name}")
|
1781
|
-
|
1782
|
-
params[param_name] = val
|
1783
|
-
|
1784
|
-
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
1785
|
-
|
1786
|
-
# 计算总数
|
1787
|
-
count_query = text(f"""
|
1788
|
-
SELECT COUNT(*) as total
|
1789
|
-
FROM scheduled_tasks
|
1790
|
-
WHERE {where_clause}
|
1791
|
-
""")
|
1792
|
-
print(f'{count_query.text=}')
|
1793
|
-
count_result = await session.execute(count_query, params)
|
1794
|
-
total = count_result.scalar() or 0
|
1795
|
-
|
1796
|
-
# 获取分页数据
|
1797
|
-
offset = (page - 1) * page_size
|
1798
|
-
data_query = text(f"""
|
1799
|
-
SELECT
|
1800
|
-
id,
|
1801
|
-
scheduler_id,
|
1802
|
-
task_name as name,
|
1803
|
-
queue_name,
|
1804
|
-
task_type as schedule_type,
|
1805
|
-
task_args,
|
1806
|
-
task_kwargs,
|
1807
|
-
cron_expression,
|
1808
|
-
interval_seconds,
|
1809
|
-
enabled as is_active,
|
1810
|
-
description,
|
1811
|
-
tags,
|
1812
|
-
metadata,
|
1813
|
-
last_run_time as last_run,
|
1814
|
-
next_run_time as next_run,
|
1815
|
-
created_at,
|
1816
|
-
updated_at,
|
1817
|
-
COALESCE(execution_count, 0) as execution_count
|
1818
|
-
FROM scheduled_tasks
|
1819
|
-
WHERE {where_clause}
|
1820
|
-
ORDER BY created_at DESC, id ASC
|
1821
|
-
LIMIT :limit OFFSET :offset
|
1822
|
-
""")
|
1823
|
-
params['limit'] = page_size
|
1824
|
-
params['offset'] = offset
|
1825
|
-
|
1826
|
-
result = await session.execute(data_query, params)
|
1827
|
-
tasks = []
|
1828
|
-
for row in result:
|
1829
|
-
task = dict(row._mapping)
|
1830
|
-
|
1831
|
-
# 构建schedule_config字段
|
1832
|
-
if task['schedule_type'] == 'cron':
|
1833
|
-
task['schedule_config'] = {'cron_expression': task.get('cron_expression')}
|
1834
|
-
elif task['schedule_type'] == 'interval':
|
1835
|
-
task['schedule_config'] = {'seconds': float(task.get('interval_seconds', 0))}
|
1836
|
-
else:
|
1837
|
-
task['schedule_config'] = {}
|
1838
|
-
|
1839
|
-
# 合并task_args和task_kwargs为task_data
|
1840
|
-
task['task_data'] = {
|
1841
|
-
'args': task.get('task_args', []),
|
1842
|
-
'kwargs': task.get('task_kwargs', {})
|
1843
|
-
}
|
1844
|
-
|
1845
|
-
# 删除不需要的字段
|
1846
|
-
task.pop('task_args', None)
|
1847
|
-
task.pop('task_kwargs', None)
|
1848
|
-
task.pop('cron_expression', None)
|
1849
|
-
task.pop('interval_seconds', None)
|
1850
|
-
task.pop('scheduler_id', None)
|
1851
|
-
|
1852
|
-
# 转换时间字段为ISO格式字符串
|
1853
|
-
for field in ['last_run', 'next_run', 'created_at', 'updated_at']:
|
1854
|
-
if task.get(field):
|
1855
|
-
task[field] = task[field].isoformat()
|
1856
|
-
|
1857
|
-
tasks.append(task)
|
1858
|
-
|
1859
|
-
return tasks, total
|
1860
|
-
|
1861
|
-
except Exception as e:
|
1862
|
-
logger.error(f"获取定时任务列表失败: {e}")
|
1863
|
-
raise
|
1864
|
-
|
1865
|
-
async def create_scheduled_task(self, session, task_data: Dict) -> Dict:
|
1866
|
-
"""创建定时任务"""
|
1867
|
-
try:
|
1868
|
-
# 生成scheduler_id
|
1869
|
-
scheduler_id = f"task_{datetime.now().strftime('%Y%m%d%H%M%S')}_{int(time.time() * 1000) % 100000}"
|
1870
|
-
|
1871
|
-
# 处理schedule_config
|
1872
|
-
cron_expression = None
|
1873
|
-
interval_seconds = None
|
1874
|
-
if task_data['schedule_type'] == 'cron':
|
1875
|
-
cron_expression = task_data['schedule_config'].get('cron_expression')
|
1876
|
-
elif task_data['schedule_type'] == 'interval':
|
1877
|
-
interval_seconds = task_data['schedule_config'].get('seconds', 60)
|
1878
|
-
|
1879
|
-
# 处理task_data -> task_args和task_kwargs
|
1880
|
-
task_args = task_data.get('task_data', {}).get('args', [])
|
1881
|
-
task_kwargs = task_data.get('task_data', {}).get('kwargs', {})
|
1882
|
-
|
1883
|
-
insert_query = text("""
|
1884
|
-
INSERT INTO scheduled_tasks (
|
1885
|
-
scheduler_id, task_name, queue_name, task_type,
|
1886
|
-
task_args, task_kwargs, cron_expression, interval_seconds,
|
1887
|
-
enabled, description
|
1888
|
-
) VALUES (
|
1889
|
-
:scheduler_id, :task_name, :queue_name, :task_type,
|
1890
|
-
:task_args, :task_kwargs, :cron_expression, :interval_seconds,
|
1891
|
-
:enabled, :description
|
1892
|
-
)
|
1893
|
-
RETURNING *
|
1894
|
-
""")
|
1895
|
-
|
1896
|
-
params = {
|
1897
|
-
'scheduler_id': scheduler_id,
|
1898
|
-
'task_name': task_data['name'],
|
1899
|
-
'queue_name': task_data['queue_name'],
|
1900
|
-
'task_type': task_data['schedule_type'],
|
1901
|
-
'task_args': json.dumps(task_args),
|
1902
|
-
'task_kwargs': json.dumps(task_kwargs),
|
1903
|
-
'cron_expression': cron_expression,
|
1904
|
-
'interval_seconds': interval_seconds,
|
1905
|
-
'enabled': task_data.get('is_active', True),
|
1906
|
-
'description': task_data.get('description')
|
1907
|
-
}
|
1908
|
-
|
1909
|
-
result = await session.execute(insert_query, params)
|
1910
|
-
await session.commit()
|
1911
|
-
|
1912
|
-
created_task = dict(result.first()._mapping)
|
1913
|
-
|
1914
|
-
# 转换为前端格式
|
1915
|
-
created_task['name'] = created_task.pop('task_name', '')
|
1916
|
-
created_task['is_active'] = created_task.pop('enabled', True)
|
1917
|
-
created_task['schedule_type'] = created_task.pop('task_type', '')
|
1918
|
-
|
1919
|
-
# 构建schedule_config
|
1920
|
-
if created_task['schedule_type'] == 'cron':
|
1921
|
-
created_task['schedule_config'] = {'cron_expression': created_task.get('cron_expression')}
|
1922
|
-
elif created_task['schedule_type'] == 'interval':
|
1923
|
-
created_task['schedule_config'] = {'seconds': float(created_task.get('interval_seconds', 0))}
|
1924
|
-
else:
|
1925
|
-
created_task['schedule_config'] = {}
|
1926
|
-
|
1927
|
-
# 合并task_args和task_kwargs为task_data
|
1928
|
-
created_task['task_data'] = {
|
1929
|
-
'args': created_task.get('task_args', []),
|
1930
|
-
'kwargs': created_task.get('task_kwargs', {})
|
1931
|
-
}
|
1932
|
-
|
1933
|
-
# 删除不需要的字段
|
1934
|
-
created_task.pop('task_args', None)
|
1935
|
-
created_task.pop('task_kwargs', None)
|
1936
|
-
created_task.pop('cron_expression', None)
|
1937
|
-
created_task.pop('interval_seconds', None)
|
1938
|
-
created_task.pop('scheduler_id', None)
|
1939
|
-
|
1940
|
-
# 转换时间字段
|
1941
|
-
for field in ['last_run_time', 'next_run_time', 'created_at', 'updated_at']:
|
1942
|
-
if created_task.get(field):
|
1943
|
-
value = created_task.pop(field)
|
1944
|
-
# 重命名字段
|
1945
|
-
if field == 'last_run_time':
|
1946
|
-
created_task['last_run'] = value.isoformat()
|
1947
|
-
elif field == 'next_run_time':
|
1948
|
-
created_task['next_run'] = value.isoformat()
|
1949
|
-
else:
|
1950
|
-
created_task[field] = value.isoformat()
|
1951
|
-
|
1952
|
-
return created_task
|
1953
|
-
|
1954
|
-
except Exception as e:
|
1955
|
-
logger.error(f"创建定时任务失败: {e}")
|
1956
|
-
raise
|
1957
|
-
|
1958
|
-
async def update_scheduled_task(self, session, task_id: str, task_data: Dict) -> Dict:
|
1959
|
-
"""更新定时任务"""
|
1960
|
-
try:
|
1961
|
-
# 处理schedule_config
|
1962
|
-
cron_expression = None
|
1963
|
-
interval_seconds = None
|
1964
|
-
if task_data['schedule_type'] == 'cron':
|
1965
|
-
cron_expression = task_data['schedule_config'].get('cron_expression')
|
1966
|
-
elif task_data['schedule_type'] == 'interval':
|
1967
|
-
interval_seconds = task_data['schedule_config'].get('seconds', 60)
|
1968
|
-
|
1969
|
-
# 处理task_data -> task_args和task_kwargs
|
1970
|
-
task_args = task_data.get('task_data', {}).get('args', [])
|
1971
|
-
task_kwargs = task_data.get('task_data', {}).get('kwargs', {})
|
1972
|
-
|
1973
|
-
update_query = text("""
|
1974
|
-
UPDATE scheduled_tasks SET
|
1975
|
-
task_name = :task_name,
|
1976
|
-
queue_name = :queue_name,
|
1977
|
-
task_type = :task_type,
|
1978
|
-
task_args = :task_args,
|
1979
|
-
task_kwargs = :task_kwargs,
|
1980
|
-
cron_expression = :cron_expression,
|
1981
|
-
interval_seconds = :interval_seconds,
|
1982
|
-
enabled = :enabled,
|
1983
|
-
description = :description,
|
1984
|
-
updated_at = CURRENT_TIMESTAMP
|
1985
|
-
WHERE id = :id
|
1986
|
-
RETURNING *
|
1987
|
-
""")
|
1988
|
-
|
1989
|
-
params = {
|
1990
|
-
'id': task_id,
|
1991
|
-
'task_name': task_data['name'],
|
1992
|
-
'queue_name': task_data['queue_name'],
|
1993
|
-
'task_type': task_data['schedule_type'],
|
1994
|
-
'task_args': json.dumps(task_args),
|
1995
|
-
'task_kwargs': json.dumps(task_kwargs),
|
1996
|
-
'cron_expression': cron_expression,
|
1997
|
-
'interval_seconds': interval_seconds,
|
1998
|
-
'enabled': task_data.get('is_active', True),
|
1999
|
-
'description': task_data.get('description')
|
2000
|
-
}
|
2001
|
-
|
2002
|
-
result = await session.execute(update_query, params)
|
2003
|
-
await session.commit()
|
2004
|
-
|
2005
|
-
if result.rowcount == 0:
|
2006
|
-
return {
|
2007
|
-
'success': False,
|
2008
|
-
'error': '任务不存在'
|
2009
|
-
}
|
2010
|
-
|
2011
|
-
updated_task = dict(result.first()._mapping)
|
2012
|
-
# 转换时间字段
|
2013
|
-
for field in ['last_run', 'next_run', 'created_at', 'updated_at']:
|
2014
|
-
if updated_task.get(field):
|
2015
|
-
updated_task[field] = updated_task[field].isoformat()
|
2016
|
-
|
2017
|
-
return {
|
2018
|
-
'success': True,
|
2019
|
-
'data': updated_task,
|
2020
|
-
'message': '定时任务更新成功'
|
2021
|
-
}
|
2022
|
-
|
2023
|
-
except Exception as e:
|
2024
|
-
logger.error(f"更新定时任务失败: {e}")
|
2025
|
-
return {
|
2026
|
-
'success': False,
|
2027
|
-
'error': str(e)
|
2028
|
-
}
|
2029
|
-
|
2030
|
-
async def delete_scheduled_task(self, session, task_id: str) -> bool:
|
2031
|
-
"""删除定时任务"""
|
2032
|
-
try:
|
2033
|
-
delete_query = text("""
|
2034
|
-
DELETE FROM scheduled_tasks
|
2035
|
-
WHERE id = :id
|
2036
|
-
""")
|
2037
|
-
|
2038
|
-
result = await session.execute(delete_query, {'id': task_id})
|
2039
|
-
await session.commit()
|
2040
|
-
|
2041
|
-
return result.rowcount > 0
|
2042
|
-
|
2043
|
-
except Exception as e:
|
2044
|
-
logger.error(f"删除定时任务失败: {e}")
|
2045
|
-
raise
|
2046
|
-
|
2047
|
-
async def _sync_task_to_redis(self, task_id: str, enabled: bool):
|
2048
|
-
"""同步任务状态到 Redis"""
|
2049
|
-
try:
|
2050
|
-
if not self._redis_connector:
|
2051
|
-
logger.debug("Redis not configured, skipping sync")
|
2052
|
-
return
|
2053
|
-
|
2054
|
-
redis_client = await self.get_redis_client()
|
2055
|
-
|
2056
|
-
# Redis 中的键名格式(与 scheduler 保持一致)
|
2057
|
-
# scheduler 使用的前缀格式是 {redis_prefix}:SCHEDULER
|
2058
|
-
scheduler_prefix = f"{self.redis_prefix}:SCHEDULER"
|
2059
|
-
zset_key = f"{scheduler_prefix}:tasks"
|
2060
|
-
task_detail_key = f"{scheduler_prefix}:task:{task_id}"
|
2061
|
-
|
2062
|
-
if enabled:
|
2063
|
-
# 如果启用,需要重新加载任务到 Redis
|
2064
|
-
# 获取任务完整信息并转换为 ScheduledTask 对象
|
2065
|
-
async with self.AsyncSessionLocal() as session:
|
2066
|
-
query = text("""
|
2067
|
-
SELECT * FROM scheduled_tasks
|
2068
|
-
WHERE id = :id AND next_run_time IS NOT NULL
|
2069
|
-
""")
|
2070
|
-
result = await session.execute(query, {'id': task_id})
|
2071
|
-
task_row = result.first()
|
2072
|
-
|
2073
|
-
if task_row and task_row.next_run_time:
|
2074
|
-
# 导入必要的类
|
2075
|
-
from jettask.scheduler.models import ScheduledTask, TaskType
|
2076
|
-
from decimal import Decimal
|
2077
|
-
|
2078
|
-
# 处理 interval_seconds 的 Decimal 类型
|
2079
|
-
interval_seconds = task_row.interval_seconds
|
2080
|
-
if interval_seconds is not None and isinstance(interval_seconds, Decimal):
|
2081
|
-
interval_seconds = float(interval_seconds)
|
2082
|
-
|
2083
|
-
# 创建 ScheduledTask 对象
|
2084
|
-
task = ScheduledTask(
|
2085
|
-
id=task_row.id,
|
2086
|
-
scheduler_id=task_row.scheduler_id,
|
2087
|
-
task_name=task_row.task_name,
|
2088
|
-
task_type=TaskType(task_row.task_type) if task_row.task_type else TaskType.INTERVAL,
|
2089
|
-
queue_name=task_row.queue_name,
|
2090
|
-
task_args=task_row.task_args if isinstance(task_row.task_args, list) else json.loads(task_row.task_args or '[]'),
|
2091
|
-
task_kwargs=task_row.task_kwargs if isinstance(task_row.task_kwargs, dict) else json.loads(task_row.task_kwargs or '{}'),
|
2092
|
-
cron_expression=task_row.cron_expression,
|
2093
|
-
interval_seconds=interval_seconds,
|
2094
|
-
next_run_time=task_row.next_run_time,
|
2095
|
-
last_run_time=task_row.last_run_time,
|
2096
|
-
enabled=task_row.enabled,
|
2097
|
-
max_retries=task_row.max_retries or 3,
|
2098
|
-
retry_delay=task_row.retry_delay or 60,
|
2099
|
-
timeout=task_row.timeout or 300,
|
2100
|
-
description=task_row.description,
|
2101
|
-
tags=task_row.tags if isinstance(task_row.tags, list) else (json.loads(task_row.tags) if task_row.tags else []),
|
2102
|
-
metadata=task_row.metadata if isinstance(task_row.metadata, dict) else (json.loads(task_row.metadata) if task_row.metadata else None),
|
2103
|
-
created_at=task_row.created_at,
|
2104
|
-
updated_at=task_row.updated_at
|
2105
|
-
)
|
2106
|
-
|
2107
|
-
# 添加到 ZSET(用于调度)
|
2108
|
-
score = task.next_run_time.timestamp()
|
2109
|
-
await redis_client.zadd(zset_key, {str(task_id): score})
|
2110
|
-
|
2111
|
-
# 存储任务详情(使用 ScheduledTask 的 to_redis_value 方法)
|
2112
|
-
await redis_client.setex(
|
2113
|
-
task_detail_key,
|
2114
|
-
300, # 5分钟过期
|
2115
|
-
task.to_redis_value()
|
2116
|
-
)
|
2117
|
-
logger.info(f"Task {task_id} re-enabled and synced to Redis")
|
2118
|
-
else:
|
2119
|
-
# 如果禁用,从 Redis 中移除
|
2120
|
-
await redis_client.zrem(zset_key, str(task_id))
|
2121
|
-
await redis_client.delete(task_detail_key)
|
2122
|
-
logger.info(f"Task {task_id} disabled and removed from Redis")
|
2123
|
-
|
2124
|
-
await redis_client.close()
|
2125
|
-
|
2126
|
-
except Exception as e:
|
2127
|
-
# Redis 同步失败不应影响主要操作
|
2128
|
-
logger.warning(f"Failed to sync task {task_id} to Redis: {e}")
|
2129
|
-
|
2130
|
-
async def toggle_scheduled_task(self, session, task_id: str) -> Dict:
|
2131
|
-
"""切换定时任务状态"""
|
2132
|
-
try:
|
2133
|
-
# 先获取当前状态
|
2134
|
-
get_query = text("SELECT enabled FROM scheduled_tasks WHERE id = :id")
|
2135
|
-
result = await session.execute(get_query, {'id': task_id})
|
2136
|
-
row = result.first()
|
2137
|
-
|
2138
|
-
if not row:
|
2139
|
-
return None
|
2140
|
-
print(f'{row.enabled=}')
|
2141
|
-
# 切换状态
|
2142
|
-
new_status = not row.enabled
|
2143
|
-
update_query = text("""
|
2144
|
-
UPDATE scheduled_tasks
|
2145
|
-
SET enabled = :enabled, updated_at = CURRENT_TIMESTAMP
|
2146
|
-
WHERE id = :id
|
2147
|
-
RETURNING id, enabled
|
2148
|
-
""")
|
2149
|
-
|
2150
|
-
result = await session.execute(update_query, {
|
2151
|
-
'id': task_id,
|
2152
|
-
'enabled': new_status
|
2153
|
-
})
|
2154
|
-
await session.commit()
|
2155
|
-
|
2156
|
-
updated_task = dict(result.first()._mapping)
|
2157
|
-
|
2158
|
-
# 立即同步到 Redis
|
2159
|
-
await self._sync_task_to_redis(task_id, new_status)
|
2160
|
-
|
2161
|
-
return updated_task
|
2162
|
-
|
2163
|
-
except Exception as e:
|
2164
|
-
logger.error(f"切换定时任务状态失败: {e}")
|
2165
|
-
raise
|
2166
|
-
|
2167
|
-
async def get_scheduled_task_by_id(self, session, task_id: str) -> Optional[Dict]:
|
2168
|
-
"""根据ID获取定时任务详情"""
|
2169
|
-
try:
|
2170
|
-
query = text("""
|
2171
|
-
SELECT
|
2172
|
-
id,
|
2173
|
-
scheduler_id,
|
2174
|
-
task_name,
|
2175
|
-
queue_name,
|
2176
|
-
task_type,
|
2177
|
-
interval_seconds,
|
2178
|
-
cron_expression,
|
2179
|
-
next_run_time,
|
2180
|
-
last_run_time,
|
2181
|
-
enabled,
|
2182
|
-
task_args,
|
2183
|
-
task_kwargs,
|
2184
|
-
description,
|
2185
|
-
max_retries,
|
2186
|
-
retry_delay,
|
2187
|
-
timeout,
|
2188
|
-
created_at,
|
2189
|
-
updated_at
|
2190
|
-
FROM scheduled_tasks
|
2191
|
-
WHERE id = :task_id
|
2192
|
-
LIMIT 1
|
2193
|
-
""")
|
2194
|
-
|
2195
|
-
result = await session.execute(query, {"task_id": int(task_id)})
|
2196
|
-
row = result.first()
|
2197
|
-
|
2198
|
-
if row:
|
2199
|
-
task = dict(row._mapping)
|
2200
|
-
# 处理JSON字段
|
2201
|
-
if task.get('task_args') and isinstance(task['task_args'], str):
|
2202
|
-
import json
|
2203
|
-
try:
|
2204
|
-
task['task_args'] = json.loads(task['task_args'])
|
2205
|
-
except:
|
2206
|
-
task['task_args'] = []
|
2207
|
-
|
2208
|
-
if task.get('task_kwargs') and isinstance(task['task_kwargs'], str):
|
2209
|
-
import json
|
2210
|
-
try:
|
2211
|
-
task['task_kwargs'] = json.loads(task['task_kwargs'])
|
2212
|
-
except:
|
2213
|
-
task['task_kwargs'] = {}
|
2214
|
-
|
2215
|
-
return task
|
2216
|
-
return None
|
2217
|
-
|
2218
|
-
except Exception as e:
|
2219
|
-
logger.error(f"获取定时任务详情失败: {e}")
|
2220
|
-
raise
|
2221
|
-
|
2222
|
-
async def fetch_task_execution_history(self,
|
2223
|
-
session,
|
2224
|
-
task_id: str,
|
2225
|
-
page: int = 1,
|
2226
|
-
page_size: int = 20) -> tuple:
|
2227
|
-
"""获取定时任务执行历史"""
|
2228
|
-
try:
|
2229
|
-
# 计算总数
|
2230
|
-
count_query = text("""
|
2231
|
-
SELECT COUNT(*) as total
|
2232
|
-
FROM tasks
|
2233
|
-
WHERE scheduled_task_id = :task_id
|
2234
|
-
""")
|
2235
|
-
count_result = await session.execute(count_query, {'task_id': task_id})
|
2236
|
-
total = count_result.scalar() or 0
|
2237
|
-
|
2238
|
-
# 获取分页数据
|
2239
|
-
offset = (page - 1) * page_size
|
2240
|
-
data_query = text("""
|
2241
|
-
SELECT
|
2242
|
-
id,
|
2243
|
-
scheduled_task_id as task_id,
|
2244
|
-
status,
|
2245
|
-
created_at as scheduled_time,
|
2246
|
-
started_at,
|
2247
|
-
completed_at as finished_at,
|
2248
|
-
error_message,
|
2249
|
-
result as task_result,
|
2250
|
-
retry_count,
|
2251
|
-
execution_time,
|
2252
|
-
worker_id
|
2253
|
-
FROM tasks
|
2254
|
-
WHERE scheduled_task_id = :task_id
|
2255
|
-
ORDER BY created_at DESC
|
2256
|
-
LIMIT :limit OFFSET :offset
|
2257
|
-
""")
|
2258
|
-
|
2259
|
-
result = await session.execute(data_query, {
|
2260
|
-
'task_id': task_id,
|
2261
|
-
'limit': page_size,
|
2262
|
-
'offset': offset
|
2263
|
-
})
|
2264
|
-
|
2265
|
-
history = []
|
2266
|
-
for row in result:
|
2267
|
-
record = dict(row._mapping)
|
2268
|
-
# 转换时间字段
|
2269
|
-
for field in ['scheduled_time', 'started_at', 'finished_at']:
|
2270
|
-
if record.get(field):
|
2271
|
-
record[field] = record[field].isoformat()
|
2272
|
-
# 计算执行时长(毫秒)
|
2273
|
-
if record.get('execution_time'):
|
2274
|
-
record['duration_ms'] = int(record['execution_time'] * 1000)
|
2275
|
-
history.append(record)
|
2276
|
-
|
2277
|
-
return history, total
|
2278
|
-
|
2279
|
-
except Exception as e:
|
2280
|
-
logger.error(f"获取任务执行历史失败: {e}")
|
2281
|
-
raise
|
2282
|
-
|
2283
|
-
async def fetch_task_execution_trend(self,
|
2284
|
-
session,
|
2285
|
-
task_id: str,
|
2286
|
-
time_range: str = '7d') -> list:
|
2287
|
-
"""获取定时任务执行趋势"""
|
2288
|
-
try:
|
2289
|
-
# 根据时间范围计算开始时间
|
2290
|
-
now = datetime.now(timezone.utc)
|
2291
|
-
if time_range == '24h':
|
2292
|
-
start_time = now - timedelta(hours=24)
|
2293
|
-
interval = 'hour'
|
2294
|
-
elif time_range == '7d':
|
2295
|
-
start_time = now - timedelta(days=7)
|
2296
|
-
interval = 'day'
|
2297
|
-
elif time_range == '30d':
|
2298
|
-
start_time = now - timedelta(days=30)
|
2299
|
-
interval = 'day'
|
2300
|
-
else:
|
2301
|
-
start_time = now - timedelta(days=7)
|
2302
|
-
interval = 'day'
|
2303
|
-
|
2304
|
-
# 查询执行趋势(从tasks表)
|
2305
|
-
trend_query = text(f"""
|
2306
|
-
SELECT
|
2307
|
-
date_trunc(:interval, COALESCE(started_at, created_at)) as time,
|
2308
|
-
COUNT(*) as total,
|
2309
|
-
COUNT(CASE WHEN status = 'success' THEN 1 END) as success,
|
2310
|
-
COUNT(CASE WHEN status = 'error' THEN 1 END) as error
|
2311
|
-
FROM tasks
|
2312
|
-
WHERE scheduled_task_id = :task_id
|
2313
|
-
AND COALESCE(started_at, created_at) >= :start_time
|
2314
|
-
GROUP BY date_trunc(:interval, COALESCE(started_at, created_at))
|
2315
|
-
ORDER BY time ASC
|
2316
|
-
""")
|
2317
|
-
|
2318
|
-
result = await session.execute(trend_query, {
|
2319
|
-
'task_id': task_id,
|
2320
|
-
'start_time': start_time,
|
2321
|
-
'interval': interval
|
2322
|
-
})
|
2323
|
-
|
2324
|
-
data = []
|
2325
|
-
for row in result:
|
2326
|
-
record = dict(row._mapping)
|
2327
|
-
record['time'] = record['time'].isoformat()
|
2328
|
-
data.append(record)
|
2329
|
-
|
2330
|
-
return data
|
2331
|
-
|
2332
|
-
except Exception as e:
|
2333
|
-
logger.error(f"获取任务执行趋势失败: {e}")
|
2334
|
-
raise
|