jettask 0.2.4__py3-none-any.whl → 0.2.6__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/core/cli.py +20 -24
- jettask/monitor/run_backlog_collector.py +96 -0
- jettask/monitor/stream_backlog_monitor.py +362 -0
- jettask/pg_consumer/pg_consumer_v2.py +403 -0
- jettask/pg_consumer/sql_utils.py +182 -0
- jettask/scheduler/__init__.py +17 -0
- jettask/scheduler/add_execution_count.sql +11 -0
- jettask/scheduler/add_priority_field.sql +26 -0
- jettask/scheduler/add_scheduler_id.sql +25 -0
- jettask/scheduler/add_scheduler_id_index.sql +10 -0
- jettask/scheduler/loader.py +249 -0
- jettask/scheduler/make_scheduler_id_required.sql +28 -0
- jettask/scheduler/manager.py +696 -0
- jettask/scheduler/migrate_interval_seconds.sql +9 -0
- jettask/scheduler/models.py +200 -0
- jettask/scheduler/multi_namespace_scheduler.py +294 -0
- jettask/scheduler/performance_optimization.sql +45 -0
- jettask/scheduler/run_scheduler.py +186 -0
- jettask/scheduler/scheduler.py +715 -0
- jettask/scheduler/schema.sql +84 -0
- jettask/scheduler/unified_manager.py +450 -0
- jettask/scheduler/unified_scheduler_manager.py +280 -0
- jettask/webui/backend/api/__init__.py +3 -0
- jettask/webui/backend/api/v1/__init__.py +17 -0
- jettask/webui/backend/api/v1/monitoring.py +431 -0
- jettask/webui/backend/api/v1/namespaces.py +504 -0
- jettask/webui/backend/api/v1/queues.py +342 -0
- jettask/webui/backend/api/v1/tasks.py +367 -0
- jettask/webui/backend/core/__init__.py +3 -0
- jettask/webui/backend/core/cache.py +221 -0
- jettask/webui/backend/core/database.py +200 -0
- jettask/webui/backend/core/exceptions.py +102 -0
- jettask/webui/backend/models/__init__.py +3 -0
- jettask/webui/backend/models/requests.py +236 -0
- jettask/webui/backend/models/responses.py +230 -0
- jettask/webui/backend/services/__init__.py +3 -0
- jettask/webui/frontend/index.html +13 -0
- jettask/webui/models/__init__.py +3 -0
- jettask/webui/models/namespace.py +63 -0
- jettask/webui/sql/batch_upsert_functions.sql +178 -0
- jettask/webui/sql/init_database.sql +640 -0
- {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/METADATA +11 -9
- {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/RECORD +47 -54
- jettask/webui/frontend/package-lock.json +0 -4833
- jettask/webui/frontend/package.json +0 -30
- jettask/webui/frontend/src/App.css +0 -109
- jettask/webui/frontend/src/App.jsx +0 -66
- jettask/webui/frontend/src/components/NamespaceSelector.jsx +0 -166
- jettask/webui/frontend/src/components/QueueBacklogChart.jsx +0 -298
- jettask/webui/frontend/src/components/QueueBacklogTrend.jsx +0 -638
- jettask/webui/frontend/src/components/QueueDetailsTable.css +0 -65
- jettask/webui/frontend/src/components/QueueDetailsTable.jsx +0 -487
- jettask/webui/frontend/src/components/QueueDetailsTableV2.jsx +0 -465
- jettask/webui/frontend/src/components/ScheduledTaskFilter.jsx +0 -423
- jettask/webui/frontend/src/components/TaskFilter.jsx +0 -425
- jettask/webui/frontend/src/components/TimeRangeSelector.css +0 -21
- jettask/webui/frontend/src/components/TimeRangeSelector.jsx +0 -160
- jettask/webui/frontend/src/components/charts/QueueChart.jsx +0 -111
- jettask/webui/frontend/src/components/charts/QueueTrendChart.jsx +0 -115
- jettask/webui/frontend/src/components/charts/WorkerChart.jsx +0 -40
- jettask/webui/frontend/src/components/common/StatsCard.jsx +0 -18
- jettask/webui/frontend/src/components/layout/AppLayout.css +0 -95
- jettask/webui/frontend/src/components/layout/AppLayout.jsx +0 -49
- jettask/webui/frontend/src/components/layout/Header.css +0 -106
- jettask/webui/frontend/src/components/layout/Header.jsx +0 -106
- jettask/webui/frontend/src/components/layout/SideMenu.css +0 -137
- jettask/webui/frontend/src/components/layout/SideMenu.jsx +0 -209
- jettask/webui/frontend/src/components/layout/TabsNav.css +0 -244
- jettask/webui/frontend/src/components/layout/TabsNav.jsx +0 -206
- jettask/webui/frontend/src/components/layout/UserInfo.css +0 -197
- jettask/webui/frontend/src/components/layout/UserInfo.jsx +0 -197
- jettask/webui/frontend/src/contexts/LoadingContext.jsx +0 -27
- jettask/webui/frontend/src/contexts/NamespaceContext.jsx +0 -72
- jettask/webui/frontend/src/contexts/TabsContext.backup.jsx +0 -245
- jettask/webui/frontend/src/index.css +0 -114
- jettask/webui/frontend/src/main.jsx +0 -20
- jettask/webui/frontend/src/pages/Alerts.jsx +0 -684
- jettask/webui/frontend/src/pages/Dashboard/index.css +0 -35
- jettask/webui/frontend/src/pages/Dashboard/index.jsx +0 -281
- jettask/webui/frontend/src/pages/Dashboard.jsx +0 -1330
- jettask/webui/frontend/src/pages/QueueDetail.jsx +0 -1117
- jettask/webui/frontend/src/pages/QueueMonitor.jsx +0 -527
- jettask/webui/frontend/src/pages/Queues.jsx +0 -12
- jettask/webui/frontend/src/pages/ScheduledTasks.jsx +0 -809
- jettask/webui/frontend/src/pages/Settings.jsx +0 -800
- jettask/webui/frontend/src/pages/Workers.jsx +0 -12
- jettask/webui/frontend/src/services/api.js +0 -114
- jettask/webui/frontend/src/services/queueTrend.js +0 -152
- jettask/webui/frontend/src/utils/suppressWarnings.js +0 -22
- jettask/webui/frontend/src/utils/userPreferences.js +0 -154
- {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/WHEEL +0 -0
- {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/entry_points.txt +0 -0
- {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/licenses/LICENSE +0 -0
- {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,403 @@
|
|
1
|
+
"""
|
2
|
+
PostgreSQL Consumer V2 - 支持多消费者组的双表结构
|
3
|
+
"""
|
4
|
+
import logging
|
5
|
+
import json
|
6
|
+
from typing import Optional, Dict, Any, List
|
7
|
+
from datetime import datetime
|
8
|
+
import asyncio
|
9
|
+
import psycopg2
|
10
|
+
from psycopg2.extras import RealDictCursor, Json
|
11
|
+
import asyncpg
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
class PGConsumerV2:
|
17
|
+
"""PostgreSQL消费者V2 - 支持多消费者组"""
|
18
|
+
|
19
|
+
def __init__(self, pg_url: str, redis_prefix: str = 'jettask'):
|
20
|
+
self.pg_url = pg_url
|
21
|
+
self.redis_prefix = redis_prefix
|
22
|
+
self.conn = None
|
23
|
+
self.async_conn = None
|
24
|
+
|
25
|
+
def connect(self):
|
26
|
+
"""建立同步数据库连接"""
|
27
|
+
if not self.conn or self.conn.closed:
|
28
|
+
self.conn = psycopg2.connect(self.pg_url)
|
29
|
+
self.conn.autocommit = False
|
30
|
+
|
31
|
+
async def async_connect(self):
|
32
|
+
"""建立异步数据库连接"""
|
33
|
+
if not self.async_conn:
|
34
|
+
self.async_conn = await asyncpg.connect(self.pg_url)
|
35
|
+
|
36
|
+
def close(self):
|
37
|
+
"""关闭同步连接"""
|
38
|
+
if self.conn:
|
39
|
+
self.conn.close()
|
40
|
+
self.conn = None
|
41
|
+
|
42
|
+
async def async_close(self):
|
43
|
+
"""关闭异步连接"""
|
44
|
+
if self.async_conn:
|
45
|
+
await self.async_conn.close()
|
46
|
+
self.async_conn = None
|
47
|
+
|
48
|
+
def ensure_tables(self):
|
49
|
+
"""确保数据表存在"""
|
50
|
+
from .sql_utils import execute_sql_file
|
51
|
+
self.connect()
|
52
|
+
try:
|
53
|
+
with self.conn.cursor() as cursor:
|
54
|
+
# 检查新表是否存在
|
55
|
+
cursor.execute("""
|
56
|
+
SELECT EXISTS (
|
57
|
+
SELECT 1 FROM information_schema.tables
|
58
|
+
WHERE table_name = 'tasks'
|
59
|
+
)
|
60
|
+
""")
|
61
|
+
if not cursor.fetchone()[0]:
|
62
|
+
# 使用新的SQL执行函数
|
63
|
+
sql_path = '/home/yuyang/easy-task/jettask/pg_consumer/sql/create_new_tables.sql'
|
64
|
+
execute_sql_file(self.conn, sql_path)
|
65
|
+
logger.info("Created new table structure for multi-consumer group support")
|
66
|
+
except Exception as e:
|
67
|
+
self.conn.rollback()
|
68
|
+
logger.error(f"Error ensuring tables: {e}")
|
69
|
+
raise
|
70
|
+
|
71
|
+
async def async_ensure_tables(self):
|
72
|
+
"""异步确保数据表存在"""
|
73
|
+
from .sql_utils import split_sql_statements
|
74
|
+
await self.async_connect()
|
75
|
+
try:
|
76
|
+
# 检查新表是否存在,并且有正确的列
|
77
|
+
exists = await self.async_conn.fetchval("""
|
78
|
+
SELECT EXISTS (
|
79
|
+
SELECT 1 FROM information_schema.columns
|
80
|
+
WHERE table_name = 'tasks' AND column_name = 'stream_id'
|
81
|
+
)
|
82
|
+
""")
|
83
|
+
if not exists:
|
84
|
+
logger.info("Creating new table structure...")
|
85
|
+
# 执行创建表的SQL
|
86
|
+
with open('/home/yuyang/easy-task/jettask/pg_consumer/sql/create_new_tables.sql', 'r') as f:
|
87
|
+
sql_content = f.read()
|
88
|
+
# 使用智能分割函数
|
89
|
+
statements = split_sql_statements(sql_content)
|
90
|
+
for i, stmt in enumerate(statements, 1):
|
91
|
+
try:
|
92
|
+
logger.debug(f"执行第 {i}/{len(statements)} 个SQL语句")
|
93
|
+
await self.async_conn.execute(stmt)
|
94
|
+
except Exception as e:
|
95
|
+
# 忽略已存在的对象错误
|
96
|
+
if 'already exists' not in str(e):
|
97
|
+
logger.warning(f"Error executing statement {i}: {e}")
|
98
|
+
logger.debug(f"Statement: {stmt[:100]}...")
|
99
|
+
logger.info("Created new table structure for multi-consumer group support")
|
100
|
+
except Exception as e:
|
101
|
+
logger.error(f"Error ensuring tables: {e}")
|
102
|
+
raise
|
103
|
+
|
104
|
+
def record_task(self, event_id: str, event_data: Dict[str, Any],
|
105
|
+
queue: str, consumer_group: str) -> int:
|
106
|
+
"""
|
107
|
+
记录任务信息(同步方法)
|
108
|
+
返回task_id
|
109
|
+
"""
|
110
|
+
self.connect()
|
111
|
+
try:
|
112
|
+
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
113
|
+
# 1. 插入或获取任务基础信息
|
114
|
+
task_name = event_data.get('_task_name', event_data.get('name', 'unknown'))
|
115
|
+
|
116
|
+
# 尝试插入任务,如果已存在则忽略
|
117
|
+
cursor.execute("""
|
118
|
+
INSERT INTO tasks (
|
119
|
+
stream_id, queue, task_name, task_type,
|
120
|
+
payload, priority, source, metadata
|
121
|
+
) VALUES (
|
122
|
+
%s, %s, %s, %s, %s, %s, %s, %s
|
123
|
+
)
|
124
|
+
ON CONFLICT (stream_id) DO UPDATE
|
125
|
+
SET updated_at = CURRENT_TIMESTAMP
|
126
|
+
RETURNING id
|
127
|
+
""", (
|
128
|
+
event_id,
|
129
|
+
queue,
|
130
|
+
task_name,
|
131
|
+
event_data.get('event_type', 'task'),
|
132
|
+
Json(event_data),
|
133
|
+
event_data.get('priority', 0),
|
134
|
+
event_data.get('source', 'stream'),
|
135
|
+
Json({'consumer_group': consumer_group})
|
136
|
+
))
|
137
|
+
|
138
|
+
task_id = cursor.fetchone()['id']
|
139
|
+
|
140
|
+
# 2. 插入任务运行记录
|
141
|
+
consumer_name = event_data.get('consumer', '')
|
142
|
+
worker_id = event_data.get('worker_id', consumer_name)
|
143
|
+
|
144
|
+
cursor.execute("""
|
145
|
+
INSERT INTO task_runs (
|
146
|
+
task_id, stream_id, consumer_group,
|
147
|
+
consumer_name, worker_id, status
|
148
|
+
) VALUES (
|
149
|
+
%s, %s, %s, %s, %s, %s
|
150
|
+
)
|
151
|
+
ON CONFLICT (task_id, consumer_group) DO UPDATE
|
152
|
+
SET
|
153
|
+
status = EXCLUDED.status,
|
154
|
+
updated_at = CURRENT_TIMESTAMP
|
155
|
+
RETURNING id
|
156
|
+
""", (
|
157
|
+
task_id,
|
158
|
+
event_id,
|
159
|
+
consumer_group,
|
160
|
+
consumer_name,
|
161
|
+
worker_id,
|
162
|
+
'pending'
|
163
|
+
))
|
164
|
+
|
165
|
+
self.conn.commit()
|
166
|
+
return task_id
|
167
|
+
|
168
|
+
except Exception as e:
|
169
|
+
self.conn.rollback()
|
170
|
+
logger.error(f"Error recording task: {e}")
|
171
|
+
raise
|
172
|
+
|
173
|
+
async def async_record_task(self, event_id: str, event_data: Dict[str, Any],
|
174
|
+
queue: str, consumer_group: str) -> int:
|
175
|
+
"""
|
176
|
+
异步记录任务信息
|
177
|
+
返回task_id
|
178
|
+
"""
|
179
|
+
await self.async_connect()
|
180
|
+
try:
|
181
|
+
task_name = event_data.get('_task_name', event_data.get('name', 'unknown'))
|
182
|
+
|
183
|
+
# 使用事务
|
184
|
+
async with self.async_conn.transaction():
|
185
|
+
# 1. 插入或获取任务基础信息
|
186
|
+
task_id = await self.async_conn.fetchval("""
|
187
|
+
INSERT INTO tasks (
|
188
|
+
stream_id, queue, namespace, scheduled_task_id,
|
189
|
+
payload, priority, source, metadata
|
190
|
+
) VALUES (
|
191
|
+
$1, $2, $3, $4, $5, $6, $7, $8
|
192
|
+
)
|
193
|
+
ON CONFLICT (stream_id) DO UPDATE
|
194
|
+
SET metadata = EXCLUDED.metadata
|
195
|
+
RETURNING id
|
196
|
+
""",
|
197
|
+
event_id,
|
198
|
+
queue,
|
199
|
+
namespace,
|
200
|
+
scheduled_task_id,
|
201
|
+
json.dumps(event_data),
|
202
|
+
event_data.get('priority', 0),
|
203
|
+
event_data.get('source', 'stream'),
|
204
|
+
json.dumps({'consumer_group': consumer_group})
|
205
|
+
)
|
206
|
+
|
207
|
+
# 2. 插入任务运行记录
|
208
|
+
consumer_name = event_data.get('consumer', '')
|
209
|
+
worker_id = event_data.get('worker_id', consumer_name)
|
210
|
+
|
211
|
+
await self.async_conn.execute("""
|
212
|
+
INSERT INTO task_runs (
|
213
|
+
task_id, stream_id, task_name, consumer_group,
|
214
|
+
consumer_name, worker_id, status, created_at
|
215
|
+
) VALUES (
|
216
|
+
$1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP
|
217
|
+
)
|
218
|
+
ON CONFLICT (task_id, consumer_group) DO UPDATE
|
219
|
+
SET
|
220
|
+
status = EXCLUDED.status,
|
221
|
+
updated_at = CURRENT_TIMESTAMP
|
222
|
+
""",
|
223
|
+
task_id,
|
224
|
+
event_id,
|
225
|
+
task_name,
|
226
|
+
consumer_group,
|
227
|
+
consumer_name,
|
228
|
+
worker_id,
|
229
|
+
'pending'
|
230
|
+
)
|
231
|
+
|
232
|
+
return task_id
|
233
|
+
|
234
|
+
except Exception as e:
|
235
|
+
logger.error(f"Error recording task async: {e}")
|
236
|
+
raise
|
237
|
+
|
238
|
+
def update_task_status(self, event_id: str, consumer_group: str,
|
239
|
+
status: str, **kwargs) -> bool:
|
240
|
+
"""
|
241
|
+
更新任务执行状态(同步方法)
|
242
|
+
"""
|
243
|
+
self.connect()
|
244
|
+
try:
|
245
|
+
with self.conn.cursor() as cursor:
|
246
|
+
# 构建更新字段
|
247
|
+
update_fields = ['status = %s', 'updated_at = CURRENT_TIMESTAMP']
|
248
|
+
update_values = [status]
|
249
|
+
|
250
|
+
# 处理可选字段
|
251
|
+
if 'end_time' in kwargs:
|
252
|
+
update_fields.append('end_time = %s')
|
253
|
+
update_values.append(kwargs['end_time'])
|
254
|
+
|
255
|
+
if 'error_message' in kwargs:
|
256
|
+
update_fields.append('error_message = %s')
|
257
|
+
update_values.append(kwargs['error_message'])
|
258
|
+
|
259
|
+
if 'error_details' in kwargs:
|
260
|
+
update_fields.append('error_details = %s')
|
261
|
+
update_values.append(Json(kwargs['error_details']))
|
262
|
+
|
263
|
+
if 'result' in kwargs:
|
264
|
+
update_fields.append('result = %s')
|
265
|
+
update_values.append(Json(kwargs['result']))
|
266
|
+
|
267
|
+
if 'retry_count' in kwargs:
|
268
|
+
update_fields.append('retry_count = %s')
|
269
|
+
update_values.append(kwargs['retry_count'])
|
270
|
+
|
271
|
+
# 添加WHERE条件的值
|
272
|
+
update_values.extend([event_id, consumer_group])
|
273
|
+
|
274
|
+
# 更新task_runs表
|
275
|
+
cursor.execute(f"""
|
276
|
+
UPDATE task_runs
|
277
|
+
SET {', '.join(update_fields)}
|
278
|
+
WHERE stream_id = %s AND consumer_group = %s
|
279
|
+
""", update_values)
|
280
|
+
|
281
|
+
# tasks表已经没有status字段,不需要更新总体状态
|
282
|
+
|
283
|
+
self.conn.commit()
|
284
|
+
return cursor.rowcount > 0
|
285
|
+
|
286
|
+
except Exception as e:
|
287
|
+
self.conn.rollback()
|
288
|
+
logger.error(f"Error updating task status: {e}")
|
289
|
+
return False
|
290
|
+
|
291
|
+
async def async_update_task_status(self, event_id: str, consumer_group: str,
|
292
|
+
status: str, **kwargs) -> bool:
|
293
|
+
"""
|
294
|
+
异步更新任务执行状态
|
295
|
+
"""
|
296
|
+
await self.async_connect()
|
297
|
+
try:
|
298
|
+
async with self.async_conn.transaction():
|
299
|
+
# 构建更新语句
|
300
|
+
set_clauses = ['status = $1', 'updated_at = CURRENT_TIMESTAMP']
|
301
|
+
values = [status]
|
302
|
+
param_count = 1
|
303
|
+
|
304
|
+
# 添加可选字段
|
305
|
+
for field, db_field in [
|
306
|
+
('end_time', 'end_time'),
|
307
|
+
('error_message', 'error_message'),
|
308
|
+
('retry_count', 'retry_count')
|
309
|
+
]:
|
310
|
+
if field in kwargs:
|
311
|
+
param_count += 1
|
312
|
+
set_clauses.append(f'{db_field} = ${param_count}')
|
313
|
+
values.append(kwargs[field])
|
314
|
+
|
315
|
+
# JSON字段
|
316
|
+
for field, db_field in [
|
317
|
+
('error_details', 'error_details'),
|
318
|
+
('result', 'result')
|
319
|
+
]:
|
320
|
+
if field in kwargs:
|
321
|
+
param_count += 1
|
322
|
+
set_clauses.append(f'{db_field} = ${param_count}::jsonb')
|
323
|
+
values.append(json.dumps(kwargs[field]))
|
324
|
+
|
325
|
+
# 添加WHERE条件的值
|
326
|
+
values.extend([event_id, consumer_group])
|
327
|
+
|
328
|
+
# 更新task_runs表
|
329
|
+
await self.async_conn.execute(f"""
|
330
|
+
UPDATE task_runs
|
331
|
+
SET {', '.join(set_clauses)}
|
332
|
+
WHERE stream_id = ${param_count + 1}
|
333
|
+
AND consumer_group = ${param_count + 2}
|
334
|
+
""", *values)
|
335
|
+
|
336
|
+
# tasks表已经没有status字段,不需要更新总体状态
|
337
|
+
|
338
|
+
return True
|
339
|
+
|
340
|
+
except Exception as e:
|
341
|
+
logger.error(f"Error updating task status async: {e}")
|
342
|
+
return False
|
343
|
+
|
344
|
+
def get_task_info(self, event_id: str) -> Optional[Dict]:
|
345
|
+
"""获取任务信息(包括所有消费者组的执行情况)"""
|
346
|
+
self.connect()
|
347
|
+
try:
|
348
|
+
with self.conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
349
|
+
cursor.execute("""
|
350
|
+
SELECT
|
351
|
+
t.*,
|
352
|
+
array_agg(
|
353
|
+
json_build_object(
|
354
|
+
'consumer_group', tr.consumer_group,
|
355
|
+
'status', tr.status,
|
356
|
+
'start_time', tr.start_time,
|
357
|
+
'end_time', tr.end_time,
|
358
|
+
'duration_ms', tr.duration_ms,
|
359
|
+
'error_message', tr.error_message,
|
360
|
+
'retry_count', tr.retry_count
|
361
|
+
)
|
362
|
+
) FILTER (WHERE tr.id IS NOT NULL) as runs
|
363
|
+
FROM tasks t
|
364
|
+
LEFT JOIN task_runs tr ON t.id = tr.task_id
|
365
|
+
WHERE t.stream_id = %s
|
366
|
+
GROUP BY t.id
|
367
|
+
""", (event_id,))
|
368
|
+
|
369
|
+
return cursor.fetchone()
|
370
|
+
|
371
|
+
except Exception as e:
|
372
|
+
logger.error(f"Error getting task info: {e}")
|
373
|
+
return None
|
374
|
+
|
375
|
+
async def async_get_task_info(self, event_id: str) -> Optional[Dict]:
|
376
|
+
"""异步获取任务信息"""
|
377
|
+
await self.async_connect()
|
378
|
+
try:
|
379
|
+
row = await self.async_conn.fetchrow("""
|
380
|
+
SELECT
|
381
|
+
t.*,
|
382
|
+
array_agg(
|
383
|
+
json_build_object(
|
384
|
+
'consumer_group', tr.consumer_group,
|
385
|
+
'status', tr.status,
|
386
|
+
'start_time', tr.start_time,
|
387
|
+
'end_time', tr.end_time,
|
388
|
+
'duration_ms', tr.duration_ms,
|
389
|
+
'error_message', tr.error_message,
|
390
|
+
'retry_count', tr.retry_count
|
391
|
+
)
|
392
|
+
) FILTER (WHERE tr.id IS NOT NULL) as runs
|
393
|
+
FROM tasks t
|
394
|
+
LEFT JOIN task_runs tr ON t.id = tr.task_id
|
395
|
+
WHERE t.stream_id = $1
|
396
|
+
GROUP BY t.id
|
397
|
+
""", event_id)
|
398
|
+
|
399
|
+
return dict(row) if row else None
|
400
|
+
|
401
|
+
except Exception as e:
|
402
|
+
logger.error(f"Error getting task info async: {e}")
|
403
|
+
return None
|
@@ -0,0 +1,182 @@
|
|
1
|
+
"""SQL 文件处理工具"""
|
2
|
+
import re
|
3
|
+
from typing import List
|
4
|
+
import logging
|
5
|
+
|
6
|
+
logger = logging.getLogger(__name__)
|
7
|
+
|
8
|
+
|
9
|
+
def split_sql_statements(sql_content: str) -> List[str]:
|
10
|
+
"""
|
11
|
+
智能分割 SQL 语句,正确处理 PL/pgSQL 函数定义
|
12
|
+
|
13
|
+
Args:
|
14
|
+
sql_content: SQL 文件内容
|
15
|
+
|
16
|
+
Returns:
|
17
|
+
分割后的 SQL 语句列表
|
18
|
+
"""
|
19
|
+
statements = []
|
20
|
+
current_statement = []
|
21
|
+
in_function = False
|
22
|
+
in_string = False
|
23
|
+
in_dollar_quote = False
|
24
|
+
dollar_quote_tag = None
|
25
|
+
|
26
|
+
lines = sql_content.split('\n')
|
27
|
+
|
28
|
+
for line in lines:
|
29
|
+
# 跳过纯注释行
|
30
|
+
stripped = line.strip()
|
31
|
+
if stripped.startswith('--') and not current_statement:
|
32
|
+
continue
|
33
|
+
|
34
|
+
# 检测函数开始
|
35
|
+
if not in_function and not in_string and not in_dollar_quote:
|
36
|
+
# 检查是否是函数定义的开始
|
37
|
+
if re.match(r'^\s*CREATE\s+(OR\s+REPLACE\s+)?FUNCTION', line, re.IGNORECASE):
|
38
|
+
in_function = True
|
39
|
+
logger.debug(f"进入函数定义: {line[:50]}")
|
40
|
+
|
41
|
+
# 处理美元引号($$)
|
42
|
+
if not in_string:
|
43
|
+
# 查找美元引号
|
44
|
+
dollar_matches = re.findall(r'\$([^$]*)\$', line)
|
45
|
+
for match in dollar_matches:
|
46
|
+
if not in_dollar_quote:
|
47
|
+
# 开始美元引号
|
48
|
+
in_dollar_quote = True
|
49
|
+
dollar_quote_tag = match
|
50
|
+
logger.debug(f"进入美元引号: ${match}$")
|
51
|
+
elif dollar_quote_tag == match:
|
52
|
+
# 结束美元引号
|
53
|
+
in_dollar_quote = False
|
54
|
+
dollar_quote_tag = None
|
55
|
+
logger.debug(f"退出美元引号: ${match}$")
|
56
|
+
|
57
|
+
# 处理普通字符串(单引号)
|
58
|
+
if not in_dollar_quote:
|
59
|
+
# 简单的单引号检测(不处理转义)
|
60
|
+
quote_count = line.count("'") - line.count("\\'")
|
61
|
+
if quote_count % 2 == 1:
|
62
|
+
in_string = not in_string
|
63
|
+
|
64
|
+
current_statement.append(line)
|
65
|
+
|
66
|
+
# 检查语句是否结束
|
67
|
+
if stripped.endswith(';') and not in_string and not in_dollar_quote:
|
68
|
+
# 对于函数,需要特殊处理
|
69
|
+
if in_function:
|
70
|
+
# 检查是否是函数结束(以 language 'xxx' 或 $$ language 结尾)
|
71
|
+
if re.search(r"(language\s+['\"]?\w+['\"]?\s*;?\s*$|\$\$\s*language\s+['\"]?\w+['\"]?\s*;?\s*$)",
|
72
|
+
stripped, re.IGNORECASE):
|
73
|
+
in_function = False
|
74
|
+
logger.debug(f"函数定义结束: {line[:50]}")
|
75
|
+
# 完整的函数定义
|
76
|
+
full_statement = '\n'.join(current_statement)
|
77
|
+
if full_statement.strip():
|
78
|
+
statements.append(full_statement)
|
79
|
+
current_statement = []
|
80
|
+
# else: 仍在函数内部,继续累积
|
81
|
+
else:
|
82
|
+
# 普通语句结束
|
83
|
+
full_statement = '\n'.join(current_statement)
|
84
|
+
if full_statement.strip():
|
85
|
+
statements.append(full_statement)
|
86
|
+
current_statement = []
|
87
|
+
|
88
|
+
# 处理最后可能未完成的语句
|
89
|
+
if current_statement:
|
90
|
+
full_statement = '\n'.join(current_statement)
|
91
|
+
if full_statement.strip():
|
92
|
+
statements.append(full_statement)
|
93
|
+
|
94
|
+
logger.info(f"SQL 文件分割完成,共 {len(statements)} 个语句")
|
95
|
+
|
96
|
+
# 清理语句
|
97
|
+
cleaned_statements = []
|
98
|
+
for stmt in statements:
|
99
|
+
stmt = stmt.strip()
|
100
|
+
if stmt and not stmt.startswith('--'):
|
101
|
+
# 移除尾部多余的分号(保留一个)
|
102
|
+
while stmt.endswith(';;'):
|
103
|
+
stmt = stmt[:-1]
|
104
|
+
cleaned_statements.append(stmt)
|
105
|
+
|
106
|
+
return cleaned_statements
|
107
|
+
|
108
|
+
|
109
|
+
def execute_sql_file(connection, file_path: str):
|
110
|
+
"""
|
111
|
+
执行 SQL 文件(同步版本)
|
112
|
+
|
113
|
+
Args:
|
114
|
+
connection: 数据库连接
|
115
|
+
file_path: SQL 文件路径
|
116
|
+
"""
|
117
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
118
|
+
sql_content = f.read()
|
119
|
+
|
120
|
+
statements = split_sql_statements(sql_content)
|
121
|
+
|
122
|
+
cursor = connection.cursor()
|
123
|
+
try:
|
124
|
+
for i, stmt in enumerate(statements, 1):
|
125
|
+
try:
|
126
|
+
logger.debug(f"执行第 {i}/{len(statements)} 个语句")
|
127
|
+
cursor.execute(stmt)
|
128
|
+
except Exception as e:
|
129
|
+
if 'already exists' not in str(e):
|
130
|
+
logger.warning(f"语句 {i} 执行失败: {e}")
|
131
|
+
logger.debug(f"失败的语句: {stmt[:100]}...")
|
132
|
+
connection.commit()
|
133
|
+
logger.info(f"SQL 文件执行完成: {file_path}")
|
134
|
+
except Exception as e:
|
135
|
+
connection.rollback()
|
136
|
+
logger.error(f"SQL 文件执行失败: {e}")
|
137
|
+
raise
|
138
|
+
finally:
|
139
|
+
cursor.close()
|
140
|
+
|
141
|
+
|
142
|
+
async def execute_sql_file_async(async_session, file_path: str):
|
143
|
+
"""
|
144
|
+
执行 SQL 文件(异步版本)
|
145
|
+
|
146
|
+
Args:
|
147
|
+
async_session: 异步数据库会话
|
148
|
+
file_path: SQL 文件路径
|
149
|
+
"""
|
150
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
151
|
+
sql_content = f.read()
|
152
|
+
|
153
|
+
statements = split_sql_statements(sql_content)
|
154
|
+
|
155
|
+
# 为每个语句创建单独的事务,避免一个失败影响其他语句
|
156
|
+
for i, stmt in enumerate(statements, 1):
|
157
|
+
try:
|
158
|
+
logger.debug(f"异步执行第 {i}/{len(statements)} 个语句")
|
159
|
+
await async_session.execute(stmt)
|
160
|
+
await async_session.commit() # 立即提交每个成功的语句
|
161
|
+
except Exception as e:
|
162
|
+
# 静默处理一些预期的错误
|
163
|
+
error_str = str(e)
|
164
|
+
if any(x in error_str for x in [
|
165
|
+
'already exists',
|
166
|
+
'InFailedSqlTransaction',
|
167
|
+
'DeadlockDetected',
|
168
|
+
'duplicate key',
|
169
|
+
'already exists'
|
170
|
+
]):
|
171
|
+
logger.debug(f"语句 {i} 跳过(预期错误): {type(e).__name__}")
|
172
|
+
else:
|
173
|
+
logger.warning(f"语句 {i} 执行失败: {e}")
|
174
|
+
logger.debug(f"失败的语句: {stmt[:100]}...")
|
175
|
+
|
176
|
+
# 始终尝试回滚失败的事务
|
177
|
+
try:
|
178
|
+
await async_session.rollback()
|
179
|
+
except:
|
180
|
+
pass
|
181
|
+
|
182
|
+
logger.info(f"SQL 文件异步执行完成: {file_path}")
|
@@ -0,0 +1,17 @@
|
|
1
|
+
"""
|
2
|
+
定时任务调度模块
|
3
|
+
支持 Redis + PostgreSQL 双存储方案
|
4
|
+
"""
|
5
|
+
|
6
|
+
from .models import ScheduledTask, TaskExecutionHistory
|
7
|
+
from .scheduler import TaskScheduler
|
8
|
+
from .loader import TaskLoader
|
9
|
+
from .manager import ScheduledTaskManager
|
10
|
+
|
11
|
+
__all__ = [
|
12
|
+
'ScheduledTask',
|
13
|
+
'TaskExecutionHistory',
|
14
|
+
'TaskScheduler',
|
15
|
+
'TaskLoader',
|
16
|
+
'ScheduledTaskManager'
|
17
|
+
]
|
@@ -0,0 +1,11 @@
|
|
1
|
+
-- 为scheduled_tasks表添加执行次数字段
|
2
|
+
ALTER TABLE scheduled_tasks
|
3
|
+
ADD COLUMN IF NOT EXISTS execution_count INTEGER DEFAULT 0;
|
4
|
+
|
5
|
+
-- 添加注释
|
6
|
+
COMMENT ON COLUMN scheduled_tasks.execution_count IS '任务执行次数';
|
7
|
+
|
8
|
+
-- 为现有记录设置初始值(可选,根据历史数据估算)
|
9
|
+
UPDATE scheduled_tasks
|
10
|
+
SET execution_count = 0
|
11
|
+
WHERE execution_count IS NULL;
|
@@ -0,0 +1,26 @@
|
|
1
|
+
-- 添加priority字段到scheduled_tasks表
|
2
|
+
-- 用于支持定时任务的优先级设置
|
3
|
+
|
4
|
+
-- 检查是否已经存在priority字段,避免重复添加
|
5
|
+
DO $$
|
6
|
+
BEGIN
|
7
|
+
IF NOT EXISTS (
|
8
|
+
SELECT 1
|
9
|
+
FROM information_schema.columns
|
10
|
+
WHERE table_name = 'scheduled_tasks'
|
11
|
+
AND column_name = 'priority'
|
12
|
+
) THEN
|
13
|
+
ALTER TABLE scheduled_tasks
|
14
|
+
ADD COLUMN priority INTEGER DEFAULT NULL;
|
15
|
+
|
16
|
+
-- 添加注释
|
17
|
+
COMMENT ON COLUMN scheduled_tasks.priority IS '任务优先级 (1=最高, 数字越大优先级越低,NULL=默认最低)';
|
18
|
+
|
19
|
+
-- 创建索引以提高查询性能
|
20
|
+
CREATE INDEX idx_scheduled_tasks_priority ON scheduled_tasks(priority);
|
21
|
+
|
22
|
+
RAISE NOTICE 'Added priority column to scheduled_tasks table';
|
23
|
+
ELSE
|
24
|
+
RAISE NOTICE 'Priority column already exists in scheduled_tasks table';
|
25
|
+
END IF;
|
26
|
+
END $$;
|
@@ -0,0 +1,25 @@
|
|
1
|
+
-- Migration to add scheduler_id column to scheduled_tasks table
|
2
|
+
-- This allows unique identification and deduplication of tasks
|
3
|
+
|
4
|
+
-- Add the scheduler_id column if it doesn't exist
|
5
|
+
DO $$
|
6
|
+
BEGIN
|
7
|
+
IF NOT EXISTS (
|
8
|
+
SELECT 1
|
9
|
+
FROM information_schema.columns
|
10
|
+
WHERE table_name = 'scheduled_tasks'
|
11
|
+
AND column_name = 'scheduler_id'
|
12
|
+
) THEN
|
13
|
+
ALTER TABLE scheduled_tasks
|
14
|
+
ADD COLUMN scheduler_id VARCHAR(255) UNIQUE;
|
15
|
+
|
16
|
+
-- Add comment
|
17
|
+
COMMENT ON COLUMN scheduled_tasks.scheduler_id IS
|
18
|
+
'Unique identifier for the task, used for deduplication';
|
19
|
+
END IF;
|
20
|
+
END $$;
|
21
|
+
|
22
|
+
-- Create index for scheduler_id if it doesn't exist
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_scheduler_id
|
24
|
+
ON scheduled_tasks(scheduler_id)
|
25
|
+
WHERE scheduler_id IS NOT NULL;
|
@@ -0,0 +1,10 @@
|
|
1
|
+
-- Migration to add index for scheduler_id field
|
2
|
+
-- This index is critical for performance as we heavily rely on scheduler_id for lookups
|
3
|
+
|
4
|
+
-- Create unique index on scheduler_id for fast lookups
|
5
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_scheduled_tasks_scheduler_id
|
6
|
+
ON scheduled_tasks(scheduler_id);
|
7
|
+
|
8
|
+
-- Also add a comment to clarify the importance
|
9
|
+
COMMENT ON INDEX idx_scheduled_tasks_scheduler_id IS
|
10
|
+
'Unique index on scheduler_id for fast task lookups and deduplication';
|