Files
lionwebsite-backend/docs/SUBSCRIPTION_CACHE_REWRITE.md

62 lines
4.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 订阅缓存分代化 — 重写模块说明
## 问题
一个订阅账号对应两份内容:`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` 引用(由主模型提供) |
## 目录布局
```
<cacheRoot>/<accountId>/current 指针(Properties 文本,原子替换)
<cacheRoot>/<accountId>/generations/<generation>/v2ray.txt
<cacheRoot>/<accountId>/generations/<generation>/clash.yaml
<cacheRoot>/<accountId>/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 语义过滤(内联节点与引用)与畸形正文拒绝 |