Here is how you handle partial ordering failure recovery when keeping strict sequential order is critical.
When a message processing fails in a consumer, you cannot simply skip it or send it straight to a standard Dead-Letter Queue (DLQ). If you process subsequent messages for that same key while the failed message waits in a DLQ, you have broken message ordering.
To handle errors safely without permanently blocking the entire partition, architectures use a pattern called Ordered DLQ / Letterbox Routing or Redirected Processing Loops.
The Failure Strategy
- Detect Failure: A message fails business processing.
- Publish to DLQ with Expiry: Route the failed message to a DLQ, but pause consumption for that specific partition or route subsequent messages for that specific key to a hold state. Alternatively, route all subsequent messages for that specific entity key to the DLQ behind the failed message to preserve their relative order.
- Stop & Retry (Circuit Breaker): For global partition ordering, pause the consumer, back off, and retry processing the exact same offset.
Here is a Java implementation using a Retry-with-Backoff Circuit Breaker approach. If a record fails, it loops and retries up to a maximum limit before safely halting or logging, without advancing the offset.
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
public class ResilientOrderedConsumer {
private static final int MAX_RETRY_ATTEMPTS = 3;
public static void main(String[] args) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "resilient-order-group");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
consumer.subscribe(Collections.singletonList("order-events"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
boolean success = processWithRetry(record);
if (!success) {
// ─── CRITICAL ORDERING BREAKDOWN ─────────────────────────────────
// Message failed all retries. To guarantee strict order, we CANNOT
// move to the next message. We must stop processing this partition.
System.err.printf("CRITICAL: Order blocked at partition %d, offset %d. Routing to alert system.%n",
record.partition(), record.offset());
// Seek back to this specific failed offset so it retries on the next poll cycle
TopicPartition tp = new TopicPartition(record.topic(), record.partition());
consumer.seek(tp, record.offset());
// Break out of processing the current batch to prevent executing next records
break;
}
// Explicitly commit individual offsets only after successful execution
TopicPartition tp = new TopicPartition(record.topic(), record.partition());
OffsetAndMetadata offsetMeta = new OffsetAndMetadata(record.offset() + 1);
consumer.commitSync(Collections.singletonMap(tp, offsetMeta));
}
}
}
}
private static boolean processWithRetry(ConsumerRecord<String, String> record) {
int attempts = 0;
while (attempts < MAX_RETRY_ATTEMPTS) {
try {
attempts++;
// Simulate business logic
executeBusinessLogic(record);
return true; // Success!
} catch (Exception e) {
System.err.printf("Attempt %d failed for key %s: %s. Retrying...%n",
attempts, record.key(), e.getMessage());
// Exponential backoff before retrying the exact same message
try {
Thread.sleep(1000L * attempts);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return false;
}
}
}
return false; // All retry attempts exhausted
}
private static void executeBusinessLogic(ConsumerRecord<String, String> record) throws Exception {
// Example failure simulator
if ("Event_3".equals(record.value())) {
throw new RuntimeException("Database timeout error");
}
System.out.printf("Successfully processed: %s%n", record.value());
}
}Would you like to explore how to implement an Entity-based Hold State (where you block only the failed customer_id but let other customers’ data process normally), or do you want to look at how Spring Kafka handles this natively using its DefaultErrorHandler?