funboost 49.5__py3-none-any.whl → 49.7__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.
Potentially problematic release.
This version of funboost might be problematic. Click here for more details.
- funboost/__init__.py +1 -1
- funboost/beggar_version_implementation/beggar_redis_consumer.py +3 -1
- funboost/constant.py +39 -3
- funboost/consumers/base_consumer.py +34 -7
- funboost/consumers/celery_consumer.py +1 -0
- funboost/consumers/empty_consumer.py +12 -1
- funboost/consumers/faststream_consumer.py +1 -1
- funboost/consumers/http_consumer.py +12 -7
- funboost/consumers/kafka_consumer_manually_commit.py +0 -2
- funboost/consumers/kombu_consumer.py +0 -50
- funboost/consumers/tcp_consumer.py +11 -10
- funboost/consumers/udp_consumer.py +9 -6
- funboost/consumers/zeromq_consumer.py +18 -11
- funboost/core/exceptions.py +7 -0
- funboost/core/func_params_model.py +16 -7
- funboost/core/function_result_status_saver.py +15 -0
- funboost/core/msg_result_getter.py +51 -1
- funboost/core/serialization.py +28 -1
- funboost/factories/consumer_factory.py +1 -1
- funboost/factories/publisher_factotry.py +1 -1
- funboost/funboost_config_deafult.py +3 -2
- funboost/function_result_web/__pycache__/app.cpython-39.pyc +0 -0
- funboost/publishers/base_publisher.py +16 -2
- funboost/publishers/http_publisher.py +7 -1
- funboost/publishers/tcp_publisher.py +10 -8
- funboost/publishers/udp_publisher.py +8 -6
- funboost/publishers/zeromq_publisher.py +5 -1
- funboost/timing_job/apscheduler_use_redis_store.py +18 -4
- funboost/timing_job/timing_push.py +3 -1
- funboost/utils/ctrl_c_end.py +1 -1
- funboost/utils/redis_manager.py +6 -4
- {funboost-49.5.dist-info → funboost-49.7.dist-info}/METADATA +168 -173
- {funboost-49.5.dist-info → funboost-49.7.dist-info}/RECORD +37 -37
- {funboost-49.5.dist-info → funboost-49.7.dist-info}/WHEEL +1 -1
- {funboost-49.5.dist-info → funboost-49.7.dist-info}/LICENSE +0 -0
- {funboost-49.5.dist-info → funboost-49.7.dist-info}/entry_points.txt +0 -0
- {funboost-49.5.dist-info → funboost-49.7.dist-info}/top_level.txt +0 -0
funboost/__init__.py
CHANGED
|
@@ -19,7 +19,9 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
19
19
|
from funboost.funboost_config_deafult import BrokerConnConfig
|
|
20
20
|
|
|
21
21
|
redis_db_frame = redis.Redis(host=BrokerConnConfig.REDIS_HOST, password=BrokerConnConfig.REDIS_PASSWORD,
|
|
22
|
-
port=BrokerConnConfig.REDIS_PORT, db=BrokerConnConfig.REDIS_DB,
|
|
22
|
+
port=BrokerConnConfig.REDIS_PORT, db=BrokerConnConfig.REDIS_DB,
|
|
23
|
+
ssl=BrokerConnConfig.REDIS_USE_SSL,
|
|
24
|
+
decode_responses=True)
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
class BeggarRedisConsumer:
|
funboost/constant.py
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class BrokerEnum:
|
|
6
|
+
"""
|
|
7
|
+
在funboost中万物皆可为消息队列broker,funboost内置了所有 知名的正经经典消息队列作为broker,
|
|
8
|
+
也支持了基于 内存 各种数据库 文件系统 tcp/udp/http这些socket 模拟作为broker.
|
|
9
|
+
funboost也内置支持了各种python三方包和消费框架作为broker,例如 sqlachemy kombu celery rq dramtiq huey nameko 等等
|
|
10
|
+
|
|
11
|
+
用户也可以按照文档4.21章节,轻松扩展任何物质概念作为funboost的broker.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# funboost框架能轻松兼容消息队列各种工作模式, 拉模式/推模式/轮询模式,单条获取 批量获取
|
|
15
|
+
"""
|
|
16
|
+
funboost 的 consumer的 _shedual_task 非常灵活,用户实现把从消息队列取出的消息通过_submit_task方法
|
|
17
|
+
丢到并发池,他不是强制用户重写实现怎么取一条消息,例如强制你实现一个 _get_one_message的法,
|
|
18
|
+
那就不灵活和限制扩展任意东西作为broker了,而是用户完全自己来写灵活代码。
|
|
19
|
+
所以无论获取消息是 拉模式 还是推模式 还是轮询模式,是单条获取 还是多条批量获取,
|
|
20
|
+
不管你的新中间件和rabbitmq api用法差别有多么巨大,都能轻松扩展任意东西作为funboost的中间件。
|
|
21
|
+
所以你能看到funboost源码中能轻松实现任物质概念作为funboost的broker。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
6
25
|
EMPTY = 'EMPTY' # 空的实现,需要搭配 boost入参的 consumer_override_cls 和 publisher_override_cls使用,或者被继承。
|
|
7
26
|
|
|
8
27
|
RABBITMQ_AMQPSTORM = 'RABBITMQ_AMQPSTORM' # 使用 amqpstorm 包操作rabbitmq 作为 分布式消息队列,支持消费确认.强烈推荐这个作为funboost中间件。
|
|
@@ -10,12 +29,21 @@ class BrokerEnum:
|
|
|
10
29
|
|
|
11
30
|
RABBITMQ_RABBITPY = 'RABBITMQ_RABBITPY' # 使用 rabbitpy 包操作rabbitmq 作为 分布式消息队列,支持消费确认,不建议使用
|
|
12
31
|
|
|
32
|
+
"""
|
|
33
|
+
以下是各种redis数据结构和各种方式来实现作为消息队列的,redis简直被作者玩出花来了.
|
|
34
|
+
因为redis本身是缓存数据库,不是消息队列,redis没有实现经典AMQP协议,所以redis是模拟消息队列不是真消息队列.
|
|
35
|
+
例如要实现消费确认,随意重启但消息万无一失,你搞个简单的 redis.blpop 弹出删除消息,那就压根不行.重启就丢失了,但消息可能还没开始运行或者正在运行中.
|
|
36
|
+
|
|
37
|
+
redis做ack挑战难点不是怎么实现确认消费本身,而是何时应该把关闭或宕机进程的消费者的待确认消费的孤儿消息重回队列.
|
|
38
|
+
在 Redis 上实现 ACK 的真正难点,根本不在于“确认”这个动作本身,而在于建立一套可靠的、能够准确判断“何时可以安全地及时地进行任务恢复”的分布式故障检测机制。
|
|
39
|
+
所以你以为只要使用 brpoplpush 或者 REDIS_STREAM 就能自动轻易解决ack问题,那就太天真了,因为redis服务端不能像rabbitmq服务端那样天生自带自动重回宕机消费者的消息机制,需要你在redis客户端来维护实现这套机制.
|
|
40
|
+
"""
|
|
13
41
|
REDIS = 'REDIS' # 使用 redis 的 list结构,brpop 作为分布式消息队列。随意重启和关闭会丢失大量消息,不支持消费确认。注重性能不在乎丢失消息可以选这个redis方案。
|
|
14
42
|
REDIS_ACK_ABLE = 'REDIS_ACK_ABLE' # 基于redis的 list + 临时unack的set队列,采用了 lua脚本操持了取任务和加到pengding为原子性,,基于进程心跳消失判断消息是否为掉线进程的,随意重启和掉线不会丢失任务。
|
|
15
|
-
REIDS_ACK_USING_TIMEOUT = 'reids_ack_using_timeout' # 基于redis的 list + 临时unack的set队列,使用超时多少秒没确认消费就自动重回队列,请注意 ack_timeout的设置值和函数耗时大小,否则会发生反复重回队列的后果,boost可以设置ack超时,broker_exclusive_config={'ack_timeout': 1800}
|
|
43
|
+
REIDS_ACK_USING_TIMEOUT = 'reids_ack_using_timeout' # 基于redis的 list + 临时unack的set队列,使用超时多少秒没确认消费就自动重回队列,请注意 ack_timeout的设置值和函数耗时大小,否则会发生反复重回队列的后果,boost可以设置ack超时,broker_exclusive_config={'ack_timeout': 1800}.缺点是无法区分执行太慢还是真宕机
|
|
16
44
|
REDIS_PRIORITY = 'REDIS_PRIORITY' # # 基于redis的多 list + 临时unack的set队列,blpop监听多个key,和rabbitmq的x-max-priority属性一样,支持任务优先级。看文档4.29优先级队列说明。
|
|
17
45
|
REDIS_STREAM = 'REDIS_STREAM' # 基于redis 5.0 版本以后,使用 stream 数据结构作为分布式消息队列,支持消费确认和持久化和分组消费,是redis官方推荐的消息队列形式,比list结构更适合。
|
|
18
|
-
RedisBrpopLpush = 'RedisBrpopLpush' # 基于redis的list结构但是采用brpoplpush 双队列形式,和 redis_ack_able的实现差不多,实现上采用了原生命令就不需要lua脚本来实现取出和加入unack了。
|
|
46
|
+
RedisBrpopLpush = 'RedisBrpopLpush' # 基于redis的list结构但是采用 brpoplpush 双队列形式,和 redis_ack_able的实现差不多,实现上采用了原生命令就不需要lua脚本来实现取出和加入unack了。
|
|
19
47
|
REDIS_PUBSUB = 'REDIS_PUBSUB' # 基于redis 发布订阅的,发布一个消息多个消费者都能收到同一条消息,但不支持持久化
|
|
20
48
|
|
|
21
49
|
MEMORY_QUEUE = 'MEMORY_QUEUE' # 使用python queue.Queue实现的基于当前python进程的消息队列,不支持跨进程 跨脚本 跨机器共享任务,不支持持久化,适合一次性短期简单任务。
|
|
@@ -44,8 +72,15 @@ class BrokerEnum:
|
|
|
44
72
|
|
|
45
73
|
ZEROMQ = 'ZEROMQ' # 基于zeromq作为分布式消息队列,不需要安装中间件,可以支持跨机器但不支持持久化。
|
|
46
74
|
|
|
75
|
+
|
|
47
76
|
"""
|
|
48
|
-
|
|
77
|
+
kombu 和 celery 都是 funboost中的神级别broker_kind。
|
|
78
|
+
使得funboost以逸待劳,支持kombu的所有现有和未来的消息队列。
|
|
79
|
+
通过直接支持 kombu,funboost 相当于一瞬间就继承了 `kombu` 支持的所有现有和未来的消息队列能力。无论 kombu 社区未来增加了对哪种新的云消息服务(如 Google
|
|
80
|
+
Pub/Sub、Azure Service Bus)或小众 MQ 的支持,funboost 无需修改自身代码,就能自动获得这种能力。这
|
|
81
|
+
是一种“以逸待劳”的策略,极大地扩展了 funboost 的适用范围。
|
|
82
|
+
|
|
83
|
+
kombu 包可以作为funboost的broker,这个包也是celery的中间件依赖包,这个包可以操作10种中间件(例如rabbitmq redis),但没包括分布式函数调度框架的kafka nsq zeromq 等。
|
|
49
84
|
同时 kombu 包的性能非常差,可以用原生redis的lpush和kombu的publish测试发布,使用brpop 和 kombu 的 drain_events测试消费,对比差距相差了5到10倍。
|
|
50
85
|
由于性能差,除非是分布式函数调度框架没实现的中间件才选kombu方式(例如kombu支持亚马逊队列 qpid pyro 队列),否则强烈建议使用此框架的操作中间件方式而不是使用kombu。
|
|
51
86
|
"""
|
|
@@ -74,6 +109,7 @@ class BrokerEnum:
|
|
|
74
109
|
|
|
75
110
|
CELERY = 'CELERY' # funboost支持celery框架来发布和消费任务,由celery框架来调度执行任务,但是写法简单远远暴击用户亲自使用celery的麻烦程度,
|
|
76
111
|
# 用户永无无需关心和操作Celery对象实例,无需关心celery的task_routes和includes配置,funboost来自动化设置这些celery配置。
|
|
112
|
+
# funboost将Celery本身纳入了自己的Broker体系。能“吞下”另一个大型框架,简直太妙了。本身就证明了funboost架构的包容性和精妙性和复杂性。
|
|
77
113
|
|
|
78
114
|
DRAMATIQ = 'DRAMATIQ' # funboost使用 dramatiq 框架作为消息队列,dramatiq类似celery也是任务队列框架。用户使用funboost api来操作dramatiq核心调度。
|
|
79
115
|
|
|
@@ -37,7 +37,7 @@ from funboost.core.current_task import funboost_current_task, FctContext
|
|
|
37
37
|
from funboost.core.loggers import develop_logger
|
|
38
38
|
|
|
39
39
|
from funboost.core.func_params_model import BoosterParams, PublisherParams, BaseJsonAbleModel
|
|
40
|
-
from funboost.core.serialization import Serialization
|
|
40
|
+
from funboost.core.serialization import PickleHelper, Serialization
|
|
41
41
|
from funboost.core.task_id_logger import TaskIdLogger
|
|
42
42
|
from funboost.constant import FunctionKind
|
|
43
43
|
|
|
@@ -437,14 +437,21 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
437
437
|
def _shedual_task(self):
|
|
438
438
|
"""
|
|
439
439
|
每个子类必须实现这个的方法,完成如何从中间件取出消息,并将函数和运行参数添加到工作池。
|
|
440
|
+
|
|
441
|
+
funboost 的 _shedual_task 哲学是:“我不管你怎么从你的系统里拿到任务,我只要求你拿到任务后,
|
|
442
|
+
调用 self._submit_task(msg) 方法把它交给我处理就行。”
|
|
443
|
+
|
|
444
|
+
所以无论获取消息是 拉模式 还是推模式 还是轮询模式,无论是是单条获取 还是多条批量多条获取,
|
|
445
|
+
都能轻松扩展任意东西作为funboost的中间件。
|
|
446
|
+
|
|
440
447
|
:return:
|
|
441
448
|
"""
|
|
442
449
|
raise NotImplementedError
|
|
443
450
|
|
|
444
|
-
def
|
|
451
|
+
def _convert_msg_before_run(self, msg: typing.Union[str, dict]) -> dict:
|
|
445
452
|
"""
|
|
446
453
|
转换消息,消息没有使用funboost来发送,并且没有extra相关字段时候
|
|
447
|
-
用户也可以按照4.21文档,继承任意Consumer
|
|
454
|
+
用户也可以按照4.21文档,继承任意Consumer类,并实现方法 _user_convert_msg_before_run,先转换不规范的消息.
|
|
448
455
|
"""
|
|
449
456
|
""" 一般消息至少包含这样
|
|
450
457
|
{
|
|
@@ -462,6 +469,7 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
462
469
|
extra_params = {'task_id': task_id, 'publish_time': round(time.time(), 4),
|
|
463
470
|
'publish_time_format': time.strftime('%Y-%m-%d %H:%M:%S')}
|
|
464
471
|
"""
|
|
472
|
+
msg = self._user_convert_msg_before_run(msg)
|
|
465
473
|
msg = Serialization.to_dict(msg)
|
|
466
474
|
# 以下是清洗补全字段.
|
|
467
475
|
if 'extra' not in msg:
|
|
@@ -474,10 +482,17 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
474
482
|
if 'publish_time_format':
|
|
475
483
|
extra['publish_time_format'] = MsgGenerater.generate_publish_time_format()
|
|
476
484
|
return msg
|
|
485
|
+
|
|
486
|
+
def _user_convert_msg_before_run(self, msg: typing.Union[str, dict]) -> dict:
|
|
487
|
+
"""
|
|
488
|
+
用户也可以提前清洗数据
|
|
489
|
+
"""
|
|
490
|
+
# print(msg)
|
|
491
|
+
return msg
|
|
477
492
|
|
|
478
493
|
def _submit_task(self, kw):
|
|
479
494
|
|
|
480
|
-
kw['body'] = self.
|
|
495
|
+
kw['body'] = self._convert_msg_before_run(kw['body'])
|
|
481
496
|
self._print_message_get_from_broker(kw['body'])
|
|
482
497
|
if self._judge_is_daylight():
|
|
483
498
|
self._requeue(kw)
|
|
@@ -628,8 +643,9 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
628
643
|
async def aio_user_custom_record_process_info_func(self, current_function_result_status: FunctionResultStatus): # 这个可以继承
|
|
629
644
|
pass
|
|
630
645
|
|
|
631
|
-
def _convert_real_function_only_params_by_conusuming_function_kind(self, function_only_params: dict):
|
|
646
|
+
def _convert_real_function_only_params_by_conusuming_function_kind(self, function_only_params: dict,extra_params:dict):
|
|
632
647
|
"""对于实例方法和classmethod 方法, 从消息队列的消息恢复第一个入参, self 和 cls"""
|
|
648
|
+
can_not_json_serializable_keys = extra_params.get('can_not_json_serializable_keys',[])
|
|
633
649
|
if self.consumer_params.consuming_function_kind in [FunctionKind.CLASS_METHOD, FunctionKind.INSTANCE_METHOD]:
|
|
634
650
|
real_function_only_params = copy.copy(function_only_params)
|
|
635
651
|
method_first_param_name = None
|
|
@@ -651,8 +667,14 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
651
667
|
obj = method_cls(**method_first_param_value[ConstStrForClassMethod.OBJ_INIT_PARAMS])
|
|
652
668
|
real_function_only_params[method_first_param_name] = obj
|
|
653
669
|
# print(real_function_only_params)
|
|
670
|
+
if can_not_json_serializable_keys:
|
|
671
|
+
for key in can_not_json_serializable_keys:
|
|
672
|
+
real_function_only_params[key] = PickleHelper.to_obj(real_function_only_params[key])
|
|
654
673
|
return real_function_only_params
|
|
655
674
|
else:
|
|
675
|
+
if can_not_json_serializable_keys:
|
|
676
|
+
for key in can_not_json_serializable_keys:
|
|
677
|
+
function_only_params[key] = PickleHelper.to_obj(function_only_params[key])
|
|
656
678
|
return function_only_params
|
|
657
679
|
|
|
658
680
|
# noinspection PyProtectedMember
|
|
@@ -751,7 +773,7 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
751
773
|
self.logger.warning(f'取消运行 {task_id} {function_only_params}')
|
|
752
774
|
return function_result_status
|
|
753
775
|
function_run = kill_remote_task.kill_fun_deco(task_id)(function_run) # 用杀死装饰器包装起来在另一个线程运行函数,以便等待远程杀死。
|
|
754
|
-
function_result_status.result = function_run(**self._convert_real_function_only_params_by_conusuming_function_kind(function_only_params))
|
|
776
|
+
function_result_status.result = function_run(**self._convert_real_function_only_params_by_conusuming_function_kind(function_only_params,kw['body']['extra']))
|
|
755
777
|
# if asyncio.iscoroutine(function_result_status.result):
|
|
756
778
|
# log_msg = f'''异步的协程消费函数必须使用 async 并发模式并发,请设置消费函数 {self.consuming_function.__name__} 的concurrent_mode 为 ConcurrentModeEnum.ASYNC 或 4'''
|
|
757
779
|
# # self.logger.critical(msg=f'{log_msg} \n')
|
|
@@ -806,6 +828,9 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
806
828
|
self.logger.error(msg=log_msg, exc_info=self._get_priority_conf(kw, 'is_print_detail_exception'))
|
|
807
829
|
# traceback.print_exc()
|
|
808
830
|
function_result_status.exception = f'{e.__class__.__name__} {str(e)}'
|
|
831
|
+
function_result_status.exception_msg = str(e)
|
|
832
|
+
function_result_status.exception_type = e.__class__.__name__
|
|
833
|
+
|
|
809
834
|
function_result_status.result = FunctionResultStatus.FUNC_RUN_ERROR
|
|
810
835
|
return function_result_status
|
|
811
836
|
|
|
@@ -892,7 +917,7 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
892
917
|
logger=self.logger,queue_name=self.queue_name,)
|
|
893
918
|
fct.set_fct_context(fct_context)
|
|
894
919
|
try:
|
|
895
|
-
corotinue_obj = self.consuming_function(**self._convert_real_function_only_params_by_conusuming_function_kind(function_only_params))
|
|
920
|
+
corotinue_obj = self.consuming_function(**self._convert_real_function_only_params_by_conusuming_function_kind(function_only_params,kw['body']['extra']))
|
|
896
921
|
if not asyncio.iscoroutine(corotinue_obj):
|
|
897
922
|
log_msg = f'''当前设置的并发模式为 async 并发模式,但消费函数不是异步协程函数,请不要把消费函数 {self.consuming_function.__name__} 的 concurrent_mode 设置错误'''
|
|
898
923
|
# self.logger.critical(msg=f'{log_msg} \n')
|
|
@@ -939,6 +964,8 @@ class AbstractConsumer(LoggerLevelSetterMixin, metaclass=abc.ABCMeta, ):
|
|
|
939
964
|
# self.error_file_logger.error(msg=f'{log_msg} \n', exc_info=self._get_priority_conf(kw, 'is_print_detail_exception'))
|
|
940
965
|
self.logger.error(msg=log_msg, exc_info=self._get_priority_conf(kw, 'is_print_detail_exception'))
|
|
941
966
|
function_result_status.exception = f'{e.__class__.__name__} {str(e)}'
|
|
967
|
+
function_result_status.exception_msg = str(e)
|
|
968
|
+
function_result_status.exception_type = e.__class__.__name__
|
|
942
969
|
function_result_status.result = FunctionResultStatus.FUNC_RUN_ERROR
|
|
943
970
|
return function_result_status
|
|
944
971
|
|
|
@@ -8,19 +8,30 @@ from funboost.consumers.base_consumer import AbstractConsumer
|
|
|
8
8
|
|
|
9
9
|
class EmptyConsumer(AbstractConsumer, metaclass=abc.ABCMeta):
|
|
10
10
|
"""
|
|
11
|
-
|
|
11
|
+
一个空的消费者基类,作为自定义 Broker 的模板。
|
|
12
|
+
|
|
13
|
+
这个类其实是多余的,因为用户完全可以继承AbstractConsumer,然后实现custom_init方法,然后实现_shedual_task, _confirm_consume, _requeue方法来新增自定义broker。
|
|
14
|
+
这个类是为了清晰明确的告诉你,仅仅需要下面三个方法,就可以实现一个自定义broker,因为AbstractConsumer基类功能太丰富了,基类方法是在太多了,用户不知道需要继承重写哪方法
|
|
15
|
+
|
|
16
|
+
|
|
12
17
|
"""
|
|
13
18
|
def custom_init(self):
|
|
14
19
|
pass
|
|
15
20
|
|
|
16
21
|
@abc.abstractmethod
|
|
17
22
|
def _shedual_task(self):
|
|
23
|
+
"""
|
|
24
|
+
核心调度任务。此方法需要实现一个循环,负责从你的中间件中获取消息,
|
|
25
|
+
然后调用 `self._submit_task(msg)` 将任务提交到框架的并发池中执行。 可以参考funboos源码中的各种消费者实现。
|
|
26
|
+
"""
|
|
18
27
|
raise NotImplemented('not realization')
|
|
19
28
|
|
|
20
29
|
@abc.abstractmethod
|
|
21
30
|
def _confirm_consume(self, kw):
|
|
31
|
+
"""确认消费,就是ack概念"""
|
|
22
32
|
raise NotImplemented('not realization')
|
|
23
33
|
|
|
24
34
|
@abc.abstractmethod
|
|
25
35
|
def _requeue(self, kw):
|
|
36
|
+
"""重新入队"""
|
|
26
37
|
raise NotImplemented('not realization')
|
|
@@ -22,7 +22,7 @@ class FastStreamConsumer(EmptyConsumer):
|
|
|
22
22
|
# print(logger.name)
|
|
23
23
|
# return self.consuming_function(*args, **kwargs) # 如果没有声明 autoretry_for ,那么消费函数出错了就不会自动重试了。
|
|
24
24
|
# print(msg)
|
|
25
|
-
function_only_params = delete_keys_and_return_new_dict(
|
|
25
|
+
function_only_params = delete_keys_and_return_new_dict(Serialization.to_dict(msg))
|
|
26
26
|
if self._consuming_function_is_asyncio:
|
|
27
27
|
result = await self.consuming_function(**function_only_params)
|
|
28
28
|
else:
|
|
@@ -10,20 +10,25 @@ import json
|
|
|
10
10
|
from funboost.consumers.base_consumer import AbstractConsumer
|
|
11
11
|
from funboost.core.lazy_impoter import AioHttpImporter
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
class HTTPConsumer(AbstractConsumer, ):
|
|
14
15
|
"""
|
|
15
16
|
http 实现消息队列,不支持持久化,但不需要安装软件。
|
|
16
17
|
"""
|
|
17
|
-
|
|
18
|
+
BROKER_EXCLUSIVE_CONFIG_DEFAULT = {'host': '127.0.0.1', 'port': None}
|
|
18
19
|
|
|
19
20
|
# noinspection PyAttributeOutsideInit
|
|
20
21
|
def custom_init(self):
|
|
21
|
-
try:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
except BaseException as e:
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
# try:
|
|
23
|
+
# self._ip, self._port = self.queue_name.split(':')
|
|
24
|
+
# self._port = int(self._port)
|
|
25
|
+
# except BaseException as e:
|
|
26
|
+
# self.logger.critical(f'http作为消息队列时候,队列名字必须设置为 例如 192.168.1.101:8200 这种, ip:port')
|
|
27
|
+
# raise e
|
|
28
|
+
self._ip = self.consumer_params.broker_exclusive_config['host']
|
|
29
|
+
self._port = self.consumer_params.broker_exclusive_config['port']
|
|
30
|
+
if self._port is None:
|
|
31
|
+
raise ValueError('please specify port')
|
|
27
32
|
|
|
28
33
|
# noinspection DuplicatedCode
|
|
29
34
|
def _shedual_task(self):
|
|
@@ -72,8 +72,6 @@ class KafkaConsumerManuallyCommit(AbstractConsumer):
|
|
|
72
72
|
f'从kafka的 [{self._queue_name}] 主题,分区 {msg.partition()} 中 的 offset {msg.offset()} 取出的消息是: {msg.value()}') # noqa
|
|
73
73
|
self._submit_task(kw)
|
|
74
74
|
|
|
75
|
-
# kw = {'consumer': consumer, 'message': message, 'body': json.loads(message.value)}
|
|
76
|
-
# self._submit_task(kw)
|
|
77
75
|
|
|
78
76
|
def _manually_commit(self):
|
|
79
77
|
"""
|
|
@@ -17,55 +17,6 @@ from kombu.transport.redis import Empty
|
|
|
17
17
|
from funboost.consumers.base_consumer import AbstractConsumer
|
|
18
18
|
from funboost.funboost_config_deafult import BrokerConnConfig
|
|
19
19
|
|
|
20
|
-
def patch_kombu_redis000():
|
|
21
|
-
# 这个也可以,代码长了一点。
|
|
22
|
-
"""
|
|
23
|
-
给kombu的redis 模式打猴子补丁
|
|
24
|
-
kombu有bug,redis中间件 unnacked 中的任务即使客户端掉线了或者突然关闭脚本中正在运行的任务,也永远不会被重新消费。
|
|
25
|
-
这个很容易验证那个测试,把消费函数写成sleep 100秒,启动20秒后把脚本关掉,取出来的任务在 unacked 队列中那个永远不会被确认消费,也不会被重新消费。
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
# noinspection PyUnusedLocal
|
|
29
|
-
def monkey_get(self, callback, timeout=None):
|
|
30
|
-
self._in_protected_read = True
|
|
31
|
-
try:
|
|
32
|
-
for channel in self._channels:
|
|
33
|
-
if channel.active_queues: # BRPOP mode?
|
|
34
|
-
if channel.qos.can_consume():
|
|
35
|
-
self._register_BRPOP(channel)
|
|
36
|
-
if channel.active_fanout_queues: # LISTEN mode?
|
|
37
|
-
self._register_LISTEN(channel)
|
|
38
|
-
|
|
39
|
-
events = self.poller.poll(timeout)
|
|
40
|
-
if events:
|
|
41
|
-
for fileno, event in events:
|
|
42
|
-
ret = None
|
|
43
|
-
# noinspection PyBroadException,PyUnusedLocal
|
|
44
|
-
try:
|
|
45
|
-
ret = self.handle_event(fileno, event) # 主要是这行改了加了try,不然会raise empty 导致self.maybe_restore_messages()没执行
|
|
46
|
-
except BaseException as e:
|
|
47
|
-
pass
|
|
48
|
-
# print(traceback.format_exc())
|
|
49
|
-
# print(e)
|
|
50
|
-
if ret:
|
|
51
|
-
return
|
|
52
|
-
# - no new data, so try to restore messages.
|
|
53
|
-
# - reset active redis commands.
|
|
54
|
-
self.maybe_restore_messages()
|
|
55
|
-
raise Empty()
|
|
56
|
-
# raise Exception('kombu.five.Empty')
|
|
57
|
-
finally:
|
|
58
|
-
self._in_protected_read = False
|
|
59
|
-
# print(self.after_read)
|
|
60
|
-
while self.after_read:
|
|
61
|
-
try:
|
|
62
|
-
fun = self.after_read.pop()
|
|
63
|
-
except KeyError:
|
|
64
|
-
break
|
|
65
|
-
else:
|
|
66
|
-
fun()
|
|
67
|
-
|
|
68
|
-
redis.MultiChannelPoller.get = monkey_get
|
|
69
20
|
|
|
70
21
|
|
|
71
22
|
has_patch_kombu_redis = False
|
|
@@ -211,7 +162,6 @@ Transport Options
|
|
|
211
162
|
self.conn.drain_events()
|
|
212
163
|
|
|
213
164
|
def _confirm_consume(self, kw):
|
|
214
|
-
pass # redis没有确认消费的功能。
|
|
215
165
|
kw['message'].ack()
|
|
216
166
|
|
|
217
167
|
def _requeue(self, kw):
|
|
@@ -13,32 +13,34 @@ class TCPConsumer(AbstractConsumer, ):
|
|
|
13
13
|
socket 实现消息队列,不支持持久化,但不需要安装软件。
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
BUFSIZE = 10240
|
|
16
|
+
BROKER_EXCLUSIVE_CONFIG_DEFAULT = {'host': '127.0.0.1', 'port': None, 'bufsize': 10240}
|
|
18
17
|
|
|
19
18
|
# noinspection PyAttributeOutsideInit
|
|
20
19
|
def custom_init(self):
|
|
21
|
-
ip__port_str = self.queue_name.split(':')
|
|
22
|
-
ip_port = (ip__port_str[0], int(ip__port_str[1]))
|
|
23
|
-
self._ip_port_raw = ip_port
|
|
24
|
-
self._ip_port = ('', ip_port[1])
|
|
20
|
+
# ip__port_str = self.queue_name.split(':')
|
|
21
|
+
# ip_port = (ip__port_str[0], int(ip__port_str[1]))
|
|
22
|
+
# self._ip_port_raw = ip_port
|
|
23
|
+
# self._ip_port = ('', ip_port[1])
|
|
25
24
|
# ip_port = ('', 9999)
|
|
25
|
+
self._ip_port = (self.consumer_params.broker_exclusive_config['host'],
|
|
26
|
+
self.consumer_params.broker_exclusive_config['port'])
|
|
27
|
+
self.bufsize = self.consumer_params.broker_exclusive_config['bufsize']
|
|
26
28
|
|
|
27
29
|
# noinspection DuplicatedCode
|
|
28
30
|
def _shedual_task(self):
|
|
29
|
-
|
|
31
|
+
|
|
30
32
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # tcp协议
|
|
31
33
|
server.bind(self._ip_port)
|
|
32
34
|
server.listen(128)
|
|
33
35
|
self._server = server
|
|
34
36
|
while True:
|
|
35
37
|
tcp_cli_sock, addr = self._server.accept()
|
|
36
|
-
Thread(target=self.__handle_conn, args=(tcp_cli_sock,)).start()
|
|
38
|
+
Thread(target=self.__handle_conn, args=(tcp_cli_sock,)).start() # 服务端多线程,可以同时处理多个tcp长链接客户端发来的消息。
|
|
37
39
|
|
|
38
40
|
def __handle_conn(self, tcp_cli_sock):
|
|
39
41
|
try:
|
|
40
42
|
while True:
|
|
41
|
-
data = tcp_cli_sock.recv(self.
|
|
43
|
+
data = tcp_cli_sock.recv(self.bufsize)
|
|
42
44
|
# print('server收到的数据', data)
|
|
43
45
|
if not data:
|
|
44
46
|
break
|
|
@@ -56,4 +58,3 @@ class TCPConsumer(AbstractConsumer, ):
|
|
|
56
58
|
|
|
57
59
|
def _requeue(self, kw):
|
|
58
60
|
pass
|
|
59
|
-
|
|
@@ -12,14 +12,17 @@ class UDPConsumer(AbstractConsumer, ):
|
|
|
12
12
|
socket 实现消息队列,不支持持久化,但不需要安装软件。
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
BROKER_EXCLUSIVE_CONFIG_DEFAULT = {'host': '127.0.0.1', 'port': None, 'bufsize': 10240}
|
|
16
16
|
|
|
17
17
|
# noinspection PyAttributeOutsideInit
|
|
18
18
|
def custom_init(self):
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
self.__udp_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
21
|
-
ip__port_str = self.queue_name.split(':')
|
|
22
|
-
self.__ip_port = (ip__port_str[0], int(ip__port_str[1]))
|
|
21
|
+
# ip__port_str = self.queue_name.split(':')
|
|
22
|
+
# self.__ip_port = (ip__port_str[0], int(ip__port_str[1]))
|
|
23
|
+
self.__ip_port = (self.consumer_params.broker_exclusive_config['host'],
|
|
24
|
+
self.consumer_params.broker_exclusive_config['port'])
|
|
25
|
+
self._bufsize = self.consumer_params.broker_exclusive_config['bufsize']
|
|
23
26
|
self.__udp_client.connect(self.__ip_port)
|
|
24
27
|
|
|
25
28
|
# noinspection DuplicatedCode
|
|
@@ -29,7 +32,7 @@ class UDPConsumer(AbstractConsumer, ):
|
|
|
29
32
|
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # udp协议
|
|
30
33
|
server.bind(ip_port)
|
|
31
34
|
while True:
|
|
32
|
-
data, client_addr = server.recvfrom(self.
|
|
35
|
+
data, client_addr = server.recvfrom(self._bufsize)
|
|
33
36
|
# print('server收到的数据', data)
|
|
34
37
|
# self._print_message_get_from_broker(f'udp {ip_port}', data.decode())
|
|
35
38
|
server.sendto('has_recived'.encode(), client_addr)
|
|
@@ -41,4 +44,4 @@ class UDPConsumer(AbstractConsumer, ):
|
|
|
41
44
|
|
|
42
45
|
def _requeue(self, kw):
|
|
43
46
|
self.__udp_client.send(json.dumps(kw['body']).encode())
|
|
44
|
-
data = self.__udp_client.recv(self.
|
|
47
|
+
data = self.__udp_client.recv(self._bufsize)
|
|
@@ -67,31 +67,38 @@ class ZeroMqConsumer(AbstractConsumer):
|
|
|
67
67
|
zeromq 中间件的消费者,zeromq基于socket代码,不会持久化,且不需要安装软件。
|
|
68
68
|
"""
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
BROKER_EXCLUSIVE_CONFIG_DEFAULT = {'port': None}
|
|
71
|
+
|
|
72
|
+
def custom_init(self):
|
|
73
|
+
self._port = self.consumer_params.broker_exclusive_config['port']
|
|
74
|
+
if self._port is None:
|
|
75
|
+
raise ValueError('please specify port')
|
|
76
|
+
|
|
77
|
+
def _start_broker_port(self):
|
|
71
78
|
# threading.Thread(target=self._start_broker).start()
|
|
72
79
|
# noinspection PyBroadException
|
|
73
80
|
try:
|
|
74
|
-
if not (10000 < int(self.
|
|
75
|
-
raise ValueError("
|
|
81
|
+
if not (10000 < int(self._port) < 65535):
|
|
82
|
+
raise ValueError("请设置port是一个 10000到65535的之间的一个端口数字")
|
|
76
83
|
except BaseException:
|
|
77
|
-
self.logger.critical(f"
|
|
84
|
+
self.logger.critical(f" 请设置port是一个 10000到65535的之间的一个端口数字")
|
|
78
85
|
# noinspection PyProtectedMember
|
|
79
86
|
os._exit(444)
|
|
80
|
-
if check_port_is_used('127.0.0.1', int(self.
|
|
81
|
-
self.logger.debug(f"""{int(self.
|
|
87
|
+
if check_port_is_used('127.0.0.1', int(self._port)):
|
|
88
|
+
self.logger.debug(f"""{int(self._port)} router端口已经启动(或占用) """)
|
|
82
89
|
return
|
|
83
|
-
if check_port_is_used('127.0.0.1', int(self.
|
|
84
|
-
self.logger.debug(f"""{int(self.
|
|
90
|
+
if check_port_is_used('127.0.0.1', int(self._port) + 1):
|
|
91
|
+
self.logger.debug(f"""{int(self._port) + 1} dealer 端口已经启动(或占用) """)
|
|
85
92
|
return
|
|
86
|
-
multiprocessing.Process(target=start_broker, args=(int(self.
|
|
93
|
+
multiprocessing.Process(target=start_broker, args=(int(self._port), int(self._port) + 1)).start()
|
|
87
94
|
|
|
88
95
|
# noinspection DuplicatedCode
|
|
89
96
|
def _shedual_task(self):
|
|
90
|
-
self.
|
|
97
|
+
self._start_broker_port()
|
|
91
98
|
context = ZmqImporter().zmq.Context()
|
|
92
99
|
# noinspection PyUnresolvedReferences
|
|
93
100
|
zsocket = context.socket(ZmqImporter().zmq.REP)
|
|
94
|
-
zsocket.connect(f"tcp://localhost:{int(self.
|
|
101
|
+
zsocket.connect(f"tcp://localhost:{int(self._port) + 1}")
|
|
95
102
|
|
|
96
103
|
while True:
|
|
97
104
|
message = zsocket.recv()
|
funboost/core/exceptions.py
CHANGED
|
@@ -11,6 +11,11 @@ class ExceptionForRetry(FunboostException):
|
|
|
11
11
|
class ExceptionForRequeue(FunboostException):
|
|
12
12
|
"""框架检测到此错误,重新放回当前队列中"""
|
|
13
13
|
|
|
14
|
+
class FunboostWaitRpcResultTimeout(FunboostException):
|
|
15
|
+
"""等待rpc结果超过了指定时间"""
|
|
16
|
+
|
|
17
|
+
class FunboostRpcResultError(FunboostException):
|
|
18
|
+
"""rpc结果是错误状态"""
|
|
14
19
|
|
|
15
20
|
class ExceptionForPushToDlxqueue(FunboostException):
|
|
16
21
|
"""框架检测到ExceptionForPushToDlxqueue错误,发布到死信队列"""
|
|
@@ -40,3 +45,5 @@ def f(x):
|
|
|
40
45
|
|
|
41
46
|
def __str__(self):
|
|
42
47
|
return self.new_version_change_hint
|
|
48
|
+
|
|
49
|
+
|
|
@@ -109,8 +109,8 @@ class FunctionResultStatusPersistanceConfig(BaseJsonAbleModel):
|
|
|
109
109
|
flogger.warning(f'你设置的过期时间为 {value} ,设置的时间过长。 ')
|
|
110
110
|
return value
|
|
111
111
|
|
|
112
|
-
@root_validator(skip_on_failure=True
|
|
113
|
-
def
|
|
112
|
+
@root_validator(skip_on_failure=True)
|
|
113
|
+
def check_values(cls, values: dict):
|
|
114
114
|
if not values['is_save_status'] and values['is_save_result']:
|
|
115
115
|
raise ValueError(f'你设置的是不保存函数运行状态但保存函数运行结果。不允许你这么设置')
|
|
116
116
|
return values
|
|
@@ -159,10 +159,18 @@ class BoosterParams(BaseJsonAbleModel):
|
|
|
159
159
|
consumin_function_decorator: typing.Optional[typing.Callable] = None # 函数的装饰器。因为此框架做参数自动转指点,需要获取精准的入参名称,不支持在消费函数上叠加 @ *args **kwargs的装饰器,如果想用装饰器可以这里指定。
|
|
160
160
|
function_timeout: typing.Union[int, float,None] = None # 超时秒数,函数运行超过这个时间,则自动杀死函数。为0是不限制。 谨慎使用,非必要别去设置超时时间,设置后性能会降低(因为需要把用户函数包装到另一个线单独的程中去运行),而且突然强制超时杀死运行中函数,可能会造成死锁.(例如用户函数在获得线程锁后突然杀死函数,别的线程再也无法获得锁了)
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
"""
|
|
163
|
+
log_level:
|
|
164
|
+
logger_name 对应的 日志级别
|
|
165
|
+
消费者和发布者的日志级别,建议设置DEBUG级别,不然无法知道正在运行什么消息.
|
|
166
|
+
这个是funboost每个队列的单独命名空间的日志级别,丝毫不会影响改变用户其他日志以及root命名空间的日志级别,所以DEBUG级别就好,
|
|
167
|
+
用户不要压根不懂什么是python logger 的name,还去手痒调高级别.
|
|
168
|
+
不懂python日志命名空间的小白去看nb_log文档,或者直接问 ai大模型 python logger name的作用是什么.
|
|
169
|
+
"""
|
|
170
|
+
log_level: int = logging.DEBUG # 不需要改这个级别,请看上面原因
|
|
163
171
|
logger_prefix: str = '' # 日志名字前缀,可以设置前缀
|
|
164
172
|
create_logger_file: bool = True # 发布者和消费者是否创建文件文件日志,为False则只打印控制台不写文件.
|
|
165
|
-
logger_name: str = '' # 队列消费者发布者的日志命名空间.
|
|
173
|
+
logger_name: typing.Union[str, None] = '' # 队列消费者发布者的日志命名空间.
|
|
166
174
|
log_filename: typing.Union[str, None] = None # 消费者发布者的文件日志名字.如果为None,则自动使用 funboost.队列 名字作为文件日志名字. 日志文件夹是在nb_log_config.py的 LOG_PATH中决定的.
|
|
167
175
|
is_show_message_get_from_broker: bool = False # 运行时候,是否记录从消息队列获取出来的消息内容
|
|
168
176
|
is_print_detail_exception: bool = True # 消费函数出错时候,是否打印详细的报错堆栈,为False则只打印简略的报错信息不包含堆栈.
|
|
@@ -221,7 +229,7 @@ class BoosterParams(BaseJsonAbleModel):
|
|
|
221
229
|
|
|
222
230
|
|
|
223
231
|
|
|
224
|
-
@root_validator(skip_on_failure=True)
|
|
232
|
+
@root_validator(skip_on_failure=True, )
|
|
225
233
|
def check_values(cls, values: dict):
|
|
226
234
|
|
|
227
235
|
|
|
@@ -307,7 +315,7 @@ class PriorityConsumingControlConfig(BaseJsonAbleModel):
|
|
|
307
315
|
eta: typing.Union[datetime.datetime, str,None] = None # 时间对象, 或 %Y-%m-%d %H:%M:%S 字符串。
|
|
308
316
|
misfire_grace_time: typing.Union[int, None] = None
|
|
309
317
|
|
|
310
|
-
other_extra_params: dict = None # 其他参数, 例如消息优先级 , priority_control_config=PriorityConsumingControlConfig(other_extra_params={'priroty': priorityxx}),
|
|
318
|
+
other_extra_params: typing.Optional[dict] = None # 其他参数, 例如消息优先级 , priority_control_config=PriorityConsumingControlConfig(other_extra_params={'priroty': priorityxx}),
|
|
311
319
|
|
|
312
320
|
"""filter_str:
|
|
313
321
|
用户指定过滤字符串, 例如函数入参是 def fun(userid,username,sex,user_description),
|
|
@@ -318,6 +326,7 @@ class PriorityConsumingControlConfig(BaseJsonAbleModel):
|
|
|
318
326
|
"""
|
|
319
327
|
filter_str :typing.Optional[str] = None
|
|
320
328
|
|
|
329
|
+
can_not_json_serializable_keys: typing.List[str] = None # 不能json序列化的入参名字,反序列化时候需要使用pickle来反序列化这些字段(这个是自动生成的,用户不需要手动指定此入参。)
|
|
321
330
|
@root_validator(skip_on_failure=True)
|
|
322
331
|
def cehck_values(cls, values: dict):
|
|
323
332
|
if values['countdown'] and values['eta']:
|
|
@@ -356,4 +365,4 @@ if __name__ == '__main__':
|
|
|
356
365
|
# print(PriorityConsumingControlConfig().get_str_dict())
|
|
357
366
|
|
|
358
367
|
print(BoosterParams(queue_name='3213', specify_concurrent_pool=FlexibleThreadPool(100)).json_pre())
|
|
359
|
-
print(PublisherParams.schema_json())
|
|
368
|
+
# print(PublisherParams.schema_json()) # 注释掉,因为 PublisherParams 包含 Callable 类型字段,无法生成 JSON Schema
|
|
@@ -51,6 +51,9 @@ class FunctionResultStatus():
|
|
|
51
51
|
self.result = None
|
|
52
52
|
self.run_times = 0
|
|
53
53
|
self.exception = None
|
|
54
|
+
self.exception_type = None
|
|
55
|
+
self.exception_msg = None
|
|
56
|
+
self.rpc_chain_error_msg_dict:dict = None
|
|
54
57
|
self.time_start = time.time()
|
|
55
58
|
self.time_cost = None
|
|
56
59
|
self.time_end = None
|
|
@@ -62,6 +65,15 @@ class FunctionResultStatus():
|
|
|
62
65
|
self._has_kill_task = False
|
|
63
66
|
self.rpc_result_expire_seconds = None
|
|
64
67
|
|
|
68
|
+
@classmethod
|
|
69
|
+
def parse_status_and_result_to_obj(cls,status_dict:dict):
|
|
70
|
+
obj = cls(status_dict['queue_name'],status_dict['function'],status_dict['msg_dict'])
|
|
71
|
+
for k,v in status_dict.items():
|
|
72
|
+
# if k.startswith('_'):
|
|
73
|
+
# continue
|
|
74
|
+
setattr(obj,k,v)
|
|
75
|
+
return obj
|
|
76
|
+
|
|
65
77
|
def get_status_dict(self, without_datetime_obj=False):
|
|
66
78
|
self.time_end = time.time()
|
|
67
79
|
if self.run_status == RunStatus.running:
|
|
@@ -102,6 +114,9 @@ class FunctionResultStatus():
|
|
|
102
114
|
def __str__(self):
|
|
103
115
|
return f'''{self.__class__} {Serialization.to_json_str(self.get_status_dict())}'''
|
|
104
116
|
|
|
117
|
+
def to_pretty_json_str(self):
|
|
118
|
+
return json.dumps(self.get_status_dict(),indent=4,ensure_ascii=False)
|
|
119
|
+
|
|
105
120
|
|
|
106
121
|
class ResultPersistenceHelper(MongoMixin, FunboostFileLoggerMixin):
|
|
107
122
|
TASK_STATUS_DB = 'task_status'
|