diff --git a/llm_readme.txt b/llm_readme.txt index 4464901..c5329d6 100644 --- a/llm_readme.txt +++ b/llm_readme.txt @@ -45,13 +45,15 @@ storageNode/ │ │ │ └── ResponseMessage.java # 通用响应(type=0) │ │ └── Service/ │ │ ├── DeleteService.java # 删除画廊目录 - │ │ └── DownloadCheckService.java # 下载监控与压缩服务 + │ │ ├── DownloadCheckService.java # 下载监控与压缩服务 + │ │ ├── PrimaryChannelTracker.java # 主站通道引用登记与自愈 + │ │ └── RekickPolicy.java # 主动重新唤起主站的限流判定 │ └── resources/ │ ├── config.properties # DouNai 订阅地址配置 │ ├── simplelogger.properties # SLF4J 日志配置(输出到 run.out) │ └── reflect-config.json # GraalVM 反射配置(Jackson 序列化) └── test/ - └── java/ # 订阅快照与下载/压缩恢复测试 + └── java/ # 订阅快照、下载/压缩恢复与主站通道自愈测试 ``` --- @@ -130,6 +132,17 @@ storageNode/ - 按名称删除画廊目录 - 失败时返回 `ErrorCode.IO_ERROR` 或 `ErrorCode.FILE_NOT_FOUND` +### PrimaryChannelTracker +- 登记「哪条通道是主站」,供任务状态上报与心跳使用 +- 只有主站会发的消息类型(身份/任务下发/探活/订阅快照)才可用于认领引用; + 备机的身份消息与节点自己的出站类型都不算证据 +- 首选通道断开时立即回退到其它仍可用的已认证通道;引用被清空时, + 已认证通道上的下一条消息即可恢复它,避免任务状态静默停止上报 + +### RekickPolicy +- 判定「有待上报任务、却无可用通道」时是否应主动重新唤起主站 +- 以 30 秒为最小间隔限流,既能快速自愈,又不会在主站确实离线时形成重连风暴 + --- ## 配置说明 diff --git a/src/main/java/lion/Service/PrimaryChannelTracker.java b/src/main/java/lion/Service/PrimaryChannelTracker.java new file mode 100644 index 0000000..3509336 --- /dev/null +++ b/src/main/java/lion/Service/PrimaryChannelTracker.java @@ -0,0 +1,92 @@ +package lion.Service; + +import io.netty.channel.Channel; +import io.netty.util.AttributeKey; +import lombok.extern.slf4j.Slf4j; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 「哪条通道是主站」的唯一登记处。 + * + *

历史缺陷:主站重连期间可能出现多条已认证通道,节点先引用 X,随后被后建立的 Y + * 覆盖;Y 断开时把引用清空,而仍可用的 X 继续发送心跳与任务消息,节点却因为引用为空 + * 不再上报任何任务状态。主站侧的存活探测又能收到 X 上的响应,双方于是都判定 + * 「连接正常」,未完成任务的状态就此永久卡住。 + * + *

因此把引用管理收敛到这里,并保留全部已认证通道:首选通道一旦失效,立即回退到 + * 其它仍可用的已认证通道;引用被误清时,任何一条已认证通道上的消息也能把它恢复。 + * 这样「有已认证通道在,就一定能上报」成为不变式,不必等人工重连或下一个探测周期。 + * + *

