diff --git a/src/test/java/com/njcn/middle/stream/container/StreamAutoClaimConcurrentClaimTest.java b/src/test/java/com/njcn/middle/stream/container/StreamAutoClaimConcurrentClaimTest.java new file mode 100644 index 0000000..0627f90 --- /dev/null +++ b/src/test/java/com/njcn/middle/stream/container/StreamAutoClaimConcurrentClaimTest.java @@ -0,0 +1,231 @@ +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 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.data.domain.Range; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.PendingMessages; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +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.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 集成测试(Gate):autoclaim 多实例并发认领同一条 idle 超时的 PEL 消息时,业务必须只被处理一次。 + * + *
端到端验证 B1 幂等:两个 consumerName 不同的 {@link StreamAutoClaimer} 用 {@link CyclicBarrier} + * 同时对同一条消息发 {@code XCLAIM}(不带 JUSTID)。Redis 单线程串行处理命令——先到者认领成功、把 + * idle 重置为 0;后到者看到 {@code idle≈0 < minIdle} 必然抢不到({@link StreamAutoClaimer#claim} + * 返回 false)→ 直接退出,不 dispatch。故无论哪个线程先到,{@code handleMessage} 恒只触发一次。 + * + *
这条用例覆盖纯 mock 单测({@code StreamAutoClaimerClaimTest})和单实例死信集成测试都证明不了的 + * 真实并发竞态:mock 短路了 XCLAIM 的真实归属转移 + idle 重置语义,唯有真 Redis 能裁判。 + * + *
测试放在 {@code container} 包以调用 package-private 的 {@link StreamAutoClaimer#claimOne}, + * 直接复现 autoclaim 单条认领,不依赖定时器时序,结果确定(非 flaky)。 + * + *
Redis 不可达时整类禁用(skipped)。连接默认 {@code 127.0.0.1:6379},用
+ * {@code -Dspring.redis.host=… -Dspring.redis.port=…} 覆盖。本地起:{@code docker run -d -p 6379:6379 redis:6.2}。
+ */
+@ExtendWith(StreamAutoClaimConcurrentClaimTest.RedisReachableCondition.class)
+@SpringBootTest(
+ classes = StreamAutoClaimConcurrentClaimTest.TestApp.class,
+ properties = {
+ "redis-stream.env-isolation=false",
+ "redis-stream.read-count=10"
+ })
+class StreamAutoClaimConcurrentClaimTest {
+
+ private static final String TOPIC = "IT_Concurrent_Topic";
+ private static final String GROUP = "IT_Concurrent_Group";
+ private static final long MIN_IDLE_MS = 500L;
+
+ @Autowired
+ private RedisStreamEnhanceTemplate template;
+
+ @Autowired
+ private StringRedisTemplate redis;
+
+ @Autowired
+ private StreamKeyBuilder keyBuilder;
+
+ @Test
+ void onlyOneInstanceProcessesUnderConcurrentClaim() throws Exception {
+ CountingHandler handler = new CountingHandler();
+ String stream = keyBuilder.buildStream(TOPIC);
+
+ // 清历史 + 重建组(delete 会连带删组,XADD 不重建,故须手动 MKSTREAM 建组)。
+ redis.delete(stream);
+ createGroup(stream, GROUP);
+
+ // 发一条真实消息(含 enc/body,可被 dispatchMessage 正常 decode→反序列化)。
+ ConcurrentMsg msg = new ConcurrentMsg();
+ msg.setText("process-once");
+ template.send(TOPIC, msg, false);
+
+ // 用 origin consumer XREADGROUP 读进 PEL(不 ACK),投递计数=1;之后 idle 从 0 起涨。
+ List