Files
go-omni/cmd/go-omni/projectgen/testdata/consumer_test.go

284 lines
8.9 KiB
Go

package main
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"example.com/plain/internal/config"
program "example.com/plain/internal/run"
"example.com/plain/internal/svc"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
)
func setup(t *testing.T) {
t.Helper()
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
t.Cleanup(func() {
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
})
}
func shortConfig(t *testing.T, logfile string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "config.yaml")
text := "log:\n output: discard\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
if logfile != "" {
text = "log:\n file: " + logfile + "\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
}
if err := os.WriteFile(p, []byte(text), 0600); err != nil {
t.Fatal(err)
}
return p
}
func TestFiniteRunAndCleanupOrder(t *testing.T) {
setup(t)
var events []string
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
events = append(events, "init")
c.OnClose(func() error { events = append(events, "first"); return nil })
c.OnClose(func() error { events = append(events, "second"); return nil })
return nil
}
program.Execute = func(context.Context, *svc.ServiceContext) error {
defer func() { events = append(events, "run defer") }()
events = append(events, "run")
return nil
}
if err := run(context.Background(), ""); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(events, []string{"init", "run", "run defer", "second", "first"}) {
t.Fatal(events)
}
}
func TestInitializationAndRunFailures(t *testing.T) {
for _, stage := range []string{"init error", "init panic", "run error", "run panic", "run goexit", "component failure"} {
t.Run(stage, func(t *testing.T) {
setup(t)
boom := errors.New("failure marker")
var closed atomic.Int32
var started atomic.Bool
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
c.OnClose(func() error { closed.Add(1); return nil })
if stage == "init error" {
return boom
}
if stage == "init panic" {
panic(boom)
}
return nil
}
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
started.Store(true)
switch stage {
case "run error":
return boom
case "run panic":
panic(boom)
case "run goexit":
runtime.Goexit()
case "component failure":
c.RequestShutdown(boom)
<-ctx.Done()
}
return nil
}
err := run(context.Background(), "")
if err == nil || closed.Load() != 1 {
t.Fatal(stage, err, closed.Load())
}
if strings.HasPrefix(stage, "init") && started.Load() {
t.Fatal("run started after failed initialization")
}
if strings.Contains(stage, "error") || stage == "component failure" {
if !errors.Is(err, boom) {
t.Fatal("failure cause lost", err)
}
}
})
}
}
func TestGracefulStopDrainsAcknowledgementBeforeClose(t *testing.T) {
setup(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan struct{})
stop := make(chan struct{})
done := make(chan struct{})
var closed, acked atomic.Bool
var canceledEarly atomic.Bool
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
c.OnClose(func() error {
if !acked.Load() {
return errors.New("closed before acknowledgement")
}
closed.Store(true)
return nil
})
c.OnStop("component", func(context.Context) error { close(stop); return nil })
c.OnDrain("component", func(context.Context) error { <-done; return nil })
go func() {
defer close(done)
<-stop
if c.WorkContext().Err() != nil {
canceledEarly.Store(true)
}
time.Sleep(20 * time.Millisecond)
acked.Store(true)
}()
return nil
}
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { close(ready); <-ctx.Done(); return nil }
result := make(chan error, 1)
go func() { result <- run(ctx, "") }()
<-ready
cancel()
if err := <-result; err != nil || !closed.Load() || canceledEarly.Load() {
t.Fatal(err, closed.Load(), canceledEarly.Load())
}
}
func TestInitializationFailureDrainsStartedWork(t *testing.T) {
setup(t)
done := make(chan struct{})
stop := make(chan struct{})
var closed atomic.Bool
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
c.OnClose(func() error {
select {
case <-done:
closed.Store(true)
return nil
default:
return errors.New("resource closed while starting work still active")
}
})
c.OnStop("partial", func(context.Context) error { close(stop); return nil })
c.OnDrain("partial", func(context.Context) error { <-done; return nil })
go func() { defer close(done); <-stop; time.Sleep(10 * time.Millisecond) }()
return errors.New("later dependency failed")
}
if err := run(context.Background(), ""); err == nil || !closed.Load() {
t.Fatal(err, closed.Load())
}
}
func TestCancellationGraceJoinsWork(t *testing.T) {
setup(t)
done := make(chan struct{})
var closed atomic.Bool
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
c.OnClose(func() error { closed.Store(true); return nil })
c.OnStop("cooperative", func(context.Context) error { return nil })
c.OnDrain("cooperative", func(context.Context) error { <-done; return nil })
go func() { <-c.WorkContext().Done(); close(done) }()
return nil
}
err := run(context.Background(), shortConfig(t, ""))
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) || !closed.Load() {
t.Fatal(err, closed.Load())
}
}
func TestUnjoinedWorkRetainsDependenciesAndLogger(t *testing.T) {
for _, which := range []string{"run", "background", "init failure"} {
t.Run(which, func(t *testing.T) {
setup(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release := make(chan struct{})
joined := make(chan struct{})
ready := make(chan struct{})
var closed atomic.Bool
var held *svc.ServiceContext
logfile := filepath.Join(t.TempDir(), "retained.log")
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
held = c
c.OnClose(func() error { closed.Store(true); return nil })
if which != "run" {
c.OnStop("stuck", func(context.Context) error { return nil })
c.OnDrain("stuck", func(context.Context) error { <-joined; return nil })
go func() { defer close(joined); <-release; c.Logger.Info("after shutdown deadline") }()
}
if which == "init failure" {
return errors.New("initialization failed")
}
return nil
}
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
close(ready)
if which == "run" {
defer close(joined)
<-release
c.Logger.Info("after shutdown deadline")
} else {
<-ctx.Done()
}
return nil
}
result := make(chan error, 1)
go func() { result <- run(ctx, shortConfig(t, logfile)) }()
if which != "init failure" {
<-ready
cancel()
}
var err error
select {
case err = <-result:
case <-time.After(3 * time.Second):
t.Fatal("shutdown hung")
}
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
t.Fatal(err, closed.Load())
}
close(release)
<-joined
// In the run case the entry's completion notification follows the user defer.
// Close is idempotent and must retain resources even after late completion.
if err = held.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
t.Fatal("retention was lost", err)
}
data, _ := os.ReadFile(logfile)
if !strings.Contains(string(data), "after shutdown deadline") {
t.Fatal("logger closed while work still running", string(data))
}
_ = held.Logger.Close()
})
}
}
func TestExplicitConfigurationAndPreflight(t *testing.T) {
setup(t)
t.Setenv("PLAIN_LEVEL", "debug")
t.Setenv("LOG_LEVEL", "error")
p := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(p, []byte("log:\n level: ${PLAIN_LEVEL}\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(p)
if err != nil || cfg.Log.Level != "debug" {
t.Fatal(cfg.Log.Level, err)
}
if err = os.WriteFile(p, []byte("log:\n level: info\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err = config.Load(p)
if err != nil || cfg.Log.Level != "info" {
t.Fatal("implicit environment override", err)
}
var initCalled atomic.Bool
svc.Initialize = func(context.Context, *svc.ServiceContext) error { initCalled.Store(true); return nil }
if err = os.WriteFile(p, []byte("lifecycle:\n drainTimeout: -1s\n"), 0600); err != nil {
t.Fatal(err)
}
if err = run(context.Background(), p); err == nil || initCalled.Load() {
t.Fatal("invalid configuration initialized dependencies", err)
}
}