cache: add optional runtime cache contracts
This commit is contained in:
Generated
+1
@@ -433,6 +433,7 @@ dependencies = [
|
|||||||
name = "crank-core"
|
name = "crank-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
|||||||
@@ -2,6 +2,60 @@
|
|||||||
|
|
||||||
## Current
|
## 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`
|
### `feat/open-core-repo-boundary`
|
||||||
|
|
||||||
Status: in_progress
|
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
|
- 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
|
- next management gate is to actually create the `3` target repositories before physical split starts
|
||||||
|
|
||||||
## Planned
|
|
||||||
|
|
||||||
### `feat/enterprise-access-governance`
|
### `feat/enterprise-access-governance`
|
||||||
|
|
||||||
Status: ready
|
Status: ready
|
||||||
@@ -123,45 +175,6 @@ DoD:
|
|||||||
- Enterprise и Cloud используют private delivery path;
|
- Enterprise и Cloud используют private delivery path;
|
||||||
- коммерческий код не требуется публиковать в open source ради поставки.
|
- коммерческий код не требуется публиковать в 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`
|
### `feat/live-authenticated-staging-entry`
|
||||||
|
|
||||||
Status: ready
|
Status: ready
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ rust-version.workspace = true
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.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 access;
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod cache;
|
||||||
pub mod edition;
|
pub mod edition;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
@@ -22,6 +23,11 @@ pub use auth::{
|
|||||||
BasicAuthConfig, BearerAuthConfig, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest,
|
BasicAuthConfig, BearerAuthConfig, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest,
|
||||||
IssuedAgentTokenResponse,
|
IssuedAgentTokenResponse,
|
||||||
};
|
};
|
||||||
|
pub use cache::{
|
||||||
|
CacheBackend, CacheScope, CacheStoreError, CachedHeader, CachedResponse,
|
||||||
|
CoordinationStateStore, CoordinationStateValue, ParseCacheBackendError, RateLimitBucketState,
|
||||||
|
RateLimitStateStore, ReplayGuardStatus, ReplayGuardStore, ResponseCacheStore,
|
||||||
|
};
|
||||||
pub use edition::{
|
pub use edition::{
|
||||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
|
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 aggregation;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod cache;
|
||||||
mod error;
|
mod error;
|
||||||
mod executor;
|
mod executor;
|
||||||
mod limits;
|
mod limits;
|
||||||
@@ -11,6 +12,7 @@ mod secret_crypto;
|
|||||||
mod streaming;
|
mod streaming;
|
||||||
|
|
||||||
pub use auth::ResolvedAuth;
|
pub use auth::ResolvedAuth;
|
||||||
|
pub use cache::{RuntimeCacheConfig, RuntimeCacheConfigError};
|
||||||
pub use error::RuntimeError;
|
pub use error::RuntimeError;
|
||||||
pub use executor::RuntimeExecutor;
|
pub use executor::RuntimeExecutor;
|
||||||
pub use limits::{RuntimeLimits, RuntimeLimitsConfigError};
|
pub use limits::{RuntimeLimits, RuntimeLimitsConfigError};
|
||||||
|
|||||||
Reference in New Issue
Block a user