Compare commits

..

2 Commits

27 changed files with 1393 additions and 44 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## v0.25.0-preview(2026-09-26)
- database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
- 回调失败后只有显式回滚返回成功才标记 rolledBack;提交报错一律保守返回 unknown,不根据断网/超时或 ErrTxDone 推断回滚。不自动重试事务,不处理文件清理等业务决策。
- Err/RollbackErr 分开保留,errors.As/Is 可遍历两条原因链;业务错误目录和公开响应行为保持。nil context 接受,nil 回调在开启前报错,panic/Goexit 保持传播并尝试回滚。
- 新增阶段/取消竞争/确认丢失/并发/公开响应回归、真实 MySQL 驱动边界故障验收、性能比较和 examples/transactions;同步 README 与 database 指南。
- 错误现在有类型包装;直接错误相等、直接类型断言和错误文本依赖需改为 errors.Is/As。标准库异步回滚不公开的底层原因无法还原,结果保持 unknown;不改变 GORM 自己的 Transaction。
## v0.24.0-preview(2026-09-26)
- 新增可选 cors 包和 server.cors 配置;生成项目直接加载,手写项目使用 omni.WithCORS,独立 HTTP 服务可使用 Policy.Wrap。默认关闭,无新增第三方依赖。

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.24.0-preview)
# 通用业务开发指南(v0.25.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。

View File

@@ -1,6 +1,6 @@
# 生成项目与文件维护边界
v0.24.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
v0.25.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发
@@ -215,7 +215,7 @@ get /download (DownloadReq)
## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.24.0-preview:
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.25.0-preview:
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。

View File

@@ -2,7 +2,9 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.24.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**从这份 README 开始接入。** 当前版本 **`v0.25.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
**v0.24 改动**:新增可选 CORS,生成项目通过 `server.cors` 自动接入,支持预检、错误响应和明确的来源白名单;默认关闭。[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)。
@@ -45,7 +47,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持独立时区、轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
| 文件配置与环境引用 | Default()→JSON/YAML/XML 字段引用→类型绑定→校验;无隐式覆盖,支持 `-check-config` | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;事务与检查由项目显式调用 | [通用数据库](database/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;`InTx` 的 `TxError` 提供阶段、已知结果与原因链 | [通用数据库](database/README.md)、[事务失败示例](examples/transactions/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
@@ -151,7 +153,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
```sh
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
@@ -308,7 +310,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.24.0-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -634,7 +636,7 @@ go test ./...
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
CLI 与业务 module 固定使用 `v0.24.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
CLI 与业务 module 固定使用 `v0.25.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入

View File

@@ -1,4 +1,26 @@
# v0.24.0-preview
# v0.25.0-preview
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
- `database.InTx` 签名不变,普通错误返回 `*database.TxError`;Stage 为 begin/work/commit,Outcome 为 notStarted/rolledBack/unknown。成功提交仍返回 nil。
- Commit 报错一律 unknown;回调失败且显式 Rollback 返回 nil 才标记 rolledBack。BeginTx 失败不执行回调。ErrTxDone 不作为回滚成功证据,框架不重试事务。
- `Err` 与 `RollbackErr` 保留两条原因链,可通过 errors.Is/As 识别;APIError、目录参数提示和公开响应继续工作。内部原因不自动进入 HTTP 提示。
- 提供无需外部数据库的 [故障演示](examples/transactions/README.md)。真实 MySQL 与性能结果见 [验收记录](reports/transactionAcceptance.md)。不引入新依赖,不包含文件删除、补偿任务或业务规则。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
go-omni api generate --check
go test ./...
```
**兼容边界:** 既有 errors.Is/As 保持;直接 `err == sentinel`、直接类型断言或字符串匹配需调整。nil context 使用 Background,nil 回调在开启前返回 typed error。panic/Goexit 继续传播并尝试回滚,不转换为 error;标准库自动回滚不公开的原因无法还原,不能推断已回滚。结果仅覆盖按约定使用的当前事务,不覆盖非事务表、隐式提交 SQL、其他连接或外部副作用。GORM 原生 Transaction 没有被替换。
[database 使用规则](database/README.md#事务阶段与结果) 已同步。本轮 runtime 能力需要 v0.25,模板最低版本仍为 v0.22;没有新增必填配置,当前仍为 preview。
---
# v0.24.0-preview(历史版本)
业务项目通过配置开启跨域,不需要手写 OPTIONS Handler 或逐条修改接口。
@@ -19,6 +41,8 @@ go run . -config etc/config.yaml -check-config
[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)、[验收与性能](reports/corsAcceptance.md) 已同步。49 个测试包全量 race、Go 1.22、vet/build、实际生成项目、三种 transport 的 TLS 场景和 20 项真实浏览器检查通过;30 秒 fuzz 约 288 万次。当前仍为 preview。
空缓存安装远程版本后,9 组生成消费者和 70 项发布检查通过,耗时 66.78 秒,无本地 replace;包括下载模块的 cors/app race、三种 transport 的 TLS 检查和只改配置的生成项目。标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`,见 [远程记录](reports/corsRemote.json)。
---
# v0.23.0-preview(历史版本)

View File

