修订说明:本 issue 原标题/正文把问题定性为「两次 DB 写非原子,需要事务」。查证持久层后确认这个定性是错的 —— 事务修不好它。正文已按真实情况重写。短期的爆炸半径收窄已由 PR #449 完成。
真实问题
executeRecovery 链上确认后要把结果落库。链已经动了、回滚不了,而链不可能参与数据库事务。所以这是 chain + DB 的双写问题,不是数据库原子性问题。
不可约的失败场景:
链上 tx 确认 → 进程在任何 DB 写之前崩溃 → DB 说 pending,链说已恢复
加事务不能消除它,只能把窗口从「两次写之间」缩小到「链确认与事务提交之间」。
两次写改为按爆炸半径排序:
updateAccountByAddress(signerAddress,安全相关)先写
updateRecoveryRequest(状态,账务)后写,单独 catch,记录 tx hash / account / new owner 供人工对账,不重新抛出(恢复确实发生了,抛错等于谎报)
最坏情况从「account 行记着旧 owner 且永不自愈」退化为「request 状态陈旧」。窗口收窄,未闭合。
待做:对账(真正的解法)
让 DB 从链派生,而不是试图与链保持同步写:
- 读路径以链为准 —— 返回账户 owner 的地方,对存在
executed/pending recovery 的账户校验链上实际 owner,不一致时以链为准并修正 DB
- 周期性 reconciler —— 扫描
status = "pending" 且已过 timelock 的 recovery request,读链上 activeRecovery() 与账户实际 owner:
- 链上 proposal 已消耗且 owner 已变 → 补写 DB,标
executed
- 链上仍有 active proposal → 保持 pending
- 两者都无 → 标记异常待人工
这同时能自动修掉 PR #449 里那个「request 状态陈旧」的残留情况。
为什么不建议先加事务
查证结果(供后来人判断投入):
persistence.interface.ts 是 47 行、约 30 个方法的扁平 CRUD 接口,无事务概念
PostgresAdapter 构造函数注入 7 个独立 Repository<T>,每次写是 repository.update()。要跨表事务须改注入 DataSource 并用 dataSource.transaction(manager => ...),事务内所有方法必须改用该 manager 而非注入的 repository —— 等于给 30 个方法加一条可选 manager 通路,或重构整个 adapter
JsonAdapter 是 fs.writeFile(整个文件),两个操作分别写 recovery-requests.json / accounts.json。无锁、无原子 rename、无 fsync —— 连单次写的崩溃安全都不具备。跨两个 JSON 文件做 ACID 需要 WAL 或合并为单文件存储,是 adapter 重写
投入最大、且仍不解决本 issue。若将来因其他原因(例如转账流程)需要事务,那是独立议题,不应挂在这里。
关联
真实问题
executeRecovery链上确认后要把结果落库。链已经动了、回滚不了,而链不可能参与数据库事务。所以这是 chain + DB 的双写问题,不是数据库原子性问题。不可约的失败场景:
加事务不能消除它,只能把窗口从「两次写之间」缩小到「链确认与事务提交之间」。
已完成(PR #449)
两次写改为按爆炸半径排序:
updateAccountByAddress(signerAddress,安全相关)先写updateRecoveryRequest(状态,账务)后写,单独 catch,记录 tx hash / account / new owner 供人工对账,不重新抛出(恢复确实发生了,抛错等于谎报)最坏情况从「account 行记着旧 owner 且永不自愈」退化为「request 状态陈旧」。窗口收窄,未闭合。
待做:对账(真正的解法)
让 DB 从链派生,而不是试图与链保持同步写:
executed/pendingrecovery 的账户校验链上实际 owner,不一致时以链为准并修正 DBstatus = "pending"且已过 timelock 的 recovery request,读链上activeRecovery()与账户实际 owner:executed这同时能自动修掉 PR #449 里那个「request 状态陈旧」的残留情况。
为什么不建议先加事务
查证结果(供后来人判断投入):
persistence.interface.ts是 47 行、约 30 个方法的扁平 CRUD 接口,无事务概念PostgresAdapter构造函数注入 7 个独立Repository<T>,每次写是repository.update()。要跨表事务须改注入DataSource并用dataSource.transaction(manager => ...),事务内所有方法必须改用该 manager 而非注入的 repository —— 等于给 30 个方法加一条可选 manager 通路,或重构整个 adapterJsonAdapter是fs.writeFile(整个文件),两个操作分别写recovery-requests.json/accounts.json。无锁、无原子 rename、无 fsync —— 连单次写的崩溃安全都不具备。跨两个 JSON 文件做 ACID 需要 WAL 或合并为单文件存储,是 adapter 重写投入最大、且仍不解决本 issue。若将来因其他原因(例如转账流程)需要事务,那是独立议题,不应挂在这里。
关联
onChain.newOwner写入,把这个既有问题暴露得更清楚)