use std::collections::HashMap;
use std::time::Duration;
use async_trait::async_trait;
use crate::error::BackendError;
use crate::metrics::MetricsProvider;
#[derive(Debug, Clone)]
pub struct HealthStatus {
pub is_healthy: bool,
pub latency_ms: f64,
pub backend_type: String,
pub details: HashMap<String, String>,
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Freshness {
pub is_stale: bool,
pub fresh_for: Option<Duration>,
}
#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
#[async_trait]
pub trait Backend: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
#[doc(hidden)]
async fn get_with_freshness(
&self,
key: &str,
) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
Ok(self
.get(key)
.await?
.map(|bytes| (bytes, Freshness::default())))
}
async fn set(
&self,
key: &str,
value: Vec<u8>,
ttl: Option<Duration>,
) -> Result<(), BackendError>;
async fn delete(&self, key: &str) -> Result<bool, BackendError>;
async fn exists(&self, key: &str) -> Result<bool, BackendError>;
async fn health(&self) -> Result<HealthStatus, BackendError>;
fn as_lockable(&self) -> Option<&dyn LockableBackend> {
None
}
fn attach_metrics(&self, _provider: MetricsProvider) {}
}
#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
#[async_trait(?Send)]
pub trait Backend {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
#[doc(hidden)]
async fn get_with_freshness(
&self,
key: &str,
) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
Ok(self
.get(key)
.await?
.map(|bytes| (bytes, Freshness::default())))
}
async fn set(
&self,
key: &str,
value: Vec<u8>,
ttl: Option<Duration>,
) -> Result<(), BackendError>;
async fn delete(&self, key: &str) -> Result<bool, BackendError>;
async fn exists(&self, key: &str) -> Result<bool, BackendError>;
async fn health(&self) -> Result<HealthStatus, BackendError>;
fn as_lockable(&self) -> Option<&dyn LockableBackend> {
None
}
fn attach_metrics(&self, _provider: MetricsProvider) {}
}
#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
#[async_trait]
pub trait TtlInspectable: Backend {
async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
Err(BackendError::permanent(
"refresh_ttl not supported by this backend",
))
}
}
#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
#[async_trait(?Send)]
pub trait TtlInspectable: Backend {
async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
Err(BackendError::permanent(
"refresh_ttl not supported by this backend",
))
}
}
#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
#[async_trait]
pub trait LockableBackend: Backend {
async fn acquire_lock(
&self,
key: &str,
timeout_ms: u64,
) -> Result<Option<String>, BackendError>;
async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
}
#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
#[async_trait(?Send)]
pub trait LockableBackend: Backend {
async fn acquire_lock(
&self,
key: &str,
timeout_ms: u64,
) -> Result<Option<String>, BackendError>;
async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
}
#[cfg(all(any(feature = "file", feature = "memcached"), not(feature = "unsync")))]
pub(crate) async fn run_blocking<T: Send + 'static>(
f: impl FnOnce() -> Result<T, crate::error::BackendError> + Send + 'static,
) -> Result<T, crate::error::BackendError> {
tokio::task::spawn_blocking(f).await.map_err(|e| {
crate::error::BackendError::permanent(format!("backend blocking task failed: {e}"))
})?
}
#[cfg(all(any(feature = "file", feature = "memcached"), feature = "unsync"))]
pub(crate) async fn run_blocking<T>(
f: impl FnOnce() -> Result<T, crate::error::BackendError>,
) -> Result<T, crate::error::BackendError> {
f()
}
#[cfg(any(feature = "cachekitio", feature = "workers", test))]
pub(crate) fn encode_key(key: &str) -> Result<std::borrow::Cow<'_, str>, BackendError> {
let encoded = urlencoding::encode(key);
if matches!(
encoded.as_ref(),
"" | "." | ".." | "health" | "ttl" | "lock"
) {
return Err(BackendError::permanent(
"cache key must not be empty or a reserved path segment (`.`, `..`, \
`health`, `ttl`, `lock`): the request path would leave \
`/v1/cache/{key}` (CWE-22), so it cannot be addressed on the wire",
));
}
Ok(encoded)
}
#[cfg(any(feature = "cachekitio", feature = "workers", test))]
pub(crate) fn delete_succeeded(status: u16) -> bool {
matches!(status, 200 | 204)
}
#[cfg(any(
feature = "cachekitio",
feature = "workers",
feature = "l1",
feature = "tracing",
test
))]
pub(crate) fn ttl_wire_secs(ttl: Duration) -> u64 {
if ttl.is_zero() {
return 0;
}
ttl.as_secs().max(1)
}
#[cfg(any(feature = "cachekitio", feature = "workers", test))]
pub(crate) fn ttl_header(ttl: Duration) -> (&'static str, String) {
("X-CacheKit-TTL", ttl_wire_secs(ttl).to_string())
}
#[cfg(any(
all(feature = "cachekitio", not(target_arch = "wasm32")),
feature = "workers"
))]
pub(crate) const READ_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(any(
all(feature = "cachekitio", not(target_arch = "wasm32")),
feature = "workers"
))]
pub(crate) const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(any(feature = "workers", test))]
mod saas_wire;
#[cfg(feature = "cachekitio")]
pub mod cachekitio;
#[cfg(feature = "cachekitio")]
mod cachekitio_lock;
#[cfg(feature = "cachekitio")]
mod cachekitio_ttl;
#[cfg(feature = "redis")]
pub mod redis;
#[cfg(feature = "memcached")]
pub mod memcached;
#[cfg(feature = "file")]
pub mod file;
#[cfg(feature = "workers")]
pub mod workers;
#[cfg(test)]
#[allow(clippy::expect_used)] pub(crate) mod path_encoding_vectors {
use serde::Deserialize;
const JSON: &str = include_str!("../../tests/vectors/path-encoding.json");
const SHA256: &str = "807af0d39ccf3f5afda65577787ac5adcd01ff6617468060161c84267fa7e0cb";
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Vector {
pub(crate) key: String,
pub(crate) encoded: Option<String>,
#[serde(default)]
pub(crate) reject: bool,
#[serde(rename = "decoded")]
_decoded: Option<String>,
#[serde(rename = "encoded_alternates", default)]
_encoded_alternates: Vec<String>,
#[serde(rename = "note")]
_note: String,
}
#[derive(Deserialize)]
struct Fixture {
vectors: Vec<Vector>,
}
fn all() -> Vec<Vector> {
serde_json::from_str::<Fixture>(JSON)
.expect("vendored path-encoding.json must match the fixture schema")
.vectors
}
pub(crate) fn reject_keys() -> Vec<String> {
all()
.into_iter()
.filter(|v| v.reject)
.map(|v| v.key)
.collect()
}
pub(crate) fn transmittable() -> Vec<Vector> {
all().into_iter().filter(|v| !v.reject).collect()
}
pub(crate) const RS_NEAR_MISSES: &[&str] = &["healthy", "HEALTH", "ttls", "unlock"];
#[test]
fn vendored_fixture_matches_the_pinned_sha256() {
use sha2::{Digest, Sha256};
assert_eq!(
hex::encode(Sha256::digest(JSON.as_bytes())),
SHA256,
"tests/vectors/path-encoding.json differs from the pinned protocol copy: \
re-vendor it from protocol and update SHA256"
);
}
}
#[cfg(test)]
#[allow(clippy::expect_used)] mod encode_key_tests {
use super::encode_key;
use super::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
#[test]
fn fixture_reject_rows_are_rejected() {
let keys = reject_keys();
assert!(!keys.is_empty(), "fixture has no reject rows");
for k in &keys {
assert!(
encode_key(k).is_err(),
"reserved key {k:?} must be rejected"
);
}
}
#[test]
fn fixture_transmittable_rows_encode_to_the_reference_form() {
let rows = transmittable();
assert!(!rows.is_empty(), "fixture has no transmittable rows");
for row in &rows {
let enc = encode_key(&row.key).expect("transmittable fixture key must encode");
assert_eq!(
Some(enc.as_ref()),
row.encoded.as_deref(),
"encode_key({:?}) is not the fixture's reference form",
row.key
);
}
}
#[test]
fn near_misses_are_not_rejected() {
for k in RS_NEAR_MISSES {
assert!(encode_key(k).is_ok(), "near-miss key {k:?} must encode");
}
}
}
#[cfg(test)]
mod delete_status_tests {
use super::delete_succeeded;
#[test]
fn delete_success_is_200_or_204() {
assert!(delete_succeeded(200));
assert!(delete_succeeded(204));
}
#[test]
fn delete_404_is_not_a_miss() {
assert!(!delete_succeeded(404));
for status in [201, 400, 401, 403, 429, 500, 503] {
assert!(!delete_succeeded(status), "{status}");
}
}
}
#[cfg(test)]
mod ttl_header_tests {
use std::time::Duration;
use super::{ttl_header, ttl_wire_secs};
#[test]
fn ttl_header_is_the_canonical_name_in_whole_seconds() {
assert_eq!(
ttl_header(Duration::from_millis(60_900)),
("X-CacheKit-TTL", "60".to_owned())
);
}
#[test]
fn ttl_header_sends_a_sub_second_ttl_as_one() {
assert_eq!(
ttl_header(Duration::from_millis(500)),
("X-CacheKit-TTL", "1".to_owned())
);
}
#[test]
fn wire_secs_ceil_sub_second_and_truncate_the_rest() {
assert_eq!(ttl_wire_secs(Duration::from_millis(500)), 1);
assert_eq!(ttl_wire_secs(Duration::from_nanos(1)), 1);
assert_eq!(ttl_wire_secs(Duration::from_secs(60)), 60);
assert_eq!(ttl_wire_secs(Duration::from_millis(60_900)), 60);
}
#[test]
fn wire_secs_keep_zero_as_zero() {
assert_eq!(ttl_wire_secs(Duration::ZERO), 0);
}
}
#[cfg(test)]
#[allow(dead_code)] #[derive(Clone, Default)]
pub(crate) struct MemoryBackend {
pub(crate) store: std::sync::Arc<tokio::sync::Mutex<HashMap<String, Vec<u8>>>>,
}
#[cfg(test)]
#[cfg_attr(not(any(target_arch = "wasm32", feature = "unsync")), async_trait)]
#[cfg_attr(any(target_arch = "wasm32", feature = "unsync"), async_trait(?Send))]
impl Backend for MemoryBackend {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
Ok(self.store.lock().await.get(key).cloned())
}
async fn set(
&self,
key: &str,
value: Vec<u8>,
_ttl: Option<Duration>,
) -> Result<(), BackendError> {
self.store.lock().await.insert(key.to_owned(), value);
Ok(())
}
async fn delete(&self, key: &str) -> Result<bool, BackendError> {
Ok(self.store.lock().await.remove(key).is_some())
}
async fn exists(&self, key: &str) -> Result<bool, BackendError> {
Ok(self.store.lock().await.contains_key(key))
}
async fn health(&self) -> Result<HealthStatus, BackendError> {
Ok(HealthStatus {
is_healthy: true,
latency_ms: 0.0,
backend_type: "memory".to_owned(),
details: HashMap::new(),
})
}
}