fix: 复习和错题本展示前实时回查词库释义 - #3
Open
SeanXLChen wants to merge 2 commits into
Open
Conversation
review.json 里存的 meaning/phonetic 是练习当时的快照,而「今日复习」和 「错题本」直接拿它去展示,从不回查词库。于是编辑词库文件改掉某个词的释义 之后,这两个界面永远显示旧释义 —— 重启也没用,只有那个词恰好被重新练到 一次才会更新。本质是一个没有失效策略的缓存。 内置词库基本不变,缓存和源永远一致,所以这个 bug 只有自己维护词库的用户 会踩到,而词库会变恰恰是自定义词库这个功能的设计前提。 改动: - storage.js 提取导出 resolveDictPath,loadDict 复用。顺带把 path.join 换成 path.resolve —— 对现有 13 个词库条目逐字节相同,但让绝对路径的 dictInfo.file 也能正常工作。 - review.js 新增按文件指纹失效的词库缓存。指纹取 dev:ino:size:mtimeNs: ino+dev 覆盖「写临时文件再 rename」式保存,mtimeNs 覆盖同秒内的两次 原地写入。必须用 statSync 而非 lstatSync,因为词库可能是软链,而软链 自身的 mtime 创建后永不改变。 - 缓存能在进程运行中失效,codep 在 tmux 里挂几天也不需要重启。 - stat 本身抛错时(软链循环等,throwIfNoEntry 只压制 ENOENT)返回 null 且不写缓存也不沿用旧缓存:拿不到指纹就无从判断缓存是否有效。解析失败 则按指纹缓存,因为文件修好后指纹会变,能自愈。 - mergeCardWithDict 采用条目级覆盖:词库里还能索引到就一律以词库为准, 索引不到才整体退回快照。字段级覆盖会在字段粒度上重造同一个问题。 - recordResult 保持 ||,不改成 ??。读和写是两条不同的规则:读要说真话 (词库为准,空就显示空),写只维护兜底(只在词库给出非空值时升级)。 若写入也用 ??,词库某条目释义为空时会把历史快照冲成空字符串永久丢失, 而词从词库删除后卡片不会被清理,那份快照是唯一还能说明词义的东西。 - getDueCount / getMistakeCount 只数个数,既不排序也不回查词库 —— 菜单里每按一次方向键都会调用它们。 - 排序权重给 totalMistakes 加 || 0 守卫。codep 自己产生的卡片一定带这个 字段,这里防的是手工编辑过的 review.json,属一致性加固。 - 删掉重构后不再有调用方的 isDue。 测试:26 个新增用例。纯函数测合并与筛选规则,集成测试用临时词库和临时 CODEP_DATA_DIR 覆盖运行中编辑、软链词库改目标文件、stat 抛错后自愈、 指纹未变时复用缓存、多词库混排、快照往返、词被删除、未注册词库、词库 损坏、文件不存在等路径。集成测试开头硬断言 REVIEW_FILE 落在临时目录内, 避免将来有人打乱 require 顺序时静默写坏使用者真实的复习进度。 每条设计决策都用变异测试验证过确实被钉住:回查退化成只读快照挂 9 个用例, statSync 换成 lstatSync、stat 失败沿用旧缓存、meaning 或 phonetic 的写入 改成 ?? 各挂 1 个,字段级覆盖挂 3 个。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
说明这个 bug 为什么会存在、为什么一直没被发现,以及几个反直觉的实现
选择:读写规则为什么不对称、指纹为什么取 dev:ino:size:mtimeNs、为什么
必须 statSync 而不是 lstatSync(附实测的软链数字)。
附已知边界:指纹在秒级精度文件系统上的盲区、loadDict 对 {word,meaning:""}
这种写法的格式分叉、章节练习与「再来一轮」的内存态陈旧,以及本次新增的
同步读盘和常驻内存开销。
README 的说明加上指向本文的链接。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
编辑
dicts/下的词库文件、改掉某个词的释义之后,「今日复习」和「错题本」里显示的还是旧释义。而且不会自己恢复:重启 codep 也没用,只有那个词恰好被重新练到一次,显示才会更新。原因是
recordResult()会把练习当时看到的meaning/phonetic一起写进~/.codep/review.json,而getDueWords()/getMistakeWords()直接拿这份快照去展示,从不回查词库文件。本质上是一个没有失效策略的缓存 —— 陈旧不是「持续一小段时间」,而是永久。为什么一直没人报
内置词库(CET-4、GRE、程序员常见词……)基本不变。词库不变,缓存和源就永远一致,这个 bug 从不触发。
只有自己维护词库的用户会踩到 —— 边学边改释义、调整格式的那种用法。而词库会变,恰恰是「自定义词库」这个功能的设计前提。
改动
展示前实时回查词库,背后是一个按文件指纹失效的缓存:
storage.js:提取导出resolveDictPath,loadDict复用(避免两处重复实现 primary/legacy 双路径逻辑而漂移)。顺带把path.join换成path.resolve—— 对现有 13 个词库条目逐字节相同,但让绝对路径的dictInfo.file也能正常工作。review.js:新增词库缓存,指纹取dev:ino:size:mtimeNs。ino+dev覆盖「写临时文件再 rename」式保存(inode 变而 mtime 未必动),mtimeNs覆盖同秒内的两次原地写入。缓存能在进程运行中失效,codep 在 tmux 里挂几天也不需要重启。getDueCount/getMistakeCount拆成只数个数,既不排序也不回查词库 —— 菜单里每按一次方向键都会调用它们。三个不太直观的地方
必须
statSync而不是lstatSync。 词库文件可以是软链(自己维护生词本的人常常软链到笔记库)。软链自身的 mtime 是创建那天、之后永不改变,用lstat会做出一个看起来在失效、实际永不失效的缓存 —— 悄悄把这个 bug 原样复现一遍。recordResult保持||,没有跟着改成??。 读和写是两条不同的规则:读要说真话(词库为准,释义被清空就显示空),写只维护兜底(只在词库给出非空值时才升级快照)。卡片按dictId:word存、词从词库删掉后不会被清理,那份快照是唯一还能说明词义的东西;如果写入也用??,词库某条目释义一旦为空,一次练习就会把它冲成空字符串永久丢失。stat 本身抛错时不缓存也不沿用旧缓存。
throwIfNoEntry: false只压制 ENOENT,软链循环仍会抛 ELOOP、路径穿过普通文件会抛 ENOTDIR,所以 stat 要自己包一层 try/catch。拿不到有效指纹就无从判断缓存是否还有效,只能退回快照。解析失败则按指纹缓存,因为文件修好后指纹会变,能自愈。测试
26 个新增用例,沿用仓库现有风格(
node --test、零依赖、零 mock、中文用例名)。纯函数覆盖合并与筛选规则;集成测试用临时词库 + 临时
CODEP_DATA_DIR,覆盖运行中编辑、软链词库改目标文件、stat 抛错后自愈、指纹未变时复用缓存、多词库混排、快照往返、词被删除、未注册词库、词库损坏、文件不存在(coca20000.json被 gitignore,这就是每个新 clone 的默认状态)等路径。集成测试开头有一条硬断言,确保
REVIEW_FILE落在临时目录内 —— 否则将来有人打乱 require 顺序时,测试会照常全绿并静默写坏使用者真实的复习进度。每条设计决策都用变异测试验证过确实被钉住:
statSync→lstatSyncrecordResult的meaning写入改成??recordResult的phonetic写入改成??本次没有涉及
以下都是同一 bug 类的其他缓存,但都只在内存里、重启即恢复,远没有
review.json那份跨重启永久陈旧严重,修它们要动 UI 状态管理、会让这个 PR 大很多:input.js在选词库/启动时words = loadDict(dict)加载一次。停在同一个词库里不退回菜单时,编辑不生效;退回菜单重进即刷新。lastReviewWords冻结整个进程生命周期。dictId:word存且从不清理。既有问题,但这次修复会让孤立卡片变成「唯一不更新的那一个」,更隐蔽。loadDict的格式分叉 ——if (item.word && item.meaning) return item;对{word:"x", meaning:""}这种写法会让word变成undefined、索引不到。README 记载的{name, trans}格式不受影响。这些都记在
docs/review-cache.md里了。第二个 commit 是一篇设计笔记(
docs/review-cache.md),记录这个 bug 为什么存在、为什么一直没被发现,以及上面那几个反直觉选择的理由,主要是防止以后被「优化」回去。它新建了一个顶层docs/目录 —— 如果你觉得不合适,单独 drop 掉第二个 commit 即可,第一个 commit 是自洽的(README 里的链接也在第二个 commit 才加上)。