use serde::Serialize; use crate::{label, topic}; use config::rocketmq::RocketMqConfig; use queue::{EVENT_PREFIX, Envelope, Labels, Publisher, QueueError, QueueResult, RocketMqConn}; pub const DURABLE_DOMAINS: &[&str] = &[ "auth", "email", "guild", "chat", "git", "issue", "notify", "organization", "pull", "pull_request", ]; #[derive(Clone, Debug)] pub struct EventHandler { publisher: Publisher, } impl EventHandler { pub fn new(mq: RocketMqConn) -> Self { Self { publisher: Publisher::new(mq), } } pub async fn connect(config: &RocketMqConfig) -> QueueResult { let conn = RocketMqConn::init(config).await?; Ok(Self::new(conn)) } pub async fn optional(config: Option<&RocketMqConfig>) -> QueueResult> { match config { Some(config) => Self::connect(config).await.map(Some), None => Ok(None), } } pub fn publisher(&self) -> &Publisher { &self.publisher } pub fn max_payload(&self) -> usize { self.publisher.max_payload() } pub fn envelope(&self, kind: &str, payload: T) -> Envelope { Envelope::new(kind, payload) } #[tracing::instrument(skip_all, fields(kind = kind, request_id = %request_id.unwrap_or_default()))] pub async fn send(&self, kind: &str, payload: T, request_id: Option<&str>) -> QueueResult<()> where T: Serialize, { self.send_labeled(kind, Labels::default(), payload, request_id) .await } #[tracing::instrument(skip_all, fields(kind = kind, request_id = %request_id.unwrap_or_default()))] pub async fn send_labeled( &self, kind: &str, labels: Labels, payload: T, request_id: Option<&str>, ) -> QueueResult<()> where T: Serialize, { self.send_keyed(kind, labels, None, payload, request_id) .await } #[tracing::instrument(skip_all, fields(kind = kind, request_id = %request_id.unwrap_or_default()))] pub async fn send_keyed( &self, kind: &str, labels: Labels, idempotency_key: Option, payload: T, request_id: Option<&str>, ) -> QueueResult<()> where T: Serialize, { let mut envelope = self.envelope(kind, payload).with_labels(labels); if let Some(key) = idempotency_key { envelope = envelope.with_idempotency_key(key); } self.send_envelope(&envelope, request_id).await } #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))] pub async fn send_envelope( &self, envelope: &Envelope, request_id: Option<&str>, ) -> QueueResult<()> where T: Serialize, { let subject = format!("{EVENT_PREFIX}{}", envelope.kind); envelope.verify_subject(EVENT_PREFIX, &subject)?; self.dispatch(envelope, request_id).await } #[tracing::instrument(skip_all, fields(subject = %subject, request_id = %request_id.unwrap_or_default()))] pub async fn publish_durable( &self, subject: &str, envelope: &Envelope, request_id: Option<&str>, ) -> QueueResult<()> { envelope.verify_event_subject(subject)?; self.dispatch(envelope, request_id).await } pub async fn health(&self) -> QueueResult<()> { let topic = self.publisher.topic(topic::HEALTH); self.publisher.conn().health(&topic).await } pub async fn shutdown(&self) -> QueueResult<()> { self.publisher.conn().shutdown().await; Ok(()) } #[tracing::instrument(skip_all, fields(kind = %envelope.kind, request_id = %request_id.unwrap_or_default()))] async fn dispatch( &self, envelope: &Envelope, request_id: Option<&str>, ) -> QueueResult<()> { ensure_kind(&envelope.kind)?; let payload = serde_json::to_value(&envelope.payload).map_err(QueueError::from)?; let area = topic::of(&envelope.kind); let tag = label::tag(&envelope.kind, Some(&payload)); let request_id = envelope::context::request_id().or_else(|| request_id.map(str::to_owned)); let properties = envelope.properties(Some(&payload), request_id.as_deref()); let topic = self.publisher.topic(area); let body = envelope.encode()?; self.publisher .publish(&topic, tag, vec![envelope.message_id()], properties, body) .await } } fn ensure_kind(kind: &str) -> QueueResult<()> { let mut parts = kind.split('.'); let domain = parts.next().unwrap_or_default(); let name = parts.next().unwrap_or_default(); let operate = parts.next().unwrap_or_default(); if domain.is_empty() || name.is_empty() || operate.is_empty() { return Err(QueueError::payload(format!( "event kind `{kind}` is not a concrete event" ))); } Ok(()) } const PUBLISH_ATTEMPTS: usize = 3; const PUBLISH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); const PUBLISH_BACKOFF: std::time::Duration = std::time::Duration::from_millis(50); /// 统一的事件转发重试:单次尝试带超时,失败按退避重试, /// 最终仍失败打 ERROR,便于告警发现事件链断了。 /// `context` 仅用于日志文案(如 "auth" / "email")。 #[tracing::instrument(skip_all, fields(kind = kind, context = context, request_id = %_request_id.unwrap_or_default()))] pub async fn retry_publish( events: &EventHandler, kind: &str, labels: Labels, payload: T, context: &str, _request_id: Option<&str>, ) where T: Serialize + Clone, { for attempt in 1..=PUBLISH_ATTEMPTS { let published = events.send_labeled(kind, labels.clone(), payload.clone(), _request_id); match tokio::time::timeout(PUBLISH_TIMEOUT, published).await { Ok(Ok(())) => { if attempt > 1 { tracing::info!(kind, attempt, "{context} event forwarded after retry"); } return; } Ok(Err(error)) => { tracing::warn!(kind, attempt, %error, "failed to publish {context} event"); } Err(_) => { tracing::warn!(kind, attempt, "publishing {context} event timed out"); } } if attempt < PUBLISH_ATTEMPTS { tokio::time::sleep(PUBLISH_BACKOFF * attempt as u32).await; } } tracing::error!( kind, attempts = PUBLISH_ATTEMPTS, "{context} event could not be forwarded to rocketmq" ); }