ADR-0013 单一事务边界落地——请求级事务(issue #108 Q1) #151
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent
#108 Q1(issue #108 grill,2026-08-18 拍板:现在就做真正的请求级事务)
What to build
ADR-0013 决策节写的是"core 内部持久写包进单一 DB 事务,崩溃=从未commit=自动回滚",但实测约 44 处
写入点(画像/事件/关系/情感态等)各自独立开 session、各自提交,没有共享请求级事务。把 session 沿
StoragePort全协议往下传,让同一次触发内的全部持久写共享一个事务。原文(
docs/adr/0013-crash-recovery-and-side-effect-contract.md「决策」节):排除项(ADR-0013 决策节明文豁免,不要动):
Acceptance criteria
StoragePort协议方法新增可选的事务/session 传递机制,_debounced_flush/_evaluate_drive_tick_chat等触发入口在一次触发开始时开一个 session,贯穿传给该次触发内的全部持久写调用
await s.commit()调用点(画像/事件/知识/关系轴/情感态等,storage.py)改为复用传入的 session,不再各自开 session 各自提交
不要并进请求级事务
entry_callback.py×1、entry_drive_tick.py×1、entry_offline_jobs.py×3)各自的写入范围需要显式判断:是否也该在各自的作业执行范围内共享一个事务,还是维持独立提交——写进本票的实现说明,不要含糊带过
有落库(不是只测其中一处)
Not in scope
Understand + Design-Verify 阶段发现记录
在开始 TDD 实现前,先做了 Understand 阶段(核实 storage.py 写入点全貌 + StoragePort 协议面 + 5 个 APScheduler 作业的写入范围),再针对据此提出的一版实现方案(session 可选参数穿透 + 事务边界按"不可逆副作用前后拆段"切分)跑了一轮对抗式 design-verify(2 个角度并行审查产出 10 条候选发现,每条再各自独立二次对抗复核)。
结论:9/10 条发现被复核确认成立(含 4 条 HIGH),1 条被推翻(但推翻它的复核本身也订正了一个数字错误)。真实改动面远超本票 AC 字面描述的"约44处独立commit调用点",记录如下供后续拆票参考。
已确认发现(9条)
[HIGH] 机制存在致命实现陷阱:字面实现会导致数据静默丢失
storage.py 现有44处写入结构均为
session = get_scoped_session(); async with session() as s: ...; await s.commit()。若只把commit()换成flush()、不去掉外层async with包装,async with退出时仍会无条件close()——已用本仓真实 SQLAlchemy + aiosqlite 实测复现:flush 过的数据在外层最终 commit 之后依然不在库里,且全程不抛任何异常,不会被现有测试捕获。落地时必须明确:接收到外部传入 session 的分支绝不能再用async with包装它,只能直接操作调用方传入的对象。[HIGH] 即使写方法改对,合并事务窗口内的"读方法"仍会打断事务
get_scoped_session() 按 (event, matcher) 缓存同一个 Session 对象没错,但每个方法各自的
async with session() as s:退出都会 close() 一次——44个写方法之外的"读方法"目前全部保持独立开关,如果它们在合并事务窗口内被调用,会把前面已 flush 未 commit 的写入静默回滚。已在方案自己点名要合并的三处目标里找到真实实例:entry_callback.py::_deliver_callback:delete_callback(写) →get_fast_affective_state(读,未改造) →_write_decision_snapshot(写)——中间这次读会导致 delete_callback 被静默回滚,下一轮_callback_scan重新判定为待定,造成 Callback 重复送出。delta.py::_compress_one_chat:write_tentative_facts/write_event/write_knowledge之后,循环里先get_relationship_edges(读) 再判断写——会把前面已 flush 的写入回滚,且不触发方案自己设计的"失败回退到 Delta 缓冲"兜底。reflection.py::_apply_outcome:update_fast_affective_state/update_slow_affective_state之后get_proactivity_offset(读) 再写——同样会被打断。[HIGH] session 从触发入口传到 storage.py 的路径缺失
中间还隔着
gate_pipeline.py(_evaluate_unified_gate/_write_decision_snapshot)、gating.py(evaluate_gate) 等中转函数,以及delta.py/reflection.py/drift_veto.py/unanswered_escalation.py/proactive_reconnect.py等模块里同样承担"触发入口→storage调用"中转职责的函数——这些函数目前都不接受 session 参数,若不改,storage.py 新增的可选 session 参数永远只会拿到默认 None,AC 要求的"贯穿传给该次触发内的全部持久写调用"无法达成。另外核实了一条可能的捷径不可行:ArisePorts.storage是跨并发请求的固定单例(不是工厂),不能在触发开始时替换成"绑定本次 session 的适配器",否则会和其它并发到达的 chat/event 互相踩踏。[HIGH] 5个 APScheduler 作业的 session 隔离机制在设计里完全缺失
_callback_scan/_drive_tick/_compress_pending_delta/_run_reflection/_run_unanswered_escalation全部是@scheduler.scheduled_job直接装饰的顶层协程,跑在 nonebot 事件/matcher 上下文之外——get_scoped_session()的 scope key(id(current_event.get(None)), current_matcher.get(None))对这 5 个作业全部坍缩成同一个(id(None), None),即它们实际共享同一个 Python Session 对象。现状因为每次调用都是"开-用-commit-close"的极短窗口所以风险很小;但方案要把持有窗口拉长到跨越真实 LLM 调用的整个 item 处理过程(秒级)。APScheduler 默认配置不会阻止 5 个不同 job 之间的并发重叠执行——一旦重叠,会在同一个不支持并发使用的 AsyncSession 对象上互相踩踏,存在真实的会话状态损坏风险。这 5 个作业需要一套完全独立于 nonebot scoped-session 的会话机制,不能直接套用事件驱动入口那一套。[MEDIUM] "不可逆副作用前后拆两段"字面上收窄了 ADR-0013 的范围,未被记录为 ADR 更新
ADR-0013 决策原文把请求内副作用分三层,"①core内部持久写 → 整个请求包进单一DB事务",明文豁免的只有第③层"已发射到平台的动作"和 Sent Log。silence_budget/决策快照/reconnect_cooldown 等写入按字面都属于第①层,理应在"整个请求"范围内——而方案把 proceed 分支的 silence_budget 写入摘出来、改成 LLM 调用前独立提交、不参与共享事务(工程理由合理:真实发送已不可逆,事后写入失败不该连带撤销已发生的预算消费),但这字面上是对 ADR-0013 §1 范围的收窄,需要走一次 ADR 更新记录,而不只是体现在实现方案的散文里。
[MEDIUM] 遗漏了 2 个结构相同的触发入口
_evaluate_unified_gate实际有 4 个调用点(反应式、Drive Tick、环境信号、即刻追问),方案设计段落只讨论了反应式和 Drive Tick 两个。_run_group_followup_check(群聊即刻追问)和_process_environment_signal(环境信号)具有完全相同的"门控前写→拦截或放行→真实LLM/发送→门控后写"结构,需要同样纳入设计范围(_run_group_followup_check因为是asyncio.create_task发起的脱钩后台任务,还需要额外考虑"不能参与父调用请求级事务,需独立开自己的session")。[MEDIUM]
_write_decision_snapshot吞异常在共享事务下会变成"伪装成功"陷阱该函数内部
try: await ports.storage.write_decision_snapshot(...) except Exception: logger.exception(...),只记日志不重新抛出——这个设计在"快照写入本身失败"场景下合理,但一旦这次写入参与共享事务,前面某条语句已经失败导致事务处于中止状态时,这次写入会把"事务已中止"的错误当成"这次快照写坏了"悄悄吞掉,函数正常返回,日志仍显示决策成功,而同一事务里更早的、本该一起提交的写入也全部没了,且没有任何异常线索指向真实原因。设计需要给出应对(例如共享事务场景下改为失败即重新抛出,或把这次写入排除在共享事务之外)。[MEDIUM]
record_pool_usage现有的 rollback 降级逻辑与共享 session 冲突该方法 UPDATE 命中0行时尝试 INSERT,撞主键触发 IntegrityError 后显式
await s.rollback()再退回 UPDATE 重试——这是应对并发写同一 (pool, day) 行的既有安全设计,不是可删的模板代码。若这次写入的 session 被外层共享,rollback()会撤销该 session 自上次 commit 以来的全部未提交更改,不只是它自己的 INSERT 尝试,会连带撤销同一共享事务内其它已 flush 的兄弟写入(例如 delta 压缩批里更早产出的画像候选/事件)。需要改用 SAVEPOINT(session.begin_nested())等手段把这次 rollback 的影响范围收窄到它自己的语句。[MEDIUM] SendFailureModel 的"纳入常规改造"裁定值得重新考虑
record_send_failure/list_send_failures/clear_send_failures三个方法的唯一调用点全部深埋在RuntimeLoop.run()/run_light()/_run_directive_turn()内部(_flush()的发送失败分支,以及run()方法体首尾),与被 AC 明文排除的 Sent Logrecord()/Outboxclear_outbox()紧邻同一段代码、同属"真实发送已跑完/正在跑"这个不可逆区间。既然这个区间结构性地不参与共享请求事务,"纳入常规改造"这个裁定在实践中不会引入 bug,但也不会有任何实际效果(只是死代码),应该像 Sent Log/Outbox 一样明确排除,而不是保留一个不产生效果的"纳入"标注。被推翻的发现(1条,附带一次数字订正)
有一条发现指出 Understand 阶段"storage.py 恰好44个方法"的计数应订正为43个(
ensure_schema/recover是模块级函数不应计入 PersistentStorage 类方法)——复核时用 AST 独立重新统计,确认这条发现指出的问题方向是对的,但它自己给出的订正数字"43"同样有误:真实数字应为 42(search_learned_stickers是纯 Qdrant 向量检索,不含get_scoped_session/commit,被错误计入了43这个数字里)。SendFailureModel 的两个写方法本来就已经在这42个之内,这个计数订正不影响其它任何裁定。对范围的影响
真实改动面不只是 storage.py 的 ~42 个写方法 + SentLog 镜像,还需要:
gate_pipeline.py/gating.py/delta.py/reflection.py/drift_veto.py/unanswered_escalation.py/proactive_reconnect.py等中间层模块record_pool_usage需要 SAVEPOINT 特殊处理这个规模已经超出单个 PR 合理的评审/合并粒度,建议在开始 TDD 前先按逻辑边界重新拆分子任务。
评估侧已处理上面 Design-Verify 发现里 ADR 层面的那一条:silence_budget/决策快照/reconnect 冷却
写入独立于请求事务这个范围收窄,已补进 ADR-0013 更新节(PR #160)。
其余 9 条(含 4 条 HIGH)都是纯技术实现范围——
async with误用陷阱、写后读打断事务、session传递路径缺失中间层、5 个 APScheduler 作业的隔离机制缺失、
_write_decision_snapshot吞异常陷阱、record_pool_usage的 SAVEPOINT 需求、SendFailureModel 范围裁定、遗漏的 2 个触发入口、44→42数字订正——这些不涉及 ADR/产品决策,原样保留在上面评论里,供拆票时逐条对应到子票 AC,不要在
转手过程中丢失。
按实现侧自己的结论"规模超出单个 PR 合理粒度",状态位改为「待拆片」,转交工单侧按逻辑边界拆分
子任务。
全 ADR 查漏第二轮(PR #174)给 ADR-0013 补了一条如实标注现状的更新节:「单一 DB 事务」这个①层核心机制本身尚未实现(不只是范围定义不精确),design.md「决策基线」表也一并加了标注。没有开新票——本票就是这条落差的归宿,拆片时这条更新节可以直接引用。
全量ADR设计合理性对抗审查发现(2026-08-21):单DB事务目标机制本身与多轮工具循环架构存在结构性冲突
评估侧对全部32条ADR做了一轮设计合理性对抗审查(不是文档-代码一致性核查,是质疑决策本身合不合理),其中一条独立agent对抗式反驳后仍确认成立的发现,与本票直接相关,记录如下供拆片/实现时一并考虑:
发现:ADR-0013①层"整个请求包进单一DB事务"这一目标机制,与本项目自己的多轮工具循环架构存在结构性冲突——要把一次请求内全部持久写纳入同一个事务,就必须让这个事务跨越多次外部LLM API往返(以及可能的host工具调用)持续存活。这是数据库使用上公认的反模式(长时间持有事务横跨慢速外部I/O,易致连接池耗尽/长事务锁争用)。
这与上面Design-Verify阶段已确认的9条发现(尤其"5个APScheduler作业的session隔离机制缺失""写后读打断事务"两条)指向同一类风险的更根本成因:之所以会有这么多session/事务边界的实现陷阱,根源可能就是"整个请求共享一个事务"这个目标本身,在有外部LLM调用穿插的架构下就不是一个健康的设计目标。
建议:拆片时评估是否要把事务边界重新设计为"只包住真正连续的DB写入片段,LLM/工具调用发生在事务之外"(而不是让一个事务横跨整个触发处理过程),而不是把当前"整个请求单一事务"这个目标原样实现出来。这可能意味着 ADR-0013①层的"崩溃=自动回滚"这个免费保证需要重新论证——如果事务本来就不包住LLM调用期间的状态,"崩溃恢复"的实际保证范围会比决策节原文描述的更窄。
这条发现建议在拆片时一并纳入设计考虑,不需要因此新开票——本票(及其"待拆片"状态)就是这条落差的归宿。
拆片前的事务边界设计方向已与用户确认,传递给评估侧正式记录
2026-08-21 那条"全量ADR设计合理性对抗审查"发现(①层目标机制与多轮工具循环架构结构性冲突)明确写着"拆片时评估是否要重新设计事务边界……不需要因此新开票,本票就是这条落差的归宿"——工单侧拆片前就这个分岔跟用户确认了方向,结论如下,供评估侧正式写进 ADR-0013 更新节(本片不代为写这段文档,按分工文档由评估侧维护):
已确认方向:事务边界改为"分段",不是原字面的"整个请求单一长事务"。
具体口径:
while True工具循环一个事务",是严格按"两次外部 I/O 之间"切分——如果某一轮工具循环内又调用了一次外部 host 工具,这次工具执行前也要先把当前事务收口。选择这个方向而非按原字面实现的理由:design-verify 阶段 + 两轮 ADR 查漏 + 一轮全量设计合理性对抗审查,三条独立线索都指向同一个根因——把事务持有窗口拉长到跨越外部 LLM 调用,是数据库使用上公认的反模式(长事务横跨慢速外部 I/O,易致连接池耗尽/长事务锁争用),也是本票 design-verify 阶段那一批"写后读打断事务""5个APScheduler作业session collapse"等具体实现陷阱的更根本成因。按原字面"单一长事务"实现,等于把已经识别出的反模式原样做实。
后续 5 片 slice ticket(存储层事务基建 / 四个门控触发入口 / Delta压缩周期 / 反思闭环家族 / APScheduler定时作业)均按这个方向设计,AC 里会引用这条评论。