@@ -14,7 +14,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
```sh
# 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -49,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
CLI 与框架依赖固定使用 v0.24.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
CLI 与框架依赖固定使用 v0.25.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
```sh
# 在 go-omni 源码仓库中执行
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
```
当前模板与依赖以 v0.24.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
当前模板与依赖以 v0.25.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令

View File

@@ -22,7 +22,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Runtime-only releases need not raise the managed template compatibility floor.
const minimumVersion = "v0.22.0-preview"
const currentVersion = "v0.24.0-preview"
const currentVersion = "v0.25.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.24.0-preview"
const defaultFrameworkVersion = "v0.25.0-preview"
// This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published.

View File

@@ -9,7 +9,7 @@
- `Config`:驱动、DSN 与连接池参数;嵌入项目配置,通过文件显式引用变量。
- `Open` / `OpenConnector`:通过 DSN 或标准 Connector 创建连接池、设置连接上限并在返回前执行 `PingContext`。
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时尝试回滚。返回 `TxError` 区分阶段与结果,提交错误不代表已回滚。
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
框架不导入 MySQL、PostgreSQL 等具体驱动。应用负责注册驱动:
@@ -63,6 +63,55 @@ python3 scripts/verifyDatabase.py --version v0.2.0
配置结构作为项目 Config 的字段,Default() 初始化连接池,文件按需覆盖。DSN 可使用 `dsn: "${DB_DSN}"`;database.Config 的 JSON 序列化不输出 DSN 原值。不要打印原始配置对象。当前 API 脚手架使用结构化 MySQL 适配器,由 svc 统一初始化与关闭。
## 事务阶段与结果
v0.25 起,`db.InTx(ctx, options, work)` 签名不变,所有普通错误返回都是 `*database.TxError`。返回 nil 表示 Commit 返回成功;框架不重试回调、提交或整个事务。业务通过标准库 errors.As/Is 判断,不解析错误文本:
```go
err := db.InTx(ctx, nil, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "INSERT INTO objects (id, storage_key) VALUES (?, ?)", id, key)
return err
})
if err != nil {
var txErr *database.TxError
if errors.As(err, &txErr) {
switch txErr.Outcome {
case database.TxOutcomeNotStarted, database.TxOutcomeRolledBack:
// 当前受管理事务未执行回调或已确认回滚。
// 业务结合文件归属、并发操作和其他副作用,决定是否清理。
default:
// 包括 TxOutcomeUnknown:保留文件,交给业务后续核查。
// 不因为收到提交错误就删除文件或直接重试整个操作。
}
// txErr.Stage 是 begin / work / commit。
// txErr.Err 是主要原因;txErr.RollbackErr 是回滚调用返回的原因。
}
if errors.Is(err, context.DeadlineExceeded) {
// 说明错误链包含超时,不意味着数据库没有提交。
}
return err
}
```
`errors` 为标准库包,业务自己的数据库驱动和 SQL 参数规则不变。完整可运行示例见 [examples/transactions](../examples/transactions/README.md)。
| 情况 | Stage | Outcome | RollbackErr |
| --- | --- | --- | --- |
| 未初始化数据库、nil 回调、BeginTx 报错 | TxStageBegin | TxOutcomeNotStarted | nil;框架没有拿到事务,不执行回调 |
| 回调报错,显式 Rollback 成功 | TxStageWork | TxOutcomeRolledBack | nil |
| 回调报错,Rollback 失败或返回 ErrTxDone | TxStageWork | TxOutcomeUnknown | 原始回滚错误 |
| Commit 报错,包括取消/断网/驱动错误 | TxStageCommit | TxOutcomeUnknown | 后续清理的 Rollback 返回值,可能为 ErrTxDone 或 nil |
`Stage` 描述主要失败发生的位置;`Outcome` 只描述框架掌握的结果,二者独立。提交调用返回错误后一律保守标为 unknown,即使某个驱动的某种错误意味着没有提交,通用层也不根据错误文本猜测。标准库在 Commit 后通常已经结束本地事务对象,后续 Rollback 返回 `sql.ErrTxDone`;它不能证明数据库已回滚,框架保留该原因。
`TxError.Err` 与 `RollbackErr` 分开保存;`Unwrap() []error` 按主要原因、回滚原因顺序暴露,所以 errors.Is/As 可查两条链。需要区分来源时对 `txErr.Err` 或 `txErr.RollbackErr` 单独判断。回调返回另一个事务的 TxError 时外层不把它压平:外层结果只属于外层事务,内部事务的结果仍在 Err 中。
**结果保证的范围:** 回调使用给定 `*sql.Tx` 完成事务操作,不自行 Commit/Rollback,不执行会隐式提交的 SQL,不将事务交给后台 goroutine。非事务表、其他连接、嵌套的独立事务、文件和第三方调用的副作用不在这份保证内。框架没有删除文件、入队补偿或分布式事务规则。
**取消与异常边界:** context 取消时 database/sql 可能自行异步回滚,且不公开那次驱动 Rollback 的原始错误。框架若只观察到 ErrTxDone,就返回 unknown,不能补造成功证据或不存在的底层原因。正常返回路径上,框架实际观察到的主要/回滚原因全部保留。panic/runtime.Goexit 保持原有传播行为并尝试回滚,不转换为普通 error;这类展开没有错误返回值,因此不能把回滚原因作为返回值交给调用方。nil context 使用 Background,nil 回调在开启事务前报错。
**兼容说明:** 已有 errors.Is/As 继续生效;以前直接用 `err == sentinel`、`err.(*SomeError)` 或匹配文本的调用要改成 errors.Is/As。业务 APIError、Go 错误目录和参数提示仍可穿过包装解析,驱动错误不会自动进入公开提示。TxError.Error 包含内部原因,仅供内部诊断。GORM 自己的 Transaction 不经过本方法,也不会自动变成 TxError;可选 gormx 的连接池接入不变。
## Connector 接入(v0.9.1-preview)
项目已有 `database/sql/driver.Connector` 时使用 `OpenConnector`。这是标准驱动接口,核心包不增加具体驱动依赖。项目可以在同一进程创建多个使用不同证书、日志、拨号策略的连接池。

View File

@@ -107,27 +107,3 @@ func (db *DB) Health(ctx context.Context) error {
defer cancel()
return db.PingContext(checkCtx)
}
// InTx commits only when work returns nil. Any work error or panic rolls back.
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) {
if db == nil || db.DB == nil {
return errors.New("database is not initialized")
}
tx, err := db.BeginTx(ctx, options)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
rollbackErr := tx.Rollback()
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
}
}()
if err = work(tx); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls"
"crypto/x509"
"database/sql"
"database/sql/driver"
"errors"
"net"
"os"
@@ -166,11 +167,64 @@ func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
return rollbackErr
}); !errors.Is(err, rollbackErr) {
t.Fatal(err)
} else {
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageWork || txErr.Outcome != database.TxOutcomeRolledBack {
t.Fatal("real MySQL rollback was not classified", err)
}
}
var count int
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check").Scan(&count); err != nil || count != 1 {
t.Fatalf("transaction count=%d err=%v", count, err)
}
// Commit/Rollback run against real MySQL, then a driver-boundary wrapper
// suppresses the acknowledgement. This is deterministic fault injection,
// not a claim to reproduce every wire-level disconnect timing.
for _, loseCommit := range []bool{true, false} {
name, id := "commitAcknowledgementLost", 3
if !loseCommit {
name, id = "rollbackAcknowledgementLost", 4
}
t.Run(name, func(t *testing.T) {
connector, err := mysql.NewConnector(cfg.Clone())
if err != nil {
t.Fatal(err)
}
ackErr, workErr := errors.New("injected lost acknowledgement"), errors.New("work rejected")
fault := &ackLossConnector{Connector: connector, loseCommit: loseCommit, ackErr: ackErr}
faultPool, err := database.OpenConnector(ctx, fault, pool)
if err != nil {
t.Fatal(err)
}
defer faultPool.Close()
err = faultPool.InTx(ctx, nil, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, "INSERT INTO connector_check VALUES (?)", id); err != nil {
return err
}
if !loseCommit {
return workErr
}
return nil
})
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Outcome != database.TxOutcomeUnknown || !errors.Is(err, ackErr) {
t.Fatal("unknown acknowledgement was treated as definite outcome", err)
}
wantCount := 0
if loseCommit {
wantCount = 1
if txErr.Stage != database.TxStageCommit || txErr.Err != ackErr || txErr.RollbackErr != sql.ErrTxDone || fault.commits.Load() != 1 || fault.rollbacks.Load() != 0 {
t.Fatal("commit failure was retried or misclassified", err)
}
} else if txErr.Stage != database.TxStageWork || !errors.Is(err, workErr) || txErr.RollbackErr != ackErr || fault.commits.Load() != 0 || fault.rollbacks.Load() != 1 {
t.Fatal("rollback failure lost its primary/secondary causes", err)
}
var stored int
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check WHERE id = ?", id).Scan(&stored); err != nil || stored != wantCount {
t.Fatal("independent connection observed unexpected persistence", stored, err)
}
})
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
@@ -178,3 +232,58 @@ func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
t.Fatal("closing first pool affected second:", err)
}
}
type ackLossConnector struct {
driver.Connector
loseCommit bool
ackErr error
commits, rollbacks atomic.Int32
}
func (c *ackLossConnector) Connect(ctx context.Context) (driver.Conn, error) {
conn, err := c.Connector.Connect(ctx)
if err != nil {
return nil, err
}
return &ackLossConn{Conn: conn, owner: c}, nil
}
type ackLossConn struct {
driver.Conn
owner *ackLossConnector
}
func (c *ackLossConn) Ping(ctx context.Context) error { return c.Conn.(driver.Pinger).Ping(ctx) }
func (c *ackLossConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
tx, err := c.Conn.(driver.ConnBeginTx).BeginTx(ctx, opts)
if err != nil {
return nil, err
}
return &ackLossTx{Tx: tx, owner: c.owner}, nil
}
type ackLossTx struct {
driver.Tx
owner *ackLossConnector
}
func (tx *ackLossTx) Commit() error {
tx.owner.commits.Add(1)
if err := tx.Tx.Commit(); err != nil {
return err
}
if tx.owner.loseCommit {
return tx.owner.ackErr
}
return nil
}
func (tx *ackLossTx) Rollback() error {
tx.owner.rollbacks.Add(1)
if err := tx.Tx.Rollback(); err != nil {
return err
}
if !tx.owner.loseCommit {
return tx.owner.ackErr
}
return nil
}

126
database/transaction.go Normal file
View File

@@ -0,0 +1,126 @@
package database
import (
"context"
"database/sql"
"errors"
)
// TxStage identifies the operation which produced the primary error.
type TxStage string
const (
TxStageBegin TxStage = "begin"
TxStageWork TxStage = "work"
TxStageCommit TxStage = "commit"
)
// TxOutcome describes only what InTx can confirm about its managed transaction.
// An absent/unrecognized value must be treated as unknown, never as rolled back.
type TxOutcome string
const (
TxOutcomeUnknown TxOutcome = "unknown"
TxOutcomeNotStarted TxOutcome = "notStarted"
TxOutcomeRolledBack TxOutcome = "rolledBack"
)
// TxError retains the primary failure and any rollback failure independently.
// Use errors.As to inspect it. errors.Is/As traverse Err first, then RollbackErr;
// inspect Err alone when the distinction between the two causes matters.
// Error text is for internal diagnostics and may include driver/business details.
type TxError struct {
Stage TxStage
Outcome TxOutcome
Err error
RollbackErr error
}
func (e *TxError) Error() string {
if e == nil {
return "transaction error"
}
message := string(e.Stage) + " transaction (" + string(e.Outcome) + ")"
if e.Err != nil {
message += ": " + e.Err.Error()
}
if e.RollbackErr != nil {
message += "; rollback transaction: " + e.RollbackErr.Error()
}
return message
}
// Unwrap preserves both causes, including sql.ErrTxDone. That sentinel means
// the local transaction is finished, not that the database confirmed rollback.
func (e *TxError) Unwrap() []error {
if e == nil {
return nil
}
if e.Err == nil {
if e.RollbackErr == nil {
return nil
}
return []error{e.RollbackErr}
}
if e.RollbackErr == nil {
return []error{e.Err}
}
return []error{e.Err, e.RollbackErr}
}
// InTx begins a transaction, calls work once, and commits only if work returns
// nil. Every returned error is a *TxError; a nil result confirms Commit succeeded.
// Commit failures always have an unknown outcome, even for cancellation or a
// driver error normally associated with a lost connection. InTx does not retry.
//
// work must use only the supplied transaction for transactional operations and
// must not Commit/Rollback it, issue transaction-control/implicit-commit SQL, or
// let it escape to background goroutines. Outcome describes this transaction,
// not files, other connections, nontransactional tables or external side effects.
//
// Panic and runtime.Goexit still attempt rollback and propagate unchanged; they
// do not return a TxError. Rollback errors during those unwinds cannot be returned.
// A nil context is treated as context.Background().
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) error {
if db == nil || db.DB == nil {
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: errors.New("database is not initialized")}
}
if work == nil {
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: errors.New("transaction callback is nil")}
}
if ctx == nil {
ctx = context.Background()
}
tx, err := db.BeginTx(ctx, options)
if err != nil {
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: err}
}
finished := false
defer func() {
if !finished {
// Only the panic/Goexit path reaches here without explicit cleanup.
_ = tx.Rollback()
}
}()
if err = work(tx); err != nil {
failure := txFailure(tx, TxStageWork, err)
finished = true
return failure
}
if err = tx.Commit(); err != nil {
failure := txFailure(tx, TxStageCommit, err)
finished = true
return failure
}
finished = true
return nil
}
func txFailure(tx *sql.Tx, stage TxStage, cause error) *TxError {
rollbackErr := tx.Rollback()
outcome := TxOutcomeUnknown
if stage == TxStageWork && rollbackErr == nil {
outcome = TxOutcomeRolledBack
}
return &TxError{Stage: stage, Outcome: outcome, Err: cause, RollbackErr: rollbackErr}
}

View File

@@ -0,0 +1,56 @@
package database_test
import (
"database/sql"
"encoding/json"
"errors"
"net/http/httptest"
"strings"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestInTxPublicErrorCompatibility(t *testing.T) {
private := errors.New("private database details")
for _, scenario := range []string{"business", "businessAndRollback", "commit"} {
t.Run(scenario, func(t *testing.T) {
f := &txFixture{}
if scenario == "businessAndRollback" {
f.rollbackErr = private
} else if scenario == "commit" {
f.commitErr = private
}
pool := txPool(t, f)
app := omni.New(omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}),
omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
"INVALID": {Code: 400002, Message: "{field} is invalid", Params: []apierrors.Parameter{{Name: "field", Type: "string"}}},
}}))
defer app.Close()
app.POST("/record", func(c *omni.Context) error {
return pool.InTx(c.Context(), nil, func(*sql.Tx) error {
if scenario == "commit" {
return nil
}
return apierrors.ByCode("INVALID").WithParams(map[string]any{"field": "name"}).WithCause(private)
})
})
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("POST", "/record", nil))
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
wantStatus, wantCode, wantMessage := 200, float64(400002), "name is invalid"
if scenario == "commit" {
wantStatus, wantCode, wantMessage = 500, 9999, "internal server error"
}
data, hasData := body["data"]
if w.Code != wantStatus || body["code"] != wantCode || body["msg"] != wantMessage || !hasData || data != nil || strings.Contains(w.Body.String(), "private") {
t.Fatal("transaction wrapper changed public error response:", w.Code, w.Body.String())
}
})
}
}

View File

@@ -0,0 +1,441 @@
package database_test
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/database"
)
// The driver can persist a write and lose the commit acknowledgement, or fail
// before persisting it. Both must be indistinguishable to the generic wrapper.
type txFixture struct {
beginErr, commitErr, rollbackErr error
persistBeforeError bool
onBegin func(context.Context, driver.TxOptions)
onCommit func()
onRollback func()
begins, commits, rollbacks, saved atomic.Int64
}
func (f *txFixture) Connect(context.Context) (driver.Conn, error) { return &txConn{owner: f}, nil }
func (f *txFixture) Driver() driver.Driver { return f }
func (f *txFixture) Open(string) (driver.Conn, error) { return f.Connect(context.Background()) }
type txConn struct {
owner *txFixture
pending int64
}
func (c *txConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not used") }
func (c *txConn) Close() error { return nil }
func (c *txConn) Begin() (driver.Tx, error) {
return c.BeginTx(context.Background(), driver.TxOptions{})
}
func (c *txConn) BeginTx(ctx context.Context, options driver.TxOptions) (driver.Tx, error) {
c.owner.begins.Add(1)
if c.owner.onBegin != nil {
c.owner.onBegin(ctx, options)
}
if c.owner.beginErr != nil {
return nil, c.owner.beginErr
}
c.pending = 0
return &txDriver{conn: c}, nil
}
func (c *txConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
c.pending++
return driver.RowsAffected(1), nil
}
type txDriver struct{ conn *txConn }
func (tx *txDriver) Commit() error {
f := tx.conn.owner
f.commits.Add(1)
if f.commitErr == nil || f.persistBeforeError {
f.saved.Add(tx.conn.pending)
}
if f.onCommit != nil {
f.onCommit()
}
return f.commitErr
}
func (tx *txDriver) Rollback() error {
f := tx.conn.owner
f.rollbacks.Add(1)
if f.onRollback != nil {
f.onRollback()
}
return f.rollbackErr
}
func txPool(t testing.TB, f *txFixture) *database.DB {
t.Helper()
pool := sql.OpenDB(f)
pool.SetMaxOpenConns(8)
t.Cleanup(func() { _ = pool.Close() })
return &database.DB{DB: pool}
}
type txCause struct{ name string }
func (e *txCause) Error() string { return e.name }
func checkTxError(t *testing.T, err error, stage database.TxStage, outcome database.TxOutcome, cause, rollback error) *database.TxError {
t.Helper()
var txErr *database.TxError
if !errors.As(fmt.Errorf("repository: %w", err), &txErr) {
t.Fatalf("missing typed transaction error: %v", err)
}
if txErr.Stage != stage || txErr.Outcome != outcome || txErr.Err != cause || txErr.RollbackErr != rollback {
t.Fatalf("unexpected transaction error: %+v", txErr)
}
if cause != nil && !errors.Is(err, cause) || rollback != nil && !errors.Is(err, rollback) {
t.Fatalf("cause chain lost: %v", err)
}
return txErr
}
func TestInTxStagesAndCauses(t *testing.T) {
primary, rollback := &txCause{"primary"}, &txCause{"rollback"}
for _, tc := range []struct {
name string
beginErr, workErr, commitErr, rb error
stage database.TxStage
outcome database.TxOutcome
wantWork, wantCommit, wantRB int64
}{
{"begin", primary, nil, nil, nil, database.TxStageBegin, database.TxOutcomeNotStarted, 0, 0, 0},
{"work", nil, primary, nil, nil, database.TxStageWork, database.TxOutcomeRolledBack, 1, 0, 1},
{"workAndRollback", nil, primary, nil, rollback, database.TxStageWork, database.TxOutcomeUnknown, 1, 0, 1},
{"commit", nil, nil, primary, nil, database.TxStageCommit, database.TxOutcomeUnknown, 1, 1, 0},
} {
t.Run(tc.name, func(t *testing.T) {
f := &txFixture{beginErr: tc.beginErr, commitErr: tc.commitErr, rollbackErr: tc.rb}
pool := txPool(t, f)
var calls int64
err := pool.InTx(context.Background(), nil, func(tx *sql.Tx) error {
calls++
_, err := tx.ExecContext(context.Background(), "write")
if err != nil {
t.Fatal(err)
}
return tc.workErr
})
rb := tc.rb
if tc.stage == database.TxStageCommit {
rb = sql.ErrTxDone // No driver rollback happened after Commit.
}
checkTxError(t, err, tc.stage, tc.outcome, primary, rb)
var typedCause *txCause
if !errors.As(err, &typedCause) || typedCause != primary {
t.Fatal("primary typed cause not first in chain")
}
if calls != tc.wantWork || f.commits.Load() != tc.wantCommit || f.rollbacks.Load() != tc.wantRB || f.saved.Load() != 0 {
t.Fatal("unexpected callback/commit/rollback calls or persisted write")
}
})
}
t.Run("rollbackTypedCause", func(t *testing.T) {
pool := txPool(t, &txFixture{rollbackErr: rollback})
cause := errors.New("work failed")
err := pool.InTx(nil, nil, func(*sql.Tx) error { return cause })
var found *txCause
if !errors.As(err, &found) || found != rollback {
t.Fatal("typed rollback cause lost")
}
})
}
func TestInTxCommitOutcomeUnknown(t *testing.T) {
for _, cause := range []error{&txCause{"lost acknowledgement"}, context.DeadlineExceeded, driver.ErrBadConn} {
for _, persisted := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/persisted=%t", cause, persisted), func(t *testing.T) {
f := &txFixture{commitErr: cause, persistBeforeError: persisted}
pool := txPool(t, f)
calls := 0
err := pool.InTx(nil, nil, func(tx *sql.Tx) error {
calls++
_, err := tx.ExecContext(context.Background(), "register object")
return err
})
checkTxError(t, err, database.TxStageCommit, database.TxOutcomeUnknown, cause, sql.ErrTxDone)
wantSaved := int64(0)
if persisted {
wantSaved = 1
}
if f.saved.Load() != wantSaved || calls != 1 || f.commits.Load() != 1 || f.rollbacks.Load() != 0 {
t.Fatal("commit was retried, rolled back, or incorrectly modeled")
}
})
}
}
}
func TestInTxCancellationAndOwnership(t *testing.T) {
t.Run("beforeBegin", func(t *testing.T) {
f := &txFixture{}
pool := txPool(t, f)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := pool.InTx(ctx, nil, func(*sql.Tx) error { t.Fatal("work called"); return nil })
checkTxError(t, err, database.TxStageBegin, database.TxOutcomeNotStarted, context.Canceled, nil)
if f.begins.Load() != 0 {
t.Fatal("driver began a canceled transaction")
}
})
for _, returnedWorkError := range []bool{false, true} {
t.Run(fmt.Sprintf("automaticRollback/workError=%t", returnedWorkError), func(t *testing.T) {
rolledBack := make(chan struct{})
rollbackCause := errors.New("rollback connection lost")
f := &txFixture{rollbackErr: rollbackCause, onRollback: func() { close(rolledBack) }}
pool := txPool(t, f)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := pool.InTx(ctx, nil, func(*sql.Tx) error {
cancel()
select {
case <-rolledBack:
case <-time.After(3 * time.Second):
t.Fatal("automatic rollback did not run")
}
if returnedWorkError {
return context.Canceled
}
return nil
})
stage, primary := database.TxStageCommit, error(sql.ErrTxDone)
if returnedWorkError {
stage, primary = database.TxStageWork, context.Canceled
}
checkTxError(t, err, stage, database.TxOutcomeUnknown, primary, sql.ErrTxDone)
// database/sql owns this asynchronous rollback and does not expose
// its driver error. Do not pretend ErrTxDone means successful rollback.
if errors.Is(err, rollbackCause) || f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
t.Fatal("unexpected automatic rollback accounting")
}
})
}
t.Run("cancelDuringCommit", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f := &txFixture{commitErr: context.Canceled, persistBeforeError: true, onCommit: cancel}
pool := txPool(t, f)
err := pool.InTx(ctx, nil, func(tx *sql.Tx) error { _, err := tx.ExecContext(ctx, "write"); return err })
checkTxError(t, err, database.TxStageCommit, database.TxOutcomeUnknown, context.Canceled, sql.ErrTxDone)
if f.saved.Load() != 1 || f.rollbacks.Load() != 0 {
t.Fatal("cancellation hid a committed write")
}
})
for _, action := range []string{"commit", "rollback"} {
t.Run("manual/"+action, func(t *testing.T) {
f := &txFixture{}
pool := txPool(t, f)
cause := errors.New("callback failed")
err := pool.InTx(nil, nil, func(tx *sql.Tx) error {
var err error
if action == "commit" {
err = tx.Commit()
} else {
err = tx.Rollback()
}
if err != nil {
t.Fatal(err)
}
return cause
})
checkTxError(t, err, database.TxStageWork, database.TxOutcomeUnknown, cause, sql.ErrTxDone)
})
}
}
func TestInTxSuccessPanicAndConcurrency(t *testing.T) {
t.Run("success", func(t *testing.T) {
type key struct{}
ctx := context.WithValue(context.Background(), key{}, "value")
f := &txFixture{onBegin: func(received context.Context, opts driver.TxOptions) {
if received.Value(key{}) != "value" || !opts.ReadOnly || opts.Isolation != driver.IsolationLevel(sql.LevelSerializable) {
t.Error("context or options changed")
}
}}
pool := txPool(t, f)
if err := pool.InTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable, ReadOnly: true}, func(*sql.Tx) error { return nil }); err != nil {
t.Fatal(err)
}
if f.commits.Load() != 1 || f.rollbacks.Load() != 0 {
t.Fatal("unexpected successful transaction calls")
}
})
for _, rbErr := range []error{nil, errors.New("rollback failed during unwind")} {
t.Run(fmt.Sprintf("panic/%v", rbErr), func(t *testing.T) {
f := &txFixture{rollbackErr: rbErr}
pool := txPool(t, f)
value := &struct{ text string }{"original panic"}
func() {
defer func() {
if recover() != value {
t.Error("panic identity lost")
}
}()
_ = pool.InTx(nil, nil, func(*sql.Tx) error { panic(value) })
}()
if f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
t.Fatal("panic did not roll back exactly once")
}
})
}
t.Run("goexit", func(t *testing.T) {
f := &txFixture{}
pool := txPool(t, f)
done := make(chan struct{})
go func() {
defer close(done)
_ = pool.InTx(nil, nil, func(*sql.Tx) error { runtime.Goexit(); return nil })
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Goexit did not finish")
}
if f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
t.Fatal("Goexit did not roll back exactly once")
}
})
t.Run("concurrent", func(t *testing.T) {
f := &txFixture{}
pool := txPool(t, f)
var group sync.WaitGroup
for i := 0; i < 64; i++ {
group.Add(1)
go func(i int) {
defer group.Done()
cause := &txCause{fmt.Sprint(i)}
err := pool.InTx(nil, nil, func(*sql.Tx) error { return cause })
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Err != cause || txErr.Outcome != database.TxOutcomeRolledBack || !errors.Is(err, cause) {
t.Error("cross-request error state")
}
}(i)
}
group.Wait()
if f.rollbacks.Load() != 64 || f.commits.Load() != 0 {
t.Fatal("incorrect concurrent calls")
}
})
}
func TestInTxPreconditionsAndNestedErrors(t *testing.T) {
for _, pool := range []*database.DB{nil, {}} {
err := pool.InTx(nil, nil, func(*sql.Tx) error { t.Fatal("unexpected callback"); return nil })
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageBegin || txErr.Outcome != database.TxOutcomeNotStarted || txErr.Err == nil {
t.Fatal("uninitialized database was not classified")
}
}
f := &txFixture{}
pool := txPool(t, f)
err := pool.InTx(nil, nil, nil)
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageBegin || f.begins.Load() != 0 {
t.Fatal("nil callback began a transaction")
}
inner := &database.TxError{Stage: database.TxStageCommit, Outcome: database.TxOutcomeUnknown, Err: errors.New("other transaction")}
err = pool.InTx(nil, nil, func(*sql.Tx) error { return inner })
outer := checkTxError(t, err, database.TxStageWork, database.TxOutcomeRolledBack, inner, nil)
if outer == inner || !errors.As(outer.Err, &txErr) || txErr != inner {
t.Fatal("nested error was flattened")
}
// Empty or typed-nil error values must not panic or expose nil unwrap entries.
for _, value := range []*database.TxError{nil, {}, {RollbackErr: inner}} {
_ = value.Error()
for _, child := range value.Unwrap() {
if child == nil {
t.Fatal("nil unwrap child")
}
}
}
}
func TestInTxCancellationRace(t *testing.T) {
const count = 128
rollbackCause := errors.New("rollback acknowledgement unavailable")
var rollbacks sync.WaitGroup
rollbacks.Add(count)
f := &txFixture{rollbackErr: rollbackCause, onRollback: rollbacks.Done}
pool := txPool(t, f)
for i := 0; i < count; i++ {
ctx, cancel := context.WithCancel(context.Background())
workFailure := i%2 == 0
err := pool.InTx(ctx, nil, func(*sql.Tx) error {
cancel()
if i%3 == 0 {
runtime.Gosched() // Let the automatic rollback race explicit cleanup.
}
if workFailure {
return context.Canceled
}
return nil
})
cancel()
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Outcome != database.TxOutcomeUnknown || f.commits.Load() != 0 {
t.Fatal("cancellation was treated as confirmed rollback", err)
}
wantStage := database.TxStageCommit
if workFailure {
wantStage = database.TxStageWork
}
if txErr.Stage != wantStage || txErr.RollbackErr != rollbackCause && txErr.RollbackErr != sql.ErrTxDone {
t.Fatal("unexpected cancellation diagnostics", err)
}
if txErr.RollbackErr == rollbackCause && !errors.Is(err, rollbackCause) {
t.Fatal("observed rollback failure lost")
}
}
done := make(chan struct{})
go func() { rollbacks.Wait(); close(done) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("canceled transactions did not finish rollback")
}
}
func BenchmarkInTx(b *testing.B) {
cause := errors.New("example failure")
for _, name := range []string{"success", "beginFailure", "workFailure", "rollbackFailure", "commitUnknown"} {
b.Run(name, func(b *testing.B) {
f := &txFixture{}
var workErr error
switch name {
case "beginFailure":
f.beginErr = cause
case "workFailure":
workErr = cause
case "rollbackFailure":
workErr, f.rollbackErr = cause, cause
case "commitUnknown":
f.commitErr = cause
}
pool := txPool(b, f)
work := func(*sql.Tx) error { return workErr }
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := pool.InTx(context.Background(), nil, work)
if (err == nil) != (name == "success") {
b.Fatal(err)
}
}
})
}
}

View File

@@ -2,7 +2,7 @@ module example.com/managed
go 1.22.0
require git.yuntuo.hk/k2safe/go-omni v0.24.0-preview
require git.yuntuo.hk/k2safe/go-omni v0.25.0-preview
require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect

View File

@@ -0,0 +1,24 @@
# 事务失败与结果不确定
在框架根目录运行:
```sh
go run ./examples/transactions
```
示例使用确定性的演示 SQL 驱动,没有真实数据库、上传文件或外部服务。业务调用仍走真实 `database/sql` 和 `database.InTx`;`demoDriver.go` 仅用来注入数据库操作成功后确认丢失的情况,项目继续使用自己的正常驱动。
```text
success: committed, stored=true
beginFailure: stage=begin outcome=notStarted stored=false cleanup=true
workFailure: stage=work outcome=rolledBack stored=false cleanup=true
rollbackFailure: stage=work outcome=unknown stored=false cleanup=false
commitRejected: stage=commit outcome=unknown stored=false cleanup=false
commitAcknowledgementLost: stage=commit outcome=unknown stored=true cleanup=false
```
最后两个场景收到一样的提交错误,但一个没有保存、另一个已经保存。调用方不能从提交错误推断数据库没记录,因此示例只在已知未执行回调或已回滚时考虑清理,不确定时保留。`cleanup` 只是演示业务决策,没有执行任何删除;实际业务仍需考虑文件是否只属于本次操作、其他事务或外部副作用。
`errors.As` 取得 TxError 的阶段/结果,`errors.Is` 仍找到原始演示错误。回滚失败有独立原因;TxError 不规定业务码、HTTP 状态或清理策略。
完整接口、自动回滚的观察边界和兼容注意见 [database](../../database/README.md#事务阶段与结果)。真实 MySQL 和故障回归见 [验收记录](../../reports/transactionAcceptance.md)。

View File

@@ -0,0 +1,48 @@
package main
import (
"context"
"database/sql/driver"
"errors"
"sync/atomic"
)
// This driver models an acknowledgement lost AFTER persistence. Applications
// use their normal SQL driver; this is solely a runnable fault demonstration.
type demoConnector struct {
beginErr, commitErr, rollbackErr error
persistBeforeError bool
stored atomic.Bool
}
func (d *demoConnector) Connect(context.Context) (driver.Conn, error) {
return &demoConn{owner: d}, nil
}
func (d *demoConnector) Driver() driver.Driver { return d }
func (d *demoConnector) Open(string) (driver.Conn, error) { return d.Connect(context.Background()) }
type demoConn struct{ owner *demoConnector }
func (c *demoConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("not implemented by demo")
}
func (c *demoConn) Close() error { return nil }
func (c *demoConn) Begin() (driver.Tx, error) {
if c.owner.beginErr != nil {
return nil, c.owner.beginErr
}
return &demoTx{owner: c.owner}, nil
}
func (c *demoConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
return driver.RowsAffected(1), nil
}
type demoTx struct{ owner *demoConnector }
func (tx *demoTx) Commit() error {
if tx.owner.commitErr == nil || tx.owner.persistBeforeError {
tx.owner.stored.Store(true)
}
return tx.owner.commitErr
}
func (tx *demoTx) Rollback() error { return tx.owner.rollbackErr }

View File

@@ -0,0 +1,55 @@
// Run with go run ./examples/transactions. Uses a deterministic demonstration
// driver, no external database and no actual uploaded files.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/database"
)
func main() {
failure := errors.New("demonstration failure")
for _, name := range []string{"success", "beginFailure", "workFailure", "rollbackFailure", "commitRejected", "commitAcknowledgementLost"} {
driver := &demoConnector{}
var workErr error
switch name {
case "beginFailure":
driver.beginErr = failure
case "workFailure":
workErr = failure
case "rollbackFailure":
workErr, driver.rollbackErr = failure, failure
case "commitRejected":
driver.commitErr = failure
case "commitAcknowledgementLost":
driver.commitErr, driver.persistBeforeError = failure, true
}
pool := &database.DB{DB: sql.OpenDB(driver)}
err := pool.InTx(context.Background(), nil, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(context.Background(), "INSERT demonstration record"); err != nil {
return err
}
return workErr
})
_ = pool.Close()
if err == nil {
fmt.Printf("%s: committed, stored=%t\n", name, driver.stored.Load())
continue
}
var txErr *database.TxError
if !errors.As(err, &txErr) || !errors.Is(err, failure) {
panic("transaction phase or original cause lost")
}
// A project decides whether cleanup is appropriate for its own operation.
// Unknown outcomes must not be treated as evidence that no record exists.
cleanup := txErr.Outcome == database.TxOutcomeNotStarted || txErr.Outcome == database.TxOutcomeRolledBack
if cleanup && driver.stored.Load() {
panic("unsafe cleanup decision")
}
fmt.Printf("%s: stage=%s outcome=%s stored=%t cleanup=%t\n", name, txErr.Stage, txErr.Outcome, driver.stored.Load(), cleanup)
}
}

View File

@@ -3,7 +3,7 @@ module example.com/userService
go 1.22.0
require (
git.yuntuo.hk/k2safe/go-omni v0.24.0-preview
git.yuntuo.hk/k2safe/go-omni v0.25.0-preview
github.com/go-sql-driver/mysql v1.9.3
)

View File

@@ -7,6 +7,7 @@
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:49 个有测试的包通过,包含实际生成消费者和既有生命周期、错误目录、配置、上传下载等回归。
- 最后补充 IPv4-mapped IPv6 来源序列化后,cors/config/app 的 race 复测通过。
- Go 1.22.12 的 cors/config/app 测试通过;`go vet ./...`、`go build ./...` 通过。
- 最后补充来源序列化后,同三个包在 Go 1.22.12 上复测通过。
- managedService 重生成检查、race、根目录 build 通过;userService race 通过。没有提供外部数据库配置的测试按原规则跳过。
- 实际生成的 CORS 消费者只修改配置,不修改 main、svc、handler;构建/race、重复生成、dry-run、check、业务文件字节保留通过。
- `FuzzCORSHeaders` 运行 30 秒、4 worker,执行 2,877,406 次,通过。输入覆盖来源、方法和预检请求头,不等于穷尽所有协议组合。
@@ -59,3 +60,17 @@ GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/apigen -run '^TestGen
默认关闭,同源反向代理可继续使用原部署方式。只支持 HTTP(S) 精确来源或单一无凭据通配来源,不提供子域通配/正则/null 来源。WebSocket Origin、Private Network Access 和对象存储自身的 CORS 属于其他接口,本轮不包含。
不批准来源不等于阻止服务端执行实际请求,项目的身份认证、权限和 CSRF 策略仍然必要。代理和应用只选择一处负责 CORS,下游不能覆盖许可头或 Vary。详细用法见 [cors 指南](../cors/README.md)。
## 远程版本验收
`v0.24.0-preview` 标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`。空 module 缓存安装远程 CLI,生成项目固定引用远程框架版本,无本地 replace;checksum 为 `h1:tHQSk+XawxJLWnN60so45GoA9An0m1xw5GFU8Ds1NoQ=`。
验收耗时 66.78 秒,70 项检查通过。9 组实际生成消费者执行 race/build;新增 CORS 消费者只改配置即可使用,重复生成/check 保留业务文件。下载模块的 cors/app 全包 race 通过,含三种 transport 的真实 TLS 检查;此前 config/httpc/copyx/logx、初始化/监听失败清理、后台关闭顺序、错误目录继承、上传下载、workspace 等回归继续通过。
版本、检查项及源码哈希见 [corsRemote.json](corsRemote.json)。复现命令:
```sh
python3 scripts/verifyManagedRelease.py --version v0.24.0-preview --report reports/corsRemote.json
```
发布标签不随此验收记录提交移动。

