Readest 同步数据丢失修复实录:整行 LWW 抹掉书籍分组与描述的根本原因链,以及 groupUpdatedAt 字段时钟修复
2026/9/20 23:36:18 网站建设 项目流程

Readest 同步数据丢失修复实录:整行 LWW 抹掉书籍分组与描述的根本原因链,以及 groupUpdatedAt 字段时钟修复

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

导读

本文完整还原 Readest 中两个曾被报告为独立缺陷的同步数据丢失问题——**#5911「全新安装 + WebDAV 全量同步抹掉书籍分组」**与#5912「第三方同步丢失书籍描述」——如何被确认为同一根因,并通过一次「字段级时钟(field-clock)」修复(PR #5921)彻底根治。你将掌握:整行 LWW(Last-Writer-Wins)合并为什么会在updatedAt被上传等无关操作误打戳时产生不可逆的数据擦除;Readest 如何用groupUpdatedAt复刻此前readingStatusUpdatedAt/coverUpdatedAt/metadataUpdatedAt的修复范式;以及修复过程中「未打戳 TIE 不回退行胜者」「缺失 metadata 永不获胜」「增量同步 O(changed)」三条承重设计原则为何缺一不可。


一、问题全景:#5911 与 #5912 是同一个缺陷

  • #5911:全新安装后执行 WebDAV 全量同步,会把已有的书籍分组(book groups)整组抹掉;
  • #5912:第三方同步(文件型同步后端)会丢失书籍描述(metadata.description);
  • #5910:阅读器菜单对第三方同步用户显示「Never synced」,属于独立问题,不在本次修复范围内。

文档明确记录:#5911 与 #5912 是同一处缺陷的两个表象;而 PR #5905 对三者一个都没有修——它只动了engine.tsrunLibrarySync.tsuseBooksSync.ts的三行,merge.tswire.tstransform.tsapi/sync.ts以及阅读器菜单均未触及。三个报告者运行的 0.12.1 版本也早于该 PR。

二、根因:分组与描述挂在整行updatedAt时钟上,且是「裸覆盖赋值」

核心机制是 Readest 的同步合并策略:整行 LWWgroupId/groupNamemetadata(其中承载description)都依靠整行的updatedAt判断谁新谁旧,合并时用对象展开(spread)直接覆盖。

问题的关键在updatedAt本身被与分组、描述无关的操作反复打戳。最典型的是上传:

  • src/services/cloudService.ts 中的uploadBook每一次上传时都会执行book.updatedAt = Date.now()(见 L236),同时打戳uploadedAtdownloadedAtcoverDownloadedAt
  • 该调用由transferManager.executeBookTransfer以队列驱动,产生相隔数秒的连续时间戳——这正是报告者观察到的「19 条记录在约 8 秒内updatedAt连续递增」指纹。

于是出现这样的链条:一台设备上传书籍文件 → 本行updatedAt被推高 → 它持有的「从未分组的旧行」在整行比较中胜出 → 把真正做过分组编辑的另一台设备的分组覆盖掉 → 空行再被同步回每一台设备,形成全舰队级擦除。transformBookFromDB永远物化groupId/groupName/metadata键(列值为空时输出undefined/null),所以updateLibrary里的 spread 一定会覆盖;且比较用的是>=仅仅是时间戳持平(TIE)也会抹掉分组

