Appearance
规范 12 · 事务契约
立案:
issues/144(MoonBit 那栈把事务句柄挂进程级全局槽,并发请求互相提交、 把别人已确认的写入抹掉)与issues/145(八栈事务现状普查)。 owner 2026-10-06 两条拍板:① 允许每门语言用自己的机制(语言特性不同),本章只钉行为; ② 拦截器保持现有效果(不隔离,抛错连坐主流程)。 本章是事务这件事的唯一权威;规范 05 §事务约定 的机制表降为"各栈机制登记处",判据一律看本章 §12.4。
12.1 四条立约原则
- 钉行为,不钉机制。用什么机制(Spring 事务管理器 / 共享 PDO 连接 /
context.WithValue/contextvars/AsyncLocalStorage/AsyncLocal<T>/ 仓储实例携带句柄)由该语言决定, 只要能过 §12.4 三档判据就算合规。禁止把"必须用某某 API"写进契约——那是把语言差异当成缺陷。 - 事务边界必须是调用作用域(thread / task / request / 调用方持有的实例), 禁止进程级可变句柄槽。判据见 §12.4 归属档。
- 引擎对事务是可选参与,不是不感知(本条更正规范 05 旧原则 1"引擎核心不感知事务"): 引擎在其命令入口内包一层事务钩子(有则用、无则裸跑)与宿主自己在外面包,两种形态都合规, 因为行为判据只看"这次动作的写是否原子",不看包在哪一层。
- 默认降级必须显式且可观测:未注册事务模板/未包事务时,该动作逐语句自动提交—— 这是允许的运行态,但集成层不得把它当契约默认值来验收(§12.6)。
12.2 四个面
原子面(哪些动作必须原子)
判据是一次动作内含 ≥2 条写语句,不是动作名。按 MoonBit 门面现读(facade/+core/engine/):
| 必须原子 | 内含写(现读证据) |
|---|---|
processInstance/startAndExecute、processDefine/startAndExecute | save_instance + create_cc_instance_if_absent + 每新任务 save_task+add_task_actor + update_instance ⇒ 4 表 4~8 语句 |
processTask/execute(AGREE / REJECT / ROLLBACK / JUMP / ROLLBACK_TO_OPERATOR / COUNTERSIGN_* 全分支) | update_task + 新任务 save_task/add_task_actor + 历史腿 save_task + update_instance + cc |
processInstance/withdraw | update_task(级联)+ update_instance |
processTask/transfer、processTask/surrogate、processTask/addCandidate、processTask/removeTaskActor | 参与者表多行读写 + 任务行更新 |
processInstance/createCCInstance、create_cc_instance_if_absent 路径 | 读判重 + 逐人 insert(读写混合,非单语句) |
processDesign/updateDefine、processDesign/redeploy、processDesign/save、processDefine/upAndDown、processDefine/redeploy | 跨 define+design 两表(MoonBit 现读:updateDefine=save_design+update_define+update_design;redeploy=update_define+update_design;save=save_design+update_design) |
级联删除——仅内置版形态:processDefine/remove、processInstance/remove 带 cascade=True(jeeflow 门面没有 processInstance/remove 这个 action,现读 spec/06 清单,删除族只有 processDefine/remove/processDesign/remove/processSurrogate/remove) | 主行+设计/历史/实例/任务/参与者多表 |
不需要包:纯读(*/page、*/detail、todoList、doneList、highLight、approvalRecord、 candidatePage、stats 族)与单语句写(如 processInstance/updateCCStatus)。
⚠ 宿主外包事务时的两条硬义务(不是可选建议,八栈里已有两栈在这上面翻过车):
- 门面/引擎的出口若把异常吞成错误信封,外包方必须把"非成功出口"转成抛出。 否则动作内部失败时事务照常
COMMIT,留下半完成实例——原子面直接失效。 典型形状是"统一响应{code,msg,data}"这类门面(本仓 MoonBit 侧已用真库把这条钉成读数: 事务内跑一次内部失败的发起,回调正常返回 ⇒ 模板走 COMMIT ⇒ 实例行留存)。 引擎内包那一派没这个问题:它在 raise 处回滚,错误信封只在事务之外生成。 - 回调/模板的错误集不要收成闭集。MoonBit 这类语言里"未标注
raise的async函数"会被推成 开放效应,若模板回调声明成闭集(只收本栈错误类型),宿主连门面调用都包不进去(编译期直接拒)。 模板对"抛什么错"本就不该挑:任何错都回滚、原样外抛。
覆盖面(事务必须盖住这次动作的全部写)
- 同一事务内所有仓储调用必须走同一事务载体(连接/句柄)。扩展仓储不是豁免面——
design/design_his/surrogate/cc这些走 ext 端点的写,与主表写同属一次动作。 - 集成层若启用了 persist(业务数据动态入库,规范 09/10),那张业务表的写也在同一事务内(与内置版一致)。
- ⚠ 两类纸面看不出来的破口形状(各栈是否命中,见
issues/145的普查读数): ① 引擎确实开了事务,但仓储每条语句各自取一条新连接(走 Spring 时必须经DataSourceUtils/TransactionAwareDataSourceProxy取连接,裸dataSource.getConnection()拿不到事务连接); ② 主仓储绑了上下文载体、扩展仓储另绑一枚从不写入的载体(各绑一枚 ALS/Local ⇒ 扩展仓储的写永远逃逸)。
归属面(边界挂在哪个作用域上)
- 禁止进程级可变事务句柄槽(全局
Ref/static var/裸let)。 载体必须是 thread/task/request 作用域,或"随调用方持有的对象实例走"(MoonBit 采后者: 句柄是仓储实例字段,请求边界用派生构造切开)。 - 嵌套必须复用:同一调用作用域内重入事务钩子,必须并入当前事务(不得另开连接、不得重复 BEGIN)。 反向也禁:不得因为"已经有事务在跑"就拒绝开启业务动作(
required语义)。 - 跨作用域必须切开:两个并发请求/任务各自开启,彼此不得看到对方的句柄。 判据是 §12.4 归属档,这条是 144 的立法化——案文里那条"环境连接=上下文绑定"的 contextvars/AsyncLocalStorage 类比不成立(那两者按任务隔离,进程级全局槽不是)。
- "派生"必须是新建一个对象。在按引用传递宿主对象的语言里(MoonBit 实测:结构体
let b = a之后 写b的可变字段会改到a,Map/Array赋值同样共享底层),若请求级切换写成"原地改字段再返回自己", 一次派生就等于全进程换血——归属档会直接红。容器要逐个copy(), 而无状态共享的资源(id 生成器等)反向要按引用带过去:雪花worker_id固定、sequence每毫秒归零, 每请求新建生成器会在同一毫秒撞出同一个 id ⇒ 主键冲突。
副作用面(监听器与拦截器)
- 监听器:逐支隔离,且不回滚主流程。每支监听器单独捕获异常、记日志、继续广播其余支。 八栈现状在此已一致(各栈
ProcessPublisher/notify/#fireEvent内逐支 try-catch,行号清单见issues/145)。 - 拦截器:不隔离,抛错即中断并可回滚整次动作(owner 2026-10-06 确认保持现有效果)。 ⇒ 拦截器是规则(业务前置校验、persist 落库),失败就该让动作作废;监听器是通知, 一支坏不能连累流程。这条差异是契约,不是实现巧合。
- 通知型副作用可以另开通道(站内信、推送、第三方调用另取连接/另提交,宿主选择), 但必须明写它不在本动作事务内:流程回滚时它已提交,不可指望事务撤销它。 内置版即此形态(Python 站内信 handler 自开 session 自提交)。
- ⚠ 与规范 11 §11.2 原则 3"只在落库之后 fire"的接缝:本章把"落库"读作 写入已进入当前事务之后(提交之前)。因此监听器用同一连接反查会读到未提交数据—— 这是预期行为,不是脏读缺陷;用另一条连接反查则读不到,集成方必须按此选型。
12.3 各栈机制映射(登记用,允许不同、不要求同形)
| 栈 | 载体 | 边界开在 | 未注册/未包时的降级形态 |
|---|---|---|---|
| Java | Spring TransactionTemplate(ThreadLocal)+ SPI ITransactionTemplate | 引擎命令入口 runInTx | 引擎直接执行动作(runInTx 取不到模板即裸跑) |
| PHP | 共享 \PDO + SPI TransactionTemplateInterface | 引擎 inTx(五入口+门面整次 dispatch,R9 145-7 起) | 引擎内联裸跑(查不到注册件即 return $action();NoOpTransactionTemplate 件引擎零引用、仅测试自装用,issues/223) |
| C# | AsyncLocal<MySqlConnection?>/AsyncLocal<MySqlTransaction?> + ITransactionTemplate | 引擎 RunInTxAsync(命令入口)+门面 FlowAsync 整次 dispatch 包进公开 InTxAsync(R9 145-4 起) | 无模板则逐语句自动提交 |
| Go | context.WithValue(txKey, *sql.Tx) | 引擎 InTx(五入口+门面 Flow 整次 dispatch,R9 145-3 起)+仓储 WithTx join 并入(调用方自包仍可) | 无 ctx 事务时每条语句走池连接自动提交 |
| Python | contextvars.ContextVar | 引擎 _in_tx(五入口)+门面 flow() 整次 dispatch 经 repo.with_tx(R9 145-3 起;仓储无 with_tx 裸跑降级) | 同上 |
| Node | AsyncLocalStorage<SqlConnection> | 引擎 inTx(五入口+门面 flow() 整次 dispatch,R9 145-2 起) | 同上 |
| Rust | SPI TransactionTemplate(execute_in_tx 闭包)+发行实现经线程本地槽路由连接(sqlx TX_SLOT,仓储与集成写腿同源取用) | 引擎 in_tx(各命令入口+门面 flow() 整次 dispatch,R9 145-5 起);注册件由宿主注入(with_transaction_template) | ServiceContext 无槽时引擎裸跑 |
| MoonBit | 仓储实例字段 tx : Ref[...],new_request() 派生请求级实例(execute_in_tx 是具体类型扩展、不在 ProcessRepository trait 契约面上) | 仓储 execute_in_tx(调用方决定) | open_or_tx 落 self.open(),每语句自动提交 |
各栈现状是否过 §12.4 三档、以及逐条证据行号与待修形状,见
issues/145。 本表只登记机制,不构成任何合规声明。
12.3.1 事件派发与事务的关系(issues/146 补)
事务这件事对事件监听器只有两条约束,写在这里免得各栈各自解释:
- fire 的位置=数据对外可见的位置。宿主把整轮写包进事务时,派发必须排在
COMMIT之后 (boot4 用 afterCommit 监听器正是这个理由);否则监听器读到/写出的是"尚未提交"的状态,回滚后 站内信/通知已经发出去了。门面级没有整轮事务模板的栈,逐条写自动提交 ⇒ "落库后"与"提交后"等价, 在该栈的事务模板真包住一次动作之前,落库后 fire 就是合规的。 ⚠ 这条是时序承诺,不是实现细节:哪一栈把模板接到门面上(§12.4 的判据),那一栈的 fire 点必须同步后移。 承诺对象=监听器看到的世界(audit8 R77,2026-10-16 owner 拍读法一):fire 在事务内、由监听器侧挂提交后钩子,与 fire 直接排在COMMIT之后,两形都算兑现本条——java 参考实现正是前形(JeeflowEngineImpl.java:167「fire 在 runInTx 事务内…监听器侧用 afterCommit 延迟反查」,boot4 的 afterCommit 监听器同形)。 但接了整轮事务模板的栈必须提供可用的提交后钩子通道:现读 go/python/node/csharp/rust 五栈afterCommit/after_commit全域零命中 ⇒ 缺的是这条通道(php 已随 issues/191 补齐=引擎委托模板可选afterCommit+壳接DB::afterCommit),不是把 fire 搬家。下限不变:回滚不得已让监听器看到成功。 - 监听器/回调的错误集不因派发收紧。§12.1 条 2 要求事务模板回调保持开放错误集; 事件总线对监听器抛错的处置是逐个隔离(单监听器失败不回滚主流程、不中断后续监听器,见规范 11 §11.5), 所以"监听器只能抛某几种错"不是规范 12 的要求——各栈按自己语言能表达的最自然形状来(MoonBit 把 监听器改成 async 闭包以便 await 写库,闭集
raise是它的语言表达方式,不构成对其他七栈的签名要求)。
12.4 三档行为判据(各栈用自己的机制去过)
三档都必须在真库上跑(内存仓造不出连接归属),并进各栈 T1;每档都要能红,红样原文进报告。
- 原子档:一次发起(或办理)中途抛错 ⇒ 断言该动作的全部写入都不存在 (实例行、任务行、参与者行、cc 行、业务表行逐个反查)。未包事务时这一档必红——它是"启用了事务就要原子"的判据。
- 归属档:两个并发调用作用域各走一次事务,A 写→让出→抛错回滚,B 在让出窗口写完并成功返回 ⇒ 断言 A 的行不在 且 B 的行在;并加一条同作用域嵌套档,断言内层不重复 BEGIN、随外层一起回滚。 ⚠ 这一档在进程级槽形状下必红(144 的 X2 实测即
B 的行=0)。 - 空转档(专抓"名义有事务、实际盖不住写"):事务内写入一行,在提交之前用同事务的连接断言读得到、 随后回滚并断言不存在;再用另一条独立连接在提交前断言读不到。 若某栈的仓储其实每句自取新连接,第 1 步"同连接读得到"就会失败 ⇒ 报红。 这一档不可省:SPI 声明在不在、模板注册没注册、代码读起来像不像有事务,全都看不出这个破口—— 只有跑这一档才现形。
阳性对照要求(不许省):每档都要做一次"故意改坏"的对照并留红样原文—— 把归属退回进程级全局槽(归属档必须红)、把仓储改回每句自取连接(空转档必须红)、 去掉回滚臂(原子档必须红)。判据在坏形状下不红=测的是空气。
12.5 内置版(框架自带 wf 模块)口径参照
内置版 Python 的事务开在 controller 层(transactional_session(flow_engine.db) 包 9 个写动作), 引擎 core 只 add/flush、从不 commit;拦截器与流程写同一 session;站内信监听器另开 session 独立提交。 ⇒ 它天然满足本章的覆盖面/归属面,且示范了副作用面的两种处理。集成层没有引擎事务钩子时,可照这个形态在宿主侧包。
12.6 门票与待办
- 发版门票(建议即刻生效):各栈的 demo 或 T1 必须注册事务模板并跑 §12.4 三档; 未注册即裸跑是允许运行态,但不得拿"全绿"的未注册态当契约读数——那会让空转与零接线继续隐身。
- 八栈现状债(含逐条证据行号与修复形状)见
issues/145;本章不排期、不替各栈定实现方案。