#![cfg(feature = "integration-tests")]
mod support;
use support::install_crypto_provider;
use camel_api::ComponentMetrics;
use camel_api::cache::{CacheEntry, CacheRepository, ContentType};
use camel_api::metrics::MetricsHandle;
use camel_config::CamelConfig;
use camel_core::cache::OffloadRepository;
use camel_redis_repo::{RedisCacheRepository, RedisEndpointConfig};
use redis::AsyncCommands;
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use testcontainers::ContainerAsync;
use testcontainers::GenericImage;
use testcontainers::core::{ContainerPort, WaitFor};
use testcontainers::runners::AsyncRunner;
const REDIS_IMAGE_TAG: &str = "7-alpine";
async fn own_redis() -> (ContainerAsync<GenericImage>, String) {
let container = GenericImage::new("redis", REDIS_IMAGE_TAG)
.with_exposed_port(ContainerPort::Tcp(6379))
.with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
.start()
.await
.expect("Redis container failed to start");
let port = container
.get_host_port_ipv4(6379)
.await
.expect("Redis port not available");
(container, format!("redis://127.0.0.1:{port}"))
}
fn disk_offload_toml(url: &str, payload_dir: &str, stale_retention: &str, extra: &str) -> String {
format!(
r#"
[default.cache_repo]
backend = "redis"
url = "{url}"
payload = "disk"
payload_dir = "{payload_dir}"
stale_retention = "{stale_retention}"
{extra}
"#
)
}
fn redis_offload_toml(url: &str, stale_retention: &str, extra: &str) -> String {
format!(
r#"
[default.cache_repo]
backend = "redis"
url = "{url}"
payload = "redis"
stale_retention = "{stale_retention}"
{extra}
"#
)
}
fn load_camel_toml(toml_str: &str) -> CamelConfig {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("Camel.toml");
std::fs::write(&path, toml_str).expect("write Camel.toml");
CamelConfig::from_file(path.to_str().unwrap()).expect("Camel.toml loads")
}
fn shared_log_buffer() -> Arc<Mutex<Vec<u8>>> {
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
static BUFFER: Mutex<Option<Arc<Mutex<Vec<u8>>>>> = Mutex::new(None);
let mut slot = BUFFER.lock().unwrap();
if let Some(buf) = slot.as_ref() {
return Arc::clone(buf);
}
let buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let writer = Arc::clone(&buf);
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new("camel_config=warn"))
.with_ansi(false)
.with_writer(move || SharedWriter(Arc::clone(&writer)))
.try_init();
*slot = Some(Arc::clone(&buf));
Arc::clone(&buf)
}
async fn context_with_disk_offload(
url: &str,
dir: &Path,
stale_retention: &str,
extra: &str,
) -> camel_core::CamelContext {
install_crypto_provider();
shared_log_buffer();
let cfg = load_camel_toml(&disk_offload_toml(
url,
dir.to_str().unwrap(),
stale_retention,
extra,
));
CamelConfig::configure_context(&cfg)
.await
.expect("context builds with redis disk-offload cache_repo")
}
async fn raw_connection(url: &str) -> redis::aio::MultiplexedConnection {
let client = redis::Client::open(url.to_string()).expect("raw client opens");
client
.get_multiplexed_async_connection()
.await
.expect("raw connection established")
}
async fn context_with_redis_offload(
url: &str,
stale_retention: &str,
extra: &str,
) -> camel_core::CamelContext {
install_crypto_provider();
shared_log_buffer();
let cfg = load_camel_toml(&redis_offload_toml(url, stale_retention, extra));
CamelConfig::configure_context(&cfg)
.await
.expect("context builds with redis payload-tier cache_repo")
}
async fn direct_redis_offload_repo(url: &str) -> OffloadRepository {
install_crypto_provider();
let (repo, store) = RedisCacheRepository::connect_with_payload_store(
"redis",
&RedisEndpointConfig::from_uri(url).expect("redis:// endpoint parses"),
"camel:cache",
Duration::from_secs(30),
ComponentMetrics::new(Arc::new(MetricsHandle::new()), false),
)
.await
.expect("redis cache repository connects with its payload store");
OffloadRepository::new(
Arc::new(repo),
Arc::new(store),
Duration::from_secs(30),
Duration::from_secs(60),
Duration::from_secs(60),
)
}
async fn raw_index_row(conn: &mut redis::aio::MultiplexedConnection, key: &str) -> CacheEntry {
let raw: String = conn
.get(format!("camel:cache:redis:{key}"))
.await
.expect("raw GET returns the index row");
serde_json::from_str(&raw).expect("index row is CacheEntry JSON")
}
fn blob_names(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.expect("payload dir is readable")
.map(|entry| {
entry
.expect("dir entry readable")
.file_name()
.to_string_lossy()
.to_string()
})
.filter(|name| name.ends_with(".blob"))
.collect()
}
fn the_blob(dir: &Path) -> std::path::PathBuf {
let blobs = blob_names(dir);
assert_eq!(blobs.len(), 1, "expected exactly one blob, got {blobs:?}");
dir.join(&blobs[0])
}
fn cache_entry(bytes: Vec<u8>) -> CacheEntry {
CacheEntry {
bytes,
payload_path: None,
content_type: ContentType::Bytes,
expires_at: None,
}
}
#[tokio::test(flavor = "multi_thread")]
async fn redis_disk_offload_round_trip() {
let (_container, url) = own_redis().await;
let dir = tempfile::TempDir::new().expect("payload dir tempdir");
let ctx = context_with_disk_offload(&url, dir.path(), "30s", "").await;
let repo = ctx
.cache_repository("redis")
.expect("redis cache repository registered when payload = disk");
assert_eq!(repo.name(), "redis");
let payload = vec![0xD4u8; 50 * 1024];
repo.set(
"k",
cache_entry(payload.clone()),
Some(Duration::from_secs(60)),
)
.await
.expect("set succeeds");
let blobs = blob_names(dir.path());
assert_eq!(
blobs.len(),
1,
"exactly one offloaded blob in the payload dir"
);
let got = repo
.get("k")
.await
.expect("get succeeds")
.expect("entry is present");
assert_eq!(
got.bytes, payload,
"hydrated payload must equal the stored one"
);
let logs = shared_log_buffer().lock().unwrap().clone();
let logs = String::from_utf8_lossy(&logs);
assert!(
logs.contains("offloaded entries under"),
"startup WARN must mention offloaded entries, got: {logs}"
);
assert!(
logs.contains(dir.path().to_str().unwrap()),
"startup WARN must name the payload dir, got: {logs}"
);
let mut conn = raw_connection(&url).await;
let raw: String = conn
.get("camel:cache:redis:k")
.await
.expect("raw GET returns the index row");
let row: serde_json::Value = serde_json::from_str(&raw).expect("index row is JSON");
assert!(
row["bytes"].as_array().is_some_and(Vec::is_empty),
"index row must store an empty bytes array, got: {raw}"
);
let stored = row["payload_path"]
.as_str()
.expect("index row must carry payload_path");
assert_eq!(
stored, blobs[0],
"index row must name the blob present in the payload dir"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn redis_disk_offload_early_sweep_is_miss() {
let (_container, url) = own_redis().await;
let dir = tempfile::TempDir::new().expect("payload dir tempdir");
let ctx = context_with_disk_offload(&url, dir.path(), "30s", "").await;
let repo = ctx
.cache_repository("redis")
.expect("redis cache repository registered");
repo.set(
"k",
cache_entry(b"early-sweep-payload".to_vec()),
Some(Duration::from_secs(60)),
)
.await
.expect("set succeeds");
let blob = the_blob(dir.path());
std::fs::remove_file(&blob).expect("blob file deleted");
let got = repo.get("k").await.expect("get succeeds");
assert!(got.is_none(), "vanished blob must degrade get to a miss");
let stale = repo.peek_stale("k").await.expect("peek_stale succeeds");
assert!(
stale.is_none(),
"vanished blob must degrade peek_stale to a miss"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn redis_disk_offload_sweeper_reclaims_orphan() {
let (_container, url) = own_redis().await;
let dir = tempfile::TempDir::new().expect("payload dir tempdir");
let ctx =
context_with_disk_offload(&url, dir.path(), "1ms", "payload_sweep_interval = \"1s\"").await;
let repo = ctx
.cache_repository("redis")
.expect("redis cache repository registered");
repo.set(
"k",
cache_entry(b"sweeper-orphan".to_vec()),
Some(Duration::from_millis(100)),
)
.await
.expect("set succeeds");
let blob = the_blob(dir.path());
support::wait::wait_until(
"sweeper reclaims the dead payload blob",
Duration::from_secs(8),
Duration::from_millis(100),
|| async { Ok(!blob.exists()) },
)
.await
.expect("blob must be gone from the payload dir");
}
#[tokio::test(flavor = "multi_thread")]
async fn redis_disk_offload_no_ttl_capped() {
let (_container, url) = own_redis().await;
let dir = tempfile::TempDir::new().expect("payload dir tempdir");
let ctx = context_with_disk_offload(
&url,
dir.path(),
"1ms",
"payload_sweep_interval = \"1s\"\npayload_max_ttl = \"1s\"",
)
.await;
let repo = ctx
.cache_repository("redis")
.expect("redis cache repository registered");
repo.set("k", cache_entry(b"capped-payload".to_vec()), None)
.await
.expect("set without ttl succeeds");
let blob = the_blob(dir.path());
support::wait::wait_until(
"no-ttl entry expires at payload_max_ttl",
Duration::from_secs(5),
Duration::from_millis(100),
|| async {
repo.get("k")
.await
.map(|entry| entry.is_none())
.map_err(|e| e.to_string())
},
)
.await
.expect("no-ttl entry must expire at payload_max_ttl");
support::wait::wait_until(
"sweeper reclaims the capped payload blob",
Duration::from_secs(8),
Duration::from_millis(100),
|| async { Ok(!blob.exists()) },
)
.await
.expect("blob must be swept from the payload dir");
}
#[tokio::test(flavor = "multi_thread")]
async fn redis_payload_tier_roundtrip_index_split_and_ttl() {
let (_container, url) = own_redis().await;
let repo = direct_redis_offload_repo(&url).await;
repo.set(
"k",
cache_entry(b"hello-tier".to_vec()),
Some(Duration::from_secs(60)),
)
.await
.expect("set succeeds");
let got = repo
.get("k")
.await
.expect("get succeeds")
.expect("entry is present");
assert_eq!(
got.bytes,
b"hello-tier".to_vec(),
"hydrated payload must equal the stored one"
);
let mut conn = raw_connection(&url).await;
let row = raw_index_row(&mut conn, "k").await;
assert!(
row.bytes.is_empty(),
"index row must store no bytes, got {}",
row.bytes.len()
);
let payload_path = row.payload_path.expect("index row must carry payload_path");
let payload_key = format!("camel:cache:redis:payload:{payload_path}");
let exists: i64 = conn
.exists(&payload_key)
.await
.expect("payload EXISTS reads");
assert_eq!(exists, 1, "payload key must exist");
let stored: Vec<u8> = conn
.get(&payload_key)
.await
.expect("raw GET returns the payload key");
assert_eq!(
stored,
b"hello-tier".to_vec(),
"payload key must hold the raw bytes"
);
let ttl: i64 = conn.ttl(&payload_key).await.expect("payload TTL reads");
assert!(
ttl > 0,
"payload key must have a live deadline, got TTL {ttl}"
);
assert!(
ttl <= 160,
"payload deadline must be the finite death epoch (~150s), got TTL {ttl}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn redis_payload_tier_overwrite_reclaims_predecessor() {
let (_container, url) = own_redis().await;
let repo = direct_redis_offload_repo(&url).await;
let mut conn = raw_connection(&url).await;
repo.set(
"k",
cache_entry(b"payload-v1".to_vec()),
Some(Duration::from_secs(60)),
)
.await
.expect("first set succeeds");
let predecessor = raw_index_row(&mut conn, "k")
.await
.payload_path
.expect("v1 index row carries payload_path");
repo.set(
"k",
cache_entry(b"payload-v2".to_vec()),
Some(Duration::from_secs(60)),
)
.await
.expect("second set succeeds");
let predecessor_key = format!("camel:cache:redis:payload:{predecessor}");
let exists: i64 = conn
.exists(&predecessor_key)
.await
.expect("predecessor EXISTS reads");
assert_eq!(
exists, 0,
"overwrite must reclaim the predecessor payload key"
);
let successor = raw_index_row(&mut conn, "k")
.await
.payload_path
.expect("v2 index row carries payload_path");
assert_ne!(
successor, predecessor,
"overwrite must mint a new payload key"
);
let stored: Vec<u8> = conn
.get(format!("camel:cache:redis:payload:{successor}"))
.await
.expect("raw GET returns the successor payload key");
assert_eq!(
stored,
b"payload-v2".to_vec(),
"successor payload key must hold v2"
);
let got = repo
.get("k")
.await
.expect("get succeeds")
.expect("entry is present");
assert_eq!(
got.bytes,
b"payload-v2".to_vec(),
"get must return the successor bytes"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn redis_payload_tier_invalidate_prefix_spares_payload_keys() {
let (_container, url) = own_redis().await;
let repo = direct_redis_offload_repo(&url).await;
let mut conn = raw_connection(&url).await;
repo.set(
"k1",
cache_entry(b"tiered-payload".to_vec()),
Some(Duration::from_secs(60)),
)
.await
.expect("set k1 succeeds");
repo.set(
"p-user",
cache_entry(b"index-only".to_vec()),
Some(Duration::from_secs(60)),
)
.await
.expect("set p-user succeeds");
let removed = repo
.invalidate_prefix("p")
.await
.expect("invalidate_prefix succeeds");
assert_eq!(
removed, 1,
"the count covers only the p-user index row, never payload keys"
);
let p_row: i64 = conn
.exists("camel:cache:redis:p-user")
.await
.expect("p-user EXISTS reads");
assert_eq!(p_row, 0, "invalidate_prefix must drop the p-user index row");
let blob = raw_index_row(&mut conn, "k1")
.await
.payload_path
.expect("k1 index row carries payload_path");
let payload_exists: i64 = conn
.exists(format!("camel:cache:redis:payload:{blob}"))
.await
.expect("payload EXISTS reads");
assert_eq!(
payload_exists, 1,
"the k1 payload blob must survive the prefix sweep"
);
let got = repo
.get("k1")
.await
.expect("get succeeds")
.expect("k1 is present");
assert_eq!(
got.bytes,
b"tiered-payload".to_vec(),
"get must hydrate the surviving payload"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn redis_payload_tier_boots_from_toml() {
let (_container, url) = own_redis().await;
let ctx = context_with_redis_offload(&url, "30s", "").await;
let repo = ctx
.cache_repository("redis")
.expect("redis cache repository registered when payload = redis");
assert_eq!(repo.name(), "redis");
repo.set(
"k",
cache_entry(b"boot-path-tier".to_vec()),
Some(Duration::from_secs(60)),
)
.await
.expect("set succeeds");
let got = repo
.get("k")
.await
.expect("get succeeds")
.expect("entry is present");
assert_eq!(
got.bytes,
b"boot-path-tier".to_vec(),
"hydrated payload must equal the stored one"
);
let mut conn = raw_connection(&url).await;
let row = raw_index_row(&mut conn, "k").await;
assert!(
row.bytes.is_empty(),
"boot-path index row must store no bytes, got {}",
row.bytes.len()
);
assert!(
row.payload_path.is_some(),
"boot-path index row must carry payload_path"
);
}