/*
* Copyright 2015 the original author or authors.
*
* Licensed 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 org.springframework.integration.x.kafka;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.integration.kafka.core.ConnectionFactory;
import org.springframework.integration.kafka.core.Partition;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Is responsible for managing the partition allocation between multiple instances of a Kafka source module
* deployed in a stream.
* As a {@link FactoryBean} it will return the partitions that the current module instance should listen to.
* The partitions for the given topic would be extracted from Kafka unless an explicit partition list
* is provided.
* Partitions are allocated evenly based on the number of module instances.
* Zero-count Kafka source modules are not supported.
*
* @author Marius Bogoevici
* @author Ilayaperumal Gopinathan
*/
public class KafkaPartitionAllocator implements FactoryBean<Partition[]> {
private static final Logger log = LoggerFactory.getLogger(KafkaPartitionAllocator.class);
private static final Pattern PARTITION_LIST_VALIDATION_REGEX = Pattern.compile("\\d+([,-]\\d+)*");
private final List<String> topics;
private final String partitionList;
private final ConnectionFactory connectionFactory;
private final int count;
private final int sequence;
private final String moduleName;
private final String streamName;
public KafkaPartitionAllocator(ConnectionFactory connectionFactory, String moduleName,
String streamName, String topics, String partitionList, int sequence, int count) {
Assert.notNull(connectionFactory, "cannot be null");
Assert.hasText(moduleName, "cannot be empty");
Assert.hasText(streamName, "cannot be empty");
Assert.hasText(topics, "cannot be empty");
Assert.isTrue(sequence > 0, " must be a positive number. 0-count kafka sources are not currently supported");
Assert.isTrue(count > 0, " must be a positive number. 0-count kafka sources are not currently supported");
Assert.notNull(count > 0, " must be a positive number. 0-count kafka sources are not currently supported");
this.connectionFactory = connectionFactory;
this.moduleName = moduleName;
this.streamName = streamName;
this.topics = Arrays.asList(topics.split("\\s*,\\s*"));
this.partitionList = partitionList;
this.sequence = sequence;
this.count = count;
}
@Override
public synchronized Partition[] getObject() throws Exception {
if (log.isDebugEnabled()) {
log.debug("Module name is " + moduleName);
log.debug("Stream name is " + streamName);
log.debug("Cardinality is " + count);
log.debug("Sequence is " + sequence);
}
Map<String, Collection<Partition>> partitionsMapByTopic = new HashMap<String, Collection<Partition>>();
int maxPartitionCount = 0;
for (String topic : topics) {
List<Partition> partitions = new ArrayList<Partition>(connectionFactory.getPartitions(topic));
Collections.sort(partitions, new Comparator<Partition>() {
@Override
public int compare(Partition partition1, Partition partition2) {
return partition1.getId() - partition2.getId();
}
});
partitionsMapByTopic.put(topic, partitions);
maxPartitionCount = (partitions.size() > maxPartitionCount) ? partitions.size() : maxPartitionCount;
}
Assert.isTrue(maxPartitionCount >= count, "Total module count should not be less than the maximum of " +
"partitions from the given topics");
if (topics.size() > 1) {
Assert.isTrue(!StringUtils.hasText(partitionList), "Explicit partitions list isn't supported for " +
"multi-topics");
}
Map<String, Collection<Partition>> partitionsToListen = StringUtils.hasText(partitionList) ?
toPartitionsMap(topics.get(0), parseNumberList(partitionList)) : partitionsMapByTopic;
Collection<Partition> partitionsToReturn = new ArrayList<Partition>();
// To evenly distribute the partitions, assign the group of deterministic partitions to the given moduleInstance
for (Collection<Partition> partitions : partitionsToListen.values()) {
Partition[] partitionsArray = partitions.toArray(new Partition[partitions.size()]);
for (int i = (sequence - 1); i < partitions.size(); i = i + count) {
partitionsToReturn.add(partitionsArray[i]);
}
}
return partitionsToReturn.toArray(new Partition[partitionsToReturn.size()]);
}
/**
* @param topic the topic name which is the key for the map
* @param partitionIds the partition Ids to map
* @return the map of {@ref Partition} collections for the given topic.
*/
private Map<String, Collection<Partition>> toPartitionsMap(String topic, Iterable<Integer> partitionIds) {
List<Partition> partitions = new ArrayList<Partition>();
for (Integer partitionId : partitionIds) {
partitions.add(new Partition(topic, partitionId));
}
Map<String, Collection<Partition>> partitionsMap = new HashMap<String, Collection<Partition>>();
partitionsMap.put(topic, partitions);
return partitionsMap;
}
/**
* Expects a String containing a list of numbers or ranges, e.g. {@code "1-10"}, {@code "1,3,5"},
* {@code "1,5,10-20,26,100-110,145"}. One-sized ranges or ranges where the start is after the end
* are not permitted.
* Returns an array of Integers containing the actual numbers.
*
* @param numberList a string containing numbers, or ranges
* @return the list of integers
* @throws IllegalArgumentException if the format of the list is incorrect
*/
public static Iterable<Integer> parseNumberList(String numberList) throws IllegalArgumentException {
Assert.hasText(numberList, "must contain a list of values");
Assert.isTrue(PARTITION_LIST_VALIDATION_REGEX.matcher(numberList).matches(), "is not a list of numbers or ranges");
Set<Integer> numbers = new TreeSet<Integer>();
String[] numbersOrRanges = numberList.split(",");
for (String numberOrRange : numbersOrRanges) {
if (numberOrRange.contains("-")) {
String[] split = numberOrRange.split("-");
Integer start = Integer.parseInt(split[0]);
Integer end = Integer.parseInt(split[1]);
if (start >= end) {
throw new IllegalArgumentException(String.format("A range contains a start which is after the end: %d-%d",
start, end));
}
for (int i = start; i <= end; i++) {
numbers.add(i);
}
}
else {
numbers.add(Integer.parseInt(numberOrRange));
}
}
return Collections.unmodifiableSet(numbers);
}
@Override
public Class<?> getObjectType() {
return Partition[].class;
}
@Override
public boolean isSingleton() {
return true;
}
}