py-messor 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 (50) hide show
  1. messor/__init__.py +7 -0
  2. messor/backends/__init__.py +1 -0
  3. messor/backends/queue/__init__.py +1 -0
  4. messor/backends/queue/broker.py +35 -0
  5. messor/backends/queue/build.py +20 -0
  6. messor/backends/queue/config.py +17 -0
  7. messor/backends/queue/consumer.py +47 -0
  8. messor/backends/queue/producer.py +28 -0
  9. messor/backends/queue/resource.py +23 -0
  10. messor/backends/rabbit_aio_pika/__init__.py +1 -0
  11. messor/backends/rabbit_aio_pika/broker.py +46 -0
  12. messor/backends/rabbit_aio_pika/build.py +26 -0
  13. messor/backends/rabbit_aio_pika/config.py +55 -0
  14. messor/backends/rabbit_aio_pika/consumer.py +77 -0
  15. messor/backends/rabbit_aio_pika/producer.py +68 -0
  16. messor/backends/rabbit_aio_pika/resource.py +111 -0
  17. messor/core/__init__.py +1 -0
  18. messor/core/_inflight.py +74 -0
  19. messor/core/broker.py +63 -0
  20. messor/core/config.py +35 -0
  21. messor/core/consumer.py +121 -0
  22. messor/core/producer.py +90 -0
  23. messor/core/resource.py +25 -0
  24. messor/errors.py +7 -0
  25. messor/factory/__init__.py +11 -0
  26. messor/factory/app.py +135 -0
  27. messor/factory/backend.py +14 -0
  28. messor/factory/deco.py +48 -0
  29. messor/factory/registry.py +52 -0
  30. messor/pool_executor/__init__.py +1 -0
  31. messor/pool_executor/base.py +31 -0
  32. messor/pool_executor/general.py +42 -0
  33. messor/serializer/__init__.py +1 -0
  34. messor/serializer/base.py +27 -0
  35. messor/serializer/orjson_ser.py +27 -0
  36. messor/serializer/raw.py +24 -0
  37. messor/task/__init__.py +1 -0
  38. messor/task/base.py +28 -0
  39. messor/task/thread.py +26 -0
  40. messor/utils/__init__.py +1 -0
  41. messor/utils/deco.py +36 -0
  42. messor/utils/hook.py +121 -0
  43. messor/utils/lazy.py +46 -0
  44. messor/utils/sem.py +49 -0
  45. messor/utils/timer.py +34 -0
  46. messor/utils/waiter.py +35 -0
  47. py_messor-0.2.4.dist-info/METADATA +311 -0
  48. py_messor-0.2.4.dist-info/RECORD +50 -0
  49. py_messor-0.2.4.dist-info/WHEEL +4 -0
  50. py_messor-0.2.4.dist-info/licenses/LICENSE +201 -0
