feat: complete contract-driven business development and MySQL integration
This commit is contained in:
81
lifecycle/runtime.go
Normal file
81
lifecycle/runtime.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Runtime owns resources registered during dependency initialization. A server
|
||||
// must finish draining before calling Close. Embed *Runtime in the project's svc.
|
||||
// The logger and any other borrowed resources must not be registered here.
|
||||
type Runtime struct {
|
||||
Lifecycle *Controller
|
||||
mu sync.Mutex
|
||||
cleanups []func() error
|
||||
closed bool
|
||||
once sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func NewRuntime(ctx context.Context) *Runtime { return &Runtime{Lifecycle: New(ctx)} }
|
||||
func (r *Runtime) Context() context.Context { return r.Lifecycle.Context() }
|
||||
func (r *Runtime) RequestShutdown(err error) { r.Lifecycle.RequestShutdown(err) }
|
||||
|
||||
// OnClose transfers cleanup ownership. Register immediately after creation,
|
||||
// before exposing resources to other goroutines. Registration after Close is a
|
||||
// programming error. Callbacks may request shutdown, but must not call Close.
|
||||
func (r *Runtime) OnClose(cleanup func() error) {
|
||||
if cleanup == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
panic("lifecycle: register cleanup after Close")
|
||||
}
|
||||
r.cleanups = append(r.cleanups, cleanup)
|
||||
}
|
||||
|
||||
// Close releases all resources in reverse order, including after a failed
|
||||
// initialization. All callbacks are attempted even if one fails or panics.
|
||||
func (r *Runtime) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.once.Do(func() {
|
||||
r.RequestShutdown(nil)
|
||||
r.mu.Lock()
|
||||
r.closed = true
|
||||
cleanups := r.cleanups
|
||||
r.cleanups = nil
|
||||
r.mu.Unlock()
|
||||
for i := len(cleanups) - 1; i >= 0; i-- {
|
||||
r.closeErr = errors.Join(r.closeErr, runCleanup(cleanups[i]))
|
||||
}
|
||||
})
|
||||
return r.closeErr
|
||||
}
|
||||
|
||||
func runCleanup(fn func() error) (err error) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
err = fmt.Errorf("lifecycle: cleanup panic: %v", p)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
// Finish closes resources and preserves the operation, cleanup and shutdown
|
||||
// errors. Read the final cause only after monitors have been stopped by Close.
|
||||
func (r *Runtime) Finish(err error) error {
|
||||
if r == nil {
|
||||
return err
|
||||
}
|
||||
err = errors.Join(err, r.Close())
|
||||
if cause := r.Lifecycle.Err(); cause != nil && !errors.Is(err, cause) {
|
||||
err = errors.Join(err, cause)
|
||||
}
|
||||
return err
|
||||
}
|
||||
56
lifecycle/runtime_test.go
Normal file
56
lifecycle/runtime_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuntimeRollbackAndConcurrentClose(t *testing.T) {
|
||||
r := NewRuntime(context.Background())
|
||||
var order []int
|
||||
failure, cleanup := errors.New("initialization failed"), errors.New("cleanup failed")
|
||||
r.OnClose(func() error { order = append(order, 1); return cleanup })
|
||||
r.OnClose(func() error { order = append(order, 2); panic("broken dependency") })
|
||||
r.OnClose(func() error { order = append(order, 3); return nil })
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := r.Close(); !errors.Is(err, cleanup) {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if !reflect.DeepEqual(order, []int{3, 2, 1}) {
|
||||
t.Fatal(order)
|
||||
}
|
||||
err := r.Finish(failure)
|
||||
if !errors.Is(err, failure) || !errors.Is(err, cleanup) || !strings.Contains(err.Error(), "cleanup panic") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Context().Err() == nil {
|
||||
t.Fatal("runtime context was not stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRetainsMonitorFaultDuringCleanup(t *testing.T) {
|
||||
r := NewRuntime(context.Background())
|
||||
fault := errors.New("monitor failed")
|
||||
r.OnClose(func() error { r.RequestShutdown(fault); return nil })
|
||||
r.RequestShutdown(nil)
|
||||
if err := r.Finish(nil); !errors.Is(err, fault) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("late ownership transfer accepted")
|
||||
}
|
||||
}()
|
||||
r.OnClose(func() error { return nil })
|
||||
}
|
||||
Reference in New Issue
Block a user