cache: add graphql query response caching
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user