messor/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # coding: utf-8
2
+
3
+ from messor.errors import PublishTimeoutError
4
+
5
+ __all__ = (
6
+ "PublishTimeoutError",
7
+ )
@@ -0,0 +1 @@
1
+ # coding: utf-8
@@ -0,0 +1 @@
1
+ # coding: utf-8
@@ -0,0 +1,35 @@
1
+ # coding: utf-8
2
+
3
+ import asyncio
4
+ import typing as t
5
+
6
+ from messor.serializer.base import Message
7
+ from messor.backends.queue.resource import Resource
8
+ from messor.core.broker import BaseBroker as _BaseBroker
9
+ from messor.backends.queue.producer import Producer
10
+ from messor.backends.queue.consumer import Consumer
11
+
12
+ if t.TYPE_CHECKING:
13
+ from messor.backends.queue.config import Config
14
+
15
+
16
+ class Broker(_BaseBroker["Config", Message]):
17
+ """内存队列通道。队列由这里创建,再交给 Producer 和 Consumer。"""
18
+
19
+ IS_MEMORY = True
20
+
21
+ def __init__(self, config: "Config"):
22
+ resource = Resource(
23
+ serializer_cls=config.serializer_cls,
24
+ maxsize=config.maxsize)
25
+ self._producer = Producer(
26
+ resource=resource,
27
+ publish_timeout=config.publish_timeout,
28
+ )
29
+ self._consumer = Consumer(
30
+ resource=resource,
31
+ coro=config.coro,
32
+ consume_timeout=config.consume_timeout,
33
+ pool_executor_cls=config.consumer_pool_executor_cls,
34
+ pool_maxsize=config.consumer_pool_maxsize,
35
+ )
@@ -0,0 +1,20 @@
1
+ # coding: utf-8
2
+
3
+ import typing as t
4
+
5
+ from messor.factory.backend import Backend
6
+ from messor.factory.registry import register_backend
7
+
8
+ if t.TYPE_CHECKING:
9
+ from messor.core.broker import BaseBroker
10
+
11
+
12
+ @register_backend(Backend.QUEUE)
13
+ def build_queue_broker(
14
+ coro: t.Callable[..., t.Coroutine],
15
+ **kwargs: t.Any) -> "BaseBroker[t.Any, t.Any]":
16
+
17
+ from messor.backends.queue.broker import Broker
18
+ from messor.backends.queue.config import Config
19
+
20
+ return Broker(Config(coro=coro, **kwargs))
@@ -0,0 +1,17 @@
1
+ # coding: utf-8
2
+
3
+ import typing as t
4
+ from pydantic.dataclasses import dataclass as pyd_dataclass
5
+
6
+ from messor.core.config import Config as _Config
7
+ from messor.core.config import ExtraIgnoreConfig
8
+ from messor.serializer.base import BaseSerializer
9
+ from messor.serializer.raw import Serializer
10
+
11
+
12
+ @pyd_dataclass(frozen=True, config=ExtraIgnoreConfig)
13
+ class Config(_Config):
14
+
15
+ ## shared resource
16
+ maxsize: int = 200
17
+ serializer_cls: type[BaseSerializer] = Serializer
@@ -0,0 +1,47 @@
1
+ # coding: utf-8
2
+
3
+ import asyncio
4
+
5
+ from messor.serializer.base import Message
6
+ from messor.backends.queue.resource import Resource
7
+ from messor.core._inflight import dec
8
+ from messor.core.consumer import BaseConsumer as _BaseConsumer
9
+
10
+
11
+ class Consumer(_BaseConsumer[Message, Resource]):
12
+
13
+ __slots__ = ()
14
+
15
+ def __init__(self, *args, **kwargs):
16
+
17
+ super().__init__(*args, **kwargs)
18
+ # coro 执行完成后 -1
19
+ self._on_coro_done.register(dec)
20
+
21
+ async def _consume_once(self) -> Message | None:
22
+
23
+ queue = self._resource.queue
24
+ try:
25
+ message: Message = await asyncio.wait_for(
26
+ queue.get(),
27
+ timeout=self._consume_timeout,
28
+ )
29
+ except asyncio.TimeoutError:
30
+ return None
31
+ else:
32
+ await self._dispatch(message)
33
+ queue.task_done()
34
+ return message
35
+
36
+ async def _drain(self) -> None:
37
+
38
+ queue = self._resource.queue
39
+ while not queue.empty():
40
+ message: Message = queue.get_nowait()
41
+ await self._dispatch(message)
42
+ queue.task_done()
43
+
44
+ async def _on_shutdown(self) -> None:
45
+
46
+ await self._resource.queue.join()
47
+ await self._pool_executor.obj.join()
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+
3
+ import asyncio
4
+
5
+ from messor.errors import PublishTimeoutError
6
+ from messor.serializer.base import Message
7
+ from messor.core._inflight import inc
8
+ from messor.core.producer import BaseProducer
9
+ from messor.backends.queue.resource import Resource
10
+
11
+
12
+ class Producer(BaseProducer[Message, Resource]):
13
+
14
+ def __init__(self, *args, **kwargs):
15
+
16
+ super().__init__(*args, **kwargs)
17
+ # push 成功后 +1
18
+ self._on_publish_success.register(inc)
19
+
20
+ async def _publish(self, msg: Message, *, timeout: float | None) -> None:
21
+
22
+ if timeout:
23
+ try:
24
+ await asyncio.wait_for(self._resource.queue.put(msg), timeout)
25
+ except asyncio.TimeoutError as exc:
26
+ raise PublishTimeoutError("publish timed out") from exc
27
+ return None
28
+ await self._resource.queue.put(msg)
@@ -0,0 +1,23 @@
1
+ # coding: utf-8
2
+
3
+ import asyncio
4
+
5
+ from messor.serializer.base import Message, BaseSerializer
6
+ from messor.core.resource import BaseResource as _Base
7
+
8
+
9
+ class Resource(_Base[Message]):
10
+
11
+ __slots__ = (
12
+ "_queue",
13
+ )
14
+
15
+ def __init__(self, serializer_cls: type[BaseSerializer], maxsize: int) -> None:
16
+
17
+ super().__init__(serializer_cls)
18
+ self._queue: asyncio.Queue[Message] = asyncio.Queue(maxsize=maxsize)
19
+
20
+ @property
21
+ def queue(self) -> asyncio.Queue[Message]:
22
+
23
+ return self._queue
@@ -0,0 +1 @@
1
+ # coding: utf-8
@@ -0,0 +1,46 @@
1
+ # coding: utf-8
2
+
3
+ import typing as t
4
+
5
+ from messor.core.broker import BaseBroker as _BaseBroker
6
+ from messor.backends.rabbit_aio_pika.consumer import Consumer
7
+ from messor.backends.rabbit_aio_pika.producer import Producer
8
+ from messor.backends.rabbit_aio_pika.resource import Resource
9
+
10
+ if t.TYPE_CHECKING:
11
+ from messor.backends.rabbit_aio_pika.config import Config
12
+
13
+
14
+ class Broker(_BaseBroker["Config", bytes]):
15
+ """RabbitMQ 通道。一条连接上分开发布和消费,再交给 Producer 和 Consumer。"""
16
+
17
+ def __init__(self, config: "Config"):
18
+
19
+ resource = Resource(
20
+ serializer_cls=config.serializer_cls,
21
+ url=config.url,
22
+ queue_name=config.queue_name,
23
+ enable_declare_queue=config.enable_declare_queue,
24
+ queue_durable=config.queue_durable,
25
+ queue_exclusive=config.queue_exclusive,
26
+ queue_passive=config.queue_passive,
27
+ queue_auto_delete=config.queue_auto_delete,
28
+ queue_arguments=config.queue_arguments,
29
+ )
30
+ self._producer = Producer(
31
+ resource=resource,
32
+ publish_timeout=config.publish_timeout,
33
+ exchange_name=config.exchange_name,
34
+ routing_key=config.routing_key,
35
+ delivery_mode=config.delivery_mode,
36
+ )
37
+ self._consumer = Consumer(
38
+ resource=resource,
39
+ prefetch_count=config.prefetch_count,
40
+ prefetch_size=config.prefetch_size,
41
+ global_qos=config.global_qos,
42
+ coro=config.coro,
43
+ consume_timeout=config.consume_timeout,
44
+ pool_executor_cls=config.consumer_pool_executor_cls,
45
+ pool_maxsize=config.consumer_pool_maxsize,
46
+ )
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+
3
+ import typing as t
4
+
5
+ from messor.factory.backend import Backend
6
+ from messor.factory.registry import register_backend
7
+
8
+ if t.TYPE_CHECKING:
9
+ from messor.core.broker import BaseBroker
10
+
11
+
12
+ @register_backend(Backend.RABBIT_AIO_PIKA)
13
+ def build_rabbit_aio_pika_broker(
14
+ coro: t.Callable[..., t.Coroutine],
15
+ **kwargs: t.Any) -> "BaseBroker[t.Any, t.Any]":
16
+
17
+ try:
18
+ from messor.backends.rabbit_aio_pika.broker import Broker
19
+ from messor.backends.rabbit_aio_pika.config import Config
20
+ except ImportError as exc:
21
+ raise ImportError(
22
+ "Backend.RABBIT_AIO_PIKA requires aio-pika. "
23
+ "Install it with the rabbit extra.",
24
+ ) from exc
25
+
26
+ return Broker(Config(coro=coro, **kwargs))
@@ -0,0 +1,55 @@
1
+ # coding: utf-8
2
+
3
+ from enum import unique, StrEnum, IntEnum
4
+
5
+ from pydantic.dataclasses import dataclass as pyd_dataclass
6
+
7
+ from messor.core.config import Config as _Config
8
+ from messor.core.config import ExtraIgnoreConfig
9
+
10
+
11
+ @unique
12
+ class ExchangeName(StrEnum):
13
+
14
+ DEFAULT = ""
15
+ DIRECT = "amq.direct"
16
+ TOPIC = "amq.topic"
17
+ FANOUT = "amq.fanout"
18
+ HEADERS = "amq.headers"
19
+
20
+
21
+ @unique
22
+ class DeliveryMode(IntEnum):
23
+
24
+ NOT_PERSISTENT = 1
25
+ PERSISTENT = 2
26
+
27
+
28
+ @pyd_dataclass(frozen=True, config=ExtraIgnoreConfig, kw_only=True)
29
+ class Config(_Config):
30
+
31
+ ## shared resource
32
+ # amqp://user:password@host/vhost?heartbeat=60&x=y
33
+ url: str
34
+ queue_name: str
35
+ enable_declare_queue: bool = False
36
+ # broker 重启后队列仍在;消息是否保留看 delivery_mode
37
+ queue_durable: bool = False
38
+ # 仅当前连接可使用,连接断开后队列删除
39
+ queue_exclusive: bool = False
40
+ # 不创建队列,只确认同名队列已存在,不存在则失败
41
+ queue_passive: bool = False
42
+ # 最后一个消费者取消或断开后删除队列
43
+ queue_auto_delete: bool = False
44
+ # 声明时的附加参数,例如 x-message-ttl、x-dead-letter-exchange
45
+ queue_arguments: dict | None = None
46
+
47
+ ## producer
48
+ exchange_name: ExchangeName | str = ExchangeName.DEFAULT
49
+ routing_key: str = ""
50
+ delivery_mode: DeliveryMode = DeliveryMode.PERSISTENT
51
+
52
+ ## consumer
53
+ prefetch_count: int = 0
54
+ prefetch_size: int = 0
55
+ global_qos: bool = False
@@ -0,0 +1,77 @@
1
+ # coding: utf-8
2
+
3
+ import asyncio
4
+ import typing as t
5
+ from functools import partial
6
+
7
+ from messor.core.consumer import BaseConsumer as _BaseConsumer
8
+ from messor.pool_executor.base import BasePoolExecutor
9
+ from messor.backends.rabbit_aio_pika.resource import Resource
10
+
11
+ if t.TYPE_CHECKING:
12
+ from aio_pika.abc import AbstractIncomingMessage, AbstractQueue
13
+
14
+
15
+ class Consumer(_BaseConsumer[bytes, Resource]):
16
+
17
+ __slots__ = (
18
+ "_prefetch_count",
19
+ "_prefetch_size",
20
+ "_global_qos",
21
+ # "_channel",
22
+ "_queue",
23
+ )
24
+
25
+ def __init__(
26
+ self,
27
+ resource: Resource,
28
+ prefetch_count: int,
29
+ prefetch_size: int,
30
+ global_qos: bool,
31
+ coro: t.Callable[..., t.Coroutine],
32
+ consume_timeout: float,
33
+ pool_executor_cls: type[BasePoolExecutor],
34
+ pool_maxsize: int):
35
+
36
+ super().__init__(
37
+ resource=resource,
38
+ coro=coro,
39
+ consume_timeout=consume_timeout,
40
+ pool_executor_cls=pool_executor_cls,
41
+ pool_maxsize=pool_maxsize,
42
+ )
43
+ self._prefetch_count = prefetch_count
44
+ self._prefetch_size = prefetch_size
45
+ self._global_qos = global_qos
46
+ # self._channel: "AbstractChannel | None" = None
47
+ self._queue: "AbstractQueue | None" = None
48
+
49
+ async def _consume_once(self) -> None:
50
+
51
+ queue = await self._ensure_queue()
52
+ async with queue.iterator(timeout=self._consume_timeout) as queue_iter:
53
+ message: "AbstractIncomingMessage"
54
+ try:
55
+ async for message in queue_iter:
56
+ body: bytes = message.body
57
+ await self._dispatch(body)
58
+ await message.ack()
59
+ except asyncio.TimeoutError:
60
+ pass
61
+
62
+ async def _ensure_queue(self) -> "AbstractQueue":
63
+
64
+ if self._queue is not None:
65
+ return self._queue
66
+
67
+ self._on_cleanup.register(self._resource.try_close_connection)
68
+ channel = await self._resource.get_channel()
69
+ self._on_cleanup.register(partial(self._resource.close_channel, channel))
70
+ await channel.set_qos(
71
+ prefetch_count=self._prefetch_count,
72
+ prefetch_size=self._prefetch_size,
73
+ global_=self._global_qos,
74
+ )
75
+ queue = await self._resource.declare_queue(channel)
76
+ self._queue = queue
77
+ return queue
@@ -0,0 +1,68 @@
1
+ # coding: utf-8
2
+
3
+ import asyncio
4
+ import typing as t
5
+ from functools import partial
6
+
7
+ import aio_pika
8
+
9
+ from messor.backends.rabbit_aio_pika.config import DeliveryMode, ExchangeName
10
+ from messor.errors import PublishTimeoutError
11
+ from messor.core.producer import BaseProducer
12
+ from messor.backends.rabbit_aio_pika.resource import Resource
13
+
14
+ if t.TYPE_CHECKING:
15
+ from aio_pika.abc import AbstractExchange
16
+
17
+
18
+ class Producer(BaseProducer[bytes, Resource]):
19
+
20
+ __slots__ = (
21
+ "_exchange_name",
22
+ "_exchange_type",
23
+ "_routing_key",
24
+ "_delivery_mode",
25
+ "_exchange",
26
+ )
27
+
28
+ def __init__(
29
+ self,
30
+ resource: Resource,
31
+ publish_timeout: float | None,
32
+ exchange_name: ExchangeName | str,
33
+ routing_key: str,
34
+ delivery_mode: DeliveryMode):
35
+ super().__init__(resource, publish_timeout)
36
+ self._exchange_name = exchange_name
37
+ self._exchange: "AbstractExchange | None" = None
38
+ self._routing_key = routing_key
39
+ self._delivery_mode = delivery_mode
40
+
41
+ async def _publish(self, msg: bytes, *, timeout: float | None) -> None:
42
+
43
+ routing_key = self._routing_key or self._resource.queue_name
44
+ message = aio_pika.Message(msg, delivery_mode=self._delivery_mode)
45
+
46
+ await self._ensure_exchange()
47
+ try:
48
+ await self._exchange.publish(message, routing_key=routing_key, timeout=timeout)
49
+ except TimeoutError as exc:
50
+ raise PublishTimeoutError("publish timed out") from exc
51
+
52
+ async def _ensure_exchange(self) -> "AbstractExchange":
53
+
54
+ if self._exchange is not None:
55
+ return self._exchange
56
+
57
+ async with self._lock:
58
+ if self._exchange is None:
59
+ self._on_cleanup.register(self._resource.try_close_connection)
60
+ channel = await self._resource.get_channel()
61
+ self._on_cleanup.register(partial(self._resource.close_channel, channel))
62
+ await self._resource.declare_queue(channel)
63
+ if self._exchange_name == ExchangeName.DEFAULT:
64
+ exchange = channel.default_exchange
65
+ else:
66
+ exchange = await channel.get_exchange(self._exchange_name)
67
+ self._exchange = exchange
68
+ return exchange
@@ -0,0 +1,111 @@
1
+ # coding: utf-8
2
+
3
+ import asyncio
4
+ import typing as t
5
+
6
+ import aio_pika
7
+
8
+ from messor.utils.deco import before
9
+ from messor.core.resource import BaseResource as _Base
10
+ from messor.serializer.base import Message, BaseSerializer
11
+
12
+ if t.TYPE_CHECKING:
13
+ from aio_pika.abc import AbstractConnection, AbstractChannel, AbstractQueue
14
+
15
+
16
+ class Resource(_Base[Message]):
17
+
18
+ __slots__ = (
19
+ "_queue_name",
20
+ "_url",
21
+ "_conn",
22
+ "_lock",
23
+ "_active_channel_num",
24
+ "_enable_declare_queue",
25
+ "_queue_durable",
26
+ "_queue_exclusive",
27
+ "_queue_passive",
28
+ "_queue_auto_delete",
29
+ "_queue_arguments",
30
+ )
31
+
32
+ def __init__(
33
+ self,
34
+ serializer_cls: type[BaseSerializer],
35
+ url: str,
36
+ queue_name: str,
37
+ enable_declare_queue: bool,
38
+ queue_durable: bool,
39
+ queue_exclusive: bool,
40
+ queue_passive: bool,
41
+ queue_auto_delete: bool,
42
+ queue_arguments: dict[str, t.Any] | None):
43
+
44
+ super().__init__(serializer_cls)
45
+ self._queue_name = queue_name
46
+ self._url = url
47
+ self._conn: "AbstractConnection | None" = None
48
+ self._lock = asyncio.Lock()
49
+ self._active_channel_num = 0
50
+ self._enable_declare_queue = enable_declare_queue
51
+ self._queue_durable = queue_durable
52
+ self._queue_exclusive = queue_exclusive
53
+ self._queue_passive = queue_passive
54
+ self._queue_auto_delete = queue_auto_delete
55
+ self._queue_arguments = queue_arguments
56
+
57
+ @property
58
+ def queue_name(self) -> str:
59
+
60
+ return self._queue_name
61
+
62
+ async def declare_queue(self, channel: "AbstractChannel") -> "AbstractQueue":
63
+
64
+ if self._enable_declare_queue:
65
+ return await channel.declare_queue(
66
+ self._queue_name,
67
+ durable=self._queue_durable,
68
+ exclusive=self._queue_exclusive,
69
+ passive=self._queue_passive,
70
+ auto_delete=self._queue_auto_delete,
71
+ arguments=self._queue_arguments,
72
+ )
73
+ return await channel.declare_queue(self._queue_name, passive=True)
74
+
75
+ async def _ensure_conn(self) -> None:
76
+
77
+ if self._conn is not None:
78
+ return None
79
+
80
+ async with self._lock:
81
+ if self._conn is None:
82
+ self._conn = await aio_pika.connect_robust(self._url)
83
+
84
+ @property
85
+ def _required_conn(self) -> "AbstractConnection":
86
+
87
+ if self._conn is None:
88
+ raise RuntimeError("RabbitMQ connection not established")
89
+ return self._conn
90
+
91
+ @before(_ensure_conn)
92
+ async def get_channel(self) -> "AbstractChannel":
93
+
94
+ async with self._lock:
95
+ channel = await self._required_conn.channel()
96
+ self._active_channel_num += 1
97
+ return channel
98
+
99
+ async def close_channel(self, channel: "AbstractChannel") -> None:
100
+
101
+ async with self._lock:
102
+ await channel.close()
103
+ self._active_channel_num -= 1
104
+
105
+ async def try_close_connection(self) -> None:
106
+
107
+ async with self._lock:
108
+ if self._active_channel_num > 0 or self._conn is None:
109
+ return None
110
+ await self._required_conn.close()
111
+ self._conn = None
@@ -0,0 +1 @@
1
+ # coding: utf-8
@@ -0,0 +1,74 @@
1
+ # coding: utf-8
2
+
3
+ import asyncio
4
+
5
+ __all__ = (
6
+ "inc",
7
+ "dec",
8
+ "wait_drained",
9
+ )
10
+
11
+
12
+ class _Inflight:
13
+ """进程级全局在途消息计数(内存队列专用)。
14
+
15
+ push 成功后 +1,coro 执行完成后 -1;
16
+ 计数归 0 表示无在途消息,可通过 wait_drained 等待该状态。
17
+ """
18
+
19
+ __slots__ = (
20
+ "_count",
21
+ "_event",
22
+ )
23
+
24
+ def __init__(self) -> None:
25
+
26
+ self._count = 0
27
+ self._event = asyncio.Event()
28
+ self._event.set()
29
+
30
+ def inc(self, n: int = 1) -> None:
31
+
32
+ self._count += n
33
+ # 有在途消息,阻塞 wait_drained
34
+ self._event.clear()
35
+
36
+ def dec(self, n: int = 1) -> None:
37
+
38
+ self._count = max(0, self._count - n)
39
+ if self._count == 0:
40
+ # 无在途消息,放行 wait_drained
41
+ self._event.set()
42
+
43
+ async def wait_drained(self) -> None:
44
+
45
+ while self._count > 0:
46
+ await self._event.wait()
47
+
48
+ def reset(self) -> None:
49
+ """清空计数(测试隔离用,不对外暴露)。"""
50
+
51
+ self._count = 0
52
+ self._event.set()
53
+
54
+
55
+ # 全局单例:内存队列 producer/consumer 共享,不对外暴露
56
+ _INFLIGHT = _Inflight()
57
+
58
+
59
+ def inc(n: int = 1) -> None:
60
+ """push 成功后 +n。"""
61
+
62
+ _INFLIGHT.inc(n)
63
+
64
+
65
+ def dec(n: int = 1) -> None:
66
+ """coro 执行完成后 -n(默认 -1,可传批量值)。"""
67
+
68
+ _INFLIGHT.dec(n)
69
+
70
+
71
+ async def wait_drained() -> None:
72
+ """等待在途消息全部处理完成。"""
73
+
74
+ await _INFLIGHT.wait_drained()