use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use nafka::{AckMode, GroupSpec, KafkaRecord, NafkaError, SingleConsumer};
use nasaga_core::{DefinitionVersion, ServiceIdentity, StepName, WorkflowName};
use crate::transport_shared::{SagaCommandHandler, SagaResultHandler};
use crate::{
record_kafka_ack, record_kafka_command_ack, record_kafka_command_dlt_requested,
record_kafka_command_processing, record_kafka_command_retry, record_kafka_dlt_requested,
record_kafka_processing, record_kafka_retry,
};
use crate::{
CommandDeliveryDisposition, CommandDeliveryPolicy, HandleOutcome, ParticipantHandled,
ResultDeliveryDisposition, ResultDeliveryPolicy, SagaCommandEnvelope,
SagaCommandProcessingError, SagaResultEnvelope, COMMAND_EVENT_TYPE, RESULT_EVENT_TYPE,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SagaCommandRoute {
producer: ServiceIdentity,
workflow: WorkflowName,
definition_version: DefinitionVersion,
definition_digest: String,
step: StepName,
}
impl SagaCommandRoute {
pub fn new(
producer: ServiceIdentity,
workflow: WorkflowName,
definition_version: DefinitionVersion,
definition_digest: impl Into<String>,
step: StepName,
) -> anyhow::Result<Self> {
let definition_digest = definition_digest.into();
if definition_digest.len() != 64
|| !definition_digest
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
{
anyhow::bail!(
"saga command route definition digest must be 64 lowercase hex characters"
);
}
Ok(Self {
producer,
workflow,
definition_version,
definition_digest,
step,
})
}
pub fn producer(&self) -> &ServiceIdentity {
&self.producer
}
fn matches(&self, identity: &crate::VerifiedIdentity) -> bool {
self.workflow == identity.workflow
&& self.definition_version == identity.definition_version
&& self.definition_digest == identity.definition_digest
&& self.step == identity.step
}
}
#[derive(Debug, Clone)]
pub struct SagaKafkaCommandConsumerConfig {
pub topic_routes: BTreeMap<String, SagaCommandRoute>,
pub group: String,
pub delivery_policy: CommandDeliveryPolicy,
}
impl SagaKafkaCommandConsumerConfig {
pub fn new(
topic_routes: BTreeMap<String, SagaCommandRoute>,
group: impl Into<String>,
delivery_policy: CommandDeliveryPolicy,
) -> anyhow::Result<Self> {
let group = group.into();
if topic_routes.is_empty()
|| topic_routes
.keys()
.any(|topic| !valid_kafka_name(topic, 249))
|| !valid_kafka_name(&group, 255)
|| delivery_policy.max_retries == 0
{
anyhow::bail!(
"saga Kafka command consumer requires non-empty topic routes, a stable group and a positive retry budget"
);
}
Ok(Self {
topic_routes,
group,
delivery_policy,
})
}
}
pub struct SagaKafkaCommandConsumer<H> {
handler: Arc<H>,
config: SagaKafkaCommandConsumerConfig,
}
impl<H> SagaKafkaCommandConsumer<H> {
pub fn new(handler: Arc<H>, config: SagaKafkaCommandConsumerConfig) -> Self {
Self { handler, config }
}
}
impl<H: SagaCommandHandler> SagaKafkaCommandConsumer<H> {
pub async fn handle_delivery(
&self,
envelope: &SagaCommandEnvelope,
context: &nafka::ConsumeCtx,
) -> nafka::Result<ParticipantHandled> {
let started_at = Instant::now();
let Some(route) = self.config.topic_routes.get(&context.topic) else {
record_kafka_command_processing(started_at.elapsed());
record_kafka_command_dlt_requested();
return Err(NafkaError::HandlerDeadLetter(
"saga_command_topic_route_unbound".into(),
));
};
let identity = match envelope.verified_for_delivery() {
Ok(identity) => identity,
Err(error) => {
record_kafka_command_processing(started_at.elapsed());
record_kafka_command_dlt_requested();
return Err(NafkaError::HandlerDeadLetter(
command_dead_letter_reason(&error).into(),
));
}
};
if !route.matches(&identity) {
record_kafka_command_processing(started_at.elapsed());
record_kafka_command_dlt_requested();
return Err(NafkaError::HandlerDeadLetter(
SagaCommandProcessingError::RouteUnauthorized
.dead_letter_reason()
.into(),
));
}
let receipt_trace = context.trace_context();
let result = self
.handler
.handle_authenticated_command_traced(envelope, route.producer(), receipt_trace.as_ref())
.await;
record_kafka_command_processing(started_at.elapsed());
match result {
Ok(outcome) => Ok(outcome),
Err(error) => match self
.config
.delivery_policy
.decide(&error, context.retry_attempt)
{
CommandDeliveryDisposition::Retry => {
record_kafka_command_retry();
tracing::warn!(
kafka_topic = %context.topic,
kafka_partition = context.partition,
kafka_offset = context.offset,
delivery_attempt = context.delivery_attempt,
retry_attempt = context.retry_attempt,
"Saga command 本地事务未提交,保留 offset 退避重投"
);
Err(NafkaError::Broker(
"saga command transaction not committed".into(),
))
}
CommandDeliveryDisposition::Defer => {
record_kafka_command_retry();
tracing::error!(
kafka_topic = %context.topic,
kafka_partition = context.partition,
kafka_offset = context.offset,
delivery_attempt = context.delivery_attempt,
"Saga command COMMIT/回滚阶段不可确认,保留 offset 且不消耗 DLT 预算"
);
Err(NafkaError::HandlerDeferred(
"saga_command_transaction_outcome_unresolved".into(),
))
}
CommandDeliveryDisposition::DeadLetter => {
record_kafka_command_dlt_requested();
let reason = command_dead_letter_reason(&error);
tracing::error!(
kafka_topic = %context.topic,
kafka_partition = context.partition,
kafka_offset = context.offset,
delivery_attempt = context.delivery_attempt,
retry_attempt = context.retry_attempt,
reason,
"Saga command 进入 durability-first DLT"
);
Err(NafkaError::HandlerDeadLetter(reason.into()))
}
},
}
}
}
impl<H: SagaCommandHandler> SingleConsumer for SagaKafkaCommandConsumer<H> {
type Message = SagaCommandEnvelope;
fn topics(&self) -> Vec<String> {
self.config.topic_routes.keys().cloned().collect()
}
fn event(&self) -> String {
COMMAND_EVENT_TYPE.into()
}
fn group(&self) -> GroupSpec {
GroupSpec::Named(self.config.group.clone())
}
fn id(&self) -> &'static str {
"nasaga-runtime::saga-command-consumer"
}
fn ack_mode(&self) -> AckMode {
AckMode::Manual
}
async fn consume(&self, record: KafkaRecord<Self::Message>) -> nafka::Result<()> {
let outcome = self.handle_delivery(&record.value, &record.ctx).await?;
record.ack()?;
record_kafka_command_ack(matches!(outcome, ParticipantHandled::Duplicate));
tracing::info!(
saga_id = %record.value.saga_id,
workflow = %record.value.workflow,
step = %record.value.step,
phase = %record.value.phase,
kafka_topic = %record.ctx.topic,
kafka_partition = record.ctx.partition,
kafka_offset = record.ctx.offset,
delivery_attempt = record.ctx.delivery_attempt,
duplicate = matches!(outcome, ParticipantHandled::Duplicate),
"Saga command 本地事务已提交并完成手动 ACK"
);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SagaKafkaResultConsumerConfig {
pub topic_owners: BTreeMap<String, ServiceIdentity>,
pub group: String,
pub delivery_policy: ResultDeliveryPolicy,
}
impl SagaKafkaResultConsumerConfig {
pub fn new(
topic_owners: BTreeMap<String, ServiceIdentity>,
group: impl Into<String>,
delivery_policy: ResultDeliveryPolicy,
) -> anyhow::Result<Self> {
let group = group.into();
if topic_owners.is_empty()
|| topic_owners
.keys()
.any(|topic| !valid_kafka_name(topic, 249))
|| !valid_kafka_name(&group, 255)
|| delivery_policy.max_retries == 0
{
anyhow::bail!(
"saga Kafka result consumer requires non-empty topic owners, a stable group and a positive retry budget"
);
}
Ok(Self {
topic_owners,
group,
delivery_policy,
})
}
}
pub struct SagaKafkaResultConsumer<H> {
handler: Arc<H>,
config: SagaKafkaResultConsumerConfig,
}
impl<H> SagaKafkaResultConsumer<H> {
pub fn new(handler: Arc<H>, config: SagaKafkaResultConsumerConfig) -> Self {
Self { handler, config }
}
}
impl<H: SagaResultHandler> SagaKafkaResultConsumer<H> {
pub async fn handle_delivery(
&self,
envelope: &SagaResultEnvelope,
context: &nafka::ConsumeCtx,
) -> nafka::Result<HandleOutcome> {
let started_at = Instant::now();
let Some(producer) = self.config.topic_owners.get(&context.topic) else {
record_kafka_processing(started_at.elapsed());
record_kafka_dlt_requested();
return Err(NafkaError::HandlerDeadLetter(
"saga_result_topic_owner_unbound".into(),
));
};
let now_ms = match current_epoch_ms() {
Ok(now_ms) => now_ms,
Err(reason) => {
record_kafka_processing(started_at.elapsed());
record_kafka_retry();
return Err(NafkaError::Broker(reason.into()));
}
};
let receipt_trace = context.trace_context();
let result = self
.handler
.handle_authenticated_result_traced(envelope, producer, receipt_trace.as_ref(), now_ms)
.await;
record_kafka_processing(started_at.elapsed());
match result {
Ok(outcome) => Ok(outcome),
Err(error) => {
let disposition = self
.config
.delivery_policy
.decide(&error, context.retry_attempt);
match disposition {
ResultDeliveryDisposition::Retry => {
record_kafka_retry();
tracing::warn!(
kafka_topic = %context.topic,
kafka_partition = context.partition,
kafka_offset = context.offset,
delivery_attempt = context.delivery_attempt,
retry_attempt = context.retry_attempt,
"Saga 结果事务未提交,保留 offset 退避重投"
);
Err(NafkaError::Broker(
"saga result transaction not committed".into(),
))
}
ResultDeliveryDisposition::Defer => {
record_kafka_retry();
tracing::info!(
kafka_topic = %context.topic,
kafka_partition = context.partition,
kafka_offset = context.offset,
"Saga 结果受暂停或事务阶段门禁延后,保留 offset 且不消耗 DLT 预算"
);
Err(NafkaError::HandlerDeferred("saga_result_deferred".into()))
}
ResultDeliveryDisposition::DeadLetter => {
record_kafka_dlt_requested();
let reason = dead_letter_reason(&error);
tracing::error!(
kafka_topic = %context.topic,
kafka_partition = context.partition,
kafka_offset = context.offset,
delivery_attempt = context.delivery_attempt,
retry_attempt = context.retry_attempt,
reason,
"Saga 结果进入 durability-first DLT"
);
Err(NafkaError::HandlerDeadLetter(reason.into()))
}
}
}
}
}
}
impl<H: SagaResultHandler> SingleConsumer for SagaKafkaResultConsumer<H> {
type Message = SagaResultEnvelope;
fn topics(&self) -> Vec<String> {
self.config.topic_owners.keys().cloned().collect()
}
fn event(&self) -> String {
RESULT_EVENT_TYPE.into()
}
fn group(&self) -> GroupSpec {
GroupSpec::Named(self.config.group.clone())
}
fn id(&self) -> &'static str {
"nasaga-runtime::saga-result-consumer"
}
fn ack_mode(&self) -> AckMode {
AckMode::Manual
}
async fn consume(&self, record: KafkaRecord<Self::Message>) -> nafka::Result<()> {
let outcome = self.handle_delivery(&record.value, &record.ctx).await?;
record.ack()?;
record_kafka_ack(matches!(outcome, HandleOutcome::Duplicate));
match outcome {
HandleOutcome::Applied { status } => tracing::info!(
saga_id = %record.value.saga_id,
workflow = %record.value.workflow,
step = %record.value.step,
phase = %record.value.phase,
saga_status = status.as_str(),
kafka_topic = %record.ctx.topic,
kafka_partition = record.ctx.partition,
kafka_offset = record.ctx.offset,
delivery_attempt = record.ctx.delivery_attempt,
"Saga 结果事务已提交并完成手动 ACK"
),
HandleOutcome::Duplicate => tracing::info!(
saga_id = %record.value.saga_id,
kafka_topic = %record.ctx.topic,
kafka_partition = record.ctx.partition,
kafka_offset = record.ctx.offset,
delivery_attempt = record.ctx.delivery_attempt,
"Saga 重复结果已由 Inbox 吸收并完成手动 ACK"
),
}
Ok(())
}
}
fn valid_kafka_name(value: &str, max_len: usize) -> bool {
!value.is_empty()
&& value.len() <= max_len
&& value.trim() == value
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
}
fn current_epoch_ms() -> Result<i64, &'static str> {
let elapsed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| "system clock is before Unix epoch")?;
i64::try_from(elapsed.as_millis()).map_err(|_| "system clock exceeds supported epoch range")
}
fn dead_letter_reason(error: &anyhow::Error) -> &'static str {
error
.chain()
.find_map(|cause| {
cause
.downcast_ref::<crate::SagaResultProcessingError>()
.and_then(|classified| classified.dead_letter_reason())
})
.unwrap_or("saga_result_retry_exhausted")
}
fn command_dead_letter_reason(error: &anyhow::Error) -> &'static str {
error
.chain()
.find_map(|cause| {
cause
.downcast_ref::<SagaCommandProcessingError>()
.map(|classified| classified.dead_letter_reason())
})
.unwrap_or("saga_command_retry_exhausted")
}