cache: add in-memory fallback stores
This commit is contained in:
Generated
+1
@@ -486,6 +486,7 @@ name = "crank-runtime"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"crank-adapter-graphql",
|
"crank-adapter-graphql",
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ Progress:
|
|||||||
- done:
|
- done:
|
||||||
- product/docs/runtime plan already define cache layer as optional and edition-aware
|
- 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`
|
- public cache contracts and runtime cache config now exist in `crank-core` / `crank-runtime`
|
||||||
|
- Community fallback in-memory implementations now exist for response cache, rate-limit state, replay guard, and coordination state
|
||||||
- pending:
|
- pending:
|
||||||
- Community fallback implementations
|
|
||||||
- optional `Valkey` in Community manifests
|
- optional `Valkey` in Community manifests
|
||||||
- external cache backend for managed / enterprise contours
|
- external cache backend for managed / enterprise contours
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ version.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aes-gcm.workspace = true
|
aes-gcm.workspace = true
|
||||||
|
async-trait = "0.1"
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
crank-adapter-graphql = { path = "../crank-adapter-graphql" }
|
crank-adapter-graphql = { path = "../crank-adapter-graphql" }
|
||||||
crank-adapter-grpc = { path = "../crank-adapter-grpc" }
|
crank-adapter-grpc = { path = "../crank-adapter-grpc" }
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
use std::{env, num::ParseIntError};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
env,
|
||||||
|
num::ParseIntError,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use crank_core::CacheBackend;
|
use async_trait::async_trait;
|
||||||
|
use crank_core::{
|
||||||
|
CacheBackend, CacheScope, CacheStoreError, CachedResponse, CoordinationStateStore,
|
||||||
|
CoordinationStateValue, RateLimitBucketState, RateLimitStateStore, ReplayGuardStatus,
|
||||||
|
ReplayGuardStore, ResponseCacheStore,
|
||||||
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct RuntimeCacheConfig {
|
pub struct RuntimeCacheConfig {
|
||||||
@@ -38,6 +50,186 @@ impl RuntimeCacheConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct InMemoryResponseCacheStore {
|
||||||
|
entries: Arc<RwLock<HashMap<String, ExpiringValue<CachedResponse>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct InMemoryRateLimitStateStore {
|
||||||
|
entries: Arc<RwLock<HashMap<String, ExpiringValue<RateLimitBucketState>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct InMemoryReplayGuardStore {
|
||||||
|
entries: Arc<RwLock<HashMap<String, Instant>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct InMemoryCoordinationStateStore {
|
||||||
|
entries: Arc<RwLock<HashMap<String, ExpiringValue<CoordinationStateValue>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ExpiringValue<T> {
|
||||||
|
value: T,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ResponseCacheStore for InMemoryResponseCacheStore {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<CachedResponse>, CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
retain_unexpired(&mut entries, now);
|
||||||
|
Ok(entries.get(key).map(|entry| entry.value.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: CachedResponse,
|
||||||
|
ttl: Duration,
|
||||||
|
) -> Result<(), CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let expires_at = expiry_from_ttl(ttl)?;
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.insert(key.to_owned(), ExpiringValue { value, expires_at });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, key: &str) -> Result<(), CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RateLimitStateStore for InMemoryRateLimitStateStore {
|
||||||
|
async fn get_bucket(&self, key: &str) -> Result<Option<RateLimitBucketState>, CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
retain_unexpired(&mut entries, now);
|
||||||
|
Ok(entries.get(key).map(|entry| entry.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_bucket(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: RateLimitBucketState,
|
||||||
|
ttl: Duration,
|
||||||
|
) -> Result<(), CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let expires_at = expiry_from_ttl(ttl)?;
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.insert(key.to_owned(), ExpiringValue { value, expires_at });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_bucket(&self, key: &str) -> Result<(), CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ReplayGuardStore for InMemoryReplayGuardStore {
|
||||||
|
async fn mark_seen(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
ttl: Duration,
|
||||||
|
) -> Result<ReplayGuardStatus, CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let expires_at = expiry_from_ttl(ttl)?;
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.retain(|_, entry_expires_at| *entry_expires_at > now);
|
||||||
|
if entries.get(key).is_some() {
|
||||||
|
return Ok(ReplayGuardStatus::AlreadySeen);
|
||||||
|
}
|
||||||
|
entries.insert(key.to_owned(), expires_at);
|
||||||
|
Ok(ReplayGuardStatus::Fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear(&self, key: &str) -> Result<(), CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CoordinationStateStore for InMemoryCoordinationStateStore {
|
||||||
|
async fn get_value(
|
||||||
|
&self,
|
||||||
|
scope: CacheScope,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Option<CoordinationStateValue>, CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let storage_key = scoped_key(scope, key);
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
retain_unexpired(&mut entries, now);
|
||||||
|
Ok(entries.get(&storage_key).map(|entry| entry.value.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_value(
|
||||||
|
&self,
|
||||||
|
scope: CacheScope,
|
||||||
|
key: &str,
|
||||||
|
value: CoordinationStateValue,
|
||||||
|
ttl: Duration,
|
||||||
|
) -> Result<(), CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let expires_at = expiry_from_ttl(ttl)?;
|
||||||
|
let storage_key = scoped_key(scope, key);
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.insert(storage_key, ExpiringValue { value, expires_at });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_value(&self, scope: CacheScope, key: &str) -> Result<(), CacheStoreError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
let storage_key = scoped_key(scope, key);
|
||||||
|
let mut entries = self.entries.write().await;
|
||||||
|
entries.remove(&storage_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_key(key: &str) -> Result<(), CacheStoreError> {
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err(CacheStoreError::InvalidKey {
|
||||||
|
message: "cache key must not be empty".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expiry_from_ttl(ttl: Duration) -> Result<Instant, CacheStoreError> {
|
||||||
|
if ttl.is_zero() {
|
||||||
|
return Err(CacheStoreError::InvalidKey {
|
||||||
|
message: "cache ttl must be greater than zero".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Instant::now() + ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retain_unexpired<T>(entries: &mut HashMap<String, ExpiringValue<T>>, now: Instant) {
|
||||||
|
entries.retain(|_, entry| entry.expires_at > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scoped_key(scope: CacheScope, key: &str) -> String {
|
||||||
|
format!("{scope:?}:{key}")
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_backend() -> Result<CacheBackend, RuntimeCacheConfigError> {
|
fn parse_backend() -> Result<CacheBackend, RuntimeCacheConfigError> {
|
||||||
match env::var("CRANK_CACHE_BACKEND") {
|
match env::var("CRANK_CACHE_BACKEND") {
|
||||||
Ok(raw) => raw
|
Ok(raw) => raw
|
||||||
@@ -103,9 +295,20 @@ pub enum RuntimeCacheConfigError {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crank_core::CacheBackend;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::{RuntimeCacheConfig, RuntimeCacheConfigError};
|
use crank_core::CacheBackend;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
InMemoryCoordinationStateStore, InMemoryRateLimitStateStore, InMemoryReplayGuardStore,
|
||||||
|
InMemoryResponseCacheStore, RuntimeCacheConfig, RuntimeCacheConfigError,
|
||||||
|
};
|
||||||
|
use crank_core::{
|
||||||
|
CacheScope, CacheStoreError, CachedHeader, CachedResponse, CoordinationStateStore,
|
||||||
|
CoordinationStateValue, RateLimitBucketState, RateLimitStateStore, ReplayGuardStatus,
|
||||||
|
ReplayGuardStore, ResponseCacheStore,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_to_in_memory_cache_without_url() {
|
fn defaults_to_in_memory_cache_without_url() {
|
||||||
@@ -177,4 +380,144 @@ mod tests {
|
|||||||
std::env::remove_var("CRANK_CACHE_DEFAULT_TTL_MS");
|
std::env::remove_var("CRANK_CACHE_DEFAULT_TTL_MS");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn in_memory_response_cache_roundtrips_values() {
|
||||||
|
let store = InMemoryResponseCacheStore::default();
|
||||||
|
let value = CachedResponse {
|
||||||
|
status: 200,
|
||||||
|
headers: vec![CachedHeader {
|
||||||
|
name: "content-type".to_owned(),
|
||||||
|
value: "application/json".to_owned(),
|
||||||
|
}],
|
||||||
|
body: br#"{"ok":true}"#.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store
|
||||||
|
.put("response:crm:list", value.clone(), Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.get("response:crm:list").await.unwrap(),
|
||||||
|
Some(value.clone())
|
||||||
|
);
|
||||||
|
|
||||||
|
store.delete("response:crm:list").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(store.get("response:crm:list").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn in_memory_rate_limit_store_roundtrips_bucket_state() {
|
||||||
|
let store = InMemoryRateLimitStateStore::default();
|
||||||
|
let bucket = RateLimitBucketState {
|
||||||
|
tokens_micros: 1_500_000,
|
||||||
|
last_refill_unix_ms: 1_735_689_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
store
|
||||||
|
.put_bucket("tenant:alpha", bucket, Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.get_bucket("tenant:alpha").await.unwrap(),
|
||||||
|
Some(bucket)
|
||||||
|
);
|
||||||
|
|
||||||
|
store.delete_bucket("tenant:alpha").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(store.get_bucket("tenant:alpha").await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn in_memory_replay_guard_marks_key_only_once_until_cleared() {
|
||||||
|
let store = InMemoryReplayGuardStore::default();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.mark_seen("token:nonce:1", Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
ReplayGuardStatus::Fresh
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.mark_seen("token:nonce:1", Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
ReplayGuardStatus::AlreadySeen
|
||||||
|
);
|
||||||
|
|
||||||
|
store.clear("token:nonce:1").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.mark_seen("token:nonce:1", Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
ReplayGuardStatus::Fresh
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn in_memory_coordination_store_scopes_keys() {
|
||||||
|
let store = InMemoryCoordinationStateStore::default();
|
||||||
|
let response_value = CoordinationStateValue {
|
||||||
|
payload: json!({ "cursor": "abc" }),
|
||||||
|
};
|
||||||
|
let session_value = CoordinationStateValue {
|
||||||
|
payload: json!({ "cursor": "xyz" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
store
|
||||||
|
.put_value(
|
||||||
|
CacheScope::Response,
|
||||||
|
"job-1",
|
||||||
|
response_value.clone(),
|
||||||
|
Duration::from_secs(20),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.put_value(
|
||||||
|
CacheScope::Coordination,
|
||||||
|
"job-1",
|
||||||
|
session_value.clone(),
|
||||||
|
Duration::from_secs(20),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_value(CacheScope::Response, "job-1")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Some(response_value)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_value(CacheScope::Coordination, "job-1")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Some(session_value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn in_memory_stores_reject_empty_keys() {
|
||||||
|
let response_store = InMemoryResponseCacheStore::default();
|
||||||
|
let replay_store = InMemoryReplayGuardStore::default();
|
||||||
|
|
||||||
|
let error = response_store.get("").await.unwrap_err();
|
||||||
|
assert!(matches!(error, CacheStoreError::InvalidKey { .. }));
|
||||||
|
|
||||||
|
let error = replay_store
|
||||||
|
.mark_seen("", Duration::from_secs(1))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, CacheStoreError::InvalidKey { .. }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ mod secret_crypto;
|
|||||||
mod streaming;
|
mod streaming;
|
||||||
|
|
||||||
pub use auth::ResolvedAuth;
|
pub use auth::ResolvedAuth;
|
||||||
pub use cache::{RuntimeCacheConfig, RuntimeCacheConfigError};
|
pub use cache::{
|
||||||
|
InMemoryCoordinationStateStore, InMemoryRateLimitStateStore, InMemoryReplayGuardStore,
|
||||||
|
InMemoryResponseCacheStore, 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