jettask 0.2.1__py3-none-any.whl → 0.2.4__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 (89) hide show
  1. jettask/constants.py +213 -0
  2. jettask/core/app.py +525 -205
  3. jettask/core/cli.py +193 -185
  4. jettask/core/consumer_manager.py +126 -34
  5. jettask/core/context.py +3 -0
  6. jettask/core/enums.py +137 -0
  7. jettask/core/event_pool.py +501 -168
  8. jettask/core/message.py +147 -0
  9. jettask/core/offline_worker_recovery.py +181 -114
  10. jettask/core/task.py +10 -174
  11. jettask/core/task_batch.py +153 -0
  12. jettask/core/unified_manager_base.py +243 -0
  13. jettask/core/worker_scanner.py +54 -54
  14. jettask/executors/asyncio.py +184 -64
  15. jettask/webui/backend/config.py +51 -0
  16. jettask/webui/backend/data_access.py +2083 -92
  17. jettask/webui/backend/data_api.py +3294 -0
  18. jettask/webui/backend/dependencies.py +261 -0
  19. jettask/webui/backend/init_meta_db.py +158 -0
  20. jettask/webui/backend/main.py +1358 -69
  21. jettask/webui/backend/main_unified.py +78 -0
  22. jettask/webui/backend/main_v2.py +394 -0
  23. jettask/webui/backend/namespace_api.py +295 -0
  24. jettask/webui/backend/namespace_api_old.py +294 -0
  25. jettask/webui/backend/namespace_data_access.py +611 -0
  26. jettask/webui/backend/queue_backlog_api.py +727 -0
  27. jettask/webui/backend/queue_stats_v2.py +521 -0
  28. jettask/webui/backend/redis_monitor_api.py +476 -0
  29. jettask/webui/backend/unified_api_router.py +1601 -0
  30. jettask/webui/db_init.py +204 -32
  31. jettask/webui/frontend/package-lock.json +492 -1
  32. jettask/webui/frontend/package.json +4 -1
  33. jettask/webui/frontend/src/App.css +105 -7
  34. jettask/webui/frontend/src/App.jsx +49 -20
  35. jettask/webui/frontend/src/components/NamespaceSelector.jsx +166 -0
  36. jettask/webui/frontend/src/components/QueueBacklogChart.jsx +298 -0
  37. jettask/webui/frontend/src/components/QueueBacklogTrend.jsx +638 -0
  38. jettask/webui/frontend/src/components/QueueDetailsTable.css +65 -0
  39. jettask/webui/frontend/src/components/QueueDetailsTable.jsx +487 -0
  40. jettask/webui/frontend/src/components/QueueDetailsTableV2.jsx +465 -0
  41. jettask/webui/frontend/src/components/ScheduledTaskFilter.jsx +423 -0
  42. jettask/webui/frontend/src/components/TaskFilter.jsx +425 -0
  43. jettask/webui/frontend/src/components/TimeRangeSelector.css +21 -0
  44. jettask/webui/frontend/src/components/TimeRangeSelector.jsx +160 -0
  45. jettask/webui/frontend/src/components/layout/AppLayout.css +95 -0
  46. jettask/webui/frontend/src/components/layout/AppLayout.jsx +49 -0
  47. jettask/webui/frontend/src/components/layout/Header.css +34 -10
  48. jettask/webui/frontend/src/components/layout/Header.jsx +31 -23
  49. jettask/webui/frontend/src/components/layout/SideMenu.css +137 -0
  50. jettask/webui/frontend/src/components/layout/SideMenu.jsx +209 -0
  51. jettask/webui/frontend/src/components/layout/TabsNav.css +244 -0
  52. jettask/webui/frontend/src/components/layout/TabsNav.jsx +206 -0
  53. jettask/webui/frontend/src/components/layout/UserInfo.css +197 -0
  54. jettask/webui/frontend/src/components/layout/UserInfo.jsx +197 -0
  55. jettask/webui/frontend/src/contexts/NamespaceContext.jsx +72 -0
  56. jettask/webui/frontend/src/contexts/TabsContext.backup.jsx +245 -0
  57. jettask/webui/frontend/src/main.jsx +1 -0
  58. jettask/webui/frontend/src/pages/Alerts.jsx +684 -0
  59. jettask/webui/frontend/src/pages/Dashboard.jsx +1330 -0
  60. jettask/webui/frontend/src/pages/QueueDetail.jsx +1109 -10
  61. jettask/webui/frontend/src/pages/QueueMonitor.jsx +236 -115
  62. jettask/webui/frontend/src/pages/Queues.jsx +5 -1
  63. jettask/webui/frontend/src/pages/ScheduledTasks.jsx +809 -0
  64. jettask/webui/frontend/src/pages/Settings.jsx +800 -0
  65. jettask/webui/frontend/src/services/api.js +7 -5
  66. jettask/webui/frontend/src/utils/suppressWarnings.js +22 -0
  67. jettask/webui/frontend/src/utils/userPreferences.js +154 -0
  68. jettask/webui/multi_namespace_consumer.py +543 -0
  69. jettask/webui/pg_consumer.py +983 -246
  70. jettask/webui/static/dist/assets/index-7129cfe1.css +1 -0
  71. jettask/webui/static/dist/assets/index-8d1935cc.js +774 -0
  72. jettask/webui/static/dist/index.html +2 -2
  73. jettask/webui/task_center.py +216 -0
  74. jettask/webui/task_center_client.py +150 -0
  75. jettask/webui/unified_consumer_manager.py +193 -0
  76. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/METADATA +1 -1
  77. jettask-0.2.4.dist-info/RECORD +134 -0
  78. jettask/webui/pg_consumer_slow.py +0 -1099
  79. jettask/webui/pg_consumer_test.py +0 -678
  80. jettask/webui/static/dist/assets/index-823408e8.css +0 -1
  81. jettask/webui/static/dist/assets/index-9968b0b8.js +0 -543
  82. jettask/webui/test_pg_consumer_recovery.py +0 -547
  83. jettask/webui/test_recovery_simple.py +0 -492
  84. jettask/webui/test_self_recovery.py +0 -467
  85. jettask-0.2.1.dist-info/RECORD +0 -91
  86. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/WHEEL +0 -0
  87. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/entry_points.txt +0 -0
  88. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/licenses/LICENSE +0 -0
  89. {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/top_level.txt +0 -0
jettask/core/app.py CHANGED
@@ -107,7 +107,7 @@ def get_async_redis_pool(redis_url: str, max_connections: int = 200):
107
107
  return _async_redis_pools[redis_url]
108
108
 
109
109
  def get_binary_redis_pool(redis_url: str, max_connections: int = 200):
110
- """获取或创建用于二进制数据的Redis连接池(不解码响应)"""
110
+ """获取或创建用于二进制数据的Redis连接池(Stream操作需要)"""
111
111
  if redis_url not in _binary_redis_pools:
112
112
  # 构建socket keepalive选项,仅在Linux上使用
113
113
  socket_keepalive_options = {}
@@ -120,7 +120,7 @@ def get_binary_redis_pool(redis_url: str, max_connections: int = 200):
120
120
 
121
121
  _binary_redis_pools[redis_url] = redis.ConnectionPool.from_url(
122
122
  redis_url,
123
- decode_responses=False, # 不解码,保持二进制
123
+ decode_responses=False, # 不解码,因为Stream数据是msgpack二进制
124
124
  max_connections=max_connections,
125
125
  retry_on_timeout=True,
126
126
  retry_on_error=[ConnectionError, TimeoutError],
@@ -134,7 +134,7 @@ def get_binary_redis_pool(redis_url: str, max_connections: int = 200):
134
134
  return _binary_redis_pools[redis_url]
135
135
 
136
136
  def get_async_binary_redis_pool(redis_url: str, max_connections: int = 200):
137
- """获取或创建用于二进制数据的异步Redis连接池(不解码响应)"""
137
+ """获取或创建用于二进制数据的异步Redis连接池(Stream操作需要)"""
138
138
  if redis_url not in _async_binary_redis_pools:
139
139
  # 构建socket keepalive选项,仅在Linux上使用
140
140
  socket_keepalive_options = {}
@@ -147,7 +147,7 @@ def get_async_binary_redis_pool(redis_url: str, max_connections: int = 200):
147
147
 
148
148
  _async_binary_redis_pools[redis_url] = aioredis.ConnectionPool.from_url(
149
149
  redis_url,
150
- decode_responses=False, # 不解码,保持二进制
150
+ decode_responses=False, # 不解码,因为Stream数据是msgpack二进制
151
151
  max_connections=max_connections,
152
152
  retry_on_timeout=True,
153
153
  retry_on_error=[ConnectionError, TimeoutError],
@@ -183,8 +183,15 @@ class Jettask(object):
183
183
  local delay_seconds = tonumber(ARGV[i+3])
184
184
  local queue = ARGV[i+4]
185
185
 
186
- -- 1. 添加消息到Stream
187
- local stream_id = redis.call('XADD', stream_key, '*', 'data', stream_data)
186
+ -- 使用Hash存储所有队列的offset
187
+ local offsets_hash = prefix .. ':QUEUE_OFFSETS'
188
+ -- 使用HINCRBY原子递增offset
189
+ local offset = redis.call('HINCRBY', offsets_hash, queue, 1)
190
+
191
+ -- 1. 添加消息到Stream(包含offset字段)
192
+ local stream_id = redis.call('XADD', stream_key, '*',
193
+ 'data', stream_data,
194
+ 'offset', offset)
188
195
 
189
196
  -- 2. 添加到延迟队列ZSET
190
197
  local delayed_queue_key = prefix .. ':DELAYED_QUEUE:' .. queue
@@ -216,8 +223,17 @@ class Jettask(object):
216
223
  local stream_key = ARGV[i]
217
224
  local stream_data = ARGV[i+1]
218
225
 
219
- -- 1. 添加消息到Stream
220
- local stream_id = redis.call('XADD', stream_key, '*', 'data', stream_data)
226
+ -- 从stream_key中提取队列名(格式: prefix:STREAM:queue_name)
227
+ local queue_name = string.match(stream_key, prefix .. ':STREAM:(.*)')
228
+
229
+ -- 获取并递增offset
230
+ local offset_key = prefix .. ':STREAM:' .. queue_name .. ':next_offset'
231
+ local offset = redis.call('INCR', offset_key)
232
+
233
+ -- 1. 添加消息到Stream(包含offset字段)
234
+ local stream_id = redis.call('XADD', stream_key, '*',
235
+ 'data', stream_data,
236
+ 'offset', offset)
221
237
 
222
238
  -- 2. 设置任务状态Hash(只存储status)
223
239
  local task_key = prefix .. ':TASK:' .. stream_id
@@ -235,10 +251,19 @@ class Jettask(object):
235
251
 
236
252
  def __init__(self, redis_url: str = None, include: list = None, max_connections: int = 200,
237
253
  consumer_strategy: str = None, consumer_config: dict = None, tasks=None,
238
- redis_prefix: str = None, scheduler_config: dict = None, pg_url: str = None) -> None:
254
+ redis_prefix: str = None, scheduler_config: dict = None, pg_url: str = None,
255
+ task_center=None) -> None:
239
256
  self._tasks = tasks or {}
257
+ self._queue_tasks = {} # 记录每个队列对应的任务列表
240
258
  self.asyncio = False
241
259
  self.include = include or []
260
+
261
+ # 任务中心相关属性
262
+ self.task_center = None # 将通过mount_task_center方法挂载或初始化时指定
263
+ self._task_center_config = None
264
+ self._original_redis_url = redis_url
265
+ self._original_pg_url = pg_url
266
+
242
267
  self.redis_url = redis_url
243
268
  self.pg_url = pg_url # 存储PostgreSQL URL
244
269
  self.max_connections = max_connections
@@ -249,6 +274,10 @@ class Jettask(object):
249
274
  # Redis prefix configuration
250
275
  self.redis_prefix = redis_prefix or "jettask"
251
276
 
277
+ # 如果初始化时提供了task_center,直接挂载
278
+ if task_center:
279
+ self.mount_task_center(task_center)
280
+
252
281
  # Update prefixes with the configured prefix using colon namespace
253
282
  self.STATUS_PREFIX = f"{self.redis_prefix}:STATUS:"
254
283
  self.RESULT_PREFIX = f"{self.redis_prefix}:RESULT:"
@@ -270,6 +299,236 @@ class Jettask(object):
270
299
  self._worker_started = False
271
300
  self._handlers_registered = False
272
301
 
302
+ async def _load_config_from_task_center_async(self):
303
+ """异步加载任务中心配置"""
304
+ try:
305
+ if not self.task_center:
306
+ return False
307
+
308
+ # 连接并获取配置
309
+ connected = await self.task_center.connect()
310
+ if not connected:
311
+ return False
312
+
313
+ config = {
314
+ 'redis_config': self.task_center.redis_config,
315
+ 'pg_config': self.task_center.pg_config,
316
+ 'namespace_name': self.task_center.namespace_name,
317
+ 'version': self.task_center.version
318
+ }
319
+
320
+ if config['redis_config']:
321
+ # 任务中心配置优先级高于手动配置
322
+ redis_config = config.get('redis_config', {})
323
+ pg_config = config.get('pg_config', {})
324
+ # 构建Redis URL
325
+ if redis_config:
326
+ redis_host = redis_config.get('host', 'localhost')
327
+ redis_port = redis_config.get('port', 6379)
328
+ redis_password = redis_config.get('password')
329
+ redis_db = redis_config.get('db', 0)
330
+
331
+ if redis_password:
332
+ self.redis_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}"
333
+ else:
334
+ self.redis_url = f"redis://{redis_host}:{redis_port}/{redis_db}"
335
+
336
+ logger.info(f"从任务中心加载Redis配置: {redis_host}:{redis_port}/{redis_db}")
337
+
338
+ # 构建PostgreSQL URL
339
+ if pg_config:
340
+ pg_host = pg_config.get('host', 'localhost')
341
+ pg_port = pg_config.get('port', 5432)
342
+ pg_user = pg_config.get('user', 'postgres')
343
+ pg_password = pg_config.get('password', '')
344
+ pg_database = pg_config.get('database', 'jettask')
345
+
346
+ self.pg_url = f"postgresql://{pg_user}:{pg_password}@{pg_host}:{pg_port}/{pg_database}"
347
+ logger.info(f"从任务中心加载PostgreSQL配置: {pg_host}:{pg_port}/{pg_database}")
348
+
349
+ # 保存配置供后续使用
350
+ self._task_center_config = config
351
+
352
+ # 更新Redis前缀为命名空间名称
353
+ if self.task_center and self.task_center.redis_prefix != "jettask":
354
+ self.redis_prefix = self.task_center.redis_prefix
355
+ # 更新相关前缀
356
+ self.STATUS_PREFIX = f"{self.redis_prefix}:STATUS:"
357
+ self.RESULT_PREFIX = f"{self.redis_prefix}:RESULT:"
358
+
359
+ # 清理已缓存的Redis连接,强制重新创建
360
+ if hasattr(self, '_redis'):
361
+ delattr(self, '_redis')
362
+ if hasattr(self, '_async_redis'):
363
+ delattr(self, '_async_redis')
364
+ if hasattr(self, '_ep'):
365
+ delattr(self, '_ep')
366
+
367
+ return True
368
+ except Exception as e:
369
+ logger.warning(f"从任务中心加载配置失败: {e}")
370
+ return False
371
+
372
+ def _load_config_from_task_center(self):
373
+ """从任务中心加载配置"""
374
+ try:
375
+ import asyncio
376
+ # 检查是否已经在事件循环中
377
+ try:
378
+ loop = asyncio.get_running_loop()
379
+ # 已在事件循环中,无法同步加载
380
+ return False
381
+ except RuntimeError:
382
+ # 不在事件循环中,可以创建新的
383
+ loop = asyncio.new_event_loop()
384
+ if self.task_center:
385
+ # 如果已经初始化,直接获取配置
386
+ if self.task_center._initialized:
387
+ config = self.task_center._config
388
+ else:
389
+ # 使用异步模式连接
390
+ success = loop.run_until_complete(self.task_center.connect(asyncio=True))
391
+ if success:
392
+ config = self.task_center._config
393
+ else:
394
+ config = None
395
+ else:
396
+ config = None
397
+ loop.close()
398
+
399
+ if config:
400
+ # 任务中心配置优先级高于手动配置
401
+ redis_config = config.get('redis_config', {})
402
+ pg_config = config.get('pg_config', {})
403
+ # 构建Redis URL
404
+ if redis_config:
405
+ redis_host = redis_config.get('host', 'localhost')
406
+ redis_port = redis_config.get('port', 6379)
407
+ redis_password = redis_config.get('password')
408
+ redis_db = redis_config.get('db', 0)
409
+
410
+ if redis_password:
411
+ self.redis_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}"
412
+ else:
413
+ self.redis_url = f"redis://{redis_host}:{redis_port}/{redis_db}"
414
+
415
+ logger.info(f"从任务中心加载Redis配置: {redis_host}:{redis_port}/{redis_db}")
416
+
417
+ # 构建PostgreSQL URL
418
+ if pg_config:
419
+ pg_host = pg_config.get('host', 'localhost')
420
+ pg_port = pg_config.get('port', 5432)
421
+ pg_user = pg_config.get('user', 'postgres')
422
+ pg_password = pg_config.get('password', '')
423
+ pg_database = pg_config.get('database', 'jettask')
424
+
425
+ self.pg_url = f"postgresql://{pg_user}:{pg_password}@{pg_host}:{pg_port}/{pg_database}"
426
+ logger.info(f"从任务中心加载PostgreSQL配置: {pg_host}:{pg_port}/{pg_database}")
427
+
428
+ # 保存配置供后续使用
429
+ self._task_center_config = config
430
+
431
+ # 更新Redis前缀为命名空间名称
432
+ if self.task_center and self.task_center.redis_prefix != "jettask":
433
+ self.redis_prefix = self.task_center.redis_prefix
434
+ # 更新相关前缀
435
+ self.STATUS_PREFIX = f"{self.redis_prefix}:STATUS:"
436
+ self.RESULT_PREFIX = f"{self.redis_prefix}:RESULT:"
437
+
438
+ # 清理已缓存的Redis连接,强制重新创建
439
+ if hasattr(self, '_redis'):
440
+ delattr(self, '_redis')
441
+ if hasattr(self, '_async_redis'):
442
+ delattr(self, '_async_redis')
443
+ if hasattr(self, '_ep'):
444
+ delattr(self, '_ep')
445
+
446
+ except Exception as e:
447
+ import traceback
448
+ traceback.print_exc()
449
+ logger.warning(f"从任务中心加载配置失败,使用手动配置: {e}")
450
+ # 恢复原始配置
451
+ self.redis_url = self._original_redis_url
452
+ self.pg_url = self._original_pg_url
453
+
454
+ def mount_task_center(self, task_center):
455
+ """
456
+ 挂载任务中心到Jettask应用
457
+
458
+ 如果task_center已经连接,会自动应用配置到当前app。
459
+
460
+ Args:
461
+ task_center: TaskCenter实例
462
+
463
+ 使用示例:
464
+ from jettask.webui.task_center import TaskCenter
465
+
466
+ # 创建任务中心客户端(可复用)
467
+ task_center = TaskCenter("http://localhost:8001/api/namespaces/demo")
468
+ await task_center.connect() # 只需连接一次
469
+
470
+ # 创建多个app实例,共享同一个task_center
471
+ app1 = Jettask()
472
+ app1.mount_task_center(task_center) # 自动应用配置
473
+
474
+ app2 = Jettask()
475
+ app2.mount_task_center(task_center) # 复用配置
476
+ """
477
+ self.task_center = task_center
478
+
479
+ # 如果任务中心已连接,立即应用所有配置
480
+ if task_center and task_center._initialized:
481
+ # 应用Redis配置
482
+ if task_center.redis_config:
483
+ redis_url = task_center.get_redis_url()
484
+ if redis_url:
485
+ self.redis_url = redis_url
486
+
487
+ # 应用PostgreSQL配置
488
+ if task_center.pg_config:
489
+ pg_url = task_center.get_pg_url()
490
+ if pg_url:
491
+ self.pg_url = pg_url
492
+
493
+ # 更新Redis前缀
494
+ self.redis_prefix = task_center.redis_prefix
495
+ # 更新相关前缀
496
+ self.STATUS_PREFIX = f"{self.redis_prefix}:STATUS:"
497
+ self.RESULT_PREFIX = f"{self.redis_prefix}:RESULT:"
498
+ self.QUEUE_PREFIX = f"{self.redis_prefix}:QUEUE:"
499
+ self.DELAYED_QUEUE_PREFIX = f"{self.redis_prefix}:DELAYED_QUEUE:"
500
+ self.STREAM_PREFIX = f"{self.redis_prefix}:STREAM:"
501
+ self.TASK_PREFIX = f"{self.redis_prefix}:TASK:"
502
+ self.SCHEDULER_PREFIX = f"{self.redis_prefix}:SCHEDULED:"
503
+ self.LOCK_PREFIX = f"{self.redis_prefix}:LOCK:"
504
+
505
+ # 标记配置已加载
506
+ self._task_center_config = {
507
+ 'redis_config': task_center.redis_config,
508
+ 'pg_config': task_center.pg_config,
509
+ 'namespace_name': task_center.namespace_name,
510
+ 'version': task_center.version
511
+ }
512
+
513
+ async def init_from_task_center(self):
514
+ """
515
+ 从任务中心初始化配置(异步方法)
516
+
517
+ 在异步环境中发送任务前调用此方法,确保配置已加载
518
+
519
+ 使用示例:
520
+ from jettask.webui.task_center import TaskCenter
521
+
522
+ task_center = TaskCenter("http://localhost:8001/api/namespaces/demo")
523
+ app = Jettask()
524
+ app.mount_task_center(task_center)
525
+ await app.init_from_task_center() # 会自动连接task_center
526
+ await task.apply_async(...)
527
+ """
528
+ if self.task_center and self.task_center.is_enabled and not self._task_center_config:
529
+ return await self._load_config_from_task_center_async()
530
+ return True
531
+
273
532
  def _setup_cleanup_handlers(self):
