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
+10 -1
View File
@@ -38,6 +38,10 @@ Implementation slices:
- ключи response cache изолируются минимум по `workspace + agent + operation + request fingerprint`;
- ключи coordination state изолируются минимум по `workspace + agent + cache scope`;
- при отсутствии внешнего cache все эти контуры продолжают работать через in-memory fallback.
7. Довести response cache не только до Community baseline, но и до первых коммерчески ценных read-only hot paths:
- `REST GET`
- `GraphQL query`
при сохранении той же изоляции и opt-in policy.
DoD:
- без `Valkey/Redis` Community и self-hosted deployment остаются рабочими;
@@ -46,6 +50,10 @@ DoD:
- `Enterprise` может подключать cache layer на своей инфраструктуре, включая кластерный вариант;
- runtime/docs/manifests не делают cache обязательным для базового запуска.
- cache boundaries и key namespaces документированы так, чтобы разные агенты и рабочие области не пересекались друг с другом.
- response cache покрывает первый коммерчески значимый protocol set без перехода к небезопасному "cache everything":
- `REST GET`
- `GraphQL query`
- дальнейшее расширение на `gRPC unary` возможно только для явно read-only / cacheable операций.
Verification:
- targeted runtime/admin/mcp tests for cache contracts and fallback mode;
@@ -64,8 +72,9 @@ Progress:
- runtime executor now performs actual response cache reads/writes for eligible `REST GET` operations with keys isolated by `workspace + agent + operation + request fingerprint`
- response cache keys now also include `operation version`, so a new published version does not reuse stale cached payloads from the previous one
- mcp-server published tool catalog now uses shared coordination snapshots across instances, while preserving the same `workspace + agent` isolation model
- response cache runtime/admin contracts now also cover `GraphQL query` as the first commercial protocol extension beyond the `REST GET` baseline
- pending:
- preserve the same isolation model while extending response cache beyond the first `REST GET` hot path
- preserve the same isolation model while extending response cache to the next monetizable read-only path: explicit `gRPC unary` read operations
- wire replay guard into the first real token or nonce flow once commercial machine-token issuance stops being a `Community` stub
- wire coordination state into additional runtime or transport paths beyond the shared published catalog snapshot
+80 -1
View File
@@ -5063,9 +5063,12 @@ fn validate_response_cache_policy(
match target {
Target::Rest(rest_target) if rest_target.method == crank_core::HttpMethod::Get => {}
Target::Graphql(graphql_target)
if graphql_target.operation_type == crank_core::GraphqlOperationType::Query => {}
_ => {
return Err(ApiError::validation_with_context(
"response cache is supported only for REST GET operations".to_owned(),
"response cache is supported only for REST GET and GraphQL query operations"
.to_owned(),
json!({
"field": "execution_config.response_cache",
}),
@@ -5085,6 +5088,82 @@ fn validate_response_cache_policy(
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use crank_core::{
ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, ResponseCachePolicy,
RestTarget, Target,
};
use super::validate_response_cache_policy;
fn cacheable_execution_config() -> ExecutionConfig {
ExecutionConfig {
timeout_ms: 1_000,
retry_policy: None,
response_cache: Some(ResponseCachePolicy { ttl_ms: 5_000 }),
auth_profile_ref: None,
headers: BTreeMap::new(),
protocol_options: None,
streaming: None,
}
}
#[test]
fn accepts_response_cache_for_graphql_query() {
let target = Target::Graphql(GraphqlTarget {
endpoint: "http://example.invalid/graphql".to_owned(),
operation_type: GraphqlOperationType::Query,
operation_name: "LookupLead".to_owned(),
query_template:
"query LookupLead($email: String!) { lookupLead(email: $email) { id } }".to_owned(),
response_path: "$.response.body.data.lookupLead".to_owned(),
});
let result = validate_response_cache_policy(&target, &cacheable_execution_config());
assert!(result.is_ok());
}
#[test]
fn rejects_response_cache_for_graphql_mutation() {
let target = Target::Graphql(GraphqlTarget {
endpoint: "http://example.invalid/graphql".to_owned(),
operation_type: GraphqlOperationType::Mutation,
operation_name: "CreateLead".to_owned(),
query_template:
"mutation CreateLead($email: String!) { createLead(email: $email) { id } }"
.to_owned(),
response_path: "$.response.body.data.createLead".to_owned(),
});
let error =
validate_response_cache_policy(&target, &cacheable_execution_config()).unwrap_err();
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
assert_eq!(
error.to_string(),
"response cache is supported only for REST GET and GraphQL query operations"
);
}
#[test]
fn accepts_response_cache_for_rest_get() {
let target = Target::Rest(RestTarget {
base_url: "http://example.invalid".to_owned(),
method: HttpMethod::Get,
path_template: "/catalog".to_owned(),
static_headers: BTreeMap::new(),
});
let result = validate_response_cache_policy(&target, &cacheable_execution_config());
assert!(result.is_ok());
}
}
fn enrich_operation_summary(
summary: OperationSummary,
usage_summary: OperationUsageSummaryView,
+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"),
+2 -1
View File
@@ -141,9 +141,10 @@
- `execution_config.response_cache` допускается только для:
- `REST`
- `GET`
- или `GraphQL query`
- операций без `auth_profile_ref`
- `execution_config.response_cache` не означает глобальный shared cache на все вызовы системы:
- response cache должен быть изолирован минимум по `workspace + agent + operation + request fingerprint`
- response cache должен быть изолирован минимум по `workspace + agent + operation + operation version + request fingerprint`
- попытка создать, обновить или импортировать операцию с неподдерживаемым `protocol` или `security_level` должна завершаться `validation_error` еще на стороне `admin-api`, а не только скрываться в UI.
### 5.4. Samples and descriptors
+5 -1
View File
@@ -184,7 +184,7 @@
### Цель
Подготовить optional cache/coordination layer так, чтобы платформа могла использовать `Valkey/Redis`,
но не зависела от него для базового запуска.
но не зависела от него для базового запуска, и при этом закрыть первые коммерчески ценные cache hot paths.
### DoD
@@ -198,6 +198,10 @@
- response cache по умолчанию изолировался по `workspace + agent + operation + operation version + request fingerprint`;
- внешний cache backend можно было безопасно шарить между несколькими рабочими областями и агентами.
- shared coordination cache уже применяется не только для rate limiting, но и для multi-instance snapshots published MCP catalogs.
- response cache уже покрывает:
- `REST GET` как Community baseline;
- `GraphQL query` как первый коммерчески ценный read-only protocol path.
- дальнейшее расширение response cache идет только по явно обоснованным read-only сценариям, начиная с `gRPC unary`, а не как общий cache для всех протоколов подряд.
## 14. Этап 11. Live staging and demo readiness
+3 -3
View File
@@ -237,9 +237,9 @@ Crank должен корректно работать, если MCP client де
- `POST` и `GET` transport semantics соответствуют MCP spec `2025-06-18`;
- endpoint определяется парой `workspace + agent`;
- одна published operation = один MCP tool внутри agent;
- если операция явно включает `execution_config.response_cache`, первый поддержанный runtime path ограничен:
- `REST`
- `GET`
- если операция явно включает `execution_config.response_cache`, текущий поддержанный runtime path ограничен:
- `REST GET`
- `GraphQL query`
- без `auth_profile_ref`
- cache keys изолируются минимум по `workspace + agent + operation + operation version + request fingerprint`
- streaming operations публикуются как bounded tools или tool families;
+8
View File
@@ -183,6 +183,14 @@ Demo/deployment:
Второй контур хранит только кэшируемые ответы операций.
Текущий безопасный runtime scope для response cache:
- `REST GET`;
- `GraphQL query`.
Это не означает автоматическое кэширование всех protocol families. Следующим кандидатом на расширение
может быть только явно read-only `gRPC unary`.
Эти контуры не должны смешивать ключи друг с другом.
### Cache key isolation