use crate::error::BusError;
use crate::ids::{DurableName, JobId};
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 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,
}
#[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(),
))
}
}
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)]
pub struct Job {
pub payload: Bytes,
pub dedup_key: Option<String>,
pub max_attempts: Option<u32>,
}
impl Job {
pub fn new(payload: impl Into<Bytes>) -> Self {
Self {
payload: payload.into(),
dedup_key: None,
max_attempts: None,
}
}
pub fn builder(payload: impl Into<Bytes>) -> JobBuilder {
JobBuilder {
payload: payload.into(),
dedup_key: None,
max_attempts: None,
}
}
}
pub struct JobBuilder {
payload: Bytes,
dedup_key: Option<String>,
max_attempts: Option<u32>,
}
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 build(self) -> Job {
Job {
payload: self.payload,
dedup_key: self.dedup_key,
max_attempts: self.max_attempts,
}
}
}
pub struct ClaimedJob {
pub id: JobId,
pub payload: Bytes,
pub lease: Lease,
pub claim: ClaimHandle,
}
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 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);
}
#[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);
}
}