use crate::kv::MemoryKv;
use crate::pubsub::MemoryPubsub;
use async_trait::async_trait;
use bytes::Bytes;
use klieo_core::bus::{ClaimHandle, ClaimHandleImpl, ClaimedJob, Job, JobQueue, Lease, LeaseImpl};
use klieo_core::error::BusError;
use klieo_core::ids::JobId;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
#[derive(Debug)]
struct JobEntry {
payload: Bytes,
attempts: u32,
max_attempts: u32,
lease_until: Option<Instant>,
not_before: Option<Instant>,
}
#[derive(Default)]
struct PerQueue {
entries: HashMap<JobId, JobEntry>,
ready: VecDeque<JobId>,
leased: HashSet<JobId>,
}
#[derive(Default)]
pub(crate) struct State {
queues: HashMap<String, PerQueue>,
}
pub struct MemoryJobQueue {
state: Arc<Mutex<State>>,
pub(crate) pubsub: Arc<MemoryPubsub>,
kv: Arc<MemoryKv>,
next_id: AtomicU64,
}
impl MemoryJobQueue {
pub fn new(pubsub: Arc<MemoryPubsub>, kv: Arc<MemoryKv>) -> Self {
Self {
state: Arc::new(Mutex::new(State::default())),
pubsub,
kv,
next_id: AtomicU64::new(1),
}
}
fn fresh_id(&self) -> JobId {
let n = self.next_id.fetch_add(1, Ordering::Relaxed);
JobId(format!("mem-{n}"))
}
}
fn sweep_expired_leases(pq: &mut PerQueue, now: Instant) {
let expired: Vec<JobId> = pq
.leased
.iter()
.filter(|id| {
pq.entries
.get(*id)
.and_then(|e| e.lease_until)
.map_or(true, |t| t <= now)
})
.cloned()
.collect();
for id in expired {
pq.leased.remove(&id);
if let Some(entry) = pq.entries.get_mut(&id) {
entry.lease_until = None;
}
pq.ready.push_back(id);
}
}
#[async_trait]
impl JobQueue for MemoryJobQueue {
async fn enqueue(&self, queue: &str, job: Job) -> Result<JobId, BusError> {
let id = self.fresh_id();
if let Some(dedup_key) = &job.dedup_key {
use klieo_core::KvStore;
let bucket = format!("dedup.{queue}");
let id_bytes: Bytes = id.0.clone().into_bytes().into();
match self.kv.cas(&bucket, dedup_key, id_bytes, None).await {
Ok(_) => {}
Err(BusError::CasConflict { .. }) => {
let existing = self.kv.get(&bucket, dedup_key).await?.ok_or_else(|| {
BusError::Permanent("dedup record missing after CAS conflict".into())
})?;
let id_str = String::from_utf8(existing.value.to_vec())
.map_err(|e| BusError::Permanent(format!("dedup id bytes: {e}")))?;
return Ok(JobId(id_str));
}
Err(e) => return Err(e),
}
}
let max_attempts = job.max_attempts.unwrap_or(5);
let mut g = self.state.lock().await;
let pq = g.queues.entry(queue.into()).or_default();
pq.entries.insert(
id.clone(),
JobEntry {
payload: job.payload,
attempts: 0,
max_attempts,
lease_until: None,
not_before: None,
},
);
pq.ready.push_back(id.clone());
Ok(id)
}
async fn claim(
&self,
queue: &str,
_worker_id: &str,
lease_ttl: Duration,
) -> Result<Option<ClaimedJob>, BusError> {
let now = Instant::now();
let mut g = self.state.lock().await;
let pq = g.queues.entry(queue.into()).or_default();
if let Some(claim) = try_claim_from_ready(pq, now, lease_ttl) {
drop(g);
return Ok(Some(build_claimed_job(
self.state.clone(),
self.pubsub.clone(),
queue.to_string(),
claim,
lease_ttl,
)));
}
sweep_expired_leases(pq, now);
if let Some(claim) = try_claim_from_ready(pq, now, lease_ttl) {
drop(g);
return Ok(Some(build_claimed_job(
self.state.clone(),
self.pubsub.clone(),
queue.to_string(),
claim,
lease_ttl,
)));
}
Ok(None)
}
}
fn try_claim_from_ready(
pq: &mut PerQueue,
now: Instant,
lease_ttl: Duration,
) -> Option<ClaimSnapshot> {
let mut rotated = 0;
let initial_len = pq.ready.len();
while rotated < initial_len {
let Some(id) = pq.ready.pop_front() else {
return None;
};
let claimable = pq
.entries
.get(&id)
.map(|e| e.not_before.is_none_or(|t| t <= now))
.unwrap_or(false);
if !claimable {
pq.ready.push_back(id);
rotated += 1;
continue;
}
let entry = pq.entries.get_mut(&id).expect("ready id without entry");
entry.attempts += 1;
entry.lease_until = Some(now + lease_ttl);
entry.not_before = None;
let snapshot = ClaimSnapshot {
id: id.clone(),
payload: entry.payload.clone(),
max_attempts: entry.max_attempts,
};
pq.leased.insert(id);
return Some(snapshot);
}
None
}
struct ClaimSnapshot {
id: JobId,
payload: Bytes,
max_attempts: u32,
}
fn build_claimed_job(
state: Arc<Mutex<State>>,
pubsub: Arc<MemoryPubsub>,
queue: String,
snapshot: ClaimSnapshot,
lease_ttl: Duration,
) -> ClaimedJob {
let id = snapshot.id.clone();
ClaimedJob {
id: id.clone(),
payload: snapshot.payload,
lease: Lease::new(Box::new(JobLease {
state: state.clone(),
queue: queue.clone(),
job_id: id.clone(),
ttl: lease_ttl,
})),
claim: ClaimHandle::new(Box::new(MemoryClaimHandle {
state,
pubsub,
queue,
job_id: id,
max_attempts: snapshot.max_attempts,
})),
}
}
struct JobLease {
state: Arc<Mutex<State>>,
queue: String,
job_id: JobId,
ttl: Duration,
}
#[async_trait]
impl LeaseImpl for JobLease {
async fn heartbeat(&self) -> Result<(), BusError> {
let mut g = self.state.lock().await;
let pq = g
.queues
.get_mut(&self.queue)
.ok_or_else(|| BusError::NotFound(self.queue.clone()))?;
let entry = pq
.entries
.get_mut(&self.job_id)
.ok_or_else(|| BusError::NotFound(format!("job {}", self.job_id.0)))?;
entry.lease_until = Some(Instant::now() + self.ttl);
Ok(())
}
}
pub(crate) struct MemoryClaimHandle {
pub(crate) state: Arc<Mutex<State>>,
pub(crate) pubsub: Arc<MemoryPubsub>,
pub(crate) queue: String,
pub(crate) job_id: JobId,
pub(crate) max_attempts: u32,
}
#[async_trait]
impl ClaimHandleImpl for MemoryClaimHandle {
async fn ack(self: Box<Self>) -> Result<(), BusError> {
let mut g = self.state.lock().await;
if let Some(pq) = g.queues.get_mut(&self.queue) {
pq.entries.remove(&self.job_id);
pq.leased.remove(&self.job_id);
pq.ready.retain(|id| id != &self.job_id);
}
Ok(())
}
async fn nak(self: Box<Self>, delay: Duration) -> Result<(), BusError> {
let payload_to_dlq;
{
let mut g = self.state.lock().await;
let pq = match g.queues.get_mut(&self.queue) {
Some(q) => q,
None => return Ok(()),
};
let entry = match pq.entries.get_mut(&self.job_id) {
Some(e) => e,
None => return Ok(()),
};
if entry.attempts >= self.max_attempts {
let removed = pq
.entries
.remove(&self.job_id)
.expect("entry exists, just checked");
pq.leased.remove(&self.job_id);
pq.ready.retain(|id| id != &self.job_id);
payload_to_dlq = Some(removed.payload);
} else {
entry.lease_until = None;
entry.not_before = Some(Instant::now() + delay);
pq.leased.remove(&self.job_id);
pq.ready.push_back(self.job_id.clone());
payload_to_dlq = None;
}
}
if let Some(payload) = payload_to_dlq {
use klieo_core::bus::Headers;
use klieo_core::Pubsub;
let mut headers = Headers::new();
headers.insert("dlq_reason".into(), "max_attempts_exceeded".into());
let subject = format!("{}.dlq", self.queue);
self.pubsub.publish(&subject, payload, headers).await?;
}
Ok(())
}
async fn dead_letter(self: Box<Self>, reason: &str) -> Result<(), BusError> {
let payload = {
let mut g = self.state.lock().await;
let pq = match g.queues.get_mut(&self.queue) {
Some(q) => q,
None => return Ok(()),
};
let removed = match pq.entries.remove(&self.job_id) {
Some(e) => e,
None => return Ok(()),
};
pq.leased.remove(&self.job_id);
pq.ready.retain(|id| id != &self.job_id);
removed.payload
};
use klieo_core::bus::Headers;
use klieo_core::Pubsub;
let mut headers = Headers::new();
headers.insert("dlq_reason".into(), reason.into());
let subject = format!("{}.dlq", self.queue);
self.pubsub.publish(&subject, payload, headers).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kv::MemoryKv;
use crate::pubsub::MemoryPubsub;
use klieo_core::bus::Job;
use klieo_core::ids::DurableName;
use klieo_core::{JobQueue, Pubsub};
use std::sync::Arc;
use std::time::Duration;
fn build_jq() -> MemoryJobQueue {
MemoryJobQueue::new(Arc::new(MemoryPubsub::new()), Arc::new(MemoryKv::new()))
}
#[tokio::test]
async fn claim_returns_none_when_empty() {
let jq = build_jq();
let claimed = jq.claim("q", "w1", Duration::from_secs(1)).await.unwrap();
assert!(claimed.is_none());
}
#[tokio::test]
async fn enqueue_then_claim_returns_job() {
let jq = build_jq();
let id = jq
.enqueue("q", Job::new(Bytes::from_static(b"payload")))
.await
.unwrap();
let claimed = jq
.claim("q", "w1", Duration::from_secs(1))
.await
.unwrap()
.unwrap();
assert_eq!(claimed.id, id);
assert_eq!(claimed.payload, Bytes::from_static(b"payload"));
}
#[tokio::test]
async fn ack_removes_job() {
let jq = build_jq();
jq.enqueue("q", Job::new(Bytes::from_static(b"x")))
.await
.unwrap();
let claimed = jq
.claim("q", "w1", Duration::from_secs(1))
.await
.unwrap()
.unwrap();
claimed.ack().await.unwrap();
let none = jq.claim("q", "w1", Duration::from_secs(1)).await.unwrap();
assert!(none.is_none());
}
#[tokio::test]
async fn lease_expiry_redelivers_job() {
let jq = build_jq();
jq.enqueue("q", Job::new(Bytes::from_static(b"x")))
.await
.unwrap();
let claim1 = jq
.claim("q", "w1", Duration::from_millis(20))
.await
.unwrap()
.unwrap();
drop(claim1);
tokio::time::sleep(Duration::from_millis(40)).await;
let claim2 = jq
.claim("q", "w2", Duration::from_secs(1))
.await
.unwrap()
.unwrap();
assert_eq!(claim2.payload, Bytes::from_static(b"x"));
}
#[tokio::test]
async fn nak_with_delay_suppresses_claim_until_deadline() {
let jq = build_jq();
jq.enqueue("q", Job::new(Bytes::from_static(b"x")))
.await
.unwrap();
let claim1 = jq
.claim("q", "w1", Duration::from_secs(1))
.await
.unwrap()
.unwrap();
claim1.nak(Duration::from_millis(40)).await.unwrap();
let none = jq.claim("q", "w1", Duration::from_secs(1)).await.unwrap();
assert!(
none.is_none(),
"should be suppressed during not_before window"
);
tokio::time::sleep(Duration::from_millis(60)).await;
let some = jq.claim("q", "w1", Duration::from_secs(1)).await.unwrap();
assert!(
some.is_some(),
"should be reclaimable after not_before elapses"
);
}
#[tokio::test]
async fn nak_after_max_attempts_routes_to_dlq() {
let jq = build_jq();
let pubsub_handle = jq.pubsub.clone();
let mut dlq_sub = pubsub_handle
.subscribe("q.dlq", DurableName::new("dlq"))
.await
.unwrap();
let mut job = Job::new(Bytes::from_static(b"x"));
job.max_attempts = Some(2);
jq.enqueue("q", job).await.unwrap();
for _ in 0..2 {
let claim = jq
.claim("q", "w1", Duration::from_secs(1))
.await
.unwrap()
.unwrap();
claim.nak(Duration::from_millis(0)).await.unwrap();
}
let none = jq.claim("q", "w1", Duration::from_secs(1)).await.unwrap();
assert!(none.is_none(), "queue should be empty after DLQ routing");
use tokio_stream::StreamExt;
let dlq_msg = tokio::time::timeout(Duration::from_millis(200), dlq_sub.next())
.await
.expect("DLQ delivery should arrive within 200ms")
.expect("stream did not close")
.expect("no error");
assert_eq!(dlq_msg.payload, Bytes::from_static(b"x"));
assert_eq!(
dlq_msg.headers.get("dlq_reason").map(|s| s.as_str()),
Some("max_attempts_exceeded")
);
}
#[tokio::test]
async fn dead_letter_removes_job_and_publishes_with_reason() {
let jq = build_jq();
use klieo_core::Pubsub;
let mut dlq_sub = jq
.pubsub
.subscribe("q.dlq", klieo_core::ids::DurableName::new("dlq"))
.await
.unwrap();
jq.enqueue("q", Job::new(Bytes::from_static(b"x")))
.await
.unwrap();
let claim = jq
.claim("q", "w1", Duration::from_secs(1))
.await
.unwrap()
.unwrap();
claim.dead_letter("explicit").await.unwrap();
let none = jq.claim("q", "w1", Duration::from_secs(1)).await.unwrap();
assert!(none.is_none(), "queue should be empty after DLQ");
use tokio_stream::StreamExt;
let msg = tokio::time::timeout(Duration::from_millis(200), dlq_sub.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(msg.payload, Bytes::from_static(b"x"));
assert_eq!(
msg.headers.get("dlq_reason").map(|s| s.as_str()),
Some("explicit")
);
}
#[tokio::test]
async fn dedup_key_returns_existing_id_on_duplicate_enqueue() {
let jq = build_jq();
let mut j1 = Job::new(Bytes::from_static(b"x"));
j1.dedup_key = Some("d1".into());
let id1 = jq.enqueue("q", j1).await.unwrap();
let mut j2 = Job::new(Bytes::from_static(b"y"));
j2.dedup_key = Some("d1".into());
let id2 = jq.enqueue("q", j2).await.unwrap();
assert_eq!(id1, id2, "duplicate dedup_key returns existing id");
let _claimed = jq
.claim("q", "w", Duration::from_secs(1))
.await
.unwrap()
.unwrap();
let none = jq.claim("q", "w", Duration::from_secs(1)).await.unwrap();
assert!(none.is_none(), "second enqueue must not have queued a job");
}
#[tokio::test]
async fn dedup_key_distinct_keys_enqueue_independently() {
let jq = build_jq();
let mut j1 = Job::new(Bytes::from_static(b"x"));
j1.dedup_key = Some("a".into());
let id1 = jq.enqueue("q", j1).await.unwrap();
let mut j2 = Job::new(Bytes::from_static(b"y"));
j2.dedup_key = Some("b".into());
let id2 = jq.enqueue("q", j2).await.unwrap();
assert_ne!(id1, id2);
let claim1 = jq
.claim("q", "w", Duration::from_secs(1))
.await
.unwrap()
.expect("first job should be claimable");
let claim2 = jq
.claim("q", "w", Duration::from_secs(1))
.await
.unwrap()
.expect("second job should be claimable");
assert_eq!(claim1.id, id1);
assert_eq!(claim2.id, id2);
assert_eq!(claim1.payload, Bytes::from_static(b"x"));
assert_eq!(claim2.payload, Bytes::from_static(b"y"));
}
#[tokio::test]
async fn claim_skips_leased_entries_fast_path() {
let jq = build_jq();
for i in 0..100u8 {
jq.enqueue("q", Job::new(Bytes::from(vec![i])))
.await
.unwrap();
}
let mut held = Vec::new();
for _ in 0..50 {
held.push(
jq.claim("q", "w", Duration::from_secs(10))
.await
.unwrap()
.unwrap(),
);
}
let claim51 = jq
.claim("q", "w", Duration::from_secs(10))
.await
.unwrap()
.expect("51st claim returns next ready entry");
assert_eq!(claim51.payload, Bytes::from(vec![50]));
drop(held);
}
}