代码结构:事务显式传到用例
状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。
总线大部分就是"接口后面的 infra"(queue-behind-ports),没太多可说的;真正要定的是三件事:事务怎么到达用例、订阅方如何零胶水注册、副作用端口搬到哪里。
flowchart TB
subgraph infra["internal/infra"]
pg["pgstore<br/>InTx(ctx, Beginner, fn) · Nested · DBTX<br/>Listener · Notify"]
ev["events<br/>Recorder · declarations · relay loop<br/>retention · reads · webhook signing"]
jb["jobs<br/>Jobs · Inspector · Runtime · Kind · Periodic"]
rv["jobs/river<br/>only importer of riverqueue"]
se["sideeffect/mail · sideeffect/supplier"]
dt["detach<br/>detach.Go"]
end
subgraph dom["internal/<domain>"]
uc["usecase<br/>InTx → repo.With(tx) · Record · Enqueue"]
repo["repo<br/>With(tx) · Nested for its own tx"]
sub["subscriber<br/>subscriptions + job handlers"]
fac["facade<br/>EventTypes · Subscriptions · JobKinds (data)"]
end
root["cmd/server/wire/periodic.go: one line per source, collects every declaration, hands EmbedAdmits to the webhook fan-out"]
uc --> pg
uc --> ev
uc --> jb
repo --> pg
sub --> se
sub --> repo
sub --> uc
ev --> pg
jb --> rv
ev --> jb
fac --> sub
root -.-> fac
事务怎么到达用例
原来事务是在 repo 内部开的,大多数写入是单条语句 autocommit,用例手里没有事务,所以显式的 Record 没法和业务写入进同一个事务。触发器捕获的行事件(two-sources-of-events)不受影响,语义事件受影响。
事务是显式参数,不进 ctx。 ctx 里已经有 14 处 WithValue;再放事务就是给它加一种隐式依赖——同一个 repo 调用进不进事务,取决于调用方 ctx 里恰好装了什么——这正是 ctx 变神类的路。
sequenceDiagram participant UC as usecase participant P as pgstore participant R as repo participant E as Recorder participant J as Jobs UC->>P: InTx(ctx, pool, fn) P->>UC: fn(tx), the transaction handed over as a parameter UC->>R: requests.With(tx).Create(ctx, …) UC->>E: events.With(tx).Record(ctx, ownerID, type, subject, data) UC->>J: jobs.With(tx).Enqueue(ctx, kind, args, opts) UC-->>P: fn returns nil P->>P: COMMIT (business rows + event + job land together) Note over UC,J: Who is in the transaction is visible at the call site, a call without With behaves as before
pgstore.InTx(ctx, db, fn):在db上开事务,db是一个Beginner:连接池,或一个已打开的事务(那就是一个 savepoint)。fn返回 nil 就提交,否则回滚;fn里 panic 会回滚后再抛出。pgstore.Nested(q, pool):被With(q)绑定的 repo 需要自己开事务时用它:q是事务就在里面开 savepoint,否则在连接池上开一个新事务。With(tx):每个 repo、Recorder、Jobs都有,返回绑定到该事务的副本。原有方法签名不动;谁在事务里由编译器检查。- ctx 只装取消与截止时间:本计划不增加任何 ctx key。
Record要的 owner 等信息显式传参,不从 ctx 读。 - 门禁
check-tx-only-via-pgstore.sh:.Begin(、BeginTx(、BeginFunc(只能出现在internal/infra/pgstore。现有的 11 处Begin都已改完(计划时估的是约 20 处)(no-bypass-by-structure)。 - UT:fn 返回错误或 panic 时业务、事件、任务一起回滚;
With(tx)的副本不影响原对象;不经With的调用行为不变(events-test-plan)。
订阅方零胶水
各域把事件类型(event-model)、订阅和任务种类都声明成数据(和 dispatcher op 同样形状),经自己的 facade 暴露。组合根(cmd/server/wire/periodic.go)每个来源一行把它们收集起来,不关心任务具体做什么。唯一的专门接线是把 embed 范围函数(EmbedAdmits,来自 access 域)交给 owner 域的 webhook 扇出。
副作用端口搬了家
OutboundSender 原来在 cmd/server/port,后台 supplier 调用在 plugin/adapters。现在邮件端口是 internal/infra/sideeffect/mail;后台 supplier 调用是 internal/infra/sideeffect/supplier 里持久的 supplier.invoke 任务。错误表、lint 豁免、测试钩子都跟着搬了。只有 subscriber 包、infra 和 cmd/server 可以 import 它们。