cache: define response cache boundaries

This commit is contained in:
a.tolmachev
2026-05-03 22:22:16 +00:00
parent d3b7d246da
commit 851d70ad7a
9 changed files with 234 additions and 19 deletions
+11 -1
View File
@@ -33,6 +33,11 @@ Implementation slices:
- managed Cloud default
- enterprise optional cluster-aware deployment
5. Протянуть capability/config model так, чтобы решение "использовать или нет внешний cache" принимал оператор, а не кодовая база.
6. Зафиксировать safe cache boundaries:
- platform cache и response cache не смешиваются;
- ключи response cache изолируются минимум по `workspace + agent + operation + request fingerprint`;
- ключи coordination state изолируются минимум по `workspace + agent + cache scope`;
- при отсутствии внешнего cache все эти контуры продолжают работать через in-memory fallback.
DoD:
- без `Valkey/Redis` Community и self-hosted deployment остаются рабочими;
@@ -40,6 +45,7 @@ DoD:
- `Cloud` может использовать shared cache layer по умолчанию;
- `Enterprise` может подключать cache layer на своей инфраструктуре, включая кластерный вариант;
- runtime/docs/manifests не делают cache обязательным для базового запуска.
- cache boundaries и key namespaces документированы так, чтобы разные агенты и рабочие области не пересекались друг с другом.
Verification:
- targeted runtime/admin/mcp tests for cache contracts and fallback mode;
@@ -53,8 +59,12 @@ Progress:
- Community deployment manifest now includes optional `valkey` profile and cache env wiring without making cache mandatory
- shared `Valkey/Redis` backend layer now exists in `crank-runtime` with config-driven store factory for managed / enterprise contours
- 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`
- pending:
- wire response cache / replay guard / coordination state into additional concrete runtime and transport paths
- 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
- wire replay guard / coordination state into additional concrete runtime and transport paths
## Planned
+73 -3
View File
@@ -240,9 +240,9 @@ mod tests {
use crank_core::{
AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionConfig, ExecutionMode,
GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, JobStatus, MembershipRole,
OperationId, OperationSecurityLevel, Protocol, RestTarget, SecretKind, SoapBindingStyle,
SoapOperationMetadata, SoapTarget, SoapVersion, StreamSession, StreamStatus, Target,
ToolDescription, TransportBehavior, WebsocketTarget, WorkspaceId,
OperationId, OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind,
SoapBindingStyle, SoapOperationMetadata, SoapTarget, SoapVersion, StreamSession,
StreamStatus, Target, ToolDescription, TransportBehavior, WebsocketTarget, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{CreateAsyncJobRequest, CreateStreamSessionRequest, PostgresRegistry};
@@ -1362,6 +1362,71 @@ mod tests {
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn rejects_response_cache_for_non_get_rest_operation_create() {
let registry = test_registry().await;
let storage_root = test_storage_root("community_response_cache_post_reject");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let mut payload = test_operation_payload(&upstream_base_url, "crm_cache_post");
payload.execution_config.response_cache = Some(ResponseCachePolicy { ttl_ms: 5_000 });
let response = client
.post(format!("{base_url}/operations"))
.json(&payload)
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "validation_error");
assert_eq!(
body["error"]["context"]["field"],
"execution_config.response_cache"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn rejects_response_cache_for_operation_with_auth_profile() {
let registry = test_registry().await;
let storage_root = test_storage_root("community_response_cache_auth_reject");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let mut payload = test_operation_payload(&upstream_base_url, "crm_cache_auth");
payload.target = Target::Rest(RestTarget {
base_url: upstream_base_url,
method: HttpMethod::Get,
path_template: "/crm/leads".to_owned(),
static_headers: BTreeMap::new(),
});
payload.execution_config.response_cache = Some(ResponseCachePolicy { ttl_ms: 5_000 });
payload.execution_config.auth_profile_ref = Some("auth_profile_01".into());
let response = client
.post(format!("{base_url}/operations"))
.json(&payload)
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "validation_error");
assert_eq!(
body["error"]["context"]["field"],
"execution_config.auth_profile_ref"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_workspace_access_lifecycle() {
@@ -3455,6 +3520,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,
@@ -3512,6 +3578,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,
@@ -3568,6 +3635,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,
@@ -3725,6 +3793,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,
@@ -3780,6 +3849,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,
+55 -9
View File
@@ -1,10 +1,7 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use base64::{
Engine as _,
engine::general_purpose::URL_SAFE_NO_PAD,
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_adapter_soap::{SoapServiceSummary, inspect_wsdl};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
@@ -15,9 +12,9 @@ use crank_core::{
IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse, JobStatus, MachineAccessMode,
MembershipRole, OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey,
PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, ProductEdition, Protocol,
SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus,
Target, TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId,
WorkspaceStatus,
ResponseCachePolicy, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus,
StreamSession, StreamStatus, Target, TransportBehavior, UsagePeriod, UserId, UserSessionId,
Workspace, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
@@ -3890,6 +3887,7 @@ impl AdminService {
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
self.validate_operation_capabilities(payload.protocol, payload.security_level)?;
validate_protocol_target(payload.protocol, &payload.target)?;
validate_response_cache_policy(&payload.target, &payload.execution_config)?;
payload.input_mapping.validate_paths()?;
payload.output_mapping.validate_paths()?;
Ok(())
@@ -3898,6 +3896,7 @@ impl AdminService {
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
self.validate_operation_capabilities(operation.protocol, operation.security_level)?;
validate_protocol_target(operation.protocol, &operation.target)?;
validate_response_cache_policy(&operation.target, &operation.execution_config)?;
operation.input_mapping.validate_paths()?;
operation.output_mapping.validate_paths()?;
Ok(())
@@ -4067,8 +4066,12 @@ impl AdminService {
)
.await?;
self.seed_demo_invocation_logs(workspace_id, &AgentId::new(revops_agent.id), &rest_operation.id)
.await?;
self.seed_demo_invocation_logs(
workspace_id,
&AgentId::new(revops_agent.id),
&rest_operation.id,
)
.await?;
Ok(())
}
@@ -4539,6 +4542,7 @@ fn demo_rest_operation_payload() -> OperationPayload {
execution_config: crank_core::ExecutionConfig {
timeout_ms: 10_000,
retry_policy: None,
response_cache: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
protocol_options: None,
@@ -4601,6 +4605,7 @@ fn demo_archived_operation_payload() -> OperationPayload {
execution_config: crank_core::ExecutionConfig {
timeout_ms: 6_000,
retry_policy: None,
response_cache: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
protocol_options: None,
@@ -5039,6 +5044,47 @@ fn agent_mcp_endpoint(workspace_slug: &str, agent_slug: &str) -> String {
format!("/mcp/v1/{workspace_slug}/{agent_slug}")
}
fn validate_response_cache_policy(
target: &Target,
execution_config: &crank_core::ExecutionConfig,
) -> Result<(), ApiError> {
let Some(ResponseCachePolicy { ttl_ms }) = execution_config.response_cache.as_ref() else {
return Ok(());
};
if *ttl_ms == 0 {
return Err(ApiError::validation_with_context(
"response cache ttl must be greater than zero".to_owned(),
json!({
"field": "execution_config.response_cache.ttl_ms",
}),
));
}
match target {
Target::Rest(rest_target) if rest_target.method == crank_core::HttpMethod::Get => {}
_ => {
return Err(ApiError::validation_with_context(
"response cache is supported only for REST GET operations".to_owned(),
json!({
"field": "execution_config.response_cache",
}),
));
}
}
if execution_config.auth_profile_ref.is_some() {
return Err(ApiError::validation_with_context(
"response cache is not supported for operations with auth_profile_ref".to_owned(),
json!({
"field": "execution_config.auth_profile_ref",
}),
));
}
Ok(())
}
fn enrich_operation_summary(
summary: OperationSummary,
usage_summary: OperationUsageSummaryView,
+3 -3
View File
@@ -41,9 +41,9 @@ pub use observability::{
};
pub use operation::{
ConfigExport, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlTarget,
GrpcProtocolOptions, GrpcTarget, Operation, OperationStatus, ProtocolOptions, RestTarget,
RetryPolicy, Samples, SoapProtocolOptions, SoapTarget, Target, ToolDescription, ToolExample,
WebsocketProtocolOptions, WebsocketTarget,
GrpcProtocolOptions, GrpcTarget, Operation, OperationStatus, ProtocolOptions,
ResponseCachePolicy, RestTarget, RetryPolicy, Samples, SoapProtocolOptions, SoapTarget, Target,
ToolDescription, ToolExample, WebsocketProtocolOptions, WebsocketTarget,
};
pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol};
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
+7
View File
@@ -107,6 +107,11 @@ pub struct RetryPolicy {
pub max_attempts: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResponseCachePolicy {
pub ttl_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct GrpcProtocolOptions {
pub use_tls: bool,
@@ -148,6 +153,8 @@ pub struct ExecutionConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_policy: Option<RetryPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_cache: Option<ResponseCachePolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_profile_ref: Option<AuthProfileId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
+6
View File
@@ -138,6 +138,12 @@
- в `Community` backend принимает только:
- `REST`
- `security_level = standard`
- `execution_config.response_cache` допускается только для:
- `REST`
- `GET`
- операций без `auth_profile_ref`
- `execution_config.response_cache` не означает глобальный shared cache на все вызовы системы:
- response cache должен быть изолирован минимум по `workspace + agent + operation + request fingerprint`
- попытка создать, обновить или импортировать операцию с неподдерживаемым `protocol` или `security_level` должна завершаться `validation_error` еще на стороне `admin-api`, а не только скрываться в UI.
### 5.4. Samples and descriptors
+39 -3
View File
@@ -510,7 +510,43 @@
- `validate_certificate`
- `ws_security_profile`
## 6. XML normalization model
## 6. `ExecutionConfig`
`ExecutionConfig` хранит общие runtime-настройки операции.
Ключевые поля:
- `timeout_ms`
- `retry_policy`
- `response_cache`
- `auth_profile_ref`
- `headers`
- `protocol_options`
- `streaming`
### 6.1. `ResponseCachePolicy`
Первый поддержанный вариант response cache policy:
- `ttl_ms`
Ограничения стартовой реализации:
- policy задается явно на операции;
- response cache допускается только для read-only вызовов;
- в открытой редакции первый поддержанный путь:
- `REST`
- `GET`
- без `auth_profile_ref`
- cache key должен строиться не глобально, а минимум в контексте:
- `workspace`
- `agent`
- `operation`
- `request fingerprint`
Это позволяет избежать неявного кэширования ответов, зависящих от upstream credentials.
## 7. XML normalization model
Для SOAP/XSD-пайплайна `crank-schema` теперь фиксирует XML-origin metadata отдельными code-level types:
@@ -524,7 +560,7 @@
- локальное имя и namespace;
- repeated / nillable semantics.
## 7. `Schema`
## 8. `Schema`
`Schema` - нормализованное описание входа или выхода.
@@ -537,7 +573,7 @@
- nullable-поля;
- `oneof` для protobuf.
## 8. Принцип совместимости
## 9. Принцип совместимости
Если UI требует сущность, которой нет в текущем backend, эта сущность должна быть сначала явно добавлена в эту модель данных, а уже потом в код и БД.
+4
View File
@@ -193,6 +193,10 @@
- Community deployment path умеет поднимать optional `Valkey`;
- Cloud использует shared cache layer как default managed runtime component;
- Enterprise может подключать свой `Valkey/Redis` на уровне single-node или cluster deployment.
- cache namespaces документированы так, чтобы:
- platform / coordination state не смешивался с response cache;
- response cache по умолчанию изолировался по `workspace + agent + operation + request fingerprint`;
- внешний cache backend можно было безопасно шарить между несколькими рабочими областями и агентами.
## 14. Этап 11. Live staging and demo readiness
+36
View File
@@ -166,6 +166,42 @@ Demo/deployment:
- `CRANK_CACHE_BACKEND=valkey` или `redis` при наличии внешнего cache store;
- без этих переменных система должна оставаться полностью работоспособной.
### Cache boundaries
В платформе должны существовать два разных cache-контура:
- `platform / coordination cache`
- `response cache`
Первый контур хранит служебное краткоживущее состояние:
- ingress rate limiting;
- replay guard;
- ephemeral coordination state;
- будущие short-lived / one-time token helpers.
Второй контур хранит только кэшируемые ответы операций.
Эти контуры не должны смешивать ключи друг с другом.
### Cache key isolation
Базовое правило изоляции:
- разные `workspace` не должны делить одни и те же cache keys;
- разные `agent` внутри одного `workspace` тоже не должны делить одни и те же response cache keys по умолчанию;
- разные `operation` внутри одного `agent` не должны попадать в общий response cache namespace.
Стартовая модель namespace для response cache:
- `workspace + agent + operation + request fingerprint`
Стартовая модель namespace для platform / coordination cache:
- `workspace + agent + cache scope + logical key`
Это позволяет безопасно использовать один внешний `Valkey/Redis` сразу для нескольких агентов и рабочих областей без взаимного пересечения данных.
### Auth env
Для app-level auth нужны: