Skip to content

src/genro_asgi/pool.py

Source from this local checkout, regenerated when the reader rebuilds. Line links use #L<number>; a GitHub line range opens its first line.

1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 #     https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """The server's single thread pool for blocking work (SPECIFICATION.md §4, D2).16 17 ``WorkPool`` wraps one ``concurrent.futures.ThreadPoolExecutor`` and is held by18 the server as a dual parent-child (``self.server``). Async handlers stay on the19 event loop; only sync handlers reach the pool, dispatched through20 ``BaseServer.run_sync`` via ``loop.run_in_executor``.21 22 Lazily provisioned (invariant #1 — build lazily on the running loop): the23 executor is created on the first dispatch, never at boot, and torn down at24 lifespan shutdown only if it was ever provisioned.25 """26 27 from __future__ import annotations28 29 import asyncio30 import contextvars31 import os32 from concurrent.futures import ThreadPoolExecutor33 from typing import TYPE_CHECKING, Any, Callable34 35 if TYPE_CHECKING:36     from .server import BaseServer37 38 __all__ = ["WorkPool"]39 40 41 class WorkPool:42     """One thread pool for blocking (sync) handlers, owned by the server.43 44     Constructor kwarg: ``max_threads`` — the executor's ``max_workers``45     (``None`` uses the stdlib default: ``min(32, cpus + 4)``, where ``cpus``46     are the CPUs granted to the process on interpreters that have47     ``os.process_cpu_count``, the machine's on older ones). Threads are48     named ``genro-pool*`` so a handler can assert it ran off the loop.49     """50 51     def __init__(self, server: BaseServer, max_threads: int | None = None) -> None:52         self.server = server53         self._max_threads = max_threads54         self._executor: ThreadPoolExecutor | None = None55         self._busy = 056         self._total = 057 58     @property59     def provisioned(self) -> bool:60         """Whether the executor exists yet (a sync dispatch has happened)."""61         return self._executor is not None62 63     @property64     def metrics(self) -> dict[str, int]:65         """Pressure gauges of the pool: the slots that exist, the calls in flight.66 67         ``busy`` counts every ``run()`` entered and not yet exited — DEMAND, not68         slots held: past saturation the excess is queued inside the executor and69         still counts, so ``busy`` can exceed ``total`` (the consumers clamp).70 71         Zeros until the executor is provisioned — before the first sync dispatch72         there is nothing to measure, and reporting the configured size of a pool73         that does not exist would read as pressure that isn't there.74 75         ``total`` mirrors our own argument resolution, frozen at provision —76         never a private executor attribute.77         """78         if not self.provisioned:79             return {"total": 0, "busy": 0}80         return {"total": self._total, "busy": self._busy}81 82     @property83     def executor(self) -> ThreadPoolExecutor:84         """The pool's executor, created on first access (lazy provisioning).85 86         The moment of truth for ``total``: the slot count is resolved and87         frozen HERE, where the stdlib takes the same decision — from the CPUs88         granted to the process (``os.process_cpu_count``) on interpreters that89         have it, the machine's otherwise, exactly mirroring the executor's own90         default on each. A later affinity change cannot move the threads the91         pool already built, so the frozen number stays the true one.92         """93         if self._executor is None:94             cpus = getattr(os, "process_cpu_count", os.cpu_count)() or 195             self._total = self._max_threads if self._max_threads is not None else min(32, cpus + 4)96             self._executor = ThreadPoolExecutor(97                 max_workers=self._max_threads,98                 thread_name_prefix="genro-pool",99             )100         return self._executor101 102     async def run(self, fn: Callable[..., Any], *args: Any) -> Any:103         """Run blocking ``fn`` on a pool thread, provisioning on first call.104 105         The caller's context is copied into the worker thread (what106         ``asyncio.to_thread`` does), so a sync handler sees the loop-side107         ContextVars — e.g. the registry's current request.108         """109         loop = asyncio.get_running_loop()110         ctx = contextvars.copy_context()111         self._busy += 1112         try:113             return await loop.run_in_executor(self.executor, ctx.run, fn, *args)114         finally:115             self._busy -= 1116 117     def shutdown(self, wait: bool = True) -> None:118         """Tear the executor down — a no-op if it was never provisioned.119 120         Resets the lazy slot so a later dispatch re-provisions: a server121         reused through repeated ``serve()`` rounds keeps working.122         """123         if self.provisioned:124             self.executor.shutdown(wait=wait)125             self._executor = None126             self._total = 0