cache: add graphql query response caching

This commit is contained in:
a.tolmachev
2026-05-04 09:04:27 +00:00
parent 6cec445b4f
commit b2c5e28cba
9 changed files with 271 additions and 12 deletions
+1
View File
@@ -65,6 +65,7 @@ pub struct CachedResponse {
pub status: u16,
pub headers: Vec<CachedHeader>,
pub body: Vec<u8>,
pub data: Vec<u8>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+3
View File
@@ -679,6 +679,7 @@ mod tests {
value: "application/json".to_owned(),
}],
body: br#"{"ok":true}"#.to_vec(),
data: br#"{"ok":true}"#.to_vec(),
};
store
@@ -825,6 +826,7 @@ mod tests {
status: 200,
headers: vec![],
body: b"ok".to_vec(),
data: br#"null"#.to_vec(),
},
Duration::from_secs(5),
)
@@ -868,6 +870,7 @@ mod tests {
value: "hit".to_owned(),
}],
body: br#"{"queued":true}"#.to_vec(),
data: br#"null"#.to_vec(),
};
let encoded = serde_json::to_vec(&response).unwrap();
+159 -5
View File
@@ -757,10 +757,12 @@ fn log_runtime_event(
}
fn response_cache_ttl(operation: &RuntimeOperation) -> Option<Duration> {
if !matches!(
&operation.target,
Target::Rest(target) if target.method == HttpMethod::Get
) {
let is_cacheable_protocol = match &operation.target {
Target::Rest(target) => target.method == HttpMethod::Get,
Target::Graphql(target) => target.operation_type == crank_core::GraphqlOperationType::Query,
_ => false,
};
if !is_cacheable_protocol {
return None;
}
@@ -784,6 +786,8 @@ fn response_cache_key(
"query_params": prepared_request.query_params,
"headers": prepared_request.headers,
"body": prepared_request.body,
"variables": prepared_request.variables,
"grpc": prepared_request.grpc,
});
let fingerprint_bytes = serde_json::to_vec(&fingerprint).ok()?;
let fingerprint_hash = URL_SAFE_NO_PAD.encode(Sha256::digest(fingerprint_bytes));
@@ -811,6 +815,7 @@ fn cached_response_from_adapter(adapter_response: &AdapterResponse) -> Option<Ca
})
.collect(),
body,
data: serde_json::to_vec(&adapter_response.data).ok()?,
})
}
@@ -819,6 +824,8 @@ fn adapter_response_from_cached(
) -> Result<AdapterResponse, String> {
let body: Value =
serde_json::from_slice(&cached_response.body).map_err(|error| error.to_string())?;
let data: Value =
serde_json::from_slice(&cached_response.data).map_err(|error| error.to_string())?;
Ok(AdapterResponse {
status_code: cached_response.status,
headers: cached_response
@@ -827,7 +834,7 @@ fn adapter_response_from_cached(
.map(|header| (header.name, header.value))
.collect(),
body,
data: Value::Null,
data,
})
}
@@ -1089,6 +1096,40 @@ mod tests {
assert_eq!(request_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn caches_graphql_query_responses_within_agent_scope() {
let request_count = Arc::new(AtomicUsize::new(0));
let endpoint = spawn_cached_graphql_server(Arc::clone(&request_count)).await;
let executor = RuntimeExecutor::new()
.with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default()));
let operation = test_cached_graphql_query_operation(&endpoint);
let first_context = RuntimeRequestContext::from_request_id("req_cache_graphql_1")
.with_response_cache_scope("ws_cache", "agent_sales");
let second_context = RuntimeRequestContext::from_request_id("req_cache_graphql_2")
.with_response_cache_scope("ws_cache", "agent_sales");
let first_output = executor
.execute_with_context(
&operation,
&json!({ "email": "user@example.com" }),
Some(&first_context),
)
.await
.unwrap();
let second_output = executor
.execute_with_context(
&operation,
&json!({ "email": "user@example.com" }),
Some(&second_context),
)
.await
.unwrap();
assert_eq!(first_output, json!({ "id": "lead_123" }));
assert_eq!(second_output, json!({ "id": "lead_123" }));
assert_eq!(request_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn emits_runtime_tracing_with_request_context() {
let base_url = spawn_runtime_server().await;
@@ -1607,6 +1648,41 @@ mod tests {
format!("http://{}", address)
}
async fn spawn_cached_graphql_server(request_count: Arc<AtomicUsize>) -> String {
let app = Router::new().route(
"/",
post(move |Json(payload): Json<Value>| {
let request_count = Arc::clone(&request_count);
async move {
let count = request_count.fetch_add(1, Ordering::SeqCst) + 1;
let email = payload
.get("variables")
.and_then(|variables| variables.get("email"))
.and_then(Value::as_str)
.unwrap_or_default();
Json(json!({
"data": {
"lookupLead": {
"id": "lead_123",
"email": email,
"request_count": count,
}
}
}))
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{}", address)
}
async fn spawn_graphql_context_server() -> String {
let app = Router::new().route("/", post(graphql_context_handler));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -2090,6 +2166,84 @@ mod tests {
})
}
fn test_cached_graphql_query_operation(endpoint: &str) -> RuntimeOperation {
RuntimeOperation::from(Operation {
id: OperationId::new("op_graphql_cached_runtime"),
name: "crm_lookup_lead_graphql".to_owned(),
display_name: "Lookup Lead GraphQL".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Graphql,
security_level: OperationSecurityLevel::Standard,
status: OperationStatus::Published,
version: 1,
target: Target::Graphql(GraphqlTarget {
endpoint: endpoint.to_owned(),
operation_type: GraphqlOperationType::Query,
operation_name: "LookupLead".to_owned(),
query_template:
"query LookupLead($email: String!) { lookupLead(email: $email) { id email request_count } }"
.to_owned(),
response_path: "$.response.body.data.lookupLead".to_owned(),
}),
input_schema: object_schema("email", SchemaKind::String),
output_schema: object_schema("id", SchemaKind::String),
input_mapping: MappingSet {
rules: vec![MappingRule {
source: "$.mcp.email".to_owned(),
target: "$.request.variables.email".to_owned(),
required: true,
default_value: None,
transform: None,
condition: None,
notes: None,
}],
},
output_mapping: MappingSet {
rules: vec![MappingRule {
source: "$.response.data.id".to_owned(),
target: "$.output.id".to_owned(),
required: true,
default_value: None,
transform: None,
condition: None,
notes: None,
}],
},
execution_config: ExecutionConfig {
timeout_ms: 1_000,
retry_policy: None,
response_cache: Some(crank_core::ResponseCachePolicy { ttl_ms: 5_000 }),
auth_profile_ref: None,
headers: BTreeMap::new(),
protocol_options: None,
streaming: None,
},
tool_description: ToolDescription {
title: "Lookup Lead GraphQL".to_owned(),
description: "Reads a CRM lead through GraphQL".to_owned(),
tags: vec!["crm".to_owned(), "graphql".to_owned()],
examples: vec![ToolExample {
input: json!({ "email": "user@example.com" }),
}],
},
samples: Some(Samples::default()),
generated_draft: Some(GeneratedDraft {
status: GeneratedDraftStatus::Available,
source_types: vec!["input_json".to_owned()],
generated_at: Some("2026-03-25T20:00:00Z".to_owned()),
input_schema_generated: true,
output_schema_generated: true,
input_mapping_generated: true,
output_mapping_generated: true,
warnings: Vec::new(),
}),
config_export: None,
created_at: timestamp("2026-03-25T20:00:00Z"),
updated_at: timestamp("2026-03-25T20:00:00Z"),
published_at: Some(timestamp("2026-03-25T20:00:00Z")),
})
}
fn test_grpc_operation(server_addr: &str) -> RuntimeOperation {
RuntimeOperation::from(Operation {
id: OperationId::new("op_grpc_runtime"),