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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.3
2
+ Name: async-conveyor
3
+ Version: 0.0.1
4
+ Requires-Dist: anyio>=4.15.1
5
+ Requires-Python: >=3.11
@@ -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")