use crate::error::BusError;
use crate::ids::{DurableName, JobId, RunId};
use async_trait::async_trait;
use bytes::Bytes;
use futures_core::Stream;
use std::collections::HashMap;
use std::pin::Pin;
use std::time::Duration;
pub type Headers = HashMap<String, String>;
pub const CAUSATION_HEADER: &str = "klieo-causation-run";
pub struct Msg {
pub subject: String,
pub payload: Bytes,
pub headers: Headers,
pub ack: AckHandle,
}
impl std::fmt::Debug for Msg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Msg")
.field("subject", &self.subject)
.field("payload_len", &self.payload.len())
.field("headers", &self.headers)
.finish()
}
}
pub struct AckHandle(Box<dyn AckHandleImpl>);
impl AckHandle {
pub fn new(inner: Box<dyn AckHandleImpl>) -> Self {
Self(inner)
}
}
#[async_trait]
pub trait AckHandleImpl: Send + Sync {
async fn ack(self: Box<Self>) -> Result<(), BusError>;
async fn nak(self: Box<Self>, delay: Duration) -> Result<(), BusError>;
async fn term(self: Box<Self>) -> Result<(), BusError>;
}
impl AckHandle {
pub async fn ack(self) -> Result<(), BusError> {
self.0.ack().await
}
pub async fn nak(self, delay: Duration) -> Result<(), BusError> {
self.0.nak(delay).await
}
pub async fn term(self) -> Result<(), BusError> {
self.0.term().await
}
}
pub type MsgStream = Pin<Box<dyn Stream<Item = Result<Msg, BusError>> + Send + 'static>>;
#[async_trait]
pub trait Pubsub: Send + Sync {
async fn publish(
&self,
subject: &str,
payload: Bytes,
headers: Headers,
) -> Result<(), BusError>;
async fn subscribe(&self, subject: &str, durable: DurableName) -> Result<MsgStream, BusError>;
}
pub fn validate_subject_token(token: &str) -> Result<(), BusError> {
if token.is_empty() {
return Err(BusError::Invalid("subject segment is empty".into()));
}
for byte in token.bytes() {
let forbidden = matches!(byte, b'.' | b'*' | b'>')
|| byte.is_ascii_whitespace()
|| byte.is_ascii_control()
|| !byte.is_ascii();
if forbidden {
return Err(BusError::Invalid(format!(
"subject segment contains forbidden character (byte 0x{byte:02x})"
)));
}
}
Ok(())
}
#[cfg(test)]
mod subject_token_tests {
use super::*;
#[test]
fn accepts_uuid_like_token() {
validate_subject_token("550e8400-e29b-41d4-a716-446655440000").unwrap();
validate_subject_token("task_42").unwrap();
validate_subject_token("abc123").unwrap();
}
#[test]
fn rejects_empty() {
let e = validate_subject_token("").unwrap_err();
assert!(matches!(e, BusError::Invalid(_)));
}
#[test]
fn rejects_greedy_wildcard() {
assert!(matches!(
validate_subject_token(">"),
Err(BusError::Invalid(_))
));
}
#[test]
fn rejects_single_token_wildcard() {
assert!(matches!(
validate_subject_token("*"),
Err(BusError::Invalid(_))
));
}
#[test]
fn rejects_dot_separator() {
assert!(matches!(
validate_subject_token("a.b"),
Err(BusError::Invalid(_))
));
}
#[test]
fn rejects_whitespace_and_control() {
assert!(matches!(
validate_subject_token("a b"),
Err(BusError::Invalid(_))
));
assert!(matches!(
validate_subject_token("a\nb"),
Err(BusError::Invalid(_))
));
assert!(matches!(
validate_subject_token("a\tb"),
Err(BusError::Invalid(_))
));
}
#[test]
fn rejects_non_ascii() {
assert!(matches!(
validate_subject_token("café"),
Err(BusError::Invalid(_))
));
}
}
#[async_trait]
pub trait RequestReply: Send + Sync {
async fn request(
&self,
subject: &str,
payload: Bytes,
timeout: Duration,
) -> Result<Bytes, BusError>;
}
pub type Revision = u64;
#[derive(Debug, Clone)]
pub struct KvEntry {
pub value: Bytes,
pub revision: Revision,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct KeyPage {
pub keys: Vec<String>,
pub next: Option<String>,
}
impl KeyPage {
pub fn new(keys: Vec<String>, next: Option<String>) -> Self {
Self { keys, next }
}
}
#[async_trait]
pub trait KvStore: Send + Sync {
async fn get(&self, bucket: &str, key: &str) -> Result<Option<KvEntry>, BusError>;
async fn put(&self, bucket: &str, key: &str, value: Bytes) -> Result<Revision, BusError>;
async fn cas(
&self,
bucket: &str,
key: &str,
value: Bytes,
expected: Option<Revision>,
) -> Result<Revision, BusError>;
async fn delete(&self, bucket: &str, key: &str) -> Result<(), BusError>;
async fn lease(&self, bucket: &str, key: &str, ttl: Duration) -> Result<Lease, BusError>;
async fn keys(&self, bucket: &str) -> Result<Vec<String>, BusError> {
let _ = bucket;
Err(BusError::Unsupported(
"keys() not implemented for this KvStore".into(),
))
}
async fn scan_bucket(&self, bucket: &str) -> Result<Vec<(String, Bytes)>, BusError> {
let keys = self.keys(bucket).await?;
let mut out = Vec::with_capacity(keys.len());
for k in keys {
if is_causer_key(&k) {
continue;
}
if let Some(entry) = self.get(bucket, &k).await? {
out.push((k, entry.value));
}
}
Ok(out)
}
async fn keys_paginated(
&self,
bucket: &str,
cursor: Option<String>,
limit: usize,
) -> Result<KeyPage, BusError> {
let mut keys = self.keys(bucket).await?;
keys.sort();
Ok(page_from_sorted(keys, cursor.as_deref(), limit))
}
async fn put_causer(&self, bucket: &str, key: &str, run: RunId) -> Result<(), BusError> {
match self
.cas(bucket, &causer_key(key), Bytes::from(run.to_string()), None)
.await
{
Ok(_) => Ok(()),
Err(BusError::CasConflict { .. }) => Ok(()),
Err(other) => Err(other),
}
}
async fn causer_of(&self, bucket: &str, key: &str) -> Result<Option<RunId>, BusError> {
let Some(entry) = self.get(bucket, &causer_key(key)).await? else {
return Ok(None);
};
Ok(std::str::from_utf8(&entry.value)
.ok()
.and_then(|s| ulid::Ulid::from_string(s).ok())
.map(RunId))
}
}
const KV_CAUSER_PREFIX: &str = "__klieo_causer__.";
fn causer_key(key: &str) -> String {
format!("{KV_CAUSER_PREFIX}{key}")
}
pub fn is_causer_key(key: &str) -> bool {
key.starts_with(KV_CAUSER_PREFIX)
}
pub(crate) fn page_from_sorted(keys: Vec<String>, cursor: Option<&str>, limit: usize) -> KeyPage {
let limit = limit.max(1);
let start = match cursor {
Some(c) => keys.partition_point(|k| k.as_str() <= c),
None => 0,
};
let end = start.saturating_add(limit).min(keys.len());
let page = keys.get(start..end).unwrap_or(&[]).to_vec();
let next = if end < keys.len() {
page.last().cloned()
} else {
None
};
KeyPage::new(page, next)
}
pub struct Lease(Box<dyn LeaseImpl>);
impl Lease {
pub fn new(inner: Box<dyn LeaseImpl>) -> Self {
Self(inner)
}
}
#[async_trait]
pub trait LeaseImpl: Send + Sync {
async fn heartbeat(&self) -> Result<(), BusError>;
}
impl Lease {
pub async fn heartbeat(&self) -> Result<(), BusError> {
self.0.heartbeat().await
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Job {
pub payload: Bytes,
pub dedup_key: Option<String>,
pub max_attempts: Option<u32>,
pub causation_run_id: Option<RunId>,
}
impl Job {
pub fn new(payload: impl Into<Bytes>) -> Self {
Self {
payload: payload.into(),
dedup_key: None,
max_attempts: None,
causation_run_id: None,
}
}
pub fn builder(payload: impl Into<Bytes>) -> JobBuilder {
JobBuilder {
payload: payload.into(),
dedup_key: None,
max_attempts: None,
causation_run_id: None,
}
}
pub fn caused_by(mut self, run: RunId) -> Self {
self.causation_run_id = Some(run);
self
}
}
pub struct JobBuilder {
payload: Bytes,
dedup_key: Option<String>,
max_attempts: Option<u32>,
causation_run_id: Option<RunId>,
}
impl JobBuilder {
pub fn dedup(mut self, key: impl Into<String>) -> Self {
self.dedup_key = Some(key.into());
self
}
pub fn max_attempts(mut self, n: u32) -> Self {
self.max_attempts = Some(n);
self
}
pub fn caused_by(mut self, run: RunId) -> Self {
self.causation_run_id = Some(run);
self
}
pub fn build(self) -> Job {
Job {
payload: self.payload,
dedup_key: self.dedup_key,
max_attempts: self.max_attempts,
causation_run_id: self.causation_run_id,
}
}
}
#[non_exhaustive]
pub struct ClaimedJob {
pub id: JobId,
pub payload: Bytes,
pub lease: Lease,
pub claim: ClaimHandle,
pub causation_run_id: Option<RunId>,
}
impl std::fmt::Debug for ClaimedJob {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClaimedJob")
.field("id", &self.id)
.field("payload_len", &self.payload.len())
.finish()
}
}
pub struct ClaimHandle(Box<dyn ClaimHandleImpl>);
impl ClaimHandle {
pub fn new(inner: Box<dyn ClaimHandleImpl>) -> Self {
Self(inner)
}
}
#[async_trait]
pub trait ClaimHandleImpl: Send + Sync {
async fn ack(self: Box<Self>) -> Result<(), BusError>;
async fn nak(self: Box<Self>, delay: Duration) -> Result<(), BusError>;
async fn dead_letter(self: Box<Self>, reason: &str) -> Result<(), BusError>;
}
impl ClaimedJob {
pub fn new(id: JobId, payload: Bytes, lease: Lease, claim: ClaimHandle) -> Self {
Self {
id,
payload,
lease,
claim,
causation_run_id: None,
}
}
pub fn with_causation(mut self, run: Option<RunId>) -> Self {
self.causation_run_id = run;
self
}
pub async fn heartbeat(&self) -> Result<(), BusError> {
self.lease.heartbeat().await
}
pub async fn ack(self) -> Result<(), BusError> {
self.claim.0.ack().await
}
pub async fn nak(self, delay: Duration) -> Result<(), BusError> {
self.claim.0.nak(delay).await
}
pub async fn dead_letter(self, reason: &str) -> Result<(), BusError> {
self.claim.0.dead_letter(reason).await
}
}
#[async_trait]
pub trait JobQueue: Send + Sync {
async fn enqueue(&self, queue: &str, job: Job) -> Result<JobId, BusError>;
async fn claim(
&self,
queue: &str,
worker_id: &str,
lease_ttl: Duration,
) -> Result<Option<ClaimedJob>, BusError>;
}
#[derive(Clone)]
pub struct BusHandles {
pub pubsub: std::sync::Arc<dyn Pubsub>,
pub kv: std::sync::Arc<dyn KvStore>,
pub request_reply: std::sync::Arc<dyn RequestReply>,
pub jobs: std::sync::Arc<dyn JobQueue>,
}
impl BusHandles {
pub fn new(
pubsub: std::sync::Arc<dyn Pubsub>,
kv: std::sync::Arc<dyn KvStore>,
request_reply: std::sync::Arc<dyn RequestReply>,
jobs: std::sync::Arc<dyn JobQueue>,
) -> Self {
Self {
pubsub,
kv,
request_reply,
jobs,
}
}
}
#[cfg(feature = "otel")]
use opentelemetry::propagation::{Extractor, Injector, TextMapPropagator};
#[cfg(feature = "otel")]
use opentelemetry_sdk::propagation::TraceContextPropagator;
#[cfg(feature = "otel")]
pub fn inject_traceparent(headers: &mut Headers, context: &opentelemetry::Context) {
let propagator = TraceContextPropagator::new();
let mut injector = HeaderMapInjector(headers);
propagator.inject_context(context, &mut injector);
}
#[cfg(feature = "otel")]
pub fn extract_traceparent(headers: &Headers) -> opentelemetry::Context {
let propagator = TraceContextPropagator::new();
let extractor = HeaderMapExtractor(headers);
propagator.extract(&extractor)
}
#[cfg(feature = "otel")]
struct HeaderMapInjector<'a>(&'a mut Headers);
#[cfg(feature = "otel")]
impl<'a> Injector for HeaderMapInjector<'a> {
fn set(&mut self, key: &str, value: String) {
self.0.insert(key.to_string(), value);
}
}
#[cfg(feature = "otel")]
struct HeaderMapExtractor<'a>(&'a Headers);
#[cfg(feature = "otel")]
impl<'a> Extractor for HeaderMapExtractor<'a> {
fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).map(|s| s.as_str())
}
fn keys(&self) -> Vec<&str> {
self.0.keys().map(|s| s.as_str()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(dead_code)]
fn _assert_dyn_pubsub(_: &dyn Pubsub) {}
#[allow(dead_code)]
fn _assert_dyn_request(_: &dyn RequestReply) {}
#[allow(dead_code)]
fn _assert_dyn_kv(_: &dyn KvStore) {}
#[allow(dead_code)]
fn _assert_dyn_jobs(_: &dyn JobQueue) {}
#[test]
fn job_builder_defaults_match_job_new() {
let by_builder = Job::builder(Bytes::from_static(b"x")).build();
let by_new = Job::new(Bytes::from_static(b"x"));
assert_eq!(by_builder.payload, by_new.payload);
assert_eq!(by_builder.dedup_key, by_new.dedup_key);
assert_eq!(by_builder.max_attempts, by_new.max_attempts);
assert_eq!(by_builder.causation_run_id, by_new.causation_run_id);
}
#[test]
fn job_carries_causation_run_id() {
let run = crate::ids::RunId::new();
let job = Job::new(Bytes::from_static(b"x")).caused_by(run);
assert_eq!(job.causation_run_id, Some(run));
let plain = Job::new(Bytes::from_static(b"y"));
assert_eq!(plain.causation_run_id, None);
let via_builder = Job::builder(Bytes::from_static(b"z"))
.caused_by(run)
.build();
assert_eq!(via_builder.causation_run_id, Some(run));
}
#[test]
fn job_builder_sets_dedup_and_max_attempts() {
let job = Job::builder(Bytes::from_static(b"payload"))
.dedup("idempotency-key-42")
.max_attempts(7)
.build();
assert_eq!(job.payload, Bytes::from_static(b"payload"));
assert_eq!(job.dedup_key.as_deref(), Some("idempotency-key-42"));
assert_eq!(job.max_attempts, Some(7));
}
#[test]
fn job_builder_accepts_string_dedup_via_into() {
let owned = String::from("k");
let job = Job::builder(Bytes::from_static(b"x")).dedup(owned).build();
assert_eq!(job.dedup_key.as_deref(), Some("k"));
}
#[test]
fn job_field_assignment_still_compiles() {
let mut job = Job::new(Bytes::from_static(b"x"));
job.dedup_key = Some("k".into());
job.max_attempts = Some(3);
assert_eq!(job.dedup_key.as_deref(), Some("k"));
assert_eq!(job.max_attempts, Some(3));
}
#[test]
fn bus_error_unsupported_renders_message() {
let e = BusError::Unsupported("keys() not implemented".into());
assert_eq!(
e.to_string(),
"unsupported operation: keys() not implemented"
);
}
#[cfg(feature = "otel")]
#[test]
fn tracecontext_inject_then_extract_roundtrip() {
use opentelemetry::trace::{
SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
};
let trace_id = TraceId::from_hex("0123456789abcdef0123456789abcdef").unwrap();
let span_id = SpanId::from_hex("0123456789abcdef").unwrap();
let span_ctx = SpanContext::new(
trace_id,
span_id,
TraceFlags::SAMPLED,
true,
TraceState::default(),
);
let cx = opentelemetry::Context::new().with_remote_span_context(span_ctx);
let mut headers: Headers = HashMap::new();
inject_traceparent(&mut headers, &cx);
assert!(
headers.contains_key("traceparent"),
"traceparent header must be injected"
);
let extracted = extract_traceparent(&headers);
let extracted_span_ctx = extracted.span().span_context().clone();
assert_eq!(extracted_span_ctx.trace_id(), trace_id);
assert_eq!(extracted_span_ctx.span_id(), span_id);
}
fn owned(keys: &[&str]) -> Vec<String> {
keys.iter().map(|k| k.to_string()).collect()
}
#[test]
fn page_from_sorted_walks_every_key_once_in_order() {
let keys = owned(&["a", "b", "c", "d", "e"]);
let mut seen = Vec::new();
let mut cursor: Option<String> = None;
loop {
let page = page_from_sorted(keys.clone(), cursor.as_deref(), 2);
assert!(page.keys.len() <= 2, "page never exceeds the limit");
seen.extend(page.keys);
match page.next {
Some(c) => cursor = Some(c),
None => break,
}
}
assert_eq!(seen, owned(&["a", "b", "c", "d", "e"]));
}
#[test]
fn page_from_sorted_cursor_resumes_strictly_after() {
let page = page_from_sorted(owned(&["a", "b", "c", "d"]), Some("b"), 10);
assert_eq!(page.keys, owned(&["c", "d"]));
assert!(page.next.is_none());
}
#[test]
fn page_from_sorted_limit_at_or_past_count_is_terminal() {
let page = page_from_sorted(owned(&["a", "b"]), None, 2);
assert_eq!(page.keys, owned(&["a", "b"]));
assert!(page.next.is_none(), "exactly-fits page has no next cursor");
}
#[test]
fn page_from_sorted_floors_zero_limit_to_one() {
let page = page_from_sorted(owned(&["a", "b", "c"]), None, 0);
assert_eq!(page.keys, owned(&["a"]));
assert_eq!(page.next.as_deref(), Some("a"));
}
#[test]
fn page_from_sorted_empty_and_exhausted_cursor_yield_terminal_pages() {
let empty = page_from_sorted(Vec::new(), None, 4);
assert!(empty.keys.is_empty() && empty.next.is_none());
let past_end = page_from_sorted(owned(&["a", "b"]), Some("z"), 4);
assert!(past_end.keys.is_empty() && past_end.next.is_none());
}
}