cache: wire rest get response caching
This commit is contained in:
@@ -61,9 +61,9 @@ Progress:
|
||||
- admin-api and mcp-server ingress rate limiting now use shared external cache state when `CRANK_CACHE_BACKEND` is `valkey` or `redis`
|
||||
- `ExecutionConfig.response_cache` is now part of the public operation model, and `admin-api` validates the safe Community baseline: only explicit `REST GET` operations without `auth_profile_ref`
|
||||
- cache boundary and namespace rules are now documented for `platform / coordination cache` versus `response cache`
|
||||
- runtime executor now performs actual response cache reads/writes for eligible `REST GET` operations with keys isolated by `workspace + agent + operation + request fingerprint`
|
||||
- pending:
|
||||
- preserve strict cache isolation between workspace/agent/operation scopes while wiring concrete cache-key builders into runtime hot paths
|
||||
- wire actual response cache reads/writes into concrete runtime execution paths
|
||||
- preserve the same isolation model while extending response cache beyond the first `REST GET` hot path
|
||||
- wire replay guard / coordination state into additional concrete runtime and transport paths
|
||||
|
||||
## Planned
|
||||
|
||||
@@ -66,7 +66,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let runtime = RuntimeExecutor::with_limits(runtime_limits);
|
||||
let runtime = RuntimeExecutor::with_limits(runtime_limits)
|
||||
.with_response_cache_store(cache_stores.response.clone());
|
||||
let service = AdminService::new_with_runtime(
|
||||
registry,
|
||||
storage_root,
|
||||
|
||||
@@ -702,7 +702,11 @@ async fn handle_base_tool_call(
|
||||
transport_request_id: &str,
|
||||
) -> Response {
|
||||
let operation = runtime_operation(&tool);
|
||||
let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id);
|
||||
let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id)
|
||||
.with_response_cache_scope(
|
||||
tool.workspace_id.as_str().to_owned(),
|
||||
tool.agent_id.as_str().to_owned(),
|
||||
);
|
||||
let request_preview = build_request_preview(&state.runtime, &operation, &arguments);
|
||||
let started_at = Instant::now();
|
||||
let is_window_mode = matches!(
|
||||
@@ -813,7 +817,11 @@ async fn handle_session_start_call(
|
||||
transport_request_id: &str,
|
||||
) -> Response {
|
||||
let runtime_operation = runtime_operation(&tool);
|
||||
let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id);
|
||||
let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id)
|
||||
.with_response_cache_scope(
|
||||
tool.workspace_id.as_str().to_owned(),
|
||||
tool.agent_id.as_str().to_owned(),
|
||||
);
|
||||
let request_preview = build_request_preview(&state.runtime, &runtime_operation, &arguments);
|
||||
let started_at = Instant::now();
|
||||
let Some(streaming) = runtime_operation.execution_config.streaming.as_ref() else {
|
||||
@@ -1240,7 +1248,11 @@ async fn handle_async_job_start_call(
|
||||
transport_request_id: &str,
|
||||
) -> Response {
|
||||
let operation = runtime_operation(&tool);
|
||||
let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id);
|
||||
let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id)
|
||||
.with_response_cache_scope(
|
||||
tool.workspace_id.as_str().to_owned(),
|
||||
tool.agent_id.as_str().to_owned(),
|
||||
);
|
||||
let request_preview = build_request_preview(&state.runtime, &operation, &arguments);
|
||||
let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else {
|
||||
return tool_error_response(
|
||||
|
||||
@@ -54,7 +54,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
)
|
||||
.await?;
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let runtime = RuntimeExecutor::with_limits(runtime_limits);
|
||||
let runtime = RuntimeExecutor::with_limits(runtime_limits)
|
||||
.with_response_cache_store(cache_stores.response.clone());
|
||||
let app = build_app(
|
||||
registry,
|
||||
refresh_interval,
|
||||
@@ -3043,6 +3044,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
@@ -3109,6 +3111,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
@@ -3176,6 +3179,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
|
||||
@@ -413,6 +413,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 10_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: Some(ProtocolOptions {
|
||||
@@ -502,6 +503,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 10_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: Some(ProtocolOptions::default()),
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest};
|
||||
use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest};
|
||||
use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest};
|
||||
use crank_adapter_soap::{SoapAdapter, SoapRequest};
|
||||
use crank_adapter_websocket::{WebsocketAdapter, WebsocketWindowRequest};
|
||||
use crank_core::{ExecutionMode, Target, TransportBehavior};
|
||||
use crank_core::{
|
||||
CachedHeader, CachedResponse, ExecutionMode, HttpMethod, ResponseCacheStore, Target,
|
||||
TransportBehavior,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
use tracing::debug;
|
||||
|
||||
@@ -16,7 +22,7 @@ use crate::{
|
||||
RuntimeRequestContext, WindowExecutionResult,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeExecutor {
|
||||
graphql_adapter: GraphqlAdapter,
|
||||
grpc_adapter: GrpcAdapter,
|
||||
@@ -28,6 +34,7 @@ pub struct RuntimeExecutor {
|
||||
window_limiter: Arc<Semaphore>,
|
||||
session_limiter: Arc<Semaphore>,
|
||||
job_limiter: Arc<Semaphore>,
|
||||
response_cache: Option<Arc<dyn ResponseCacheStore>>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeExecutor {
|
||||
@@ -53,9 +60,18 @@ impl RuntimeExecutor {
|
||||
session_limiter: Arc::new(Semaphore::new(limits.max_concurrent_sessions)),
|
||||
job_limiter: Arc::new(Semaphore::new(limits.max_concurrent_jobs)),
|
||||
limits,
|
||||
response_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_response_cache_store(
|
||||
mut self,
|
||||
response_cache: Arc<dyn ResponseCacheStore>,
|
||||
) -> Self {
|
||||
self.response_cache = Some(response_cache);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
@@ -260,9 +276,25 @@ impl RuntimeExecutor {
|
||||
prepared_request: PreparedRequest,
|
||||
request_context: Option<&RuntimeRequestContext>,
|
||||
) -> Result<Value, RuntimeError> {
|
||||
let adapter_response = self
|
||||
.execute_adapter(operation, prepared_request, request_context)
|
||||
.await?;
|
||||
let adapter_response = match self
|
||||
.load_cached_adapter_response(operation, &prepared_request, request_context)
|
||||
.await
|
||||
{
|
||||
Some(response) => response,
|
||||
None => {
|
||||
let adapter_response = self
|
||||
.execute_adapter(operation, prepared_request.clone(), request_context)
|
||||
.await?;
|
||||
self.store_cached_adapter_response(
|
||||
operation,
|
||||
&prepared_request,
|
||||
request_context,
|
||||
&adapter_response,
|
||||
)
|
||||
.await;
|
||||
adapter_response
|
||||
}
|
||||
};
|
||||
let finalized_output = finalize_output(operation, &adapter_response)?;
|
||||
|
||||
operation.output_schema.validate_shape(&finalized_output)?;
|
||||
@@ -543,6 +575,81 @@ impl RuntimeExecutor {
|
||||
self.limits.max_concurrent_sessions,
|
||||
)
|
||||
}
|
||||
|
||||
async fn load_cached_adapter_response(
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
prepared_request: &PreparedRequest,
|
||||
request_context: Option<&RuntimeRequestContext>,
|
||||
) -> Option<AdapterResponse> {
|
||||
let response_cache = self.response_cache.as_ref()?;
|
||||
let cache_key = response_cache_key(operation, prepared_request, request_context)?;
|
||||
let cached = match response_cache.get(&cache_key).await {
|
||||
Ok(cached) => cached?,
|
||||
Err(error) => {
|
||||
debug!(
|
||||
operation_id = %operation.operation_id,
|
||||
cache_key,
|
||||
error = %error,
|
||||
"response cache lookup skipped"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match adapter_response_from_cached(cached) {
|
||||
Ok(response) => Some(response),
|
||||
Err(error) => {
|
||||
debug!(
|
||||
operation_id = %operation.operation_id,
|
||||
cache_key,
|
||||
error,
|
||||
"cached response payload was invalid"
|
||||
);
|
||||
let _ = response_cache.delete(&cache_key).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn store_cached_adapter_response(
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
prepared_request: &PreparedRequest,
|
||||
request_context: Option<&RuntimeRequestContext>,
|
||||
adapter_response: &AdapterResponse,
|
||||
) {
|
||||
let Some(response_cache) = self.response_cache.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !(200..=299).contains(&adapter_response.status_code) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(cache_ttl) = response_cache_ttl(operation) else {
|
||||
return;
|
||||
};
|
||||
let Some(cache_key) = response_cache_key(operation, prepared_request, request_context)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(cached_response) = cached_response_from_adapter(adapter_response) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(error) = response_cache
|
||||
.put(&cache_key, cached_response, cache_ttl)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
operation_id = %operation.operation_id,
|
||||
cache_key,
|
||||
error = %error,
|
||||
"response cache write skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreparedRequest {
|
||||
@@ -649,6 +756,80 @@ fn log_runtime_event(
|
||||
);
|
||||
}
|
||||
|
||||
fn response_cache_ttl(operation: &RuntimeOperation) -> Option<Duration> {
|
||||
if !matches!(
|
||||
&operation.target,
|
||||
Target::Rest(target) if target.method == HttpMethod::Get
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let policy = operation.execution_config.response_cache.as_ref()?;
|
||||
if operation.execution_config.auth_profile_ref.is_some() || policy.ttl_ms == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Duration::from_millis(policy.ttl_ms))
|
||||
}
|
||||
|
||||
fn response_cache_key(
|
||||
operation: &RuntimeOperation,
|
||||
prepared_request: &PreparedRequest,
|
||||
request_context: Option<&RuntimeRequestContext>,
|
||||
) -> Option<String> {
|
||||
let _ = response_cache_ttl(operation)?;
|
||||
let scope = request_context?.response_cache_scope()?;
|
||||
let fingerprint = json!({
|
||||
"path_params": prepared_request.path_params,
|
||||
"query_params": prepared_request.query_params,
|
||||
"headers": prepared_request.headers,
|
||||
"body": prepared_request.body,
|
||||
});
|
||||
let fingerprint_bytes = serde_json::to_vec(&fingerprint).ok()?;
|
||||
let fingerprint_hash = URL_SAFE_NO_PAD.encode(Sha256::digest(fingerprint_bytes));
|
||||
|
||||
Some(format!(
|
||||
"crank:response:workspace:{}:agent:{}:operation:{}:request:{}",
|
||||
scope.workspace_key,
|
||||
scope.agent_key,
|
||||
operation.operation_id.as_str(),
|
||||
fingerprint_hash
|
||||
))
|
||||
}
|
||||
|
||||
fn cached_response_from_adapter(adapter_response: &AdapterResponse) -> Option<CachedResponse> {
|
||||
let body = serde_json::to_vec(&adapter_response.body).ok()?;
|
||||
Some(CachedResponse {
|
||||
status: adapter_response.status_code,
|
||||
headers: adapter_response
|
||||
.headers
|
||||
.iter()
|
||||
.map(|(name, value)| CachedHeader {
|
||||
name: name.clone(),
|
||||
value: value.clone(),
|
||||
})
|
||||
.collect(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn adapter_response_from_cached(
|
||||
cached_response: CachedResponse,
|
||||
) -> Result<AdapterResponse, String> {
|
||||
let body: Value =
|
||||
serde_json::from_slice(&cached_response.body).map_err(|error| error.to_string())?;
|
||||
Ok(AdapterResponse {
|
||||
status_code: cached_response.status,
|
||||
headers: cached_response
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|header| (header.name, header.value))
|
||||
.collect(),
|
||||
body,
|
||||
data: Value::Null,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_string_map(
|
||||
value: Option<&Value>,
|
||||
field_name: &str,
|
||||
@@ -706,13 +887,17 @@ fn non_empty_payload(value: Option<Value>) -> Option<Value> {
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::Query,
|
||||
http::HeaderMap,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
};
|
||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||
use crank_core::{
|
||||
@@ -736,8 +921,8 @@ mod tests {
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||
|
||||
use crate::{
|
||||
PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeLimits, RuntimeOperation,
|
||||
RuntimeRequestContext,
|
||||
InMemoryResponseCacheStore, PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeLimits,
|
||||
RuntimeOperation, RuntimeRequestContext,
|
||||
};
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
@@ -817,6 +1002,66 @@ mod tests {
|
||||
assert_eq!(output, json!({ "id": "req_rest_123|corr_rest_123" }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn caches_rest_get_responses_within_agent_scope() {
|
||||
let request_count = Arc::new(AtomicUsize::new(0));
|
||||
let base_url = spawn_cached_rest_server(Arc::clone(&request_count)).await;
|
||||
let executor = RuntimeExecutor::new()
|
||||
.with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default()));
|
||||
let operation = test_cached_rest_get_operation(&base_url);
|
||||
let first_context = RuntimeRequestContext::from_request_id("req_cache_1")
|
||||
.with_response_cache_scope("ws_cache", "agent_sales");
|
||||
let second_context = RuntimeRequestContext::from_request_id("req_cache_2")
|
||||
.with_response_cache_scope("ws_cache", "agent_sales");
|
||||
|
||||
let first_output = executor
|
||||
.execute_with_context(&operation, &json!({ "city": "msk" }), Some(&first_context))
|
||||
.await
|
||||
.unwrap();
|
||||
let second_output = executor
|
||||
.execute_with_context(&operation, &json!({ "city": "msk" }), Some(&second_context))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first_output, json!({ "city": "msk", "request_count": 1 }));
|
||||
assert_eq!(second_output, json!({ "city": "msk", "request_count": 1 }));
|
||||
assert_eq!(request_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn isolates_rest_get_cache_between_agents() {
|
||||
let request_count = Arc::new(AtomicUsize::new(0));
|
||||
let base_url = spawn_cached_rest_server(Arc::clone(&request_count)).await;
|
||||
let executor = RuntimeExecutor::new()
|
||||
.with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default()));
|
||||
let operation = test_cached_rest_get_operation(&base_url);
|
||||
let agent_a_context = RuntimeRequestContext::from_request_id("req_cache_agent_a")
|
||||
.with_response_cache_scope("ws_cache", "agent_a");
|
||||
let agent_b_context = RuntimeRequestContext::from_request_id("req_cache_agent_b")
|
||||
.with_response_cache_scope("ws_cache", "agent_b");
|
||||
|
||||
let first_output = executor
|
||||
.execute_with_context(
|
||||
&operation,
|
||||
&json!({ "city": "msk" }),
|
||||
Some(&agent_a_context),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let second_output = executor
|
||||
.execute_with_context(
|
||||
&operation,
|
||||
&json!({ "city": "msk" }),
|
||||
Some(&agent_b_context),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first_output, json!({ "city": "msk", "request_count": 1 }));
|
||||
assert_eq!(second_output, json!({ "city": "msk", "request_count": 2 }));
|
||||
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emits_runtime_tracing_with_request_context() {
|
||||
let base_url = spawn_runtime_server().await;
|
||||
@@ -1295,6 +1540,34 @@ mod tests {
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
async fn spawn_cached_rest_server(request_count: Arc<AtomicUsize>) -> String {
|
||||
let app = Router::new().route(
|
||||
"/catalog",
|
||||
get(move |Query(params): Query<BTreeMap<String, String>>| {
|
||||
let request_count = Arc::clone(&request_count);
|
||||
async move {
|
||||
let count = request_count.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let city = params.get("city").cloned().unwrap_or_default();
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
Json(json!({
|
||||
"city": city,
|
||||
"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_server() -> String {
|
||||
let app = Router::new().route("/", post(graphql_handler));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -1680,6 +1953,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
@@ -1757,6 +2031,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
@@ -1833,6 +2108,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
@@ -1903,6 +2179,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
@@ -2001,6 +2278,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
@@ -2080,6 +2358,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
@@ -2184,6 +2463,7 @@ mod tests {
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: Some(ProtocolOptions {
|
||||
@@ -2275,6 +2555,122 @@ mod tests {
|
||||
operation
|
||||
}
|
||||
|
||||
fn test_cached_rest_get_operation(base_url: &str) -> RuntimeOperation {
|
||||
RuntimeOperation::from(Operation {
|
||||
id: OperationId::new("op_rest_cache_runtime"),
|
||||
name: "catalog_lookup".to_owned(),
|
||||
display_name: "Catalog Lookup".to_owned(),
|
||||
category: "lookup".to_owned(),
|
||||
protocol: Protocol::Rest,
|
||||
security_level: OperationSecurityLevel::Standard,
|
||||
status: OperationStatus::Published,
|
||||
version: 1,
|
||||
target: Target::Rest(RestTarget {
|
||||
base_url: base_url.to_owned(),
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/catalog".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
}),
|
||||
input_schema: object_schema("city", SchemaKind::String),
|
||||
output_schema: Schema {
|
||||
kind: SchemaKind::Object,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::from([
|
||||
(
|
||||
"city".to_owned(),
|
||||
Schema {
|
||||
kind: SchemaKind::String,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"request_count".to_owned(),
|
||||
Schema {
|
||||
kind: SchemaKind::Number,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
),
|
||||
]),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
input_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.city".to_owned(),
|
||||
target: "$.request.query.city".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
output_mapping: MappingSet {
|
||||
rules: vec![
|
||||
MappingRule {
|
||||
source: "$.response.body.city".to_owned(),
|
||||
target: "$.output.city".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
},
|
||||
MappingRule {
|
||||
source: "$.response.body.request_count".to_owned(),
|
||||
target: "$.output.request_count".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: 60_000 }),
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Catalog Lookup".to_owned(),
|
||||
description: "Loads a read-only catalog item".to_owned(),
|
||||
tags: vec!["lookup".to_owned()],
|
||||
examples: vec![ToolExample {
|
||||
input: json!({ "city": "msk" }),
|
||||
}],
|
||||
},
|
||||
samples: Some(Samples::default()),
|
||||
generated_draft: None,
|
||||
config_export: None,
|
||||
created_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
updated_at: timestamp("2026-04-06T12:00:00Z"),
|
||||
published_at: Some(timestamp("2026-04-06T12:00:00Z")),
|
||||
})
|
||||
}
|
||||
|
||||
fn test_session_seed_operation(base_url: &str) -> RuntimeOperation {
|
||||
let mut operation =
|
||||
test_window_snapshot_operation(base_url, AggregationMode::RawItems, Some(10), None);
|
||||
|
||||
@@ -24,6 +24,6 @@ pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation};
|
||||
pub use rate_limit::{
|
||||
RateLimitRejection, RequestRateLimitConfig, RequestRateLimitConfigError, RequestRateLimiter,
|
||||
};
|
||||
pub use request_context::RuntimeRequestContext;
|
||||
pub use request_context::{ResponseCacheScope, RuntimeRequestContext};
|
||||
pub use secret_crypto::SecretCrypto;
|
||||
pub use streaming::WindowExecutionResult;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ResponseCacheScope {
|
||||
pub workspace_key: String,
|
||||
pub agent_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeRequestContext {
|
||||
pub request_id: String,
|
||||
pub correlation_id: String,
|
||||
pub response_cache_scope: Option<ResponseCacheScope>,
|
||||
}
|
||||
|
||||
impl RuntimeRequestContext {
|
||||
@@ -11,6 +18,7 @@ impl RuntimeRequestContext {
|
||||
Self {
|
||||
request_id: request_id.into(),
|
||||
correlation_id: correlation_id.into(),
|
||||
response_cache_scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +33,22 @@ impl RuntimeRequestContext {
|
||||
("x-correlation-id".to_owned(), self.correlation_id.clone()),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn with_response_cache_scope(
|
||||
mut self,
|
||||
workspace_key: impl Into<String>,
|
||||
agent_key: impl Into<String>,
|
||||
) -> Self {
|
||||
self.response_cache_scope = Some(ResponseCacheScope {
|
||||
workspace_key: workspace_key.into(),
|
||||
agent_key: agent_key.into(),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn response_cache_scope(&self) -> Option<&ResponseCacheScope> {
|
||||
self.response_cache_scope.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -37,5 +61,16 @@ mod tests {
|
||||
|
||||
assert_eq!(context.request_id, "req_123");
|
||||
assert_eq!(context.correlation_id, "req_123");
|
||||
assert!(context.response_cache_scope.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaches_response_cache_scope() {
|
||||
let context = RuntimeRequestContext::from_request_id("req_123")
|
||||
.with_response_cache_scope("ws_01", "agent_01");
|
||||
|
||||
let scope = context.response_cache_scope().unwrap();
|
||||
assert_eq!(scope.workspace_key, "ws_01");
|
||||
assert_eq!(scope.agent_key, "agent_01");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,11 @@ 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`
|
||||
- без `auth_profile_ref`
|
||||
- cache keys изолируются минимум по `workspace + agent + operation + request fingerprint`
|
||||
- streaming operations публикуются как bounded tools или tool families;
|
||||
- reload published tools без пересборки сервиса;
|
||||
- никакой draft-логики или admin CRUD в MCP слое.
|
||||
|
||||
Reference in New Issue
Block a user