feat: add generic phased background shutdown to managed services

This commit is contained in:
2026-09-25 00:35:59 +08:00
parent adcb6f60b6
commit 16deead70e
37 changed files with 1330 additions and 68 deletions

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.14.0-preview) # 通用业务开发指南(v0.15.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 从 [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: 中间件用业务所需的小接口接收依赖,避免反向导入 svc:
@@ -243,7 +243,7 @@ go-omni workspace check --file omni.workspace.json
## v0.13 项目升级 ## v0.13 项目升级
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.14.0-preview(或显式引用当前源码)。 先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.15.0-preview(或显式引用当前源码)。
```sh ```sh
go-omni api upgrade --dir . --dry-run go-omni api upgrade --dir . --dry-run

View File

@@ -1,6 +1,6 @@
# 生成项目与文件维护边界 # 生成项目与文件维护边界
v0.14.1-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。 最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 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 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。 多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。 `Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
@@ -208,7 +208,7 @@ get /download (DownloadReq)
## 已有服务接入步骤 ## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.1-preview: 每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.15.0-preview:
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。 1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。 2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。

View File

@@ -2,7 +2,7 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.14.1-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 适配器。 Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
@@ -10,6 +10,8 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。 新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。 使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
## 选择接入方式 ## 选择接入方式
@@ -32,7 +34,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
```sh ```sh
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
export PATH="$(go env GOPATH)/bin:$PATH" export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help go-omni --help
``` ```
@@ -188,7 +190,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行: 在业务项目的 **go.mod 所在目录**执行:
```sh ```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview
``` ```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -458,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#契约变更提示)。 `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.1-preview`。v0.14.0 项目直接升级依赖与 CLI,GORM 按需导入;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 CLI 与业务 module 固定使用 `v0.15.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入 ## 按需深入

View File

@@ -1,4 +1,28 @@
# v0.14.1-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。 增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。

View File

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

View File

@@ -19,7 +19,7 @@ import (
) )
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" 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" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct { type Options struct {
@@ -151,7 +151,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
} }
} }
if !localCurrent { 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 != "" { if o.FrameworkDir != "" {

View File

@@ -71,6 +71,7 @@ func TestManagedGeneratedProject(t *testing.T) {
files := map[string]string{ 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", "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", "shutdown_test.go": "shutdown_test.go",
"phases_test.go": "phases_test.go", "internal/svc/background.go": "background.go",
} }
for path, fixture := range files { for path, fixture := range files {
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture))) 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") writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
} }
configPath := filepath.Join(dir, "internal/config/config.go") 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") 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(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1) source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)

View File

@@ -13,6 +13,7 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"MODULE/internal/config" "MODULE/internal/config"
"MODULE/internal/handler" "MODULE/internal/handler"
"MODULE/internal/svc" "MODULE/internal/svc"
@@ -27,7 +28,9 @@ func main(){
func run(ctx context.Context,path string)(runErr error){ func run(ctx context.Context,path string)(runErr error){
cfg,err:=config.Load(path);if err!=nil{return err} cfg,err:=config.Load(path);if err!=nil{return err}
logger,err:=logx.New(cfg.Server.Log);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} deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
// RunContext drains HTTP requests before returning. This scope owns dependencies // RunContext drains HTTP requests before returning. This scope owns dependencies
// and closes them exactly once, before closing the borrowed logger. // 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 runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
once before route registration; logging is configured earlier through Config. 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 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 context cancels on signals; long-lived clients/workers that requests depend on need
their own lifetime, canceled by OnClose after HTTP draining. their own lifetime, canceled by OnClose after HTTP draining.

View 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
}

View File

@@ -20,6 +20,11 @@ func initClient(ctx context.Context, c *ServiceContext) error {
Opened.Add(1) Opened.Add(1)
c.Client = &Client{} c.Client = &Client{}
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil }) 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 { if c.Config.FailInit {
return errors.New("client initialization failed") return errors.New("client initialization failed")
} }

View 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))
}
}

View File

