fix: enforce gb32960 kafka ack boundary

This commit is contained in:
lingniu
2026-06-23 17:27:12 +08:00
parent 633b3ea9c9
commit a80b38bd04
6 changed files with 474 additions and 30 deletions

View File

@@ -13,8 +13,12 @@ import com.lmax.disruptor.dsl.ProducerType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;
@@ -30,11 +34,13 @@ public final class DisruptorEventBus implements AutoCloseable {
private final Disruptor<VehicleEventSlot> disruptor;
private final List<EventSink> sinks;
private final ExecutorService awaitExecutor;
private final AtomicLong published = new AtomicLong();
private final AtomicLong failed = new AtomicLong();
public DisruptorEventBus(int ringBufferSize, String waitStrategyName, List<EventSink> sinks) {
this.sinks = List.copyOf(sinks);
this.awaitExecutor = Executors.newVirtualThreadPerTaskExecutor();
ThreadFactory tf = Thread.ofVirtual().name("ingest-bus-", 0).factory();
this.disruptor = new Disruptor<>(
VehicleEventSlot::new,
@@ -58,21 +64,31 @@ public final class DisruptorEventBus implements AutoCloseable {
published.incrementAndGet();
}
public CompletableFuture<Void> publishAndAwait(VehicleEvent event) {
public CompletableFuture<Void> publishAndAwait(VehicleEvent event, String requiredSinkName) {
String requiredSink = Objects.requireNonNull(requiredSinkName, "requiredSinkName");
published.incrementAndGet();
List<CompletableFuture<Void>> futures = sinks.stream()
.filter(sink -> sink.accepts(event))
.map(sink -> publishToSinkAndTrack(sink, event))
.toList();
if (futures.isEmpty()) {
return CompletableFuture.completedFuture(null);
List<CompletableFuture<Void>> requiredFutures = new ArrayList<>();
for (EventSink sink : sinks) {
if (!sink.accepts(event)) continue;
CompletableFuture<Void> future = CompletableFuture
.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
public void close() {
disruptor.shutdown();
awaitExecutor.shutdown();
log.info("DisruptorEventBus stopped published={} failed={}", published.get(), failed.get());
}

View File

@@ -35,6 +35,7 @@ public final class Dispatcher {
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
private static final AtomicLong RAW_ARCHIVE_SEQUENCE = new AtomicLong();
private static final String REQUIRED_DURABLE_SINK = "kafka";
private final HandlerRegistry registry;
private final InterceptorChain interceptors;
@@ -57,7 +58,7 @@ public final class Dispatcher {
public void dispatch(RawFrame frame) {
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
try {
DispatchPlan plan = mapFrameToEvents(frame, ctx);
DispatchPlan plan = mapFrameToEvents(frame, ctx, false);
for (VehicleEvent e : plan.events()) {
eventBus.publish(e);
}
@@ -73,15 +74,21 @@ public final class Dispatcher {
public CompletableFuture<Void> dispatchAndAwait(RawFrame frame) {
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
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()
.map(eventBus::publishAndAwait)
.map(event -> eventBus.publishAndAwait(event, REQUIRED_DURABLE_SINK))
.toArray(CompletableFuture[]::new);
CompletableFuture<Void> boundary = CompletableFuture.allOf(futures);
if (plan.failure() == null) {
return boundary;
}
return boundary.handle((ignored, publishFailure) -> {
log.error("dispatch failure traceId={}", ctx.traceId(), plan.failure());
interceptors.onError(plan.failure(), ctx);
if (publishFailure != null) {
throw new CompletionException(publishFailure);
}
@@ -102,7 +109,7 @@ public final class Dispatcher {
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<>();
// 在 interceptor 之前发 RawArchive保证原始字节被无条件落盘dedup/rate-limit
// 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 Sink 消费。
@@ -125,6 +132,11 @@ public final class Dispatcher {
for (HandlerDefinition def : handlers) {
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));
continue;
}
@@ -137,6 +149,9 @@ public final class Dispatcher {
}
return new DispatchPlan(out, null);
} catch (Throwable t) {
if (t instanceof AwaitedAsyncBatchUnsupportedException) {
return new DispatchPlan(List.of(), t);
}
return new DispatchPlan(out, t);
}
}
@@ -238,4 +253,10 @@ public final class Dispatcher {
private record DispatchPlan(List<VehicleEvent> events, Throwable failure) {
}
private static final class AwaitedAsyncBatchUnsupportedException extends IllegalStateException {
private AwaitedAsyncBatchUnsupportedException(String message) {
super(message);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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"));
}
}
}