274
533
  """设置清理处理器"""
275
534
  # 避免重复注册
@@ -343,6 +602,10 @@ class Jettask(object):
343
602
  if hasattr(self, name):
344
603
  return getattr(self, name)
345
604
 
605
+ # 如果配置了任务中心且还未加载配置,先加载配置
606
+ if self.task_center and self.task_center.is_enabled and not self._task_center_config:
607
+ self._load_config_from_task_center()
608
+
346
609
  pool = get_async_redis_pool(self.redis_url, self.max_connections)
347
610
  async_redis = aioredis.StrictRedis(connection_pool=pool)
348
611
  setattr(self, name, async_redis)
@@ -354,7 +617,11 @@ class Jettask(object):
354
617
  name = "_redis"
355
618
  if hasattr(self, name):
356
619
  return getattr(self, name)
357
-
620
+
621
+ # 如果配置了任务中心且还未加载配置,先加载配置
622
+ if self.task_center and self.task_center.is_enabled and not self._task_center_config:
623
+ self._load_config_from_task_center()
624
+
358
625
  pool = get_redis_pool(self.redis_url, self.max_connections)
359
626
  redis_cli = redis.StrictRedis(connection_pool=pool)
360
627
  setattr(self, name, redis_cli)
@@ -416,6 +683,9 @@ class Jettask(object):
416
683
  ) -> Task:
