From 851d70ad7a04530ae35906bf635da9573714225a Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Sun, 3 May 2026 22:22:16 +0000 Subject: [PATCH] cache: define response cache boundaries --- TASKS.md | 12 ++++- apps/admin-api/src/app.rs | 76 ++++++++++++++++++++++++++++-- apps/admin-api/src/service.rs | 64 +++++++++++++++++++++---- crates/crank-core/src/lib.rs | 6 +-- crates/crank-core/src/operation.rs | 7 +++ docs/admin-api.md | 6 +++ docs/data-model.md | 42 +++++++++++++++-- docs/implementation-plan.md | 4 ++ docs/runtime-config.md | 36 ++++++++++++++ 9 files changed, 234 insertions(+), 19 deletions(-) diff --git a/TASKS.md b/TASKS.md index e4a03fc..b0b63c4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index ef3712d..b1f4a17 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -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::().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::().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, diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 9f96012..b26b65b 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -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, diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index 28b3cd8..2dde309 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -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}; diff --git a/crates/crank-core/src/operation.rs b/crates/crank-core/src/operation.rs index b20dbc8..0a31ab0 100644 --- a/crates/crank-core/src/operation.rs +++ b/crates/crank-core/src/operation.rs @@ -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, #[serde(skip_serializing_if = "Option::is_none")] + pub response_cache: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub auth_profile_ref: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, diff --git a/docs/admin-api.md b/docs/admin-api.md index 72f98f1..c351f01 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -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 diff --git a/docs/data-model.md b/docs/data-model.md index f9644c2..3fcc421 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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, эта сущность должна быть сначала явно добавлена в эту модель данных, а уже потом в код и БД. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index fb61f50..528c998 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -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 diff --git a/docs/runtime-config.md b/docs/runtime-config.md index c6decb3..820371c 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -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 нужны: