存储层分段事务基建:session 传递契约与 SAVEPOINT 修复 #197

Open
opened 2026-08-21 06:12:44 +00:00 by KumaAgent · 0 comments
Member

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() 拿到的真实 AsyncSessionSentLog(内存实现)的 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:

@asynccontextmanager
async def _writer(self, segment: StorageSegment | None):
    if segment is not None:
        yield _session_of(segment)
        return  # 不 commit、不 close——生命周期属于 begin_segment() 的调用方
    session_factory = get_scoped_session()
    async with session_factory() as s:
        yield s
        await s.commit()

每个方法体变成 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 → 撞主键 IntegrityErrorawait 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):

core 内部持久写(记忆/画像/情感态/关系/delta)→ 整个请求包进单一 DB 事务;崩溃 = 从未 commit = 自动回滚。

原文(本票同一批要新增的 ADR-0013 更新节,见下方"五"):分段设计下这句改为"事务只包住连续无外部 I/O 打断的一段……下一步要发起任何外部调用……当前事务必须先提交/关闭"。

二、storage.py ~70 个方法的 segment 参数改造

截至 2026-08-21 核实(storage.py,逐方法用 AST 边界统计,非手数):PersistentStorage82 个实例方法调用 get_scoped_session()(46 个含 await s.commit() 的写方法 + 36 个只读方法),另有 2 个模块级函数 ensure_schema/recoverstorage.py 末尾,插件启动/恢复用,不是 per-请求写入,不在本次改造范围)。get_archive_entries/search_learned_stickers 是纯 Qdrant 向量操作,不含 get_scoped_session(),结构上就不可能参与 DB 事务,同样不在范围内。这个数字与 issue #151 早期讨论中的"42"口径不同(那次计数似乎只统计了写方法且经过两轮订正,本票的 82/46/36 是本次独立重新核实的数字,用于圈定改造范围,不是要跟历史数字对账)。

  • 上述 82 个方法中,按下方"四"排除的 12 个之外,其余全部新增可选关键字参数 segment: StorageSegment | None = None,通过"一"里的共享 _writer(或等价机制)接入:segment=None 时行为与现在完全一致(不改变任何现有调用方);segmentNone 时直接在传入的连接上操作,不 commit、不 close
  • sent_log.pySentLog 类对应方法同步加上相同的 segment 参数(签名对齐、内部忽略),维持两个实现都满足 StoragePort 协议
  • 新增一条结构性 guard 测试(不是逐方法手写断言):反射/自省 PersistentStorage,枚举所有满足"方法体调用了 get_scoped_session()"的公开异步方法,断言除"四"里显式登记的排除名单外,其余方法的签名都含 segment 参数——防止将来新增方法或本票本身遗漏某个方法而无声脱离分段机制(本仓已有教训:机械枚举驱动的覆盖在枚举源头变化时会静默失效,必须有独立的完整性断言而不是依赖人工数出的清单)

三、record_pool_usage SAVEPOINT

  • record_pool_usagestorage.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 评论):

若这次写入的 session 被外层共享,rollback() 会撤销该 session 自上次 commit 以来的全部未提交更改,不只是它自己的 INSERT 尝试,会连带撤销同一共享事务内其它已 flush 的兄弟写入……需要改用 SAVEPOINT(session.begin_nested())等手段把这次 rollback 的影响范围收窄到它自己的语句。

四、显式排除范围(本条只需要在枚举/文档里划清楚,不需要写任何代码改动)

  • Sent Log/Outbox 六个方法维持原样,不加 segment 参数:record/get/mark_self_recalled/mark_editedstorage.py:851 起)/stage_outbox/clear_outbox——ADR-0013 决策节③层原文明文豁免:

    Sent Log 写入必须独立于请求事务、随发即持久(不进第①层的回滚事务)。

  • SendFailureModel 三个方法维持原样,不加 segment 参数:record_send_failureruntime_loop.py:2390_flush 发送失败分支,发生在 _send_pending 真实平台发送尝试之后)、list_send_failuresruntime_loop.py:999run() 方法体开头)、clear_send_failuresruntime_loop.py:1042run() 方法体末尾,_flush_after_silence_window 已跑完之后)——三者唯一调用点均在 RuntimeLoop.run()/_flush 内部这个"真实发送已跑完/正在跑"的不可逆区间,纳入常规改造不会产生任何实际效果(design-verify MEDIUM 发现,issue #151 评论原文)
  • "决策记账"三件套维持原样,不加 segment 参数:update_silence_budgetstorage.py:1548)、write_decision_snapshotstorage.py:2036)、update_reconnect_cooldownstorage.py:1592)——ADR-0013 2026-08-18 更新节明文排除:

    这几类写入在决策产生的那一刻(LLM 调用之前)独立提交,不参与①层的请求级共享事务。

  • 这三组共 12 个方法的排除理由要写进新增 guard 测试(上面"二")的排除名单旁注释,不能只是口头共识——guard 测试本身就是这份排除清单的强制执行点

五、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 查漏第二轮已加的标注):

core 内部持久写包单一 DB 事务(崩溃即回滚,目标机制尚未实现,全仓约 50 处写入点仍各自独立提交,见 issue #151)……

六、测试(须防空转,不能只测"全部回滚"或"全部不回滚")

  • 新增测试文件(建议 tests/test_storage_transaction_segments.py),复用现有 nonebug app: App fixture + 真实 SQLAlchemy(同 tests/test_cost_storage.py 的既有模式,不用 mock session)
  • 核心正例(对应"防空转"要求):在一个 begin_segment() 段内完成一次写入并正常退出(段 N-1,提交成功);再开一个新的 begin_segment() 段(段 N),段内写入一条数据后让段体本身抛异常(模拟"下一步要发外部调用前触发的崩溃");断言:段 N-1 写入的数据在库里存在,段 N 写入的数据不存在——正例断言两件事都要各自成立,不能只断言其中一件
  • async-with 误用陷阱回归测试:在一个共享 segment 内依次调用两个(或以上)不同的存储写方法(不 commit,只让 _writer 内部 flush),段结束后统一 commit,断言全部方法各自写入的数据都在库里——直接复现 design-verify 阶段"flush 过的数据被静默丢弃、不抛异常"这条 HIGH 发现对应的坏结果,用真实 SQLAlchemy+aiosqlite 而非 mock
  • record_pool_usage SAVEPOINT 回归测试见"三"
  • 结构性 guard 测试见"二"
  • 全量测试通过(含既有 ~30+ 个 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 个 APScheduler entry_*.py)怎么开段、怎么传 segment——那是其余 4 片的工作,本票只交付它们要接线的机制
  • 不设计 5 个 APScheduler 作业的独立会话隔离机制(design-verify HIGH #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 那一片重新评估是否仍然成立
  • 不新增崩溃恢复相关的新机制,也不改变哪些持久写类别属于 ADR-0013 ①层——这是把已拍板的分段设计做实,不是新决策

Blocked by

None — can start immediately

## 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: ```python @asynccontextmanager async def _writer(self, segment: StorageSegment | None): if segment is not None: yield _session_of(segment) return # 不 commit、不 close——生命周期属于 begin_segment() 的调用方 session_factory = get_scoped_session() async with session_factory() as s: yield s await s.commit() ``` 每个方法体变成 `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`): > core 内部持久写(记忆/画像/情感态/关系/delta)→ 整个请求包进**单一 DB 事务**;崩溃 = 从未 commit = 自动回滚。 原文(本票同一批要新增的 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 是本次独立重新核实的数字,用于圈定改造范围,不是要跟历史数字对账)。 - [ ] 上述 82 个方法中,按下方"四"排除的 12 个之外,**其余全部**新增可选关键字参数 `segment: StorageSegment | None = None`,通过"一"里的共享 `_writer`(或等价机制)接入:`segment=None` 时行为与现在完全一致(不改变任何现有调用方);`segment` 非 `None` 时直接在传入的连接上操作,不 commit、不 close - [ ] `sent_log.py` 的 `SentLog` 类对应方法同步加上相同的 `segment` 参数(签名对齐、内部忽略),维持两个实现都满足 `StoragePort` 协议 - [ ] 新增一条**结构性 guard 测试**(不是逐方法手写断言):反射/自省 `PersistentStorage`,枚举所有满足"方法体调用了 `get_scoped_session()`"的公开异步方法,断言除"四"里显式登记的排除名单外,其余方法的签名都含 `segment` 参数——防止将来新增方法或本票本身遗漏某个方法而无声脱离分段机制(本仓已有教训:机械枚举驱动的覆盖在枚举源头变化时会静默失效,必须有独立的完整性断言而不是依赖人工数出的清单) **三、`record_pool_usage` SAVEPOINT** - [ ] `record_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 评论): > 若这次写入的 session 被外层共享,`rollback()` 会撤销该 session 自上次 commit 以来的全部未提交更改,不只是它自己的 INSERT 尝试,会连带撤销同一共享事务内其它已 flush 的兄弟写入……需要改用 SAVEPOINT(`session.begin_nested()`)等手段把这次 rollback 的影响范围收窄到它自己的语句。 **四、显式排除范围(本条只需要在枚举/文档里划清楚,不需要写任何代码改动)** - [ ] Sent Log/Outbox 六个方法维持原样,不加 `segment` 参数:`record`/`get`/`mark_self_recalled`/`mark_edited`(`storage.py:851` 起)/`stage_outbox`/`clear_outbox`——ADR-0013 决策节③层原文明文豁免: > **Sent Log 写入必须独立于请求事务、随发即持久**(不进第①层的回滚事务)。 - [ ] `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 更新节明文排除: > 这几类写入在决策产生的那一刻(LLM 调用之前)独立提交,不参与①层的请求级共享事务。 - [ ] 这三组共 12 个方法的排除理由要写进新增 guard 测试(上面"二")的排除名单旁注释,不能只是口头共识——guard 测试本身就是这份排除清单的强制执行点 **五、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 查漏第二轮已加的标注): > core 内部持久写包**单一 DB 事务**(崩溃即回滚,**目标机制尚未实现,全仓约 50 处写入点仍各自独立提交,见 issue #151**)…… **六、测试(须防空转,不能只测"全部回滚"或"全部不回滚")** - [ ] 新增测试文件(建议 `tests/test_storage_transaction_segments.py`),复用现有 `nonebug` `app: App` fixture + 真实 SQLAlchemy(同 `tests/test_cost_storage.py` 的既有模式,不用 mock session) - [ ] **核心正例**(对应"防空转"要求):在一个 `begin_segment()` 段内完成一次写入并正常退出(段 N-1,提交成功);再开一个新的 `begin_segment()` 段(段 N),段内写入一条数据后让段体本身抛异常(模拟"下一步要发外部调用前触发的崩溃");断言:**段 N-1 写入的数据在库里存在,段 N 写入的数据不存在**——正例断言两件事都要各自成立,不能只断言其中一件 - [ ] async-with 误用陷阱回归测试:在一个共享 `segment` 内依次调用两个(或以上)不同的存储写方法(不 commit,只让 `_writer` 内部 flush),段结束后统一 commit,断言**全部**方法各自写入的数据都在库里——直接复现 design-verify 阶段"flush 过的数据被静默丢弃、不抛异常"这条 HIGH 发现对应的坏结果,用真实 SQLAlchemy+aiosqlite 而非 mock - [ ] `record_pool_usage` SAVEPOINT 回归测试见"三" - [ ] 结构性 guard 测试见"二" - [ ] 全量测试通过(含既有 ~30+ 个 `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 个 APScheduler `entry_*.py`)怎么开段、怎么传 `segment`——那是其余 4 片的工作,本票只交付它们要接线的机制 - 不设计 5 个 APScheduler 作业的独立会话隔离机制(design-verify HIGH #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` 那一片重新评估是否仍然成立 - 不新增崩溃恢复相关的新机制,也不改变哪些持久写类别属于 ADR-0013 ①层——这是把已拍板的分段设计做实,不是新决策 ## Blocked by None — can start immediately
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ProjectKuma/arise#197
No description provided.