417
684
  name = name or gen_task_name(fun.__name__, fun.__module__)
418
685
  base = base or Task
686
+
687
+ # 不再限制队列模式,因为每个task都有独立的consumer group
688
+
419
689
  if name not in self._tasks:
420
690
  run = staticmethod(fun)
421
691
  task: Task = type(
@@ -441,6 +711,12 @@ class Jettask(object):
441
711
  with contextlib.suppress(AttributeError):
442
712
  task.__qualname__ = fun.__qualname__
443
713
  self._tasks[task.name] = task
714
+
715
+ # 记录队列和任务的映射(用于查找)
716
+ if queue:
717
+ if queue not in self._queue_tasks:
718
+ self._queue_tasks[queue] = []
719
+ self._queue_tasks[queue].append(name)
444
720
  else:
445
721
  task = self._tasks[name]
446
722
  return task
@@ -477,56 +753,196 @@ class Jettask(object):
477
753
 
478
754
  return _create_task_cls
479
755
 
480
- def publish_broadcast(
481
- self,
482
- queue: str,
483
- message: dict,
484
- target_tasks: list = None,
485
- asyncio_mode: bool = False
486
- ):
756
+ async def send_tasks(self, messages: list):
487
757
  """
488
- 发布广播消息到队列
758
+ 统一的任务发送接口 - 只有这一个发送方法
489
759
 
490
760
  Args:
491
- queue: 目标队列名
492
- message: 消息内容
493
- target_tasks: 目标任务列表(None表示所有监听该队列的任务)
494
- asyncio_mode: 是否使用异步模式
495
-
761
+ messages: TaskMessage对象列表(或字典列表)
762
+
496
763
  Returns:
497
- 消息ID或协程对象
498
-
499
- 使用示例:
500
- # 同步模式
501
- app.publish_broadcast(
502
- queue="events",
503
- message={"type": "customer_registered", "data": {...}}
504
- )
764
+ List[str]: 任务ID列表
505
765
 
506
- # 异步模式
507
- await app.publish_broadcast(
508
- queue="events",
509
- message={"type": "order_created", "data": {...}},
510
- target_tasks=["send_email", "update_inventory"],
511
- asyncio_mode=True
766
+ 使用示例:
767
+ from jettask.core.message import TaskMessage
768
+
769
+ # 发送单个任务(也是用列表)
770
+ msg = TaskMessage(
771
+ queue="order_processing",
772
+ args=(12345,),
773
+ kwargs={"customer_id": "C001", "amount": 99.99}
512
774
  )
775
+ task_ids = await app.send_tasks([msg])
776
+
777
+ # 批量发送
778
+ messages = [
779
+ TaskMessage(queue="email", kwargs={"to": "user1@example.com"}),
780
+ TaskMessage(queue="email", kwargs={"to": "user2@example.com"}),
781
+ TaskMessage(queue="sms", kwargs={"phone": "123456789"}),
782
+ ]
783
+ task_ids = await app.send_tasks(messages)
784
+
785
+ # 跨项目发送(不需要task定义)
786
+ messages = [
787
+ TaskMessage(queue="remote_queue", kwargs={"data": "value"})
788
+ ]
789
+ task_ids = await app.send_tasks(messages)
513
790
  """
514
- # 构建广播消息,直接序列化整个消息
791
+ if not messages:
792
+ return []
793
+
794
+ # 导入TaskMessage
795
+ from .message import TaskMessage
796
+
797
+ results = []
798
+
799
+ # 按队列分组消息,以便批量处理
800
+ queue_messages = {}
801
+ for msg in messages:
802
+ # 支持TaskMessage对象或字典
803
+ if isinstance(msg, dict):
804
+ msg = TaskMessage.from_dict(msg)
805
+ elif not isinstance(msg, TaskMessage):
806
+ raise ValueError(f"Invalid message type: {type(msg)}. Expected TaskMessage or dict")
807
+
808
+ # 验证消息
809
+ msg.validate()
810
+
811
+ # 确定实际的队列名(考虑优先级)
812
+ actual_queue = msg.queue
813
+ if msg.priority is not None:
814
+ # 将优先级拼接到队列名后面
815
+ actual_queue = f"{msg.queue}:{msg.priority}"
816
+ # 更新消息体中的queue字段,确保与实际发送的stream key一致
817
+ msg.queue = actual_queue
818
+
819
+ # 按队列分组
820
+ if actual_queue not in queue_messages:
821
+ queue_messages[actual_queue] = []
822
+ queue_messages[actual_queue].append(msg)
823
+
824
+ # 处理每个队列的消息
825
+ for queue, queue_msgs in queue_messages.items():
826
+ # 统一使用批量发送,无论是否广播模式
827
+ # 广播/单播由消费端的consumer group name决定
828
+ batch_results = await self._send_batch_messages(queue, queue_msgs)
829
+ results.extend(batch_results)
830
+
831
+ return results
832
+
833
+ async def _send_batch_messages(self, queue: str, messages: list) -> list:
834
+ """批量发送single模式消息(内部方法)"""
515
835
  from ..utils.serializer import dumps_str
516
836
 
517
- # 创建完整的广播消息
518
- broadcast_message = {
519
- **message, # 用户的原始消息
520
- "_broadcast": True,
521
- "_target_tasks": target_tasks,
522
- "_timestamp": time.time(),
523
- "trigger_time": time.time()
524
- }
525
- # 使用EventPool的send_event方法,保持和apply_async一致的格式
526
- if asyncio_mode:
527
- return self.ep.send_event(queue, broadcast_message, asyncio=True)
528
- else:
529
- return self.ep.send_event(queue, broadcast_message, asyncio=False)
837
+ # 分离普通任务和延迟任务
838
+ normal_messages = []
839
+ delayed_messages = []
840
+
841
+ for msg in messages:
842
+ msg_dict = msg.to_dict()
843
+
844
+ # 处理延迟任务
845
+ if msg.delay and msg.delay > 0:
846
+ # 添加延迟执行标记
847
+ current_time = time.time()
848
+ msg_dict['execute_at'] = current_time + msg.delay
849
+ msg_dict['is_delayed'] = 1
850
+ delayed_messages.append((msg_dict, msg.delay))
851
+ else:
852
+ normal_messages.append(msg_dict)
853
+
854
+ results = []
855
+
856
+ # 发送普通任务(统一使用批量发送)
857
+ if normal_messages:
858
+ batch_results = await self.ep._batch_send_event(
859
+ self.ep.get_prefixed_queue_name(queue),
860
+ [{'data': dumps_str(msg)} for msg in normal_messages],
861
+ self.ep.get_redis_client(asyncio=True, binary=True).pipeline()
862
+ )
863
+ results.extend(batch_results)
864
+
865
+ # 发送延迟任务(需要同时添加到DELAYED_QUEUE)
866
+ if delayed_messages:
867
+ delayed_results = await self._send_delayed_tasks(queue, delayed_messages)
868
+ results.extend(delayed_results)
869
+
870
+ return results
871
+
872
+ async def _send_delayed_tasks(self, queue: str, delayed_messages: list) -> list:
873
+ """发送延迟任务到Stream并添加到延迟队列"""
874
+ from ..utils.serializer import dumps_str
875
+
876
+ # 使用Lua脚本原子性地处理延迟任务
877
+ lua_script = """
878
+ local prefix = ARGV[1]
879
+ local results = {}
880
+
881
+ -- 从ARGV[2]开始,每4个参数为一组任务信息
882
+ -- [stream_key, stream_data, execute_at, queue]
883
+ for i = 2, #ARGV, 4 do
884
+ local stream_key = ARGV[i]
885
+ local stream_data = ARGV[i+1]
886
+ local execute_at = tonumber(ARGV[i+2])
887
+ local queue_name = ARGV[i+3]
888
+
889
+ -- 使用Hash存储所有队列的offset
890
+ local offsets_hash = prefix .. ':QUEUE_OFFSETS'
891
+
892
+ -- 从stream_key中提取队列名
893
+ local queue_name = string.gsub(stream_key, '^' .. prefix .. ':QUEUE:', '')
894
+
895
+ -- 使用HINCRBY原子递增offset
896
+ local current_offset = redis.call('HINCRBY', offsets_hash, queue_name, 1)
897
+
898
+ -- 1. 添加消息到Stream(包含offset字段)
899
+ local stream_id = redis.call('XADD', stream_key, '*',
900
+ 'data', stream_data,
901
+ 'offset', current_offset)
902
+
903
+ -- 2. 添加到延迟队列ZSET
904
+ local delayed_queue_key = prefix .. ':DELAYED_QUEUE:' .. queue_name
905
+ redis.call('ZADD', delayed_queue_key, execute_at, stream_id)
906
+
907
+ -- 3. 设置任务状态Hash
908
+ local task_key = prefix .. ':TASK:' .. stream_id
909
+ redis.call('HSET', task_key, 'status', 'delayed')
910
+ redis.call('EXPIRE', task_key, 3600)
911
+
912
+ -- 保存stream_id到结果
913
+ table.insert(results, stream_id)
914
+ end
915
+
916
+ return results
917
+ """
918
+
919
+ # 准备Lua脚本参数
920
+ lua_args = [self.redis_prefix]
921
+ prefixed_queue = self.ep.get_prefixed_queue_name(queue)
922
+
923
+ for msg_dict, delay_seconds in delayed_messages:
924
+ stream_data = dumps_str(msg_dict)
925
+ execute_at = msg_dict['execute_at']
926
+
927
+ lua_args.extend([
928
+ prefixed_queue,
929
+ stream_data,
930
+ str(execute_at),
931
+ queue
932
+ ])
933
+
934
+ # 执行Lua脚本
935
+ client = self.ep.get_redis_client(asyncio=True, binary=True)
936
+
937
+ # 注册Lua脚本
938
+ if not hasattr(self, '_delayed_task_script'):
939
+ self._delayed_task_script = client.register_script(lua_script)
940
+
941
+ # 执行脚本
942
+ results = await self._delayed_task_script(keys=[], args=lua_args)
943
+
944
+ # 解码结果
945
+ return [r.decode('utf-8') if isinstance(r, bytes) else r for r in results]
530
946
 
531
947
  def register_router(self, router, prefix: str = None):
532
948
  """
@@ -662,12 +1078,16 @@ class Jettask(object):
662
1078
  executor = AsyncioExecutor(async_event_queue, self, concurrency)
663
1079
  await executor.loop()
664
1080
 
665
- # try:
666
- loop = asyncio.get_event_loop()
667
- # except RuntimeError:
668
- # # 如果当前线程没有事件循环,创建一个新的
669
- # loop = asyncio.new_event_loop()
670
- # asyncio.set_event_loop(loop)
1081
+ try:
1082
+ loop = asyncio.get_event_loop()
1083
+ if loop.is_running():
1084
+ # 如果事件循环已经在运行,创建一个新的
1085
+ loop = asyncio.new_event_loop()
1086
+ asyncio.set_event_loop(loop)
1087
+ except RuntimeError:
1088
+ # 如果当前线程没有事件循环,创建一个新的
1089
+ loop = asyncio.new_event_loop()
1090
+ asyncio.set_event_loop(loop)
671
1091
 
672
1092
  try:
673
1093
  loop.run_until_complete(run_asyncio_executor())
@@ -718,6 +1138,10 @@ class Jettask(object):
718
1138
  # 标记worker已启动
719
1139
  self._worker_started = True
720
1140
 
1141
+ # 如果配置了任务中心,从任务中心获取配置
1142
+ if self.task_center and self.task_center.is_enabled:
1143
+ self._load_config_from_task_center()
1144
+
721
1145
  # 注册清理处理器(只在启动worker时注册)
722
1146
  self._setup_cleanup_handlers()
723
1147
 
@@ -754,147 +1178,6 @@ class Jettask(object):
754
1178
  logger.warning("Process did not terminate, killing...")
755
1179
  self.process.kill()
756
1180
 
757
- def bulk_write(self, tasks: list, asyncio: bool = None):
758
- """
759
- 统一的批量写入方法,支持同步和异步模式,以及延迟任务
760
-
761
- Args:
762
- tasks: 任务列表
763
- asyncio: 是否使用异步模式。如果为None,自动检测
764
-
765
- Returns:
766
- 同步模式: 返回event_ids列表
767
- 异步模式: 如果在异步环境中调用,返回协程对象
768
- """
769
- if not tasks:
770
- raise ValueError("tasks 参数不能为空!")
771
-
772
- # 自动检测异步模式
773
- if asyncio is None:
774
- import asyncio as aio
775
- try:
776
- loop = aio.get_running_loop()
777
- asyncio = True
778
- except RuntimeError:
779
- asyncio = False
780
-
781
- # 如果需要异步执行
782
- if asyncio:
783
- return self._bulk_write_async(tasks)
784
-
785
- # 同步执行
786
- import asyncio as aio
787
- # 在同步模式下,使用 asyncio.run 来运行异步函数
788
- return aio.run(self._bulk_write_impl(tasks, asyncio_mode=False))
789
-
790
- async def _bulk_write_impl(self, tasks: list, asyncio_mode: bool):
791
- """批量写入的内部实现,支持同步和异步"""
792
- # 获取对应的Redis客户端
793
- redis_client = self.get_redis_client(asyncio=asyncio_mode)
794
-
795
- # 分离延迟任务和普通任务
796
- delayed_tasks = []
797
- normal_tasks = []
798
-
799
- for task in tasks:
800
- if 'delay' in task:
801
- delayed_tasks.append(task)
802
- else:
803
- normal_tasks.append(task)
804
-
805
- event_ids = []
806
-
807
- # 处理延迟任务 - 使用Lua脚本原子性处理
808
- if delayed_tasks:
809
-
810
- # 准备Lua脚本参数
811
- current_time = time.time()
812
- lua_args = [self.redis_prefix, str(current_time)]
813
-
814
- for task in delayed_tasks:
815
- delay_seconds = task.pop('delay') # 移除delay参数
816
- queue = task.get("queue")
817
-
818
- # 添加执行时间到消息中
819
- execute_at = current_time + delay_seconds
820
- task['execute_at'] = execute_at
821
- task['is_delayed'] = 1 # 使用1代替True
822
- task['trigger_time'] = current_time # 添加trigger_time
823
-
824
- # 如果queue为None,使用任务名作为队列名
825
- task_name = task.get("name", "")
826
- actual_queue = queue or task_name
827
- task['queue'] = actual_queue
828
-
829
- # 准备Stream数据
830
- prefixed_queue = self.ep.get_prefixed_queue_name(actual_queue)
831
- from ..utils.serializer import dumps_str
832
- stream_data = dumps_str(task)
833
-
834
- # 添加到Lua脚本参数
835
- lua_args.extend([
836
- prefixed_queue,
837
- stream_data,
838
- str(execute_at),
839
- str(delay_seconds),
840
- actual_queue
841
- ])
842
-
843
- # 注册并执行Lua脚本(使用类常量)
844
- script_attr = '_bulk_delayed_script_async' if asyncio_mode else '_bulk_delayed_script_sync'
845
- if not hasattr(self, script_attr):
846
- setattr(self, script_attr, redis_client.register_script(self._LUA_SCRIPT_DELAYED_TASKS))
847
-
848
- script = getattr(self, script_attr)
849
- if asyncio_mode:
850
- # 异步执行需要await
851
- stream_ids = await script(keys=[], args=lua_args)
852
- else:
853
- stream_ids = script(keys=[], args=lua_args)
854
-
855
- event_ids.extend(stream_ids)
856
-
857
- # 处理普通任务 - 使用Lua脚本原子性处理
858
- if normal_tasks:
859
-
860
- # 准备Lua脚本参数
861
- current_time = str(time.time())
862
- lua_args = [self.redis_prefix, current_time]
863
-
864
- # 按队列分组并准备参数
865
- from ..utils.serializer import dumps_str
866
- for task in normal_tasks:
867
- queue = task.get("queue")
868
- task_name = task.get("name", "")
869
- actual_queue = queue or task_name
870
-
871
- # 准备Stream数据
872
- prefixed_queue = self.ep.get_prefixed_queue_name(actual_queue)
873
- stream_data = dumps_str(task)
874
-
875
- # 添加到Lua脚本参数
876
- lua_args.extend([prefixed_queue, stream_data])
877
-
878
- # 注册并执行Lua脚本(使用类常量)
879
- script_attr = '_bulk_normal_script_async' if asyncio_mode else '_bulk_normal_script_sync'
880
- if not hasattr(self, script_attr):
881
- setattr(self, script_attr, redis_client.register_script(self._LUA_SCRIPT_NORMAL_TASKS))
882
-
883
- script = getattr(self, script_attr)
884
- if asyncio_mode:
885
- # 异步执行需要await
886
- stream_ids = await script(keys=[], args=lua_args)
887
- else:
888
- stream_ids = script(keys=[], args=lua_args)
889
-
890
- event_ids.extend(stream_ids)
891
-
892
- return event_ids
893
-
894
-
895
- async def _bulk_write_async(self, tasks: list):
896
- """异步批量写入,直接调用统一的实现"""
897
- return await self._bulk_write_impl(tasks, asyncio_mode=True)
898
1181
 
899
1182
  def get_task_info(self, event_id: str, asyncio: bool = False):
900
1183
  """获取任务信息(从TASK:hash)"""
@@ -1001,7 +1284,7 @@ class Jettask(object):
1001
1284
 
1002
1285
  def get_result(self, event_id: str, delete: bool = False, asyncio: bool = False,
1003
1286
  delayed_deletion_ex: int = None, wait: bool = False, timeout: int = 300,
1004
- poll_interval: float = 0.5, suppress_traceback: bool = False):
1287
+ poll_interval: float = 0.5):
1005
1288
  """获取任务结果(从TASK:hash的result字段)
1006
1289
 
1007
1290
  Args:
@@ -1040,12 +1323,33 @@ class Jettask(object):
1040
1323
  client.expire(key, delayed_deletion_ex)
1041
1324
  return result
1042
1325
  elif delete:
1043
- # 获取结果并删除整个hash
1326
+ # 如果配置了任务中心,不删除消息,等任务中心同步后删除
1327
+ if self.task_center and self.task_center.is_enabled:
1328
+ result = client.hget(key, "result")
1329
+ # 仅标记为待删除,不实际删除
1330
+ client.hset(key, "__pending_delete", "1")
1331
+ return result
1332
+ else:
1333
+ # 获取结果并删除整个hash
1334
+ result = client.hget(key, "result")
1335
+ client.delete(key)
1336
+ return result
1337
+ else:
1338
+ # 先尝试从Redis获取
1044
1339
  result = client.hget(key, "result")
1045
- client.delete(key)
1340
+ # 如果Redis中没有且配置了任务中心,从任务中心获取
1341
+ if result is None and self.task_center_client.is_enabled:
1342
+ import asyncio
1343
+ loop = asyncio.new_event_loop()
1344
+ try:
1345
+ task_data = loop.run_until_complete(
1346
+ self.task_center_client.get_task_result(event_id)
1347
+ )
1348
+ if task_data:
1349
+ result = task_data.get('result')
1350
+ finally:
1351
+ loop.close()
1046
1352
  return result
1047
- else:
1048
- return client.hget(key, "result")
1049
1353
 
1050
1354
  def _get_result_sync_wait(self, event_id: str, delete: bool, delayed_deletion_ex: int,
1051
1355
  timeout: int, poll_interval: float):
@@ -1153,14 +1457,12 @@ class Jettask(object):
1153
1457
 
1154
1458
  # 创建调度器
1155
1459
  scheduler_config = self.scheduler_config.copy()
1156
- scheduler_config.setdefault('redis_prefix', f"{self.redis_prefix}:SCHEDULER")
1157
1460
  scheduler_config.setdefault('scan_interval', 0.1)
1158
1461
  scheduler_config.setdefault('batch_size', 100)
1159
1462
  scheduler_config.setdefault('leader_ttl', 10)
1160
1463
 
1161
1464
  self.scheduler = TaskScheduler(
1162
1465
  app=self,
1163
- redis_url=self.redis_url,
1164
1466
  db_manager=self.scheduler_manager,
1165
1467
  **scheduler_config
1166
1468
  )
@@ -1272,12 +1574,21 @@ class Jettask(object):
1272
1574
  if not scheduler_id:
1273
1575
  raise ValueError("scheduler_id is required and must be provided")
1274
1576
 
1577
+ # 获取当前命名空间
1578
+ namespace = 'default'
1579
+ if self.task_center and hasattr(self.task_center, 'namespace_name'):
1580
+ namespace = self.task_center.namespace_name
1581
+ elif self.redis_prefix and self.redis_prefix != 'jettask':
1582
+ # 如果没有task_center,使用redis_prefix作为命名空间
1583
+ namespace = self.redis_prefix
1584
+
1275
1585
  # 创建任务对象
1276
1586
  task = ScheduledTask(
1277
1587
  scheduler_id=scheduler_id,
1278
1588
  task_name=full_task_name, # 使用完整的任务名称(包含模块前缀)
1279
1589
  task_type=TaskType(task_type),
1280
1590
  queue_name=queue_name,
1591
+ namespace=namespace, # 设置命名空间
1281
1592
  task_args=task_args or [],
1282
1593
  task_kwargs=task_kwargs or {},
1283
1594
  interval_seconds=interval_seconds,
@@ -1375,12 +1686,21 @@ class Jettask(object):
1375
1686
  if not scheduler_id:
1376
1687
  raise ValueError(f"Task config for '{task_name}' missing required scheduler_id")
1377
1688
 
1689
+ # 获取当前命名空间
1690
+ namespace = 'default'
1691
+ if self.task_center and hasattr(self.task_center, 'namespace_name'):
1692
+ namespace = self.task_center.namespace_name
1693
+ elif self.redis_prefix and self.redis_prefix != 'jettask':
1694
+ # 如果没有task_center,使用redis_prefix作为命名空间
1695
+ namespace = self.redis_prefix
1696
+
1378
1697
  # 创建任务对象
1379
1698
  task_obj = ScheduledTask(
1380
1699
  scheduler_id=scheduler_id,
1381
1700
  task_name=task_name,
1382
1701
  task_type=TaskType(task_type),
1383
1702
  queue_name=queue_name,
1703
+ namespace=namespace, # 设置命名空间
1384
1704
  task_args=task_config.get('task_args', []),
1385
1705
  task_kwargs=task_config.get('task_kwargs', {}),
1386
1706
  interval_seconds=task_config.get('interval_seconds'),