50 changed files with 1279 additions and 989 deletions
@ -0,0 +1,37 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard 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.thingsboard.server.queue.kafka; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Getter; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
@Component |
|||
@ConditionalOnProperty(prefix = "queue", value = "type", havingValue = "kafka") |
|||
@Getter |
|||
@AllArgsConstructor |
|||
@NoArgsConstructor |
|||
public class TbKafkaConsumerStatisticConfig { |
|||
@Value("${queue.kafka.consumer-stats.enabled:true}") |
|||
private Boolean enabled; |
|||
@Value("${queue.kafka.consumer-stats.print-interval-ms:60000}") |
|||
private Long printIntervalMs; |
|||
@Value("${queue.kafka.consumer-stats.kafka-response-timeout-ms:1000}") |
|||
private Long kafkaResponseTimeoutMs; |
|||
} |
|||
@ -0,0 +1,184 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard 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.thingsboard.server.queue.kafka; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.apache.kafka.clients.admin.AdminClient; |
|||
import org.apache.kafka.clients.consumer.Consumer; |
|||
import org.apache.kafka.clients.consumer.ConsumerConfig; |
|||
import org.apache.kafka.clients.consumer.KafkaConsumer; |
|||
import org.apache.kafka.clients.consumer.OffsetAndMetadata; |
|||
import org.apache.kafka.common.TopicPartition; |
|||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.util.StringUtils; |
|||
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.msg.queue.ServiceType; |
|||
import org.thingsboard.server.queue.discovery.PartitionService; |
|||
|
|||
import javax.annotation.PostConstruct; |
|||
import javax.annotation.PreDestroy; |
|||
import java.time.Duration; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Properties; |
|||
import java.util.Set; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.ScheduledExecutorService; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
@ConditionalOnProperty(prefix = "queue", value = "type", havingValue = "kafka") |
|||
public class TbKafkaConsumerStatsService { |
|||
private final Set<String> monitoredGroups = ConcurrentHashMap.newKeySet(); |
|||
|
|||
private final TbKafkaSettings kafkaSettings; |
|||
private final TbKafkaConsumerStatisticConfig statsConfig; |
|||
private final PartitionService partitionService; |
|||
|
|||
private AdminClient adminClient; |
|||
private Consumer<String, byte[]> consumer; |
|||
private ScheduledExecutorService statsPrintScheduler; |
|||
|
|||
@PostConstruct |
|||
public void init() { |
|||
if (!statsConfig.getEnabled()) { |
|||
return; |
|||
} |
|||
this.adminClient = AdminClient.create(kafkaSettings.toAdminProps()); |
|||
this.statsPrintScheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("kafka-consumer-stats")); |
|||
|
|||
Properties consumerProps = kafkaSettings.toConsumerProps(); |
|||
consumerProps.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer-stats-loader-client"); |
|||
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "consumer-stats-loader-client-group"); |
|||
this.consumer = new KafkaConsumer<>(consumerProps); |
|||
|
|||
startLogScheduling(); |
|||
} |
|||
|
|||
private void startLogScheduling() { |
|||
Duration timeoutDuration = Duration.ofMillis(statsConfig.getKafkaResponseTimeoutMs()); |
|||
statsPrintScheduler.scheduleWithFixedDelay(() -> { |
|||
if (!isStatsPrintRequired()) { |
|||
return; |
|||
} |
|||
for (String groupId : monitoredGroups) { |
|||
try { |
|||
Map<TopicPartition, OffsetAndMetadata> groupOffsets = adminClient.listConsumerGroupOffsets(groupId).partitionsToOffsetAndMetadata() |
|||
.get(statsConfig.getKafkaResponseTimeoutMs(), TimeUnit.MILLISECONDS); |
|||
Map<TopicPartition, Long> endOffsets = consumer.endOffsets(groupOffsets.keySet(), timeoutDuration); |
|||
|
|||
List<GroupTopicStats> lagTopicsStats = getTopicsStatsWithLag(groupOffsets, endOffsets); |
|||
if (!lagTopicsStats.isEmpty()) { |
|||
StringBuilder builder = new StringBuilder(); |
|||
for (int i = 0; i < lagTopicsStats.size(); i++) { |
|||
builder.append(lagTopicsStats.get(i).toString()); |
|||
if (i != lagTopicsStats.size() - 1) { |
|||
builder.append(", "); |
|||
} |
|||
} |
|||
log.info("[{}] Topic partitions with lag: [{}].", groupId, builder.toString()); |
|||
} |
|||
} catch (Exception e) { |
|||
log.warn("[{}] Failed to get consumer group stats. Reason - {}.", groupId, e.getMessage()); |
|||
log.trace("Detailed error: ", e); |
|||
} |
|||
} |
|||
|
|||
}, statsConfig.getPrintIntervalMs(), statsConfig.getPrintIntervalMs(), TimeUnit.MILLISECONDS); |
|||
} |
|||
|
|||
private boolean isStatsPrintRequired() { |
|||
boolean isMyRuleEnginePartition = partitionService.resolve(ServiceType.TB_RULE_ENGINE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition(); |
|||
boolean isMyCorePartition = partitionService.resolve(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition(); |
|||
return log.isInfoEnabled() && (isMyRuleEnginePartition || isMyCorePartition); |
|||
} |
|||
|
|||
private List<GroupTopicStats> getTopicsStatsWithLag(Map<TopicPartition, OffsetAndMetadata> groupOffsets, Map<TopicPartition, Long> endOffsets) { |
|||
List<GroupTopicStats> consumerGroupStats = new ArrayList<>(); |
|||
for (TopicPartition topicPartition : groupOffsets.keySet()) { |
|||
long endOffset = endOffsets.get(topicPartition); |
|||
long committedOffset = groupOffsets.get(topicPartition).offset(); |
|||
long lag = endOffset - committedOffset; |
|||
if (lag != 0) { |
|||
GroupTopicStats groupTopicStats = GroupTopicStats.builder() |
|||
.topic(topicPartition.topic()) |
|||
.partition(topicPartition.partition()) |
|||
.committedOffset(committedOffset) |
|||
.endOffset(endOffset) |
|||
.lag(lag) |
|||
.build(); |
|||
consumerGroupStats.add(groupTopicStats); |
|||
} |
|||
} |
|||
return consumerGroupStats; |
|||
} |
|||
|
|||
public void registerClientGroup(String groupId) { |
|||
if (statsConfig.getEnabled() && !StringUtils.isEmpty(groupId)) { |
|||
monitoredGroups.add(groupId); |
|||
} |
|||
} |
|||
|
|||
public void unregisterClientGroup(String groupId) { |
|||
if (statsConfig.getEnabled() && !StringUtils.isEmpty(groupId)) { |
|||
monitoredGroups.remove(groupId); |
|||
} |
|||
} |
|||
|
|||
@PreDestroy |
|||
public void destroy() { |
|||
if (statsPrintScheduler != null) { |
|||
statsPrintScheduler.shutdownNow(); |
|||
} |
|||
if (adminClient != null) { |
|||
adminClient.close(); |
|||
} |
|||
if (consumer != null) { |
|||
consumer.close(); |
|||
} |
|||
} |
|||
|
|||
|
|||
@Builder |
|||
@Data |
|||
private static class GroupTopicStats { |
|||
private String topic; |
|||
private int partition; |
|||
private long committedOffset; |
|||
private long endOffset; |
|||
private long lag; |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "[" + |
|||
"topic=[" + topic + ']' + |
|||
", partition=[" + partition + "]" + |
|||
", committedOffset=[" + committedOffset + "]" + |
|||
", endOffset=[" + endOffset + "]" + |
|||
", lag=[" + lag + "]" + |
|||
"]"; |
|||
} |
|||
} |
|||
} |
|||
Binary file not shown.
@ -0,0 +1,53 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard 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. |
|||
*/ |
|||
|
|||
import com.google.gson.JsonParser; |
|||
import org.junit.Assert; |
|||
import org.junit.Test; |
|||
import org.junit.runner.RunWith; |
|||
import org.mockito.junit.MockitoJUnitRunner; |
|||
import org.thingsboard.server.common.transport.adaptor.JsonConverter; |
|||
|
|||
@RunWith(MockitoJUnitRunner.class) |
|||
public class JsonConverterTest { |
|||
|
|||
private static final JsonParser JSON_PARSER = new JsonParser(); |
|||
|
|||
@Test |
|||
public void testParseBigDecimalAsLong() { |
|||
var result = JsonConverter.convertToTelemetry(JSON_PARSER.parse("{\"meterReadingDelta\": 1E+1}"), 0L); |
|||
Assert.assertEquals(10L, result.get(0L).get(0).getLongValue().get().longValue()); |
|||
} |
|||
|
|||
@Test |
|||
public void testParseBigDecimalAsDouble() { |
|||
var result = JsonConverter.convertToTelemetry(JSON_PARSER.parse("{\"meterReadingDelta\": 101E-1}"), 0L); |
|||
Assert.assertEquals(10.1, result.get(0L).get(0).getDoubleValue().get(), 0.0); |
|||
} |
|||
|
|||
@Test |
|||
public void testParseAsDouble() { |
|||
var result = JsonConverter.convertToTelemetry(JSON_PARSER.parse("{\"meterReadingDelta\": 1.1}"), 0L); |
|||
Assert.assertEquals(1.1, result.get(0L).get(0).getDoubleValue().get(), 0.0); |
|||
} |
|||
|
|||
@Test |
|||
public void testParseAsLong() { |
|||
var result = JsonConverter.convertToTelemetry(JSON_PARSER.parse("{\"meterReadingDelta\": 11}"), 0L); |
|||
Assert.assertEquals(11L, result.get(0L).get(0).getLongValue().get().longValue()); |
|||
} |
|||
|
|||
} |
|||
Binary file not shown.
Loading…
Reference in new issue