async-conveyor 0.0.1__tar.gz
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.
- async_conveyor-0.0.1/PKG-INFO +5 -0
- async_conveyor-0.0.1/pyproject.toml +43 -0
- async_conveyor-0.0.1/pyproject.toml.orig +34 -0
- async_conveyor-0.0.1/src/async_conveyor/__init__.py +20 -0
- async_conveyor-0.0.1/src/async_conveyor/conveyor.py +118 -0
- async_conveyor-0.0.1/src/async_conveyor/stage.py +108 -0
- async_conveyor-0.0.1/src/async_conveyor/telemetry.py +105 -0
- async_conveyor-0.0.1/src/async_conveyor/topology.py +119 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "async-conveyor"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = ["anyio>=4.15.1"]
|
|
6
|
+
|
|
7
|
+
[build-system]
|
|
8
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
9
|
+
build-backend = "uv_build"
|
|
10
|
+
|
|
11
|
+
[dependency-groups]
|
|
12
|
+
dev = [
|
|
13
|
+
"pytest>=8.0",
|
|
14
|
+
"ruff>=0.6",
|
|
15
|
+
"mypy>=1.11",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.pytest.ini_options]
|
|
19
|
+
testpaths = ["tests"]
|
|
20
|
+
addopts = "-q"
|
|
21
|
+
|
|
22
|
+
[tool.ruff]
|
|
23
|
+
line-length = 120
|
|
24
|
+
target-version = "py311"
|
|
25
|
+
|
|
26
|
+
[tool.ruff.lint]
|
|
27
|
+
select = [
|
|
28
|
+
"E",
|
|
29
|
+
"F",
|
|
30
|
+
"W",
|
|
31
|
+
"I",
|
|
32
|
+
"UP",
|
|
33
|
+
"B",
|
|
34
|
+
"SIM",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
python_version = "3.11"
|
|
39
|
+
strict = true
|
|
40
|
+
files = [
|
|
41
|
+
"src",
|
|
42
|
+
"tests",
|
|
43
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "async-conveyor"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"anyio>=4.15.1",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
11
|
+
build-backend = "uv_build"
|
|
12
|
+
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = [
|
|
15
|
+
"pytest>=8.0",
|
|
16
|
+
"ruff>=0.6",
|
|
17
|
+
"mypy>=1.11",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
testpaths = ["tests"]
|
|
22
|
+
addopts = "-q"
|
|
23
|
+
|
|
24
|
+
[tool.ruff]
|
|
25
|
+
line-length = 120
|
|
26
|
+
target-version = "py311"
|
|
27
|
+
|
|
28
|
+
[tool.ruff.lint]
|
|
29
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
|
30
|
+
|
|
31
|
+
[tool.mypy]
|
|
32
|
+
python_version = "3.11"
|
|
33
|
+
strict = true
|
|
34
|
+
files = ["src", "tests"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Run a DAG of pipeline stages concurrently on anyio, with bounded queues between them."""
|
|
2
|
+
|
|
3
|
+
from .conveyor import BaseAsyncConveyor
|
|
4
|
+
from .stage import AnyStage, BaseAsyncPipelineStage, BasePipelineStage, BaseSyncPipelineStage
|
|
5
|
+
from .telemetry import ItemStats, LoggingTelemetry, StageSummary, SummaryTelemetry, Telemetry
|
|
6
|
+
from .topology import TopologyError
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AnyStage",
|
|
10
|
+
"BaseAsyncConveyor",
|
|
11
|
+
"BaseAsyncPipelineStage",
|
|
12
|
+
"BasePipelineStage",
|
|
13
|
+
"BaseSyncPipelineStage",
|
|
14
|
+
"ItemStats",
|
|
15
|
+
"LoggingTelemetry",
|
|
16
|
+
"StageSummary",
|
|
17
|
+
"SummaryTelemetry",
|
|
18
|
+
"Telemetry",
|
|
19
|
+
"TopologyError",
|
|
20
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
|
|
9
|
+
from .stage import AnyStage, BasePipelineStage
|
|
10
|
+
from .telemetry import ItemStats, LoggingTelemetry, Telemetry
|
|
11
|
+
from .topology import TopologyMixin
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
Stage = BasePipelineStage[Any, Any, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseAsyncConveyor(TopologyMixin, abc.ABC):
|
|
18
|
+
def __init__(self, topology: dict[AnyStage, list[AnyStage]], telemetry: Telemetry | None = None):
|
|
19
|
+
"""Base class for the conveyor that can run multiple stages concurrently.
|
|
20
|
+
Telemetry - metrics, tracing and error hooks; defaults to LoggingTelemetry.
|
|
21
|
+
Topology - routing for stages. Supports broadcast fan-out, doesn't support fan-in.
|
|
22
|
+
Exactly one root stage (the one that is not listed as anyone's output) is required. It is called
|
|
23
|
+
exactly once with item=None; the end of its generator is the end of the stream.
|
|
24
|
+
Keys and values are stage classes. Example:
|
|
25
|
+
{
|
|
26
|
+
Producer: [Embed, Metadata], # broadcast fan-out
|
|
27
|
+
Embed: [Writer], # direct
|
|
28
|
+
}
|
|
29
|
+
Unsupported:
|
|
30
|
+
{
|
|
31
|
+
Producer: [Embed, Metadata],
|
|
32
|
+
Qwe: [Metadata], # fan-in
|
|
33
|
+
Metadata: [Producer], # cycle
|
|
34
|
+
}
|
|
35
|
+
"""
|
|
36
|
+
self.topology = topology
|
|
37
|
+
self.telemetry: Telemetry = telemetry if telemetry is not None else LoggingTelemetry()
|
|
38
|
+
self.validate_topology()
|
|
39
|
+
|
|
40
|
+
async def run(self) -> None:
|
|
41
|
+
"""Run until the root's stream is exhausted and every stage has drained. See the class docstring."""
|
|
42
|
+
stages = self._wire()
|
|
43
|
+
try:
|
|
44
|
+
async with AsyncExitStack() as stack:
|
|
45
|
+
contexts = {s: await stack.enter_async_context(_shielded(s.context())) for s in stages}
|
|
46
|
+
async with anyio.create_task_group() as tg:
|
|
47
|
+
for s in stages:
|
|
48
|
+
tg.start_soon(self._run_stage, s, contexts[s]) # noqa
|
|
49
|
+
except BaseExceptionGroup as group: # each stage has its own task group, so groups nest; unwrap to the cause
|
|
50
|
+
exc: BaseException = group
|
|
51
|
+
while isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1:
|
|
52
|
+
exc = exc.exceptions[0]
|
|
53
|
+
raise exc from None
|
|
54
|
+
|
|
55
|
+
def _wire(self) -> list[Stage]:
|
|
56
|
+
instances = {cls: cls() for cls in self.stages}
|
|
57
|
+
for parent, children in self.topology.items():
|
|
58
|
+
for child in children:
|
|
59
|
+
send, receive = anyio.create_memory_object_stream[Any](max_buffer_size=parent.output_queue_size)
|
|
60
|
+
instances[parent].outputs[child.name] = send
|
|
61
|
+
instances[child].inbox = receive
|
|
62
|
+
return list(instances.values())
|
|
63
|
+
|
|
64
|
+
async def _run_stage(self, stage: Stage, context: Any) -> None:
|
|
65
|
+
async with AsyncExitStack() as stack:
|
|
66
|
+
for out in stage.outputs.values():
|
|
67
|
+
await stack.enter_async_context(out) # closing the queues tells the children nothing more is coming
|
|
68
|
+
if stage.inbox is None:
|
|
69
|
+
await self._process(stage, 0, None, context)
|
|
70
|
+
return
|
|
71
|
+
async with stage.inbox, anyio.create_task_group() as tg:
|
|
72
|
+
for worker in range(stage.workers):
|
|
73
|
+
tg.start_soon(self._worker, stage, worker, context) # noqa
|
|
74
|
+
|
|
75
|
+
async def _worker(self, stage: Stage, worker: int, context: Any) -> None:
|
|
76
|
+
assert stage.inbox is not None
|
|
77
|
+
async for item in stage.inbox:
|
|
78
|
+
await self._process(stage, worker, item, context)
|
|
79
|
+
|
|
80
|
+
async def _process(self, stage: Stage, worker: int, item: Any, context: Any) -> None:
|
|
81
|
+
started = time.perf_counter()
|
|
82
|
+
stage.active_workers += 1
|
|
83
|
+
try:
|
|
84
|
+
async with self.telemetry.item_span(stage.name, worker):
|
|
85
|
+
emitted, blocked = await stage.run_one(item, context)
|
|
86
|
+
stats = ItemStats(
|
|
87
|
+
stage_name=stage.name,
|
|
88
|
+
worker=worker,
|
|
89
|
+
duration=time.perf_counter() - started,
|
|
90
|
+
emitted=emitted,
|
|
91
|
+
blocked=blocked,
|
|
92
|
+
in_queue_size=stage.in_queue_size,
|
|
93
|
+
out_queue_sizes=stage.out_queue_sizes,
|
|
94
|
+
active_workers=stage.active_workers,
|
|
95
|
+
)
|
|
96
|
+
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
|
97
|
+
return # a consumer went away: the conveyor is shutting down and the cause is reported elsewhere
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
await self.telemetry.on_error(stage.name, worker, exc, item)
|
|
100
|
+
raise
|
|
101
|
+
finally:
|
|
102
|
+
stage.active_workers -= 1
|
|
103
|
+
await self.telemetry.on_item_done(stats)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@asynccontextmanager
|
|
107
|
+
async def _shielded(cm: AbstractAsyncContextManager[T]) -> AsyncIterator[T]:
|
|
108
|
+
"""`async with cm`, but the exit survives cancellation, so resources do get closed."""
|
|
109
|
+
value = await cm.__aenter__()
|
|
110
|
+
try:
|
|
111
|
+
yield value
|
|
112
|
+
except BaseException as exc:
|
|
113
|
+
with anyio.CancelScope(shield=True):
|
|
114
|
+
if not await cm.__aexit__(type(exc), exc, exc.__traceback__):
|
|
115
|
+
raise
|
|
116
|
+
else:
|
|
117
|
+
with anyio.CancelScope(shield=True):
|
|
118
|
+
await cm.__aexit__(None, None, None)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import AsyncGenerator, Generator
|
|
4
|
+
from contextlib import AbstractAsyncContextManager, nullcontext
|
|
5
|
+
from typing import Any, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
9
|
+
|
|
10
|
+
InT = TypeVar("InT")
|
|
11
|
+
OutT = TypeVar("OutT")
|
|
12
|
+
ContextT = TypeVar("ContextT")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BasePipelineStage(abc.ABC, Generic[InT, OutT, ContextT]):
|
|
16
|
+
"""One stage of the conveyor. Stages are used as classes, not instances: all config is class-level.
|
|
17
|
+
Retries and timeouts are the responsibility of `process` itself; an exception escaping `process` stops the conveyor.
|
|
18
|
+
Items must be treated as immutable: on broadcast fan-out the same object is handed to every consumer.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name: str = NotImplemented # unique name for this stage
|
|
22
|
+
workers: int = NotImplemented # items processed concurrently (coroutines for async, threads for sync). 1 for root
|
|
23
|
+
output_queue_size: int = NotImplemented # OOM protection - block producer when an output queue holds this many
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
"""Runtime state. The conveyor instantiates every stage class once per run; users never do."""
|
|
27
|
+
self.inbox: MemoryObjectReceiveStream[InT] | None = None # None for the root
|
|
28
|
+
self.outputs: dict[str, MemoryObjectSendStream[OutT]] = {} # child stage name -> queue into it
|
|
29
|
+
self.active_workers = 0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def in_queue_size(self) -> int:
|
|
33
|
+
return self.inbox.statistics().current_buffer_used if self.inbox is not None else 0
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def out_queue_sizes(self) -> dict[str, int]:
|
|
37
|
+
return {name: out.statistics().current_buffer_used for name, out in self.outputs.items()}
|
|
38
|
+
|
|
39
|
+
async def send(self, out: OutT) -> float:
|
|
40
|
+
"""Broadcast one item to every child. Returns seconds spent waiting for room in their queues."""
|
|
41
|
+
if not self.outputs:
|
|
42
|
+
return 0.0
|
|
43
|
+
started = time.perf_counter()
|
|
44
|
+
for queue in self.outputs.values():
|
|
45
|
+
await queue.send(out)
|
|
46
|
+
return time.perf_counter() - started
|
|
47
|
+
|
|
48
|
+
@abc.abstractmethod
|
|
49
|
+
async def run_one(self, item: InT, context: ContextT) -> tuple[int, float]:
|
|
50
|
+
"""Run process() on one item and send everything it yields downstream.
|
|
51
|
+
Returns (items emitted, seconds blocked sending)."""
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def context(cls) -> AbstractAsyncContextManager[ContextT]:
|
|
55
|
+
"""Resources shared by every process() call of this stage: HTTP clients, DB pools, models.
|
|
56
|
+
Entered once on the event loop before the stage's workers start and exited after they finish,
|
|
57
|
+
also on error or cancellation. Default: no resources, process() gets None (declare ContextT as None).
|
|
58
|
+
Override with `@asynccontextmanager`, or return the client itself when it is an async context manager:
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
@asynccontextmanager
|
|
62
|
+
async def context(cls) -> AsyncIterator[httpx.AsyncClient]:
|
|
63
|
+
async with httpx.AsyncClient() as client:
|
|
64
|
+
yield client
|
|
65
|
+
|
|
66
|
+
Plain state that needs no closing is `return nullcontext(value)`.
|
|
67
|
+
The value must be safe for `workers` concurrent process() calls. For a sync stage it is used
|
|
68
|
+
from threads, so it must be thread-safe and must not be an asyncio-only object
|
|
69
|
+
(use httpx.Client, not httpx.AsyncClient).
|
|
70
|
+
"""
|
|
71
|
+
return nullcontext(None) # type: ignore[arg-type]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BaseSyncPipelineStage(BasePipelineStage[InT, OutT, ContextT]):
|
|
75
|
+
@classmethod
|
|
76
|
+
@abc.abstractmethod
|
|
77
|
+
def process(cls, item: InT, context: ContextT) -> Generator[OutT, None, None]:
|
|
78
|
+
"""Process one item. Runs in a thread, should be thread-safe.
|
|
79
|
+
The generator is lazy: it must be consumed entirely inside the thread."""
|
|
80
|
+
|
|
81
|
+
async def run_one(self, item: InT, context: ContextT) -> tuple[int, float]:
|
|
82
|
+
def consume() -> tuple[int, float]: # the whole generator runs in this one thread
|
|
83
|
+
emitted, blocked = 0, 0.0
|
|
84
|
+
for out in self.process(item, context):
|
|
85
|
+
emitted += 1
|
|
86
|
+
blocked += anyio.from_thread.run(self.send, out)
|
|
87
|
+
return emitted, blocked
|
|
88
|
+
|
|
89
|
+
return await anyio.to_thread.run_sync(consume)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BaseAsyncPipelineStage(BasePipelineStage[InT, OutT, ContextT]):
|
|
93
|
+
@classmethod
|
|
94
|
+
@abc.abstractmethod
|
|
95
|
+
def process(cls, item: InT, context: ContextT) -> AsyncGenerator[OutT, None]:
|
|
96
|
+
"""Process one item. Runs on the event loop.
|
|
97
|
+
Implement as an async generator function (`async def` with `yield`), not as a coroutine returning a generator.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
async def run_one(self, item: InT, context: ContextT) -> tuple[int, float]:
|
|
101
|
+
emitted, blocked = 0, 0.0
|
|
102
|
+
async for out in self.process(item, context):
|
|
103
|
+
emitted += 1
|
|
104
|
+
blocked += await self.send(out)
|
|
105
|
+
return emitted, blocked
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
AnyStage = type[BasePipelineStage[Any, Any, Any]]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ItemStats:
|
|
13
|
+
"""Outcome of processing one item by one worker, plus a snapshot of the stage's queues at that moment."""
|
|
14
|
+
|
|
15
|
+
stage_name: str
|
|
16
|
+
worker: int
|
|
17
|
+
duration: float # seconds spent in process(), including time blocked on output
|
|
18
|
+
emitted: int # items yielded by process()
|
|
19
|
+
blocked: float # seconds spent waiting for output queues (backpressure)
|
|
20
|
+
in_queue_size: int # items waiting for this stage
|
|
21
|
+
out_queue_sizes: dict[str, int] # child stage name -> items waiting in the queue into it
|
|
22
|
+
active_workers: int # workers of this stage busy right now, this one included
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Telemetry(Protocol):
|
|
26
|
+
"""Extension point for metrics, tracing and error reporting. Implement and pass to the conveyor."""
|
|
27
|
+
|
|
28
|
+
def item_span(self, stage_name: str, worker: int) -> AbstractAsyncContextManager[None]:
|
|
29
|
+
"""Wraps one process() call. Open a span or transaction here."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
async def on_item_done(self, stats: ItemStats) -> None: ...
|
|
33
|
+
|
|
34
|
+
async def on_error(self, stage_name: str, worker: int, exc: BaseException, item: object) -> None:
|
|
35
|
+
"""Called before the exception propagates and stops the conveyor."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LoggingTelemetry:
|
|
40
|
+
"""Default telemetry: writes everything to the `async_conveyor.telemetry` logger with structured `extra` fields."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, log: logging.Logger = logger) -> None:
|
|
43
|
+
self.log = log
|
|
44
|
+
|
|
45
|
+
@asynccontextmanager
|
|
46
|
+
async def item_span(self, stage_name: str, worker: int) -> AsyncIterator[None]:
|
|
47
|
+
yield
|
|
48
|
+
|
|
49
|
+
async def on_item_done(self, stats: ItemStats) -> None:
|
|
50
|
+
self.log.debug(
|
|
51
|
+
"item done: stage=%s worker=%d duration=%.3fs emitted=%d blocked=%.3fs in=%d out=%s workers=%d",
|
|
52
|
+
stats.stage_name,
|
|
53
|
+
stats.worker,
|
|
54
|
+
stats.duration,
|
|
55
|
+
stats.emitted,
|
|
56
|
+
stats.blocked,
|
|
57
|
+
stats.in_queue_size,
|
|
58
|
+
stats.out_queue_sizes,
|
|
59
|
+
stats.active_workers,
|
|
60
|
+
extra=asdict(stats),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def on_error(self, stage_name: str, worker: int, exc: BaseException, item: object) -> None:
|
|
64
|
+
self.log.error(
|
|
65
|
+
"stage=%s worker=%d failed on item %r",
|
|
66
|
+
stage_name,
|
|
67
|
+
worker,
|
|
68
|
+
item,
|
|
69
|
+
exc_info=exc,
|
|
70
|
+
extra={"stage_name": stage_name, "worker": worker},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class StageSummary:
|
|
76
|
+
"""Per-stage totals over a run."""
|
|
77
|
+
|
|
78
|
+
items: int = 0 # process() calls completed
|
|
79
|
+
emitted: int = 0 # items yielded downstream
|
|
80
|
+
max_active_workers: int = 0 # peak concurrency reached
|
|
81
|
+
blocked_seconds: float = 0.0 # total time spent waiting on full output queues
|
|
82
|
+
max_in_queue: int = 0 # peak backlog waiting for this stage
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SummaryTelemetry:
|
|
86
|
+
"""Aggregates per-stage counters in memory. Read `.stages` and `.errors` after run(): scripts, notebooks, tests."""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self.stages: dict[str, StageSummary] = defaultdict(StageSummary)
|
|
90
|
+
self.errors: list[tuple[str, int, BaseException, object]] = [] # (stage_name, worker, exc, item)
|
|
91
|
+
|
|
92
|
+
@asynccontextmanager
|
|
93
|
+
async def item_span(self, stage_name: str, worker: int) -> AsyncIterator[None]:
|
|
94
|
+
yield
|
|
95
|
+
|
|
96
|
+
async def on_item_done(self, stats: ItemStats) -> None:
|
|
97
|
+
s = self.stages[stats.stage_name]
|
|
98
|
+
s.items += 1
|
|
99
|
+
s.emitted += stats.emitted
|
|
100
|
+
s.blocked_seconds += stats.blocked
|
|
101
|
+
s.max_active_workers = max(s.max_active_workers, stats.active_workers)
|
|
102
|
+
s.max_in_queue = max(s.max_in_queue, stats.in_queue_size)
|
|
103
|
+
|
|
104
|
+
async def on_error(self, stage_name: str, worker: int, exc: BaseException, item: object) -> None:
|
|
105
|
+
self.errors.append((stage_name, worker, exc, item))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from .stage import AnyStage, BaseAsyncPipelineStage, BasePipelineStage, BaseSyncPipelineStage
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TopologyError(ValueError):
|
|
7
|
+
"""Raised when the conveyor topology is invalid."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TopologyMixin:
|
|
11
|
+
"""Queries and validation over `self.topology`: a dict of stage class -> list of child stage classes."""
|
|
12
|
+
|
|
13
|
+
topology: dict[AnyStage, list[AnyStage]]
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def stages(self) -> list[AnyStage]:
|
|
17
|
+
"""All stages in the topology, in first-seen order: keys first, then their outputs."""
|
|
18
|
+
seen: dict[AnyStage, None] = {}
|
|
19
|
+
for parent, children in self.topology.items():
|
|
20
|
+
seen.setdefault(parent)
|
|
21
|
+
for child in children:
|
|
22
|
+
seen.setdefault(child)
|
|
23
|
+
return list(seen)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def root(self) -> AnyStage:
|
|
27
|
+
"""The single stage that is nobody's output. It is the source of the conveyor."""
|
|
28
|
+
(root,) = self._roots()
|
|
29
|
+
return root
|
|
30
|
+
|
|
31
|
+
def _roots(self) -> list[AnyStage]:
|
|
32
|
+
children = {child for outputs in self.topology.values() for child in outputs}
|
|
33
|
+
return [stage for stage in self.stages if stage not in children]
|
|
34
|
+
|
|
35
|
+
def validate_topology(self) -> None:
|
|
36
|
+
"""Raise TopologyError if the topology cannot be run."""
|
|
37
|
+
stages = self.stages
|
|
38
|
+
if not stages:
|
|
39
|
+
raise TopologyError("topology is empty")
|
|
40
|
+
for stage in stages:
|
|
41
|
+
self._validate_stage(stage)
|
|
42
|
+
self._validate_unique_names(stages)
|
|
43
|
+
self._validate_no_fan_in()
|
|
44
|
+
self._validate_no_cycles(stages)
|
|
45
|
+
self._validate_root()
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _validate_stage(stage: AnyStage) -> None:
|
|
49
|
+
if not isinstance(stage, type) or not issubclass(stage, BasePipelineStage):
|
|
50
|
+
raise TopologyError(f"{stage!r} is not a stage class")
|
|
51
|
+
if not issubclass(stage, BaseSyncPipelineStage | BaseAsyncPipelineStage):
|
|
52
|
+
raise TopologyError(f"{stage.__name__} must subclass BaseSyncPipelineStage or BaseAsyncPipelineStage")
|
|
53
|
+
if inspect.isabstract(stage):
|
|
54
|
+
missing = ", ".join(sorted(stage.__abstractmethods__))
|
|
55
|
+
raise TopologyError(f"{stage.__name__} is abstract, missing: {missing}")
|
|
56
|
+
if issubclass(stage, BaseAsyncPipelineStage) and not inspect.isasyncgenfunction(stage.process):
|
|
57
|
+
raise TopologyError(
|
|
58
|
+
f"{stage.__name__}.process must be an async generator function (`async def` with `yield`)"
|
|
59
|
+
)
|
|
60
|
+
if issubclass(stage, BaseSyncPipelineStage) and not inspect.isgeneratorfunction(stage.process):
|
|
61
|
+
raise TopologyError(f"{stage.__name__}.process must be a generator function (`def` with `yield`)")
|
|
62
|
+
for attr in ("name", "workers", "output_queue_size"):
|
|
63
|
+
if getattr(stage, attr) is NotImplemented:
|
|
64
|
+
raise TopologyError(f"{stage.__name__}.{attr} is not set")
|
|
65
|
+
if stage.workers < 1:
|
|
66
|
+
raise TopologyError(f"{stage.__name__}.workers must be >= 1")
|
|
67
|
+
if stage.output_queue_size < 0:
|
|
68
|
+
raise TopologyError(f"{stage.__name__}.output_queue_size must be >= 0")
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _validate_unique_names(stages: list[AnyStage]) -> None:
|
|
72
|
+
by_name: dict[str, AnyStage] = {}
|
|
73
|
+
for stage in stages:
|
|
74
|
+
other = by_name.setdefault(stage.name, stage)
|
|
75
|
+
if other is not stage:
|
|
76
|
+
raise TopologyError(f"duplicate stage name {stage.name!r}: {other.__name__} and {stage.__name__}")
|
|
77
|
+
|
|
78
|
+
def _validate_no_fan_in(self) -> None:
|
|
79
|
+
parents: dict[AnyStage, AnyStage] = {}
|
|
80
|
+
for parent, children in self.topology.items():
|
|
81
|
+
for child in children:
|
|
82
|
+
other = parents.setdefault(child, parent)
|
|
83
|
+
if other is not parent:
|
|
84
|
+
raise TopologyError(
|
|
85
|
+
f"fan-in is not supported: {child.__name__} is fed by both "
|
|
86
|
+
f"{other.__name__} and {parent.__name__}"
|
|
87
|
+
)
|
|
88
|
+
if children.count(child) > 1:
|
|
89
|
+
raise TopologyError(f"{child.__name__} is listed twice in outputs of {parent.__name__}")
|
|
90
|
+
|
|
91
|
+
def _validate_no_cycles(self, stages: list[AnyStage]) -> None:
|
|
92
|
+
done: set[AnyStage] = set()
|
|
93
|
+
path: list[AnyStage] = []
|
|
94
|
+
|
|
95
|
+
def visit(stage: AnyStage) -> None:
|
|
96
|
+
if stage in done:
|
|
97
|
+
return
|
|
98
|
+
if stage in path:
|
|
99
|
+
cycle = [*path[path.index(stage) :], stage]
|
|
100
|
+
raise TopologyError("cycle: " + " -> ".join(s.__name__ for s in cycle))
|
|
101
|
+
path.append(stage)
|
|
102
|
+
for child in self.topology.get(stage, []):
|
|
103
|
+
visit(child)
|
|
104
|
+
path.pop()
|
|
105
|
+
done.add(stage)
|
|
106
|
+
|
|
107
|
+
for stage in stages:
|
|
108
|
+
visit(stage)
|
|
109
|
+
|
|
110
|
+
def _validate_root(self) -> None:
|
|
111
|
+
roots = self._roots()
|
|
112
|
+
if not roots:
|
|
113
|
+
raise TopologyError("topology has no root stage")
|
|
114
|
+
if len(roots) > 1:
|
|
115
|
+
names = ", ".join(root.__name__ for root in roots)
|
|
116
|
+
raise TopologyError(f"topology must have exactly one root stage, found {len(roots)}: {names}")
|
|
117
|
+
(root,) = roots
|
|
118
|
+
if root.workers != 1:
|
|
119
|
+
raise TopologyError(f"root stage {root.__name__} must have workers == 1, it is called once")
|