feat: Redis Stream Spring Boot starter 首次提交
封装 Redis Streams 收发,零业务依赖。含自动装配、发送模板、监听容器、 autoclaim 崩溃恢复+死信淘汰、stream 定时裁剪、gzip 编解码、环境隔离。 本次随提交的改进: - per-handler 重试次数:getMaxRetryTimes() 覆写生效,全局 redis-stream.max-retry 兜底 - 死信回调传入反序列化后的业务消息(saveExceptionFromFields) - pom 升级 maven-surefire-plugin 至 2.22.2,使 JUnit 5 测试真正执行 - 集成测试连接信息改为 -D 注入(默认 localhost:6379),凭据不入代码 - lombok 改为 optional,不污染下游依赖 - 新增 README 使用文档 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package com.njcn.middle.stream.autoconfig;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
class RedisStreamPropertiesTest {
|
||||
|
||||
@Test
|
||||
void hasSaneDefaults() {
|
||||
RedisStreamProperties p = new RedisStreamProperties();
|
||||
assertFalse(p.isEnvIsolation(), "envIsolation 默认 false");
|
||||
assertEquals("", p.getEnv(), "env 默认空串");
|
||||
assertEquals("", p.getConsumerName(), "consumerName 默认空串");
|
||||
assertEquals(10, p.getReadCount(), "readCount 默认 10");
|
||||
assertEquals(2000L, p.getBlockMs(), "blockMs 默认 2000");
|
||||
assertEquals(3, p.getMaxRetry(), "maxRetry 默认 3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoclaimHasDefaults() {
|
||||
RedisStreamProperties.Autoclaim a = new RedisStreamProperties().getAutoclaim();
|
||||
assertNotNull(a, "autoclaim 默认非 null");
|
||||
assertEquals(60000L, a.getMinIdleMs(), "autoclaim.minIdleMs 默认 60000");
|
||||
assertEquals(30000L, a.getIntervalMs(), "autoclaim.intervalMs 默认 30000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trimHasDefaults() {
|
||||
RedisStreamProperties.Trim t = new RedisStreamProperties().getTrim();
|
||||
assertNotNull(t, "trim 默认非 null");
|
||||
assertEquals(60000L, t.getIntervalMs(), "trim.intervalMs 默认 60000");
|
||||
assertEquals(100000L, t.getMaxlen(), "trim.maxlen 默认 100000");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.njcn.middle.stream.codec;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.middle.stream.codec.MessageCodec.Encoded;
|
||||
import com.njcn.middle.stream.constant.StreamMessageConstant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MessageCodecTest {
|
||||
|
||||
/** 构造一段足够大且高度可压缩的 json(重复内容)。 */
|
||||
private static String bigCompressibleJson() {
|
||||
return "{\"data\":\"" + StrUtil.repeat("abcdefgh", 200) + "\"}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainRoundTrip() {
|
||||
String json = "{\"a\":1,\"name\":\"hello\"}";
|
||||
Encoded e = MessageCodec.encodeBody(json, false);
|
||||
assertEquals(StreamMessageConstant.ENC_PLAIN, e.getEnc(), "不压缩时 enc=plain");
|
||||
assertEquals(json, e.getBody(), "明文 body 即原 json");
|
||||
assertEquals(json, MessageCodec.decodeBody(e.getEnc(), e.getBody()), "plain 往返原样");
|
||||
}
|
||||
|
||||
@Test
|
||||
void gzipRoundTrip() {
|
||||
String json = bigCompressibleJson();
|
||||
Encoded e = MessageCodec.encodeBody(json, true);
|
||||
assertEquals(StreamMessageConstant.ENC_GZIP, e.getEnc(), "压缩时 enc=gzip");
|
||||
assertEquals(json, MessageCodec.decodeBody(e.getEnc(), e.getBody()), "gzip 往返逐字节一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void gzipBodyIsShorterForCompressible() {
|
||||
String json = bigCompressibleJson();
|
||||
Encoded e = MessageCodec.encodeBody(json, true);
|
||||
assertTrue(e.getBody().length() < json.length(),
|
||||
"高可压缩内容压缩后 base64 应短于原 json:" + e.getBody().length() + " < " + json.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gzipBodyHasNoNewlines() {
|
||||
String json = bigCompressibleJson();
|
||||
Encoded e = MessageCodec.encodeBody(json, true);
|
||||
assertFalse(e.getBody().contains("\n"), "base64 不应含 \\n");
|
||||
assertFalse(e.getBody().contains("\r"), "base64 不应含 \\r");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unicodeGzipRoundTrip() {
|
||||
// UTF-8 多字节字符(中文 + emoji)往返不丢
|
||||
String json = "{\"msg\":\"中文测试🚀\"}";
|
||||
Encoded e = MessageCodec.encodeBody(json, true);
|
||||
assertEquals(json, MessageCodec.decodeBody(StreamMessageConstant.ENC_GZIP, e.getBody()),
|
||||
"gzip 路径 UTF-8 中文/emoji 往返不丢");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownEncFallsBackToPlain() {
|
||||
// 消费侧只按 enc 解码;未知/缺省 enc 兜底按明文返回,不抛异常
|
||||
String body = "{\"x\":1}";
|
||||
assertEquals(body, MessageCodec.decodeBody("", body), "空 enc 兜底明文");
|
||||
assertEquals(body, MessageCodec.decodeBody(StreamMessageConstant.ENC_PLAIN, body), "plain 直接返回");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.njcn.middle.stream.container;
|
||||
|
||||
import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
|
||||
import com.njcn.middle.stream.domain.StreamBaseMessage;
|
||||
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 单元测试:per-handler 重试次数解析({@link StreamAutoClaimer#resolveMaxRetry})。
|
||||
*
|
||||
* <p>不依赖 Redis:仅校验「handler 自配优先、否则沿用全局 {@code redis-stream.max-retry}」的优先级。
|
||||
*/
|
||||
class StreamAutoClaimerMaxRetryTest {
|
||||
|
||||
private StreamAutoClaimer claimerWithGlobal(int globalMaxRetry) {
|
||||
RedisStreamProperties props = new RedisStreamProperties();
|
||||
props.setMaxRetry(globalMaxRetry);
|
||||
// resolveMaxRetry 只用到 props 与 handler,redis/keyBuilder 传 null 即可
|
||||
return new StreamAutoClaimer(null, null, props, Collections.emptyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlerInheritsGlobalWhenNotOverridden() {
|
||||
StreamAutoClaimer claimer = claimerWithGlobal(5);
|
||||
// 默认 handler 返回哨兵 INHERIT_GLOBAL_MAX_RETRY → 沿用全局 5
|
||||
assertEquals(5, claimer.resolveMaxRetry(
|
||||
new StubHandler(EnhanceStreamConsumerHandler.INHERIT_GLOBAL_MAX_RETRY)),
|
||||
"未覆写时应沿用全局 max-retry");
|
||||
}
|
||||
|
||||
@Test
|
||||
void perHandlerOverrideWins() {
|
||||
StreamAutoClaimer claimer = claimerWithGlobal(5);
|
||||
assertEquals(10, claimer.resolveMaxRetry(new StubHandler(10)),
|
||||
"handler 自配的正整数应优先于全局");
|
||||
assertEquals(2, claimer.resolveMaxRetry(new StubHandler(2)),
|
||||
"handler 可配置比全局更小的次数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonPositiveHandlerValueFallsBackToGlobal() {
|
||||
StreamAutoClaimer claimer = claimerWithGlobal(3);
|
||||
assertEquals(3, claimer.resolveMaxRetry(new StubHandler(0)), "0 视为未配 → 全局");
|
||||
assertEquals(3, claimer.resolveMaxRetry(new StubHandler(-7)), "负值视为未配 → 全局");
|
||||
}
|
||||
|
||||
static class TestMsg extends StreamBaseMessage {
|
||||
}
|
||||
|
||||
/** 可指定 getMaxRetryTimes() 返回值的测试桩。 */
|
||||
static class StubHandler extends EnhanceStreamConsumerHandler<TestMsg> {
|
||||
private final int maxRetry;
|
||||
|
||||
StubHandler(int maxRetry) {
|
||||
this.maxRetry = maxRetry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String topic() {
|
||||
return "T";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String group() {
|
||||
return "G";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<TestMsg> messageType() {
|
||||
return TestMsg.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(TestMsg message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRetry() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean throwException() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxRetryTimes() {
|
||||
return maxRetry;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.njcn.middle.stream.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
class StreamBaseMessageTest {
|
||||
|
||||
@Test
|
||||
void newMessageHasDefaults() {
|
||||
StreamBaseMessage m = new StreamBaseMessage();
|
||||
// key 默认雪花 id,非空
|
||||
assertNotNull(m.getKey(), "key 应有雪花默认值");
|
||||
assertFalse(m.getKey().isEmpty(), "key 不应为空串");
|
||||
// tag/source 默认空串(非 null)
|
||||
assertEquals("", m.getTag(), "tag 默认空串");
|
||||
assertEquals("", m.getSource(), "source 默认空串");
|
||||
// sendTime 默认 now()
|
||||
assertNotNull(m.getSendTime(), "sendTime 默认 now()");
|
||||
// retryTimes 默认 0
|
||||
assertEquals(0, m.getRetryTimes(), "retryTimes 默认 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keysAreUnique() {
|
||||
StreamBaseMessage a = new StreamBaseMessage();
|
||||
StreamBaseMessage b = new StreamBaseMessage();
|
||||
assertNotEquals(a.getKey(), b.getKey(), "不同实例 key 必须唯一");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.njcn.middle.stream.handler;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.njcn.middle.stream.codec.MessageCodec;
|
||||
import com.njcn.middle.stream.domain.StreamBaseMessage;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.ENC_PLAIN;
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_BODY;
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_ENC;
|
||||
import static com.njcn.middle.stream.handler.ConsumeResult.ACK;
|
||||
import static com.njcn.middle.stream.handler.ConsumeResult.RETRY;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class EnhanceStreamConsumerHandlerTest {
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
static class Foo extends StreamBaseMessage {
|
||||
private String name;
|
||||
}
|
||||
|
||||
/** 可配置行为、记录调用次数的测试桩。 */
|
||||
static class FooHandler extends EnhanceStreamConsumerHandler<Foo> {
|
||||
boolean retry = false;
|
||||
boolean doFilter = false;
|
||||
boolean throwOnHandle = false;
|
||||
int handled = 0;
|
||||
int succeeded = 0;
|
||||
int exceptionLogged = 0;
|
||||
Foo lastHandled;
|
||||
Foo lastExceptionMsg;
|
||||
String lastExceptionBody;
|
||||
|
||||
@Override
|
||||
public String topic() {
|
||||
return "T";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String group() {
|
||||
return "G";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<Foo> messageType() {
|
||||
return Foo.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(Foo message) throws Exception {
|
||||
if (throwOnHandle) {
|
||||
throw new RuntimeException("boom");
|
||||
}
|
||||
handled++;
|
||||
lastHandled = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRetry() {
|
||||
return retry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean throwException() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean filter(Foo message) {
|
||||
return doFilter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void consumeSuccess(Foo message) {
|
||||
succeeded++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveExceptionMsgLog(Foo message, String body, Exception e) {
|
||||
exceptionLogged++;
|
||||
lastExceptionMsg = message;
|
||||
lastExceptionBody = body;
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> fieldsOf(Foo foo, boolean compress) {
|
||||
String json = JSON.toJSONString(foo);
|
||||
MessageCodec.Encoded e = MessageCodec.encodeBody(json, compress);
|
||||
Map<String, String> m = new HashMap<>();
|
||||
m.put(F_ENC, e.getEnc());
|
||||
m.put(F_BODY, e.getBody());
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPathAcksAndHandles() {
|
||||
FooHandler h = new FooHandler();
|
||||
Foo foo = new Foo();
|
||||
foo.setName("hi");
|
||||
|
||||
ConsumeResult r = h.dispatchMessage(fieldsOf(foo, false));
|
||||
|
||||
assertEquals(ACK, r);
|
||||
assertEquals(1, h.handled, "handleMessage 被调用");
|
||||
assertEquals("hi", h.lastHandled.getName(), "反序列化内容正确");
|
||||
assertEquals(1, h.succeeded, "consumeSuccess 被调用");
|
||||
assertEquals(0, h.exceptionLogged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gzipBodyDecodesAndHandles() {
|
||||
FooHandler h = new FooHandler();
|
||||
Foo foo = new Foo();
|
||||
foo.setName("hi");
|
||||
|
||||
ConsumeResult r = h.dispatchMessage(fieldsOf(foo, true));
|
||||
|
||||
assertEquals(ACK, r);
|
||||
assertEquals(1, h.handled, "gzip body 能解码后处理");
|
||||
assertEquals("hi", h.lastHandled.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterTrueAcksWithoutHandling() {
|
||||
FooHandler h = new FooHandler();
|
||||
h.doFilter = true;
|
||||
|
||||
ConsumeResult r = h.dispatchMessage(fieldsOf(new Foo(), false));
|
||||
|
||||
assertEquals(ACK, r);
|
||||
assertEquals(0, h.handled, "filter 真则不处理");
|
||||
assertEquals(0, h.succeeded);
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleExceptionWithRetryReturnsRetry() {
|
||||
FooHandler h = new FooHandler();
|
||||
h.throwOnHandle = true;
|
||||
h.retry = true;
|
||||
|
||||
ConsumeResult r = h.dispatchMessage(fieldsOf(new Foo(), false));
|
||||
|
||||
assertEquals(RETRY, r, "异常 + isRetry → RETRY");
|
||||
assertEquals(0, h.succeeded);
|
||||
assertEquals(0, h.exceptionLogged, "retry 路径不落异常(留 PEL 由 autoclaim 兜)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleExceptionWithoutRetryAcksAndLogs() {
|
||||
FooHandler h = new FooHandler();
|
||||
h.throwOnHandle = true;
|
||||
h.retry = false;
|
||||
|
||||
ConsumeResult r = h.dispatchMessage(fieldsOf(new Foo(), false));
|
||||
|
||||
assertEquals(ACK, r, "异常 + 不 retry → ACK");
|
||||
assertEquals(1, h.exceptionLogged, "落异常日志");
|
||||
assertEquals(0, h.succeeded);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisonMessageAcksAndLogs() {
|
||||
FooHandler h = new FooHandler();
|
||||
Map<String, String> bad = new HashMap<>();
|
||||
bad.put(F_ENC, ENC_PLAIN);
|
||||
bad.put(F_BODY, "{not valid json");
|
||||
|
||||
ConsumeResult r = h.dispatchMessage(bad);
|
||||
|
||||
assertEquals(ACK, r, "毒消息 ACK(不卡队列)");
|
||||
assertEquals(0, h.handled, "毒消息不进 handleMessage");
|
||||
assertEquals(1, h.exceptionLogged, "毒消息落异常");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveExceptionFromFieldsParsesMessageForDeadLetter() {
|
||||
FooHandler h = new FooHandler();
|
||||
Foo foo = new Foo();
|
||||
foo.setName("dead");
|
||||
Map<String, String> fields = fieldsOf(foo, false);
|
||||
|
||||
h.saveExceptionFromFields(fields, new IllegalStateException("max retry exceeded"));
|
||||
|
||||
assertEquals(1, h.exceptionLogged);
|
||||
assertNotNull(h.lastExceptionMsg, "死信回调应拿到反序列化后的消息");
|
||||
assertEquals("dead", h.lastExceptionMsg.getName(), "消息内容正确");
|
||||
assertEquals(fields.get(F_BODY), h.lastExceptionBody, "body 原样透传");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveExceptionFromFieldsParsesGzipBody() {
|
||||
FooHandler h = new FooHandler();
|
||||
Foo foo = new Foo();
|
||||
foo.setName("dead-gzip");
|
||||
|
||||
h.saveExceptionFromFields(fieldsOf(foo, true), new IllegalStateException("x"));
|
||||
|
||||
assertNotNull(h.lastExceptionMsg);
|
||||
assertEquals("dead-gzip", h.lastExceptionMsg.getName(), "gzip body 也能解析后回调");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveExceptionFromFieldsFallsBackToNullOnParseFailure() {
|
||||
FooHandler h = new FooHandler();
|
||||
Map<String, String> bad = new HashMap<>();
|
||||
bad.put(F_ENC, ENC_PLAIN);
|
||||
bad.put(F_BODY, "{not valid json");
|
||||
|
||||
h.saveExceptionFromFields(bad, new IllegalStateException("x"));
|
||||
|
||||
assertEquals(1, h.exceptionLogged);
|
||||
assertNull(h.lastExceptionMsg, "解析失败时 message 兜底为 null");
|
||||
assertEquals("{not valid json", h.lastExceptionBody, "body 仍透传");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.njcn.middle.stream.integration;
|
||||
|
||||
import com.njcn.middle.stream.domain.StreamBaseMessage;
|
||||
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
||||
import com.njcn.middle.stream.support.StreamKeyBuilder;
|
||||
import com.njcn.middle.stream.template.RedisStreamEnhanceTemplate;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import org.junit.jupiter.api.extension.ExecutionCondition;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.domain.Range;
|
||||
import org.springframework.data.redis.connection.stream.PendingMessages;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 集成测试(Gate):autoclaim 对「永久失败 + isRetry」消息的死信淘汰必须在有限轮内触发并清空 PEL。
|
||||
*
|
||||
* <p>覆盖 review 发现的 Major:{@code XCLAIM ... JUSTID} 不递增投递计数、且 listener 用
|
||||
* {@code lastConsumed()} 永不重读 PEL,会让 {@code getTotalDeliveryCount()} 永久冻结在首投值 1,
|
||||
* 使 {@code deliveries >= maxRetry} 恒 false、死信分支永不执行 → 消息被无限重投、PEL 不收敛。
|
||||
*
|
||||
* <p>本用例配 {@code maxRetry=2}(须 ≥2 才能暴露冻结,=1 时 1>=1 会被掩盖)、{@code min-idle-ms=0}、
|
||||
* 小 {@code interval-ms},断言:有限时间内 handler 收到 max-retry 落异常回调,且该 group 的 PEL 清零。
|
||||
*
|
||||
* <p>Redis 不可达时整类禁用(skipped)。连接默认 {@code 127.0.0.1:6379} 无密码,指向其它 Redis 用
|
||||
* {@code -Dspring.redis.host=… -Dspring.redis.port=… -Dspring.redis.password=…} 覆盖(凭据不入代码)。
|
||||
* 本地起 Redis:{@code docker run -d -p 6379:6379 redis:6.2}。
|
||||
*/
|
||||
@ExtendWith(StreamAutoClaimDeadLetterTest.RedisReachableCondition.class)
|
||||
@SpringBootTest(
|
||||
classes = StreamAutoClaimDeadLetterTest.TestApp.class,
|
||||
properties = {
|
||||
"redis-stream.env-isolation=false",
|
||||
"redis-stream.block-ms=300",
|
||||
"redis-stream.read-count=10",
|
||||
"redis-stream.max-retry=2",
|
||||
"redis-stream.consumer-name=dl-it-consumer",
|
||||
"redis-stream.autoclaim.min-idle-ms=0",
|
||||
"redis-stream.autoclaim.interval-ms=300"
|
||||
})
|
||||
class StreamAutoClaimDeadLetterTest {
|
||||
|
||||
@Autowired
|
||||
private RedisStreamEnhanceTemplate template;
|
||||
|
||||
@Autowired
|
||||
private StringRedisTemplate redis;
|
||||
|
||||
@Autowired
|
||||
private StreamKeyBuilder keyBuilder;
|
||||
|
||||
@Test
|
||||
void permanentlyFailingMessageIsDeadLetteredAndPelDrains() throws Exception {
|
||||
AlwaysFailHandler.DEAD_LETTER_LATCH = new CountDownLatch(1);
|
||||
AlwaysFailHandler.DEAD_LETTER_EX.set(null);
|
||||
|
||||
// 先清掉历史 stream(避免上一轮残留 PEL 干扰),再重建消费组:
|
||||
// delete 会把 listener 启动时建的组一并删除,而 XADD 不会重建组,故须手动用
|
||||
// XGROUP CREATE ... MKSTREAM 重新建组,否则 listener/autoclaim 全部 NOGROUP。
|
||||
String stream = keyBuilder.buildStream(AlwaysFailHandler.TOPIC);
|
||||
redis.delete(stream);
|
||||
redis.execute((RedisCallback<Object>) conn -> {
|
||||
try {
|
||||
return conn.execute("XGROUP",
|
||||
"CREATE".getBytes(StandardCharsets.UTF_8),
|
||||
stream.getBytes(StandardCharsets.UTF_8),
|
||||
AlwaysFailHandler.GROUP.getBytes(StandardCharsets.UTF_8),
|
||||
"$".getBytes(StandardCharsets.UTF_8),
|
||||
"MKSTREAM".getBytes(StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException e) {
|
||||
return null; // BUSYGROUP:组已存在,忽略
|
||||
}
|
||||
});
|
||||
|
||||
AlwaysFail msg = new AlwaysFail();
|
||||
msg.setText("boom");
|
||||
template.send(AlwaysFailHandler.TOPIC, msg, false);
|
||||
|
||||
// 永久失败 + isRetry=true:经首投 RETRY → autoclaim 反复重投,maxRetry=2 时必须在有限轮内落死信。
|
||||
boolean deadLettered = AlwaysFailHandler.DEAD_LETTER_LATCH.await(15, TimeUnit.SECONDS);
|
||||
assertTrue(deadLettered, "15s 内应触发 max-retry 死信落异常(当前代码因计数冻结永不触发)");
|
||||
assertTrue(AlwaysFailHandler.DEAD_LETTER_EX.get() != null
|
||||
&& AlwaysFailHandler.DEAD_LETTER_EX.get().contains("max retry exceeded"),
|
||||
"死信落异常应携带 max-retry 信息,实际=" + AlwaysFailHandler.DEAD_LETTER_EX.get());
|
||||
|
||||
// 死信后 PEL 必须清空。强制 ACK 在 saveExceptionMsgLog 回调之后执行(latch 先于 ACK trip),
|
||||
// 故轮询等待 ACK 完成,避免与 ACK 抢跑。
|
||||
long pending = -1;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
PendingMessages pel = redis.opsForStream()
|
||||
.pending(stream, AlwaysFailHandler.GROUP, Range.unbounded(), 100);
|
||||
pending = pel == null ? 0 : pel.size();
|
||||
if (pending == 0) {
|
||||
break;
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
assertEquals(0L, pending, "死信后该 group 的 PEL 应清零,实际仍有 " + pending + " 条");
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
static class TestApp {
|
||||
@Bean
|
||||
AlwaysFailHandler alwaysFailHandler() {
|
||||
return new AlwaysFailHandler();
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
static class AlwaysFail extends StreamBaseMessage {
|
||||
private String text;
|
||||
}
|
||||
|
||||
static class AlwaysFailHandler extends EnhanceStreamConsumerHandler<AlwaysFail> {
|
||||
static final String TOPIC = "IT_DeadLetter_Topic";
|
||||
static final String GROUP = "IT_DeadLetter_Group";
|
||||
static volatile CountDownLatch DEAD_LETTER_LATCH = new CountDownLatch(1);
|
||||
static final AtomicReference<String> DEAD_LETTER_EX = new AtomicReference<>();
|
||||
|
||||
@Override
|
||||
public String topic() {
|
||||
return TOPIC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String group() {
|
||||
return GROUP;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<AlwaysFail> messageType() {
|
||||
return AlwaysFail.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(AlwaysFail message) {
|
||||
throw new IllegalStateException("permanent failure: " + message.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRetry() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean throwException() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** autoclaim 超 maxRetry 落异常时回调(dispatchMessage 的 RETRY 分支不会调用本方法)。 */
|
||||
@Override
|
||||
public void saveExceptionMsgLog(AlwaysFail message, String body, Exception e) {
|
||||
String em = e == null ? null : e.getMessage();
|
||||
if (em != null && em.contains("max retry exceeded")) {
|
||||
DEAD_LETTER_EX.set(em);
|
||||
DEAD_LETTER_LATCH.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Redis 不可达时禁用整个测试类(上下文加载即连 Redis,故须在加载前判断)。 */
|
||||
static class RedisReachableCondition implements ExecutionCondition {
|
||||
@Override
|
||||
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
|
||||
String host = System.getProperty("spring.redis.host", "127.0.0.1");
|
||||
int port = Integer.parseInt(System.getProperty("spring.redis.port", "6379"));
|
||||
try (Socket s = new Socket()) {
|
||||
s.connect(new InetSocketAddress(host, port), 500);
|
||||
return ConditionEvaluationResult.enabled("redis reachable at " + host + ":" + port);
|
||||
} catch (Exception e) {
|
||||
return ConditionEvaluationResult.disabled(
|
||||
"no redis at " + host + ":" + port + ", skip integration test");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.njcn.middle.stream.integration;
|
||||
|
||||
import com.njcn.middle.stream.domain.StreamBaseMessage;
|
||||
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
||||
import com.njcn.middle.stream.template.RedisStreamEnhanceTemplate;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import org.junit.jupiter.api.extension.ExecutionCondition;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
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.assertNotNull;
|
||||
|
||||
/**
|
||||
* 集成测试(Gate):真实 Redis 下跑通 {@code XADD → XREADGROUP → XACK} 闭环。
|
||||
*
|
||||
* <p>Redis 不可达时整个类被 {@link RedisReachableCondition} 禁用(skipped,非 failed),
|
||||
* 保证无 Redis 环境 {@code mvn test} 仍全绿。
|
||||
*
|
||||
* <p>连接默认 {@code 127.0.0.1:6379} 无密码;指向其它 Redis 用系统属性覆盖(凭据不入代码):
|
||||
* {@code mvn test -Dspring.redis.host=x.x.x.x -Dspring.redis.port=12379 -Dspring.redis.password=***}。
|
||||
* 本地起 Redis:{@code docker run -d -p 6379:6379 redis:6.2}。
|
||||
*/
|
||||
@ExtendWith(StreamRoundTripTest.RedisReachableCondition.class)
|
||||
@SpringBootTest(
|
||||
classes = StreamRoundTripTest.TestApp.class,
|
||||
properties = {
|
||||
"redis-stream.env-isolation=false",
|
||||
"redis-stream.block-ms=500",
|
||||
"redis-stream.consumer-name=it-consumer"
|
||||
})
|
||||
class StreamRoundTripTest {
|
||||
|
||||
@Autowired
|
||||
private RedisStreamEnhanceTemplate template;
|
||||
|
||||
@Test
|
||||
void sendAndReceiveRoundTrip() throws Exception {
|
||||
PingHandler.RECEIVED.clear();
|
||||
Ping ping = new Ping();
|
||||
ping.setText("hello-it");
|
||||
|
||||
template.send(PingHandler.TOPIC, ping, false);
|
||||
|
||||
Ping got = PingHandler.RECEIVED.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(got, "5s 内应通过 XREADGROUP 收到消息");
|
||||
assertEquals("hello-it", got.getText(), "内容往返一致");
|
||||
assertEquals(ping.getKey(), got.getKey(), "key 往返一致");
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
static class TestApp {
|
||||
@Bean
|
||||
PingHandler pingHandler() {
|
||||
return new PingHandler();
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
static class Ping extends StreamBaseMessage {
|
||||
private String text;
|
||||
}
|
||||
|
||||
static class PingHandler extends EnhanceStreamConsumerHandler<Ping> {
|
||||
static final String TOPIC = "IT_Ping_Topic";
|
||||
static final BlockingQueue<Ping> RECEIVED = new LinkedBlockingQueue<>();
|
||||
|
||||
@Override
|
||||
public String topic() {
|
||||
return TOPIC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String group() {
|
||||
return "IT_Ping_Group";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<Ping> messageType() {
|
||||
return Ping.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(Ping message) {
|
||||
RECEIVED.add(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRetry() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean throwException() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Redis 不可达时禁用整个测试类(上下文加载即连 Redis,故须在加载前判断)。 */
|
||||
static class RedisReachableCondition implements ExecutionCondition {
|
||||
@Override
|
||||
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
|
||||
String host = System.getProperty("spring.redis.host", "127.0.0.1");
|
||||
int port = Integer.parseInt(System.getProperty("spring.redis.port", "6379"));
|
||||
try (Socket s = new Socket()) {
|
||||
s.connect(new InetSocketAddress(host, port), 500);
|
||||
return ConditionEvaluationResult.enabled("redis reachable at " + host + ":" + port);
|
||||
} catch (Exception e) {
|
||||
return ConditionEvaluationResult.disabled(
|
||||
"no redis at " + host + ":" + port + ", skip integration test");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.njcn.middle.stream.support;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class StreamKeyBuilderTest {
|
||||
|
||||
@Test
|
||||
void isolatedWithEnvAppendsSuffix() {
|
||||
StreamKeyBuilder b = new StreamKeyBuilder(true, "prod");
|
||||
assertEquals("FrontData_Topic_prod", b.buildStream("FrontData_Topic"),
|
||||
"开隔离且 env 非空 → topic_env");
|
||||
}
|
||||
|
||||
@Test
|
||||
void notIsolatedReturnsTopicAsIs() {
|
||||
StreamKeyBuilder b = new StreamKeyBuilder(false, "prod");
|
||||
assertEquals("FrontData_Topic", b.buildStream("FrontData_Topic"),
|
||||
"关隔离 → 原 topic(忽略 env)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isolatedWithBlankEnvReturnsTopicAsIs() {
|
||||
assertEquals("T", new StreamKeyBuilder(true, "").buildStream("T"), "env 空串 → 原 topic");
|
||||
assertEquals("T", new StreamKeyBuilder(true, " ").buildStream("T"), "env 全空白 → 原 topic");
|
||||
assertEquals("T", new StreamKeyBuilder(true, null).buildStream("T"), "env null → 原 topic");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.njcn.middle.stream.template;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.njcn.middle.stream.domain.StreamBaseMessage;
|
||||
import com.njcn.middle.stream.support.StreamKeyBuilder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.data.redis.connection.stream.MapRecord;
|
||||
import org.springframework.data.redis.connection.stream.RecordId;
|
||||
import org.springframework.data.redis.core.StreamOperations;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.ENC_GZIP;
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.ENC_PLAIN;
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_BODY;
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_ENC;
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_KEY;
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_TAG;
|
||||
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_TS;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class RedisStreamEnhanceTemplateTest {
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
static class Foo extends StreamBaseMessage {
|
||||
private String name;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private MapRecord<String, String, String> captureSend(boolean compress, Foo foo, String topic, StreamKeyBuilder kb) {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
StreamOperations ops = mock(StreamOperations.class);
|
||||
doReturn(ops).when(redis).opsForStream();
|
||||
// send() 内 ofMap 返回 MapRecord,编译期绑定 add(MapRecord) 重载(非 add(Record))
|
||||
doReturn(RecordId.of("0-1")).when(ops).add(any(MapRecord.class));
|
||||
|
||||
RedisStreamEnhanceTemplate tpl = new RedisStreamEnhanceTemplate(redis, kb);
|
||||
RecordId id = tpl.send(topic, foo, compress);
|
||||
assertEquals("0-1", id.getValue(), "返回 XADD 的 RecordId");
|
||||
|
||||
ArgumentCaptor<MapRecord> cap = ArgumentCaptor.forClass(MapRecord.class);
|
||||
verify(ops).add(cap.capture());
|
||||
return (MapRecord<String, String, String>) cap.getValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPlainFillsFiveFields() {
|
||||
Foo foo = new Foo();
|
||||
foo.setTag("t1");
|
||||
foo.setName("hello");
|
||||
MapRecord<String, String, String> rec = captureSend(false, foo, "MyTopic", new StreamKeyBuilder(false, ""));
|
||||
|
||||
assertEquals("MyTopic", rec.getStream(), "关隔离 → 原 topic 作 stream 名");
|
||||
Map<String, String> v = rec.getValue();
|
||||
assertEquals(5, v.size(), "固定五字段 key/tag/enc/body/ts");
|
||||
assertEquals(foo.getKey(), v.get(F_KEY), "key 取自 message");
|
||||
assertEquals("t1", v.get(F_TAG), "tag 取自 message");
|
||||
assertEquals(ENC_PLAIN, v.get(F_ENC), "不压缩 enc=plain");
|
||||
assertTrue(v.get(F_BODY).contains("hello"), "明文 body 含原文");
|
||||
assertNotNull(v.get(F_TS), "ts 非空");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendCompressUsesGzipAndShorterBody() {
|
||||
Foo foo = new Foo();
|
||||
foo.setName(StrUtil.repeat("abcdefgh", 200));
|
||||
String json = JSONObject.toJSONString(foo);
|
||||
|
||||
MapRecord<String, String, String> rec = captureSend(true, foo, "MyTopic", new StreamKeyBuilder(false, ""));
|
||||
Map<String, String> v = rec.getValue();
|
||||
assertEquals(ENC_GZIP, v.get(F_ENC), "压缩 enc=gzip");
|
||||
assertTrue(v.get(F_BODY).length() < json.length(), "压缩 body 短于原 json");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendUsesEnvIsolatedStreamName() {
|
||||
MapRecord<String, String, String> rec =
|
||||
captureSend(false, new Foo(), "MyTopic", new StreamKeyBuilder(true, "prod"));
|
||||
assertEquals("MyTopic_prod", rec.getStream(), "开隔离 → stream 名带 env 后缀");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user