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.
Files changed (94) hide show
  1. jettask/core/cli.py +20 -24
  2. jettask/monitor/run_backlog_collector.py +96 -0
  3. jettask/monitor/stream_backlog_monitor.py +362 -0
  4. jettask/pg_consumer/pg_consumer_v2.py +403 -0
  5. jettask/pg_consumer/sql_utils.py +182 -0
  6. jettask/scheduler/__init__.py +17 -0
  7. jettask/scheduler/add_execution_count.sql +11 -0
  8. jettask/scheduler/add_priority_field.sql +26 -0
  9. jettask/scheduler/add_scheduler_id.sql +25 -0
  10. jettask/scheduler/add_scheduler_id_index.sql +10 -0
  11. jettask/scheduler/loader.py +249 -0
  12. jettask/scheduler/make_scheduler_id_required.sql +28 -0
  13. jettask/scheduler/manager.py +696 -0
  14. jettask/scheduler/migrate_interval_seconds.sql +9 -0
  15. jettask/scheduler/models.py +200 -0
  16. jettask/scheduler/multi_namespace_scheduler.py +294 -0
  17. jettask/scheduler/performance_optimization.sql +45 -0
  18. jettask/scheduler/run_scheduler.py +186 -0
  19. jettask/scheduler/scheduler.py +715 -0
  20. jettask/scheduler/schema.sql +84 -0
  21. jettask/scheduler/unified_manager.py +450 -0
  22. jettask/scheduler/unified_scheduler_manager.py +280 -0
  23. jettask/webui/backend/api/__init__.py +3 -0
  24. jettask/webui/backend/api/v1/__init__.py +17 -0
  25. jettask/webui/backend/api/v1/monitoring.py +431 -0
  26. jettask/webui/backend/api/v1/namespaces.py +504 -0
  27. jettask/webui/backend/api/v1/queues.py +342 -0
  28. jettask/webui/backend/api/v1/tasks.py +367 -0
  29. jettask/webui/backend/core/__init__.py +3 -0
  30. jettask/webui/backend/core/cache.py +221 -0
  31. jettask/webui/backend/core/database.py +200 -0
  32. jettask/webui/backend/core/exceptions.py +102 -0
  33. jettask/webui/backend/models/__init__.py +3 -0
  34. jettask/webui/backend/models/requests.py +236 -0
  35. jettask/webui/backend/models/responses.py +230 -0
  36. jettask/webui/backend/services/__init__.py +3 -0
  37. jettask/webui/frontend/index.html +13 -0
  38. jettask/webui/models/__init__.py +3 -0
  39. jettask/webui/models/namespace.py +63 -0
  40. jettask/webui/sql/batch_upsert_functions.sql +178 -0
  41. jettask/webui/sql/init_database.sql +640 -0
  42. {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/METADATA +11 -9
  43. {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/RECORD +47 -54
  44. jettask/webui/frontend/package-lock.json +0 -4833
  45. jettask/webui/frontend/package.json +0 -30
  46. jettask/webui/frontend/src/App.css +0 -109
  47. jettask/webui/frontend/src/App.jsx +0 -66
  48. jettask/webui/frontend/src/components/NamespaceSelector.jsx +0 -166
  49. jettask/webui/frontend/src/components/QueueBacklogChart.jsx +0 -298
  50. jettask/webui/frontend/src/components/QueueBacklogTrend.jsx +0 -638
  51. jettask/webui/frontend/src/components/QueueDetailsTable.css +0 -65
  52. jettask/webui/frontend/src/components/QueueDetailsTable.jsx +0 -487
  53. jettask/webui/frontend/src/components/QueueDetailsTableV2.jsx +0 -465
  54. jettask/webui/frontend/src/components/ScheduledTaskFilter.jsx +0 -423
  55. jettask/webui/frontend/src/components/TaskFilter.jsx +0 -425
  56. jettask/webui/frontend/src/components/TimeRangeSelector.css +0 -21
  57. jettask/webui/frontend/src/components/TimeRangeSelector.jsx +0 -160
  58. jettask/webui/frontend/src/components/charts/QueueChart.jsx +0 -111
  59. jettask/webui/frontend/src/components/charts/QueueTrendChart.jsx +0 -115
  60. jettask/webui/frontend/src/components/charts/WorkerChart.jsx +0 -40
  61. jettask/webui/frontend/src/components/common/StatsCard.jsx +0 -18
  62. jettask/webui/frontend/src/components/layout/AppLayout.css +0 -95
  63. jettask/webui/frontend/src/components/layout/AppLayout.jsx +0 -49
  64. jettask/webui/frontend/src/components/layout/Header.css +0 -106
  65. jettask/webui/frontend/src/components/layout/Header.jsx +0 -106
  66. jettask/webui/frontend/src/components/layout/SideMenu.css +0 -137
  67. jettask/webui/frontend/src/components/layout/SideMenu.jsx +0 -209
  68. jettask/webui/frontend/src/components/layout/TabsNav.css +0 -244
  69. jettask/webui/frontend/src/components/layout/TabsNav.jsx +0 -206
  70. jettask/webui/frontend/src/components/layout/UserInfo.css +0 -197
  71. jettask/webui/frontend/src/components/layout/UserInfo.jsx +0 -197
  72. jettask/webui/frontend/src/contexts/LoadingContext.jsx +0 -27
  73. jettask/webui/frontend/src/contexts/NamespaceContext.jsx +0 -72
  74. jettask/webui/frontend/src/contexts/TabsContext.backup.jsx +0 -245
  75. jettask/webui/frontend/src/index.css +0 -114
  76. jettask/webui/frontend/src/main.jsx +0 -20
  77. jettask/webui/frontend/src/pages/Alerts.jsx +0 -684
  78. jettask/webui/frontend/src/pages/Dashboard/index.css +0 -35
  79. jettask/webui/frontend/src/pages/Dashboard/index.jsx +0 -281
  80. jettask/webui/frontend/src/pages/Dashboard.jsx +0 -1330
  81. jettask/webui/frontend/src/pages/QueueDetail.jsx +0 -1117
  82. jettask/webui/frontend/src/pages/QueueMonitor.jsx +0 -527
  83. jettask/webui/frontend/src/pages/Queues.jsx +0 -12
  84. jettask/webui/frontend/src/pages/ScheduledTasks.jsx +0 -809
  85. jettask/webui/frontend/src/pages/Settings.jsx +0 -800
  86. jettask/webui/frontend/src/pages/Workers.jsx +0 -12
  87. jettask/webui/frontend/src/services/api.js +0 -114
  88. jettask/webui/frontend/src/services/queueTrend.js +0 -152
  89. jettask/webui/frontend/src/utils/suppressWarnings.js +0 -22
  90. jettask/webui/frontend/src/utils/userPreferences.js +0 -154
  91. {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/WHEEL +0 -0
  92. {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/entry_points.txt +0 -0
  93. {jettask-0.2.4.dist-info → jettask-0.2.6.dist-info}/licenses/LICENSE +0 -0
  94. {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';