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.
Files changed (110) hide show
  1. jettask/__init__.py +2 -0
  2. jettask/cli.py +12 -8
  3. jettask/config/lua_scripts.py +37 -0
  4. jettask/config/nacos_config.py +1 -1
  5. jettask/core/app.py +313 -340
  6. jettask/core/container.py +4 -4
  7. jettask/{persistence → core}/namespace.py +93 -27
  8. jettask/core/task.py +16 -9
  9. jettask/core/unified_manager_base.py +136 -26
  10. jettask/db/__init__.py +67 -0
  11. jettask/db/base.py +137 -0
  12. jettask/{utils/db_connector.py → db/connector.py} +130 -26
  13. jettask/db/models/__init__.py +16 -0
  14. jettask/db/models/scheduled_task.py +196 -0
  15. jettask/db/models/task.py +77 -0
  16. jettask/db/models/task_run.py +85 -0
  17. jettask/executor/__init__.py +0 -15
  18. jettask/executor/core.py +76 -31
  19. jettask/executor/process_entry.py +29 -114
  20. jettask/executor/task_executor.py +4 -0
  21. jettask/messaging/event_pool.py +928 -685
  22. jettask/messaging/scanner.py +30 -0
  23. jettask/persistence/__init__.py +28 -103
  24. jettask/persistence/buffer.py +170 -0
  25. jettask/persistence/consumer.py +330 -249
  26. jettask/persistence/manager.py +304 -0
  27. jettask/persistence/persistence.py +391 -0
  28. jettask/scheduler/__init__.py +15 -3
  29. jettask/scheduler/{task_crud.py → database.py} +61 -57
  30. jettask/scheduler/loader.py +2 -2
  31. jettask/scheduler/{scheduler_coordinator.py → manager.py} +23 -6
  32. jettask/scheduler/models.py +14 -10
  33. jettask/scheduler/schedule.py +166 -0
  34. jettask/scheduler/scheduler.py +12 -11
  35. jettask/schemas/__init__.py +50 -1
  36. jettask/schemas/backlog.py +43 -6
  37. jettask/schemas/namespace.py +70 -19
  38. jettask/schemas/queue.py +19 -3
  39. jettask/schemas/responses.py +493 -0
  40. jettask/task/__init__.py +0 -2
  41. jettask/task/router.py +3 -0
  42. jettask/test_connection_monitor.py +1 -1
  43. jettask/utils/__init__.py +7 -5
  44. jettask/utils/db_init.py +8 -4
  45. jettask/utils/namespace_dep.py +167 -0
  46. jettask/utils/queue_matcher.py +186 -0
  47. jettask/utils/rate_limit/concurrency_limiter.py +7 -1
  48. jettask/utils/stream_backlog.py +1 -1
  49. jettask/webui/__init__.py +0 -1
  50. jettask/webui/api/__init__.py +4 -4
  51. jettask/webui/api/alerts.py +806 -71
  52. jettask/webui/api/example_refactored.py +400 -0
  53. jettask/webui/api/namespaces.py +390 -45
  54. jettask/webui/api/overview.py +300 -54
  55. jettask/webui/api/queues.py +971 -267
  56. jettask/webui/api/scheduled.py +1249 -56
  57. jettask/webui/api/settings.py +129 -7
  58. jettask/webui/api/workers.py +442 -0
  59. jettask/webui/app.py +46 -2329
  60. jettask/webui/middleware/__init__.py +6 -0
  61. jettask/webui/middleware/namespace_middleware.py +135 -0
  62. jettask/webui/services/__init__.py +146 -0
  63. jettask/webui/services/heartbeat_service.py +251 -0
  64. jettask/webui/services/overview_service.py +60 -51
  65. jettask/webui/services/queue_monitor_service.py +426 -0
  66. jettask/webui/services/redis_monitor_service.py +87 -0
  67. jettask/webui/services/settings_service.py +174 -111
  68. jettask/webui/services/task_monitor_service.py +222 -0
  69. jettask/webui/services/timeline_pg_service.py +452 -0
  70. jettask/webui/services/timeline_service.py +189 -0
  71. jettask/webui/services/worker_monitor_service.py +467 -0
  72. jettask/webui/utils/__init__.py +11 -0
  73. jettask/webui/utils/time_utils.py +122 -0
  74. jettask/worker/lifecycle.py +8 -2
  75. {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/METADATA +1 -1
  76. jettask-0.2.24.dist-info/RECORD +142 -0
  77. jettask/executor/executor.py +0 -338
  78. jettask/persistence/backlog_monitor.py +0 -567
  79. jettask/persistence/base.py +0 -2334
  80. jettask/persistence/db_manager.py +0 -516
  81. jettask/persistence/maintenance.py +0 -81
  82. jettask/persistence/message_consumer.py +0 -259
  83. jettask/persistence/models.py +0 -49
  84. jettask/persistence/offline_recovery.py +0 -196
  85. jettask/persistence/queue_discovery.py +0 -215
  86. jettask/persistence/task_persistence.py +0 -218
  87. jettask/persistence/task_updater.py +0 -583
  88. jettask/scheduler/add_execution_count.sql +0 -11
  89. jettask/scheduler/add_priority_field.sql +0 -26
  90. jettask/scheduler/add_scheduler_id.sql +0 -25
  91. jettask/scheduler/add_scheduler_id_index.sql +0 -10
  92. jettask/scheduler/make_scheduler_id_required.sql +0 -28
  93. jettask/scheduler/migrate_interval_seconds.sql +0 -9
  94. jettask/scheduler/performance_optimization.sql +0 -45
  95. jettask/scheduler/run_scheduler.py +0 -186
  96. jettask/scheduler/schema.sql +0 -84
  97. jettask/task/task_executor.py +0 -318
  98. jettask/webui/api/analytics.py +0 -323
  99. jettask/webui/config.py +0 -90
  100. jettask/webui/models/__init__.py +0 -3
  101. jettask/webui/models/namespace.py +0 -63
  102. jettask/webui/namespace_manager/__init__.py +0 -10
  103. jettask/webui/namespace_manager/multi.py +0 -593
  104. jettask/webui/namespace_manager/unified.py +0 -193
  105. jettask/webui/run.py +0 -46
  106. jettask-0.2.23.dist-info/RECORD +0 -145
  107. {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/WHEEL +0 -0
  108. {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/entry_points.txt +0 -0
  109. {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/licenses/LICENSE +0 -0
  110. {jettask-0.2.23.dist-info → jettask-0.2.24.dist-info}/top_level.txt +0 -0
@@ -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