feat: make gb32960 archive history query production ready

This commit is contained in:
kkfluous
2026-06-23 11:55:44 +08:00
parent b14871ff1c
commit ba68ffe061
462 changed files with 23639 additions and 2341 deletions

View File

@@ -0,0 +1,203 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.core.dispatcher.HandlerDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
/**
* {@link AsyncBatch} 注解的执行器:把 Handler 方法从"单条调用"变成"批量调用"。
*
* <p>每个 Handler 方法对应一个 {@link Batcher},背后是一个固定容量的 {@link BlockingQueue}
* 和 {@link AsyncBatch#poolSize()} 条守护虚拟线程。批量触发条件:
* <ul>
* <li>累积 {@link AsyncBatch#size()} 条立即 flush
* <li>从第一条入队起等待 {@link AsyncBatch#waitMs()} 毫秒后强制 flush
* </ul>
*
* <p>目标 Handler 方法签名约定:
* <pre>{@code
* @MessageMapping(...)
* @AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2)
* public List<VehicleEvent> onBatch(List<PayloadType> batch) { ... }
* }</pre>
*
* <p>flush 产出的事件由构造器注入的 {@code eventPublisher} 异步投递(通常是
* {@link DisruptorEventBus#publish}),不阻塞 Netty EventLoop。
*/
public final class AsyncBatchExecutor implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(AsyncBatchExecutor.class);
private final Consumer<VehicleEvent> eventPublisher;
private final ConcurrentMap<Method, Batcher> batchers = new ConcurrentHashMap<>();
public AsyncBatchExecutor(Consumer<VehicleEvent> eventPublisher) {
this.eventPublisher = eventPublisher;
}
/** 供 Dispatcher 调用:把单条消息交给目标 Handler 的 batcher。 */
public void submit(HandlerDefinition def, Object message) {
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
b.offer(new BatchItem(message, UnaryOperator.identity(), false));
}
public void submit(HandlerDefinition def, Object message, UnaryOperator<VehicleEvent> eventTransformer) {
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
b.offer(new BatchItem(message, eventTransformer == null ? UnaryOperator.identity() : eventTransformer, true));
}
@Override
public void close() {
batchers.values().forEach(Batcher::close);
batchers.clear();
log.info("AsyncBatchExecutor closed");
}
// ===== internals =====
private static final class Batcher implements AutoCloseable {
private final HandlerDefinition def;
private final Consumer<VehicleEvent> publisher;
private final int batchSize;
private final long waitMs;
private final BlockingQueue<BatchItem> queue;
private final Thread[] workers;
private volatile boolean running = true;
Batcher(HandlerDefinition def, Consumer<VehicleEvent> publisher) {
AsyncBatch cfg = def.asyncBatch();
this.def = def;
this.publisher = publisher;
this.batchSize = Math.max(1, cfg.size());
this.waitMs = Math.max(1, cfg.waitMs());
this.queue = new ArrayBlockingQueue<>(Math.max(batchSize * 4, 16));
int pool = Math.max(1, cfg.poolSize());
this.workers = new Thread[pool];
for (int i = 0; i < pool; i++) {
Thread t = Thread.ofVirtual()
.name("batcher-" + def.method().getName() + "-" + i)
.unstarted(this::loop);
workers[i] = t;
t.start();
}
log.info("batcher started method={} size={} waitMs={} pool={}",
def.method().getName(), batchSize, waitMs, pool);
}
void offer(BatchItem item) {
try {
if (!queue.offer(item, 100, TimeUnit.MILLISECONDS)) {
log.warn("batcher queue full, dropping item for {}", def.method().getName());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void loop() {
while (running) {
try {
BatchItem first = queue.poll(200, TimeUnit.MILLISECONDS);
if (first == null) continue;
List<BatchItem> buf = new ArrayList<>(batchSize);
buf.add(first);
long deadlineNanos = System.nanoTime() + waitMs * 1_000_000L;
while (buf.size() < batchSize) {
long remain = deadlineNanos - System.nanoTime();
if (remain <= 0) break;
BatchItem next = queue.poll(remain, TimeUnit.NANOSECONDS);
if (next == null) break;
buf.add(next);
}
flush(buf);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
log.error("batcher loop error method={}", def.method().getName(), e);
}
}
}
private void flush(List<BatchItem> buf) {
if (requiresPerItemTransform(buf)) {
for (BatchItem item : buf) {
flushTransformedItem(item);
}
return;
}
publishEvents(invoke(buf.stream().map(BatchItem::message).toList()), UnaryOperator.identity());
}
private boolean requiresPerItemTransform(List<BatchItem> buf) {
for (BatchItem item : buf) {
if (item.transformRequired()) {
return true;
}
}
return false;
}
private void flushTransformedItem(BatchItem item) {
publishEvents(invoke(List.of(item.message())), item.eventTransformer());
}
@SuppressWarnings("unchecked")
private List<VehicleEvent> invoke(List<Object> messages) {
List<VehicleEvent> events;
try {
Object result = def.method().invoke(def.bean(), messages);
events = switch (result) {
case null -> List.of();
case List<?> list -> (List<VehicleEvent>) list;
case VehicleEvent e -> List.of(e);
default -> throw new IllegalStateException(
"@AsyncBatch method must return List<VehicleEvent>: " + def.method());
};
} catch (InvocationTargetException e) {
log.error("batcher invoke failed method={}", def.method().getName(), e.getCause());
return List.of();
} catch (IllegalAccessException e) {
log.error("batcher access denied method={}", def.method().getName(), e);
return List.of();
}
return events;
}
private void publishEvents(List<VehicleEvent> events, UnaryOperator<VehicleEvent> eventTransformer) {
for (VehicleEvent e : events) {
try {
publisher.accept(eventTransformer.apply(e));
} catch (Exception ex) {
log.warn("batcher publish failed eventId={}", e.eventId(), ex);
}
}
}
@Override
public void close() {
running = false;
for (Thread t : workers) t.interrupt();
}
}
private record BatchItem(Object message,
UnaryOperator<VehicleEvent> eventTransformer,
boolean transformRequired) {
}
}

View File

@@ -0,0 +1,87 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.sink.EventSink;
import com.lmax.disruptor.BlockingWaitStrategy;
import com.lmax.disruptor.BusySpinWaitStrategy;
import com.lmax.disruptor.EventHandler;
import com.lmax.disruptor.SleepingWaitStrategy;
import com.lmax.disruptor.WaitStrategy;
import com.lmax.disruptor.YieldingWaitStrategy;
import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.dsl.ProducerType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;
/**
* 基于 Disruptor 的事件总线Dispatcher 投递事件 → RingBuffer → Sink 扇出。
*
* <p>使用虚拟线程作为 Sink 消费线程,避免阻塞 IO 占用平台线程。
* 单 VIN 有序性由上游 Netty + 分区投递保证RingBuffer 不做 per-vin 排序。
*/
public final class DisruptorEventBus implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(DisruptorEventBus.class);
private final Disruptor<VehicleEventSlot> disruptor;
private final AtomicLong published = new AtomicLong();
private final AtomicLong failed = new AtomicLong();
public DisruptorEventBus(int ringBufferSize, String waitStrategyName, List<EventSink> sinks) {
ThreadFactory tf = Thread.ofVirtual().name("ingest-bus-", 0).factory();
this.disruptor = new Disruptor<>(
VehicleEventSlot::new,
ringBufferSize,
tf,
ProducerType.MULTI,
waitStrategy(waitStrategyName));
EventHandler<VehicleEventSlot>[] handlers = sinks.stream()
.map(this::toHandler)
.toArray(EventHandler[]::new);
disruptor.handleEventsWith(handlers);
disruptor.start();
log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}",
ringBufferSize, waitStrategyName, sinks.size());
}
public void publish(VehicleEvent event) {
disruptor.getRingBuffer().publishEvent((slot, seq, e) -> slot.event = e, event);
published.incrementAndGet();
}
@Override
public void close() {
disruptor.shutdown();
log.info("DisruptorEventBus stopped published={} failed={}", published.get(), failed.get());
}
private EventHandler<VehicleEventSlot> toHandler(EventSink sink) {
return (slot, seq, endOfBatch) -> {
VehicleEvent e = slot.event;
if (e == null || !sink.accepts(e)) return;
try {
sink.publish(e).exceptionally(ex -> {
failed.incrementAndGet();
log.warn("sink {} publish failed", sink.name(), ex);
return null;
});
} finally {
if (endOfBatch) slot.clear();
}
};
}
private static WaitStrategy waitStrategy(String name) {
return switch (name == null ? "yielding" : name.toLowerCase()) {
case "blocking" -> new BlockingWaitStrategy();
case "sleeping" -> new SleepingWaitStrategy();
case "busy-spin" -> new BusySpinWaitStrategy();
default -> new YieldingWaitStrategy();
};
}
}

