diff --git a/CHANGELOG.md b/CHANGELOG.md index f419517..98d42ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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。默认关闭,无新增第三方依赖。 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d4a64ce..3ff49db 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,4 +1,4 @@ -# 通用业务开发指南(v0.24.0-preview) +# 通用业务开发指南(v0.25.0-preview) 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 diff --git a/PROJECT.md b/PROJECT.md index 0161bde..927434a 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 和提前返回的中间件一起验收。 diff --git a/README.md b/README.md index 79d9220..7181195 100644 --- a/README.md +++ b/README.md @@ -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)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index bda475e..e420d1b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 或逐条修改接口。 diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 4678540..f486368 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -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` 命令的手动路由装配。 ## 数据库迁移命令 diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index d6f91aa..2db6825 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -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 { diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 4c8be80..8d826a7 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -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. diff --git a/database/README.md b/database/README.md index 9103bba..0e06869 100644 --- a/database/README.md +++ b/database/README.md @@ -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`。这是标准驱动接口,核心包不增加具体驱动依赖。项目可以在同一进程创建多个使用不同证书、日志、拨号策略的连接池。 diff --git a/database/database.go b/database/database.go index 6fde91d..47fc27f 100644 --- a/database/database.go +++ b/database/database.go @@ -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 -} diff --git a/database/mysqlConnector_test.go b/database/mysqlConnector_test.go index b5ddcc3..0791a6e 100644 --- a/database/mysqlConnector_test.go +++ b/database/mysqlConnector_test.go @@ -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 +} diff --git a/database/transaction.go b/database/transaction.go new file mode 100644 index 0000000..2be3ebd --- /dev/null +++ b/database/transaction.go @@ -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} +} diff --git a/database/transactionResponse_test.go b/database/transactionResponse_test.go new file mode 100644 index 0000000..5dfb635 --- /dev/null +++ b/database/transactionResponse_test.go @@ -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()) + } + }) + } +} diff --git a/database/transaction_test.go b/database/transaction_test.go new file mode 100644 index 0000000..8e5b1c7 --- /dev/null +++ b/database/transaction_test.go @@ -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) + } + } + }) + } +} diff --git a/examples/managedService/go.mod b/examples/managedService/go.mod index c7a41c7..b45226a 100644 --- a/examples/managedService/go.mod +++ b/examples/managedService/go.mod @@ -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 diff --git a/examples/transactions/README.md b/examples/transactions/README.md new file mode 100644 index 0000000..2b11ff1 --- /dev/null +++ b/examples/transactions/README.md @@ -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)。 diff --git a/examples/transactions/demoDriver.go b/examples/transactions/demoDriver.go new file mode 100644 index 0000000..77fe4d1 --- /dev/null +++ b/examples/transactions/demoDriver.go @@ -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 } diff --git a/examples/transactions/main.go b/examples/transactions/main.go new file mode 100644 index 0000000..a9ca6fb --- /dev/null +++ b/examples/transactions/main.go @@ -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) + } +} diff --git a/examples/userService/go.mod b/examples/userService/go.mod index aed552e..77094a0 100644 --- a/examples/userService/go.mod +++ b/examples/userService/go.mod @@ -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 ) diff --git a/reports/transactionAcceptance.md b/reports/transactionAcceptance.md new file mode 100644 index 0000000..0fe874d --- /dev/null +++ b/reports/transactionAcceptance.md @@ -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 不属于本次接口。 diff --git a/reports/transactionBenchmark.txt b/reports/transactionBenchmark.txt new file mode 100644 index 0000000..51a8e67 --- /dev/null +++ b/reports/transactionBenchmark.txt @@ -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 diff --git a/reports/transactionMySQL.json b/reports/transactionMySQL.json new file mode 100644 index 0000000..369e712 --- /dev/null +++ b/reports/transactionMySQL.json @@ -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" + } +} diff --git a/scripts/testdata/mysqlGenerated_test.go b/scripts/testdata/mysqlGenerated_test.go index 368ecd3..f396ddc 100644 --- a/scripts/testdata/mysqlGenerated_test.go +++ b/scripts/testdata/mysqlGenerated_test.go @@ -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() diff --git a/scripts/verifyConnector.py b/scripts/verifyConnector.py index 44e17cc..84d0dff 100644 --- a/scripts/verifyConnector.py +++ b/scripts/verifyConnector.py @@ -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: diff --git a/scripts/verifyManagedRelease.py b/scripts/verifyManagedRelease.py index cf42100..0d8455f 100644 --- a/scripts/verifyManagedRelease.py +++ b/scripts/verifyManagedRelease.py @@ -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,