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 @@ -24,6 +24,7 @@
import org.apache.druid.common.utils.IdUtils;
import org.apache.druid.data.input.impl.CsvInputFormat;
import org.apache.druid.data.input.impl.TimestampSpec;
import org.apache.druid.indexer.TaskStatusPlus;
import org.apache.druid.indexing.common.task.CompactionTask;
import org.apache.druid.indexing.common.task.IndexTask;
import org.apache.druid.indexing.common.task.NoopTask;
Expand All @@ -33,13 +34,17 @@
import org.apache.druid.indexing.kafka.simulate.KafkaResource;
import org.apache.druid.indexing.kafka.supervisor.KafkaSupervisorSpec;
import org.apache.druid.indexing.overlord.Segments;
import org.apache.druid.indexing.overlord.TaskMaster;
import org.apache.druid.indexing.overlord.TaskRunner;
import org.apache.druid.indexing.overlord.TaskRunnerWorkItem;
import org.apache.druid.indexing.overlord.supervisor.SupervisorStatus;
import org.apache.druid.java.util.common.DateTimes;
import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.metadata.storage.postgresql.PostgreSQLMetadataStorageModule;
import org.apache.druid.query.DruidMetrics;
import org.apache.druid.query.http.SqlTaskStatus;
import org.apache.druid.rpc.indexing.OverlordClient;
import org.apache.druid.segment.metadata.Metric;
import org.apache.druid.tasklogs.TaskLogStreamer;
import org.apache.druid.testing.embedded.EmbeddedBroker;
Expand Down Expand Up @@ -67,6 +72,7 @@
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -145,7 +151,51 @@ protected EmbeddedDruidCluster addServers(EmbeddedDruidCluster cluster)
@AfterEach
public void cleanUp()
{
markSegmentsAsUnused(dataSource);
try {
final List<SupervisorStatus> supervisors = new ArrayList<>();
cluster.callApi().onLeaderOverlord(OverlordClient::supervisorStatuses).forEachRemaining(supervisors::add);
for (final SupervisorStatus supervisor : supervisors) {
if (dataSource.equals(supervisor.getId())) {
cluster.callApi().onLeaderOverlord(o -> o.terminateSupervisor(supervisor.getId()));
}
}

cluster.callApi()
.waitForResult(this::cancelTasksForCurrentTest, Set::isEmpty)
.withTimeoutMillis(60_000)
.go();
}
finally {
markSegmentsAsUnused(dataSource);
}
}

private Set<String> cancelTasksForCurrentTest()
{
final Set<String> taskIds = new HashSet<>();
for (final String state : List.of("running", "pending", "waiting")) {
for (final TaskStatusPlus task : cluster.callApi().getTasks(dataSource, state)) {
taskIds.add(task.getId());
}
}

// A task can be complete in storage while still occupying a worker slot.
// Docker subclasses may use an external leader, so its runner is not available here.
final Optional<TaskRunner> taskRunner = overlord.bindings().getInstance(TaskMaster.class).getTaskRunner();
if (taskRunner.isPresent()) {
final List<TaskRunnerWorkItem> runnerTasks = new ArrayList<>(taskRunner.get().getPendingTasks());
runnerTasks.addAll(taskRunner.get().getRunningTasks());
for (final TaskRunnerWorkItem task : runnerTasks) {
if (dataSource.equals(task.getDataSource())) {
taskIds.add(task.getTaskId());
}
}
}

for (final String taskId : taskIds) {
cluster.callApi().onLeaderOverlord(o -> o.cancelTask(taskId));
}
return taskIds;
}

protected int markSegmentsAsUnused(String dataSource)
Expand Down Expand Up @@ -363,7 +413,7 @@ public void test_streamLogs_ofCancelledTask() throws Exception
final String taskId = IdUtils.getRandomId();
final long runDurationMillis = 100_000L;
cluster.callApi().onLeaderOverlord(
o -> o.runTask(taskId, new NoopTask(taskId, null, null, runDurationMillis, 0L, null))
o -> o.runTask(taskId, new NoopTask(taskId, null, dataSource, runDurationMillis, 0L, null))
);

eventCollector.latchableEmitter().waitForEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@
import org.apache.druid.query.DruidMetrics;
import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
import org.apache.druid.testing.embedded.StreamIngestResource;
import org.apache.druid.testing.embedded.tools.EventSerializer;
import org.apache.druid.testing.embedded.tools.JsonEventSerializer;
import org.apache.druid.testing.embedded.tools.StreamGenerator;
import org.apache.druid.testing.embedded.tools.WikipediaStreamEventStreamGenerator;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.joda.time.Period;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
Expand Down Expand Up @@ -203,12 +210,26 @@ private KafkaSupervisorSpec createBoundedKafkaSupervisor(
.build(dataSource, topic);
}

