☰
Plannotator PR Context Warm Cache:基于会话级 Promise 缓存消除 PR 概览面板加载闪烁的工程实践
2026/9/25 2:29:01 网站建设 项目流程

【免费下载链接】plannotator

Annotate and review coding agent plans and code diffs visually, share with your team, send feedback to agents with one click.

项目地址:https://gitcode.com/gh_mirrors/pl/plannotator
点击查看免费下载

导读

本文围绕 Plannotator 仓库中的架构决策记录 adr/decisions/002-pr-context-warm-cache-20260630-110601.md(下称“ADR-002”),剖析其如何通过“会话级 Promise 缓存 + 后台预热”机制,消除 PR Overview 面板打开时的 “Loading PR...” 闪烁。文章首先还原决策背景与设计约束,然后给出完整的实现思路与源码级佐证,最后讨论失效语义、演进方向与可验证的实验方法。读完本文,你将掌握一套可复用的“服务端预热 + 共享 in-flight Promise + 失败驱逐”缓存模式,并能直接对照 Plannotator 的 Bun 主服务器与 Pi 扩展服务器两份实现进行落地。

一、决策背景:PR 概览数据为什么总是“慢半拍”

Plannotator 的 PR Overview 面板集中展示 PR 的 description、comments、review threads、checks、labels、merge state 以及 linked issues 等上下文数据。在 ADR-002 记录时(2026-06-30),其加载流程存在一个明显的时序缺陷:

  1. 审查 UI 打开 Overview 面板后,向服务器发起GET /api/pr-context请求;
  2. 服务器此前并未做任何准备工作,直到收到该请求才调用fetchPRContext(prRef)去拉取数据;
  3. 于是,在 provider 命令(如gh pr view、GraphQL 查询等)真正跑完之前,面板只能显示 “Loading PR...”。

换言之,面板的等待时间 = provider 冷启动的完整耗时,而这一等待完全可以通过在服务器端提前预热来消除或缩短。

当时的有利条件是:

  • 主 Bun 审查服务器与 Pi 审查服务器都已经维护了 PR 模式下的会话级缓存(PR 列表缓存、PR 切换缓存、栈树缓存),具备成熟的会话缓存先例;
  • 客户端已经统一收敛为“按 PR URL 键控的单一路径”来获取 PR 上下文,因此这个问题可以在服务器端修复,UI 完全不需要改动。

同时,ADR-002 明确了两条硬性约束:

  • 预热的 fetch不得阻塞服务器启动,也不得拖慢 PR 切换(/api/pr-switch)的响应;
  • provider 的临时失败不得污染缓存——UI 的重试路径必须能在瞬时故障后重新发起一次全新请求。

二、决策内容:会话级 PR 上下文 Promise 缓存

ADR-002 的决策非常聚焦:在两个审查服务器实现中各加入一个按 PR URL 键控、值为Promise<PRContext>的会话级缓存,并配套“启动预热、切换预热、请求时共享、失败驱逐”四条行为规则。

2.1 涉及的文件

决策点明了两份需要同步修改的服务器实现:

  • packages/server/review.ts —— 主 Bun 审查服务器;
  • apps/pi-extension/server/serverReview.ts—— Pi 扩展审查服务器。

配套的规范文档 adr/specs/pr-context-warm-cache-20260630-110258.md 进一步把修改范围限定为“仅服务器端,React 客户端不改一行代码”。

2.2 四个核心行为

行为一:启动预热。PR 审查会话启动时,只要初始的prRef与prMetadata已就绪,服务器立即在后台调用fetchPRContext(prRef),把返回的 Promise 存入缓存,不 await 它。这样 UI 随后发起请求时,数据往往已经拉完或正在拉取。

行为二:请求时共享。/api/pr-context处理器对“当前活跃 PR 的 URL”await 缓存中的 Promise:

  • 若缓存中已有该 URL 的 Promise(无论是否已完成),直接共享它,等待同一个 Promise;
  • 若缓存中不存在(例如预热失败被驱逐,或从未来过),则现场创建一个、先存入缓存、再 await 并返回结果。

行为三:切换预热。当/api/pr-switch切换活跃 PR 后,一旦新的prRef已知,服务器立刻为新 PR 的 URL 预热上下文缓存。关键点与行为一相同:预热不阻塞切换响应,切换返回的 diff 载荷仍然即时下发。

行为四:失败驱逐。若某个 PR URL 的上下文 fetch reject,则在把错误返回给调用方之前,先从缓存中删除该 URL 的条目。这样后续请求不会被一个陈旧的 rejected Promise 卡死,而是能重新发起全新请求完成重试。

