From db53de1a7ee729e74dc4c1e162765cc46a030ed3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 23:21:15 +0800 Subject: [PATCH] =?UTF-8?q?W77=20=E5=A2=9E=E9=87=8F=E8=BF=BD=E5=B0=BE?= =?UTF-8?q?=EF=BC=9A=E6=BB=A1=E9=A1=B5=E4=B9=8B=E5=90=8E=E7=9A=84=E7=A9=BA?= =?UTF-8?q?=E9=A1=B5=E4=B8=8D=E5=86=8D=E5=BD=93=E4=BD=9C=E5=B0=BE=E5=B7=B4?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E8=AF=BB=E5=9F=BA=E6=95=B0=E7=9A=84=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E5=A6=82=E5=AE=9E=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 判了两条 P2,都只动 W77 这一摊。 1) `loadIncrement` 里 `newest <= cursor` 那条同毫秒防线是死代码。服务端的 `after` 是 **严格** `>`(`list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 一致),返回的 每一行都 `createdAt > cursor`,那个分支永不触发。真实风险在别处:满页恰好把同一毫秒劈开 时,下一次 `after=newest` 只会回空页,旧代码把那次空页当成「追到了尾巴」⇒ 静默少给几条 且不回落。改成「拿过一整页之后紧跟的空页 ⇒ `complete = false`」:客户端分不出干净尾巴与 这个洞,就如实说没追完,把决定权交回调用方(未读面上是回落整表)。第一页就空仍算干净 尾巴(没有可少给的),不满一页仍算追到尾巴,翻满预算仍算没追完。 连带把测试改成真后端的形状:满页后空页那条的期望从 `complete = true` 改成 `false`;同 毫秒那条不再让假后端把同一批行回两次(严格 `>` 下后端给不出这种响应),改成「三条同毫秒、 页宽 2、第二页空」并断言第二次请求真的带 `after=500`;新增「第一页空页 ⇒ `complete = true`」把新逻辑的另一半钉住。KDoc 里「再翻也只会拿到同一批行」这类与契约矛盾的措辞一并 重写。 2) `applyIncrement` 的 `base + unseen` 在断线期间他端已读时会多计:`session_read` 帧永久 丢,`read_at` 又是账号级游标。代码行为不动,只把措辞限定清楚 ——「同源」的只是**过滤口径** (`count(created_at > read_at)`),基数仍是上次整表的旧值,跨端读会让它漂移,由下一次整表 刷新自愈。这是**已接受的边界**,KDoc 写明了,另加一条测试把它钉住 (`a read elsewhere while offline over-counts until the next whole table`),免得日后被 当成「已修好」。 不装「`readAt > from` ⇒ 回落整表」的保守闸:本端刚点开过该会话时 `readAt` 本来就被推到 「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量路径在正常 使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门不如 不装,理由写在 KDoc 里。 顺手把 `SessionItem.lastMessageAt` 与 `flushPendingIncrements` 里过时的注释改成新口径 (它现在同时是增量追尾的 `after` 游标)。 验证:`./gradlew.bat testDebugUnitTest assembleDebug` 全绿 —— 135 个 suite / 1691 条 (比上轮 +2)/ 0 失败 / 0 跳过,`assembleDebug` 打包成功。 --- .../data/repository/MessageRepository.kt | 39 ++++++++++++++----- .../cyrene/ui/sessions/InactiveSessionPull.kt | 20 ++++++++-- .../cyrene/ui/sessions/SessionsViewModel.kt | 10 +++-- .../MessageIncrementRepositoryTest.kt | 37 +++++++++++++----- .../ui/sessions/SessionIncrementTest.kt | 23 ++++++++++- 5 files changed, 101 insertions(+), 28 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt index 4334439..0180592 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt @@ -88,7 +88,9 @@ class MessageRepository( * `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。 * * 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)—— - * 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。 + * 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id` + * 去重,并且**不能把「满页之后的空页」当作追到了尾巴**:要翻页又要把边界说清楚,用 + * [loadIncrement],不要自己拿这一页凑。 */ suspend fun loadAfter( sessionId: String, @@ -104,10 +106,24 @@ class MessageRepository( /** * **增量追尾**(W77):从 [after] 起把某会话的新消息一直翻到尾巴,最多 [maxPages] 页。 * - * 与裸的 [loadAfter] 的差别只有一件:**它负责把「还没翻完」这件事如实说出来**。`after` 方向的 - * 游标是本页**最新**一条的 `createdAt`,同毫秒并列超过一页时纯 `createdAt` 游标推不动 - * (契约 §A5 明说的已知边界),再翻也只会拿到同一批行——那种情况返回 `complete = false`, - * 由调用方决定怎么办(未读面上是**回落到整表**),这里**不猜**、也不悄悄少给几条。 + * 与裸的 [loadAfter] 的差别只有一件:**它负责把「还没翻完」这件事如实说出来**。 + * + * ## 什么时候只能说 `complete = false` + * 游标是本页**最新**一条的 `createdAt`,而服务端只给 `created_at > after`(**严格**大于: + * `list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 两处一致)。于是**落在页边界上被 + * 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次只会得到空页 + * (契约 §A5 的已知边界:纯 `createdAt` 游标没有毫秒内的次序)。 + * + * 客户端**分不出**那次空页是「干净的尾巴」还是「空页底下还压着同一毫秒的几条」,所以: + * + * - **拿过一整页之后紧跟的空页 ⇒ `complete = false`**,把决定权交回调用方(未读面上是回落到 + * 整表)。代价是「积压条数正好是页宽的整数倍」这种干净尾巴也要回落一次——罕见,且比静默少给 + * 几条便宜得多; + * - **第一页就是空页 ⇒ `complete = true`**:一条新消息都没有,没有可少给的; + * - **不满一页 ⇒ `complete = true`**:服务端 `LIMIT` 语义,给不满就说明 `> cursor` 的给完了; + * - **翻满 [maxPages] ⇒ `complete = false`**。 + * + * 这里**不猜**、也不悄悄少给几条:说不完整,调用方还能去问唯一权威。 * * 不传 `platform`:未读面上的口径是服务端 `count(created_at > read_at)`(`unreadCountQuery`, * 不带平台过滤),按平台过滤会让本地数出来的条数**与服务端的 `unreadCount` 不同源**。 @@ -126,6 +142,8 @@ class MessageRepository( val size = pageSize.coerceIn(1, MAX_PAGE_SIZE) val byId = LinkedHashMap() var cursor = after + // 已经翻到过一整页 ⇒ 此后那个空页不再可信(页边界可能劈开了一毫秒,见 KDoc)。 + var chasedFullPage = false fun done(at: Long, complete: Boolean) = Result.success( MessageIncrement(messages = byId.values.sortedWith(MessageOrder), cursor = at, complete = complete), @@ -137,14 +155,17 @@ class MessageRepository( val raw = apiCall { api.listMessages(sessionId, size, before = null, platform = platform, after = cursor) } .getOrElse { return@withContext Result.failure(it) } val paged = toPagedMessages(raw, size) - // 空页 = 真的追到了尾巴。`hasMore` 只是「拿到满页」的保守判断,条数正好是页宽的整数倍时 - // 会多问一次,那一次拿到的就是这一页空的。 - if (paged.messages.isEmpty()) return@withContext done(cursor, complete = true) + // 空页有两种含义,客户端分不出来:第一页就空 = 真的没有新的;拿过一整页之后空 = 可能还有 + // 同一毫秒的几条卡在游标上(服务端只给严格大于)。后者只能说「没追完」。 + if (paged.messages.isEmpty()) { + return@withContext done(cursor, complete = !chasedFullPage) + } + // 去重只是防御:严格 `>` 下相邻两页不可能重叠(下一页只要 > 本页最新那条)。 paged.messages.forEach { byId[it.id] = it } val newest = paged.newestCreatedAt ?: return@withContext done(cursor, complete = false) if (!paged.hasMore) return@withContext done(newest, complete = true) - if (newest <= cursor) return@withContext done(newest, complete = false) cursor = newest + chasedFullPage = true } done(cursor, complete = false) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPull.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPull.kt index 1f95dbb..ca1a0ad 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPull.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/InactiveSessionPull.kt @@ -252,14 +252,26 @@ internal fun incrementCursors( * * ## 未读数怎么算:先过已读游标,再加 * 增量条数**不是**直接往角标上加的,要先按本地已知的已读游标 [readAt](`readCursors[sessionId]`)筛一遍。 - * 服务端的口径是 `count(created_at > read_at)`(`session_read.go` 的 `unreadCountQuery`),而 `read_at` - * 是**账号级单一游标**,可能比本端已知的最新消息还新——用户在这台机器上点开过它([SessionsViewModel.select] - * 推的就是「现在」),随后别的设备又发了消息。那一刻 `(lastMessageAt, readAt]` 之间的消息**服务端算已读**, - * 一律加进角标就会多出来一个红点。所以只数 `createdAt > readAt` 的那几条。 + * **过滤口径**与服务端同源:`session_read.go` 的 `unreadCountQuery` 是 `count(created_at > read_at)`, + * 而 `read_at` 是**账号级单一游标**,可能比本端已知的最新消息还新——用户在这台机器上点开过它 + * ([SessionsViewModel.select] 推的就是「现在」),随后别的设备又发了消息。那一刻 + * `(lastMessageAt, readAt]` 之间的消息**服务端算已读**,一律加进角标就会多出来一个红点。所以只数 + * `createdAt > readAt` 的那几条。 * * 已有的 [SessionItem.unreadCount] 直接保留相加是安全的:它来自服务端,而这次增量里的每一条都严格新于 * [from](服务端的 `after` 就是 `created_at > ?`),两者没有交集,不会重复计数。 * + * ## 已知边界:同源的只是**过滤口径**,基数仍是上次整表的旧值 + * 相加的底数 [SessionItem.unreadCount] 来自上一次整表刷新,而 `read_at` 是账号级的、会被别的设备改写。 + * 断线期间他端把某个会话读掉时,那条 `session_read` 帧本地**永久收不到**(断线不补帧),于是底数偏大, + * 这一行会一直多算那几条。这是**已接受的边界**,不是漏网:红点本来就只承诺「大概有几条新的」,漂移 + * 到下一次整表刷新(打开抽屉 / 初始化 / 任何一次回落)就自愈。 + * + * 这里**不**去检测它(例如「[readAt] > [from] ⇒ 底数可疑 ⇒ 回落整表」):本端刚点开过这个会话时 + * `readAt` 本来就被推到「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量 + * 路径在正常使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门 + * 不如不装。 + * * ## 并发的那一份真相 * [from] 不是「这次用了什么游标」的装饰:行上的 [SessionItem.lastMessageAt] **已经不等于** [from], * 就说明在这次请求飞在天上的时候,另一份更权威的结果(一次整表刷新、或另一次增量)已经推进过这一行。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index 80a3f66..392ce30 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -28,7 +28,8 @@ data class SessionItem( val lastMessage: String?, /** * 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。 - * 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。 + * 两处用:已读游标比对(见 [SessionItem.withReadCursor]),以及增量追尾的 `after` 游标 + * (见 `incrementCursorFor`,由 `applyIncrement` 推进到这次增量的 `cursor`)。不直接显示。 */ val lastMessageAt: Long? = null, /** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */ @@ -412,9 +413,10 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { * * ## 回落的三条理由(都落到同一条路:整表) * 1. **问不了增量**:[incrementCursors] 给 `null` —— 行不在列表里,或本端不知道它看到哪儿了; - * 2. **追不到尾**:服务端说有更多却推不动(`complete = false`:同毫秒并列超过一页、或翻满了 - * `MessageRepository.MAX_INCREMENT_PAGES`)—— 那时增量只覆盖了积压的一部分,拿它当「全部」 - * 就是把红点做小; + * 2. **追不到尾**:`complete = false` —— 拿过一整页之后紧跟的空页(页边界可能劈开了同一毫秒, + * 而服务端只给严格 `>`,那一毫秒的尾巴再也追不回来;客户端分不出干净尾巴与这个洞),或翻满了 + * `MessageRepository.MAX_INCREMENT_PAGES`。那时增量只覆盖了积压的一部分,拿它当「全部」就是 + * 把红点做小; * 3. **请求失败**:含会话已在别处被删掉的 `404` —— 那一档要靠整表把这一行从列表里去掉。 * * 三条都是「增量给不出可信答案」⇒ 去问唯一权威。**一次批次最多回落一次**:整批共用这一个失败 diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageIncrementRepositoryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageIncrementRepositoryTest.kt index 1b08ede..54098b6 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageIncrementRepositoryTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageIncrementRepositoryTest.kt @@ -60,34 +60,53 @@ class MessageIncrementRepositoryTest { } @Test - fun `a full page is followed by one more request and the empty page ends the chase`() = runBlocking { + fun `an empty first page is a clean tail`() = runBlocking { + backend.enqueue(200, """{"data":[]}""") + + val increment = repository().loadIncrement("web", after = 300).getOrThrow() + + // 第一页就空 ⇒ 一条新消息都没有,没有可少给的:这是真的追到尾巴,游标原地不动。 + assertEquals(1, backend.uris.size) + assertTrue(backend.uris.single(), backend.uris.single().contains("after=300")) + assertTrue(increment.complete) + assertEquals(300L, increment.cursor) + assertTrue(increment.messages.isEmpty()) + } + + @Test + fun `an empty page after a full page is not proof of a clean tail`() = runBlocking { backend.enqueue(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""") backend.enqueue(200, """{"data":[]}""") val increment = repository().loadIncrement("web", after = 0, pageSize = 2).getOrThrow() - // 满页 ⇒ 多问一次;那一次拿到空页,于是「追到了」而不是「翻不动了」。 + // 满页 ⇒ 多问一次,第二次拿到空页。但客户端**分不出**这次空页是「干净的尾巴」还是 + // 「空页底下还压着同一毫秒的几条」:服务端只给严格 `> cursor`,被页边界劈开的那一毫秒 + // 再也追不回来。分不出来就只能说「没追完」,把「要不要回落整表」交回调用方。 assertEquals(2, backend.uris.size) assertTrue(backend.uris[1], backend.uris[1].contains("after=200")) - assertTrue(increment.complete) + assertFalse(increment.complete) assertEquals(200L, increment.cursor) assertEquals(listOf("m1", "m2"), increment.messages.map { it.id }) } @Test fun `a full page stuck on one millisecond is reported as incomplete`() = runBlocking { - // 同毫秒并列且超过页宽:纯 `createdAt` 游标推不动,服务端只会把同一批行再给一次。 - val sameMillisecond = """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""" - backend.enqueue(200, sameMillisecond) - backend.enqueue(200, sameMillisecond) + // 真后端的形状:m1 / m2 / m3 三条落在同一毫秒,页宽 2。第一页给 m1、m2,第二页问 + // `after=500` —— 严格 `>` 下后端只会回空页,m3@500 永远拿不到(这里不入队 m3:队列里 + // 那份空响应就是真后端在 `after=500` 时会给的答案)。 + backend.enqueue(200, """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""") + backend.enqueue(200, """{"data":[]}""") val increment = repository().loadIncrement("web", after = 100, pageSize = 2).getOrThrow() assertFalse(increment.complete) - // 游标没有越过那一毫秒(推不动就不装作推得动)。 + // 游标停在那一毫秒(推不动就不装作推得动)。 assertEquals(500L, increment.cursor) - // 同一批行被问了两次,按 id 去重后仍是两条 —— 不能因为翻了两页就把条数翻倍。 assertEquals(listOf("m1", "m2"), increment.messages.map { it.id }) + // 线上证据:第二次真的带上了 `after=500`,即「m3 拿不到」的那一问。 + assertEquals(2, backend.uris.size) + assertTrue(backend.uris[1], backend.uris[1].contains("after=500")) } @Test diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionIncrementTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionIncrementTest.kt index 9e19314..0bcea4b 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionIncrementTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionIncrementTest.kt @@ -130,8 +130,9 @@ class SessionIncrementTest { @Test fun `messages at or before the read cursor are already read on the server`() { - // 服务端口径是 `count(created_at > read_at)`,而 `read_at` 是账号级单一游标: - // 这台机器点开过它(游标推到「现在」)之后别的设备又发了消息,那段区间服务端算已读。 + // 与服务端**同源的只是过滤口径**(`count(created_at > read_at)`),不是基数: + // `read_at` 是账号级单一游标,这台机器点开过它(游标推到「现在」)之后别的设备又发了消息, + // 那段区间服务端算已读 —— 加进角标就多出一个红点,所以先按 readAt 筛。 val sessions = listOf(item(id = "web", lastMessageAt = 100)) val updated = sessions.applyIncrement( @@ -145,6 +146,24 @@ class SessionIncrementTest { assertEquals(1, updated.single().unreadCount) } + @Test + fun `a read elsewhere while offline over-counts until the next whole table`() { + // 已接受的边界(见 `applyIncrement` 的 KDoc),钉在这里是为了它不会被当成「已修好」: + // 底数 `unreadCount` 来自上一次整表刷新,而 `read_at` 是账号级的。断线期间他端把这个会话 + // 读掉了,那条 `session_read` 帧本地**永久收不到**(断线不补帧)⇒ 本端 readAt 还是 null, + // 整批都进了角标(4 + 3 = 7),而服务端此刻算 0。漂移到下一次整表刷新自愈,这里不改行为。 + val sessions = listOf(item(id = "web", lastMessageAt = 100, unreadCount = 4)) + + val updated = sessions.applyIncrement( + "web", + from = 100, + readAt = null, + increment = increment(listOf(message("m1", 200), message("m2", 300), message("m3", 400))), + ) + + assertEquals(7, updated.single().unreadCount) + } + @Test fun `an unknown read cursor counts everything in the increment`() { val sessions = listOf(item(id = "web", lastMessageAt = 100))