@@ -78,6 +78,12 @@ func PlanUpgrade(dir string) (UpgradePlan, error) {
names[name] = true 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)) sorted := make([]string, 0, len(names))
for name := range names { for name := range names {
sorted = append(sorted, name) sorted = append(sorted, name)

View File

@@ -60,6 +60,49 @@ func TestUpgradeRejectsEscapingEntry(t *testing.T) {
t.Fatal("invalid upgrade wrote project") 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) { func TestSafeInfrastructureUpgrade(t *testing.T) {
dir := oldInfrastructureProject(t) dir := oldInfrastructureProject(t)
before := snapshot(t, dir) before := snapshot(t, dir)

View File

@@ -8,7 +8,7 @@ import (
) )
func TestNewWithRemoteFrameworkVersion(t *testing.T) { 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") project := filepath.Join(t.TempDir(), "billing")
args := append([]string{"--module", "example.com/billing"}, flag...) args := append([]string{"--module", "example.com/billing"}, flag...)
if err := runNew(append(args, project)); err != nil { if err := runNew(append(args, project)); err != nil {
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("CLI did not pin remote dependency: %s", content)
} }
} }

View File

@@ -28,6 +28,8 @@ type Context struct {
{{end}} {{end}}
} }
// Init is project-owned. Add dependencies here and immediately register each // 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. // Generated infrastructure is ready here. Never create another App or logger.
func(c *Context)Init(ctx context.Context)error {return nil} func(c *Context)Init(ctx context.Context)error {return nil}

View File

@@ -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]} cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
if err=cfg.Validate();err!=nil{return nil,err} if err=cfg.Validate();err!=nil{return nil,err}
checker,err:=health.New();if 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) ctx=logx.WithLogger(c.Context(),logger)
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}() defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
{{if eq .Database "mysql"}} {{if eq .Database "mysql"}}

View File

@@ -4,7 +4,11 @@ server:
enableLogger: true enableLogger: true
slowRequestThreshold: 500ms slowRequestThreshold: 500ms
shutdownTimeout: 10s shutdownTimeout: 10s
{{if .API}} endpoints: {{if .API}} lifecycle:
stopTimeout: 5s
drainTimeout: 10s
cancelTimeout: 5s
endpoints:
liveness: liveness:
enabled: true enabled: true
path: /health/live path: /health/live

View File

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

View File