2.3 API 契约保持不变

ADR-002 与规范文档都强调:/api/pr-context的响应契约不变——成功时返回原始的PRContextJSON,失败时返回{ error: string }(状态码 500),非 PR 模式返回{ error: "Not in PR mode" }(状态码 400)。这一约束保证服务端预热是“无侵入优化”。

三、实现要点:从设计图到可运行代码

规范文档给出了可直接落地的实现骨架,理解它能帮你把这个模式迁移到其他系统。

3.1 缓存容器与核心 helper

const prContextCache = new Map<string, Promise<PRContext>>(); const getCachedPRContext = (url: string, ref: PRRef): Promise<PRContext> => { const cached = prContextCache.get(url); if (cached) return cached; const promise = fetchPRContext(ref).catch((error: unknown) => { prContextCache.delete(url); // 失败立即驱逐,允许后续重试 throw error; }); prContextCache.set(url, promise); // 先存 Promise 再 await,天然合并并发 return promise; };

这段代码的精妙之处在于:

  • 以 Promise 为缓存值,使“还在飞行中的请求”也能被后续调用方共享,避免了重复的 provider 调用;
  • .catch中先驱逐再抛出,实现“失败不缓存”的语义;
  • 先set再返回,保证并发请求拿到的是同一个 Promise(等价于单飞行合并)。

3.2 启动预热与切换预热

两处预热都使用void ... .catch(() => {})显式“不等待、不吞错”,确保任何异常都不会冒泡到启动或切换主流程:

// 启动预热:初始 prRef 与 prMetadata 已就绪时 if (prRef && prMetadata) { void getCachedPRContext(prMetadata.url, prRef).catch(() => {}); } // 切换预热:新 PR 的 metadata.url 与 prRef 已知后 void getCachedPRContext(pr.metadata.url, prRef).catch(() => {});

3.3 端点改造

if (!isPRMode || !prRef || !prMetadata) { return Response.json({ error: "Not in PR mode" }, { status: 400 }); } const context = await getCachedPRContext(prMetadata.url, prRef); return Response.json(context);

规范文档还提示了两个运行时差异:主服务器可直接从./pr导入PRContext与PRRef类型,Pi 服务器则从../generated/pr-types.js导入,且 Pi 实现要使用其自身的json(res, ...)辅助函数。这正对应“两个实现必须同步演进”的决策要求。

3.4 源码佐证:当前的 PRContextLiveCache 正是该决策的落地演进

打开当前仓库源码,可以看到该决策已演进为共享包 packages/shared/pr-context-live.ts 中的PRContextLiveCache类,其设计完整继承了 ADR-002 的四条行为:

  • 会话级entries = new Map<string, PRContextCacheEntry>()(对应“按 PR URL 键控的会话缓存”);
  • warm(url, ref)方法调用runDetached(...)发起best-effort 后台预热且不等待(对应行为一与行为三,见 pr-context-live.ts);
  • getContext(url, ref)中“若存在 in-flight 则返回该 Promise,否则刷新并共享”(对应行为二,见 pr-context-live.ts);
  • 每条 entry 记录error与失败冷却时间failureCooldownMs(默认 60s),从机制上保证失败可重试而非永久污染。

两个服务器端点在当前代码中均已切换到这一统一入口:主服务器在 review.ts 通过prContextLive.getContext(prMetadata.url, prRef)处理/api/pr-context;Pi 服务器在apps/pi-extension/server/serverReview.ts的对应路由中采用相同调用。fetchPRContext本身则是两个运行时各自的薄封装:主服务器见 packages/server/pr.ts,内部调用共享的@plannotator/shared/pr-provider核心实现;Pi 服务器见apps/pi-extension/server/pr.ts。

3.5 底层 provider 的失败形态(为什么需要驱逐)

要理解“失败驱逐”为何必要,需要看底层 provider 的失败行为。根据研究文档 adr/research/SPIKE-pr-context-warm-cache-20260630-110258.md 的发现:

  • GitHub 的 PR context 在gh pr view失败时会 throw(对应packages/shared/pr-github.ts的实现),这是必须可重试的硬错误;
  • GitHub review threads 在 GraphQL 失败时会降级为空列表;
  • GitLab 的 context 会并行发起多个只读调用(对应packages/shared/pr-gitlab.ts的实现),并把部分失败大多降级为空切片。

也就是说,一部分失败是“软失败”(已降级为可用数据),一部分是“硬失败”(会 reject)。缓存策略对硬失败必须做到“驱逐即重试”,这正是.catch里先delete再throw的设计动机。

四、预期的收益与已知代价

ADR-002 在 Consequences 部分对收益与代价做了非常克制的陈述,值得逐条还原:

收益:

  1. 加载闪烁基本消失:PR Overview 通常在其数据被 UI 请求时已经就绪;
  2. 消除重复 provider 调用:即使预热仍未完成,UI 等待的是“已开始的同一份工作”,而不是再触发一次重复 fetch;
  3. 失败可重试:rejected Promise 会被驱逐出缓存,后续请求能重新发起。

代价与已知边界:

  1. 会话级缓存可能变陈旧:成功获取的 PR context 会在整个审查服务器会话生命周期内被缓存,若服务器保持打开期间 comments 或 checks 发生变化,数据可能过期。ADR 明确“当前接受这一点”,并说明这与既有的 PR list / switch / stack tree 会话缓存风格一致;
  2. 慢 provider 下仍可能显示 loading:如果 provider 调用特别慢,预热仍可能在 Overview 挂载前未完成,此时 UI 仍显示加载,但等待的是已在跑的 fetch,而非新开一次;
  3. 一次多余的只读调用:对“从未打开过 Overview 上下文”的 PR 审查,服务器会多执行一次 provider 调用。规范文档认为这是可接受的,因为 PR Overview 现在默认打开,且该调用是只读的。

五、验证方式与演进脉络

5.1 验证手段

规范文档给出两条验证路径:

  • 类型检查:bun run typecheck(根目录 package.json 中定义了bun test与bun run typecheck等脚本);
  • 可选的手动聚焦检查:启动一个 PR 审查 → 立即打开 PR Overview → 确认/api/pr-context正常返回;切换到另一个 PR → 确认新 PR 的 Overview 仍能加载,且 provider fetch 失败时重试可用。

5.2 从 ADR 到实时更新:决策的后续演进

ADR-002 之后,仓库又产生了相邻决策 adr/decisions/003-live-pr-context-updates-20260630-114643.md,把“会话级快照缓存”进一步演进为“实时 PR 上下文更新”。这也是当前PRContextLiveCache类如此之大的原因——它在保留 ADR-002 的 warm / in-flight 共享 / 失败冷却语义之上,增加了watch订阅、SSE 事件流(/api/pr-context/stream端点)、周期刷新(默认refreshIntervalMs为 30 秒)、失败冷却(默认 60 秒)以及“写操作后立即刷新(refreshAfterWrite)”等能力。

如果你要阅读这段演进,建议按此顺序:先读 002 决策(本文主题) → 读 003 决策 → 再读 pr-context-live.ts 的getContext与warm实现 → 最后对照两个服务器端点。这样你能完整看到“简单的 Promise 缓存”如何成长为“带 TTL、冷却、订阅与 SSE 的实时缓存”。

六、可迁移的工程模式总结

抛开 Plannotator 的具体业务,ADR-002 沉淀的是一套通用且克制的最佳实践,可直接迁移到任何“面板类 UI + 服务端拉取”的架构中:

  1. 服务端预热而非客户端等待:让数据准备发生在“用户可能请求之前”,而不是“用户请求之时”;
  2. 缓存 Promise 而非结果:缓存值取 Promise,天然支持 in-flight 共享,多请求自动合并为一次 provider 调用;
  3. 预热必须 detach:用void promise.catch(() => {})或等价手段发起后台任务,绝不阻塞启动与主响应路径;
  4. 失败立即驱逐:rejected 的 Promise 绝不留驻缓存,保证瞬时故障后重试仍然有效;
  5. API 契约冻结:所有优化都发生在服务端内部,对外响应结构一字不改,从而把改动风险控制在单侧;
  6. 双实现同步:当同一能力存在于两套服务器实现时,把共享逻辑下沉到公共包(如@plannotator/shared/pr-context-live),避免两份代码漂移——这正是当前仓库最终的走向。

这六条原则合在一起,回答了 ADR-002 的核心问题:如何在不改 UI、不拖慢启动与切换、不污染缓存的前提下,让“PR 概览数据”在用户看到面板之前就已准备就绪。

【免费下载链接】plannotator

Annotate and review coding agent plans and code diffs visually, share with your team, send feedback to agents with one click.

项目地址:https://gitcode.com/gh_mirrors/pl/plannotator
点击查看免费下载

相关推荐

上一篇:5 分钟搭建网站变更监控:用 changedetection.io 盯住降价与补货的完整实操
下一篇:LevelDB 打开数据库报比较器名称不匹配(does not match existing comparator)怎么排查

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询