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,因此读模板只设超时、不设只读标志。
|
||||
Reference in New Issue
Block a user