use std::future::Future;
use std::pin::Pin;
use redis::AsyncCommands;
use redis::aio::ConnectionManager;
use crate::error::StateError;
pub trait DispatchLedger: Send + Sync {
fn get<'a>(
&'a self,
key: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, StateError>> + Send + 'a>>;
fn record<'a>(
&'a self,
key: &'a str,
output: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>>;
}
pub struct NoopDispatchLedger;
impl DispatchLedger for NoopDispatchLedger {
fn get<'a>(
&'a self,
_key: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, StateError>> + Send + 'a>>
{
Box::pin(async { Ok(None) })
}
fn record<'a>(
&'a self,
_key: &'a str,
_output: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
}
const DISPATCH_TTL_SECS: u64 = 3600;
fn dispatch_key(key: &str) -> String {
format!("aw:dispatch:{key}")
}
pub struct RedisDispatchLedger {
manager: ConnectionManager,
}
impl RedisDispatchLedger {
pub fn new(manager: ConnectionManager) -> Self {
Self { manager }
}
}
impl DispatchLedger for RedisDispatchLedger {
fn get<'a>(
&'a self,
key: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, StateError>> + Send + 'a>>
{
Box::pin(async move {
let redis_key = dispatch_key(key);
let mut conn = self.manager.clone();
let raw: Option<String> = conn
.get(&redis_key)
.await
.map_err(|e| StateError::Redis(format!("dispatch ledger get: {e}")))?;
match raw {
Some(json) => {
let value: serde_json::Value = serde_json::from_str(&json)
.map_err(|e| StateError::Decode(format!("dispatch ledger decode: {e}")))?;
Ok(Some(value))
}
None => Ok(None),
}
})
}
fn record<'a>(
&'a self,
key: &'a str,
output: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>> {
Box::pin(async move {
let redis_key = dispatch_key(key);
let json = serde_json::to_string(&output)
.map_err(|e| StateError::Decode(format!("dispatch ledger encode: {e}")))?;
let mut conn = self.manager.clone();
let _: () = conn
.set_ex(&redis_key, json, DISPATCH_TTL_SECS)
.await
.map_err(|e| StateError::Redis(format!("dispatch ledger set_ex: {e}")))?;
Ok(())
})
}
}
#[cfg(any(test, feature = "test-mock"))]
pub use in_memory::InMemoryDispatchLedger;
#[cfg(any(test, feature = "test-mock"))]
mod in_memory {
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use crate::error::StateError;
use super::DispatchLedger;
pub struct InMemoryDispatchLedger {
entries: Mutex<HashMap<String, serde_json::Value>>,
}
impl Default for InMemoryDispatchLedger {
fn default() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
}
impl InMemoryDispatchLedger {
pub fn with(key: &str, value: serde_json::Value) -> Self {
let mut map = HashMap::new();
map.insert(key.to_string(), value);
Self {
entries: Mutex::new(map),
}
}
#[allow(clippy::expect_used)]
pub fn stored(&self, key: &str) -> Option<serde_json::Value> {
self.entries
.lock()
.expect("InMemoryDispatchLedger mutex poisoned")
.get(key)
.cloned()
}
}
impl DispatchLedger for InMemoryDispatchLedger {
#[allow(clippy::expect_used)]
fn get<'a>(
&'a self,
key: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, StateError>> + Send + 'a>>
{
let result = self
.entries
.lock()
.expect("InMemoryDispatchLedger mutex poisoned")
.get(key)
.cloned();
Box::pin(async move { Ok(result) })
}
#[allow(clippy::expect_used)]
fn record<'a>(
&'a self,
key: &'a str,
output: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>> {
self.entries
.lock()
.expect("InMemoryDispatchLedger mutex poisoned")
.insert(key.to_string(), output);
Box::pin(async { Ok(()) })
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[tokio::test]
async fn noop_ledger_always_misses() {
let ledger = NoopDispatchLedger;
assert!(ledger.get("any-key").await.unwrap().is_none());
ledger
.record("any-key", serde_json::json!({"ok": true}))
.await
.unwrap();
assert!(ledger.get("any-key").await.unwrap().is_none());
}
#[tokio::test]
async fn in_memory_ledger_stores_and_retrieves() {
let ledger = InMemoryDispatchLedger::default();
assert!(ledger.get("k1").await.unwrap().is_none());
ledger
.record("k1", serde_json::json!({"reply": "hello"}))
.await
.unwrap();
let got = ledger.get("k1").await.unwrap();
assert_eq!(got, Some(serde_json::json!({"reply": "hello"})));
}
#[tokio::test]
async fn in_memory_ledger_with_preseed_returns_sentinel_on_first_get() {
let ledger = InMemoryDispatchLedger::with("k1", serde_json::json!({"reply": "CACHED"}));
let got = ledger.get("k1").await.unwrap();
assert_eq!(got, Some(serde_json::json!({"reply": "CACHED"})));
}
#[test]
fn dispatch_key_format() {
assert_eq!(
dispatch_key("sess::pack=p::flow=f"),
"aw:dispatch:sess::pack=p::flow=f"
);
}
}