diff --git a/Dockerfile b/Dockerfile
index b191f28..ef96adb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,9 +9,10 @@ RUN apk add --no-cache bash
# Add forklift server
WORKDIR /tmp
-ADD server/target/universal/forklift-server-0.32.zip forklift.zip
+ADD server/target/universal/forklift-server-0.33.zip forklift.zip
RUN yes | unzip -d /usr/local forklift.zip
-RUN ln -s /usr/local/forklift-server-0.32 /usr/local/forklift
+RUN ln -s /usr/local/forklift-server-0.33 /usr/local/forklift
+
RUN rm forklift.zip
RUN mkdir -p /usr/local/forklift/consumers
diff --git a/README.adoc b/README.adoc
index 8de4ccd..2726256 100644
--- a/README.adoc
+++ b/README.adoc
@@ -8,6 +8,9 @@ full documentation.
link:doc/forklift.adoc[Documentation]
= Releases
+* 0.33
+** Added support for embedding Forklift Server and runtime deployments
+
* 0.32
** Improve performance of replay indexing.
diff --git a/connectors/kafka/.gitignore b/connectors/kafka/.gitignore
new file mode 100644
index 0000000..ae3c172
--- /dev/null
+++ b/connectors/kafka/.gitignore
@@ -0,0 +1 @@
+/bin/
diff --git a/connectors/kafka/README.md b/connectors/kafka/README.md
new file mode 100644
index 0000000..f2be862
--- /dev/null
+++ b/connectors/kafka/README.md
@@ -0,0 +1,21 @@
+# README #
+
+## Kafka Connector ##
+The kafka connector allows the forklift to utilize kafka and confluent's schema registry.
+
+## JMS ##
+Forklift was developed against the JMS spec. Kafka is not a JMS implementation so some adaptation
+has occurred in order to allow Kafka to function with Forklift.
+
+### Queues and Topics ###
+Kafka only provides one form of topic which is analogous to ActiveMQ's Virtual Topic. Only one member from
+each subscribed group will receive a message. Requests for a queue or topic will return the
+same producer type.
+
+### At Most Once Delivery ###
+The kafka connector does not guarantee at most once delivery although it makes a best effort.
+In order to adapt to the JMS spec, messages are acknowledged and added to a pending commit batch,
+which are committed to kafka every poll cycle. It is therefore possible for the server to crash
+before the acknowledgment batch has been committed, and after the message has processed.
+
+
diff --git a/connectors/kafka/build.sbt b/connectors/kafka/build.sbt
new file mode 100644
index 0000000..bbbb5df
--- /dev/null
+++ b/connectors/kafka/build.sbt
@@ -0,0 +1,82 @@
+organization := "com.github.dcshock"
+
+name := "forklift-kafka"
+
+version := "0.1"
+
+// target and Xlint cause sbt dist to fail
+javacOptions ++= Seq("-source", "1.8")//, "-target", "1.8", "-Xlint")
+
+javacOptions in compile ++= Seq("-g:lines,vars,source")
+
+initialize := {
+ val _ = initialize.value
+ if (sys.props("java.specification.version") != "1.8")
+ sys.error("Java 8 is required for this project.")
+}
+
+libraryDependencies ++= Seq(
+ "com.github.dcshock" % "forklift" % "0.19" ,
+ "com.fasterxml.jackson.core" % "jackson-databind" % "2.7.3",
+ "com.fasterxml.jackson.datatype" % "jackson-datatype-jsr310" % "2.7.3",
+ "org.apache.kafka" % "kafka-clients" % "0.10.1.1-cp1",
+ "io.confluent" % "kafka-avro-serializer" % "3.1.1"
+
+)
+
+lazy val testDependencies = Seq(
+ "commons-io" % "commons-io" % "2.4" ,
+ "com.novocode" % "junit-interface" % "0.11",
+ "org.mockito" % "mockito-core" % "1.9.5"
+)
+
+libraryDependencies ++= testDependencies.map(_ % "test")
+
+resolvers ++= Seq(
+ "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots",
+ "Maven Central" at "http://repo1.maven.org/maven2",
+ "Fuse Snapshots" at "http://repo.fusesource.com/nexus/content/repositories/snapshots",
+ "Fuse" at "http://repo.fusesource.com/nexus/content/groups/public",
+ "Confluent Maven Repo" at "http://packages.confluent.io/maven/"
+)
+
+// Remove scala dependency for pure Java libraries
+autoScalaLibrary := false
+
+// Remove the scala version from the generated/published artifact
+crossPaths := false
+
+publishMavenStyle := true
+
+publishTo := {
+ val nexus = "https://oss.sonatype.org/"
+ if (isSnapshot.value)
+ Some("snapshots" at nexus + "content/repositories/snapshots")
+ else
+ Some("releases" at nexus + "service/local/staging/deploy/maven2")
+}
+
+pomIncludeRepository := { _ => false }
+
+pomExtra := (
+ https://github.com/dcshock/forklift
+
+
+ BSD-style
+ http://www.opensource.org/licenses/bsd-license.php
+ repo
+
+
+
+ git@github.com:dcshock/forklift.git
+ scm:git:git@github.com:dcshock/forklift.git
+
+
+
+ dcshock
+ Matt Conroy
+ http://www.mattconroy.com
+
+ )
+
+useGpg := true
diff --git a/connectors/kafka/project/Dependencies.scala b/connectors/kafka/project/Dependencies.scala
new file mode 100644
index 0000000..c11e0a2
--- /dev/null
+++ b/connectors/kafka/project/Dependencies.scala
@@ -0,0 +1,5 @@
+import sbt._
+
+object Dependencies {
+ lazy val scalaTest = "org.scalatest" %% "scalatest" % "3.0.1"
+}
diff --git a/connectors/kafka/project/build.properties b/connectors/kafka/project/build.properties
new file mode 100644
index 0000000..27e88aa
--- /dev/null
+++ b/connectors/kafka/project/build.properties
@@ -0,0 +1 @@
+sbt.version=0.13.13
diff --git a/connectors/kafka/project/plugins.sbt b/connectors/kafka/project/plugins.sbt
new file mode 100644
index 0000000..4ce4d9e
--- /dev/null
+++ b/connectors/kafka/project/plugins.sbt
@@ -0,0 +1 @@
+addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0")
diff --git a/connectors/kafka/src/main/java/forklift/connectors/AcknowledgedRecordHandler.java b/connectors/kafka/src/main/java/forklift/connectors/AcknowledgedRecordHandler.java
new file mode 100644
index 0000000..5795380
--- /dev/null
+++ b/connectors/kafka/src/main/java/forklift/connectors/AcknowledgedRecordHandler.java
@@ -0,0 +1,146 @@
+package forklift.connectors;
+
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.common.TopicPartition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Maintains a batch of acknowledged records and provides management for adding and removing {@link org.apache.kafka.common.TopicPartition partitions}.
+ * Acknowledged records are those that have started processing but have not yet been committed to the Kafka Broker. This class is threadsafe.
+ */
+public class AcknowledgedRecordHandler {
+ private static final Logger log = LoggerFactory.getLogger(AcknowledgedRecordHandler.class);
+ private Map pendingOffsets = new ConcurrentHashMap<>();
+ private Object pausedLock = new Object();
+ private Object unpausedLock = new Object();
+ private AtomicInteger acknowledgeEntryCount = new AtomicInteger(0);
+ private volatile boolean acknowledgementsPaused = false;
+ private Set assignment = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Acknowledges that a record has been received before processing begins. True is returned if processing should occur else false.
+ * Only records belonging to {@link #addPartitions(java.util.Collection) added partitions} may be processed. Note that this is a
+ * blocking method and a short delay may occur should the available topic paritions be changing.
+ *
+ * @param record
+ * @return true if the record has been achnowledged and may be processed, else false
+ * @throws InterruptedException
+ */
+ public boolean acknowledgeRecord(ConsumerRecord, ?> record) throws InterruptedException {
+ boolean acknowledged = false;
+ synchronized (unpausedLock) {
+ while (acknowledgementsPaused) {
+ unpausedLock.wait();
+ }
+ acknowledgeEntryCount.incrementAndGet();
+ }
+ if (!assignment.contains(new TopicPartition(record.topic(), record.partition()))) {
+ acknowledged = false;
+ } else {
+ long offset = record.offset() + 1;
+ TopicPartition topicPartition = new TopicPartition(record.topic(), record.partition());
+ synchronized(this) {
+ if (!pendingOffsets.containsKey(topicPartition) || pendingOffsets.get(topicPartition).offset() < offset) {
+ OffsetAndMetadata offsetAndMetadata = new OffsetAndMetadata(offset, "Commit From Forklift Server");
+ pendingOffsets.put(topicPartition, offsetAndMetadata);
+ }
+ }
+ acknowledged = true;
+ }
+
+ synchronized (pausedLock) {
+ int count = acknowledgeEntryCount.decrementAndGet();
+ if (acknowledgementsPaused && count == 0) {
+ pausedLock.notifyAll();
+ }
+ }
+ return acknowledged;
+ }
+
+ /**
+ * Removes and returns the highest offsets of any acknowledged records.
+ * This is a blocking method as a short delay may occur while any threads which are currently acknowledging records are allowed to
+ * complete and any incoming threads are paused.
+ *
+ * @return a Map of the highest offset data for any acknowledged records
+ * @throws InterruptedException if interrupted
+ */
+ public Map flushAcknowledged() throws InterruptedException {
+ try {
+ this.pauseAcknowledgments();
+ Map flushed = pendingOffsets;
+ pendingOffsets = new ConcurrentHashMap<>();
+ return flushed;
+ } catch (InterruptedException interrupt) {
+ Thread.currentThread().interrupt();
+ throw interrupt;
+ } catch (Throwable e) {
+ log.error("Error flushing Acknowledged", e);
+ throw e;
+ } finally {
+ this.unpauseAcknowledgements();
+ }
+ }
+
+ /**
+ * Adds additional partitions to be managed. Only added partitions can be
+ * {@link #acknowledgeRecord(org.apache.kafka.clients.consumer.ConsumerRecord) acknowledged}
+ *
+ * @param addedPartitions the partitions to add
+ */
+ public void addPartitions(Collection addedPartitions) {
+ this.assignment.addAll(addedPartitions);
+ }
+
+ /**
+ * Remove partitions from management. Any existing offsets for the removed partitions are returned. Note that the offest is the highest
+ * acknowleged message's offset + 1 per kafka's specification of how to commit offsets. Note that this
+ * is a blocking method as any threads which are currently acknowledging records are allowed to complete and any
+ * incoming threads are paused.
+ *
+ * @param removedPartitions the partitions to remove
+ * @return the highest offsets of the removed partitions
+ * @throws InterruptedException
+ */
+ public Map removePartitions(Collection removedPartitions)
+ throws InterruptedException {
+ pauseAcknowledgments();
+ try {
+ Map removedOffsets = new HashMap<>();
+ for (TopicPartition topicPartition : removedPartitions) {
+ if (pendingOffsets.containsKey(topicPartition)) {
+ removedOffsets.put(topicPartition, pendingOffsets.remove(topicPartition));
+ }
+ }
+ assignment.removeAll(removedPartitions);
+ return removedOffsets;
+ } finally {
+ unpauseAcknowledgements();
+ }
+ }
+
+ private void pauseAcknowledgments() throws InterruptedException {
+ synchronized (pausedLock) {
+ acknowledgementsPaused = true;
+ while (acknowledgeEntryCount.get() != 0) {
+ pausedLock.wait();
+ }
+ }
+ }
+
+ private void unpauseAcknowledgements() {
+ synchronized (unpausedLock){
+ acknowledgementsPaused = false;
+ unpausedLock.notifyAll();
+ }
+ }
+
+}
diff --git a/connectors/kafka/src/main/java/forklift/connectors/KafkaConnector.java b/connectors/kafka/src/main/java/forklift/connectors/KafkaConnector.java
new file mode 100644
index 0000000..f493979
--- /dev/null
+++ b/connectors/kafka/src/main/java/forklift/connectors/KafkaConnector.java
@@ -0,0 +1,167 @@
+package forklift.connectors;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import forklift.consumer.ForkliftConsumerI;
+import forklift.consumer.KafkaTopicConsumer;
+import forklift.message.KafkaMessage;
+import forklift.producers.ForkliftProducerI;
+import forklift.producers.KafkaForkliftProducer;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import javax.jms.Connection;
+import javax.jms.Message;
+
+/**
+ * Manages a {@link org.apache.kafka.clients.consumer.KafkaConsumer}. A subscription is made whenever a call to getQueue or get Topic
+ * is received. Subscriptions are removed if no consumption is seen for
+ */
+public class KafkaConnector implements ForkliftConnectorI {
+ private static final Logger log = LoggerFactory.getLogger(KafkaConnector.class);
+ private final String kafkaHosts;
+ private final String schemaRegistries;
+ private final String groupId;
+ private KafkaConsumer, ?> kafkaConsumer;
+ private KafkaProducer, ?> kafkaProducer;
+ private MessageStream messageStream = new MessageStream();
+
+ static ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ KafkaController controller;
+
+ public KafkaConnector(String kafkaHosts, String schemaRegistries, String groupId) {
+ this.kafkaHosts = kafkaHosts;
+ this.schemaRegistries = schemaRegistries;
+ this.groupId = groupId;
+ }
+
+ @Override
+ public void start() throws ConnectorException {
+
+ Properties producerProperties = new Properties();
+ producerProperties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaHosts);
+ producerProperties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
+ io.confluent.kafka.serializers.KafkaAvroSerializer.class);
+ producerProperties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
+ io.confluent.kafka.serializers.KafkaAvroSerializer.class);
+ //schema.registry.url is a comma separated list of urls
+ producerProperties.put("schema.registry.url", schemaRegistries);
+ this.kafkaProducer = new KafkaProducer(producerProperties);
+ this.controller = createController();
+ this.controller.start();
+ }
+
+ private KafkaController createController() {
+ Properties props = new Properties();
+ props.put("bootstrap.servers", kafkaHosts);
+ props.put("group.id", groupId);
+ props.put("enable.auto.commit", false);
+ props.put("consumer.timeout.ms", "-1");
+ props.put("key.deserializer", io.confluent.kafka.serializers.KafkaAvroDeserializer.class);
+ props.put("value.deserializer", io.confluent.kafka.serializers.KafkaAvroDeserializer.class);
+ props.put("schema.registry.url", schemaRegistries);
+ props.put("specific.avro.reader", false);
+ //props.put("auto.offset.reset", "earliest");
+ this.kafkaConsumer = new KafkaConsumer(props);
+ this.controller = new KafkaController(kafkaConsumer, messageStream);
+ return controller;
+ }
+
+ @Override
+ public void stop() throws ConnectorException {
+ try {
+ this.controller.stop(2000, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ log.error("KafkaConnector interrupted while stopping");
+ }
+ this.kafkaProducer.close();
+ }
+
+ @Override
+ public Connection getConnection() throws ConnectorException {
+ throw new UnsupportedOperationException("Kafka does not support Connections, please work with Queues and Topics");
+ }
+
+ @Override
+ public ForkliftConsumerI getQueue(String name) throws ConnectorException {
+ return this.getTopic(name);
+ }
+
+ @Override
+ public ForkliftConsumerI getTopic(String name) throws ConnectorException {
+ synchronized (this) {
+ if (this.controller == null || !this.controller.isRunning()) {
+ this.controller = createController();
+ this.controller.start();
+ }
+ }
+ return new KafkaTopicConsumer(name, controller, messageStream);
+ }
+
+ @Override
+ public ForkliftProducerI getQueueProducer(String name) {
+ return this.getTopicProducer(name);
+ }
+
+ @Override
+ public ForkliftProducerI getTopicProducer(String name) {
+ return new KafkaForkliftProducer(name, this.kafkaProducer);
+ }
+
+ @Override
+ public ForkliftMessage jmsToForklift(Message m) {
+ try {
+ final ForkliftMessage msg = new ForkliftMessage(m);
+ if (m instanceof KafkaMessage) {
+ KafkaMessage kafkaMessage = (KafkaMessage)m;
+ msg.setProperties(kafkaMessage.getProperties());
+ ConsumerRecord, ?> record = kafkaMessage.getConsumerRecord();
+ if (record.value() instanceof GenericRecord) {
+ GenericRecord genericRecord = (GenericRecord)record.value();
+ Object value = genericRecord.get("forkliftMapMsg");
+ value = value == null ? genericRecord.get("forkliftMsg") : value;
+ value = value == null ? genericRecord.get("forkliftJsonMsg") : value;
+ if (value == null) {
+ String jsonValue = genericRecord.toString();
+ value = jsonValue != null && jsonValue.startsWith("{") ? jsonValue : value;
+ }
+ if (value == null) {
+ msg.setFlagged(true);
+ msg.setWarning("Unable to parse message for topic: " + record.topic() + " with value: " + record.value());
+ } else {
+ msg.setMsg(value.toString());
+ }
+ } else {
+ ObjectMapper mapper = new ObjectMapper();
+ try {
+ //inefficient as we map from object to json, then back to the object. This approach works
+ //without any changes to the forklift core libraries however.
+ msg.setMsg(mapper.writeValueAsString(record.value()));
+ } catch (JsonProcessingException e) {
+ msg.setFlagged(true);
+ msg.setWarning("Unable to parse object to json for topic: " +
+ record.topic() +
+ " with value: " +
+ record.value());
+ }
+ }
+ } else {
+ msg.setFlagged(true);
+ msg.setWarning("Unexpected message type: " + m.getClass().getName());
+ }
+
+ return msg;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/connectors/kafka/src/main/java/forklift/connectors/KafkaController.java b/connectors/kafka/src/main/java/forklift/connectors/KafkaController.java
new file mode 100644
index 0000000..9a80998
--- /dev/null
+++ b/connectors/kafka/src/main/java/forklift/connectors/KafkaController.java
@@ -0,0 +1,225 @@
+package forklift.connectors;
+
+import forklift.message.KafkaMessage;
+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.common.TopicPartition;
+import org.apache.kafka.common.errors.WakeupException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import javax.jms.JMSException;
+
+/**
+ * Manages the {@link org.apache.kafka.clients.consumer.KafkaConsumer} thread. Polled records are sent to the MessageStream. Commits
+ * are batched and performed on any {@link #acknowledge(org.apache.kafka.clients.consumer.ConsumerRecord) acknowledged records}. Best
+ * performance is usually achieved using one controller instance for all topic subscriptions.
+ *
+ * WARNING: Kafka does not lend itself well to message level commits. For this reason, the controller sends commits
+ * as a batch once every poll cycle. It should be noted that it is possible for a message to be
+ * processed twice should an error occur after the acknowledgement and processing of a message but before the commit takes place.
+ */
+public class KafkaController {
+
+ private static final Logger log = LoggerFactory.getLogger(KafkaController.class);
+ private volatile boolean running = false;
+ private final Set topics = ConcurrentHashMap.newKeySet();
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ private final KafkaConsumer, ?> kafkaConsumer;
+ private final MessageStream messageStream;
+ private volatile boolean topicsChanged = false;
+ private Object topicsMonitor = new Object();
+ private AcknowledgedRecordHandler acknowlegmentHandler = new AcknowledgedRecordHandler();
+
+ public KafkaController(KafkaConsumer, ?> kafkaConsumer, MessageStream messageStream) {
+ this.kafkaConsumer = kafkaConsumer;
+ this.messageStream = messageStream;
+ }
+
+ /**
+ * Adds a topic which the underlying {@link org.apache.kafka.clients.consumer.KafkaConsumer} will be subscribed to. Adds
+ * the topic to the messageStream.
+ *
+ * @param topic the topic to subscribe to
+ * @return true if the topic was added, false if already added
+ */
+ public boolean addTopic(String topic) {
+ if (!topics.contains(topic)) {
+ messageStream.addTopic(topic);
+ topics.add(topic);
+ topicsChanged = true;
+ synchronized (topicsMonitor) {
+ topicsMonitor.notify();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Unsubscribes the underlying {@link org.apache.kafka.clients.consumer.KafkaConsumer} from the passed in topic and removes the
+ * topic from the message stream.
+ *
+ * @param topic the topic to remove
+ * @return true if the topic was removed, false if it wasn't present
+ */
+ public boolean removeTopic(String topic) {
+ boolean removed = topics.remove(topic);
+ if (removed) {
+ messageStream.removeTopic(topic);
+ topicsChanged = true;
+ }
+ return removed;
+ }
+
+ /**
+ * Whether or not this service is running.
+ *
+ * @return true if this service is running, else false
+ */
+ public boolean isRunning() {
+ return running;
+ }
+
+ /**
+ * Acknowledge that processing is beginning on a record. True is returned if this controller is still managing the
+ * partition the record originated from. If the partition is no longer owned by this controller, false is returned
+ * and the message should not be processed.
+ *
+ * @param record the record to acknowledge
+ * @return true if the record should be processed, else false
+ * @throws InterruptedException
+ * @throws JMSException
+ */
+ public boolean acknowledge(ConsumerRecord, ?> record) throws InterruptedException, JMSException {
+ log.debug("Acknowledge message with topic {} partition {} offset {}", record.topic(), record.partition(), record.offset());
+ return running && this.acknowlegmentHandler.acknowledgeRecord(record);
+ }
+
+ /**
+ * Starts the controller. Must be called before any other methods.
+ *
+ * WARNING: Avoid starting a service which has already run and been stopped. No attempt is made
+ * to recover a stopped controller.
+ */
+ public void start() {
+ running = true;
+ executor.submit(() -> controlLoop());
+ }
+
+ /**
+ * Stops the controller.
+ *
+ * @param timeout the time to wait for the service to stop
+ * @param timeUnit the TimeUnit of timeout
+ * @throws InterruptedException
+ */
+ public void stop(long timeout, TimeUnit timeUnit) throws InterruptedException {
+ running = false;
+ kafkaConsumer.wakeup();
+ executor.shutdownNow();
+ executor.awaitTermination(timeout, timeUnit);
+ }
+
+ private void controlLoop() {
+ try {
+ while (running) {
+ boolean updatedAssignment = false;
+ if (topics.size() == 0) {
+ //check if the last remaining topic was removed
+ if (kafkaConsumer.assignment().size() > 0) {
+ kafkaConsumer.unsubscribe();
+ }
+ synchronized (topicsMonitor) {
+ //recheck wait condition inside synchronized block
+ if (topics.size() == 0) {
+ //pause the polling thread until a topic comes in
+ topicsMonitor.wait();
+ }
+ }
+ }
+ if (topicsChanged) {
+ topicsChanged = false;
+ kafkaConsumer.subscribe(topics, new RebalanceListener());
+ updatedAssignment = true;
+ }
+ ConsumerRecords, ?> records = kafkaConsumer.poll(100);
+ if (updatedAssignment) {
+ this.acknowlegmentHandler.addPartitions(kafkaConsumer.assignment());
+ }
+ if (records.count() > 0) {
+ log.debug("Adding: " + records.count() + " to record stream");
+ messageStream.addRecords(consumerRecordsToKafkaMessages(records));
+ }
+ Map offsetData = this.acknowlegmentHandler.flushAcknowledged();
+ if (offsetData.size() > 0) {
+ String offsetDescription =
+ offsetData.entrySet()
+ .stream()
+ .map(entry -> "topic: " + entry.getKey().topic() + ", " +
+ "partition: " + entry.getKey().partition() + ", " +
+ "offset: " + entry.getValue().offset())
+ .collect(Collectors.joining("|"));
+ log.debug("Commiting offsets {}", offsetDescription);
+ kafkaConsumer.commitSync(offsetData);
+ }
+ }
+ } catch (WakeupException | InterruptedException e) {
+ log.info("Control Loop exiting");
+ } catch (Throwable t) {
+ log.error("Control Loop error, exiting", t);
+ throw t;
+ } finally {
+ running = false;
+ //the kafkaConsumer must be closed in the poll thread
+ log.info("KafkaConsumer closing");
+ kafkaConsumer.close();
+ }
+ }
+
+ private Map> consumerRecordsToKafkaMessages(ConsumerRecords, ?> records) {
+ Map> messages = new HashMap<>();
+ records.partitions().forEach(tp -> messages.put(tp, new ArrayList<>()));
+ for (ConsumerRecord, ?> record : records) {
+ TopicPartition partition = new TopicPartition(record.topic(), record.partition());
+ messages.get(partition).add(new KafkaMessage(this, record));
+ }
+ return messages;
+ }
+
+ private class RebalanceListener implements ConsumerRebalanceListener {
+ @Override public void onPartitionsRevoked(Collection partitions) {
+ log.debug("Partitions revoked");
+ try {
+ Map
+ removedOffsetData =
+ acknowlegmentHandler.removePartitions(partitions);
+
+ kafkaConsumer.commitSync(removedOffsetData);
+ } catch (InterruptedException e) {
+ log.info("Partition rebalance interrupted", e);
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ public void onPartitionsAssigned(Collection partitions) {
+ log.debug("partitions assigned");
+ acknowlegmentHandler.addPartitions(partitions);
+ }
+
+ }
+}
diff --git a/connectors/kafka/src/main/java/forklift/connectors/MessageStream.java b/connectors/kafka/src/main/java/forklift/connectors/MessageStream.java
new file mode 100644
index 0000000..18279d4
--- /dev/null
+++ b/connectors/kafka/src/main/java/forklift/connectors/MessageStream.java
@@ -0,0 +1,76 @@
+package forklift.connectors;
+
+import forklift.message.KafkaMessage;
+import org.apache.kafka.common.TopicPartition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A stream of available messages which can be retrieved by topic.
+ */
+public class MessageStream {
+ private static final Logger log = LoggerFactory.getLogger(MessageStream.class);
+ Map> topicQueue = new ConcurrentHashMap<>();
+
+ /**
+ * Adds the passed in records to the stream. After being added, a record is available to be retreived through a
+ * call to {@link #nextRecord(String, long)}.
+ *
+ * Note: All passed in records must belong to a topic added through a call to {@link #addTopic(String)}.
+ *
+ * @throws IllegalStateException if the capacity of a stream has been exceeded.
+ * @param records the records to add
+ */
+ public void addRecords(Map> records) {
+ log.debug("Adding records to stream");
+ for (TopicPartition partition : records.keySet()) {
+ for (KafkaMessage message : records.get(partition)) {
+ topicQueue.get(partition.topic()).add(message);
+ }
+ }
+ }
+
+ /**
+ * Configures this stream to receive messages for a given topic.
+ *
+ * @param topic the topic to add
+ */
+ public void addTopic(String topic) {
+ //The capacity of the blocking queue is never expected to be hit as the Consumer should close the topic if it crashes.
+ //This is mainly in place as a safeguard against a memory leak and blocking the consumption of messages
+ this.topicQueue.put(topic, new LinkedBlockingQueue<>(100000));
+ }
+
+ /**
+ * Removes a topic from the stream. Any queued up messages belonging to the removed topic are discarded.
+ *
+ * @param topic the topic to remove
+ */
+ public void removeTopic(String topic) {
+ this.topicQueue.remove(topic);
+ }
+
+ /**
+ * Blocking method to retrieve the next available record for a topic. Messages are retreived
+ * in FIFO order within their topic.
+ *
+ * @param topic the topic of the {@link javax.jms.Message}
+ * @param timeout how long to wait for a message in milliseconds
+ * @return a message if one is available, else null
+ * @throws InterruptedException
+ */
+ public KafkaMessage nextRecord(String topic, long timeout) throws InterruptedException {
+ KafkaMessage message = null;
+ BlockingQueue queue = topicQueue.get(topic);
+ if (queue != null) {
+ message = queue.poll(timeout, TimeUnit.MILLISECONDS);
+ }
+ return message;
+ }
+}
diff --git a/connectors/kafka/src/main/java/forklift/consumer/KafkaTopicConsumer.java b/connectors/kafka/src/main/java/forklift/consumer/KafkaTopicConsumer.java
new file mode 100644
index 0000000..2a32a5e
--- /dev/null
+++ b/connectors/kafka/src/main/java/forklift/consumer/KafkaTopicConsumer.java
@@ -0,0 +1,70 @@
+package forklift.consumer;
+
+import forklift.connectors.KafkaController;
+import forklift.connectors.MessageStream;
+import javax.jms.JMSException;
+import javax.jms.Message;
+
+/**
+ * Retrieves messages from a kafka topic and adds consumer's topic to the {@link forklift.connectors.KafkaController}.
+ */
+public class KafkaTopicConsumer implements ForkliftConsumerI {
+ private final String topic;
+ private final KafkaController controller;
+ private final MessageStream messageStream;
+ private volatile boolean topicAdded = false;
+
+ public KafkaTopicConsumer(String topic, KafkaController controller, MessageStream messageStream) {
+ this.topic = topic;
+ this.controller = controller;
+ this.messageStream = messageStream;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Retrieves the {@link forklift.connectors.MessageStream#nextRecord(String, long) nextRecord} from the messageStream.
+ * If no record is available within the specified timeout, null is returned.
+ *
+ * Note: Because we do not wish to poll for kafka topics until we are actively receiving messages, this method is
+ * also responsible for calling {@link forklift.connectors.KafkaController#addTopic(String) addTopic} on the kafkaController.
+ *
+ * @param timeout the time in milliseconds to wait for a record to become available
+ * @return a message if one is available, else null
+ * @throws JMSException if the process is interrupted or the controller is no longer running
+ */
+ @Override
+ public Message receive(long timeout) throws JMSException {
+ try {
+ if(!topicAdded) {
+ synchronized(this) {
+ if(!topicAdded) {
+ controller.addTopic(topic);
+ topicAdded = true;
+ }
+ }
+
+ }
+ //ensure that our controller is still running
+ if(!controller.isRunning()){
+ throw new JMSException("Connection to Kafka Controller lost");
+ }
+ return messageStream.nextRecord(this.topic, timeout);
+ } catch (InterruptedException e) {
+ throw new JMSException("Kafka message receive interrupted");
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Removes this consumer's topic from the controller. Future calls to {@link #receive(long) receive} will re-add the
+ * topic to the controller.
+ *
+ * @throws JMSException
+ */
+ @Override
+ public void close() throws JMSException {
+ controller.removeTopic(topic);
+ }
+}
diff --git a/connectors/kafka/src/main/java/forklift/message/KafkaMessage.java b/connectors/kafka/src/main/java/forklift/message/KafkaMessage.java
new file mode 100644
index 0000000..38a8aef
--- /dev/null
+++ b/connectors/kafka/src/main/java/forklift/message/KafkaMessage.java
@@ -0,0 +1,241 @@
+package forklift.message;
+
+import forklift.connectors.KafkaController;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Vector;
+import javax.jms.Destination;
+import javax.jms.JMSException;
+import javax.jms.Message;
+
+/**
+ * A Kafka {@link org.apache.kafka.clients.consumer.ConsumerRecord } wrapped in a JMS {@link javax.jms.Message} functionality.
+ * Please note that many JMS specific properties, such as JMSMessageID and JMSTimestamp, are simply ignored. Properties are
+ * held to allow the storage of metadata, but are not transmitted across the KafkaBroker.
+ */
+public class KafkaMessage implements Message {
+ private final KafkaController controller;
+ private final ConsumerRecord, ?> consumerRecord;
+ Map properties = new HashMap<>();
+
+ public KafkaMessage(KafkaController controller, ConsumerRecord, ?> consumerRecord) {
+ this.controller = controller;
+ this.consumerRecord = consumerRecord;
+ }
+
+ public ConsumerRecord, ?> getConsumerRecord() {
+ return this.consumerRecord;
+ }
+
+ @Override public String getJMSMessageID() throws JMSException {
+ return null;
+ }
+
+ @Override public void setJMSMessageID(String id) throws JMSException {
+
+ }
+
+ @Override public long getJMSTimestamp() throws JMSException {
+ return 0;
+ }
+
+ @Override public void setJMSTimestamp(long timestamp) throws JMSException {
+
+ }
+
+ @Override public byte[] getJMSCorrelationIDAsBytes() throws JMSException {
+ return new byte[0];
+ }
+
+ @Override public void setJMSCorrelationIDAsBytes(byte[] correlationID) throws JMSException {
+
+ }
+
+ @Override public void setJMSCorrelationID(String correlationID) throws JMSException {
+
+ }
+
+ @Override public String getJMSCorrelationID() throws JMSException {
+ return null;
+ }
+
+ @Override public Destination getJMSReplyTo() throws JMSException {
+ return null;
+ }
+
+ @Override public void setJMSReplyTo(Destination replyTo) throws JMSException {
+
+ }
+
+ @Override public Destination getJMSDestination() throws JMSException {
+ return null;
+ }
+
+ @Override public void setJMSDestination(Destination destination) throws JMSException {
+
+ }
+
+ @Override public int getJMSDeliveryMode() throws JMSException {
+ return 0;
+ }
+
+ @Override public void setJMSDeliveryMode(int deliveryMode) throws JMSException {
+
+ }
+
+ @Override public boolean getJMSRedelivered() throws JMSException {
+ return false;
+ }
+
+ @Override public void setJMSRedelivered(boolean redelivered) throws JMSException {
+
+ }
+
+ @Override public String getJMSType() throws JMSException {
+ return null;
+ }
+
+ @Override public void setJMSType(String type) throws JMSException {
+
+ }
+
+ @Override public long getJMSExpiration() throws JMSException {
+ return 0;
+ }
+
+ @Override public void setJMSExpiration(long expiration) throws JMSException {
+
+ }
+
+ @Override public int getJMSPriority() throws JMSException {
+ return 0;
+ }
+
+ @Override public void setJMSPriority(int priority) throws JMSException {
+
+ }
+
+ @Override public void clearProperties() throws JMSException {
+ properties.clear();
+ }
+
+ @Override public boolean propertyExists(String name) throws JMSException {
+ return properties.containsKey(name);
+ }
+
+ @Override public boolean getBooleanProperty(String name) throws JMSException {
+ return (boolean)properties.get(name);
+ }
+
+ @Override public byte getByteProperty(String name) throws JMSException {
+ return (byte)properties.get(name);
+ }
+
+ @Override public short getShortProperty(String name) throws JMSException {
+ return (short)properties.get(name);
+ }
+
+ @Override public int getIntProperty(String name) throws JMSException {
+ return (int)properties.get(name);
+ }
+
+ @Override public long getLongProperty(String name) throws JMSException {
+ return (long)properties.get(name);
+ }
+
+ @Override public float getFloatProperty(String name) throws JMSException {
+ return (float)properties.get(name);
+ }
+
+ @Override public double getDoubleProperty(String name) throws JMSException {
+ return (double)properties.get(name);
+ }
+
+ @Override public String getStringProperty(String name) throws JMSException {
+ return (String)properties.get(name);
+ }
+
+ @Override public Object getObjectProperty(String name) throws JMSException {
+ return properties.get(name);
+ }
+
+ @Override public Enumeration getPropertyNames() throws JMSException {
+ return new Vector(properties.keySet()).elements();
+ }
+
+ @Override public void setBooleanProperty(String name, boolean value) throws JMSException {
+ properties.put(name, value);
+ }
+
+ @Override public void setByteProperty(String name, byte value) throws JMSException {
+ properties.put(name, value);
+ }
+
+ @Override public void setShortProperty(String name, short value) throws JMSException {
+ properties.put(name, value);
+ }
+
+ @Override public void setIntProperty(String name, int value) throws JMSException {
+ properties.put(name, value);
+
+ }
+
+ @Override public void setLongProperty(String name, long value) throws JMSException {
+ properties.put(name, value);
+
+ }
+
+ @Override public void setFloatProperty(String name, float value) throws JMSException {
+ properties.put(name, value);
+
+ }
+
+ @Override public void setDoubleProperty(String name, double value) throws JMSException {
+ properties.put(name, value);
+
+ }
+
+ @Override public void setStringProperty(String name, String value) throws JMSException {
+ properties.put(name, value);
+
+ }
+
+ @Override public void setObjectProperty(String name, Object value) throws JMSException {
+ properties.put(name, value);
+
+ }
+
+ public Map getProperties() {
+ return properties;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * JMS Messages can typically be acknowledged either synchronously or asynchronously at a message level before processing begins.
+ * This kafka implementation does not allow for that. In order to adapt to the JMS specification, we send the acknowledgment to the
+ * {@link forklift.connectors.KafkaController#acknowledge(org.apache.kafka.clients.consumer.ConsumerRecord)} method, with the
+ * expectation that false will be returned should we no longer hold onto the partition the message came from. In that case, a
+ * {@link javax.jms.JMSException} with an {@link javax.jms.JMSException#getErrorCode() errorCode} of "KAFKA-REBALANCE" will be thrown.
+ * As Kafka can be expected to rebalance somewhat frequently as consumers are added and removed, it is up to the consumer of
+ * this message to appropriate handle the error and prevent processing of this message.
+ *
+ * @throws JMSException
+ */
+ @Override
+ public void acknowledge() throws JMSException {
+ try {
+ if (!controller.acknowledge(consumerRecord)) {
+ throw new JMSException("Unable to acknowledge message, possibly due to kafka partition rebalance", "KAFKA-REBALANCE");
+ }
+ } catch (InterruptedException e) {
+ throw new JMSException("Error acknowledging message");
+ }
+ }
+
+ @Override public void clearBody() throws JMSException {
+
+ }
+}
diff --git a/connectors/kafka/src/main/java/forklift/producers/KafkaForkliftProducer.java b/connectors/kafka/src/main/java/forklift/producers/KafkaForkliftProducer.java
new file mode 100644
index 0000000..b26d2a2
--- /dev/null
+++ b/connectors/kafka/src/main/java/forklift/producers/KafkaForkliftProducer.java
@@ -0,0 +1,141 @@
+package forklift.producers;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import forklift.connectors.ForkliftMessage;
+import forklift.message.Header;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.avro.specific.SpecificRecord;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.errors.SerializationException;
+import java.io.IOException;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Created by afrieze on 2/27/17.
+ */
+public class KafkaForkliftProducer implements ForkliftProducerI {
+
+ private final String topic;
+ private final KafkaProducer, ?> kafkaProducer;
+ static ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ String stringSchema = "{\"type\":\"record\"," +
+ "\"name\":\"ForkliftStringMessage\"," +
+ "\"fields\":[{\"name\":\"forkliftMsg\",\"type\":\"string\"}]}";
+
+ String mapSchema = "{\"type\":\"record\"," +
+ "\"name\":\"ForkliftMapMessage\"," +
+ "\"fields\":[{\"name\":\"forkliftMapMsg\",\"type\":\"string\"}]}";
+
+ String jsonSchema = "{\"type\":\"record\"," +
+ "\"name\":\"ForkliftJsonMessage\"," +
+ "\"fields\":[{\"name\":\"forkliftJsonMsg\",\"type\":\"string\"}]}";
+
+ Schema parsedStringSchema = null;
+ Schema parsedMapSchema = null;
+ Schema parsedJsonSchema = null;
+
+ public KafkaForkliftProducer(String topic, KafkaProducer, ?> kafkaProducer) {
+ this.kafkaProducer = kafkaProducer;
+ this.topic = topic;
+ Schema.Parser parser = new Schema.Parser();
+ this.parsedMapSchema = parser.parse(mapSchema);
+ this.parsedStringSchema = parser.parse(stringSchema);
+ this.parsedJsonSchema = parser.parse(jsonSchema);
+ }
+
+ @Override
+ public String send(String message) throws ProducerException {
+ GenericRecord avroRecord = new GenericData.Record(parsedStringSchema);
+ avroRecord.put("forkliftMsg", message);
+ ProducerRecord record = new ProducerRecord<>(topic, null, avroRecord);
+ try {
+ kafkaProducer.send(record);
+ } catch (SerializationException e) {
+ throw new ProducerException("Error creating Kafka Message", e);
+ }
+ return null;
+ }
+
+ @Override
+ public String send(ForkliftMessage message) throws ProducerException {
+ return this.send(message.getMsg());
+ }
+
+ @Override
+ public String send(Object message) throws ProducerException {
+ ProducerRecord record = null;
+ if(message instanceof SpecificRecord){
+ record = new ProducerRecord(topic, null, message);
+ }
+ else{
+ try {
+ GenericRecord avroRecord = new GenericData.Record(parsedJsonSchema);
+ String json = mapper.writeValueAsString(message);
+ avroRecord.put("forkliftJsonMsg", json);
+ record = new ProducerRecord<>(topic, null, avroRecord);
+ }catch(Exception e){
+ throw new ProducerException("Error creating Kafka Message", e);
+ }
+ }
+ try {
+ kafkaProducer.send(record);
+ } catch (SerializationException e) {
+ throw new ProducerException("Error creating Kafka Message", e);
+ }
+ return null;
+ }
+
+ @Override
+ public String send(Map message) throws ProducerException {
+ GenericRecord avroRecord = new GenericData.Record(parsedMapSchema);
+
+ String formattedMap = message.keySet().stream().map(key -> key + "=" + message.get(key)).collect(Collectors.joining("\n"));
+
+ avroRecord.put("forkliftMapMsg", formattedMap);
+ ProducerRecord record = new ProducerRecord<>(topic, null, avroRecord);
+ try {
+ kafkaProducer.send(record);
+ } catch (SerializationException e) {
+ throw new ProducerException("Error creating Kafka Message", e);
+ }
+ return null;
+ }
+
+ @Override
+ public String send(Map headers, Map properties, ForkliftMessage message)
+ throws ProducerException {
+ throw new UnsupportedOperationException("Kafka Producer does not support headers or properties");
+ }
+
+ @Override
+ public Map getHeaders() throws ProducerException {
+ throw new UnsupportedOperationException("Kafka Producer does not support headers");
+ }
+
+ @Override
+ public void setHeaders(Map headers) throws ProducerException {
+ throw new UnsupportedOperationException("Kafka Producer does not support headers");
+ }
+
+ @Override
+ public Map getProperties() throws ProducerException {
+ throw new UnsupportedOperationException("Kafka Producer does not support propertied");
+ }
+
+ @Override
+ public void setProperties(Map properties) throws ProducerException {
+ throw new UnsupportedOperationException("Kafka Producer does not support properties");
+ }
+
+ @Override
+ public void close() throws IOException {
+ //do nothing, the passed in KafkaProducer may be used elsewhere and should be closed by the KafkaController
+ }
+}
diff --git a/connectors/kafka/src/test/java/forklift/connectors/AcknowledgedRecordHandlerTests.java b/connectors/kafka/src/test/java/forklift/connectors/AcknowledgedRecordHandlerTests.java
new file mode 100644
index 0000000..43ab2c5
--- /dev/null
+++ b/connectors/kafka/src/test/java/forklift/connectors/AcknowledgedRecordHandlerTests.java
@@ -0,0 +1,77 @@
+package forklift.connectors;
+
+import static org.junit.Assert.assertEquals;
+import com.sun.scenario.effect.Offset;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.common.TopicPartition;
+import org.junit.Before;
+import org.junit.Test;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Created by afrieze on 3/3/17.
+ */
+public class AcknowledgedRecordHandlerTests {
+
+ private AcknowledgedRecordHandler handler;
+
+ @Before
+ public void setup() {
+ this.handler = new AcknowledgedRecordHandler();
+ }
+
+ @Test
+ public void acknowledgeRecordFalseTest() throws InterruptedException{
+ ConsumerRecord,?> record = generateRecord("topic1", 0, "value1", 0);
+ boolean acknowledged = this.handler.acknowledgeRecord(record);
+ assertEquals(false, acknowledged);
+ }
+
+ @Test
+ public void acknowledgeRecordTrueTest() throws InterruptedException{
+ int partition = 0;
+ String topic1 = "topic1";
+ ConsumerRecord,?> record = generateRecord(topic1, partition, "value1", 0);
+ List partitions = Arrays.asList(new TopicPartition(topic1, partition));
+ this.handler.addPartitions(partitions);
+ boolean acknowledged = this.handler.acknowledgeRecord(record);
+ assertEquals(true, acknowledged);
+ }
+
+ @Test
+ public void removePartitionsOffsetTest() throws InterruptedException{
+ int partition = 0;
+ long offset = 123;
+ String topic1 = "topic1";
+ ConsumerRecord,?> record = generateRecord(topic1, partition, "value1", offset);
+ TopicPartition topicPartition = new TopicPartition(topic1, partition);
+ List partitions = Arrays.asList(topicPartition);
+ this.handler.addPartitions(partitions);
+ boolean acknowledged = this.handler.acknowledgeRecord(record);
+ Map offsets = this.handler.removePartitions(partitions);
+ assertEquals(true, offsets.containsKey(topicPartition));
+ assertEquals(offset+1, offsets.get(topicPartition).offset());
+ }
+
+
+ @Test
+ public void removePartitionAcknowledgeTest() throws InterruptedException{
+ int partition = 0;
+ long offset = 123;
+ String topic1 = "topic1";
+ ConsumerRecord,?> record = generateRecord(topic1, partition, "value1", offset);
+ TopicPartition topicPartition = new TopicPartition(topic1, partition);
+ List partitions = Arrays.asList(topicPartition);
+ this.handler.addPartitions(partitions);
+ Map offsets = this.handler.removePartitions(partitions);
+ boolean acknowledged = this.handler.acknowledgeRecord(record);
+ assertEquals(false, acknowledged);
+ }
+
+ private ConsumerRecord, ?> generateRecord(String topic, int partition, String value, long offset) {
+ return new ConsumerRecord