更隐蔽的是:库内其他具有同类风险的字段早就拥有了独立时钟——readingStatusUpdatedAt(#4634 / 迁移 015)、coverUpdatedAt(#4544 / 迁移 017)、metadataUpdatedAt(#5438 / 迁移 018)。只有分组从未获得自己的时钟;而 metadata 的时钟在「旧数据未打戳」的 TIE 场景下会被绕过——pickFresherMetadata0 === 0时返回null,于是行级 spread 照常生效,描述照样被清空。

三、为什么「全新安装 + WebDAV」恰好触发

对只启用 WebDAV 的用户,Readest Cloud 分支为何会介入?答案在 src/services/sync/cloudSyncProvider.ts:

export const isReadestCloudEnabled = (settings) => settings?.readestCloud?.enabled ?? !hasAnyThirdPartyEnabled(settings);
  • 纯 WebDAV 用户readestCloud.enabled未显式设置时推导为false,其分组编辑永远不会到达 Readest Cloud,云上行停留在「最后一次开启云同步时」的旧状态;
  • 全新安装:尚未配置任何后端,推导为true,登录后立即把那些陈旧的未分组行恢复下来;
  • 随后 WebDAV 全量同步的索引重推(index re-push)把这些空行写进好行,全舰队拉取到被清空的行。

这条链路恰好验证了报告者自己的第 2、3 条猜测。

四、探针验证:在真实引擎上复现的八个场景

文档记录了针对真实同步引擎(运行后即删除)的探针结果,下表完整列出:

探针结果
A文件同步:本地未分组行 + 较新updatedAt推送的 library.json 中分组被抹掉
B同上但时间戳持平→ 分组仍被抹掉(拉取侧shouldApplyRemoteBookMetadata是严格>,推送侧无条件把本地写在远端之上)
C书籍只存在于远端 hash 目录 → 被搁置为author: 'Unknown'无 metadata、无分组,且updatedAt: Date.now()使其胜过所有真实行(engine.ts约 L965)
DmergeBookMetadata遇到无 metadata 的远端 → metadata 通过?? local保留,正常
EuseBooksSync.updateLibrary{...localRow, ...cloudRow}在 TIE 下 →groupId: undefinedmetadata: null
F推送的索引确实携带 metadata 与分组,正常
G本地无 metadata 的行 →抹掉 library.json 中的metadata.description(#5912 传播链)
I完整 #5911 链路端到端:BOOX 发布未分组行 → PC 上已分组的行在拉取时被覆盖

五、修复落地:完整字段时钟镜像 015/017/018

PR #5921(squash 80f196a9b)按既有范式为分组补上了自己的时钟,全链路共六块:

1. 数据库迁移 022

docker/volumes/db/migrations/022_add_group_updated_at.sql 新增可空列:

ALTER TABLE public.books ADD COLUMN IF NOT EXISTS group_updated_at timestamp with time zone NULL;

迁移文件明确声明故意不回填(DELIBERATELY NOT BACKFILLED),两条理由中第二条是承重级的:

  • updated_at记录的不是「分组何时被设置」,它被翻页、上传随意打戳(这正是缺陷本身),把它抄进group_updated_at等于伪造一次从未发生过的分组编辑,会把 #5911 永久复现;
  • 服务端回填够不到真正坏掉的数据——#5911 报告自 WebDAV,其行数据存在于各设备的 library.json 与第三方存储中,任何迁移都不触及。历史数据必须在合并逻辑下安全,而非依赖一次性 UPDATE,否则只有 Readest Cloud 用户受益。

2. 类型与双向 transform

  • Book类型新增groupUpdatedAt(见 src/utils/book.ts 的BookGroupFieldstypes/book.ts),DBBook新增group_updated_at(src/types/records.ts);
  • src/utils/transform.ts 两个方向都做了转换:transformBookToDB输出 ISO 字符串(L102)、transformBookFromDB还原为毫秒时间戳(L154)。

3. 客户端共享解析器:pickFresherGroup/bookGroupDiffers

src/utils/book.ts 的pickFresherGroup是分组合并的唯一真源,同时被 Readest Cloud 原生合并(useBooksSync)与第三方文件同步合并(services/sync/file/merge.ts)复用,保证两个后端解析结果一致。解析顺序如下(源码注释即契约):

  1. 打戳不同 → 新戳胜出:真实的分组编辑——包括移除分组——无论谁赢得整行,都能传播(#4942 契约);
  2. 打戳相同、一边有组一边无组 → 有组的一边胜出:TIE 下「从未分组」与「被过老客户端移除了分组」无法区分,而存量设备全部未打戳(0 === 0)。抹掉真实分组不可恢复,丢失一次「未分组」可以恢复,因此保留分组是唯一安全解读;
  3. 打戳相同且两边都有组 → 行胜者:两个真正竞争的组回退到历史整行行为。

bookGroupDiffers(L303-L305)做值级差异比较,用于判断解析结果是否真的改变了当前分组,避免纯时间戳差异引发无谓写回。

4. 服务端镜像:resolveGroupMerge/bookGroupChanged

src/pages/api/sync.ts 的resolveGroupMerge是服务端同名实现,语义与客户端完全一致(clientRowWins对应remoteRowWins);bookGroupChanged(L209-L214)是传播阶段的 no-op 守卫,仅时间戳不同而分组相同不得重写服务端行。在合并主流程中(L768),分组与阅读状态、封面、metadata 一样在自己的时钟上解析,然后接入toUpdate/ 传播分支(L782-L784、L820)。

5. 客户端合并接入点

  • Readest Cloud:src/app/library/hooks/useBooksSync.ts 在行级 spread 之后调用pickFresherGroup(oldBook, matchingBook, remoteRowWins)并把结果写回mergedBook,随后用mergedBook.metadata = mergedBook.metadata ?? oldBook.metadata ?? matchingBook.metadata兜底 #5912;
  • 第三方文件同步:src/services/sync/file/merge.ts 在mergeBookMetadata末尾以pickFresherGroup(local, remote, remoteMetaNewer)解析分组,再以 L202 的??链保护 metadata blob。

6. 全部分组变更点打戳

groupUpdatedAt只在真实的分组编辑发生时被设置,仓库中的四处均已覆盖:

  • GroupingModal.tsx 四处:移除分组(L138)、重命名(L160、L165)、确认分组(L218),且明确「移除也必须打戳——未打戳的未分组行会被解读为『从未知道过该组』而在设计上输给有组的对端」;
  • ingestService.ts:导入时指定分组,包括空串「降级到根目录」也必须打戳;
  • useClipUrlIngress.ts:剪贴板 URL 导入入库时打戳。

六、承重设计选择:未打戳的 TIE 不回退到行胜者

这是与 metadata / status / cover 三套时钟最大的不同——它们打戳相同时回退到行胜者,而分组绝不

在 TIE 上,有组的一边获胜。否则修复只能防止新损伤:每一行存量数据都未打戳,整个存量舰队会一直坏下去。

代价是改变了 #4942 契约merge.test.ts中「propagates group removal」用例被重新打戳后通过;而旧客户端发来的未打戳移除不再传播——这是刻意为之的 fail-safe:真正的移除总是带更新的group_updated_at,在第一步就能赢;会输掉的只有「未打戳的移除」,它本就无法与「从未分组」区分。

七、#5912 的另一半:缺失的 metadata blob 永不获胜

文档与代码共同确认一条原则:应用内没有任何代码会清空book.metadata,因此缺失永远意味着「从未有过」,绝不意味着「用户清除了它」。所以:

  • mergeBookMetadata(merge.ts L202)、resolveMetadataMerge(服务端)与useBooksSync.updateLibrary(L302)三处都改为??兜底——缺失 blob 在任何时钟上都不允许获胜;
  • 否则一个 metadata-less 的对端(云书架行、发现行、旧客户端)会随手抹掉全舰队的书籍描述。

八、性能陷阱:第一次写错了,以及 O(changed) 铁律

初版修复(commit af4c3355f)被 chrox 发现存在性能问题,在 22a8841f3 中修正。问题链条:

  1. 无时钟的修复性合并子句让shouldApplyRemoteBookMetadata修复后首次运行时对整个图书馆同时触发;
  2. 最初以为把封面/config 的 GET 用新的isRemoteBookClockNewer门控就够了——但昂贵的部分其实是写store.updateBookMetadatauseLibraryStore.updateBooksaveLibraryBooks重写整个 library 文件外加其.bak备份,每本书一次;
  3. 181 本书 = 后台同步期间 181 次全库写,字节量呈二次方增长。

由此确立(chrox 的规则,与 [[file-sync-converge-5900]] 相同):增量同步默认必须 O(changed);修复属于全量同步(Full Sync)的职责。评估 O(changed) 时数的是本地库写入次数而非远端请求——saveLibraryBooks是整文件写,任何逐书调用都是 O(library)。

修复 = 把两个关注点拆开:

  • isRemoteBookMissingLocally(修复本机书架)→只在fullSync时执行
  • resolvePublishedBook止损)→无条件且免费(FREE)。传播完全发生在推送侧:索引重推用本地行重建 library.json,一个仅仅在行时钟上打平的行会把自己的空副本发布到对端之上。而「逐行对照远端索引条目做解析」是在推送本来就要遍历的 map 上纯内存完成的——无请求、无写入。它只主张一句话:「发布绝不允许删除远端已拥有的东西」;本地行在自己时钟上仍然获胜。

最终效果:增量同步永不修复、永不写入,但也不再能清空对端的书架;全量同步把设备自己的分组/描述放回去。

resolvePublishedBook的完整实现见 src/services/sync/file/merge.ts。

九、CodeRabbit 补刀:发布路径的 metadata 必须按组解析

CodeRabbit(r3880791184,fix 8211233aa)指出resolvePublishedBook解析 metadata 组时必须metadataUpdatedAt时钟上、且作为「组」整体移动(title / author / tags / blob / 打戳一起),而不能偏好任何非空本地 blob——否则发布行会把一台设备的书名配另一台设备的简介。

该路径在两种同步策略下可达性不同:

  • 'silent'策略下不可达:远端metadataUpdatedAt更新本身就令isRemoteBookClockNewer为真,reconcile 会在推送之前把解析结果应用到本地行;
  • 'send'策略下可达且承重:该策略不应用远端任何东西,整个 reconcile 块被跳过,发布解析器是唯一的防线。探针验证:send把「Stale blurb」发布到「Newer blurb」之上,silent则不会。

由此沉淀一条普适规则:在 PUSH/发布路径上加任何守卫,都要单独在'send'策略下验证——canPull为 false 会跳过每个 reconcile 块,那时发布路径独自承重。

十、验证与测试覆盖

  • 门禁:pnpm test10349 通过 / 16 跳过,pnpm lintpnpm format:check均干净;
  • 三个新守卫做了 mutation 检查:各自被删除时分别有 7 / 3 / 6 个用例翻红;
  • 客户端解析器测试 src/tests/utils/group-clock.test.ts 覆盖:新戳胜出、打戳分组输了行时钟仍保留、带戳移除传播未打戳未分组行永不抹组(#5911 数据丢失场景)、无组侧采纳对端分组、同戳双组回退行胜者、双未分组保持未分组;
  • 服务端镜像测试 src/tests/pages/api/sync-group-merge.test.ts 覆盖同名语义,以及bookGroupChanged的 undefined/null 等价与无传播抖动;
  • 引擎级端到端测试 src/tests/services/sync/file/engine-group-metadata-5911.test.ts 直接复现 BOOX 场景:未打戳未分组本地行不抹索引分组、全量同步恢复丢失分组、TIE 不抹组、带戳移除仍传播、输了行时钟的分组编辑仍到达索引,以及增量保持 O(changed)、发布永不抹掉对端 metadata 编辑等分组。

十一、遗留问题:#5910 未修复

阅读器菜单的同步状态行仍只读取 Readest Cloud 的四个BookConfig打戳字段,且其点击派发的是sync-book-progress事件——useFileSync/ KOSync并不监听该事件,因此对第三方同步用户,这个手动操作是无效的。属于独立工单,修复时需要注意与本次工作区分。

十二、工程经验:共享 worktree 并行会话的 git 陷阱

本次修复还踩到一个协作坑:同一共享 worktree 上并行的另一个会话提交并推送到了当前分支,导致 PR #5921 静默地把对方的 AZW3 提交(377a26164)、foliate 重固定等带进了自己的 diff。给出的实践规范是:

git checkout -B <branch> origin/main git cherry-pick <mine...> git push --force-with-lease

并且在宣告 PR 干净之前,永远执行git diff --name-only origin/main..HEAD核对文件清单。对方提交在其自身分支(74aea1bf4)上是安全的。

十三、相关记忆与后续阅读

  • [[file-sync-converge-5900]](增量同步 O(changed) 规则的发源地)
  • [[sync-fixes]]、[[sync-clock-skew-lastsynced-5661]]

如需从源码继续深入:分组合并客户端真源在 src/utils/book.ts,服务端镜像在 src/pages/api/sync.ts,推送止损逻辑在 src/services/sync/file/merge.ts,迁移脚本在 docker/volumes/db/migrations/022_add_group_updated_at.sql。

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询