205
reports/corsRemote.json Normal file
View File

@@ -0,0 +1,205 @@
{
"passed": true,
"frameworkVersion": "v0.24.0-preview",
"frameworkSum": "h1:tHQSk+XawxJLWnN60so45GoA9An0m1xw5GFU8Ds1NoQ=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 66.78,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "54bb2f746bec30068102a004db1f009f2bef214cb44053b11ae9d99d0dcd09fd",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
"app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"scripts/verifyManagedRelease.py": "825b02631bd9186bb9f8f85b799ce5c84e2d19a64b6b80f35c47cb11343afd12",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "70c8db3b7d5ee5e92b2086a5a18db671e5a3b34ccba19c56514422c11c461c38",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "6fbeac9c21f5dedad05471deb03a6b4465a0cb10f252d651da2f6f6cb3841998",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "0376fc8ac461571fc028e144a00b1e324ec4f3bb1f6fa1bcf573b86dd32c7167",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2"
}
}

View File

@@ -0,0 +1,63 @@
# v0.25 事务错误验收(2026-09-26)
目标是让业务可靠地区分错误阶段和框架实际掌握的事务结果。保持 InTx 签名,保留原始/回滚错误链;不引入业务补偿、文件删除或事务重试。
## 回归
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:49 个有测试的包通过,包含既有生成消费者、响应目录、生命周期、上传下载、配置等回归。
- `GOTOOLCHAIN=go1.22.12 go test ./database ./errors ./app`:通过。
- `go vet ./...`、`go build ./...`:通过。
- `go test -race ./database -run '^TestInTxCancellation' -count=20`:通过,包含 20 × 128 次取消与回滚竞争检查。
- `go run ./examples/transactions`:六种结果通过,包括“已保存但提交报错”时不清理文件的演示决策;示例没有真实文件删除。
| 场景 | 验证结果 |
| --- | --- |
| 开启失败 | callback 不执行,StageBegin/NotStarted,原始驱动或 context 原因可 Is/As;nil DB、nil callback 在开启前报错 |
| 成功事务 | callback/Commit 各一次,返回 nil,不再调用显式 Rollback;context、隔离级别、只读选项传递保持 |
| 回调失败 | 显式回滚 nil 才返回 Work/RolledBack;原始业务原因可 Is/As;回滚报错为 Unknown 且两条原因完整保留 |
| 提交失败 | 同样的驱动错误分别模拟已保存/未保存,都返回 Commit/Unknown;超时、ErrBadConn 也不当成回滚;不重试 Commit/callback |
| 取消 | 开启前取消、回调取消、自动回滚完成、取消与显式回滚竞争、提交过程中取消;ErrTxDone 从不表示回滚成功 |
| 原因链 | 外层再次 `%w` 包装后 As/Is 可用;主要 typed cause 优先,回滚 typed cause 可单独找到;嵌套 TxError 不压平 |
| 异常与隔离 | panic 原值传播,Goexit 正常展开;都尝试一次回滚,不提交;64 个并发调用的原因/结果互不串扰 |
| HTTP 兼容 | 回调返回业务错误目录引用与参数,包装后仍输出项目 HTTP 200、数字业务码、提示与 data:null;回滚附加原因、提交内部原因不外泄 |
## 真实 MySQL
```sh
python3 scripts/verifyConnector.py --local --gorm --phases --report reports/transactionMySQL.json
```
使用独立本地 MySQL 8.4 容器,1 CPU / 768 MiB / 128 pids,TLS 与临时随机凭据;不连接接入项目数据库或远程服务器。53 条通过记录(含子用例),耗时 13.2 秒。容器、数据卷、证书与临时消费者已经清理。完整证据见 [transactionMySQL.json](transactionMySQL.json)。
- 正常 Commit 后,另一个连接看到记录;回调失败且真实 Rollback 成功,返回 Work/RolledBack,另一个连接确认没有该记录。
- 在真实驱动 Commit 成功返回后,由测试包装器注入“确认丢失”错误:另一个连接确认记录已经持久化,InTx 返回 Commit/Unknown,没有重试或误报回滚。
- 在真实驱动 Rollback 成功后注入确认错误:库只能返回 Work/Unknown,并保留业务原因与回滚确认错误;不能利用测试已知的数据库状态推断生产结果。
- 这是驱动返回边界的确定性故障注入,不等同于覆盖所有 TCP 断线时序。
- 真实生成项目的配置/TLS/独立连接池/关闭、可选 GORM 事务与连接池归属、后台 drain 后关闭数据库均通过。
- 本轮发现旧 MySQL 验收脚本仍依赖已移除的自动环境覆盖。现已改为测试配置文件显式引用变量,复测通过;没有恢复旧加载模式。
## 性能
Go 1.27.1、darwin/arm64、Apple M2 Max。使用相同演示驱动和 benchmark,比较 v0.24 的原 InTx 实现与当前实现,每场景三轮、300ms,取中位数。旧实现从提交 `8eddef9cc8e9902150f4f712a40bc761d4350415` 取出,临时 benchmark 文件测后删除。[原始结果](transactionBenchmark.txt)。
| 场景 | 原实现 ns/次 | 当前 ns/次 | 原分配 | 当前分配 |
| --- | ---: | ---: | ---: | ---: |
| 成功提交 | 617.3 | 587.0 | 392 B / 7 | 392 B / 7 |
| 开启失败 | 212.9 | 146.0 | 96 B / 3 | 80 B / 2 |
| 回调失败并成功回滚 | 556.5 | 540.9 | 280 B / 6 | 344 B / 7 |
| 回调和回滚同时失败 | 681.1 | 732.6 | 416 B / 10 | 344 B / 7 |
| 提交结果不确定 | 719.6 | 814.8 | 472 B / 9 | 456 B / 8 |
成功路径没有新增分配。原先直接返回的回调错误现在需要 TxError,增加 64 B / 1 次分配;其他失败路径移除了即时格式化/Join 的部分开销。部分失败路径耗时增加,以上是受 goroutine 调度影响的本机微基准,不含真实数据库网络、SQL 和磁盘耗时,不承诺固定提速比例或服务 QPS。
当前实现复现:
```sh
go test ./database -run '^$' -bench '^BenchmarkInTx$' -benchmem -benchtime=300ms -count=3
```
## 保证范围
Outcome 只属于当前受管理事务;回调不得自己提交/回滚、执行隐式提交 SQL、把事务交给其他 goroutine。其他连接、独立事务、非事务表、文件和第三方系统的副作用由业务负责。
标准库异步回滚不公开的驱动原因无法还原。只拿到 ErrTxDone 时保守返回 Unknown;直接观察到的回滚失败全部保留。panic/Goexit 保持原有传播,没有普通返回值可携带回滚原因。返回错误的文本/具体最外层类型有变化,调用方使用 errors.Is/As;GORM 自己的 Transaction 不属于本次接口。

