Mole 的缓存失效、数值一致性、进度反馈与采样新鲜度:macOS 清理工具的状态核算工程实践
【免费下载链接】Mole🐹 Clean, uninstall, analyze, optimize, and monitor your Mac. Free open-source CLI, plus a native Mac app.项目地址: https://gitcode.com/GitHub_Trending/mole15/Mole
本文以 Mole 仓库中 state-accounting-and-progress.md 这份缺陷模式参考文档为主体,系统讲解 Mole 在"持久化派生数据、双路径数值一致性、终端进度反馈、异步采样新鲜度"四个方面的工程约束。该文档是 Mole 缺陷模式目录(bugs/SKILL.md)中编号 8、9、10、16 四类复发性缺陷的修复准则。读完后,你可以掌握:如何为每个缓存派生值确定 schema 版本、TTL 与失效条件;如何避免 dry-run 预览与最终汇总对同一数字给出不同答案;如何让慢速扫描在终端里"看起来活着";以及如何用代次标签与原子采样契约管理异步指标的过期数据。
一、背景:为什么需要一份"状态与核算"缺陷清单
Mole 是一个 macOS 清理与分析工具(CLI 加原生 App),其核心动作——扫描目录、预估可清理空间、执行删除、展示系统状态——全部依赖"计算出来的派生值":目录大小快照、清理预览总额、指标采样、异步探针结果。这些值一旦出错,用户看到的可能不是"数据不准",而是"删错了东西"或"界面卡死了"。
在 SKILL.md 的路由表中,这四类问题各有明确的"第一探针":
| 编号 | 复发性形态 | 第一探针 |
|---|---|---|
| 8 | 持久化派生数据比其算法活得更久 | 追踪 schema、TTL、证据指纹与变更点 |
| 9 | 两条路径用不同算法计算同一个数字 | 找出所有生产者,选定唯一定义 |
| 10 | 慢速工作看起来像冻结 | 找出所有约超过一秒且无反馈的操作 |
| 16 | 异步或缓存数据没有代次或新鲜度契约 | 把结果绑定到请求纪元,保持采集时间、stale、完整性字段整体一致 |
原文档开头给出了适用条件:当一次修复涉及缓存派生值、展示合计、预览核算或终端反馈时序时,应阅读此参考。以下逐条展开,并结合 Mole 仓库中的真实实现与测试佐证每一条准则的落点。
二、模式 8:持久化派生数据比其算法活得更久
2.1 核心命题
改变了一个计算却不清空它的缓存,就会让旧结果在源码修复之后继续被使用。
文档给出的代表形态是提交7a996aa5:硬链接去重(hardlink-dedup)的语义变化要求同时提升缓存 schema 版本,并把依赖去重的子树标记为不可缓存。Analyze 命令历史上还需要过期机制、变更驱动的失效,以及一条绕过嵌套缓存的手动刷新路径。
2.2 Mole 源码中的落点:schema 版本 + TTL + 失效
在 cmd/analyze/cache.go 中可以看到准则的第一条"schema version"如何落地:
// v2: analyze deduplicates hardlinked files to match `du`. // v3: ordinary Parallels VM storage is included instead of skipped by name. const cacheSchemaVersion = 3注释直接记录了 v2 就是"硬链接去重"这次语义变化——与文档中7a996aa5的叙述相互印证。读取端 loadRawCacheFromDisk 对 schema 不匹配的条目直接删除并报错,而不是静默复用:
if entry.SchemaVersion != cacheSchemaVersion { _ = os.Remove(cachePath) return nil, fmt.Errorf("cache schema mismatch: got %d, want %d", entry.SchemaVersion, cacheSchemaVersion) }文档要求"为每个持久化派生值识别"的五项清单,在 Mole 的 analyze 缓存里逐一有对应实现:
- Schema 版本:
cacheSchemaVersion = 3,不匹配即作废(见上)。 - TTL:constants.go 定义了
analyzerCacheTTL = 7*24h、overviewCacheTTL = 7*24h、staleCacheTTL = 3*24h("首次绘制"窗口)、cacheModTimeGrace = 30m(目录 mtime 噪声宽限)、cacheReuseWindow = 24h。loadCacheFromDisk 同时校验扫描年龄、目录 ModTime 与宽限窗口;loadStaleCacheFromDisk 则提供"宽松加载"通道,用于先画第一帧再后台刷新。 - 对每个输入变更的失效:invalidateCache 与 invalidateCacheTree(后者因 issue #812 增加,失效目标目录及其全部直接子目录,防止重扫时复用过期子目录大小)。
- 调用者是否把该值当作"不存在"的证明:这正是 2.3 节要单独讨论的完整性问题。
- 验证跑是否读到了上一个 release 的数据:schema 版本检查 + 启动时的 pruneAnalyzerCache 清扫,保证旧版本写入的条目不会进入新版本会话。
此外,缓存写入本身也是原子且可裁剪的:条目通过临时文件 +os.Rename落盘(避免杀进程留下截断文件),pruneAnalyzerCacheDirWithLimits 用最小堆按修改时间逐批淘汰,维持 constants.go 中analyzerCacheMaxEntries = 5000/analyzerCacheMaxBytes = 50MB的预算。注释里记录了一个真实案例:无准入控制时曾有用户的缓存达到 188 万个文件 / 7.82GB。
2.3 TTL 只证明"不够旧",从不证明"完整"
这是文档中最重要的一个概念区分,原文:
A TTL proves only that an entry is not too old. It never proves completeness.
Mole 中曾有一个真实缺陷:pkg_receipt_nonstandard_app_paths --require-complete一度接受一小时前的 pkgutil 回执缓存作为"不存在同级安装(sibling install)"的证据。问题在于:复查缓存中的路径可以删掉失效条目,却无法发现新安装的属主。删除操作把"缓存里没有"读成"系统里不存在",于是误删。
修复(提交b4f00651)把完整性绑定到pkgutil --pkgs输出的指纹上——新证据出现即作废旧条目。这条修复在 lib/core/pkg_receipts.sh 中可以完整看到:
- 指纹生成(L49-L52):
pkgutil --pkgs输出经cksum归一化为纯数字与连字符组成的指纹。注释解释了为什么选它作缓存键——"安装包必然新增回执,回执变化即指纹变化,从而强制重扫"。 - 缓存命中条件(L59-L81):文件头必须是
#receipts:<指纹>且通过 TTL(默认 3600 秒,可用MOLE_PKG_RECEIPT_CACHE_TTL调整)检查才可用;且当调用方要求--require-complete时,无指纹的缓存一律不可用(L60 的[[ -n "$receipts_fingerprint" || "$require_complete" != "1" ]])。 - 超时语义区分(L90-L97):普通模式下扫描超时只是
break提前结束(返回部分结果);--require-complete模式下超时返回 124,把"不完整"显式暴露给调用方,而不是静默当作空结果。 - 写缓存(L177-L197):无法生成指纹时宁可不写,"留下一个永远不可能命中的文件"比不写更危险。
删除路径如何消费这个"完整证明",可以在 lib/uninstall/batch.sh 看到:批量卸载调用pkg_receipt_nonstandard_app_paths --require-complete,其空输出即"没有其他安装拥有这些残留文件"的权威证据(同文件 L1393 注释:"Complete absence proof; the empty fingerprint is authoritative.")。
文档由此给出的通用规则:
当调用者要用缓存数据授权删除时,要么绕过缓存,要么把缓存绑定到"其出现会改变结论的所有证据"的指纹上。
三、模式 9:两条路径计算同一个数字,算法不同
3.1 核心命题
任何被渲染两次的值,最终都会不一致:dry-run 预览对最终汇总、条目数对原始目标数、子树大小对
du、十进制单位对二进制单位。
文档给出的处置方法:找出所有生产者,选定唯一定义;优先把已测得的值传入汇总/渲染端,而不是重新计算;然后在回归测试里比较两个渲染面,而不是钉死某个无关的字面量。
3.2 Mole 的核算规则(Accounting Rules)
文档列出的五条规则,直接对应 Mole 清理管线的实现约束:
- 被过滤、被拒绝、超时、失败或已消失的候选,既不贡献清理条目数,也不贡献回收字节;
- dry-run 与真实模式使用同一批合格候选,只是动作不同;
- 大小超时只能产生显式的"未知"或"部分合计",绝不许伪造一个看起来完整的零;
- 大候选快速路径可以跳过逐项精确计量,前提是输出必须声明合计是"部分的"或"未扫描的";
- 硬链接必须按同一条具名策略在子树与汇总两条路径上计数(这正与第二章 v2 schema 的"硬链接去重以对齐
du"呼应——两条路径共用同一策略,否则预览和汇总必然分歧)。
3.3 回归测试:比较两个渲染面,而非钉死字面量
tests/clean_core.bats 中的用例mo clean --dry-run keeps container totals and preview paths consistent (#1282)是"预览 vs 汇总"模式的活样例:它先解析预览文件中的# Potential cleanup:/# Items:/# Categories:三个头部,再断言最终输出中这三个数字与预览文件一致:
preview_total=$(sed -n 's/^# Potential cleanup: //p' "$preview") preview_items=$(sed -n 's/^# Items: //p' "$preview") preview_categories=$(sed -n 's/^# Categories: //p' "$preview") ... grep -F "Items: $preview_items" | grep -F "Categories: $preview_categories" | grep -qF "$preview_total" || return 1注意测试的写法:它没有写死 "Items: 3" 这样的字面量,而是从预览面取值再与汇总面比对——这正是文档所要求的"compare the two rendered surfaces in a regression test rather than pinning an unrelated literal"。同文件 L221-L242 还验证了另一条规则:guard 拒绝的候选必须在 dry-run 下同样阻止预览登记("A guard that refuses must stop the preview the same way it stops the real run"),即"拒绝项不进入预览账本"。
3.4 单位定义也要"唯一定义"
"十进制对二进制单位"这一对分歧在 Mole 中被 internal/units/bytes.go 显式管理。包注释说明了两条命令有意采用不同约定:analyze格式化磁盘数字用 SI(1000 进制)以对齐 Finder/diskutil;status报告内存与实时计数器用二进制(1024 进制)以对齐活动监视器与 gopsutil。包内提供BytesSI、BytesBin、BytesBinShort、BytesBinCompact四个格式化器,连边界语义都是刻意区分的:BytesBin用>使 1024 仍显示为 "1024 B",BytesBinShort用>=使 1024 升格为 "1K"。把定义集中在一处,任何精度/标签调整都只改一个文件——这是"选择唯一定义"准则在单位层的落地。
四、模式 10:沉默会被读成冻结
4.1 核心命题
慢速工作在 spinner 窗口之外,即使有界,看起来也像挂死。
文档记录了代表案例:提交8f064707中,一个删除循环在做昂贵工作之前就停掉了 spinner;dotdir、登录项、System Data、大文件扫描都出现过同构问题。修复方法是"走查完整的渲染区块,测量每一个大约超过一秒的操作"。
4.2 Mole 的输出节奏
文档给出了 Mole 的终端区块节奏(rhythm):
section title loading state content one trailing blank line两条关键时序规则:
- spinner 必须在会覆盖它的输出出现之前立即停止;如果之后还有更多静默工作,则重新启动 spinner;
- 超时警告不能替代健康慢扫描期间的进度反馈。
在 cmd/analyze/constants.go 可以看到与"可见进度"直接相关的参数:batchUpdateSize = 100(每批多少条目刷新一次 UI)、uiTickInterval = 100ms(UI 心跳间隔)、scanSendTimeout = 100ms、scanPathInlineMinWidth = 24(终端窄于 24 列时扫描路径独占一行,避免过度截断)。spinnerFrames则定义了| / - \四帧动画。这些常量共同保证:扫描期间 UI 以 100ms 级心跳推进,进度数字以 100 条为一批更新,慢操作不会长时间停留在同一帧。
4.3 性能工作的"两张凭证"与禁区
文档要求性能优化必须交出两份凭证:
- 有界的微基准或调用次数不变量,隔离被测路径;
- 同一模式、同一机器条件下的端到端命令计时。
同时明确了一条禁区,原文:
不要靠"缓存目录大小"来优化:APFS 不会把子孙文件的 mtime 传播到父目录。
这条禁区解释了 constants.go 为什么需要cacheModTimeGrace = 30 * time.Minute宽限窗口——macOS 上目录 ModTime 噪声大(见 cache.go 的注释:"Directory mod time is noisy on macOS; reuse recent cache..."),直接以父目录 mtime 判断内容变化在 APFS 上不可靠。文档建议的优化方向是:不存在的目标、重复的属主工具启动、仅用于报告的计量、错误作用域的扫描;而对破坏性工作,最终属主探测与身份重绑定即使昂贵也必须保留(与 SKILL.md "Do not trade final-sink rebinding or fail-closed owner checks for speed" 一致)。
五、模式 16:异步代次与采样新鲜度是同一份契约
5.1 代次(generation)契约
文档的核心命题:
一个异步结果在它产生时可以是有效的,到达时却可能已经过期。
处置方法:每个请求打上单调变化的代次或探针 ID,随结果消息携带,结果只应用到匹配的代次上。代次递增发生在刷新或导航过渡产生新请求时,而不是视图重绘时。
测试要求双向覆盖生命周期:旧结果不得覆盖新刷新;当用户处于钻取(drill-down)状态时,一个匹配代次的结果到达仍然值得保留;返回概览时必须调度新探针,而不是把旧结果当新测数据展示。文档举例:异步 Time Machine 计数需要覆盖"刷新、离开、返回、乱序到达"四类用例,而不是只测一条快乐路径命令。
5.2 缓存指标的"原子采样"契约
缓存指标使用平行契约——把相关字段当作一个原子样本对待:
value group + collected_at + stale + completeness刷新瞬时失败时:返回错误,但保留上一成功组的可见性,带上其原始采集时间与stale=true。三个禁止项:不许把旧值和新刷新时间拼在一起;不许只清空组内一半字段;不许把未测数据变成"测得的零"。下一次成功采样整体替换该组并把 stale 复位为 false。
这条契约在 Mole 源码中有直接的结构性证据。cmd/status/metrics.go 的MetricsSnapshot中,进程相关字段被刻意设计为可空指针并成组出现:
ProcessCollectedAt *time.Time `json:"process_collected_at,omitempty"` ProcessStale *bool `json:"process_stale,omitempty"` ZombieCount *int `json:"zombie_count,omitempty"` ZombieParents []ZombieParent `json:"zombie_parents"` ZombieParentsComplete *bool `json:"zombie_parents_complete,omitempty"`文档明确点名:"状态进程行、僵尸计数、父进程归属与父进程完整性是同一组"——对应到上表就是TopProcesses+ProcessCollectedAt+ProcessStale与ZombieCount+ZombieParents+ZombieParentsComplete。指针类型区分"测得的零"(ZombieCount = 0)与"从未测得"(字段缺省),collected_at与stale随组整体替换。顶层还有快照级的CollectedAt(L63),保证每个样本自带采集时间。
文档最后一条要求:每个序列化器与 fast/full/watch 路径都必须保持"新鲜数据 / 过期但最后已知良好 / 测得的零 / 从未测得"四种状态的区别;一个只测首次采集、不测"下一帧或消费它的失败刷新"的缓存测试是不完整的。
六、可操作的核查清单
把四节准则压缩成一条可直接执行的检查流程,与原文档一一对应:
- 每个持久化派生值:schema 版本?TTL?每个输入变更点是否都触发了失效?调用者是否把缓存值当作"不存在"的证明?验证跑会不会读到上一 release 的数据?(证据:cmd/analyze/cache.go、lib/core/pkg_receipts.sh)
- 每个被渲染两次的数值:列出所有生产者,选定唯一定义,把测量值传入渲染端;回归测试比较两个渲染面。(证据:tests/clean_core.bats、internal/units/bytes.go)
- 每个渲染区块:逐操作计时,凡约超过一秒且无反馈的,纳入 spinner 管理;spinner 先停后输出,超时警告不替代进度。(证据:cmd/analyze/constants.go 的
uiTickInterval/batchUpdateSize) - 每个异步结果:有代次标签、按代次应用、双向生命周期测试;每个缓存指标组保持
value group + collected_at + stale + completeness原子替换。(证据:cmd/status/metrics.go 的ProcessStale/ZombieParentsComplete成组字段)
这四类缺陷的共同根源是把"数据曾经被正确计算过"误当作"数据现在仍然可用"。Mole 的实践给出了一致的答案:让每个派生值自带版本、时间、完整性与归属字段,让任何消费方(尤其是授权删除的消费方)在消费前先验证这些字段,而不是信任数据本身的在场。
【免费下载链接】Mole🐹 Clean, uninstall, analyze, optimize, and monitor your Mac. Free open-source CLI, plus a native Mac app.项目地址: https://gitcode.com/GitHub_Trending/mole15/Mole
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考