# 订阅缓存分代化 — 重写模块说明 ## 问题 一个订阅账号对应两份内容:`v2ray.txt`(Base64 节点)与 `clash.yaml`(Clash 配置)。 旧实现先 `atomicWrite` 写 v2ray,再 `atomicWrite` 写 clash,两次替换彼此独立: 1. **两份文件可以不同版本**。第二次写入失败(磁盘满、进程被杀)时,用户会拿到 「新 v2ray + 旧 clash」的混合版本,且没有任何状态能表明它已经撕裂。 2. **读取方无法保证配对**。分发路径与备机快照各自 `Files.readAllBytes` 两个路径, 在这两次读之间完成一次刷新,就会读到跨版本组合。 3. **过期刷新可能复活**。在途刷新返回时只比较上游 key 与过滤开关;一次 `invalidateCache` 之后,迟到的响应仍会把内容写回被清空的路径。 4. **失败留半成品**。第一次写成功、第二次失败时,磁盘上就是只有一份文件的账号。 ## 交付物与职责 | 文件 | 职责 | |---|---| | `Util/SubscriptionCacheStore.java` | 分代存储:两份文件同一分代写入 → 校验 → 指针原子发布;读取始终来自指针指向的同一分代 | | `Service/SubscriptionRefreshService.java` | 抓取与解析在协调器锁外进行,发布短持锁;`invalidateCache` 递增版本使在途刷新失效 | | `Service/SubscriptionStandbySnapshotService.java` | 快照构建在读锁内通过同一 store 解析成对内容,保证同版本 | | `Util/ClashSubscriptionParser.java` | 按 YAML 语义过滤高倍节点并清理 `proxy-groups` 引用(由主模型提供) | ## 目录布局 ``` //current 指针(Properties 文本,原子替换) //generations//v2ray.txt //generations//clash.yaml //v2ray.txt + clash.yaml 旧结构,仅在两份都在时按 legacy 读 ``` `current` 记录分代名与两份文件的 SHA-256。分代名是定宽 `时间戳-序号`, 因此字典序即时间序;默认只保留最近两个分代(当前与上一个)。 ## 不变量 - **发布最后一步才切指针**:先写两份文件,读回校验长度与内容,再原子替换 `current`。 抓取失败、解析失败、任一份写入失败、指针写入失败,都会回滚新分代目录并保留上一个完整版本。 - **读取配对**:`read` / `resolve` / `hasComplete` 走同一份 `load` 契约,每个调用只读一次 指针快照,再按该快照校验双文件哈希。只有两份齐全且匹配才算一个有效版本; 指针缺失才回退 legacy(两份齐全时),指针存在但读不出完整版本(半写、篡改、缺字段) 时按无可用缓存处理,不回退到更旧的半份内容。 - **分发不会发出半份缓存**:`cachedPath` 在无有效版本时返回保证不存在的哨兵路径 (`.unavailable/`),使 `SubService` 判定为 503,而不会被单份 legacy 文件骗过。 `SubService.updateSub` 在读锁内完成 resolve 与 `FileDownload.export`,与 publish/invalidate 互斥。 - **失效不可复活**:`invalidateCache` 在写锁内删除指针/分代/旧结构文件,并移除该账号的 刷新版本标记;在途响应返回时因版本标记对不上而被丢弃,不会重新发布。 - **兼容旧结构**:只有当账号根下 `v2ray.txt` 与 `clash.yaml` 都在时才读为 legacy 版本; 新分代发布成功后旧结构文件被移除,避免两份来源并存。 ## 测试 | 测试 | 锁定行为 | |---|---| | `Util/SubscriptionCacheStoreTest` | 发布后两份同分代;第二份写入失败 / 指针发布失败均保留旧版本;legacy 双文件才有效;半份 legacy、缺一份分代、内容被篡改、坏指针、指针指向缺失分代均判定不可用;每次查询只读一次指针;哨兵路径保证不存在 | | `Service/SubscriptionCacheGenerationTest` | 在途旧刷新在失效与新版发布后被丢弃;被丢弃的刷新不留任何缓存 | | `Service/SubscriptionFilteringTest` | 过滤规则;失败保留上次完整版本;`cachedPath` / `hasCompleteCache` 新契约;legacy 路径定位 | | `Service/SubscriptionStandbySnapshotServiceTest` | 快照从单一分代读成对内容;不完整 legacy 账号不入快照 | | `Util/ClashSubscriptionParserTest` | YAML 语义过滤(内联节点与引用)与畸形正文拒绝 |