1use crate::error::BusError;
9use crate::ids::{DurableName, JobId, RunId};
10use async_trait::async_trait;
11use bytes::Bytes;
12use futures_core::Stream;
13use std::collections::HashMap;
14use std::pin::Pin;
15use std::time::Duration;
16
17pub type Headers = HashMap<String, String>;
19
20pub const CAUSATION_HEADER: &str = "klieo-causation-run";
24
25pub struct Msg {
27 pub subject: String,
29 pub payload: Bytes,
31 pub headers: Headers,
33 pub ack: AckHandle,
37}
38
39impl std::fmt::Debug for Msg {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("Msg")
42 .field("subject", &self.subject)
43 .field("payload_len", &self.payload.len())
44 .field("headers", &self.headers)
45 .finish()
46 }
47}
48
49pub struct AckHandle(Box<dyn AckHandleImpl>);
51
52impl AckHandle {
53 pub fn new(inner: Box<dyn AckHandleImpl>) -> Self {
55 Self(inner)
56 }
57}
58
59#[async_trait]
62pub trait AckHandleImpl: Send + Sync {
63 async fn ack(self: Box<Self>) -> Result<(), BusError>;
65 async fn nak(self: Box<Self>, delay: Duration) -> Result<(), BusError>;
68 async fn term(self: Box<Self>) -> Result<(), BusError>;
70}
71
72impl AckHandle {
73 pub async fn ack(self) -> Result<(), BusError> {
75 self.0.ack().await
76 }
77 pub async fn nak(self, delay: Duration) -> Result<(), BusError> {
79 self.0.nak(delay).await
80 }
81 pub async fn term(self) -> Result<(), BusError> {
83 self.0.term().await
84 }
85}
86
87pub type MsgStream = Pin<Box<dyn Stream<Item = Result<Msg, BusError>> + Send + 'static>>;
89
90#[async_trait]
103pub trait Pubsub: Send + Sync {
104 async fn publish(
106 &self,
107 subject: &str,
108 payload: Bytes,
109 headers: Headers,
110 ) -> Result<(), BusError>;
111
112 async fn subscribe(&self, subject: &str, durable: DurableName) -> Result<MsgStream, BusError>;
115}
116
117pub fn validate_subject_token(token: &str) -> Result<(), BusError> {
133 if token.is_empty() {
134 return Err(BusError::Invalid("subject segment is empty".into()));
135 }
136 for byte in token.bytes() {
137 let forbidden = matches!(byte, b'.' | b'*' | b'>')
138 || byte.is_ascii_whitespace()
139 || byte.is_ascii_control()
140 || !byte.is_ascii();
141 if forbidden {
142 return Err(BusError::Invalid(format!(
143 "subject segment contains forbidden character (byte 0x{byte:02x})"
144 )));
145 }
146 }
147 Ok(())
148}
149
150#[cfg(test)]
151mod subject_token_tests {
152 use super::*;
153
154 #[test]
155 fn accepts_uuid_like_token() {
156 validate_subject_token("550e8400-e29b-41d4-a716-446655440000").unwrap();
157 validate_subject_token("task_42").unwrap();
158 validate_subject_token("abc123").unwrap();
159 }
160
161 #[test]
162 fn rejects_empty() {
163 let e = validate_subject_token("").unwrap_err();
164 assert!(matches!(e, BusError::Invalid(_)));
165 }
166
167 #[test]
168 fn rejects_greedy_wildcard() {
169 assert!(matches!(
170 validate_subject_token(">"),
171 Err(BusError::Invalid(_))
172 ));
173 }
174
175 #[test]
176 fn rejects_single_token_wildcard() {
177 assert!(matches!(
178 validate_subject_token("*"),
179 Err(BusError::Invalid(_))
180 ));
181 }
182
183 #[test]
184 fn rejects_dot_separator() {
185 assert!(matches!(
186 validate_subject_token("a.b"),
187 Err(BusError::Invalid(_))
188 ));
189 }
190
191 #[test]
192 fn rejects_whitespace_and_control() {
193 assert!(matches!(
194 validate_subject_token("a b"),
195 Err(BusError::Invalid(_))
196 ));
197 assert!(matches!(
198 validate_subject_token("a\nb"),
199 Err(BusError::Invalid(_))
200 ));
201 assert!(matches!(
202 validate_subject_token("a\tb"),
203 Err(BusError::Invalid(_))
204 ));
205 }
206
207 #[test]
208 fn rejects_non_ascii() {
209 assert!(matches!(
210 validate_subject_token("café"),
211 Err(BusError::Invalid(_))
212 ));
213 }
214}
215
216#[async_trait]
233pub trait RequestReply: Send + Sync {
234 async fn request(
236 &self,
237 subject: &str,
238 payload: Bytes,
239 timeout: Duration,
240 ) -> Result<Bytes, BusError>;
241}
242
243pub type Revision = u64;
245
246#[derive(Debug, Clone)]
248pub struct KvEntry {
249 pub value: Bytes,
251 pub revision: Revision,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257#[non_exhaustive]
258pub struct KeyPage {
259 pub keys: Vec<String>,
262 pub next: Option<String>,
265}
266
267impl KeyPage {
268 pub fn new(keys: Vec<String>, next: Option<String>) -> Self {
271 Self { keys, next }
272 }
273}
274
275#[async_trait]
288pub trait KvStore: Send + Sync {
289 async fn get(&self, bucket: &str, key: &str) -> Result<Option<KvEntry>, BusError>;
291
292 async fn put(&self, bucket: &str, key: &str, value: Bytes) -> Result<Revision, BusError>;
294
295 async fn cas(
299 &self,
300 bucket: &str,
301 key: &str,
302 value: Bytes,
303 expected: Option<Revision>,
304 ) -> Result<Revision, BusError>;
305
306 async fn delete(&self, bucket: &str, key: &str) -> Result<(), BusError>;
308
309 async fn lease(&self, bucket: &str, key: &str, ttl: Duration) -> Result<Lease, BusError>;
313
314 async fn keys(&self, bucket: &str) -> Result<Vec<String>, BusError> {
321 let _ = bucket;
322 Err(BusError::Unsupported(
323 "keys() not implemented for this KvStore".into(),
324 ))
325 }
326
327 async fn scan_bucket(&self, bucket: &str) -> Result<Vec<(String, Bytes)>, BusError> {
331 let keys = self.keys(bucket).await?;
332 let mut out = Vec::with_capacity(keys.len());
333 for k in keys {
334 if is_causer_key(&k) {
335 continue;
336 }
337 if let Some(entry) = self.get(bucket, &k).await? {
338 out.push((k, entry.value));
339 }
340 }
341 Ok(out)
342 }
343
344 async fn keys_paginated(
355 &self,
356 bucket: &str,
357 cursor: Option<String>,
358 limit: usize,
359 ) -> Result<KeyPage, BusError> {
360 let mut keys = self.keys(bucket).await?;
361 keys.sort();
362 Ok(page_from_sorted(keys, cursor.as_deref(), limit))
363 }
364
365 async fn put_causer(&self, bucket: &str, key: &str, run: RunId) -> Result<(), BusError> {
373 match self
374 .cas(bucket, &causer_key(key), Bytes::from(run.to_string()), None)
375 .await
376 {
377 Ok(_) => Ok(()),
378 Err(BusError::CasConflict { .. }) => Ok(()),
379 Err(other) => Err(other),
380 }
381 }
382
383 async fn causer_of(&self, bucket: &str, key: &str) -> Result<Option<RunId>, BusError> {
386 let Some(entry) = self.get(bucket, &causer_key(key)).await? else {
387 return Ok(None);
388 };
389 Ok(std::str::from_utf8(&entry.value)
390 .ok()
391 .and_then(|s| ulid::Ulid::from_string(s).ok())
392 .map(RunId))
393 }
394}
395
396const KV_CAUSER_PREFIX: &str = "__klieo_causer__.";
404
405fn causer_key(key: &str) -> String {
406 format!("{KV_CAUSER_PREFIX}{key}")
407}
408
409pub fn is_causer_key(key: &str) -> bool {
414 key.starts_with(KV_CAUSER_PREFIX)
415}
416
417pub(crate) fn page_from_sorted(keys: Vec<String>, cursor: Option<&str>, limit: usize) -> KeyPage {
423 let limit = limit.max(1);
424 let start = match cursor {
425 Some(c) => keys.partition_point(|k| k.as_str() <= c),
426 None => 0,
427 };
428 let end = start.saturating_add(limit).min(keys.len());
429 let page = keys.get(start..end).unwrap_or(&[]).to_vec();
430 let next = if end < keys.len() {
431 page.last().cloned()
432 } else {
433 None
434 };
435 KeyPage::new(page, next)
436}
437
438pub struct Lease(Box<dyn LeaseImpl>);
440
441impl Lease {
442 pub fn new(inner: Box<dyn LeaseImpl>) -> Self {
444 Self(inner)
445 }
446}
447
448#[async_trait]
450pub trait LeaseImpl: Send + Sync {
451 async fn heartbeat(&self) -> Result<(), BusError>;
453}
454
455impl Lease {
456 pub async fn heartbeat(&self) -> Result<(), BusError> {
458 self.0.heartbeat().await
459 }
460}
461
462#[derive(Debug, Clone)]
464#[non_exhaustive]
465pub struct Job {
466 pub payload: Bytes,
468 pub dedup_key: Option<String>,
471 pub max_attempts: Option<u32>,
474 pub causation_run_id: Option<RunId>,
478}
479
480impl Job {
481 pub fn new(payload: impl Into<Bytes>) -> Self {
483 Self {
484 payload: payload.into(),
485 dedup_key: None,
486 max_attempts: None,
487 causation_run_id: None,
488 }
489 }
490
491 pub fn builder(payload: impl Into<Bytes>) -> JobBuilder {
494 JobBuilder {
495 payload: payload.into(),
496 dedup_key: None,
497 max_attempts: None,
498 causation_run_id: None,
499 }
500 }
501
502 pub fn caused_by(mut self, run: RunId) -> Self {
505 self.causation_run_id = Some(run);
506 self
507 }
508}
509
510pub struct JobBuilder {
512 payload: Bytes,
513 dedup_key: Option<String>,
514 max_attempts: Option<u32>,
515 causation_run_id: Option<RunId>,
516}
517
518impl JobBuilder {
519 pub fn dedup(mut self, key: impl Into<String>) -> Self {
522 self.dedup_key = Some(key.into());
523 self
524 }
525
526 pub fn max_attempts(mut self, n: u32) -> Self {
528 self.max_attempts = Some(n);
529 self
530 }
531
532 pub fn caused_by(mut self, run: RunId) -> Self {
534 self.causation_run_id = Some(run);
535 self
536 }
537
538 pub fn build(self) -> Job {
540 Job {
541 payload: self.payload,
542 dedup_key: self.dedup_key,
543 max_attempts: self.max_attempts,
544 causation_run_id: self.causation_run_id,
545 }
546 }
547}
548
549#[non_exhaustive]
551pub struct ClaimedJob {
552 pub id: JobId,
554 pub payload: Bytes,
556 pub lease: Lease,
559 pub claim: ClaimHandle,
561 pub causation_run_id: Option<RunId>,
565}
566
567impl std::fmt::Debug for ClaimedJob {
568 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569 f.debug_struct("ClaimedJob")
570 .field("id", &self.id)
571 .field("payload_len", &self.payload.len())
572 .finish()
573 }
574}
575
576pub struct ClaimHandle(Box<dyn ClaimHandleImpl>);
578
579impl ClaimHandle {
580 pub fn new(inner: Box<dyn ClaimHandleImpl>) -> Self {
582 Self(inner)
583 }
584}
585
586#[async_trait]
588pub trait ClaimHandleImpl: Send + Sync {
589 async fn ack(self: Box<Self>) -> Result<(), BusError>;
591 async fn nak(self: Box<Self>, delay: Duration) -> Result<(), BusError>;
593 async fn dead_letter(self: Box<Self>, reason: &str) -> Result<(), BusError>;
595}
596
597impl ClaimedJob {
598 pub fn new(id: JobId, payload: Bytes, lease: Lease, claim: ClaimHandle) -> Self {
603 Self {
604 id,
605 payload,
606 lease,
607 claim,
608 causation_run_id: None,
609 }
610 }
611
612 pub fn with_causation(mut self, run: Option<RunId>) -> Self {
614 self.causation_run_id = run;
615 self
616 }
617
618 pub async fn heartbeat(&self) -> Result<(), BusError> {
620 self.lease.heartbeat().await
621 }
622 pub async fn ack(self) -> Result<(), BusError> {
624 self.claim.0.ack().await
625 }
626 pub async fn nak(self, delay: Duration) -> Result<(), BusError> {
628 self.claim.0.nak(delay).await
629 }
630 pub async fn dead_letter(self, reason: &str) -> Result<(), BusError> {
632 self.claim.0.dead_letter(reason).await
633 }
634}
635
636#[async_trait]
651pub trait JobQueue: Send + Sync {
652 async fn enqueue(&self, queue: &str, job: Job) -> Result<JobId, BusError>;
654
655 async fn claim(
659 &self,
660 queue: &str,
661 worker_id: &str,
662 lease_ttl: Duration,
663 ) -> Result<Option<ClaimedJob>, BusError>;
664}
665
666#[derive(Clone)]
674pub struct BusHandles {
675 pub pubsub: std::sync::Arc<dyn Pubsub>,
677 pub kv: std::sync::Arc<dyn KvStore>,
679 pub request_reply: std::sync::Arc<dyn RequestReply>,
681 pub jobs: std::sync::Arc<dyn JobQueue>,
683}
684
685impl BusHandles {
686 pub fn new(
689 pubsub: std::sync::Arc<dyn Pubsub>,
690 kv: std::sync::Arc<dyn KvStore>,
691 request_reply: std::sync::Arc<dyn RequestReply>,
692 jobs: std::sync::Arc<dyn JobQueue>,
693 ) -> Self {
694 Self {
695 pubsub,
696 kv,
697 request_reply,
698 jobs,
699 }
700 }
701}
702
703#[cfg(feature = "otel")]
721use opentelemetry::propagation::{Extractor, Injector, TextMapPropagator};
722#[cfg(feature = "otel")]
723use opentelemetry_sdk::propagation::TraceContextPropagator;
724
725#[cfg(feature = "otel")]
737pub fn inject_traceparent(headers: &mut Headers, context: &opentelemetry::Context) {
738 let propagator = TraceContextPropagator::new();
739 let mut injector = HeaderMapInjector(headers);
740 propagator.inject_context(context, &mut injector);
741}
742
743#[cfg(feature = "otel")]
752pub fn extract_traceparent(headers: &Headers) -> opentelemetry::Context {
753 let propagator = TraceContextPropagator::new();
754 let extractor = HeaderMapExtractor(headers);
755 propagator.extract(&extractor)
756}
757
758#[cfg(feature = "otel")]
759struct HeaderMapInjector<'a>(&'a mut Headers);
760
761#[cfg(feature = "otel")]
762impl<'a> Injector for HeaderMapInjector<'a> {
763 fn set(&mut self, key: &str, value: String) {
764 self.0.insert(key.to_string(), value);
765 }
766}
767
768#[cfg(feature = "otel")]
769struct HeaderMapExtractor<'a>(&'a Headers);
770
771#[cfg(feature = "otel")]
772impl<'a> Extractor for HeaderMapExtractor<'a> {
773 fn get(&self, key: &str) -> Option<&str> {
774 self.0.get(key).map(|s| s.as_str())
775 }
776
777 fn keys(&self) -> Vec<&str> {
778 self.0.keys().map(|s| s.as_str()).collect()
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 #[allow(dead_code)]
787 fn _assert_dyn_pubsub(_: &dyn Pubsub) {}
788 #[allow(dead_code)]
789 fn _assert_dyn_request(_: &dyn RequestReply) {}
790 #[allow(dead_code)]
791 fn _assert_dyn_kv(_: &dyn KvStore) {}
792 #[allow(dead_code)]
793 fn _assert_dyn_jobs(_: &dyn JobQueue) {}
794
795 #[test]
796 fn job_builder_defaults_match_job_new() {
797 let by_builder = Job::builder(Bytes::from_static(b"x")).build();
798 let by_new = Job::new(Bytes::from_static(b"x"));
799 assert_eq!(by_builder.payload, by_new.payload);
800 assert_eq!(by_builder.dedup_key, by_new.dedup_key);
801 assert_eq!(by_builder.max_attempts, by_new.max_attempts);
802 assert_eq!(by_builder.causation_run_id, by_new.causation_run_id);
803 }
804
805 #[test]
806 fn job_carries_causation_run_id() {
807 let run = crate::ids::RunId::new();
808 let job = Job::new(Bytes::from_static(b"x")).caused_by(run);
809 assert_eq!(job.causation_run_id, Some(run));
810 let plain = Job::new(Bytes::from_static(b"y"));
811 assert_eq!(plain.causation_run_id, None);
812 let via_builder = Job::builder(Bytes::from_static(b"z"))
813 .caused_by(run)
814 .build();
815 assert_eq!(via_builder.causation_run_id, Some(run));
816 }
817
818 #[test]
819 fn job_builder_sets_dedup_and_max_attempts() {
820 let job = Job::builder(Bytes::from_static(b"payload"))
821 .dedup("idempotency-key-42")
822 .max_attempts(7)
823 .build();
824 assert_eq!(job.payload, Bytes::from_static(b"payload"));
825 assert_eq!(job.dedup_key.as_deref(), Some("idempotency-key-42"));
826 assert_eq!(job.max_attempts, Some(7));
827 }
828
829 #[test]
830 fn job_builder_accepts_string_dedup_via_into() {
831 let owned = String::from("k");
832 let job = Job::builder(Bytes::from_static(b"x")).dedup(owned).build();
833 assert_eq!(job.dedup_key.as_deref(), Some("k"));
834 }
835
836 #[test]
837 fn job_field_assignment_still_compiles() {
838 let mut job = Job::new(Bytes::from_static(b"x"));
839 job.dedup_key = Some("k".into());
840 job.max_attempts = Some(3);
841 assert_eq!(job.dedup_key.as_deref(), Some("k"));
842 assert_eq!(job.max_attempts, Some(3));
843 }
844
845 #[test]
846 fn bus_error_unsupported_renders_message() {
847 let e = BusError::Unsupported("keys() not implemented".into());
848 assert_eq!(
849 e.to_string(),
850 "unsupported operation: keys() not implemented"
851 );
852 }
853
854 #[cfg(feature = "otel")]
855 #[test]
856 fn tracecontext_inject_then_extract_roundtrip() {
857 use opentelemetry::trace::{
858 SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
859 };
860
861 let trace_id = TraceId::from_hex("0123456789abcdef0123456789abcdef").unwrap();
862 let span_id = SpanId::from_hex("0123456789abcdef").unwrap();
863 let span_ctx = SpanContext::new(
864 trace_id,
865 span_id,
866 TraceFlags::SAMPLED,
867 true,
868 TraceState::default(),
869 );
870 let cx = opentelemetry::Context::new().with_remote_span_context(span_ctx);
871
872 let mut headers: Headers = HashMap::new();
873 inject_traceparent(&mut headers, &cx);
874 assert!(
875 headers.contains_key("traceparent"),
876 "traceparent header must be injected"
877 );
878
879 let extracted = extract_traceparent(&headers);
880 let extracted_span_ctx = extracted.span().span_context().clone();
881 assert_eq!(extracted_span_ctx.trace_id(), trace_id);
882 assert_eq!(extracted_span_ctx.span_id(), span_id);
883 }
884
885 fn owned(keys: &[&str]) -> Vec<String> {
886 keys.iter().map(|k| k.to_string()).collect()
887 }
888
889 #[test]
890 fn page_from_sorted_walks_every_key_once_in_order() {
891 let keys = owned(&["a", "b", "c", "d", "e"]);
892 let mut seen = Vec::new();
893 let mut cursor: Option<String> = None;
894 loop {
895 let page = page_from_sorted(keys.clone(), cursor.as_deref(), 2);
896 assert!(page.keys.len() <= 2, "page never exceeds the limit");
897 seen.extend(page.keys);
898 match page.next {
899 Some(c) => cursor = Some(c),
900 None => break,
901 }
902 }
903 assert_eq!(seen, owned(&["a", "b", "c", "d", "e"]));
904 }
905
906 #[test]
907 fn page_from_sorted_cursor_resumes_strictly_after() {
908 let page = page_from_sorted(owned(&["a", "b", "c", "d"]), Some("b"), 10);
910 assert_eq!(page.keys, owned(&["c", "d"]));
911 assert!(page.next.is_none());
912 }
913
914 #[test]
915 fn page_from_sorted_limit_at_or_past_count_is_terminal() {
916 let page = page_from_sorted(owned(&["a", "b"]), None, 2);
917 assert_eq!(page.keys, owned(&["a", "b"]));
918 assert!(page.next.is_none(), "exactly-fits page has no next cursor");
919 }
920
921 #[test]
922 fn page_from_sorted_floors_zero_limit_to_one() {
923 let page = page_from_sorted(owned(&["a", "b", "c"]), None, 0);
925 assert_eq!(page.keys, owned(&["a"]));
926 assert_eq!(page.next.as_deref(), Some("a"));
927 }
928
929 #[test]
930 fn page_from_sorted_empty_and_exhausted_cursor_yield_terminal_pages() {
931 let empty = page_from_sorted(Vec::new(), None, 4);
932 assert!(empty.keys.is_empty() && empty.next.is_none());
933
934 let past_end = page_from_sorted(owned(&["a", "b"]), Some("z"), 4);
935 assert!(past_end.keys.is_empty() && past_end.next.is_none());
936 }
937}