@@ -7,6 +7,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/contract" "git.yuntuo.hk/k2safe/go-omni/contract"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx" "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/logx"
"git.yuntuo.hk/k2safe/go-omni/response" "git.yuntuo.hk/k2safe/go-omni/response"
) )
@@ -38,6 +39,7 @@ type Config struct {
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"` SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
TrustedProxies []string `json:"trustedProxies"` TrustedProxies []string `json:"trustedProxies"`
ShutdownTimeout time.Duration `json:"shutdownTimeout"` ShutdownTimeout time.Duration `json:"shutdownTimeout"`
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
Log logx.Config `json:"log"` Log logx.Config `json:"log"`
Errors apierrors.Catalog `json:"errors"` Errors apierrors.Catalog `json:"errors"`
Response response.Config `json:"response"` Response response.Config `json:"response"`
@@ -51,6 +53,7 @@ func Default() Config {
Transport: TransportNetHTTP, Transport: TransportNetHTTP,
RequestIDHeader: defaultRequestIDHeader, RequestIDHeader: defaultRequestIDHeader,
ShutdownTimeout: 10 * time.Second, ShutdownTimeout: 10 * time.Second,
Lifecycle: lifecycle.DefaultShutdownConfig(),
Endpoints: DefaultEndpoints(), Endpoints: DefaultEndpoints(),
Log: logx.Default(), Log: logx.Default(),
} }
@@ -58,6 +61,9 @@ func Default() Config {
// Validate checks configuration values before an application starts. // Validate checks configuration values before an application starts.
func (c Config) Validate() error { func (c Config) Validate() error {
if err := c.Lifecycle.Validate(); err != nil {
return err
}
if err := c.Request.Validate(); err != nil { if err := c.Request.Validate(); err != nil {
return err return err
} }

View File

@@ -77,6 +77,9 @@ func applyEnv(cfg Config, prefix string) (Config, error) {
{"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout}, {"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout},
{"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout}, {"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout},
{"HTTP_IDLE_TIMEOUT", &cfg.HTTP.IdleTimeout}, {"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 != "" { if value := strings.TrimSpace(os.Getenv(prefix + field.name)); value != "" {
*field.value, err = time.ParseDuration(value) *field.value, err = time.ParseDuration(value)

37
config/lifecycle_test.go Normal file
View 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")
}
}
}

View File

@@ -1,5 +1,5 @@
{ {
"templateVersion": "v0.14.0-preview", "templateVersion": "v0.15.0-preview",
"profile": { "profile": {
"database": "none", "database": "none",
"storage": "none", "storage": "none",
@@ -17,10 +17,10 @@
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399", "internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
"internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e", "internal/handler/routesGen.go": "76c1a5b2644e75e56553dc6e9e862a63128d902dd94807ce7c8aee5491cd1e3e",
"internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4", "internal/middleware/registryGen.go": "e5f47efa57a1432765ef3419bae59dbf3b4d55b3aac0a6c935ce7a52ba6b47f4",
"internal/svc/runtimeGen.go": "78ffd0b2a196fbc29e29473919d1341e8a2f508f6594ad148c0b399fbcc40018", "internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344", "internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1", "internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
"main.go": "8a4051c0d51fc14ddf22c36d09de706f02499817accc937f0785e3d0fb5e2aa5" "main.go": "64e26d97de7e7c6c17f47186785edc73d43c23d6c20b50bfc7fff6ef2e914f17"
}, },
"contract": { "contract": {
"routes": { "routes": {

View File

@@ -2,7 +2,7 @@ module example.com/managed
go 1.22.0 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 ( require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect

View File

@@ -37,7 +37,11 @@ func New(ctx context.Context, logger *logx.Logger, configs ...config.Config) (re
if err != nil { if err != nil {
return nil, err 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) ctx = logx.WithLogger(c.Context(), logger)
defer func() { defer func() {
if p := recover(); p != nil { if p := recover(); p != nil {

View File

@@ -8,6 +8,7 @@ import (
"example.com/managed/internal/handler" "example.com/managed/internal/handler"
"example.com/managed/internal/svc" "example.com/managed/internal/svc"
"flag" "flag"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"log" "log"
"os" "os"
@@ -33,7 +34,13 @@ func run(ctx context.Context, path string) (runErr error) {
if err != nil { if err != nil {
return err 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) deps, err := svc.New(ctx, logger, cfg)
if err != nil { if err != nil {
return err return err

View File

@@ -1,5 +1,67 @@
# lifecycle # 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#依赖主动申请退出)。 Controller 是服务退出通知入口,不管理具体数据库、租约或后台任务。生成项目已经将它接入 svc 与 main,业务在 svc 调用 `c.RequestShutdown(err)` 即可,见 [项目指南](../PROJECT.md#依赖主动申请退出)。
手写服务也可组合现有 App: 手写服务也可组合现有 App:

184
lifecycle/phases.go Normal file
View 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
View 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)
}
}

View File

@@ -17,12 +17,37 @@ type Runtime struct {
closed bool closed bool
once sync.Once once sync.Once
closeErr error 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 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) Context() context.Context { return r.Lifecycle.Context() }
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) } 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, // OnClose transfers cleanup ownership. Register immediately after creation,
// before exposing resources to other goroutines. Registration after Close is a // before exposing resources to other goroutines. Registration after Close is a
// programming error. Callbacks may request shutdown, but must not call Close. // programming error. Callbacks may request shutdown, but must not call Close.
@@ -38,8 +63,10 @@ func (r *Runtime) OnClose(cleanup func() error) {
r.cleanups = append(r.cleanups, cleanup) r.cleanups = append(r.cleanups, cleanup)
} }
// Close releases all resources in reverse order, including after a failed // Close runs stop and drain barriers before releasing resources in reverse
// initialization. All callbacks are attempted even if one fails or panics. // 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 { func (r *Runtime) Close() error {
if r == nil { if r == nil {
return nil return nil
@@ -48,9 +75,43 @@ func (r *Runtime) Close() error {
r.RequestShutdown(nil) r.RequestShutdown(nil)
r.mu.Lock() r.mu.Lock()
r.closed = true r.closed = true
cleanups := r.cleanups cleanups, stops, drains := r.cleanups, r.stops, r.drains
r.cleanups = nil r.cleanups = nil
r.stops, r.drains = nil, nil
r.mu.Unlock() 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-- { for i := len(cleanups) - 1; i >= 0; i-- {
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i])) r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
} }

View 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。

View 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"
}
}

View 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"
}
}

85
scripts/testdata/phasedMySQL_test.go vendored Normal file
View 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)
}
}

View File

@@ -44,6 +44,7 @@ def main():
parser.add_argument("--report", type=Path, required=True) parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--image", default="mysql:8.4") 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("--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() args = parser.parse_args()
if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version): 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") parser.error("a fixed release version is required")
@@ -71,7 +72,7 @@ def main():
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid. path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
project = temp / "consumer" project = temp / "consumer"
project.mkdir() project.mkdir()
version = args.version or "v0.14.1-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") (project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n")
if args.local: if args.local:
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env) run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
@@ -102,6 +103,8 @@ def main():
command += ["--framework-dir", ROOT] command += ["--framework-dir", ROOT]
run([*command, generated], temp, env) run([*command, generated], temp, env)
shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go") 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: if args.gorm:
# Only the documented project-owned extension is customized. # Only the documented project-owned extension is customized.
service = generated / "internal/svc/serviceContext.go" service = generated / "internal/svc/serviceContext.go"
@@ -169,6 +172,8 @@ def main():
generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] 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] generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e]
assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output
if args.phases:
assert "TestGeneratedMySQLBackgroundDrain" in generated_passed, output
if args.gorm: if args.gorm:
assert "TestGeneratedOptionalGORM" in generated_passed, output 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 assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output
@@ -178,9 +183,11 @@ def main():
if created: if created:
run(["docker", "rm", "--force", "--volumes", name], temp, env) run(["docker", "rm", "--force", "--volumes", name], temp, env)
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"] report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"]
if args.phases:
report_sources += ["scripts/testdata/phasedMySQL_test.go", "lifecycle/runtime.go", "lifecycle/phases.go"]
if args.gorm: if args.gorm:
report_sources += ["scripts/testdata/gormGenerated_test.go", "database/gormx/gorm.go", "database/gormx/pool.go", "database/gormx/logger.go"] 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, report = {"passed": True, "frameworkVersion": version, "localSource": args.local, "gormAdapter": args.gorm, "phasedLifecycle": args.phases,
"frameworkSum": module.get("Sum"), "freshModuleCache": not args.local, "frameworkSum": module.get("Sum"), "freshModuleCache": not args.local,
"noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image, "noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image,
"mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128}, "mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128},

View File

@@ -81,9 +81,9 @@ def main():
evidence = "".join(e.get("Output", "") for e in events) evidence = "".join(e.get("Output", "") for e in events)
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence) 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" 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/managed").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").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"], report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],

60
task/phases_test.go Normal file
View 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")
}
}

View File

@@ -227,10 +227,9 @@ func run(ctx context.Context, fn Func) (err error) {
return err return err
} }
// Shutdown stops schedules and submission, then drains accepted jobs. Deadline // StopAccepting stops schedules and submission without canceling accepted jobs.
// expiry cancels running/queued jobs and returns without waiting for code that // Pair it with Wait to establish completion before releasing dependencies.
// ignores cancellation. Such code cannot be forcibly stopped by Go. func (r *Runner) StopAccepting(ctx context.Context) error {
func (r *Runner) Shutdown(ctx context.Context) error {
if ctx == nil { if ctx == nil {
return errors.New("task: nil context") 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) }() go func() { r.wg.Wait(); r.cancel(); close(r.done) }()
} }
r.mu.Unlock() 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 { select {
case <-r.done: case <-r.done:
return nil 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 { func (r *Runner) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout) ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout)
defer cancel() defer cancel()