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:
@@ -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"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user