fix(other): index empty rooms and singleflight discovery rebuild
This commit is contained in:
parent
0177ca0dce
commit
691ef81b12
@ -0,0 +1,35 @@
|
||||
package com.red.circle.other.inner.model.cache;
|
||||
|
||||
/**
|
||||
* 空房间缓存的跨服务 Redis key 约定。
|
||||
*
|
||||
* <p>live-service 负责写入空房间快照,other-service 负责消费索引生成发现页。key 只能在这个
|
||||
* 共享模型中定义,避免两个服务独立拼接后发生不可见的数据分叉。</p>
|
||||
*/
|
||||
public final class EmptyRoomCacheKeys {
|
||||
|
||||
/** 保留旧前缀,使滚动发布期间尚未升级的 other-service 仍可读取房间快照。 */
|
||||
private static final String PAYLOAD_PREFIX = "empty_room_cache:";
|
||||
|
||||
/**
|
||||
* 索引必须避开 {@code empty_room_cache:*}。旧版 other-service 会扫描该通配符并对结果执行
|
||||
* GET;如果索引也使用旧前缀,旧实例读到 ZSet 会产生 WRONGTYPE。
|
||||
*/
|
||||
private static final String INDEX_PREFIX = "empty_room_index:v2:";
|
||||
private static final String ALL_REGIONS = "__ALL__";
|
||||
|
||||
private EmptyRoomCacheKeys() {
|
||||
}
|
||||
|
||||
public static String payload(Long roomId) {
|
||||
return PAYLOAD_PREFIX + roomId;
|
||||
}
|
||||
|
||||
public static String regionIndex(String sysOrigin, String region) {
|
||||
return INDEX_PREFIX + sysOrigin + ":" + region;
|
||||
}
|
||||
|
||||
public static String allRegionsIndex(String sysOrigin) {
|
||||
return INDEX_PREFIX + sysOrigin + ":" + ALL_REGIONS;
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
package com.red.circle.live.app.command.user;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.red.circle.common.business.dto.cmd.app.AppRoomIdCmd;
|
||||
import com.red.circle.component.redis.service.RedisService;
|
||||
import com.red.circle.framework.dto.ResultResponse;
|
||||
import com.red.circle.live.domain.gateway.LiveMicrophoneGateway;
|
||||
import com.red.circle.live.domain.live.LiveMicrophone;
|
||||
import com.red.circle.live.domain.live.LiveMusicStatus;
|
||||
import com.red.circle.framework.dto.ResultResponse;
|
||||
import com.red.circle.live.domain.gateway.LiveMicrophoneGateway;
|
||||
import com.red.circle.live.domain.live.LiveMicrophone;
|
||||
import com.red.circle.live.domain.live.LiveMusicStatus;
|
||||
import com.red.circle.live.infra.database.cache.service.EmptyRoomCacheWriter;
|
||||
import com.red.circle.live.infra.database.cache.service.LiveMicCacheService;
|
||||
import com.red.circle.live.infra.database.cache.service.LiveMusicHeartbeatService;
|
||||
import com.red.circle.live.infra.database.cache.service.UserAgoraTokenCacheService;
|
||||
@ -16,7 +16,6 @@ import com.red.circle.tool.core.collection.CollectionUtils;
|
||||
import com.red.circle.tool.core.thread.ThreadPoolManager;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
@ -37,10 +36,8 @@ public class LiveQuitRoomExe {
|
||||
private final ActiveVoiceRoomClient activeVoiceRoomClient;
|
||||
private final RedisService redisService;
|
||||
private final UserAgoraTokenCacheService userAgoraTokenCacheService;
|
||||
|
||||
private static final String EMPTY_ROOM_CACHE_KEY = "empty_room_cache:";
|
||||
private static final int CACHE_EXPIRE_MINUTES = 10;
|
||||
|
||||
private final EmptyRoomCacheWriter emptyRoomCacheWriter;
|
||||
|
||||
public void execute(AppRoomIdCmd cmd) {
|
||||
Long roomId = cmd.getRoomId();
|
||||
Long userId = cmd.requiredReqUserId();
|
||||
@ -94,13 +91,8 @@ public class LiveQuitRoomExe {
|
||||
ResultResponse<ActiveVoiceRoomCO> noBodyRoom = activeVoiceRoomClient.createNoBodyRoom(roomId);
|
||||
ActiveVoiceRoomCO body = noBodyRoom.getBody();
|
||||
if (Objects.nonNull(body)) {
|
||||
String cacheKey = EMPTY_ROOM_CACHE_KEY + roomId;
|
||||
redisService.setIfAbsent(
|
||||
cacheKey,
|
||||
JSON.toJSONString(body),
|
||||
CACHE_EXPIRE_MINUTES,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
// 新 live 同时维护 legacy payload 与 ZSet,使它可以先于 other 发布且不影响旧 reader。
|
||||
emptyRoomCacheWriter.save(body);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("quitRoom cacheEmptyRoom error roomId={}, userId={}", roomId, userId, e);
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package com.red.circle.live.infra.database.cache.service;
|
||||
|
||||
import com.red.circle.other.inner.model.dto.live.ActiveVoiceRoomCO;
|
||||
|
||||
/**
|
||||
* 维护发现页所需的空房间快照与索引。
|
||||
*/
|
||||
public interface EmptyRoomCacheWriter {
|
||||
|
||||
/**
|
||||
* 保存空房间快照,并把房间登记到所属区域和同系统全区域索引。
|
||||
*
|
||||
* @param room other-service 生成的完整空房间快照
|
||||
*/
|
||||
void save(ActiveVoiceRoomCO room);
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
package com.red.circle.live.infra.database.cache.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.red.circle.component.redis.service.RedisService;
|
||||
import com.red.circle.live.infra.database.cache.service.EmptyRoomCacheWriter;
|
||||
import com.red.circle.other.inner.model.cache.EmptyRoomCacheKeys;
|
||||
import com.red.circle.other.inner.model.dto.live.ActiveVoiceRoomCO;
|
||||
import com.red.circle.tool.core.text.StringUtils;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 使用“单房间快照 + ZSet 到期索引”维护空房间。
|
||||
*
|
||||
* <p>ZSet score 是快照实际过期毫秒。这样 Set/Hash 不具备的成员级 TTL 可以在读取时通过
|
||||
* ZREMRANGEBYSCORE 有界清理,同时发现页不再遍历整个 Redis DB。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EmptyRoomCacheWriterImpl implements EmptyRoomCacheWriter {
|
||||
|
||||
private static final long PAYLOAD_TTL_MINUTES = 10L;
|
||||
private static final long INDEX_TTL_MINUTES = PAYLOAD_TTL_MINUTES + 1L;
|
||||
|
||||
private final RedisService redisService;
|
||||
|
||||
@Override
|
||||
public void save(ActiveVoiceRoomCO room) {
|
||||
if (!isIndexable(room)) {
|
||||
log.warn("跳过字段不完整的空房间索引, roomId={}, sysOrigin={}, region={}",
|
||||
Objects.isNull(room) ? null : room.getId(),
|
||||
Objects.isNull(room) ? null : room.getSysOrigin(),
|
||||
Objects.isNull(room) ? null : room.getRegion());
|
||||
return;
|
||||
}
|
||||
|
||||
String payloadKey = EmptyRoomCacheKeys.payload(room.getId());
|
||||
try {
|
||||
// 保持原有 SETNX 语义:普通成员频繁退出不能持续延长同一空房间的展示窗口。
|
||||
redisService.setIfAbsent(
|
||||
payloadKey,
|
||||
JSON.toJSONString(room),
|
||||
PAYLOAD_TTL_MINUTES,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
|
||||
Long remainingMillis = ensurePayloadTtl(payloadKey, room);
|
||||
if (Objects.isNull(remainingMillis) || remainingMillis <= 0L) {
|
||||
log.warn("空房间快照在建立索引前已过期, roomId={}, payloadKey={}", room.getId(), payloadKey);
|
||||
return;
|
||||
}
|
||||
|
||||
double expiresAtMillis = System.currentTimeMillis() + remainingMillis;
|
||||
String roomMember = room.getId().toString();
|
||||
addIndexMember(
|
||||
EmptyRoomCacheKeys.regionIndex(room.getSysOrigin(), room.getRegion()),
|
||||
roomMember,
|
||||
expiresAtMillis
|
||||
);
|
||||
addIndexMember(
|
||||
EmptyRoomCacheKeys.allRegionsIndex(room.getSysOrigin()),
|
||||
roomMember,
|
||||
expiresAtMillis
|
||||
);
|
||||
} catch (RuntimeException e) {
|
||||
// 空房间只是发现页补充数据,索引故障不能反向破坏已经完成的退房主链路。
|
||||
log.error("维护空房间索引失败, roomId={}, sysOrigin={}, region={}",
|
||||
room.getId(), room.getSysOrigin(), room.getRegion(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private Long ensurePayloadTtl(String payloadKey, ActiveVoiceRoomCO room) {
|
||||
Long remainingMillis = redisService.getExpire(payloadKey, TimeUnit.MILLISECONDS);
|
||||
if (Objects.nonNull(remainingMillis) && remainingMillis > 0L) {
|
||||
return remainingMillis;
|
||||
}
|
||||
|
||||
if (Objects.equals(remainingMillis, -1L)) {
|
||||
// 修复历史上可能残留的无 TTL 快照,避免索引成员永久存在。
|
||||
redisService.expire(payloadKey, PAYLOAD_TTL_MINUTES, TimeUnit.MINUTES);
|
||||
return TimeUnit.MINUTES.toMillis(PAYLOAD_TTL_MINUTES);
|
||||
}
|
||||
|
||||
// 快照可能恰好在 SETNX 与 TTL 查询之间过期;仅重试一次,避免异常 Redis 下无界重试。
|
||||
redisService.setIfAbsent(
|
||||
payloadKey,
|
||||
JSON.toJSONString(room),
|
||||
PAYLOAD_TTL_MINUTES,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
return redisService.getExpire(payloadKey, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void addIndexMember(String indexKey, String roomMember, double expiresAtMillis) {
|
||||
// 每次写入先增量淘汰已过期成员;持续活跃的 ALL 索引也只保留最近十分钟窗口,避免集中大删除。
|
||||
redisService.zsetRemoveByScore(indexKey, 0D, System.currentTimeMillis());
|
||||
redisService.zsetAdd(indexKey, roomMember, expiresAtMillis);
|
||||
// 索引 TTL 比成员窗口多一分钟;无新增写入时,最后一批成员清理后索引也会自然释放。
|
||||
redisService.expire(indexKey, INDEX_TTL_MINUTES, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
private boolean isIndexable(ActiveVoiceRoomCO room) {
|
||||
return Objects.nonNull(room)
|
||||
&& Objects.nonNull(room.getId())
|
||||
&& StringUtils.isNotBlank(room.getSysOrigin())
|
||||
&& StringUtils.isNotBlank(room.getRegion());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,276 @@
|
||||
package com.red.circle.live.infra.database.cache.service.impl;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.red.circle.component.redis.service.RedisService;
|
||||
import com.red.circle.other.inner.model.dto.live.ActiveVoiceRoomCO;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.Test;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.ZSetOperations;
|
||||
|
||||
public class EmptyRoomCacheWriterImplTest {
|
||||
|
||||
private static final long ROOM_ID = 1001L;
|
||||
private static final long PAYLOAD_TTL_MILLIS = TimeUnit.MINUTES.toMillis(10L);
|
||||
|
||||
@Test
|
||||
public void saveShouldKeepLegacyPayloadAndWriteRegionAndAllRegionIndexes() {
|
||||
RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
|
||||
redisTemplate.remainingTtls.add(TimeUnit.MINUTES.toMillis(7L));
|
||||
EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
|
||||
|
||||
long beforeSave = System.currentTimeMillis();
|
||||
writer.save(room(ROOM_ID, "YOLO", "AE"));
|
||||
long afterSave = System.currentTimeMillis();
|
||||
|
||||
assertEquals(1, redisTemplate.payloadWrites.size());
|
||||
PayloadWrite payloadWrite = redisTemplate.payloadWrites.get(0);
|
||||
assertEquals("empty_room_cache:1001", payloadWrite.key());
|
||||
assertEquals(10L, payloadWrite.ttl());
|
||||
assertEquals(TimeUnit.MINUTES, payloadWrite.unit());
|
||||
ActiveVoiceRoomCO cachedRoom = JSON.parseObject(payloadWrite.value().toString(),
|
||||
ActiveVoiceRoomCO.class);
|
||||
assertEquals(Long.valueOf(ROOM_ID), cachedRoom.getId());
|
||||
assertEquals("YOLO", cachedRoom.getSysOrigin());
|
||||
assertEquals("AE", cachedRoom.getRegion());
|
||||
|
||||
assertEquals(2, redisTemplate.indexWrites.size());
|
||||
assertEquals(2, redisTemplate.indexCleanupCalls.size());
|
||||
IndexWrite regionIndex = redisTemplate.indexWrites.get(0);
|
||||
IndexWrite allRegionsIndex = redisTemplate.indexWrites.get(1);
|
||||
assertEquals("empty_room_index:v2:YOLO:AE", regionIndex.key());
|
||||
assertEquals("empty_room_index:v2:YOLO:__ALL__", allRegionsIndex.key());
|
||||
assertEquals("1001", regionIndex.member());
|
||||
assertEquals("1001", allRegionsIndex.member());
|
||||
assertEquals(regionIndex.score(), allRegionsIndex.score(), 0D);
|
||||
assertEquals("empty_room_index:v2:YOLO:AE", redisTemplate.indexCleanupCalls.get(0).key());
|
||||
assertEquals("empty_room_index:v2:YOLO:__ALL__", redisTemplate.indexCleanupCalls.get(1).key());
|
||||
|
||||
long remainingMillis = TimeUnit.MINUTES.toMillis(7L);
|
||||
assertTrue(regionIndex.score() >= beforeSave + remainingMillis);
|
||||
assertTrue(regionIndex.score() <= afterSave + remainingMillis);
|
||||
assertEquals(List.of(
|
||||
new ExpireCall("empty_room_index:v2:YOLO:AE", 11L, TimeUnit.MINUTES),
|
||||
new ExpireCall("empty_room_index:v2:YOLO:__ALL__", 11L, TimeUnit.MINUTES)
|
||||
), redisTemplate.expireCalls);
|
||||
assertTrue(redisTemplate.events.indexOf("payload:empty_room_cache:1001")
|
||||
< redisTemplate.events.indexOf("index:empty_room_index:v2:YOLO:AE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveShouldRepairLegacyPayloadWithoutTtlBeforeIndexing() {
|
||||
RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
|
||||
redisTemplate.remainingTtls.add(-1L);
|
||||
EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
|
||||
|
||||
long beforeSave = System.currentTimeMillis();
|
||||
writer.save(room(ROOM_ID, "YOLO", "AE"));
|
||||
long afterSave = System.currentTimeMillis();
|
||||
|
||||
assertEquals(new ExpireCall("empty_room_cache:1001", 10L, TimeUnit.MINUTES),
|
||||
redisTemplate.expireCalls.get(0));
|
||||
assertEquals(3, redisTemplate.expireCalls.size());
|
||||
assertEquals(2, redisTemplate.indexWrites.size());
|
||||
double score = redisTemplate.indexWrites.get(0).score();
|
||||
assertTrue(score >= beforeSave + PAYLOAD_TTL_MILLIS);
|
||||
assertTrue(score <= afterSave + PAYLOAD_TTL_MILLIS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveShouldRetrySnapshotThatExpiredBeforeTtlRead() {
|
||||
RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
|
||||
redisTemplate.remainingTtls.add(-2L);
|
||||
redisTemplate.remainingTtls.add(TimeUnit.MINUTES.toMillis(5L));
|
||||
EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
|
||||
|
||||
writer.save(room(ROOM_ID, "YOLO", "AE"));
|
||||
|
||||
assertEquals(2, redisTemplate.payloadWrites.size());
|
||||
assertEquals("empty_room_cache:1001", redisTemplate.payloadWrites.get(0).key());
|
||||
assertEquals("empty_room_cache:1001", redisTemplate.payloadWrites.get(1).key());
|
||||
assertEquals(2, redisTemplate.indexWrites.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveShouldIgnoreRoomsThatCannotBePartitioned() {
|
||||
RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
|
||||
EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
|
||||
|
||||
writer.save(null);
|
||||
writer.save(room(null, "YOLO", "AE"));
|
||||
writer.save(room(ROOM_ID, " ", "AE"));
|
||||
writer.save(room(ROOM_ID, "YOLO", " "));
|
||||
|
||||
assertTrue(redisTemplate.payloadWrites.isEmpty());
|
||||
assertTrue(redisTemplate.indexWrites.isEmpty());
|
||||
assertTrue(redisTemplate.expireCalls.isEmpty());
|
||||
assertEquals(0, redisTemplate.getExpireCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveShouldNotPropagatePayloadWriteFailure() {
|
||||
RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
|
||||
redisTemplate.payloadWriteFailure = new IllegalStateException("redis unavailable");
|
||||
EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
|
||||
|
||||
// 空房缓存是退房后的附加效果;Redis 故障不能逃逸到调用者并破坏主链路。
|
||||
writer.save(room(ROOM_ID, "YOLO", "AE"));
|
||||
|
||||
assertEquals(1, redisTemplate.payloadWriteAttempts);
|
||||
assertTrue(redisTemplate.indexWrites.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveShouldNotPropagateIndexWriteFailure() {
|
||||
RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
|
||||
redisTemplate.remainingTtls.add(TimeUnit.MINUTES.toMillis(5L));
|
||||
redisTemplate.indexWriteFailure = new IllegalStateException("zset unavailable");
|
||||
EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
|
||||
|
||||
writer.save(room(ROOM_ID, "YOLO", "AE"));
|
||||
|
||||
assertEquals(1, redisTemplate.payloadWrites.size());
|
||||
assertEquals(1, redisTemplate.indexWriteAttempts);
|
||||
assertTrue(redisTemplate.indexWrites.isEmpty());
|
||||
assertTrue(redisTemplate.expireCalls.isEmpty());
|
||||
}
|
||||
|
||||
private static EmptyRoomCacheWriterImpl writer(RecordingRedisTemplate redisTemplate) {
|
||||
return new EmptyRoomCacheWriterImpl(new RedisService(redisTemplate));
|
||||
}
|
||||
|
||||
private static ActiveVoiceRoomCO room(Long roomId, String sysOrigin, String region) {
|
||||
ActiveVoiceRoomCO room = new ActiveVoiceRoomCO();
|
||||
room.setId(roomId);
|
||||
room.setSysOrigin(sysOrigin);
|
||||
room.setRegion(region);
|
||||
room.setUserId(2002L);
|
||||
room.setRoomAccount("10001");
|
||||
return room;
|
||||
}
|
||||
|
||||
private record PayloadWrite(String key, Object value, long ttl, TimeUnit unit) {
|
||||
}
|
||||
|
||||
private record IndexWrite(String key, Object member, double score) {
|
||||
}
|
||||
|
||||
private record IndexCleanupCall(String key, double minScore, double maxScore) {
|
||||
}
|
||||
|
||||
private record ExpireCall(String key, long ttl, TimeUnit unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 只实现 writer 允许使用的 Redis 命令。生产代码若意外增加扫描或其他命令,测试会立即失败,
|
||||
* 从而守住房间发现链路不能再退化为全库遍历的边界。
|
||||
*/
|
||||
private static final class RecordingRedisTemplate extends RedisTemplate<String, Object> {
|
||||
|
||||
private final List<PayloadWrite> payloadWrites = new ArrayList<>();
|
||||
private final List<IndexWrite> indexWrites = new ArrayList<>();
|
||||
private final List<IndexCleanupCall> indexCleanupCalls = new ArrayList<>();
|
||||
private final List<ExpireCall> expireCalls = new ArrayList<>();
|
||||
private final List<String> events = new ArrayList<>();
|
||||
private final Queue<Long> remainingTtls = new ArrayDeque<>();
|
||||
private RuntimeException payloadWriteFailure;
|
||||
private RuntimeException indexWriteFailure;
|
||||
private int payloadWriteAttempts;
|
||||
private int indexWriteAttempts;
|
||||
private int getExpireCalls;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public ValueOperations<String, Object> opsForValue() {
|
||||
return proxy(ValueOperations.class, (methodName, args) -> {
|
||||
if (Objects.equals("setIfAbsent", methodName) && args.length == 4) {
|
||||
payloadWriteAttempts++;
|
||||
if (payloadWriteFailure != null) {
|
||||
throw payloadWriteFailure;
|
||||
}
|
||||
payloadWrites.add(new PayloadWrite(
|
||||
args[0].toString(), args[1], ((Number) args[2]).longValue(), (TimeUnit) args[3]
|
||||
));
|
||||
events.add("payload:" + args[0]);
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
throw new UnsupportedOperationException(methodName);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public ZSetOperations<String, Object> opsForZSet() {
|
||||
return proxy(ZSetOperations.class, (methodName, args) -> {
|
||||
if (Objects.equals("add", methodName) && args.length == 3) {
|
||||
indexWriteAttempts++;
|
||||
if (indexWriteFailure != null) {
|
||||
throw indexWriteFailure;
|
||||
}
|
||||
indexWrites.add(new IndexWrite(
|
||||
args[0].toString(), args[1], ((Number) args[2]).doubleValue()
|
||||
));
|
||||
events.add("index:" + args[0]);
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
if (Objects.equals("removeRangeByScore", methodName) && args.length == 3) {
|
||||
indexCleanupCalls.add(new IndexCleanupCall(
|
||||
args[0].toString(),
|
||||
((Number) args[1]).doubleValue(),
|
||||
((Number) args[2]).doubleValue()
|
||||
));
|
||||
return 0L;
|
||||
}
|
||||
throw new UnsupportedOperationException(methodName);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getExpire(String key, TimeUnit timeUnit) {
|
||||
getExpireCalls++;
|
||||
assertEquals(TimeUnit.MILLISECONDS, timeUnit);
|
||||
if (remainingTtls.isEmpty()) {
|
||||
throw new AssertionError("Missing getExpire result for " + key);
|
||||
}
|
||||
return remainingTtls.remove();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean expire(String key, long timeout, TimeUnit unit) {
|
||||
expireCalls.add(new ExpireCall(key, timeout, unit));
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T proxy(Class<T> type, Operation operation) {
|
||||
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type},
|
||||
(proxy, method, args) -> {
|
||||
if (method.getDeclaringClass().equals(Object.class)) {
|
||||
return switch (method.getName()) {
|
||||
case "toString" -> type.getSimpleName() + "RecordingProxy";
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "equals" -> proxy == args[0];
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
};
|
||||
}
|
||||
return operation.invoke(method.getName(), args == null ? new Object[0] : args);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Operation {
|
||||
|
||||
Object invoke(String methodName, Object[] args);
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ import com.red.circle.other.domain.gateway.user.ability.UserSVipGateway;
|
||||
import com.red.circle.other.domain.model.user.UserProfile;
|
||||
import com.red.circle.other.domain.rocket.RocketStatus;
|
||||
import com.red.circle.other.infra.common.sys.CountryCodeAliasSupport;
|
||||
import com.red.circle.other.infra.database.cache.service.other.EmptyRoomCacheReader;
|
||||
import com.red.circle.other.infra.database.cache.service.user.RegionRoomCacheService;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.RoomProfileManager;
|
||||
@ -26,18 +27,15 @@ import com.red.circle.other.infra.database.mongo.service.live.RoomProfileManager
|
||||
import com.red.circle.other.infra.database.mongo.service.live.RoomUserBlacklistService;
|
||||
import com.red.circle.other.infra.database.rds.entity.sys.SysCountryCode;
|
||||
import com.red.circle.other.infra.database.rds.service.sys.SysCountryCodeService;
|
||||
import com.red.circle.other.infra.database.rds.service.user.device.LatestMobileDeviceService;
|
||||
import com.red.circle.other.infra.database.rds.service.user.device.LatestMobileDeviceService;
|
||||
import com.red.circle.other.inner.model.cmd.live.AppActiveVoiceRoomQryCmd;
|
||||
import com.red.circle.other.inner.model.dto.live.ActiveVoiceRoomCO;
|
||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
@ -57,22 +55,23 @@ public class RoomVoiceDiscoverQryExe {
|
||||
private final ActiveVoiceRoomService activeVoiceRoomService;
|
||||
private final RoomUserBlacklistService roomUserBlacklistService;
|
||||
private final LatestMobileDeviceService latestMobileDeviceService;
|
||||
private final RoomProfileManagerService roomProfileManagerService;
|
||||
private final RegionRoomCacheService regionRoomCacheService;
|
||||
private final LiveMicClient liveMicClient;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final RoomProfileManagerService roomProfileManagerService;
|
||||
private final RegionRoomCacheService regionRoomCacheService;
|
||||
private final LiveMicClient liveMicClient;
|
||||
private final EmptyRoomCacheReader emptyRoomCacheReader;
|
||||
private final RocketStatusCacheService rocketStatusCacheService;
|
||||
private final GameLudoService gameLudoService;
|
||||
private final UserProfileGateway userProfileGateway;
|
||||
private final CountryCodeAliasSupport countryCodeAliasSupport;
|
||||
|
||||
private static final String EMPTY_ROOM_CACHE_KEY = "empty_room_cache:*";
|
||||
private static final String MEMBER_QUANTITY_EXT_KEY = "memberQuantity";
|
||||
private static final String FIXED_WEIGHTS_EXT_KEY = "fixedWeights";
|
||||
private static final int DISCOVERY_LIMIT = 50;
|
||||
private static final long DISCOVERY_SLOW_STEP_MILLIS = 100L;
|
||||
private static final long DISCOVERY_SLOW_TOTAL_MILLIS = 300L;
|
||||
private static final long EMPTY_ROOM_SCAN_COUNT = 200L;
|
||||
private static final int REBUILD_WAIT_ATTEMPTS = 20;
|
||||
private static final long REBUILD_WAIT_MILLIS = 50L;
|
||||
private static final long EMPTY_RESULT_CACHE_SECONDS = 30L;
|
||||
|
||||
public List<RoomVoiceProfileCO> execute(AppExtCommand cmd) {
|
||||
long totalStart = System.currentTimeMillis();
|
||||
@ -87,99 +86,160 @@ public class RoomVoiceDiscoverQryExe {
|
||||
stepStart = logDiscoveryStep("prepare", stepStart, reqUserId, sysOrigin, region);
|
||||
|
||||
String key = discoveryCacheKey(sysOrigin, region, allRegionWhiteListUser);
|
||||
String regionsRoom = regionRoomCacheService.getRegionsRoom(key);
|
||||
List<RoomVoiceProfileCO> cachedRooms = readDiscoveryCache(key);
|
||||
stepStart = logDiscoveryStep("read-region-cache", stepStart, reqUserId, sysOrigin, region);
|
||||
if (Objects.nonNull(regionsRoom)) {
|
||||
List<RoomVoiceProfileCO> roomList = JSON.parseArray(regionsRoom, RoomVoiceProfileCO.class);
|
||||
if (CollectionUtils.isEmpty(roomList)) {
|
||||
logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, true, 0);
|
||||
if (Objects.nonNull(cachedRooms)) {
|
||||
stepStart = logDiscoveryStep("filter-cache", stepStart, reqUserId, sysOrigin, region);
|
||||
logDiscoveryTotal(
|
||||
totalStart,
|
||||
reqUserId,
|
||||
sysOrigin,
|
||||
region,
|
||||
allRegionWhiteListUser,
|
||||
true,
|
||||
cachedRooms.size()
|
||||
);
|
||||
return cachedRooms;
|
||||
}
|
||||
|
||||
String rebuildToken = UUID.randomUUID().toString();
|
||||
boolean rebuildLockAcquired = false;
|
||||
boolean rebuildLockUnavailable = false;
|
||||
try {
|
||||
rebuildLockAcquired = regionRoomCacheService.tryAcquireRebuildLock(key, rebuildToken);
|
||||
} catch (RuntimeException e) {
|
||||
// Redis 锁异常时保留发现页可用性;后续缓存/索引访问仍各自 fail-open,不扩大主链路故障。
|
||||
rebuildLockUnavailable = true;
|
||||
log.error("发现缓存重建锁不可用,进入无锁降级, key={}, userId={}", key, reqUserId, e);
|
||||
}
|
||||
|
||||
if (!rebuildLockAcquired && !rebuildLockUnavailable) {
|
||||
List<RoomVoiceProfileCO> publishedRooms = waitForPublishedDiscoveryCache(key);
|
||||
stepStart = logDiscoveryStep("wait-region-cache", stepStart, reqUserId, sysOrigin, region);
|
||||
if (Objects.nonNull(publishedRooms)) {
|
||||
logDiscoveryTotal(
|
||||
totalStart,
|
||||
reqUserId,
|
||||
sysOrigin,
|
||||
region,
|
||||
allRegionWhiteListUser,
|
||||
true,
|
||||
publishedRooms.size()
|
||||
);
|
||||
return publishedRooms;
|
||||
}
|
||||
|
||||
try {
|
||||
// leader 可能失败且已释放锁;等待结束后只允许重新竞争,禁止 follower 直接并发回源。
|
||||
rebuildLockAcquired = regionRoomCacheService.tryAcquireRebuildLock(key, rebuildToken);
|
||||
} catch (RuntimeException e) {
|
||||
rebuildLockUnavailable = true;
|
||||
log.error("等待后重试发现缓存重建锁失败,进入无锁降级, key={}, userId={}", key, reqUserId, e);
|
||||
}
|
||||
if (!rebuildLockAcquired && !rebuildLockUnavailable) {
|
||||
log.warn("等待发现缓存重建超时,拒绝并发回源, key={}, userId={}", key, reqUserId);
|
||||
logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, false, 0);
|
||||
return CollectionUtils.newArrayList();
|
||||
}
|
||||
List<RoomVoiceProfileCO> availableRooms = roomVoiceProfileCommon
|
||||
.filterAvailableRoomProfiles(roomList);
|
||||
if (availableRooms.size() != roomList.size()) {
|
||||
regionRoomCacheService.remove(key);
|
||||
}
|
||||
stepStart = logDiscoveryStep("filter-cache", stepStart, reqUserId, sysOrigin, region);
|
||||
logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, true, availableRooms.size());
|
||||
return availableRooms;
|
||||
}
|
||||
|
||||
List<ActiveVoiceRoom> activeVoiceRooms = allRegionWhiteListUser
|
||||
? activeVoiceRoomService.listDiscover(
|
||||
try {
|
||||
if (rebuildLockAcquired) {
|
||||
// 获取锁后必须二次检查,覆盖“首次 miss 与 SETNX 之间已有 leader 发布”的竞态窗口。
|
||||
List<RoomVoiceProfileCO> doubleCheckedRooms = readDiscoveryCache(key);
|
||||
stepStart = logDiscoveryStep(
|
||||
"double-check-region-cache",
|
||||
stepStart,
|
||||
reqUserId,
|
||||
sysOrigin,
|
||||
true,
|
||||
region,
|
||||
Optional.ofNullable(userProfile).map(UserProfile::getCountryCode).orElse(null),
|
||||
DISCOVERY_LIMIT
|
||||
)
|
||||
: roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(
|
||||
activeVoiceRoomService.listByRegion(sysOrigin, region, DISCOVERY_LIMIT),
|
||||
region
|
||||
);
|
||||
stepStart = logDiscoveryStep("query-active-rooms", stepStart, reqUserId, sysOrigin, region);
|
||||
if (Objects.nonNull(doubleCheckedRooms)) {
|
||||
logDiscoveryTotal(
|
||||
totalStart,
|
||||
reqUserId,
|
||||
sysOrigin,
|
||||
region,
|
||||
allRegionWhiteListUser,
|
||||
true,
|
||||
doubleCheckedRooms.size()
|
||||
);
|
||||
return doubleCheckedRooms;
|
||||
}
|
||||
}
|
||||
|
||||
// 查询没人的房间缓存,加入进去
|
||||
List<ActiveVoiceRoom> emptyRooms = getEmptyRoomsFromCache();
|
||||
stepStart = logDiscoveryStep("scan-empty-room-cache", stepStart, reqUserId, sysOrigin, region);
|
||||
if (CollectionUtils.isNotEmpty(emptyRooms)) {
|
||||
List<ActiveVoiceRoom> matchedEmptyRooms = allRegionWhiteListUser
|
||||
? emptyRooms.stream()
|
||||
.filter(room -> Objects.equals(sysOrigin, room.getSysOrigin()))
|
||||
.toList()
|
||||
List<ActiveVoiceRoom> activeVoiceRooms = allRegionWhiteListUser
|
||||
? activeVoiceRoomService.listDiscover(
|
||||
sysOrigin,
|
||||
true,
|
||||
region,
|
||||
Optional.ofNullable(userProfile).map(UserProfile::getCountryCode).orElse(null),
|
||||
DISCOVERY_LIMIT
|
||||
)
|
||||
: roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(
|
||||
emptyRooms.stream()
|
||||
.filter(room -> Objects.equals(sysOrigin, room.getSysOrigin()))
|
||||
.toList(),
|
||||
activeVoiceRoomService.listByRegion(sysOrigin, region, DISCOVERY_LIMIT),
|
||||
region
|
||||
);
|
||||
if (CollectionUtils.isNotEmpty(matchedEmptyRooms)) {
|
||||
List<ActiveVoiceRoom> allRooms = new ArrayList<>(activeVoiceRooms);
|
||||
Set<Long> existingRoomIds = activeVoiceRooms.stream()
|
||||
.map(ActiveVoiceRoom::getId)
|
||||
.collect(Collectors.toSet());
|
||||
matchedEmptyRooms.stream()
|
||||
.filter(room -> !existingRoomIds.contains(room.getId()))
|
||||
.forEach(allRooms::add);
|
||||
activeVoiceRooms = allRooms;
|
||||
}
|
||||
}
|
||||
stepStart = logDiscoveryStep("merge-empty-rooms", stepStart, reqUserId, sysOrigin, region);
|
||||
stepStart = logDiscoveryStep("query-active-rooms", stepStart, reqUserId, sysOrigin, region);
|
||||
|
||||
List<RoomVoiceProfileCO> roomList = roomVoiceProfileCommon.toListRoomVoiceProfileCO(activeVoiceRooms);
|
||||
stepStart = logDiscoveryStep("build-profile-list", stepStart, reqUserId, sysOrigin, region);
|
||||
if (roomList.size() < DISCOVERY_LIMIT) {
|
||||
List<RoomProfileManager> fallbackRoomManagersSource = roomProfileManagerService.listSelectiveLimitByCountryCodes(
|
||||
// 索引已经按系统/区域收敛,读取成本只与当前候选空房数量相关,不再遍历 DB0 全量 key。
|
||||
List<ActiveVoiceRoom> emptyRooms = emptyRoomCacheReader.list(
|
||||
sysOrigin,
|
||||
allRegionWhiteListUser ? List.of() : roomRegionFilterCommon.listCountryCodes(currentRegion),
|
||||
roomList.stream().map(RoomVoiceProfileCO::getId).toList(),
|
||||
region,
|
||||
allRegionWhiteListUser,
|
||||
DISCOVERY_LIMIT
|
||||
);
|
||||
List<RoomProfileManager> fallbackRoomManagers = allRegionWhiteListUser
|
||||
? fallbackRoomManagersSource
|
||||
: roomRegionFilterCommon.filterRoomProfileManagersByRegion(
|
||||
fallbackRoomManagersSource,
|
||||
region
|
||||
);
|
||||
roomList = mergeRoomList(roomList, roomVoiceProfileCommon.listRoomVoiceProfilesV2(fallbackRoomManagers),
|
||||
DISCOVERY_LIMIT);
|
||||
}
|
||||
stepStart = logDiscoveryStep("fallback-room-profiles", stepStart, reqUserId, sysOrigin, region);
|
||||
roomList = sortByTopWeightAndMemberQuantityDesc(roomList);
|
||||
stepStart = logDiscoveryStep("sort-rooms", stepStart, reqUserId, sysOrigin, region);
|
||||
stepStart = logDiscoveryStep("read-empty-room-index", stepStart, reqUserId, sysOrigin, region);
|
||||
if (CollectionUtils.isNotEmpty(emptyRooms)) {
|
||||
List<ActiveVoiceRoom> allRooms = new ArrayList<>(activeVoiceRooms);
|
||||
Set<Long> existingRoomIds = activeVoiceRooms.stream()
|
||||
.map(ActiveVoiceRoom::getId)
|
||||
.collect(Collectors.toSet());
|
||||
emptyRooms.stream()
|
||||
.filter(room -> !existingRoomIds.contains(room.getId()))
|
||||
.forEach(allRooms::add);
|
||||
activeVoiceRooms = allRooms;
|
||||
}
|
||||
stepStart = logDiscoveryStep("merge-empty-rooms", stepStart, reqUserId, sysOrigin, region);
|
||||
|
||||
if (CollectionUtils.isEmpty(roomList)) {
|
||||
logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, false, 0);
|
||||
return CollectionUtils.newArrayList();
|
||||
}
|
||||
Map<Long, SVIPLevelEnum> svipLevelEnumMap = userSVipGateway.checkSVipIdentity(
|
||||
roomList.stream().map(RoomVoiceProfileCO::getUserId).collect(
|
||||
Collectors.toSet()));
|
||||
roomList = roomList.stream()
|
||||
.peek(room -> {
|
||||
room.setUserSVipLevel(svipLevelEnumMap.get(room.getUserId()));
|
||||
})
|
||||
.toList();
|
||||
List<RoomVoiceProfileCO> roomList = roomVoiceProfileCommon.toListRoomVoiceProfileCO(activeVoiceRooms);
|
||||
stepStart = logDiscoveryStep("build-profile-list", stepStart, reqUserId, sysOrigin, region);
|
||||
if (roomList.size() < DISCOVERY_LIMIT) {
|
||||
List<RoomProfileManager> fallbackRoomManagersSource =
|
||||
roomProfileManagerService.listSelectiveLimitByCountryCodes(
|
||||
sysOrigin,
|
||||
allRegionWhiteListUser ? List.of() : roomRegionFilterCommon.listCountryCodes(currentRegion),
|
||||
roomList.stream().map(RoomVoiceProfileCO::getId).toList(),
|
||||
DISCOVERY_LIMIT
|
||||
);
|
||||
List<RoomProfileManager> fallbackRoomManagers = allRegionWhiteListUser
|
||||
? fallbackRoomManagersSource
|
||||
: roomRegionFilterCommon.filterRoomProfileManagersByRegion(
|
||||
fallbackRoomManagersSource,
|
||||
region
|
||||
);
|
||||
roomList = mergeRoomList(
|
||||
roomList,
|
||||
roomVoiceProfileCommon.listRoomVoiceProfilesV2(fallbackRoomManagers),
|
||||
DISCOVERY_LIMIT
|
||||
);
|
||||
}
|
||||
stepStart = logDiscoveryStep("fallback-room-profiles", stepStart, reqUserId, sysOrigin, region);
|
||||
roomList = sortByTopWeightAndMemberQuantityDesc(roomList);
|
||||
stepStart = logDiscoveryStep("sort-rooms", stepStart, reqUserId, sysOrigin, region);
|
||||
|
||||
if (CollectionUtils.isEmpty(roomList)) {
|
||||
// 短负缓存让空区域的 follower 也能结束等待,但不会把新开房间隐藏十分钟。
|
||||
saveDiscoveryCache(key, roomList, reqUserId, sysOrigin, region);
|
||||
logDiscoveryStep("write-empty-region-cache", stepStart, reqUserId, sysOrigin, region);
|
||||
logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, false, 0);
|
||||
return CollectionUtils.newArrayList();
|
||||
}
|
||||
Map<Long, SVIPLevelEnum> svipLevelEnumMap = userSVipGateway.checkSVipIdentity(
|
||||
roomList.stream().map(RoomVoiceProfileCO::getUserId).collect(Collectors.toSet()));
|
||||
roomList = roomList.stream()
|
||||
.peek(room -> room.setUserSVipLevel(svipLevelEnumMap.get(room.getUserId())))
|
||||
.toList();
|
||||
stepStart = logDiscoveryStep("fill-svip", stepStart, reqUserId, sysOrigin, region);
|
||||
|
||||
// 填充火箭状态
|
||||
@ -192,7 +252,12 @@ public class RoomVoiceDiscoverQryExe {
|
||||
logDiscoveryStep("write-region-cache", stepStart, reqUserId, sysOrigin, region);
|
||||
logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, false, roomList.size());
|
||||
|
||||
return roomList;
|
||||
return roomList;
|
||||
} finally {
|
||||
if (rebuildLockAcquired) {
|
||||
releaseRebuildLock(key, rebuildToken, reqUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<RoomVoiceProfileCO> sortByTopWeightAndMemberQuantityDesc(
|
||||
@ -236,13 +301,105 @@ public class RoomVoiceDiscoverQryExe {
|
||||
return allRegionWhiteListUser ? sysOrigin + region + ":ALL_REGION" : sysOrigin + region;
|
||||
}
|
||||
|
||||
private void saveDiscoveryCache(String key, List<RoomVoiceProfileCO> roomList, Long userId,
|
||||
String sysOrigin, String region) {
|
||||
if (CollectionUtils.isEmpty(roomList)) {
|
||||
return;
|
||||
/**
|
||||
* 读取并校验发现页缓存。返回 null 表示真正 miss;空 List 表示命中的负缓存。
|
||||
*/
|
||||
private List<RoomVoiceProfileCO> readDiscoveryCache(String key) {
|
||||
return readDiscoveryCache(key, true);
|
||||
}
|
||||
|
||||
private List<RoomVoiceProfileCO> readDiscoveryCache(String key, boolean logReadFailure) {
|
||||
String regionsRoom;
|
||||
try {
|
||||
regionsRoom = regionRoomCacheService.getRegionsRoom(key);
|
||||
} catch (RuntimeException e) {
|
||||
// Redis GET 超时按真正 miss 处理,后续仍由分布式锁或无锁降级决定是否回源。
|
||||
if (logReadFailure) {
|
||||
log.warn("读取发现缓存失败,按 miss 降级, key={}", key, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (Objects.isNull(regionsRoom)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
regionRoomCacheService.saveRegionsRoom(JSON.toJSONString(roomList), key);
|
||||
List<RoomVoiceProfileCO> roomList = JSON.parseArray(regionsRoom, RoomVoiceProfileCO.class);
|
||||
if (Objects.isNull(roomList)) {
|
||||
removeDiscoveryCache(key);
|
||||
return null;
|
||||
}
|
||||
if (CollectionUtils.isEmpty(roomList)) {
|
||||
return CollectionUtils.newArrayList();
|
||||
}
|
||||
|
||||
List<RoomVoiceProfileCO> availableRooms = roomVoiceProfileCommon
|
||||
.filterAvailableRoomProfiles(roomList);
|
||||
if (availableRooms.size() != roomList.size()) {
|
||||
// 本次仍返回过滤后的安全结果,下一请求通过 singleflight 重建完整缓存。
|
||||
removeDiscoveryCache(key);
|
||||
}
|
||||
return availableRooms;
|
||||
} catch (RuntimeException e) {
|
||||
removeDiscoveryCache(key);
|
||||
log.warn("清理无法解析的发现页缓存, key={}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<RoomVoiceProfileCO> waitForPublishedDiscoveryCache(String key) {
|
||||
for (int attempt = 0; attempt < REBUILD_WAIT_ATTEMPTS; attempt++) {
|
||||
// follower 轮询期间抑制重复 GET 异常日志;初次读取和锁竞争仍会保留故障证据。
|
||||
List<RoomVoiceProfileCO> cachedRooms = readDiscoveryCache(key, false);
|
||||
if (Objects.nonNull(cachedRooms)) {
|
||||
return cachedRooms;
|
||||
}
|
||||
if (attempt + 1 >= REBUILD_WAIT_ATTEMPTS) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(REBUILD_WAIT_MILLIS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void removeDiscoveryCache(String key) {
|
||||
try {
|
||||
regionRoomCacheService.remove(key);
|
||||
} catch (RuntimeException e) {
|
||||
// 清理失败不能覆盖本次安全返回,残留坏值由 TTL 或下一次成功清理收敛。
|
||||
log.warn("删除失效发现缓存失败, key={}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseRebuildLock(String key, String token, Long userId) {
|
||||
try {
|
||||
if (!regionRoomCacheService.releaseRebuildLock(key, token)) {
|
||||
// token 不一致通常表示租约已过期;Lua 保证不会误删新的持有者。
|
||||
log.warn("发现缓存重建锁未释放,等待 TTL 自愈, key={}, userId={}", key, userId);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.error("释放发现缓存重建锁失败, key={}, userId={}", key, userId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveDiscoveryCache(String key, List<RoomVoiceProfileCO> roomList, Long userId,
|
||||
String sysOrigin, String region) {
|
||||
try {
|
||||
String payload = JSON.toJSONString(roomList);
|
||||
if (CollectionUtils.isEmpty(roomList)) {
|
||||
regionRoomCacheService.saveRegionsRoom(
|
||||
payload,
|
||||
key,
|
||||
EMPTY_RESULT_CACHE_SECONDS,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
} else {
|
||||
regionRoomCacheService.saveRegionsRoom(payload, key);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("写入房间发现缓存失败, key={}, userId={}, sysOrigin={}, region={}",
|
||||
key, userId, sysOrigin, region, e);
|
||||
@ -410,88 +567,8 @@ public class RoomVoiceDiscoverQryExe {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存中获取没人的房间
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private List<ActiveVoiceRoom> getEmptyRoomsFromCache() {
|
||||
try {
|
||||
Set<String> keys = scanEmptyRoomKeys();
|
||||
if (CollectionUtils.isEmpty(keys)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<ActiveVoiceRoom> emptyRooms = new ArrayList<>();
|
||||
for (String key : keys) {
|
||||
String cachedRoom = (String) redisTemplate.opsForValue().get(key);
|
||||
if (cachedRoom != null) {
|
||||
try {
|
||||
// 将缓存对象转换为ActiveVoiceRoomCO,再转换为ActiveVoiceRoom
|
||||
ActiveVoiceRoomCO roomCO = JSON.parseObject(cachedRoom, ActiveVoiceRoomCO.class);
|
||||
if (roomCO != null) {
|
||||
ActiveVoiceRoom room = convertToActiveVoiceRoom(roomCO);
|
||||
if (room != null) {
|
||||
emptyRooms.add(room);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("转换缓存房间对象失败, key: {}", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return emptyRooms;
|
||||
} catch (Exception e) {
|
||||
log.error("获取空房间缓存失败", e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> scanEmptyRoomKeys() {
|
||||
Set<String> keys = new HashSet<>();
|
||||
ScanOptions options = ScanOptions.scanOptions()
|
||||
.match(EMPTY_ROOM_CACHE_KEY)
|
||||
.count(EMPTY_ROOM_SCAN_COUNT)
|
||||
.build();
|
||||
try (var cursor = redisTemplate.scan(options)) {
|
||||
if (Objects.isNull(cursor)) {
|
||||
return keys;
|
||||
}
|
||||
while (cursor.hasNext()) {
|
||||
keys.add(Objects.toString(cursor.next(), ""));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将ActiveVoiceRoomCO转换为ActiveVoiceRoom
|
||||
*
|
||||
* @param roomCO
|
||||
* @return
|
||||
*/
|
||||
private ActiveVoiceRoom convertToActiveVoiceRoom(ActiveVoiceRoomCO roomCO) {
|
||||
try {
|
||||
ActiveVoiceRoom room = new ActiveVoiceRoom();
|
||||
BeanUtils.copyProperties(roomCO, room);
|
||||
// 设置在线人数为0,因为这是没人的房间
|
||||
room.setOnlineQuantity(0L);
|
||||
// 设置默认权重(如果未设置的话)
|
||||
if (room.getWeights() == null) {
|
||||
room.setWeights(0);
|
||||
}
|
||||
if (room.getFixedWeights() == null) {
|
||||
room.setFixedWeights(0);
|
||||
}
|
||||
return room;
|
||||
} catch (Exception e) {
|
||||
log.error("转换ActiveVoiceRoomCO失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除拉黑和被上锁的的房间
|
||||
* 去除拉黑和被上锁的的房间
|
||||
*
|
||||
* @param roomIds
|
||||
* @return
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.red.circle.other.app.command.room.query;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.anySet;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@ -22,6 +23,7 @@ import com.red.circle.other.domain.gateway.user.ability.UserSVipGateway;
|
||||
import com.red.circle.other.domain.model.user.UserProfile;
|
||||
import com.red.circle.other.domain.model.user.ability.RegionConfig;
|
||||
import com.red.circle.other.infra.common.sys.CountryCodeAliasSupport;
|
||||
import com.red.circle.other.infra.database.cache.service.other.EmptyRoomCacheReader;
|
||||
import com.red.circle.other.infra.database.cache.service.user.RegionRoomCacheService;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.RoomProfileManager;
|
||||
@ -31,9 +33,8 @@ import com.red.circle.other.infra.database.mongo.service.live.RoomUserBlacklistS
|
||||
import com.red.circle.other.infra.database.rds.service.user.device.LatestMobileDeviceService;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
|
||||
class RoomVoiceDiscoverQryExeTest {
|
||||
|
||||
@ -51,7 +52,7 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
RoomProfileManagerService roomProfileManagerService = mock(RoomProfileManagerService.class);
|
||||
RegionRoomCacheService regionRoomCacheService = mock(RegionRoomCacheService.class);
|
||||
LiveMicClient liveMicClient = mock(LiveMicClient.class);
|
||||
RedisTemplate<String, Object> redisTemplate = mock(RedisTemplate.class);
|
||||
EmptyRoomCacheReader emptyRoomCacheReader = mock(EmptyRoomCacheReader.class);
|
||||
RocketStatusCacheService rocketStatusCacheService = mock(RocketStatusCacheService.class);
|
||||
GameLudoService gameLudoService = mock(GameLudoService.class);
|
||||
UserProfileGateway userProfileGateway = mock(UserProfileGateway.class);
|
||||
@ -67,7 +68,7 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
roomProfileManagerService,
|
||||
regionRoomCacheService,
|
||||
liveMicClient,
|
||||
redisTemplate,
|
||||
emptyRoomCacheReader,
|
||||
rocketStatusCacheService,
|
||||
gameLudoService,
|
||||
userProfileGateway,
|
||||
@ -94,10 +95,12 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
when(roomRegionFilterCommon.requireRegionConfig(1L)).thenReturn(currentRegion);
|
||||
when(userProfileGateway.getByUserId(1L)).thenReturn(userProfile);
|
||||
when(regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null);
|
||||
when(regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString())).thenReturn(true);
|
||||
when(regionRoomCacheService.releaseRebuildLock(anyString(), anyString())).thenReturn(true);
|
||||
when(activeVoiceRoomService.listByRegion("YOLO", "AE", 50)).thenReturn(List.of(activeVoiceRoom));
|
||||
when(roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(List.of(activeVoiceRoom), "AE"))
|
||||
.thenReturn(List.of(activeVoiceRoom));
|
||||
when(redisTemplate.keys("empty_room_cache:*")).thenReturn(Set.of());
|
||||
when(emptyRoomCacheReader.list("YOLO", "AE", false, 50)).thenReturn(List.of());
|
||||
when(roomVoiceProfileCommon.toListRoomVoiceProfileCO(List.of(activeVoiceRoom))).thenReturn(
|
||||
List.of(activeRoomCo)
|
||||
);
|
||||
@ -121,6 +124,8 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
|
||||
assertEquals(List.of(fallbackRoomCo, activeRoomCo), roomList);
|
||||
verify(activeVoiceRoomService).listByRegion("YOLO", "AE", 50);
|
||||
verify(emptyRoomCacheReader).list("YOLO", "AE", false, 50);
|
||||
verify(regionRoomCacheService).releaseRebuildLock(anyString(), anyString());
|
||||
verify(activeVoiceRoomService, never()).listDiscover("YOLO", true, "AE", null, 50);
|
||||
}
|
||||
|
||||
@ -136,7 +141,7 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
RoomProfileManagerService roomProfileManagerService = mock(RoomProfileManagerService.class);
|
||||
RegionRoomCacheService regionRoomCacheService = mock(RegionRoomCacheService.class);
|
||||
LiveMicClient liveMicClient = mock(LiveMicClient.class);
|
||||
RedisTemplate<String, Object> redisTemplate = mock(RedisTemplate.class);
|
||||
EmptyRoomCacheReader emptyRoomCacheReader = mock(EmptyRoomCacheReader.class);
|
||||
RocketStatusCacheService rocketStatusCacheService = mock(RocketStatusCacheService.class);
|
||||
GameLudoService gameLudoService = mock(GameLudoService.class);
|
||||
UserProfileGateway userProfileGateway = mock(UserProfileGateway.class);
|
||||
@ -152,7 +157,7 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
roomProfileManagerService,
|
||||
regionRoomCacheService,
|
||||
liveMicClient,
|
||||
redisTemplate,
|
||||
emptyRoomCacheReader,
|
||||
rocketStatusCacheService,
|
||||
gameLudoService,
|
||||
userProfileGateway,
|
||||
@ -179,9 +184,12 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
when(roomRegionFilterCommon.requireRegionConfig(1L)).thenReturn(currentRegion);
|
||||
when(userProfileGateway.getByUserId(1L)).thenReturn(userProfile);
|
||||
when(regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null);
|
||||
when(regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString())).thenReturn(true);
|
||||
when(regionRoomCacheService.releaseRebuildLock(anyString(), anyString())).thenReturn(true);
|
||||
when(activeVoiceRoomService.listByRegion("YOLO", "AE", 50)).thenReturn(List.of(activeVoiceRoom));
|
||||
when(roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(List.of(activeVoiceRoom), "AE"))
|
||||
.thenReturn(List.of(activeVoiceRoom));
|
||||
when(emptyRoomCacheReader.list("YOLO", "AE", false, 50)).thenReturn(List.of());
|
||||
when(roomVoiceProfileCommon.toListRoomVoiceProfileCO(List.of(activeVoiceRoom))).thenReturn(
|
||||
List.of(topRoomCo)
|
||||
);
|
||||
@ -218,7 +226,7 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
RoomProfileManagerService roomProfileManagerService = mock(RoomProfileManagerService.class);
|
||||
RegionRoomCacheService regionRoomCacheService = mock(RegionRoomCacheService.class);
|
||||
LiveMicClient liveMicClient = mock(LiveMicClient.class);
|
||||
RedisTemplate<String, Object> redisTemplate = mock(RedisTemplate.class);
|
||||
EmptyRoomCacheReader emptyRoomCacheReader = mock(EmptyRoomCacheReader.class);
|
||||
RocketStatusCacheService rocketStatusCacheService = mock(RocketStatusCacheService.class);
|
||||
GameLudoService gameLudoService = mock(GameLudoService.class);
|
||||
UserProfileGateway userProfileGateway = mock(UserProfileGateway.class);
|
||||
@ -234,7 +242,7 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
roomProfileManagerService,
|
||||
regionRoomCacheService,
|
||||
liveMicClient,
|
||||
redisTemplate,
|
||||
emptyRoomCacheReader,
|
||||
rocketStatusCacheService,
|
||||
gameLudoService,
|
||||
userProfileGateway,
|
||||
@ -260,9 +268,11 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
when(roomRegionFilterCommon.requireRegionConfig(ALL_REGION_WHITE_USER_ID)).thenReturn(currentRegion);
|
||||
when(userProfileGateway.getByUserId(ALL_REGION_WHITE_USER_ID)).thenReturn(userProfile);
|
||||
when(regionRoomCacheService.getRegionsRoom("YOLOAE:ALL_REGION")).thenReturn(null);
|
||||
when(regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString())).thenReturn(true);
|
||||
when(regionRoomCacheService.releaseRebuildLock(anyString(), anyString())).thenReturn(true);
|
||||
when(activeVoiceRoomService.listDiscover("YOLO", true, "AE", "AE", 50))
|
||||
.thenReturn(List.of(activeVoiceRoom));
|
||||
when(redisTemplate.keys("empty_room_cache:*")).thenReturn(Set.of());
|
||||
when(emptyRoomCacheReader.list("YOLO", "AE", true, 50)).thenReturn(List.of());
|
||||
when(roomVoiceProfileCommon.toListRoomVoiceProfileCO(List.of(activeVoiceRoom))).thenReturn(
|
||||
List.of(activeRoomCo)
|
||||
);
|
||||
@ -282,7 +292,151 @@ class RoomVoiceDiscoverQryExeTest {
|
||||
|
||||
assertEquals(List.of(activeRoomCo, fallbackRoomCo), roomList);
|
||||
verify(activeVoiceRoomService, never()).listByRegion("YOLO", "AE", 50);
|
||||
verify(emptyRoomCacheReader).list("YOLO", "AE", true, 50);
|
||||
verify(roomRegionFilterCommon, never()).filterActiveVoiceRoomsByRegion(List.of(activeVoiceRoom), "AE");
|
||||
verify(roomRegionFilterCommon, never()).filterRoomProfileManagersByRegion(List.of(fallbackRoomManager), "AE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_shouldDoubleCheckCacheAfterWinningRebuildLock() {
|
||||
DiscoveryFixture fixture = new DiscoveryFixture();
|
||||
AppExtCommand cmd = fixture.stubCurrentRegionRequest();
|
||||
|
||||
// 首次读取 miss 后,另一实例可能已经发布;leader 获锁后必须二次检查,不能重复回源。
|
||||
when(fixture.regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null, "[]");
|
||||
when(fixture.regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(fixture.regionRoomCacheService.releaseRebuildLock(anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
|
||||
assertEquals(List.of(), fixture.exe.execute(cmd));
|
||||
|
||||
verify(fixture.activeVoiceRoomService, never()).listByRegion("YOLO", "AE", 50);
|
||||
verify(fixture.emptyRoomCacheReader, never()).list("YOLO", "AE", false, 50);
|
||||
verify(fixture.regionRoomCacheService).releaseRebuildLock(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_shouldWaitForLeaderCacheWithoutFollowerOriginQuery() {
|
||||
DiscoveryFixture fixture = new DiscoveryFixture();
|
||||
AppExtCommand cmd = fixture.stubCurrentRegionRequest();
|
||||
|
||||
// follower 第一次 miss 后立即读到 leader 发布的负缓存,不能自行查询 Mongo 或空房索引。
|
||||
when(fixture.regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null, "[]");
|
||||
when(fixture.regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString()))
|
||||
.thenReturn(false);
|
||||
|
||||
assertEquals(List.of(), fixture.exe.execute(cmd));
|
||||
|
||||
verify(fixture.activeVoiceRoomService, never()).listByRegion("YOLO", "AE", 50);
|
||||
verify(fixture.emptyRoomCacheReader, never()).list("YOLO", "AE", false, 50);
|
||||
verify(fixture.regionRoomCacheService, never()).releaseRebuildLock(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_shouldPublishShortNegativeCacheForEmptyRegion() {
|
||||
DiscoveryFixture fixture = new DiscoveryFixture();
|
||||
AppExtCommand cmd = fixture.stubCurrentRegionRequest();
|
||||
|
||||
when(fixture.regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null);
|
||||
when(fixture.regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(fixture.regionRoomCacheService.releaseRebuildLock(anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
fixture.stubEmptyRegionOrigin();
|
||||
|
||||
assertEquals(List.of(), fixture.exe.execute(cmd));
|
||||
|
||||
verify(fixture.regionRoomCacheService)
|
||||
.saveRegionsRoom("[]", "YOLOAE", 30L, TimeUnit.SECONDS);
|
||||
verify(fixture.regionRoomCacheService).releaseRebuildLock(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_shouldFailOpenToOriginWhenDiscoveryRedisIsUnavailable() {
|
||||
DiscoveryFixture fixture = new DiscoveryFixture();
|
||||
AppExtCommand cmd = fixture.stubCurrentRegionRequest();
|
||||
|
||||
when(fixture.regionRoomCacheService.getRegionsRoom("YOLOAE"))
|
||||
.thenThrow(new IllegalStateException("redis get timeout"));
|
||||
when(fixture.regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString()))
|
||||
.thenThrow(new IllegalStateException("redis setnx timeout"));
|
||||
fixture.stubEmptyRegionOrigin();
|
||||
|
||||
assertEquals(List.of(), fixture.exe.execute(cmd));
|
||||
|
||||
verify(fixture.activeVoiceRoomService).listByRegion("YOLO", "AE", 50);
|
||||
verify(fixture.emptyRoomCacheReader).list("YOLO", "AE", false, 50);
|
||||
verify(fixture.regionRoomCacheService, never()).releaseRebuildLock(anyString(), anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 只为 singleflight 分支集中装配依赖,避免测试中的大量 mock 掩盖真正的回源边界。
|
||||
*/
|
||||
private static final class DiscoveryFixture {
|
||||
|
||||
private final UserSVipGateway userSVipGateway = mock(UserSVipGateway.class);
|
||||
private final UserRegionGateway userRegionGateway = mock(UserRegionGateway.class);
|
||||
private final RoomRegionFilterCommon roomRegionFilterCommon = mock(RoomRegionFilterCommon.class);
|
||||
private final RoomVoiceProfileCommon roomVoiceProfileCommon = mock(RoomVoiceProfileCommon.class);
|
||||
private final ActiveVoiceRoomService activeVoiceRoomService = mock(ActiveVoiceRoomService.class);
|
||||
private final RoomUserBlacklistService roomUserBlacklistService =
|
||||
mock(RoomUserBlacklistService.class);
|
||||
private final LatestMobileDeviceService latestMobileDeviceService =
|
||||
mock(LatestMobileDeviceService.class);
|
||||
private final RoomProfileManagerService roomProfileManagerService =
|
||||
mock(RoomProfileManagerService.class);
|
||||
private final RegionRoomCacheService regionRoomCacheService = mock(RegionRoomCacheService.class);
|
||||
private final LiveMicClient liveMicClient = mock(LiveMicClient.class);
|
||||
private final EmptyRoomCacheReader emptyRoomCacheReader = mock(EmptyRoomCacheReader.class);
|
||||
private final RocketStatusCacheService rocketStatusCacheService =
|
||||
mock(RocketStatusCacheService.class);
|
||||
private final GameLudoService gameLudoService = mock(GameLudoService.class);
|
||||
private final UserProfileGateway userProfileGateway = mock(UserProfileGateway.class);
|
||||
private final CountryCodeAliasSupport countryCodeAliasSupport =
|
||||
mock(CountryCodeAliasSupport.class);
|
||||
private final RegionConfig currentRegion =
|
||||
new RegionConfig().setRegionCode("AE").setCountryCodes("AE,SA");
|
||||
private final RoomVoiceDiscoverQryExe exe = new RoomVoiceDiscoverQryExe(
|
||||
userSVipGateway,
|
||||
userRegionGateway,
|
||||
roomRegionFilterCommon,
|
||||
roomVoiceProfileCommon,
|
||||
activeVoiceRoomService,
|
||||
roomUserBlacklistService,
|
||||
latestMobileDeviceService,
|
||||
roomProfileManagerService,
|
||||
regionRoomCacheService,
|
||||
liveMicClient,
|
||||
emptyRoomCacheReader,
|
||||
rocketStatusCacheService,
|
||||
gameLudoService,
|
||||
userProfileGateway,
|
||||
countryCodeAliasSupport
|
||||
);
|
||||
|
||||
private AppExtCommand stubCurrentRegionRequest() {
|
||||
when(roomRegionFilterCommon.requireRegionConfig(1L)).thenReturn(currentRegion);
|
||||
when(userProfileGateway.getByUserId(1L)).thenReturn(new UserProfile());
|
||||
AppExtCommand cmd = new AppExtCommand();
|
||||
cmd.setReqUserId(1L);
|
||||
cmd.setReqSysOrigin(ReqSysOrigin.of("YOLO", "YOLO"));
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private void stubEmptyRegionOrigin() {
|
||||
when(activeVoiceRoomService.listByRegion("YOLO", "AE", 50)).thenReturn(List.of());
|
||||
when(roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(List.of(), "AE"))
|
||||
.thenReturn(List.of());
|
||||
when(emptyRoomCacheReader.list("YOLO", "AE", false, 50)).thenReturn(List.of());
|
||||
when(roomVoiceProfileCommon.toListRoomVoiceProfileCO(List.of())).thenReturn(List.of());
|
||||
when(roomRegionFilterCommon.listCountryCodes(currentRegion)).thenReturn(List.of("AE", "SA"));
|
||||
when(roomProfileManagerService.listSelectiveLimitByCountryCodes(
|
||||
"YOLO", List.of("AE", "SA"), List.of(), 50
|
||||
)).thenReturn(List.of());
|
||||
when(roomRegionFilterCommon.filterRoomProfileManagersByRegion(List.of(), "AE"))
|
||||
.thenReturn(List.of());
|
||||
when(roomVoiceProfileCommon.listRoomVoiceProfilesV2(List.of())).thenReturn(List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
package com.red.circle.other.infra.database.cache.service.other;
|
||||
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 按系统和区域读取发现页的空房间补充数据。
|
||||
*/
|
||||
public interface EmptyRoomCacheReader {
|
||||
|
||||
/**
|
||||
* 从单个有界索引批量读取空房间,不允许退回全库 SCAN/KEYS。
|
||||
*
|
||||
* @param sysOrigin 业务系统
|
||||
* @param region 当前区域
|
||||
* @param allRegions 是否读取同系统的全区域聚合索引
|
||||
* @param limit 最大返回数量
|
||||
* @return 有效空房间快照
|
||||
*/
|
||||
List<ActiveVoiceRoom> list(String sysOrigin, String region, boolean allRegions, int limit);
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
package com.red.circle.other.infra.database.cache.service.other.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.red.circle.component.redis.service.RedisService;
|
||||
import com.red.circle.other.infra.database.cache.service.other.EmptyRoomCacheReader;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
import com.red.circle.other.inner.model.cache.EmptyRoomCacheKeys;
|
||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
||||
import com.red.circle.tool.core.text.StringUtils;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 基于 ZSet 到期索引批量加载空房间快照。
|
||||
*
|
||||
* <p>新 reader 不允许回退 legacy 全库 SCAN。滚动发布必须先全量升级 live writer,等待旧快照
|
||||
* 十分钟窗口收敛后再升级 other,确保任何在途 legacy-only payload 都已过期。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EmptyRoomCacheReaderImpl implements EmptyRoomCacheReader {
|
||||
|
||||
private final RedisService redisService;
|
||||
|
||||
@Override
|
||||
public List<ActiveVoiceRoom> list(String sysOrigin, String region, boolean allRegions, int limit) {
|
||||
if (StringUtils.isBlank(sysOrigin)
|
||||
|| (!allRegions && StringUtils.isBlank(region))
|
||||
|| limit <= 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
String indexKey = allRegions
|
||||
? EmptyRoomCacheKeys.allRegionsIndex(sysOrigin)
|
||||
: EmptyRoomCacheKeys.regionIndex(sysOrigin, region);
|
||||
try {
|
||||
long nowMillis = System.currentTimeMillis();
|
||||
// 过期成员在读取前先删除,索引开销只与该系统/区域的空房数量相关。
|
||||
redisService.zsetRemoveByScore(indexKey, 0D, nowMillis);
|
||||
// score 越大代表快照剩余窗口越长;按 rank 在 Redis 端截断,网络与 MGET 都最多处理 limit 条。
|
||||
Set<Object> indexedMembers = redisService.zsetGetReverseRange(indexKey, 0L, limit - 1L);
|
||||
if (CollectionUtils.isEmpty(indexedMembers)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<String> roomMembers = indexedMembers.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(Objects::toString)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (CollectionUtils.isEmpty(roomMembers)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
Set<String> staleMembers = new LinkedHashSet<>();
|
||||
List<String> validMembers = new ArrayList<>();
|
||||
List<Long> roomIds = new ArrayList<>();
|
||||
for (String member : roomMembers) {
|
||||
Long roomId = parseRoomId(member);
|
||||
if (Objects.isNull(roomId)) {
|
||||
staleMembers.add(member);
|
||||
continue;
|
||||
}
|
||||
validMembers.add(member);
|
||||
roomIds.add(roomId);
|
||||
}
|
||||
if (CollectionUtils.isEmpty(validMembers)) {
|
||||
// 全部成员都损坏时不要向 Redis 发送空 MGET;先清掉当前索引,避免每次发现请求重复解析。
|
||||
redisService.zsetRemove(indexKey, staleMembers.toArray());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<String> payloadKeys = roomIds.stream()
|
||||
.map(EmptyRoomCacheKeys::payload)
|
||||
.toList();
|
||||
List<String> payloads = Objects.requireNonNullElse(
|
||||
redisService.multiGetString(payloadKeys),
|
||||
List.of()
|
||||
);
|
||||
List<ActiveVoiceRoom> rooms = new ArrayList<>();
|
||||
|
||||
for (int index = 0; index < validMembers.size(); index++) {
|
||||
String member = validMembers.get(index);
|
||||
String payload = index < payloads.size() ? payloads.get(index) : null;
|
||||
ActiveVoiceRoom room = parseRoom(
|
||||
payload,
|
||||
member,
|
||||
roomIds.get(index),
|
||||
payloadKeys.get(index),
|
||||
staleMembers
|
||||
);
|
||||
if (Objects.isNull(room)
|
||||
|| !matchesIndex(room, sysOrigin, region, allRegions)) {
|
||||
staleMembers.add(member);
|
||||
continue;
|
||||
}
|
||||
normalizeEmptyRoom(room);
|
||||
rooms.add(room);
|
||||
}
|
||||
|
||||
if (CollectionUtils.isNotEmpty(staleMembers)) {
|
||||
// payload 已过期、损坏或索引归属错误时只清理当前小索引,绝不回退全库扫描。
|
||||
redisService.zsetRemove(indexKey, staleMembers.toArray());
|
||||
}
|
||||
return rooms;
|
||||
} catch (RuntimeException e) {
|
||||
// 索引是发现页补充数据;异常时返回活跃房间主链路结果,而不是放大 Redis 故障。
|
||||
log.error("读取空房间索引失败, indexKey={}", indexKey, e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private ActiveVoiceRoom parseRoom(
|
||||
String payload,
|
||||
String member,
|
||||
Long expectedRoomId,
|
||||
String payloadKey,
|
||||
Set<String> staleMembers
|
||||
) {
|
||||
if (StringUtils.isBlank(payload)) {
|
||||
staleMembers.add(member);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ActiveVoiceRoom room = JSON.parseObject(payload, ActiveVoiceRoom.class);
|
||||
if (Objects.isNull(room) || !Objects.equals(room.getId(), expectedRoomId)) {
|
||||
staleMembers.add(member);
|
||||
return null;
|
||||
}
|
||||
return room;
|
||||
} catch (RuntimeException e) {
|
||||
// 坏 payload 对新旧 reader 都不可用,删除后允许后续退房重新生成一致快照。
|
||||
staleMembers.add(member);
|
||||
redisService.delete(payloadKey);
|
||||
log.warn("清理无法解析的空房间快照, payloadKey={}", payloadKey, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matchesIndex(
|
||||
ActiveVoiceRoom room,
|
||||
String sysOrigin,
|
||||
String region,
|
||||
boolean allRegions
|
||||
) {
|
||||
return Objects.equals(sysOrigin, room.getSysOrigin())
|
||||
&& (allRegions || Objects.equals(region, room.getRegion()));
|
||||
}
|
||||
|
||||
private void normalizeEmptyRoom(ActiveVoiceRoom room) {
|
||||
room.setOnlineQuantity(0L);
|
||||
if (Objects.isNull(room.getWeights())) {
|
||||
room.setWeights(0);
|
||||
}
|
||||
if (Objects.isNull(room.getFixedWeights())) {
|
||||
room.setFixedWeights(0);
|
||||
}
|
||||
}
|
||||
|
||||
private Long parseRoomId(String member) {
|
||||
try {
|
||||
return Long.valueOf(member);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("清理非法空房间索引成员, member={}", member);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,47 +1,72 @@
|
||||
package com.red.circle.other.infra.database.cache.service.user;
|
||||
|
||||
import com.red.circle.other.infra.database.cache.key.user.UserHashKey;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 区域房间缓存服务.
|
||||
*
|
||||
* @author lixiaofei on 2024/9/4
|
||||
*/
|
||||
public interface RegionRoomCacheService {
|
||||
|
||||
/**
|
||||
* 移除缓存.
|
||||
*
|
||||
* @param key 用户id
|
||||
*/
|
||||
void remove(String key);
|
||||
|
||||
/**
|
||||
* 添加用户区域缓存.
|
||||
*
|
||||
* @param activeVoiceRooms 房间信息
|
||||
* @param key key
|
||||
*/
|
||||
void save(List<ActiveVoiceRoom> activeVoiceRooms, String key);
|
||||
|
||||
/**
|
||||
* 添加用户区域缓存.
|
||||
*
|
||||
* @param jsonString 房间信息
|
||||
* @param key key
|
||||
*/
|
||||
void saveRegionsRoom(String jsonString, String key);
|
||||
|
||||
/**
|
||||
* 获取区域房间信息.
|
||||
*
|
||||
* @param key 用户id
|
||||
*/
|
||||
String getRegionsRoom(String key);
|
||||
|
||||
}
|
||||
package com.red.circle.other.infra.database.cache.service.user;
|
||||
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 区域房间缓存服务.
|
||||
*
|
||||
* @author lixiaofei on 2024/9/4
|
||||
*/
|
||||
public interface RegionRoomCacheService {
|
||||
|
||||
/**
|
||||
* 移除缓存.
|
||||
*
|
||||
* @param key 用户id
|
||||
*/
|
||||
void remove(String key);
|
||||
|
||||
/**
|
||||
* 添加用户区域缓存.
|
||||
*
|
||||
* @param activeVoiceRooms 房间信息
|
||||
* @param key key
|
||||
*/
|
||||
void save(List<ActiveVoiceRoom> activeVoiceRooms, String key);
|
||||
|
||||
/**
|
||||
* 添加用户区域缓存.
|
||||
*
|
||||
* @param jsonString 房间信息
|
||||
* @param key key
|
||||
*/
|
||||
void saveRegionsRoom(String jsonString, String key);
|
||||
|
||||
/**
|
||||
* 使用指定 TTL 写入发现页缓存;空结果使用短 TTL 负缓存,避免所有请求持续回源。
|
||||
*
|
||||
* @param jsonString 房间信息
|
||||
* @param key 发现页缓存逻辑 key
|
||||
* @param timeout 缓存时长
|
||||
* @param timeUnit 时长单位
|
||||
*/
|
||||
void saveRegionsRoom(String jsonString, String key, long timeout, TimeUnit timeUnit);
|
||||
|
||||
/**
|
||||
* 获取区域房间信息.
|
||||
*
|
||||
* @param key 用户id
|
||||
*/
|
||||
String getRegionsRoom(String key);
|
||||
|
||||
/**
|
||||
* 竞争指定发现缓存 key 的跨实例重建权。
|
||||
*
|
||||
* @param key 发现页缓存逻辑 key
|
||||
* @param token 本次持有者的唯一 token
|
||||
* @return 是否获得锁
|
||||
*/
|
||||
boolean tryAcquireRebuildLock(String key, String token);
|
||||
|
||||
/**
|
||||
* 仅当 token 仍属于当前持有者时释放锁,防止旧请求删除超时后新持有者的锁。
|
||||
*
|
||||
* @param key 发现页缓存逻辑 key
|
||||
* @param token 本次持有者的唯一 token
|
||||
* @return 是否删除了当前锁
|
||||
*/
|
||||
boolean releaseRebuildLock(String key, String token);
|
||||
|
||||
}
|
||||
|
||||
@ -1,59 +1,98 @@
|
||||
package com.red.circle.other.infra.database.cache.service.user.impl;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.red.circle.component.redis.service.RedisService;
|
||||
import com.red.circle.other.infra.database.cache.key.user.UserHashKey;
|
||||
import com.red.circle.other.infra.database.cache.key.user.UserKey;
|
||||
import com.red.circle.other.infra.database.cache.service.user.RegionRoomCacheService;
|
||||
import com.red.circle.other.infra.database.cache.service.user.UserRegionCacheService;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 区域房间缓存服务.
|
||||
*
|
||||
* @author lixiaofei on 2024/9/4
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RegionRoomCacheServiceImpl implements RegionRoomCacheService {
|
||||
|
||||
private final RedisService redisService;
|
||||
private static final long CACHE_DAYS = 3;
|
||||
private static final long CACHE_TIME = 10;
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
redisService.delete(getRegionsRoomKey(key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(List<ActiveVoiceRoom> activeVoiceRooms, String key) {
|
||||
Gson gson = new Gson();
|
||||
String jsonString = gson.toJson(activeVoiceRooms);
|
||||
log.warn("jsonString:{}", jsonString);
|
||||
redisService.setIfAbsent(getRegionsRoomKey(key), jsonString, CACHE_TIME, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveRegionsRoom(String jsonString, String key) {
|
||||
redisService.setIfAbsent(getRegionsRoomKey(key), jsonString, CACHE_TIME, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRegionsRoom(String key) {
|
||||
return redisService.getString(getRegionsRoomKey(key));
|
||||
}
|
||||
|
||||
private String getRegionsRoomKey(String userId) {
|
||||
return UserKey.REGION_ROOM.getKey(userId);
|
||||
}
|
||||
|
||||
}
|
||||
package com.red.circle.other.infra.database.cache.service.user.impl;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.red.circle.component.redis.service.RedisService;
|
||||
import com.red.circle.other.infra.database.cache.key.user.UserKey;
|
||||
import com.red.circle.other.infra.database.cache.service.user.RegionRoomCacheService;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 区域房间缓存服务.
|
||||
*
|
||||
* @author lixiaofei on 2024/9/4
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RegionRoomCacheServiceImpl implements RegionRoomCacheService {
|
||||
|
||||
private static final String REBUILD_LOCK_PREFIX = "discovery_cache_rebuild_lock:v2:";
|
||||
private static final String RELEASE_LOCK_SCRIPT =
|
||||
"if redis.call('GET', KEYS[1]) == ARGV[1] then "
|
||||
+ "return redis.call('DEL', KEYS[1]) "
|
||||
+ "end "
|
||||
+ "return 0";
|
||||
private static final long REBUILD_LOCK_SECONDS = 30L;
|
||||
private static final long CACHE_TIME_MINUTES = 10L;
|
||||
|
||||
private final RedisService redisService;
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
redisService.delete(getRegionsRoomKey(key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(List<ActiveVoiceRoom> activeVoiceRooms, String key) {
|
||||
Gson gson = new Gson();
|
||||
String jsonString = gson.toJson(activeVoiceRooms);
|
||||
log.warn("jsonString:{}", jsonString);
|
||||
redisService.setIfAbsent(
|
||||
getRegionsRoomKey(key),
|
||||
jsonString,
|
||||
CACHE_TIME_MINUTES,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveRegionsRoom(String jsonString, String key) {
|
||||
saveRegionsRoom(jsonString, key, CACHE_TIME_MINUTES, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveRegionsRoom(String jsonString, String key, long timeout, TimeUnit timeUnit) {
|
||||
redisService.setIfAbsent(getRegionsRoomKey(key), jsonString, timeout, timeUnit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRegionsRoom(String key) {
|
||||
return redisService.getString(getRegionsRoomKey(key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean tryAcquireRebuildLock(String key, String token) {
|
||||
return redisService.setIfAbsent(
|
||||
getRebuildLockKey(key),
|
||||
token,
|
||||
REBUILD_LOCK_SECONDS,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean releaseRebuildLock(String key, String token) {
|
||||
Long removed = redisService.execute(
|
||||
RELEASE_LOCK_SCRIPT,
|
||||
Long.class,
|
||||
Collections.singletonList(getRebuildLockKey(key)),
|
||||
token
|
||||
);
|
||||
return Long.valueOf(1L).equals(removed);
|
||||
}
|
||||
|
||||
private String getRegionsRoomKey(String userId) {
|
||||
return UserKey.REGION_ROOM.getKey(userId);
|
||||
}
|
||||
|
||||
private String getRebuildLockKey(String key) {
|
||||
return REBUILD_LOCK_PREFIX + key;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
package com.red.circle.other.infra.database.cache.service.other.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.red.circle.component.redis.service.RedisService;
|
||||
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
|
||||
import com.red.circle.other.inner.model.cache.EmptyRoomCacheKeys;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class EmptyRoomCacheReaderImplTest {
|
||||
|
||||
private RedisService redisService;
|
||||
private EmptyRoomCacheReaderImpl reader;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
redisService = mock(RedisService.class);
|
||||
reader = new EmptyRoomCacheReaderImpl(redisService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_shouldReadRegionIndexNormalizeDefaultsAndRemoveStaleMembers() {
|
||||
String indexKey = EmptyRoomCacheKeys.regionIndex("YOLO", "AE");
|
||||
Set<Object> indexedMembers = new LinkedHashSet<>(
|
||||
List.of("101", "102", "invalid-room-id", "103", "104"));
|
||||
when(redisService.zsetGetReverseRange(indexKey, 0L, 49L))
|
||||
.thenReturn(indexedMembers);
|
||||
|
||||
ActiveVoiceRoom validRoom = room(101L, "YOLO", "AE")
|
||||
.setOnlineQuantity(99L)
|
||||
.setWeights(null)
|
||||
.setFixedWeights(null);
|
||||
ActiveVoiceRoom wrongRegionRoom = room(104L, "YOLO", "SA");
|
||||
List<String> payloadKeys = List.of(
|
||||
EmptyRoomCacheKeys.payload(101L),
|
||||
EmptyRoomCacheKeys.payload(102L),
|
||||
EmptyRoomCacheKeys.payload(103L),
|
||||
EmptyRoomCacheKeys.payload(104L)
|
||||
);
|
||||
// MGET 返回值与索引中的合法房间 ID 一一对齐:null、坏 JSON 和归属错误都应淘汰。
|
||||
when(redisService.multiGetString(payloadKeys)).thenReturn(Arrays.asList(
|
||||
JSON.toJSONString(validRoom),
|
||||
null,
|
||||
"{invalid-json",
|
||||
JSON.toJSONString(wrongRegionRoom)
|
||||
));
|
||||
|
||||
List<ActiveVoiceRoom> result = reader.list("YOLO", "AE", false, 50);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(validRoom.getId(), result.get(0).getId());
|
||||
assertEquals(0L, result.get(0).getOnlineQuantity());
|
||||
assertEquals(0, result.get(0).getWeights());
|
||||
assertEquals(0, result.get(0).getFixedWeights());
|
||||
verify(redisService).zsetRemoveByScore(eq(indexKey), eq(0D), anyDouble());
|
||||
verify(redisService).multiGetString(payloadKeys);
|
||||
verify(redisService).delete(EmptyRoomCacheKeys.payload(103L));
|
||||
|
||||
ArgumentCaptor<Object[]> staleMembersCaptor = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(redisService).zsetRemove(eq(indexKey), staleMembersCaptor.capture());
|
||||
assertEquals(
|
||||
Set.of("102", "invalid-room-id", "103", "104"),
|
||||
Set.copyOf(Arrays.asList(staleMembersCaptor.getValue()))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_shouldUseAllRegionsIndexWithoutRejectingRoomRegion() {
|
||||
String indexKey = EmptyRoomCacheKeys.allRegionsIndex("YOLO");
|
||||
when(redisService.zsetGetReverseRange(indexKey, 0L, 49L))
|
||||
.thenReturn(new LinkedHashSet<>(List.of("201")));
|
||||
ActiveVoiceRoom saRoom = room(201L, "YOLO", "SA")
|
||||
.setOnlineQuantity(7L)
|
||||
.setWeights(8)
|
||||
.setFixedWeights(9);
|
||||
when(redisService.multiGetString(List.of(EmptyRoomCacheKeys.payload(201L))))
|
||||
.thenReturn(List.of(JSON.toJSONString(saRoom)));
|
||||
|
||||
List<ActiveVoiceRoom> result = reader.list("YOLO", "AE", true, 50);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("SA", result.get(0).getRegion());
|
||||
// 空房快照的在线人数必须归零;已有排序权重则保持写入值。
|
||||
assertEquals(0L, result.get(0).getOnlineQuantity());
|
||||
assertEquals(8, result.get(0).getWeights());
|
||||
assertEquals(9, result.get(0).getFixedWeights());
|
||||
verify(redisService).zsetRemoveByScore(eq(indexKey), eq(0D), anyDouble());
|
||||
verify(redisService).zsetGetReverseRange(indexKey, 0L, 49L);
|
||||
verify(redisService, never()).zsetRemove(eq(indexKey), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_shouldRemoveAllInvalidMembersWithoutEmptyMultiGet() {
|
||||
String indexKey = EmptyRoomCacheKeys.regionIndex("YOLO", "AE");
|
||||
when(redisService.zsetGetReverseRange(indexKey, 0L, 49L))
|
||||
.thenReturn(new LinkedHashSet<>(List.of("invalid-room-id")));
|
||||
|
||||
assertEquals(List.of(), reader.list("YOLO", "AE", false, 50));
|
||||
|
||||
verify(redisService, never()).multiGetString(any());
|
||||
verify(redisService).zsetRemove(indexKey, "invalid-room-id");
|
||||
}
|
||||
|
||||
private ActiveVoiceRoom room(Long roomId, String sysOrigin, String region) {
|
||||
return new ActiveVoiceRoom()
|
||||
.setId(roomId)
|
||||
.setSysOrigin(sysOrigin)
|
||||
.setRegion(region);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package com.red.circle.other.infra.database.cache.service.user.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.red.circle.component.redis.service.RedisService;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RegionRoomCacheServiceImplTest {
|
||||
|
||||
private static final String RELEASE_LOCK_SCRIPT =
|
||||
"if redis.call('GET', KEYS[1]) == ARGV[1] then "
|
||||
+ "return redis.call('DEL', KEYS[1]) "
|
||||
+ "end "
|
||||
+ "return 0";
|
||||
private static final String CACHE_KEY = "USER:REGION_ROOM:LIKEISA";
|
||||
private static final String REBUILD_LOCK_KEY = "discovery_cache_rebuild_lock:v2:LIKEISA";
|
||||
|
||||
private RedisService redisService;
|
||||
private RegionRoomCacheServiceImpl cacheService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
redisService = mock(RedisService.class);
|
||||
cacheService = new RegionRoomCacheServiceImpl(redisService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveRegionsRoom_shouldPassThroughCustomCacheTtl() {
|
||||
// 空结果使用调用方指定的短 TTL,不能退回默认十分钟而持续放大回源窗口。
|
||||
cacheService.saveRegionsRoom("[]", "LIKEISA", 45L, TimeUnit.SECONDS);
|
||||
|
||||
verify(redisService).setIfAbsent(CACHE_KEY, "[]", 45L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryAcquireRebuildLock_shouldStoreOwnerTokenForThirtySeconds() {
|
||||
when(redisService.setIfAbsent(REBUILD_LOCK_KEY, "owner-token", 30L, TimeUnit.SECONDS))
|
||||
.thenReturn(true);
|
||||
|
||||
boolean acquired = cacheService.tryAcquireRebuildLock("LIKEISA", "owner-token");
|
||||
|
||||
assertTrue(acquired);
|
||||
verify(redisService).setIfAbsent(
|
||||
REBUILD_LOCK_KEY,
|
||||
"owner-token",
|
||||
30L,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseRebuildLock_shouldAtomicallyCompareOwnerTokenBeforeDelete() {
|
||||
when(redisService.execute(
|
||||
RELEASE_LOCK_SCRIPT,
|
||||
Long.class,
|
||||
Collections.singletonList(REBUILD_LOCK_KEY),
|
||||
"owner-token"
|
||||
)).thenReturn(1L);
|
||||
|
||||
boolean released = cacheService.releaseRebuildLock("LIKEISA", "owner-token");
|
||||
|
||||
assertTrue(released);
|
||||
verify(redisService).execute(
|
||||
RELEASE_LOCK_SCRIPT,
|
||||
Long.class,
|
||||
Collections.singletonList(REBUILD_LOCK_KEY),
|
||||
"owner-token"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseRebuildLock_shouldReportTokenMismatchWithoutClaimingRelease() {
|
||||
// Lua 返回 0 代表锁已过期或 owner 已变化,旧请求不得声称释放成功。
|
||||
when(redisService.execute(
|
||||
RELEASE_LOCK_SCRIPT,
|
||||
Long.class,
|
||||
Collections.singletonList(REBUILD_LOCK_KEY),
|
||||
"expired-owner-token"
|
||||
)).thenReturn(0L);
|
||||
|
||||
boolean released = cacheService.releaseRebuildLock("LIKEISA", "expired-owner-token");
|
||||
|
||||
assertFalse(released);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user