jettask 0.2.5__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 (93) hide show
  1. jettask/monitor/run_backlog_collector.py +96 -0
  2. jettask/monitor/stream_backlog_monitor.py +362 -0
  3. jettask/pg_consumer/pg_consumer_v2.py +403 -0
  4. jettask/pg_consumer/sql_utils.py +182 -0
  5. jettask/scheduler/__init__.py +17 -0
  6. jettask/scheduler/add_execution_count.sql +11 -0
  7. jettask/scheduler/add_priority_field.sql +26 -0
  8. jettask/scheduler/add_scheduler_id.sql +25 -0
  9. jettask/scheduler/add_scheduler_id_index.sql +10 -0
  10. jettask/scheduler/loader.py +249 -0
  11. jettask/scheduler/make_scheduler_id_required.sql +28 -0
  12. jettask/scheduler/manager.py +696 -0
  13. jettask/scheduler/migrate_interval_seconds.sql +9 -0
  14. jettask/scheduler/models.py +200 -0
  15. jettask/scheduler/multi_namespace_scheduler.py +294 -0
  16. jettask/scheduler/performance_optimization.sql +45 -0
  17. jettask/scheduler/run_scheduler.py +186 -0
  18. jettask/scheduler/scheduler.py +715 -0
  19. jettask/scheduler/schema.sql +84 -0
  20. jettask/scheduler/unified_manager.py +450 -0
  21. jettask/scheduler/unified_scheduler_manager.py +280 -0
  22. jettask/webui/backend/api/__init__.py +3 -0
  23. jettask/webui/backend/api/v1/__init__.py +17 -0
  24. jettask/webui/backend/api/v1/monitoring.py +431 -0
  25. jettask/webui/backend/api/v1/namespaces.py +504 -0
  26. jettask/webui/backend/api/v1/queues.py +342 -0
  27. jettask/webui/backend/api/v1/tasks.py +367 -0
  28. jettask/webui/backend/core/__init__.py +3 -0
  29. jettask/webui/backend/core/cache.py +221 -0
  30. jettask/webui/backend/core/database.py +200 -0
  31. jettask/webui/backend/core/exceptions.py +102 -0
  32. jettask/webui/backend/models/__init__.py +3 -0
  33. jettask/webui/backend/models/requests.py +236 -0
  34. jettask/webui/backend/models/responses.py +230 -0
  35. jettask/webui/backend/services/__init__.py +3 -0
  36. jettask/webui/frontend/index.html +13 -0
  37. jettask/webui/models/__init__.py +3 -0
  38. jettask/webui/models/namespace.py +63 -0
  39. jettask/webui/sql/batch_upsert_functions.sql +178 -0
  40. jettask/webui/sql/init_database.sql +640 -0
  41. {jettask-0.2.5.dist-info → jettask-0.2.6.dist-info}/METADATA +11 -9
  42. {jettask-0.2.5.dist-info → jettask-0.2.6.dist-info}/RECORD +46 -53
  43. jettask/webui/frontend/package-lock.json +0 -4833
  44. jettask/webui/frontend/package.json +0 -30
  45. jettask/webui/frontend/src/App.css +0 -109
  46. jettask/webui/frontend/src/App.jsx +0 -66
  47. jettask/webui/frontend/src/components/NamespaceSelector.jsx +0 -166
  48. jettask/webui/frontend/src/components/QueueBacklogChart.jsx +0 -298
  49. jettask/webui/frontend/src/components/QueueBacklogTrend.jsx +0 -638
  50. jettask/webui/frontend/src/components/QueueDetailsTable.css +0 -65
  51. jettask/webui/frontend/src/components/QueueDetailsTable.jsx +0 -487
  52. jettask/webui/frontend/src/components/QueueDetailsTableV2.jsx +0 -465
  53. jettask/webui/frontend/src/components/ScheduledTaskFilter.jsx +0 -423
  54. jettask/webui/frontend/src/components/TaskFilter.jsx +0 -425
  55. jettask/webui/frontend/src/components/TimeRangeSelector.css +0 -21
  56. jettask/webui/frontend/src/components/TimeRangeSelector.jsx +0 -160
  57. jettask/webui/frontend/src/components/charts/QueueChart.jsx +0 -111
  58. jettask/webui/frontend/src/components/charts/QueueTrendChart.jsx +0 -115
  59. jettask/webui/frontend/src/components/charts/WorkerChart.jsx +0 -40
  60. jettask/webui/frontend/src/components/common/StatsCard.jsx +0 -18
  61. jettask/webui/frontend/src/components/layout/AppLayout.css +0 -95
  62. jettask/webui/frontend/src/components/layout/AppLayout.jsx +0 -49
  63. jettask/webui/frontend/src/components/layout/Header.css +0 -106
  64. jettask/webui/frontend/src/components/layout/Header.jsx +0 -106
  65. jettask/webui/frontend/src/components/layout/SideMenu.css +0 -137
  66. jettask/webui/frontend/src/components/layout/SideMenu.jsx +0 -209
  67. jettask/webui/frontend/src/components/layout/TabsNav.css +0 -244
  68. jettask/webui/frontend/src/components/layout/TabsNav.jsx +0 -206
  69. jettask/webui/frontend/src/components/layout/UserInfo.css +0 -197
  70. jettask/webui/frontend/src/components/layout/UserInfo.jsx +0 -197
  71. jettask/webui/frontend/src/contexts/LoadingContext.jsx +0 -27
  72. jettask/webui/frontend/src/contexts/NamespaceContext.jsx +0 -72
  73. jettask/webui/frontend/src/contexts/TabsContext.backup.jsx +0 -245
  74. jettask/webui/frontend/src/index.css +0 -114
  75. jettask/webui/frontend/src/main.jsx +0 -20
  76. jettask/webui/frontend/src/pages/Alerts.jsx +0 -684
  77. jettask/webui/frontend/src/pages/Dashboard/index.css +0 -35
  78. jettask/webui/frontend/src/pages/Dashboard/index.jsx +0 -281
  79. jettask/webui/frontend/src/pages/Dashboard.jsx +0 -1330
  80. jettask/webui/frontend/src/pages/QueueDetail.jsx +0 -1117
  81. jettask/webui/frontend/src/pages/QueueMonitor.jsx +0 -527
  82. jettask/webui/frontend/src/pages/Queues.jsx +0 -12
  83. jettask/webui/frontend/src/pages/ScheduledTasks.jsx +0 -809
  84. jettask/webui/frontend/src/pages/Settings.jsx +0 -800
  85. jettask/webui/frontend/src/pages/Workers.jsx +0 -12
  86. jettask/webui/frontend/src/services/api.js +0 -114
  87. jettask/webui/frontend/src/services/queueTrend.js +0 -152
  88. jettask/webui/frontend/src/utils/suppressWarnings.js +0 -22
  89. jettask/webui/frontend/src/utils/userPreferences.js +0 -154
  90. {jettask-0.2.5.dist-info → jettask-0.2.6.dist-info}/WHEEL +0 -0
  91. {jettask-0.2.5.dist-info → jettask-0.2.6.dist-info}/entry_points.txt +0 -0
  92. {jettask-0.2.5.dist-info → jettask-0.2.6.dist-info}/licenses/LICENSE +0 -0
  93. {jettask-0.2.5.dist-info → jettask-0.2.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,715 @@
1
+ """
2
+ 定时任务调度器 - 负责从Redis获取任务并触发执行
3
+ """
4
+ import asyncio
5
+ import time
6
+ from redis import asyncio as aioredis
7
+ from redis.asyncio.lock import Lock as AsyncLock
8
+ import uuid
9
+ from typing import Optional, List, TYPE_CHECKING
10
+ from datetime import datetime
11
+
12
+ from ..utils.task_logger import get_task_logger, LogContext
13
+ from .manager import ScheduledTaskManager
14
+ from .models import ScheduledTask, TaskExecutionHistory, TaskType
15
+ from .models import TaskStatus as ScheduledTaskStatus # 定时任务专用的状态枚举
16
+ from .loader import TaskLoader
17
+
18
+ # 类型注解导入(避免循环导入)
19
+ if TYPE_CHECKING:
20
+ from ..core.app import Jettask
21
+
22
+
23
+ logger = get_task_logger(__name__)
24
+
25
+
26
+ class TaskScheduler:
27
+ """
28
+ 任务调度器
29
+ 从Redis ZSET中获取到期任务并投递到执行队列
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ app: 'Jettask', # Jettask实例
35
+ db_manager: ScheduledTaskManager,
36
+ scan_interval: float = 0.1,
37
+ batch_size: int = 100,
38
+ leader_ttl: int = 30
39
+ ):
40
+ """
41
+ 初始化调度器
42
+
43
+ Args:
44
+ app: Jettask应用实例(包含redis_url和redis_prefix)
45
+ db_manager: 数据库管理器
46
+ scan_interval: 扫描间隔(秒)
47
+ batch_size: 每批处理的任务数
48
+ leader_ttl: Leader锁的TTL(秒)
49
+ """
50
+ self.app: 'Jettask' = app
51
+ # 从app获取Redis配置
52
+ self.redis_url = app.redis_url
53
+ self.redis_prefix = f"{app.redis_prefix}:scheduled" # 使用app的前缀加上scheduled命名空间
54
+ self.db_manager = db_manager
55
+ self.scan_interval = scan_interval
56
+ self.batch_size = batch_size
57
+ self.leader_ttl = leader_ttl
58
+
59
+ self.redis: Optional[aioredis.Redis] = None
60
+ self.scheduler_id = f"scheduler-{uuid.uuid4().hex[:8]}"
61
+ self.running = False
62
+ self.is_leader = False
63
+ self.leader_lock: Optional[AsyncLock] = None
64
+
65
+ # 任务加载器
66
+ self.loader = TaskLoader(
67
+ redis_url=self.redis_url,
68
+ db_manager=db_manager,
69
+ redis_prefix=self.redis_prefix,
70
+ sync_interval_cycles=2 # 每2个周期同步一次(约1分钟)
71
+ )
72
+
73
+
74
+ def _get_zset_key(self) -> str:
75
+ """获取ZSET键名"""
76
+ return f"{self.redis_prefix}:tasks"
77
+
78
+ def _get_task_detail_key(self, task_id: int) -> str:
79
+ """获取任务详情键名"""
80
+ return f"{self.redis_prefix}:task:{task_id}"
81
+
82
+ def _get_leader_key(self) -> str:
83
+ """获取Leader锁键名"""
84
+ return f"{self.redis_prefix}:leader"
85
+
86
+ def _get_processing_key(self, task_id: int) -> str:
87
+ """获取任务处理中标记键名"""
88
+ return f"{self.redis_prefix}:processing:{task_id}"
89
+
90
+ async def acquire_leader(self) -> bool:
91
+ """
92
+ 尝试获取Leader锁(使用redis-py的AsyncLock)
93
+
94
+ Returns:
95
+ 是否成功获取Leader
96
+ """
97
+ # 如果已经持有锁,检查是否仍然有效
98
+ if self.is_leader and self.leader_lock:
99
+ try:
100
+ # AsyncLock的owned()方法检查是否仍然拥有锁
101
+ if await self.leader_lock.owned():
102
+ return True
103
+ else:
104
+ # 锁已经失效
105
+ self.is_leader = False
106
+ self.leader_lock = None
107
+ except Exception as e:
108
+ logger.warning(f"Error checking leader lock: {e}")
109
+ self.is_leader = False
110
+ self.leader_lock = None
111
+
112
+ # 创建或获取锁对象
113
+ if not self.leader_lock:
114
+ self.leader_lock = AsyncLock(
115
+ self.redis,
116
+ self._get_leader_key(),
117
+ timeout=self.leader_ttl, # 锁的超时时间
118
+ sleep=0.1, # 重试间隔
119
+ blocking=False, # 非阻塞模式
120
+ blocking_timeout=None, # 不等待
121
+ thread_local=False # 不使用线程本地存储
122
+ )
123
+
124
+ # 尝试获取锁
125
+ try:
126
+ acquired = await self.leader_lock.acquire(blocking=False)
127
+ if acquired:
128
+ self.is_leader = True
129
+ logger.info(f"Scheduler {self.scheduler_id} acquired leader lock")
130
+ return True
131
+ else:
132
+ # 获取当前锁的信息用于调试
133
+ lock_info = await self.redis.get(self._get_leader_key())
134
+ if lock_info:
135
+ logger.debug(f"Leader lock is held by another instance, will retry")
136
+ else:
137
+ logger.debug(f"Leader lock exists but no value, will retry")
138
+ return False
139
+
140
+ except Exception as e:
141
+ logger.error(f"Error acquiring leader lock: {e}")
142
+ return False
143
+
144
+ async def renew_leader(self) -> bool:
145
+ """
146
+ 续期Leader锁(使用AsyncLock的extend方法)
147
+
148
+ Returns:
149
+ 是否成功续期
150
+ """
151
+ if not self.is_leader or not self.leader_lock:
152
+ return False
153
+
154
+ try:
155
+ # 使用extend方法续期锁
156
+ await self.leader_lock.extend(self.leader_ttl, replace_ttl=True)
157
+ logger.debug(f"Scheduler {self.scheduler_id} renewed leader lock")
158
+ return True
159
+
160
+ except Exception as e:
161
+ logger.error(f"Error renewing leader lock: {e}")
162
+ self.is_leader = False
163
+ self.leader_lock = None
164
+ return False
165
+
166
+ async def release_leader(self, force=False):
167
+ """
168
+ 释放Leader锁(使用AsyncLock的release方法)
169
+
170
+ Args:
171
+ force: 是否强制删除锁(用于CTRL+C退出时)
172
+ """
173
+ if not self.leader_lock and not force:
174
+ return
175
+
176
+ try:
177
+ # 如果是强制释放且当前是leader,直接删除Redis中的key
178
+ if force and self.is_leader:
179
+ leader_key = self._get_leader_key()
180
+ deleted = await self.redis.delete(leader_key)
181
+ if deleted:
182
+ logger.info(f"Scheduler {self.scheduler_id} forcefully deleted leader lock key")
183
+ else:
184
+ logger.debug(f"Leader lock key was already deleted")
185
+ elif self.leader_lock:
186
+ # 正常释放流程:只有当我们拥有锁时才释放
187
+ if await self.leader_lock.owned():
188
+ await self.leader_lock.release()
189
+ logger.info(f"Scheduler {self.scheduler_id} released leader lock")
190
+ else:
191
+ logger.debug(f"Scheduler {self.scheduler_id} does not own the lock")
192
+
193
+ except Exception as e:
194
+ logger.warning(f"Error releasing leader lock: {e}")
195
+ finally:
196
+ self.is_leader = False
197
+ self.leader_lock = None
198
+
199
+ async def get_due_tasks_with_details(self) -> List[tuple]:
200
+ """
201
+ 获取到期的任务及其详情(使用Lua脚本原子操作)
202
+
203
+ Returns:
204
+ 任务列表,每个元素为(task_id, score, task_detail_json)
205
+ """
206
+ now = datetime.now().timestamp()
207
+
208
+ # Lua脚本:原子性地获取到期任务并获取其详情
209
+ lua_script = """
210
+ local zset_key = KEYS[1]
211
+ local detail_prefix = KEYS[2]
212
+ local now = ARGV[1]
213
+ local batch_size = ARGV[2]
214
+
215
+ -- 获取到期任务
216
+ local due_tasks = redis.call('ZRANGEBYSCORE', zset_key, '-inf', now, 'WITHSCORES', 'LIMIT', 0, batch_size)
217
+
218
+ if #due_tasks == 0 then
219
+ return {}
220
+ end
221
+
222
+ local result = {}
223
+
224
+ -- 遍历任务,获取详情
225
+ for i = 1, #due_tasks, 2 do
226
+ local task_id = due_tasks[i]
227
+ local score = due_tasks[i + 1]
228
+
229
+ -- 获取任务详情
230
+ local detail_key = detail_prefix .. ':' .. task_id
231
+ local task_detail = redis.call('GET', detail_key)
232
+
233
+ -- 添加到结果集
234
+ table.insert(result, {task_id, score, task_detail or ''})
235
+ end
236
+
237
+ return result
238
+ """
239
+
240
+ # 执行Lua脚本
241
+ result = await self.redis.eval(
242
+ lua_script,
243
+ 2, # KEYS数量
244
+ self._get_zset_key(), # KEYS[1]
245
+ f"{self.redis_prefix}:task", # KEYS[2] - 任务详情前缀
246
+ str(now), # ARGV[1]
247
+ str(self.batch_size) # ARGV[2]
248
+ )
249
+
250
+ return result or []
251
+
252
+ async def get_due_tasks(self) -> List[tuple]:
253
+ """
254
+ 获取到期的任务(保留兼容性)
255
+
256
+ Returns:
257
+ 任务ID和分数的列表
258
+ """
259
+ now = datetime.now().timestamp()
260
+
261
+ # 使用ZRANGEBYSCORE获取到期任务
262
+ tasks = await self.redis.zrangebyscore(
263
+ self._get_zset_key(),
264
+ min='-inf',
265
+ max=now,
266
+ withscores=True,
267
+ start=0,
268
+ num=self.batch_size
269
+ )
270
+
271
+ return tasks
272
+
273
+ async def load_task_detail(self, task_id: int) -> Optional[ScheduledTask]:
274
+ """
275
+ 加载任务详情
276
+
277
+ Args:
278
+ task_id: 任务ID
279
+
280
+ Returns:
281
+ 任务对象
282
+ """
283
+ # 先从Redis获取
284
+ task_data = await self.redis.get(self._get_task_detail_key(task_id))
285
+
286
+ if task_data:
287
+ try:
288
+ task_str = task_data.decode() if isinstance(task_data, bytes) else task_data
289
+ return ScheduledTask.from_redis_value(task_str)
290
+ except Exception as e:
291
+ logger.error(f"Failed to parse task from Redis: {e}")
292
+
293
+ # Redis中没有,从数据库获取
294
+ task = await self.db_manager.get_task(task_id)
295
+
296
+ if task:
297
+ # 缓存到Redis
298
+ await self.redis.setex(
299
+ self._get_task_detail_key(task_id),
300
+ 300, # 5分钟过期
301
+ task.to_redis_value()
302
+ )
303
+
304
+ return task
305
+
306
+ async def trigger_task(self, task: ScheduledTask) -> str:
307
+ """
308
+ 触发任务执行
309
+
310
+ Args:
311
+ task: 任务对象
312
+
313
+ Returns:
314
+ 事件ID
315
+ """
316
+ with LogContext(task_id=task.id):
317
+ try:
318
+ # 直接使用新的发送方式,无需获取task对象
319
+ from ..core.message import TaskMessage
320
+
321
+ # 准备kwargs
322
+ kwargs = task.task_kwargs or {}
323
+ # 添加任务名称用于路由
324
+ kwargs['__task_name'] = task.task_name
325
+ kwargs['__scheduled_task_id'] = task.id
326
+
327
+ # 创建TaskMessage
328
+ # 将timeout、max_retries等参数放入kwargs中传递
329
+ if task.timeout:
330
+ kwargs['__timeout'] = task.timeout
331
+ if task.max_retries:
332
+ kwargs['__max_retries'] = task.max_retries
333
+ if task.retry_delay:
334
+ kwargs['__retry_delay'] = task.retry_delay
335
+
336
+ msg = TaskMessage(
337
+ queue=task.queue_name,
338
+ kwargs=kwargs,
339
+ priority=task.priority
340
+ )
341
+
342
+ # 发送任务
343
+ event_ids = await self.app.send_tasks([msg])
344
+ event_id = event_ids[0] if event_ids else None
345
+
346
+ logger.info(f"Triggered task {task.id} with event_id {event_id}")
347
+
348
+ # 记录执行历史
349
+ history = TaskExecutionHistory(
350
+ task_id=task.id,
351
+ event_id=event_id,
352
+ scheduled_time=task.next_run_time or datetime.now(),
353
+ status=ScheduledTaskStatus.PENDING,
354
+ started_at=datetime.now()
355
+ )
356
+ await self.db_manager.record_execution(history)
357
+
358
+ return event_id
359
+
360
+ except Exception as e:
361
+ logger.error(f"Failed to trigger task {task.id}: {e}", exc_info=True)
362
+
363
+ # 记录失败
364
+ history = TaskExecutionHistory(
365
+ task_id=task.id,
366
+ event_id=f"failed-{uuid.uuid4().hex[:8]}",
367
+ scheduled_time=task.next_run_time or datetime.now(),
368
+ status=ScheduledTaskStatus.FAILED,
369
+ error_message=str(e),
370
+ started_at=datetime.now(),
371
+ finished_at=datetime.now()
372
+ )
373
+ await self.db_manager.record_execution(history)
374
+
375
+ raise
376
+
377
+ async def process_task(self, task_id: int, score: float):
378
+ """
379
+ 处理单个任务
380
+
381
+ Args:
382
+ task_id: 任务ID
383
+ score: 任务分数(执行时间戳)
384
+ """
385
+ # 使用Redis原生锁避免重复处理
386
+ processing_key = self._get_processing_key(task_id)
387
+ processing_lock = AsyncLock(
388
+ self.redis,
389
+ processing_key,
390
+ timeout=60, # 60秒自动过期
391
+ blocking=False # 非阻塞
392
+ )
393
+
394
+ if not await processing_lock.acquire():
395
+ # 任务正在被其他调度器处理
396
+ return
397
+
398
+ try:
399
+ with LogContext(task_id=task_id, score=score):
400
+ # 加载任务详情
401
+ task = await self.load_task_detail(task_id)
402
+
403
+ if not task:
404
+ logger.warning(f"Task {task_id} not found, removing from schedule")
405
+ await self.redis.zrem(self._get_zset_key(), str(task_id))
406
+ return
407
+
408
+ if not task.enabled:
409
+ logger.info(f"Task {task_id} is disabled, removing from schedule")
410
+ await self.redis.zrem(self._get_zset_key(), str(task_id))
411
+ return
412
+
413
+ # 触发任务
414
+ await self.trigger_task(task)
415
+
416
+ # 更新下次执行时间
417
+ task.update_next_run_time()
418
+
419
+ if task.next_run_time:
420
+ # 更新Redis中的分数
421
+ new_score = task.next_run_time.timestamp()
422
+ await self.redis.zadd(self._get_zset_key(), {str(task_id): new_score})
423
+
424
+ # 更新数据库
425
+ await self.db_manager.update_task_next_run(
426
+ task_id=task.id,
427
+ next_run_time=task.next_run_time,
428
+ last_run_time=task.last_run_time
429
+ )
430
+
431
+ logger.info(f"Rescheduled task {task_id} for {task.next_run_time}")
432
+ else:
433
+ # 一次性任务或已完成,从调度中移除
434
+ await self.redis.zrem(self._get_zset_key(), str(task_id))
435
+
436
+ # 对于一次性任务,禁用以防止被重新加载
437
+ if task.task_type == TaskType.ONCE or (isinstance(task.task_type, str) and task.task_type == 'once'):
438
+ # 只更新必要的字段,不覆盖用户设置的其他字段
439
+ await self.db_manager.disable_once_task(task.id)
440
+ logger.info(f"One-time task {task_id} completed and disabled")
441
+ else:
442
+ logger.info(f"Task {task_id} completed, removed from schedule")
443
+
444
+ except Exception as e:
445
+ logger.error(f"Failed to process task {task_id}: {e}", exc_info=True)
446
+ finally:
447
+ # 释放处理锁
448
+ try:
449
+ await processing_lock.release()
450
+ except Exception:
451
+ pass # 锁可能已经过期
452
+
453
+ async def batch_process_tasks_optimized(self, tasks_with_details: List[tuple]):
454
+ """批量处理任务(优化版,任务详情已包含)"""
455
+ if not tasks_with_details:
456
+ return
457
+
458
+ # 收集需要触发的任务消息
459
+ bulk_tasks = []
460
+ tasks_to_update = []
461
+ tasks_to_remove = []
462
+
463
+ for task_data in tasks_with_details:
464
+ # 解析数据
465
+ task_id_str = task_data[0].decode() if isinstance(task_data[0], bytes) else str(task_data[0])
466
+ task_id = int(task_id_str)
467
+ score = float(task_data[1])
468
+ task_detail_json = task_data[2]
469
+
470
+ # 解析任务详情
471
+ task = None
472
+ if task_detail_json:
473
+ try:
474
+ task = ScheduledTask.from_redis_value(task_detail_json)
475
+ except Exception as e:
476
+ logger.error(f"Failed to parse task {task_id} from Redis: {e}")
477
+
478
+ # 如果Redis中没有详情,从数据库获取
479
+ if not task:
480
+ task = await self.db_manager.get_task(task_id)
481
+ if not task:
482
+ # 任务不存在,从调度中移除
483
+ tasks_to_remove.append(str(task_id))
484
+ continue
485
+
486
+
487
+ # 检查任务状态
488
+ if not task.enabled:
489
+ logger.info(f"Task {task_id} is disabled, skipping")
490
+ tasks_to_remove.append(str(task_id))
491
+ continue
492
+
493
+ # 无需获取task对象,直接准备消息
494
+ bulk_tasks.append(task)
495
+ tasks_to_update.append(task)
496
+
497
+ # 批量发送任务
498
+ if bulk_tasks:
499
+ # 直接创建TaskMessage对象
500
+ from ..core.message import TaskMessage
501
+ task_messages = []
502
+ for task in bulk_tasks:
503
+ # 准备kwargs
504
+ kwargs = task.task_kwargs or {}
505
+ # 添加任务名称和scheduled_task_id用于路由和跟踪
506
+ kwargs['__task_name'] = task.task_name
507
+ kwargs['__scheduled_task_id'] = task.id
508
+
509
+ # 将timeout、max_retries等参数放入kwargs中传递
510
+ if task.timeout:
511
+ kwargs['__timeout'] = task.timeout
512
+ if task.max_retries:
513
+ kwargs['__max_retries'] = task.max_retries
514
+ if task.retry_delay:
515
+ kwargs['__retry_delay'] = task.retry_delay
516
+
517
+ # 创建TaskMessage
518
+ task_msg = TaskMessage(
519
+ queue=task.queue_name,
520
+ kwargs=kwargs,
521
+ priority=task.priority
522
+ )
523
+ task_messages.append(task_msg)
524
+
525
+ # 使用send_tasks方法发送
526
+ event_ids = await self.app.send_tasks(task_messages)
527
+ logger.info(f"Triggered {len(task_messages)} tasks via send_tasks")
528
+
529
+ # 准备批量操作的数据
530
+ tasks_for_reschedule = {} # {task_id: next_timestamp}
531
+ completed_task_ids = []
532
+
533
+ # 处理每个任务
534
+ for task, event_id in zip(tasks_to_update, event_ids):
535
+ # 更新上次运行时间
536
+ task.last_run_time = datetime.now()
537
+
538
+ # 更新任务的下次运行时间
539
+ task.update_next_run_time()
540
+
541
+ # 调试日志
542
+ logger.info(f"Task {task.id} updated: type={task.task_type}, next_run_time={task.next_run_time}")
543
+
544
+ # 注意:不再这里记录执行历史,因为任务已经通过bulk_write发送到队列
545
+ # 任务记录会由消费者创建,并带有scheduled_task_id
546
+
547
+ if task.task_type == TaskType.ONCE or (isinstance(task.task_type, str) and task.task_type == 'once') or not task.next_run_time:
548
+ # 一次性任务或已完成
549
+ completed_task_ids.append(str(task.id))
550
+
551
+ # 对于一次性任务,禁用任务(但不更新整个对象)
552
+ if task.task_type == TaskType.ONCE or (isinstance(task.task_type, str) and task.task_type == 'once'):
553
+ # 稍后单独处理一次性任务的禁用
554
+ logger.info(f"Will disable one-time task {task.id} after execution")
555
+ else:
556
+ # 重复任务,准备重新调度(只更新时间字段)
557
+ next_timestamp = task.next_run_time.timestamp()
558
+ tasks_for_reschedule[str(task.id)] = next_timestamp
559
+
560
+ # 注释掉批量记录执行历史,避免ID冲突
561
+ # 执行历史将由消费者在处理任务时记录
562
+ # if execution_histories:
563
+ # await self.db_manager.batch_record_executions(execution_histories)
564
+
565
+ # 批量更新任务的下次执行时间(只更新时间字段)
566
+ update_time_tasks = []
567
+ for task in tasks_to_update:
568
+ if task.next_run_time and str(task.id) not in completed_task_ids:
569
+ update_time_tasks.append((task.id, task.next_run_time, task.last_run_time))
570
+
571
+ if update_time_tasks:
572
+ await self.db_manager.batch_update_next_run_times(update_time_tasks)
573
+
574
+ # 处理需要禁用的一次性任务
575
+ once_task_ids = []
576
+ for task in tasks_to_update:
577
+ if (task.task_type == TaskType.ONCE or (isinstance(task.task_type, str) and task.task_type == 'once')) and str(task.id) in completed_task_ids:
578
+ once_task_ids.append(task.id)
579
+
580
+ if once_task_ids:
581
+ await self.db_manager.batch_disable_once_tasks(once_task_ids)
582
+
583
+ # 使用管道批量执行所有Redis操作
584
+ if tasks_for_reschedule or completed_task_ids:
585
+ pipeline = self.redis.pipeline()
586
+
587
+ # 批量更新Redis调度
588
+ if tasks_for_reschedule:
589
+ pipeline.zadd(self._get_zset_key(), tasks_for_reschedule)
590
+
591
+ # 批量移除完成的任务
592
+ if completed_task_ids:
593
+ pipeline.zrem(self._get_zset_key(), *completed_task_ids)
594
+
595
+ # 一次性执行所有操作
596
+ await pipeline.execute()
597
+
598
+ if completed_task_ids:
599
+ logger.info(f"Removed {len(completed_task_ids)} completed tasks from schedule")
600
+
601
+
602
+ async def scan_and_trigger(self):
603
+ """扫描并触发到期任务"""
604
+ if not self.is_leader:
605
+ # 只有Leader才能触发任务
606
+ return
607
+
608
+ with LogContext(operation="scan_tasks"):
609
+ # 使用优化的方法获取到期任务及详情
610
+ tasks_with_details = await self.get_due_tasks_with_details()
611
+
612
+ if not tasks_with_details:
613
+ return
614
+
615
+ logger.info(f"Found {len(tasks_with_details)} due tasks")
616
+
617
+ # 使用优化的批量处理方法
618
+ await self.batch_process_tasks_optimized(tasks_with_details)
619
+
620
+ async def run(self):
621
+ """运行调度器主循环"""
622
+ # 建立Redis连接
623
+ if not self.redis:
624
+ self.redis = await aioredis.from_url(
625
+ self.redis_url,
626
+ encoding="utf-8",
627
+ decode_responses=False
628
+ )
629
+
630
+ # 连接加载器和数据库管理器
631
+ await self.loader.connect()
632
+ await self.db_manager.connect()
633
+
634
+ self.running = True
635
+ logger.info(f"Scheduler {self.scheduler_id} started")
636
+
637
+ # 启动任务加载器
638
+ loader_task = asyncio.create_task(self.loader.run())
639
+
640
+ # Leader续期任务
641
+ async def renew_leader_loop():
642
+ while self.running:
643
+ if self.is_leader:
644
+ if not await self.renew_leader():
645
+ logger.warning("Failed to renew leader, will retry")
646
+ await asyncio.sleep(self.leader_ttl // 2)
647
+
648
+ renew_task = asyncio.create_task(renew_leader_loop())
649
+
650
+ # 记录上次尝试获取Leader的时间
651
+ last_leader_attempt = 0
652
+ leader_retry_interval = 5 # 5秒重试一次获取Leader
653
+
654
+ try:
655
+ while self.running:
656
+ try:
657
+ # 尝试获取Leader(但不要太频繁)
658
+ if not self.is_leader:
659
+ current_time = asyncio.get_event_loop().time()
660
+ if current_time - last_leader_attempt >= leader_retry_interval:
661
+ logger.debug(f"Scheduler {self.scheduler_id} attempting to acquire leader...")
662
+ acquired = await self.acquire_leader()
663
+ last_leader_attempt = current_time
664
+
665
+ if acquired:
666
+ logger.info(f"Scheduler {self.scheduler_id} became leader")
667
+ else:
668
+ logger.debug(f"Scheduler {self.scheduler_id} will retry acquiring leader in {leader_retry_interval}s")
669
+
670
+ # 扫描并触发任务(只有Leader执行)
671
+ if self.is_leader:
672
+ await self.scan_and_trigger()
673
+
674
+ except Exception as e:
675
+ logger.error(f"Scheduler cycle error: {e}", exc_info=True)
676
+
677
+ # 根据是否是Leader使用不同的睡眠时间
678
+ if self.is_leader:
679
+ await asyncio.sleep(self.scan_interval) # Leader正常扫描间隔
680
+ else:
681
+ await asyncio.sleep(1) # 非Leader短暂睡眠,但不会频繁尝试获取锁
682
+
683
+ finally:
684
+ # 停止子任务
685
+ self.loader.stop()
686
+ renew_task.cancel()
687
+
688
+ # 等待子任务结束,但设置超时避免卡住
689
+ try:
690
+ await asyncio.wait_for(loader_task, timeout=2.0)
691
+ except (asyncio.TimeoutError, asyncio.CancelledError):
692
+ pass
693
+
694
+ try:
695
+ await asyncio.wait_for(renew_task, timeout=0.5)
696
+ except (asyncio.TimeoutError, asyncio.CancelledError):
697
+ pass
698
+
699
+ # 强制释放Leader锁(确保CTRL+C时清理)
700
+ await self.release_leader(force=True)
701
+
702
+ # 关闭连接
703
+ await self.loader.disconnect()
704
+ await self.db_manager.disconnect()
705
+
706
+ if self.redis:
707
+ await self.redis.close()
708
+ self.redis = None
709
+
710
+ logger.info(f"Scheduler {self.scheduler_id} stopped")
711
+
712
+ def stop(self):
713
+ """停止调度器"""
714
+ self.running = False
715
+ logger.info(f"Scheduler {self.scheduler_id} stop() called, setting running=False")