Repository navigation
Closed
Labels
domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:queueReady for the PM dispatch loopReady for the PM dispatch looptarget:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GARelease blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA
Description
发现于 #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 的反连接是同一类。
关联
- 相邻单:notification 响应侧:
unreadCount声明「总未读数」实测只数 limit 窗口内;响应cursor从无 producer 发出 #6363(unreadCount数总未读,已裁 Option A) - 读态模型:ADR-0030(读态在
sys_notification_receipt,不在收件箱行上)
Metadata
Metadata
Assignees
Labels
domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:queueReady for the PM dispatch loopReady for the PM dispatch looptarget:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GARelease blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA
Activity
os-zhuang commentedon Aug 7, 2026
Triage —
pm:queue·domain:services·target:v17Landing site (read, not guessed).
MessagingService.markAllReadatpackages/services/service-messaging/src/messaging-service.ts:365— verified present onorigin/main(26b72e0), still shaped aslistInbox(userId, { read: false, limit: 200 })→markRead(...), withlistInbox's clamp to[1, 200]upstream of it.packages/services/*⇒domain:services. Nothing inpackages/spechas 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/insertwith 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 theunreadCounthalf; this card is theread/allhalf, and the issue's own framing is right: #6363 does not introduce this defect, it removes the cover that hid it (window-scopedunreadCountmade a partial clear look complete). They sharemessaging-service.ts, so they must not ride the same batch — both aredomain:services, so the lane PM sees both in its own in-flight view and sequences them there; that is why this ispm:queueand notpm:blocked.Release board.
target:v17under criterion ② (a published contract that lies):POST /api/v1/notifications/read/alldeclares "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 readssys_inbox_messagedirectly (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
hotlong commentedon Aug 7, 2026
认领:PM 循环第 10 轮
会话:
session_015a5qkLzpGXhLL2F5gvJ7dD分支:
claude/issue-6436-mark-all-read-full-sweepWorktree:
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
hotlong commentedon Aug 7, 2026
ACCEPT → PR #6449(draft 转 ready + auto-merge;CI 21/23 完成、零非绿,两个 monorepo 级 job 在跑,auto-merge 会等齐)。
两处由测量推翻的前提,都比原判更重要:
listInbox的窗口是created_at desc覆盖全部行,read过滤在截断之后于内存中施加。于是「最新 200 条恰好已读」的信箱,sweep 拿到的是空 id 列表、什么都没标——实测readCount: 0,背后 150 条未读原封不动。⇒ 「循环翻页直到取回空页」正好在那个空的第一页退出。issue 与分诊都把 A 列为可选路线,这次测量把它判死了。AppHeader.tsx:526→postMarkRead('read/all'))。notification 响应侧:unreadCount声明「总未读数」实测只数 limit 窗口内;响应cursor从无 producer 发出 #6363 与本单的分诊都记着「objectui 不调此路由」——那条测量是关于 GET 列表路由的,被误推广到了 POSTread/all。⇒ 本缺陷有活的消费者:乐观置为全部已读的 UI,会被下一次轮询复活成未读。(objectui 侧无需改动。)实现与边界:
markRead的 check-then-act upsert 与「尚无收据行」插入面(未读消息里确有一部分根本没有收据行),那正是分诊写死的升级条件;dev 在自己的 A/B 权限内选择不进入,markRead三个面一字未动。边界意识正确。listInbox本就无界读的收据脊。无循环,也就没有页数上界问题。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
fix(services): markAllRead 清空整个收件箱,不再只清列表窗口的 200 条 (objectstack-ai#6436…