Skip to content
Open
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 @@ -42,6 +42,7 @@
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.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;

import javax.annotation.Nonnull;
Expand All @@ -52,6 +53,7 @@
import java.util.Collections;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
Expand Down Expand Up @@ -176,14 +178,21 @@ public boolean start()
final ListenableFuture<?> future = executorService.submit(() -> {
final Consumer<String, String> consumer = getConsumer();
consumer.subscribe(Collections.singletonList(topic));
Map<TopicPartition, Long> startupEndOffsets = null;
try {
while (!executorService.isShutdown()) {
try {
if (executorService.isShutdown()) {
break;
}
final ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
startingReads.countDown();
if (startingReads.getCount() > 0) {
final Set<TopicPartition> assignment = consumer.assignment();
if (!assignment.isEmpty()
&& (startupEndOffsets == null || !startupEndOffsets.keySet().equals(assignment))) {
startupEndOffsets = consumer.endOffsets(assignment);
}
}

for (final ConsumerRecord<String, String> record : records) {
final String key = record.key();
Expand All @@ -204,6 +213,11 @@ public boolean start()
doubleEventCount.incrementAndGet();
LOG.trace("Placed key[%s] val[%s]", key, message);
}
if (startingReads.getCount() > 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Wait for the catch-up worker before disposing its cache

When a finite connectTimeout expires while this new catch-up loop is processing a backlog, start() calls future.cancel(true) and immediately cacheHandler.close(). Cancellation only interrupts the worker; the record loop has no interrupt check, so it can continue put/remove calls after close. With the off-heap cache manager, close deletes the underlying MapDB map, racing those writes against deletion. Coordinate worker termination, or move cache disposal into the worker's completion path, before closing the cache.

&& startupEndOffsets != null
&& hasReachedEndOffsets(consumer, startupEndOffsets)) {
startingReads.countDown();
}
}
catch (Exception e) {
LOG.error(e, "Error reading stream for topic [%s]", topic);
Expand Down Expand Up @@ -251,10 +265,8 @@ public void onFailure(Throwable t)
}
}
catch (InterruptedException | ExecutionException | TimeoutException e) {
executorService.shutdown();
future.cancel(true);
LOG.error(e, "Failed to start kafka extraction factory");
cacheHandler.close();
shutdownExecutorAndCloseCache();
return false;
}

Expand All @@ -272,13 +284,7 @@ public boolean close()
return !started.get();
}
started.set(false);
executorService.shutdown();

final ListenableFuture<?> future = this.future;
if (future != null) {
future.cancel(true);
}
cacheHandler.close();
shutdownExecutorAndCloseCache();
return true;
}
}
Expand Down Expand Up @@ -381,6 +387,36 @@ ListenableFuture<?> getFuture()
return future;
}

boolean awaitExecutorTermination(final long timeout, final TimeUnit unit) throws InterruptedException
{
return executorService.awaitTermination(timeout, unit);
}

private void shutdownExecutorAndCloseCache()
{
// The executor is single-threaded, so the cache is not closed until the Kafka worker stops using it.
executorService.execute(cacheHandler::close);
executorService.shutdown();

final ListenableFuture<?> future = this.future;
if (future != null) {
future.cancel(true);
}
}

private static boolean hasReachedEndOffsets(
final Consumer<String, String> consumer,
final Map<TopicPartition, Long> endOffsets
)
{
for (final Map.Entry<TopicPartition, Long> endOffset : endOffsets.entrySet()) {
if (consumer.position(endOffset.getKey()) < endOffset.getValue()) {
return false;
}
}
return true;
}

