Skip to content

Commit 2bd284c

Browse files
committed
Optimize gen openapi
1 parent 1047907 commit 2bd284c

9 files changed

Lines changed: 396 additions & 56 deletions

File tree

crates/vespera_macro/src/vespera_impl/cache.rs

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,24 @@ use crate::{
1414

1515
use super::path_utils::{current_crate_tag, find_target_dir};
1616

17+
/// Current cache format. Bump when the on-disk layout changes —
18+
/// old caches deserialize with `cache_format: 0` (serde default) and
19+
/// are treated as a miss.
20+
pub(super) const CACHE_FORMAT: u32 = 1;
21+
1722
/// Cache for avoiding redundant route scanning and OpenAPI generation.
1823
/// Persisted to `target/vespera/routes.cache` across builds.
24+
///
25+
/// The spec JSON strings themselves live in **sidecar files** (the
26+
/// `include_str!` embed file and the pretty sidecar) — the cache only
27+
/// stores their content hashes. Embedding them inline as JSON strings
28+
/// doubled the cache size via escaping and dominated warm-rebuild
29+
/// `read_cache` time.
1930
#[derive(Serialize, Deserialize)]
2031
pub(super) struct VesperaCache {
32+
/// On-disk layout version — see [`CACHE_FORMAT`].
33+
#[serde(default)]
34+
pub(super) cache_format: u32,
2135
/// Macro crate version — invalidates cache when macro code changes
2236
#[serde(default)]
2337
pub(super) macro_version: String,
@@ -35,10 +49,21 @@ pub(super) struct VesperaCache {
3549
pub(super) config_hash: u64,
3650
/// Cached route/struct metadata
3751
pub(super) metadata: CollectedMetadata,
38-
/// Compact JSON for docs embedding (None if docs disabled)
39-
pub(super) spec_json: Option<String>,
40-
/// Pretty JSON for file output (None if no openapi file configured)
41-
pub(super) spec_pretty: Option<String>,
52+
/// Content hash of the compact spec in the embed sidecar file
53+
/// (`vespera_spec-<tag>.json`). `None` if docs disabled.
54+
#[serde(default)]
55+
pub(super) spec_json_hash: Option<u64>,
56+
/// Content hash of the pretty spec in the pretty sidecar file
57+
/// (`openapi_pretty-<tag>.json`). `None` if no openapi file configured.
58+
#[serde(default)]
59+
pub(super) spec_pretty_hash: Option<u64>,
60+
}
61+
62+
/// Deterministic content hash for sidecar spec validation.
63+
pub(super) fn hash_str(s: &str) -> u64 {
64+
let mut hasher = std::collections::hash_map::DefaultHasher::new();
65+
s.hash(&mut hasher);
66+
hasher.finish()
4267
}
4368

4469
/// Compute a deterministic hash of SCHEMA_STORAGE contents.
@@ -94,6 +119,15 @@ pub(super) fn get_cache_path() -> std::path::PathBuf {
94119
/// every `.rs` mtime in it; for downstream users the directory is
95120
/// absent and this is a single failed `stat` (returns 0).
96121
pub(super) fn compute_macro_dev_fingerprint() -> u64 {
122+
// Memoized per proc-macro process: macro source mtimes cannot change
123+
// the dll that is currently executing, so one scan per process is
124+
// exactly as precise as one scan per invocation. (A fresh cargo
125+
// build of vespera_macro loads a fresh dll → fresh process state.)
126+
static MEMO: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
127+
*MEMO.get_or_init(compute_macro_dev_fingerprint_uncached)
128+
}
129+
130+
fn compute_macro_dev_fingerprint_uncached() -> u64 {
97131
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
98132
let target_dir = find_target_dir(Path::new(&manifest_dir));
99133
let Some(workspace_root) = target_dir.parent() else {
@@ -118,6 +152,10 @@ pub(super) fn compute_macro_dev_fingerprint() -> u64 {
118152
}
119153

120154
/// Recursively collect `(path, mtime)` pairs for `.rs` files.
155+
///
156+
/// Uses `DirEntry::metadata()` (not `fs::metadata(&path)`): on Windows
157+
/// the entry already carries the `FindNextFile` data, so this avoids a
158+
/// second `stat` syscall per file.
121159
fn collect_rs_mtimes(dir: &Path, out: &mut Vec<(String, u64)>) {
122160
let Ok(read_dir) = std::fs::read_dir(dir) else {
123161
return;
@@ -127,13 +165,11 @@ fn collect_rs_mtimes(dir: &Path, out: &mut Vec<(String, u64)>) {
127165
if path.is_dir() {
128166
collect_rs_mtimes(&path, out);
129167
} else if path.extension().is_some_and(|e| e == "rs") {
130-
let mtime = std::fs::metadata(&path)
131-
.and_then(|m| m.modified())
132-
.map_or(0, |t| {
133-
t.duration_since(std::time::UNIX_EPOCH)
134-
.unwrap_or_default()
135-
.as_secs()
136-
});
168+
let mtime = entry.metadata().and_then(|m| m.modified()).map_or(0, |t| {
169+
t.duration_since(std::time::UNIX_EPOCH)
170+
.unwrap_or_default()
171+
.as_secs()
172+
});
137173
out.push((path.display().to_string(), mtime));
138174
}
139175
}
@@ -238,4 +274,49 @@ mod tests {
238274
"Merge paths should affect config hash"
239275
);
240276
}
277+
278+
#[test]
279+
fn test_read_cache_corrupt_file_returns_none() {
280+
let dir = tempfile::TempDir::new().unwrap();
281+
let path = dir.path().join("routes.cache");
282+
std::fs::write(&path, "{not valid json").unwrap();
283+
assert!(read_cache(&path).is_none(), "corrupt cache must be a miss");
284+
}
285+
286+
#[test]
287+
fn test_read_cache_missing_file_returns_none() {
288+
let dir = tempfile::TempDir::new().unwrap();
289+
assert!(read_cache(&dir.path().join("nope.cache")).is_none());
290+
}
291+
292+
#[test]
293+
fn test_old_format_cache_deserializes_with_format_zero() {
294+
// A pre-sidecar cache (inline spec strings, no cache_format
295+
// field) must still parse — with cache_format defaulting to 0
296+
// so the orchestrator's `== CACHE_FORMAT` check misses.
297+
let dir = tempfile::TempDir::new().unwrap();
298+
let path = dir.path().join("routes.cache");
299+
let old_format = serde_json::json!({
300+
"macro_version": "0.1.0",
301+
"macro_dev_fingerprint": 1u64,
302+
"file_fingerprints": {},
303+
"schema_hash": 2u64,
304+
"config_hash": 3u64,
305+
"metadata": { "routes": [], "structs": [] },
306+
"spec_json": "{\"openapi\":\"3.1.0\"}",
307+
"spec_pretty": "{\n \"openapi\": \"3.1.0\"\n}"
308+
});
309+
std::fs::write(&path, old_format.to_string()).unwrap();
310+
let cache = read_cache(&path).expect("old format must still deserialize");
311+
assert_eq!(cache.cache_format, 0, "missing field defaults to 0");
312+
assert_ne!(cache.cache_format, CACHE_FORMAT, "format check must miss");
313+
assert!(cache.spec_json_hash.is_none());
314+
assert!(cache.spec_pretty_hash.is_none());
315+
}
316+
317+
#[test]
318+
fn test_hash_str_deterministic_and_content_sensitive() {
319+
assert_eq!(hash_str("abc"), hash_str("abc"));
320+
assert_ne!(hash_str("abc"), hash_str("abd"));
321+
}
241322
}

crates/vespera_macro/src/vespera_impl/openapi_io.rs

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -131,33 +131,117 @@ pub fn ensure_openapi_files_from_cache(
131131
Ok(())
132132
}
133133

134-
/// Write compact spec JSON to target dir for `include_str!` embedding.
134+
/// Path of the compact-spec embed sidecar (`include_str!` target).
135135
///
136136
/// The file name is **namespaced per crate**: two workspace members
137137
/// both using `vespera!` compile in parallel under the same shared
138138
/// `target/vespera/` directory — with a single shared file name, crate
139139
/// A's `include_str!` could read the spec crate B just wrote.
140+
pub(super) fn embed_spec_path() -> std::path::PathBuf {
141+
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
142+
find_target_dir(Path::new(&manifest_dir))
143+
.join("vespera")
144+
.join(format!("vespera_spec-{}.json", current_crate_tag()))
145+
}
146+
147+
/// Path of the pretty-spec sidecar (warm-rebuild source for
148+
/// `openapi.json` recovery — see `ensure_openapi_files_from_cache`).
149+
pub(super) fn pretty_sidecar_path() -> std::path::PathBuf {
150+
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
151+
find_target_dir(Path::new(&manifest_dir))
152+
.join("vespera")
153+
.join(format!("openapi_pretty-{}.json", current_crate_tag()))
154+
}
155+
156+
/// Build the `include_str!` tokens pointing at the embed sidecar.
157+
fn embed_tokens(spec_file: &Path) -> proc_macro2::TokenStream {
158+
let path_str = spec_file.display().to_string().replace('\\', "/");
159+
quote::quote! { include_str!(#path_str) }
160+
}
161+
162+
/// Hash-validated sidecar specs loaded on a warm cache hit.
163+
pub(super) struct SidecarSpecs {
164+
/// Pretty spec content (for `openapi.json` recovery); `None` when
165+
/// no openapi file is configured.
166+
pub(super) pretty: Option<String>,
167+
/// `include_str!` tokens for the embed sidecar; `None` when docs
168+
/// are disabled.
169+
pub(super) spec_tokens: Option<proc_macro2::TokenStream>,
170+
}
171+
172+
/// Load and hash-validate the sidecar spec files on a warm cache hit.
173+
///
174+
/// Returns `None` when any expected sidecar is missing or fails its
175+
/// content-hash check — the caller must then treat the cache as a miss
176+
/// (a full regeneration rewrites both sidecars, so corruption
177+
/// self-heals on the next build).
178+
pub(super) fn load_validated_sidecar_specs(
179+
spec_json_hash: Option<u64>,
180+
spec_pretty_hash: Option<u64>,
181+
) -> Option<SidecarSpecs> {
182+
let spec_tokens = match spec_json_hash {
183+
None => None,
184+
Some(expected) => {
185+
let path = embed_spec_path();
186+
let content = std::fs::read_to_string(&path).ok()?;
187+
if super::cache::hash_str(&content) != expected {
188+
return None;
189+
}
190+
Some(embed_tokens(&path))
191+
}
192+
};
193+
let pretty = match spec_pretty_hash {
194+
None => None,
195+
Some(expected) => {
196+
let content = std::fs::read_to_string(pretty_sidecar_path()).ok()?;
197+
if super::cache::hash_str(&content) != expected {
198+
return None;
199+
}
200+
Some(content)
201+
}
202+
};
203+
Some(SidecarSpecs {
204+
pretty,
205+
spec_tokens,
206+
})
207+
}
208+
209+
/// Write the pretty-spec sidecar (write-if-differs). Best-effort like
210+
/// the cache itself: failures only cost a future cache miss.
211+
pub(super) fn write_pretty_sidecar(spec_pretty: Option<&str>) {
212+
let Some(pretty) = spec_pretty else {
213+
return;
214+
};
215+
let path = pretty_sidecar_path();
216+
if let Some(parent) = path.parent() {
217+
let _ = std::fs::create_dir_all(parent);
218+
}
219+
let should_write = std::fs::read_to_string(&path).map_or(true, |existing| existing != pretty);
220+
if should_write {
221+
let _ = std::fs::write(&path, pretty);
222+
}
223+
}
224+
225+
/// Write compact spec JSON to target dir for `include_str!` embedding.
140226
pub(super) fn write_spec_for_embedding(
141227
spec_json: Option<String>,
142228
) -> syn::Result<Option<proc_macro2::TokenStream>> {
143229
let Some(json) = spec_json else {
144230
return Ok(None);
145231
};
146-
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
147-
let manifest_path = Path::new(&manifest_dir);
148-
let target_dir = find_target_dir(manifest_path);
149-
let vespera_dir = target_dir.join("vespera");
150-
std::fs::create_dir_all(&vespera_dir).map_err(|e| {
151-
syn::Error::new(
152-
Span::call_site(),
153-
format!(
154-
"vespera! macro: failed to create directory '{}': {}",
155-
vespera_dir.display(),
156-
e
157-
),
158-
)
159-
})?;
160-
let spec_file = vespera_dir.join(format!("vespera_spec-{}.json", current_crate_tag()));
232+
let spec_file = embed_spec_path();
233+
if let Some(parent) = spec_file.parent() {
234+
std::fs::create_dir_all(parent).map_err(|e| {
235+
syn::Error::new(
236+
Span::call_site(),
237+
format!(
238+
"vespera! macro: failed to create directory '{}': {}",
239+
parent.display(),
240+
e
241+
),
242+
)
243+
})?;
244+
}
161245
let should_write =
162246
std::fs::read_to_string(&spec_file).map_or(true, |existing| existing != json);
163247
if should_write {
@@ -172,8 +256,7 @@ pub(super) fn write_spec_for_embedding(
172256
)
173257
})?;
174258
}
175-
let path_str = spec_file.display().to_string().replace('\\', "/");
176-
Ok(Some(quote::quote! { include_str!(#path_str) }))
259+
Ok(Some(embed_tokens(&spec_file)))
177260
}
178261

179262
#[cfg(test)]

0 commit comments

Comments
 (0)