ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Readest OPDS 自动下载批处理持久化实战:重试计数器重置与已提交批次丢失两个隐蔽 Bug 的定位与修复

Readest OPDS 自动下载批处理持久化实战:重试计数器重置与已提交批次丢失两个隐蔽 Bug 的定位与修复 Readest OPDS 自动下载批处理持久化实战重试计数器重置与已提交批次丢失两个隐蔽 Bug 的定位与修复【免费下载链接】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 仓库中的项目记忆文档 apps/readest-app/.claude/memory/opds-batch-persist-retry-reset-6237.md 展开。该文档记录了 PR #6237 为 OPDS 订阅目录自动下载引入分批持久化能力后随之产生并已被修复的两个隐蔽 Bug 的完整根因分析与修复方案。读完后你将掌握 Readest OPDS 自动下载同步的完整调用链、批处理循环中循环携带的读写别名loop-carried read/write aliasing这类 Bug 的识别方法以及如何通过onBooksImported回调与错误返回结构保住已落盘的成果。一、背景OPDS 自动下载为什么需要分批持久化Readest 的 OPDS 订阅功能允许用户订阅远程 OPDS 目录例如 Calibre-Web、copyparty 文件列表等并开启autoDownload自动把目录中的新书下载并导入本地书库。整个同步由 syncSubscribedCatalogs 入口驱动核心流程分为三阶段发现DiscoverycheckFeedForNewItems从订阅源抓取新增条目获取Acquisition对候选条目pendingItems进行有界并发下载与导入落盘Persistence把已导入条目的entryId写入knownEntryIds把失败条目写入failedEntries。同步状态通过 loadSubscriptionState / saveSubscriptionState 以 JSON 文件形式存放在OPDS/catalogId.json中包含catalogId、lastCheckedAt、knownEntryIds与failedEntries四个字段。改造动机在 PR #6237 之前进度只在整轮同步全部结束后写盘一次。但首次同步一个大型目录往往要运行数分钟恰好是 Android 低内存杀手Low-Memory KillerLMK的重点打击目标——在同步进行到第 N 本书时进程被杀此前 N 本书的导入成果全部作废下一轮又得从零开始同步永远无法收敛。PR #6237由贡献者 raman325 提交将syncCatalog改为每处理PERSIST_BATCH_SIZE10个条目就落盘一次把一次进程被杀可丢弃的工作量从整个首次同步缩小到一个批次。改造后同步进度按批次推进每完成一批下载先通过onBooksImported回调把本批导入的书籍交给调用方排队上传云端再更新knownEntryIds、failedEntries与lastCheckedAt并写盘。相关常量定义在 src/services/opds/types.tsexport const MAX_RETRY_ATTEMPTS 3; // 重试上限达到后永久跳过 export const RETRY_BACKOFF_MS 60_000; // 退避基数60s * 2^attempts export const DOWNLOAD_CONCURRENCY 3; // 每目录并发下载数 export const PERSIST_BATCH_SIZE 10; // 每批多少个条目写盘一次 export const AUTO_CHECK_INTERVAL_MS 5 * 60 * 1000; // 后台自动检查周期二、Bug 一从第 2 个批次开始重试计数器被悄悄重置2.1 现象与危害分批改造引入了第一个 Bug重试计数器在批次 1 之后全部坍缩为 1。其后果是死条目永远下载失败的文件永远达不到MAX_RETRY_ATTEMPTS3 次因此永远不会被写入knownEntryIds永久跳过isRetryEligible的指数退避RETRY_BACKOFF_MS * 2^attempts永远钉死在第一步60 秒于是每隔 5 分钟AUTO_CHECK_INTERVAL_MS的后台检查都会重新下载这个死条目无限重试、永不放弃。2.2 根因读操作读到了被每次批处理改写的状态罪魁祸首是批处理循环内部对state.failedEntries的读操作。改动前循环体只读state.failedEntries一次改动后每一批结束时都会执行state.failedEntries updatedFailedEntries重新赋值而updatedFailedEntries数组只保留了不可重试的失败项见 autoDownload.tsconst updatedFailedEntries: FailedEntry[] [ // 保留不可重试的失败项原样 ...state.failedEntries.filter((fe) !isRetryEligible(fe)), ];可重试的失败项会被转成retryItems追加到候选列表末尾排在eligiblePendingItems之后因此在大目录中它们必然越过第一个批次边界、落入第 2 个及以后的批次。而第 2 个批次计算attempts时若仍从已被改写过的state.failedEntries里查找priorAttemptsconst attempts (priorAttempts.get(item.entryId) ?? 0) 1; // 修复前是 state.failedEntries.find(...)——这个数组里已经没有可重试的原条目了查找必然落空attempts被重置为1。这正是文档中概括的那类写操作从每轮一次变成每批一次从而污染后续读操作的经典陷阱文档将其命名为loop-carried read/write aliasing循环携带的读写别名。2.3 修复进入循环前对初始状态做快照修复方案是在循环开始前把加载时刻的状态快照下来全程只读快照、不读被反复赋值的state。当前源码 autoDownload.ts 中的实现// 尝试次数必须来自加载时的状态。下面每个批次都会重新赋值 // state.failedEntries而该数组已丢弃可重试的原始项——后续批次再 // 去查它什么都查不到计数会被重置为 1从而永远重试而不是在 // MAX_RETRY_ATTEMPTS 处放弃。 const priorAttempts new Map(state.failedEntries.map((fe) [fe.entryId, fe.attempts]));以entryId - attempts的Map快照替代数组查找批次 2 及之后每次计算attempts都能拿到真实的累计次数。这样当累计次数达到MAX_RETRY_ATTEMPTS时条目会被推入knownEntryIds并打印permanently skipping日志见 autoDownload.ts彻底放弃该条目。该修复行为有专门的回归测试兜底keeps counting retry attempts for an entry in a later persist batch测试文件构造了attempts MAX_RETRY_ATTEMPTS - 1的死条目并塞满整整一个批次的健康条目把它挤进第二个批次断言最终该条目不在failedEntries中、且已进入knownEntryIds即第三次失败后按预期永久跳过。三、Bug 二批次 k 抛异常把批次 1..k-1 已提交的书也一起丢掉3.1 现象与危害第二个 Bug 的危害更加隐蔽且不可逆如果第 k 个批次处理中抛出了异常前 k-1 个批次已经导入的书会被丢弃。关键在于这些书已经写入磁盘书库文件已存在写入了knownEntryIds下一轮同步不会再重新发现它们而queueOPDSBookUploads永远不会看到这些书——它们永远不会被上传到云端造成本地与云端书库的永久性丢失。更糟的是由于它们已在knownEntryIds中后续同步也无法补救。3.2 修复让syncCatalog带着已提交成果和错误一起返回修复方案是把syncCatalog的返回值从单纯的成功结果改为错误与已提交成果并存的结构。当前签名autoDownload.tsasync function syncCatalog( catalog: OPDSCatalog, appService: AppService, books: Book[], onBooksImported?: (newBooks: Book[]) Promisevoid, ): Promise{ newBooks: Book[]; state: OPDSSubscriptionState; error?: unknown }批处理循环的catch分支不再丢弃一切而是把已经积累的newBooks原样带出autoDownload.ts} catch (error) { return { newBooks, state, error }; }调用方 syncSubscribedCatalogs 则先收下newBooks再重新抛出errorconst { newBooks, error } await syncCatalog(catalog, appService, books, onBooksImported); // 先接收失败前已提交的书再走既有的错误路径—— // 这些批次已在磁盘上且已被标记为 known。 allNewBooks.push(...newBooks); if (error) throw error;这样前 k-1 批的书照常进入SyncResult.newBooks被汇总返回最终由调用方排队上传云端同时第 k 批的错误照常进入errors列表、lastCheckedAt被刷新。代码注释也明确点出了failure inside the download loop is returned rather than thrown的设计意图失败可以发生但不能抹掉已经完成的成果。对应的回归测试是still reports books from batches committed before a later batch failed测试文件构造两批条目让onBooksImported第一次成功、第二次抛disk full断言最终result.errors长度为 1、而result.newBooks仍保留第一整批PERSIST_BATCH_SIZE本的书。四、可复用的通用规则给单遍循环套上每批持久化外壳时审计每一个state.X读取文档提炼出的这条规律是整个案例最有迁移价值的部分通用规则当你把一个原有的单遍循环改造成每批持久化的批处理循环时必须审计循环内部对state.X的每一次读取——一个以前只发生一次的写操作现在每批都发生一次就会污染后续的读取。循环携带的读写别名就是这一类 Bug 的全部本质。对照本次案例可以总结出三条可操作的检查清单读取时机凡是依赖初始状态的读取如累计计数、快照类信息必须在循环外、写操作发生前快照例如new Map(...)或const snapshot [...state.x]写入时机state的赋值如state.failedEntries updatedFailedEntries、state.knownEntryIds ...发生在批循环末尾任何后置批次的逻辑都不得再依赖state的旧内容异常路径批处理循环一旦引入每批持久化就必须确认异常路径不会把已持久化的成果一起吞掉——要么返回成果 错误结构要么确保调用方能在重新抛出前接管成果。五、同步中另外两个值得注意的持久化顺序细节除文档记载的两个 Bug 外当前源码中还固化了两条同样服务于崩溃安全的顺序规则可作为本主题的延伸理解先持久化书、再记录 known 标记#5658批处理循环内先调用onBooksImported把书落库再更新knownEntryIdsautoDownload.ts。因为knownEntryIds中的条目永远不会再被下载若顺序颠倒进程在两次写入之间被杀会导致标记还在、书没了的不可逆损失反之倒过来最坏只是多一次冗余下载导入是幂等的。失败条目去重与回退窗口过滤loadSubscriptionState加载时会用dedupeFailedEntries修复旧版本可能写入的重复失败条目subscriptionState.ts同步开始前还会用isRetryEligible过滤仍在退避窗口内的失败条目避免每个同步周期都重复下载并产生重复失败记录autoDownload.ts。六、如何验证与运行相关测试文档对测试环境给出了非常实用的两条提醒直接复刻自项目实际踩坑经验vi.clearAllMocks()不会重置mockImplementation测试文件模块级对loadSubscriptionState的 mock 解析的是同一个被多个测试共享并修改的状态对象因此新增测试必须调用文件内定义的freshStatePerLoad()辅助函数测试文件且新增用例要放在该辅助函数定义之后没有快捷的单文件测试通道pnpm test -- file中的--参数并非过滤器会跑完整套件而pnpm vitest run file会因缺少 Supabase 环境变量在atob处失败。因此完整跑一次测试套件需要预留约 95 秒。涉及本主题的关键测试用例集中在 apps/readest-app/src/tests/services/opds-auto-download.test.ts其中包括persists progress incrementally during a large sync验证大目录同步中每批写盘一次、每次写盘都携带已累积的knownEntryIds测试文件persists imported books before recording their entries as known (#5658)验证持久化顺序测试文件does not record entries as known when persisting the library fails验证持久化失败时条目保持未知以便下次重试测试文件。七、小结PR #6237 的分批持久化改造本身是一次针对 Android 低内存杀手的正确架构决策从整轮一次写盘到每 10 个条目一次写盘但它暴露出的两个回归——重试计数器重置与已提交批次丢失——展示了批处理循环改造中极易踩中的两类陷阱循环携带的读写别名与异常路径吞掉已持久化成果。前者通过在循环外对初始状态做Map快照解决后者通过成果与错误同返、调用方先收成果再抛错的返回结构解决。这两处修复都已随合并提交进入主分支并有针对性回归测试守护可作为任何为进程崩溃安全而引入分批持久化改造的可复用范本。【免费下载链接】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),仅供参考
返回列表