use std::sync::Arc;
use tracing::{error, info, warn};
use crate::FlatbedWorkerError;
pub enum NatsResult {
Ack,
Skip(String),
Retry(String),
}
pub trait HasJetStream {
fn jetstream(&self) -> &async_nats::jetstream::Context;
fn stream_replicas(&self) -> usize {
1
}
}
pub trait StreamWorker: Send + Sync + 'static {
type Message: Send + 'static;
#[cfg(all(feature = "nats", feature = "k8s"))]
type Context: HasJetStream + crate::k8s::HasLeaderElection + Send + Sync + 'static;
#[cfg(not(feature = "k8s"))]
type Context: HasJetStream + Send + Sync + 'static;
type ParseError: std::fmt::Display + Send + 'static;
const NAME: &'static str;
const DESCRIPTION: Option<&'static str> = None;
const STREAM: &'static str;
const SUBJECT: &'static str;
fn handle(&self, ctx: Arc<Self::Context>, msg: Self::Message) -> crate::BoxFuture<NatsResult>;
fn parse_message(bytes: &[u8]) -> Result<Self::Message, Self::ParseError>;
}
async fn consume_loop<W, C>(worker: &W, ctx: Arc<C>) -> Result<(), FlatbedWorkerError>
where
W: StreamWorker<Context = C>,
C: HasJetStream + Send + Sync + 'static,
{
let jetstream = HasJetStream::jetstream(&*ctx);
let backoff_ms = [100u64, 250, 500, 1_000, 2_000, 5_000];
let mut attempt = 0;
loop {
match jetstream.get_stream(W::STREAM).await {
Ok(_) => break,
Err(e) => {
let delay = backoff_ms[attempt.min(backoff_ms.len() - 1)];
tracing::debug!(
worker = W::NAME,
stream = W::STREAM,
attempt,
delay_ms = delay,
error = %e,
"stream not yet available, retrying"
);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
attempt += 1;
}
}
}
let consumer = jetstream
.create_consumer_on_stream(
async_nats::jetstream::consumer::pull::Config {
durable_name: Some(W::NAME.to_string()),
filter_subject: W::SUBJECT.to_string(),
..Default::default()
},
W::STREAM,
)
.await
.map_err(|e| {
FlatbedWorkerError::new(
"consumer_init",
format!("[{}] JetStream consumer failed: {}", W::NAME, e),
)
})?;
info!(worker = W::NAME, subject = W::SUBJECT, "subscribed");
let mut messages = consumer.messages().await.map_err(|e| {
FlatbedWorkerError::new(
"messages_init",
format!("[{}] Failed to get messages: {}", W::NAME, e),
)
})?;
use futures::StreamExt;
while let Some(msg_result) = messages.next().await {
let nats_msg = match msg_result {
Ok(m) => m,
Err(e) => {
error!(worker = W::NAME, error = %e, "message receive error");
continue;
}
};
let payload = nats_msg.payload.as_ref();
let decoded = match W::parse_message(payload) {
Ok(m) => m,
Err(e) => {
warn!(worker = W::NAME, error = %e, "deserialization failed, ACKing to skip");
if let Err(ack_err) = nats_msg.ack().await {
error!(worker = W::NAME, error = %ack_err, "failed to ACK message");
}
continue;
}
};
match worker.handle(Arc::clone(&ctx), decoded).await {
NatsResult::Ack => {
if let Err(ack_err) = nats_msg.ack().await {
error!(worker = W::NAME, error = %ack_err, "failed to ACK message");
}
}
NatsResult::Skip(reason) => {
info!(worker = W::NAME, reason = %reason, "skipping message");
if let Err(ack_err) = nats_msg.ack().await {
error!(worker = W::NAME, error = %ack_err, "failed to ACK message");
}
}
NatsResult::Retry(reason) => {
warn!(worker = W::NAME, reason = %reason, "retrying message");
}
}
}
Ok(())
}
#[cfg(feature = "k8s")]
pub async fn run_stream_worker<W, C>(
ctx: Arc<dyn std::any::Any + Send + Sync>,
) -> Result<(), FlatbedWorkerError>
where
W: StreamWorker<Context = C> + Default,
C: HasJetStream + crate::k8s::HasLeaderElection + Send + Sync + 'static,
{
let ctx: Arc<C> = ctx
.downcast::<C>()
.unwrap_or_else(|_| panic!("stream_worker '{}' context type mismatch", W::NAME));
let worker = W::default();
if !crate::k8s::HasLeaderElection::ha_enabled(&*ctx) {
info!(worker = W::NAME, "standalone mode, consuming directly");
return consume_loop(&worker, ctx).await;
}
let mut is_leader_rx = crate::k8s::HasLeaderElection::is_leader_rx(&*ctx);
loop {
if !crate::k8s::wait_for_follower(&mut is_leader_rx, W::NAME).await {
return Ok(());
}
info!(worker = W::NAME, "follower mode, starting consumer");
let mut rx2 = crate::k8s::HasLeaderElection::is_leader_rx(&*ctx);
tokio::select! {
result = consume_loop(&worker, Arc::clone(&ctx)) => {
result?;
}
_ = crate::k8s::wait_for_follower_loss(&mut rx2, W::NAME) => {
info!(worker = W::NAME, "became leader, stopping consumer");
}
}
}
}
#[cfg(not(feature = "k8s"))]
pub async fn run_stream_worker<W, C>(
ctx: Arc<dyn std::any::Any + Send + Sync>,
) -> Result<(), FlatbedWorkerError>
where
W: StreamWorker<Context = C> + Default,
C: HasJetStream + Send + Sync + 'static,
{
let ctx: Arc<C> = ctx
.downcast::<C>()
.unwrap_or_else(|_| panic!("stream_worker '{}' context type mismatch", W::NAME));
let worker = W::default();
consume_loop(&worker, ctx).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nats_result_ack_variant() {
let result = NatsResult::Ack;
assert!(matches!(result, NatsResult::Ack));
}
#[test]
fn nats_result_skip_variant() {
let result = NatsResult::Skip("duplicate message".to_string());
match result {
NatsResult::Skip(reason) => assert_eq!(reason, "duplicate message"),
_ => panic!("Expected Skip variant"),
}
}
#[test]
fn nats_result_retry_variant() {
let result = NatsResult::Retry("transient error".to_string());
match result {
NatsResult::Retry(reason) => assert_eq!(reason, "transient error"),
_ => panic!("Expected Retry variant"),
}
}
#[derive(Debug, PartialEq)]
struct TestMessage {
value: u32,
}
struct TestStreamWorker;
impl Default for TestStreamWorker {
fn default() -> Self {
TestStreamWorker
}
}
impl StreamWorker for TestStreamWorker {
type Message = TestMessage;
#[cfg(all(feature = "nats", feature = "k8s"))]
type Context = TestStreamWorkerCtx;
#[cfg(not(feature = "k8s"))]
type Context = TestStreamWorkerCtx;
type ParseError = String;
const NAME: &'static str = "test-stream-worker";
const STREAM: &'static str = "TEST_STREAM";
const SUBJECT: &'static str = "test.subject";
fn handle(
&self,
_ctx: Arc<Self::Context>,
_msg: Self::Message,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = NatsResult> + Send>> {
Box::pin(async { NatsResult::Ack })
}
fn parse_message(bytes: &[u8]) -> Result<Self::Message, Self::ParseError> {
if bytes.len() != 4 {
return Err(format!("expected 4 bytes, got {}", bytes.len()));
}
let value = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
Ok(TestMessage { value })
}
}
struct TestStreamWorkerCtx;
impl HasJetStream for TestStreamWorkerCtx {
fn jetstream(&self) -> &async_nats::jetstream::Context {
panic!("StubJetStream: not expected to be called in parse_message tests");
}
}
#[cfg(feature = "k8s")]
impl crate::k8s::HasLeaderElection for TestStreamWorkerCtx {
fn is_leader_rx(&self) -> tokio::sync::watch::Receiver<bool> {
panic!("StubLeaderElection: not expected to be called in parse_message tests");
}
}
#[test]
fn parse_message_valid_bytes() {
let bytes = 42u32.to_le_bytes();
let result = TestStreamWorker::parse_message(&bytes);
assert_eq!(result, Ok(TestMessage { value: 42 }));
}
#[test]
fn parse_message_invalid_bytes_returns_error() {
let result = TestStreamWorker::parse_message(&[1, 2]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("expected 4 bytes"));
}
#[test]
fn parse_message_empty_bytes_returns_error() {
let result = TestStreamWorker::parse_message(&[]);
assert!(result.is_err());
}
#[derive(Debug, PartialEq)]
enum CustomParseError {
TooShort { got: usize, expected: usize },
Empty,
}
impl std::fmt::Display for CustomParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooShort { got, expected } => {
write!(f, "payload too short: got {got}, expected {expected}")
}
Self::Empty => write!(f, "payload empty"),
}
}
}
struct TypedErrorStreamWorker;
impl StreamWorker for TypedErrorStreamWorker {
type Message = TestMessage;
#[cfg(all(feature = "nats", feature = "k8s"))]
type Context = TestStreamWorkerCtx;
#[cfg(not(feature = "k8s"))]
type Context = TestStreamWorkerCtx;
type ParseError = CustomParseError;
const NAME: &'static str = "typed-error-stream-worker";
const STREAM: &'static str = "TEST_STREAM";
const SUBJECT: &'static str = "test.typed";
fn handle(
&self,
_ctx: Arc<Self::Context>,
_msg: Self::Message,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = NatsResult> + Send>> {
Box::pin(async { NatsResult::Ack })
}
fn parse_message(bytes: &[u8]) -> Result<Self::Message, Self::ParseError> {
if bytes.is_empty() {
return Err(CustomParseError::Empty);
}
if bytes.len() < 4 {
return Err(CustomParseError::TooShort {
got: bytes.len(),
expected: 4,
});
}
let value = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
Ok(TestMessage { value })
}
}
#[test]
fn typed_parse_error_round_trips_structured_variant() {
let err = TypedErrorStreamWorker::parse_message(&[1, 2]).unwrap_err();
assert_eq!(
err,
CustomParseError::TooShort {
got: 2,
expected: 4
}
);
let err_empty = TypedErrorStreamWorker::parse_message(&[]).unwrap_err();
assert_eq!(err_empty, CustomParseError::Empty);
}
#[test]
fn typed_parse_error_display_matches_expected_template() {
let err = CustomParseError::TooShort {
got: 2,
expected: 4,
};
assert_eq!(err.to_string(), "payload too short: got 2, expected 4");
assert_eq!(CustomParseError::Empty.to_string(), "payload empty");
}
#[test]
fn stream_worker_description_default_is_none() {
assert!(
<TestStreamWorker as StreamWorker>::DESCRIPTION.is_none(),
"Default DESCRIPTION should be None"
);
}
#[test]
fn stream_worker_constants_match() {
assert_eq!(
<TestStreamWorker as StreamWorker>::NAME,
"test-stream-worker"
);
assert_eq!(<TestStreamWorker as StreamWorker>::STREAM, "TEST_STREAM");
assert_eq!(<TestStreamWorker as StreamWorker>::SUBJECT, "test.subject");
}
#[test]
fn stream_replicas_default_is_one() {
struct DefaultCtx;
impl HasJetStream for DefaultCtx {
fn jetstream(&self) -> &async_nats::jetstream::Context {
panic!("not called in this test")
}
}
assert_eq!(DefaultCtx.stream_replicas(), 1);
}
#[test]
fn stream_replicas_override_returns_override_value() {
struct OverrideCtx;
impl HasJetStream for OverrideCtx {
fn jetstream(&self) -> &async_nats::jetstream::Context {
panic!("not called in this test")
}
fn stream_replicas(&self) -> usize {
3
}
}
assert_eq!(OverrideCtx.stream_replicas(), 3);
}
}