feat(mq-starter): redis-stream 重试可放量(PEL reclaim + 超限落库)

- MqSubscription 增加 retryExhaustedHandler;registry 从 errorHandler 接线 RETRY_EXHAUSTED 回调
- redis driver 每轮 reclaim PEL:connection 层 xClaim 认领超 idle 消息并使 delivery+1
  - delivery <= maxRetry:重投消费,成功 ACK / 失败留 PEL
  - delivery > maxRetry:回调 retryExhaustedHandler 落库 + ACK 终止,不再卡队列
- 可配 reclaimMinIdleMs(默认 5000ms,对齐 legacy 5s 延迟);4 参构造 + 3 参重载
- 集成测试连真实 redis 验证 重试耗尽→落库→ACK;全仓 28 测试绿

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 20:17:05 +08:00
parent 407df30457
commit dd2b933c08
5 changed files with 168 additions and 7 deletions

View File

@@ -3,6 +3,7 @@ import com.njcn.mq.annotation.MqListener;
import com.njcn.mq.core.MqContext;
import com.njcn.mq.core.MqMessage;
import com.njcn.mq.spi.MqConsumeErrorHandler;
import com.njcn.mq.spi.MqErrorIdentity;
import com.njcn.mq.spi.MqDriver;
import com.njcn.mq.spi.MqIdempotentStore;
import com.njcn.mq.spi.MqMessageListener;
@@ -33,29 +34,39 @@ public class MqListenerRegistry implements SmartLifecycle {
if (running) return;
for (MqListenerEndpoint ep : endpoints) {
MqListener meta = ep.getMeta();
MqConsumeErrorHandler eh = resolveErrorHandler(meta);
MqConsumeDispatcher.Builder db = MqConsumeDispatcher.builder()
.batchSize(meta.batchSize())
.consumer(batch -> consume(ep, batch));
if (meta.idempotent()) {
db.idempotent(true).store(store);
}
if (errorHandlerResolver != null && !meta.errorHandler().isEmpty()) {
MqConsumeErrorHandler eh = errorHandlerResolver.apply(meta.errorHandler());
if (eh != null) {
db.errorHandler(eh);
}
if (eh != null) {
db.errorHandler(eh);
}
MqConsumeDispatcher dispatcher = db.build();
java.util.function.Consumer<MqMessage> retryExhausted = eh == null ? null
: msg -> eh.onError(msg, MqErrorIdentity.RETRY_EXHAUSTED, null);
MqSubscription sub = MqSubscription.builder()
.topic(meta.topic()).group(meta.group()).tag(meta.tag())
.payloadType(ep.getPayloadType())
.concurrency(meta.concurrency()).batchSize(meta.batchSize()).maxRetry(meta.maxRetry())
.listener(dispatcher::dispatch).build();
.listener(dispatcher::dispatch)
.retryExhaustedHandler(retryExhausted)
.build();
driver.subscribe(sub);
}
running = true;
}
private MqConsumeErrorHandler resolveErrorHandler(MqListener meta) {
if (errorHandlerResolver == null || meta.errorHandler().isEmpty()) return null;
return errorHandlerResolver.apply(meta.errorHandler());
}
/** 业务调用适配:批量 endpoint 调 (List&lt;payload&gt;[,ctx]);单条 endpoint 逐条调 (payload[,ctx])。 */
private void consume(MqListenerEndpoint ep, List<MqMessage> batch) throws Exception {
if (ep.isBatch()) {

View File

@@ -1,6 +1,8 @@
package com.njcn.mq.spi;
import com.njcn.mq.core.MqMessage;
import lombok.Builder;
import lombok.Data;
import java.util.function.Consumer;
@Data
@Builder
@@ -13,4 +15,6 @@ public class MqSubscription {
private int batchSize;
private int maxRetry;
private MqMessageListener listener;
/** 重试耗尽(超过 maxRetry时由 driver 回调,用于落库 RETRY_EXHAUSTED可空。 */
private Consumer<MqMessage> retryExhaustedHandler;
}

View File

@@ -61,6 +61,13 @@ class MqListenerRegistryTest {
catch (Exception e) { throw new RuntimeException(e); }
}
}
/** fake driver捕获 subscription不喂消息。 */
static class CaptureDriver implements MqDriver {
MqSubscription captured;
@Override public void send(String topic, MqMessage m) {}
@Override public void stop() {}
@Override public void subscribe(MqSubscription s) { this.captured = s; }
}
/** fake driversubscribe 时按序喂多条消息。 */
static class FeedDriver implements MqDriver {
private final MqMessage[] msgs;
@@ -123,4 +130,23 @@ class MqListenerRegistryTest {
assertEquals(1, errors.size(), "按名解析的 errorHandler 应被调用");
assertEquals(MqErrorIdentity.SINGLE, errors.get(0));
}
@Test
void retryExhaustedHandler_wiredFromErrorHandler() {
ErrBiz biz = new ErrBiz();
MqListenerAnnotationBeanPostProcessor bpp = new MqListenerAnnotationBeanPostProcessor();
bpp.postProcessAfterInitialization(biz, "biz");
List<MqErrorIdentity> errors = new ArrayList<>();
MqConsumeErrorHandler eh = (m, id, ex) -> errors.add(id);
Function<String, MqConsumeErrorHandler> resolver = name -> "myEH".equals(name) ? eh : null;
CaptureDriver driver = new CaptureDriver();
MqListenerRegistry reg = new MqListenerRegistry(driver, bpp.getEndpoints(), null, resolver);
reg.start();
assertNotNull(driver.captured.getRetryExhaustedHandler(), "应从 errorHandler 接线重试耗尽回调");
driver.captured.getRetryExhaustedHandler().accept(MqMessage.of("k", "", "p"));
assertEquals(1, errors.size());
assertEquals(MqErrorIdentity.RETRY_EXHAUSTED, errors.get(0), "重试耗尽应落库 RETRY_EXHAUSTED");
}
}