Skip to content

ADR-016: 账本写入幂等性(可重放触发必须有原子幂等闸) ​

状态: 提议中 日期: 2026-06-16 作者: Code Review


背景 ​

"钱"散在两个 gitignore 的 store:memory/portfolio.md(cash + holdings)+ db/trades.db(交易账本)。任何把一笔逻辑事件应用进账本的写路径,如果该事件 可被重放/重触发(HTTP 重试、邮件轮询、cron 重跑、agent 重发、双击),而代码用 "累加"语义(cur_units + units、cash -= amount)且没有按事件去重,就会重复入账—— 记的是真金白银。

这一类 bug 已经咬了两次:

#路径触发根因修复
1PortfolioManager.record_external_trade(CommSec 邮件导入)邮件轮询重跑 / 并发 commsec_applyprocessed_emails 在 portfolio-tx 外标记,check→mark 有 TOCTOU#62:动账本前原子 state_claim(email_id),失败 state_unclaim 重试
2web_api patch_trade_status → _sync_trade_to_portfolio(web/GUI/agent 手动 PATCH executed)双击 / 客户端超时重试 / agent 重发endpoint 只看目标 status、不看当前 status;_sync 是累加的,docstring 却谎称"幂等"状态机守卫:trade_before["status"] 判定跃迁,已 executed 再 PATCH 直接幂等返回
3同上(并发场景,#109)两个并发 PATCH executed 同时到达状态机守卫的 check(读 trade_before.status)与 set(patch_status)之间无锁,是三步 read-modify-write,非原子#127:TradesDB.claim_status_transition 单条 SQL UPDATE ... WHERE status != ? 原子 CAS,取代状态机守卫成为本路径现行机制

#1 修完,2026-06-16 全量审计所有账本写路径,又揪出第三处(payday TOCTOU)。#127 里 对同一条 patch_trade_status 路径又发现第四处子问题(见下)。本 ADR 把 这一类不变量正式落档,并附完整审计表,避免第五次。

#127 一并修的两处子问题(同一路径,非独立事故计数,记在此备查) ​

  1. CAS 赢家 sync 失败后的回退曾是无条件 UPDATE——patch_status(trade_id, trade_before.status) 不检查当前状态,若回退期间另一并发请求已把该行改成别的状态(如 cancelled), 无条件回退会静默覆盖对方的合法写入。改用 TradesDB.release_claim(trade_id, from_status="executed", to_status=trade_before.status)——只在行仍是本请求刚 claim 到的 executed 时才回退,否则返回 False + 记错误日志,绝不覆盖别人的写。
  2. _sync_trade_to_portfolio 把"落盘后的 pm._reload() 失败"也算作"同步失败"—— with_portfolio_tx 退出即已 fsync+atomic rename 落盘,之后的 pm._reload() 只是刷新内存视图,与本次写入是否成功无关;若把它的异常也归为 return False, None,调用方会把已经生效的写入误判为"未同步"→回退状态→允许重试→重放 同一笔 delta(在已经落盘一次之上再叠一次,构成新的双花窗口)。修复: pm._reload() 单独 try/except,失败只记 warning,不影响 (True, ...) 返回值。

决策 ​

规则:可重放的账本写入,必须有"按逻辑事件去重"的幂等保证 ​

一个写路径只要满足"累加语义 + 触发可重放",就必须具备下列之一:

  1. 原子幂等闸——动 cash/holdings 之前 用 MemoryStore.state_claim(name, key) 原子 claim 该事件的去重键(email_id / YYYY-MM / trade 状态跃迁);claim 失败=已处理=早退; 应用失败再 state_unclaim 让下次能重试。(check+append 同一把 fcntl 锁,杜绝 TOCTOU。)
  2. 状态机守卫——应用前读当前持久状态,只在"真实状态跃迁"时应用一次 (patch_trade_status:仅非-executed → executed 才同步)。
  3. SET / reject-duplicate 语义——写成幂等的覆盖(PUT holdings = SET)或拒绝重复 (POST holdings symbol 已存在 → 409),天然安全,无需去重键。

docstring 不许谎称"幂等"——除非上面三条之一在代码里真的存在。累加函数本身永远不幂等。

残余窗口(接受的权衡) ​

原子 claim 写盘后、portfolio-tx 提交前若被 SIGKILL/OOM → 键已 claim 但账本未改,该事件被 静默跳过,需人工 state_unclaim 重放。这比"崩在提交之后 = 双重记账真金白银"好,故接受。 (见 record_external_trade 注释。)

2026-10-07 #231 补记:

  • record_external_trade 的去重键本身曾不稳定:CommSec email_id 原是 IMAP 序号, Gmail 归档/删信(INBOX expunge)后序号整体前移 → 已记账邮件换新序号二次入账、新成交撞旧 序号被静默跳过——闸是原子的,键却会漂。改为 msgid:<Message-ID>(缺失退化 uid:<UIDVALIDITY>:<UID>,UID SEARCH/FETCH)。#231 前的纯数字旧键仍按当前序号比对跳过 (不比旧实现差;直接无视会让 lookback 内已记账邮件换新键批量重记)。上面的残余窗口不变。
  • patch_trade_status 的同款窗口此前没列在这里(docstring 还写反了顺序,声称"先同步 portfolio 再提交 status"):CAS claim 先提交 trades.db,崩在同步前 → 行已 executed、账本 没动,之后每次重试都命中幂等早退 = 永久欠账。现已闭合,不再是残余窗口:claim 同一条 SQL 置 sync_pending=1,同步成功后 clear_sync_pending;重试见 executed + pending → 接手同步。接手同步可能撞上"崩在 portfolio 已落盘、标记未清"——所以 _sync_trade_to_portfolio 把 <trade_id>@<ts> 写进 portfolio.md synced_trade_ids,与 cash/holdings 同一次 atomic write,重复同步去重 no-op。只加 trades.db 标记、portfolio 侧不去重的方案会把后一种 崩溃重放成双记(TestClaimSyncCrashRecovery 有反证)。副作用:executed→planned→executed 也不再二次入账。键只留最近 500 个;更老的单子重放回到 #231 前行为。

2026-06-16 审计结果(全部账本写路径) ​

路径触发语义幂等保证结论
record_external_trade邮件轮询 / commsec_apply累加state_claim(email_id),email_id = Message-ID / UID(#231,原 IMAP 序号会漂)✅ #62 / #231
commsec_apply 端点HTTP POST—透传上面✅
patch_trade_status → _sync_trade_to_portfolioPATCH 重试 / 双击 / 并发累加原子 claim_status_transition CAS(#127,取代原状态机守卫)+ 回退用 release_claim CAS(#127)+ sync_pending 崩溃恢复 & portfolio 侧 synced_trade_ids 去重(#231)✅ #127 / #231
payday_check.run → add_incomecron + 手动 /payday 并发累加原子 state_claim(YYYY-MM)(本 PR)✅(payday_check 已随 #177 删除,行保留作历史审计)
POST /api/holdingsHTTP POSTappendsymbol 已存在 → 409✅
PUT /api/holdings/{symbol}HTTP PUTSET覆盖✅
DELETE /api/holdings/{symbol}HTTP DELETEremove第二次 → 404✅
napcat /deposit /withdraw /gold*QQ 私聊累加单 WS 连接内事件串行处理、无重放✅(直接用户动作)
deposit_cash / withdraw_cash / buy / sellCLI --idempotency-key / MCP idempotency_key(含 record_existing_position)/ /api/skill/buy|sell|deposit|withdraw(body idempotency_key 或 Idempotency-Key header)累加可选幂等键:PortfolioManager._ledger_tx 把 key 记进 portfolio 文档自身的 ledger_idempotency_keys(同 synced_trade_ids),check+append 与 cash/holdings 同一把锁、同一次 atomic write;重复 key 返回 {"status": "duplicate"} 不提交事务;失败 / 中断时 key 与账本一起不落盘。远端 CLI 不传 key 自动生成 uuid4,网络失败回显供重试复用✅ PR #432(tests/test_ledger_idempotency.py 20 线程同 key 恰好 1 次入账)
record_dividend(含 --reinvest DRIP)CLI record_dividend / MCP record_dividend(kind=dividend)累加(现金 +净额;DRIP 再 +units、更新 avg_cost)同上 PortfolioManager._ledger_tx 幂等键(ADR-027 PR2):key 与 cash / holdings 同一次 atomic write;DRIP 的第二条流水行(buy,source=drip)在同一事务之后追加,键为 <key>#drip,重放整笔返回 duplicate 两条流水都不重复;流水行都在事务提交之后追加(与 buy / sell 同一约定)——进程恰在两步之间被杀 = 快照已变、流水缺行,业绩的 unreconciled 会显示差额✅ tests/test_ledger_activities.py(含 20 线程同 key 恰好 1 次)
record_interest(record_dividend --kind interest)CLI / MCP record_dividend(kind=interest)累加(现金 +)同上✅
record_fee(--kind fee|tax)CLI record_fee / MCP record_fee累加(现金 −)同上;余额不足在锁内校验、不烧 key✅
record_splitCLI record_split / MCP record_split乘法(units × ratio、avg_cost ÷ ratio)——重放最危险:第二次会再乘一遍同上;强烈建议调用方总带 key(MCP 工具描述里写明)✅(带 key);不带 key 的重复调用 = 再拆一次,与 buy 同一"不传 key 行为不变"约定
record_return_of_capitalCLI / MCP record_return_of_capital累加(现金 +,avg_cost −)同上✅
place_fund_order(场外基金申购第一段)CLI / MCP place_fund_order累加(现金 −amount,订单追加进 holding.extra.pending_orders)同上 PortfolioManager._ledger_tx 幂等键(ADR-027 PR3):key、现金、在途订单同一次 atomic write;重放返回 duplicate,不二次扣现金、不多一张订单。order_id 在锁外生成,重放不会换号。不传 key = 每次都下一张新单(与 buy 同一约定)✅ tests/test_fund_two_phase.py(含 20 线程同 key 恰好 1 张)
confirm_fund_order(手动:CLI / MCP)CLI / MCP confirm_fund_order状态转换 placed → confirmed(units +、avg_cost 加权)——重放最危险:第二次会再加一遍份额CAS,不靠 key:读订单 status、改持仓、写 confirmed 在同一个 with_portfolio_tx 里;读到非 placed 就在锁内抛 _NoChange 中止事务(不提交、文件一个字节都不动),原样返回 already_confirmed / cancelled。联网取净值在锁外(净值没公布 → pending_nav,同样不动账)✅ 并发 8 线程恰好 1 个 ok、其余 already_confirmed;另有"锁外先被别人确认"的确定性用例
confirm_fund_order(jobs/fund_confirm,工作日 21:00-23:30 每 30 分钟)调度器 cron同上同上 CAS——job 重跑 / 与手动确认并发 / 持有旧视图的 job 都只有一个赢家;job 只处理 nav_date ≤ 今天 的单,且读到 already_confirmed 不算失败✅ tests/test_fund_two_phase.py::test_job_*
cancel_fund_orderCLI / MCP cancel_fund_order状态转换 placed → cancelled(现金 +amount)同一个 CAS:只有 placed 能撤;已撤销再撤 → cancelled(不二次退钱);已确认 → 拒绝(ValueError,份额已到账,要卖走 sell)✅
delete_holdingCLI / MCP / /api/skill/delete_holdingremove第二次 → "不在持仓" ValueError✅(天然幂等)
退役 GUI 端点 /api/deposit /api/withdraw /api/gold/buy|sell|set|offset(routers/write.py)HTTP POST累加无去重键✅ 已删除(#236):与 cash_write.py / /api/skill/* 重复、无消费者
退役 GUI 端点 /api/cash/{ccy}/deposit|withdraw(cash_write.py)HTTP POST累加无去重键⚠ GUI 2026-07-05 退役、Web API 已 deprecated,无现存客户端;随 Web API 退场一并删,不再加键
db.trades_db.record_trade/api/trades/record 重试INSERT无去重键⚠ agent 重试产生重复 planned 行,但每行各执行一次(apply 端幂等已守)

已解决(PR #432):累加型写路径的幂等键 ​

原"未决":所有"累加型 POST 写端点"没有幂等键,hub-and-spoke 远端模式(INVEST_API_BASE) 下 HTTP 客户端超时重发会双重应用。

落地方案(不设 TTL,不缓存上次响应——只记"见过的 key",最近 500 个):

  • 核心:core/portfolio_manager.py:PortfolioManager._ledger_tx(idempotency_key) 包住 with_portfolio_tx;有 key 时在 portfolio 锁内读文档自身的 ledger_idempotency_keys 列表 (与 synced_trade_ids 同款,#231):已存在 → 抛 _DuplicateKey 中止事务(transaction commit-on-success,不落盘),方法返回 {"status": "duplicate", "idempotency_key": ...};不存在 → append 进列表,与 cash/holdings 改动同一次 atomic write 落盘。所以业务异常(余额不足)、 schema 校验失败、写盘中途被 kill,key 和账本一起不落盘——不存在"key 烧掉了但账没记"的丢单 窗口(独立 .state 文件的 state_claim 是两个文件两次写,record_external_trade 那条就留着这个 已记录的残余风险)。不传 key 行为完全不变。 buy / sell / deposit_cash / withdraw_cash 四个方法接 idempotency_key=,record_existing_position 经 buy(source_type="external_funding") 同受保护。
  • 入口透传:CLI deposit/withdraw/buy/sell --idempotency-key KEY;MCP 五个动钱工具可选 idempotency_key(docstring:"重试同一笔请复用同一个 key");/api/skill/buy|sell|deposit|withdraw 接 body idempotency_key 或 Idempotency-Key header。远端 CLI(remote_dispatch.py)不传 key 时本地生成一次 uuid4 随请求发送;超时/连不上时 error JSON 回显该 key 并提示 --idempotency-key <key> 重试——这就是"重试复用"的落点(requests 本身不重试 POST)。
  • 测试:tests/test_ledger_idempotency.py(同 key 两次 buy 只变一次 / 不同 key 两次 / 并发 20 线程同 key 恰好 1 次 / 失败释放 key / MCP 层透传),tests/test_web_api.py (body 与 header 两种 key 都去重),tests/test_remote_dispatch.py(自动 uuid4 / 显式 key 原样转发 / 超时回显 key)。

仍留的两条见上表 ⚠:退役 GUI 端点随 Web API 退场一并删;record_trade 的重复 planned 行可加 (verdict_id, symbol, intended_date) 唯一约束,优先级低(每行各执行一次,真正的钱由 apply 端幂等守住)。

不做的事 ​

  • 不合并两条 sync 路径:record_external_trade(CommSec=真实已结算,sold-无持仓仍记 cash) 与 _sync_trade_to_portfolio(web 手工,sold-无持仓 _SkipSync 回滚)语义有意不同, 勿抽成共用底层函数。见两处注释。
  • 本 PR 不做 executed → cancelled 的反向冲销(撤销已执行单子时回滚 cash/holdings)—— 独立改动,单独 PR。
  • 本 PR 不引入 Idempotency-Key 端点方案(见"未决")。

参考 ​

  • CLAUDE.md "公开数据红线 #4"(账本一致性)——已链接本 ADR
  • #62 fix(portfolio): make CommSec record_external_trade idempotent
  • core/memory_store.py:state_claim / state_unclaim(原子去重原语)
  • tests/test_trades_portfolio_sync.py::TestPatchStatusIdempotency(#1 回归守卫)