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
/// with a clean error naming the budget (OAI-11).
pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result<Value, AdapterError> {
let r = self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0, 0);
if std::env::var("OAI11_TRACE").is_ok() {
eprintln!("resolve done");
}
r
self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0, 0)
}
fn resolve_refs_bounded(
@@ -525,9 +521,7 @@ struct RefResolution {
fn count_nodes(value: &Value) -> usize {
match value {
Value::Object(map) => {
1 + map.values().map(count_nodes).sum::<usize>()
}
Value::Object(map) => 1 + map.values().map(count_nodes).sum::<usize>(),
Value::Array(items) => 1 + items.iter().map(count_nodes).sum::<usize>(),
_ => 1,
}
@@ -681,7 +675,7 @@ mod tests {
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!({
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
@@ -948,10 +942,7 @@ mod tests {
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("node budget"),
"message was: {message}"
);
assert!(message.contains("node budget"), "message was: {message}");
}
other => panic!("expected node-budget SchemaParse error, got {other:?}"),
}
@@ -982,7 +973,7 @@ mod tests {
.resolve_refs_recursive(&schema)
.expect("acyclic chain resolves");
let mut cursor = &resolved;
for _ in 1..levels {
for _ in 1..=levels {
cursor = &cursor["next"];
}
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"];
}
}
}