use std::collections::HashMap;
use std::time::Instant;
use rdkafka::Message as _;
use rdkafka::consumer::Consumer;
use tokio::sync::watch;
use crate::metrics;
use super::ConsumeLoopContext;
use super::dlq::{FailureReport, report_failure_and_dlq};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MsgOutcome {
Processed,
Deduplicated,
DeadLettered,
Failed,
}
impl MsgOutcome {
fn commits_offset(self) -> bool {
matches!(
self,
MsgOutcome::Processed | MsgOutcome::Deduplicated | MsgOutcome::DeadLettered
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum GuardDisposition {
Terminal,
Duplicate,
Deferred,
}
pub(super) fn classify_guard_refusal(error: &crate::errors::OrionError) -> GuardDisposition {
use crate::errors::OrionError;
match error {
OrionError::Conflict(_) => GuardDisposition::Duplicate,
OrionError::RateLimited(_) | OrionError::ServiceUnavailable(_) => {
GuardDisposition::Deferred
}
_ => GuardDisposition::Terminal,
}
}
async fn process_one_kafka_message(
ctx: &ConsumeLoopContext,
msg: &rdkafka::message::BorrowedMessage<'_>,
count_outcome: bool,
) -> MsgOutcome {
let topic: &str = msg.topic();
let channel: &str = match ctx.topic_map.get(topic) {
Some(ch) => ch.as_str(),
None => {
return report_failure_and_dlq(
ctx,
FailureReport {
channel: "unknown",
topic,
payload: msg.payload().unwrap_or_default(),
message_status: "error",
error_kind: "kafka_unmapped_topic",
log_msg: "No channel mapping for Kafka topic",
dlq_reason: &format!("No channel mapping for topic '{topic}'"),
},
count_outcome,
)
.await;
}
};
let payload = match msg.payload_view::<str>() {
Some(Ok(text)) => text,
Some(Err(e)) => {
return report_failure_and_dlq(
ctx,
FailureReport {
channel,
topic,
payload: msg.payload().unwrap_or_default(),
message_status: "error",
error_kind: "kafka_decode",
log_msg: "Failed to decode Kafka message payload as UTF-8",
dlq_reason: &format!("UTF-8 decode error: {e}"),
},
count_outcome,
)
.await;
}
None => {
return report_failure_and_dlq(
ctx,
FailureReport {
channel,
topic,
payload: &[],
message_status: "error",
error_kind: "kafka_empty_payload",
log_msg: "Empty Kafka message payload",
dlq_reason: "Empty message payload",
},
count_outcome,
)
.await;
}
};
let fail = async |message_status: &'static str,
error_kind: &'static str,
log_msg: &'static str,
dlq_reason: String| {
report_failure_and_dlq(
ctx,
FailureReport {
channel,
topic,
payload: payload.as_bytes(),
message_status,
error_kind,
log_msg,
dlq_reason: &dlq_reason,
},
count_outcome,
)
.await
};
let data: serde_json::Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(e) => {
return fail(
"error",
"kafka_parse",
"Failed to parse Kafka message as JSON",
format!("JSON parse error: {e}"),
)
.await;
}
};
let headers = kafka_headers_map(msg);
let _parent_cx = crate::server::trace_context::set_parent_from_map(&headers);
let metadata = kafka_metadata_value(channel, topic, msg);
let channel_runtime = match ctx.channel_registry.require_serviceable(channel) {
Ok(runtime) => runtime,
Err(e) => {
return fail(
"error",
"channel_quarantined",
"Kafka message for a channel that failed to load",
e.to_string(),
)
.await;
}
};
let header_lookup = |name: &str| headers.get(&name.to_ascii_lowercase()).cloned();
let record_key = msg.key().and_then(|k| std::str::from_utf8(k).ok());
let dedup_owner = format!("kafka:{topic}/{}/{}", msg.partition(), msg.offset());
let guard_result = crate::channel::guards::admit(crate::channel::guards::GuardRequest {
transport: crate::channel::guards::Transport::Kafka,
channel,
runtime: &channel_runtime,
data: &data,
metadata: &metadata,
datalogic: &ctx.datalogic,
origin: None,
caller_identity: topic,
header: &header_lookup,
raw_body: None,
dedup_key_fallback: record_key,
dedup_owner: Some(&dedup_owner),
default_timeout_ms: Some(ctx.processing_timeout_ms),
max_timeout_ms: Some(ctx.processing_timeout_ms),
})
.await;
let admission = match guard_result {
Ok(admission) => admission,
Err(e) => match classify_guard_refusal(&e) {
GuardDisposition::Duplicate => {
if count_outcome {
metrics::record_message(channel, "duplicate");
}
tracing::debug!(
topic = %topic,
channel = %channel,
"Kafka message suppressed by the channel's deduplication window"
);
return MsgOutcome::Deduplicated;
}
GuardDisposition::Deferred => {
metrics::record_error("kafka_guard_deferred");
tracing::warn!(
topic = %topic,
channel = %channel,
error = %e,
"Kafka message deferred by a channel guard; offset not committed, will retry"
);
return MsgOutcome::Failed;
}
GuardDisposition::Terminal => {
return fail(
"error",
"kafka_validation",
"Kafka message rejected by a channel ingress guard",
format!("Validation failed: {e}"),
)
.await;
}
},
};
let _backpressure_permit = admission.backpressure_permit;
let dedup_claim = admission.dedup_claim;
let processing_timeout_ms = admission.timeout_ms.unwrap_or(ctx.processing_timeout_ms);
let start = Instant::now();
let mut message = dataflow_rs::Message::builder()
.payload_json(&data)
.metadata_json(&metadata)
.build();
let engine_ref = ctx.engine.load();
let process_result = crate::engine::run_for_channel(
&engine_ref,
channel,
&mut message,
Some(processing_timeout_ms),
None,
None,
)
.await;
let outcome = match process_result {
Err(_) => {
fail(
"timeout",
"kafka_timeout",
"Kafka message processing timed out",
format!("Processing timed out after {processing_timeout_ms}ms"),
)
.await
}
Ok((Err(e), _)) => {
fail(
"error",
"kafka_processing",
"Failed to process Kafka message",
format!("Processing error: {e}"),
)
.await
}
Ok((Ok(()), _)) if message.has_errors() => {
let summary = message
.errors()
.iter()
.map(|e| format!("{}: {}", e.code, e.message))
.collect::<Vec<_>>()
.join("; ");
fail(
"error",
"kafka_processing",
"Kafka message processed with workflow errors",
format!("Workflow errors: {summary}"),
)
.await
}
Ok((Ok(()), _)) => {
let duration = start.elapsed().as_secs_f64();
metrics::record_message(channel, "ok");
metrics::record_message_duration(channel, duration);
tracing::debug!(
topic = %topic,
channel = %channel,
"Kafka message processed successfully"
);
MsgOutcome::Processed
}
};
settle_dedup_claim(dedup_claim, outcome).await;
outcome
}
async fn settle_dedup_claim(
claim: Option<crate::channel::guards::DedupClaim>,
outcome: MsgOutcome,
) {
let Some(claim) = claim else {
return;
};
if outcome.commits_offset() {
claim.confirm().await;
} else {
claim.release().await;
}
}
fn kafka_headers_map(msg: &rdkafka::message::BorrowedMessage<'_>) -> HashMap<String, String> {
use rdkafka::message::Headers;
let mut header_map = HashMap::new();
if let Some(headers) = msg.headers() {
for idx in 0..headers.count() {
if let Ok(header) = headers.get_as::<str>(idx)
&& let Some(value) = header.value
{
header_map.insert(header.key.to_ascii_lowercase(), value.to_string());
}
}
}
header_map
}
pub(crate) const INITIAL_RETRY_BACKOFF_MS: u64 = 1_000;
const MAX_RETRY_BACKOFF_MS: u64 = 60_000;
pub(super) const DEFAULT_IN_PLACE_RETRY_BUDGET_MS: u64 = 240_000;
pub(super) fn in_place_retry_budget_ms(extra_config: &HashMap<String, String>) -> u64 {
extra_config
.get("max.poll.interval.ms")
.and_then(|v| v.trim().parse::<u64>().ok())
.map(|v| v / 5 * 4)
.unwrap_or(DEFAULT_IN_PLACE_RETRY_BUDGET_MS)
}
pub(crate) fn next_backoff_ms(current_ms: u64) -> u64 {
current_ms.saturating_mul(2).min(MAX_RETRY_BACKOFF_MS)
}
pub(super) async fn process_until_committed(
ctx: &ConsumeLoopContext,
msg: &rdkafka::message::BorrowedMessage<'_>,
shutdown_rx: &mut watch::Receiver<bool>,
) -> bool {
let deadline = Instant::now() + std::time::Duration::from_millis(ctx.retry_budget_ms);
let mut backoff_ms = INITIAL_RETRY_BACKOFF_MS;
let mut attempt: u64 = 0;
loop {
if abandon_if_revoked(ctx, msg, "before processing") {
return true;
}
let outcome = process_one_kafka_message(ctx, msg, attempt == 0).await;
if outcome.commits_offset() {
if abandon_if_revoked(ctx, msg, "after processing") {
return true;
}
commit_offset(ctx, msg);
return true;
}
attempt += 1;
metrics::record_error("kafka_retry");
let projected_ms = backoff_ms.saturating_add(ctx.processing_timeout_ms);
if Instant::now() + std::time::Duration::from_millis(projected_ms) >= deadline {
seek_back_for_redelivery(ctx, msg, attempt);
return true;
}
tracing::error!(
topic = %msg.topic(),
partition = msg.partition(),
offset = msg.offset(),
attempt,
backoff_ms,
"Kafka message failed without a confirmed DLQ write; offset not committed, retrying in place"
);
if !super::sleep_or_shutdown(shutdown_rx, backoff_ms).await {
return false;
}
backoff_ms = next_backoff_ms(backoff_ms);
}
}
fn seek_back_for_redelivery(
ctx: &ConsumeLoopContext,
msg: &rdkafka::message::BorrowedMessage<'_>,
attempts: u64,
) {
metrics::record_error("kafka_retry_budget_exhausted");
match ctx.consumer.seek(
msg.topic(),
msg.partition(),
rdkafka::Offset::Offset(msg.offset()),
std::time::Duration::from_secs(5),
) {
Ok(()) => tracing::warn!(
topic = %msg.topic(),
partition = msg.partition(),
offset = msg.offset(),
attempts,
budget_ms = ctx.retry_budget_ms,
"In-place retry budget exhausted; partition rewound so the message is redelivered through the poll loop (offset not committed)"
),
Err(e) => tracing::error!(
topic = %msg.topic(),
partition = msg.partition(),
offset = msg.offset(),
error = %e,
"Failed to rewind partition after exhausting the retry budget; if this consumer still owns the partition, the message is not redelivered until the next rebalance or restart"
),
}
}
fn abandon_if_revoked(
ctx: &ConsumeLoopContext,
msg: &rdkafka::message::BorrowedMessage<'_>,
stage: &'static str,
) -> bool {
if !ctx.rebalance.is_revoked(msg.topic(), msg.partition()) {
return false;
}
metrics::record_error("kafka_partition_revoked");
tracing::warn!(
topic = %msg.topic(),
partition = msg.partition(),
offset = msg.offset(),
stage,
"Partition revoked; abandoning message uncommitted for its new owner"
);
true
}
fn commit_offset(ctx: &ConsumeLoopContext, msg: &rdkafka::message::BorrowedMessage<'_>) {
use rdkafka::consumer::CommitMode;
match ctx.consumer.commit_message(msg, CommitMode::Async) {
Ok(()) => ctx
.rebalance
.record_committable(msg.topic(), msg.partition(), msg.offset() + 1),
Err(e) => tracing::error!(error = %e, "Failed to commit Kafka offset"),
}
}
fn kafka_metadata_value(
channel: &str,
topic: &str,
msg: &rdkafka::message::BorrowedMessage<'_>,
) -> serde_json::Value {
let mut meta = serde_json::json!({
"channel": channel,
"kafka_topic": topic,
"kafka_partition": msg.partition(),
"kafka_offset": msg.offset(),
});
if let Some(key) = msg.key().and_then(|k| std::str::from_utf8(k).ok()) {
meta["kafka_key"] = serde_json::json!(key);
}
meta
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_outcome_commit_decision() {
assert!(MsgOutcome::Processed.commits_offset());
assert!(MsgOutcome::Deduplicated.commits_offset());
assert!(MsgOutcome::DeadLettered.commits_offset());
assert!(!MsgOutcome::Failed.commits_offset());
}
#[test]
fn guard_refusals_map_to_the_right_commit_decision() {
use crate::errors::OrionError;
assert_eq!(
classify_guard_refusal(&OrionError::validation("Input validation failed")),
GuardDisposition::Terminal
);
assert_eq!(
classify_guard_refusal(&OrionError::Conflict("Duplicate request".into())),
GuardDisposition::Duplicate
);
assert_eq!(
classify_guard_refusal(&OrionError::RateLimited("Too many requests".into())),
GuardDisposition::Deferred
);
assert_eq!(
classify_guard_refusal(&OrionError::ServiceUnavailable("at capacity".into())),
GuardDisposition::Deferred
);
assert_eq!(
classify_guard_refusal(&OrionError::RateLimitKeyUnavailable(
"Too many requests".into()
)),
GuardDisposition::Terminal
);
}
#[test]
fn the_dedup_claim_is_settled_by_the_commit_decision() {
for outcome in [MsgOutcome::Processed, MsgOutcome::DeadLettered] {
assert!(
outcome.commits_offset(),
"{outcome:?} advances the offset, so its key is confirmed"
);
}
assert!(
!MsgOutcome::Failed.commits_offset(),
"a failed delivery is coming back, so its key must be released"
);
assert!(MsgOutcome::Deduplicated.commits_offset());
}
#[test]
fn a_deferred_guard_refusal_leaves_the_offset_uncommitted() {
assert!(!MsgOutcome::Failed.commits_offset());
}
#[test]
fn test_retry_budget_defaults_below_default_max_poll_interval() {
assert_eq!(in_place_retry_budget_ms(&HashMap::new()), 240_000);
}
#[test]
fn test_retry_budget_derives_from_configured_max_poll_interval() {
let extra = HashMap::from([("max.poll.interval.ms".to_string(), "100000".to_string())]);
assert_eq!(in_place_retry_budget_ms(&extra), 80_000);
}
#[test]
fn test_retry_budget_ignores_unparseable_values() {
let extra = HashMap::from([("max.poll.interval.ms".to_string(), "ten".to_string())]);
assert_eq!(
in_place_retry_budget_ms(&extra),
DEFAULT_IN_PLACE_RETRY_BUDGET_MS
);
}
#[test]
fn test_retry_backoff_doubles_and_caps() {
let mut backoff = INITIAL_RETRY_BACKOFF_MS;
assert_eq!(backoff, 1_000);
backoff = next_backoff_ms(backoff);
assert_eq!(backoff, 2_000);
backoff = next_backoff_ms(backoff);
assert_eq!(backoff, 4_000);
while backoff < MAX_RETRY_BACKOFF_MS {
backoff = next_backoff_ms(backoff);
}
assert_eq!(backoff, MAX_RETRY_BACKOFF_MS);
assert_eq!(next_backoff_ms(MAX_RETRY_BACKOFF_MS), MAX_RETRY_BACKOFF_MS);
assert_eq!(next_backoff_ms(u64::MAX), MAX_RETRY_BACKOFF_MS);
}
}