Skip to content

notification: markAllRead 只清窗口内的 200 条 —— 未读超过 200 的用户按「全部已读」清不掉角标 #6436

Closed

Description

@hotlong

发现于 #6363 的实施(PR 见下方关联)。不在该 PR 修 —— #6363 的裁决范围明确只覆盖 unreadCount 半边,本条是相邻但独立的一条。

事实

MessagingService.markAllRead(packages/services/service-messaging/src/messaging-service.ts)的文档说的是「Mark every currently-unread inbox message for the user as read」,实现是:

const { notifications } = await this.listInbox(userId, { read: false, limit: 200 });
return this.markRead(userId, notifications.map((n) => n.id));

limit: 200 是 listInbox 的硬上限(clamp 到 [1, 200]),所以这条路由一次最多翻 200 条收据。未读 350 条的用户点一次「全部已读」,剩 150 条仍是未读 —— 而路由名与 .describe() 都说是「all」。

#6363 让它从「看不见」变成「看得见」

在 #6363 之前,unreadCount 数的是窗口内,所以这个截断自洽地隐身:清完 200 条后再 GET /api/v1/notifications,窗口内确实一条不剩,角标显示 0,用户以为清干净了。#6363 让 unreadCount 变成真总数之后,同一条路径的响应自己就把矛盾摆出来了:

POST /api/v1/notifications/read/all  →  { success: true, readCount: 200 }
GET  /api/v1/notifications           →  { unreadCount: 150 }   ← 「全部已读」之后还有 150

也就是说这不是 #6363 引入的缺陷,是 #6363 揭掉了掩盖它的那块布。严重度也因此上升:以前是「静默漏清」,现在是「用户点了全部已读、界面仍然显示未读」的可见故障。

可选路线(不猜,留给分诊)

  • A. 循环翻页直到清空 —— listInbox(read:false, limit:200) 反复取到空为止,逐批 markRead。改动最小,无新能力面;代价是大信箱下一次请求打多轮查询,且没有上界。
  • B. 谓词式批量写 —— 直接对该用户 channel:'inbox' 的未读收据做一次批量 upsert / update,不经过列表。真正 O(1) 次查询;但 markRead 的 upsert 是 check-then-act(findOne → update/insert,处理 unique 冲突),改成谓词式要重新想并发与「尚无收据行」的插入面。
  • C. 声明侧收口 —— 承认它是「清当前窗口」,改名/改描述。与 notification 响应侧:unreadCount 声明「总未读数」实测只数 limit 窗口内;响应 cursor 从无 producer 发出 #6363 的裁决方向相反(那次的理由是「让声明成真而不是把谎话写进文档」),列在这里只是为了完整。

倾向 A 或 B,由维护者定;B 更长远但需要一次真实设计工作,和 #6363 的反连接是同一类。

关联

