Skip to content

Commit 74cff02

Browse files
committed
Decrease reallocs
1 parent e650330 commit 74cff02

3 files changed

Lines changed: 89 additions & 12 deletions

File tree

crates/vespera_inprocess/src/wire.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,31 @@ pub fn header_capacity_estimate(headers: &http::HeaderMap, metadata: &ResponseMe
302302
est
303303
}
304304

305+
/// Cheap upper-ish estimate of the serialized `validation_errors` JSON
306+
/// array byte length, added to the response-`Vec` capacity **only on the
307+
/// 422 path** (`validation_errors` is `None` for every other status, so the
308+
/// hot success path pays nothing). Each item renders as
309+
/// `{"path":"…","code":"…","message":"…"},` inside the
310+
/// `,"validation_errors":[…]` wrapper — count the field bytes plus a fixed
311+
/// per-item scaffold. A safe over-estimate (absent `code`/`message` only
312+
/// shrink the real output), so it only ever prevents the mid-serialize
313+
/// realloc the hoisted errors would otherwise force; it never changes the
314+
/// emitted bytes.
315+
fn validation_errors_capacity_estimate(items: &[ValidationErrorItem]) -> usize {
316+
// `,"validation_errors":[]` wrapper, plus per item the
317+
// `{"path":"","code":"","message":""},` scaffold.
318+
const WRAPPER: usize = 24;
319+
const ITEM_SCAFFOLD: usize = 36;
320+
let mut est = WRAPPER;
321+
for item in items {
322+
est += ITEM_SCAFFOLD
323+
+ item.path.len()
324+
+ item.code.as_deref().map_or(0, str::len)
325+
+ item.message.as_deref().map_or(0, str::len);
326+
}
327+
est
328+
}
329+
305330
/// Append `[u32 BE header_len | header JSON]` to `out`. Returns `false`
306331
/// when the serialized header JSON exceeds `u32::MAX` bytes — unreachable for
307332
/// any real `HeaderMap` (4 GiB of header JSON), so callers map it to a `500`
@@ -407,12 +432,19 @@ pub fn to_wire_bytes(parts: ResponseParts) -> Vec<u8> {
407432
} else {
408433
None
409434
};
410-
let header_cap = header_capacity_estimate(&headers, &metadata).max(WIRE_HEADER_RESERVE);
435+
let header_cap = header_capacity_estimate(&headers, &metadata).max(WIRE_HEADER_RESERVE)
436+
+ validation_errors
437+
.as_deref()
438+
.map_or(0, validation_errors_capacity_estimate);
411439
// `4 + header_cap + body_bytes.len()` cannot overflow `usize` on a
412440
// 64-bit target (it would require a multi-exabyte body); plain `+` is
413441
// used so the hot response path keeps its exact arithmetic — a
414442
// `saturating_add` variant was benchmarked and cost ~2-3% on the small
415443
// `wire_path`/`request_headers_path` cases for zero real-world benefit.
444+
// The `validation_errors` term is `0` for every non-422 response (the hot
445+
// success path is byte-for-byte unchanged); on the 422 path it sizes the
446+
// `Vec` to serialise the hoisted errors without the mid-write realloc a
447+
// hoist-blind estimate paid (locked by tests/alloc_budget.rs case F).
416448
let mut out = Vec::with_capacity(4 + header_cap + body_bytes.len());
417449
if !write_wire_header_into(
418450
&mut out,

crates/vespera_inprocess/tests/alloc_budget.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,33 @@ async fn echo(body: Bytes) -> Bytes {
7676
body
7777
}
7878

79+
/// Returns a `422` with a JSON `{"errors":[...]}` body so the wire path
80+
/// exercises the `to_wire_bytes` 422 `validation_errors` hoist plus the
81+
/// header-capacity estimate. Two realistic errors push the hoisted header
82+
/// JSON past the `WIRE_HEADER_RESERVE` floor, so a build whose capacity
83+
/// estimate ignores the hoisted errors reallocates once mid-serialize; the
84+
/// validation-errors capacity estimate removes that realloc.
85+
async fn validate_fail() -> axum::response::Response {
86+
use axum::response::IntoResponse;
87+
(
88+
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
89+
[(
90+
axum::http::header::CONTENT_TYPE,
91+
axum::http::HeaderValue::from_static("application/json"),
92+
)],
93+
r#"{"errors":[{"path":"username","message":"length is lower than 3"},{"path":"email","message":"not a valid email"}]}"#,
94+
)
95+
.into_response()
96+
}
97+
7998
fn install() {
8099
static INIT: Once = Once::new();
81100
INIT.call_once(|| {
82101
register_app(|| {
83102
Router::new()
84103
.route("/ping", get(ping))
85104
.route("/echo", post(echo))
105+
.route("/validate", post(validate_fail))
86106
});
87107
});
88108
}
@@ -209,6 +229,21 @@ fn allocation_budgets() {
209229
let _ = dispatch_into(wire_get.clone(), &mut out, &rt);
210230
});
211231

