feat(chat): 重试记忆改多槽 —— 每条失败各留自己的幂等键
RetryKeyMemory 从单槽改成多槽(slots: List<RetryKeySlot>,上限 MAX_SLOTS = 8,FIFO 淘汰最旧)。 单槽只记得最近一次失败那条,而用户的重试顺序不必和失败顺序一致:他重试 A 又失败、再回头去重试 更早失败的 B 时,B 的槽早被顶掉,那次重发只能铸新键 —— 而 B 那次失败完全可能是「服务端已经收下、 客户端没看见」,于是又落成第二条消息,正是本机制要消灭的重复。 - remember:同指纹先摘掉旧的、再追加到末尾(同一条又失败一次是更新它的键,不是并列两条), 超过上限从头部丢最旧的,且刚写进去的那条一定留得住。 - forget:按 localId 清;一条都对不上就原样返回同一个实例;同 localId 万一有多条则一并清掉。 - keyFor:命中只消费那一条、其余槽全部保留;未命中铸新键且一条槽也不清 —— 单槽时代「未命中也要 清空」在多槽下会把别的失败记录一起抹掉,这条已显式写进 KDoc 以防后人照着旧注释改回去。 - ChatViewModel:代码逻辑一个字节没动(三个调用点的签名都没变),只把提到「单槽」「下一次发送就 被消费」的 KDoc 改成与新实现一致;值比较指纹、正文不归一、晚到的 message_persisted 刻意不清键 这几条 W15 纪律原样保留。 - 测试:RetryKeyMemoryTest 由 10 条重写为 16 条。删掉「未命中即清空记忆」那两条旧断言(新语义下 不再成立),新增:两条失败各自原样重试都落回自己的键(两种重试顺序各一条)、重试 A 不抹掉 B 的槽、 同指纹重复 remember 只留一条且键是最新的、超上限 FIFO 淘汰最旧且刚写的仍在、未命中时全部槽保留、 命中消费后其余槽保留。 验证:assembleDebug 与 testDebugUnitTest 均通过(--rerun-tasks 全量重跑):760 → 766 测试 / 0 失败 / 0 错误。
This commit is contained in:
1 parent
b579732db6
commit
d3fc0c8005
3 files changed
+231
-100
No files matched your search
@@ -1,7 +1,6 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -12,16 +11,19 @@ import top.yeij.cyrene.ui.chat.QueueFullOutcome
|
||||
import top.yeij.cyrene.ui.chat.applyQueueFull
|
||||
|
||||
/**
|
||||
* 重试记忆:幂等键什么时候**复用**、什么时候**重铸**。
|
||||
* 重试记忆:幂等键什么时候**复用**、什么时候**重铸**、什么时候**被清**。
|
||||
*
|
||||
* 这是本模块要修的那条「文档与实现不符」的判据所在:`PendingMessage` 的 KDoc 一直写着「重试复用
|
||||
* 同一个 id」,而实现每次发送都铸新键 —— 于是「服务端其实已经收下、客户端判成失败」(队列满 /
|
||||
* 超时 / 回执丢失)之后,用户的手动重试会落成**第二条**消息。
|
||||
*
|
||||
* 这里把三条纪律逐条钉死,它们**缺一不可**:
|
||||
* 记忆是**多槽**的,下面这几条纪律**缺一不可**:
|
||||
* 1. 指纹相等 ⇒ 复用;不相等 ⇒ 重铸(否则用户的编辑会被服务端判重丢掉);
|
||||
* 2. 记下的键**用掉就没了**(否则他后来又打了一遍同样的话,会被无声吞掉);
|
||||
* 3. 只有这一条能清的清得掉、别的条清不动(否则两条同时在飞时会互相踩)。
|
||||
* 2. 复用时**只消费命中的那一条**,其余槽原样保留;未命中则**一条槽也不清** —— 单槽时代「未命中
|
||||
* 也要清空」是对的(那时只有一条),多槽下照抄会把用户**别的**失败记录一起抹掉,那些重发就
|
||||
* 退化成非幂等;
|
||||
* 3. 只有这一条能清的清得掉、别的条清不动(否则几条同时在飞时会互相踩);
|
||||
* 4. 满了按 FIFO 丢最旧的,**且绝不丢刚写进去的那条**。
|
||||
*/
|
||||
class RetryKeyMemoryTest {
|
||||
|
||||
@@ -37,9 +39,17 @@ class RetryKeyMemoryTest {
|
||||
|
||||
private val edited = MessageFingerprint.of("你好!", null, emptyList())
|
||||
|
||||
private val other = MessageFingerprint.of("另一个问题", null, emptyList())
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// —— 铸新键:没有对得上的记录时 ——
|
||||
|
||||
@Test
|
||||
fun `an empty memory mints a brand new key`() {
|
||||
@@ -49,7 +59,21 @@ class RetryKeyMemoryTest {
|
||||
|
||||
assertEquals("new-1", decision.localId)
|
||||
assertEquals(1, minter.count)
|
||||
assertNull(decision.memory.slot)
|
||||
assertTrue(decision.memory.slots.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a brand new message mints a key without touching any slot`() {
|
||||
val memory = failed(text to "local-K1", other to "local-K2")
|
||||
val minter = KeyMinter()
|
||||
|
||||
val decision = memory.keyFor(edited, 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)
|
||||
}
|
||||
|
||||
// —— 复用:指纹一字不差 ——
|
||||
@@ -66,47 +90,6 @@ class RetryKeyMemoryTest {
|
||||
assertEquals(0, minter.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `using the remembered key consumes it`() {
|
||||
val remembered = RetryKeyMemory().remember(text, "local-K1")
|
||||
|
||||
val afterUse = remembered.keyFor(text) { "local-K1" }.memory
|
||||
assertNull(afterUse.slot)
|
||||
|
||||
// 用户后来**又打了一遍**同样的话(上一条早就发出去了):那是真的第二条消息,
|
||||
// 必须铸新键 —— 记录要是留在这儿,这句话会被服务端当重复吞掉,屏幕上什么都不出现。
|
||||
val minter = KeyMinter()
|
||||
assertEquals("new-1", afterUse.keyFor(text, minter::next).localId)
|
||||
assertEquals(1, minter.count)
|
||||
}
|
||||
|
||||
// —— 重铸:用户改过 ——
|
||||
|
||||
@Test
|
||||
fun `editing the text mints a new key and drops the record`() {
|
||||
val remembered = RetryKeyMemory().remember(text, "local-K1")
|
||||
val minter = KeyMinter()
|
||||
|
||||
val decision = remembered.keyFor(edited, minter::next)
|
||||
|
||||
// 改过字就是**另一条**消息:复用旧键会让服务端判重,用户这次的编辑一个字都发不出去。
|
||||
assertEquals("new-1", decision.localId)
|
||||
assertEquals(1, minter.count)
|
||||
assertNull(decision.memory.slot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit followed by re-sending the original text mints yet another key`() {
|
||||
val remembered = RetryKeyMemory().remember(text, "local-K1")
|
||||
|
||||
// 「改了再发」这一步把记录消费掉了 ⇒ 之后发回原来那句话也拿不回旧键。
|
||||
val afterEdit = remembered.keyFor(edited) { "new-1" }.memory
|
||||
|
||||
val minter = KeyMinter()
|
||||
assertEquals("new-1", afterEdit.keyFor(text, minter::next).localId)
|
||||
assertEquals(1, minter.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a fingerprint that matches exactly reuses the key`() {
|
||||
val original = MessageFingerprint.of("你好", "content://img/1", listOf(doc("content://doc/a")))
|
||||
@@ -134,35 +117,153 @@ class RetryKeyMemoryTest {
|
||||
assertEquals("local-K1", remembered.keyFor(original) { "new-x" }.localId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the retry that used the key consumes exactly that record`() {
|
||||
val remembered = RetryKeyMemory().remember(text, "local-K1")
|
||||
|
||||
val afterUse = remembered.keyFor(text) { "local-K1" }.memory
|
||||
assertTrue(afterUse.slots.isEmpty())
|
||||
|
||||
// 用户后来**又打了一遍**同样的话(上一条早就发出去了):那是真的第二条消息,
|
||||
// 必须铸新键 —— 记录要是留在这儿,这句话会被服务端当重复吞掉,屏幕上什么都不出现。
|
||||
val minter = KeyMinter()
|
||||
assertEquals("new-1", afterUse.keyFor(text, minter::next).localId)
|
||||
assertEquals(1, minter.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
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
|
||||
|
||||
assertEquals(listOf(RetryKeySlot(text, "local-K1"), RetryKeySlot(edited, "local-K3")), afterHit.slots)
|
||||
}
|
||||
|
||||
// —— 多槽:各自失败、各自复用(本次改动的核心) ——
|
||||
|
||||
@Test
|
||||
fun `two failures each keep their own key however the user retries them`() {
|
||||
val memory = failed(text to "local-K1", other to "local-K2")
|
||||
val minter = KeyMinter()
|
||||
|
||||
// 用户的重试顺序不必和失败顺序一致:先重试更早失败的 `text`,同样落回**它自己**的键。
|
||||
val afterText = memory.keyFor(text, minter::next)
|
||||
assertEquals("local-K1", afterText.localId)
|
||||
|
||||
val afterOther = afterText.memory.keyFor(other, minter::next)
|
||||
assertEquals("local-K2", afterOther.localId)
|
||||
assertEquals(0, minter.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying the later failure first still finds the earlier one`() {
|
||||
val memory = failed(text to "local-K1", other to "local-K2")
|
||||
val minter = KeyMinter()
|
||||
|
||||
val afterOther = memory.keyFor(other, minter::next)
|
||||
assertEquals("local-K2", afterOther.localId)
|
||||
|
||||
assertEquals("local-K1", afterOther.memory.keyFor(text, minter::next).localId)
|
||||
assertEquals(0, minter.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
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
|
||||
|
||||
// 单槽时代这一步会清空整份记忆 ⇒ 之后重试 B 只能铸新键,而 B 那次失败完全可能是
|
||||
// 「服务端其实已经收下、客户端判成失败」:那次重发就落成了第二条消息,正是本机制要消灭的重复。
|
||||
assertEquals(listOf(RetryKeySlot(other, "local-K2")), afterRetry.slots)
|
||||
assertEquals("local-K2", afterRetry.keyFor(other, 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")
|
||||
|
||||
assertEquals(listOf(RetryKeySlot(other, "local-K2"), RetryKeySlot(text, "local-K1b")), memory.slots)
|
||||
assertEquals("local-K1b", memory.keyFor(text, KeyMinter()::next).localId)
|
||||
}
|
||||
|
||||
// —— 编辑:重铸但不迁怒别的槽 ——
|
||||
|
||||
@Test
|
||||
fun `an edit mints a new key and keeps the failed record`() {
|
||||
val remembered = RetryKeyMemory().remember(text, "local-K1")
|
||||
val minter = KeyMinter()
|
||||
|
||||
val decision = remembered.keyFor(edited, minter::next)
|
||||
|
||||
// 改过字就是**另一条**消息:复用旧键会让服务端判重,用户这次的编辑一个字都发不出去。
|
||||
assertEquals("new-1", decision.localId)
|
||||
assertEquals(1, minter.count)
|
||||
// 但记忆不因此作废 —— 未命中只说明这次的内容没有记录对得上(见类注释第 2 条)。
|
||||
assertSame(remembered, decision.memory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit does not cost the original text its key`() {
|
||||
val remembered = RetryKeyMemory().remember(text, "local-K1")
|
||||
|
||||
// 「改了再发」这一步不清槽 ⇒ 之后发回原来那句话仍然拿得回旧键。这是「未命中不清」的代价:
|
||||
// 单槽时代的测试在这里断言的是「铸新键」,多槽下那条断言**不再成立**,也不是我们想要的。
|
||||
val afterEdit = remembered.keyFor(edited) { "new-1" }.memory
|
||||
|
||||
val minter = KeyMinter()
|
||||
assertEquals("local-K1", afterEdit.keyFor(text, minter::next).localId)
|
||||
assertEquals(0, minter.count)
|
||||
}
|
||||
|
||||
// —— 上限:FIFO 丢最旧,刚写的必须留 ——
|
||||
|
||||
@Test
|
||||
fun `more than MAX_SLOTS failures drop the oldest and keep the newest`() {
|
||||
val count = RetryKeyMemory.MAX_SLOTS + 3
|
||||
val records = List(count) { index ->
|
||||
MessageFingerprint.of("第 $index 条", null, emptyList()) to "local-K$index"
|
||||
}
|
||||
|
||||
val memory = records.fold(RetryKeyMemory()) { acc, (fingerprint, localId) ->
|
||||
acc.remember(fingerprint, localId)
|
||||
}
|
||||
|
||||
assertEquals(RetryKeyMemory.MAX_SLOTS, memory.slots.size)
|
||||
assertEquals(
|
||||
records.takeLast(RetryKeyMemory.MAX_SLOTS).map { RetryKeySlot(it.first, it.second) },
|
||||
memory.slots,
|
||||
)
|
||||
|
||||
// 刚写进去的那条一定在(淘汰只可能从头开始),还认得出自己的键。
|
||||
val minter = KeyMinter()
|
||||
assertEquals("local-K${count - 1}", memory.keyFor(records.last().first, minter::next).localId)
|
||||
assertEquals(0, minter.count)
|
||||
|
||||
// 被挤出去的那条就只剩「铸新键」了:它的键确实已经不在记忆里。
|
||||
assertEquals("new-1", memory.keyFor(records.first().first, minter::next).localId)
|
||||
}
|
||||
|
||||
// —— 清:只在无歧义时清得动 ——
|
||||
|
||||
@Test
|
||||
fun `forget clears exactly the message that settled`() {
|
||||
val remembered = RetryKeyMemory().remember(text, "local-K1")
|
||||
val memory = failed(text to "local-K1", other to "local-K2")
|
||||
|
||||
assertNull(remembered.forget("local-K1").slot)
|
||||
assertEquals(listOf(RetryKeySlot(other, "local-K2")), memory.forget("local-K1").slots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forget leaves another message's key alone`() {
|
||||
val remembered = RetryKeyMemory().remember(text, "local-K1")
|
||||
val memory = failed(text to "local-K1", other to "local-K2")
|
||||
|
||||
// 两条同时在飞时,一条被受理(这里键是 local-other)不该把另一条刚回滚的记忆抹掉:
|
||||
// 抹掉它,那条的重试就退回「铸新键」,正是本模块要消灭的重复。
|
||||
assertSame(remembered, remembered.forget("local-other"))
|
||||
assertEquals(RetryKeySlot(text, "local-K1"), remembered.forget("local-other").slot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remembering a later failure replaces the earlier one`() {
|
||||
// 单槽:记忆只对「用户下一次按发送」有意义,而那一次只可能是最近一次失败的那条。
|
||||
val memory = RetryKeyMemory()
|
||||
.remember(text, "local-K1")
|
||||
.remember(edited, "local-K2")
|
||||
|
||||
assertEquals(RetryKeySlot(edited, "local-K2"), memory.slot)
|
||||
// 更早那条已经不在输入栏里了,它的键也就跟着作废。
|
||||
assertEquals("new-1", memory.keyFor(text) { "new-1" }.localId)
|
||||
// 几条同时在飞时,一条被受理(这里键是 local-other)不该把别条刚回滚的记忆抹掉:
|
||||
// 抹掉它们,那几条的重试就退回「铸新键」,正是本模块要消灭的重复。
|
||||
assertSame(memory, memory.forget("local-other"))
|
||||
assertEquals(2, memory.forget("local-other").slots.size)
|
||||
}
|
||||
|
||||
// —— 与气泡对齐的衔接:重试真的撞上原来那条 ——
|
||||
|
||||
Reference in new issue
Block a user