perf(reader): M32 书架索引落盘 — 冷启动只 stat 434 个目录 mtime,跳过 readdir(3.0s→~0.15s)

M29 只解决了「回书架不重扫」;首次进书架仍要全量扫 434 目录(真机 3.0–3.4s,
其中 opendir+readdir 占 96%)。本单把扫描结果 + 每个扫描目录的 mtime 存到
MeowData/os.meow.reader/index/shelf.idx,冷启动逐目录 stat 比对 mtime,命中即
完全跳过 readdir;任何异常一律 fail-open 全量重扫。

先量再选(任务 1):宿主 434 目录/595 本/51 rar 虚拟库(贴真机量级)
- 434 次目录 stat = 0.48ms/pass(1.1µs/次,不含 readdir);
- 读 72KB 索引 = 0.009ms;全量扫描 3.54ms;索引命中 1.29ms(ts_fn=NULL)。
真机按 M28 实测 1079 次 stat=160–170ms(≈150µs/次)外推:434 次 ≈ 65ms,
远低于「可能不可行」的 1s 阈值 → 选「校验全部扫描目录 mtime」(非只 level-1)。
strace 断言:indexonly 载入 0 次 getdents64、恰好 434 次 stat(真的没 readdir)。
生产路径 ≈ 65ms(stat) + <20ms(索引读) + 40–50ms(rdprog×595) + <20ms(build_rows)
≈ 0.13–0.15s(对比 3.0s)。

索引格式 v1(手工小端序列化,不依赖结构体布局):
header(magic/version/header_size/root_len) + root + dir 记录×(rel,mtime_s,
mtime_ns,books_here) + book 记录×(rel,size,fmt) + trailer(ndirs,nbooks,flags,
depth_skipped,crc32,magic)。truncated/depth_skipped 存在 flags/字段里,载入时
如实重建提示条;**不存阅读进度 ts**,载入后每本由 rdprog_load 现读再排序。

失效语义(存每个扫描过的目录,不只 level-1):
- level-2 新增 / level-3 深处新增 / 删书 → 该目录 mtime 变 → 重扫;
- 一级目录改名 → 父目录 mtime 变 + 旧目录 stat ENOENT → 重扫;
- 超深(>6 层)未扫描子树不在索引内,其内部变化不自动失效(本来就不显示),
  点「刷新」更新 depth_skipped 计数;mtime 精度受限(exFAT 10ms/FAT32 2s)的
  同刻度变更也可能漏判,点「刷新」必可纠正。
M29 语义不变:首次进书架=索引或全扫;回书架=不扫;刷新=删索引+全扫(之后原子
写回新索引)。

安全/健壮(宁可慢不可错):
- 写:shelf.idx.tmp 全量写完(尽力 fsync)后 rename;中途被杀只剩 tmp,正式索引
  要么旧要么完整新;rename 覆盖失败先删旧再改名;fflush/写错即放弃,绝不影响书架。
- 读:O_NOFOLLOW|O_NONBLOCK + S_ISREG(FIFO/符号链接直接拒绝不阻塞);CRC32 覆盖
  全部记录;rel 做绝对路径/“..”/内嵌 NUL/长度校验;版本/根路径/计数/逐目录 mtime
  任一不符即重扫;载入崩溃路径全部 goto 清理,不改书架可用性。
- writer_open 失败(卡满/写保护/无 MeowData)→ 扫描不采集目录、照常出书;
  commit 失败(含只读目录 rename 失败)不留正式索引。

测试:test/shelf_index_smoke 154 pass(含四种变更场景失效、截断/字节翻转/垃圾/
缺失/FIFO/符号链接/版本不符/根路径不符、ts 现读、truncated/depth_skipped 重建、
只读目录写失败、空 Books、root 缺失、.tmp 残渣),ASan+UBSan 干净;
test/shelf_index_bench 模拟 434 目录/595 本/51 rar:scan/load 均 595 本、
truncated=0/depth_skipped=0、索引 72,156B。build_reader.sh 全绿 0 warning
(core 6846 / seg 19 / chapter 71 / shelf_group 63 / index 154 / font 14 /
loaders PASS)。shelf_bench_ondev 增加 index_load/scan_save 计时与
MEOW_BENCH_IDX=miss(真机首开命中 vs 未命中对比,待有卡复核)。

