diff --git a/docs/routing.md b/docs/routing.md index 9a5afd7..39b9d41 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -21,6 +21,14 @@ Each is a decorator that registers a route and returns the handler unchanged (so - If no route matches, the response is **404** with a plain `Not Found` body (from the Rust dispatch layer). - Non-`http` RSGI scopes are ignored in the current implementation (no response is sent for unknown `proto` values). +## Compiled routing snapshot + +- OxyRoute now **auto-compiles** the route snapshot on the first HTTP request, so hot-path + matching uses the lock-free compiled tables even if `freeze()` was not called explicitly. +- Calling `freeze()` is still supported and remains the strict "no more route registration" switch. +- If routes are added before `freeze()`, OxyRoute invalidates the previous compiled snapshot and + lazily rebuilds it on the next request. + ## OpenAPI and discovery If OpenAPI is enabled, route registration also updates a minimal OpenAPI document. See [openapi.md](openapi.md). diff --git a/src/dispatch.rs b/src/dispatch.rs index 6f6166c..86957c0 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -145,6 +145,13 @@ async fn send_python_error( send_internal_error(protocol, method, path, err).await } +fn ensure_compiled_snapshot(state: &Arc>) { + let mut st = state.write(); + if st.compiled.is_none() { + st.compiled = Some(Arc::new(st.snapshot_routers())); + } +} + pub async fn run_rsgi( state: Arc>, scope: Py, @@ -249,6 +256,9 @@ pub async fn run_rsgi( return send_handler_map(&protocol, is_head, mapped).await; } } + // Auto-enable compiled route snapshot on first request to keep hot-path matching lock-free + // even when users forget to call `freeze()` explicitly. + ensure_compiled_snapshot(&state); let route_out: Option<(usize, HashMap)> = { let st = state.read(); match match_route(&st, &method, &path) { diff --git a/src/lib.rs b/src/lib.rs index 5910db8..3500dca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -277,6 +277,8 @@ impl App { m.insert(&path, idx) .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))?; } + // Keep auto-compiled routing snapshots fresh when routes are added before explicit freeze(). + st.compiled = None; Ok(()) } diff --git a/tests/test_routing_autocompile.py b/tests/test_routing_autocompile.py new file mode 100644 index 0000000..61a3c1f --- /dev/null +++ b/tests/test_routing_autocompile.py @@ -0,0 +1,52 @@ +"""Issue #75: routing snapshot auto-compiles on first request.""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App + + +def test_routes_added_after_first_request_still_resolve() -> None: + app = App() + + @app.get("/a") + def a() -> str: + return "a" + + async def _run() -> None: + tr = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: + r1 = await c.get("/a") + assert r1.status_code == 200 + assert r1.text == "a" + + # This route is registered after first request/auto-compile snapshot. + @app.get("/b") + def b() -> str: + return "b" + + r2 = await c.get("/b") + assert r2.status_code == 200 + assert r2.text == "b" + + asyncio.run(_run()) + + +def test_autocompile_keeps_405_behavior() -> None: + app = App() + + @app.post("/x") + def x() -> str: + return "ok" + + async def _run() -> None: + tr = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: + r = await c.get("/x") + assert r.status_code == 405 + allow = (r.headers.get("allow") or "").upper() + assert "POST" in allow + + asyncio.run(_run())