Skip to content

Commit 367a65b

Browse files
authored
feat: graceful shutdown core implementation (#195)
## Type <!-- Check ALL that apply --> - [x] `feat` — New feature - [ ] `fix` — Bug fix - [ ] `refactor` — Code restructure (no behavior change) - [ ] `docs` — Documentation only - [x] `test` — Test coverage - [ ] `chore` — Build, CI, tooling - [ ] `perf` — Performance improvement ## Summary Implements core graceful shutdown support by tracking provider build order, detecting `io.Closer` providers, adding `App.Close()` (reverse order), and adding unit tests (including error behavior). ## Changes - Record provider build order and `io.Closer` build order in container - Add `App.Close()` and keep `App.Closers()` compatibility - Add close ordering tests including dependency-driven order and error continuation ## Breaking Changes None ## Validation ```bash make fmt && make lint && make test ``` ## Checklist - [x] Code follows project style (`make fmt` passes) - [x] Linter passes (`make lint`) - [x] Tests pass (`make test`) - [x] Tests added/updated for new functionality - [x] Documentation updated (if applicable) - [x] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) ## Resolves Resolves #137 Resolves #143 Resolves #144 Resolves #145 ## Notes N/A <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Implemented graceful shutdown support; registered resources are closed in reverse initialization (LIFO) order. * Shutdown captures and returns the first error encountered while continuing to close remaining resources. * **Tests** * Added unit tests verifying closer registration order, reverse-close sequencing, dependency-aware shutdown, and error-handling during close. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 7b98076 commit 367a65b

4 files changed

Lines changed: 341 additions & 0 deletions

File tree

modkit/kernel/bootstrap.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package kernel
22

33
import (
44
"context"
5+
"io"
56

67
"github.com/go-modkit/modkit/modkit/module"
78
)
@@ -73,3 +74,19 @@ func (a *App) Get(token module.Token) (any, error) {
7374
func (a *App) CleanupHooks() []func(context.Context) error {
7475
return a.container.cleanupHooksLIFO()
7576
}
77+
78+
// Closers returns provider closers in build order.
79+
func (a *App) Closers() []io.Closer {
80+
return a.container.closersInBuildOrder()
81+
}
82+
83+
// Close calls Close on all io.Closer providers in reverse build order.
84+
func (a *App) Close() error {
85+
var firstErr error
86+
for _, closer := range a.container.closersLIFO() {
87+
if err := closer.Close(); err != nil && firstErr == nil {
88+
firstErr = err
89+
}
90+
}
91+
return firstErr
92+
}

modkit/kernel/container.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package kernel
22

33
import (
44
"context"
5+
"io"
56
"sync"
67

78
"github.com/go-modkit/modkit/modkit/module"
@@ -20,6 +21,8 @@ type Container struct {
2021
locks map[module.Token]*sync.Mutex
2122
waitingOn map[module.Token]module.Token
2223
cleanupHooks []func(context.Context) error
24+
closers []io.Closer
25+
buildOrder []module.Token
2326
mu sync.Mutex
2427
}
2528

@@ -48,6 +51,8 @@ func newContainer(graph *Graph, visibility Visibility) (*Container, error) {
4851
locks: make(map[module.Token]*sync.Mutex),
4952
waitingOn: make(map[module.Token]module.Token),
5053
cleanupHooks: make([]func(context.Context) error, 0),
54+
closers: make([]io.Closer, 0),
55+
buildOrder: make([]module.Token, 0),
5156
}, nil
5257
}
5358

@@ -110,6 +115,10 @@ func (c *Container) getWithStack(token module.Token, requester string, stack []m
110115
if entry.cleanup != nil {
111116
c.cleanupHooks = append(c.cleanupHooks, entry.cleanup)
112117
}
118+
if closer, ok := instance.(io.Closer); ok {
119+
c.closers = append(c.closers, closer)
120+
}
121+
c.buildOrder = append(c.buildOrder, token)
113122
c.mu.Unlock()
114123
return instance, nil
115124
}
@@ -125,6 +134,35 @@ func (c *Container) cleanupHooksLIFO() []func(context.Context) error {
125134
return hooks
126135
}
127136

137+
func (c *Container) closersLIFO() []io.Closer {
138+
c.mu.Lock()
139+
defer c.mu.Unlock()
140+
141+
closers := make([]io.Closer, len(c.closers))
142+
for i, closer := range c.closers {
143+
closers[len(c.closers)-1-i] = closer
144+
}
145+
return closers
146+
}
147+
148+
func (c *Container) closersInBuildOrder() []io.Closer {
149+
c.mu.Lock()
150+
defer c.mu.Unlock()
151+
152+
closers := make([]io.Closer, len(c.closers))
153+
copy(closers, c.closers)
154+
return closers
155+
}
156+
157+
func (c *Container) providerBuildOrder() []module.Token {
158+
c.mu.Lock()
159+
defer c.mu.Unlock()
160+
161+
order := make([]module.Token, len(c.buildOrder))
162+
copy(order, c.buildOrder)
163+
return order
164+
}
165+
128166
type moduleResolver struct {
129167
container *Container
130168
moduleName string
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package kernel
2+
3+
import (
4+
"testing"
5+
6+
"github.com/go-modkit/modkit/modkit/module"
7+
)
8+
9+
type modHelperInternal struct {
10+
def module.ModuleDef
11+
}
12+
13+
func (m *modHelperInternal) Definition() module.ModuleDef {
14+
return m.def
15+
}
16+
17+
func modInternal(
18+
name string,
19+
imports []module.Module,
20+
providers []module.ProviderDef,
21+
controllers []module.ControllerDef,
22+
exports []module.Token,
23+
) module.Module {
24+
return &modHelperInternal{
25+
def: module.ModuleDef{
26+
Name: name,
27+
Imports: imports,
28+
Providers: providers,
29+
Controllers: controllers,
30+
Exports: exports,
31+
},
32+
}
33+
}
34+
35+
func TestContainerRecordsProviderBuildOrder(t *testing.T) {
36+
first := module.Token("provider.first")
37+
second := module.Token("provider.second")
38+
39+
modA := modInternal("A", nil,
40+
[]module.ProviderDef{{
41+
Token: first,
42+
Build: func(r module.Resolver) (any, error) {
43+
return "first", nil
44+
},
45+
}, {
46+
Token: second,
47+
Build: func(r module.Resolver) (any, error) {
48+
return "second", nil
49+
},
50+
}},
51+
nil,
52+
nil,
53+
)
54+
55+
app, err := Bootstrap(modA)
56+
if err != nil {
57+
t.Fatalf("Bootstrap failed: %v", err)
58+
}
59+
60+
if _, err := app.Get(second); err != nil {
61+
t.Fatalf("Get second failed: %v", err)
62+
}
63+
if _, err := app.Get(first); err != nil {
64+
t.Fatalf("Get first failed: %v", err)
65+
}
66+
67+
order := app.container.providerBuildOrder()
68+
if len(order) != 2 {
69+
t.Fatalf("expected 2 providers, got %d", len(order))
70+
}
71+
if order[0] != second || order[1] != first {
72+
t.Fatalf("unexpected order: %v", order)
73+
}
74+
}
75+
76+
func TestContainerRecordsClosersInBuildOrder(t *testing.T) {
77+
closerA := module.Token("closer.a")
78+
closerB := module.Token("closer.b")
79+
80+
modA := modInternal("A", nil,
81+
[]module.ProviderDef{{
82+
Token: closerA,
83+
Build: func(r module.Resolver) (any, error) {
84+
return &testCloser{name: "a"}, nil
85+
},
86+
}, {
87+
Token: closerB,
88+
Build: func(r module.Resolver) (any, error) {
89+
return &testCloser{name: "b"}, nil
90+
},
91+
}},
92+
nil,
93+
nil,
94+
)
95+
96+
app, err := Bootstrap(modA)
97+
if err != nil {
98+
t.Fatalf("Bootstrap failed: %v", err)
99+
}
100+
101+
_, _ = app.Get(closerA)
102+
_, _ = app.Get(closerB)
103+
104+
closers := app.container.closersInBuildOrder()
105+
if len(closers) != 2 {
106+
t.Fatalf("expected 2 closers, got %d", len(closers))
107+
}
108+
109+
first, ok := closers[0].(*testCloser)
110+
if !ok {
111+
t.Fatalf("expected *testCloser, got %T", closers[0])
112+
}
113+
second, ok := closers[1].(*testCloser)
114+
if !ok {
115+
t.Fatalf("expected *testCloser, got %T", closers[1])
116+
}
117+
if first.Name() != "a" || second.Name() != "b" {
118+
t.Fatalf("unexpected order: %v", closers)
119+
}
120+
}
121+
122+
type testCloser struct {
123+
name string
124+
}
125+
126+
func (c *testCloser) Close() error {
127+
return nil
128+
}
129+
130+
func (c *testCloser) Name() string {
131+
return c.name
132+
}

0 commit comments

Comments
 (0)