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 已经咬了两次:
| # | 路径 | 触发 | 根因 | 修复 |
|---|---|---|---|---|
| 1 | PortfolioManager.record_external_trade(CommSec 邮件导入) | 邮件轮询重跑 / 并发 commsec_apply | processed_emails 在 portfolio-tx 外标记,check→mark 有 TOCTOU | #62:动账本前原子 state_claim(email_id),失败 state_unclaim 重试 |
| 2 | web_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 一并修的两处子问题(同一路径,非独立事故计数,记在此备查)
- 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 + 记错误日志,绝不覆盖别人的写。 _sync_trade_to_portfolio把"落盘后的pm._reload()失败"也算作"同步失败"——with_portfolio_tx退出即已 fsync+atomic rename 落盘,之后的pm._reload()只是刷新内存视图,与本次写入是否成功无关;若把它的异常也归为return False, None,调用方会把已经生效的写入误判为"未同步"→回退状态→允许重试→重放 同一笔 delta(在已经落盘一次之上再叠一次,构成新的双花窗口)。修复:pm._reload()单独 try/except,失败只记 warning,不影响(True, ...)返回值。
决策
规则:可重放的账本写入,必须有"按逻辑事件去重"的幂等保证
一个写路径只要满足"累加语义 + 触发可重放",就必须具备下列之一:
- 原子幂等闸——动 cash/holdings 之前 用
MemoryStore.state_claim(name, key)原子 claim 该事件的去重键(email_id /YYYY-MM/ trade 状态跃迁);claim 失败=已处理=早退; 应用失败再state_unclaim让下次能重试。(check+append 同一把 fcntl 锁,杜绝 TOCTOU。) - 状态机守卫——应用前读当前持久状态,只在"真实状态跃迁"时应用一次 (
patch_trade_status:仅非-executed → executed 才同步)。 - SET / reject-duplicate 语义——写成幂等的覆盖(
PUT holdings= SET)或拒绝重复 (POST holdingssymbol 已存在 → 409),天然安全,无需去重键。
docstring 不许谎称"幂等"——除非上面三条之一在代码里真的存在。累加函数本身永远不幂等。
残余窗口(接受的权衡)
原子 claim 写盘后、portfolio-tx 提交前若被 SIGKILL/OOM → 键已 claim 但账本未改,该事件被 静默跳过,需人工 state_unclaim 重放。这比"崩在提交之后 = 双重记账真金白银"好,故接受。 (见 record_external_trade 注释。)
2026-10-07 #231 补记:
record_external_trade的去重键本身曾不稳定:CommSecemail_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.mdsynced_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_portfolio | PATCH 重试 / 双击 / 并发 | 累加 | 原子 claim_status_transition CAS(#127,取代原状态机守卫)+ 回退用 release_claim CAS(#127)+ sync_pending 崩溃恢复 & portfolio 侧 synced_trade_ids 去重(#231) | ✅ #127 / #231 |
payday_check.run → add_income | cron + 手动 /payday 并发 | 累加 | 原子 state_claim(YYYY-MM)(本 PR) | ✅(payday_check 已随 #177 删除,行保留作历史审计) |
POST /api/holdings | HTTP POST | append | symbol 已存在 → 409 | ✅ |
PUT /api/holdings/{symbol} | HTTP PUT | SET | 覆盖 | ✅ |
DELETE /api/holdings/{symbol} | HTTP DELETE | remove | 第二次 → 404 | ✅ |
napcat /deposit /withdraw /gold* | QQ 私聊 | 累加 | 单 WS 连接内事件串行处理、无重放 | ✅(直接用户动作) |
deposit_cash / withdraw_cash / buy / sell | CLI --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_split | CLI record_split / MCP record_split | 乘法(units × ratio、avg_cost ÷ ratio)——重放最危险:第二次会再乘一遍 | 同上;强烈建议调用方总带 key(MCP 工具描述里写明) | ✅(带 key);不带 key 的重复调用 = 再拆一次,与 buy 同一"不传 key 行为不变"约定 |
record_return_of_capital | CLI / 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_order | CLI / MCP cancel_fund_order | 状态转换 placed → cancelled(现金 +amount) | 同一个 CAS:只有 placed 能撤;已撤销再撤 → cancelled(不二次退钱);已确认 → 拒绝(ValueError,份额已到账,要卖走 sell) | ✅ |
delete_holding | CLI / MCP / /api/skill/delete_holding | remove | 第二次 → "不在持仓" ValueError | ✅(天然幂等) |
/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中止事务(transactioncommit-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接 bodyidempotency_key或Idempotency-Keyheader。远端 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 回归守卫)