From 8938fffd08ee7fa314a09297dbbc80759212c3a9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 21:37:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E9=87=8D=E8=AF=95=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E7=9A=84=E6=A7=BD=E5=8A=A0=2010=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E4=B8=8A=E7=95=8C=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=88=AB=E6=8A=8A=E7=94=A8=E6=88=B7=E6=96=B0=E6=89=93=E7=9A=84?= =?UTF-8?q?=E5=90=8C=E5=86=85=E5=AE=B9=E5=BD=93=E6=88=90=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多槽让失败记录跨很多次发送存活,于是开了个单槽时代没有的窗口:用户几小时前失败过一句 「你好」,之后新打一遍「你好」(心里当的是一条新消息),指纹却与那条旧记录一字不差, keyFor 命中旧键 ⇒ 服务端回 duplicate ⇒ 这条新消息根本不发出去,屏幕上看着像「按了没反应」。 给每个槽加一道时间上界,窗口就被限死在 10 分钟内;而「刚失败完,用户马上再按一次发送」 这个真正需要幂等的场景一秒都没被削掉。 - RetryKeyMemory: 新增公开常量 SLOT_TTL_MILLIS = 10 分钟(测试拿它做断言,不硬编码); RetryKeySlot 增 atMillis;remember(fingerprint, localId, atMillis) 与 keyFor(fingerprint, nowMillis, mint) 都在入口先按时间淘汰过期槽、再做自己那件事, 写入时最后才看条数上限(过期槽不占 8 条的额度);同指纹重复失败更新键与时刻而不是留两条; forget(localId) 与时间无关,签名与行为不变;FIFO 上限 8 与 TTL 两条规则同时生效。 - 边界口径:正好等于 TTL 不算过期(判据是 now - atMillis > TTL,严格大于),理由写在类注释里, 测试把它钉住。 - ChatViewModel: 只改两个调用点,喂 System.currentTimeMillis()(emitAndDeliver 的 keyFor、 rememberRetryKey 的 remember;forget 的三处不动),并按新语义更新相关 KDoc。 - RetryKeyMemoryTest: 既有用例补上时刻;新增 7 条覆盖「过期槽不复用且被淘汰」「TTL 边界」 「remember 刷新时间戳」「过期的 A 不影响新鲜的 B」「TTL 先于 FIFO 上限生效」。 --- .../yeij/cyrene/domain/chat/RetryKeyMemory.kt | 121 ++++++++-- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 23 +- .../cyrene/domain/chat/RetryKeyMemoryTest.kt | 217 ++++++++++++++---- 3 files changed, 295 insertions(+), 66 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt index 2173ef6..fc1ba12 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt @@ -1,7 +1,16 @@ package top.yeij.cyrene.domain.chat -/** 一次「没发出去」留下的记录:**那一条**的内容指纹 + 当时铸的幂等键(`localId`)。 */ -data class RetryKeySlot(val fingerprint: MessageFingerprint, val localId: String) +/** + * 一次「没发出去」留下的记录:**那一条**的内容指纹 + 当时铸的幂等键(`localId`)+ 记下它的时刻。 + * + * [atMillis] 只服务于 [RetryKeyMemory.SLOT_TTL_MILLIS] 那道时间闸,与键本身的语义无关:没有它,一条 + * 几小时前的失败记录会一直活着,用户**新**打的一句同内容的话就会被当成重试(见 [RetryKeyMemory])。 + */ +data class RetryKeySlot( + val fingerprint: MessageFingerprint, + val localId: String, + val atMillis: Long, +) /** * [RetryKeyMemory.keyFor] 的判定结果:这一次发送该用哪个幂等键,以及记忆变成什么样。 @@ -12,7 +21,7 @@ data class RetryKeySlot(val fingerprint: MessageFingerprint, val localId: String data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory) /** - * 「没发出去的那几条」的记忆——**多槽**、纯值、可单测。 + * 「没发出去的那几条」的记忆——**多槽**、**有时间上界**、纯值、可单测。 * * ## 它存在的理由 * 幂等键要想真的挡住重复,就必须在**同一条消息**的两次发送之间保持不变。而「某次失败」并不 @@ -22,6 +31,24 @@ data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory) * 让「一模一样的内容再发一次」落回同一个键,由服务端用 `chat_duplicate` / * `{duplicate:true,messageId}` 替本端裁决。 * + * ## 时间上界:10 分钟,为什么多槽必须配一个时钟 + * 记录是**跨很多次发送**存活的(这正是多槽的意义,见下面「为什么是多槽」),于是有了一个单槽时代 + * 不存在的窗口:用户**几小时前**失败过一句「你好」,之后**新**打一遍「你好」—— 他心里当的是一条 + * **新**消息,指纹却与那条旧记录一字不差 ⇒ [keyFor] 命中 ⇒ 复用旧键 ⇒ 服务端回 `chat_duplicate` + * ⇒ 这条新消息**根本不发出去**,屏幕上看着像「按了没反应」。给每个槽加一道时间闸 + * [SLOT_TTL_MILLIS],这个窗口就被钉死在 10 分钟内;而真正需要幂等的那一幕 ——「刚失败完,用户马上 + * 又按了一次发送」—— 一秒都没被削掉。 + * + * 时钟由调用方喂([remember] 的 `atMillis`、[keyFor] 的 `nowMillis`),纯值类型里**不藏** + * `System.currentTimeMillis()`:判据才可单测,也才不用为了「等 10 分钟」写 sleep。 + * + * ## 边界口径:正好等于 TTL **不算**过期 + * 判据是 `nowMillis - atMillis > SLOT_TTL_MILLIS`(严格大于),所以 `nowMillis == atMillis + TTL` + * 那一条**仍然命中**,真正过期要再晚一毫秒。选这一边的理由:常量自身就能当「仍在窗口内」的边界值 + * 直接断言(测试不必为「还活着」那一侧发明魔法数),过期那一侧才需要显式的 `+1`;而真实时钟几乎 + * 不可能精确落在这 1 毫秒上,口径选哪边都没有实害,可测的那边更有价值。测试 + * `a slot exactly at the TTL boundary still counts as fresh` 把这条钉住。 + * * ## 三条纪律 * 1. **只在回滚时写**([remember]:发送失败 / 队列满被拒 / 附件与图片上传失败 / 图片处理失败…): * 被受理的那条没有任何要记的东西。 @@ -36,12 +63,19 @@ data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory) * 「服务端其实已经收下」的那条 B 落成**第二条**消息,正是本机制要消灭的重复。多槽让每条失败各留 * 一个槽,用户重试哪一条就落回哪一条的键,与他的操作顺序无关。 * - * ## 上限与淘汰:FIFO,不是 LRU + * ## 上限与淘汰:条数上限 8 + 时间上界 10 分钟,两条规则同时生效 * 一条记录只在「用户可能还要重试」的窗口里有意义:失败后正文 / 图片 / 附件都还回了输入栏,他要么 * 马上原样重发、要么早就改了内容(那就成了另一条消息,记录永远命中不了)。窗口外留下的只是垃圾, - * 所以给一个小上界 [MAX_SLOTS],满了丢**最旧**的([remember] 里 FIFO)。刻意**不**做 LRU:命中 - * 并消费是**轮询**而不是访问 —— [keyFor] 把命中的那条整个摘走,与它有多旧无关,谈「最近使用」没有 - * 意义;也正因如此,剩下的槽天然还是写入顺序。 + * 所以有两把尺子:条数上限 [MAX_SLOTS](满了丢**最旧**的,[remember] 里 FIFO)与时间上界 + * [SLOT_TTL_MILLIS](比上限还旧的那条,无论排第几都作废)。 + * + * **顺序是死的**:[remember] / [keyFor] **先按时间淘汰**过期槽,再做自己那件事(去重追加 / 查命中), + * 写入时**最后**才看条数上限。先按时间清的好处是过期槽不占那份本该给新鲜记录的额度 —— 否则 8 个 + * 早已过期的槽会把刚写进来的那条挤出去(或者更糟:让 [keyFor] 在一个条数已满、内容却全过期的记忆里 + * 报「满了」)。 + * + * 刻意**不**做 LRU:命中并消费是**轮询**而不是访问 —— [keyFor] 把命中的那条整个摘走,与它有多旧 + * 无关(只要求没过期),谈「最近使用」没有意义;也正因如此,剩下的槽天然还是写入顺序。 * * ## 与「绝不自动重试」不矛盾 * 这里一个字节都不自动重发:复用键只发生在**用户自己又按了一次发送**、且内容一字不差的时候。 @@ -52,12 +86,20 @@ data class RetryKeyMemory(val slots: List = emptyList()) { /** * 记下「这一条没发出去」(回滚时调用)。 * - * 同一个指纹**先摘掉旧的、再把新的追加到末尾**:同一条又失败一次是**更新**它的键,而不是并列 - * 两条 —— 两条同指纹时 [keyFor] 只会命中更早那条,用户的重试反而用不上刚铸的键。超过 - * [MAX_SLOTS] 就从头部丢最旧的:本函数最多让**末尾**那条变新,淘汰因此绝不会伤到刚写的这条。 + * 三件事按固定顺序做,别调换(理由见类注释「上限与淘汰」): + * 1. **先按 [atMillis] 淘汰过期槽**——[atMillis] 既是这条记录的出生时刻,也是「现在」; + * 2. 同一个指纹**先摘掉旧的、再把新的追加到末尾**:同一条又失败一次是**更新**它的键与时刻, + * 而不是并列两条 —— 两条同指纹时 [keyFor] 只会命中更早那条,用户的重试反而用不上刚铸的键, + * 而且那条更早的还会**先**过期(明明刚失败过,记录却已经超时,重试退回铸新键); + * 3. 超过 [MAX_SLOTS] 就从头部丢最旧的:本函数最多让**末尾**那条变新,淘汰因此绝不会伤到刚写的 + * 这条。 + * + * @param atMillis 这次失败发生的时刻,由调用方喂(`System.currentTimeMillis()`)。 */ - fun remember(fingerprint: MessageFingerprint, localId: String): RetryKeyMemory { - val kept = slots.filterNot { it.fingerprint == fingerprint } + RetryKeySlot(fingerprint, localId) + fun remember(fingerprint: MessageFingerprint, localId: String, atMillis: Long): RetryKeyMemory { + val fresh = purge(atMillis) + val kept = fresh.slots.filterNot { it.fingerprint == fingerprint } + + RetryKeySlot(fingerprint, localId, atMillis) return RetryKeyMemory(if (kept.size > MAX_SLOTS) kept.takeLast(MAX_SLOTS) else kept) } @@ -67,6 +109,10 @@ data class RetryKeyMemory(val slots: List = emptyList()) { * 一条都对不上就**原样返回同一个实例**(一条也不清):槽里躺的多半是别的刚回滚的消息,顺手清掉 * 它们会让那几条的重试退回「铸新键」。同一个 `localId` 万一有多条([remember] 保证不会有), * 一并清掉:留着一条键已被服务端受理过的记录,只会让将来的某次重发被当成重复吞掉。 + * + * **与时间无关**:受理回执说的是「这条键服务端认了」,无论它记于几分钟前。此处也不顺手扫过期槽 + * —— 那要一个调用方没给的「现在」,而且过期槽本来就命中不了([keyFor] 入口会清掉它们),留着 + * 最多是几个死条目的内存占用。 */ fun forget(localId: String): RetryKeyMemory { val kept = slots.filterNot { it.localId == localId } @@ -74,25 +120,58 @@ data class RetryKeyMemory(val slots: List = emptyList()) { } /** - * 这一次发送该用哪个幂等键:内容与某条记录一致 ⇒ **复用**它的键,并**只消费那一条**(其余槽 - * 原样保留 —— 它们对应的是别的失败消息,正等着被各自的重试捡起来);一条都对不上 ⇒ 铸一个新的 - * ([mint] 由调用方给,测试才能喂确定性的键,也才能断言「该不该铸」),**且一条槽也不清**。 + * 这一次发送该用哪个幂等键:内容与某条**未过期**的记录一致 ⇒ **复用**它的键,并**只消费那一条** + * (其余槽原样保留 —— 它们对应的是别的失败消息,正等着被各自的重试捡起来);一条都对不上 ⇒ + * 铸一个新的([mint] 由调用方给,测试才能喂确定性的键,也才能断言「该不该铸」),**且一条槽也不清**。 + * + * 入口**先**按 [nowMillis] 淘汰过期槽,过期的那条**视同不存在**:找不到就铸新键,与「从来没记过」 + * 这条完全一样。这正是时间上界要挡住的那一幕(见类注释「时间上界」)—— 几小时前失败过的同内容 + * 消息,今天再打一遍就是一条**新**消息,绝不能因为撞上旧键而被服务端当成重复吞掉。 * * 最后这半句是与单槽时代最本质的差别,**别照着旧注释改回去**:那时记忆只有一条,「未命中就清空」 * 与「保留」是同一件事;多槽下清空会把用户**别的**失败记录一并抹掉(他重试 A 时铸了新键,接着去 * 重试 B,B 的槽已经没了 ⇒ 那次重发退化成非幂等)。未命中只说明「这次的正文没有任何失败记录对得 - * 上」,不是「记忆都作废了」。 + * 上」,不是「记忆都作废了」。(过期槽被摘掉不算例外:它们本来就再也命中不了。) + * + * @param nowMillis 这次发送的时刻,由调用方喂(`System.currentTimeMillis()`)。 */ - fun keyFor(fingerprint: MessageFingerprint, mint: () -> String): RetryKeyDecision { - val hitIndex = slots.indexOfFirst { it.fingerprint == fingerprint } - if (hitIndex < 0) return RetryKeyDecision(mint(), this) + fun keyFor( + fingerprint: MessageFingerprint, + nowMillis: Long, + mint: () -> String, + ): RetryKeyDecision { + val fresh = purge(nowMillis) + val hitIndex = fresh.slots.indexOfFirst { it.fingerprint == fingerprint } + if (hitIndex < 0) return RetryKeyDecision(mint(), fresh) - val consumed = RetryKeyMemory(slots.filterIndexed { index, _ -> index != hitIndex }) - return RetryKeyDecision(slots[hitIndex].localId, consumed) + val consumed = RetryKeyMemory(fresh.slots.filterIndexed { index, _ -> index != hitIndex }) + return RetryKeyDecision(fresh.slots[hitIndex].localId, consumed) + } + + /** + * 摘掉在 [nowMillis] 这一刻已经过期的槽;一条都没过期就**原样返回同一个实例**(不必新建对象)。 + * + * 过期判据见类注释「边界口径」——正好等于 [SLOT_TTL_MILLIS] 的那条**留着**。 + */ + private fun purge(nowMillis: Long): RetryKeyMemory { + val kept = slots.filterNot { it.isExpiredAt(nowMillis) } + return if (kept.size == slots.size) this else RetryKeyMemory(kept) } companion object { /** 同时保留的失败记录条数上限;淘汰策略见类注释「上限与淘汰」。 */ const val MAX_SLOTS = 8 + + /** + * 一条失败记录最多活多久(10 分钟):超过它,[keyFor] 就再也认不出这条记录。 + * + * 公开是因为**测试拿它做断言**(别在测试里重写一遍 `10 * 60 * 1000L`,那样改口径时测试不会 + * 跟着变)。为什么需要这道闸、边界怎么算,见类注释「时间上界」与「边界口径」。 + */ + const val SLOT_TTL_MILLIS = 10 * 60 * 1000L + + /** 这条记录在 [nowMillis] 这一刻是否已过期;`>` 而不是 `>=`,见类注释「边界口径」。 */ + private fun RetryKeySlot.isExpiredAt(nowMillis: Long): Boolean = + nowMillis - atMillis > SLOT_TTL_MILLIS } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 94eb552..a86a97f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -503,7 +503,9 @@ data class ChatUiState( * 幂等由 [MessageFingerprint] + [retryKey] 这一对承担:**回滚**时按内容指纹记下那条用过的 * `clientMsgId`,用户**自己**再按一次发送、且内容一字不差时复用它([emitAndDeliver] 是唯一的 * 消费者)——服务端据此判重(`chat_duplicate` / `{duplicate:true}`)而不是落成第二条消息; - * 内容改过(哪怕只多一个空格)就铸新键,因为那是另一条消息。**始终没有自动重试**: + * 内容改过(哪怕只多一个空格)就铸新键,因为那是另一条消息。记录**不是永久的**:每条只活 + * [RetryKeyMemory.SLOT_TTL_MILLIS](10 分钟,见那里「时间上界」)——多槽让记录跨很多次发送存活, + * 太久以前那条失败过的内容再打一遍是**新**消息,不能被当成重试吞掉。**始终没有自动重试**: * 键的复用只发生在用户手动重发的那一刻。 * * ## 单条消息的编辑 / 删除(D3) @@ -542,6 +544,12 @@ class ChatViewModel( * 刻意不清**:一条晚到的 `message_persisted` 恰恰是「服务端其实收下了、客户端当时判成失败」 * 的证据,这时候把键留着,用户接下来的重试才会被判重而不是落成第二条消息 —— 那正是本机制 * 存在的理由。 + * + * 记录**有时间上界**([RetryKeyMemory.SLOT_TTL_MILLIS] = 10 分钟):比这还旧的槽在这一次发送时 + * 视同不存在。没有这道闸,多槽会让几小时前失败过的记录一直活着,用户**新**打一遍同样的内容就 + * 被服务端当成重复吞掉(看屏幕就是「按了没反应」);而「刚失败完,用户马上再按一次发送」这个 + * 真正需要幂等的场景一秒都没被削掉。时间由上面两个调用点喂(`System.currentTimeMillis()`), + * 这个纯值类型里不藏时钟。 */ private var retryKey = RetryKeyMemory() @@ -844,7 +852,12 @@ class ChatViewModel( // 复用键会让服务端判重并回 `chat_duplicate` / `{duplicate:true}`,而不是落成第二条消息。 // 内容不同(改过字 / 换图 / 动了附件)则铸新键——用户的编辑是一条**新**消息。 // 消费只发生在命中那一支:命中的那条记录在这里被摘走,别的失败记录原样留着(见 [RetryKeyMemory])。 - val decision = retryKey.keyFor(MessageFingerprint.of(text, imageUri, documents)) { + // 「现在」由这里喂进去(纯值类型里不藏时钟):记下超过 `SLOT_TTL_MILLIS` 的那条已经不可复用、 + // 视同从来没记过——几小时前失败过同内容的消息,用户今天再打一遍是一条**新**消息。 + val decision = retryKey.keyFor( + MessageFingerprint.of(text, imageUri, documents), + System.currentTimeMillis(), + ) { "local-${UUID.randomUUID()}" } retryKey = decision.memory @@ -1172,7 +1185,10 @@ class ChatViewModel( } /** - * 把「这一条没发出去」记进 [retryKey](指纹 + 当时用的幂等键)。 + * 把「这一条没发出去」记进 [retryKey](指纹 + 当时用的幂等键 + 记下的**时刻**)。 + * + * 时刻由这里喂(`System.currentTimeMillis()`),它是 [RetryKeyMemory.SLOT_TTL_MILLIS] 那道时间闸 + * 的起算点:同内容再次失败会把时刻一起刷新,所以「记得早」不会让一条刚更新的记录提前过期。 * * 气泡**不在**待发列表里就什么都不记:那种情况下没有任何东西被回滚,记一条只会在下一次 * 发送时凭空复用别人的键。传 [pending] 而不是自己读 `_state`,是因为调用点必须能在这条 @@ -1183,6 +1199,7 @@ class ChatViewModel( retryKey = retryKey.remember( MessageFingerprint.of(failed.text, failed.imageUri, failed.documents), localId, + System.currentTimeMillis(), ) } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt index 80bf7be..fec2cde 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt @@ -11,7 +11,7 @@ import top.yeij.cyrene.ui.chat.QueueFullOutcome import top.yeij.cyrene.ui.chat.applyQueueFull /** - * 重试记忆:幂等键什么时候**复用**、什么时候**重铸**、什么时候**被清**。 + * 重试记忆:幂等键什么时候**复用**、什么时候**重铸**、什么时候**被清**、什么时候**已过期作废**。 * * 这是本模块要修的那条「文档与实现不符」的判据所在:`PendingMessage` 的 KDoc 一直写着「重试复用 * 同一个 id」,而实现每次发送都铸新键 —— 于是「服务端其实已经收下、客户端判成失败」(队列满 / @@ -23,7 +23,13 @@ import top.yeij.cyrene.ui.chat.applyQueueFull * 也要清空」是对的(那时只有一条),多槽下照抄会把用户**别的**失败记录一起抹掉,那些重发就 * 退化成非幂等; * 3. 只有这一条能清的清得掉、别的条清不动(否则几条同时在飞时会互相踩); - * 4. 满了按 FIFO 丢最旧的,**且绝不丢刚写进去的那条**。 + * 4. 满了按 FIFO 丢最旧的,**且绝不丢刚写进去的那条**; + * 5. 每条记录只在 [RetryKeyMemory.SLOT_TTL_MILLIS] 内有效 —— 多槽让记录跨很多次发送存活,没有这道 + * 时间闸,几小时前失败过的同一句话再打一遍就会被当成重试(服务端判重 ⇒ 新消息根本不出去)。 + * 过期槽**视同不存在**并被淘汰,而**正好等于 TTL 不算过期**(判据是严格大于,见 + * [RetryKeyMemory] 的「边界口径」与下面 `... exactly at the TTL boundary ...` 那条)。 + * + * 时间一律由测试喂进去([t0] 起算),所以判据确定、也不必为了「等 10 分钟」写 sleep。 */ class RetryKeyMemoryTest { @@ -35,6 +41,15 @@ class RetryKeyMemoryTest { fun next(): String = "new-${++count}" } + /** 失败发生的基准时刻;所有时间断言都相对它写,测试里不出现裸的「10 分钟」。 */ + private val t0 = 1_700_000_000_000L + + /** 刚好卡在边界上:`t0 + TTL` 这一毫秒**还没**过期。 */ + private val atTheBoundary = t0 + RetryKeyMemory.SLOT_TTL_MILLIS + + /** 过了边界一毫秒:从这一刻起,记于 [t0] 的槽作废。 */ + private val pastTheBoundary = atTheBoundary + 1 + private val text = MessageFingerprint.of("你好", null, emptyList()) private val edited = MessageFingerprint.of("你好!", null, emptyList()) @@ -44,10 +59,12 @@ class RetryKeyMemoryTest { private fun doc(uri: String) = DraftDocument(uri = uri, displayName = "a.txt", sizeBytes = 1L) /** 按失败发生的先后逐条 `remember`(与 `ChatViewModel.rollback` 的调用顺序一致)。 */ - private fun failed(vararg records: Pair): RetryKeyMemory = - records.fold(RetryKeyMemory()) { memory, (fingerprint, localId) -> - memory.remember(fingerprint, localId) - } + private fun failed( + vararg records: Pair, + atMillis: Long = t0, + ): RetryKeyMemory = records.fold(RetryKeyMemory()) { memory, (fingerprint, localId) -> + memory.remember(fingerprint, localId, atMillis) + } // —— 铸新键:没有对得上的记录时 —— @@ -55,7 +72,7 @@ class RetryKeyMemoryTest { fun `an empty memory mints a brand new key`() { val minter = KeyMinter() - val decision = RetryKeyMemory().keyFor(text, minter::next) + val decision = RetryKeyMemory().keyFor(text, t0, minter::next) assertEquals("new-1", decision.localId) assertEquals(1, minter.count) @@ -67,23 +84,26 @@ class RetryKeyMemoryTest { val memory = failed(text to "local-K1", other to "local-K2") val minter = KeyMinter() - val decision = memory.keyFor(edited, minter::next) + val decision = memory.keyFor(edited, t0, minter::next) // 未命中 ≠ 记忆作废:这里是「另一条消息」,不是「那两条记录都不算数了」。 assertEquals("new-1", decision.localId) assertEquals(1, minter.count) assertSame(memory, decision.memory) - assertEquals(listOf(RetryKeySlot(text, "local-K1"), RetryKeySlot(other, "local-K2")), decision.memory.slots) + assertEquals( + listOf(RetryKeySlot(text, "local-K1", t0), RetryKeySlot(other, "local-K2", t0)), + decision.memory.slots, + ) } // —— 复用:指纹一字不差 —— @Test fun `the same content sent again reuses the key that failed`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") + val remembered = RetryKeyMemory().remember(text, "local-K1", t0) val minter = KeyMinter() - val decision = remembered.keyFor(text, minter::next) + val decision = remembered.keyFor(text, t0, minter::next) // 键保住了,服务端才可能用 `chat_duplicate` / `{duplicate:true}` 认出这是同一条。 assertEquals("local-K1", decision.localId) @@ -93,7 +113,7 @@ class RetryKeyMemoryTest { @Test fun `only a fingerprint that matches exactly reuses the key`() { val original = MessageFingerprint.of("你好", "content://img/1", listOf(doc("content://doc/a"))) - val remembered = RetryKeyMemory().remember(original, "local-K1") + val remembered = RetryKeyMemory().remember(original, "local-K1", t0) // 三根轴各差一点点,都必须走「铸新键」这一支:正文多一个空格 / 换了图 / 少一个附件 / // 多一个附件。任何一条判成「同一条」,用户这次的改动就发不出去。 @@ -109,25 +129,25 @@ class RetryKeyMemoryTest { ) variants.forEachIndexed { index, variant -> val minter = KeyMinter() - assertEquals("new-1", remembered.keyFor(variant, minter::next).localId) + assertEquals("new-1", remembered.keyFor(variant, t0, minter::next).localId) assertEquals(1, minter.count) } // 对照组:原封不动的那条仍然复用(上面每一条的差别都真的来自内容)。 - assertEquals("local-K1", remembered.keyFor(original) { "new-x" }.localId) + assertEquals("local-K1", remembered.keyFor(original, t0) { "new-x" }.localId) } @Test fun `the retry that used the key consumes exactly that record`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") + val remembered = RetryKeyMemory().remember(text, "local-K1", t0) - val afterUse = remembered.keyFor(text) { "local-K1" }.memory + val afterUse = remembered.keyFor(text, t0) { "local-K1" }.memory assertTrue(afterUse.slots.isEmpty()) // 用户后来**又打了一遍**同样的话(上一条早就发出去了):那是真的第二条消息, // 必须铸新键 —— 记录要是留在这儿,这句话会被服务端当重复吞掉,屏幕上什么都不出现。 val minter = KeyMinter() - assertEquals("new-1", afterUse.keyFor(text, minter::next).localId) + assertEquals("new-1", afterUse.keyFor(text, t0, minter::next).localId) assertEquals(1, minter.count) } @@ -135,12 +155,15 @@ class RetryKeyMemoryTest { fun `consuming the hit leaves every other slot in place`() { val memory = failed(text to "local-K1", other to "local-K2", edited to "local-K3") - val afterHit = memory.keyFor(other) { "new-x" }.memory + val afterHit = memory.keyFor(other, t0) { "new-x" }.memory - assertEquals(listOf(RetryKeySlot(text, "local-K1"), RetryKeySlot(edited, "local-K3")), afterHit.slots) + assertEquals( + listOf(RetryKeySlot(text, "local-K1", t0), RetryKeySlot(edited, "local-K3", t0)), + afterHit.slots, + ) } - // —— 多槽:各自失败、各自复用(本次改动的核心) —— + // —— 多槽:各自失败、各自复用 —— @Test fun `two failures each keep their own key however the user retries them`() { @@ -148,10 +171,10 @@ class RetryKeyMemoryTest { val minter = KeyMinter() // 用户的重试顺序不必和失败顺序一致:先重试更早失败的 `text`,同样落回**它自己**的键。 - val afterText = memory.keyFor(text, minter::next) + val afterText = memory.keyFor(text, t0, minter::next) assertEquals("local-K1", afterText.localId) - val afterOther = afterText.memory.keyFor(other, minter::next) + val afterOther = afterText.memory.keyFor(other, t0, minter::next) assertEquals("local-K2", afterOther.localId) assertEquals(0, minter.count) } @@ -161,10 +184,10 @@ class RetryKeyMemoryTest { val memory = failed(text to "local-K1", other to "local-K2") val minter = KeyMinter() - val afterOther = memory.keyFor(other, minter::next) + val afterOther = memory.keyFor(other, t0, minter::next) assertEquals("local-K2", afterOther.localId) - assertEquals("local-K1", afterOther.memory.keyFor(text, minter::next).localId) + assertEquals("local-K1", afterOther.memory.keyFor(text, t0, minter::next).localId) assertEquals(0, minter.count) } @@ -172,32 +195,122 @@ class RetryKeyMemoryTest { fun `retrying one failed message does not wipe the other slot`() { val memory = failed(text to "local-K1", other to "local-K2") - val afterRetry = memory.keyFor(text) { "new-1" }.memory + val afterRetry = memory.keyFor(text, t0) { "new-1" }.memory // 单槽时代这一步会清空整份记忆 ⇒ 之后重试 B 只能铸新键,而 B 那次失败完全可能是 // 「服务端其实已经收下、客户端判成失败」:那次重发就落成了第二条消息,正是本机制要消灭的重复。 - assertEquals(listOf(RetryKeySlot(other, "local-K2")), afterRetry.slots) - assertEquals("local-K2", afterRetry.keyFor(other, KeyMinter()::next).localId) + assertEquals(listOf(RetryKeySlot(other, "local-K2", t0)), afterRetry.slots) + assertEquals("local-K2", afterRetry.keyFor(other, t0, KeyMinter()::next).localId) } @Test fun `remembering the same content again updates its key instead of adding a second slot`() { // 同一条(指纹相同)又失败一次:键换成新的,槽还是那一个(并列两条只会命中更早那条, // 用户的重试反而用不上刚铸的键)。 - val memory = failed(text to "local-K1", other to "local-K2").remember(text, "local-K1b") + val memory = failed(text to "local-K1", other to "local-K2").remember(text, "local-K1b", t0) - assertEquals(listOf(RetryKeySlot(other, "local-K2"), RetryKeySlot(text, "local-K1b")), memory.slots) - assertEquals("local-K1b", memory.keyFor(text, KeyMinter()::next).localId) + assertEquals( + listOf(RetryKeySlot(other, "local-K2", t0), RetryKeySlot(text, "local-K1b", t0)), + memory.slots, + ) + assertEquals("local-K1b", memory.keyFor(text, t0, KeyMinter()::next).localId) + } + + // —— 时间上界:过期槽视同不存在(本次改动的核心) —— + + @Test + fun `a slot older than the TTL is not reused and gets dropped`() { + val remembered = RetryKeyMemory().remember(text, "local-K1", t0) + val minter = KeyMinter() + + val decision = remembered.keyFor(text, pastTheBoundary, minter::next) + + // 用户几小时前失败过一句「你好」,今天**新**打一遍:在他心里那是另一条消息。复用旧键会让 + // 服务端回 `duplicate` 把这条新消息吞掉 —— 屏幕上什么都不出现,看着像「按了没反应」。 + assertEquals("new-1", decision.localId) + assertEquals(1, minter.count) + // 淘汰是顺带的:作废的那条不留下占额度(下一次 [RetryKeyMemory.MAX_SLOTS] 满了时它不该在场)。 + assertTrue(decision.memory.slots.isEmpty()) + } + + @Test + fun `a slot exactly at the TTL boundary still counts as fresh`() { + val remembered = RetryKeyMemory().remember(text, "local-K1", t0) + val minter = KeyMinter() + + // 边界口径钉在这里:判据是 `now - atMillis > TTL`(**严格大于**),所以正好等于 TTL 的这一 + // 毫秒**仍然**命中,过期要再晚一毫秒(上一条)。选这一边是因为常量自身就是一条可直接断言的 + // 「还活着」的边界值,不必为它发明魔法数;真实时钟也几乎不可能精确落在这一毫秒上。 + val decision = remembered.keyFor(text, atTheBoundary, minter::next) + + assertEquals("local-K1", decision.localId) + assertEquals(0, minter.count) + } + + @Test + fun `a lookup that expires nothing leaves the memory instance untouched`() { + val memory = failed(text to "local-K1") + + // 未过期的那条不该因为查了一次就被重写:淘汰只在真的有时过期的改写这份记忆。 + assertSame(memory, memory.keyFor(edited, atTheBoundary, KeyMinter()::next).memory) + assertSame(memory, memory.forget("local-other")) + } + + @Test + fun `an expired slot does not cost a fresh one its key`() { + // A 记在 [t0](马上就过期),B 记在很久以后(新鲜)—— 两条同处一份记忆。 + val memory = failed(text to "local-K1").remember(other, "local-K2", pastTheBoundary) + + // 命中新鲜的那条:A 被顺手淘汰,B 照常给出自己的键、并被消费掉。 + val hitB = memory.keyFor(other, pastTheBoundary, KeyMinter()::next) + assertEquals("local-K2", hitB.localId) + assertTrue(hitB.memory.slots.isEmpty()) + + // 同一时刻问那条过期的 A:结果是**新键**(与「从来没记过」一样),而 B 的槽原样留着。 + val minter = KeyMinter() + val missA = memory.keyFor(text, pastTheBoundary, minter::next) + assertEquals("new-1", missA.localId) + assertEquals(1, minter.count) + assertEquals(listOf(RetryKeySlot(other, "local-K2", pastTheBoundary)), missA.memory.slots) + } + + @Test + fun `remembering a new failure drops the ones that have expired`() { + val memory = RetryKeyMemory().remember(text, "local-K1", t0) + + val later = memory.remember(other, "local-K2", pastTheBoundary) + + // 写入口也先按时间清:过期槽一个都不留,新写的那条马上就认得出自己的键。 + assertEquals(listOf(RetryKeySlot(other, "local-K2", pastTheBoundary)), later.slots) + assertEquals("local-K2", later.keyFor(other, pastTheBoundary, KeyMinter()::next).localId) + } + + @Test + fun `remembering the same content again refreshes its timestamp`() { + val firstFailure = RetryKeyMemory().remember(text, "local-K1", t0) + + // 用户 7.5 分钟后重试、又失败一次:同一条消息(指纹相同)**键与时刻一起**更新。 + val secondFailureAt = t0 + RetryKeyMemory.SLOT_TTL_MILLIS * 3 / 4 + val refreshed = firstFailure.remember(text, "local-K1b", secondFailureAt) + + // 只有一条槽:并列两条只会让 `keyFor` 命中最旧那条,而它还会**先**过期。 + assertEquals(listOf(RetryKeySlot(text, "local-K1b", secondFailureAt)), refreshed.slots) + + // 关键一刻:此时距**第一次**失败早已超过 TTL,距**更新**才过了 TTL/4。刚记下的失败必须还能 + // 复用 —— 若时间戳不刷新,用户紧接着的重试会铸新键,「服务端其实已收下」的那条就落成第二条。 + val minter = KeyMinter() + assertEquals("local-K1b", refreshed.keyFor(text, pastTheBoundary, minter::next).localId) + assertEquals(0, minter.count) } // —— 编辑:重铸但不迁怒别的槽 —— @Test fun `an edit mints a new key and keeps the failed record`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") + val remembered = RetryKeyMemory().remember(text, "local-K1", t0) val minter = KeyMinter() - val decision = remembered.keyFor(edited, minter::next) + val decision = remembered.keyFor(edited, t0, minter::next) // 改过字就是**另一条**消息:复用旧键会让服务端判重,用户这次的编辑一个字都发不出去。 assertEquals("new-1", decision.localId) @@ -208,14 +321,14 @@ class RetryKeyMemoryTest { @Test fun `an edit does not cost the original text its key`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") + val remembered = RetryKeyMemory().remember(text, "local-K1", t0) // 「改了再发」这一步不清槽 ⇒ 之后发回原来那句话仍然拿得回旧键。这是「未命中不清」的代价: // 单槽时代的测试在这里断言的是「铸新键」,多槽下那条断言**不再成立**,也不是我们想要的。 - val afterEdit = remembered.keyFor(edited) { "new-1" }.memory + val afterEdit = remembered.keyFor(edited, t0) { "new-1" }.memory val minter = KeyMinter() - assertEquals("local-K1", afterEdit.keyFor(text, minter::next).localId) + assertEquals("local-K1", afterEdit.keyFor(text, t0, minter::next).localId) assertEquals(0, minter.count) } @@ -229,22 +342,42 @@ class RetryKeyMemoryTest { } val memory = records.fold(RetryKeyMemory()) { acc, (fingerprint, localId) -> - acc.remember(fingerprint, localId) + acc.remember(fingerprint, localId, t0) } assertEquals(RetryKeyMemory.MAX_SLOTS, memory.slots.size) assertEquals( - records.takeLast(RetryKeyMemory.MAX_SLOTS).map { RetryKeySlot(it.first, it.second) }, + records.takeLast(RetryKeyMemory.MAX_SLOTS).map { RetryKeySlot(it.first, it.second, t0) }, memory.slots, ) // 刚写进去的那条一定在(淘汰只可能从头开始),还认得出自己的键。 val minter = KeyMinter() - assertEquals("local-K${count - 1}", memory.keyFor(records.last().first, minter::next).localId) + assertEquals("local-K${count - 1}", memory.keyFor(records.last().first, t0, minter::next).localId) assertEquals(0, minter.count) // 被挤出去的那条就只剩「铸新键」了:它的键确实已经不在记忆里。 - assertEquals("new-1", memory.keyFor(records.first().first, minter::next).localId) + assertEquals("new-1", memory.keyFor(records.first().first, t0, minter::next).localId) + } + + @Test + fun `the TTL is applied before the FIFO cap counts`() { + // 8 条全记在 [t0],正好顶到条数上限(上限在 TTL 之内依然生效)。 + val records = List(RetryKeyMemory.MAX_SLOTS) { index -> + MessageFingerprint.of("第 $index 条", null, emptyList()) to "local-K$index" + } + val full = records.fold(RetryKeyMemory()) { acc, (fingerprint, localId) -> + acc.remember(fingerprint, localId, t0) + } + assertEquals(RetryKeyMemory.MAX_SLOTS, full.slots.size) + + // 过期那一刻再写一条新的。 + val afterExpiry = full.remember(edited, "local-Knew", pastTheBoundary) + + // 顺序是「先按时间清、写入时最后才看条数」:8 个过期槽一个都不留,新写的这条**不**会被它们 + // 挤出上限(反过来先砍条数的话,这里会留下 7 个死条目 + 新的那条,额度白占)。 + assertEquals(listOf(RetryKeySlot(edited, "local-Knew", pastTheBoundary)), afterExpiry.slots) + assertEquals("local-Knew", afterExpiry.keyFor(edited, pastTheBoundary, KeyMinter()::next).localId) } // —— 清:只在无歧义时清得动 —— @@ -253,7 +386,7 @@ class RetryKeyMemoryTest { fun `forget clears exactly the message that settled`() { val memory = failed(text to "local-K1", other to "local-K2") - assertEquals(listOf(RetryKeySlot(other, "local-K2")), memory.forget("local-K1").slots) + assertEquals(listOf(RetryKeySlot(other, "local-K2", t0)), memory.forget("local-K1").slots) } @Test @@ -272,8 +405,8 @@ class RetryKeyMemoryTest { fun `a retried message the server already has collapses into the single persisted bubble`() { // 全链路:失败时记住键 ⇒ 用户按发送复用它 ⇒ 服务端按同一个键判重 ⇒ 客户端只摘待发气泡, // 由**原来那条**落库消息顶上(不再冒出第二个气泡,也不再另造一套对齐逻辑)。 - val remembered = RetryKeyMemory().remember(text, "local-K1") - val retryKey = remembered.keyFor(text) { "new-1" }.localId + val remembered = RetryKeyMemory().remember(text, "local-K1", t0) + val retryKey = remembered.keyFor(text, t0) { "new-1" }.localId assertEquals("local-K1", retryKey) val persisted = HistoryMessageDto(