232+
// ── Case F: 422 JSON response via the buffered materialise path. The
233+
// `to_wire_bytes` 422 path hoists `{"errors":[...]}` into the wire
234+
// header's `validation_errors`. With the hoisted-errors length folded
235+
// into the capacity estimate the response `Vec` is sized to serialise the
236+
// header in one shot — the realloc a hoist-blind estimate paid is gone.
237+
let wire_validate = encode(
238+
"POST",
239+
"/validate",
240+
&[("content-type", "application/json")],
241+
br#"{"x":1}"#,
242+
);
243+
let validate_422 = measure(200, 2000, || {
244+
let _ = dispatch_from_bytes(wire_validate.clone(), &rt);
245+
});
246+
212247
// (label, sample, budget). The gate metric is total per-op allocation
213248
// OPS (`alloc` + `realloc` calls) — the deterministic, noise-free
214249
// figure; bytes/op is informational only.
@@ -234,6 +269,7 @@ fn allocation_budgets() {
234269
&direct_into,
235270
BUDGET_DISPATCH_INTO,
236271
),
272+
("F 422-validate materialise", &validate_422, BUDGET_VALIDATE_422),
237273
];
238274

239275
// Print every case first so a regression failure still shows the full
@@ -282,3 +318,8 @@ const BUDGET_HEADERS_POST: usize = 40; // borrowed: 40 alloc + 0 realloc (header
282318
// path copy (or any other owned-path allocation) trips these tightened budgets.
283319
const BUDGET_MATERIALISE: usize = 16; // dispatch_from_bytes: +input clone +response Vec, URI shared
284320
const BUDGET_DISPATCH_INTO: usize = 15; // dispatch_into: +input clone, reused out, URI shared
321+
// 422 materialise path: the hoisted `validation_errors` JSON is now folded
322+
// into the response-`Vec` capacity estimate, so the wire header serialises
323+
// without the mid-write realloc a hoist-blind estimate paid (26 alloc + 0
324+
// realloc; was 26 alloc + 1 realloc). A re-introduced realloc trips this.
325+
const BUDGET_VALIDATE_422: usize = 26; // realloc-free 422 hoist (was 27 w/ realloc)

crates/vespera_macro/src/schema_macro/file_cache.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -678,15 +678,19 @@ fn ensure_path_lookup_caches_fresh(cache: &mut FileCache) {
678678
/// The `Arc` makes cache hits O(1) instead of cloning the full struct
679679
/// definition text per lookup.
680680
///
681-
/// The cache is scoped to the current epoch
682-
/// ([`ensure_path_lookup_caches_fresh`]): a new top-level macro invocation
683-
/// drops prior entries so an edited model file is re-resolved (correctness
684-
/// for long-lived rust-analyzer servers), while repeated lookups within the
685-
/// same invocation still hit the cache.
681+
/// The cache **survives epoch bumps** (see
682+
/// [`ensure_path_lookup_caches_fresh`]): entries key on a schema PATH string,
683+
/// and a cache MISS re-resolves through the lower file-content /
684+
/// struct-definition mtime caches — so within one `cargo build` (no source
685+
/// file changes mid-build) a surviving entry only ever returns the result a
686+
/// re-resolution would produce, while keeping repeated lookups O(1). A
687+
/// long-lived rust-analyzer proc-macro server therefore keeps a resolved
688+
/// entry until the server restarts — the documented cost of the shared-work
689+
/// optimisation (a future mtime-aware path cache could be warm AND fresh).
686690
pub fn get_struct_from_schema_path(path_str: &str) -> Option<Arc<StructMetadata>> {
687-
// Drop stale (pre-edit) entries when the epoch advanced, then read this
688-
// epoch's cache. The borrow ends before the lookup below, which
689-
// re-enters FILE_CACHE.
691+
// Re-stamp the path-lookup epoch (entries deliberately SURVIVE bumps — see
692+
// `ensure_path_lookup_caches_fresh`), then read the cache. The borrow ends
693+
// before the lookup below, which re-enters FILE_CACHE.
690694
let cached = FILE_CACHE.with(|cache| {
691695
let mut cache = cache.borrow_mut();
692696
ensure_path_lookup_caches_fresh(&mut cache);
@@ -716,9 +720,9 @@ pub fn get_struct_from_schema_path(path_str: &str) -> Option<Arc<StructMetadata>
716720
pub fn get_fk_column(schema_path: &str, via_rel: &str) -> Option<String> {
717721
let key = (schema_path.to_string(), via_rel.to_string());
718722

719-
// Drop stale entries when the epoch advanced, then read this epoch's
720-
// cache. The borrow ends before the lookup below, which re-enters
721-
// FILE_CACHE.
723+
// Re-stamp the path-lookup epoch (entries deliberately SURVIVE bumps — see
724+
// `ensure_path_lookup_caches_fresh`), then read this epoch's cache. The
725+
// borrow ends before the lookup below, which re-enters FILE_CACHE.
722726
let cached = FILE_CACHE.with(|cache| {
723727
let mut cache = cache.borrow_mut();
724728
ensure_path_lookup_caches_fresh(&mut cache);

0 commit comments

Comments
 (0)