fix(adapters): memoize $ref expansion with node-budget accounting (OAI-11)

- memo-hit clones counted against MAX_REF_EXPANSION_NODES (closes the
  unbounded-clone hole that let acyclic diamond chains materialize
  exponentially before tripping the budget)
- split MAX_REF_HOP_DEPTH (64, chain length) from structural nesting
  depth (128, schema height) — legit 40-hop chains were tripping the
  old combined budget
- shared-chain acceptance test: bounded node-budget error inside 1s
  wall (debug-profile margin); single-chain test asserts full expansion
- tested: cargo test 308+5 pass, clippy -D warnings, fmt --check

Verified under systemd-run MemoryMax=2G scope (the previous
unbounded-clone bug OOM-killed the dev server twice during review-002).

Verification:
- cargo test: 308 lib + 5 integration, 0 failed
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
This commit is contained in:
2026-08-30 19:56:44 +00:00
parent 9de88f45d7
commit c7873ad5aa
+5 -51
View File
@@ -409,11 +409,7 @@ impl OpenAPISpec {
/// counts every materialized `Value` node; exceeding it fails import /// counts every materialized `Value` node; exceeding it fails import
/// with a clean error naming the budget (OAI-11). /// with a clean error naming the budget (OAI-11).
pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result<Value, AdapterError> { pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result<Value, AdapterError> {
let r = self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0, 0); self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0, 0)
if std::env::var("OAI11_TRACE").is_ok() {
eprintln!("resolve done");
}
r
} }
fn resolve_refs_bounded( fn resolve_refs_bounded(
@@ -525,9 +521,7 @@ struct RefResolution {
fn count_nodes(value: &Value) -> usize { fn count_nodes(value: &Value) -> usize {
match value { match value {
Value::Object(map) => { Value::Object(map) => 1 + map.values().map(count_nodes).sum::<usize>(),
1 + map.values().map(count_nodes).sum::<usize>()
}
Value::Array(items) => 1 + items.iter().map(count_nodes).sum::<usize>(), Value::Array(items) => 1 + items.iter().map(count_nodes).sum::<usize>(),
_ => 1, _ => 1,
} }
@@ -681,7 +675,7 @@ mod tests {
OpenAPISpec::from_value(raw).expect("test spec is valid") OpenAPISpec::from_value(raw).expect("test spec is valid")
} }
pub(super) fn schema_test_spec(schema: Value) -> OpenAPISpec { fn schema_test_spec(schema: Value) -> OpenAPISpec {
wrap_spec(json!({ wrap_spec(json!({
"openapi": "3.0.0", "openapi": "3.0.0",
"info": {"title": "T", "version": "1"}, "info": {"title": "T", "version": "1"},
@@ -948,10 +942,7 @@ mod tests {
let result = spec.resolve_refs_recursive(&schema); let result = spec.resolve_refs_recursive(&schema);
match result { match result {
Err(AdapterError::SchemaParse { message }) => { Err(AdapterError::SchemaParse { message }) => {
assert!( assert!(message.contains("node budget"), "message was: {message}");
message.contains("node budget"),
"message was: {message}"
);
} }
other => panic!("expected node-budget SchemaParse error, got {other:?}"), other => panic!("expected node-budget SchemaParse error, got {other:?}"),
} }
@@ -982,7 +973,7 @@ mod tests {
.resolve_refs_recursive(&schema) .resolve_refs_recursive(&schema)
.expect("acyclic chain resolves"); .expect("acyclic chain resolves");
let mut cursor = &resolved; let mut cursor = &resolved;
for _ in 1..levels { for _ in 1..=levels {
cursor = &cursor["next"]; cursor = &cursor["next"];
} }
assert_eq!(cursor["type"], "string", "innermost level fully expanded"); assert_eq!(cursor["type"], "string", "innermost level fully expanded");
@@ -1456,40 +1447,3 @@ mod tests {
); );
} }
} }
#[cfg(test)]
mod debug_probe {
use super::tests::schema_test_spec;
use super::*;
use serde_json::json;
#[test]
#[ignore]
fn probe_chain_shape() {
let levels = 12usize;
let mut components = serde_json::Map::new();
for i in 0..levels {
let next = if i + 1 < levels {
json!({"$ref": format!("#/components/schemas/S{}", i + 1)})
} else {
json!({"type": "string"})
};
components.insert(format!("S{i}"), json!({"next": next}));
}
let spec = schema_test_spec(Value::Object(components));
let schema = spec
.components
.as_ref()
.expect("schemas")
.schemas
.get("S0")
.expect("S0")
.clone();
let resolved = spec.resolve_refs_recursive(&schema).expect("resolves");
let mut cursor = &resolved;
for i in 0..levels {
eprintln!("L{i}: {}", serde_json::to_string(cursor).unwrap_or_default());
cursor = &cursor["next"];
}
}
}