fix: enforce gb32960 kafka ack boundary
This commit is contained in:
@@ -13,8 +13,12 @@ import com.lmax.disruptor.dsl.ProducerType;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ThreadFactory;
|
import java.util.concurrent.ThreadFactory;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@@ -30,11 +34,13 @@ public final class DisruptorEventBus implements AutoCloseable {
|
|||||||
|
|
||||||
private final Disruptor<VehicleEventSlot> disruptor;
|
private final Disruptor<VehicleEventSlot> disruptor;
|
||||||
private final List<EventSink> sinks;
|
private final List<EventSink> sinks;
|
||||||
|
private final ExecutorService awaitExecutor;
|
||||||
private final AtomicLong published = new AtomicLong();
|
private final AtomicLong published = new AtomicLong();
|
||||||
private final AtomicLong failed = new AtomicLong();
|
private final AtomicLong failed = new AtomicLong();
|
||||||
|
|
||||||
public DisruptorEventBus(int ringBufferSize, String waitStrategyName, List<EventSink> sinks) {
|
public DisruptorEventBus(int ringBufferSize, String waitStrategyName, List<EventSink> sinks) {
|
||||||
this.sinks = List.copyOf(sinks);
|
this.sinks = List.copyOf(sinks);
|
||||||
|
this.awaitExecutor = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
ThreadFactory tf = Thread.ofVirtual().name("ingest-bus-", 0).factory();
|
ThreadFactory tf = Thread.ofVirtual().name("ingest-bus-", 0).factory();
|
||||||
this.disruptor = new Disruptor<>(
|
this.disruptor = new Disruptor<>(
|
||||||
VehicleEventSlot::new,
|
VehicleEventSlot::new,
|
||||||
@@ -58,21 +64,31 @@ public final class DisruptorEventBus implements AutoCloseable {
|
|||||||
published.incrementAndGet();
|
published.incrementAndGet();
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Void> publishAndAwait(VehicleEvent event) {
|
public CompletableFuture<Void> publishAndAwait(VehicleEvent event, String requiredSinkName) {
|
||||||
|
String requiredSink = Objects.requireNonNull(requiredSinkName, "requiredSinkName");
|
||||||
published.incrementAndGet();
|
published.incrementAndGet();
|
||||||
List<CompletableFuture<Void>> futures = sinks.stream()
|
List<CompletableFuture<Void>> requiredFutures = new ArrayList<>();
|
||||||
.filter(sink -> sink.accepts(event))
|
for (EventSink sink : sinks) {
|
||||||
.map(sink -> publishToSinkAndTrack(sink, event))
|
if (!sink.accepts(event)) continue;
|
||||||
.toList();
|
|
||||||
if (futures.isEmpty()) {
|
CompletableFuture<Void> future = CompletableFuture
|
||||||
return CompletableFuture.completedFuture(null);
|
.supplyAsync(() -> publishToSinkAndTrack(sink, event), awaitExecutor)
|
||||||
|
.thenCompose(f -> f);
|
||||||
|
if (requiredSink.equals(sink.name())) {
|
||||||
|
requiredFutures.add(future);
|
||||||
}
|
}
|
||||||
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
|
}
|
||||||
|
if (requiredFutures.isEmpty()) {
|
||||||
|
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||||
|
"required sink '" + requiredSink + "' did not accept event " + event.eventId()));
|
||||||
|
}
|
||||||
|
return CompletableFuture.allOf(requiredFutures.toArray(CompletableFuture[]::new));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
disruptor.shutdown();
|
disruptor.shutdown();
|
||||||
|
awaitExecutor.shutdown();
|
||||||
log.info("DisruptorEventBus stopped published={} failed={}", published.get(), failed.get());
|
log.info("DisruptorEventBus stopped published={} failed={}", published.get(), failed.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public final class Dispatcher {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
|
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
|
||||||
private static final AtomicLong RAW_ARCHIVE_SEQUENCE = new AtomicLong();
|
private static final AtomicLong RAW_ARCHIVE_SEQUENCE = new AtomicLong();
|
||||||
|
private static final String REQUIRED_DURABLE_SINK = "kafka";
|
||||||
|
|
||||||
private final HandlerRegistry registry;
|
private final HandlerRegistry registry;
|
||||||
private final InterceptorChain interceptors;
|
private final InterceptorChain interceptors;
|
||||||
@@ -57,7 +58,7 @@ public final class Dispatcher {
|
|||||||
public void dispatch(RawFrame frame) {
|
public void dispatch(RawFrame frame) {
|
||||||
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
|
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
|
||||||
try {
|
try {
|
||||||
DispatchPlan plan = mapFrameToEvents(frame, ctx);
|
DispatchPlan plan = mapFrameToEvents(frame, ctx, false);
|
||||||
for (VehicleEvent e : plan.events()) {
|
for (VehicleEvent e : plan.events()) {
|
||||||
eventBus.publish(e);
|
eventBus.publish(e);
|
||||||
}
|
}
|
||||||
@@ -73,15 +74,21 @@ public final class Dispatcher {
|
|||||||
public CompletableFuture<Void> dispatchAndAwait(RawFrame frame) {
|
public CompletableFuture<Void> dispatchAndAwait(RawFrame frame) {
|
||||||
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
|
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
|
||||||
try {
|
try {
|
||||||
DispatchPlan plan = mapFrameToEvents(frame, ctx);
|
DispatchPlan plan = mapFrameToEvents(frame, ctx, true);
|
||||||
|
if (plan.events().isEmpty() && plan.failure() == null) {
|
||||||
|
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||||
|
"awaited dispatch produced no events; required sink '" + REQUIRED_DURABLE_SINK + "' was not reached"));
|
||||||
|
}
|
||||||
CompletableFuture<?>[] futures = plan.events().stream()
|
CompletableFuture<?>[] futures = plan.events().stream()
|
||||||
.map(eventBus::publishAndAwait)
|
.map(event -> eventBus.publishAndAwait(event, REQUIRED_DURABLE_SINK))
|
||||||
.toArray(CompletableFuture[]::new);
|
.toArray(CompletableFuture[]::new);
|
||||||
CompletableFuture<Void> boundary = CompletableFuture.allOf(futures);
|
CompletableFuture<Void> boundary = CompletableFuture.allOf(futures);
|
||||||
if (plan.failure() == null) {
|
if (plan.failure() == null) {
|
||||||
return boundary;
|
return boundary;
|
||||||
}
|
}
|
||||||
return boundary.handle((ignored, publishFailure) -> {
|
return boundary.handle((ignored, publishFailure) -> {
|
||||||
|
log.error("dispatch failure traceId={}", ctx.traceId(), plan.failure());
|
||||||
|
interceptors.onError(plan.failure(), ctx);
|
||||||
if (publishFailure != null) {
|
if (publishFailure != null) {
|
||||||
throw new CompletionException(publishFailure);
|
throw new CompletionException(publishFailure);
|
||||||
}
|
}
|
||||||
@@ -102,7 +109,7 @@ public final class Dispatcher {
|
|||||||
return future.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
return future.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DispatchPlan mapFrameToEvents(RawFrame frame, IngestContext ctx) {
|
private DispatchPlan mapFrameToEvents(RawFrame frame, IngestContext ctx, boolean awaitDurability) {
|
||||||
List<VehicleEvent> out = new ArrayList<>();
|
List<VehicleEvent> out = new ArrayList<>();
|
||||||
// 在 interceptor 之前发 RawArchive,保证原始字节被无条件落盘(dedup/rate-limit
|
// 在 interceptor 之前发 RawArchive,保证原始字节被无条件落盘(dedup/rate-limit
|
||||||
// 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 Sink 消费。
|
// 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 Sink 消费。
|
||||||
@@ -125,6 +132,11 @@ public final class Dispatcher {
|
|||||||
|
|
||||||
for (HandlerDefinition def : handlers) {
|
for (HandlerDefinition def : handlers) {
|
||||||
if (def.asyncBatch() != null) {
|
if (def.asyncBatch() != null) {
|
||||||
|
if (awaitDurability) {
|
||||||
|
throw new AwaitedAsyncBatchUnsupportedException(
|
||||||
|
"@AsyncBatch handler cannot be used with dispatchAndAwait until batch completion is awaited: "
|
||||||
|
+ def.method());
|
||||||
|
}
|
||||||
batchExecutor.submit(def, frame.payload(), event -> enrichWithRawArchive(event, rawArchive));
|
batchExecutor.submit(def, frame.payload(), event -> enrichWithRawArchive(event, rawArchive));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -137,6 +149,9 @@ public final class Dispatcher {
|
|||||||
}
|
}
|
||||||
return new DispatchPlan(out, null);
|
return new DispatchPlan(out, null);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
|
if (t instanceof AwaitedAsyncBatchUnsupportedException) {
|
||||||
|
return new DispatchPlan(List.of(), t);
|
||||||
|
}
|
||||||
return new DispatchPlan(out, t);
|
return new DispatchPlan(out, t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,4 +253,10 @@ public final class Dispatcher {
|
|||||||
|
|
||||||
private record DispatchPlan(List<VehicleEvent> events, Throwable failure) {
|
private record DispatchPlan(List<VehicleEvent> events, Throwable failure) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final class AwaitedAsyncBatchUnsupportedException extends IllegalStateException {
|
||||||
|
private AwaitedAsyncBatchUnsupportedException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.lingniu.ingest.core.concurrency;
|
||||||
|
|
||||||
|
import com.lingniu.ingest.api.ProtocolId;
|
||||||
|
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||||
|
import com.lingniu.ingest.api.sink.EventSink;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DisruptorEventBusAwaitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publishAndAwaitDoesNotInvokeSinkPublishOnCallerThread() throws Exception {
|
||||||
|
long callerThreadId = Thread.currentThread().threadId();
|
||||||
|
CapturingSink sink = new CapturingSink("kafka");
|
||||||
|
|
||||||
|
try (DisruptorEventBus bus = new DisruptorEventBus(1024, "blocking", java.util.List.of(sink))) {
|
||||||
|
bus.publishAndAwait(event(), "kafka").get(3, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(sink.publishThreadId.get()).isNotEqualTo(callerThreadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VehicleEvent.Heartbeat event() {
|
||||||
|
Instant now = Instant.parse("2026-06-23T10:00:00Z");
|
||||||
|
return new VehicleEvent.Heartbeat(
|
||||||
|
"heartbeat-1",
|
||||||
|
"VIN001",
|
||||||
|
ProtocolId.GB32960,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
"trace-1",
|
||||||
|
Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CapturingSink implements EventSink {
|
||||||
|
private final String name;
|
||||||
|
private final AtomicLong publishThreadId = new AtomicLong(-1);
|
||||||
|
|
||||||
|
private CapturingSink(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||||
|
publishThreadId.set(Thread.currentThread().threadId());
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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.VehicleEvent;
|
||||||
|
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||||
|
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
|
||||||
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
|
import java.util.concurrent.CompletionStage;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class DispatcherDurableAckBoundaryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dispatchAndAwaitFailsWhenNoKafkaSinkAcceptsEvent() throws Exception {
|
||||||
|
EventSink fileSink = new NamedSink("event-file-store", true);
|
||||||
|
|
||||||
|
try (Harness harness = new Harness(registryWithHeartbeatHandler(), List.of(fileSink), List.of())) {
|
||||||
|
CompletionStage<Void> future = harness.dispatcher.dispatchAndAwait(frame(0x07, "payload"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> future.toCompletableFuture().join())
|
||||||
|
.isInstanceOf(CompletionException.class)
|
||||||
|
.hasRootCauseInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("kafka");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dispatchAndAwaitNotifiesInterceptorWhenPlanCapturesFailure() throws Exception {
|
||||||
|
CapturingInterceptor interceptor = new CapturingInterceptor();
|
||||||
|
|
||||||
|
try (Harness harness = new Harness(registryWithThrowingHandler(), List.of(new NamedSink("kafka", true)),
|
||||||
|
List.of(interceptor))) {
|
||||||
|
CompletionStage<Void> future = harness.dispatcher.dispatchAndAwait(frame(0x08, "payload"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> future.toCompletableFuture().join())
|
||||||
|
.isInstanceOf(CompletionException.class)
|
||||||
|
.hasRootCauseInstanceOf(IllegalStateException.class)
|
||||||
|
.rootCause()
|
||||||
|
.hasMessageContaining("handler failed");
|
||||||
|
assertThat(interceptor.error.get())
|
||||||
|
.isInstanceOf(RuntimeException.class)
|
||||||
|
.hasMessageContaining("Handler threw exception");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dispatchAndAwaitFailsFastForAsyncBatchHandler() throws Exception {
|
||||||
|
NamedSink kafka = new NamedSink("kafka", true);
|
||||||
|
try (Harness harness = new Harness(registryWithAsyncBatchHandler(), List.of(kafka), List.of())) {
|
||||||
|
CompletionStage<Void> future = harness.dispatcher.dispatchAndAwait(
|
||||||
|
frame(0x09, "payload", new byte[]{0x23, 0x23, 0x09}));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> future.toCompletableFuture().join())
|
||||||
|
.isInstanceOf(CompletionException.class)
|
||||||
|
.hasRootCauseInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("@AsyncBatch");
|
||||||
|
assertThat(kafka.published.get()).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HandlerRegistry registryWithHeartbeatHandler() throws NoSuchMethodException {
|
||||||
|
HeartbeatHandler bean = new HeartbeatHandler();
|
||||||
|
Method method = HeartbeatHandler.class.getDeclaredMethod("onHeartbeat", Object.class);
|
||||||
|
return registry(bean, method, 0x07, method.getAnnotation(AsyncBatch.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HandlerRegistry registryWithThrowingHandler() throws NoSuchMethodException {
|
||||||
|
ThrowingHandler bean = new ThrowingHandler();
|
||||||
|
Method method = ThrowingHandler.class.getDeclaredMethod("onFrame", Object.class);
|
||||||
|
return registry(bean, method, 0x08, method.getAnnotation(AsyncBatch.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HandlerRegistry registryWithAsyncBatchHandler() throws NoSuchMethodException {
|
||||||
|
AsyncHandler bean = new AsyncHandler();
|
||||||
|
Method method = AsyncHandler.class.getDeclaredMethod("onBatch", List.class);
|
||||||
|
return registry(bean, method, 0x09, method.getAnnotation(AsyncBatch.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HandlerRegistry registry(Object bean, Method method, int command, AsyncBatch asyncBatch) {
|
||||||
|
HandlerRegistry registry = new HandlerRegistry();
|
||||||
|
registry.register(new HandlerDefinition(
|
||||||
|
ProtocolId.GB32960,
|
||||||
|
command,
|
||||||
|
0,
|
||||||
|
method.getName(),
|
||||||
|
bean,
|
||||||
|
method,
|
||||||
|
Object.class,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
asyncBatch));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RawFrame frame(int command, Object payload) {
|
||||||
|
return frame(command, payload, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RawFrame frame(int command, Object payload, byte[] rawBytes) {
|
||||||
|
return new RawFrame(
|
||||||
|
ProtocolId.GB32960,
|
||||||
|
command,
|
||||||
|
0,
|
||||||
|
payload,
|
||||||
|
rawBytes,
|
||||||
|
Map.of("vin", "VIN001"),
|
||||||
|
Instant.parse("2026-06-23T10:00:00Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VehicleEvent.Heartbeat event(String id) {
|
||||||
|
Instant now = Instant.parse("2026-06-23T10:00:00Z");
|
||||||
|
return new VehicleEvent.Heartbeat(id, "VIN001", ProtocolId.GB32960, now, now, "trace-" + id, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Harness implements AutoCloseable {
|
||||||
|
private final DisruptorEventBus eventBus;
|
||||||
|
private final AsyncBatchExecutor batchExecutor;
|
||||||
|
private final Dispatcher dispatcher;
|
||||||
|
|
||||||
|
private Harness(HandlerRegistry registry, List<EventSink> sinks, List<IngestInterceptor> interceptors) {
|
||||||
|
this.eventBus = new DisruptorEventBus(1024, "blocking", sinks);
|
||||||
|
this.batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||||
|
this.dispatcher = new Dispatcher(
|
||||||
|
registry,
|
||||||
|
new InterceptorChain(interceptors),
|
||||||
|
new HandlerInvoker(),
|
||||||
|
eventBus,
|
||||||
|
batchExecutor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
batchExecutor.close();
|
||||||
|
eventBus.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NamedSink implements EventSink {
|
||||||
|
private final String name;
|
||||||
|
private final boolean accepts;
|
||||||
|
private final AtomicInteger published = new AtomicInteger();
|
||||||
|
|
||||||
|
private NamedSink(String name, boolean accepts) {
|
||||||
|
this.name = name;
|
||||||
|
this.accepts = accepts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||||
|
published.incrementAndGet();
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean accepts(VehicleEvent event) {
|
||||||
|
return accepts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CapturingInterceptor implements IngestInterceptor {
|
||||||
|
private final AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable error, IngestContext ctx) {
|
||||||
|
this.error.set(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProtocolHandler(protocol = ProtocolId.GB32960)
|
||||||
|
static final class HeartbeatHandler {
|
||||||
|
@MessageMapping(command = 0x07)
|
||||||
|
VehicleEvent.Heartbeat onHeartbeat(Object ignored) {
|
||||||
|
return event("heartbeat-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProtocolHandler(protocol = ProtocolId.GB32960)
|
||||||
|
static final class ThrowingHandler {
|
||||||
|
@MessageMapping(command = 0x08)
|
||||||
|
VehicleEvent.Heartbeat onFrame(Object ignored) {
|
||||||
|
throw new IllegalStateException("handler failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProtocolHandler(protocol = ProtocolId.GB32960)
|
||||||
|
public static final class AsyncHandler {
|
||||||
|
@MessageMapping(command = 0x09)
|
||||||
|
@AsyncBatch(size = 2, waitMs = 10, poolSize = 1)
|
||||||
|
public List<VehicleEvent> onBatch(List<Object> ignored) {
|
||||||
|
return List.of(event("async-1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -55,6 +56,9 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
|||||||
public static final AttributeKey<String> PLATFORM_ACCOUNT_ATTR =
|
public static final AttributeKey<String> PLATFORM_ACCOUNT_ATTR =
|
||||||
Gb32960AccessService.PLATFORM_ACCOUNT_ATTR;
|
Gb32960AccessService.PLATFORM_ACCOUNT_ATTR;
|
||||||
|
|
||||||
|
private static final AttributeKey<CompletableFuture<Void>> DURABLE_ACK_CHAIN_ATTR =
|
||||||
|
AttributeKey.valueOf("gb32960.durableAckChain");
|
||||||
|
|
||||||
private final Gb32960MessageDecoder decoder;
|
private final Gb32960MessageDecoder decoder;
|
||||||
private final Dispatcher dispatcher;
|
private final Dispatcher dispatcher;
|
||||||
private final Gb32960AccessService accessService;
|
private final Gb32960AccessService accessService;
|
||||||
@@ -123,7 +127,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 鉴权通过:先完成命令侧状态变更/日志,再统一 dispatch 到通用管线。
|
// 鉴权通过:先完成命令侧状态变更/日志,再统一 dispatch 到通用管线。
|
||||||
// 需要成功 ACK 的已接收帧,等待 dispatch 的持久化边界完成后再回 ACK。
|
// 需要成功 ACK 的已接收帧,等待配置的 Kafka dispatch 边界完成后再回 ACK。
|
||||||
// 平台登入鉴权失败会短路 return,不进 dispatch。
|
// 平台登入鉴权失败会短路 return,不进 dispatch。
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case VEHICLE_LOGIN -> logVehicleLogin(ctx, msg);
|
case VEHICLE_LOGIN -> logVehicleLogin(ctx, msg);
|
||||||
@@ -143,16 +147,62 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatcher.dispatchAndAwait(rf).whenComplete((ignored, ex) ->
|
enqueueDurableAck(ctx, msg, rawVin, vin, cmd, dispatcher.dispatchAndAwait(rf));
|
||||||
ctx.executor().execute(() -> {
|
}
|
||||||
if (ex != null) {
|
|
||||||
|
private void enqueueDurableAck(ChannelHandlerContext ctx,
|
||||||
|
Gb32960Message msg,
|
||||||
|
byte[] rawVin,
|
||||||
|
String vin,
|
||||||
|
CommandType cmd,
|
||||||
|
CompletableFuture<Void> dispatchFuture) {
|
||||||
|
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, msg, rawVin, vin, cmd, failure)));
|
||||||
|
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,
|
||||||
|
Gb32960Message msg,
|
||||||
|
byte[] rawVin,
|
||||||
|
String vin,
|
||||||
|
CommandType cmd,
|
||||||
|
Throwable failure) {
|
||||||
|
if (!ctx.channel().isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
log.warn("[gb32960] dispatch failed before ack peer={} vin={} cmd=0x{}",
|
log.warn("[gb32960] dispatch failed before ack peer={} vin={} cmd=0x{}",
|
||||||
addr(ctx), vin, Integer.toHexString(cmd.code()), ex);
|
addr(ctx), vin, Integer.toHexString(cmd.code()), failure);
|
||||||
ctx.close();
|
ctx.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeAckForCommand(ctx, msg, rawVin);
|
writeAckForCommand(ctx, msg, rawVin);
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private RawFrame rawFrame(ChannelHandlerContext ctx, Gb32960Message msg, byte[] frame) {
|
private RawFrame rawFrame(ChannelHandlerContext ctx, Gb32960Message msg, byte[] frame) {
|
||||||
@@ -304,11 +354,11 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 0x02/0x03/0x07 实时 / 补发 / 心跳:记录帧诊断;成功 ACK 在 dispatch 持久化边界后写回。
|
* 0x02/0x03/0x07 实时 / 补发 / 心跳:记录帧诊断;成功 ACK 在配置的 Kafka dispatch 边界后写回。
|
||||||
* 部分车端实现严格按规范,没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。
|
* 部分车端实现严格按规范,没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。
|
||||||
*
|
*
|
||||||
* <p>注意:应答成功不代表下游已完成持久化,只表示平台已接收并通过 Dispatcher 投递。
|
* <p>注意:应答成功表示当前配置的 Kafka sink 已完成该帧映射事件的 dispatch 边界。
|
||||||
* 生产排查存储问题时,应继续检查 raw archive sink、EventFileStoreSink 和 Kafka sink 的日志。
|
* 生产排查其他存储问题时,应继续检查 raw archive sink、EventFileStoreSink 等可选 sink 的日志。
|
||||||
*
|
*
|
||||||
* <p>日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏):
|
* <p>日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏):
|
||||||
* <ul>
|
* <ul>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.lingniu.ingest.protocol.gb32960.inbound;
|
|||||||
|
|
||||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||||
import com.lingniu.ingest.api.sink.EventSink;
|
import com.lingniu.ingest.api.sink.EventSink;
|
||||||
|
import com.lingniu.ingest.codec.BccChecksum;
|
||||||
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
|
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
|
||||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||||
@@ -21,6 +22,9 @@ import io.netty.buffer.ByteBuf;
|
|||||||
import io.netty.channel.embedded.EmbeddedChannel;
|
import io.netty.channel.embedded.EmbeddedChannel;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
@@ -36,6 +40,7 @@ class Gb32960ChannelHandlerAckBoundaryTest {
|
|||||||
assertThat(channel.writeInbound(Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000001"))).isFalse();
|
assertThat(channel.writeInbound(Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000001"))).isFalse();
|
||||||
assertThat((Object) channel.readOutbound()).isNull();
|
assertThat((Object) channel.readOutbound()).isNull();
|
||||||
|
|
||||||
|
dispatch.awaitPublishCount(1);
|
||||||
dispatch.completeDurability();
|
dispatch.completeDurability();
|
||||||
channel.runPendingTasks();
|
channel.runPendingTasks();
|
||||||
|
|
||||||
@@ -55,6 +60,7 @@ class Gb32960ChannelHandlerAckBoundaryTest {
|
|||||||
|
|
||||||
assertThat(channel.writeInbound(Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000001"))).isFalse();
|
assertThat(channel.writeInbound(Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000001"))).isFalse();
|
||||||
|
|
||||||
|
dispatch.awaitPublishCount(1);
|
||||||
dispatch.failDurability();
|
dispatch.failDurability();
|
||||||
channel.runPendingTasks();
|
channel.runPendingTasks();
|
||||||
|
|
||||||
@@ -64,6 +70,35 @@ class Gb32960ChannelHandlerAckBoundaryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void durableAcksAreWrittenInInboundOrderWhenLaterDispatchCompletesFirst() {
|
||||||
|
try (DispatchHarness dispatch = new DispatchHarness()) {
|
||||||
|
EmbeddedChannel channel = new EmbeddedChannel(handlerWith(dispatch.dispatcher()));
|
||||||
|
|
||||||
|
assertThat(channel.writeInbound(Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000001"))).isFalse();
|
||||||
|
assertThat(channel.writeInbound(buildHeartbeatFrame("LTEST000000000001"))).isFalse();
|
||||||
|
assertThat((Object) channel.readOutbound()).isNull();
|
||||||
|
|
||||||
|
dispatch.awaitPublishCount(2);
|
||||||
|
dispatch.completeDurability(1);
|
||||||
|
channel.runPendingTasks();
|
||||||
|
assertThat((Object) channel.readOutbound()).isNull();
|
||||||
|
|
||||||
|
dispatch.completeDurability(0);
|
||||||
|
channel.runPendingTasks();
|
||||||
|
|
||||||
|
ByteBuf firstAck = channel.readOutbound();
|
||||||
|
ByteBuf secondAck = channel.readOutbound();
|
||||||
|
assertThat(firstAck).isNotNull();
|
||||||
|
assertThat(secondAck).isNotNull();
|
||||||
|
assertThat(firstAck.getUnsignedByte(2)).isEqualTo((short) 0x02);
|
||||||
|
assertThat(secondAck.getUnsignedByte(2)).isEqualTo((short) 0x07);
|
||||||
|
firstAck.release();
|
||||||
|
secondAck.release();
|
||||||
|
channel.finishAndReleaseAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Gb32960ChannelHandler handlerWith(Dispatcher dispatcher) {
|
private static Gb32960ChannelHandler handlerWith(Dispatcher dispatcher) {
|
||||||
return new Gb32960ChannelHandler(
|
return new Gb32960ChannelHandler(
|
||||||
decoder(),
|
decoder(),
|
||||||
@@ -87,6 +122,23 @@ class Gb32960ChannelHandlerAckBoundaryTest {
|
|||||||
new Gb32960PlatformAuthorizer(auth.getPlatforms()));
|
new Gb32960PlatformAuthorizer(auth.getPlatforms()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static byte[] buildHeartbeatFrame(String vin) {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
out.write(0x23);
|
||||||
|
out.write(0x23);
|
||||||
|
out.write(0x07);
|
||||||
|
out.write(0xFE);
|
||||||
|
byte[] vinBytes = vin.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
out.write(vinBytes, 0, 17);
|
||||||
|
out.write(0x01);
|
||||||
|
out.write(0x00);
|
||||||
|
out.write(0x00);
|
||||||
|
|
||||||
|
byte[] almost = out.toByteArray();
|
||||||
|
out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
private static final class DispatchHarness implements AutoCloseable {
|
private static final class DispatchHarness implements AutoCloseable {
|
||||||
private final ControlledSink sink = new ControlledSink();
|
private final ControlledSink sink = new ControlledSink();
|
||||||
private final DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
private final DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||||
@@ -103,11 +155,28 @@ class Gb32960ChannelHandlerAckBoundaryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void completeDurability() {
|
void completeDurability() {
|
||||||
sink.future.complete(null);
|
completeDurability(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void completeDurability(int index) {
|
||||||
|
sink.future(index).complete(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
void failDurability() {
|
void failDurability() {
|
||||||
sink.future.completeExceptionally(new IllegalStateException("durability failed"));
|
sink.future(0).completeExceptionally(new IllegalStateException("durability failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void awaitPublishCount(int count) {
|
||||||
|
long deadline = System.currentTimeMillis() + 3000;
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
synchronized (sink.futures) {
|
||||||
|
if (sink.futures.size() >= count) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.onSpinWait();
|
||||||
|
}
|
||||||
|
throw new AssertionError("expected " + count + " sink publishes, actual=" + sink.futures.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -118,16 +187,26 @@ class Gb32960ChannelHandlerAckBoundaryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static final class ControlledSink implements EventSink {
|
private static final class ControlledSink implements EventSink {
|
||||||
private final CompletableFuture<Void> future = new CompletableFuture<>();
|
private final List<CompletableFuture<Void>> futures = new ArrayList<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String name() {
|
public String name() {
|
||||||
return "controlled";
|
return "kafka";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CompletableFuture<Void> publish(VehicleEvent event) {
|
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||||
|
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||||
|
synchronized (futures) {
|
||||||
|
futures.add(future);
|
||||||
|
}
|
||||||
return future;
|
return future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Void> future(int index) {
|
||||||
|
synchronized (futures) {
|
||||||
|
return futures.get(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user