fix: harden jt808 tdengine ingestion
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -44,11 +44,15 @@ public final class KafkaEnvelopeConsumerFactory {
|
||||
if (topics.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
KafkaEnvelopeConsumerWorker worker = new KafkaEnvelopeConsumerWorker(
|
||||
consumerFactory.apply(consumerProperties(props, binding, processorBeanName)),
|
||||
topicProcessors(topics, processor));
|
||||
worker.subscribe(topics);
|
||||
workers.add(worker);
|
||||
int concurrency = Math.max(1, props.getConsumer().getConcurrency());
|
||||
for (int workerIndex = 0; workerIndex < concurrency; workerIndex++) {
|
||||
KafkaEnvelopeConsumerWorker worker = new KafkaEnvelopeConsumerWorker(
|
||||
consumerFactory.apply(consumerProperties(props, binding, processorBeanName,
|
||||
workerIndex, concurrency)),
|
||||
topicProcessors(topics, processor));
|
||||
worker.subscribe(topics);
|
||||
workers.add(worker);
|
||||
}
|
||||
}
|
||||
return workers;
|
||||
}
|
||||
@@ -104,14 +108,17 @@ public final class KafkaEnvelopeConsumerFactory {
|
||||
|
||||
private Properties consumerProperties(SinkMqProperties props,
|
||||
SinkMqProperties.Binding binding,
|
||||
String processorBeanName) {
|
||||
String processorBeanName,
|
||||
int workerIndex,
|
||||
int concurrency) {
|
||||
SinkMqProperties.Consumer consumer = props.getConsumer();
|
||||
Properties p = new Properties();
|
||||
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, props.getBootstrapServers());
|
||||
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
|
||||
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class.getName());
|
||||
p.put(ConsumerConfig.GROUP_ID_CONFIG, groupId(binding, processorBeanName));
|
||||
p.put(ConsumerConfig.CLIENT_ID_CONFIG, consumer.getClientIdPrefix() + "-" + processorBeanName);
|
||||
p.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId(consumer.getClientIdPrefix(), processorBeanName,
|
||||
workerIndex, concurrency));
|
||||
// 手动提交 offset:只有本批次至少有一条记录被处理器接收后才 commit,
|
||||
// 避免轮询到空批次或未绑定 topic 时推进消费位点。
|
||||
p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
|
||||
@@ -121,6 +128,11 @@ public final class KafkaEnvelopeConsumerFactory {
|
||||
return p;
|
||||
}
|
||||
|
||||
private String clientId(String prefix, String processorBeanName, int workerIndex, int concurrency) {
|
||||
String base = prefix + "-" + processorBeanName;
|
||||
return concurrency <= 1 ? base : base + "-" + workerIndex;
|
||||
}
|
||||
|
||||
private String groupId(SinkMqProperties.Binding binding, String processorBeanName) {
|
||||
if (binding.getGroupId() != null && !binding.getGroupId().isBlank()) {
|
||||
return binding.getGroupId();
|
||||
|
||||
@@ -63,7 +63,7 @@ public final class KafkaEventSink implements EventSink, AutoCloseable {
|
||||
}
|
||||
|
||||
String topic = breaker.tryAcquirePermission() ? router.route(event) : dlqTopic;
|
||||
ProducerRecord<String, byte[]> record = new ProducerRecord<>(topic, event.vin(), payload);
|
||||
ProducerRecord<String, byte[]> record = new ProducerRecord<>(topic, partitionKey(event), payload);
|
||||
record.headers().add("event-id", event.eventId().getBytes());
|
||||
record.headers().add("trace-id", event.traceId() == null ? new byte[0] : event.traceId().getBytes());
|
||||
record.headers().add("source", event.source().name().getBytes());
|
||||
@@ -80,6 +80,36 @@ public final class KafkaEventSink implements EventSink, AutoCloseable {
|
||||
return cf;
|
||||
}
|
||||
|
||||
private static String partitionKey(VehicleEvent event) {
|
||||
String vin = event.vin();
|
||||
if (isKnown(vin)) {
|
||||
return vin;
|
||||
}
|
||||
String vehicleKey = metadata(event, "vehicleKey");
|
||||
if (!isKnown(vehicleKey)) {
|
||||
vehicleKey = metadata(event, "vehicle_key");
|
||||
}
|
||||
if (isKnown(vehicleKey)) {
|
||||
return "vehicleKey:" + vehicleKey;
|
||||
}
|
||||
String phone = metadata(event, "phone");
|
||||
if (isKnown(phone)) {
|
||||
return "phone:" + phone;
|
||||
}
|
||||
return vin == null || vin.isBlank() ? "unknown" : vin;
|
||||
}
|
||||
|
||||
private static String metadata(VehicleEvent event, String key) {
|
||||
if (event.metadata() == null) {
|
||||
return "";
|
||||
}
|
||||
return event.metadata().getOrDefault(key, "").trim();
|
||||
}
|
||||
|
||||
private static boolean isKnown(String value) {
|
||||
return value != null && !value.isBlank() && !"unknown".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
producer.flush();
|
||||
|
||||
@@ -94,6 +94,7 @@ public class SinkMqProperties {
|
||||
private String autoOffsetReset = "earliest";
|
||||
private int maxPollRecords = 500;
|
||||
private int maxPollIntervalMillis = 1800000;
|
||||
private int concurrency = 1;
|
||||
/**
|
||||
* Processor bean name -> Kafka binding。
|
||||
*
|
||||
@@ -118,6 +119,8 @@ public class SinkMqProperties {
|
||||
public void setMaxPollRecords(int maxPollRecords) { this.maxPollRecords = maxPollRecords; }
|
||||
public int getMaxPollIntervalMillis() { return maxPollIntervalMillis; }
|
||||
public void setMaxPollIntervalMillis(int maxPollIntervalMillis) { this.maxPollIntervalMillis = maxPollIntervalMillis; }
|
||||
public int getConcurrency() { return concurrency; }
|
||||
public void setConcurrency(int concurrency) { this.concurrency = concurrency; }
|
||||
public Map<String, Binding> getBindings() { return bindings; }
|
||||
public void setBindings(Map<String, Binding> bindings) { this.bindings = bindings; }
|
||||
}
|
||||
|
||||
@@ -44,6 +44,32 @@ class KafkaEnvelopeConsumerFactoryTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsParallelConsumersForEachBindingWhenConsumerConcurrencyIsConfigured() {
|
||||
List<Properties> created = new ArrayList<>();
|
||||
KafkaEnvelopeConsumerFactory factory = new KafkaEnvelopeConsumerFactory(props -> {
|
||||
created.add(props);
|
||||
return new MockConsumer<>(OffsetResetStrategy.EARLIEST);
|
||||
});
|
||||
SinkMqProperties props = new SinkMqProperties();
|
||||
props.getConsumer().setConcurrency(3);
|
||||
EnvelopeConsumerProcessor stateProcessor = processor();
|
||||
|
||||
List<KafkaEnvelopeConsumerWorker> workers = factory.createWorkers(Map.of(
|
||||
"vehicleStateEnvelopeConsumerProcessor", stateProcessor), props);
|
||||
|
||||
assertThat(workers).hasSize(3);
|
||||
assertThat(created)
|
||||
.extracting(p -> p.getProperty(ConsumerConfig.GROUP_ID_CONFIG))
|
||||
.containsExactly("vehicle-state", "vehicle-state", "vehicle-state");
|
||||
assertThat(created)
|
||||
.extracting(p -> p.getProperty(ConsumerConfig.CLIENT_ID_CONFIG))
|
||||
.containsExactly(
|
||||
"lingniu-envelope-consumer-vehicleStateEnvelopeConsumerProcessor-0",
|
||||
"lingniu-envelope-consumer-vehicleStateEnvelopeConsumerProcessor-1",
|
||||
"lingniu-envelope-consumer-vehicleStateEnvelopeConsumerProcessor-2");
|
||||
}
|
||||
|
||||
private EnvelopeConsumerProcessor processor() {
|
||||
return new EnvelopeConsumerProcessor(
|
||||
"service",
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
package com.lingniu.ingest.sink.mq;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.LocationPayload;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import org.apache.kafka.clients.producer.Callback;
|
||||
import org.apache.kafka.clients.producer.KafkaProducer;
|
||||
import org.apache.kafka.clients.producer.ProducerRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class KafkaEventSinkTest {
|
||||
|
||||
@@ -24,6 +33,28 @@ class KafkaEventSinkTest {
|
||||
assertThat(sink.accepts(rawArchive())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesPhoneAsKafkaKeyWhenVinIsUnknown() {
|
||||
@SuppressWarnings("unchecked")
|
||||
KafkaProducer<String, byte[]> producer = mock(KafkaProducer.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<ProducerRecord<String, byte[]>> sent = ArgumentCaptor.forClass(ProducerRecord.class);
|
||||
when(producer.send(sent.capture(), any())).thenAnswer(invocation -> {
|
||||
invocation.<Callback>getArgument(1).onCompletion(null, null);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
});
|
||||
KafkaEventSink sink = new KafkaEventSink(
|
||||
producer,
|
||||
new EnvelopeMapper("node-1"),
|
||||
new TopicRouter(new SinkMqProperties.Topics()),
|
||||
"vehicle.dlq.gb32960.v1",
|
||||
CircuitBreaker.ofDefaults("kafka-test"));
|
||||
|
||||
sink.publish(jt808Location()).join();
|
||||
|
||||
assertThat(sent.getValue().key()).isEqualTo("phone:13079979000");
|
||||
}
|
||||
|
||||
private static VehicleEvent.RawArchive rawArchive() {
|
||||
return new VehicleEvent.RawArchive(
|
||||
"raw-1",
|
||||
@@ -37,4 +68,16 @@ class KafkaEventSinkTest {
|
||||
0,
|
||||
new byte[] {0x23, 0x23});
|
||||
}
|
||||
|
||||
private static VehicleEvent.Location jt808Location() {
|
||||
return new VehicleEvent.Location(
|
||||
"loc-1",
|
||||
"unknown",
|
||||
ProtocolId.JT808,
|
||||
Instant.parse("2026-06-29T11:22:00Z"),
|
||||
Instant.parse("2026-06-29T11:22:01Z"),
|
||||
"trace-loc-1",
|
||||
Map.of("phone", "13079979000"),
|
||||
new LocationPayload(121.1, 31.2, 8.0, 40.0, 90.0, 0L, 1L, null));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user