private void publishRecordsToBothPartitions(String topic)
{
final EventSerializer serializer = new JsonEventSerializer(overlord.bindings().jsonMapper());
final StreamGenerator generator = new WikipediaStreamEventStreamGenerator(serializer, 100, 100);
final List<byte[]> records = generator.generateEvents(10);
final List<ProducerRecord<byte[], byte[]>> producerRecords = new ArrayList<>();
// Fixed per-partition end offsets require data in both partitions; Kafka's default partitioner need not balance it.
for (int i = 0; i < records.size(); i++) {
producerRecords.add(new ProducerRecord<>(topic, i % 2, null, records.get(i)));
}
kafkaServer.produceRecordsWithoutTransaction(producerRecords);
Assertions.assertEquals(Map.of("0", 500L, "1", 500L), kafkaServer.getPartitionOffsets(topic));
}

@Test
public void test_boundedSupervisor_withMismatchedMetadata_is_unhealthy()
{
final String topic = IdUtils.getRandomId();
kafkaServer.createTopicWithPartitions(topic, 2);
publish1kRecords(topic, false);
publishRecordsToBothPartitions(topic);

// Get the current end offsets for all partitions
Map<String, Long> currentOffsets = kafkaServer.getPartitionOffsets(topic);
Expand Down Expand Up @@ -282,7 +303,7 @@ public void test_boundedSupervisor_doesNotSilentlyCompleteWhenStaleOffsetExceeds
{
final String topic = IdUtils.getRandomId();
kafkaServer.createTopicWithPartitions(topic, 2);
publish1kRecords(topic, false);
publishRecordsToBothPartitions(topic);

// Run 1: ingest up to offset 100 on each partition and complete.
Map<String, Long> startOffsets1 = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,8 @@ public void doMassLaunchAndExit() throws Exception
taskQueue.add(testTask);
}

// in theory we can get a race here, since we fetch the counts at separate times
// The running, pending, and waiting counters are separate snapshots and cannot be summed while tasks transition.
Assertions.assertEquals(numTasks, taskQueue.getTasks().size(), "all tasks should be known");
long runningTasks = taskQueue.getRunningTaskCount().values().stream().mapToLong(Long::longValue).sum();
long pendingTasks = taskQueue.getPendingTaskCount().values().stream().mapToLong(Long::longValue).sum();
long waitingTasks = taskQueue.getWaitingTaskCount().values().stream().mapToLong(Long::longValue).sum();
Assertions.assertEquals(numTasks, (runningTasks + pendingTasks + waitingTasks), "all tasks should be known");

// Wait for all tasks to finish.
final TaskLookup.CompleteTaskLookup completeTaskLookup =
Expand All @@ -194,12 +190,18 @@ public void doMassLaunchAndExit() throws Exception
Thread.sleep(100);
}

Thread.sleep(100);
// Completion is persisted before cleanup finishes. The test timeout bounds this wait.
while (!taskStorage.getActiveTasks().isEmpty()
|| taskQueue.getRunningTaskCount().values().stream().anyMatch(count -> count != 0)
|| taskQueue.getPendingTaskCount().values().stream().anyMatch(count -> count != 0)
|| taskQueue.getWaitingTaskCount().values().stream().anyMatch(count -> count != 0)) {
Thread.sleep(100);
}

Assertions.assertEquals(0, taskStorage.getActiveTasks().size(), "no tasks should be active");
runningTasks = taskQueue.getRunningTaskCount().values().stream().mapToLong(Long::longValue).sum();
pendingTasks = taskQueue.getPendingTaskCount().values().stream().mapToLong(Long::longValue).sum();
waitingTasks = taskQueue.getWaitingTaskCount().values().stream().mapToLong(Long::longValue).sum();
final long runningTasks = taskQueue.getRunningTaskCount().values().stream().mapToLong(Long::longValue).sum();
final long pendingTasks = taskQueue.getPendingTaskCount().values().stream().mapToLong(Long::longValue).sum();
final long waitingTasks = taskQueue.getWaitingTaskCount().values().stream().mapToLong(Long::longValue).sum();
Assertions.assertEquals(0, runningTasks, "no tasks should be running");
Assertions.assertEquals(0, pendingTasks, "no tasks should be pending");
Assertions.assertEquals(0, waitingTasks, "no tasks should be waiting");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ public void removeSegment(final DataSegment segment)
segmentsNeedingRefresh.remove(segment.getId());
unmarkSegmentAsMutable(segment.getId());

