diff --git a/.github/ISSUE_BACKLOG/PRIORITIES.md b/.github/ISSUE_BACKLOG/PRIORITIES.md index 51ef7ed..afc9d3e 100644 --- a/.github/ISSUE_BACKLOG/PRIORITIES.md +++ b/.github/ISSUE_BACKLOG/PRIORITIES.md @@ -8,7 +8,6 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next |---|------|-----------| | 4 | [04.md](bodies/04.md) | **Route hot path** — reduce lock contention / route snapshot after `freeze()`. | | 17 | [17.md](bodies/17.md) | **ASGI bridge** — `run_coroutine_threadsafe` / thread safety under concurrent load. | -| 18 | [18.md](bodies/18.md) | **App state / lifespan** — shared resources across workers. | | 22 | [22.md](bodies/22.md) | **Multipart / urlencoded body** — [GitHub #47](https://github.com/QueryaHub/OxyRoute/issues/47). | ## P1 (API ergonomics, security helpers, protocol features) @@ -36,10 +35,11 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next - **Query, errors, E2E:** 2, 3, 12 - **API surface:** 1, 5, 6, 7, 10, 11, 19, 20 - **CI / release / docs / PyO3:** 13, 14, 15, 16 +- **Lifespan / `app.state`:** 18 (see `App.state`, `examples/rsgi_lifespan_app.py`, `docs/rsgi.md`) ## Roadmap phasing (summary) -1. **P0:** 4, 17, 18, 47 (order flexible). +1. **P0:** 4, 17, 47; **18** done — `app.state` + lifespan docs (order flexible). 2. **P1:** 8, 46, 48, 49, 53, 54; 9 as polish. 3. **Research:** 50, 51, 52 — as capacity allows. diff --git a/docs/feature.md b/docs/feature.md index 66ea521..caa2347 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -68,7 +68,7 @@ | Тема | Зазор | Комментарий | |------|--------|-------------| -| **Встроенный `app.state` / lifespan** | Слабо | `__rsgi_init__` и общий state — [#18](https://github.com/QueryaHub/OxyRoute/issues/18). | +| **Встроенный `app.state` / lifespan** | Частично | `App.state` (`SimpleNamespace`), хуки `__rsgi_init__` / `__rsgi_del__`, пример `examples/rsgi_lifespan_app.py` — закрыто в [#18](https://github.com/QueryaHub/OxyRoute/issues/18). | | **Сессии (signed cookie, server-side)** | Нет | | | **Кэш глобальных настроек** | Нет | Нет первого класса для config. | diff --git a/docs/rsgi.md b/docs/rsgi.md index 6ab0292..a6f709c 100644 --- a/docs/rsgi.md +++ b/docs/rsgi.md @@ -25,6 +25,8 @@ The Python `oxyroute.app.App` class implements the async RSGI entry that Granian You can **override** these in a **subclass** of `App` to open DB pools, HTTP clients, `asyncio` primitives, etc. The default base implementation does nothing. +Every `App` exposes **`app.state`**, a `types.SimpleNamespace` for attaching **per-process** objects. Use it in `__rsgi_init__` (or a factory) instead of ad hoc attributes on `self` if you want a single obvious place for shared services; it is the same not-shared-across-processes story as any other in-memory `App` data. + ### Workers and shared state (Granian) - With **`granian --workers N`**, the server runs **N independent worker processes** (typical for CPU-bound HTTP). Each process loads your module, constructs your `app`, and may call `__rsgi_init__` **once per worker** (exact call pattern is defined by the server; see [Granian’s docs](https://github.com/emmett-framework/granian)). **In-memory** attributes you set in `__rsgi_init__` are **not** shared between workers: two requests may hit different processes and see different `self.foo`. diff --git a/examples/rsgi_lifespan_app.py b/examples/rsgi_lifespan_app.py index 1bd5647..6deed60 100644 --- a/examples/rsgi_lifespan_app.py +++ b/examples/rsgi_lifespan_app.py @@ -6,34 +6,57 @@ granian --interface rsgi examples.rsgi_lifespan_app:app ``examples/rsgi_app.py`` is the minimal app. This file shows **subclassing** ``App`` to -open resources when the host starts a worker. In-memory fields are **per OS process**; -with ``granian --workers N`` each worker has its own object graph — use Redis, a DB -pool, or a message bus for **cross-worker** or **cross-machine** state. +open resources when the host starts a worker, using :attr:`oxyroute.app.App.state` and a +:func:`concurrent.futures.ThreadPoolExecutor` (typical for blocking I/O in sync handlers; +use ``asyncio`` primitives in ``__rsgi_init__`` when your stack is natively async). + +In-memory data is **per OS process**; with ``granian --workers N`` each worker has its +own object graph — use Redis, a DB pool, or a message bus for **cross-worker** or +**cross-machine** state. See https://github.com/QueryaHub/OxyRoute/blob/main/docs/rsgi.md (lifespan section). """ from __future__ import annotations +import asyncio import time +from concurrent.futures import ThreadPoolExecutor from oxyroute import App +_WORKERS = 4 # per-process pool size; not shared across ``granian --workers N`` + class LifespanApp(App): - """Example: set attributes when the RSGI worker calls ``__rsgi_init__``.""" + """Example: attach per-process state when the RSGI worker calls ``__rsgi_init__``.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.ready_at: float | None = None + # ``state`` is a :class:`types.SimpleNamespace` on the base :class:`App`. + self.state.ready_at = None + self.state.thread_pool = None async def __rsgi_init__(self, *args, **kwargs) -> None: - # Place per-process setup here (DB engine, pool, clients, asyncio primitives). - self.ready_at = time.time() + # ``asyncio`` primitive example (use from async callables you control). + self.state.bg_limit = asyncio.Semaphore(8) + # Thread pool: run blocking work via ``run_in_executor(self.state.thread_pool, ...)`` + # from async glue, or keep sync handlers cheap and use the pool in dependencies. + self.state.thread_pool = ThreadPoolExecutor( + max_workers=_WORKERS, + thread_name_prefix="rsgi", + ) + self.state.ready_at = time.time() return None async def __rsgi_del__(self, *args, **kwargs) -> None: - self.ready_at = None + pool = self.state.thread_pool + if pool is not None: + pool.shutdown(wait=True) + self.state.ready_at = None + self.state.thread_pool = None + if hasattr(self.state, "bg_limit"): + del self.state.bg_limit return None @@ -42,7 +65,7 @@ async def __rsgi_del__(self, *args, **kwargs) -> None: @app.get("/") def root() -> str: - t = app.ready_at + t = app.state.ready_at if t is None: return "ok (lifespan not run — use a real RSGI server)" return f"ok boot_timestamp={t:.4f}" @@ -51,5 +74,13 @@ def root() -> str: @app.get("/meta") def meta() -> str: """In multi-worker mode each worker has its own ``ready_at`` (not a global counter).""" - t = app.ready_at + t = app.state.ready_at return f"ready_at={t}" + + +@app.get("/pool") +def pool_info() -> str: + p = app.state.thread_pool + if p is None: + return "thread_pool=off" + return f"thread_pool max_workers={_WORKERS} (per process, not across workers)" diff --git a/oxyroute/app.py b/oxyroute/app.py index 0d1a772..b682e50 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -2,6 +2,7 @@ import json from collections.abc import Callable, Mapping +from types import SimpleNamespace from typing import Any, TypeVar from . import _oxyroute @@ -34,12 +35,18 @@ class App: """ Application object: pass a module instance to Granian, e.g. ``granian app:app --interface rsgi`` or for ASGI ``granian app:app --interface asgi``. + + ``state`` is an empty ``types.SimpleNamespace`` for per-process data; set fields in + ``__rsgi_init__`` or a factory, or on a subclass. In-memory data is not shared across + Granian worker processes. """ def __init__(self, title: str = "OxyRoute", *, include_openapi: bool = True) -> None: self._app = _oxyroute.App(include_openapi=include_openapi) self._app.set_openapi_title(title) self.title = title + # Per-process mutable bag for ``__rsgi_init__`` / factory setup (DB pool, clients, …). + self.state: SimpleNamespace = SimpleNamespace() self._asgi3: Callable[..., Any] = build_asgi_caller(self) def freeze(self) -> None: diff --git a/tests/test_rsgi_lifespan.py b/tests/test_rsgi_lifespan.py index 1a04419..9776c05 100644 --- a/tests/test_rsgi_lifespan.py +++ b/tests/test_rsgi_lifespan.py @@ -3,10 +3,18 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace from oxyroute import App +def test_app_state_is_simple_namespace() -> None: + a = App() + assert isinstance(a.state, SimpleNamespace) + a.state.pool = "mock" + assert a.state.pool == "mock" + + def test_base_rsgi_init_and_del_are_noop() -> None: async def _go() -> None: a = App()