fix(mq-starter): 去重跳过区分 processing/success,processing 残留不 ACK 避免静默丢消息

tryAcquire 返回 false 的两类跳过此前一律静默 return,导致 driver 无差别 ACK:
消费者在 markSuccess/markFail 前崩溃残留的 processing 标记,会被当成"已处理"
而 ACK,该消息从未成功消费却被丢弃(Critical 静默丢消息)。

- MqIdempotentStore 新增 isCompleted(key),默认 true 保持旧行为,状态机实现须覆写
- MqConsumeDispatcher:跳过时按 isCompleted 分流——success 正常 return 让 driver ACK;
  仍 processing 则抛 MqMessageInProgressException(新,纯控制流信号、不带栈)让 driver 不 ACK
- RedisMqIdempotentStore.isCompleted 仅 success 视为完成;RedisStreamMqDriver 捕获信号异常时留 PEL 待重投
- 补 dispatcher 两类跳过 + store isCompleted 语义单测

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:41:02 +08:00
parent 886c02ff96
commit e660afe2a2
7 changed files with 140 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import com.njcn.mq.core.MqMessage;
import com.njcn.mq.spi.MqConsumeErrorHandler; import com.njcn.mq.spi.MqConsumeErrorHandler;
import com.njcn.mq.spi.MqErrorIdentity; import com.njcn.mq.spi.MqErrorIdentity;
import com.njcn.mq.spi.MqIdempotentStore; import com.njcn.mq.spi.MqIdempotentStore;
import com.njcn.mq.spi.MqMessageInProgressException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList; import java.util.ArrayList;
@@ -35,7 +36,15 @@ public class MqConsumeDispatcher {
public void dispatch(MqMessage msg) throws Exception { public void dispatch(MqMessage msg) throws Exception {
if (idempotent && store != null && !store.tryAcquire(msg.getKey())) { if (idempotent && store != null && !store.tryAcquire(msg.getKey())) {
return; // processing 中或已 success跳过 // 跳过分两类,决定 driver 能否 ACK
// - 已 success正常返回 → driver ACK挡掉重复投递正常去重
// - 仍 processing并发处理中或消费者在 mark 前崩溃残留的标记):抛信号异常 →
// driver 不 ACK、保留消息待 processing 标记过期后真正重投。否则该消息可能从未被
// 成功消费却被去重挡回并 ACK → 静默丢消息。
if (store.isCompleted(msg.getKey())) {
return;
}
throw new MqMessageInProgressException(msg.getKey());
} }
if (batchSize <= 1) { if (batchSize <= 1) {
try { try {

View File

@@ -21,4 +21,22 @@ public interface MqIdempotentStore {
/** 消费失败,标记 fail(允许后续重新消费)。 */ /** 消费失败,标记 fail(允许后续重新消费)。 */
void markFail(String key); void markFail(String key);
/**
* {@link #tryAcquire(String)} 返回 false(跳过)后,判断该 key 是否已<b>消费成功</b>。
*
* <p>用于让 driver 区分两类"跳过",这决定了 driver 能否 ACK 该消息:
* <ul>
* <li>已 success:可安全 ACK —— 正常去重,挡掉重复投递;</li>
* <li>仍 processing(并发处理中,或消费者在 mark 前崩溃残留的标记):<b>不可 ACK</b> ——
* 该消息可能从未被成功消费,必须保留在 MQ,待 processing 标记过期后真正重投;
* 否则会被去重挡回并 ACK,造成<b>静默丢消息</b>。</li>
* </ul>
*
* @return true=已成功(driver 可 ACK);false=processing 中(driver 应保留待重投)。
* 默认返回 true 以保持旧行为(向后兼容);具备状态机的实现应覆写。
*/
default boolean isCompleted(String key) {
return true;
}
} }

View File

@@ -0,0 +1,21 @@
package com.njcn.mq.spi;
/**
* 控制流信号:消费去重标记仍处于 {@code processing}(并发处理中,或消费者在 mark 前崩溃残留的标记)。
*
* <p>本次投递既不能视为成功(否则 driver 会 ACK,导致该消息若从未被成功消费即<b>静默丢失</b>),
* 也不属于业务处理失败。driver 捕获后应<b>保留消息(不 ACK)</b>,待 processing 标记过期后由 MQ 真正重投。
*
* <p>不携带堆栈(纯控制流,规避噪音与开销)。
*/
public class MqMessageInProgressException extends RuntimeException {
public MqMessageInProgressException(String key) {
super("message in-progress (dedup processing), leave for replay: key=" + key);
}
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}

View File

@@ -143,6 +143,50 @@ class MqConsumeDispatcherTest {
assertEquals(2, succeeded.size(), "flush 成功后应对每条标记 success"); assertEquals(2, succeeded.size(), "flush 成功后应对每条标记 success");
} }
@Test
void idempotent_skipInProgress_throwsSoDriverWontAckAndReplays() {
// 崩溃残留场景:去重标记停在 processing(tryAcquire=false 且 isCompleted=false)。
// 消费者在 tryAcquire 之后、markSuccess/markFail 之前崩溃,消息从未被成功消费。
// 若此时静默返回,driver 会把这条消息 ACK → 静默丢消息(Critical)。
// 正确行为:抛异常,使 driver 不 ACK、保留消息,待 processing 标记过期后真正重投。
List<List<MqMessage>> consumed = new ArrayList<>();
MqIdempotentStore store = new MqIdempotentStore() {
@Override public boolean tryAcquire(String key) { return false; }
@Override public void markSuccess(String key) { }
@Override public void markFail(String key) { }
@Override public boolean isCompleted(String key) { return false; }
};
MqConsumeDispatcher d = MqConsumeDispatcher.builder()
.batchSize(1).idempotent(true).store(store)
.consumer(consumed::add)
.build();
assertThrows(Exception.class,
() -> d.dispatch(MqMessage.of("k1", "", "a")),
"processing 残留(非 success)跳过时应抛异常,使 driver 不 ACK、留待过期后重投,避免静默丢消息");
assertEquals(0, consumed.size(), "不应消费");
}
@Test
void idempotent_skipCompleted_returnsNormallySoDriverAcks() {
// 已成功的重复投递:应被静默去重(driver 正常 ACK),不抛异常、不重复消费。
List<List<MqMessage>> consumed = new ArrayList<>();
MqIdempotentStore store = new MqIdempotentStore() {
@Override public boolean tryAcquire(String key) { return false; }
@Override public void markSuccess(String key) { }
@Override public void markFail(String key) { }
@Override public boolean isCompleted(String key) { return true; }
};
MqConsumeDispatcher d = MqConsumeDispatcher.builder()
.batchSize(1).idempotent(true).store(store)
.consumer(consumed::add)
.build();
assertDoesNotThrow(() -> d.dispatch(MqMessage.of("k1", "", "a")),
"已成功的重复投递应被静默去重(driver ACK),不抛异常");
assertEquals(0, consumed.size(), "不应重复消费");
}
@Test @Test
void singleMode_whenErrorHandlerThrows_stillMarksFailAndRethrowsOriginal() { void singleMode_whenErrorHandlerThrows_stillMarksFailAndRethrowsOriginal() {
List<String> failed = new ArrayList<>(); List<String> failed = new ArrayList<>();

View File

@@ -59,4 +59,11 @@ public class RedisMqIdempotentStore implements MqIdempotentStore {
public void markFail(String key) { public void markFail(String key) {
redis.opsForValue().set(prefix + key, FAIL, Duration.ofSeconds(failTtl)); redis.opsForValue().set(prefix + key, FAIL, Duration.ofSeconds(failTtl));
} }
@Override
public boolean isCompleted(String key) {
// 仅 success 视为"已完成"(driver 可 ACK)。processing(并发处理中/崩溃残留)与
// 无记录(标记已过期)一律 false使 driver 保留消息、待重投,避免静默丢消息。
return SUCCESS.equals(redis.opsForValue().get(prefix + key));
}
} }

View File

@@ -6,6 +6,7 @@ import com.njcn.middle.stream.constant.StreamMessageConstant;
import com.njcn.middle.stream.support.StreamKeyBuilder; import com.njcn.middle.stream.support.StreamKeyBuilder;
import com.njcn.mq.core.MqMessage; import com.njcn.mq.core.MqMessage;
import com.njcn.mq.spi.MqDriver; import com.njcn.mq.spi.MqDriver;
import com.njcn.mq.spi.MqMessageInProgressException;
import com.njcn.mq.spi.MqSubscription; import com.njcn.mq.spi.MqSubscription;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.stream.*; import org.springframework.data.redis.connection.stream.*;
@@ -98,6 +99,11 @@ public class RedisStreamMqDriver implements MqDriver {
try { try {
sub.getListener().onMessage(msg); sub.getListener().onMessage(msg);
return true; return true;
} catch (MqMessageInProgressException inProgress) {
// 去重标记仍 processing并发处理中/上次崩溃残留):不 ACK留 PEL
// 待 processing 标记过期后由 reclaimPending 重新 acquire 并真正重投。
log.debug("[mq-redis] in-progress, leave PEL key={}", msg.getKey());
return false;
} catch (Exception e) { } catch (Exception e) {
log.warn("[mq-redis] handle failed key={}, leave PEL for retry (max={})", msg.getKey(), maxRetry, e); log.warn("[mq-redis] handle failed key={}, leave PEL for retry (max={})", msg.getKey(), maxRetry, e);
return false; // 留 PEL由 reclaimPending 重投或落库 return false; // 留 PEL由 reclaimPending 重投或落库

View File

@@ -0,0 +1,34 @@
package com.njcn.mq.driver.redis;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* {@link RedisMqIdempotentStore#isCompleted(String)} 纯单测(mock StringRedisTemplate不依赖真实 Redis
*
* <p>isCompleted 必须仅在 key 值为 {@code success} 时返回 trueprocessing / 无记录 一律 false
* 否则消费者崩溃后残留的 processing 标记会被 driver 当成"已完成"而 ACK造成静默丢消息。
*/
class RedisMqIdempotentStoreTest {
@Test
void isCompleted_trueOnlyForSuccessValue() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> ops = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(ops);
RedisMqIdempotentStore store = new RedisMqIdempotentStore(redis, "mq:idem:", 60, 300, 300);
when(ops.get("mq:idem:kSucc")).thenReturn("success");
when(ops.get("mq:idem:kProc")).thenReturn("processing");
when(ops.get("mq:idem:kNone")).thenReturn(null);
assertTrue(store.isCompleted("kSucc"), "success 应视为已完成driver 可 ACK");
assertFalse(store.isCompleted("kProc"), "processing 不可视为完成,否则崩溃残留会被 ACK 丢消息");
assertFalse(store.isCompleted("kNone"), "无记录(标记已过期)不可视为完成");
}
}