View File

@@ -0,0 +1,12 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.event.VehicleEvent;
/** Disruptor RingBuffer 槽位:持有可变引用,避免每次发布都分配新对象。 */
public final class VehicleEventSlot {
public VehicleEvent event;
public void clear() {
this.event = null;
}
}

View File

@@ -0,0 +1,87 @@
package com.lingniu.ingest.core.config;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.sink.EventSink;
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.dispatcher.AnnotationHandlerBeanPostProcessor;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
import com.lingniu.ingest.core.pipeline.InterceptorChain;
import com.lingniu.ingest.core.pipeline.builtin.DedupInterceptor;
import com.lingniu.ingest.core.pipeline.builtin.RateLimitInterceptor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import java.util.List;
/**
* ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。
*/
@AutoConfiguration
@EnableConfigurationProperties(IngestCoreProperties.class)
public class IngestCoreAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public HandlerRegistry handlerRegistry() {
return new HandlerRegistry();
}
@Bean
@ConditionalOnMissingBean
public HandlerInvoker handlerInvoker() {
return new HandlerInvoker();
}
@Bean
public AnnotationHandlerBeanPostProcessor annotationHandlerBeanPostProcessor(HandlerRegistry registry) {
return new AnnotationHandlerBeanPostProcessor(registry);
}
@Bean
@ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true)
public DedupInterceptor dedupInterceptor(IngestCoreProperties props) {
return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds());
}
@Bean
public RateLimitInterceptor rateLimitInterceptor(IngestCoreProperties props) {
return new RateLimitInterceptor(props.getRateLimit().getPerVinQps(), props.getRateLimit().getMaxVins());
}
@Bean
@ConditionalOnMissingBean
public InterceptorChain interceptorChain(List<IngestInterceptor> interceptors) {
return new InterceptorChain(interceptors);
}
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public DisruptorEventBus disruptorEventBus(IngestCoreProperties props, List<EventSink> sinks) {
return new DisruptorEventBus(
props.getDisruptor().getRingBufferSize(),
props.getDisruptor().getWaitStrategy(),
sinks);
}
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public AsyncBatchExecutor asyncBatchExecutor(DisruptorEventBus eventBus) {
return new AsyncBatchExecutor(eventBus::publish);
}
@Bean
@ConditionalOnMissingBean
public Dispatcher dispatcher(HandlerRegistry registry,
InterceptorChain chain,
HandlerInvoker invoker,
DisruptorEventBus eventBus,
AsyncBatchExecutor batchExecutor) {
return new Dispatcher(registry, chain, invoker, eventBus, batchExecutor);
}
}

View File

@@ -0,0 +1,56 @@
package com.lingniu.ingest.core.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.pipeline")
public class IngestCoreProperties {
private Disruptor disruptor = new Disruptor();
private Dedup dedup = new Dedup();
private RateLimit rateLimit = new RateLimit();
public Disruptor getDisruptor() { return disruptor; }
public void setDisruptor(Disruptor disruptor) { this.disruptor = disruptor; }
public Dedup getDedup() { return dedup; }
public void setDedup(Dedup dedup) { this.dedup = dedup; }
public RateLimit getRateLimit() { return rateLimit; }
public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; }
public static class Disruptor {
private int ringBufferSize = 131072;
private String waitStrategy = "yielding";
private String producerType = "multi";
public int getRingBufferSize() { return ringBufferSize; }
public void setRingBufferSize(int ringBufferSize) { this.ringBufferSize = ringBufferSize; }
public String getWaitStrategy() { return waitStrategy; }
public void setWaitStrategy(String waitStrategy) { this.waitStrategy = waitStrategy; }
public String getProducerType() { return producerType; }
public void setProducerType(String producerType) { this.producerType = producerType; }
}
public static class Dedup {
private boolean enabled = true;
private int cacheSize = 200000;
private long ttlSeconds = 600;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getCacheSize() { return cacheSize; }
public void setCacheSize(int cacheSize) { this.cacheSize = cacheSize; }
public long getTtlSeconds() { return ttlSeconds; }
public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; }
}
public static class RateLimit {
private int perVinQps = 50;
private int maxVins = 100000;
public int getPerVinQps() { return perVinQps; }
public void setPerVinQps(int perVinQps) { this.perVinQps = perVinQps; }
public int getMaxVins() { return maxVins; }
public void setMaxVins(int maxVins) { this.maxVins = maxVins; }
}
}

View File

@@ -0,0 +1,64 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.annotation.IdempotentKey;
import com.lingniu.ingest.api.annotation.MessageMapping;
import com.lingniu.ingest.api.annotation.ProtocolHandler;
import com.lingniu.ingest.api.annotation.RateLimited;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.annotation.AnnotatedElementUtils;
import java.lang.reflect.Method;
/**
* Spring BeanPostProcessor扫描带 {@link ProtocolHandler} 注解的 Bean
* 把每个带 {@link MessageMapping} 的方法注册到 {@link HandlerRegistry}。
*
* <p>替代旧代码里遍布的 {@code if (msgId == 0x0100) ... else if (msgId == 0x0102) ...} 风格。
*/
public class AnnotationHandlerBeanPostProcessor implements BeanPostProcessor {
private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerBeanPostProcessor.class);
private final HandlerRegistry registry;
public AnnotationHandlerBeanPostProcessor(HandlerRegistry registry) {
this.registry = registry;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
Class<?> type = bean.getClass();
ProtocolHandler classAnno = AnnotatedElementUtils.findMergedAnnotation(type, ProtocolHandler.class);
if (classAnno == null) return bean;
for (Method method : type.getMethods()) {
MessageMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, MessageMapping.class);
if (mapping == null) continue;
Class<?> paramType = method.getParameterCount() > 0 ? method.getParameterTypes()[0] : Object.class;
int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command();
int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType();
RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class);
IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class);
AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class);
for (int cmd : commands) {
for (int info : infoTypes) {
HandlerDefinition def = new HandlerDefinition(
classAnno.protocol(), cmd, info, mapping.desc(),
bean, method, paramType, rl, ik, ab);
registry.register(def);
log.info("registered handler {} protocol={} command=0x{} info=0x{}",
type.getSimpleName() + "#" + method.getName(),
classAnno.protocol(), Integer.toHexString(cmd), Integer.toHexString(info));
}
}
}
return bean;
}
}

View File

@@ -0,0 +1,183 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.event.RawArchiveKeys;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.pipeline.InterceptorChain;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
/**
* 核心分发器RawFrame → 拦截链 → Handler → 事件 → Disruptor EventBus。
*
* <p>所有 Inbound AdapterNetty / MQTT / PushClient都调用 {@link #dispatch(RawFrame)}
* 协议差异在这里被彻底抹平。
*
* <p>对于带 {@code @AsyncBatch} 的 HandlerDispatcher 不直接反射调用,而是把消息交给
* {@link AsyncBatchExecutor} 进行聚合 → 批量调用 → 异步发布事件。
*/
public final class Dispatcher {
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
private static final AtomicLong RAW_ARCHIVE_SEQUENCE = new AtomicLong();
private final HandlerRegistry registry;
private final InterceptorChain interceptors;
private final HandlerInvoker invoker;
private final DisruptorEventBus eventBus;
private final AsyncBatchExecutor batchExecutor;
public Dispatcher(HandlerRegistry registry,
InterceptorChain interceptors,
HandlerInvoker invoker,
DisruptorEventBus eventBus,
AsyncBatchExecutor batchExecutor) {
this.registry = registry;
this.interceptors = interceptors;
this.invoker = invoker;
this.eventBus = eventBus;
this.batchExecutor = batchExecutor;
}
public void dispatch(RawFrame frame) {
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
try {
// 在 interceptor 之前发 RawArchive保证原始字节被无条件落盘dedup/rate-limit
// 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 ArchiveEventSink 消费。
RawArchiveLookup rawArchive = emitRawArchive(frame, ctx);
if (!interceptors.before(frame, ctx)) {
log.debug("frame aborted: {}", ctx.abortReason());
return;
}
List<HandlerDefinition> handlers = registry.resolve(
frame.protocolId(), frame.command(), frame.infoType());
if (handlers.isEmpty()) {
log.debug("no handler for {} cmd=0x{} info=0x{}",
frame.protocolId(), Integer.toHexString(frame.command()),
Integer.toHexString(frame.infoType()));
return;
}
for (HandlerDefinition def : handlers) {
if (def.asyncBatch() != null) {
batchExecutor.submit(def, frame.payload(), event -> enrichWithRawArchive(event, rawArchive));
continue;
}
List<VehicleEvent> events = invoker.invoke(def, frame, ctx);
for (VehicleEvent e : events) {
e = enrichWithRawArchive(e, rawArchive);
interceptors.after(e, ctx);
eventBus.publish(e);
}
}
} catch (Throwable t) {
log.error("dispatch failure traceId={}", ctx.traceId(), t);
interceptors.onError(t, ctx);
}
}
/**
* 从 {@link RawFrame} 构造一条 {@link VehicleEvent.RawArchive} 发到 EventBus。
* 仅在 {@code rawBytes} 非空时发——有些入站适配器(未来)可能只传解析后的对象。
*
* <p>VIN 取自 sourceMeta 里的 {@code vin} key由各入站适配器负责填充缺失时
* 留空字符串archive sink 会用 "unknown-vin" 占位保证 key 可解析。
*/
private RawArchiveLookup emitRawArchive(RawFrame frame, IngestContext ctx) {
byte[] bytes = frame.rawBytes();
if (bytes == null || bytes.length == 0) return RawArchiveLookup.empty();
Map<String, String> meta = frame.sourceMeta() == null ? Map.of() : frame.sourceMeta();
String vin = meta.getOrDefault("vin", "");
Instant ingestTime = frame.receivedAt() != null ? frame.receivedAt() : Instant.now();
String eventId = nextRawArchiveEventId(ingestTime);
String key = RawArchiveKeys.key(ingestTime, frame.protocolId(), vin, eventId);
Map<String, String> archiveMeta = addRawArchiveMetadata(meta, eventId, key);
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
eventId,
vin,
frame.protocolId(),
ingestTime,
ingestTime,
ctx.traceId(),
archiveMeta,
frame.command(),
frame.infoType(),
bytes);
eventBus.publish(raw);
return new RawArchiveLookup(eventId, key, RawArchiveKeys.logicalUri(key));
}
private static String nextRawArchiveEventId(Instant ingestTime) {
long base = (ingestTime == null ? Instant.now() : ingestTime).toEpochMilli() * 1000L;
long next = RAW_ARCHIVE_SEQUENCE.updateAndGet(previous -> Math.max(previous + 1, base));
return Long.toString(next);
}
private static Map<String, String> addRawArchiveMetadata(Map<String, String> metadata,
String eventId,
String key) {
Map<String, String> out = new LinkedHashMap<>(metadata == null ? Map.of() : metadata);
out.putIfAbsent(RawArchiveKeys.META_EVENT_ID, eventId);
out.putIfAbsent(RawArchiveKeys.META_KEY, key);
out.putIfAbsent(RawArchiveKeys.META_URI, RawArchiveKeys.logicalUri(key));
return Map.copyOf(out);
}
private static VehicleEvent enrichWithRawArchive(VehicleEvent event, RawArchiveLookup rawArchive) {
if (event == null || rawArchive.isEmpty() || event instanceof VehicleEvent.RawArchive) {
return event;
}
Map<String, String> metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key());
return switch (event) {
case VehicleEvent.Realtime e -> new VehicleEvent.Realtime(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.payload());
case VehicleEvent.Location e -> new VehicleEvent.Location(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.payload());
case VehicleEvent.Alarm e -> new VehicleEvent.Alarm(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.payload());
case VehicleEvent.Login e -> new VehicleEvent.Login(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.iccid(), e.protocolVersion());
case VehicleEvent.Logout e -> new VehicleEvent.Logout(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata);
case VehicleEvent.Heartbeat e -> new VehicleEvent.Heartbeat(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata);
case VehicleEvent.MediaMeta e -> new VehicleEvent.MediaMeta(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.mediaId(), e.mediaType(), e.sizeBytes(), e.archiveRef());
case VehicleEvent.Passthrough e -> new VehicleEvent.Passthrough(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.passthroughType(), e.data());
case VehicleEvent.RawArchive e -> e;
};
}
private record RawArchiveLookup(String eventId, String key, String uri) {
private static RawArchiveLookup empty() {
return new RawArchiveLookup("", "", "");
}
private boolean isEmpty() {
return key == null || key.isBlank();
}
}
}

View File

@@ -0,0 +1,31 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.annotation.IdempotentKey;
import com.lingniu.ingest.api.annotation.RateLimited;
import java.lang.reflect.Method;
/**
* 一个被注解扫描到的 Handler 方法的静态描述。不可变。
*/
public record HandlerDefinition(
ProtocolId protocol,
int command,
int infoType,
String desc,
Object bean,
Method method,
Class<?> parameterType,
RateLimited rateLimited,
IdempotentKey idempotentKey,
AsyncBatch asyncBatch
) {
public boolean matches(ProtocolId p, int cmd, int info) {
if (p != protocol) return false;
if (command != 0 && command != cmd) return false;
if (infoType != 0 && infoType != info) return false;
return true;
}
}

View File

