lysync 1.0.0__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.
lysync/tasks.py ADDED
@@ -0,0 +1,453 @@
1
+ """Celery 任务(移植自 costexcel/tasks/export_task.py 并适配新模型)。
2
+
3
+ 与旧版差异:
4
+ * 任务名统一加 ``lysync_`` 前缀,避免与主项目其它 app 冲突。
5
+ * 队列名为 ``lysync_export_queue``。
6
+ * 导出逻辑改为直接写入目标库(见 :mod:`lysync.export.engine`),移除 Excel/OSS。
7
+ * ``init_periodic_tasks`` 在 app 的 ``ready()`` 中调用,初始化定时任务。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import time
14
+ import uuid
15
+
16
+ from celery import shared_task
17
+ from celery.exceptions import SoftTimeLimitExceeded
18
+ from django.conf import settings
19
+ from django.db import close_old_connections
20
+ from django.utils import timezone
21
+ from django_celery_beat.models import CrontabSchedule, PeriodicTask
22
+ from loguru import logger
23
+ from sqlglot import exp
24
+ from sqlglot import parse_one
25
+
26
+ from lysync.export.engine import exec_sync_export_check_item, exec_sync_export_task_item
27
+ from lysync.export.utils import (
28
+ add_limit_with_sqlglot,
29
+ fetch_data_from_temp_db,
30
+ format_datasource_error_message,
31
+ get_sql_context_for_ai,
32
+ grade_order,
33
+ render_sql_template,
34
+ sql_performance_grade,
35
+ )
36
+ from lysync.models import (
37
+ EXPLAIN_STATUS_CHECKING,
38
+ EXPLAIN_STATUS_FAILED,
39
+ EXPORT_RECORD_STATUS_CHECKING,
40
+ EXPORT_RECORD_STATUS_FAIL,
41
+ EXPORT_RECORD_STATUS_RUNNING,
42
+ EXPORT_RECORD_STATUS_STOPPED,
43
+ EXPORT_RECORD_STATUS_STOPPING,
44
+ EXPORT_RECORD_STATUS_SUCCESS,
45
+ EXPORT_RECORD_STATUS_WAIT,
46
+ SyncDataSource,
47
+ SyncExportRecord,
48
+ SyncExportTask,
49
+ )
50
+
51
+ EXPORT_TASK_LOCK_PREFIX = "lysync_export_task_lock_"
52
+ QUEUE_NAME = "lysync_export_queue"
53
+
54
+
55
+ def _patch_logger(request_id=None):
56
+ try:
57
+ from util.middleware import get_request_id
58
+
59
+ return logger.patch(lambda record: record["extra"].update(**get_request_id()))
60
+ except Exception:
61
+ return logger
62
+
63
+
64
+ # ============================ 定时任务初始化 ============================
65
+
66
+
67
+ def init_periodic_tasks():
68
+ """初始化周期性任务(在 app ready() 时调用)。"""
69
+ try:
70
+ try:
71
+ schedule, _ = CrontabSchedule.objects.get_or_create(
72
+ minute="1", hour="1", day_of_week="*", day_of_month="*", month_of_year="*"
73
+ )
74
+ except CrontabSchedule.MultipleObjectsReturned:
75
+ schedule = CrontabSchedule.objects.filter(
76
+ minute="1", hour="1", day_of_week="*", day_of_month="*", month_of_year="*"
77
+ ).first()
78
+
79
+ PeriodicTask.objects.get_or_create(
80
+ name="lysync导出数据", task="lysync_export_data", defaults={"crontab": schedule}
81
+ )
82
+ except Exception as e:
83
+ logger.exception(f"初始化导出定时任务失败: {e}")
84
+
85
+ try:
86
+ try:
87
+ schedule, _ = CrontabSchedule.objects.get_or_create(
88
+ minute="*", hour="*", day_of_week="*", day_of_month="*", month_of_year="*"
89
+ )
90
+ except CrontabSchedule.MultipleObjectsReturned:
91
+ schedule = CrontabSchedule.objects.filter(
92
+ minute="*", hour="*", day_of_week="*", day_of_month="*", month_of_year="*"
93
+ ).first()
94
+
95
+ PeriodicTask.objects.get_or_create(
96
+ name="lysync校验中断任务", task="lysync_check_export_task_interrupt", defaults={"crontab": schedule}
97
+ )
98
+ except Exception as e:
99
+ logger.exception(f"初始化校验中断定时任务失败: {e}")
100
+
101
+
102
+ # ============================ 中断校验 ============================
103
+
104
+
105
+ @shared_task(name="lysync_check_export_task_interrupt")
106
+ def check_export_task_interrupt():
107
+ from util.lock import RedisLock
108
+
109
+ close_old_connections()
110
+ logger.info("开始校验中断任务")
111
+ query = SyncExportTask.objects.filter(last_status=EXPORT_RECORD_STATUS_RUNNING, is_active=True)
112
+ for export_task in query:
113
+ if not RedisLock.check_lock_pattern(f"{EXPORT_TASK_LOCK_PREFIX}{export_task.id}"):
114
+ SyncExportTask.objects.filter(
115
+ id=export_task.id,
116
+ last_status=EXPORT_RECORD_STATUS_RUNNING,
117
+ update_time=export_task.update_time,
118
+ ).update(
119
+ last_status=EXPORT_RECORD_STATUS_FAIL,
120
+ last_message="任务被中断",
121
+ update_time=datetime.datetime.now(),
122
+ )
123
+
124
+
125
+ # ============================ 导出主流程 ============================
126
+
127
+
128
+ @shared_task(name="lysync_export_data")
129
+ def exec_export_task(export_task_id=None, export_all=True):
130
+ request_id = uuid.uuid4().hex
131
+ logger.info(f"[{request_id}]开始执行导出任务: export_task_id:{export_task_id} export_all:{export_all}")
132
+ close_old_connections()
133
+ query_export_task = SyncExportTask.objects.filter(is_active=True).order_by("model_type__sorted")
134
+ now = datetime.datetime.now()
135
+ if export_task_id:
136
+ query_export_task = query_export_task.filter(id=export_task_id)
137
+ if not query_export_task.exists():
138
+ logger.info(f"[{request_id}]没有待处理的导出任务")
139
+ return
140
+ export_task_id_list = list(query_export_task.values_list("id", flat=True))
141
+ exec_export_task_queue.apply_async(
142
+ kwargs={
143
+ "request_id": request_id,
144
+ "export_task_id_list": export_task_id_list,
145
+ "export_all": export_all,
146
+ "now": now,
147
+ "index": 0,
148
+ },
149
+ queue=QUEUE_NAME,
150
+ )
151
+
152
+
153
+ @shared_task(name="lysync_export_task_queue")
154
+ def exec_export_task_queue(export_task_id_list, export_all, now, index=0, request_id=None):
155
+ try:
156
+ close_old_connections()
157
+ exec_export_task_queue_func(
158
+ export_task_id_list, export_all=export_all, now=now, index=index, request_id=request_id
159
+ )
160
+ except SoftTimeLimitExceeded:
161
+ logger.error(f"[{request_id}]任务超时: export_task_id_list={export_task_id_list}")
162
+ SyncExportTask.objects.filter(id=export_task_id_list[0], last_status=EXPORT_RECORD_STATUS_RUNNING).update(
163
+ last_status=EXPORT_RECORD_STATUS_FAIL, last_message="任务执行超时"
164
+ )
165
+
166
+
167
+ def exec_export_task_queue_func(export_task_id_list, export_all, now, index, request_id=None):
168
+ from util.lock import RedisLock
169
+
170
+ if not export_task_id_list:
171
+ return
172
+ lock = None
173
+ try:
174
+ lock = RedisLock(f"{EXPORT_TASK_LOCK_PREFIX}{export_task_id_list[0]}", blocking=False)
175
+ if not lock.acquire():
176
+ logger.error(f"[{request_id}][SKIP]任务执行中:id:{export_task_id_list[0]}")
177
+ return
178
+ res = SyncExportTask.objects.filter(id=export_task_id_list[0], is_active=True).update(
179
+ last_status=EXPORT_RECORD_STATUS_RUNNING, update_time=timezone.now(), last_message=""
180
+ )
181
+ export_task = SyncExportTask.objects.filter(id=export_task_id_list[0], is_active=True).first()
182
+ if not res:
183
+ logger.error(f"[{request_id}]未找到导出任务: {export_task_id_list[0]}")
184
+ if len(export_task_id_list) > 1:
185
+ exec_export_task_queue.apply_async(
186
+ kwargs={
187
+ "request_id": request_id,
188
+ "export_task_id_list": export_task_id_list[1:],
189
+ "export_all": export_all,
190
+ "now": now,
191
+ "index": index + 1,
192
+ },
193
+ queue=QUEUE_NAME,
194
+ )
195
+ return
196
+ success = False
197
+ stop_task = False
198
+ start_new_task = False
199
+ message = ""
200
+ try:
201
+ success, message, has_more = exec_sync_export_task_item(export_task, request_id=request_id)
202
+ if export_all and has_more:
203
+ export_task = SyncExportTask.objects.filter(
204
+ id=export_task_id_list[0], last_status=EXPORT_RECORD_STATUS_RUNNING
205
+ ).first()
206
+ if not export_task:
207
+ stop_task = True
208
+ logger.error(f"[{request_id}]任务已终止: {export_task_id_list}")
209
+ else:
210
+ start_new_task = True
211
+ finally:
212
+ if stop_task:
213
+ SyncExportTask.objects.filter(
214
+ id=export_task_id_list[0], last_status=EXPORT_RECORD_STATUS_STOPPING
215
+ ).update(last_status=EXPORT_RECORD_STATUS_STOPPED, last_message="已停止", update_time=timezone.now())
216
+ elif success:
217
+ SyncExportTask.objects.filter(
218
+ id=export_task_id_list[0], last_status=EXPORT_RECORD_STATUS_RUNNING
219
+ ).update(last_status=EXPORT_RECORD_STATUS_SUCCESS, last_message=message, update_time=timezone.now())
220
+ else:
221
+ SyncExportTask.objects.filter(
222
+ id=export_task_id_list[0], last_status=EXPORT_RECORD_STATUS_RUNNING
223
+ ).update(last_status=EXPORT_RECORD_STATUS_FAIL, last_message=message, update_time=timezone.now())
224
+
225
+ if start_new_task:
226
+ logger.info(f"[{request_id}]继续执行本任务的后续数据: id={export_task.id}, name={export_task.name}")
227
+ exec_export_task_queue.apply_async(
228
+ kwargs={
229
+ "request_id": request_id,
230
+ "export_task_id_list": export_task_id_list,
231
+ "export_all": export_all,
232
+ "now": now,
233
+ "index": index + 1,
234
+ },
235
+ queue=QUEUE_NAME,
236
+ )
237
+ else:
238
+ exec_export_task_check.apply_async(kwargs={"export_task_id": export_task_id_list[0]}, queue=QUEUE_NAME)
239
+ if len(export_task_id_list) > 1:
240
+ logger.info(f"[{request_id}]继续执行下一个任务:{export_task_id_list[1:]}")
241
+ exec_export_task_queue.apply_async(
242
+ kwargs={
243
+ "request_id": request_id,
244
+ "export_task_id_list": export_task_id_list[1:],
245
+ "export_all": export_all,
246
+ "now": now,
247
+ "index": index + 1,
248
+ },
249
+ queue=QUEUE_NAME,
250
+ )
251
+ else:
252
+ logger.info(f"[{request_id}]无其他任务,本次请求完成")
253
+ finally:
254
+ if lock:
255
+ lock.release()
256
+
257
+
258
+ # ============================ 增量补录(check) ============================
259
+
260
+
261
+ @shared_task(name="lysync_export_task_check")
262
+ def exec_export_task_check(export_task_id=None):
263
+ close_old_connections()
264
+ logger.info("开始执行导出数据补录任务")
265
+ query_export_task = SyncExportTask.objects.filter(is_active=True)
266
+ now = datetime.datetime.now()
267
+ if export_task_id:
268
+ query_export_task = query_export_task.filter(id=export_task_id)
269
+ if not query_export_task.exists():
270
+ logger.info("没有待处理的任务")
271
+ return
272
+ for export_task in query_export_task:
273
+ exec_export_task_check_one_task.apply_async(
274
+ kwargs={"export_task_id": export_task.id, "now": now}, queue=QUEUE_NAME
275
+ )
276
+
277
+
278
+ @shared_task(name="lysync_export_task_check_one_task")
279
+ def exec_export_task_check_one_task(export_task_id, now):
280
+ export_record_query = SyncExportRecord.objects.filter(
281
+ export_task_id=export_task_id,
282
+ is_active=True,
283
+ check_status__in=[EXPORT_RECORD_STATUS_WAIT, EXPORT_RECORD_STATUS_CHECKING],
284
+ )
285
+ if not export_record_query.exists():
286
+ logger.info(f"未找到导出记录: {export_task_id}")
287
+ return
288
+ for index, export_record in enumerate(export_record_query):
289
+ exec_export_task_check_one_record.apply_async(
290
+ kwargs={
291
+ "export_task_id": export_task_id,
292
+ "export_record_id": export_record.id,
293
+ "now": now,
294
+ "index": index,
295
+ },
296
+ queue=QUEUE_NAME,
297
+ )
298
+
299
+
300
+ @shared_task(name="lysync_export_task_check_one_record")
301
+ def exec_export_task_check_one_record(export_task_id, export_record_id, now, index=0):
302
+ try:
303
+ close_old_connections()
304
+ export_record = SyncExportRecord.objects.filter(id=export_record_id, is_active=True).first()
305
+ if not export_record:
306
+ return
307
+ exec_sync_export_check_item(export_record)
308
+ except SoftTimeLimitExceeded:
309
+ logger.error(f"导出数据补录_单个任务超时: id={export_record_id}")
310
+ SyncExportRecord.objects.filter(id=export_record_id, check_status=EXPORT_RECORD_STATUS_CHECKING).update(
311
+ check_status=EXPORT_RECORD_STATUS_WAIT, last_check_message="任务执行超时"
312
+ )
313
+
314
+
315
+ # ============================ 数据源连接校验 ============================
316
+
317
+
318
+ @shared_task(name="lysync_check_datasource_connect", ignore_result=True)
319
+ def check_datasource_connect(datasource_id):
320
+ logger.info(f"开始检查数据源连接: {datasource_id}")
321
+ for i in range(3):
322
+ obj = SyncDataSource.objects.filter(
323
+ id=datasource_id,
324
+ source_type=SyncDataSource.SOURCE_TYPE_DB,
325
+ is_active=True,
326
+ ).first()
327
+ if not obj:
328
+ logger.error(f"未找到数据源: {datasource_id},第{i+1}次尝试")
329
+ time.sleep(3)
330
+ else:
331
+ break
332
+ if obj.db_connect_status != SyncDataSource.CONNECT_STATUS_CHECKING:
333
+ logger.info(f"数据源 {datasource_id}未启动检查")
334
+ return
335
+ try:
336
+ fetch_data_from_temp_db(
337
+ obj.db_type,
338
+ obj.db_host,
339
+ obj.db_port,
340
+ obj.db_user,
341
+ obj.db_password,
342
+ obj.db_name,
343
+ obj.db_options,
344
+ "select 1",
345
+ )
346
+ except Exception as e:
347
+ error_msg = format_datasource_error_message(str(e), obj.db_name, obj.db_host, obj.db_port)
348
+ obj.db_connect_status = SyncDataSource.CONNECT_STATUS_FAIL
349
+ obj.db_connect_message = error_msg
350
+ obj.save(update_fields=["db_connect_status", "db_connect_message"])
351
+ return
352
+ obj.db_connect_status = SyncDataSource.CONNECT_STATUS_SUCCESS
353
+ obj.db_connect_message = "连接成功"
354
+ obj.save(update_fields=["db_connect_status", "db_connect_message"])
355
+
356
+
357
+ # ============================ SQL 性能分析 / AI 优化 ============================
358
+
359
+
360
+ @shared_task(name="lysync_explain_item", ignore_result=True)
361
+ def explain_item(export_task_id):
362
+ export_task = SyncExportTask.objects.filter(id=export_task_id, is_active=True).first()
363
+ if not export_task:
364
+ return
365
+ export_task.explain_status = EXPLAIN_STATUS_CHECKING
366
+ export_task.explain_json = None
367
+ export_task.save(update_fields=["explain_status", "explain_json", "update_time"])
368
+ try:
369
+ data_source = export_task.data_source
370
+ if data_source.db_type == "tsql":
371
+ export_task.explain_status = EXPLAIN_STATUS_FAILED
372
+ export_task.explain_json = {"error_message": "不支持 SQL Server"}
373
+ export_task.save(update_fields=["explain_status", "explain_json", "update_time"])
374
+ return
375
+ sql = export_task.sql
376
+ # SQL 现在是 jinja2 模板,先渲染成可解析的 SQL 再做 EXPLAIN
377
+ rendered_sql = render_sql_template(
378
+ sql, start_time=datetime.datetime(2020, 1, 1), end_time=datetime.datetime(2020, 1, 2)
379
+ )
380
+ parsed = parse_one(rendered_sql, dialect=data_source.db_type)
381
+ if not isinstance(parsed, exp.Select):
382
+ export_task.explain_status = EXPLAIN_STATUS_FAILED
383
+ export_task.explain_json = {"error_message": "外层应为SELECT语句"}
384
+ export_task.save(update_fields=["explain_status", "explain_json", "update_time"])
385
+ return
386
+ if settings.EXPORT_DATA_LIMIT:
387
+ rendered_sql = add_limit_with_sqlglot(data_source.db_type, rendered_sql, settings.EXPORT_DATA_LIMIT)
388
+
389
+ try:
390
+ explain_sql = f"EXPLAIN FORMAT=TRADITIONAL {rendered_sql}"
391
+ explain_res = fetch_data_from_temp_db(
392
+ data_source.db_type,
393
+ data_source.db_host,
394
+ data_source.db_port,
395
+ data_source.db_user,
396
+ data_source.db_password,
397
+ data_source.db_name,
398
+ data_source.db_options,
399
+ explain_sql,
400
+ )
401
+ except Exception:
402
+ explain_sql = f"EXPLAIN {rendered_sql}"
403
+ explain_res = fetch_data_from_temp_db(
404
+ data_source.db_type,
405
+ data_source.db_host,
406
+ data_source.db_port,
407
+ data_source.db_user,
408
+ data_source.db_password,
409
+ data_source.db_name,
410
+ data_source.db_options,
411
+ explain_sql,
412
+ )
413
+
414
+ worst_index = 0
415
+ for item in explain_res:
416
+ item["p"] = sql_performance_grade(item)
417
+ order = grade_order.index(item["p"])
418
+ worst_index = max(worst_index, order)
419
+ export_task.explain_status = grade_order[worst_index]
420
+ export_task.explain_json = {"explain": explain_res}
421
+ export_task.save(update_fields=["explain_status", "explain_json", "update_time"])
422
+ except Exception as e:
423
+ logger.exception(f"explain_item执行失败: {e}")
424
+ export_task.explain_status = EXPLAIN_STATUS_FAILED
425
+ export_task.explain_json = {"error_message": str(e)}
426
+ export_task.save(update_fields=["explain_status", "explain_json", "update_time"])
427
+
428
+
429
+ @shared_task(name="lysync_ai_sql_optimization_task")
430
+ def ai_sql_optimization_task(export_task_id):
431
+ logger.info("开始执行:lysync_ai_sql_optimization_task")
432
+ export_task = SyncExportTask.objects.filter(id=export_task_id).first()
433
+ if not export_task:
434
+ raise Exception("未找到任务")
435
+ sql = export_task.sql
436
+ data_source = export_task.data_source
437
+ if data_source.db_type == "tsql":
438
+ return "# 不支持 SQL Server"
439
+ context = get_sql_context_for_ai(
440
+ sql,
441
+ data_source.db_type,
442
+ data_source.db_host,
443
+ data_source.db_port,
444
+ data_source.db_user,
445
+ data_source.db_password,
446
+ data_source.db_name,
447
+ data_source.db_options,
448
+ )
449
+ try:
450
+ from dify import call as dify_call
451
+ except Exception:
452
+ return "# 未配置 dify 调用(宿主项目需提供 dify.call)"
453
+ return dify_call(context)
lysync/tests.py ADDED
@@ -0,0 +1,3 @@
1
+ from django.test import TestCase
2
+
3
+ # Create your tests here.
lysync/urls.py ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env python
2
+ # encoding: utf-8
3
+
4
+ """lysync 导出功能 URL 配置。
5
+
6
+ 宿主项目可在根 urlconf 中通过 ``path("lysync/", include("lysync.urls"))`` 引入。
7
+ """
8
+ from django.urls import re_path
9
+
10
+ from lysync import views
11
+
12
+ urlpatterns = [
13
+ re_path(r"^create_export_task$", views.create_export_task),
14
+ re_path(r"^update_export_task$", views.update_export_task),
15
+ re_path(r"^recover_export_task$", views.recover_export_task),
16
+ re_path(r"^exec_task_immediately$", views.exec_task_immediately),
17
+ re_path(r"^reset_task_last_export$", views.reset_task_last_export),
18
+ re_path(r"^reset_task_status$", views.reset_task_status),
19
+ re_path(r"^update_sync_data_source$", views.update_sync_data_source),
20
+ re_path(r"^get_collect_funcs$", views.get_collect_funcs),
21
+ re_path(r"^save_sync_model_type$", views.save_sync_model_type),
22
+ re_path(r"^get_process_funcs$", views.get_process_funcs),
23
+ re_path(r"^get_content_types$", views.get_content_types),
24
+ re_path(r"^get_model_fields$", views.get_model_fields),
25
+ re_path(r"^query_raw_data$", views.query_raw_data),
26
+ re_path(r"^check_explains$", views.check_explains),
27
+ re_path(r"^data_source_check_connect$", views.data_source_check_connect),
28
+ re_path(r"^stop_export_task$", views.stop_export_task),
29
+ re_path(r"^ai_sql_optimization$", views.ai_sql_optimization),
30
+ re_path(r"^get_ai_sql_optimization_result$", views.get_ai_sql_optimization_result),
31
+ ]
lysync/utils.py ADDED
@@ -0,0 +1,22 @@
1
+ import datetime
2
+
3
+ from django.conf import settings
4
+ from django.utils import timezone
5
+
6
+
7
+ def get_queue_name():
8
+ return getattr(settings, "QUEUE_QUEUE_EXPORT", "celery")
9
+
10
+
11
+ def local_today():
12
+ """返回“今天”的本地日期,兼容宿主项目 ``USE_TZ=True/False``。
13
+
14
+ 当宿主项目 ``USE_TZ=False`` 时,``timezone.now()`` 返回 naive datetime,
15
+ 此时调用 ``timezone.localdate()`` 会抛
16
+ ``ValueError: localdate() cannot be applied to a naive datetime``。
17
+ 这里做兼容:检测到 naive 时直接用 ``datetime.date.today()``,
18
+ 避免该异常破坏 SQL 模板渲染 / 任务推进逻辑。
19
+ """
20
+ if timezone.is_naive(timezone.now()):
21
+ return datetime.date.today()
22
+ return timezone.localdate()