交付:app.json 0.1.4/code5 → 0.1.5/code6;reader md5 df4cadcf
1,711,924B;mpk md5 fec7b1bb 814,535B;未签名不推卡。
This commit is contained in:
AskaEth committed 2026-11-10 18:14:52 +08:00
1 parent 3f09f7c137
commit e0dfab95c3
11 files changed
+1388 -29

No files matched your search

+31 -7
View File
@@ -100,6 +100,8 @@ typedef struct scan_ctx {
int truncated;
int depth_skipped;
shelf_ts_fn ts_fn;
shelf_dir_fn dir_fn; /* M32:目录 mtime 采集(NULL=不采集) */
void *dir_ctx;
} scan_ctx;
/* 统计 dir 子树里还能识别的书籍文件数(只数不收集)。自身留 8 层硬上限,
@@ -125,13 +127,19 @@ static int count_books_under(const char *dir, int depth)
}
/* 递归收集。rel = 相对 root 的完整目录路径(root 自身为 ""),
* top = 第一级目录名(root 自身/根下文件为 "")。 */
static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, const char *top)
* top = 第一级目录名(root 自身/根下文件为 "")。
* dst = 本目录的 stat 结果(调用方已 stat;NULL=没有 mtime 可上报)。
* M32:扫描完一个目录后用 dir_fn 上报 (rel, mtime, 本目录收录书数),供索引做失效校验。
* books_here 只数**实际写进 books[] 的**书(被 cap 丢掉的不计),保证索引里
* sum(books_here) == nbooks,载入时可用它做一致性校验。 */
static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel,
const char *top, const struct stat *dst)
{
if (depth > SHELF_SCAN_MAX_DEPTH) return; /* 防深嵌套/循环 */
if (c->n >= c->cap) return; /* 已满:不再进新目录 */
DIR *dp = opendir(dir);
if (!dp) return;
int books_here = 0;
struct dirent *de;
while ((de = readdir(dp))) {
if (de->d_name[0] == '.') continue;
@@ -154,7 +162,7 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, c
char subrel[SHELF_GROUP_MAX * 16];
snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name);
/* 第一级目录名 = top;更深层沿用。de->d_name 在递归返回前有效。 */
scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top);
scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top, &st);
continue;
}
int f = shelf_fmt_from_ext(de->d_name);
@@ -172,12 +180,17 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, c
b->ts = c->ts_fn ? c->ts_fn(b->path) : 0;
shelf_fmt_sub(b); /* 副题:格式 · 大小 [· 读](+ folder 子路径) */
c->n++;
books_here++;
}
closedir(dp);
if (c->dir_fn && dst)
c->dir_fn(c->dir_ctx, rel, (int64_t)dst->st_mtime,
(long)dst->st_mtim.tv_nsec, books_here);
}
int shelf_scan_books(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep)
int shelf_scan_books_ex(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep,
shelf_dir_fn dir_fn, void *dir_ctx)
{
if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->depth_skipped = 0; rep->root_missing = 0; }
if (!root || !books || cap <= 0) {
@@ -191,9 +204,14 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap,
}
closedir(dp);
/* root 自身的 mtime 也要上报(新增/删除 Books/ 下的一级目录会改它)。 */
struct stat rst;
int have_rst = (stat(root, &rst) == 0);
scan_ctx c;
c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0; c.ts_fn = ts_fn;
scan_dir(&c, root, 0, "", "");
c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0;
c.ts_fn = ts_fn; c.dir_fn = dir_fn; c.dir_ctx = dir_ctx;
scan_dir(&c, root, 0, "", "", have_rst ? &rst : NULL);
shelf_sort_recent(books, c.n);
if (rep) {
rep->nbooks = c.n;
@@ -203,6 +221,12 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap,
return c.n;
}
int shelf_scan_books(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep)
{
return shelf_scan_books_ex(root, books, cap, ts_fn, rep, NULL, NULL);
}
/* ---- 行序列 ---- */
/* 分组键 = top(Books 第一级目录名)。长度截到第一个 '/',排序时忽略大小写。 */