From 6d158ca9bd62981fb97b6b24b2d64af60c788ed2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 23:34:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(w77):=20=E6=BB=A1=E9=A1=B5=E4=B9=8B?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E3=80=8C=E4=B8=8D=E6=BB=A1=E9=A1=B5=E3=80=8D?= =?UTF-8?q?=E4=B9=9F=E8=AF=B4=E4=B8=8D=E6=B8=85=E5=B0=BE=E5=B7=B4=EF=BC=8C?= =?UTF-8?q?complete=20=E6=94=B6=E7=B4=A7=E4=B8=BA=E3=80=8C=E4=BB=8E?= =?UTF-8?q?=E6=9C=AA=E6=8B=BF=E8=BF=87=E6=BB=A1=E9=A1=B5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 复审指出 `loadIncrement` 的第三档(`!hasMore` ⇒ `complete = true`)是空页档同一个 洞的另一个出口:它只看「本页满不满」,不看整个追尾里有没有拿过满页。 反例(页宽 2、`after=0`、积压 m1@500 / m2@500 / m3@500 / m4@600):第一页给 [m1,m2](满页,服务端 LIMIT 恰好把 500 那一毫秒劈开),第二页 `after=500` 只给 [m4@600]、不满页 ⇒ 旧代码报 `complete = true`,而 m3@500 既不 `> 500`、永远拿不到, 也不会触发回落整表 —— 未读红点被做小,正是这条增量路径最不能出的错。 改法:第三档改为 `complete = !chasedFullPage`,不变式收紧为 complete = true 当且仅当整个追尾从未拿过满页。 理由写在 KDoc 里:只有服务端的 LIMIT 在某一毫秒中间切过一刀才会留尾巴,客户端看不见 那一刀落在哪儿,只知道自己拿过满页就等于发生过一次切点 —— 此后无论下一页是空页还是 不满页,都说不出「追到了尾巴」。四档表(第一页空 / 第一页不满 / 满页后空 / 满页后不满 / 翻满 maxPages)与代价段一并重写。 代价(复审已认可):积压 ≥ 页宽(默认 50 条)一律回落整表,与「翻满 MAX_INCREMENT_PAGES ⇒ 回落」同类,也正合那句「积压多到增量不再便宜」;主路径(几条新消息、第一页就不满) 不受影响。 - `MessageRepository.loadIncrement`:标志位 + 第三档出口,KDoc 不变式与四档表重写; - `MessageRepository.loadAfter`:KDoc 的告警从「满页之后的空页」改为「满页之后的一页 (空页、不满页都一样)」; - `SessionsViewModel.flushPendingIncrements`:回落理由 2 同步为「整个追尾里拿过整页」; - 新增反例测试 `a short page after a full page is not proof of a clean tail either`。 验证:`./gradlew testDebugUnitTest assembleDebug` BUILD SUCCESSFUL;135 个套件 1692 条测试全绿(上一轮 1691,+1 即上述反例),0 failures / 0 errors / 0 skipped。 --- .../data/repository/MessageRepository.kt | 38 +++++++++++++------ .../cyrene/ui/sessions/SessionsViewModel.kt | 8 ++-- .../MessageIncrementRepositoryTest.kt | 18 +++++++++ 3 files changed, 48 insertions(+), 16 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 0180592..ffa1897 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 @@ -89,8 +89,8 @@ class MessageRepository( * * 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)—— * 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id` - * 去重,并且**不能把「满页之后的空页」当作追到了尾巴**:要翻页又要把边界说清楚,用 - * [loadIncrement],不要自己拿这一页凑。 + * 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界 + * 说清楚,用 [loadIncrement],不要自己拿这一页凑。 */ suspend fun loadAfter( sessionId: String, @@ -111,17 +111,28 @@ class MessageRepository( * ## 什么时候只能说 `complete = false` * 游标是本页**最新**一条的 `createdAt`,而服务端只给 `created_at > after`(**严格**大于: * `list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 两处一致)。于是**落在页边界上被 - * 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次只会得到空页 + * 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次也问不到 * (契约 §A5 的已知边界:纯 `createdAt` 游标没有毫秒内的次序)。 * - * 客户端**分不出**那次空页是「干净的尾巴」还是「空页底下还压着同一毫秒的几条」,所以: + * 要紧的是**切点**:只有当服务端的 `LIMIT` 在某一毫秒中间切过一刀,才会留下追不回来的尾巴。 + * 客户端看不见那一刀落在哪儿,只知道自己**拿过满页**就等于「发生过一次切点」。所以这里只有一条 + * 不变式,下面几档都是它的推论: * - * - **拿过一整页之后紧跟的空页 ⇒ `complete = false`**,把决定权交回调用方(未读面上是回落到 - * 整表)。代价是「积压条数正好是页宽的整数倍」这种干净尾巴也要回落一次——罕见,且比静默少给 - * 几条便宜得多; - * - **第一页就是空页 ⇒ `complete = true`**:一条新消息都没有,没有可少给的; - * - **不满一页 ⇒ `complete = true`**:服务端 `LIMIT` 语义,给不满就说明 `> cursor` 的给完了; - * - **翻满 [maxPages] ⇒ `complete = false`**。 + * > `complete = true` **当且仅当整个追尾从未拿过满页**。 + * + * - **第一页就是空页 ⇒ `true`**:一条新消息都没有,没有可少给的; + * - **第一页就不满 ⇒ `true`**:`LIMIT` 没切到任何东西,`> cursor` 的行全在这一页里; + * - **拿过整页之后,下一页是空页 ⇒ `false`**:那次空页分不出「干净的尾巴」与「空页底下还压着 + * 同一毫秒的几条」; + * - **拿过整页之后,下一页不满 ⇒ 也是 `false`**:不满只说明「`> cursor` 的行就这么多了」,对 + * 切点那一毫秒还有没有行**一无所知**。例如积压 `m1@500 m2@500 m3@500 m4@600`、页宽 2: + * 第一页给 `m1 m2`(切点正好劈开 500 那一毫秒),第二页 `after=500` 只给 `m4@600`、不满页, + * 而 `m3@500` 既不 `> 500`、也就永远拿不到; + * - **翻满 [maxPages] ⇒ `false`**。 + * + * 代价:积压一到页宽(默认 50 条)就回落整表。这与「翻满 [maxPages] ⇒ 回落」同类——那时增量 + * 已经取回 ≥50 行,多问一次整表是边际成本,也正合 [MAX_INCREMENT_PAGES] 那句「积压多到增量不再 + * 便宜」。主路径(几条新消息、第一页就不满)不受影响。 * * 这里**不猜**、也不悄悄少给几条:说不完整,调用方还能去问唯一权威。 * @@ -142,7 +153,8 @@ class MessageRepository( val size = pageSize.coerceIn(1, MAX_PAGE_SIZE) val byId = LinkedHashMap() var cursor = after - // 已经翻到过一整页 ⇒ 此后那个空页不再可信(页边界可能劈开了一毫秒,见 KDoc)。 + // 只要翻到过一整页,就说明服务端的 LIMIT 切过一刀,那一刀可能劈开了一毫秒(见 KDoc)。 + // 此后无论下一页是空页还是不满页,都说不出「追到了尾巴」。 var chasedFullPage = false fun done(at: Long, complete: Boolean) = Result.success( @@ -163,7 +175,9 @@ class MessageRepository( // 去重只是防御:严格 `>` 下相邻两页不可能重叠(下一页只要 > 本页最新那条)。 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) + // 不满页只说明「> cursor 的行就这么多了」,对切点那一毫秒一无所知 —— 与空页档同一个洞, + // 同样是拿过整页之后就不作数。 + if (!paged.hasMore) return@withContext done(newest, complete = !chasedFullPage) cursor = newest chasedFullPage = true } 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 392ce30..435a6a6 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 @@ -413,10 +413,10 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { * * ## 回落的三条理由(都落到同一条路:整表) * 1. **问不了增量**:[incrementCursors] 给 `null` —— 行不在列表里,或本端不知道它看到哪儿了; - * 2. **追不到尾**:`complete = false` —— 拿过一整页之后紧跟的空页(页边界可能劈开了同一毫秒, - * 而服务端只给严格 `>`,那一毫秒的尾巴再也追不回来;客户端分不出干净尾巴与这个洞),或翻满了 - * `MessageRepository.MAX_INCREMENT_PAGES`。那时增量只覆盖了积压的一部分,拿它当「全部」就是 - * 把红点做小; + * 2. **追不到尾**:`complete = false` —— 整个追尾里拿过整页(服务端的 `LIMIT` 切过一刀,那一刀 + * 可能劈开了同一毫秒,而服务端只给严格 `>`,被劈开的尾巴再也追不回来;此后的空页与不满页 + * 都说不清),或翻满了 `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 54098b6..2f38eac 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 @@ -90,6 +90,24 @@ class MessageIncrementRepositoryTest { assertEquals(listOf("m1", "m2"), increment.messages.map { it.id }) } + @Test + fun `a short page after a full page is not proof of a clean tail either`() = runBlocking { + // 页宽 2、积压 m1@500 / m2@500 / m3@500 / m4@600。第一页给 m1、m2 —— LIMIT 恰好把 500 那一 + // 毫秒劈开;第二页 after=500 只能给 > 500 的,于是 [m4@600]、不满页。不满只证明「> 500 的行 + // 就这一条」,对切点那一毫秒还有没有行一无所知:m3@500 既不 > 500,也就永远拿不到。 + // 空页档与不满页档是同一个洞的两个出口,都要说「没追完」。 + backend.enqueue(200, """{"data":[${message("m1", 500)}, ${message("m2", 500)}]}""") + backend.enqueue(200, """{"data":[${message("m4", 600)}]}""") + + val increment = repository().loadIncrement("web", after = 0, pageSize = 2).getOrThrow() + + assertEquals(2, backend.uris.size) + assertTrue(backend.uris[1], backend.uris[1].contains("after=500")) + assertFalse(increment.complete) + assertEquals(600L, increment.cursor) + assertEquals(listOf("m1", "m2", "m4"), increment.messages.map { it.id }) + } + @Test fun `a full page stuck on one millisecond is reported as incomplete`() = runBlocking { // 真后端的形状:m1 / m2 / m3 三条落在同一毫秒,页宽 2。第一页给 m1、m2,第二页问