use crate::{Error, Result, Sidecar};
use axum::{
Json, Router,
body::Bytes,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
type InMemoryProducer = tokio::sync::mpsc::UnboundedSender<serde_json::Value>;
type InMemoryConsumer = Arc<Mutex<tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>>>;
#[derive(Clone)]
struct InMemoryQueue {
producer: InMemoryProducer,
consumer: InMemoryConsumer,
}
#[derive(Clone)]
enum QueueBackend {
InMemory {
topics: Arc<Mutex<HashMap<String, InMemoryQueue>>>,
queues: Arc<Mutex<HashMap<String, InMemoryQueue>>>,
},
#[cfg(not(target_os = "windows"))]
Omniqueue(OmniqueueBackend),
}
#[cfg(not(target_os = "windows"))]
#[derive(Clone)]
struct OmniqueueBackend {
backend_type: OmniqueueType,
producers: Arc<Mutex<HashMap<String, Arc<omniqueue::DynProducer>>>>,
consumers: Arc<Mutex<HashMap<String, Arc<Mutex<omniqueue::DynConsumer>>>>>,
}
#[cfg(not(target_os = "windows"))]
#[derive(Clone, Debug)]
enum OmniqueueType {
Redis { dsn: String },
RabbitMq { uri: String },
}
#[derive(Clone)]
pub struct QueueService {
backend: QueueBackend,
}
impl std::fmt::Debug for QueueService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QueueService")
.field("backend", &"<queue backend>")
.finish()
}
}
impl QueueService {
#[must_use]
pub fn in_memory() -> Self {
Self {
backend: QueueBackend::InMemory {
topics: Arc::new(Mutex::new(HashMap::new())),
queues: Arc::new(Mutex::new(HashMap::new())),
},
}
}
pub async fn from_env() -> Result<Self> {
let url = std::env::var("QUEUE_URL").unwrap_or_else(|_| "memory://".to_string());
Self::from_url(&url).await
}
#[allow(clippy::unused_async)] pub async fn from_url(url: &str) -> Result<Self> {
if url.starts_with("memory://") {
return Ok(Self::in_memory());
}
#[cfg(target_os = "windows")]
{
Err(Error::Config(format!(
"Backend '{}' not available on Windows. Use memory:// or deploy in Linux container.",
url.split("://").next().unwrap_or("unknown")
)))
}
#[cfg(not(target_os = "windows"))]
{
Self::from_omniqueue_url(url)
}
}
#[cfg(not(target_os = "windows"))]
fn from_omniqueue_url(url: &str) -> Result<Self> {
let backend_type = if url.starts_with("redis://") {
tracing::info!(url = %url, "Configuring Redis queue backend");
OmniqueueType::Redis {
dsn: url.to_string(),
}
} else if url.starts_with("amqp://") {
tracing::info!(url = %url, "Configuring RabbitMQ queue backend");
OmniqueueType::RabbitMq {
uri: url.to_string(),
}
} else {
return Err(Error::Config(format!(
"Unknown queue backend: {url}. Supported: memory://, redis://, amqp://"
)));
};
Ok(Self {
backend: QueueBackend::Omniqueue(OmniqueueBackend {
backend_type,
producers: Arc::new(Mutex::new(HashMap::new())),
consumers: Arc::new(Mutex::new(HashMap::new())),
}),
})
}
async fn get_or_create_inmemory_topic(
&self,
topics: &Arc<Mutex<HashMap<String, InMemoryQueue>>>,
topic: &str,
) -> Result<InMemoryQueue> {
let mut topics = topics.lock().await;
if let Some(queue) = topics.get(topic) {
return Ok(queue.clone());
}
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let queue = InMemoryQueue {
producer: tx,
consumer: Arc::new(Mutex::new(rx)),
};
topics.insert(topic.to_string(), queue.clone());
Ok(queue)
}
async fn get_or_create_inmemory_queue(
&self,
queues: &Arc<Mutex<HashMap<String, InMemoryQueue>>>,
name: &str,
) -> Result<InMemoryQueue> {
let mut queues = queues.lock().await;
if let Some(queue) = queues.get(name) {
return Ok(queue.clone());
}
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let queue = InMemoryQueue {
producer: tx,
consumer: Arc::new(Mutex::new(rx)),
};
queues.insert(name.to_string(), queue.clone());
Ok(queue)
}
#[cfg(not(target_os = "windows"))]
async fn ensure_redis_consumer_group(
dsn: &str,
queue_name: &str,
group_name: &str,
) -> Result<()> {
#![allow(unused_imports)]
use redis::AsyncCommands;
let client = redis::Client::open(dsn)
.map_err(|e| Error::Internal(format!("Redis connection error: {e}")))?;
let mut conn = client
.get_multiplexed_async_connection()
.await
.map_err(|e| Error::Internal(format!("Redis connection error: {e}")))?;
let result: redis::RedisResult<String> = redis::cmd("XGROUP")
.arg("CREATE")
.arg(queue_name)
.arg(group_name)
.arg("$")
.arg("MKSTREAM")
.query_async(&mut conn)
.await;
match result {
Ok(_) => {
tracing::info!(queue = %queue_name, group = %group_name, "Created Redis consumer group");
}
Err(e) if e.to_string().contains("BUSYGROUP") => {
}
Err(e) => {
return Err(Error::Internal(format!(
"Failed to create consumer group: {e}"
)));
}
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
async fn ensure_rabbitmq_queue(uri: &str, queue_name: &str) -> Result<()> {
use lapin::{
Connection, ConnectionProperties, options::QueueDeclareOptions, types::FieldTable,
};
let conn = Connection::connect(uri, ConnectionProperties::default())
.await
.map_err(|e| Error::Internal(format!("RabbitMQ connection error: {e}")))?;
let channel = conn
.create_channel()
.await
.map_err(|e| Error::Internal(format!("RabbitMQ channel error: {e}")))?;
channel
.queue_declare(
queue_name,
QueueDeclareOptions {
durable: true,
..Default::default()
},
FieldTable::default(),
)
.await
.map_err(|e| Error::Internal(format!("Failed to declare RabbitMQ queue: {e}")))?;
tracing::info!(queue = %queue_name, "Ensured RabbitMQ queue exists");
Ok(())
}
#[cfg(not(target_os = "windows"))]
async fn get_or_create_producer(
&self,
backend: &OmniqueueBackend,
queue_name: &str,
) -> Result<Arc<omniqueue::DynProducer>> {
use lapin::options::{BasicConsumeOptions, BasicPublishOptions};
use lapin::types::FieldTable;
use lapin::{BasicProperties, ConnectionProperties};
use omniqueue::backends::rabbitmq::RabbitMqConfig;
use omniqueue::backends::redis::RedisConfig;
use omniqueue::backends::{RabbitMqBackend, RedisBackend};
let mut producers = backend.producers.lock().await;
if let Some(producer) = producers.get(queue_name) {
return Ok(Arc::clone(producer));
}
let producer: omniqueue::DynProducer = match &backend.backend_type {
OmniqueueType::Redis { dsn } => {
Self::ensure_redis_consumer_group(dsn, queue_name, "mikcar").await?;
let config = RedisConfig {
dsn: dsn.clone(),
max_connections: 8,
reinsert_on_nack: true,
queue_key: queue_name.to_string(),
delayed_queue_key: format!("{queue_name}:delayed"),
delayed_lock_key: format!("{queue_name}:delayed_lock"),
consumer_group: "mikcar".to_string(),
consumer_name: format!("mikcar-{}", uuid::Uuid::new_v4()),
payload_key: "payload".to_string(),
ack_deadline_ms: 30_000,
dlq_config: None,
sentinel_config: None,
};
RedisBackend::builder(config)
.make_dynamic()
.build_producer()
.await
.map_err(|e| Error::Internal(format!("Failed to create Redis producer: {e}")))?
}
OmniqueueType::RabbitMq { uri } => {
Self::ensure_rabbitmq_queue(uri, queue_name).await?;
let config = RabbitMqConfig {
uri: uri.clone(),
connection_properties: ConnectionProperties::default(),
publish_exchange: String::new(),
publish_routing_key: queue_name.to_string(),
publish_options: BasicPublishOptions::default(),
publish_properties: BasicProperties::default(),
consume_queue: queue_name.to_string(),
consumer_tag: format!("mikcar-{}", uuid::Uuid::new_v4()),
consume_options: BasicConsumeOptions::default(),
consume_arguments: FieldTable::default(),
consume_prefetch_count: Some(10),
requeue_on_nack: true,
};
RabbitMqBackend::builder(config)
.make_dynamic()
.build_producer()
.await
.map_err(|e| {
Error::Internal(format!("Failed to create RabbitMQ producer: {e}"))
})?
}
};
let producer = Arc::new(producer);
producers.insert(queue_name.to_string(), Arc::clone(&producer));
Ok(producer)
}
#[cfg(not(target_os = "windows"))]
async fn get_or_create_consumer(
&self,
backend: &OmniqueueBackend,
queue_name: &str,
) -> Result<Arc<Mutex<omniqueue::DynConsumer>>> {
use lapin::options::{BasicConsumeOptions, BasicPublishOptions};
use lapin::types::FieldTable;
use lapin::{BasicProperties, ConnectionProperties};
use omniqueue::backends::rabbitmq::RabbitMqConfig;
use omniqueue::backends::redis::RedisConfig;
use omniqueue::backends::{RabbitMqBackend, RedisBackend};
let mut consumers = backend.consumers.lock().await;
if let Some(consumer) = consumers.get(queue_name) {
return Ok(Arc::clone(consumer));
}
let consumer: omniqueue::DynConsumer = match &backend.backend_type {
OmniqueueType::Redis { dsn } => {
let config = RedisConfig {
dsn: dsn.clone(),
max_connections: 8,
reinsert_on_nack: true,
queue_key: queue_name.to_string(),
delayed_queue_key: format!("{queue_name}:delayed"),
delayed_lock_key: format!("{queue_name}:delayed_lock"),
consumer_group: "mikcar".to_string(),
consumer_name: format!("mikcar-{}", uuid::Uuid::new_v4()),
payload_key: "payload".to_string(),
ack_deadline_ms: 30_000,
dlq_config: None,
sentinel_config: None,
};
RedisBackend::builder(config)
.make_dynamic()
.build_consumer()
.await
.map_err(|e| Error::Internal(format!("Failed to create Redis consumer: {e}")))?
}
OmniqueueType::RabbitMq { uri } => {
let config = RabbitMqConfig {
uri: uri.clone(),
connection_properties: ConnectionProperties::default(),
publish_exchange: String::new(),
publish_routing_key: queue_name.to_string(),
publish_options: BasicPublishOptions::default(),
publish_properties: BasicProperties::default(),
consume_queue: queue_name.to_string(),
consumer_tag: format!("mikcar-{}", uuid::Uuid::new_v4()),
consume_options: BasicConsumeOptions::default(),
consume_arguments: FieldTable::default(),
consume_prefetch_count: Some(10),
requeue_on_nack: true,
};
RabbitMqBackend::builder(config)
.make_dynamic()
.build_consumer()
.await
.map_err(|e| {
Error::Internal(format!("Failed to create RabbitMQ consumer: {e}"))
})?
}
};
let consumer = Arc::new(Mutex::new(consumer));
consumers.insert(queue_name.to_string(), Arc::clone(&consumer));
Ok(consumer)
}
}
impl Sidecar for QueueService {
fn name(&self) -> &'static str {
"queue"
}
fn router(&self) -> Router {
Router::new()
.route("/publish/{topic}", post(publish_message))
.route("/subscribe/{topic}", get(subscribe_messages))
.route("/ack/{topic}/{id}", post(ack_message))
.route("/push/{queue}", post(push_to_queue))
.route("/pop/{queue}", get(pop_from_queue))
.route("/len/{queue}", get(queue_length))
.with_state(Arc::new(self.clone()))
}
fn health_check(&self) -> bool {
match &self.backend {
QueueBackend::InMemory { .. } => {
true
}
#[cfg(not(target_os = "windows"))]
QueueBackend::Omniqueue(omni) => {
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
let backend_type = omni.backend_type.clone();
tokio::task::block_in_place(|| {
handle.block_on(async {
match backend_type {
OmniqueueType::Redis { ref dsn } => {
match redis::Client::open(dsn.as_str()) {
Ok(client) => {
match client.get_multiplexed_async_connection().await {
Ok(mut conn) => {
match redis::cmd("PING")
.query_async::<String>(&mut conn)
.await
{
Ok(_) => true,
Err(e) => {
tracing::warn!(error = %e, "Queue Redis health check failed");
false
}
}
}
Err(e) => {
tracing::warn!(error = %e, "Queue Redis connection failed");
false
}
}
}
Err(e) => {
tracing::warn!(error = %e, "Queue Redis client creation failed");
false
}
}
}
OmniqueueType::RabbitMq { ref uri } => {
match lapin::Connection::connect(uri, lapin::ConnectionProperties::default()).await {
Ok(_conn) => true,
Err(e) => {
tracing::warn!(error = %e, "Queue RabbitMQ health check failed");
false
}
}
}
}
})
})
}
Err(_) => {
true
}
}
}
}
}
}
#[derive(Serialize)]
struct MessageResponse {
id: String,
payload: serde_json::Value,
received_at: String,
}
#[derive(Deserialize)]
struct SubscribeQuery {
#[serde(default = "default_timeout")]
timeout: u32,
#[serde(default = "default_max_messages")]
max_messages: u32,
}
fn default_timeout() -> u32 {
30
}
fn default_max_messages() -> u32 {
10
}
async fn publish_message(
State(service): State<Arc<QueueService>>,
Path(topic): Path<String>,
body: Bytes,
) -> Result<impl IntoResponse> {
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap_or_else(|_| {
serde_json::json!({
"data": String::from_utf8_lossy(&body).to_string()
})
});
match &service.backend {
QueueBackend::InMemory { topics, .. } => {
let queue = service.get_or_create_inmemory_topic(topics, &topic).await?;
queue
.producer
.send(payload)
.map_err(|e| Error::Internal(format!("Failed to publish message: {e}")))?;
}
#[cfg(not(target_os = "windows"))]
QueueBackend::Omniqueue(_) => {
return Err(Error::Config(
"Pub/sub not supported with omniqueue backends. Use /push/{queue} for work queues, or use native pub/sub clients.".to_string()
));
}
}
let message_id = uuid::Uuid::new_v4().to_string();
Ok((
StatusCode::OK,
Json(serde_json::json!({
"status": "published",
"topic": topic,
"message_id": message_id
})),
))
}
async fn subscribe_messages(
State(service): State<Arc<QueueService>>,
Path(topic): Path<String>,
Query(query): Query<SubscribeQuery>,
) -> Result<impl IntoResponse> {
match &service.backend {
QueueBackend::InMemory { topics, .. } => {
let queue = service.get_or_create_inmemory_topic(topics, &topic).await?;
let timeout = Duration::from_secs(u64::from(query.timeout));
let max_messages = query.max_messages;
let mut messages = Vec::new();
let mut consumer = queue.consumer.lock().await;
let deadline = tokio::time::Instant::now() + timeout;
while messages.len() < max_messages as usize {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, consumer.recv()).await {
Ok(Some(payload)) => {
messages.push(MessageResponse {
id: uuid::Uuid::new_v4().to_string(),
payload,
received_at: chrono::Utc::now().to_rfc3339(),
});
}
Ok(None) | Err(_) => break,
}
}
Ok(Json(serde_json::json!({
"messages": messages,
"topic": topic,
"count": messages.len()
})))
}
#[cfg(not(target_os = "windows"))]
QueueBackend::Omniqueue(_) => {
Err(Error::Config(
"Pub/sub not supported with omniqueue backends. Use /pop/{queue} for work queues, or use native pub/sub clients.".to_string()
))
}
}
}
async fn ack_message(
State(_service): State<Arc<QueueService>>,
Path((topic, id)): Path<(String, String)>,
) -> Result<impl IntoResponse> {
Ok(Json(serde_json::json!({
"status": "acknowledged",
"topic": topic,
"message_id": id
})))
}
async fn push_to_queue(
State(service): State<Arc<QueueService>>,
Path(queue_name): Path<String>,
body: Bytes,
) -> Result<impl IntoResponse> {
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap_or_else(|_| {
serde_json::json!({
"data": String::from_utf8_lossy(&body).to_string()
})
});
let message_id = uuid::Uuid::new_v4().to_string();
match &service.backend {
QueueBackend::InMemory { queues, .. } => {
let queue = service
.get_or_create_inmemory_queue(queues, &queue_name)
.await?;
queue
.producer
.send(payload.clone())
.map_err(|e| Error::Internal(format!("Failed to push message: {e}")))?;
}
#[cfg(not(target_os = "windows"))]
QueueBackend::Omniqueue(backend) => {
let producer = service.get_or_create_producer(backend, &queue_name).await?;
let payload_bytes = serde_json::to_vec(&payload)
.map_err(|e| Error::Internal(format!("Failed to serialize payload: {e}")))?;
producer
.send_raw(&payload_bytes)
.await
.map_err(|e| Error::Internal(format!("Failed to push message: {e}")))?;
}
}
Ok((
StatusCode::OK,
Json(serde_json::json!({
"status": "pushed",
"queue": queue_name,
"message_id": message_id
})),
))
}
async fn pop_from_queue(
State(service): State<Arc<QueueService>>,
Path(queue_name): Path<String>,
Query(query): Query<SubscribeQuery>,
) -> Result<impl IntoResponse> {
let timeout = Duration::from_secs(u64::from(query.timeout));
match &service.backend {
QueueBackend::InMemory { queues, .. } => {
let queue = service
.get_or_create_inmemory_queue(queues, &queue_name)
.await?;
let mut consumer = queue.consumer.lock().await;
match tokio::time::timeout(timeout, consumer.recv()).await {
Ok(Some(payload)) => {
let message = MessageResponse {
id: uuid::Uuid::new_v4().to_string(),
payload,
received_at: chrono::Utc::now().to_rfc3339(),
};
Ok(Json(serde_json::json!({
"message": message,
"queue": queue_name
})))
}
Ok(None) => Err(Error::Internal("Queue channel closed".to_string())),
Err(_) => Ok(Json(serde_json::json!({
"message": null,
"queue": queue_name
}))),
}
}
#[cfg(not(target_os = "windows"))]
QueueBackend::Omniqueue(backend) => {
let consumer = service.get_or_create_consumer(backend, &queue_name).await?;
let mut consumer = consumer.lock().await;
match tokio::time::timeout(timeout, consumer.receive()).await {
Ok(Ok(delivery)) => {
let payload: serde_json::Value = delivery
.payload_serde_json()
.unwrap_or(None)
.unwrap_or_else(|| {
serde_json::json!({
"data": String::from_utf8_lossy(delivery.borrow_payload().unwrap_or(&[])).to_string()
})
});
if let Err(e) = delivery.ack().await {
tracing::warn!(error = %e.0, "Failed to ack message");
}
let message = MessageResponse {
id: uuid::Uuid::new_v4().to_string(),
payload,
received_at: chrono::Utc::now().to_rfc3339(),
};
Ok(Json(serde_json::json!({
"message": message,
"queue": queue_name
})))
}
Ok(Err(e)) => Err(Error::Internal(format!("Failed to receive message: {e}"))),
Err(_) => Ok(Json(serde_json::json!({
"message": null,
"queue": queue_name
}))),
}
}
}
}
async fn queue_length(
State(_service): State<Arc<QueueService>>,
Path(queue): Path<String>,
) -> Result<impl IntoResponse> {
Ok(Json(serde_json::json!({
"queue": queue,
"length": null,
"note": "Queue length requires backend-specific queries"
})))
}