Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/ISSUE_BACKLOG/PRIORITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
2 changes: 2 additions & 0 deletions docs/rsgi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
51 changes: 41 additions & 10 deletions examples/rsgi_lifespan_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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}"
Expand All @@ -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)"
7 changes: 7 additions & 0 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
from collections.abc import Callable, Mapping
from types import SimpleNamespace
from typing import Any, TypeVar

from . import _oxyroute
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions tests/test_rsgi_lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading