fix: delay jt808 ack until durable dispatch
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
lingniu
2026-06-29 18:07:00 +08:00
parent 705b507f88
commit bf09f4e9b2
2 changed files with 172 additions and 17 deletions

View File

@@ -25,6 +25,7 @@ import com.lingniu.ingest.session.SessionStore;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.AttributeKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -34,6 +35,7 @@ import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* JT808 Netty 入站处理器。职责:
@@ -49,6 +51,8 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
private static final Logger log = LoggerFactory.getLogger(Jt808ChannelHandler.class);
private static final String PROCESSING_ERROR_PREFIX = "processing failed: ";
private static final AttributeKey<CompletableFuture<Void>> DURABLE_ACK_CHAIN_ATTR =
AttributeKey.valueOf("jt808.durableAckChain");
private final Jt808MessageDecoder decoder;
private final Dispatcher dispatcher;
@@ -119,25 +123,21 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
// ====== 会话维护 & 同步应答匹配 ======
String phone = msg.header().phone();
IdentityResolution identity = resolveIdentity(msg);
byte[] durableAck = null;
switch (msg.header().messageId()) {
case Jt808MessageId.TERMINAL_REGISTER -> {
DeviceSession session = upsertSession(ctx, msg, identity.identity());
channelRegistry.bind(phone, ctx.channel());
byte[] registerAck = Jt808FrameEncoder.encode(
Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).messageId(),
phone,
channelRegistry.nextSerial(phone),
Jt808Commands.registerAck(msg.header().serialNo(), 0, session.token()).body());
ctx.writeAndFlush(Unpooled.wrappedBuffer(registerAck));
durableAck = registerAckFrame(phone, msg.header().serialNo(), session.token());
}
case Jt808MessageId.TERMINAL_AUTH -> {
upsertSession(ctx, msg, identity.identity());
channelRegistry.bind(phone, ctx.channel());
ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0);
durableAck = terminalAckFrame(phone, msg.header().serialNo(), msg.header().messageId(), 0);
}
case Jt808MessageId.TERMINAL_HEARTBEAT -> {
channelRegistry.bind(phone, ctx.channel());
ackTerminal(ctx, phone, msg.header().serialNo(), msg.header().messageId(), 0);
durableAck = terminalAckFrame(phone, msg.header().serialNo(), msg.header().messageId(), 0);
}
case Jt808MessageId.TERMINAL_GENERAL_RESPONSE -> {
// body: ackSerial(2) + ackMsgId(2) + result(1)
@@ -173,7 +173,63 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
frame,
meta,
Instant.now());
dispatcher.dispatch(rf);
if (durableAck == null) {
dispatcher.dispatch(rf);
return;
}
enqueueDurableAck(ctx, phone, msg.header().messageId(), dispatcher.dispatchAndAwait(rf), durableAck);
}
private void enqueueDurableAck(ChannelHandlerContext ctx,
String phone,
int messageId,
CompletableFuture<Void> dispatchFuture,
byte[] ackFrame) {
CompletableFuture<Void> previous = ctx.channel().attr(DURABLE_ACK_CHAIN_ATTR).get();
if (previous == null) {
previous = CompletableFuture.completedFuture(null);
}
CompletableFuture<Void> next = previous
.handle((ignored, previousFailure) -> null)
.thenCompose(ignored -> dispatchFuture.handle((ok, failure) -> failure))
.thenCompose(failure -> runOnEventLoop(ctx,
() -> handleDurableAckResult(ctx, phone, messageId, failure, ackFrame)));
ctx.channel().attr(DURABLE_ACK_CHAIN_ATTR).set(next);
}
private CompletableFuture<Void> runOnEventLoop(ChannelHandlerContext ctx, Runnable task) {
CompletableFuture<Void> done = new CompletableFuture<>();
Runnable wrapped = () -> {
try {
task.run();
done.complete(null);
} catch (Throwable t) {
done.completeExceptionally(t);
}
};
if (ctx.executor().inEventLoop()) {
wrapped.run();
} else {
ctx.executor().execute(wrapped);
}
return done;
}
private void handleDurableAckResult(ChannelHandlerContext ctx,
String phone,
int messageId,
Throwable failure,
byte[] ackFrame) {
if (!ctx.channel().isActive()) {
return;
}
if (failure != null) {
log.warn("[jt808] dispatch failed before ack peer={} phone={} msgId=0x{}",
addr(ctx), phone, Integer.toHexString(messageId), failure);
ctx.close();
return;
}
ctx.writeAndFlush(Unpooled.wrappedBuffer(ackFrame));
}
private IdentityResolution resolveIdentity(Jt808Message msg) {
@@ -341,10 +397,14 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
return value == null ? "" : value;
}
private void ackTerminal(ChannelHandlerContext ctx, String phone, int ackSerial, int ackMsgId, int result) {
private byte[] registerAckFrame(String phone, int ackSerial, String token) {
var cmd = Jt808Commands.registerAck(ackSerial, 0, token);
return Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body());
}
private byte[] terminalAckFrame(String phone, int ackSerial, int ackMsgId, int result) {
var cmd = Jt808Commands.platformAck(ackSerial, ackMsgId, result);
byte[] frame = Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body());
ctx.writeAndFlush(Unpooled.wrappedBuffer(frame));
return Jt808FrameEncoder.encode(cmd.messageId(), phone, channelRegistry.nextSerial(phone), cmd.body());
}
private static String generateToken(String phone) {

View File

@@ -29,6 +29,7 @@ import com.lingniu.ingest.protocol.jt808.model.Jt808MessageId;
import com.lingniu.ingest.protocol.jt808.session.Jt808ChannelRegistry;
import com.lingniu.ingest.protocol.jt808.session.Jt808PendingRequests;
import com.lingniu.ingest.session.InMemorySessionStore;
import io.netty.buffer.ByteBuf;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
@@ -49,7 +50,7 @@ class Jt808ChannelHandlerTest {
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
identity.bind(new VehicleIdentityBinding(
ProtocolId.JT808, "LNVIN000000000808", "123456789012", "DEV808", "B80808"));
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
Dispatcher dispatcher = new Dispatcher(
new HandlerRegistry(),
@@ -88,7 +89,7 @@ class Jt808ChannelHandlerTest {
void unresolvedRegisterDoesNotBindUnknownVinToExternalIdentifiers() {
InMemorySessionStore sessions = new InMemorySessionStore();
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
Dispatcher dispatcher = new Dispatcher(
new HandlerRegistry(),
@@ -129,7 +130,7 @@ class Jt808ChannelHandlerTest {
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
identity.bind(new VehicleIdentityBinding(
ProtocolId.JT808, "LNVIN000000AUTH01", "123456789012", "123456789012345", ""));
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
Dispatcher dispatcher = new Dispatcher(
new HandlerRegistry(),
@@ -161,10 +162,48 @@ class Jt808ChannelHandlerTest {
eventBus.close();
}
@Test
void registerAckWaitsForDispatchDurability() {
ControlledSink sink = new ControlledSink();
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
Dispatcher dispatcher = new Dispatcher(
new HandlerRegistry(),
new InterceptorChain(List.of()),
new HandlerInvoker(),
eventBus,
batchExecutor);
Jt808ChannelHandler handler = new Jt808ChannelHandler(
new Jt808MessageDecoder(new BodyParserRegistry(List.of(new RegisterBodyParser()))),
dispatcher,
new InMemorySessionStore(),
new InMemoryVehicleIdentityService(),
new Jt808ChannelRegistry(),
new Jt808PendingRequests());
EmbeddedChannel channel = new EmbeddedChannel(handler);
channel.writeInbound(buildFrame(
Jt808MessageId.TERMINAL_REGISTER,
"123456789012",
1,
buildRegisterBody("DEV808", "B80808")));
assertThat((Object) channel.readOutbound()).isNull();
sink.awaitPublishCount(1);
sink.future(0).complete(null);
ByteBuf ack = readOutboundAfterRunningTasks(channel);
assertThat(ack).isNotNull();
ack.release();
batchExecutor.close();
eventBus.close();
}
@Test
void inactiveChannelRemovesSessionStoreEntry() {
InMemorySessionStore sessions = new InMemorySessionStore();
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of());
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(new ImmediateKafkaSink()));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
Dispatcher dispatcher = new Dispatcher(
new HandlerRegistry(),
@@ -596,6 +635,62 @@ class Jt808ChannelHandlerTest {
os.write((int) (v & 0xFF));
}
private static ByteBuf readOutboundAfterRunningTasks(EmbeddedChannel channel) {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
channel.runPendingTasks();
ByteBuf outbound = channel.readOutbound();
if (outbound != null) {
return outbound;
}
Thread.onSpinWait();
}
return null;
}
private static final class ControlledSink implements EventSink {
private final List<CompletableFuture<Void>> futures = new CopyOnWriteArrayList<>();
@Override
public String name() {
return "kafka";
}
@Override
public CompletableFuture<Void> publish(VehicleEvent event) {
CompletableFuture<Void> future = new CompletableFuture<>();
futures.add(future);
return future;
}
private CompletableFuture<Void> future(int index) {
return futures.get(index);
}
private void awaitPublishCount(int expected) {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
if (futures.size() >= expected) {
return;
}
Thread.onSpinWait();
}
throw new AssertionError("expected " + expected + " sink publishes, actual=" + futures.size());
}
}
private static final class ImmediateKafkaSink implements EventSink {
@Override
public String name() {
return "kafka";
}
@Override
public CompletableFuture<Void> publish(VehicleEvent event) {
return CompletableFuture.completedFuture(null);
}
}
private static final class RecordingSink implements EventSink {
private final List<VehicleEvent> events = new CopyOnWriteArrayList<>();
private final CountDownLatch latch;
@@ -606,7 +701,7 @@ class Jt808ChannelHandlerTest {
@Override
public String name() {
return "recording";
return "kafka";
}
@Override