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

@@ -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)
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")
}

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