cache: add optional runtime cache contracts
This commit is contained in:
Generated
+1
@@ -433,6 +433,7 @@ dependencies = [
|
||||
name = "crank-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user