Rewrite durable task lifecycle, gallery indexing and subscription publication
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
# 图片索引缓存(GalleryIndex)— 重写模块说明
|
||||
|
||||
## 问题
|
||||
|
||||
在线阅读的索引是「gid → 每一页 imgkey」,存在 `cache.db` 的两张表里:
|
||||
|
||||
| 表 | 列 | 含义 |
|
||||
|---|---|---|
|
||||
| `gidToKey` | `gid`, `key`, `pages` | 画廊头部:gid、画廊 key、总页数 |
|
||||
| `ImageKeyCache` | `gid`, `imgkey`, `page` | 每页的 imgkey |
|
||||
|
||||
旧实现(`GalleryManageService.cacheImagesKey`)依次做四件事:查缓存、判断是否需要重建、
|
||||
外呼抓 mpv 页、逐页 `INSERT`。由此产生的实际问题:
|
||||
|
||||
1. **没有唯一约束**。实测生产缓存库有 4 组重复 gid、97 组重复 `(gid,page)`。
|
||||
2. **整组替换不是原子的**。先写头部再逐页写,中途失败会留下「头部命中、页 key 缺失」的
|
||||
半截索引;后续请求直接判定为已缓存,永远取不到图。
|
||||
3. **用行数判断完整性**。`count(*) == pages` 无法发现「页码 1、3 而 pages=2」这种跳号。
|
||||
4. **没有并发合并**。同一本画廊被并发打开时,两个构建者会互相删除对方刚写入的行。
|
||||
5. **逐页写入放大写事务**,抓取与写入之间没有事务边界。
|
||||
|
||||
## 交付物与职责
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `Service/GalleryIndexService.java` | 索引的唯一读写入口:同 gid 合并、抓取在事务外、校验后整组短事务替换 |
|
||||
| `Exception/GalleryIndexException.java` | 分类失败原因:INVALID_LINK / UNAVAILABLE / MALFORMED / TRANSIENT |
|
||||
| `Configuration/GalleryIndexConfiguration.java` | 缓存库的事务管理器与读写模板(独立文件,未改 SqlConfiguration) |
|
||||
| `Dao/cache/ImageCacheMapper.java` | 新增页码查询、脏行计数、批量写入(原有方法全部保留) |
|
||||
| `Maintenance/GalleryIndexMaintenance.java` | 离线体检与修复工具(只删不健康 gid 的整组索引) |
|
||||
| `scripts/migrate_image_index_cache.{sh,sql}` | 迁移:默认 dry-run、备份、清理、建唯一索引 |
|
||||
| `Service/GalleryIndexServiceTest.java` | 服务编排的行为约束(无 Spring、无网络) |
|
||||
| `Maintenance/GalleryIndexMaintenanceTest.java` | 工具在真实临时 SQLite 上的判定与删除范围 |
|
||||
| `Dao/cache/ImageCacheMapperSqlTest.java` | 注解 SQL 在真实 MyBatis + SQLite 上的验证 |
|
||||
|
||||
## 接口
|
||||
|
||||
```java
|
||||
GidToKey ensureIndex(String url) throws GalleryIndexException;
|
||||
```
|
||||
|
||||
返回该画廊的 `gid` / `key` / `pages`。三条不变量:
|
||||
|
||||
- **抓取在事务外**:`upstream.fetchPageKeys(url)` 先于任何写事务执行;写事务只在
|
||||
`publish(...)` 里开启,删除旧索引与批量写入在同一个事务内整体生效。
|
||||
- **校验先于写入**:行必须满足 gid 与请求一致、页码恰好为 1..N、不重复、imgkey 非空,
|
||||
否则抛 MALFORMED,此时尚未开启写事务,库中旧索引原样保留。
|
||||
- **读在快照内**:`lookup` 在同一个读事务里读头部与页码,避免读到整组替换的中间状态。
|
||||
|
||||
失败原因:
|
||||
|
||||
| Reason | 触发条件 | isPermanent() | 建议处置 |
|
||||
|---|---|---|---|
|
||||
| INVALID_LINK | 链接里取不到 gid 或 key | true | 直接回「链接错误」 |
|
||||
| UNAVAILABLE | 上游返回空页列表(下架/删除) | true | 回「该图片已下架或已被删除」 |
|
||||
| MALFORMED | 上游页 key 不自洽 | false | 可重试,回「网络波动或其他异常」 |
|
||||
| TRANSIENT | 上游异常或写库失败 | false | 可重试,回「网络波动或其他异常」 |
|
||||
|
||||
## 接入方式(由主模型完成 GalleryManageService 侧改动)
|
||||
|
||||
构造函数注入:
|
||||
|
||||
```java
|
||||
final GalleryIndexService galleryIndexService;
|
||||
```
|
||||
|
||||
`cacheImagesKey` 改为:
|
||||
|
||||
```java
|
||||
public String cacheImagesKey(String url) {
|
||||
Response response = Response.generateResponse();
|
||||
try {
|
||||
GidToKey gidToKey = galleryIndexService.ensureIndex(url);
|
||||
return response.success(objectMapper.valueToTree(gidToKey)).toJSONString();
|
||||
} catch (GalleryIndexException e) {
|
||||
response.failure(e.getReason() == GalleryIndexException.Reason.INVALID_LINK
|
||||
? "链接错误"
|
||||
: e.isPermanent() ? "该图片已下架或已被删除" : "网络波动或其他异常");
|
||||
return response.toJSONString();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Spring 装配无需额外配置:`GalleryIndexConfiguration` 已把 `GalleryIndexService` 注册为 bean,
|
||||
依赖 `ImageCacheMapper`(cache 数据源)与两个 TransactionTemplate。原来的
|
||||
`isIndexComplete` / `rollbackImageIndex` 私有方法与 `ImageCacheMapper` 的
|
||||
`countImageKeyCacheByGid` / `delete*ByGid` 仍保留,供旧调用方与非索引路径使用。
|
||||
|
||||
读取路径 `getCachedImage` 不需要改动:它按 `(gid,page)` 查 ImageKeyCache,现在会走新加的
|
||||
`ux_ImageKeyCache_gid_page` 唯一索引。
|
||||
|
||||
## 迁移
|
||||
|
||||
```bash
|
||||
# 1. dry-run(只读,不带 --apply 绝不写入)
|
||||
scripts/migrate_image_index_cache.sh --db /path/to/copy.db
|
||||
|
||||
# 2. 先在 SQLite 备份副本上演练
|
||||
sqlite3 -readonly /home/lionwebsite/cache.db ".backup '/tmp/cache-copy.db'"
|
||||
|
||||
# 3. 确认后执行(自动做 .backup 备份并校验)
|
||||
scripts/migrate_image_index_cache.sh --db /tmp/cache-copy.db --apply
|
||||
```
|
||||
|
||||
迁移只做两类写入:删除不健康 gid 的整组索引(重复头部、半截、跳号、空 imgkey)、
|
||||
删除孤儿页 key;随后建立 `ux_gidToKey_gid` 与 `ux_ImageKeyCache_gid_page`。
|
||||
若清理后仍有重复,建唯一索引会失败并整体回滚。真实的 `/home/lionwebsite/cache.db` 未被本次工作修改。
|
||||
|
||||
离线修复工具:
|
||||
|
||||
```bash
|
||||
java -cp sqlite-jdbc.jar Maintenance/GalleryIndexMaintenance.java --db /tmp/cache-copy.db # dry-run
|
||||
java -cp sqlite-jdbc.jar Maintenance/GalleryIndexMaintenance.java --db /tmp/cache-copy.db --apply # 执行
|
||||
```
|
||||
|
||||
对 `/home/lionwebsite` 下的库运行 `--apply` 会被拒绝,除非显式加 `--allow-production`。
|
||||
|
||||
## 测试
|
||||
|
||||
在 `/tmp` 的独立源码副本中运行(未复制任何生产 runtime config、未改生产库):
|
||||
|
||||
```
|
||||
mvn -o -Djacoco.skip=true \
|
||||
-Dtest='GalleryIndexServiceTest,GalleryIndexMaintenanceTest,ImageCacheMapperSqlTest' \
|
||||
-DfailIfNoSpecifiedTests=false test
|
||||
|
||||
Tests run: 9, Failures: 0, Errors: 0 -- GalleryIndexMaintenanceTest
|
||||
Tests run: 21, Failures: 0, Errors: 0 -- GalleryIndexServiceTest
|
||||
Tests run: 7, Failures: 0, Errors: 0 -- ImageCacheMapperSqlTest
|
||||
Tests run: 37, Failures: 0, Errors: 0
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
覆盖的关键场景:
|
||||
|
||||
- 同 gid 24 线程并发只抓取一次、只发布一次;失败结果也被等待者共享;失败后可重试。
|
||||
- 抓取发生在事务之外;所有删除与写入都在事务内;整组替换只有一个写事务。
|
||||
- 行数相同但页码跳号、空 imgkey、空 key、pages<=0 均判定为不完整并重建。
|
||||
- 写入失败整体回滚,旧索引与失败前逐字段一致。
|
||||
- 上游畸形数据(gid 不符、页码重复/跳号/越界、imgkey 为空)在开启写事务之前被拒绝。
|
||||
- 大规模索引按 BATCH_SIZE 分片写入;重复发布替换旧行而非累积。
|
||||
- 注解 SQL 在真实 SQLite 上验证,含唯一约束确实拒绝重复写入。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- 迁移后 gidToKey 433 条、ImageKeyCache 43704 条(原 443 / 43918)。
|
||||
被删除的是 4 个冲突或不完整 gid 的整组索引与 214 行关联页 key,由下一次在线阅读重抓。
|
||||
- 未做性能压测,不承诺加索引后的具体延迟收益,只确认查询计划已由全表扫描变为索引查找。
|
||||
- lookup 的读事务只是为了拿到一致快照;实测 sqlite-jdbc 不允许在已建立的连接上设置
|
||||
readOnly,因此读模板只设超时、不设只读标志。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 订阅缓存分代化 — 重写模块说明
|
||||
|
||||
## 问题
|
||||
|
||||
一个订阅账号对应两份内容:`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 语义过滤(内联节点与引用)与畸形正文拒绝 |
|
||||
@@ -0,0 +1,80 @@
|
||||
# 任务生命周期与协议 v2
|
||||
|
||||
范围:主站下载任务的持久化生命周期、与存储节点的可靠上报协议。对应评估报告第 2、6 节。
|
||||
|
||||
## 为什么重写
|
||||
|
||||
原实现里创建先`POST`上游、后落库,删除先删库、后等节点,节点上报异步写完后立刻清队列:上下游任一侧失败都会留下「主站与节点说法不一致」的状态,且没有任何东西能把它们对回来。
|
||||
|
||||
复现过的具体后果:模拟状态消息发送失败后,完成任务已从节点队列移除、消息却未发出,于是「归档已存在、主站仍显示未完成」。
|
||||
|
||||
## 不变量
|
||||
|
||||
1. `gid` 只有一个有效生命周期意图。创建先落唯一 `CREATE` 意图(并写一条可见的占位任务行),靠 `gallery(gid)` 主键唯一把并发创建收敛成一个赢家;输家直接得到业务失败。
|
||||
2. 上游副作用只在意图持久化之后发生。顺序固定为:登记意图 → 只读解析(`prepare`,无 POST)→ 持久化 `SUBMITTING` → 真正 `submit`。
|
||||
3. `SUBMITTING` 之后绝不自动重发。上游不支持幂等,因此超时/断连/落库失败/重启中断一律落 `UNKNOWN` 等待人工对账处理,而不是再 `POST` 一次。
|
||||
4. 任务行与周用量在同一短事务里写入;重复落库不重复计量。数据库失败时任务行与意图保持一致(要么都在,要么明确为 `UNKNOWN`),不允许出现「有任务没计量」。
|
||||
5. 删除失败保留记录。先落 `DELETE_PENDING` 与稳定 `deleteOperationId`,只有节点确认(或文件已不存在)后才在同一事务里清收藏、删任务行并留 `DELETED` 墓碑。
|
||||
6. 上报按 `revision` 单调推进,按 `operationId` 归属。迟到的旧世代上报被丢弃且不改变当前任务;墓碑(`DELETED`)永久忽略所有上报,已删除任务只能由显式 `CREATE` 重新建立新世代。
|
||||
7. 回执只在提交之后发送。数据库失败不回 ACK,节点保留快照重投;重复上报也回 ACK 以停止重放,但不会重复通知。
|
||||
8. 通知与 WebSocket 推送在事务提交之后、由独立执行器发送;慢通知不阻塞状态提交。
|
||||
|
||||
9. 只有确认已提交(`SUBMITTED`)或迁移前确实提交过的旧任务才允许下发给节点。`UNKNOWN`、`RESERVED`、`SUBMITTING`、`DELETE_PENDING`、`DELETED` 一律不下发:上游不支持幂等,把未确认/未计量的任务重投会在节点侧变成「下载完成却 0 额度」。
|
||||
10. 启动时先绑定上报处理器与判定解析器、执行恢复(`SUBMITTING → UNKNOWN`、释放 `RESERVED`),完成之后才允许连接与自动重投;恢复失败时只保持连接,禁止自动派发。
|
||||
|
||||
## 状态表
|
||||
|
||||
| 层 | 取值 |
|
||||
| --- | --- |
|
||||
| `task_operation.state` | `RESERVED` `SUBMITTING` `SUBMITTED` `UNKNOWN` `DELETE_PENDING` `DELETED` |
|
||||
| 任务展示状态 | `提交中` `已提交` `下载中` `等待压缩` `压缩中` `下载完成` `提交未知` `待删除` `删除失败` |
|
||||
| 节点上报状态码 | `1` 下载中 `2` 等待压缩 `3` 压缩中 `4` 下载完成(沿用旧码) |
|
||||
|
||||
状态码到中文文案只在 `TaskStatus` 一处映射。
|
||||
|
||||
## 线协议
|
||||
|
||||
帧格式不变:`[messageType(1B)][length(4B, 大端)][JSON]`。旧类型 `0..9` 的字段一个都不动(旧节点 Jackson 会拒绝未知字段)。
|
||||
|
||||
| 类型 | 消息 | 关键字段 |
|
||||
| --- | --- | --- |
|
||||
| 10 | `ProtocolHelloMessage` | `protocolVersion=2`, `features=["reliable-task-v2"]` |
|
||||
| 11 | `TaskCommandMessage` | `operationId`, `action=DOWNLOAD\|DELETE`, `gid`, `name` |
|
||||
| 12 | `TaskReportMessage` | `reportId`, `revision`, `galleryTasks[]`, `operationIds{gid→op}` |
|
||||
| 13 | `TaskReportAckMessage` | `reportId`, `result` |
|
||||
|
||||
流程:主站先发旧 `Identity`,再发类型 10;节点回类型 10 声明 v2 后,才允许使用 `11..13`。未协商成功时自动退化为旧的 `DownloadPostMessage`/`DeleteGalleryMessage`(此时不承诺可靠 ACK,属已知兼容边界)。
|
||||
|
||||
## 部署与迁移
|
||||
|
||||
1. 用 `scripts/migrate_task_operation_v2.sql` 建表。脚本只有独立语句、不含 `BEGIN/COMMIT`,由外层 wrapper 包事务并在执行前备份。
|
||||
2. 该脚本还会在 `gallery(gid)` 上建唯一索引。历史库若已有重复 gid,这条语句会失败:先人工确认重复来源再处理,不要直接删行。
|
||||
3. 主库连接需要 busy 等待(`SqlConfiguration` 已为主数据源设置 `PRAGMA busy_timeout=5000`)。单文件 SQLite 并发开短事务时,没有等待窗口会直接抛出 `SQLITE_BUSY`。生产库建议同时启用 WAL。
|
||||
4. 旧任务行没有操作记录,首次收到上报时会按显式策略补建一次「世代」(优先采纳上报里的 `operationId`),不会改动其已有状态。
|
||||
|
||||
## 验证
|
||||
|
||||
`mvn -o test`:549 项,0 失败/错误/跳过。
|
||||
|
||||
新增关键用例(`TaskLifecycleIntegrationTest`,真实 SQLite 文件 + 真实事务/触发器/唯一约束,落在 `/tmp` 临时目录):
|
||||
|
||||
- 意图与占位行先于上游 `POST`;`SUBMITTING` 先于 `submit`。
|
||||
- 同 `gid` 6 线程并发创建:上游只被提交一次、任务行不超过一条。
|
||||
- 上游超时后落 `UNKNOWN`;再次点击创建也不会第二次 `POST`。
|
||||
- 重启恢复把中断的 `SUBMITTING` 判为 `UNKNOWN`(任务行同步显示「提交未知」),安全释放 `RESERVED` 占位,且不改写 `operationId`、不重发。
|
||||
- 额度写入失败时落库事务回滚,任务保持可对账的 `提交未知` + `UNKNOWN`。
|
||||
- 重投/重试复用同一 `operationId`。
|
||||
- 判定为拒绝的任务(`UNKNOWN` 等)绝不下发,也不退化为无 `operationId` 的旧命令。
|
||||
- 删除失败保留任务行与意图,重试复用同一删除 ID;成功后清收藏、删行、留墓碑。删除意图 ID 不匹配时不得完成删除。
|
||||
- 正在提交(`RESERVED`/`SUBMITTING`)的任务拒绝删除;释放预占只清匹配世代的记录与占位行。
|
||||
- 落库严格匹配本次创建世代(expectedOperationId),被删除/换代后不得复活已删 gid。
|
||||
- 墓碑永久忽略所有上报(含同一 `operationId`),只允许显式 `CREATE` 用新 `operationId` 重建。
|
||||
- 旧节点不带 `operationId` 的连续上报保持可接受;新 v2 已知世代缺归属的上报仍被拒。
|
||||
- 过期 `revision`、缺 `operationId`、待删除任务的上报都被丢弃;未知 gid 忽略但回执。
|
||||
- 重复上报不重复通知;回执写入失败时 `apply` 返回不可回执结果。
|
||||
|
||||
`TaskProtocolV2CodecTest` 锁定类型 10..13 的帧头与字段名,并断言旧 `DownloadPostMessage` 没有混入新字段。
|
||||
|
||||
## 已知限制
|
||||
|
||||
未做双机真实故障演练。`UNKNOWN` 目前只提供状态与持久化记录:任务行会显示「提交未知」并拒绝自动重投/重试,对账与人工解决的接口由后续批次补齐。
|
||||
Reference in New Issue
Block a user