Compare commits
4 Commits
v0.14.0-pr
...
v0.15.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 16deead70e | |||
| adcb6f60b6 | |||
| b8ab6b3521 | |||
| da8356e18b |
@@ -13,7 +13,9 @@
|
||||
- [x] 文档工具:字段注释、example/枚举描述进入 DTO 与 OpenAPI;保注释、幂等格式化及 check;显式 Go 错误目录元数据导出与文档合并,不自动执行项目代码。
|
||||
- [x] 共享契约:受控 roots/摘要、循环/深度/文件数/路径保护;分源文件 DTO、多个独立 module 的工作区检查;版本提示与基础模板升级预览,保留业务文件。
|
||||
- [x] 集成:实际生成项目验证新能力、连续生成、业务文件保留、失败清理、nethttp/native 请求与退出;现有错误继承/参数/上传下载回归。
|
||||
- [ ] 最终回归:全量 race(含生成器集成)、vet、build、格式化/链接检查;使用说明、迁移步骤及验收报告完整,发布后远程无 replace 验收。
|
||||
- [x] 最终回归:全量 race(含生成器集成)、vet、build、格式化/链接检查;使用说明、迁移步骤及验收报告完整,发布后远程无 replace 验收。
|
||||
|
||||
已发布 `v0.14.0-preview`。逐项验收、可替换的业务适配点与远程报告见 [验收记录](reports/businessDevelopmentAcceptance.md);接入和升级见 [开发指南](DEVELOPMENT.md)。
|
||||
|
||||
## 核心约定
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 通用业务开发指南(v0.14.0-preview)
|
||||
# 通用业务开发指南(v0.15.0-preview)
|
||||
|
||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||
|
||||
@@ -128,7 +128,7 @@ func (c *ServiceContext) Init(ctx context.Context) error {
|
||||
}
|
||||
```
|
||||
|
||||
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。项目清理函数仍须自行停止、等待后台任务并保证能返回,框架不能强制杀死任意 goroutine。
|
||||
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。
|
||||
|
||||
中间件用业务所需的小接口接收依赖,避免反向导入 svc:
|
||||
|
||||
@@ -243,7 +243,7 @@ go-omni workspace check --file omni.workspace.json
|
||||
|
||||
## v0.13 项目升级
|
||||
|
||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.14.0-preview(或显式引用当前源码)。
|
||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.15.0-preview(或显式引用当前源码)。
|
||||
|
||||
```sh
|
||||
go-omni api upgrade --dir . --dry-run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 生成项目与文件维护边界
|
||||
|
||||
v0.14.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
v0.15.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
|
||||
## 创建和日常开发
|
||||
|
||||
@@ -93,7 +93,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
||||
|
||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||
|
||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||
|
||||
## 依赖主动申请退出
|
||||
|
||||
@@ -106,7 +106,7 @@ c.RequestShutdown(err)
|
||||
|
||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||
|
||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||
|
||||
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
|
||||
|
||||
@@ -208,7 +208,7 @@ get /download (DownloadReq)
|
||||
|
||||
## 已有服务接入步骤
|
||||
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.0-preview:
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.15.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 和提前返回的中间件一起验收。
|
||||
|
||||
12
README.md
12
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.14.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.15.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
|
||||
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||
|
||||
@@ -10,6 +10,10 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
|
||||
新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
||||
|
||||
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
|
||||
|
||||
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
|
||||
|
||||
## 选择接入方式
|
||||
|
||||
| 你的项目 | 从哪里开始 |
|
||||
@@ -30,7 +34,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.14.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
go-omni --help
|
||||
```
|
||||
@@ -186,7 +190,7 @@ go-omni new --api catalog.api \
|
||||
在业务项目的 **go.mod 所在目录**执行:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.14.0-preview
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview
|
||||
```
|
||||
|
||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||
@@ -456,7 +460,7 @@ go test ./...
|
||||
|
||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||
|
||||
CLI 与业务 module 固定使用 `v0.14.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
CLI 与业务 module 固定使用 `v0.15.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
45
RELEASE.md
45
RELEASE.md
@@ -1,4 +1,45 @@
|
||||
# v0.14.0-preview
|
||||
# v0.15.0-preview
|
||||
|
||||
增加通用后台分阶段关闭:生成入口先完成 HTTP 排空,再运行全部 OnStop、全部 OnDrain,确认组件收尾后逆序释放 OnClose 资源,最后关闭日志。适用于任意后台组件;不引入 Orbit,不改变业务事务或任务一致性策略。
|
||||
|
||||
- svc 登记成对、同名的 OnStop/OnDrain,Context 用于早期退出通知,WorkContext 供已接受工作在收尾期间使用。同一阶段并发执行,存在内部先后依赖的组件组合登记。
|
||||
- server.lifecycle 配置 stopTimeout、drainTimeout、cancelTimeout。阶段超时取消工作并给未返回钩子收尾窗口;错误与阶段/组件名称可通过错误链追踪。
|
||||
- 不能确认后台已经结束时,返回 ErrResourcesRetained,保留共享资源和借用日志,生成进程非零退出。未返回的 Stop 不会与自己的 Drain 重叠;错误、panic 不阻止其他组件尝试收尾。
|
||||
- task.Runner 增加 StopAccepting/Wait,原 Shutdown/Close 继续可用。核心 lifecycle 不依赖 task 或其他适配器。
|
||||
- 初始化和监听失败复用同一流程;重复关闭只执行一次。生成器继续保留配置、svc 和业务代码。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
v0.14.x 无需重写 svc/config;生成器更新 main/runtimeGen,未配置时使用默认时限。也可 api upgrade --dry-run/--apply 更新依赖与生成文件,保留已有项目扩展。需要阶段保证的旧后台 OnClose 登记,应显式迁到 Stop/Drain;v0.13 及更早版本仍按原迁移指南处理。
|
||||
|
||||
全量 race(含实际生成项目)、vet、build 和隔离 MySQL/GORM 验收已通过。详见 [生命周期接入](lifecycle/README.md#后台组件分阶段关闭) 与 [验收记录](reports/phasedLifecycleAcceptance.md)。Go 无法强制结束任意 goroutine;OnClose 仍须能返回,HTTP 处理器仍须响应取消。此版不会将未知的第三方组件自动改造成优雅关闭组件。
|
||||
|
||||
---
|
||||
|
||||
# v0.14.1-preview(历史版本)
|
||||
|
||||
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
|
||||
|
||||
在 svc.Init 中调用 `gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))`;通用 Open 工厂保留其他方言入口。适配器保护借用池不被初始化失败路径关闭,并为初始化查询设置时限。查询使用 WithContext,SQL 日志接入 logx,请求标识、慢查询和错误分类贯通;默认不输出绑定参数与驱动完整错误文本。
|
||||
|
||||
v0.14.0 项目更新 module 与 CLI 即可,不改生成目录或 main。模板最低运行时保持 v0.14.0;修正新版 CLI 把最低兼容版本误当自身版本、导致同版消费者出现版本提示的问题。其他数据库方言和管理额外池的插件未纳入本次实库验收,原生 PrepareStmt 的生命周期限制见 [接入说明](database/gormx/README.md)。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||
```
|
||||
|
||||
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过。发布后的空缓存远程 MySQL/GORM 和生成项目验收均通过,无本地 replace,CLI 与框架使用同版。标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`,详见 [GORM 验收](reports/gormAcceptance.md)。
|
||||
|
||||
---
|
||||
|
||||
# v0.14.0-preview(历史版本)
|
||||
|
||||
本版补齐契约驱动的业务开发与通用基础设施。项目继续维护接口、配置、依赖装配、Logic、SQL 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。
|
||||
|
||||
@@ -24,6 +65,8 @@ go-omni new --module example.com/billing ./billing
|
||||
|
||||
详见 [业务开发指南](DEVELOPMENT.md)、[数据库接入](database/mysql/README.md) 和 [验收记录](reports/businessDevelopmentAcceptance.md)。
|
||||
|
||||
全量 race(含生成项目)、vet、build 与本地进程验收通过。发布后的空缓存远程消费者和隔离 MySQL 验收也均通过;CLI/框架同版且无本地 replace。标签固定在 `f5478cdc7662704b164637649305361e52f7c598`,报告单独提交。
|
||||
|
||||
仍需业务维护:契约、配置、svc 依赖创建/探针/清理登记、错误目录、Logic、Repository 和权限中间件。特殊协议可使用保留的扩展点。声明式上传的暂存磁盘和并发量需配置容量;共享契约版本标签由项目管理,框架校验摘要但不下载依赖。此版仍为 preview,不代表 eTax/ePay 已完成业务迁移。
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# GoOmni 成熟度计划
|
||||
|
||||
当前进展:`v0.14.0-preview` 已完成契约驱动的业务开发能力、通用 Runtime/配置和官方 MySQL 适配器,并通过全量 race、实际生成服务及空缓存远程验收。新项目/升级以 [README](README.md) 和 [开发指南](DEVELOPMENT.md) 为准;下方为历史阶段记录,完整本轮验收见 [报告](reports/businessDevelopmentAcceptance.md)。具体 eTax/ePay 业务迁移与业务回归仍由接入项目完成。
|
||||
|
||||
## 1. 新项目启动与发布流程(已完成,v0.1.1)
|
||||
|
||||
- 修正脚手架固定版本引用,保留显式本地开发选项。
|
||||
|
||||
@@ -8,7 +8,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.14.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -43,14 +43,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.14.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.15.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.14.0-preview 的通用 Runtime、配置和绑定能力,继续使用 manifest v2。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
模板最低需要 v0.15.0-preview 的分阶段 Runtime 能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const minimumVersion = "v0.14.0-preview"
|
||||
const minimumVersion = "v0.15.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
@@ -151,7 +151,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
}
|
||||
}
|
||||
if !localCurrent {
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates the generated Runtime, request policy and structured MySQL support; use %s or a current local replacement", minimumVersion)
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates the generated phased lifecycle support; use %s or a current local replacement", minimumVersion)
|
||||
}
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
@@ -242,8 +242,12 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
}
|
||||
}
|
||||
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
||||
if requiredVersion != "" && requiredVersion != minimumVersion {
|
||||
out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: minimumVersion, Message: "project dependency differs from this CLI template version; use matching CLI/framework versions, or verify the explicit local replacement"})
|
||||
expectedVersion := minimumVersion
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||
expectedVersion = info.Main.Version
|
||||
}
|
||||
if requiredVersion != "" && requiredVersion != expectedVersion {
|
||||
out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: expectedVersion, Message: "project dependency differs from this CLI version; use matching CLI/framework versions, or verify the explicit local replacement"})
|
||||
sortDiagnostics(out.Diagnostics)
|
||||
}
|
||||
return out, err
|
||||
|
||||
@@ -71,6 +71,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
|
||||
"shutdown_test.go": "shutdown_test.go",
|
||||
"phases_test.go": "phases_test.go", "internal/svc/background.go": "background.go",
|
||||
}
|
||||
for path, fixture := range files {
|
||||
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
||||
@@ -79,7 +80,7 @@ func TestManagedGeneratedProject(t *testing.T) {
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
||||
}
|
||||
configPath := filepath.Join(dir, "internal/config/config.go")
|
||||
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
||||
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n BackgroundMode string `json:\"backgroundMode\"`\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
||||
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
|
||||
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
|
||||
source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"MODULE/internal/config"
|
||||
"MODULE/internal/handler"
|
||||
"MODULE/internal/svc"
|
||||
@@ -27,7 +28,9 @@ func main(){
|
||||
func run(ctx context.Context,path string)(runErr error){
|
||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
||||
// Unjoined background work may still log. The owning process exits on this
|
||||
// error; do not close its borrowed logger before that work has finished.
|
||||
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
|
||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||
// and closes them exactly once, before closing the borrowed logger.
|
||||
@@ -182,7 +185,16 @@ then immediately register each cleanup with OnClose. A later initialization fail
|
||||
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
||||
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||
once before route registration; logging is configured earlier through Config.
|
||||
The generated main runs HTTP draining before closing svc resources, then the logger.
|
||||
The generated main runs HTTP draining, all OnStop hooks, all OnDrain hooks, resource
|
||||
OnClose callbacks, then logger close. Stop/drain hooks within a phase run concurrently;
|
||||
combine components with ordering dependencies into one pair. Pair OnStop/OnDrain
|
||||
with the same name. OnDrain returns nil only after final work and acknowledgements
|
||||
have finished. Use WorkContext for accepted background work; Context is an early
|
||||
shutdown notification. server.lifecycle configures stopTimeout (5s), drainTimeout
|
||||
(10s), and cancelTimeout (5s grace per timed-out phase). Zero uses defaults.
|
||||
Timeout/failure cancels WorkContext. If hooks cannot prove completion, resources
|
||||
and the borrowed logger are retained and the generated process exits unsuccessfully.
|
||||
Errors retain their causes. No automatic retry of close or force killing of goroutines.
|
||||
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
|
||||
context cancels on signals; long-lived clients/workers that requests depend on need
|
||||
their own lifetime, canceled by OnClose after HTTP draining.
|
||||
|
||||
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/task"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BackgroundState struct {
|
||||
Stop, Drain, Release, Started chan struct{}
|
||||
Wrote atomic.Bool
|
||||
Future *task.Future
|
||||
}
|
||||
|
||||
var Background atomic.Pointer[BackgroundState]
|
||||
|
||||
func initBackground(c *ServiceContext) error {
|
||||
state := &BackgroundState{Stop: make(chan struct{}), Drain: make(chan struct{}), Release: make(chan struct{}), Started: make(chan struct{})}
|
||||
baseline := Closed.Load()
|
||||
runner, err := task.New(task.Config{Workers: 1, QueueSize: 1, TaskTimeout: time.Minute, OnError: func(string, error) {}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.OnStop("jobs", func(ctx context.Context) error { close(state.Stop); return runner.StopAccepting(ctx) })
|
||||
c.OnDrain("jobs", func(ctx context.Context) error { close(state.Drain); return runner.Wait(ctx) })
|
||||
state.Future, err = runner.Submit(c.WorkContext(), "finish", func(ctx context.Context) error {
|
||||
close(state.Started)
|
||||
if c.Config.BackgroundMode == "auto" {
|
||||
<-state.Stop
|
||||
} else {
|
||||
<-state.Release
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if Closed.Load() != baseline {
|
||||
return errors.New("shared client closed before background write")
|
||||
}
|
||||
state.Wrote.Store(true)
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
<-state.Started
|
||||
}
|
||||
Background.Store(state)
|
||||
return err
|
||||
}
|
||||
@@ -20,6 +20,11 @@ func initClient(ctx context.Context, c *ServiceContext) error {
|
||||
Opened.Add(1)
|
||||
c.Client = &Client{}
|
||||
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
|
||||
if c.Config.BackgroundMode != "" {
|
||||
if err := initBackground(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.Config.FailInit {
|
||||
return errors.New("client initialization failed")
|
||||
}
|
||||
|
||||
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"example.com/managed/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func phasedConfig(t *testing.T, address, mode string, fail bool) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
drainTimeout := "1s"
|
||||
if mode == "stuck" {
|
||||
drainTimeout = "50ms"
|
||||
}
|
||||
data, _ := json.Marshal(map[string]any{"backgroundMode": mode, "failInit": fail, "server": map[string]any{"address": address, "lifecycle": map[string]string{"stopTimeout": "1s", "drainTimeout": drainTimeout, "cancelTimeout": "50ms"}}})
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
func await(t *testing.T, ch <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("phase did not arrive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedBackgroundPhases(t *testing.T) {
|
||||
for _, transport := range []string{"nethttp", "native"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
t.Setenv("GO_OMNI_TRANSPORT", transport)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
svc.Started = make(chan struct{}, 1)
|
||||
svc.Release = make(chan struct{})
|
||||
before := svc.Closed.Load()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
path := phasedConfig(t, addr, "hold", false)
|
||||
go func() { done <- run(ctx, path) }()
|
||||
waitReady(t, "http://"+addr, done)
|
||||
state := svc.Background.Load()
|
||||
defer func() {
|
||||
select {
|
||||
case <-state.Release:
|
||||
default:
|
||||
close(state.Release)
|
||||
}
|
||||
}()
|
||||
requestDone := make(chan error, 1)
|
||||
go func() {
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
resp, e := client.Get("http://" + addr + "/slow")
|
||||
if e == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
e = errors.New("HTTP drain failed")
|
||||
}
|
||||
}
|
||||
requestDone <- e
|
||||
}()
|
||||
await(t, svc.Started)
|
||||
deps := svc.Current.Load()
|
||||
deps.RequestShutdown(svc.FatalDependency)
|
||||
if deps.WorkContext().Err() != nil {
|
||||
t.Fatal("accepted work canceled at signal")
|
||||
}
|
||||
select {
|
||||
case <-state.Stop:
|
||||
t.Fatal("stopped background before HTTP finished")
|
||||
default:
|
||||
}
|
||||
close(svc.Release)
|
||||
if err := <-requestDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
await(t, state.Drain)
|
||||
if svc.Closed.Load() != before {
|
||||
t.Fatal("client closed during background drain")
|
||||
}
|
||||
close(state.Release)
|
||||
if err := <-done; !errors.Is(err, svc.FatalDependency) || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
|
||||
t.Fatal("missing final write or cleanup")
|
||||
}
|
||||
if err := state.Future.Wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedBackgroundFailureCleanup(t *testing.T) {
|
||||
for _, failInit := range []bool{false, true} {
|
||||
t.Run(map[bool]string{true: "initialization", false: "listen"}[failInit], func(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
before := svc.Closed.Load()
|
||||
err = run(context.Background(), phasedConfig(t, l.Addr().String(), "auto", failInit))
|
||||
if err == nil || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := svc.Background.Load()
|
||||
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
|
||||
t.Fatal("failure cleanup did not join background first")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedBackgroundTimeoutProcess(t *testing.T) {
|
||||
if path := os.Getenv("OMNI_PHASE_TIMEOUT_CONFIG"); path != "" {
|
||||
os.Args = []string{os.Args[0], "-config", path}
|
||||
main()
|
||||
return
|
||||
}
|
||||
path := phasedConfig(t, "127.0.0.1:0", "stuck", true)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedBackgroundTimeoutProcess$")
|
||||
cmd.Env = append(os.Environ(), "OMNI_PHASE_TIMEOUT_CONFIG="+path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || exit.ExitCode() != 1 || ctx.Err() != nil {
|
||||
t.Fatal(err, string(output))
|
||||
}
|
||||
if strings.Contains(string(output), "test.client.closed") || !strings.Contains(string(output), "shared resources retained") || !strings.Contains(string(output), "client initialization failed") {
|
||||
t.Fatal(string(output))
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,12 @@ func PlanUpgrade(dir string) (UpgradePlan, error) {
|
||||
names[name] = true
|
||||
}
|
||||
}
|
||||
// v0.14 already owns config/svc extensions. Only bump the module here;
|
||||
// Generate updates generator-owned main/runtime files without replaying the
|
||||
// v0.13 seed migration over developer configuration and dependency code.
|
||||
if semver.IsValid(m.TemplateVersion) && semver.Compare(m.TemplateVersion, "v0.14.0-preview") >= 0 {
|
||||
names = map[string]bool{}
|
||||
}
|
||||
sorted := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
sorted = append(sorted, name)
|
||||
|
||||
@@ -60,6 +60,49 @@ func TestUpgradeRejectsEscapingEntry(t *testing.T) {
|
||||
t.Fatal("invalid upgrade wrote project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeV014PreservesExtensions(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "service")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/upgrade", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.TemplateVersion = "v0.14.0-preview"
|
||||
raw, _ := json.Marshal(m)
|
||||
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||
mod := filepath.Join(dir, "go.mod")
|
||||
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), minimumVersion, "v0.14.1-preview"))
|
||||
owned := map[string]string{}
|
||||
for _, name := range []string{"internal/svc/serviceContext.go", "internal/config/config.go", "etc/config.yaml"} {
|
||||
path := filepath.Join(dir, name)
|
||||
value := readFile(t, path)
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
value += "\n// project extension kept\n"
|
||||
} else {
|
||||
value += "\n# project configuration kept\n"
|
||||
}
|
||||
writeFile(t, path, value)
|
||||
owned[path] = value
|
||||
}
|
||||
plan, err := PlanUpgrade(dir)
|
||||
if err != nil || len(plan.Changes) != 1 || plan.Changes[0].Path != "go.mod" {
|
||||
t.Fatal(plan, err)
|
||||
}
|
||||
if _, err := ApplyUpgrade(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, want := range owned {
|
||||
if readFile(t, path) != want {
|
||||
t.Fatal("developer file modified", path)
|
||||
}
|
||||
}
|
||||
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestSafeInfrastructureUpgrade(t *testing.T) {
|
||||
dir := oldInfrastructureProject(t)
|
||||
before := snapshot(t, dir)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.14.0-preview"}, {"--framework-version=v0.14.0-preview"}} {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.15.0-preview"}, {"--framework-version=v0.15.0-preview"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "go-omni v0.14.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
if !strings.Contains(string(content), "go-omni v0.15.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ type Context struct {
|
||||
{{end}}
|
||||
}
|
||||
// Init is project-owned. Add dependencies here and immediately register each
|
||||
// cleanup with c.OnClose. Returning an error automatically rolls everything back.
|
||||
// cleanup with c.OnClose. Background components use paired OnStop/OnDrain hooks
|
||||
// and WorkContext. Failed initialization stops/drains before releasing resources;
|
||||
// unjoined background work returns ErrResourcesRetained instead of closing its dependencies.
|
||||
// Generated infrastructure is ready here. Never create another App or logger.
|
||||
func(c *Context)Init(ctx context.Context)error {return nil}
|
||||
|
||||
@@ -18,7 +18,8 @@ func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(res
|
||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
checker,err:=health.New();if err!=nil{return nil,err}
|
||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Runtime:lifecycle.NewRuntime(ctx)}
|
||||
runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Server.Lifecycle);if err!=nil{return nil,err}
|
||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Runtime:runtime}
|
||||
ctx=logx.WithLogger(c.Context(),logger)
|
||||
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
|
||||
@@ -4,7 +4,11 @@ server:
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
{{if .API}} endpoints:
|
||||
{{if .API}} lifecycle:
|
||||
stopTimeout: 5s
|
||||
drainTimeout: 10s
|
||||
cancelTimeout: 5s
|
||||
endpoints:
|
||||
liveness:
|
||||
enabled: true
|
||||
path: /health/live
|
||||
|
||||
@@ -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.14.0-preview"
|
||||
const defaultFrameworkVersion = "v0.15.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.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
@@ -27,22 +28,23 @@ type TLSConfig struct {
|
||||
|
||||
// Config controls the default runtime behavior of an App.
|
||||
type Config struct {
|
||||
Request contract.RequestPolicy `json:"request"`
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
Endpoints EndpointsConfig `json:"endpoints"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
TrustedProxies []string `json:"trustedProxies"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
Log logx.Config `json:"log"`
|
||||
Errors apierrors.Catalog `json:"errors"`
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
LogInstance *logx.Logger `json:"-"`
|
||||
Request contract.RequestPolicy `json:"request"`
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
Endpoints EndpointsConfig `json:"endpoints"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
TrustedProxies []string `json:"trustedProxies"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||
Log logx.Config `json:"log"`
|
||||
Errors apierrors.Catalog `json:"errors"`
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
LogInstance *logx.Logger `json:"-"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
@@ -51,6 +53,7 @@ func Default() Config {
|
||||
Transport: TransportNetHTTP,
|
||||
RequestIDHeader: defaultRequestIDHeader,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
Lifecycle: lifecycle.DefaultShutdownConfig(),
|
||||
Endpoints: DefaultEndpoints(),
|
||||
Log: logx.Default(),
|
||||
}
|
||||
@@ -58,6 +61,9 @@ func Default() Config {
|
||||
|
||||
// Validate checks configuration values before an application starts.
|
||||
func (c Config) Validate() error {
|
||||
if err := c.Lifecycle.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Request.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ func applyEnv(cfg Config, prefix string) (Config, error) {
|
||||
{"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout},
|
||||
{"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout},
|
||||
{"HTTP_IDLE_TIMEOUT", &cfg.HTTP.IdleTimeout},
|
||||
{"LIFECYCLE_STOP_TIMEOUT", &cfg.Lifecycle.StopTimeout},
|
||||
{"LIFECYCLE_DRAIN_TIMEOUT", &cfg.Lifecycle.DrainTimeout},
|
||||
{"LIFECYCLE_CANCEL_TIMEOUT", &cfg.Lifecycle.CancelTimeout},
|
||||
} {
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + field.name)); value != "" {
|
||||
*field.value, err = time.ParseDuration(value)
|
||||
|
||||
37
config/lifecycle_test.go
Normal file
37
config/lifecycle_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLifecycleConfiguration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: 2s\n drainTimeout: 3s\n cancelTimeout: 4s\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "PHASE_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Lifecycle != (lifecycle.ShutdownConfig{StopTimeout: 2 * time.Second, DrainTimeout: 3 * time.Second, CancelTimeout: 4 * time.Second}) {
|
||||
t.Fatal(cfg.Lifecycle)
|
||||
}
|
||||
for _, key := range []string{"STOP_TIMEOUT", "DRAIN_TIMEOUT", "CANCEL_TIMEOUT"} {
|
||||
t.Setenv("PHASE_TEST_LIFECYCLE_"+key, "6s")
|
||||
}
|
||||
cfg, err = config.ApplyEnv(cfg, "PHASE_TEST_")
|
||||
if err != nil || cfg.Lifecycle.StopTimeout != 6*time.Second || cfg.Lifecycle.DrainTimeout != 6*time.Second || cfg.Lifecycle.CancelTimeout != 6*time.Second {
|
||||
t.Fatal(cfg.Lifecycle, err)
|
||||
}
|
||||
for _, v := range []string{"bad", "-1s"} {
|
||||
t.Setenv("PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT", v)
|
||||
if _, err := config.ApplyEnv(cfg, "PHASE_TEST_"); err == nil {
|
||||
t.Fatal("invalid duration accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。
|
||||
|
||||
使用 GORM 的项目可显式接入 [gormx 可选适配器](gormx/README.md),复用本包连接池,并将 SQL 日志接入 logx;普通 database 包不导入 GORM。
|
||||
|
||||
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
|
||||
|
||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||
|
||||
104
database/gormx/README.md
Normal file
104
database/gormx/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 可选 GORM 适配器
|
||||
|
||||
从 `v0.14.1-preview` 提供。`gormx` 把 GORM 接到 go-omni 已初始化的 `*sql.DB`,保留一份连接池和一处资源所有权。使用 GORM v2 API(本版验收版本为 `gorm.io/gorm v1.31.2`、MySQL dialect `v1.6.0`)。
|
||||
|
||||
核心 `omni`、`database`、`database/mysql` 不导入此包;普通项目的编译依赖链不包含 GORM。仓库同一 Go module 的 go.mod 会记录可选适配器依赖,使用方显式导入 `database/gormx` 才编译它。本版没有新增默认 ORM 或 `--orm` 脚手架开关。
|
||||
|
||||
## 生成项目接入
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||
go-omni new --database mysql --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
按 [MySQL 配置说明](../mysql/README.md) 设置连接信息、TLS 和池大小。在开发者维护的 `internal/svc/serviceContext.go` 中增加 `ORM *gorm.DB` 字段,并填写现有 Init:
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database/gormx"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 向现有 ServiceContext 添加 ORM *gorm.DB 字段。
|
||||
// 保留 Runtime/Config/Logger/DB 等现有字段。
|
||||
func (c *ServiceContext) Init(ctx context.Context) error {
|
||||
orm, err := gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ORM = orm
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
执行 `go mod tidy`。main、runtimeGen、Handler 和路由无需修改;重新生成保留 svc 的字段与 Init。已有项目先 `go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview`,同样接入;v0.14.0 生成项目的目录和生命周期契约不变。
|
||||
|
||||
Repository 接收 `*gorm.DB`,查询使用请求 context:
|
||||
|
||||
```go
|
||||
err := repo.db.WithContext(ctx).Where("id = ?", id).First(&item).Error
|
||||
|
||||
err = repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 同一事务内始终使用 tx。
|
||||
return tx.Create(&item).Error
|
||||
})
|
||||
```
|
||||
|
||||
现有模型、Hooks、Scopes、事务、命名策略和错误处理继续由项目维护。共享连接池不会把分别发起的 SQL/GORM 事务自动合并;不要在一个事务内切回根 ORM 或 c.DB 执行本应属于该事务的写入。
|
||||
|
||||
## 连接池与失败处理
|
||||
|
||||
- MySQL 配置、TLS、字符集、会话参数、池大小及健康检查由底层 `database/mysql` 管理,适配器不再设置另一套 DSN 或池参数。
|
||||
- GORM 方言的版本协商继续执行,自动 Ping 关闭以复用已完成的池初始化。初始化查询使用传入 context,并受池的 PingTimeout 限制;可用 `WithInitTimeout(3*time.Second)` 单独指定。
|
||||
- 返回成功后不保留启动 context,每次查询用 `WithContext(ctx)`。查询取消、错误、事务提交/回滚保持 GORM 原生语义。
|
||||
- 对方言初始化失败时 GORM 自动关闭连接池的路径提供保护;错误仍保留 errors.Is/As,原池继续归调用方管理。生成项目的 Init 返回错误时,再由 Runtime 统一回滚该项目资源。
|
||||
- 服务排空后由 go-omni 关闭原池;`orm.DB()` 返回的仍是那一个池。不要给它重复登记 Close,也不要让 Repository 关闭它。
|
||||
|
||||
适配器自身不创建后台 worker、不自动建表、不执行 AutoMigrate、不改写默认事务或全表更新保护,也不替业务重试写入。
|
||||
|
||||
## SQL 日志
|
||||
|
||||
默认借用当前 context 的 logx,也可显式 `WithLogger(c.Logger)`。每个 ORM 可以使用独立日志实例;不会改动进程全局 Logger。
|
||||
|
||||
```go
|
||||
sqlLog := gormx.DefaultLogConfig()
|
||||
sqlLog.Level = "info" // silent / error / warn / info
|
||||
sqlLog.SlowThreshold = 300*time.Millisecond
|
||||
|
||||
orm, err := gormx.MySQL(ctx, c.DB,
|
||||
gormx.WithLogger(c.Logger),
|
||||
gormx.WithLogConfig(sqlLog),
|
||||
gormx.WithConfig(gorm.Config{
|
||||
TranslateError: true,
|
||||
// NamingStrategy 等原生选项可按项目添加。
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
默认记录错误和超过 200 ms 的慢查询,忽略 ErrRecordNotFound 的错误事件;`SlowThreshold: 0` 关闭慢查询事件。事件为 `gorm.query`、`gorm.slowQuery`、`gorm.queryFailed`,携带 durationMs、rowsAffected、sql 和 context 中的 requestId/traceId。GORM 的 `Debug()`/`LogMode` 使用副本,不改变其他请求的日志级别;最终输出还受 logx 自身级别控制。
|
||||
|
||||
默认不插入绑定参数、不记录驱动完整错误文案,只记录错误类别;SQL 正文最多保留 4096 字节,截断后附加省略号。明确开启 `IncludeParameters` / `IncludeErrorDetails` 才输出这些内容。日志不会解析和脱敏直接写在原始 SQL 里的字面量,查询仍应使用占位符。仅需要日志适配的既有 GORM 实例,也可使用 `NewLogger(logger, config)` 返回的原生 `logger.Interface`。
|
||||
|
||||
`WithConfig` 中若显式设置 `gorm.Config.Logger`,则使用项目自己的 Logger,该 Logger 自行负责脱敏和上下文。配置和 Plugins/ClauseBuilders 容器会复制;用户自定义插件、NamingStrategy 等对象仍由用户保证并发安全。
|
||||
|
||||
## 其他方言与高级配置
|
||||
|
||||
MySQL 便捷入口封装了下面的通用入口:
|
||||
|
||||
```go
|
||||
orm, err := gormx.Open(ctx, pool,
|
||||
func(conn gorm.ConnPool) gorm.Dialector {
|
||||
return mysql.New(mysql.Config{Conn: conn})
|
||||
},
|
||||
gormx.WithLogger(logger),
|
||||
)
|
||||
```
|
||||
|
||||
其他方言可由项目提供相同形式的工厂,必须使用传入的 Conn,不能创建或替换连接池。本版真实数据库验收覆盖 MySQL 8.4,不代表其他数据库已经联调。
|
||||
|
||||
不能通过 WithConfig 的 ConnPool/Dialector 覆盖此约定。初始化期间不暴露裸 sql.DB;需要它的自定义插件应在 Open 成功后显式注册。创建额外池或替换 ConnPool 的路由插件不属于本适配器的一池所有权范围。
|
||||
|
||||
PrepareStmt 默认关闭;若显式开启,保留 GORM 的缓存行为,容量、TTL 和缓存清理由项目按 GORM 接口管理。当前固定的 GORM 版本,PreparedStmtDB.Close 只清理语句,不提供停止其缓存清理 goroutine 的公开接口;因此应在服务初始化时创建一次 ORM,避免按请求反复创建。适配器不修改第三方缓存实现,也不宣称解决其生命周期限制。
|
||||
|
||||
参考:[GORM 现有连接池](https://gorm.io/docs/connecting_to_the_database.html#Existing-database-connection)、[GORM Logger](https://gorm.io/docs/logger.html)。本版验收见 [GORM 验收报告](../../reports/gormAcceptance.md)。
|
||||
121
database/gormx/gorm.go
Normal file
121
database/gormx/gorm.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Package gormx optionally attaches GORM to a pool owned by GoOmni. It never
|
||||
// creates a pool, changes pool limits, migrates tables or closes the owner.
|
||||
package gormx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DialectorFactory must use the supplied connection pool. It must not create a
|
||||
// pool or retain/use the ORM before Open succeeds. For example, return
|
||||
// mysql.New(mysql.Config{Conn: conn}). Other dialects can use the same contract.
|
||||
type DialectorFactory func(conn gorm.ConnPool) gorm.Dialector
|
||||
|
||||
type options struct {
|
||||
config gorm.Config
|
||||
logger *logx.Logger
|
||||
logConfig LogConfig
|
||||
timeout time.Duration
|
||||
}
|
||||
type Option func(*options)
|
||||
|
||||
// WithConfig preserves native GORM behavior, including naming, hooks, prepared
|
||||
// statements and transactions. ConnPool/Dialector are supplied by this adapter;
|
||||
// automatic Ping is disabled because the owning pool already passed startup Ping.
|
||||
// A custom Config.Logger takes precedence over the logx adapter.
|
||||
func WithConfig(config gorm.Config) Option { return func(o *options) { o.config = config } }
|
||||
func WithLogger(logger *logx.Logger) Option { return func(o *options) { o.logger = logger } }
|
||||
func WithLogConfig(config LogConfig) Option { return func(o *options) { o.logConfig = config } }
|
||||
func WithInitTimeout(timeout time.Duration) Option { return func(o *options) { o.timeout = timeout } }
|
||||
|
||||
// Error exposes a safe initialization summary while preserving errors.Is/As.
|
||||
type Error struct{ cause error }
|
||||
|
||||
func (e *Error) Error() string { return "gormx: initialization failed" }
|
||||
func (e *Error) Unwrap() error { return e.cause }
|
||||
|
||||
// MySQL attaches GORM's MySQL dialect, including version negotiation, to the
|
||||
// existing pool. Configure TLS, timeouts and connection limits on that pool.
|
||||
func MySQL(ctx context.Context, pool *database.DB, opts ...Option) (*gorm.DB, error) {
|
||||
return Open(ctx, pool, func(conn gorm.ConnPool) gorm.Dialector {
|
||||
return gormmysql.New(gormmysql.Config{Conn: conn})
|
||||
}, opts...)
|
||||
}
|
||||
|
||||
// Open borrows pool. On failure (including dialect initialization failure),
|
||||
// the original pool remains owned by the caller. No ORM Close is required.
|
||||
// The returned ORM does not retain the startup context: each request should use
|
||||
// WithContext(ctx). Never close the sql.DB returned by orm.DB() independently.
|
||||
func Open(ctx context.Context, pool *database.DB, factory DialectorFactory, opts ...Option) (*gorm.DB, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, &Error{cause: err}
|
||||
}
|
||||
if pool == nil || pool.DB == nil || factory == nil {
|
||||
return nil, errors.New("gormx: pool and dialector factory are required")
|
||||
}
|
||||
timeout := pool.PoolConfig().PingTimeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
o := options{timeout: timeout, logConfig: DefaultLogConfig(), logger: logx.FromContext(ctx)}
|
||||
for _, option := range opts {
|
||||
if option != nil {
|
||||
option(&o)
|
||||
}
|
||||
}
|
||||
if o.timeout <= 0 {
|
||||
return nil, errors.New("gormx: initialization timeout must be positive")
|
||||
}
|
||||
if o.config.ConnPool != nil || o.config.Dialector != nil {
|
||||
return nil, errors.New("gormx: configure the existing pool through the dialector factory")
|
||||
}
|
||||
logger, err := NewLogger(o.logger, o.logConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := o.config
|
||||
config.Plugins = maps.Clone(config.Plugins)
|
||||
config.ClauseBuilders = maps.Clone(config.ClauseBuilders)
|
||||
if config.Logger == nil {
|
||||
config.Logger = logger
|
||||
}
|
||||
config.DisableAutomaticPing = true
|
||||
startup, cancel := context.WithTimeout(ctx, o.timeout)
|
||||
defer cancel()
|
||||
borrowed := &borrowedPool{db: pool.DB}
|
||||
borrowed.startup.Store(&startupContext{startup})
|
||||
config.ConnPool = borrowed
|
||||
dialect := factory(borrowed)
|
||||
if dialect == nil || (reflect.ValueOf(dialect).Kind() == reflect.Pointer && reflect.ValueOf(dialect).IsNil()) {
|
||||
return nil, errors.New("gormx: dialector factory returned nil")
|
||||
}
|
||||
orm, err := gorm.Open(dialect, &config)
|
||||
if err != nil {
|
||||
return nil, &Error{cause: err}
|
||||
}
|
||||
if err = startup.Err(); err != nil {
|
||||
return nil, &Error{cause: err}
|
||||
}
|
||||
// Prepared statements are GORM's own wrapper over the very same pool.
|
||||
conn := orm.ConnPool
|
||||
if prepared, ok := conn.(*gorm.PreparedStmtDB); ok {
|
||||
conn = prepared.ConnPool
|
||||
}
|
||||
if actual, ok := conn.(*borrowedPool); !ok || actual != borrowed {
|
||||
return nil, errors.New("gormx: dialector or plugin replaced the supplied pool")
|
||||
}
|
||||
borrowed.startup.Store(nil)
|
||||
return orm, nil
|
||||
}
|
||||
173
database/gormx/gorm_test.go
Normal file
173
database/gormx/gorm_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package gormx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
type testConnector struct {
|
||||
closed atomic.Int32
|
||||
query func(context.Context) error
|
||||
}
|
||||
|
||||
func (c *testConnector) Connect(context.Context) (driver.Conn, error) {
|
||||
return &testConn{owner: c}, nil
|
||||
}
|
||||
func (c *testConnector) Driver() driver.Driver { return testDriver{} }
|
||||
func (c *testConnector) Close() error { c.closed.Add(1); return nil }
|
||||
|
||||
type testDriver struct{}
|
||||
|
||||
func (testDriver) Open(string) (driver.Conn, error) { return nil, errors.New("unexpected new pool") }
|
||||
|
||||
type testConn struct{ owner *testConnector }
|
||||
|
||||
func (c *testConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("unused") }
|
||||
func (c *testConn) Close() error { return nil }
|
||||
func (c *testConn) Begin() (driver.Tx, error) { return nil, errors.New("unused") }
|
||||
func (c *testConn) Ping(context.Context) error { return nil }
|
||||
func (c *testConn) QueryContext(ctx context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) {
|
||||
if c.owner.query != nil {
|
||||
if err := c.owner.query(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &testRows{}, nil
|
||||
}
|
||||
|
||||
type testRows struct{ done bool }
|
||||
|
||||
func (r *testRows) Columns() []string { return []string{"version"} }
|
||||
func (r *testRows) Close() error { return nil }
|
||||
func (r *testRows) Next(values []driver.Value) error {
|
||||
if r.done {
|
||||
return io.EOF
|
||||
}
|
||||
r.done = true
|
||||
values[0] = "8.4.0"
|
||||
return nil
|
||||
}
|
||||
func poolFixture(t *testing.T) (*database.DB, *testConnector) {
|
||||
t.Helper()
|
||||
connector := &testConnector{}
|
||||
pool, err := database.OpenConnector(context.Background(), connector, database.DefaultPoolConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
return pool, connector
|
||||
}
|
||||
|
||||
func TestBorrowedPoolFailureAndStartupDeadline(t *testing.T) {
|
||||
for _, mode := range []string{"driverError", "deadline", "canceled"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
pool, connector := poolFixture(t)
|
||||
cause := errors.New("private-driver-error")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
connector.query = func(ctx context.Context) error {
|
||||
if mode == "driverError" {
|
||||
return cause
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
if mode == "canceled" {
|
||||
cancel()
|
||||
cause = context.Canceled
|
||||
} else if mode == "deadline" {
|
||||
cause = context.DeadlineExceeded
|
||||
}
|
||||
start := time.Now()
|
||||
orm, err := MySQL(ctx, pool, WithInitTimeout(20*time.Millisecond))
|
||||
if orm != nil || !errors.Is(err, cause) {
|
||||
t.Fatal("lost cause", err)
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Fatal("startup deadline ignored")
|
||||
}
|
||||
if connector.closed.Load() != 0 {
|
||||
t.Fatal("borrowed pool closed after GORM failure")
|
||||
}
|
||||
if healthErr := pool.Health(context.Background()); healthErr != nil {
|
||||
t.Fatal("owner pool unusable", healthErr)
|
||||
}
|
||||
if err.Error() == cause.Error() {
|
||||
t.Fatal("unsafe initialization error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolIdentityNativeOptionsAndRequestContext(t *testing.T) {
|
||||
pool, connector := poolFixture(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cfg := gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "business_"}, ClauseBuilders: map[string]clause.ClauseBuilder{}}
|
||||
logger, _ := logx.New(logx.Config{Output: "discard"})
|
||||
orm, err := MySQL(ctx, pool, WithConfig(cfg), WithLogger(logger))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel() // The startup context must not poison future queries.
|
||||
got, err := orm.DB()
|
||||
if err != nil || got != pool.DB {
|
||||
t.Fatal("pool identity changed", err)
|
||||
}
|
||||
if len(cfg.ClauseBuilders) != 0 || cfg.ConnPool != nil || cfg.Logger != nil {
|
||||
t.Fatal("native input config mutated")
|
||||
}
|
||||
if orm.NamingStrategy.TableName("Item") != "business_items" {
|
||||
t.Fatal("native option lost")
|
||||
}
|
||||
if orm.SkipDefaultTransaction || orm.AllowGlobalUpdate {
|
||||
t.Fatal("transaction safety defaults changed")
|
||||
}
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var v string
|
||||
if err = orm.WithContext(ctx).Raw("SELECT VERSION()").Scan(&v).Error; !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = orm.WithContext(context.Background()).Raw("SELECT VERSION()").Scan(&v).Error; err != nil || v != "8.4.0" {
|
||||
t.Fatal(v, err)
|
||||
}
|
||||
if connector.closed.Load() != 0 {
|
||||
t.Fatal("pool unexpectedly closed")
|
||||
}
|
||||
if err = pool.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = orm.Raw("SELECT VERSION()").Scan(&v).Error; err == nil {
|
||||
t.Fatal("owner close did not affect GORM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedOptionsDoNotTouchPool(t *testing.T) {
|
||||
for _, option := range []Option{WithInitTimeout(0), WithLogConfig(LogConfig{Level: "bad"}), WithConfig(gorm.Config{ConnPool: &borrowedPool{}})} {
|
||||
pool, connector := poolFixture(t)
|
||||
if _, err := MySQL(context.Background(), pool, option); err == nil {
|
||||
t.Fatal("invalid option accepted")
|
||||
}
|
||||
if connector.closed.Load() != 0 {
|
||||
t.Fatal("pool closed")
|
||||
}
|
||||
}
|
||||
if _, err := MySQL(nil, nil); err == nil {
|
||||
t.Fatal("nil pool accepted")
|
||||
}
|
||||
pool, _ := poolFixture(t)
|
||||
if _, err := Open(nil, pool, func(gorm.ConnPool) gorm.Dialector { return nil }); err == nil {
|
||||
t.Fatal("nil dialect accepted")
|
||||
}
|
||||
}
|
||||
157
database/gormx/integration_test.go
Normal file
157
database/gormx/integration_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package gormx_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database/gormx"
|
||||
adapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type record struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement:false"`
|
||||
Name string
|
||||
}
|
||||
|
||||
func (record) TableName() string { return "gorm_adapter_records" }
|
||||
|
||||
func TestGORMMySQLTransactionsCancellationAndLogs(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_CONNECTOR_MYSQL_TEST") != "1" {
|
||||
t.Skip("use scripts/verifyConnector.py --gorm with isolated MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
cfg := adapter.DefaultConfig()
|
||||
host, port, err := net.SplitHostPort(os.Getenv("GO_OMNI_CONNECTOR_ADDR"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Host = host
|
||||
cfg.Port, _ = strconv.Atoi(port)
|
||||
cfg.User = os.Getenv("GO_OMNI_CONNECTOR_USER")
|
||||
cfg.Password = os.Getenv("GO_OMNI_CONNECTOR_PASSWORD")
|
||||
cfg.Database = os.Getenv("GO_OMNI_CONNECTOR_DATABASE")
|
||||
cfg.TLS.CAFile = os.Getenv("GO_OMNI_CONNECTOR_CA_FILE")
|
||||
cfg.TLS.ServerName = "localhost"
|
||||
cfg.Pool.MaxOpenConns = 2
|
||||
cfg.Pool.MaxIdleConns = 2
|
||||
pool, err := adapter.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
var logs bytes.Buffer
|
||||
logger, err := logx.New(logx.Config{Writer: &logs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
logConfig := gormx.DefaultLogConfig()
|
||||
logConfig.Level = "info"
|
||||
orm, err := gormx.MySQL(ctx, pool, gormx.WithLogger(logger), gormx.WithLogConfig(logConfig), gormx.WithConfig(gorm.Config{PrepareStmt: true, TranslateError: true}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := orm.DB()
|
||||
if err != nil || sqlDB != pool.DB {
|
||||
t.Fatal("pool not shared", err)
|
||||
}
|
||||
if sqlDB.Stats().MaxOpenConnections != 2 {
|
||||
t.Fatal("pool limits changed")
|
||||
}
|
||||
var tables int
|
||||
if err = pool.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'gorm_adapter_records'").Scan(&tables); err != nil || tables != 0 {
|
||||
t.Fatal("adapter must not migrate schema", err)
|
||||
}
|
||||
if _, err = pool.ExecContext(ctx, "CREATE TABLE gorm_adapter_records (id BIGINT PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.ExecContext(context.Background(), "DROP TABLE gorm_adapter_records")
|
||||
requestCtx := logx.WithRequestID(ctx, "gorm-request")
|
||||
orm = orm.WithContext(requestCtx)
|
||||
secret := "private-bound-name"
|
||||
if err = orm.Transaction(func(tx *gorm.DB) error { return tx.Create(&record{ID: 1, Name: secret}).Error }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wanted := errors.New("rollback sentinel")
|
||||
if err = orm.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Create(&record{ID: 2, Name: "rollback"}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
return wanted
|
||||
}); !errors.Is(err, wanted) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != "rollback panic" {
|
||||
t.Error("panic changed")
|
||||
}
|
||||
}()
|
||||
_ = orm.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Create(&record{ID: 3, Name: "panic"}).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
panic("rollback panic")
|
||||
})
|
||||
}()
|
||||
var count int64
|
||||
if err = orm.Model(&record{}).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatal("commit/rollback differs", count, err)
|
||||
}
|
||||
if err = orm.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Create(&record{ID: 4, Name: "outer"}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e := tx.Transaction(func(inner *gorm.DB) error {
|
||||
if e := inner.Create(&record{ID: 5, Name: "inner"}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
return wanted
|
||||
}); !errors.Is(e, wanted) {
|
||||
return errors.New("nested rollback failed")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = orm.Model(&record{}).Count(&count).Error; err != nil || count != 2 {
|
||||
t.Fatal(count, err)
|
||||
}
|
||||
if err = orm.Create(&record{ID: 6, Name: secret}).Error; !errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
t.Fatal("native error translation lost", err)
|
||||
}
|
||||
var missing record
|
||||
if err = orm.First(&missing, "id = ?", 999).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatal("not-found semantics changed", err)
|
||||
}
|
||||
deadline, stop := context.WithTimeout(requestCtx, 30*time.Millisecond)
|
||||
start := time.Now()
|
||||
err = orm.WithContext(deadline).Exec("SELECT SLEEP(3)").Error
|
||||
stop()
|
||||
if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > time.Second {
|
||||
t.Fatal("query cancellation lost", err)
|
||||
}
|
||||
if err = pool.Health(ctx); err != nil {
|
||||
t.Fatal("pool did not recover", err)
|
||||
}
|
||||
if strings.Contains(logs.String(), secret) || !strings.Contains(logs.String(), `"requestId":"gorm-request"`) || !strings.Contains(logs.String(), "gorm.queryFailed") {
|
||||
t.Fatal("SQL log privacy/correlation differs")
|
||||
}
|
||||
var cipher struct {
|
||||
VariableName string
|
||||
Value string
|
||||
}
|
||||
if err = orm.Raw("SHOW SESSION STATUS LIKE 'Ssl_cipher'").Scan(&cipher).Error; err != nil || cipher.Value == "" {
|
||||
t.Fatal("GORM bypassed TLS pool", err)
|
||||
}
|
||||
}
|
||||
171
database/gormx/logger.go
Normal file
171
database/gormx/logger.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package gormx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"gorm.io/gorm"
|
||||
gormlog "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `json:"level"` // silent, error, warn (default), info
|
||||
SlowThreshold time.Duration `json:"slowThreshold"` // zero disables slow-query events
|
||||
IgnoreRecordNotFound bool `json:"ignoreRecordNotFound"`
|
||||
IncludeParameters bool `json:"includeParameters"`
|
||||
IncludeErrorDetails bool `json:"includeErrorDetails"`
|
||||
MaxSQLBytes int `json:"maxSqlBytes"` // zero uses 4096; bounds emitted SQL text
|
||||
}
|
||||
|
||||
func DefaultLogConfig() LogConfig {
|
||||
return LogConfig{Level: "warn", SlowThreshold: 200 * time.Millisecond, IgnoreRecordNotFound: true, MaxSQLBytes: 4096}
|
||||
}
|
||||
func (c LogConfig) Validate() error {
|
||||
switch c.Level {
|
||||
case "", "silent", "error", "warn", "info":
|
||||
default:
|
||||
return errors.New("gormx: invalid SQL log level")
|
||||
}
|
||||
if c.SlowThreshold < 0 || c.MaxSQLBytes < 0 {
|
||||
return errors.New("gormx: SQL log limits cannot be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type sqlLogger struct {
|
||||
logger *logx.Logger
|
||||
config LogConfig
|
||||
level gormlog.LogLevel
|
||||
}
|
||||
|
||||
// NewLogger adapts logx without changing its level, globals or ownership.
|
||||
// Bound SQL arguments and raw driver error text are omitted by default. SQL
|
||||
// literals written directly into a query are not parsed or redacted.
|
||||
func NewLogger(logger *logx.Logger, config LogConfig) (gormlog.Interface, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.MaxSQLBytes == 0 {
|
||||
config.MaxSQLBytes = 4096
|
||||
}
|
||||
level := gormlog.Warn
|
||||
switch config.Level {
|
||||
case "silent":
|
||||
level = gormlog.Silent
|
||||
case "error":
|
||||
level = gormlog.Error
|
||||
case "info":
|
||||
level = gormlog.Info
|
||||
}
|
||||
return &sqlLogger{logger: logger, config: config, level: level}, nil
|
||||
}
|
||||
func (l *sqlLogger) LogMode(level gormlog.LogLevel) gormlog.Interface {
|
||||
copy := *l
|
||||
if level < gormlog.Silent || level > gormlog.Info {
|
||||
level = gormlog.Silent
|
||||
}
|
||||
copy.level = level
|
||||
return ©
|
||||
}
|
||||
func (l *sqlLogger) output(ctx context.Context) *logx.Logger {
|
||||
if l.logger != nil {
|
||||
return l.logger
|
||||
}
|
||||
return logx.FromContext(ctx)
|
||||
}
|
||||
func (l *sqlLogger) message(msg string, args []any) string {
|
||||
if !l.config.IncludeErrorDetails {
|
||||
copy := append([]any(nil), args...)
|
||||
for i, v := range copy {
|
||||
if e, ok := v.(error); ok {
|
||||
copy[i] = errorKind(e)
|
||||
}
|
||||
}
|
||||
args = copy
|
||||
}
|
||||
return clip(fmt.Sprintf(msg, args...), l.config.MaxSQLBytes)
|
||||
}
|
||||
func (l *sqlLogger) Info(ctx context.Context, msg string, args ...any) {
|
||||
if l.level >= gormlog.Info {
|
||||
l.output(ctx).InfoContext(ctx, "gorm.message", "message", l.message(msg, args))
|
||||
}
|
||||
}
|
||||
func (l *sqlLogger) Warn(ctx context.Context, msg string, args ...any) {
|
||||
if l.level >= gormlog.Warn {
|
||||
l.output(ctx).WarnContext(ctx, "gorm.message", "message", l.message(msg, args))
|
||||
}
|
||||
}
|
||||
func (l *sqlLogger) Error(ctx context.Context, msg string, args ...any) {
|
||||
if l.level >= gormlog.Error {
|
||||
l.output(ctx).ErrorContext(ctx, "gorm.message", "message", l.message(msg, args))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sqlLogger) ParamsFilter(_ context.Context, query string, args ...any) (string, []any) {
|
||||
if !l.config.IncludeParameters {
|
||||
return query, nil
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
func (l *sqlLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
if l.level == gormlog.Silent {
|
||||
return
|
||||
}
|
||||
elapsed := time.Since(begin)
|
||||
failure := err != nil && !(l.config.IgnoreRecordNotFound && errors.Is(err, gorm.ErrRecordNotFound))
|
||||
slow := l.config.SlowThreshold > 0 && elapsed > l.config.SlowThreshold
|
||||
level := gormlog.Info
|
||||
if failure && l.level >= gormlog.Error {
|
||||
level = gormlog.Error
|
||||
} else if slow && l.level >= gormlog.Warn {
|
||||
level = gormlog.Warn
|
||||
} else if l.level < gormlog.Info {
|
||||
return
|
||||
}
|
||||
query, rows := fc()
|
||||
attrs := []any{"durationMs", float64(elapsed) / float64(time.Millisecond), "rowsAffected", rows, "sql", clip(query, l.config.MaxSQLBytes)}
|
||||
if level == gormlog.Error {
|
||||
attrs = append(attrs, "errorKind", errorKind(err))
|
||||
if l.config.IncludeErrorDetails {
|
||||
attrs = append(attrs, "error", clip(err.Error(), l.config.MaxSQLBytes))
|
||||
}
|
||||
}
|
||||
logger := l.output(ctx)
|
||||
switch level {
|
||||
case gormlog.Error:
|
||||
logger.ErrorContext(ctx, "gorm.queryFailed", attrs...)
|
||||
case gormlog.Warn:
|
||||
logger.WarnContext(ctx, "gorm.slowQuery", attrs...)
|
||||
default:
|
||||
logger.InfoContext(ctx, "gorm.query", attrs...)
|
||||
}
|
||||
}
|
||||
func errorKind(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "canceled"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "deadlineExceeded"
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return "recordNotFound"
|
||||
default:
|
||||
return fmt.Sprintf("%T", err)
|
||||
}
|
||||
}
|
||||
func clip(value string, limit int) string {
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
value = value[:limit]
|
||||
for !utf8.ValidString(value) && len(value) > 0 {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
return value + "…"
|
||||
}
|
||||
|
||||
var _ gormlog.Interface = (*sqlLogger)(nil)
|
||||
var _ gorm.ParamsFilter = (*sqlLogger)(nil)
|
||||
92
database/gormx/logger_test.go
Normal file
92
database/gormx/logger_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package gormx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"gorm.io/gorm"
|
||||
gormlog "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestSQLLoggerLevelsPrivacyAndCorrelation(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
base, _ := logx.New(logx.Config{Writer: &out})
|
||||
l, _ := NewLogger(base, DefaultLogConfig())
|
||||
ctx := logx.WithRequestID(logx.WithTraceID(context.Background(), "trace-1"), "request-1")
|
||||
count := 0
|
||||
fc := func() (string, int64) { count++; return "SELECT * FROM records WHERE token = ?", 1 }
|
||||
l.Trace(ctx, time.Now(), fc, nil)
|
||||
l.Trace(ctx, time.Now(), fc, fmt.Errorf("wrapped: %w", gorm.ErrRecordNotFound))
|
||||
l.LogMode(gormlog.Silent).Trace(ctx, time.Now(), fc, errors.New("password-value"))
|
||||
if count != 0 {
|
||||
t.Fatal("ignored event evaluated SQL")
|
||||
}
|
||||
l.Trace(ctx, time.Now().Add(-time.Second), fc, nil)
|
||||
l.Trace(ctx, time.Now(), fc, errors.New("password-value"))
|
||||
l.Error(ctx, "failed: %v", errors.New("password-value"))
|
||||
l.LogMode(gormlog.Info).Trace(ctx, time.Now(), fc, nil)
|
||||
if count != 3 {
|
||||
t.Fatal(count)
|
||||
}
|
||||
if strings.Contains(out.String(), "password-value") {
|
||||
t.Fatal("driver detail leaked", out.String())
|
||||
}
|
||||
for _, text := range []string{"gorm.slowQuery", "gorm.queryFailed", "gorm.query", `"requestId":"request-1"`, `"traceId":"trace-1"`} {
|
||||
if !strings.Contains(out.String(), text) {
|
||||
t.Fatal(text, out.String())
|
||||
}
|
||||
}
|
||||
q, args := l.(gorm.ParamsFilter).ParamsFilter(ctx, "SELECT ?", "private")
|
||||
if q != "SELECT ?" || len(args) != 0 {
|
||||
t.Fatal("default SQL arguments exposed")
|
||||
}
|
||||
// LogMode clones rather than mutating the shared instance.
|
||||
l.Trace(ctx, time.Now(), fc, nil)
|
||||
if count != 3 {
|
||||
t.Fatal("session mode changed root logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLLoggerExplicitDetailsAndConcurrentModes(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
base, _ := logx.New(logx.Config{Writer: &out})
|
||||
cfg := DefaultLogConfig()
|
||||
cfg.IncludeParameters = true
|
||||
cfg.IncludeErrorDetails = true
|
||||
cfg.Level = "info"
|
||||
cfg.MaxSQLBytes = 8
|
||||
l, err := NewLogger(base, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, args := l.(gorm.ParamsFilter).ParamsFilter(context.Background(), "SELECT ?", "value")
|
||||
if len(args) != 1 {
|
||||
t.Fatal("explicit arguments dropped")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
l.LogMode(gormlog.Error).Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT 中文标识符", -1 }, errors.New("failure"))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
|
||||
var event map[string]any
|
||||
if err = json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event["error"] != "failure" || strings.Contains(event["sql"].(string), "<22>") {
|
||||
t.Fatal(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
database/gormx/pool.go
Normal file
52
database/gormx/pool.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package gormx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync/atomic"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GORM can call DB().Close on failed initialization. Do not expose the owner's
|
||||
// sql.DB until initialization has succeeded; SQL methods remain fully usable.
|
||||
// Dialects using context.Background during version probing are bounded by the
|
||||
// startup context, which stops affecting queries as soon as Open succeeds.
|
||||
type borrowedPool struct {
|
||||
db *sql.DB
|
||||
startup atomic.Pointer[startupContext]
|
||||
}
|
||||
|
||||
type startupContext struct{ context.Context }
|
||||
|
||||
func (p *borrowedPool) operationContext(ctx context.Context) context.Context {
|
||||
if startup := p.startup.Load(); startup != nil {
|
||||
return startup.Context
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
func (p *borrowedPool) GetDBConn() (*sql.DB, error) {
|
||||
if p.startup.Load() != nil {
|
||||
return nil, gorm.ErrInvalidDB
|
||||
}
|
||||
return p.db, nil
|
||||
}
|
||||
func (p *borrowedPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
|
||||
return p.db.PrepareContext(p.operationContext(ctx), query)
|
||||
}
|
||||
func (p *borrowedPool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
return p.db.ExecContext(p.operationContext(ctx), query, args...)
|
||||
}
|
||||
func (p *borrowedPool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||
return p.db.QueryContext(p.operationContext(ctx), query, args...)
|
||||
}
|
||||
func (p *borrowedPool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
|
||||
return p.db.QueryRowContext(p.operationContext(ctx), query, args...)
|
||||
}
|
||||
func (p *borrowedPool) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
|
||||
return p.db.BeginTx(p.operationContext(ctx), opts)
|
||||
}
|
||||
|
||||
var _ gorm.ConnPool = (*borrowedPool)(nil)
|
||||
var _ gorm.TxBeginner = (*borrowedPool)(nil)
|
||||
var _ gorm.GetDBConnector = (*borrowedPool)(nil)
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。
|
||||
|
||||
项目使用 GORM 时,在 svc.Init 中调用 [gormx.MySQL](../gormx/README.md) 即可复用这个池、TLS 和清理流程。
|
||||
|
||||
生成项目选择 `--database mysql` 即接入:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"templateVersion": "v0.14.0-preview",
|
||||
"templateVersion": "v0.15.0-preview",
|
||||
"profile": {
|
||||
"database": "none",
|
||||
"storage": "none",
|
||||
@@ -17,10 +17,10 @@
|
||||
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
||||
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
|
||||
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
|
||||
"internal/svc/runtimeGen.go": "78ffd0b2a196fbc29e29473919d1341e8a2f508f6594ad148c0b399fbcc40018",
|
||||
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
|
||||
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
||||
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
||||
"main.go": "8a4051c0d51fc14ddf22c36d09de706f02499817accc937f0785e3d0fb5e2aa5"
|
||||
"main.go": "64e26d97de7e7c6c17f47186785edc73d43c23d6c20b50bfc7fff6ef2e914f17"
|
||||
},
|
||||
"contract": {
|
||||
"routes": {
|
||||
|
||||
@@ -2,7 +2,7 @@ module example.com/managed
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.14.0-preview
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.15.0-preview
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
|
||||
@@ -37,7 +37,11 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Runtime: lifecycle.NewRuntime(ctx)}
|
||||
runtime, err := lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx, logger), cfg.Server.Lifecycle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &ServiceContext{Config: cfg, Logger: logger, Health: checker, Middlewares: middleware.Defaults(), Runtime: runtime}
|
||||
ctx = logx.WithLogger(c.Context(), logger)
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"example.com/managed/internal/handler"
|
||||
"example.com/managed/internal/svc"
|
||||
"flag"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"log"
|
||||
"os"
|
||||
@@ -33,7 +34,13 @@ func run(ctx context.Context, path string) (runErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { runErr = errors.Join(runErr, logger.Close()) }()
|
||||
// Unjoined background work may still log. The owning process exits on this
|
||||
// error; do not close its borrowed logger before that work has finished.
|
||||
defer func() {
|
||||
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
|
||||
runErr = errors.Join(runErr, logger.Close())
|
||||
}
|
||||
}()
|
||||
deps, err := svc.New(ctx, logger, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
4
go.mod
4
go.mod
@@ -19,6 +19,8 @@ require (
|
||||
golang.org/x/net v0.33.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,6 +38,8 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@@ -67,6 +67,10 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -76,6 +80,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA=
|
||||
@@ -134,3 +140,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# lifecycle
|
||||
|
||||
## 后台组件分阶段关闭
|
||||
|
||||
从 `v0.15.0-preview` 起,生成项目使用以下顺序:HTTP 停止接收并排空 → 全部 OnStop → 全部 OnDrain → 逆序 OnClose → 关闭日志。初始化、创建 App 或监听失败也经过后台收尾。Runtime 不导入 Orbit、数据库、ORM 或 Redis。
|
||||
|
||||
在项目维护的 svc.Init 中登记即可,main/runtimeGen 由生成器更新。例如使用可选的内存 task.Runner:
|
||||
|
||||
```go
|
||||
jobs, err := task.New(task.Config{Workers: 4, QueueSize: 128})
|
||||
if err != nil { return err }
|
||||
c.OnStop("jobs", jobs.StopAccepting)
|
||||
c.OnDrain("jobs", jobs.Wait)
|
||||
// 把 jobs 保存到项目的 svc 字段,供 Logic 使用。
|
||||
// 已接受任务使用 c.WorkContext(),保留到 HTTP 和后台正常排空结束。
|
||||
```
|
||||
|
||||
数据库、Redis 或第三方客户端仍用已有的 `c.OnClose(client.Close)`;框架创建的 DB 已登记,不要再次登记。这里的 jobs 不再额外登记 Close:StopAccepting 停止定时调度/提交,Wait 等待 worker 与回调全部退出,已完成其生命周期。
|
||||
|
||||
任何自定义组件都可以登记 `func(context.Context) error`,不必实现或依赖框架任务接口:
|
||||
|
||||
- OnStop 只停止接收新工作,不能关闭正在执行的工作所需的资源。每个 OnStop 必须有同名 OnDrain;缺失时拒绝释放共享资源。
|
||||
- OnDrain 返回 nil 表示已经收尾,包含最终写入、确认和监控协程退出。返回错误不作为已完成证明;运行中的业务错误可以先通过 RequestShutdown(err) 上报,再等完成后由 Drain 返回 nil。
|
||||
- 同一阶段的回调并发执行,不按登记顺序执行;框架等待整个阶段结束后才进入下一阶段。有组件之间的先后依赖时,组合为一个 Stop/Drain 登记项,由该项明确组织内部顺序。Stop 不能等待 Drain 才会完成的工作。
|
||||
- OnClose 保留原来的同步逆序执行;普通项目无后台组件时无需登记新钩子。生命周期钩子不能再次调用 Runtime.Close/Finish,否则会等待自身;可以调用 RequestShutdown。
|
||||
- 所有依赖初始化成功后立即登记,再启动使用它们的工作。构造器交付前失败的内部资源仍由构造器释放。
|
||||
|
||||
### 两种 context
|
||||
|
||||
`c.Context()` / `c.Lifecycle.Context()` 是早期退出通知,会随外部信号或后台故障取消。`c.WorkContext()` 保留初始化 context 的值但不继承其取消/截止时间,允许已接受工作在 HTTP 收尾期间继续使用依赖。
|
||||
|
||||
停止接收工作由 OnStop 负责,不能仅等待 WorkContext 取消。正常情况下 WorkContext 在后台排空完成后取消;阶段失败或超时会请求取消工作。框架不假设取消已经意味着 goroutine 结束,仍以 Drain 的完成为准。每个业务任务的执行时限继续由项目/任务组件设置。
|
||||
|
||||
### 时限与失败
|
||||
|
||||
```yaml
|
||||
server:
|
||||
shutdownTimeout: 10s # HTTP 排空时限,保持原语义
|
||||
lifecycle:
|
||||
stopTimeout: 5s
|
||||
drainTimeout: 10s
|
||||
cancelTimeout: 5s
|
||||
```
|
||||
|
||||
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。可用 `GO_OMNI_LIFECYCLE_STOP_TIMEOUT`、`GO_OMNI_LIFECYCLE_DRAIN_TIMEOUT`、`GO_OMNI_LIFECYCLE_CANCEL_TIMEOUT` 覆盖;生成配置还支持完整的 `GO_OMNI_SERVER_LIFECYCLE_*` 路径,别名优先。配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
|
||||
|
||||
阶段超时会取消阶段 context 和 WorkContext,再给尚未返回的钩子一个 cancelTimeout 收尾窗口。若所有钩子都返回 nil,可以释放依赖,但仍返回超时错误,进程非零退出。task.Wait 收到取消会取消其任务并返回错误;这不证明全部任务已退出,因此仍按以下保守规则处理。
|
||||
|
||||
Stop 超时且仍未返回的组件,不会再并发调用它同名的 Drain;其他组件仍可继续收尾。此时共享资源始终保留,避免未退出的 Stop 和 Drain 同时操作同一个组件。
|
||||
|
||||
钩子返回错误、panic、缺少匹配 Drain,或窗口结束后仍有钩子未返回时,Runtime 返回可由 `errors.Is(err, lifecycle.ErrResourcesRetained)` 判断的错误,**跳过全部 OnClose**;生成入口也保留借用的日志实例并非零退出,由进程退出释放剩余 OS 资源。不会悄悄重试 Close,也不会让晚到的后台结果触发自动关闭。错误链保留初始化/监听/后台故障及具体 HookError 的阶段、名称和原始原因。
|
||||
|
||||
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;本版阶段时限不会强制中断资源 Close。HTTP 超时沿用现有传输层规则,业务处理器仍须响应请求取消。
|
||||
|
||||
手写项目使用 `lifecycle.NewRuntimeWithConfig(ctx, cfg)`;完成 HTTP 排空后调用 `runtime.Finish(runErr)`。库本身不执行 os.Exit。
|
||||
|
||||
### 升级
|
||||
|
||||
v0.14.x 受管理项目将 module 和 CLI 一起升级到 v0.15.0-preview,再运行 `go-omni api generate`、`go-omni api generate --check` 和业务测试。生成器更新 main/runtimeGen,保留 svc、Logic 和配置。已有配置不添加新字段也会使用默认时限,需要调整时再编辑 yaml。
|
||||
|
||||
要获得后台分阶段保证,项目需把原先 `OnClose(worker.Close)` 的后台组件改成对应 Stop/Drain,并使用适合的工作 context;框架不会猜测旧 Close 回调负责数据库还是 worker。仅在 OnClose 中登记的旧任务继续遵循旧的逆序清理规则。
|
||||
|
||||
## 退出通知
|
||||
|
||||
Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。
|
||||
|
||||
手写服务也可组合现有 App:
|
||||
|
||||
184
lifecycle/phases.go
Normal file
184
lifecycle/phases.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ErrResourcesRetained means background quiescence could not be established.
|
||||
// OnClose callbacks were deliberately skipped. Close is not retried; an owning
|
||||
// process should exit unsuccessfully instead of continuing with a stopped Runtime.
|
||||
var ErrResourcesRetained = errors.New("lifecycle: background shutdown incomplete; shared resources retained")
|
||||
|
||||
type ShutdownConfig struct {
|
||||
StopTimeout time.Duration `json:"stopTimeout"`
|
||||
DrainTimeout time.Duration `json:"drainTimeout"`
|
||||
CancelTimeout time.Duration `json:"cancelTimeout"`
|
||||
}
|
||||
|
||||
func DefaultShutdownConfig() ShutdownConfig {
|
||||
return ShutdownConfig{StopTimeout: 5 * time.Second, DrainTimeout: 10 * time.Second, CancelTimeout: 5 * time.Second}
|
||||
}
|
||||
func (c ShutdownConfig) Validate() error {
|
||||
if c.StopTimeout < 0 || c.DrainTimeout < 0 || c.CancelTimeout < 0 {
|
||||
return errors.New("lifecycle: shutdown timeouts must be nonnegative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c ShutdownConfig) defaults() ShutdownConfig {
|
||||
d := DefaultShutdownConfig()
|
||||
if c.StopTimeout == 0 {
|
||||
c.StopTimeout = d.StopTimeout
|
||||
}
|
||||
if c.DrainTimeout == 0 {
|
||||
c.DrainTimeout = d.DrainTimeout
|
||||
}
|
||||
if c.CancelTimeout == 0 {
|
||||
c.CancelTimeout = d.CancelTimeout
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// HookError identifies the failed phase/component and preserves the original error.
|
||||
type HookError struct {
|
||||
Phase, Name string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *HookError) Error() string { return fmt.Sprintf("lifecycle %s %q: %v", e.Phase, e.Name, e.Err) }
|
||||
func (e *HookError) Unwrap() error { return e.Err }
|
||||
|
||||
// phaseError keeps diagnostics separate from proof that every hook succeeded.
|
||||
// A deadline is still reported when all callbacks finish during cancellation grace.
|
||||
type phaseError struct {
|
||||
err error
|
||||
unsafe bool
|
||||
unjoined map[string]bool
|
||||
}
|
||||
|
||||
func (e *phaseError) Error() string { return e.err.Error() }
|
||||
func (e *phaseError) Unwrap() error { return e.err }
|
||||
func unsafePhase(err error) bool { var p *phaseError; return errors.As(err, &p) && p.unsafe }
|
||||
|
||||
type shutdownHook struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}
|
||||
|
||||
// OnStop registers a prompt, idempotent stop-admission callback. It must not
|
||||
// close resources used by accepted work. All stop hooks precede all drain hooks.
|
||||
// Hooks within a phase run concurrently: combine dependent components in one hook.
|
||||
// Every OnStop requires a matching OnDrain with the same name.
|
||||
func (r *Runtime) OnStop(name string, fn func(context.Context) error) { r.addHook(&r.stops, name, fn) }
|
||||
|
||||
// OnDrain registers proof of quiescence. Return nil only after the component can
|
||||
// no longer use shared resources, including final acknowledgements/cleanup.
|
||||
// Deadline cancellation asks the component to stop; returning ctx.Err alone does
|
||||
// not prove completion. A callback may finish during the cancellation grace.
|
||||
func (r *Runtime) OnDrain(name string, fn func(context.Context) error) {
|
||||
r.addHook(&r.drains, name, fn)
|
||||
}
|
||||
|
||||
func (r *Runtime) addHook(list *[]shutdownHook, name string, fn func(context.Context) error) {
|
||||
if fn == nil || name == "" || len(name) > 128 || strings.TrimSpace(name) != name || strings.IndexFunc(name, unicode.IsControl) >= 0 {
|
||||
panic("lifecycle: valid hook name and function required")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
panic("lifecycle: register hook after Close")
|
||||
}
|
||||
for _, h := range *list {
|
||||
if h.name == name {
|
||||
panic("lifecycle: duplicate phase hook name")
|
||||
}
|
||||
}
|
||||
*list = append(*list, shutdownHook{name, fn})
|
||||
}
|
||||
|
||||
func callHook(ctx context.Context, h shutdownHook) (err error) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
if cause, ok := p.(error); ok {
|
||||
err = fmt.Errorf("hook panic: %w", cause)
|
||||
} else {
|
||||
err = fmt.Errorf("hook panic: %v", p)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return h.fn(ctx)
|
||||
}
|
||||
|
||||
func (r *Runtime) runPhase(phase string, hooks []shutdownHook, timeout time.Duration) error {
|
||||
if len(hooks) == 0 {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.work), timeout)
|
||||
defer cancel()
|
||||
type result struct {
|
||||
index int
|
||||
err error
|
||||
}
|
||||
results := make(chan result, len(hooks))
|
||||
pending := make([]bool, len(hooks))
|
||||
for i, h := range hooks {
|
||||
pending[i] = true
|
||||
go func(i int, h shutdownHook) { results <- result{i, callHook(ctx, h)} }(i, h)
|
||||
}
|
||||
remaining := len(hooks)
|
||||
var errs error
|
||||
unsafe := false
|
||||
accept := func(v result) {
|
||||
pending[v.index] = false
|
||||
remaining--
|
||||
if v.err != nil {
|
||||
unsafe = true
|
||||
errs = errors.Join(errs, &HookError{phase, hooks[v.index].name, v.err})
|
||||
r.cancelWork()
|
||||
}
|
||||
}
|
||||
for remaining > 0 {
|
||||
select {
|
||||
case v := <-results:
|
||||
accept(v)
|
||||
case <-ctx.Done():
|
||||
r.cancelWork()
|
||||
errs = errors.Join(errs, &HookError{phase, "deadline", ctx.Err()})
|
||||
grace := time.NewTimer(r.shutdown.CancelTimeout)
|
||||
defer grace.Stop()
|
||||
for remaining > 0 {
|
||||
select {
|
||||
case v := <-results:
|
||||
accept(v)
|
||||
case <-grace.C:
|
||||
// Collect callbacks already returned at the deadline before marking pending ones.
|
||||
for {
|
||||
select {
|
||||
case v := <-results:
|
||||
accept(v)
|
||||
default:
|
||||
goto collected
|
||||
}
|
||||
}
|
||||
collected:
|
||||
unjoined := make(map[string]bool)
|
||||
for i, active := range pending {
|
||||
if active {
|
||||
unjoined[hooks[i].name] = true
|
||||
errs = errors.Join(errs, &HookError{phase, hooks[i].name, context.DeadlineExceeded})
|
||||
}
|
||||
}
|
||||
return &phaseError{err: errs, unsafe: unsafe || remaining > 0, unjoined: unjoined}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if errs != nil {
|
||||
return &phaseError{err: errs, unsafe: unsafe}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
234
lifecycle/phases_test.go
Normal file
234
lifecycle/phases_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testRuntime(t *testing.T) *Runtime {
|
||||
t.Helper()
|
||||
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{StopTimeout: time.Second, DrainTimeout: time.Second, CancelTimeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestPhasesBarrierAndWorkLifetime(t *testing.T) {
|
||||
parent, cancel := context.WithCancel(context.WithValue(context.Background(), struct{}{}, "value"))
|
||||
defer cancel()
|
||||
r := NewRuntime(parent)
|
||||
var stops, drains atomic.Int32
|
||||
var mu sync.Mutex
|
||||
var closed []int
|
||||
started := make(chan struct{}, 2)
|
||||
release := make(chan struct{})
|
||||
// Deliberately interleave registration. Phases take precedence over order.
|
||||
r.OnClose(func() error { mu.Lock(); defer mu.Unlock(); closed = append(closed, 1); return nil })
|
||||
for _, name := range []string{"producer", "consumer"} {
|
||||
r.OnStop(name, func(ctx context.Context) error {
|
||||
if r.WorkContext().Err() != nil || ctx.Err() != nil {
|
||||
return errors.New("work canceled before drain")
|
||||
}
|
||||
stops.Add(1)
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
r.OnDrain(name, func(ctx context.Context) error {
|
||||
if stops.Load() != 2 || r.WorkContext().Err() != nil || ctx.Err() != nil {
|
||||
return errors.New("stage barrier failed")
|
||||
}
|
||||
drains.Add(1)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
r.OnClose(func() error {
|
||||
if drains.Load() != 2 {
|
||||
return errors.New("dependency closed early")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
closed = append(closed, 2)
|
||||
return nil
|
||||
})
|
||||
cancel()
|
||||
if r.WorkContext().Err() != nil || r.WorkContext().Value(struct{}{}) != "value" {
|
||||
t.Fatal("work context inherited cancellation or lost values")
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- r.Finish(nil) }()
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stop callbacks did not run concurrently")
|
||||
}
|
||||
}
|
||||
if drains.Load() != 0 {
|
||||
t.Fatal("drain ran before stop barrier")
|
||||
}
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(closed, []int{2, 1}) || r.WorkContext().Err() == nil {
|
||||
t.Fatal(closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhasesFailuresRetainDependenciesAndCauses(t *testing.T) {
|
||||
for _, phase := range []string{"stop", "drain"} {
|
||||
for _, panics := range []bool{false, true} {
|
||||
t.Run(phase+map[bool]string{false: "Error", true: "Panic"}[panics], func(t *testing.T) {
|
||||
r := testRuntime(t)
|
||||
cause := errors.New("component cause")
|
||||
fatal := errors.New("fatal monitor")
|
||||
cleanup := atomic.Int32{}
|
||||
other := atomic.Int32{}
|
||||
r.OnClose(func() error { cleanup.Add(1); return nil })
|
||||
hook := func(context.Context) error {
|
||||
r.RequestShutdown(fatal)
|
||||
if panics {
|
||||
panic(cause)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if phase == "stop" {
|
||||
r.OnStop("broken", hook)
|
||||
} else {
|
||||
r.OnDrain("broken", hook)
|
||||
}
|
||||
r.OnDrain("other", func(context.Context) error { other.Add(1); return nil })
|
||||
err := r.Finish(nil)
|
||||
var detail *HookError
|
||||
if !errors.Is(err, ErrResourcesRetained) || !errors.Is(err, cause) || !errors.Is(err, fatal) || !errors.As(err, &detail) || detail.Phase != phase {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleanup.Load() != 0 || other.Load() != 1 || r.WorkContext().Err() == nil {
|
||||
t.Fatal("unsafe cleanup or omitted drain")
|
||||
}
|
||||
if !errors.Is(r.Close(), ErrResourcesRetained) || other.Load() != 1 {
|
||||
t.Fatal("close retried hooks")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhasesDeadlineCancelsWorkAndJoinsBeforeClose(t *testing.T) {
|
||||
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{DrainTimeout: 20 * time.Millisecond, CancelTimeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var joined atomic.Bool
|
||||
var closed atomic.Bool
|
||||
r.OnDrain("worker", func(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
<-r.WorkContext().Done()
|
||||
// Cleanup can still use shared resources after forced cancellation.
|
||||
if closed.Load() {
|
||||
return errors.New("dependency closed during cancellation")
|
||||
}
|
||||
joined.Store(true)
|
||||
return nil
|
||||
})
|
||||
r.OnClose(func() error {
|
||||
if !joined.Load() {
|
||||
t.Error("worker not joined")
|
||||
}
|
||||
closed.Store(true)
|
||||
return nil
|
||||
})
|
||||
err = r.Close()
|
||||
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrResourcesRetained) || !closed.Load() {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhasesUncooperativeHookIsBoundedAndNeverClosesDependencies(t *testing.T) {
|
||||
for _, phase := range []string{"stop", "drain"} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
r, err := NewRuntimeWithConfig(context.Background(), ShutdownConfig{StopTimeout: 20 * time.Millisecond, DrainTimeout: 20 * time.Millisecond, CancelTimeout: 20 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
var closed atomic.Int32
|
||||
hook := func(context.Context) error { defer close(finished); <-release; return nil }
|
||||
if phase == "stop" {
|
||||
r.OnStop("stuck", hook)
|
||||
r.OnDrain("stuck", func(context.Context) error { t.Error("Drain overlapped unfinished Stop"); return nil })
|
||||
} else {
|
||||
r.OnDrain("stuck", hook)
|
||||
}
|
||||
r.OnClose(func() error { closed.Add(1); return nil })
|
||||
began := time.Now()
|
||||
err = r.Finish(errors.New("listen failed"))
|
||||
close(release)
|
||||
<-finished
|
||||
if time.Since(began) > time.Second || !errors.Is(err, ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) || closed.Load() != 0 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = r.Close()
|
||||
if closed.Load() != 0 {
|
||||
t.Fatal("late cleanup raced work")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhasesConcurrentFinishAndValidation(t *testing.T) {
|
||||
if _, err := NewRuntimeWithConfig(nil, ShutdownConfig{DrainTimeout: -1}); err == nil {
|
||||
t.Fatal("negative timeout accepted")
|
||||
}
|
||||
r := testRuntime(t)
|
||||
var called atomic.Int32
|
||||
r.OnStop("worker", func(context.Context) error { called.Add(1); return nil })
|
||||
r.OnDrain("worker", func(context.Context) error { called.Add(1); return nil })
|
||||
r.OnClose(func() error { called.Add(1); return nil })
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := r.Finish(nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if called.Load() != 3 {
|
||||
t.Fatal(called.Load())
|
||||
}
|
||||
for _, fn := range []func(){func() { r.OnStop("late", func(context.Context) error { return nil }) }, func() { NewRuntime(nil).OnDrain("bad\nname", func(context.Context) error { return nil }) }, func() {
|
||||
x := NewRuntime(nil)
|
||||
x.OnStop("duplicate", func(context.Context) error { return nil })
|
||||
x.OnStop("duplicate", func(context.Context) error { return nil })
|
||||
}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("invalid registration accepted")
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingDrainRetainsResources(t *testing.T) {
|
||||
r := testRuntime(t)
|
||||
r.OnStop("worker", func(context.Context) error { return nil })
|
||||
closed := false
|
||||
r.OnClose(func() error { closed = true; return nil })
|
||||
if err := r.Close(); !errors.Is(err, ErrResourcesRetained) || closed {
|
||||
t.Fatal("missing completion proof accepted", err)
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,42 @@ import (
|
||||
// must finish draining before calling Close. Embed *Runtime in the project's svc.
|
||||
// The logger and any other borrowed resources must not be registered here.
|
||||
type Runtime struct {
|
||||
Lifecycle *Controller
|
||||
mu sync.Mutex
|
||||
cleanups []func() error
|
||||
closed bool
|
||||
once sync.Once
|
||||
closeErr error
|
||||
Lifecycle *Controller
|
||||
mu sync.Mutex
|
||||
cleanups []func() error
|
||||
closed bool
|
||||
once sync.Once
|
||||
closeErr error
|
||||
stops, drains []shutdownHook
|
||||
shutdown ShutdownConfig
|
||||
work context.Context
|
||||
cancelWork context.CancelFunc
|
||||
}
|
||||
|
||||
func NewRuntime(ctx context.Context) *Runtime { return &Runtime{Lifecycle: New(ctx)} }
|
||||
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
|
||||
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
|
||||
func NewRuntime(ctx context.Context) *Runtime {
|
||||
r, _ := NewRuntimeWithConfig(ctx, DefaultShutdownConfig())
|
||||
return r
|
||||
}
|
||||
|
||||
// NewRuntimeWithConfig separates the stop notification from the lifetime of
|
||||
// accepted background work. Zero timeout fields use documented defaults.
|
||||
func NewRuntimeWithConfig(ctx context.Context, cfg ShutdownConfig) (*Runtime, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
work, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||
return &Runtime{Lifecycle: New(ctx), shutdown: cfg.defaults(), work: work, cancelWork: cancel}, nil
|
||||
}
|
||||
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
|
||||
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
|
||||
|
||||
// WorkContext stays alive during HTTP draining and graceful background draining.
|
||||
// It is canceled on a shutdown failure/deadline, or after successful draining.
|
||||
// Context() remains the early stop notification; do not use it for accepted work.
|
||||
func (r *Runtime) WorkContext() context.Context { return r.work }
|
||||
|
||||
// OnClose transfers cleanup ownership. Register immediately after creation,
|
||||
// before exposing resources to other goroutines. Registration after Close is a
|
||||
@@ -38,8 +63,10 @@ func (r *Runtime) OnClose(cleanup func() error) {
|
||||
r.cleanups = append(r.cleanups, cleanup)
|
||||
}
|
||||
|
||||
// Close releases all resources in reverse order, including after a failed
|
||||
// initialization. All callbacks are attempted even if one fails or panics.
|
||||
// Close runs stop and drain barriers before releasing resources in reverse
|
||||
// order, including after failed initialization. If quiescence is unproven it
|
||||
// skips resource callbacks and returns ErrResourcesRetained. Once release is
|
||||
// safe, all OnClose callbacks are attempted even if one fails or panics.
|
||||
func (r *Runtime) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
@@ -48,9 +75,43 @@ func (r *Runtime) Close() error {
|
||||
r.RequestShutdown(nil)
|
||||
r.mu.Lock()
|
||||
r.closed = true
|
||||
cleanups := r.cleanups
|
||||
cleanups, stops, drains := r.cleanups, r.stops, r.drains
|
||||
r.cleanups = nil
|
||||
r.stops, r.drains = nil, nil
|
||||
r.mu.Unlock()
|
||||
stopErr := r.runPhase("stop", stops, r.shutdown.StopTimeout)
|
||||
var stopped *phaseError
|
||||
errors.As(stopErr, &stopped)
|
||||
for _, stop := range stops {
|
||||
found := false
|
||||
for _, drain := range drains {
|
||||
if drain.name == stop.name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
stopErr = &phaseError{err: errors.Join(stopErr, &HookError{"drain", stop.name, errors.New("matching OnDrain required")}), unsafe: true}
|
||||
}
|
||||
}
|
||||
if stopErr != nil {
|
||||
r.cancelWork()
|
||||
}
|
||||
// A timed-out Stop can still be executing. Never overlap its own Drain;
|
||||
// other components can still establish their completion independently.
|
||||
eligible := make([]shutdownHook, 0, len(drains))
|
||||
for _, drain := range drains {
|
||||
if stopped == nil || !stopped.unjoined[drain.name] {
|
||||
eligible = append(eligible, drain)
|
||||
}
|
||||
}
|
||||
drainErr := r.runPhase("drain", eligible, r.shutdown.DrainTimeout)
|
||||
r.cancelWork()
|
||||
r.closeErr = errors.Join(stopErr, drainErr)
|
||||
if unsafePhase(stopErr) || unsafePhase(drainErr) {
|
||||
r.closeErr = errors.Join(r.closeErr, ErrResourcesRetained)
|
||||
return
|
||||
}
|
||||
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
|
||||
}
|
||||
|
||||
@@ -44,7 +44,13 @@ python3 scripts/verifyConnector.py --local --report reports/businessDevelopmentC
|
||||
|
||||
## 远程发布验收
|
||||
|
||||
代码发布标签后,使用以下命令在空 module 缓存验收同版远程 CLI/框架,不使用本地 replace;结果另行归档,发布标签不移动:
|
||||
远程验收已通过。代码标签固定在 `f5478cdc7662704b164637649305361e52f7c598`,报告另行提交,标签没有移动。两个脚本分别使用空 module 缓存,同版远程 CLI/框架均无本地 replace:
|
||||
|
||||
- [生成服务远程报告](businessDevelopmentManagedRemote.json):30.00 秒,覆盖实际生成、运行/构建、错误目录和模板参数、根数组/null、严格请求、声明式上传、依赖中间件、退出与清理。
|
||||
- [数据库远程报告](businessDevelopmentConnectorRemote.json):22.99 秒,真实 MySQL TLS/mTLS、Connector/池隔离、HTTP 限额与生成 MySQL 服务的配置和生命周期。
|
||||
- 两者 module 校验值一致:`h1:qG0Ipjt3enc8/Enu8ihJ6h8c8U6FCDedoNSdzuC3rBc=`。临时容器、凭据、证书和消费者目录已清理。
|
||||
|
||||
复验命令:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyManagedRelease.py --version v0.14.0-preview --report reports/businessDevelopmentManagedRemote.json
|
||||
|
||||
74
reports/businessDevelopmentConnectorRemote.json
Normal file
74
reports/businessDevelopmentConnectorRemote.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.14.0-preview",
|
||||
"localSource": false,
|
||||
"frameworkSum": "h1:qG0Ipjt3enc8/Enu8ihJ6h8c8U6FCDedoNSdzuC3rBc=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||
"mysqlLimits": {
|
||||
"cpus": 1,
|
||||
"memoryMiB": 768,
|
||||
"pids": 128
|
||||
},
|
||||
"durationSeconds": 22.99,
|
||||
"tests": [
|
||||
"TestHTTPConfiguredStandardServer",
|
||||
"TestHTTPInvalidStartupReleasesResources",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||
"TestConnectorIndependentPoolsAndContext",
|
||||
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||
"TestConnectorStartupFailuresReleaseResources",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||
"TestConnectorIdleZeroAndLifetime/expired",
|
||||
"TestConnectorIdleZeroAndLifetime",
|
||||
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||
"TestMySQLConnectorDriverLoggerIsolation",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||
"TestStructuredMySQLAdapterTLS",
|
||||
"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",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/native",
|
||||
"TestHTTPConfiguredNetworkLimits",
|
||||
"TestGeneratedMySQLConfigurationAndLifecycle"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||
"scripts/verifyConnector.py": "4d2b1da0a1fcf641e02600710789ced51a58cf6fa1726a00c194a01f8e557e1a",
|
||||
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645"
|
||||
}
|
||||
}
|
||||
68
reports/businessDevelopmentManagedRemote.json
Normal file
68
reports/businessDevelopmentManagedRemote.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.14.0-preview",
|
||||
"frameworkSum": "h1:qG0Ipjt3enc8/Enu8ihJ6h8c8U6FCDedoNSdzuC3rBc=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 30.0,
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"freshModuleCache",
|
||||
"fixedVersionNoReplace",
|
||||
"contractChangePreview",
|
||||
"persistentLogicSignatureFailure",
|
||||
"businessSourcePreserved",
|
||||
"fixedConsumerBuild",
|
||||
"rootGoRun",
|
||||
"nethttpAndNativeSignals",
|
||||
"listenerReleased",
|
||||
"default404And405",
|
||||
"goOwnedErrorCatalog",
|
||||
"unwrappedDownloads",
|
||||
"rawCallback",
|
||||
"multipart400And413",
|
||||
"nethttpAndNativeTransfers",
|
||||
"initializationAndListenFailureCleanup",
|
||||
"drainBeforeDependencyClose",
|
||||
"generatedConsumerRaceTests",
|
||||
"errorParamsAndInheritance",
|
||||
"manualHooksAndScalars",
|
||||
"dependencyFatalShutdown",
|
||||
"fatalProcessExitCode",
|
||||
"initializationShutdownNotification",
|
||||
"configuredBuiltinEndpoints",
|
||||
"projectHealthEnvelope",
|
||||
"endpointConflictCleanup",
|
||||
"responseArrayNullableNull",
|
||||
"strictRequestPolicies",
|
||||
"declarativeMultipartBinding"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"lifecycle/runtime.go": "be9d3a483adc322b9c2633efbc87609f4b4a7aeebc2bee062e89ec32368466a3",
|
||||
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
|
||||
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
|
||||
"cmd/go-omni/apigen/generate.go": "e0290bc5b6fdd9404da7944f2697299548ca3ea3611cf0e4a3ad3db5be2f7554",
|
||||
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||
"scripts/verifyManagedRelease.py": "ac2ef8f03d4c01fa793bc381c152d5d09f76227659cd6399ef8d43087785295c",
|
||||
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||
"cmd/go-omni/apigen/managed_test.go": "2b6bdf62a06cf25b85cbf06d60a5284ba24256c6705611fad4c012f63661a03d",
|
||||
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||
"cmd/go-omni/apigen/testdata/managed/client.go": "db886c2d4dd27e67f7446265c20147335d4898d2d2091518e67403806ccd9b69",
|
||||
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
42
reports/gormAcceptance.md
Normal file
42
reports/gormAcceptance.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# v0.14.1-preview GORM 可选适配验收
|
||||
|
||||
日期:2026-09-24。GORM v2 API,固定 `gorm.io/gorm v1.31.2`、`gorm.io/driver/mysql v1.6.0`。没有修改接入项目业务,也未使用业务数据库。
|
||||
|
||||
## 已通过的检查
|
||||
|
||||
- 原池身份与限额保持;初始化错误、已取消 context、版本查询超时都保留原因,且不会关闭借用池。
|
||||
- 原生命名策略、默认事务保护、错误翻译、PreparedStmt 查询和嵌套事务保持可用。成功后启动 context 取消不会影响新请求。
|
||||
- SQL 日志级别、慢查询、ErrRecordNotFound 忽略、占位符过滤、错误详情显式开关、requestId/traceId、并发 LogMode 副本与 UTF-8 截断。
|
||||
- 真实 MySQL 8.4:TLS、同池连接、事务提交/错误回滚/panic 回滚、嵌套 savepoint 回滚、唯一约束、查询取消和后续恢复;参数与重复键错误中的值不进入默认日志。
|
||||
- 实际生成 MySQL 项目,只扩展项目拥有的 svc 字段与 Init,main/运行期不改;生成检查通过,ORM 随项目池关闭。既有 MySQL TLS/mTLS 与 HTTP 限额回归一并执行。
|
||||
- `go list -deps` 确认核心 App、database、database/mysql 的编译依赖不包含 GORM。
|
||||
|
||||
本地命令:
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
|
||||
go vet ./...
|
||||
go build ./...
|
||||
python3 scripts/verifyConnector.py --local --gorm --report reports/gormLocal.json
|
||||
```
|
||||
|
||||
[本地数据库报告](gormLocal.json) 的 MySQL 限制为 1 CPU、768 MiB、128 PIDs,只绑定 localhost;测试使用临时凭据/证书,结束后清理容器和消费者。实库用例通过脚本显式启用,不能把普通 go test 下的 skip 当作实库通过。
|
||||
|
||||
发布后的远程验收也已通过:
|
||||
|
||||
- [远程 MySQL/GORM 报告](gormRemote.json):25.42 秒,实际下载固定版本,执行 TLS、事务、取消、日志、生成项目接入及已有数据库/HTTP 回归。
|
||||
- [远程生成项目报告](gormManagedRemote.json):30.45 秒,验证根目录运行/构建、重新生成、错误目录与参数、上传下载、内置端点及初始化失败/监听失败/正常退出清理。
|
||||
- 两份报告均使用空 module 缓存,无本地 replace,CLI 和消费者版本均为 `v0.14.1-preview`;module 校验值一致为 `h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=`。同版 CLI 的生成检查没有错误版本提示。
|
||||
|
||||
复验命令:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyConnector.py --version v0.14.1-preview --gorm --report reports/gormRemote.json
|
||||
python3 scripts/verifyManagedRelease.py --version v0.14.1-preview --report reports/gormManagedRemote.json
|
||||
```
|
||||
|
||||
代码标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`。远程验收结果单独提交,标签保持不变;临时容器、证书、消费者与进程均已清理。
|
||||
|
||||
## 边界
|
||||
|
||||
本版真实数据库只验收 MySQL。自定义方言工厂必须使用给定连接池;额外连接池由其所有者管理,不自动纳入当前池生命周期。GORM 默认不启用 PrepareStmt;显式启用时保留第三方缓存语义,其清理接口及后台 goroutine 的限制已在 [适配文档](../database/gormx/README.md) 说明。默认 SQL 参数隐藏不等于自动脱敏原始 SQL 字面量。
|
||||
82
reports/gormLocal.json
Normal file
82
reports/gormLocal.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.14.1-preview",
|
||||
"localSource": true,
|
||||
"gormAdapter": true,
|
||||
"frameworkSum": null,
|
||||
"freshModuleCache": false,
|
||||
"noLocalReplace": false,
|
||||
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||
"mysqlLimits": {
|
||||
"cpus": 1,
|
||||
"memoryMiB": 768,
|
||||
"pids": 128
|
||||
},
|
||||
"durationSeconds": 15.01,
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||
"scripts/verifyConnector.py": "58d94e869e6faab1e49bd31ccaebab6d46b28e40058cf7805ad906057229149e",
|
||||
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||
}
|
||||
}
|
||||
68
reports/gormManagedRemote.json
Normal file
68
reports/gormManagedRemote.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.14.1-preview",
|
||||
"frameworkSum": "h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 30.45,
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"freshModuleCache",
|
||||
"fixedVersionNoReplace",
|
||||
"contractChangePreview",
|
||||
"persistentLogicSignatureFailure",
|
||||
"businessSourcePreserved",
|
||||
"fixedConsumerBuild",
|
||||
"rootGoRun",
|
||||
"nethttpAndNativeSignals",
|
||||
"listenerReleased",
|
||||
"default404And405",
|
||||
"goOwnedErrorCatalog",
|
||||
"unwrappedDownloads",
|
||||
"rawCallback",
|
||||
"multipart400And413",
|
||||
"nethttpAndNativeTransfers",
|
||||
"initializationAndListenFailureCleanup",
|
||||
"drainBeforeDependencyClose",
|
||||
"generatedConsumerRaceTests",
|
||||
"errorParamsAndInheritance",
|
||||
"manualHooksAndScalars",
|
||||
"dependencyFatalShutdown",
|
||||
"fatalProcessExitCode",
|
||||
"initializationShutdownNotification",
|
||||
"configuredBuiltinEndpoints",
|
||||
"projectHealthEnvelope",
|
||||
"endpointConflictCleanup",
|
||||
"responseArrayNullableNull",
|
||||
"strictRequestPolicies",
|
||||
"declarativeMultipartBinding"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"lifecycle/runtime.go": "be9d3a483adc322b9c2633efbc87609f4b4a7aeebc2bee062e89ec32368466a3",
|
||||
"config/project.go": "54b4d6f4d3a2e7a172e7f446af22067735b7c02ba253dd2483f42190616e90d3",
|
||||
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||
"contract/policy.go": "73a3a55fb88195b32f22b2bc066d9b13dccd7fd520e98c5a1b2979a3fd2447eb",
|
||||
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||
"cmd/go-omni/apigen/business_test.go": "c0af26cd270f1c971dc5886316b144b776fdb886e930f883f0ee0c94b0909226",
|
||||
"cmd/go-omni/apigen/generate.go": "138ea2809df839a5283c1f545d94e9a13d3476d149e7bfb62f73fb9c84ad1d92",
|
||||
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||
"scripts/verifyManagedRelease.py": "ac2ef8f03d4c01fa793bc381c152d5d09f76227659cd6399ef8d43087785295c",
|
||||
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||
"cmd/go-omni/apigen/managed_test.go": "2b6bdf62a06cf25b85cbf06d60a5284ba24256c6705611fad4c012f63661a03d",
|
||||
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||
"cmd/go-omni/apigen/testdata/managed/client.go": "db886c2d4dd27e67f7446265c20147335d4898d2d2091518e67403806ccd9b69",
|
||||
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
82
reports/gormRemote.json
Normal file
82
reports/gormRemote.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.14.1-preview",
|
||||
"localSource": false,
|
||||
"gormAdapter": true,
|
||||
"frameworkSum": "h1:qJ5EPUfSKe8KOHK8aU5f78Fg24HXCgbDfe2z1XXTmf4=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||
"mysqlLimits": {
|
||||
"cpus": 1,
|
||||
"memoryMiB": 768,
|
||||
"pids": 128
|
||||
},
|
||||
"durationSeconds": 25.42,
|
||||
"tests": [
|
||||
"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",
|
||||
"TestStructuredMySQLAdapterTLS",
|
||||
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||
"TestConnectorIndependentPoolsAndContext",
|
||||
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||
"TestConnectorStartupFailuresReleaseResources",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||
"TestConnectorIdleZeroAndLifetime/expired",
|
||||
"TestConnectorIdleZeroAndLifetime",
|
||||
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||
"TestMySQLConnectorDriverLoggerIsolation",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/native",
|
||||
"TestHTTPConfiguredNetworkLimits",
|
||||
"TestGeneratedOptionalGORM",
|
||||
"TestGeneratedMySQLConfigurationAndLifecycle"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||
"scripts/verifyConnector.py": "58d94e869e6faab1e49bd31ccaebab6d46b28e40058cf7805ad906057229149e",
|
||||
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||
}
|
||||
}
|
||||
41
reports/phasedLifecycleAcceptance.md
Normal file
41
reports/phasedLifecycleAcceptance.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# v0.15.0-preview 后台分阶段关闭验收
|
||||
|
||||
日期:2026-09-25。范围为通用生命周期、task 可选接入、生成项目装配和升级;没有修改业务项目,也没有引入事务任务一致性实现。
|
||||
|
||||
## 已验证行为
|
||||
|
||||
- 早期退出通知不取消 WorkContext;父 context 的值保留,截止时间不传入后台工作生命周期。阶段钩子有独立时限。
|
||||
- 所有 Stop 完成才正常进入 Drain,同一阶段并发;仅确认收尾完成后才逆序释放共享资源。OnStop 要求匹配 OnDrain。
|
||||
- 阶段错误、panic、超时和未响应取消都会保留原因;不能证明结束时跳过资源关闭,返回 ErrResourcesRetained。超时后在取消窗口成功完成的组件允许释放资源,同时仍报告超时。
|
||||
- 未返回的 Stop 不与同名 Drain 并发;其他组件继续尝试收尾。重复/并发 Finish 不重复调用钩子。
|
||||
- 实际生成项目在 nethttp/native 上验证:HTTP 请求仍在处理时后台不提前停止,后台最终操作完成后才关闭客户端;后台致命原因贯穿返回错误。
|
||||
- 初始化失败和监听失败仍等待已创建后台任务收尾。独立子进程验证未结束任务不会导致共享客户端提前关闭,进程退出码为 1,原始初始化错误保留。
|
||||
- 配置 YAML/环境覆盖、非法时限拒绝;重新生成保留业务文件,v0.14 项目的定点升级只更新依赖/生成文件,保留自定义配置与 svc。
|
||||
- 实际 MySQL 8.4 TLS:后台在退出通知后创建临时表、完成 INSERT/SELECT 并释放借用连接,再关闭池;无法证明完成时池仍通过健康检查。GORM 同池接入及既有 Connector/TLS/HTTP 限额回归同时通过。
|
||||
|
||||
本地进程记录:[生成项目](phasedManagedLocal.json)。数据库记录:[MySQL 与 GORM](phasedMySQLLocal.json)。容器限制为 1 CPU / 768 MiB / 128 PIDs,只使用本机临时数据库,已清理容器、凭据、证书和临时消费者。
|
||||
|
||||
全量 race(启用生成消费者)、go vet、go build、示例生成漂移检查和依赖隔离检查均通过。
|
||||
|
||||
## 复验
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
|
||||
go vet ./...
|
||||
go build ./...
|
||||
python3 scripts/verifyManagedProject.py --report reports/phasedManagedLocal.json
|
||||
python3 scripts/verifyConnector.py --local --phases --gorm --report reports/phasedMySQLLocal.json
|
||||
```
|
||||
|
||||
发布后使用空缓存、固定远程 module、无 replace 再验收:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyManagedRelease.py --version v0.15.0-preview --report reports/phasedManagedRemote.json
|
||||
python3 scripts/verifyConnector.py --version v0.15.0-preview --phases --gorm --report reports/phasedMySQLRemote.json
|
||||
```
|
||||
|
||||
## 使用边界
|
||||
|
||||
普通 OnClose 保留原有逆序语义;需要阶段保证的后台组件必须迁入对应钩子,框架不会猜测任意 Close 内部行为。钩子负责真实停止与等待,nil 是完成证明。未结束 goroutine 不能被 Go 强杀;资源被保留后由生成入口异常退出,手写宿主须自行终止服务。OnClose 的同步回调仍须能返回;HTTP 超时保留原传输层行为,业务必须响应请求取消。同一阶段有顺序依赖时由一个登记项组合管理。
|
||||
|
||||
lifecycle 本身仅依赖标准库;核心 App/database/task 的编译依赖不包含 Orbit/GORM/Redis。第三方可选适配依赖仍记录于同一 module,不声称已经拆成独立 module。
|
||||
59
reports/phasedManagedLocal.json
Normal file
59
reports/phasedManagedLocal.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"passed": true,
|
||||
"mode": "localSource",
|
||||
"durationSeconds": 4.31,
|
||||
"checks": [
|
||||
"rootGoRun",
|
||||
"rootGoBuild",
|
||||
"stableRegeneration",
|
||||
"checkMainOwnership",
|
||||
"contractChangePreview",
|
||||
"logicSignatureCheckPersists",
|
||||
"businessSourcePreserved",
|
||||
"fixedConsumerBuild",
|
||||
"nethttpSIGINT",
|
||||
"nethttpSIGTERM",
|
||||
"nativeSIGINT",
|
||||
"nativeSIGTERM",
|
||||
"listenerReleased",
|
||||
"temporaryProcessesRemoved",
|
||||
"nethttpGeneratedTransfers",
|
||||
"nativeGeneratedTransfers",
|
||||
"malformedMultipart400",
|
||||
"goOwnedErrors",
|
||||
"default404And405"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"cmd/go-omni/apigen/generate.go": "819ce227f1041fa9447d7fbff4bfc2d4ea60dcb73e8667a21a41a090b40f0dd3",
|
||||
"cmd/go-omni/apigen/templates.go": "6a98c26329d4c54d97abc170a324016931baac0e21028f649f1660347f5dd971",
|
||||
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
|
||||
"cmd/go-omni/apigen/managed_test.go": "8b1b86d38151e78a1a18d8ecf001b9635d32c37276abaf2c9e28af10aa983e9d",
|
||||
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
|
||||
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
|
||||
"cmd/go-omni/apigen/write.go": "180c5f5daee1441fe936cb376b1645a3d0c802cc78bf4b451dbf7e7b3e4ac0f4",
|
||||
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
|
||||
"config/config.go": "b4935ebb452c9df34b733842687ba11eec7e006a54ce42ff767273dd74d6e672",
|
||||
"httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d",
|
||||
"httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875",
|
||||
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||
"cmd/go-omni/scaffold/templates/api-config.go.tmpl": "fa37bd0a71587e2f821ec1595eb1f1a371677c90428001ea75f61815243c3b7b",
|
||||
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
|
||||
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
|
||||
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
|
||||
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
|
||||
"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": "4d826d7133027ac2399f8209f03efa46ed54aa45dcd0a35274a9009314941dca",
|
||||
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "9e103c9de7a1a906903bc09787e1b38905dc5bb4d891c88938028da5dee98f6f",
|
||||
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1"
|
||||
}
|
||||
}
|
||||
87
reports/phasedMySQLLocal.json
Normal file
87
reports/phasedMySQLLocal.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.15.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": 12.44,
|
||||
"tests": [
|
||||
"TestHTTPConfiguredStandardServer",
|
||||
"TestHTTPInvalidStartupReleasesResources",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||
"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",
|
||||
"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",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||
"TestStructuredMySQLAdapterTLS",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/native",
|
||||
"TestHTTPConfiguredNetworkLimits",
|
||||
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||
"TestGeneratedOptionalGORM",
|
||||
"TestGeneratedMySQLConfigurationAndLifecycle",
|
||||
"TestGeneratedMySQLBackgroundDrain"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||
"scripts/verifyConnector.py": "fba3064dc4611eef5a7cc25369d4523b485e633448fa14a7a1fea0fbca3796fe",
|
||||
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
|
||||
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||
"lifecycle/phases.go": "1436856c708d004ebb28704a867316bf7a44553de56b3e29152d41475a2f793a",
|
||||
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||
}
|
||||
}
|
||||
48
scripts/testdata/gormGenerated_test.go
vendored
Normal file
48
scripts/testdata/gormGenerated_test.go
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/mysqlAcceptance/internal/config"
|
||||
"example.com/mysqlAcceptance/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedOptionalGORM(t *testing.T) {
|
||||
cfg, err := config.Load("etc/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
c, err := svc.New(ctx, logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if c.ORM == nil {
|
||||
t.Fatal("svc.Init did not provide GORM")
|
||||
}
|
||||
pool, err := c.ORM.DB()
|
||||
if err != nil || pool != c.DB.DB {
|
||||
t.Fatal("GORM created another pool", err)
|
||||
}
|
||||
var value int
|
||||
if err = c.ORM.WithContext(ctx).Raw("SELECT ?", 42).Scan(&value).Error; err != nil || value != 42 {
|
||||
t.Fatal(value, err)
|
||||
}
|
||||
if err = c.Finish(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.ORM.WithContext(ctx).Raw("SELECT 1").Scan(&value).Error; err == nil {
|
||||
t.Fatal("ORM did not follow owner shutdown")
|
||||
}
|
||||
}
|
||||
85
scripts/testdata/phasedMySQL_test.go
vendored
Normal file
85
scripts/testdata/phasedMySQL_test.go
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/mysqlAcceptance/internal/config"
|
||||
"example.com/mysqlAcceptance/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGeneratedMySQLBackgroundDrain(t *testing.T) {
|
||||
cfg, err := config.Load("etc/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c, err := svc.New(parent, logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
stop := make(chan struct{})
|
||||
finished := make(chan error, 1)
|
||||
c.OnStop("writer", func(context.Context) error { close(stop); return nil })
|
||||
c.OnDrain("writer", func(context.Context) error { return <-finished })
|
||||
go func() {
|
||||
<-stop
|
||||
result := func() (err error) {
|
||||
ctx, end := context.WithTimeout(c.WorkContext(), 3*time.Second)
|
||||
defer end()
|
||||
conn, err := c.DB.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { err = errors.Join(err, conn.Close()) }()
|
||||
for _, query := range []string{"CREATE TEMPORARY TABLE omni_phase_records (id INT)", "INSERT INTO omni_phase_records VALUES (42)"} {
|
||||
if _, err = conn.ExecContext(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err = conn.QueryRowContext(ctx, "SELECT id FROM omni_phase_records").Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 42 {
|
||||
return errors.New("final background write missing")
|
||||
}
|
||||
_, err = conn.ExecContext(ctx, "DROP TEMPORARY TABLE omni_phase_records")
|
||||
return err
|
||||
}()
|
||||
// Completion follows release of the borrowed connection, including failures.
|
||||
finished <- result
|
||||
}()
|
||||
cancel()
|
||||
if err = c.Finish(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.DB.PingContext(context.Background()); err == nil {
|
||||
t.Fatal("pool not closed after worker finished")
|
||||
}
|
||||
|
||||
held, err := svc.New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.DB.Close() // This test owns recovery and has no outstanding work.
|
||||
cause := errors.New("worker completion unproven")
|
||||
held.OnDrain("unjoined", func(context.Context) error { return cause })
|
||||
if err = held.Finish(nil); !errors.Is(err, cause) || !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = held.DB.Health(context.Background()); err != nil {
|
||||
t.Fatal("unjoined worker lost shared database", err)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ def main():
|
||||
source.add_argument("--version")
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
parser.add_argument("--image", default="mysql:8.4")
|
||||
parser.add_argument("--gorm", action="store_true", help="also verify the optional GORM adapter and generated svc extension")
|
||||
parser.add_argument("--phases", action="store_true", help="verify final background writes before generated pool cleanup (v0.15+)")
|
||||
args = parser.parse_args()
|
||||
if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
|
||||
parser.error("a fixed release version is required")
|
||||
@@ -70,13 +72,16 @@ def main():
|
||||
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
|
||||
project = temp / "consumer"
|
||||
project.mkdir()
|
||||
version = args.version or "v0.14.0-preview"
|
||||
version = args.version or "v0.15.0-preview"
|
||||
(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)
|
||||
else:
|
||||
env["GOMODCACHE"] = str(temp / "modcache")
|
||||
for name in SOURCES:
|
||||
sources = list(SOURCES)
|
||||
if args.gorm:
|
||||
sources.append("database/gormx/integration_test.go")
|
||||
for name in sources:
|
||||
target = project / name
|
||||
target.parent.mkdir(exist_ok=True, parents=True)
|
||||
shutil.copyfile(ROOT / name, target)
|
||||
@@ -98,6 +103,20 @@ def main():
|
||||
command += ["--framework-dir", ROOT]
|
||||
run([*command, generated], temp, env)
|
||||
shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go")
|
||||
if args.phases:
|
||||
shutil.copyfile(ROOT / "scripts/testdata/phasedMySQL_test.go", generated / "phases_test.go")
|
||||
if args.gorm:
|
||||
# Only the documented project-owned extension is customized.
|
||||
service = generated / "internal/svc/serviceContext.go"
|
||||
code = service.read_text()
|
||||
assert '"context"' in code and 'type ServiceContext struct {' in code
|
||||
code = code.replace('"context"', '"context"\n "gorm.io/gorm"\n "' + MODULE + '/database/gormx"', 1)
|
||||
code = code.replace('type ServiceContext struct {', 'type ServiceContext struct {\n ORM *gorm.DB', 1)
|
||||
assert 'return nil' in code
|
||||
code = code.replace('return nil', 'var err error\n c.ORM, err = gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))\n return err', 1)
|
||||
service.write_text(code)
|
||||
run(["gofmt", "-w", service], generated, env)
|
||||
shutil.copyfile(ROOT / "scripts/testdata/gormGenerated_test.go", generated / "gorm_test.go")
|
||||
run(["go", "mod", "tidy"], generated, env)
|
||||
generated_module = json.loads(run(["go", "list", "-m", "-json", MODULE], generated, env))
|
||||
if not args.local:
|
||||
@@ -137,6 +156,8 @@ def main():
|
||||
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
|
||||
assert "TestMySQLConnectorTLSAndPoolIsolation" in passed
|
||||
assert "TestStructuredMySQLAdapterTLS" in passed
|
||||
if args.gorm:
|
||||
assert "TestGORMMySQLTransactionsCancellationAndLogs" in passed
|
||||
assert not any(e.get("Action") in ("fail", "skip") for e in events), output
|
||||
env.update(GO_OMNI_DB_HOST="127.0.0.1", GO_OMNI_DB_PORT=addr.rsplit(":", 1)[1],
|
||||
GO_OMNI_DB_USER="omni_test", GO_OMNI_DB_PASSWORD=env["MYSQL_PASSWORD"],
|
||||
@@ -144,23 +165,34 @@ def main():
|
||||
GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2",
|
||||
GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2")
|
||||
print("Testing generated MySQL service config, independent pools and cleanup", flush=True)
|
||||
run([cli, "api", "generate", "--check"], generated, env)
|
||||
check_output = run([cli, "api", "generate", "--check"], generated, env)
|
||||
if not args.local:
|
||||
assert "frameworkVersion" not in check_output, check_output
|
||||
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=60s", "./..."], generated, env)
|
||||
generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||
generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e]
|
||||
assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output
|
||||
if args.phases:
|
||||
assert "TestGeneratedMySQLBackgroundDrain" in generated_passed, output
|
||||
if args.gorm:
|
||||
assert "TestGeneratedOptionalGORM" in generated_passed, output
|
||||
assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output
|
||||
passed += generated_passed
|
||||
image = run(["docker", "inspect", "--format", "{{.Image}}", name], temp, env).strip()
|
||||
finally:
|
||||
if created:
|
||||
run(["docker", "rm", "--force", "--volumes", name], temp, env)
|
||||
report = {"passed": True, "frameworkVersion": version, "localSource": args.local,
|
||||
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"]
|
||||
if args.phases:
|
||||
report_sources += ["scripts/testdata/phasedMySQL_test.go", "lifecycle/runtime.go", "lifecycle/phases.go"]
|
||||
if args.gorm:
|
||||
report_sources += ["scripts/testdata/gormGenerated_test.go", "database/gormx/gorm.go", "database/gormx/pool.go", "database/gormx/logger.go"]
|
||||
report = {"passed": True, "frameworkVersion": version, "localSource": args.local, "gormAdapter": args.gorm, "phasedLifecycle": args.phases,
|
||||
"frameworkSum": module.get("Sum"), "freshModuleCache": not args.local,
|
||||
"noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image,
|
||||
"mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128},
|
||||
"durationSeconds": round(time.monotonic() - began, 2), "tests": passed,
|
||||
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in (*SOURCES, "scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go")}}
|
||||
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in report_sources}}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||
print("PASS; isolated container, credentials, certificates and consumer removed", flush=True)
|
||||
|
||||
@@ -81,9 +81,9 @@ def main():
|
||||
evidence = "".join(e.get("Output", "") for e in events)
|
||||
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence)
|
||||
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
|
||||
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "strictRequestPolicies", "declarativeMultipartBinding"]
|
||||
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
|
||||
|
||||
sources = ["lifecycle/runtime.go", "config/project.go", "contract/bind.go", "contract/policy.go", "contract/multipart.go", "contract/file.go", "cmd/go-omni/apigen/business_test.go", "cmd/go-omni/apigen/generate.go", "lifecycle/controller.go", "config/endpoints.go", "health/health.go", "httpx/response.go", "app/app.go", "app/transport.go", "scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"]
|
||||
sources = ["lifecycle/runtime.go", "lifecycle/phases.go", "config/lifecycle_test.go", "task/task.go", "cmd/go-omni/scaffold/templates/api-runtime.go.tmpl", "cmd/go-omni/apigen/templates.go", "config/project.go", "contract/bind.go", "contract/policy.go", "contract/multipart.go", "contract/file.go", "cmd/go-omni/apigen/business_test.go", "cmd/go-omni/apigen/generate.go", "lifecycle/controller.go", "config/endpoints.go", "health/health.go", "httpx/response.go", "app/app.go", "app/transport.go", "scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"]
|
||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")]
|
||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")]
|
||||
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
||||
|
||||
60
task/phases_test.go
Normal file
60
task/phases_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package task_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/task"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunnerPhasedShutdown(t *testing.T) {
|
||||
runtime := lifecycle.NewRuntime(context.Background())
|
||||
runner, err := task.New(task.Config{Workers: 1, QueueSize: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resourceClosed atomic.Bool
|
||||
runtime.OnClose(func() error { resourceClosed.Store(true); return nil })
|
||||
started, release, draining := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
runtime.OnStop("jobs", runner.StopAccepting)
|
||||
runtime.OnDrain("jobs", func(ctx context.Context) error { close(draining); return runner.Wait(ctx) })
|
||||
future, err := runner.Submit(runtime.WorkContext(), "write", func(ctx context.Context) error {
|
||||
close(started)
|
||||
<-release
|
||||
if resourceClosed.Load() {
|
||||
return errors.New("resource already closed")
|
||||
}
|
||||
return ctx.Err()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
runtime.RequestShutdown(nil)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- runtime.Close() }()
|
||||
select {
|
||||
case <-draining:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not drain")
|
||||
}
|
||||
if _, err := runner.Submit(context.Background(), "late", func(context.Context) error { return nil }); !errors.Is(err, task.ErrClosed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resourceClosed.Load() {
|
||||
t.Fatal("closed resource before task")
|
||||
}
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := future.Wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resourceClosed.Load() {
|
||||
t.Fatal("resource leaked")
|
||||
}
|
||||
}
|
||||
29
task/task.go
29
task/task.go
@@ -227,10 +227,9 @@ func run(ctx context.Context, fn Func) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Shutdown stops schedules and submission, then drains accepted jobs. Deadline
|
||||
// expiry cancels running/queued jobs and returns without waiting for code that
|
||||
// ignores cancellation. Such code cannot be forcibly stopped by Go.
|
||||
func (r *Runner) Shutdown(ctx context.Context) error {
|
||||
// StopAccepting stops schedules and submission without canceling accepted jobs.
|
||||
// Pair it with Wait to establish completion before releasing dependencies.
|
||||
func (r *Runner) StopAccepting(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("task: nil context")
|
||||
}
|
||||
@@ -242,6 +241,20 @@ func (r *Runner) Shutdown(ctx context.Context) error {
|
||||
go func() { r.wg.Wait(); r.cancel(); close(r.done) }()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait joins all workers and schedules after StopAccepting. A canceled context
|
||||
// cancels work, but callers can wait again to establish actual completion.
|
||||
func (r *Runner) Wait(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("task: nil context")
|
||||
}
|
||||
select {
|
||||
case <-r.done:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-r.done:
|
||||
return nil
|
||||
@@ -251,6 +264,14 @@ func (r *Runner) Shutdown(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops admission and waits with the caller's deadline.
|
||||
func (r *Runner) Shutdown(ctx context.Context) error {
|
||||
if err := r.StopAccepting(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Wait(ctx)
|
||||
}
|
||||
|
||||
func (r *Runner) Close() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
Reference in New Issue
Block a user