segmentMetadataInfo.compute(
final ConcurrentSkipListMap<SegmentId, AvailableSegmentMetadata> remainingSegments = segmentMetadataInfo.compute(
segment.getDataSource(),
(dataSource, segmentsMap) -> {
if (segmentsMap == null) {
Expand All @@ -605,7 +605,11 @@ public void removeSegment(final DataSegment segment)
}
removeSegmentAction(segment.getId());
if (segmentsMap.isEmpty()) {
tables.remove(segment.getDataSource());
// Emit the removal action only if this call actually removed the table, so that a concurrent
// refresh which also finds the datasource gone cannot report the same removal twice.
if (tables.remove(segment.getDataSource()) != null) {
removeDataSourceAction(segment.getDataSource());
}
log.info("dataSource [%s] no longer exists, all metadata removed.", segment.getDataSource());
return null;
} else {
Expand All @@ -615,6 +619,9 @@ public void removeSegment(final DataSegment segment)
}
}
);
if (remainingSegments == null) {
dataSourcesNeedingRebuild.remove(segment.getDataSource());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P2] Prevent duplicate removal metrics from in-flight refresh

This clears only the shared dataSourcesNeedingRebuild set. A cache refresh can already have copied that datasource into its local dataSourcesToRebuild set at the end of BrokerSegmentMetadataCache.refresh before this callback removes the last segment. The callback then emits DATASOURCE_REMOVED, but the same refresh still builds the now-empty datasource and emits DATASOURCE_REMOVED again. Coordinate this cleanup with the in-flight refresh (or make the removal emission idempotent), and cover the callback/refresh interleaving so one removal is counted once.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 6469442. Rather than coordinating with the in-flight refresh, the metric emission is now idempotent: both removeSegment and the null-signature branch of refresh emit dataSource/removed only when their own tables.remove(dataSource) returned a non-null value. Since tables is a ConcurrentHashMap, exactly one of the two paths wins the remove and emits; the other sees null and stays silent, in either interleaving. The callback still never touches the refresh thread's local dataSourcesToRebuild set, and no lock is held across metadata queries.

Test coverage: testLastSegmentRemovalClearsRebuildState now also runs a refresh that is handed the datasource explicitly after the last segment is gone (simulating a refresh that captured it before the removal) and asserts the metric count stays at 1. A new testRefreshOfUnknownDatasourceDoesNotEmitRemovalMetric covers a datasource that never had a table. Both fail on the previous revision (expected: <1> but was: <2> / expected: <0> but was: <1>) and pass now.

The separate table-resurrection race (refresh re-inserting a table after the last segment was removed) is pre-existing on master and not addressed here; I will file it as a follow-up.

}

lock.notifyAll();
}
Expand All @@ -625,6 +632,16 @@ public void removeSegment(final DataSegment segment)
*/
protected abstract void removeSegmentAction(SegmentId segmentId);

/**
* Called under the cache lock after the last segment of a datasource has been removed and its table was actually
* removed from {@link #tables} by that removal. It is not called when no table existed for the datasource, so a
* single datasource removal triggers this action at most once even if a refresh observes the removal concurrently.
*/
protected void removeDataSourceAction(String dataSource)
{
// No additional action by default.
}

@VisibleForTesting
public void removeServerSegment(final DruidServerMetadata server, final DataSegment segment)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,11 @@ public void refresh(final Set<SegmentId> segmentsToRefresh, final Set<String> da
final RowSignature rowSignature = buildDataSourceRowSignature(dataSource);
if (rowSignature == null) {
log.info("datasource [%s] no longer exists, all metadata removed.", dataSource);
tables.remove(dataSource);
emitMetric(
Metric.DATASOURCE_REMOVED,
1,
ServiceMetricEvent.builder().setDimension(DruidMetrics.DATASOURCE, dataSource));
// The last-segment callback may already have removed the table and emitted the metric while this refresh
// was in flight. Only emit if this refresh is the one that actually removed the table.
if (tables.remove(dataSource) != null) {
emitDataSourceRemoved(dataSource);
}
continue;
}

Expand All @@ -264,12 +264,11 @@ public void refresh(final Set<SegmentId> segmentsToRefresh, final Set<String> da
// and a new datasource is added
log.info("datasource [%s] schema has not been initialized yet, "
+ "check coordinator logs if this message is persistent.", dataSource);
// this is a harmless call
tables.remove(dataSource);
emitMetric(
Metric.DATASOURCE_REMOVED,
1,
ServiceMetricEvent.builder().setDimension(DruidMetrics.DATASOURCE, dataSource));
// Usually there is no table to remove here. If there was one, a concurrent last-segment callback may have
// removed it and emitted the metric already, so only emit if this refresh actually removed the table.
if (tables.remove(dataSource) != null) {
emitDataSourceRemoved(dataSource);
}
continue;
}

Expand Down Expand Up @@ -307,6 +306,22 @@ protected void removeSegmentAction(SegmentId segmentId)
// noop, no additional action needed when segment is removed.
}

@Override
protected void removeDataSourceAction(String dataSource)
{
// The last-segment callback can remove the table without another schema refresh.
emitDataSourceRemoved(dataSource);
}

private void emitDataSourceRemoved(String dataSource)
{
emitMetric(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P2] Avoid duplicate removal metrics during concurrent refresh

This callback can race with BrokerSegmentMetadataCache.refresh(): refresh drains a datasource into its local dataSourcesToRebuild set and clears the guarded set before rebuilding outside the lock, then a concurrent last-segment callback emits DATASOURCE_REMOVED here. The in-flight refresh still processes that local datasource (and can emit DATASOURCE_REMOVED again when its row signature is null), so one removal can produce duplicate metrics. The synchronous regression test only verifies that a later refresh does not use stale state; please coordinate or re-check in-flight rebuilds before emitting/rebuilding.

Metric.DATASOURCE_REMOVED,
1,
ServiceMetricEvent.builder().setDimension(DruidMetrics.DATASOURCE, dataSource)
);
}

private Set<String> queryDataSources()
{
Set<String> dataSources = new HashSet<>();
Expand Down
Loading
Loading