@@ -0,0 +1,33 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import java.lang.reflect.InvocationTargetException;
import java.util.Collections;
import java.util.List;
/**
* 反射调用 Handler。单独抽出是为了后续可插拔未来可替换为 MethodHandle 或字节码生成。
*/
public class HandlerInvoker {
@SuppressWarnings("unchecked")
public List<VehicleEvent> invoke(HandlerDefinition def, RawFrame frame, IngestContext ctx) {
try {
Object result = def.method().invoke(def.bean(), frame.payload());
return switch (result) {
case null -> Collections.emptyList();
case VehicleEvent e -> List.of(e);
case List<?> list -> (List<VehicleEvent>) list;
default -> throw new IllegalStateException(
"Handler return type must be VehicleEvent or List<VehicleEvent>: " + def.method());
};
} catch (InvocationTargetException e) {
throw new RuntimeException("Handler threw exception: " + def.method(), e.getCause());
} catch (IllegalAccessException e) {
throw new RuntimeException("Handler not accessible: " + def.method(), e);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Handler 注册中心。按 {@code (ProtocolId, command)} 索引O(1) 查找;同 key 可注册多个 Handler
* {@link HandlerDefinition#matches} 进一步精确匹配 {@code infoType}。
*/
public final class HandlerRegistry {
private final Map<RoutingKey, List<HandlerDefinition>> byRoute = new ConcurrentHashMap<>();
private final List<HandlerDefinition> wildcardHandlers = Collections.synchronizedList(new ArrayList<>());
public void register(HandlerDefinition def) {
if (def.command() == 0) {
wildcardHandlers.add(def);
return;
}
byRoute.computeIfAbsent(new RoutingKey(def.protocol(), def.command()),
k -> Collections.synchronizedList(new ArrayList<>())).add(def);
}
public List<HandlerDefinition> resolve(ProtocolId protocol, int command, int infoType) {
List<HandlerDefinition> exact = byRoute.get(new RoutingKey(protocol, command));
List<HandlerDefinition> result = new ArrayList<>();
if (exact != null) {
for (HandlerDefinition d : exact) {
if (d.matches(protocol, command, infoType)) result.add(d);
}
}
if (!result.isEmpty()) {
return result;
}
for (HandlerDefinition d : wildcardHandlers) {
if (d.matches(protocol, command, infoType)) result.add(d);
}
return result;
}
public int size() {
return byRoute.values().stream().mapToInt(List::size).sum() + wildcardHandlers.size();
}
private record RoutingKey(ProtocolId protocol, int command) {}
}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.core.pipeline;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import java.util.ArrayList;
import java.util.List;
/**
* 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。
*/
public final class InterceptorChain {
private final List<IngestInterceptor> interceptors;
public InterceptorChain(List<IngestInterceptor> interceptors) {
List<IngestInterceptor> sorted = new ArrayList<>(interceptors);
AnnotationAwareOrderComparator.sort(sorted);
this.interceptors = List.copyOf(sorted);
}
public boolean before(RawFrame frame, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
if (!i.before(frame, ctx) || ctx.aborted()) {
return false;
}
}
return true;
}
public void after(VehicleEvent event, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
i.after(event, ctx);
}
}
public void onError(Throwable error, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
i.onError(error, ctx);
}
}
}

View File

@@ -0,0 +1,57 @@
package com.lingniu.ingest.core.pipeline.builtin;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import org.springframework.core.Ordered;
import java.util.Arrays;
import java.time.Duration;
/**
* 基于 Caffeine 的本地幂等去重。
*
* <p>Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性,
* 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。
*/
public class DedupInterceptor implements IngestInterceptor, Ordered {
private final Cache<String, Boolean> seen;
public DedupInterceptor(int maxSize, long ttlSeconds) {
this.seen = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(Duration.ofSeconds(ttlSeconds))
.build();
}
@Override
public boolean before(RawFrame frame, IngestContext ctx) {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
String seq = frame.sourceMeta().get("seq");
if (seq == null || seq.isBlank()) {
seq = rawFingerprint(frame);
}
String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq;
if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) {
ctx.abort("duplicate:" + key);
return false;
}
return true;
}
@Override
public int getOrder() {
return 100;
}
private static String rawFingerprint(RawFrame frame) {
byte[] rawBytes = frame.rawBytes();
if (rawBytes != null && rawBytes.length > 0) {
return "raw:" + Integer.toHexString(Arrays.hashCode(rawBytes));
}
return "receivedAt:" + frame.receivedAt();
}
}

View File

