use std::collections::HashMap;
use affinidi_messaging_delivery::{OutboxEntry, OutboxError, OutboxState, OutboxStore};
use async_trait::async_trait;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
const PREFIX: &str = "outbox:";
fn outbox_key(idempotency_key: &str) -> String {
format!("{PREFIX}{idempotency_key}")
}
fn backend(e: AppError) -> OutboxError {
OutboxError::Backend(e.to_string())
}
pub struct VtiOutboxStore {
ks: KeyspaceHandle,
}
impl VtiOutboxStore {
pub fn new(ks: KeyspaceHandle) -> Self {
Self { ks }
}
async fn all(&self) -> Result<Vec<OutboxEntry>, OutboxError> {
let raw = self.ks.prefix_iter_raw(PREFIX).await.map_err(backend)?;
let mut out = Vec::with_capacity(raw.len());
for (_key, value) in raw {
let entry = serde_json::from_slice(&value)
.map_err(|e| OutboxError::Backend(format!("outbox decode: {e}")))?;
out.push(entry);
}
Ok(out)
}
}
#[async_trait]
impl OutboxStore for VtiOutboxStore {
async fn put(&self, entry: OutboxEntry) -> Result<(), OutboxError> {
self.ks
.insert(outbox_key(&entry.idempotency_key), &entry)
.await
.map_err(backend)
}
async fn get(&self, idempotency_key: &str) -> Result<Option<OutboxEntry>, OutboxError> {
self.ks
.get(outbox_key(idempotency_key))
.await
.map_err(backend)
}
async fn due(&self, now_ms: u64) -> Result<Vec<OutboxEntry>, OutboxError> {
let entries = self.all().await?;
let mut head_created_at: HashMap<&str, u64> = HashMap::new();
for e in &entries {
if e.state.is_terminal() {
continue;
}
if let Some(key) = &e.ordering_key {
let head = head_created_at
.entry(key.as_str())
.or_insert(e.created_at_ms);
if e.created_at_ms < *head {
*head = e.created_at_ms;
}
}
}
let mut due: Vec<OutboxEntry> = entries
.iter()
.filter(|e| e.state == OutboxState::Queued && e.next_attempt_at_ms <= now_ms)
.filter(|e| match &e.ordering_key {
Some(key) => head_created_at.get(key.as_str()) == Some(&e.created_at_ms),
None => true,
})
.cloned()
.collect();
due.sort_by(|a, b| {
a.created_at_ms
.cmp(&b.created_at_ms)
.then_with(|| a.idempotency_key.cmp(&b.idempotency_key))
});
Ok(due)
}
async fn awaiting_confirmation(&self) -> Result<Vec<OutboxEntry>, OutboxError> {
Ok(self
.all()
.await?
.into_iter()
.filter(|e| e.state == OutboxState::Sent)
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::store::Store;
use tempfile::{TempDir, tempdir};
fn fresh() -> (TempDir, KeyspaceHandle) {
let dir = tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let ks = store.keyspace("outbox").unwrap();
(dir, ks)
}
fn entry(key: &str, created: u64) -> OutboxEntry {
OutboxEntry::new(
key,
"did:example:bob",
vec![1, 2, 3],
created,
created + 60_000,
)
}
#[tokio::test]
async fn put_get_roundtrip() {
let (_dir, ks) = fresh();
let store = VtiOutboxStore::new(ks);
store.put(entry("k1", 1_000)).await.unwrap();
let got = store.get("k1").await.unwrap().unwrap();
assert_eq!(got.idempotency_key, "k1");
assert_eq!(got.dest_did, "did:example:bob");
assert_eq!(got.packed, vec![1, 2, 3]);
assert_eq!(got.state, OutboxState::Queued);
assert!(store.get("missing").await.unwrap().is_none());
}
#[tokio::test]
async fn entries_survive_reopening_the_store() {
let dir = tempdir().unwrap();
let cfg = StoreConfig {
data_dir: dir.path().into(),
};
{
let store = VtiOutboxStore::new(Store::open(&cfg).unwrap().keyspace("outbox").unwrap());
store.put(entry("persisted", 1_000)).await.unwrap();
}
let reopened = VtiOutboxStore::new(Store::open(&cfg).unwrap().keyspace("outbox").unwrap());
assert_eq!(
reopened
.get("persisted")
.await
.unwrap()
.unwrap()
.idempotency_key,
"persisted"
);
}
#[tokio::test]
async fn due_returns_queued_eligible_entries_oldest_first() {
let (_dir, ks) = fresh();
let store = VtiOutboxStore::new(ks);
store.put(entry("b", 2_000)).await.unwrap();
store.put(entry("a", 1_000)).await.unwrap();
let mut later = entry("c", 3_000);
later.next_attempt_at_ms = 10_000;
store.put(later).await.unwrap();
let due = store.due(5_000).await.unwrap();
let keys: Vec<_> = due.iter().map(|e| e.idempotency_key.as_str()).collect();
assert_eq!(keys, vec!["a", "b"]); }
#[tokio::test]
async fn due_honors_per_ordering_key_fifo_head() {
let (_dir, ks) = fresh();
let store = VtiOutboxStore::new(ks);
store
.put(entry("head", 1_000).with_ordering_key("ord"))
.await
.unwrap();
store
.put(entry("tail", 2_000).with_ordering_key("ord"))
.await
.unwrap();
let due = store.due(5_000).await.unwrap();
let keys: Vec<_> = due.iter().map(|e| e.idempotency_key.as_str()).collect();
assert_eq!(keys, vec!["head"]); }
#[tokio::test]
async fn awaiting_confirmation_returns_only_sent() {
let (_dir, ks) = fresh();
let store = VtiOutboxStore::new(ks);
store.put(entry("queued", 1_000)).await.unwrap();
let mut sent = entry("sent", 1_000);
sent.state = OutboxState::Sent;
store.put(sent).await.unwrap();
let mut delivered = entry("delivered", 1_000);
delivered.state = OutboxState::Delivered;
store.put(delivered).await.unwrap();
let awaiting = store.awaiting_confirmation().await.unwrap();
let keys: Vec<_> = awaiting
.iter()
.map(|e| e.idempotency_key.as_str())
.collect();
assert_eq!(keys, vec!["sent"]);
}
}