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