ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Readest 元数据同步一致性修复剖析:`metadata_updated_at` 字段级 LWW 合并方案(Issue 5438)

Readest 元数据同步一致性修复剖析:`metadata_updated_at` 字段级 LWW 合并方案(Issue 5438) Readest 元数据同步一致性修复剖析metadata_updated_at字段级 LWW 合并方案Issue #5438【免费下载链接】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 开源项目中的一个高价值同步一致性修复案例RSS 订阅源书籍feed book的元数据编辑标题、作者、标签、语言等在跨设备同步中反复被翻页进度覆盖丢失的问题Issue #5438及其完整修复方案。文章将以项目 memory 文档 metadata-field-lww-5438.md 为核心骨架结合数据库迁移、服务端合并逻辑、客户端 graft 逻辑、文件同步引擎与 Calibre 插件实现完整还原这套字段级 Last-Writer-WinsLWW元数据合并机制的来龙去脉。读完本文你将掌握Readest 书籍行的同步冲突是如何产生的、为什么整行 LWW 会失效、metadata_updated_at独立时钟如何让元数据编辑在翻页洪流中存活以及这套方案在服务端、客户端与第三方同步链路中的完整落点。1. 问题背景一个两个症状、两个成因的同步缺陷Issue #5438 的核心现象是RSS 订阅源书籍的元数据编辑永远不会传播且同一缺陷在 Android 上还表现为订阅源书籍缺失。文档将其拆解为两个相互独立的成因1.1 Android 端订阅源书籍缺失Play Store 0.11.20 滞后第一个成因并非代码缺陷而是一个发布时序问题Play Store 0.11.20 版本发布于 #5314 修复uploadedAt采纳门控的修复之前因此git merge-base --is-ancestor校验显示该修复不在 v0.11.20 中。旧版本仍沿用旧的uploadedAt采纳门控——无文件书籍fileless feed book因此被丢弃导致 Android 上订阅源书籍缺失。这类问题无需代码修复随下一次发布自然解决。1.2 所有平台上的元数据编辑丢失根本性缺陷第二个成因才是真正需要动刀的硬核问题元数据组metadata group——标题、作者、标签、metadata JSON含语言字段——此前只有基于updatedAt的整行 LWWLast-Writer-Wins保护。而updatedAt恰恰被翻页进度主导updateBookProgress在本地每次翻页都会 bumpupdatedAt服务端 configs 推送piggyback也会 bumpupdatedAt。于是出现如下确定性复现链路设备 A 编辑了书籍元数据如把 TTS 语言改为瑞典语→ 设备 B 随后阅读该书并翻页 → 设备 B 的整行updatedAt更新 → 设备 B 把包含陈旧元数据的新鲜行推送回云 → 设备 A 的编辑被回滚。由于订阅源书籍在多台设备上几乎每天被阅读这个问题呈现近确定性near-deterministic复现而不仅是偶发竞态。文档特别指出这是该缺陷家族hazard class的第三个实例前两个分别是缺陷家族实例Issue受影响字段修复方案阅读状态被翻页覆盖#4634reading_status迁移 015 引入reading_status_updated_at封面编辑被翻页覆盖#4544cover_hash迁移 017 引入cover_updated_at元数据组被翻页覆盖#5438title/author/tags/metadata迁移 018 引入metadata_updated_at2. 修复方案总览MERGED PR #5442文档用一段话概括了整个修复的六个组成部分这里结合源码逐一展开修复组件位置作用迁移 018books.metadata_updated_at018_add_metadata_updated_at.sql数据库新增独立元数据时间戳列getBookWithUpdatedMetadata ingest 的 subject-tag 打戳utils/book.ts 与 ingestService.ts客户端编辑元数据/追加标签时打上metadataUpdatedAt服务端resolveMetadataMerge(client, server, clientRowWins)bookMetadataChangedno-op 守卫pages/api/sync.ts服务端按独立时钟做字段级 LWW且仅在值真正变化时写库客户端pickFresherMetadatagraft在useBooksSync.updateLibrary中useBooksSync.ts 与 libraryUtils.ts拉取侧把更鲜的元数据字段嫁接到合并行上文件同步mergeBookMetadata时钟块merge.ts第三方文件同步引擎本地网盘等同样按元数据时钟合并Calibre 插件merge_for_push仅在_row_matches_wire判定组有变时打戳wire.py防止封面-only 推送错误赢得元数据组3. 数据库层迁移 018 与可空列的语义018_add_metadata_updated_at.sql 的完整内容揭示了几个关键设计决策ALTER TABLE public.books ADD COLUMN IF NOT EXISTS metadata_updated_at timestamp with time zone NULL;迁移注释明确说明了三个约束加性且可空Additive nullableNULL被合并逻辑视为 epoch 0最旧因此未打戳的历史行不会突然赢得任何冲突未打戳 vs 未打戳 → 回退整行 LWW两条旧行都是NULL0 0时保持旧行为由行级updatedAt决定胜负旧行保持逐字节的旧行为旧客户端不受影响老版本客户端根本不知道这个列推送时不会携带它服务端 merge 对NULL的处理使其自动落入整行 LWW回退路径。这与前两个先例015reading_status_updated_at、017cover_updated_at保持同一模式是同一 hazard class 的第三次收敛。4. 服务端合并resolveMetadataMerge与bookMetadataChanged服务端同步端点 pages/api/sync.ts 的POST处理中每次books推送都会对每条记录执行四个字段级合并resolveReadingStatusMerge、resolveCoverMerge、resolveMetadataMerge、resolveGroupMerge。核心实现如下第 105-163 行type BookMetadataFields Pick DBBook, title | author | tags | metadata | metadata_updated_at ; const pickMetadataFields (b: BookMetadataFields): BookMetadataFields ({ title: b.title, author: b.author, tags: b.tags, metadata: b.metadata, metadata_updated_at: b.metadata_updated_at, }); export function resolveMetadataMerge( client: BookMetadataFields, server: BookMetadataFields, clientRowWins: boolean, ): BookMetadataFields { const clientMs ms(client.metadata_updated_at); const serverMs ms(server.metadata_updated_at); const clientWins clientMs serverMs ? clientRowWins : clientMs serverMs; const winner clientWins ? client : server; const loser clientWins ? server : client; const fields pickMetadataFields(winner); // 一个缺失的 metadata blob 意味着这台设备从未有过——云书架行、文件同步 // discovery 行、旧客户端——绝不意味着用户清空了它应用里没有任何操作会 // 清空 book.metadata编辑器只会编辑其内部字段。所以它绝不能在任何时钟上 // 覆盖拥有该 blob 的副本。否则一个无 metadata 的 peer 会抹掉全 fleet 每本书 // 的描述#5912。 fields.metadata fields.metadata ?? loser.metadata; return fields; } export const bookMetadataChanged ( a: OmitBookMetadataFields, metadata_updated_at, b: OmitBookMetadataFields, metadata_updated_at, ): boolean a.title ! b.title || a.author ! b.author || (a.metadata ?? null) ! (b.metadata ?? null) || JSON.stringify(a.tags ?? null) ! JSON.stringify(b.tags ?? null);4.1 关键设计决策一平局含未打戳的 0/0跟随行赢家文档强调了一个反直觉的关键决策与 status/cover 的平局 → 客户端不同元数据合并的平局跟随行赢家clientRowWins。理由是元数据在不同设备间的差异远比cover_hash容易产生如果平局 → 客户端任何一个陈旧的 legacy 推送都能把自己的元数据嫁接到更新的服务端行上。而跟随行赢家则保证legacy 行保持逐字节的旧行为byte-for-byte on old behavior。测试用例 sync-metadata-merge.test.ts 精确验证了这四条规则it(keeps the client metadata when its metadata_updated_at is newer, () { const out resolveMetadataMerge( { ...clientFields, metadata_updated_at: iso(200) }, { ...serverFields, metadata_updated_at: iso(100) }, false, ); expect(out).toEqual({ ...clientFields, metadata_updated_at: iso(200) }); }); it(keeps the server metadata when its stamp is newer, even when the client wins the row, () { // 复现的 clobber 场景另一台设备在这个元数据编辑之后翻了页updated_at 更新、 // 元数据陈旧。行归客户端但元数据编辑必须存活。 const out resolveMetadataMerge( { ...clientFields, metadata_updated_at: iso(100) }, { ...serverFields, metadata_updated_at: iso(300) }, true, ); expect(out).toEqual({ ...serverFields, metadata_updated_at: iso(300) }); }); it(falls back to the row winner when neither side is stamped (legacy rows), () { expect(resolveMetadataMerge(clientFields, serverFields, true)).toEqual(clientFields); expect(resolveMetadataMerge(clientFields, serverFields, false)).toEqual(serverFields); }); it(equal stamps follow the row winner, () { const client { ...clientFields, metadata_updated_at: iso(150) }; const server { ...serverFields, metadata_updated_at: iso(150) }; expect(resolveMetadataMerge(client, server, true)).toEqual(client); expect(resolveMetadataMerge(client, server, false)).toEqual(server); });4.2 关键设计决策二metadata列存的是 JSON 字符串标量文档指出一个容易踩坑的实现细节metadataJSON 列实际存储的是 JSON 字符串标量pushes 把JSON.stringify的输出写进 PostgREST。这样服务端可以直接做字符串相等比较且一次传播写入后即可收敛converges after one propagation write。Calibre 插件中字典宽容的_parse_row_metadata只是防御性代码。4.3 关键设计决策三bookMetadataChangedno-op 守卫当行赢家是服务端、但合并出的元数据字段值与服务端当前值完全相等时只是时间戳不同不重写服务端行——否则会 churnupdated_at并触发无意义的再传播。这与readingStatusChanged、cover_hash比较是同一模式。在POST的处理分支中第 786-825 行只有当statusChanged || coverChanged || metadataChanged || groupChanged任一为真时才构造 propagation 行写库否则直接返回服务端权威行。5. 传播机制synced_at游标绝不触碰updated_at字段级合并写回时最精妙的设计是传播行propagation row。buildStatusPropagationRow的注释揭示了核心权衡books_set_synced_attrigger 会在这次写入时盖上synced_at now()因此 peer 能通过synced_at游标重新拉取状态变化而不会让按updated_at排序的最近阅读库跳到同步处理时间。也就是说合并结果写回服务端时保留原来的updated_at不动仅依赖 trigger 推进synced_at游标。这样 peers 能从拉取游标发现变化但书籍在按日期排序的图书馆中的位置不会因为同步处理时间而跳动。之前直接改写updated_at now()的做法正是 #4677 排序错乱症状的根源issue #4678。对应地GET拉取端对books表使用synced_at作为游标列第 319 行对无服务端合并的configs/notes仍用updated_at并保留显式deleted_at子句。6. 客户端拉取pickFresherMetadatagraft 与primaryLanguage重算6.1 graft 逻辑客户端拉取侧的合并发生在 useBooksSync.ts 的updateLibrary中。行级合并先决定整行胜负然后四个字段级合并status/cover/metadata/group各按自己的时钟叠加。其中元数据 graft第 271-283 行const meta pickFresherMetadata(oldBook, matchingBook); if (meta) { mergedBook.title meta.title; mergedBook.author meta.author; mergedBook.tags meta.tags; mergedBook.metadata meta.metadata; mergedBook.metadataUpdatedAt meta.metadataUpdatedAt; // TTS 读的是 primaryLanguage而不是 metadata.language // 与编辑设备相同的方式重算它让编辑在此处同样生效。 if (meta.metadata) { mergedBook.primaryLanguage getPrimaryLanguage(meta.metadata.language); } }pickFresherMetadata的实现libraryUtils.ts 第 998-1013 行有一个微妙点时间戳相等时返回null调用方保留行级赢家已产生的字段即 legacy 行为而不是强行走任何一方——这是客户端侧对平局跟随行赢家的镜像实现export const pickFresherMetadata ( local: MetadataFields, synced: MetadataFields, ): MetadataFields | null { const localMs local.metadataUpdatedAt ?? 0; const syncedMs synced.metadataUpdatedAt ?? 0; if (localMs syncedMs) return null; const winner localMs syncedMs ? local : synced; return { title: winner.title, author: winner.author, tags: winner.tags, metadata: winner.metadata, metadataUpdatedAt: winner.metadataUpdatedAt, }; };6.2 关键设计决策四primaryLanguage不是云列文档特别强调primaryLanguage不是云同步列peers 从不重新计算它。TTS 朗读读取的是book.primaryLanguage通过useTTSControl而不是metadata.language。因此在两个位置必须显式重算graft 时上述代码元数据合并后立即用getPrimaryLanguage(meta.metadata.language)重算primaryLanguage采纳新书时processNewBook第 347-349 行当采纳一本带metadata.language的云书时同样设置primaryLanguage——否则阅读器稍后会用解析出的文档去猜测语言完全无视用户的编辑。getPrimaryLanguageutils/book.ts 第 235-242 行负责把语言代码规范化为 ISO-639-1支持数组取首项、isValidLang校验、639-2 到 639-1 映射兜底en。6.3 客户端编辑侧getBookWithUpdatedMetadata打戳当用户在元数据对话框中编辑书籍信息时getBookWithUpdatedMetadata第 311-334 行会不可变地返回一本新书同时盖上updatedAt与metadataUpdatedAtexport const getBookWithUpdatedMetadata ( book: Book, metadata: BookMetadata, tags?: string[], ): Book { const now Date.now(); const updatedBook: Book { ...book, metadata, ...(tags ? { tags: [...tags] } : {}), title: formatTitle(metadata.title), author: formatAuthors(metadata.author), primaryLanguage: getPrimaryLanguage(metadata.language), updatedAt: now, // 元数据组在自己独立的时钟上合并这样别处的翻页主导 updatedAt无法 // 覆盖这次编辑issue #5438。 metadataUpdatedAt: now, }; const newCoverImageUrl metadata.coverImageBlobUrl || metadata.coverImageUrl; if (newCoverImageUrl) { updatedBook.coverImageUrl newCoverImageUrl; } return updatedBook; };注释还解释了不可变返回新对象的原因BookCover是 memoized 组件就地 mutation 会让 memo 的旧快照指向同一对象从而跳过重渲染。7. 标签搭乘元数据时钟ingest 的 subject-tag 打戳文档指出标签搭乘元数据时钟Tags ride the metadata clock因此每一个刻意的标签写入者都必须打戳。两个入口元数据对话框通过getBookWithUpdatedMetadata统一打戳见上ingestService 的 subject-tag 追加ingestService.ts 第 229-239 行const tag opts.subjectTag?.trim(); if (tag) { const tags book.tags ?? []; if (!tags.includes(tag)) { book.tags [...tags, tag]; book.updatedAt Date.now(); // 标签在元数据时钟上合并#5438不打戳的话peer 端更旧的带戳 // 元数据编辑会赢得该组并把这条标签丢掉。 book.metadataUpdatedAt book.updatedAt; } }而组归属group membership和进度progress仍留在行时钟上对应 #4942、#5067 的决策分组跟随group_updated_at迁移 022进度跟随updatedAt整行——这些字段与翻页的耦合是有意共存的。8. 文件同步引擎mergeBookMetadata的时钟块Readest 的同步不只有自家云还支持第三方文件同步后端本地网盘等。services/sync/file/merge.ts 中的mergeBookMetadata第 153-204 行是客户端侧原生云合并的镜像文档明确其为utils/book.ts中getBookWithUpdatedMetadata的本地侧对应物const localMetaMs local.metadataUpdatedAt ?? 0; const remoteMetaMs remote.metadataUpdatedAt ?? 0; if (localMetaMs ! remoteMetaMs) { const winner remoteMetaMs localMetaMs ? remote : local; merged.title winner.title; merged.author winner.author; merged.tags winner.tags; merged.metadata winner.metadata ?? merged.metadata; merged.primaryLanguage winner.primaryLanguage ?? merged.primaryLanguage; merged.metadataUpdatedAt winner.metadataUpdatedAt; }同样地未打戳 vs 未打戳的平局保留行级结果legacy 行为并附有 #5912 的metadatablob 缺失保护。此外该文件的isRemoteBookClockNewer第 226-229 行定义了任一时钟更鲜的判定——行时钟updatedAt、状态时钟readingStatusUpdatedAt、元数据时钟metadataUpdatedAt任一更鲜即视为远端更新。这正是useBooksSync拉取、封面/配置重拉等操作的统一门控只检查updatedAt会完全漏掉仅字段级更新的情况导致 peer 的 Finished 标记或元数据编辑永远无法到达一台之后还碰过该书行的设备。9. Calibre 插件merge_for_push的条件打戳桌面端 Readest 用户常把 Calibre 作为书库管理工具其同步插件同样需要遵循元数据时钟协议。wire.py 的merge_for_push第 337-376 行中# 服务端按 metadata_updated_at 解析 title/author/tags/metadata # 字段级 LWWreadest#5438。本次推送改变了该组才打戳 # 否则沿用行上的戳这样封面-only 推送不会赢过与它竞速的更新的 # Readest 编辑。 if server_row is None or not _row_matches_wire(server_row, wire): record[metadataUpdatedAt] now_ms else: record[metadataUpdatedAt] iso_to_ms(row.get(metadata_updated_at))关键点只有当_row_matches_wire判定元数据组真的发生了变化时才盖上metadataUpdatedAt now_ms否则沿用服务端行的旧戳。若不这样做一次纯封面更新cover-only push也会携带新鲜时间戳从而在竞速中赢得元数据组、覆盖掉用户在同一时刻于另一台设备上做的真实编辑。该行为由 test_wire.py 的三个用例锁定test_metadata_change_stamps_metadata_updated_at、test_new_book_stamps_metadata_updated_at以及封面变化但元数据不变时保留旧戳的用例。10. 部署顺序迁移必须先于发布文档在最后强调了一个运维层面的硬约束生产 Supabase 必须在 web 部署之前执行迁移 018。原因在于新客户端代码中的 insert 会显式写入metadata_updated_at列如果 schema 尚未迁移这些 insert 会直接失败。这是所有加性列 新代码写新列方案的共同部署纪律先迁移数据库再发布应用。11. 相关联动与经验沉淀文档末尾的Related链接指向同族的两个相关 memory[rss-feed-books-not-syncing-5307]feed 书不传播uploadedAt采纳门控问题与 [sync-pull-10k-worker-1102]10k 行增量拉取超出 Worker 资源限制CF error 1102对应GET /sync中基于limit的分页拉取与synced_at毫秒补齐逻辑。本次修复与 #5911分组归属同样被上传动作 bump 的updatedAt覆盖引入group_updated_at迁移 022和 #5912无 metadata 的 peer 抹掉全 fleet 的书籍描述用缺失 blob 永不赢得合并防御构成完整的行时钟污染防御族。12. 总结从 #5438 到一套可复用的合并范式Issue #5438 的修复沉淀出的核心方法论值得任何做离线优先、多设备同步的团队借鉴识别行时钟污染当单行的updatedAt被高频操作翻页主导时任何低频但重要的编辑元数据、状态、封面、分组都会周期性回滚——这是隐患的根源判据为每个易损字段组引入独立时钟*_updated_at列合并时按各自时间戳做字段级 LWW未打戳NULL/0视作最旧平局策略因字段而异status/cover 平局 → 客户端本地优先metadata 平局 → 行赢家防陈旧推送嫁接group 平局 → 有分组者赢防不可恢复的分组丢失传播不碰排序键通过 trigger 推进synced_at游标完成传播保持updated_at代表的最近阅读排序稳定no-op 守卫防 churn值未变仅戳变时不写库避免无限再传播派生态字段显式重算primaryLanguage这类非云列必须在 graft 与新书采纳两处重算否则 TTS 会读到错误的语言每个写入入口都打戳元数据对话框、ingest 标签、Calibre 推送各有自己的打戳路径漏掉任何一个都会留下回滚漏洞。这套模式在 Readest 中以metadata_updated_at迁移 018、cover_updated_at迁移 017、reading_status_updated_at迁移 015、group_updated_at迁移 022的形式四次落地并配有 sync-metadata-merge.test.ts、metadata-sync-helpers.test.ts、merge.test.ts 等测试从服务端、客户端、文件同步三侧锁定行为构成了一个完整的、可逐条验证的同步一致性工程范例。【免费下载链接】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),仅供参考
返回列表