- 桌面应用
- 跨平台
- 前端
【免费下载链接】readest
Readest 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.
本文以 Readest 仓库中
apps/readest-app/.claude/memory/koplugin-stats-push-chunking-5666.md的故障复盘为骨架,结合 readest_syncstats.lua、readest_syncclient.lua、应用端 statsSync.ts 与 syncstats_spec.lua 的源码与测试,完整还原 Issue #5666 的根因、修复方案(PR #5670)与可复用的同步工程经验。读完本文,你将掌握:为什么"无界单请求同步 + 仅在成功时推进游标 + 硬性 socket 超时"三者组合必然导致永久卡死;KOReader 插件端如何用 500 条/块的分块推送与逐块游标推进打破这一死锁;以及"永不拆分同一 start_time"、UIManager:nextTick链式调度等关键实现细节背后的原理。
事故现象:每次点击 "Push stats now" 都提示失败
2026-08-13 报告的 Issue #5666,标题为 "Failed to push reading statistics"(推送阅读统计失败)。现象是:
- 在 Kindle 与 Kobo 设备上,每次点击 "Push stats now"(立即推送统计)都返回失败;
- 该问题在用户升级 Readest 存储容量后开始出现;
- 失败表现为表面上的静默:界面只弹出一条通用的失败 toast,日志级别为
logger.dbg的诊断行不会出现在用户的 crash.log 中,排障者很难定位到真正原因。
该问题由合并的 PR #5670(commitb25a2a88c,2026-08-13)修复,修复后工作区与分支已清理,设备端验证等待下一个 koplugin 版本发布。
根因:一个请求塞下全部积压,撞上 10 秒总超时
复盘记录把根因指向三个条件的叠加:
1. 无界单请求:一次 POST 发送全部积压
修复前的SyncStats:push会把自stats_push_cursor以来的全部阅读事件放进一个/api/syncPOST 请求里,不做任何分批。问题的时间线是:
- 用户升级存储后,拉取了完整的多设备历史(当时 koplugin 的统计拉取是故意不分页的,对应应用端 statsSync.ts 中的拉取实现);
- 于是本设备的推送游标显得非常"落后",
collectSince(cursor)一次收集了数万条page 事件; - 服务端处理
stat_pages时按500 行 select + upsert的顺序批处理,整批处理时间远超客户端等待上限。
2. 硬性超时:socketutil:set_timeout(5, 10)
KOReader 插件的同步客户端在 readest_syncclient.lua 中定义:
local SYNC_TIMEOUTS = { 5, 10 }并在每次 RPC 分发前调用socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])(readest_syncclient.lua),其中10是总时长上限。数万条事件在服务端顺序批量处理下,请求永远不可能在 10 秒内完成。
3. 仅在成功时推进游标:失败即重发相同负载
游标stats_push_cursor只在请求成功时推进。这意味着:
- 第一次请求超时失败 → 游标不动;
- 下一次重试时
collectSince收集到完全相同的负载; - 再次超时 → 游标仍然不动;
- 如此循环,每次 "Push stats now" 都重发同一批数据、都失败,永久卡死。
复盘记录将这一组合总结为一个通用判定式:
任何"无界单请求同步 + 仅在成功时推进游标 + 硬超时"的组合,一旦积压量超过超时阈值,就会永久卡住。
关键对照:应用端早已有修复模式
值得注意的是,Readest 应用端早在这次事故之前就采用了正确的模式。在 statsSync.ts 中:
/** Events per push request — bounds request size for a large offline backlog. */ const PUSH_CHUNK = 500; /** Page events per pull request — bounds the receiving device's memory. */ const PULL_PAGE = 1000;应用端pushStats按PUSH_CHUNK = 500分块推送,每个块成功后才推进游标。而 koplugin 插件端一直没有同步这一模式——这是本次事故的直接缺口。
修复方案:分块推送 + 每块成功后推进并保存游标
修复后的SyncStats:push完整实现位于 readest_syncstats.lua,核心逻辑如下:
1. 块大小与镜像关系
-- Page events per push request. A large backlog (e.g. a fresh push cursor -- after pulling the full multi-device history, #5666) cannot go out as one -- request: the sync client's 10s total socket timeout kills it, the cursor -- never advances, and every retry re-sends the same payload — wedging "Push -- stats now" forever. Mirrors PUSH_CHUNK in the app's statsSync.ts and the -- server's stat_pages batch size. local PUSH_CHUNK = 500PUSH_CHUNK = 500是刻意与两处对齐的值:应用端 statsSync.ts 的PUSH_CHUNK,以及服务端stat_pages处理时的 500 行 select+upsert 批大小。三者一致,保证单个请求的负载既不会撑爆客户端超时,也不会超出服务端单批处理的能力。
2. 游标作为普通字段持久化
koplugin 插件端没有独立的游标存储,游标是readest_sync设置表中的一个普通字段,通过保存整个表来持久化:
local cursor = settings.stats_push_cursor or 0 local books, pages = self:collectSince(cursor) ... settings.stats_push_cursor = pages[last].start_time G_reader_settings:saveSetting("readest_sync", settings)collectSince(readest_syncstats.lua)的 SQL 以start_time > cursor为过滤条件,并按start_time ASC排序:
SELECT b.md5, b.title, b.authors, p.page, p.start_time, p.duration, p.total_pages FROM page_stat_data p JOIN book b ON b.id = p.id_book WHERE p.start_time > ? ORDER BY p.start_time ASC3. 永不拆分同一个 start_time
推送事件按start_time排序,而游标推进到的是块内最后一条事件的start_time。如果分块边界恰好落在共享同一start_time的事件中间(例如并行视图 split-view 产生的同一秒事件),恢复时游标会越过这些事件,导致同一秒内剩余的事件被永久丢弃。因此推送逻辑在切块边界做了保护:
local last = math.min(i + PUSH_CHUNK - 1, #pages) -- Never split a start_time across chunks (pages are ordered by -- start_time): advancing the cursor past it would drop the remaining -- same-second events on resume. while last < #pages and pages[last + 1].start_time == pages[last].start_time do last = last + 1 end应用端 statsSync.ts 有完全等价的逻辑(events[end].startTime === lastStart),这是两端必须保持的行为一致性。
4. 用UIManager:nextTick链式调度,而不是在回调里直接递归
分块之间通过UIManager:nextTick链式衔接:
UIManager:nextTick(function() pushFrom(last + 1) end)复盘记录特别强调了这个细节的必要性:如果直接在 HTTP 成功回调内部链式调用下一块,那么在同步(非 Turbo)HTTP 路径上,每个块都会在回调栈里嵌套一次coroutine.resume,积压巨大(例如数万条、约 200 个块)时会打爆 C 栈。nextTick让下一块从事件循环重新开始,栈深度始终是 O(1)。这也是 readest_syncclient.lua 中AsyncHTTP中间件在缺少 Turbo looper 时走同步回退路径时的关键约束。
5. 失败时保持游标不动,交互时给出明确提示
else logger.dbg("ReadestStats push: failed, cursor kept at " .. tostring(settings.stats_push_cursor) .. "; body=" .. tostring(body)) if interactive then UIManager:show(InfoMessage:new{ text = _("Failed to push reading statistics"), timeout = 2 }) end end失败只记录日志并保持游标,下一次重试从上次成功的块之后继续——"部分成功可恢复"是这套机制的核心收益。
拉取侧的双胞胎问题:PULL_PAGE = 1000分页
复盘记录指出,拉取侧存在同样隐患的"双胞胎"版本:koplugin 的统计拉取曾经不分页,一次拉回整个多设备历史会形成多 MB 的响应,同样撞上 10 秒总超时。修复后的pull(readest_syncstats.lua)使用PULL_PAGE = 1000:
-- Page events per pull request. The server pages stat_pages by updated_at and -- completes the trailing millisecond of each page, so a strict -- `since = cursor` next request never skips rows that share an updated_at -- (same contract as PULL_PAGE in the app's statsSync.ts). A full multi-device -- history would otherwise come back as one multi-MB response under the sync -- client's 10s total socket timeout: the pull-side twin of #5666, failing -- forever without ever advancing the cursor. Paging also keeps every -- applyRemote transaction bounded on the device. local PULL_PAGE = 1000拉取的关键机制:
- 游标语义不同:拉取游标
stats_pull_cursor跟踪的是updated_at_ms而非start_time。服务端按updated_at对stat_pages分页,并补全每页的尾毫秒,因此下一次严格since = cursor的请求不会跳过共享同一updated_at的行——这与应用端 statsSync.ts 的pullStats是同一契约; - 每页先落库再取下一页:
applyRemote在回调内、网络往返完成后执行(live_book_id在回调内解析,以反映应用时的会话状态),保证内存平稳且新设备回填可断点续传; - 满页才继续:
npages >= PULL_PAGE 且游标有推进时通过nextTick拉下一页;否则视为已追平,结束拉取; - 401/403 触发登出回调:
logout_fn在鉴权失败时被调用。
每页应用后都会调用G_reader_settings:saveSetting("readest_sync", settings)保存游标,因此"已应用的页保持已应用、游标停留在最后成功页",重试从断点恢复。
本地落库的配套能力:applyRemote与重复书籍行折叠
拉取/推送的数据落库由applyRemote(readest_syncstats.lua)完成,两个细节值得注意:
冲突解决取更长时长:同一(id_book, page, start_time)冲突时,duration取两者最大值(并刷新total_pages),避免短时长的脏数据覆盖真实阅读时长:
INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id_book, page, start_time) DO UPDATE SET duration = max(duration, excluded.duration), total_pages = excluded.total_pages;重复书籍行折叠(关联 #4861):KOReader 原生统计插件以(title, authors, md5)唯一索引建行,当它提取的元数据与 Readest 漂移时,首次原生打开会新建一条归零行,导致同步的阅读时长"滞留"在同步创建的行上。mergeDuplicateBooks(readest_syncstats.lua)只折叠"原生插件从未认领"(pages IS NULL)的行,且绝不舍弃已被认领的行(打开的阅读会话可能缓存了它的 book id),并跳过当前会话缓存的live_book_id。
落库后还会镜像应用端的recomputeBookTotals逻辑,重新计算total_read_time、total_read_pages,并保证last_open永不回退,让 KOReader 设备在拉取后立即看到新鲜的总量数据。
测试覆盖:分块、断点续传与边界条件
修复的每个关键行为都有对应的测试用例,位于 syncstats_spec.lua:
- 游标过滤语义(
collectSince):只收集start_time > cursor的事件并按 md5 关联书籍;游标为 0 时返回全部;无新事件时返回空表; - 冲突解决:
applyRemote对同(id_book, page, start_time)的远程事件保留更长duration; - 逐块推进游标:
advances stats_push_cursor as a plain field after a successful push; - 大积压分块:
pushes a large backlog in bounded chunks, advancing the cursor per chunk (#5666)—— 测试直接以 #5666 命名; - 部分失败续传:
keeps partial progress when a chunk fails, and a retry resumes from the cursor; - 不拆分同一 start_time:
never splits page events sharing a start_time across chunks; - 拉取分页:
pulls a large history in bounded pages, advancing the cursor per page、keeps partial progress when a page fails, and a retry resumes from the cursor。
测试通过drainScheduled()配合stubs.UIManager:drain()把nextTick链上的分块全部跑完(syncstats_spec.lua),从而在纯同步环境下完整验证异步链式推送。
排障陷阱:两个容易误判的"假线索"
复盘记录记录了修复过程中踩到的两个坑,对后续维护者极具参考价值:
1. crash.log 里的红鲱鱼:WebDavApi 404+statistics open income DB failed ... notadb(来自cloudstorage.koplugin)是KOReader 自身的统计云同步在报错——用户同时也配置了它——与 Readest 插件完全无关。排查 koplugin 问题时,务必先区分日志来自哪个插件。
2. spec 测试的 preload 竞态:syncannotations_spec.lua注册了一个私有package.preload["ui/uimanager"](show为 no-op),在完整套件的 busted 运行中会赢得package.loaded。因此在 syncstats 的测试里绝不能通过koreader_stubs.UIManager._shown断言 toast——正确做法是对require()出来的实例打补丁show/InfoMessage.new并在测试后恢复(即现有 toast 测试的既有模式)。当前syncstats_spec已改用共享的koreader_stubspreload,其私有 preload 均为死代码。
可复用的工程经验
这次事故沉淀出几条适用于所有"离线积压 + 云端同步"场景的通用原则:
- 任何同步请求都必须是有界的(bound)**:要么按数量分块(push 500 条/请求),要么按时间分页(pull 1000 条/页),禁止无界单请求;
- 游标必须逐块推进并立即持久化,而不是"整体成功才推进"——这是断点续传与防重发的前提;
- 块边界必须与游标粒度对齐:游标按
start_time推进时,同一start_time的事件绝不能被拆到两个块; - 异步链式调用要从事件循环调度,不要在回调栈内直接递归,否则深度积压下栈会溢出;
- 多端实现必须共享同一套契约常量与语义:应用端
PUSH_CHUNK = 500、PULL_PAGE = 1000(statsSync.ts)与插件端、服务端stat_pages批大小保持一致,才能保证任意一端都不会成为超时瓶颈; - 排障先排除"其他插件":KOReader 生态插件众多,日志中的错误可能来自无关组件,应先用模块归属确认再下结论。
对于 Readest KOReader 插件的维护者,如需深入验证或复现,可重点阅读 readest_syncstats.lua(推送/拉取核心)、readest_syncclient.lua(超时与中间件栈)、应用端对照实现 statsSync.ts,以及测试 syncstats_spec.lua 与桩模块 koreader_stubs.lua。
- 桌面应用
- 跨平台
- 前端
【免费下载链接】readest
Readest 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.
相关推荐
深度解析Cowboy:Erlang高性能HTTP服务器架构设计与百万并发实践
深度解析Cowboy:Erlang高性能HTTP服务器架构设计与百万并发实践 在当今微服务架构和云原生应用盛行的时代,选择适合的Web服务器框架对于构建高性能、
桌面应用跨平台前端Readest 阅读进度同步中的空起始 CFI 缺陷:根因分析、畸形 CFI 检测与“丢弃优先”修复策略
Readest 阅读进度同步中的空起始 CFI 缺陷:根因分析、畸形 CFI 检测与“丢弃优先”修复策略 导读 本文以 Readest 开源仓库中的问题记忆文档
桌面应用跨平台前端Readest 修复 Android 端 Edge TTS 播放随机卡死:Tauri WebSocket 传输"永不 settle"的根因剖析与工程实践
Readest 修复 Android 端 Edge TTS 播放随机卡死:Tauri WebSocket 传输"永不 settle"的根因剖析与工程实践 本文以
桌面应用跨平台前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考