Skip to content

规范 12 · 事务契约 ​

立案:issues/144(MoonBit 那栈把事务句柄挂进程级全局槽,并发请求互相提交、 把别人已确认的写入抹掉)与 issues/145(八栈事务现状普查)。 owner 2026-10-06 两条拍板:① 允许每门语言用自己的机制(语言特性不同),本章只钉行为; ② 拦截器保持现有效果(不隔离,抛错连坐主流程)。 本章是事务这件事的唯一权威;规范 05 §事务约定 的机制表降为"各栈机制登记处",判据一律看本章 §12.4。

12.1 四条立约原则 ​

  1. 钉行为,不钉机制。用什么机制(Spring 事务管理器 / 共享 PDO 连接 / context.WithValue / contextvars / AsyncLocalStorage / AsyncLocal<T> / 仓储实例携带句柄)由该语言决定, 只要能过 §12.4 三档判据就算合规。禁止把"必须用某某 API"写进契约——那是把语言差异当成缺陷。
  2. 事务边界必须是调用作用域(thread / task / request / 调用方持有的实例), 禁止进程级可变句柄槽。判据见 §12.4 归属档。
  3. 引擎对事务是可选参与,不是不感知(本条更正规范 05 旧原则 1"引擎核心不感知事务"): 引擎在其命令入口内包一层事务钩子(有则用、无则裸跑)与宿主自己在外面包,两种形态都合规, 因为行为判据只看"这次动作的写是否原子",不看包在哪一层。
  4. 默认降级必须显式且可观测:未注册事务模板/未包事务时,该动作逐语句自动提交—— 这是允许的运行态,但集成层不得把它当契约默认值来验收(§12.6)。

12.2 四个面 ​

原子面(哪些动作必须原子) ​

判据是一次动作内含 ≥2 条写语句,不是动作名。按 MoonBit 门面现读(facade/+core/engine/):

必须原子内含写(现读证据)
processInstance/startAndExecute、processDefine/startAndExecutesave_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/withdrawupdate_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)。

⚠ 宿主外包事务时的两条硬义务(不是可选建议,八栈里已有两栈在这上面翻过车):

  1. 门面/引擎的出口若把异常吞成错误信封,外包方必须把"非成功出口"转成抛出。 否则动作内部失败时事务照常 COMMIT,留下半完成实例——原子面直接失效。 典型形状是"统一响应 {code,msg,data}"这类门面(本仓 MoonBit 侧已用真库把这条钉成读数: 事务内跑一次内部失败的发起,回调正常返回 ⇒ 模板走 COMMIT ⇒ 实例行留存)。 引擎内包那一派没这个问题:它在 raise 处回滚,错误信封只在事务之外生成。
  2. 回调/模板的错误集不要收成闭集。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 各栈机制映射(登记用,允许不同、不要求同形) ​

栈载体边界开在未注册/未包时的降级形态
JavaSpring 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 起)无模板则逐语句自动提交
Gocontext.WithValue(txKey, *sql.Tx)引擎 InTx(五入口+门面 Flow 整次 dispatch,R9 145-3 起)+仓储 WithTx join 并入(调用方自包仍可)无 ctx 事务时每条语句走池连接自动提交
Pythoncontextvars.ContextVar引擎 _in_tx(五入口)+门面 flow() 整次 dispatch 经 repo.with_tx(R9 145-3 起;仓储无 with_tx 裸跑降级)同上
NodeAsyncLocalStorage<SqlConnection>引擎 inTx(五入口+门面 flow() 整次 dispatch,R9 145-2 起)同上
RustSPI 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 补) ​

事务这件事对事件监听器只有两条约束,写在这里免得各栈各自解释:

  1. 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 搬家。下限不变:回滚不得已让监听器看到成功。
  2. 监听器/回调的错误集不因派发收紧。§12.1 条 2 要求事务模板回调保持开放错误集; 事件总线对监听器抛错的处置是逐个隔离(单监听器失败不回滚主流程、不中断后续监听器,见规范 11 §11.5), 所以"监听器只能抛某几种错"不是规范 12 的要求——各栈按自己语言能表达的最自然形状来(MoonBit 把 监听器改成 async 闭包以便 await 写库,闭集 raise 是它的语言表达方式,不构成对其他七栈的签名要求)。

12.4 三档行为判据(各栈用自己的机制去过) ​

三档都必须在真库上跑(内存仓造不出连接归属),并进各栈 T1;每档都要能红,红样原文进报告。

  1. 原子档:一次发起(或办理)中途抛错 ⇒ 断言该动作的全部写入都不存在 (实例行、任务行、参与者行、cc 行、业务表行逐个反查)。未包事务时这一档必红——它是"启用了事务就要原子"的判据。
  2. 归属档:两个并发调用作用域各走一次事务,A 写→让出→抛错回滚,B 在让出窗口写完并成功返回 ⇒ 断言 A 的行不在 且 B 的行在;并加一条同作用域嵌套档,断言内层不重复 BEGIN、随外层一起回滚。 ⚠ 这一档在进程级槽形状下必红(144 的 X2 实测即 B 的行=0)。
  3. 空转档(专抓"名义有事务、实际盖不住写"):事务内写入一行,在提交之前用同事务的连接断言读得到、 随后回滚并断言不存在;再用另一条独立连接在提交前断言读不到。 若某栈的仓储其实每句自取新连接,第 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;本章不排期、不替各栈定实现方案。

jeeflow · 轻量级多语言工作流引擎