Skip to content

fix(sync): pull 整页 apply 失败时降级逐条隔离 + 修 busy_timeout - #553

Open
wq-h wants to merge 2 commits into
TNT-Likely:mainfrom
wq-h:fix/sync-pull-degrade-isolation
Open

wq-h wants to merge 2 commits into
TNT-Likely:mainfrom
wq-h:fix/sync-pull-degrade-isolation

Conversation

@wq-h

@wq-h wq-h commented Oct 12, 2026 •

Copy link
Copy Markdown

背景:现场实证(服务端侧可复现)

服务端 侧观测到 iOS 3.8.6 客户端死循环:

观测项 实测值
请求 GET /sync/pull?since=3500&limit=500 反复拉同一页
响应 200 returned=500 has_more=true server_cursor=4000(服务端游标逻辑正确)
服务端异常 48 小时无 Traceback / 无 500
客户端游标 完全不推进(blocked=true)
结果 外层调度重试 73 次后才偶然通过,19:52:07 推进到 4500
卡住设备 dev_2e6d018a712f6de023c2cad1e8ff3fe6(iOS 3.8.6 build 224);随后 dev_2535… 复现

该页 change_id 3501–4000(500 条)构成:

  • 239 交易(343 条带 attachments、412 条带 tags,全为新特性负载)
  • 235 个分类,几乎每个都带自定义图标
  • 13 标签、13 账户

即"每页 commit 后 unawaited(drainCustomIconQueue()) 用 4 并发下载图标并写库"与
"下一页 500 条整页事务"在同一 SQLite 文件上抢写锁。

根因(两层,缺一不可)

层 1:整页事务没有 per-change 隔离(= #367)

_applyPullPage 在单个整页事务里 apply 全部 change。页内任一条抛非 busy 异常
→ 整页回滚 → 返回 blocked: true → cursor 不推进 → 外层永远重拉同一页。

_applyOneWithBusyRetry 只认 sqlite/database + busy/locked,且只有 2 次快速重试
(~50/100ms)。

层 2:SQLite 没有 busy_timeout(瞬时争用即失败)

lib/data/db.dart 用 NativeDatabase.createInBackground(file),没有 setup 回调。
实测 drift 2.28.2 全仓不设置 busy_timeout(grep -rn busy_timeout 于 pub.dev
发布的 drift-2.28.2 包内 0 命中),SQLite 默认值 = 0:

任何其它写者占用写锁的瞬间,这个页事务立刻得到 SQLITE_BUSY,
连一次等待都没有 → 直接整页回滚。

这解释了"为什么重试 73 次才过":不是数据有毒,而是撞锁窗口随机。

修复

commit 1(对应 #368 的最终版,逐字节套用后仅偏移,无冲突):

  • 快路径:整页批量事务(性能不变)
  • 批量失败 → 降级逐条独立事务:好 change 入库,持久错误(脏数据/解析缺陷)
    记入 pullErrors 隔离跳过,cursor 照常推进,死循环被打破
  • 瞬时错误(busy/locked 重试耗尽)→ 仍 blocked=true 中止本页重试,
    避免好 change 被永久跳过(applyRemoteChange 幂等,重放安全)
  • 失败的 changeId 不 markResolved,UI 仍可见

commit 2(层 2 的对症加固,3 行):

return NativeDatabase.createInBackground(
  file,
  setup: (db) => db.execute('PRAGMA busy_timeout = 5000'),
);

把"撞锁即失败"变成"等锁最多 5s",从源头消掉层 2。

测试

test/cloud/sync/sync_engine_e2e_test.dart 新增/修改:

  • 批量事务失败 → 降级逐条隔离:好 change 入库,坏 change 进 pullErrors,cursor 推进
  • 降级路径:失败的 changeId 不被 markResolved,UI 仍可见
  • _isTransientError 判定逻辑正确

兼容性

  • 纯客户端改动,无协议变更、无服务端配合要求
  • 对老客户端无影响;老客户端仍需服务端/cursor 层面的重试
  • 幂等重放安全(applyRemoteChange 幂等 + 失败 change 不 markResolved)

weiqun added 2 commits October 12, 2026 21:00
改自 TNT-Likely#368(该 PR 被关闭未并入 main,3.8.6 == main 仍存在此缺陷)。

根因: _applyPullPage 整页事务失败时 return blocked=true,cursor 不推进。
下次 pull 仍拉同一页 → 同一条坏 change 再次失败 → 死循环。

现场实证(iOS 3.8.6, change_id 3501-4000 页, 500 条 = 239 交易 +
235 带自定义图标的分类 + 13 标签 + 13 账户):
- 反复 since=3500&limit=500, 服务端正常回 server_cursor=4000
- 整页 apply 失败 → blocked=true → cursor 停住
- 外层调度重试 73 次后 19:52:07 才推进到 4500(瞬时错误,非永久脏数据)

修复: 快路径整页批量事务 → 失败则降级逐条独立事务,
瞬时错误(busy/locked)中止本页重试,持久错误隔离跳过并推进 cursor。

Fixes TNT-Likely#367
drift 2.28.2 全仓不设置 busy_timeout(SQLite 默认 0),任何并发写争用都会
立刻抛 SQLITE_BUSY。sync pull 的整页事务撞上后台写(图标下载队列/后台
widget isolate)即整页回滚 → blocked=true → cursor 不推进。

结合前一个提交的逐条隔离,把瞬时争用即失败变成等锁 5s 后成功,
进一步降低整页重拉概率。
@wq-h

wq-h commented Oct 12, 2026

Copy link
Copy Markdown
Author

在 iOS 3.8.6 (build 224) 上稳定复现,附服务端侧观测,可作为定位参考:

症状:全量同步永远同步不完 / 卡在同一页。

服务端侧(bee count cloud 自建实例)观测:

POST/GET /sync/pull?since=3500&limit=500
→ 200 {"changes": [...500...], "has_more": true, "server_cursor": 4000}
  • 客户端反复拉同一页,since 不前进;服务端 48h 内 0 次 500 / 0 Traceback。
  • 客户端游标 blocked=true 原地不动,重试 73 次后 19:52:07 才推进到 4500。
    → 不是永久脏数据,而是瞬时写锁争用被整页事务放大成"整页回滚"。
  • 另一台 iOS 设备随后同样卡住(dev_2535…)。

卡住页 change_id 3501–4000(500 条)构成:

类型 条数 备注
transaction 239 343 带 attachments、412 带 tags
category 235 几乎每个都带自定义图标
tag / account 13 / 13

即该页 commit 后触发的 unawaited(drainCustomIconQueue())(4 并发下载图标 + 写库)
与下一页 500 条整页事务在同一 SQLite 文件上抢写锁。

两个叠加的成因:

  1. _applyPullPage 整页单事务、无 per-change 隔离 → 任一条抛错即整页回滚 +
    blocked=true,cursor 永不推进(= 本 issue)。
  2. lib/data/db.dart 的 NativeDatabase.createInBackground(file) 未设 setup;
    实测 drift 2.28.2 全仓不设置 busy_timeout → SQLite 默认 0 →
    撞锁瞬间即 SQLITE_BUSY,没有等待窗口。而 _applyOneWithBusyRetry
    只有 2 次 × ~50/100ms 的快速重试。

建议:#368 的方案(批量事务失败 → 降级逐条独立事务 + 隔离跳过 + 推进 cursor)
加上一行 PRAGMA busy_timeout = 5000。前者治"毒 change 永久卡死",后者治"瞬时争用"。
两者缺一,这类死循环仍会复发。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant