-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscope.go
More file actions
188 lines (159 loc) · 5.21 KB
/
scope.go
File metadata and controls
188 lines (159 loc) · 5.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
package initd
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
)
// Scope provides registration capabilities scoped to a named component.
// It carries the component's context and methods for registering
// cleanup hooks and health probes. All registrations are automatically named
// after the parent [Value] or [Exec] component.
//
// The context returned by [Scope.Context] carries the component name.
// Use [Scope.Logger] to log with an automatic "component" attribution.
type Scope struct {
Logger *slog.Logger
app *App
ctx context.Context
name string
}
// Context returns the context for this component.
// It carries the component name for automatic log attribution
// via [slog.InfoContext].
func (s *Scope) Context() context.Context {
return s.ctx
}
// OnExit registers a teardown hook for this component.
// It will run in LIFO order:
// - after all Go tasks have drained,
// - and in the case of [Exec] when [Scope.Run] exits.
func (s *Scope) OnExit(fn func(context.Context) error) {
name := s.name
s.app.OnExit(name, func(ctx context.Context) error {
s.Logger.InfoContext(ctx, "teardown")
return fn(ctx)
})
}
// Readiness registers a readiness health check for this component.
func (s *Scope) Readiness(fn func(context.Context) error, opts ...ProbeOption) {
s.app.probes.register(readinessProbe, s.name, fn, opts...)
}
// Liveness registers a liveness health check for this component.
func (s *Scope) Liveness(fn func(context.Context) error, opts ...ProbeOption) {
s.app.probes.register(livenessProbe, s.name, fn, opts...)
}
// Go spawns a supervised goroutine tied to this component.
// If fn returns a non-context error, the application shuts down.
// The goroutine is tracked by the app's drain group, so shutdown
// waits for it to finish.
// It should respect the context cancellation, and exit gracefully.
// Return an error from fn (other than context.Canceled) will cause the app to shut down
func (s *Scope) Go(name string, fn func(ctx context.Context) error) {
s.app.wg.Add(1)
go func() {
err := s.run(name, fn)
s.app.wg.Done()
if err != nil && !errors.Is(err, context.Canceled) {
s.Logger.ErrorContext(s.ctx, "failed", "error", err, "task", name)
_ = s.app.lc.Exit(fmt.Errorf("task %q: %s: %w", s.name, name, err))
return
}
s.Logger.InfoContext(s.ctx, "stopped", "task", name)
}()
}
func (s *Scope) run(name string, fn func(ctx context.Context) error) (err error) {
defer func() {
if r := recover(); r != nil {
s.Logger.ErrorContext(s.ctx, "panic", "error", r, "task", name)
err = fmt.Errorf("task %q: panic: %v", s.name, r)
}
}()
err = fn(s.ctx)
return err
}
// Run hands a long-running task to initd and returns immediately.
// The task is expected to block until the context is canceled; if it returns for any reason other than
// context cancellation, initd triggers a graceful shutdown.
// May only be called once per [Scope].
// OnExit hooks must be registered before calling Run.
func (s *Scope) Run(task func(context.Context) error) error {
s.app.wg.Add(1)
go func() {
err := s.run(s.name, task)
s.app.wg.Done()
if errors.Is(err, context.Canceled) {
s.Logger.InfoContext(s.ctx, "stopped")
return
}
if err != nil {
s.Logger.ErrorContext(s.ctx, "stopped", "error", err)
} else {
s.Logger.InfoContext(s.ctx, "stopped")
}
if err == nil {
err = fmt.Errorf("%s: task returned unexpectedly", s.name)
}
_ = s.app.lc.Exit(err)
}()
return nil
}
func newScope(app *App, name string, ctx context.Context) *Scope {
return &Scope{
Logger: app.Logger.With("component", name),
app: app,
ctx: withComponent(ctx, name),
name: name,
}
}
// Value acquires a resource by running fn immediately with the app's context.
// The [Scope] provides the context, logger, and methods for registering
// cleanup hooks and health probes scoped to this component.
func Value[T any](app *App, name string, fn func(*Scope) (T, error)) (val T, err error) {
s := newScope(app, name, app.lc.Context())
select {
case <-app.lc.Stopping():
app.Logger.WarnContext(s.ctx, "skipped: app is shutting down")
var zero T
return zero, app.lc.Exit(nil)
default:
}
start := time.Now()
defer func() {
if r := recover(); r != nil {
s.Logger.ErrorContext(s.ctx, "panic", "error", r, "duration", time.Since(start))
err = app.lc.Exit(fmt.Errorf("%s: %w", name, err))
if app.errorLinger > 0 {
time.Sleep(app.errorLinger)
}
}
}()
val, err = fn(s)
d := time.Since(start)
if err != nil {
s.Logger.ErrorContext(s.ctx, "failed", "error", err, "duration", d)
err = app.lc.Exit(fmt.Errorf("%s: %w", name, err))
if app.errorLinger > 0 {
time.Sleep(app.errorLinger)
}
return val, err
}
s.Logger.InfoContext(s.ctx, "ready", "duration", d)
return val, nil
}
// Exec runs a void setup step immediately with the app's context.
// Like [Value], but for operations that don't return a value (migrations, cache warming, etc.).
// You can use [Scope.Run] to run a long-running task.
// Example:
//
// err := initd.Exec(app, "worder", func(s *initd.Scope) error {
// //....
// return s.Run(worker.Run)
// })
func Exec(app *App, name string, fn func(*Scope) error) error {
_, err := Value(app, name, func(s *Scope) (struct{}, error) {
return struct{}{}, fn(s)
})
return err
}