Activity

  1. os-zhuang commented on Aug 7, 2026

    @os-zhuang
    Contributor

    Triage — pm:queue · domain:services · target:v17

    Landing site (read, not guessed). MessagingService.markAllRead at packages/services/service-messaging/src/messaging-service.ts:365 — verified present on origin/main (26b72e0), still shaped as listInbox(userId, { read: false, limit: 200 }) → markRead(...), with listInbox's clamp to [1, 200] upstream of it. packages/services/* ⇒ domain:services. Nothing in packages/spec has to move for routes A or B, so this does not transfer to the spec seat.

    Queue, not decision box. A/B are implementation shapes inside one landing site (loop the pages vs. one predicate-shaped write), and the lane PM or the dev picks between them on cost. Route C — redefine "all" as "the current window" — is already excluded by the maintainer's 2026-08-07 Option A ruling on #6363: make the declaration true rather than write the lie into the docs. What would be a decision is discovering that B forces a redesign of markRead's check-then-act upsert (findOne → update/insert with unique-conflict handling) or of the "no receipt row yet" insert face; if the dev lands there, escalate at that point instead of choosing silently.

    Not a duplicate of #6363 — adjacent halves of the same route family. #6363 (pm:queue, domain:services, claimed) is the unreadCount half; this card is the read/all half, and the issue's own framing is right: #6363 does not introduce this defect, it removes the cover that hid it (window-scoped unreadCount made a partial clear look complete). They share messaging-service.ts, so they must not ride the same batch — both are domain:services, so the lane PM sees both in its own in-flight view and sequences them there; that is why this is pm:queue and not pm:blocked.

    Release board. target:v17 under criterion ② (a published contract that lies): POST /api/v1/notifications/read/all declares "mark every currently-unread inbox message as read" and delivers at most 200. Honest caveat on the boarding: it only becomes visible to a user once #6363 lands — before that it fails silently — so if #6363 slips out of the RC, this one can reasonably slip with it. Boarding is this seat's single-producer call, not a priority claim.

    Duplicate search across all three repos (markAllRead, unreadCount, read/all, inbox badge): the only hits are #6363 and this card. objectui's bell does not call this route at all — it reads sys_inbox_message directly (app-shell/src/layout/AppHeader.tsx, per #6363's measurement), so there is no objectui shadow to converge with.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. added
    pm:queueReady for the PM dispatch loop
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    target:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA
    on Aug 7, 2026
  3. self-assigned this
    on Aug 7, 2026
  4. hotlong commented on Aug 7, 2026

    @hotlong
    ContributorAuthor

    认领:PM 循环第 10 轮
    会话:session_015a5qkLzpGXhLL2F5gvJ7dD
    分支:claude/issue-6436-mark-all-read-full-sweep
    Worktree:objectstack-6436
    域:domain:services
    文件面:packages/services/service-messaging/src/messaging-service.ts(markAllRead ~:365,及 B 路线下可能触及的 markRead)+ 其测试 + changeset

    串行前提已解除:同文件的 #6363 → PR #6439 已 MERGED(18:5xZ),本单可派。该单落地也让本缺陷从「静默漏清」变为可见矛盾(readCount: 200 而 unreadCount: 150),与分诊的 target:v17 上板理由一致。

    路线:A(循环翻页至清空)与 B(谓词式批量写)由实施方按实测成本选,PR 正文写清取舍;⛔ 路线 C(把「all」改成「当前窗口」)已被维护者 #6363 的 Option A 裁决排除——让声明成真,不把谎话写进文档。⚠️ 升级条件(分诊写死,派发令已带):若 B 迫使重设计 markRead 的 check-then-act upsert(findOne → update/insert + unique 冲突)或「尚无收据行」的插入面 ⇒ STOP 上报,不静默选。


    Generated by Claude Code

  5. hotlong commented on Aug 7, 2026

    @hotlong
    ContributorAuthor

    ACCEPT → PR #6449(draft 转 ready + auto-merge;CI 21/23 完成、零非绿,两个 monorepo 级 job 在跑,auto-merge 会等齐)。

    两处由测量推翻的前提,都比原判更重要:

    1. 缺陷比正文所述更严重,且路线 A 是错的——不只是贵。 listInbox 的窗口是 created_at desc 覆盖全部行,read 过滤在截断之后于内存中施加。于是「最新 200 条恰好已读」的信箱,sweep 拿到的是空 id 列表、什么都没标——实测 readCount: 0,背后 150 条未读原封不动。⇒ 「循环翻页直到取回空页」正好在那个空的第一页退出。issue 与分诊都把 A 列为可选路线,这次测量把它判死了。
    2. objectui 的铃确实调这条路由(AppHeader.tsx:526 → postMarkRead('read/all'))。notification 响应侧:unreadCount 声明「总未读数」实测只数 limit 窗口内;响应 cursor 从无 producer 发出 #6363 与本单的分诊都记着「objectui 不调此路由」——那条测量是关于 GET 列表路由的,被误推广到了 POST read/all。⇒ 本缺陷有活的消费者:乐观置为全部已读的 UI,会被下一次轮询复活成未读。(objectui 侧无需改动。)

    实现与边界:

    • 不走 A、也未进入 B——B 要重设计 markRead 的 check-then-act upsert 与「尚无收据行」插入面(未读消息里确有一部分根本没有收据行),那正是分诊写死的升级条件;dev 在自己的 A/B 权限内选择不进入,markRead 三个面一字未动。边界意识正确。
    • 改为读未读集合而非列表的一页:固定两次读,与信箱大小无关——复用 fix(services): listInbox 的 unreadCount 数总未读,不再只数取回窗口 (#6363) #6439 为角标已在发的同款单列无窗口投影,联结 listInbox 本就无界读的收据脊。无循环,也就没有页数上界问题。
    • 对「无上界」那一问的回答值得记下:不设数值安全阀,因为上限就是路线 C 换个更大的数字——超过它「all」又开始说谎,正是维护者裁决反对的那件事;读侧已有界,写侧(每条未读一个收据,ADR-0030 固有)靠幂等可重入约束而非截断。
    • readCount 语义已明确为「本次调用真正翻成已读的 distinct 通知数」;fix(services): listInbox 的 unreadCount 数总未读,不再只数取回窗口 (#6363) #6439 的反连接半边完整保留、其钉子全绿。

    验收主证:逆向验证方向先判后跑——7 红,含 expected { readCount: 200 } to deeply equal { readCount: 350 } 与混合信箱的 expected +0 to be 150;线上探针在旧代码上实测出 issue 那对矛盾读数(readCount: 200 → unreadCount: 60)。包内 195 测、runtime 1604 全绿;check:type-check-debt 建完依赖后 OK 且 runtime 实际 -7。

    衍生:#6448(无 notification_id 的收件箱行永远标不成已读,休眠——单一 emit() 入口总带事件 id)已立案交分诊。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:queueReady for the PM dispatch looptarget:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions