|
from typing import * |
|
from abc import abstractmethod |
|
from queue import Empty, Full |
|
from threading import Thread |
|
from queue import Queue |
|
from multiprocessing import Process |
|
from threading import Thread, Event |
|
import multiprocessing |
|
import threading |
|
import inspect |
|
import time |
|
import uuid |
|
from copy import deepcopy |
|
import itertools |
|
import functools |
|
|
|
__all__ = [ |
|
'Node', |
|
'Link', |
|
'ConcurrentNode', |
|
'Worker', |
|
'WorkerFunction', |
|
'Provider', |
|
'ProviderFunction', |
|
'Sequential', |
|
'Batch', |
|
'Unbatch', |
|
'Parallel', |
|
'Graph', |
|
'Buffer', |
|
] |
|
|
|
TERMINATE_CHECK_INTERVAL = 0.5 |
|
|
|
|
|
class _ItemWrapper: |
|
def __init__(self, data: Any, id: Union[int, List[int]] = None): |
|
self.data = data |
|
self.id = id |
|
|
|
|
|
class Terminate(Exception): |
|
pass |
|
|
|
|
|
def _get_queue_item(queue: Queue, terminate_flag: Event, timeout: float = None) -> _ItemWrapper: |
|
while True: |
|
try: |
|
item: _ItemWrapper = queue.get(block=True, timeout=TERMINATE_CHECK_INTERVAL if timeout is None else min(timeout, TERMINATE_CHECK_INTERVAL)) |
|
if terminate_flag.is_set(): |
|
raise Terminate() |
|
return item |
|
except Empty: |
|
if terminate_flag.is_set(): |
|
raise Terminate() |
|
|
|
if timeout is not None: |
|
timeout -= TERMINATE_CHECK_INTERVAL |
|
if timeout <= 0: |
|
raise Empty() |
|
|
|
|
|
def _put_queue_item(queue: Queue, item: _ItemWrapper, terminate_flag: Event): |
|
while True: |
|
try: |
|
queue.put(item, block=True, timeout=TERMINATE_CHECK_INTERVAL) |
|
if terminate_flag.is_set(): |
|
raise Terminate() |
|
return |
|
except Full: |
|
if terminate_flag.is_set(): |
|
raise Terminate() |
|
|
|
class Node: |
|
def __init__(self, in_buffer_size: int = 1, out_buffer_size: int = 1) -> None: |
|
self.input: Queue = Queue(maxsize=in_buffer_size) |
|
self.output: Queue = Queue(maxsize=out_buffer_size) |
|
self.in_buffer_size = in_buffer_size |
|
self.out_buffer_size = out_buffer_size |
|
|
|
@abstractmethod |
|
def start(self): |
|
pass |
|
|
|
@abstractmethod |
|
def terminate(self): |
|
pass |
|
|
|
def stop(self): |
|
self.terminate() |
|
self.join() |
|
|
|
@abstractmethod |
|
def join(self): |
|
pass |
|
|
|
def put(self, data: Any, key: str = None, block: bool = True) -> None: |
|
item = _ItemWrapper(data) |
|
self.input.put(item, block=block) |
|
|
|
def get(self, key: str = None, block: bool = True) -> Any: |
|
item: _ItemWrapper = self.output.get(block=block) |
|
return item.data |
|
|
|
def __enter__(self): |
|
self.start() |
|
return self |
|
|
|
def __exit__(self, exc_type, exc_value, traceback): |
|
self.terminate() |
|
self.join() |
|
|
|
|
|
class ConcurrentNode(Node): |
|
job: Union[Thread, Process] |
|
|
|
def __init__(self, running_as: Literal['thread', 'process'] = 'thread', in_buffer_size: int = 1, out_buffer_size: int = 1) -> None: |
|
super().__init__(in_buffer_size, out_buffer_size) |
|
self.running_as = running_as |
|
|
|
@abstractmethod |
|
def _loop_fn(self, input: Queue, output: Queue, terminate_flag: Event): |
|
pass |
|
|
|
def start(self): |
|
if self.running_as == 'thread': |
|
terminate_flag = threading.Event() |
|
job = Thread(target=self._loop_fn, args=(self.input, self.output, terminate_flag)) |
|
elif self.running_as == 'process': |
|
terminate_flag = multiprocessing.Event() |
|
job = Process(target=self._loop_fn, args=(self.input, self.output, terminate_flag)) |
|
job.start() |
|
self.job = job |
|
self.terminate_flag = terminate_flag |
|
|
|
def terminate(self): |
|
self.terminate_flag.set() |
|
|
|
def join(self): |
|
self.job.join() |
|
|
|
|
|
class Worker(ConcurrentNode): |
|
def __init__(self, running_as: Literal['thread', 'process'] = 'thread', in_buffer_size: int = 0, out_buffer_size: int = 0) -> None: |
|
super().__init__(running_as, in_buffer_size, out_buffer_size) |
|
|
|
def init(self) -> None: |
|
""" |
|
This method is called the the thread is started, to initialize any resources that is only held in the thread. |
|
""" |
|
pass |
|
|
|
@abstractmethod |
|
def work(self, *args, **kwargs) -> Union[Any, Dict[str, Any]]: |
|
""" |
|
This method defines the job that the node should do for each input item. |
|
A item obtained from the input queue is passed as arguments to this method, and the result is placed in the output queue. |
|
The method is executed concurrently with other nodes. |
|
""" |
|
pass |
|
|
|
def _loop_fn(self, input: Queue, output: Queue, terminate_flag: Event): |
|
self.init() |
|
try: |
|
while True: |
|
item = _get_queue_item(input, terminate_flag) |
|
result = self.work(item.data) |
|
_put_queue_item(output, _ItemWrapper(result, item.id), terminate_flag) |
|
|
|
except Terminate: |
|
return |
|
|
|
|
|
class Provider(ConcurrentNode): |
|
""" |
|
A node that provides data to successive nodes. It takes no input and provides data to the output queue. |
|
""" |
|
def __init__(self, running_as: Literal['thread', 'process'], out_buffer_size: int = 1) -> None: |
|
super().__init__(running_as, 0, out_buffer_size) |
|
|
|
def init(self) -> None: |
|
""" |
|
This method is called the the thread or process is started, to initialize any resources that is only held in the thread or process. |
|
""" |
|
pass |
|
|
|
@abstractmethod |
|
def provide(self) -> Generator[Any, None, None]: |
|
pass |
|
|
|
def _loop_fn(self, input: Queue, output: Queue, terminate_flag: Event): |
|
self.init() |
|
try: |
|
for data in self.provide(): |
|
_put_queue_item(output, _ItemWrapper(data), terminate_flag) |
|
except Terminate: |
|
return |
|
|
|
|
|
class WorkerFunction(Worker): |
|
def __init__(self, fn: Callable, running_as: 'thread', in_buffer_size: int = 1, out_buffer_size: int = 1) -> None: |
|
super().__init__(running_as, in_buffer_size, out_buffer_size) |
|
self.fn = fn |
|
|
|
def work(self, *args, **kwargs): |
|
return self.fn(*args, **kwargs) |
|
|
|
|
|
class ProviderFunction(Provider): |
|
def __init__(self, fn: Callable, running_as: 'thread', out_buffer_size: int = 1) -> None: |
|
super().__init__(running_as, out_buffer_size) |
|
self.fn = fn |
|
|
|
def provide(self): |
|
for item in self.fn(): |
|
yield item |
|
|
|
|
|
class Link: |
|
def __init__(self, src: Queue, dst: Queue): |
|
self.src = src |
|
self.dst = dst |
|
|
|
def _thread_fn(self): |
|
try: |
|
while True: |
|
item = _get_queue_item(self.src, self.terminate_flag) |
|
_put_queue_item(self.dst, item, self.terminate_flag) |
|
except Terminate: |
|
return |
|
|
|
def start(self): |
|
self.terminate_flag = threading.Event() |
|
self.thread = Thread(target=self._thread_fn) |
|
self.thread.start() |
|
|
|
def terminate(self): |
|
self.terminate_flag.set() |
|
|
|
def join(self): |
|
self.thread.join() |
|
|
|
|
|
class Graph(Node): |
|
""" |
|
Graph pipeline of nodes and links |
|
""" |
|
nodes: List[Node] |
|
links: List[Link] |
|
|
|
def __init__(self, in_buffer_size: int = 1, out_buffer_size: int = 1): |
|
super().__init__(in_buffer_size, out_buffer_size) |
|
self.nodes = [] |
|
self.links = [] |
|
|
|
def add(self, node: Node): |
|
self.nodes.append(node) |
|
|
|
def link(self, src: Union[Node, Tuple[Node, str]], dst: Union[Node, Tuple[Node, str]]): |
|
""" |
|
Links the output of the source node to the input of the destination node. |
|
If the source or destination node is None, the pipeline's input or output is used. |
|
""" |
|
src_queue = self.input if src is None else src.output |
|
dst_queue = self.output if dst is None else dst.input |
|
self.links.append(Link(src_queue, dst_queue)) |
|
|
|
def chain(self, nodes: Iterable[Node]): |
|
""" |
|
Link the output of each node to the input of the next node. |
|
""" |
|
nodes = list(nodes) |
|
for i in range(len(nodes) - 1): |
|
self.link(nodes[i], nodes[i + 1]) |
|
|
|
def start(self): |
|
for node in self.nodes: |
|
node.start() |
|
for link in self.links: |
|
link.start() |
|
|
|
def terminate(self): |
|
for node in self.nodes: |
|
node.terminate() |
|
for link in self.links: |
|
link.terminate() |
|
|
|
def join(self): |
|
for node in self.nodes: |
|
node.join() |
|
for link in self.links: |
|
link.join() |
|
|
|
def __iter__(self): |
|
providers = [node for node in self.nodes if isinstance(node, Provider)] |
|
if len(providers) == 0: |
|
raise ValueError("No provider node found in the pipeline. If you want to iterate over the pipeline, the pipeline must be driven by a provider node.") |
|
with self: |
|
|
|
while True: |
|
yield self.get() |
|
|
|
def __call__(self, data: Any) -> Any: |
|
""" |
|
Submit data to the pipeline's input queue, and return the output data asynchronously. |
|
NOTE: The pipeline must be streamed (i.e., every output item is uniquely associated with an input item) for this to work. |
|
""" |
|
|
|
|
|
|
|
class Sequential(Graph): |
|
""" |
|
Pipeline of nodes in sequential order, where each node takes the output of the previous node as input. |
|
The order of input and output items is preserved (FIFO) |
|
""" |
|
def __init__(self, nodes: List[Union[Node, Callable]], function_running_as: Literal['thread', 'process'] = 'thread', in_buffer_size: int = 1, out_buffer_size: int = 1): |
|
""" |
|
Initialize the pipeline with a list of nodes to execute sequentially. |
|
### Parameters: |
|
- nodes: List of nodes or functions to execute sequentially. Generator functions are wrapped in provider nodes, and other functions are wrapped in worker nodes. |
|
- function_running_as: Whether to wrap the function as a thread or process worker. Default is 'thread'. |
|
- in_buffer_size: Maximum size of the input queue of the pipeline. Default is 0 (unlimited). |
|
- out_buffer_size: Maximum size of the output queue of the pipeline. Default is 0 (unlimited). |
|
""" |
|
super().__init__(in_buffer_size, out_buffer_size) |
|
for node in nodes: |
|
if isinstance(node, Node): |
|
pass |
|
elif isinstance(node, Callable): |
|
if inspect.isgeneratorfunction(node): |
|
node = ProviderFunction(node, function_running_as) |
|
else: |
|
node = WorkerFunction(node, function_running_as) |
|
else: |
|
raise ValueError(f"Invalid node type: {type(node)}") |
|
self.add(node) |
|
self.chain([None, *self.nodes, None]) |
|
|
|
|
|
class Parallel(Node): |
|
""" |
|
A FIFO node that runs multiple nodes in parallel to process the input items. Each input item is handed to one of the nodes whoever is available. |
|
NOTE: It is FIFO if and only if all the nested nodes are FIFO. |
|
""" |
|
nodes: List[Node] |
|
|
|
def __init__(self, nodes: Iterable[Node], in_buffer_size: int = 1, out_buffer_size: int = 1, function_running_as: Literal['thread', 'process'] = 'thread'): |
|
super().__init__(in_buffer_size, out_buffer_size) |
|
self.nodes = [] |
|
for node in nodes: |
|
if isinstance(node, Node): |
|
pass |
|
elif isinstance(node, Callable): |
|
if inspect.isgeneratorfunction(node): |
|
node = ProviderFunction(node, function_running_as) |
|
else: |
|
node = WorkerFunction(node, function_running_as) |
|
else: |
|
raise ValueError(f"Invalid node type: {type(node)}") |
|
self.nodes.append(node) |
|
self.output_order = Queue() |
|
self.lock = threading.Lock() |
|
|
|
def _in_thread_fn(self, node: Node): |
|
try: |
|
while True: |
|
with self.lock: |
|
|
|
|
|
|
|
item = _get_queue_item(self.input, self.terminate_flag) |
|
self.output_order.put(node.output) |
|
_put_queue_item(node.input, item, self.terminate_flag) |
|
except Terminate: |
|
return |
|
|
|
def _out_thread_fn(self): |
|
try: |
|
while True: |
|
queue = _get_queue_item(self.output_order, self.terminate_flag) |
|
item = _get_queue_item(queue, self.terminate_flag) |
|
_put_queue_item(self.output, item, self.terminate_flag) |
|
except Terminate: |
|
return |
|
|
|
def start(self): |
|
self.terminate_flag = threading.Event() |
|
self.in_threads = [] |
|
for node in self.nodes: |
|
thread = Thread(target=self._in_thread_fn, args=(node,)) |
|
thread.start() |
|
self.in_threads.append(thread) |
|
thread = Thread(target=self._out_thread_fn) |
|
thread.start() |
|
self.out_thread = thread |
|
for node in self.nodes: |
|
node.start() |
|
|
|
def terminate(self): |
|
self.terminate_flag.set() |
|
for node in self.nodes: |
|
node.terminate() |
|
|
|
def join(self): |
|
for thread in self.in_threads: |
|
thread.join() |
|
self.out_thread.join() |
|
|
|
|
|
class UnorderedParallel(Graph): |
|
""" |
|
Pipeline of nodes in parallel, where each input item is handed to one of the nodes whoever is available. |
|
NOTE: The order of the output items is NOT guaranteed to be the same as the input items, depending on how fast the nodes handle their input. |
|
""" |
|
def __init__(self, nodes: List[Union[Node, Callable]], function_running_as: Literal['thread', 'process'] = 'thread', in_buffer_size: int = 1, out_buffer_size: int = 1): |
|
""" |
|
Initialize the pipeline with a list of nodes to execute in parallel. If a function is given, it is wrapped in a worker node. |
|
### Parameters: |
|
- nodes: List of nodes or functions to execute in parallel. Generator functions are wrapped in provider nodes, and other functions are wrapped in worker nodes. |
|
- function_running_as: Whether to wrap the function as a thread or process worker. Default is 'thread'. |
|
- in_buffer_size: Maximum size of the input queue of the pipeline. Default is 0 (unlimited). |
|
- out_buffer_size: Maximum size of the output queue of the pipeline. Default is 0 (unlimited). |
|
""" |
|
super().__init__(in_buffer_size, out_buffer_size) |
|
for node in nodes: |
|
if isinstance(node, Node): |
|
pass |
|
elif isinstance(node, Callable): |
|
if inspect.isgeneratorfunction(node): |
|
node = ProviderFunction(node, function_running_as) |
|
else: |
|
node = WorkerFunction(node, function_running_as) |
|
else: |
|
raise ValueError(f"Invalid node type: {type(node)}") |
|
self.add(node) |
|
for i in range(len(nodes)): |
|
self.chain([None, self.nodes[i], None]) |
|
|
|
|
|
class Batch(ConcurrentNode): |
|
""" |
|
Groups every `batch_size` items into a batch (a list of items) and passes the batch to successive nodes. |
|
The `patience` parameter specifies the maximum time to wait for a batch to be filled before sending it to the next node, |
|
i.e., when the earliest item in the batch is out of `patience` seconds, the batch is sent regardless of its size. |
|
""" |
|
def __init__(self, batch_size: int, patience: float = None, in_buffer_size: int = 1, out_buffer_size: int = 1): |
|
assert batch_size > 0, "Batch size must be greater than 0." |
|
super().__init__('thread', in_buffer_size, out_buffer_size) |
|
self.batch_size = batch_size |
|
self.patience = patience |
|
|
|
def _loop_fn(self, input: Queue, output: Queue, terminate_flag: Event): |
|
try: |
|
while True: |
|
batch_id, batch_data = [], [] |
|
|
|
for i in range(self.batch_size): |
|
if i == 0 or self.patience is None: |
|
timeout = None |
|
else: |
|
timeout = self.patience - (time.time() - earliest_time) |
|
if timeout < 0: |
|
break |
|
try: |
|
item = _get_queue_item(input, terminate_flag, timeout) |
|
except Empty: |
|
break |
|
|
|
if i == 0: |
|
earliest_time = time.time() |
|
batch_data.append(item.data) |
|
batch_id.append(item.id) |
|
|
|
batch = _ItemWrapper(batch_data, batch_id) |
|
_put_queue_item(output, batch, terminate_flag) |
|
except Terminate: |
|
return |
|
|
|
|
|
class Unbatch(ConcurrentNode): |
|
""" |
|
Ungroups every batch (a list of items) into individual items and passes them to successive nodes. |
|
""" |
|
def __init__(self, in_buffer_size: int = 1, out_buffer_size: int = 1): |
|
super().__init__('thread', in_buffer_size, out_buffer_size) |
|
|
|
def _loop_fn(self, input: Queue, output: Queue, terminate_flag: Event): |
|
try: |
|
while True: |
|
batch = _get_queue_item(input, terminate_flag) |
|
for id, data in zip(batch.id or itertools.repeat(None), batch.data): |
|
item = _ItemWrapper(data, id) |
|
_put_queue_item(output, item, terminate_flag) |
|
except Terminate: |
|
return |
|
|
|
|
|
class Buffer(Node): |
|
"A FIFO node that buffers items in a queue. Usefull achieve better temporal balance when its successor node has a variable processing time." |
|
def __init__(self, size: int): |
|
super().__init__(size, size) |
|
self.size = size |
|
self.input = self.output = Queue(maxsize=size) |