Coverage for src / invariant / scheduler.py: 67.82%
87 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-25 08:17 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-25 08:17 +0000
1"""Invocation schedulers for async execution."""
3import asyncio
4from collections.abc import Callable
5from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
6from dataclasses import dataclass
7from typing import Any, Protocol
9from invariant.invocation import invoke_op, invoke_op_async
10from invariant.registry import OpRegistry, import_implementation_ref
11from invariant.store.codec import deserialize, serialize
12from invariant.traits import OpTrait
15@dataclass(frozen=True)
16class InvocationRequest:
17 """A scheduler-facing operation invocation request."""
19 op_name: str
20 op: Callable[..., Any]
21 manifest: dict[str, Any]
22 traits: frozenset[str]
23 implementation_ref: str | None = None
24 cache_key: tuple[str, str] | None = None
27class InvocationScheduler(Protocol):
28 """Protocol implemented by local and remote invocation schedulers."""
30 async def invoke(self, request: InvocationRequest) -> Any:
31 """Invoke an operation and return its artifact."""
32 ...
35class InlineScheduler:
36 """Invoke operations directly on the event loop thread."""
38 def __init__(self, max_concurrency: int = 16):
39 if (
40 isinstance(max_concurrency, bool)
41 or not isinstance(max_concurrency, int)
42 or max_concurrency < 1
43 ):
44 raise ValueError("max_concurrency must be a positive integer")
45 self._slots = asyncio.Semaphore(max_concurrency)
47 async def invoke(self, request: InvocationRequest) -> Any:
48 """Invoke cheap synchronous or asynchronous operations with bounded concurrency."""
49 async with self._slots:
50 return await invoke_op_async(request.op, request.op_name, request.manifest)
53class ThreadPoolScheduler:
54 """Invoke operations in a thread pool."""
56 def __init__(
57 self,
58 max_workers: int | None = None,
59 executor: ThreadPoolExecutor | None = None,
60 ) -> None:
61 if max_workers is not None and executor is not None:
62 raise ValueError("max_workers cannot be set when executor is provided")
63 self._executor = executor or ThreadPoolExecutor(max_workers=max_workers)
64 self._owns_executor = executor is None
66 async def invoke(self, request: InvocationRequest) -> Any:
67 """Invoke an operation in the thread pool."""
68 loop = asyncio.get_running_loop()
69 return await loop.run_in_executor(
70 self._executor,
71 invoke_op,
72 request.op,
73 request.op_name,
74 request.manifest,
75 )
77 async def aclose(self) -> None:
78 """Shut down the owned thread pool."""
79 if self._owns_executor:
80 await asyncio.to_thread(self._executor.shutdown, wait=True)
83class ProcessPoolScheduler:
84 """Invoke worker-resolvable operations in a process pool."""
86 def __init__(
87 self,
88 max_workers: int | None = None,
89 executor: ProcessPoolExecutor | None = None,
90 ) -> None:
91 if max_workers is not None and executor is not None:
92 raise ValueError("max_workers cannot be set when executor is provided")
93 self._executor = executor or ProcessPoolExecutor(max_workers=max_workers)
94 self._owns_executor = executor is None
96 async def invoke(self, request: InvocationRequest) -> Any:
97 """Invoke an operation through an Invariant codec process boundary."""
98 if not request.implementation_ref:
99 raise ValueError(
100 f"Op '{request.op_name}' cannot run in a process because it has "
101 "no worker-resolvable implementation_ref"
102 )
104 manifest_payload = serialize(request.manifest)
105 loop = asyncio.get_running_loop()
106 artifact_payload = await loop.run_in_executor(
107 self._executor,
108 _process_worker_invoke,
109 request.op_name,
110 request.implementation_ref,
111 manifest_payload,
112 )
113 return deserialize(artifact_payload)
115 async def aclose(self) -> None:
116 """Shut down the owned process pool."""
117 if self._owns_executor:
118 await asyncio.to_thread(self._executor.shutdown, wait=True)
121class RoutingScheduler:
122 """Route invocations to local schedulers according to traits."""
124 def __init__(
125 self,
126 *,
127 inline_scheduler: InvocationScheduler | None = None,
128 thread_scheduler: InvocationScheduler | None = None,
129 process_scheduler: InvocationScheduler | None = None,
130 ) -> None:
131 self.inline_scheduler = inline_scheduler or InlineScheduler()
132 self.thread_scheduler = thread_scheduler
133 self.process_scheduler = process_scheduler
135 async def invoke(self, request: InvocationRequest) -> Any:
136 """Route an invocation to the first configured matching scheduler."""
137 if (
138 OpTrait.PROCESS_SAFE.value in request.traits
139 and self.process_scheduler is not None
140 ):
141 return await self.process_scheduler.invoke(request)
143 if self.thread_scheduler is not None and (
144 OpTrait.BLOCKING.value in request.traits
145 or OpTrait.IO_BOUND.value in request.traits
146 ):
147 return await self.thread_scheduler.invoke(request)
149 return await self.inline_scheduler.invoke(request)
151 async def aclose(self) -> None:
152 """Close child schedulers that expose ``aclose``."""
153 for scheduler in (
154 self.process_scheduler,
155 self.thread_scheduler,
156 self.inline_scheduler,
157 ):
158 close = getattr(scheduler, "aclose", None)
159 if close is not None:
160 await close()
163def _process_worker_invoke(
164 op_name: str,
165 implementation_ref: str,
166 manifest_payload: bytes,
167) -> bytes:
168 """Process worker entrypoint.
170 The parent sends only simple strings and Invariant codec bytes. The worker
171 resolves the exact callable locally, invokes it, and returns codec bytes.
172 """
173 registry = OpRegistry()
174 registry.clear()
175 registry.auto_discover()
177 if registry.has(op_name):
178 binding = registry.get_binding(op_name)
179 if binding.implementation_ref != implementation_ref:
180 raise ValueError(
181 f"Worker discovered op '{op_name}' as "
182 f"{binding.implementation_ref!r}, but request requires "
183 f"{implementation_ref!r}"
184 )
185 op = binding.op
186 else:
187 op = import_implementation_ref(implementation_ref)
188 registry.register(
189 op_name,
190 op,
191 implementation_ref=implementation_ref,
192 )
194 manifest = deserialize(manifest_payload)
195 artifact = invoke_op(op, op_name, manifest)
196 return serialize(artifact)
199__all__ = [
200 "InlineScheduler",
201 "InvocationRequest",
202 "InvocationScheduler",
203 "ProcessPoolScheduler",
204 "RoutingScheduler",
205 "ThreadPoolScheduler",
206]