@@ -0,0 +1,49 @@
package com.lingniu.ingest.core.pipeline.builtin;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import org.springframework.core.Ordered;
import java.time.Duration;
/**
* 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter由 Caffeine 按 LRU 管理。
*/
public class RateLimitInterceptor implements IngestInterceptor, Ordered {
private final Cache<String, RateLimiter> limiters;
private final RateLimiterConfig defaultConfig;
public RateLimitInterceptor(int perVinQps, int maxVins) {
this.defaultConfig = RateLimiterConfig.custom()
.limitForPeriod(perVinQps)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ZERO)
.build();
this.limiters = Caffeine.newBuilder()
.maximumSize(maxVins)
.expireAfterAccess(Duration.ofMinutes(10))
.build();
}
@Override
public boolean before(RawFrame frame, IngestContext ctx) {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig));
if (!rl.acquirePermission()) {
ctx.abort("rate-limited:" + vin);
return false;
}
return true;
}
@Override
public int getOrder() {
return 200;
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.core.config.IngestCoreAutoConfiguration

View File

@@ -0,0 +1,218 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.annotation.MessageMapping;
import com.lingniu.ingest.api.annotation.ProtocolHandler;
import com.lingniu.ingest.api.event.LocationPayload;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import com.lingniu.ingest.api.sink.EventSink;
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.pipeline.InterceptorChain;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static org.assertj.core.api.Assertions.assertThat;
class DispatcherRawArchiveMetadataTest {
@Test
void enrichesBusinessEventsWithRawArchiveLookupMetadata() throws Exception {
CollectingSink sink = new CollectingSink();
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
try {
Dispatcher dispatcher = new Dispatcher(
registryWithLocationHandler(),
new InterceptorChain(List.of()),
new HandlerInvoker(),
eventBus,
batchExecutor);
dispatcher.dispatch(new RawFrame(
ProtocolId.JT808,
0x0200,
0,
new Object(),
new byte[]{0x7e, 0x01, 0x7e},
Map.of("vin", "VIN001", "peer", "127.0.0.1:10001"),
Instant.parse("2026-06-22T08:00:00Z")));
VehicleEvent.RawArchive raw = awaitEvent(sink, VehicleEvent.RawArchive.class);
VehicleEvent.Location location = awaitEvent(sink, VehicleEvent.Location.class);
String rawArchiveKey = raw.metadata().get("rawArchiveKey");
assertThat(rawArchiveKey).isNotBlank();
assertThat(raw.eventId()).containsOnlyDigits();
assertThat(rawArchiveKey).endsWith("/" + raw.eventId() + ".bin");
assertThat(location.metadata())
.containsEntry("rawArchiveEventId", raw.eventId())
.containsEntry("rawArchiveKey", rawArchiveKey)
.containsEntry("rawArchiveUri", "archive://" + rawArchiveKey);
} finally {
batchExecutor.close();
eventBus.close();
}
}
@Test
void enrichesAsyncBatchEventsWithTheirRawArchiveLookupMetadata() throws Exception {
CollectingSink sink = new CollectingSink();
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
try {
Dispatcher dispatcher = new Dispatcher(
registryWithAsyncLocationHandler(),
new InterceptorChain(List.of()),
new HandlerInvoker(),
eventBus,
batchExecutor);
dispatcher.dispatch(new RawFrame(
ProtocolId.JT808,
0x0201,
0,
"payload-1",
new byte[]{0x7e, 0x02, 0x7e},
Map.of("vin", "VIN002"),
Instant.parse("2026-06-22T08:01:00Z")));
VehicleEvent.RawArchive raw = awaitEvent(sink, VehicleEvent.RawArchive.class);
VehicleEvent.Location location = awaitLocationEvent(sink, "async-location-1");
String rawArchiveKey = raw.metadata().get("rawArchiveKey");
assertThat(location.metadata())
.containsEntry("rawArchiveEventId", raw.eventId())
.containsEntry("rawArchiveKey", rawArchiveKey)
.containsEntry("rawArchiveUri", "archive://" + rawArchiveKey);
} finally {
batchExecutor.close();
eventBus.close();
}
}
private static HandlerRegistry registryWithLocationHandler() throws NoSuchMethodException {
LocationHandler bean = new LocationHandler();
Method method = LocationHandler.class.getDeclaredMethod("onLocation", Object.class);
HandlerRegistry registry = new HandlerRegistry();
registry.register(new HandlerDefinition(
ProtocolId.JT808,
0x0200,
0,
"test-location",
bean,
method,
Object.class,
null,
null,
null));
return registry;
}
private static HandlerRegistry registryWithAsyncLocationHandler() throws NoSuchMethodException {
AsyncLocationHandler bean = new AsyncLocationHandler();
Method method = AsyncLocationHandler.class.getDeclaredMethod("onLocationBatch", List.class);
HandlerRegistry registry = new HandlerRegistry();
registry.register(new HandlerDefinition(
ProtocolId.JT808,
0x0201,
0,
"test-async-location",
bean,
method,
Object.class,
null,
null,
method.getAnnotation(AsyncBatch.class)));
return registry;
}
private static <T extends VehicleEvent> T awaitEvent(CollectingSink sink, Class<T> type) throws InterruptedException {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
synchronized (sink.events) {
for (VehicleEvent event : sink.events) {
if (type.isInstance(event)) {
return type.cast(event);
}
}
}
Thread.sleep(25);
}
throw new AssertionError("event not published: " + type.getSimpleName() + ", actual=" + sink.events);
}
private static VehicleEvent.Location awaitLocationEvent(CollectingSink sink, String eventId) throws InterruptedException {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
synchronized (sink.events) {
for (VehicleEvent event : sink.events) {
if (event instanceof VehicleEvent.Location location && location.eventId().equals(eventId)) {
return location;
}
}
}
Thread.sleep(25);
}
throw new AssertionError("location event not published: " + eventId + ", actual=" + sink.events);
}
@ProtocolHandler(protocol = ProtocolId.JT808)
static final class LocationHandler {
@MessageMapping(command = 0x0200)
VehicleEvent.Location onLocation(Object ignored) {
return new VehicleEvent.Location(
"location-1",
"VIN001",
ProtocolId.JT808,
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"trace-location",
Map.of(),
new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1));
}
}
@ProtocolHandler(protocol = ProtocolId.JT808)
public static final class AsyncLocationHandler {
@MessageMapping(command = 0x0201)
@AsyncBatch(size = 2, waitMs = 10, poolSize = 1)
public List<VehicleEvent> onLocationBatch(List<Object> batch) {
return List.of(new VehicleEvent.Location(
"async-location-1",
"VIN002",
ProtocolId.JT808,
Instant.parse("2026-06-22T08:01:00Z"),
Instant.parse("2026-06-22T08:01:01Z"),
"trace-async-location",
Map.of(),
new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1)));
}
}
private static final class CollectingSink implements EventSink {
private final List<VehicleEvent> events = new ArrayList<>();
@Override
public String name() {
return "collecting";
}
@Override
public CompletableFuture<Void> publish(VehicleEvent event) {
synchronized (events) {
events.add(event);
}
return CompletableFuture.completedFuture(null);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class HandlerRegistryTest {
@Test
void exactCommandMatchTakesPrecedenceOverWildcardHandlers() throws Exception {
HandlerRegistry registry = new HandlerRegistry();
Object bean = new SampleHandlers();
Method exact = SampleHandlers.class.getMethod("exact", Object.class);
Method wildcard = SampleHandlers.class.getMethod("wildcard", Object.class);
HandlerDefinition exactDef = definition(0x0200, "exact", bean, exact);
HandlerDefinition wildcardDef = definition(0, "wildcard", bean, wildcard);
registry.register(exactDef);
registry.register(wildcardDef);
assertThat(registry.resolve(ProtocolId.XINDA_PUSH, 0x0200, 0))
.containsExactly(exactDef);
assertThat(registry.resolve(ProtocolId.XINDA_PUSH, 0x0500, 0))
.containsExactly(wildcardDef);
}
private static HandlerDefinition definition(int command, String desc, Object bean, Method method) {
return new HandlerDefinition(
ProtocolId.XINDA_PUSH,
command,
0,
desc,
bean,
method,
Object.class,
null,
null,
null);
}
public static final class SampleHandlers {
public void exact(Object ignored) {
}
public void wildcard(Object ignored) {
}
}
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.core.pipeline.builtin;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class DedupInterceptorTest {
@Test
void usesRawFingerprintWhenSequenceIsMissing() {
DedupInterceptor interceptor = new DedupInterceptor(100, 60);
IngestContext ctx = new IngestContext("trace-1");
RawFrame first = frame(new byte[]{0x23, 0x23, 0x02, 0x01});
RawFrame differentPayload = frame(new byte[]{0x23, 0x23, 0x02, 0x02});
RawFrame duplicate = frame(new byte[]{0x23, 0x23, 0x02, 0x01});
assertThat(interceptor.before(first, ctx)).isTrue();
assertThat(interceptor.before(differentPayload, ctx)).isTrue();
assertThat(interceptor.before(duplicate, ctx)).isFalse();
assertThat(ctx.aborted()).isTrue();
}
private static RawFrame frame(byte[] rawBytes) {
return new RawFrame(
ProtocolId.GB32960,
0x02,
0,
new Object(),
rawBytes,
Map.of("vin", "TESTSTATVIN000001"),
Instant.parse("2026-06-22T13:00:00Z"));
}
}