fix(sync): 整页 apply 失败时降级逐条隔离,打破 pull 死循环 - #368
Conversation
根因: _applyPullPage 整页事务失败时 return blocked=true, cursor 不推进。 下次 pull 仍 since=0, 拉到同样的 500 条, 同一条坏 change 再次失败 → 死循环, iOS 客户端数据永远同步不完。 修复: 整页事务失败时降级到逐条独立事务。好 change 正常入库, 坏 change 记入 pullErrors 表并跳过, cursor 照常推进, 同步继续。 影响: 仅改动 _applyPullPage 方法, 不影响正常路径(批量事务)性能。
TNT-Likely
left a comment
There was a problem hiding this comment.
Review 总评
死循环的根因链分析是准确的(blocked=true 让 cursor 卡在 0 → 反复拉同样 500 条),方向也对,fast-path 性能保住了,文档写得清楚。但当前不能合,有两层问题:一是实现层面没编译过 + 跳过是静默的;二是更根本的——在没搞清"那条 change 为什么 apply 失败"之前就直接跳过它。
🟥 先回答一个前置问题:那条失败的 change 到底是什么?
这个死循环是条件触发的:前提是待同步里某一条 change 在 applyRemoteChange 抛异常(且落在第一页 500 条内)。也就是说它不是普遍问题,而是"数据里碰巧有一条 apply 不过去的 change"的用户才会中。
但 issue #367 和本 PR 都只到"有一条 change 失败"为止,从没定位是哪条、为什么(描述里只是推测"脏数据/格式错误")。这恰恰是最该先查清的地方:
- 若失败是
applyRemoteChange自身的缺陷(例如新版本写的某entity_type/字段老客户端处理不了、缺迁移、apply 有顺序依赖)→ 正解是修 apply;此时"跳过"等于把一条合法数据静默丢掉,还把真 bug 盖住。 - 若确是服务端不可恢复的脏数据 → "隔离跳过"才合理,但必须可见(见 🔴#2)。
旧代码本来就 logger.error 打了 change_id+entity_type、并 pullErrors.record 落库了,报告人设备上应该能拿到。建议先补上失败 change 的 change_id / entity_type / 异常类名+message,据此判断是「该正面修的 apply 问题」还是「真脏数据」,再决定要不要跳。
死循环本身确实该防(卡死的客户端很糟),所以"降级别卡死"作为兜底有价值——但应是「定位并修根因」+「绝不死循环的可见兜底」两件事,本 PR 目前只做了兜底的一半。
🔴 必修
1. 不编译(blocking) — sync_engine.dart:1237
logger.warning('SyncEngine', '...(batch error: $batchError)', batchError, batchSt);LoggerService.warning(tag, message, [data]) 只收 3 个位置参数,这里传了 4 个 → flutter analyze 直接报 error • Too many positional arguments: 3 expected, but 4 found,只有 error(tag, message, [error, stackTrace]) 才收后两个。结果:含该文件的构建 + 整个 test/cloud/sync/ 测试套件都编不过(本地实测编译失败)。说明这版没跑过 analyze/build。修:改回 logger.error(...),或把 batchError/batchSt 拼进 message。
2. 降级跳过完全静默,UI 永远看不到 — 与 _runPullLoop 冲突
降级路径 pullErrors.record(...) 记下坏 change 后返回 blocked:false,但 _runPullLoop(约 1178-1180)会在同一次 pull里对整页每条 change 无条件 markResolved(ch.changeId)。而 markResolved = UPDATE ... SET resolvedAt WHERE change_id=? AND resolvedAt IS NULL、watchUnresolved() 只查 resolvedAt IS NULL → 刚记的错误立刻被标记已解决。于是 PR 自述的"坏 change 记入 pullErrors,UI 已有展示机制"落空,实际是 skip + cursor 推进 = 静默丢数据、用户毫无感知。
修:_applyPullPage 返回本页失败的 changeId 集合,_runPullLoop 只对成功应用的 change markResolved(失败的不要 resolve)。
🟠 强烈建议一起处理
3. 降级不区分瞬时/持久错误 — 瞬时失败也会丢数据
_applyOneWithBusyRetry 对 busy/locked 重试 2 次后 rethrow,降级 catch 一律 skip + 推进 cursor。某条好 change 若因瞬时锁竞争耗尽重试,会被永久跳过(cursor 已过,增量不再拉回)。建议:复用那段 busy/locked 判定,瞬时错误不要跳——中止本页(cursor 不进、下次重试整页),只对持久错误才隔离。
4. 无测试 — 关键同步 + 数据完整性改动只给了"测试建议"没写测试。test/cloud/sync/ 有 e2e 设施,降级路径完全可测(造一条 apply 抛错的 change → 断言好的入库、坏的进 pullErrors 且不被 markResolved、cursor 推进)。补上的话,🔴#2 当场就能被测出来。
说明
- 本 PR 后
_applyPullPage两条路径都return blocked:false,_runPullLoop里if (outcome.blocked)成了死代码 —— 要么清理,要么注释说明保留意图。 sync_engine.dart:1485的curly_bracesinfo 是历史代码(不在本 PR diff 内),非本 PR 引入。
综上 Request changes:🔴#1(编译)、🔴#2(静默丢数据)是硬阻断。更重要的是建议先定位那条 apply 失败的具体 change——很可能是个该正面修的 apply 问题,而不是"跳过了事";兜底降级保留亦可,但要能编译、可见、区分瞬时。辛苦了 🙏
…ent/persistent error, tests) Address all 4 review items from TNT-Likely#368: - TNT-Likely#1 (blocking): Fix compile error - logger.warning() takes 3 params, not 4. Changed to logger.error() which accepts (tag, message, error, stackTrace). - TNT-Likely#2 (blocking): Fix silent data loss in downgrade path. Added failedChangeIds to _PullPageOutcome. _runPullLoop now filters markResolved() to exclude failed changeIds, so UI can still see them. - TNT-Likely#3: Distinguish transient vs persistent errors in downgrade path. Added _isTransientError() static method, reused by both _applyOneWithBusyRetry and downgrade path. Transient errors abort the page (blocked=true, cursor not advanced). Persistent errors are isolated and skipped. - TNT-Likely#4: Added tests for downgrade path behavior, markResolved filtering, and _isTransientError logic. Also: unified _isTransientError() usage in _applyOneWithBusyRetry.
|
已按 review 意见全部修改完毕,请复审 🙏 修改清单
额外说明
|
TNT-Likely
left a comment
There was a problem hiding this comment.
感谢认真回应上轮 review——两阶段策略、瞬时/持久错误区分、markResolved 过滤这些设计方向都是对的,我也逐一核实了几个关键前提:SyncErrorStore.record 是按 change_id 的 update-first upsert(重试不会堆重复行)✅、applyRemoteChange 按 syncId upsert 幂等 ✅、pull() 返回值仅用于日志(transient abort 时 applied=0 无副作用)✅。
但降级路径撞上了一个 LookupCache 的回滚盲区,会静默丢好数据,且本 PR 自己的新测试就能复现失败(PR 描述中「测试通过」与实测不符——flutter test test/cloud/sync/sync_engine_e2e_test.dart 实际是 28 过 1 挂)。
🔴 机制链
- pull 入口把 4 张表 syncId→本地 id 一次性 prime 进
activePullCache,此后它被视为权威:sync_engine_apply.dart:179的if (existingId == null && activePullCache == null)—— 缓存激活时 miss 即视为「实体不存在」,不回查 DB; - 快路径批量事务内,apply 同步写缓存:INSERT 后
putTransaction(apply:259)、delete 后removeTransaction(apply:82),category/account/tag/ledger 同理; - 批量事务抛错 → Drift 只回滚 DB,缓存的修改留下来了;
- 降级重放同一页时:
- 新增类 change → 缓存命中已回滚的 id → 走 UPDATE 分支 → 0 行生效 → 实体永远没入库,而 cursor 已推进,增量不会再拉回 → 永久静默丢失;
- 删除类 change → 缓存条目已删 → delete 变 no-op → 该删的行变僵尸;
- 失败批次里新建的分类/账户 → 悬空 id → 后续交易挂坏外键。
复现:flutter test test/cloud/sync/sync_engine_e2e_test.dart --plain-name "批量事务失败" → Expected: length of <4>, Actual: length of <2>(tx-0/tx-1 消失;日志可见降级路径把 tx-0 打成「更新交易」而非「新增」)。
旧代码死循环但数据一致;当前版本会把「卡住」变成「悄悄丢账单」,必须修复后才能合。
修法建议
进入降级路径时重建缓存(LookupCache()..prime(db) 赋回 activePullCache),且降级循环中每条 change 回滚后同样需要重建(单条事务失败前的 put/remove 也会污染);或者最简单:降级期间把 activePullCache 置 null 走 DB 直查——降级是罕见路径,慢一点换绝对正确,值得。
🟡 其它
_isTransientError的测试是同义反复(见 inline);- 同类隐患順帶提一句:
sync_engine_apply.dart:72在事务内直接删磁盘附件文件,磁盘操作同样不随回滚恢复。属既有债务,但本 PR 让「回滚后继续跑」成为常态路径,建议至少加注释标注,后续单独治理。
修掉缓存一致性 + 全量测试真跑绿之后,这个 PR 我认为就可以合了——#367 的死循环有服务端日志实证,是值得修的真问题。
|
|
||
| for (final ch in changes) { | ||
| try { | ||
| await db.transaction(() async { |
There was a problem hiding this comment.
🔴 必改:降级重放前必须处理 activePullCache 的污染。快路径批量事务里的 putTransaction/removeTransaction 等缓存写入不随 Drift 回滚恢复,而 apply 侧把激活的缓存当权威(miss 即不存在、不回查 DB)。结果就是本文件自己的新测试挂掉的现象:降级重放把本该 INSERT 的 change 打成对已回滚 id 的 UPDATE(0 行生效),数据无声丢失、cursor 照推。
修法:进入降级路径时 activePullCache = LookupCache()..prime(db) 重建;且本循环内每条 change 事务回滚后也要重建(该条失败前的缓存写入同样是脏的)。或者更简单:降级期间置 activePullCache = null 走 DB 直查,牺牲罕见路径的性能换绝对正确。
| final txs = await db.select(db.transactions).get(); | ||
| expect(txs, isEmpty, | ||
| reason: 'apply 抛错时整页 rollback,前面已 INSERT 的也应回滚'); | ||
| expect(txs, hasLength(4), |
There was a problem hiding this comment.
🔴 这条断言目前实际是失败的:Expected: an object with length of <4>, Actual: has length of <2>(tx-0/tx-1 丢失,只剩 tx-3/tx-4——它们还复用了回滚前的 rowid 1/2)。复现:flutter test test/cloud/sync/sync_engine_e2e_test.dart --plain-name "批量事务失败"。根因见 sync_engine.dart 降级路径的 inline 评论。这条测试写得很好,恰好抓住了真 bug——修好缓存问题它就该绿了。
| // | ||
| // 由于 FakeProvider 无法注入 SQLiteException,我们用 _applyOneWithBusyRetry | ||
| // 的行为间接验证:如果 _isTransientError 正确分类,那么降级路径的 blocked | ||
| // 行为就正确。 |
There was a problem hiding this comment.
🟡 这条测试是同义反复:它测的是测试文件里复制的一份判定逻辑,不是生产代码——SyncEngine._isTransientError 改了它照样绿,提供的是虚假覆盖。建议把 _isTransientError 标 @visibleForTesting(import 'package:meta/meta.dart'),直接对真方法断言。
修复内容
Fixes #367
根因
_applyPullPage整页事务失败时返回blocked: true,cursor 不推进。下次 pull 仍用since=0,拉到同样的 500 条,同一条坏 change 再次失败 → 死循环,iOS 客户端数据永远同步不完。服务端日志实证:
修复方案
将
_applyPullPage改为两阶段策略:pullErrors并跳过,cursor 推进回应 Review 意见
🔴#1 编译错误 — 已修复
sync_engine.dart:1237的logger.warning(tag, message, data, st)传了 4 个参数,但LoggerService.warning只收 3 个位置参数。改为logger.error(tag, message, error, stackTrace)。🔴#2 降级跳过完全静默 — 已修复
问题:降级路径
pullErrors.record(...)记下坏 change 后返回blocked:false,但_runPullLoop在同一次 pull 里对整页每条 change 无条件markResolved。markResolved=UPDATE ... SET resolvedAt WHERE change_id=? AND resolvedAt IS NULL→ 刚记的错误立刻被标记已解决,UI 永远看不到。修复:
_PullPageOutcome新增failedChangeIds字段,记录降级路径中失败的 changeId 集合_runPullLoop中markResolved过滤掉failedChangeIds中的 changeresolvedAt保持 null,UI 通过watchUnresolved()正常展示🟠#3 降级不区分瞬时/持久错误 — 已修复
问题:
_applyOneWithBusyRetry对 busy/locked 重试 2 次后 rethrow,降级 catch 一律 skip + 推进 cursor。某条好 change 若因瞬时锁竞争耗尽重试,会被永久跳过(cursor 已过,增量不再拉回)。修复:
SyncEngine._isTransientError(Object e)静态方法,复用_applyOneWithBusyRetry中的 busy/locked 探测逻辑blocked: true,cursor 不进,下次重试整页);持久错误 → 隔离跳过(继续下一条)_applyOneWithBusyRetry也改为调用_isTransientError(),消除重复逻辑🟠#4 补测试 — 已补
pullErrors,cursor 推进到 5resolvedAt保持 null,UI 可见_isTransientError判定逻辑测试:覆盖 SQLite busy/locked(瞬时)vs TypeError/FormatException(持久)关于"先定位失败 change 根因"
Reviewer 提出的前置问题非常重要:在决定跳过之前,先搞清楚那条 change 为什么 apply 失败。
本次修复在降级路径的
logger.error中增加了err=${e.runtimeType}字段,配合pullErrors表中已有的error_class、error_message、rawChangeJson字段,现在可以精确定位:change_id失败entity_type/entity_sync_idrawChangeJson)这意味着无论是
applyRemoteChange自身的缺陷(如缺迁移、缺字段),还是服务端脏数据,都能从sync_pull_errors表中提取出来做根因分析。降级路径的定位:它是安全网,不是替代品。对于真正不可恢复的脏数据,降级让同步继续;但对于
applyRemoteChange的缺陷,应该通过上述日志定位后正面修复。改动范围
仅修改
lib/cloud/sync/sync_engine.dart:_applyPullPage:两阶段策略 + 瞬时/持久区分 +failedChangeIds_runPullLoop:markResolved过滤 +blocked注释_applyOneWithBusyRetry:复用_isTransientError()_PullPageOutcome:新增failedChangeIds字段SyncEngine._isTransientError()静态方法测试
test/cloud/sync/sync_engine_e2e_test.dart:_isTransientError判定逻辑测试