/**
* Check that the user has not set forbidden Kafka consumer props
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,31 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Sets;
import com.google.common.primitives.Bytes;
import com.google.common.util.concurrent.Uninterruptibles;
import org.apache.druid.jackson.DefaultObjectMapper;
import org.apache.druid.java.util.common.IAE;
import org.apache.druid.java.util.common.concurrent.Execs;
import org.apache.druid.server.lookup.namespace.cache.MockNamespaceExtractionCacheManager;
import org.apache.druid.server.lookup.namespace.cache.NamespaceExtractionCacheManager;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.MockConsumer;
import org.apache.kafka.clients.consumer.OffsetResetStrategy;
import org.apache.kafka.common.TopicPartition;
import org.easymock.EasyMock;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicLong;

public class KafkaLookupExtractorFactoryTest
Expand Down Expand Up @@ -75,6 +84,14 @@ public Object findInjectableValue(
});
}

private void verifyCacheManagerAfterExecutorTerminates(
final KafkaLookupExtractorFactory factory
) throws InterruptedException
{
Assert.assertTrue(factory.awaitExecutorTermination(10, TimeUnit.SECONDS));
EasyMock.verify(cacheManager);
}

@Test
public void testSimpleSerDe() throws Exception
{
Expand Down Expand Up @@ -245,9 +262,13 @@ public void testStopWithoutStart()
}

@Test
public void testStartStop()
public void testStartStop() throws InterruptedException
{
Consumer<String, String> kafkaConsumer = new MockConsumer<>(OffsetResetStrategy.EARLIEST);
final MockConsumer<String, String> kafkaConsumer = new MockConsumer<>("earliest");
final TopicPartition topicPartition = new TopicPartition(TOPIC, 0);
kafkaConsumer.updateBeginningOffsets(ImmutableMap.of(topicPartition, 0L));
kafkaConsumer.updateEndOffsets(ImmutableMap.of(topicPartition, 0L));
kafkaConsumer.schedulePollTask(() -> kafkaConsumer.rebalance(Collections.singletonList(topicPartition)));
EasyMock.replay(cacheManager);

final KafkaLookupExtractorFactory factory = new KafkaLookupExtractorFactory(
Expand All @@ -268,13 +289,96 @@ Consumer<String, String> getConsumer()
Assert.assertTrue(factory.start());
Assert.assertTrue(factory.close());
Assert.assertTrue(factory.getFuture().isDone());
EasyMock.verify(cacheManager);
verifyCacheManagerAfterExecutorTerminates(factory);
}

@Test
public void testStartWaitsForInitialEndOffsets() throws Exception
{
final MockConsumer<String, String> kafkaConsumer = new MockConsumer<>("earliest");
final TopicPartition topicPartition = new TopicPartition(TOPIC, 0);
final CountDownLatch firstPollComplete = new CountDownLatch(1);
final CountDownLatch allowCatchUp = new CountDownLatch(1);

kafkaConsumer.schedulePollTask(() -> {
kafkaConsumer.updateBeginningOffsets(ImmutableMap.of(topicPartition, 0L));
kafkaConsumer.updateEndOffsets(ImmutableMap.of(topicPartition, 2L));
kafkaConsumer.rebalance(Collections.singletonList(topicPartition));
kafkaConsumer.addRecord(new ConsumerRecord<>(TOPIC, 0, 0L, "key-0", "value-0"));
firstPollComplete.countDown();
});
kafkaConsumer.schedulePollTask(() -> {
try {
if (!allowCatchUp.await(10, TimeUnit.SECONDS)) {
throw new RuntimeException("Timed out waiting to finish the startup catch-up");
}
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
kafkaConsumer.addRecord(new ConsumerRecord<>(TOPIC, 0, 1L, "key-1", "value-1"));
});

EasyMock.replay(cacheManager);
final KafkaLookupExtractorFactory factory = new KafkaLookupExtractorFactory(
cacheManager,
TOPIC,
ImmutableMap.of("bootstrap.servers", "localhost"),
10_000L,
false
)
{
@Override
Consumer<String, String> getConsumer()
{
return kafkaConsumer;
}
};
final ExecutorService startExecutor = Execs.singleThreaded("kafka-lookup-start-test");
final Future<Boolean> startFuture = startExecutor.submit(factory::start);

try {
Assert.assertTrue(firstPollComplete.await(10, TimeUnit.SECONDS));
Assert.assertThrows(
"start returned before the consumer reached its initial end offsets",
TimeoutException.class,
() -> startFuture.get(100, TimeUnit.MILLISECONDS)
);
allowCatchUp.countDown();
Assert.assertTrue(startFuture.get(10, TimeUnit.SECONDS));
Assert.assertEquals("value-0", factory.get().apply("key-0"));
Assert.assertEquals("value-1", factory.get().apply("key-1"));
}
finally {
allowCatchUp.countDown();
factory.close();
startExecutor.shutdownNow();
}
verifyCacheManagerAfterExecutorTerminates(factory);
}


@Test
public void testStartFailsFromTimeout()
public void testStartTimeoutReturnsBeforeConsumerStops() throws Exception
{
final CountDownLatch pollStarted = new CountDownLatch(1);
final CountDownLatch allowPollToFinish = new CountDownLatch(1);
final CountDownLatch consumerClosed = new CountDownLatch(1);
final MockConsumer<String, String> kafkaConsumer = new MockConsumer<>("earliest")
{
@Override
public synchronized void close()
{
super.close();
consumerClosed.countDown();
}
};
kafkaConsumer.schedulePollTask(() -> {
pollStarted.countDown();
Uninterruptibles.awaitUninterruptibly(allowPollToFinish);
});

EasyMock.replay(cacheManager);
final KafkaLookupExtractorFactory factory = new KafkaLookupExtractorFactory(
cacheManager,
Expand All @@ -285,28 +389,37 @@ public void testStartFailsFromTimeout()
)
{
@Override
Consumer getConsumer()
Consumer<String, String> getConsumer()
{
// Lock up
try {
Thread.currentThread().join();
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
throw new RuntimeException("shouldn't make it here");
return kafkaConsumer;
}
};
Assert.assertFalse(factory.start());
Assert.assertTrue(factory.getFuture().isDone());
Assert.assertTrue(factory.getFuture().isCancelled());
final ExecutorService startExecutor = Execs.singleThreaded("kafka-lookup-timeout-test");
final Future<Boolean> startFuture = startExecutor.submit(factory::start);

try {
Assert.assertTrue(pollStarted.await(10, TimeUnit.SECONDS));
Assert.assertFalse(startFuture.get(500, TimeUnit.MILLISECONDS));
Assert.assertEquals(1L, consumerClosed.getCount());
Assert.assertFalse(factory.awaitExecutorTermination(100, TimeUnit.MILLISECONDS));

allowPollToFinish.countDown();
Assert.assertTrue(consumerClosed.await(10, TimeUnit.SECONDS));
Assert.assertTrue(factory.awaitExecutorTermination(10, TimeUnit.SECONDS));
Assert.assertTrue(factory.getFuture().isDone());
Assert.assertTrue(factory.getFuture().isCancelled());
}
finally {
allowPollToFinish.countDown();
startExecutor.shutdownNow();
}
EasyMock.verify(cacheManager);
}

@Test
public void testStartStopStart()
public void testStartStopStart() throws InterruptedException
{
Consumer<String, String> kafkaConsumer = new MockConsumer<>(OffsetResetStrategy.EARLIEST);
Consumer<String, String> kafkaConsumer = new MockConsumer<>("earliest");
EasyMock.replay(cacheManager);
final KafkaLookupExtractorFactory factory = new KafkaLookupExtractorFactory(
cacheManager,
Expand All @@ -323,13 +436,17 @@ Consumer<String, String> getConsumer()
Assert.assertTrue(factory.start());
Assert.assertTrue(factory.close());
Assert.assertFalse(factory.start());
EasyMock.verify(cacheManager);
verifyCacheManagerAfterExecutorTerminates(factory);
}

@Test
public void testStartStartStopStop()
public void testStartStartStopStop() throws InterruptedException
{
Consumer<String, String> kafkaConsumer = new MockConsumer<>(OffsetResetStrategy.EARLIEST);
final MockConsumer<String, String> kafkaConsumer = new MockConsumer<>("earliest");
final TopicPartition topicPartition = new TopicPartition(TOPIC, 0);
kafkaConsumer.updateBeginningOffsets(ImmutableMap.of(topicPartition, 0L));
kafkaConsumer.updateEndOffsets(ImmutableMap.of(topicPartition, 0L));
kafkaConsumer.schedulePollTask(() -> kafkaConsumer.rebalance(Collections.singletonList(topicPartition)));
EasyMock.replay(cacheManager);
final KafkaLookupExtractorFactory factory = new KafkaLookupExtractorFactory(
cacheManager,
Expand All @@ -349,7 +466,7 @@ Consumer<String, String> getConsumer()
Assert.assertTrue(factory.start());
Assert.assertTrue(factory.close());
Assert.assertTrue(factory.close());
EasyMock.verify(cacheManager);
verifyCacheManagerAfterExecutorTerminates(factory);
}

@Test
Expand Down
Loading