存储层分段事务基建:session 传递契约与 SAVEPOINT 修复 #197
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
#151(ADR-0013 单一事务边界落地——请求级事务,issue #108 Q1)。#151 在 2026-08-18 Design-Verify 阶段发现真实改动面远超原 AC 描述(9/10 条候选发现被确认成立,含 4 条 HIGH),状态改为"待拆片";2026-08-21 全 ADR 设计合理性对抗审查又追加一条发现:①层"整个请求单一事务"这一目标机制本身与本项目多轮工具循环架构存在结构性冲突(长事务横跨外部 LLM 调用是数据库反模式),经用户确认,事务边界改为分段——只包住连续无外部 I/O 打断的一段纯 DB 读写,下一步要发外部调用前当前段必须先提交/关闭。
本票是从 #151 拆出的 5 片之一,是地基片:其余 4 片(中间层 session 穿透
gate_pipeline.py/gating.py/delta.py/reflection.py/drift_veto.py/unanswered_escalation.py/proactive_reconnect.py、4 个触发入口的 pre/post-LLM 拆段、5 个 APScheduler 作业的独立会话隔离机制、_write_decision_snapshot吞异常修复等调用侧改造)全部依赖本票在storage.py/storage_port.py/sent_log.py落地的 session 传递机制与 API 形状。本票范围只到 storage 层的接收端——不改任何调用方(runtime_loop.py/gate_pipeline.py等)怎么开段、怎么传,那是其余 4 片的工作。What to build
核心决定:
StoragePort新增一个显式的"开段"原语begin_segment(),而不是给每个方法加一个贯穿整个请求生命周期的session参数。 调用方async with storage.begin_segment() as segment:开一段事务,把segment逐个传给这一段内所有要参与同一事务的StoragePort方法调用;段结束(无论是否还要开下一段)必须先退出这个async with块,再发起任何外部 I/O。同一次触发内可以多次调用begin_segment()——不是"一次触发只能开一次",这正是"分段"而非"重命名的长 session"的核心区别(ADR-0013 2026-08-21 更新节,见下)。StorageSegment是不透明句柄(storage_port.py内定义,Protocol 空标记类型,跟SentLogEntry/SendFailureRecord同样是"契约的一部分不是实现"):调用方不假设它有任何字段/方法,只管原样传下去。PersistentStorage.begin_segment()内部把它包一层get_scoped_session()拿到的真实AsyncSession;SentLog(内存实现)的begin_segment()是纯 no-op(yield一个哨兵,内存写入本来就没有崩溃语义,参与方法直接忽略这个参数)——跟stage_outbox/clear_outbox两个挂钩点内存实现是 no-op 同一先例(storage_port.py:192-196)。结构性消除"async with 误用陷阱"(HIGH,design-verify 已用真实 SQLAlchemy+aiosqlite 复现):不要求 70 个方法各自正确处理"收到外部 session 时不能再包一层 async with"——那正是"要求每处都记得改对"这种模式的根源,只要漏一处陷阱就复现。落地方式是引入
PersistentStorage内部一个共享 async 上下文管理器(建议命名_writer(segment)),所有参与分段的方法统一通过它拿到可写的 session:每个方法体变成
async with self._writer(segment) as s: ...。关键点:方法体里的这个async with操作的是_writer这个包装器,不是直接操作 SQLAlchemy 的session()工厂——所以segment is not None分支下退出这个async with什么都不做,不会触发原陷阱里那次无条件close()。是否 commit/close 只由_writer这一处决定,不再分散在 70 个方法各自的判断里。(具体命名/是否用flush()而非什么都不做留给实现,但"不 wrap 外部传入的 session、不由方法自己 commit/close"这条契约不可变通。)record_pool_usage的 SAVEPOINT 修复(MEDIUM,storage.py:2247-2308):现有 UPDATE 命中 0 行 → INSERT → 撞主键IntegrityError→await s.rollback()→ 退回 UPDATE 重试,这是应对并发写同一(pool, day)行的既有安全设计。当segment is not None时,这个rollback()会撤销该 session 自上次 commit 以来的全部未提交更改,不只是它自己的 INSERT 尝试——会连带撤销同一段内其它已 flush 未 commit 的兄弟写入。落地要求:segment is not None时,INSERT 尝试与撞键重试整段包进async with s.begin_nested():(SAVEPOINT),让这次自动/显式回滚只收窄到这个 SAVEPOINT 范围;segment is None时维持现有顶层rollback()不变(自己独占的临时 session,不会误伤别人)。已核实
ArisePorts.storage是跨并发请求的固定单例、不是工厂(ports.py:200-201,249:""storage是固定实例而非工厂——已发日志/Outbox 必须跨多次 Runtime Loop 触发持久存在"")——不能在触发开始时把它换成"绑定本次 segment 的适配器",否则跟并发到达的其它 chat/event 互相踩踏。这也是为什么机制必须是"每次方法调用显式传segment参数"而不是"给storage实例设一个当前 segment 属性"。APScheduler 5 个作业的独立会话构造方式不在本票范围(design-verify HIGH #4:
get_scoped_session()的(event, matcher)缓存 key 对这 5 个作业全部坍缩成同一个(id(None), None),需要一套独立于 nonebot scoped-session 的机制,留给负责该项的那一片设计)。本票只要求:begin_segment()的默认实现选择用get_scoped_session()是PersistentStorage内部实现细节,不是StoragePort协议契约的一部分——StorageSegment类型本身不与"session 一定来自get_scoped_session()"这个假设绑定,后续那一片如果需要一套不经过get_scoped_session()的构造方式,可以在不改协议签名的前提下调整/扩展PersistentStorage内部实现。Acceptance criteria
一、
begin_segment()原语storage_port.py新增StorageSegment(不透明标记类型)与一个新的 Protocol 切片(比照SendFailureStoragePort的写法),声明begin_segment() -> AbstractAsyncContextManager[StorageSegment],混入StoragePort的基类列表PersistentStorage.begin_segment():async with storage.begin_segment() as segment:正常退出(无异常)时提交;异常退出时不提交(不需要显式rollback()——async with session_factory() as s:自身退出时的隐式 close 已经对未提交事务做回滚,同现有 sent-log/outbox 写入路径已经依赖的同一条 SQLAlchemy 行为)SentLog.begin_segment():no-op 实现,yield一个哨兵StorageSegment,内存写入不受影响begin_segment()(每次对应一个新分段);不支持嵌套调用(在一个尚未退出的 segment 内部再开一个新 segment 是未定义行为,本票不处理,若后续某片确实需要真正嵌套的段由该片自己决定)原文(ADR-0013 决策节①,
docs/adr/0013-crash-recovery-and-side-effect-contract.md):原文(本票同一批要新增的 ADR-0013 更新节,见下方"五"):分段设计下这句改为"事务只包住连续无外部 I/O 打断的一段……下一步要发起任何外部调用……当前事务必须先提交/关闭"。
二、
storage.py~70 个方法的segment参数改造截至 2026-08-21 核实(
storage.py,逐方法用 AST 边界统计,非手数):PersistentStorage有 82 个实例方法调用get_scoped_session()(46 个含await s.commit()的写方法 + 36 个只读方法),另有 2 个模块级函数ensure_schema/recover(storage.py末尾,插件启动/恢复用,不是 per-请求写入,不在本次改造范围)。get_archive_entries/search_learned_stickers是纯 Qdrant 向量操作,不含get_scoped_session(),结构上就不可能参与 DB 事务,同样不在范围内。这个数字与 issue #151 早期讨论中的"42"口径不同(那次计数似乎只统计了写方法且经过两轮订正,本票的 82/46/36 是本次独立重新核实的数字,用于圈定改造范围,不是要跟历史数字对账)。segment: StorageSegment | None = None,通过"一"里的共享_writer(或等价机制)接入:segment=None时行为与现在完全一致(不改变任何现有调用方);segment非None时直接在传入的连接上操作,不 commit、不 closesent_log.py的SentLog类对应方法同步加上相同的segment参数(签名对齐、内部忽略),维持两个实现都满足StoragePort协议PersistentStorage,枚举所有满足"方法体调用了get_scoped_session()"的公开异步方法,断言除"四"里显式登记的排除名单外,其余方法的签名都含segment参数——防止将来新增方法或本票本身遗漏某个方法而无声脱离分段机制(本仓已有教训:机械枚举驱动的覆盖在枚举源头变化时会静默失效,必须有独立的完整性断言而不是依赖人工数出的清单)三、
record_pool_usageSAVEPOINTrecord_pool_usage(storage.py:2247-2308)改造为接受segment参数;segment is not None时,UPDATE 命中 0 行后的 INSERT 尝试 + 撞键重试整段包进session.begin_nested()(SAVEPOINT),使这条路径里的回滚只影响它自己的语句segment内,先用另一个方法写入一条数据(flush 但不 commit),再调用record_pool_usage触发它的撞键重试路径(例如预先插入一行使 UPDATE 命中 0 但后续 INSERT 撞主键),断言record_pool_usage返回后、segment最终整体提交时,前面那条兄弟写入仍然在库里(不能只测record_pool_usage自己的记账结果,必须验证它没有误伤同段内其它已 flush 的写入——这正是 MEDIUM 发现要防的坏结果)原文(design-verify MEDIUM 发现,issue #151 评论):
四、显式排除范围(本条只需要在枚举/文档里划清楚,不需要写任何代码改动)
segment参数:record/get/mark_self_recalled/mark_edited(storage.py:851起)/stage_outbox/clear_outbox——ADR-0013 决策节③层原文明文豁免:SendFailureModel三个方法维持原样,不加segment参数:record_send_failure(runtime_loop.py:2390,_flush发送失败分支,发生在_send_pending真实平台发送尝试之后)、list_send_failures(runtime_loop.py:999,run()方法体开头)、clear_send_failures(runtime_loop.py:1042,run()方法体末尾,_flush_after_silence_window已跑完之后)——三者唯一调用点均在RuntimeLoop.run()/_flush内部这个"真实发送已跑完/正在跑"的不可逆区间,纳入常规改造不会产生任何实际效果(design-verify MEDIUM 发现,issue #151 评论原文)segment参数:update_silence_budget(storage.py:1548)、write_decision_snapshot(storage.py:2036)、update_reconnect_cooldown(storage.py:1592)——ADR-0013 2026-08-18 更新节明文排除:五、ADR-0013 更新节 + design.md 决策基线表同步
docs/adr/0013-crash-recovery-and-side-effect-contract.md追加一段新的更新节(不改写"决策"节原文),记录"分段事务"设计方向与"崩溃保证收窄为只回滚当前未提交段"——内容需准确覆盖:(a) 触发原因是 2026-08-21 全 ADR 设计合理性对抗审查发现的"单一长事务横跨外部 LLM 调用是数据库反模式"这条结构性冲突;(b) 决策改为按"两次外部 I/O 之间"切分事务边界,不是"整个请求一个事务",也不是严格"一轮 while 循环一个事务";(c) 崩溃保证收窄为"只回滚当前未提交的那一段,更早已提交的段落不会被追溯撤销",并给一个"段 N-1 已提交、段 N 崩溃"的具体例子;(d) 明确这条调整不改变①层"记忆/画像/情感态/关系/delta"这个持久写类别列举,也不跟 2026-08-18 那节(决策记账类写入独立提交)冲突——两节调整的是不同维度(一个排除特定写入类别,一个改变其余写入被分组进事务的粒度);(e) 具体 API 形状指向本票。格式照抄本文件现有四段更新节的格式(标题行## 更新(日期,来源):一句话概括)docs/design.md"决策基线"表中复述 ADR-0013 的那一段(design.md:57-59,现文本"core 内部持久写包单一 DB 事务(崩溃即回滚,目标机制尚未实现,全仓约 50 处写入点仍各自独立提交,见 issue #151)")同步更新为反映"分段事务"这个目标机制本身;措辞需要准确区分"本票落地后 storage.py 层机制已存在"与"端到端跨全部触发路径的实际接线仍要等其余 4 片"——不要在本票合并后就把 design.md 写成"已完全落地",除非其余 4 片也已完成原文(design.md 决策基线表现状,2026-08-20 全 ADR 查漏第二轮已加的标注):
六、测试(须防空转,不能只测"全部回滚"或"全部不回滚")
tests/test_storage_transaction_segments.py),复用现有nonebugapp: Appfixture + 真实 SQLAlchemy(同tests/test_cost_storage.py的既有模式,不用 mock session)begin_segment()段内完成一次写入并正常退出(段 N-1,提交成功);再开一个新的begin_segment()段(段 N),段内写入一条数据后让段体本身抛异常(模拟"下一步要发外部调用前触发的崩溃");断言:段 N-1 写入的数据在库里存在,段 N 写入的数据不存在——正例断言两件事都要各自成立,不能只断言其中一件segment内依次调用两个(或以上)不同的存储写方法(不 commit,只让_writer内部 flush),段结束后统一 commit,断言全部方法各自写入的数据都在库里——直接复现 design-verify 阶段"flush 过的数据被静默丢弃、不抛异常"这条 HIGH 发现对应的坏结果,用真实 SQLAlchemy+aiosqlite 而非 mockrecord_pool_usageSAVEPOINT 回归测试见"三"test_*_storage.py,回归验证segment=None时行为不变)Not in scope
runtime_loop.py/gate_pipeline.py/gating.py/delta.py/reflection.py/drift_veto.py/unanswered_escalation.py/proactive_reconnect.py/5 个 APSchedulerentry_*.py)怎么开段、怎么传segment——那是其余 4 片的工作,本票只交付它们要接线的机制StorageSegment/begin_segment()契约不与"session 只能来自get_scoped_session()"这个假设强绑定_write_decision_snapshot吞异常陷阱(gate_pipeline.py:87-152)——该函数本身不在 storage.py,且它调用的write_decision_snapshot已被本票排除出共享分段,这条 MEDIUM 发现的适用前提可能已随 ADR-0013 2026-08-18 更新节变化,留给负责gate_pipeline.py那一片重新评估是否仍然成立Blocked by
None — can start immediately