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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 重投或落库
|
||||
|
||||
@@ -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} 时返回 true:processing / 无记录 一律 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"), "无记录(标记已过期)不可视为完成");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user