「已认证」的口径是「该通道发来过只有主站会发的消息类型」。事故中出问题的那条通道 + * 正是靠可用性探测消息证明自己仍在工作的,若只认 {@code IdentityMessage},这条通道 + * 就永远无法重新取得引用。节点数据端口本就只应面向主站开放,凭证由网络边界提供。 + */ +@Slf4j +public class PrimaryChannelTracker { + + static final AttributeKey AUTHENTICATED = AttributeKey.valueOf("primaryAuthenticated"); + + /** 全部已认证通道,用于在首选通道失效时立即回退。 */ + private final Set authenticated = ConcurrentHashMap.newKeySet(); + + private volatile Channel primary; + + /** 收到只有主站会发的消息:认证该通道;若当前引用不是它,则改指向它。 */ + public void onPrimaryMessage(Channel channel) { + markAuthenticated(channel); + if (primary == null) { + primary = channel; + log.info("主站通道引用已建立"); + } else if (channel != primary) { + switchTo(channel); + } + } + + private void markAuthenticated(Channel channel) { + if (Boolean.TRUE.equals(channel.attr(AUTHENTICATED).get())) + return; + channel.attr(AUTHENTICATED).set(Boolean.TRUE); + channel.closeFuture().addListener(ignored -> unregister(channel)); + authenticated.add(channel); + } + + /** 通道退出:移出已认证集合,必要时把引用移交给其它仍可用的通道。 */ + public void unregister(Channel channel) { + authenticated.remove(channel); + if (channel != primary) + return; + primary = firstUsable(); + } + + private void switchTo(Channel channel) { + log.info("主站通道引用已切换到仍在工作的通道"); + primary = channel; + } + + public Channel current() { + Channel channel = primary; + if (channel != null && channel.isActive()) + return channel; + // 首选通道已失效:立即回退,避免出现「有可用通道却无人上报」的静默状态。 + Channel fallback = firstUsable(); + if (fallback != null && fallback != channel) { + log.info("主站首选通道不可用,回退到其它已认证通道"); + primary = fallback; + } + return fallback; + } + + /** 引用是否存在且仍可写。 */ + public boolean usable() { + return current() != null; + } + + private Channel firstUsable() { + for (Channel channel : authenticated) + if (channel.isActive()) + return channel; + return null; + } +} diff --git a/src/main/java/lion/Service/RekickPolicy.java b/src/main/java/lion/Service/RekickPolicy.java new file mode 100644 index 0000000..3e5f8fb --- /dev/null +++ b/src/main/java/lion/Service/RekickPolicy.java @@ -0,0 +1,34 @@ +package lion.Service; + +/** + * 「是否该主动重新唤起主站」的判定。 + * + *

节点持有待上报的任务状态却没有可用通道时,只等主站下一个探测周期可能长达半小时; + * 期间用户在下载器里看到的是任务永远停在「已提交」。这里按固定间隔限流地允许重连, + * 既能在几十秒内自愈,又不会在主站确实离线时形成重连风暴。 + * + *

把判定单独拆出来是为了能直接对时间轴写断言,不必启动 Netty。 + */ +public class RekickPolicy { + + /** 两次主动唤起之间的最小间隔。 */ + static final long MIN_INTERVAL_MILLIS = 30_000L; + + private long lastAttemptMillis; + + /** + * 本轮是否应当唤起主站。 + * + * @param pendingReports 是否有待上报的任务状态 + * @param channelUsable 是否已存在可用通道 + * @param nowMillis 当前时间 + */ + public boolean shouldRekick(boolean pendingReports, boolean channelUsable, long nowMillis) { + if (!pendingReports || channelUsable) + return false; + if (lastAttemptMillis != 0 && nowMillis - lastAttemptMillis < MIN_INTERVAL_MILLIS) + return false; + lastAttemptMillis = nowMillis; + return true; + } +} diff --git a/src/main/java/lion/storageNode.java b/src/main/java/lion/storageNode.java index e710629..90ac98b 100644 --- a/src/main/java/lion/storageNode.java +++ b/src/main/java/lion/storageNode.java @@ -7,6 +7,8 @@ import lion.Message.Main.*; import lion.Service.DeleteService; import lion.Service.SubscriptionSnapshotStore; import lion.Service.DownloadCheckService; +import lion.Service.PrimaryChannelTracker; +import lion.Service.RekickPolicy; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.*; import io.netty.channel.nio.NioEventLoopGroup; @@ -25,8 +27,23 @@ import java.util.concurrent.locks.ReentrantLock; @Slf4j public class storageNode { - // 分别由 Netty IO 线程写入、由 5 秒定时线程读取,必须保证可见性。 - volatile Channel server; + // 主站通道引用由 PrimaryChannelTracker 统一管理,避免重复认证把可用通道误清空。 + final PrimaryChannelTracker primaryChannel = new PrimaryChannelTracker(); + + // 有待上报任务却无可用通道时,主动重新唤起主站,避免干等下一个探测周期。 + final RekickPolicy rekickPolicy = new RekickPolicy(); + + // 重新唤起主站是阻塞式端口探测(最多 21×3 秒),不得占用 5 秒调度线程, + // 否则下载进度扫描与压缩都会被拖住;同一时刻只允许一次探测。 + final java.util.concurrent.atomic.AtomicBoolean rekicking = new java.util.concurrent.atomic.AtomicBoolean(); + + // 专门用于阻塞式重连探测;不能复用单线程的 checkThreadPool, + // 在同一个线程池里提交阻塞任务会把调度线程自己堵住。 + final ExecutorService rekickExecutor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "storage-node-rekick"); + thread.setDaemon(true); + return thread; + }); volatile Channel node; @@ -72,6 +89,45 @@ public class storageNode { }).bind(real_port); log.info("listening on port {}", real_port); + wakeMainServer(); + downloadCheckService = new DownloadCheckService(queue); + checkThreadPool = Executors.newScheduledThreadPool(1); + checkThreadPool.scheduleAtFixedRate(this::mainThread, 5, 5, TimeUnit.SECONDS); + } + + /** + * 在独立线程里重新唤起主站。 + * + *

{@link #wakeMainServer()} 是阻塞的端口探测,最坏可占 21×3 秒;直接在 + * {@link #mainThread()} 里调用会让下载扫描与压缩停摆,因此投递到独立线程执行。 + * 该执行器与 5 秒调度器分离,避免阻塞任务把调度线程自己堵住; + * 同一时刻只允许一次探测,防止主站长时间离线时堆积。 + */ + void rekickMainServerAsync(){ + if (!rekicking.compareAndSet(false, true)) + return; + try { + rekickExecutor.execute(() -> { + try { + wakeMainServer(); + } finally { + rekicking.set(false); + } + }); + } catch (Exception e) { + rekicking.set(false); + log.warn("安排主站重连失败:{}", e.getMessage()); + } + } + + /** + * 按既定顺序敲主站监听端口,请它回来建立数据通道。 + * + *

启动时调用一次,另在「有待上报任务但无可用通道」时按 {@link RekickPolicy} + * 限流重试。主站侧的可用性探测只能覆盖它认为连接仍在的情况;一旦双方都判定 + * 对方不在,只有本方法能重新建立通道,否则任务状态会一直卡在未完成。 + */ + void wakeMainServer(){ int i; for(i=0; i<=20; i++) { try (Socket socket = new Socket()) { @@ -88,9 +144,6 @@ public class storageNode { if (i==20) { log.info("server connect failed"); } - downloadCheckService = new DownloadCheckService(queue); - checkThreadPool = Executors.newScheduledThreadPool(1); - checkThreadPool.scheduleAtFixedRate(this::mainThread, 5, 5, TimeUnit.SECONDS); } public void mainThread(){ @@ -104,6 +157,19 @@ public class storageNode { } finally { lock.unlock(); } + + // 有暂存内容却无可用通道:告警,并按限流主动重新唤起主站。 + // 这里只告警与重连,不提前返回——本地下载与压缩必须继续推进, + // 否则主站恢复前任务会在节点侧整体停摆。 + // 该判断在 downloadCheck 之前:正在下载、进度无变化的任务稍后会走 + // 「无需上报」的提前返回路径,若只在回报分支里判定,通道失联后 + // 这类任务既不会被发现也不会触发重连,就复现了历史上的静默卡死。 + if (!queue.isEmpty() && !primaryChannel.usable()) { + log.warn("主站通道不可用,{} 个任务状态暂存待上报", queue.size()); + if (rekickPolicy.shouldRekick(true, false, System.currentTimeMillis())) + rekickMainServerAsync(); + } + //检查,当任务状态发生变化即方法返回true时,再更新,否则return if (!downloadCheckService.downloadCheck()) { boolean isSkip = true; @@ -116,17 +182,16 @@ public class storageNode { } if(isSkip) { counter++; - if (server != null && server.isActive() && counter > 10) { - server.writeAndFlush(new MaintainMessage()); + if (primaryChannel.usable() && counter > 10) { + primaryChannel.current().writeAndFlush(new MaintainMessage()); counter = 0; } return; } } - //主站未连接时不上报。原先直接 server.writeAndFlush 会在 server 为 null 时抛 NPE, - //被外层 catch 吞掉后每 5 秒刷一条 error;队列保留,等主站重连时重放。 - Channel target = server; - if (target == null || !target.isActive()) + //主站未连接时不上报,队列保留、等主站重连时重放(无法写通道时上面的分支已兜住)。 + Channel target = primaryChannel.current(); + if (target == null) return; //没有待上报任务时不必发送空数组(此时 downloadCheck 为 false 且无压缩完成任务)。 if (queue.isEmpty()) @@ -151,6 +216,27 @@ public class storageNode { int counter; + /** + * 该消息是否只有主站会发。 + * + *

白名单而非黑名单:只有这些类型能证明对端是主站并据此认领通道引用。 + * 名单严格等于主站在 {@code RemoteService} 里实际会发出的类型; + * {@code ResponseMessage}、{@code DownloadStatusMessage}、{@code MaintainMessage} + * 都是本节点自己的出站类型,备机({@code lionwebsiteside})也会发身份消息, + * 都不能用来认领主站引用。 + */ + static boolean isPrimaryEvidence(AbstractMessage message) { + if (message.messageType == AbstractMessage.IDENTITY_MESSAGE) { + // 备机也用身份消息自我介绍,只有 identity 为 lionwebsite 的才是主站。 + IdentityMessage identity = (IdentityMessage) message; + return "lionwebsite".equals(identity.getIdentity()); + } + return message.messageType == AbstractMessage.DOWNLOAD_POST_MESSAGE + || message.messageType == AbstractMessage.DELETE_GALLERY_MESSAGE + || message.messageType == AbstractMessage.AVAILABLE_CHECK_MESSAGE + || message.messageType == AbstractMessage.SUBSCRIPTION_SNAPSHOT_MESSAGE; + } + class MyChannelInboundHandlerAdapter extends ChannelInboundHandlerAdapter{ Map queue; @@ -166,17 +252,21 @@ public class storageNode { log.info(String.valueOf(msg)); AbstractMessage abstractMessage = (AbstractMessage) msg; - // 来自已认证主站通道的任何消息都是「主站仍在」的证据。 - // 备机的过期语义是「主站失联」,因此用这个信号刷新新鲜度,而不是只在收到快照时刷新: - // 订阅内容长期不变时主站没有理由重发整份快照,若只看快照就会误判过期。 - if (ctx.channel().equals(server)) + // 只有主站会发的消息类型(身份/任务下发/删除/探活/订阅快照)同时承担两件事: + // 证明「主站仍在」,以及恢复通道引用。并发认证时引用可能先指向 X 再被 Y 覆盖, + // Y 断开后引用被清空,而仍可用的 X 继续发送探活等消息;收到这些消息即证明 + // X 就是主站,必须无条件认领——引用被清空时 current() 为 null, + // 任何以其为前提的判断都不成立,自愈也就无从触发。 + if (isPrimaryEvidence(abstractMessage)) { + primaryChannel.onPrimaryMessage(ctx.channel()); subscriptionSnapshotStore.markPrimaryContact(); + } switch (abstractMessage.messageType){ case AbstractMessage.IDENTITY_MESSAGE -> { IdentityMessage identityMessage = (IdentityMessage) abstractMessage; if(identityMessage.getIdentity().equals("lionwebsite")) { - server = ctx.channel(); + // 引用已在上面按消息类型认领,这里只保留「上线」日志语义。 log.info("server 上线"); } else if(identityMessage.getIdentity().equals("lionwebsiteside")){ node = ctx.channel(); @@ -191,7 +281,7 @@ public class storageNode { GalleryTask currentTask = downloadCheckService.addToQueue(dpm.getGalleryTask()); DownloadStatusMessage downloadStatusMessage = new DownloadStatusMessage(); downloadStatusMessage.setGalleryTasks(new GalleryTask[]{currentTask}); - Channel target = server; + Channel target = primaryChannel.current(); if (target != null && target.isActive()) target.writeAndFlush(downloadStatusMessage); log.info(String.valueOf(queue)); @@ -222,7 +312,7 @@ public class storageNode { } case AbstractMessage.SUBSCRIPTION_SNAPSHOT_MESSAGE -> { SubscriptionSnapshotMessage snapshotMessage = (SubscriptionSnapshotMessage) abstractMessage; - if (!Config.subscriptionSyncEnabled || !ctx.channel().equals(server)) { + if (!Config.subscriptionSyncEnabled || !ctx.channel().equals(primaryChannel.current())) { ctx.writeAndFlush(new ResponseMessage(snapshotMessage.messageId, SubscriptionSnapshotStore.APPLY_INVALID)); return; } @@ -241,13 +331,13 @@ public class storageNode { @Override public void channelUnregistered(ChannelHandlerContext ctx) { - if(ctx.channel().equals(server)) { + if(ctx.channel().equals(primaryChannel.current())) { log.info("server 下线"); - server = null; } else if(ctx.channel().equals(node)){ log.info("node 下线"); node = null; } + primaryChannel.unregister(ctx.channel()); } } diff --git a/src/test/java/lion/PrimaryChannelRecoveryTest.java b/src/test/java/lion/PrimaryChannelRecoveryTest.java new file mode 100644 index 0000000..720e9ad --- /dev/null +++ b/src/test/java/lion/PrimaryChannelRecoveryTest.java @@ -0,0 +1,88 @@ +package lion; + +import io.netty.channel.embedded.EmbeddedChannel; +import lion.Message.AbstractMessage; +import lion.Message.Main.AvailableCheckMessage; +import lion.Message.Main.IdentityMessage; +import lion.Service.PrimaryChannelTracker; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 复现并锁定线上事故的通道恢复语义。 + * + *

事故经过:主站重连期间节点先后认证了多条通道,引用被最后一条覆盖;那条通道断开后 + * 引用被清空,而更早建立、仍然可用的通道继续发送可用性探测。节点因引用为空不再上报 + * 任何任务状态,主站又能在旧通道上收到探活响应,双方都判定连接正常,未完成任务 + * 便永久停在「已提交」,直到人工触发重连。 + */ +class PrimaryChannelRecoveryTest { + + @Test + void availableCheckOnSurvivingChannelRestoresReporting() { + PrimaryChannelTracker tracker = new PrimaryChannelTracker(); + EmbeddedChannel survivor = new EmbeddedChannel(); + EmbeddedChannel overwriter = new EmbeddedChannel(); + try { + // 主站两次上线:引用最终落在后建立的 overwriter 上。 + tracker.onPrimaryMessage(survivor); + tracker.onPrimaryMessage(overwriter); + + // overwriter 断开,引用被清空——事故的起点。 + tracker.unregister(overwriter); + + // 仍然可用的 survivor 发来探活消息:引用应当立即恢复。 + AvailableCheckMessage probe = new AvailableCheckMessage(); + assertEquals(AbstractMessage.AVAILABLE_CHECK_MESSAGE, probe.messageType); + tracker.onPrimaryMessage(survivor); + + assertSame(survivor, tracker.current(), "探活消息应恢复引用"); + assertTrue(tracker.usable(), "恢复后必须能继续上报任务状态"); + } finally { + survivor.finishAndReleaseAll(); + overwriter.finishAndReleaseAll(); + } + } + + /** 首选通道断开但仍有其它已认证通道时,不得出现「无通道可用」的空窗。 */ + @Test + void referenceNeverDropsWhileAnotherAuthenticatedChannelLives() { + PrimaryChannelTracker tracker = new PrimaryChannelTracker(); + EmbeddedChannel first = new EmbeddedChannel(); + EmbeddedChannel second = new EmbeddedChannel(); + try { + tracker.onPrimaryMessage(first); + tracker.onPrimaryMessage(second); + + tracker.unregister(second); + + assertTrue(tracker.usable(), "仍有已认证通道时必须可用"); + assertSame(first, tracker.current()); + } finally { + first.finishAndReleaseAll(); + second.finishAndReleaseAll(); + } + } + + /** + * 备机的身份消息不得夺走主站引用。 + * + *

备机在启动时同样会自我介绍,若按消息类型无条件认领,备机通道会被误当成主站, + * 任务状态将上报到错误的连接上。 + */ + @Test + void sidecarIdentityDoesNotStealPrimaryReference() { + assertTrue(storageNode.isPrimaryEvidence(identity("lionwebsite")), "主站身份应被认作证据"); + assertFalse(storageNode.isPrimaryEvidence(identity("lionwebsiteside")), + "备机身份不是主站证据,否则任务状态会上报到错误的连接"); + assertFalse(storageNode.isPrimaryEvidence(new lion.Message.Main.ResponseMessage()), + "响应消息是节点自己的出站类型,不能用来认领主站引用"); + } + + private static IdentityMessage identity(String value) { + IdentityMessage message = new IdentityMessage(); + message.setIdentity(value); + return message; + } +} diff --git a/src/test/java/lion/Service/PrimaryChannelTrackerTest.java b/src/test/java/lion/Service/PrimaryChannelTrackerTest.java new file mode 100644 index 0000000..ccb3dde --- /dev/null +++ b/src/test/java/lion/Service/PrimaryChannelTrackerTest.java @@ -0,0 +1,117 @@ +package lion.Service; + +import io.netty.channel.Channel; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PrimaryChannelTrackerTest { + + /** + * 未认证通道退出时不得清空引用。 + * + *

这正是线上事故的成因:并发认证让引用先指向 X 再被 Y 覆盖, + * Y 退出时把引用清空,而仍在工作的 X 从此不再被用于上报任务状态。 + */ + @Test + void unregisterOfNonPrimaryChannelKeepsReference() { + PrimaryChannelTracker tracker = new PrimaryChannelTracker(); + EmbeddedChannel primary = new EmbeddedChannel(); + EmbeddedChannel other = new EmbeddedChannel(); + try { + tracker.onPrimaryMessage(primary); + tracker.onPrimaryMessage(other); + tracker.onPrimaryMessage(primary); // 引用回到仍在工作的那条通道 + + tracker.unregister(other); + + assertSame(primary, tracker.current()); + assertTrue(tracker.usable()); + } finally { + primary.finishAndReleaseAll(); + other.finishAndReleaseAll(); + } + } + + /** + * 首选通道退出时立即回退到其它仍可用的已认证通道。 + * + *

事故中节点引用的那条通道退出后引用被清空,而另一条已认证通道仍然活跃, + * 任务状态就此无人上报。回退保证「有已认证通道在,就一定能上报」。 + */ + @Test + void unregisterOfPrimaryFallsBackToSurvivingChannel() { + PrimaryChannelTracker tracker = new PrimaryChannelTracker(); + EmbeddedChannel survivor = new EmbeddedChannel(); + EmbeddedChannel transientChannel = new EmbeddedChannel(); + try { + tracker.onPrimaryMessage(survivor); + tracker.onPrimaryMessage(transientChannel); + tracker.unregister(transientChannel); + + assertSame(survivor, tracker.current(), "应回退到仍可用的已认证通道"); + assertTrue(tracker.usable()); + } finally { + survivor.finishAndReleaseAll(); + transientChannel.finishAndReleaseAll(); + } + } + + /** 实际在收发消息的已认证通道应当接管引用,即使原引用仍处于 active 状态。 */ + @Test + void authenticatedChannelTakesOverOnMessage() { + PrimaryChannelTracker tracker = new PrimaryChannelTracker(); + EmbeddedChannel stalePrimary = new EmbeddedChannel(); + EmbeddedChannel live = new EmbeddedChannel(); + try { + tracker.onPrimaryMessage(stalePrimary); + tracker.onPrimaryMessage(live); + tracker.onPrimaryMessage(stalePrimary); // 引用先落在 stalePrimary 上 + + tracker.onPrimaryMessage(live); + + assertSame(live, tracker.current(), "说话的通道应成为引用"); + } finally { + stalePrimary.finishAndReleaseAll(); + live.finishAndReleaseAll(); + } + } + + /** 未经认证的通道不得占用引用,避免陌生连接抢走状态上报通道。 */ + @Test + void unauthenticatedChannelCannotClaimReference() { + PrimaryChannelTracker tracker = new PrimaryChannelTracker(); + EmbeddedChannel authenticated = new EmbeddedChannel(); + EmbeddedChannel stranger = new EmbeddedChannel(); + try { + tracker.onPrimaryMessage(authenticated); + + // 未认证通道不会进入已认证集合,因此不能用它认领。 + + assertSame(authenticated, tracker.current()); + } finally { + authenticated.finishAndReleaseAll(); + stranger.finishAndReleaseAll(); + } + } + + /** 已下线的通道即使曾被认证,也不能再占用引用。 */ + @Test + void inactiveChannelIsNotUsable() { + PrimaryChannelTracker tracker = new PrimaryChannelTracker(); + EmbeddedChannel channel = new EmbeddedChannel(); + tracker.onPrimaryMessage(channel); + channel.finishAndReleaseAll(); + + assertFalse(tracker.usable()); + } + + @Test + void emptyTrackerHasNoUsableChannel() { + PrimaryChannelTracker tracker = new PrimaryChannelTracker(); + + assertNull(tracker.current()); + assertFalse(tracker.usable()); + } +} diff --git a/src/test/java/lion/Service/RekickPolicyTest.java b/src/test/java/lion/Service/RekickPolicyTest.java new file mode 100644 index 0000000..56e5dea --- /dev/null +++ b/src/test/java/lion/Service/RekickPolicyTest.java @@ -0,0 +1,47 @@ +package lion.Service; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RekickPolicyTest { + + @Test + void noPendingReportsNeverRekicks() { + RekickPolicy policy = new RekickPolicy(); + + assertFalse(policy.shouldRekick(false, false, 1_000L)); + } + + @Test + void usableChannelNeverRekicks() { + RekickPolicy policy = new RekickPolicy(); + + assertFalse(policy.shouldRekick(true, true, 1_000L)); + } + + @Test + void pendingWithoutChannelRekicksThenRateLimits() { + RekickPolicy policy = new RekickPolicy(); + long now = 1_000_000L; + + assertTrue(policy.shouldRekick(true, false, now), "首次应允许重连"); + assertFalse(policy.shouldRekick(true, false, now + 1), "间隔内应被限流"); + assertFalse(policy.shouldRekick(true, false, now + RekickPolicy.MIN_INTERVAL_MILLIS - 1)); + assertTrue(policy.shouldRekick(true, false, now + RekickPolicy.MIN_INTERVAL_MILLIS), + "超过最小间隔后应再次允许"); + } + + /** 限流只抑制连击,不应把「已限流」永久锁死后续尝试。 */ + @Test + void rateLimitDoesNotBlockForever() { + RekickPolicy policy = new RekickPolicy(); + long now = 500_000L; + + policy.shouldRekick(true, false, now); + for (int i = 0; i < 5; i++) + policy.shouldRekick(true, false, now + i * 1_000L); + + assertTrue(policy.shouldRekick(true, false, now + 10 * RekickPolicy.MIN_INTERVAL_MILLIS)); + } +}