cache: add optional runtime cache contracts

This commit is contained in:
a.tolmachev
2026-05-03 21:04:47 +00:00
parent 33a5773415
commit 1deb70c926
7 changed files with 444 additions and 41 deletions
Generated
+1
View File
@@ -433,6 +433,7 @@ dependencies = [
name = "crank-core"
version = "0.1.0"
dependencies = [
"async-trait",
"serde",
"serde_json",
"serde_yaml",
+54 -41
View File
@@ -2,6 +2,60 @@
## Current
### `feat/optional-cache-layer`
Status: in_progress
Goal:
- добавить в продукт optional cache/coordination layer с `Valkey/Redis` backend без обязательной зависимости от него.
Main code areas:
- `crates/crank-core`
- `crates/crank-runtime`
- `apps/admin-api`
- `apps/mcp-server`
- `deploy/community/*`
- `docs/runtime-config.md`
- `docs/product-editions.md`
- `docs/commercial-boundaries.md`
Implementation slices:
1. Зафиксировать public cache contracts:
- response cache
- rate-limit store
- token replay / one-time token store
- ephemeral coordination state
2. Подготовить Community fallback path:
- без `Valkey/Redis` все должно работать
- degraded mode documented explicitly
3. Добавить optional `Valkey` service в Community deployment manifests как рекомендованный, но не обязательный runtime component.
4. Подготовить `Valkey/Redis` backend для:
- managed Cloud default
- enterprise optional cluster-aware deployment
5. Протянуть capability/config model так, чтобы решение "использовать или нет внешний cache" принимал оператор, а не кодовая база.
DoD:
- без `Valkey/Redis` Community и self-hosted deployment остаются рабочими;
- с `Valkey/Redis` платформа умеет снижать runtime/load pressure и хранить ephemeral coordination state;
- `Cloud` может использовать shared cache layer по умолчанию;
- `Enterprise` может подключать cache layer на своей инфраструктуре, включая кластерный вариант;
- runtime/docs/manifests не делают cache обязательным для базового запуска.
Verification:
- targeted runtime/admin/mcp tests for cache contracts and fallback mode;
- deploy manifest validation.
Progress:
- done:
- product/docs/runtime plan already define cache layer as optional and edition-aware
- public cache contracts and runtime cache config now exist in `crank-core` / `crank-runtime`
- pending:
- Community fallback implementations
- optional `Valkey` in Community manifests
- external cache backend for managed / enterprise contours
## Planned
### `feat/open-core-repo-boundary`
Status: in_progress
@@ -49,8 +103,6 @@ Progress:
- remaining extension seams and packaging split still need to move from planning into concrete `crank-community` / `crank-enterprise` / `crank-cloud` manifests
- next management gate is to actually create the `3` target repositories before physical split starts
## Planned
### `feat/enterprise-access-governance`
Status: ready
@@ -123,45 +175,6 @@ DoD:
- Enterprise и Cloud используют private delivery path;
- коммерческий код не требуется публиковать в open source ради поставки.
### `feat/optional-cache-layer`
Status: ready
Goal:
- добавить в продукт optional cache/coordination layer с `Valkey/Redis` backend без обязательной зависимости от него.
Main code areas:
- `crates/crank-core`
- `crates/crank-runtime`
- `apps/admin-api`
- `apps/mcp-server`
- `deploy/community/*`
- `docs/runtime-config.md`
- `docs/product-editions.md`
- `docs/commercial-boundaries.md`
Implementation slices:
1. Зафиксировать public cache contracts:
- response cache
- rate-limit store
- token replay / one-time token store
- ephemeral coordination state
2. Подготовить Community fallback path:
- без `Valkey/Redis` все должно работать
- degraded mode documented explicitly
3. Добавить optional `Valkey` service в Community deployment manifests как рекомендованный, но не обязательный runtime component.
4. Подготовить `Valkey/Redis` backend для:
- managed Cloud default
- enterprise optional cluster-aware deployment
5. Протянуть capability/config model так, чтобы решение "использовать или нет внешний cache" принимал оператор, а не кодовая база.
DoD:
- без `Valkey/Redis` Community и self-hosted deployment остаются рабочими;
- с `Valkey/Redis` платформа умеет снижать runtime/load pressure и хранить ephemeral coordination state;
- `Cloud` может использовать shared cache layer по умолчанию;
- `Enterprise` может подключать cache layer на своей инфраструктуре, включая кластерный вариант;
- runtime/docs/manifests не делают cache обязательным для базового запуска.
### `feat/live-authenticated-staging-entry`
Status: ready
+1
View File
@@ -6,6 +6,7 @@ rust-version.workspace = true
version.workspace = true
[dependencies]
async-trait = "0.1"
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
+200
View File
@@ -0,0 +1,200 @@
use std::{fmt, str::FromStr, time::Duration};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheBackend {
Memory,
Valkey,
Redis,
}
impl CacheBackend {
pub fn is_external(self) -> bool {
matches!(self, Self::Valkey | Self::Redis)
}
}
impl fmt::Display for CacheBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Memory => "memory",
Self::Valkey => "valkey",
Self::Redis => "redis",
};
f.write_str(value)
}
}
impl FromStr for CacheBackend {
type Err = ParseCacheBackendError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"memory" => Ok(Self::Memory),
"valkey" => Ok(Self::Valkey),
"redis" => Ok(Self::Redis),
_ => Err(ParseCacheBackendError {
value: value.to_owned(),
}),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheScope {
Response,
RateLimit,
ReplayGuard,
Coordination,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachedHeader {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachedResponse {
pub status: u16,
pub headers: Vec<CachedHeader>,
pub body: Vec<u8>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RateLimitBucketState {
pub tokens_micros: u64,
pub last_refill_unix_ms: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReplayGuardStatus {
Fresh,
AlreadySeen,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoordinationStateValue {
pub payload: Value,
}
#[async_trait]
pub trait ResponseCacheStore: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<CachedResponse>, CacheStoreError>;
async fn put(
&self,
key: &str,
value: CachedResponse,
ttl: Duration,
) -> Result<(), CacheStoreError>;
async fn delete(&self, key: &str) -> Result<(), CacheStoreError>;
}
#[async_trait]
pub trait RateLimitStateStore: Send + Sync {
async fn get_bucket(&self, key: &str) -> Result<Option<RateLimitBucketState>, CacheStoreError>;
async fn put_bucket(
&self,
key: &str,
value: RateLimitBucketState,
ttl: Duration,
) -> Result<(), CacheStoreError>;
async fn delete_bucket(&self, key: &str) -> Result<(), CacheStoreError>;
}
#[async_trait]
pub trait ReplayGuardStore: Send + Sync {
async fn mark_seen(
&self,
key: &str,
ttl: Duration,
) -> Result<ReplayGuardStatus, CacheStoreError>;
async fn clear(&self, key: &str) -> Result<(), CacheStoreError>;
}
#[async_trait]
pub trait CoordinationStateStore: Send + Sync {
async fn get_value(
&self,
scope: CacheScope,
key: &str,
) -> Result<Option<CoordinationStateValue>, CacheStoreError>;
async fn put_value(
&self,
scope: CacheScope,
key: &str,
value: CoordinationStateValue,
ttl: Duration,
) -> Result<(), CacheStoreError>;
async fn delete_value(&self, scope: CacheScope, key: &str) -> Result<(), CacheStoreError>;
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CacheStoreError {
#[error("cache backend is unavailable: {message}")]
Unavailable { message: String },
#[error("cache key is invalid: {message}")]
InvalidKey { message: String },
#[error("cache value serialization failed: {message}")]
Serialization { message: String },
}
#[derive(Debug, Error, PartialEq, Eq)]
#[error("unsupported cache backend {value}")]
pub struct ParseCacheBackendError {
pub value: String,
}
#[cfg(test)]
mod tests {
use super::{CacheBackend, CacheScope, ParseCacheBackendError, ReplayGuardStatus};
#[test]
fn cache_backend_roundtrip_is_stable() {
for backend in [
CacheBackend::Memory,
CacheBackend::Valkey,
CacheBackend::Redis,
] {
let encoded = backend.to_string();
let decoded = encoded.parse::<CacheBackend>().unwrap();
assert_eq!(decoded, backend);
}
}
#[test]
fn rejects_unknown_cache_backend() {
assert_eq!(
"memcached".parse::<CacheBackend>(),
Err(ParseCacheBackendError {
value: "memcached".to_owned()
})
);
}
#[test]
fn distinguishes_external_cache_backends() {
assert!(!CacheBackend::Memory.is_external());
assert!(CacheBackend::Valkey.is_external());
assert!(CacheBackend::Redis.is_external());
}
#[test]
fn serialized_contracts_use_snake_case_values() {
assert_eq!(
serde_json::to_string(&CacheScope::ReplayGuard).unwrap(),
"\"replay_guard\""
);
assert_eq!(
serde_json::to_string(&ReplayGuardStatus::AlreadySeen).unwrap(),
"\"already_seen\""
);
}
}
+6
View File
@@ -1,6 +1,7 @@
pub mod access;
pub mod agent;
pub mod auth;
pub mod cache;
pub mod edition;
pub mod ids;
pub mod observability;
@@ -22,6 +23,11 @@ pub use auth::{
BasicAuthConfig, BearerAuthConfig, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest,
IssuedAgentTokenResponse,
};
pub use cache::{
CacheBackend, CacheScope, CacheStoreError, CachedHeader, CachedResponse,
CoordinationStateStore, CoordinationStateValue, ParseCacheBackendError, RateLimitBucketState,
RateLimitStateStore, ReplayGuardStatus, ReplayGuardStore, ResponseCacheStore,
};
pub use edition::{
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
};
+180
View File
@@ -0,0 +1,180 @@
use std::{env, num::ParseIntError};
use crank_core::CacheBackend;
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeCacheConfig {
pub backend: CacheBackend,
pub url: Option<String>,
pub default_ttl_ms: Option<u64>,
}
impl Default for RuntimeCacheConfig {
fn default() -> Self {
Self {
backend: CacheBackend::Memory,
url: None,
default_ttl_ms: None,
}
}
}
impl RuntimeCacheConfig {
pub fn from_env() -> Result<Self, RuntimeCacheConfigError> {
let backend = parse_backend()?;
let url = parse_optional_string("CRANK_CACHE_URL")?;
let default_ttl_ms = parse_optional_u64("CRANK_CACHE_DEFAULT_TTL_MS")?;
if backend.is_external() && url.is_none() {
return Err(RuntimeCacheConfigError::MissingUrl { backend });
}
Ok(Self {
backend,
url,
default_ttl_ms,
})
}
}
fn parse_backend() -> Result<CacheBackend, RuntimeCacheConfigError> {
match env::var("CRANK_CACHE_BACKEND") {
Ok(raw) => raw
.parse::<CacheBackend>()
.map_err(|source| RuntimeCacheConfigError::InvalidBackend { value: raw, source }),
Err(env::VarError::NotPresent) => Ok(CacheBackend::Memory),
Err(env::VarError::NotUnicode(_)) => Err(RuntimeCacheConfigError::InvalidUnicode {
name: "CRANK_CACHE_BACKEND",
}),
}
}
fn parse_optional_string(name: &'static str) -> Result<Option<String>, RuntimeCacheConfigError> {
match env::var(name) {
Ok(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_owned()))
}
}
Err(env::VarError::NotPresent) => Ok(None),
Err(env::VarError::NotUnicode(_)) => Err(RuntimeCacheConfigError::InvalidUnicode { name }),
}
}
fn parse_optional_u64(name: &'static str) -> Result<Option<u64>, RuntimeCacheConfigError> {
match env::var(name) {
Ok(raw) => {
let value = raw
.parse::<u64>()
.map_err(|source| RuntimeCacheConfigError::InvalidTtl { value: raw, source })?;
if value == 0 {
return Err(RuntimeCacheConfigError::ZeroTtl { name });
}
Ok(Some(value))
}
Err(env::VarError::NotPresent) => Ok(None),
Err(env::VarError::NotUnicode(_)) => Err(RuntimeCacheConfigError::InvalidUnicode { name }),
}
}
#[derive(Debug, Error)]
pub enum RuntimeCacheConfigError {
#[error("{name} must contain valid UTF-8")]
InvalidUnicode { name: &'static str },
#[error("CRANK_CACHE_BACKEND must be one of memory, valkey, redis, got {value}")]
InvalidBackend {
value: String,
source: crank_core::ParseCacheBackendError,
},
#[error("CRANK_CACHE_DEFAULT_TTL_MS must be a positive integer, got {value}")]
InvalidTtl {
value: String,
source: ParseIntError,
},
#[error("{name} must be greater than zero")]
ZeroTtl { name: &'static str },
#[error("{backend} backend requires CRANK_CACHE_URL")]
MissingUrl { backend: CacheBackend },
}
#[cfg(test)]
mod tests {
use crank_core::CacheBackend;
use super::{RuntimeCacheConfig, RuntimeCacheConfigError};
#[test]
fn defaults_to_in_memory_cache_without_url() {
let config = RuntimeCacheConfig::default();
assert_eq!(config.backend, CacheBackend::Memory);
assert_eq!(config.url, None);
assert_eq!(config.default_ttl_ms, None);
}
#[test]
fn loads_valkey_config_from_env() {
unsafe {
std::env::set_var("CRANK_CACHE_BACKEND", "valkey");
std::env::set_var("CRANK_CACHE_URL", "redis://cache:6379/0");
std::env::set_var("CRANK_CACHE_DEFAULT_TTL_MS", "15000");
}
let config = RuntimeCacheConfig::from_env().unwrap();
assert_eq!(config.backend, CacheBackend::Valkey);
assert_eq!(config.url.as_deref(), Some("redis://cache:6379/0"));
assert_eq!(config.default_ttl_ms, Some(15_000));
unsafe {
std::env::remove_var("CRANK_CACHE_BACKEND");
std::env::remove_var("CRANK_CACHE_URL");
std::env::remove_var("CRANK_CACHE_DEFAULT_TTL_MS");
}
}
#[test]
fn rejects_external_backend_without_url() {
unsafe {
std::env::set_var("CRANK_CACHE_BACKEND", "redis");
std::env::remove_var("CRANK_CACHE_URL");
}
let error = RuntimeCacheConfig::from_env().unwrap_err();
assert!(matches!(
error,
RuntimeCacheConfigError::MissingUrl {
backend: CacheBackend::Redis
}
));
unsafe {
std::env::remove_var("CRANK_CACHE_BACKEND");
}
}
#[test]
fn rejects_zero_ttl() {
unsafe {
std::env::set_var("CRANK_CACHE_DEFAULT_TTL_MS", "0");
}
let error = RuntimeCacheConfig::from_env().unwrap_err();
assert!(matches!(
error,
RuntimeCacheConfigError::ZeroTtl {
name: "CRANK_CACHE_DEFAULT_TTL_MS"
}
));
unsafe {
std::env::remove_var("CRANK_CACHE_DEFAULT_TTL_MS");
}
}
}
+2
View File
@@ -1,5 +1,6 @@
mod aggregation;
mod auth;
mod cache;
mod error;
mod executor;
mod limits;
@@ -11,6 +12,7 @@ mod secret_crypto;
mod streaming;
pub use auth::ResolvedAuth;
pub use cache::{RuntimeCacheConfig, RuntimeCacheConfigError};
pub use error::RuntimeError;
pub use executor::RuntimeExecutor;
pub use limits::{RuntimeLimits, RuntimeLimitsConfigError};