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`
|
- 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`
|
- `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`
|
- 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:
|
- pending:
|
||||||
- preserve strict cache isolation between workspace/agent/operation scopes while wiring concrete cache-key builders into runtime hot paths
|
- preserve the same isolation model while extending response cache beyond the first `REST GET` hot path
|
||||||
- wire actual response cache reads/writes into concrete runtime execution paths
|
|
||||||
- wire replay guard / coordination state into additional concrete runtime and transport paths
|
- wire replay guard / coordination state into additional concrete runtime and transport paths
|
||||||
|
|
||||||
## Planned
|
## Planned
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||||
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
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(
|
let service = AdminService::new_with_runtime(
|
||||||
registry,
|
registry,
|
||||||
storage_root,
|
storage_root,
|
||||||
|
|||||||
@@ -702,7 +702,11 @@ async fn handle_base_tool_call(
|
|||||||
transport_request_id: &str,
|
transport_request_id: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let operation = runtime_operation(&tool);
|
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 request_preview = build_request_preview(&state.runtime, &operation, &arguments);
|
||||||
let started_at = Instant::now();
|
let started_at = Instant::now();
|
||||||
let is_window_mode = matches!(
|
let is_window_mode = matches!(
|
||||||
@@ -813,7 +817,11 @@ async fn handle_session_start_call(
|
|||||||
transport_request_id: &str,
|
transport_request_id: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let runtime_operation = runtime_operation(&tool);
|
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 request_preview = build_request_preview(&state.runtime, &runtime_operation, &arguments);
|
||||||
let started_at = Instant::now();
|
let started_at = Instant::now();
|
||||||
let Some(streaming) = runtime_operation.execution_config.streaming.as_ref() else {
|
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,
|
transport_request_id: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let operation = runtime_operation(&tool);
|
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 request_preview = build_request_preview(&state.runtime, &operation, &arguments);
|
||||||
let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else {
|
let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else {
|
||||||
return tool_error_response(
|
return tool_error_response(
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
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(
|
let app = build_app(
|
||||||
registry,
|
registry,
|
||||||
refresh_interval,
|
refresh_interval,
|
||||||
@@ -3043,6 +3044,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
@@ -3109,6 +3111,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
@@ -3176,6 +3179,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 10_000,
|
timeout_ms: 10_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: Some(ProtocolOptions {
|
protocol_options: Some(ProtocolOptions {
|
||||||
@@ -502,6 +503,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 10_000,
|
timeout_ms: 10_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: Some(ProtocolOptions::default()),
|
protocol_options: Some(ProtocolOptions::default()),
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
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_graphql::{GraphqlAdapter, GraphqlRequest};
|
||||||
use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest};
|
use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest};
|
||||||
use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest};
|
use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest};
|
||||||
use crank_adapter_soap::{SoapAdapter, SoapRequest};
|
use crank_adapter_soap::{SoapAdapter, SoapRequest};
|
||||||
use crank_adapter_websocket::{WebsocketAdapter, WebsocketWindowRequest};
|
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 serde_json::{Map, Value, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
@@ -16,7 +22,7 @@ use crate::{
|
|||||||
RuntimeRequestContext, WindowExecutionResult,
|
RuntimeRequestContext, WindowExecutionResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone)]
|
||||||
pub struct RuntimeExecutor {
|
pub struct RuntimeExecutor {
|
||||||
graphql_adapter: GraphqlAdapter,
|
graphql_adapter: GraphqlAdapter,
|
||||||
grpc_adapter: GrpcAdapter,
|
grpc_adapter: GrpcAdapter,
|
||||||
@@ -28,6 +34,7 @@ pub struct RuntimeExecutor {
|
|||||||
window_limiter: Arc<Semaphore>,
|
window_limiter: Arc<Semaphore>,
|
||||||
session_limiter: Arc<Semaphore>,
|
session_limiter: Arc<Semaphore>,
|
||||||
job_limiter: Arc<Semaphore>,
|
job_limiter: Arc<Semaphore>,
|
||||||
|
response_cache: Option<Arc<dyn ResponseCacheStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RuntimeExecutor {
|
impl Default for RuntimeExecutor {
|
||||||
@@ -53,9 +60,18 @@ impl RuntimeExecutor {
|
|||||||
session_limiter: Arc::new(Semaphore::new(limits.max_concurrent_sessions)),
|
session_limiter: Arc::new(Semaphore::new(limits.max_concurrent_sessions)),
|
||||||
job_limiter: Arc::new(Semaphore::new(limits.max_concurrent_jobs)),
|
job_limiter: Arc::new(Semaphore::new(limits.max_concurrent_jobs)),
|
||||||
limits,
|
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(
|
pub async fn execute(
|
||||||
&self,
|
&self,
|
||||||
operation: &RuntimeOperation,
|
operation: &RuntimeOperation,
|
||||||
@@ -260,9 +276,25 @@ impl RuntimeExecutor {
|
|||||||
prepared_request: PreparedRequest,
|
prepared_request: PreparedRequest,
|
||||||
request_context: Option<&RuntimeRequestContext>,
|
request_context: Option<&RuntimeRequestContext>,
|
||||||
) -> Result<Value, RuntimeError> {
|
) -> Result<Value, RuntimeError> {
|
||||||
|
let adapter_response = match self
|
||||||
|
.load_cached_adapter_response(operation, &prepared_request, request_context)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Some(response) => response,
|
||||||
|
None => {
|
||||||
let adapter_response = self
|
let adapter_response = self
|
||||||
.execute_adapter(operation, prepared_request, request_context)
|
.execute_adapter(operation, prepared_request.clone(), request_context)
|
||||||
.await?;
|
.await?;
|
||||||
|
self.store_cached_adapter_response(
|
||||||
|
operation,
|
||||||
|
&prepared_request,
|
||||||
|
request_context,
|
||||||
|
&adapter_response,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
adapter_response
|
||||||
|
}
|
||||||
|
};
|
||||||
let finalized_output = finalize_output(operation, &adapter_response)?;
|
let finalized_output = finalize_output(operation, &adapter_response)?;
|
||||||
|
|
||||||
operation.output_schema.validate_shape(&finalized_output)?;
|
operation.output_schema.validate_shape(&finalized_output)?;
|
||||||
@@ -543,6 +575,81 @@ impl RuntimeExecutor {
|
|||||||
self.limits.max_concurrent_sessions,
|
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 {
|
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(
|
fn read_string_map(
|
||||||
value: Option<&Value>,
|
value: Option<&Value>,
|
||||||
field_name: &str,
|
field_name: &str,
|
||||||
@@ -706,13 +887,17 @@ fn non_empty_payload(value: Option<Value>) -> Option<Value> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{
|
||||||
|
Arc, Mutex,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
|
extract::Query,
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::sse::{Event, KeepAlive, Sse},
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
routing::post,
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
@@ -736,8 +921,8 @@ mod tests {
|
|||||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeLimits, RuntimeOperation,
|
InMemoryResponseCacheStore, PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeLimits,
|
||||||
RuntimeRequestContext,
|
RuntimeOperation, RuntimeRequestContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn timestamp(value: &str) -> OffsetDateTime {
|
fn timestamp(value: &str) -> OffsetDateTime {
|
||||||
@@ -817,6 +1002,66 @@ mod tests {
|
|||||||
assert_eq!(output, json!({ "id": "req_rest_123|corr_rest_123" }));
|
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]
|
#[tokio::test]
|
||||||
async fn emits_runtime_tracing_with_request_context() {
|
async fn emits_runtime_tracing_with_request_context() {
|
||||||
let base_url = spawn_runtime_server().await;
|
let base_url = spawn_runtime_server().await;
|
||||||
@@ -1295,6 +1540,34 @@ mod tests {
|
|||||||
format!("http://{}", address)
|
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 {
|
async fn spawn_graphql_server() -> String {
|
||||||
let app = Router::new().route("/", post(graphql_handler));
|
let app = Router::new().route("/", post(graphql_handler));
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
@@ -1680,6 +1953,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
@@ -1757,6 +2031,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
@@ -1833,6 +2108,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
@@ -1903,6 +2179,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
@@ -2001,6 +2278,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
@@ -2080,6 +2358,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: None,
|
protocol_options: None,
|
||||||
@@ -2184,6 +2463,7 @@ mod tests {
|
|||||||
execution_config: ExecutionConfig {
|
execution_config: ExecutionConfig {
|
||||||
timeout_ms: 1_000,
|
timeout_ms: 1_000,
|
||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
|
response_cache: None,
|
||||||
auth_profile_ref: None,
|
auth_profile_ref: None,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: Some(ProtocolOptions {
|
protocol_options: Some(ProtocolOptions {
|
||||||
@@ -2275,6 +2555,122 @@ mod tests {
|
|||||||
operation
|
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 {
|
fn test_session_seed_operation(base_url: &str) -> RuntimeOperation {
|
||||||
let mut operation =
|
let mut operation =
|
||||||
test_window_snapshot_operation(base_url, AggregationMode::RawItems, Some(10), None);
|
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::{
|
pub use rate_limit::{
|
||||||
RateLimitRejection, RequestRateLimitConfig, RequestRateLimitConfigError, RequestRateLimiter,
|
RateLimitRejection, RequestRateLimitConfig, RequestRateLimitConfigError, RequestRateLimiter,
|
||||||
};
|
};
|
||||||
pub use request_context::RuntimeRequestContext;
|
pub use request_context::{ResponseCacheScope, RuntimeRequestContext};
|
||||||
pub use secret_crypto::SecretCrypto;
|
pub use secret_crypto::SecretCrypto;
|
||||||
pub use streaming::WindowExecutionResult;
|
pub use streaming::WindowExecutionResult;
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
use std::collections::BTreeMap;
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct RuntimeRequestContext {
|
pub struct RuntimeRequestContext {
|
||||||
pub request_id: String,
|
pub request_id: String,
|
||||||
pub correlation_id: String,
|
pub correlation_id: String,
|
||||||
|
pub response_cache_scope: Option<ResponseCacheScope>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeRequestContext {
|
impl RuntimeRequestContext {
|
||||||
@@ -11,6 +18,7 @@ impl RuntimeRequestContext {
|
|||||||
Self {
|
Self {
|
||||||
request_id: request_id.into(),
|
request_id: request_id.into(),
|
||||||
correlation_id: correlation_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()),
|
("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)]
|
#[cfg(test)]
|
||||||
@@ -37,5 +61,16 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(context.request_id, "req_123");
|
assert_eq!(context.request_id, "req_123");
|
||||||
assert_eq!(context.correlation_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`;
|
- `POST` и `GET` transport semantics соответствуют MCP spec `2025-06-18`;
|
||||||
- endpoint определяется парой `workspace + agent`;
|
- endpoint определяется парой `workspace + agent`;
|
||||||
- одна published operation = один MCP tool внутри 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;
|
- streaming operations публикуются как bounded tools или tool families;
|
||||||
- reload published tools без пересборки сервиса;
|
- reload published tools без пересборки сервиса;
|
||||||
- никакой draft-логики или admin CRUD в MCP слое.
|
- никакой draft-логики или admin CRUD в MCP слое.
|
||||||
|
|||||||
Reference in New Issue
Block a user