Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,18 @@ public Map<String, KafkaFuture<TopicDescription>> values() {
*/

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side cleanup

public KafkaFuture<Map<String, TopicDescription>> all() {
return KafkaFuture.allOf(futures.values().toArray(new KafkaFuture[0])).
thenApply(new KafkaFuture.BaseFunction<Void, Map<String, TopicDescription>>() {
@Override
public Map<String, TopicDescription> apply(Void v) {
Map<String, TopicDescription> descriptions = new HashMap<>(futures.size());
for (Map.Entry<String, KafkaFuture<TopicDescription>> entry : futures.entrySet()) {
try {
descriptions.put(entry.getKey(), entry.getValue().get());
} catch (InterruptedException | ExecutionException e) {
// This should be unreachable, because allOf ensured that all the futures
// completed successfully.
throw new RuntimeException(e);
}
thenApply(v -> {
Map<String, TopicDescription> descriptions = new HashMap<>(futures.size());
for (Map.Entry<String, KafkaFuture<TopicDescription>> entry : futures.entrySet()) {
try {
descriptions.put(entry.getKey(), entry.getValue().get());
} catch (InterruptedException | ExecutionException e) {
// This should be unreachable, because allOf ensured that all the futures
// completed successfully.
throw new RuntimeException(e);
}
return descriptions;
}
return descriptions;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,23 +48,13 @@ public KafkaFuture<Map<String, TopicListing>> namesToListings() {
* Return a future which yields a collection of TopicListing objects.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side cleanup

*/
public KafkaFuture<Collection<TopicListing>> listings() {
return future.thenApply(new KafkaFuture.BaseFunction<Map<String, TopicListing>, Collection<TopicListing>>() {
@Override
public Collection<TopicListing> apply(Map<String, TopicListing> namesToDescriptions) {
return namesToDescriptions.values();
}
});
return future.thenApply(namesToDescriptions -> namesToDescriptions.values());
}

/**
* Return a future which yields a collection of topic names.
*/
public KafkaFuture<Set<String>> names() {
return future.thenApply(new KafkaFuture.BaseFunction<Map<String, TopicListing>, Set<String>>() {
@Override
public Set<String> apply(Map<String, TopicListing> namesToListings) {
return namesToListings.keySet();
}
});
return future.thenApply(namesToListings -> namesToListings.keySet());
}
}
8 changes: 7 additions & 1 deletion examples/README
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ To run the demo:
2. For simple consumer demo, `run bin/java-simple-consumer-demo.sh`
3. For unlimited sync-producer-consumer run, `run bin/java-producer-consumer-demo.sh sync`
4. For unlimited async-producer-consumer run, `run bin/java-producer-consumer-demo.sh`

5. For standalone mode exactly once demo run, `run bin/exactly-once-demo.sh standaloneMode 6 3 50000`,
this means we are starting 3 EOS instances with 6 topic partitions and 50000 pre-populated records
6. For group mode exactly once demo run, `run bin/exactly-once-demo.sh groupMode 6 3 50000`,
this means the same as the standalone demo, except consumers are using subscription mode.
7. Some notes for exactly once demo:
7.1. The Kafka server has to be on broker version 2.5 or higher to be able to run group mode.
7.2. You could also use Intellij to run the example directly by configuring parameters as "Program arguments"
23 changes: 23 additions & 0 deletions examples/bin/exactly-once-demo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/bash
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

base_dir=$(dirname $0)/../..

if [ "x$KAFKA_HEAP_OPTS" = "x" ]; then
export KAFKA_HEAP_OPTS="-Xmx512M"
fi

exec $base_dir/bin/kafka-run-class.sh kafka.examples.KafkaExactlyOnceDemo $@
32 changes: 29 additions & 3 deletions examples/src/main/java/kafka/examples/Consumer.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,58 @@
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;

public class Consumer extends ShutdownableThread {
private final KafkaConsumer<Integer, String> consumer;
private final String topic;
private final String groupId;
private final int numMessageToConsume;
private int messageRemaining;
private final CountDownLatch latch;

public Consumer(String topic) {
public Consumer(final String topic,
final String groupId,
final boolean readCommitted,
final int numMessageToConsume,
final CountDownLatch latch) {
super("KafkaConsumerExample", false);
this.groupId = groupId;
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KafkaProperties.KAFKA_SERVER_URL + ":" + KafkaProperties.KAFKA_SERVER_PORT);
props.put(ConsumerConfig.GROUP_ID_CONFIG, "DemoConsumer");
props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.IntegerDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
if (readCommitted) {
props.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
}
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");

consumer = new KafkaConsumer<>(props);
this.topic = topic;
this.numMessageToConsume = numMessageToConsume;
this.messageRemaining = numMessageToConsume;
this.latch = latch;
}

KafkaConsumer<Integer, String> get() {
return consumer;
}

@Override
public void doWork() {
consumer.subscribe(Collections.singletonList(this.topic));
ConsumerRecords<Integer, String> records = consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<Integer, String> record : records) {
System.out.println("Received message: (" + record.key() + ", " + record.value() + ") at offset " + record.offset());
System.out.println(groupId + " received message : from partition " + record.partition() + ", (" + record.key() + ", " + record.value() + ") at offset " + record.offset());
}
messageRemaining -= records.count();
if (messageRemaining <= 0) {
System.out.println(groupId + " finished reading " + numMessageToConsume + " messages");
latch.countDown();
}
}

Expand Down
209 changes: 209 additions & 0 deletions examples/src/main/java/kafka/examples/ExactlyOnceMessageProcessor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package kafka.examples;

import org.apache.kafka.clients.consumer.CommitFailedException;
import org.apache.kafka.clients.consumer.ConsumerRebalanceListener;
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.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.KafkaException;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.errors.FencedInstanceIdException;
import org.apache.kafka.common.errors.ProducerFencedException;

import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;

/**
* A demo class for how to write a customized EOS app. It takes a consume-process-produce loop.
* Important configurations and APIs are commented.
*/
public class ExactlyOnceMessageProcessor extends Thread {

private static final boolean READ_COMMITTED = true;

private final String mode;
private final String inputTopic;
private final String outputTopic;
private final String consumerGroupId;
private final int numPartitions;
private final int numInstances;
private final int instanceIdx;
private final String transactionalId;

private final KafkaProducer<Integer, String> producer;
private final KafkaConsumer<Integer, String> consumer;

private final CountDownLatch latch;

public ExactlyOnceMessageProcessor(final String mode,
final String inputTopic,
final String outputTopic,
final int numPartitions,
final int numInstances,
final int instanceIdx,
final CountDownLatch latch) {
this.mode = mode;
this.inputTopic = inputTopic;
this.outputTopic = outputTopic;
this.consumerGroupId = "Eos-consumer";
this.numPartitions = numPartitions;
this.numInstances = numInstances;
this.instanceIdx = instanceIdx;
this.transactionalId = "Processor-" + instanceIdx;
// A unique transactional.id must be provided in order to properly use EOS.
producer = new Producer(outputTopic, true, transactionalId, true, -1, null).get();
// Consumer must be in read_committed mode, which means it won't be able to read uncommitted data.
consumer = new Consumer(inputTopic, consumerGroupId, READ_COMMITTED, -1, null).get();
this.latch = latch;
}

@Override
public void run() {
// Init transactions call should always happen first in order to clear zombie transactions from previous generation.
producer.initTransactions();

final AtomicLong messageRemaining = new AtomicLong(Long.MAX_VALUE);

// Under group mode, topic based subscription is sufficient as EOS apps are safe to cooperate transactionally after 2.5.
// Under standalone mode, user needs to manually assign the topic partitions and make sure the assignment is unique
// across the consumer group instances.
if (this.mode.equals("groupMode")) {
consumer.subscribe(Collections.singleton(inputTopic), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
printWithTxnId("Revoked partition assignment to kick-off rebalancing: " + partitions);
}

@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
printWithTxnId("Received partition assignment after rebalancing: " + partitions);
messageRemaining.set(messagesRemaining(consumer));
}
});
} else {
// Do a range assignment of topic partitions.
List<TopicPartition> topicPartitions = new ArrayList<>();
int rangeSize = numPartitions / numInstances;
int startPartition = rangeSize * instanceIdx;
int endPartition = Math.min(numPartitions - 1, startPartition + rangeSize - 1);
for (int partition = startPartition; partition <= endPartition; partition++) {
topicPartitions.add(new TopicPartition(inputTopic, partition));
}

consumer.assign(topicPartitions);
printWithTxnId("Manually assign partitions: " + topicPartitions);
}

int messageProcessed = 0;
boolean abortPreviousTransaction = false;
while (messageRemaining.get() > 0) {
ConsumerRecords<Integer, String> records = consumer.poll(Duration.ofMillis(200));
if (records.count() > 0) {
try {
// Abort previous transaction if instructed.
if (abortPreviousTransaction) {
producer.abortTransaction();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit unconventional to have abort logic at the start of the loop. I think what users would expect is something like this:

try {
  producer.beginTransaction()
  producer.send(...)
  producer.sendOffsetsToTransaction(...)
  producer.commitTransaction()
} catch (Exception ) {
  producer.abortTransaction()
}

// The consumer fetch position also needs to be reset.
resetToLastCommittedPositions(consumer);
abortPreviousTransaction = false;
}
// Begin a new transaction session.
producer.beginTransaction();
for (ConsumerRecord<Integer, String> record : records) {
// Process the record and send to downstream.
ProducerRecord<Integer, String> customizedRecord = transform(record);
producer.send(customizedRecord);
}
Map<TopicPartition, OffsetAndMetadata> positions = new HashMap<>();
for (TopicPartition topicPartition : consumer.assignment()) {
positions.put(topicPartition, new OffsetAndMetadata(consumer.position(topicPartition), null));
}
// Checkpoint the progress by sending offsets to group coordinator broker.
// Under group mode, we must apply consumer group metadata for proper fencing.
if (this.mode.equals("groupMode")) {
producer.sendOffsetsToTransaction(positions, consumer.groupMetadata());
} else {
producer.sendOffsetsToTransaction(positions, consumerGroupId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to refresh my memory: are we going to eventually deprecate this API, or are we going to keep both, and let users apply this one with manual assignment (like you did here)? I thought we are going to deprecate, but maybe I remembered it wrong.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we are intended to keep both

}

// Finish the transaction. All sent records should be visible for consumption now.
producer.commitTransaction();
messageProcessed += records.count();
} catch (CommitFailedException e) {
// In case of a retriable exception, suggest aborting the ongoing transaction for correctness.
abortPreviousTransaction = true;
} catch (ProducerFencedException | FencedInstanceIdException e) {
throw new KafkaException("Encountered fatal error during processing: " + e.getMessage());
}
}
messageRemaining.set(messagesRemaining(consumer));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we need an atomic long here? Seems there's no concurrency.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tricky thing here is that if we define a primitive long outside of the rebalance callback, it won't compile.

printWithTxnId("Message remaining: " + messageRemaining);
}

printWithTxnId("Finished processing " + messageProcessed + " records");
latch.countDown();
}

private void printWithTxnId(final String message) {
System.out.println(transactionalId + ": " + message);
}

private ProducerRecord<Integer, String> transform(final ConsumerRecord<Integer, String> record) {
printWithTxnId("Transformed record (" + record.key() + "," + record.value() + ")");
return new ProducerRecord<>(outputTopic, record.key() / 2, "Transformed_" + record.value());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why divide the key by two?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just a way of showing the key gets processed by message copier, all the produced message key are even keys.

}

private long messagesRemaining(final KafkaConsumer<Integer, String> consumer) {
final Map<TopicPartition, Long> fullEndOffsets = consumer.endOffsets(new ArrayList<>(consumer.assignment()));
// If we couldn't detect any end offset, that means we are still not able to fetch offsets.
if (fullEndOffsets.isEmpty()) {
return Long.MAX_VALUE;
}

return consumer.assignment().stream().mapToLong(partition -> {
long currentPosition = consumer.position(partition);
printWithTxnId("Processing partition " + partition + " with full offsets " + fullEndOffsets);
if (fullEndOffsets.containsKey(partition)) {
return fullEndOffsets.get(partition) - currentPosition;
}
return 0;
}).sum();
}

private static void resetToLastCommittedPositions(KafkaConsumer<Integer, String> consumer) {
final Map<TopicPartition, OffsetAndMetadata> committed = consumer.committed(consumer.assignment());
consumer.assignment().forEach(tp -> {
OffsetAndMetadata offsetAndMetadata = committed.get(tp);
if (offsetAndMetadata != null)
consumer.seek(tp, offsetAndMetadata.offset());
else
consumer.seekToBeginning(Collections.singleton(tp));
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@
*/
package kafka.examples;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class KafkaConsumerProducerDemo {
public static void main(String[] args) {
public static void main(String[] args) throws InterruptedException {
boolean isAsync = args.length == 0 || !args[0].trim().equalsIgnoreCase("sync");
Producer producerThread = new Producer(KafkaProperties.TOPIC, isAsync);
CountDownLatch latch = new CountDownLatch(2);
Producer producerThread = new Producer(KafkaProperties.TOPIC, isAsync, null, false, 10000, latch);
producerThread.start();

Consumer consumerThread = new Consumer(KafkaProperties.TOPIC);
Consumer consumerThread = new Consumer(KafkaProperties.TOPIC, "DemoConsumer", false, 10000, latch);
consumerThread.start();

latch.await(5, TimeUnit.MINUTES);
consumerThread.shutdown();
System.out.println("All finished!");
}
}
Loading