View File

@@ -0,0 +1,36 @@
goos: darwin
goarch: arm64
pkg: git.yuntuo.hk/k2safe/go-omni/database
cpu: Apple M2 Max
BenchmarkLegacyInTx/success-12 623776 617.3 ns/op 392 B/op 7 allocs/op
BenchmarkLegacyInTx/success-12 715071 627.2 ns/op 392 B/op 7 allocs/op
BenchmarkLegacyInTx/success-12 686116 605.4 ns/op 392 B/op 7 allocs/op
BenchmarkLegacyInTx/beginFailure-12 1700702 213.6 ns/op 96 B/op 3 allocs/op
BenchmarkLegacyInTx/beginFailure-12 1692045 211.5 ns/op 96 B/op 3 allocs/op
BenchmarkLegacyInTx/beginFailure-12 1698073 212.9 ns/op 96 B/op 3 allocs/op
BenchmarkLegacyInTx/workFailure-12 787486 544.8 ns/op 280 B/op 6 allocs/op
BenchmarkLegacyInTx/workFailure-12 724378 557.3 ns/op 280 B/op 6 allocs/op
BenchmarkLegacyInTx/workFailure-12 780825 556.5 ns/op 280 B/op 6 allocs/op
BenchmarkLegacyInTx/rollbackFailure-12 598321 681.7 ns/op 416 B/op 10 allocs/op
BenchmarkLegacyInTx/rollbackFailure-12 555447 665.4 ns/op 416 B/op 10 allocs/op
BenchmarkLegacyInTx/rollbackFailure-12 516342 681.1 ns/op 416 B/op 10 allocs/op
BenchmarkLegacyInTx/commitUnknown-12 485095 734.8 ns/op 472 B/op 9 allocs/op
BenchmarkLegacyInTx/commitUnknown-12 549530 716.2 ns/op 472 B/op 9 allocs/op
BenchmarkLegacyInTx/commitUnknown-12 498938 719.6 ns/op 472 B/op 9 allocs/op
BenchmarkInTx/success-12 613156 597.3 ns/op 392 B/op 7 allocs/op
BenchmarkInTx/success-12 592444 584.3 ns/op 392 B/op 7 allocs/op
BenchmarkInTx/success-12 720130 587.0 ns/op 392 B/op 7 allocs/op
BenchmarkInTx/beginFailure-12 2452880 147.0 ns/op 80 B/op 2 allocs/op
BenchmarkInTx/beginFailure-12 2506814 146.0 ns/op 80 B/op 2 allocs/op
BenchmarkInTx/beginFailure-12 2485419 145.2 ns/op 80 B/op 2 allocs/op
BenchmarkInTx/workFailure-12 748887 538.1 ns/op 344 B/op 7 allocs/op
BenchmarkInTx/workFailure-12 753242 540.9 ns/op 344 B/op 7 allocs/op
BenchmarkInTx/workFailure-12 727633 592.8 ns/op 344 B/op 7 allocs/op
BenchmarkInTx/rollbackFailure-12 538176 732.6 ns/op 345 B/op 7 allocs/op
BenchmarkInTx/rollbackFailure-12 669040 740.1 ns/op 344 B/op 7 allocs/op
BenchmarkInTx/rollbackFailure-12 629571 696.0 ns/op 344 B/op 7 allocs/op
BenchmarkInTx/commitUnknown-12 650611 804.9 ns/op 456 B/op 8 allocs/op
BenchmarkInTx/commitUnknown-12 434694 1015 ns/op 456 B/op 8 allocs/op
BenchmarkInTx/commitUnknown-12 663369 814.8 ns/op 456 B/op 8 allocs/op
PASS
ok git.yuntuo.hk/k2safe/go-omni/database 14.818s

