use std::sync::{
OnceLock,
atomic::{AtomicU8, Ordering}
};
use anyhow::{Context, Result, bail};
use testcontainers::{ContainerAsync, runners::AsyncRunner};
use testcontainers_modules::redis::Redis;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::testing::must::testcontainers_enabled;
use super::MaeContainer;
pub const REDIS_PORT: u16 = 6379;
const REDIS_DB_COUNT: u8 = 16;
pub struct Inner {
pub container: ContainerAsync<Redis>,
pub port: u16,
pub base_url: String
}
static SINGLETON: OnceLock<Mutex<Option<Inner>>> = OnceLock::new();
static DB_COUNTER: AtomicU8 = AtomicU8::new(0);
fn redis_mutex() -> &'static Mutex<Option<Inner>> {
SINGLETON.get_or_init(|| Mutex::new(None))
}
pub struct RedisScope {
pub db_index: u8,
pub key_prefix: String,
pub url: String
}
impl RedisScope {
pub fn url(&self) -> &str {
&self.url
}
pub fn db(&self) -> u8 {
self.db_index
}
pub fn flushdb_command(&self) -> &'static str {
"FLUSHDB"
}
pub async fn flush(&self) -> Result<()> {
Ok(())
}
}
pub struct RedisContainer;
impl MaeContainer for RedisContainer {
type Scope = RedisScope;
async fn start() -> Option<()> {
redis_singleton().await.lock().await.as_ref().map(|_| ())
}
async fn scope() -> Result<RedisScope> {
spawn_scoped_keyspace().await
}
async fn teardown() {
teardown().await;
}
}
pub async fn get_redis_url() -> Result<(String, u16)> {
ensure_started().await?;
let guard = redis_mutex().lock().await;
let c = guard.as_ref().context("redis container is not running — this is a bug")?;
Ok((format!("{}/0", c.base_url), c.port))
}
pub async fn redis_singleton() -> &'static Mutex<Option<Inner>> {
let _ = ensure_started().await;
redis_mutex()
}
pub async fn spawn_scoped_keyspace() -> Result<RedisScope> {
ensure_started().await?;
let db_index = DB_COUNTER.fetch_add(1, Ordering::Relaxed) % REDIS_DB_COUNT;
let guard = redis_mutex().lock().await;
let c = guard.as_ref().context("redis container is not running — this is a bug")?;
let key_prefix = format!("test:{}", Uuid::new_v4().to_string().replace('-', ""));
let url = format!("{}/{db_index}", c.base_url);
Ok(RedisScope { db_index, key_prefix, url })
}
pub async fn teardown() {
let mut guard = redis_mutex().lock().await;
if let Some(c) = guard.take() {
let _ = c.container.stop().await;
drop(c);
}
DB_COUNTER.store(0, Ordering::Relaxed);
}
async fn ensure_started() -> Result<()> {
if !testcontainers_enabled() {
bail!(
"testcontainers disabled — set MAE_TESTCONTAINERS=1 to run Redis tests. \
In Concourse, ensure the task has Docker socket access (DinD)."
);
}
let mut guard = redis_mutex().lock().await;
if guard.is_none() {
let container: ContainerAsync<Redis> =
Redis::default().start().await.context("failed to start Redis container")?;
let port = container
.get_host_port_ipv4(REDIS_PORT)
.await
.context("failed to get Redis host port")?;
let base_url = format!("redis://localhost:{port}");
*guard = Some(Inner { container, port, base_url });
}
Ok(())
}