Skip to content

Commit a6643de

Browse files
committed
Improve
1 parent 995081c commit a6643de

53 files changed

Lines changed: 3097 additions & 1871 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,31 @@ repository = "https://github.com/dev-five-git/vespera"
1111
readme = "README.md"
1212

1313
# Release profile tuned for the shipped artifacts (JNI cdylibs, server
14-
# binaries): thin LTO + single codegen unit trade longer release-build
15-
# time for faster/smaller production code.
14+
# binaries): FAT LTO + single codegen unit deliberately trade much
15+
# longer release-build time for maximum cross-crate inlining on the
16+
# runtime-critical paths (wire parse/serialize, dispatch, JNI
17+
# callbacks). Runtime performance of the shipped artifact is
18+
# prioritized over build time by project policy.
19+
#
20+
# `strip = "debuginfo"` shrinks the artifact without touching the
21+
# exported JNI dynamic symbols (they live in `.dynsym`, not the
22+
# stripped debug/local symbol table).
1623
#
1724
# NEVER switch the panic strategy away from unwinding here — the JNI
1825
# bridge relies on `catch_unwind` to convert handler panics into `500`
1926
# wire responses; aborting would take down the host JVM instead.
2027
[profile.release]
21-
lto = "thin"
28+
lto = "fat"
29+
codegen-units = 1
30+
strip = "debuginfo"
31+
32+
# Benchmarks must measure under the SAME codegen as the shipped release
33+
# artifacts (fat LTO + single codegen unit); otherwise the absolute
34+
# numbers reflect the default `bench` profile (no LTO, 16 codegen units)
35+
# instead of production. Build time is intentionally traded for
36+
# representative measurements.
37+
[profile.bench]
38+
lto = "fat"
2239
codegen-units = 1
2340

2441
[workspace.dependencies]

crates/vespera/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ tower = { version = "0.5", features = ["util"] }
8080
vespera_inprocess = { workspace = true }
8181
# Byte-snapshot testing for 422 validation envelope contract
8282
insta = "1.48"
83+
# `Validated<T>` 422-envelope serialization before/after A/B bench.
84+
criterion = { version = "0.8", features = ["html_reports"] }
85+
# `derive` for the `#[derive(Validate)]` fixture; `email`/`url` for its
86+
# field validators (the workspace `garde` is `default-features = false`).
87+
garde = { version = "0.23", features = ["derive", "email", "url"] }
88+
serde_json = "1"
89+
90+
[[bench]]
91+
name = "validation"
92+
harness = false
8393

8494
[lints]
8595
workspace = true
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//! VESPERA-04 before/after A/B benchmark for the `422 Unprocessable
2+
//! Entity` validation-envelope serialization.
3+
//!
4+
//! Both implementations serialize the **same** [`garde::Report`] to the
5+
//! **same** bytes (`{"errors":[{"message":...,"path":...},...]}`):
6+
//!
7+
//! - `before`: the original implementation — collect every error into an
8+
//! owned `Vec<ValidationErrorOut>` (two `String` allocations per error)
9+
//! and then `serde_json::to_vec`.
10+
//! - `after`: the shipped implementation — a fully-borrowing custom
11+
//! `Serialize` chain over `&garde::Report` (zero per-error `String`
12+
//! allocation, `collect_str` straight into the serializer).
13+
//!
14+
//! The delta is the per-error allocation cost VESPERA-04 removes. Both
15+
//! arms assert byte-identical output so the bench can never silently
16+
//! drift from the real envelope contract.
17+
18+
use std::fmt::Display;
19+
20+
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
21+
use garde::Validate;
22+
use serde::{Serialize, Serializer, ser::SerializeStruct};
23+
24+
// ── Fixture: a struct whose validation fails on every field ──────────
25+
26+
#[derive(Validate)]
27+
struct Sample {
28+
#[garde(length(min = 3, max = 32))]
29+
username: String,
30+
#[garde(email)]
31+
email: String,
32+
#[garde(range(min = 18, max = 120))]
33+
age: u8,
34+
#[garde(length(min = 10))]
35+
bio: String,
36+
#[garde(url)]
37+
homepage: String,
38+
}
39+
40+
/// Produce a [`garde::Report`] with `n` failing fields by validating a
41+
/// deliberately-invalid `Sample` and truncating the report's iteration
42+
/// in the benchmarked closures (we just validate the whole struct; it
43+
/// yields 5 errors — representative of a realistic multi-error 422).
44+
fn failing_report() -> garde::Report {
45+
let sample = Sample {
46+
username: "x".to_owned(), // too short
47+
email: "not-an-email".to_owned(), // invalid
48+
age: 200, // out of range
49+
bio: "short".to_owned(), // too short
50+
homepage: "nope".to_owned(), // invalid url
51+
};
52+
sample.validate().expect_err("sample must fail validation")
53+
}
54+
55+
// ── AFTER: shipped borrowing Serialize chain (mirror of validated.rs) ─
56+
57+
fn serialize_after(report: &garde::Report) -> Vec<u8> {
58+
struct DisplayValue<T>(T);
59+
impl<T: Display> Serialize for DisplayValue<T> {
60+
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
61+
s.collect_str(&self.0)
62+
}
63+
}
64+
struct Envelope<'a>(&'a garde::Report);
65+
impl Serialize for Envelope<'_> {
66+
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
67+
let mut env = s.serialize_struct("ValidationEnvelope", 1)?;
68+
env.serialize_field("errors", &Errors(self.0))?;
69+
env.end()
70+
}
71+
}
72+
struct Errors<'a>(&'a garde::Report);
73+
impl Serialize for Errors<'_> {
74+
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
75+
s.collect_seq(self.0.iter().map(|(path, err)| OneError { path, err }))
76+
}
77+
}
78+
struct OneError<'a> {
79+
path: &'a garde::Path,
80+
err: &'a garde::Error,
81+
}
82+
impl Serialize for OneError<'_> {
83+
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
84+
let mut e = s.serialize_struct("ValidationError", 2)?;
85+
e.serialize_field("message", &DisplayValue(self.err.message()))?;
86+
e.serialize_field("path", &DisplayValue(self.path))?;
87+
e.end()
88+
}
89+
}
90+
serde_json::to_vec(&Envelope(report)).expect("infallible")
91+
}
92+
93+
// ── BEFORE: original owned-Vec<String> implementation ────────────────
94+
95+
fn serialize_before(report: &garde::Report) -> Vec<u8> {
96+
#[derive(Serialize)]
97+
struct ValidationErrorOut {
98+
message: String,
99+
path: String,
100+
}
101+
#[derive(Serialize)]
102+
struct Envelope {
103+
errors: Vec<ValidationErrorOut>,
104+
}
105+
let errors: Vec<ValidationErrorOut> = report
106+
.iter()
107+
.map(|(path, err)| ValidationErrorOut {
108+
message: err.message().to_string(),
109+
path: path.to_string(),
110+
})
111+
.collect();
112+
serde_json::to_vec(&Envelope { errors }).expect("infallible")
113+
}
114+
115+
fn bench_validation_envelope(c: &mut Criterion) {
116+
let report = failing_report();
117+
118+
// Guard: the two implementations MUST produce identical bytes, so
119+
// the A/B compares the same observable work — never a shortcut.
120+
assert_eq!(
121+
serialize_before(&report),
122+
serialize_after(&report),
123+
"before/after 422 envelope bytes diverged"
124+
);
125+
126+
let n_errors = report.iter().count();
127+
let mut group = c.benchmark_group("validation_envelope");
128+
129+
group.bench_with_input(
130+
BenchmarkId::new("owned_vec_string_before", n_errors),
131+
&report,
132+
|b, report| b.iter(|| serialize_before(std::hint::black_box(report))),
133+
);
134+
group.bench_with_input(
135+
BenchmarkId::new("borrowing_serialize_after", n_errors),
136+
&report,
137+
|b, report| b.iter(|| serialize_after(std::hint::black_box(report))),
138+
);
139+
140+
group.finish();
141+
}
142+
143+
criterion_group!(benches, bench_validation_envelope);
144+
criterion_main!(benches);

crates/vespera/src/lib.rs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ where
6767
base: axum::Router<S>,
6868
/// Routers to merge after `with_state()` is called
6969
merge_fns: Vec<fn() -> axum::Router<()>>,
70+
/// Layers deferred until **after** child routers are merged.
71+
///
72+
/// Axum's `Router::layer` only wraps the routes present at call
73+
/// time, so applying a layer eagerly to `base` would leave
74+
/// `merge`d child routes un-layered (CORS / auth / trace silently
75+
/// skipped on merged routes). Storing the layer as a closure and
76+
/// replaying it in `with_state()` after the merge guarantees it
77+
/// covers every route. Each closure captures only the layer value
78+
/// (`L: Send + Sync`), so the boxed trait object stays `Send + Sync`
79+
/// and `VesperaRouter` keeps its previous auto-trait bounds.
80+
layers: Vec<Box<dyn FnOnce(axum::Router) -> axum::Router + Send + Sync>>,
7081
}
7182

7283
impl<S> VesperaRouter<S>
@@ -76,7 +87,11 @@ where
7687
/// Create a new `VesperaRouter` with a base router and routers to merge
7788
#[must_use]
7889
pub fn new(base: axum::Router<S>, merge_fns: Vec<fn() -> axum::Router<()>>) -> Self {
79-
Self { base, merge_fns }
90+
Self {
91+
base,
92+
merge_fns,
93+
layers: Vec::new(),
94+
}
8095
}
8196

8297
/// Provide the state for the router and merge all child routers.
@@ -96,12 +111,24 @@ where
96111
router = router.merge(merge_fn());
97112
}
98113

114+
// Finally replay the deferred layers AFTER the merge so they wrap
115+
// both the base routes and every merged child route. Applied in
116+
// insertion order, preserving Axum's "last layer is outermost"
117+
// semantics identical to chained `Router::layer` calls.
118+
for apply in self.layers {
119+
router = apply(router);
120+
}
121+
99122
router
100123
}
101124

102125
/// Add a layer to the router.
126+
///
127+
/// The layer is **deferred** and applied in [`with_state`](Self::with_state)
128+
/// after child routers are merged, so it covers merged routes as well as
129+
/// the base router.
103130
#[must_use]
104-
pub fn layer<L>(self, layer: L) -> Self
131+
pub fn layer<L>(mut self, layer: L) -> Self
105132
where
106133
L: tower_layer::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
107134
L::Service: tower_service::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
@@ -111,10 +138,9 @@ where
111138
Into<std::convert::Infallible> + 'static,
112139
<L::Service as tower_service::Service<axum::extract::Request>>::Future: Send + 'static,
113140
{
114-
Self {
115-
base: self.base.layer(layer),
116-
merge_fns: self.merge_fns,
117-
}
141+
self.layers
142+
.push(Box::new(move |router: axum::Router| router.layer(layer)));
143+
self
118144
}
119145
}
120146

0 commit comments

Comments
 (0)