View File

@@ -0,0 +1,90 @@
{
"passed": true,
"frameworkVersion": "v0.25.0-preview",
"localSource": true,
"gormAdapter": true,
"phasedLifecycle": true,
"frameworkSum": null,
"freshModuleCache": false,
"noLocalReplace": false,
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
"mysqlLimits": {
"cpus": 1,
"memoryMiB": 768,
"pids": 128
},
"durationSeconds": 13.2,
"tests": [
"TestConnectorIndependentPoolsAndContext",
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
"TestConnectorStartupFailuresReleaseResources/connect",
"TestConnectorStartupFailuresReleaseResources/ping",
"TestConnectorStartupFailuresReleaseResources",
"TestConnectorTimeoutBoundsConnectAndPing/connect",
"TestConnectorTimeoutBoundsConnectAndPing/ping",
"TestConnectorTimeoutBoundsConnectAndPing",
"TestConnectorHealthBoundsPoolWaitAndRecovers",
"TestConnectorIdleZeroAndLifetime/noIdle",
"TestConnectorIdleZeroAndLifetime/expired",
"TestConnectorIdleZeroAndLifetime",
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
"TestMySQLConnectorDriverLoggerIsolation",
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
"TestMySQLConnectorTLSAndPoolIsolation/commitAcknowledgementLost",
"TestMySQLConnectorTLSAndPoolIsolation/rollbackAcknowledgementLost",
"TestMySQLConnectorTLSAndPoolIsolation",
"TestHTTPFileAndEnvironment/server.json",
"TestHTTPFileAndEnvironment/server.yaml",
"TestHTTPFileAndEnvironment",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
"TestHTTPInvalidAndUnsupportedSettings",
"TestHTTPConfiguredStandardServer",
"TestHTTPInvalidStartupReleasesResources",
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
"TestGORMMySQLTransactionsCancellationAndLogs",
"TestStructuredMySQLAdapterTLS",
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
"TestHTTPConfiguredNetworkLimits/nethttp",
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
"TestHTTPConfiguredNetworkLimits/native/slowBody",
"TestHTTPConfiguredNetworkLimits/native/idle",
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
"TestHTTPConfiguredNetworkLimits/native",
"TestHTTPConfiguredNetworkLimits",
"TestGeneratedOptionalGORM",
"TestGeneratedMySQLConfigurationAndLifecycle",
"TestGeneratedMySQLBackgroundDrain"
],
"sourceHashes": {
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
"scripts/verifyConnector.py": "2b1b1851a53efde49a44642bc92296b4b4f40bf84e0666389ec763a35f63b48a",
"scripts/testdata/mysqlGenerated_test.go": "419c83cf9d1c25e8791f92b697f8e74f55b0af76ac0bc41e1316ee87b6923711",
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
}
}

