cache: define response cache boundaries
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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, эта сущность должна быть сначала явно добавлена в эту модель данных, а уже потом в код и БД.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 нужны:
|
||||
|
||||
Reference in New Issue
Block a user