feat(mq-starter-redis-stream): 消费端按订阅 tag 过滤,不匹配 ACK 丢弃
dispatch 顶部(反序列化前)经 MqTagMatcher 比对,不匹配直接 ACK: 不碰去重、不攒批、不重试;覆盖正常消费与 reclaim 重投两条路径。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ 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.MqMessageInProgressException;
|
||||||
import com.njcn.mq.spi.MqSubscription;
|
import com.njcn.mq.spi.MqSubscription;
|
||||||
|
import com.njcn.mq.spi.MqTagMatcher;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.redis.connection.stream.*;
|
import org.springframework.data.redis.connection.stream.*;
|
||||||
import org.springframework.data.domain.Range;
|
import org.springframework.data.domain.Range;
|
||||||
@@ -87,6 +88,14 @@ public class RedisStreamMqDriver implements MqDriver {
|
|||||||
|
|
||||||
/** @return true=ACK;false=留 PEL(重试)。 */
|
/** @return true=ACK;false=留 PEL(重试)。 */
|
||||||
private boolean dispatch(MqSubscription sub, Map<String, String> fields, int maxRetry) {
|
private boolean dispatch(MqSubscription sub, Map<String, String> fields, int maxRetry) {
|
||||||
|
String msgTag = fields.get(StreamMessageConstant.F_TAG);
|
||||||
|
if (!MqTagMatcher.matches(sub.getTag(), msgTag)) {
|
||||||
|
// tag 不匹配:不归本订阅消费,直接 ACK 丢弃(对齐 rocketmq broker 过滤语义,只是位置在消费端)。
|
||||||
|
// 置于反序列化之前:不碰去重标记、不进攒批、不走重试,连 JSON 都不用解。
|
||||||
|
log.debug("[mq-redis] tag 不匹配,ACK 跳过 key={} msgTag={} subTag={}",
|
||||||
|
fields.get(StreamMessageConstant.F_KEY), msgTag, sub.getTag());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Object payload;
|
Object payload;
|
||||||
try {
|
try {
|
||||||
String json = MessageCodec.decodeBody(fields.get(StreamMessageConstant.F_ENC), fields.get(StreamMessageConstant.F_BODY));
|
String json = MessageCodec.decodeBody(fields.get(StreamMessageConstant.F_ENC), fields.get(StreamMessageConstant.F_BODY));
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.njcn.mq.driver.redis;
|
||||||
|
|
||||||
|
import com.njcn.mq.core.MqMessage;
|
||||||
|
import com.njcn.mq.spi.MqSubscription;
|
||||||
|
import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
|
||||||
|
import com.njcn.middle.stream.support.StreamKeyBuilder;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||||
|
import org.springframework.data.redis.connection.stream.PendingMessagesSummary;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
|
|
||||||
|
class RedisStreamMqDriverTagFilterIT {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tagFilter_onlyMatchingDelivered_othersAckedCleanly() throws Exception {
|
||||||
|
StringRedisTemplate redis = newRedisOrSkip();
|
||||||
|
StreamKeyBuilder kb = new StreamKeyBuilder(false, "");
|
||||||
|
RedisStreamProperties props = new RedisStreamProperties();
|
||||||
|
props.setConsumerName("it-tag");
|
||||||
|
props.setReadCount(10);
|
||||||
|
props.setBlockMs(300);
|
||||||
|
props.setMaxRetry(3);
|
||||||
|
RedisStreamMqDriver driver = new RedisStreamMqDriver(redis, kb, props, 200L);
|
||||||
|
|
||||||
|
String topic = "MQ_TAG_IT_" + System.nanoTime();
|
||||||
|
BlockingQueue<String> got = new LinkedBlockingQueue<>();
|
||||||
|
driver.subscribe(MqSubscription.builder()
|
||||||
|
.topic(topic).group("g1").tag("A").payloadType(String.class)
|
||||||
|
.concurrency(1).batchSize(1).maxRetry(3)
|
||||||
|
.listener(m -> got.add((String) m.getPayload()))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
driver.send(topic, MqMessage.of("k-a", "A", "match"));
|
||||||
|
driver.send(topic, MqMessage.of("k-b", "B", "mismatch"));
|
||||||
|
driver.send(topic, MqMessage.of("k-none", "", "untagged"));
|
||||||
|
|
||||||
|
assertEquals("match", got.poll(5, TimeUnit.SECONDS), "tag=A 的消息应正常消费");
|
||||||
|
assertNull(got.poll(1500, TimeUnit.MILLISECONDS), "tag 不匹配/未打 tag 的消息不得进业务方法");
|
||||||
|
|
||||||
|
Thread.sleep(500); // 留时间让过滤 ACK 落地
|
||||||
|
PendingMessagesSummary summary = redis.opsForStream().pending(kb.buildStream(topic), "g1");
|
||||||
|
assertEquals(0L, summary.getTotalPendingMessages(), "被过滤的消息应 ACK 干净,PEL 无残留");
|
||||||
|
driver.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StringRedisTemplate newRedisOrSkip() {
|
||||||
|
try {
|
||||||
|
LettuceConnectionFactory cf = new LettuceConnectionFactory("localhost", 6379);
|
||||||
|
cf.afterPropertiesSet();
|
||||||
|
StringRedisTemplate t = new StringRedisTemplate(cf);
|
||||||
|
t.afterPropertiesSet();
|
||||||
|
t.hasKey("ping"); // 触发真实连接
|
||||||
|
return t;
|
||||||
|
} catch (Exception e) { assumeTrue(false, "no local redis, skip"); return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user