View File

@@ -20,7 +20,7 @@ func TestGeneratedMySQLConfigurationAndLifecycle(t *testing.T) {
t.Fatal(err)
}
if cfg.Database.User != "omni_test" || cfg.Database.TLS.ServerName != "localhost" || cfg.Database.Pool.MaxOpenConns != 2 {
t.Fatal("structured environment overlay was not applied")
t.Fatal("explicit database configuration references were not applied")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

View File

@@ -72,7 +72,7 @@ def main():
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
project = temp / "consumer"
project.mkdir()
version = args.version or "v0.15.0-preview"
version = args.version or re.search(r'const currentVersion = "([^"]+)"', (ROOT / "cmd/go-omni/apigen/generate.go").read_text()).group(1)
(project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n")
if args.local:
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
@@ -155,6 +155,8 @@ def main():
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
assert "TestMySQLConnectorTLSAndPoolIsolation" in passed
assert "TestMySQLConnectorTLSAndPoolIsolation/commitAcknowledgementLost" in passed
assert "TestMySQLConnectorTLSAndPoolIsolation/rollbackAcknowledgementLost" in passed
assert "TestStructuredMySQLAdapterTLS" in passed
if args.gorm:
assert "TestGORMMySQLTransactionsCancellationAndLogs" in passed
@@ -164,6 +166,18 @@ def main():
GO_OMNI_DB_DATABASE="omni_test", GO_OMNI_DB_TLS_CA_FILE=str(certs / "ca.pem"),
GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2",
GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2")
# Since v0.22, configuration reads only explicitly declared references.
# JSON is valid YAML; keep secrets in the temporary process environment.
(generated / "etc/config.yaml").write_text(json.dumps({
"server": {"log": {"output": "discard"}},
"database": {
"host": "${GO_OMNI_DB_HOST}", "port": "${GO_OMNI_DB_PORT}",
"user": "${GO_OMNI_DB_USER}", "password": "${GO_OMNI_DB_PASSWORD}",
"database": "${GO_OMNI_DB_DATABASE}",
"tls": {"caFile": "${GO_OMNI_DB_TLS_CA_FILE}", "serverName": "${GO_OMNI_DB_TLS_SERVER_NAME}"},
"pool": {"maxOpenConns": "${GO_OMNI_DB_POOL_MAX_OPEN_CONNS}", "maxIdleConns": "${GO_OMNI_DB_POOL_MAX_IDLE_CONNS}"},
},
}, indent=2) + "\n")
print("Testing generated MySQL service config, independent pools and cleanup", flush=True)
check_output = run([cli, "api", "generate", "--check"], generated, env)
if not args.local:
@@ -182,7 +196,7 @@ def main():
finally:
if created:
run(["docker", "rm", "--force", "--volumes", name], temp, env)
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"]
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go", "database/transaction.go"]
if args.phases:
report_sources += ["scripts/testdata/phasedMySQL_test.go", "lifecycle/runtime.go", "lifecycle/phases.go"]
if args.gorm:

View File

@@ -163,6 +163,7 @@ def main():
"logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"},
"cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"},
"app": {"TestAppCORSRoutingAndErrors", "TestAppCORSConfigIsolation", "TestCORSTransports"},
"database": {"TestInTxStagesAndCauses", "TestInTxCommitOutcomeUnknown", "TestInTxCancellationAndOwnership", "TestInTxCancellationRace", "TestInTxSuccessPanicAndConcurrency", "TestInTxPublicErrorCompatibility"},
}.items():
output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/" + package], starter, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
@@ -171,6 +172,12 @@ def main():
checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"]
transaction_output = run(["go", "run", MODULE + "/examples/transactions"], starter, env)
assert "commitAcknowledgementLost: stage=commit outcome=unknown stored=true cleanup=false" in transaction_output
assert "workFailure: stage=work outcome=rolledBack stored=false cleanup=true" in transaction_output
checks += ["remoteTransactionStages", "remoteTransactionCauseChains", "remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown", "remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors", "remoteTransactionExample"]
checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
@@ -193,6 +200,7 @@ def main():
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/developerTools").iterdir() if p.is_file()]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cors").glob("*.go")]
sources += ["app/cors_test.go", "cmd/go-omni/apigen/corsConsumer_test.go", "cmd/go-omni/apigen/testdata/cors/consumer_test.go", "examples/cors/main.go", "examples/cors/config.yaml", "scripts/corsBrowser/main.go"]
sources += ["database/transaction.go", "database/transaction_test.go", "database/transactionResponse_test.go", "database/mysqlConnector_test.go", "examples/transactions/main.go", "examples/transactions/demoDriver.go"]
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,