feat: release v0.27 unified HTTP limits and safe request shutdown

This commit is contained in:
2026-09-29 23:24:19 +08:00
parent 859f1851b1
commit 3b00bb55d1
42 changed files with 1191 additions and 70 deletions

View File

@@ -35,14 +35,16 @@ c.OnDrain("jobs", jobs.Wait)
```yaml
server:
shutdownTimeout: 10s # HTTP 排空时限,保持原语义
shutdownTimeout: 10s # HTTP 排空时限
http:
shutdownCancelTimeout: 5s # 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 中。
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。环境变量仅通过配置文件显式引用,例如 `cancelTimeout: "${WORK_CANCEL_TIMEOUT:-5s}"`,不存在自动覆盖或别名优先。后台生命周期配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
阶段超时会取消阶段 context 和 WorkContext,再给尚未返回的钩子一个 cancelTimeout 收尾窗口。若所有钩子都返回 nil,可以释放依赖,但仍返回超时错误,进程非零退出。task.Wait 收到取消会取消其任务并返回错误;这不证明全部任务已退出,因此仍按以下保守规则处理。
@@ -50,10 +52,24 @@ Stop 超时且仍未返回的组件,不会再并发调用它同名的 Drain;
钩子返回错误、panic、缺少匹配 Drain,或窗口结束后仍有钩子未返回时,Runtime 返回可由 `errors.Is(err, lifecycle.ErrResourcesRetained)` 判断的错误,**跳过全部 OnClose**;生成入口也保留借用的日志实例并非零退出,由进程退出释放剩余 OS 资源。不会悄悄重试 Close,也不会让晚到的后台结果触发自动关闭。错误链保留初始化/监听/后台故障及具体 HookError 的阶段、名称和原始原因。
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;本版阶段时限不会强制中断资源 Close。HTTP 超时沿用现有传输层规则,业务处理器仍须响应请求取消。
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;阶段时限不会强制中断资源 Close。HTTP 超时也必须经过下述完成屏障,业务处理器仍须响应请求取消。
手写项目使用 `lifecycle.NewRuntimeWithConfig(ctx, cfg)`;完成 HTTP 排空后调用 `runtime.Finish(runErr)`。库本身不执行 os.Exit。
### HTTP 请求与依赖的关闭屏障
App 统计已进入且尚未退出的整个处理链,包括中间件、响应编码和 defer;停止接收与登记请求共用同步屏障。关闭连接、取消 context、HTTP/2 session 结束均不等于处理链结束。
`RunContext` / `ServeContext` 先按 `shutdownTimeout` 排空,超时取消并关闭连接;App 随后按 `http.shutdownCancelTimeout`(零默认 5s)等待处理器完成收尾。收尾成功则执行逆序 OnShutdown,仍保留原排空超时错误。收尾窗口结束时还有处理器运行,则永久返回 `ErrResourcesRetained` 并跳过所有 OnShutdown;后续 Close 或晚到的处理器返回不会触发补做清理。
`Runtime.Finish(runErr)` 收到该错误后,继续执行后台 OnStop/OnDrain 并取消 WorkContext,但跳过 OnClose;后续 `Runtime.Close` 也保持资源保留。生成入口通过这条错误链保留数据库等共享依赖和借用日志,并非零退出。手写入口必须传递 runErr 给 Finish,并在 `errors.Is(runErr, lifecycle.ErrResourcesRetained)` 时跳过自己拥有的额外资源清理,不能无条件 defer DB.Close/Logger.Close。
`App.Close` 本身会停止接收、取消请求并等待收尾,适用于外部 HTTP 宿主;正常优雅排空应先由宿主停止监听并 Shutdown,再调用 Close。不要在请求处理器或清理钩子内部调用 Close(会等待自身)。`App`、`Engine`、`NewServer().Handler` 和 `RunWith` 都经过统计;已停止的 App 返回简单 503,不再调用可能使用已关闭依赖的中间件或编码器。App 不支持关闭后重新启动。
外部 Runner 返回不构成请求完成证明,App 仍执行自己的完成屏障;Runner 负责关闭自己拥有的 listener/连接。原始 `Router().ServeHTTP` 绕过 App。处理器退出后继续运行的自建 goroutine、Hijack/WebSocket 后台工作,必须用 OnStop/OnDrain 注册,框架不会推断这些工作的生命周期。HTTP/2 低层 Shutdown 也会等待独立 handler goroutine;超时返回 error 仍不表示它已退出。
新行为增加一个有限 HTTP 收尾窗口,调用方总停机预算应计入它。已有生成项目升级框架即可获得 Runtime.Finish 的保护;重新生成可同步入口注释。可运行示例见 [httpSafety](../examples/httpSafety/README.md)。
### 升级
v0.14.x 受管理项目将 module 和 CLI 一起升级到 v0.15.0-preview,再运行 `go-omni api generate`、`go-omni api generate --check` 和业务测试。生成器更新 main/runtimeGen,保留 svc、Logic 和配置。已有配置不添加新字段也会使用默认时限,需要调整时再编辑 yaml。

View File

@@ -11,10 +11,10 @@ import (
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// ErrResourcesRetained means background quiescence could not be established.
// ErrResourcesRetained means HTTP or background quiescence was not 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")
var ErrResourcesRetained = errors.New("lifecycle: shutdown incomplete; shared resources retained")
type ShutdownConfig struct {
StopTimeout time.Duration `json:"stopTimeout"`

View File

@@ -68,6 +68,10 @@ func (r *Runtime) OnClose(cleanup func() error) {
// 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 {
return r.close(false)
}
func (r *Runtime) close(retain bool) error {
if r == nil {
return nil
}
@@ -108,7 +112,7 @@ func (r *Runtime) Close() error {
drainErr := r.runPhase("drain", eligible, r.shutdown.DrainTimeout)
r.cancelWork()
r.closeErr = errors.Join(stopErr, drainErr)
if unsafePhase(stopErr) || unsafePhase(drainErr) {
if retain || unsafePhase(stopErr) || unsafePhase(drainErr) {
r.closeErr = errors.Join(r.closeErr, ErrResourcesRetained)
return
}
@@ -129,12 +133,14 @@ func runCleanup(fn func() error) (err error) {
}
// Finish closes resources and preserves the operation, cleanup and shutdown
// errors. Read the final cause only after monitors have been stopped by Close.
// errors. An incoming ErrResourcesRetained (for example, unfinished HTTP work)
// still runs stop/drain hooks, but permanently skips resource cleanup, including
// subsequent Close calls. Read the final cause after stopping the monitors.
func (r *Runtime) Finish(err error) error {
if r == nil {
return err
}
err = errors.Join(err, r.Close())
err = errors.Join(err, r.close(errors.Is(err, ErrResourcesRetained)))
if cause := r.Lifecycle.Err(); cause != nil && !errors.Is(err, cause) {
err = errors.Join(err, cause)
}

View File

@@ -54,3 +54,28 @@ func TestRuntimeRetainsMonitorFaultDuringCleanup(t *testing.T) {
}()
r.OnClose(func() error { return nil })
}
func TestFinishRetainsDependenciesAfterIncompleteHTTPDrain(t *testing.T) {
r := NewRuntime(context.Background())
var order []string
r.OnStop("worker", func(context.Context) error { order = append(order, "stop"); return nil })
r.OnDrain("worker", func(context.Context) error { order = append(order, "drain"); return nil })
r.OnClose(func() error { t.Error("released dependency while HTTP work remains"); return nil })
fault := errors.New("HTTP shutdown timeout")
err := r.Finish(errors.Join(fault, ErrResourcesRetained))
if !errors.Is(err, fault) || !errors.Is(err, ErrResourcesRetained) {
t.Fatal(err)
}
if !reflect.DeepEqual(order, []string{"stop", "drain"}) {
t.Fatal(order)
}
if r.WorkContext().Err() == nil {
t.Fatal("background work was not canceled")
}
if err = r.Close(); !errors.Is(err, ErrResourcesRetained) {
t.Fatal("deferred Close bypassed retention", err)
}
if err = r.Finish(nil); !errors.Is(err, ErrResourcesRetained) {
t.Fatal("repeat Finish bypassed retention", err)
}
}