Skip to content

Commit 1417de8

Browse files
Merge pull request #175 from QueryaHub/perf-single-pass-routing-139
perf(routing): single-pass bitmask / unified radix lookup for 405 Method Not Allowed (#139)
2 parents ca0724b + 38bdff6 commit 1417de8

2 files changed

Lines changed: 86 additions & 73 deletions

File tree

src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ pub mod microbench {
3636
let mut get = Router::new();
3737
get.insert("/hello", 0usize).expect("static route");
3838
get.insert("/items/:id", 1usize).expect("param route");
39+
let mut all_paths = Router::new();
40+
all_paths
41+
.insert("/hello", crate::state::MethodMask::from_method("GET"))
42+
.expect("static all_paths");
43+
all_paths
44+
.insert("/items/:id", crate::state::MethodMask::from_method("GET"))
45+
.expect("param all_paths");
3946
CompiledRouters {
4047
get,
4148
post: Router::new(),
@@ -44,6 +51,7 @@ pub mod microbench {
4451
delete: Router::new(),
4552
options: Router::new(),
4653
websocket: Router::new(),
54+
all_paths,
4755
}
4856
}
4957

@@ -433,6 +441,10 @@ impl App {
433441
m.insert(&path, idx)
434442
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))?;
435443
}
444+
{
445+
let mut masks = st.path_method_masks.lock();
446+
masks.entry(path).or_default().insert_method(&method);
447+
}
436448
// Keep auto-compiled routing snapshots fresh when routes are added before explicit freeze().
437449
st.compiled = None;
438450
Ok(())

src/state.rs

Lines changed: 74 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,55 @@ use matchit::Router;
55
use parking_lot::Mutex;
66
use pyo3::prelude::*;
77

8+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9+
pub struct MethodMask(pub u8);
10+
11+
impl MethodMask {
12+
pub const GET: u8 = 1 << 0;
13+
pub const HEAD: u8 = 1 << 1;
14+
pub const POST: u8 = 1 << 2;
15+
pub const PUT: u8 = 1 << 3;
16+
pub const PATCH: u8 = 1 << 4;
17+
pub const DELETE: u8 = 1 << 5;
18+
pub const OPTIONS: u8 = 1 << 6;
19+
20+
pub fn from_method(method: &str) -> Self {
21+
match method {
22+
"GET" => Self(Self::GET | Self::HEAD),
23+
"HEAD" => Self(Self::HEAD),
24+
"POST" => Self(Self::POST),
25+
"PUT" => Self(Self::PUT),
26+
"PATCH" => Self(Self::PATCH),
27+
"DELETE" => Self(Self::DELETE),
28+
"OPTIONS" => Self(Self::OPTIONS),
29+
_ => Self(0),
30+
}
31+
}
32+
33+
pub fn insert_method(&mut self, method: &str) {
34+
self.0 |= Self::from_method(method).0;
35+
}
36+
37+
pub fn to_vec(self) -> Vec<String> {
38+
const ORDER: [(&str, u8); 7] = [
39+
("GET", MethodMask::GET),
40+
("HEAD", MethodMask::HEAD),
41+
("POST", MethodMask::POST),
42+
("PUT", MethodMask::PUT),
43+
("PATCH", MethodMask::PATCH),
44+
("DELETE", MethodMask::DELETE),
45+
("OPTIONS", MethodMask::OPTIONS),
46+
];
47+
let mut out = Vec::with_capacity(7);
48+
for (name, flag) in ORDER {
49+
if (self.0 & flag) != 0 {
50+
out.push(name.to_string());
51+
}
52+
}
53+
out
54+
}
55+
}
56+
857
/// Immutable route tables built at [`AppState::freeze`](AppState) time so the request
958
/// path can be matched without per-method `Mutex` locks (issue #4).
1059
pub struct CompiledRouters {
@@ -15,6 +64,7 @@ pub struct CompiledRouters {
1564
pub delete: Router<usize>,
1665
pub options: Router<usize>,
1766
pub websocket: Router<usize>,
67+
pub all_paths: Router<MethodMask>,
1868
}
1969

2070
fn router_for_compiled<'a>(c: &'a CompiledRouters, method: &str) -> Option<&'a Router<usize>> {
@@ -117,6 +167,8 @@ pub struct AppState {
117167
pub security_headers: Option<Py<PyAny>>,
118168
/// Global connection pool for the Postgres database.
119169
pub db_pool: Option<sqlx::PgPool>,
170+
/// Bitmask of allowed HTTP methods per registered path template.
171+
pub path_method_masks: Mutex<std::collections::HashMap<String, MethodMask>>,
120172
}
121173

122174
impl AppState {
@@ -146,6 +198,7 @@ impl AppState {
146198
cors: None,
147199
security_headers: None,
148200
db_pool: None,
201+
path_method_masks: Mutex::new(std::collections::HashMap::new()),
149202
}
150203
}
151204

