feat(chat): 重试记忆的槽加 10 分钟时间上界 —— 别把用户新打的同内容当成重试
多槽让失败记录跨很多次发送存活,于是开了个单槽时代没有的窗口:用户几小时前失败过一句 「你好」,之后新打一遍「你好」(心里当的是一条新消息),指纹却与那条旧记录一字不差, 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 上限生效」。
This commit is contained in:
1 parent
7a6f6b0863
commit
8938fffd08
3 files changed
+295
-66
No files matched your search
@@ -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<MessageFingerprint, String>): RetryKeyMemory =
|
||||
records.fold(RetryKeyMemory()) { memory, (fingerprint, localId) ->
|
||||
memory.remember(fingerprint, localId)
|
||||
}
|
||||
private fun failed(
|
||||
vararg records: Pair<MessageFingerprint, String>,
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user