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

@@ -59,4 +59,11 @@ public class RedisMqIdempotentStore implements MqIdempotentStore {
public void markFail(String key) {
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.mq.core.MqMessage;
import com.njcn.mq.spi.MqDriver;
import com.njcn.mq.spi.MqMessageInProgressException;
import com.njcn.mq.spi.MqSubscription;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.stream.*;
@@ -98,6 +99,11 @@ public class RedisStreamMqDriver implements MqDriver {
try {
sub.getListener().onMessage(msg);
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) {
log.warn("[mq-redis] handle failed key={}, leave PEL for retry (max={})", msg.getKey(), maxRetry, e);
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"), "无记录(标记已过期)不可视为完成");
}
}