@@ -172,6 +225,10 @@ impl AppState {
172225

173226
/// Clone current mutex-protected [`Router`]s into a snapshot (used at freeze / tests).
174227
pub fn snapshot_routers(&self) -> CompiledRouters {
228+
let mut all_paths = Router::new();
229+
for (path, mask) in self.path_method_masks.lock().iter() {
230+
let _ = all_paths.insert(path, *mask);
231+
}
175232
CompiledRouters {
176233
get: self.get.lock().clone(),
177234
post: self.post.lock().clone(),
@@ -180,6 +237,7 @@ impl AppState {
180237
delete: self.delete.lock().clone(),
181238
options: self.options.lock().clone(),
182239
websocket: self.websocket.lock().clone(),
240+
all_paths,
183241
}
184242
}
185243
}
@@ -235,6 +293,12 @@ pub fn match_route_compiled(
235293

236294
/// All HTTP methods that match `path` in a precomputed [`CompiledRouters`] (lock-free 405 list).
237295
pub fn methods_matching_path_compiled(compiled: &CompiledRouters, path: &str) -> Vec<String> {
296+
if let Ok(m) = compiled.all_paths.at(path) {
297+
let v = m.value.to_vec();
298+
if !v.is_empty() {
299+
return v;
300+
}
301+
}
238302
const ORDER: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
239303
let mut have = [false; 7];
240304
if compiled.get.at(path).is_ok() {
@@ -286,77 +350,16 @@ pub fn map_method_router<'a>(
286350
/// [1]: https://www.rfc-editor.org/rfc/rfc9110#name-405-method-not-allowed
287351
#[cfg(test)]
288352
fn methods_matching_path(state: &AppState, path: &str) -> Vec<String> {
289-
const ORDER: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
290-
let mut have = [false; 7];
291353
if let Some(c) = &state.compiled {
292-
if c.get.at(path).is_ok() {
293-
have[0] = true;
294-
have[1] = true;
295-
}
296-
if c.post.at(path).is_ok() {
297-
have[2] = true;
298-
}
299-
if c.put.at(path).is_ok() {
300-
have[3] = true;
301-
}
302-
if c.patch.at(path).is_ok() {
303-
have[4] = true;
304-
}
305-
if c.delete.at(path).is_ok() {
306-
have[5] = true;
307-
}
308-
if c.options.at(path).is_ok() {
309-
have[6] = true;
310-
}
354+
methods_matching_path_compiled(c, path)
311355
} else {
312-
{
313-
let g = state.get.lock();
314-
if g.at(path).is_ok() {
315-
have[0] = true;
316-
have[1] = true;
317-
}
318-
}
319-
{
320-
let r = state.post.lock();
321-
if r.at(path).is_ok() {
322-
have[2] = true;
323-
}
324-
}
325-
{
326-
let r = state.put.lock();
327-
if r.at(path).is_ok() {
328-
have[3] = true;
329-
}
330-
}
331-
{
332-
let r = state.patch.lock();
333-
if r.at(path).is_ok() {
334-
have[4] = true;
335-
}
336-
}
337-
{
338-
let r = state.delete.lock();
339-
if r.at(path).is_ok() {
340-
have[5] = true;
341-
}
342-
}
343-
{
344-
let r = state.options.lock();
345-
if r.at(path).is_ok() {
346-
have[6] = true;
347-
}
348-
}
356+
let compiled = state.snapshot_routers();
357+
methods_matching_path_compiled(&compiled, path)
349358
}
350-
ORDER
351-
.iter()
352-
.zip(have)
353-
.filter(|(_, ok)| *ok)
354-
.map(|(m, _)| (*m).to_string())
355-
.collect()
356359
}
357360

358361
/// Returns route index and path params, or `None` if the method is unsupported; `Some(None)` if
359-
/// no match; `Some(Some)` on success. Uses [`CompiledRouters`] when set (lock-free).
362+
/// method is valid but path did not match.
360363
#[cfg(test)]
361364
#[allow(clippy::type_complexity)]
362365
fn match_route(
@@ -365,14 +368,7 @@ fn match_route(
365368
path: &str,
366369
) -> Option<Option<(usize, Vec<(String, String)>)>> {
367370
if let Some(c) = &state.compiled {
368-
let g = router_for_compiled(c, method)?;
369-
return Some(g.at(path).ok().map(|m| {
370-
let mut pmap = Vec::new();
371-
for (k, v) in m.params.iter() {
372-
pmap.push((k.to_string(), v.to_string()));
373-
}
374-
(*m.value, pmap)
375-
}));
371+
return match_route_compiled(c, method, path);
376372
}
377373
let g = map_method_router(state, method)?;
378374
Some(g.at(path).ok().map(|m| {
@@ -414,6 +410,11 @@ mod tests {
414410
fn methods_matching_path_uses_compiled() {
415411
let mut s = AppState::new();
416412
s.post.lock().insert("/x", 0usize).unwrap();
413+
s.path_method_masks
414+
.lock()
415+
.entry("/x".to_string())
416+
.or_default()
417+
.insert_method("POST");
417418
s.compiled = Some(Arc::new(s.snapshot_routers()));
418419
let m = methods_matching_path(&s, "/x");
419420
assert_eq!(m, vec!["POST".to_string()]);

0 commit comments

Comments
 (0)