Skip to content

Commit 823127e

Browse files
authored
feat: add healthcheck database section (#8060)
1 parent 2db4488 commit 823127e

File tree

11 files changed

+284
-12
lines changed

11 files changed

+284
-12
lines changed

coderd/apidoc/docs.go

+18
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+18
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ func New(options *Options) *API {
262262
if options.HealthcheckFunc == nil {
263263
options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthcheck.Report {
264264
return healthcheck.Run(ctx, &healthcheck.ReportOptions{
265+
DB: options.Database,
265266
AccessURL: options.AccessURL,
266267
DERPMap: options.DERPMap.Clone(),
267268
APIKey: apiKey,

coderd/healthcheck/accessurl.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ type AccessURLReport struct {
1818
Error error `json:"error"`
1919
}
2020

21-
type AccessURLOptions struct {
21+
type AccessURLReportOptions struct {
2222
AccessURL *url.URL
2323
Client *http.Client
2424
}
2525

26-
func (r *AccessURLReport) Run(ctx context.Context, opts *AccessURLOptions) {
26+
func (r *AccessURLReport) Run(ctx context.Context, opts *AccessURLReportOptions) {
2727
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
2828
defer cancel()
2929

coderd/healthcheck/accessurl_test.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestAccessURL(t *testing.T) {
2828
)
2929
defer cancel()
3030

31-
report.Run(ctx, &healthcheck.AccessURLOptions{
31+
report.Run(ctx, &healthcheck.AccessURLReportOptions{
3232
AccessURL: client.URL,
3333
})
3434

@@ -57,7 +57,7 @@ func TestAccessURL(t *testing.T) {
5757
u, err := url.Parse(srv.URL)
5858
require.NoError(t, err)
5959

60-
report.Run(ctx, &healthcheck.AccessURLOptions{
60+
report.Run(ctx, &healthcheck.AccessURLReportOptions{
6161
Client: srv.Client(),
6262
AccessURL: u,
6363
})
@@ -93,7 +93,7 @@ func TestAccessURL(t *testing.T) {
9393
u, err := url.Parse(srv.URL)
9494
require.NoError(t, err)
9595

96-
report.Run(ctx, &healthcheck.AccessURLOptions{
96+
report.Run(ctx, &healthcheck.AccessURLReportOptions{
9797
Client: client,
9898
AccessURL: u,
9999
})

coderd/healthcheck/database.go

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package healthcheck
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"golang.org/x/exp/slices"
8+
"golang.org/x/xerrors"
9+
10+
"github.com/coder/coder/coderd/database"
11+
)
12+
13+
type DatabaseReport struct {
14+
Healthy bool `json:"healthy"`
15+
Reachable bool `json:"reachable"`
16+
Latency time.Duration `json:"latency"`
17+
Error error `json:"error"`
18+
}
19+
20+
type DatabaseReportOptions struct {
21+
DB database.Store
22+
}
23+
24+
func (r *DatabaseReport) Run(ctx context.Context, opts *DatabaseReportOptions) {
25+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
26+
defer cancel()
27+
28+
pingCount := 5
29+
pings := make([]time.Duration, 0, pingCount)
30+
// Ping 5 times and average the latency.
31+
for i := 0; i < pingCount; i++ {
32+
pong, err := opts.DB.Ping(ctx)
33+
if err != nil {
34+
r.Error = xerrors.Errorf("ping: %w", err)
35+
return
36+
}
37+
pings = append(pings, pong)
38+
}
39+
slices.Sort(pings)
40+
41+
// Take the median ping.
42+
r.Latency = pings[pingCount/2]
43+
// Somewhat arbitrary, but if the latency is over 15ms, we consider it
44+
// unhealthy.
45+
if r.Latency < 15*time.Millisecond {
46+
r.Healthy = true
47+
}
48+
r.Reachable = true
49+
}

coderd/healthcheck/database_test.go

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package healthcheck_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/golang/mock/gomock"
9+
"github.com/stretchr/testify/assert"
10+
"golang.org/x/xerrors"
11+
12+
"github.com/coder/coder/coderd/database/dbmock"
13+
"github.com/coder/coder/coderd/healthcheck"
14+
"github.com/coder/coder/testutil"
15+
)
16+
17+
func TestDatabase(t *testing.T) {
18+
t.Parallel()
19+
20+
t.Run("OK", func(t *testing.T) {
21+
t.Parallel()
22+
23+
var (
24+
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
25+
report = healthcheck.DatabaseReport{}
26+
db = dbmock.NewMockStore(gomock.NewController(t))
27+
ping = 10 * time.Millisecond
28+
)
29+
defer cancel()
30+
31+
db.EXPECT().Ping(gomock.Any()).Return(ping, nil).Times(5)
32+
33+
report.Run(ctx, &healthcheck.DatabaseReportOptions{DB: db})
34+
35+
assert.True(t, report.Healthy)
36+
assert.True(t, report.Reachable)
37+
assert.Equal(t, ping, report.Latency)
38+
assert.NoError(t, report.Error)
39+
})
40+
41+
t.Run("Error", func(t *testing.T) {
42+
t.Parallel()
43+
44+
var (
45+
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
46+
report = healthcheck.DatabaseReport{}
47+
db = dbmock.NewMockStore(gomock.NewController(t))
48+
err = xerrors.New("ping error")
49+
)
50+
defer cancel()
51+
52+
db.EXPECT().Ping(gomock.Any()).Return(time.Duration(0), err)
53+
54+
report.Run(ctx, &healthcheck.DatabaseReportOptions{DB: db})
55+
56+
assert.False(t, report.Healthy)
57+
assert.False(t, report.Reachable)
58+
assert.Zero(t, report.Latency)
59+
assert.ErrorIs(t, report.Error, err)
60+
})
61+
62+
t.Run("Median", func(t *testing.T) {
63+
t.Parallel()
64+
65+
var (
66+
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
67+
report = healthcheck.DatabaseReport{}
68+
db = dbmock.NewMockStore(gomock.NewController(t))
69+
)
70+
defer cancel()
71+
72+
db.EXPECT().Ping(gomock.Any()).Return(time.Microsecond, nil)
73+
db.EXPECT().Ping(gomock.Any()).Return(time.Second, nil)
74+
db.EXPECT().Ping(gomock.Any()).Return(time.Nanosecond, nil)
75+
db.EXPECT().Ping(gomock.Any()).Return(time.Minute, nil)
76+
db.EXPECT().Ping(gomock.Any()).Return(time.Millisecond, nil)
77+
78+
report.Run(ctx, &healthcheck.DatabaseReportOptions{DB: db})
79+
80+
assert.True(t, report.Healthy)
81+
assert.True(t, report.Reachable)
82+
assert.Equal(t, time.Millisecond, report.Latency)
83+
assert.NoError(t, report.Error)
84+
})
85+
}

coderd/healthcheck/healthcheck.go

+31-3
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,22 @@ import (
99

1010
"golang.org/x/xerrors"
1111
"tailscale.com/tailcfg"
12+
13+
"github.com/coder/coder/coderd/database"
1214
)
1315

1416
const (
1517
SectionDERP string = "DERP"
1618
SectionAccessURL string = "AccessURL"
1719
SectionWebsocket string = "Websocket"
20+
SectionDatabase string = "Database"
1821
)
1922

2023
type Checker interface {
2124
DERP(ctx context.Context, opts *DERPReportOptions) DERPReport
22-
AccessURL(ctx context.Context, opts *AccessURLOptions) AccessURLReport
25+
AccessURL(ctx context.Context, opts *AccessURLReportOptions) AccessURLReport
2326
Websocket(ctx context.Context, opts *WebsocketReportOptions) WebsocketReport
27+
Database(ctx context.Context, opts *DatabaseReportOptions) DatabaseReport
2428
}
2529

2630
type Report struct {
@@ -33,9 +37,11 @@ type Report struct {
3337
DERP DERPReport `json:"derp"`
3438
AccessURL AccessURLReport `json:"access_url"`
3539
Websocket WebsocketReport `json:"websocket"`
40+
Database DatabaseReport `json:"database"`
3641
}
3742

3843
type ReportOptions struct {
44+
DB database.Store
3945
// TODO: support getting this over HTTP?
4046
DERPMap *tailcfg.DERPMap
4147
AccessURL *url.URL
@@ -52,7 +58,7 @@ func (defaultChecker) DERP(ctx context.Context, opts *DERPReportOptions) (report
5258
return report
5359
}
5460

55-
func (defaultChecker) AccessURL(ctx context.Context, opts *AccessURLOptions) (report AccessURLReport) {
61+
func (defaultChecker) AccessURL(ctx context.Context, opts *AccessURLReportOptions) (report AccessURLReport) {
5662
report.Run(ctx, opts)
5763
return report
5864
}
@@ -62,6 +68,11 @@ func (defaultChecker) Websocket(ctx context.Context, opts *WebsocketReportOption
6268
return report
6369
}
6470

71+
func (defaultChecker) Database(ctx context.Context, opts *DatabaseReportOptions) (report DatabaseReport) {
72+
report.Run(ctx, opts)
73+
return report
74+
}
75+
6576
func Run(ctx context.Context, opts *ReportOptions) *Report {
6677
var (
6778
wg sync.WaitGroup
@@ -95,7 +106,7 @@ func Run(ctx context.Context, opts *ReportOptions) *Report {
95106
}
96107
}()
97108

98-
report.AccessURL = opts.Checker.AccessURL(ctx, &AccessURLOptions{
109+
report.AccessURL = opts.Checker.AccessURL(ctx, &AccessURLReportOptions{
99110
AccessURL: opts.AccessURL,
100111
Client: opts.Client,
101112
})
@@ -116,6 +127,20 @@ func Run(ctx context.Context, opts *ReportOptions) *Report {
116127
})
117128
}()
118129

130+
wg.Add(1)
131+
go func() {
132+
defer wg.Done()
133+
defer func() {
134+
if err := recover(); err != nil {
135+
report.Database.Error = xerrors.Errorf("%v", err)
136+
}
137+
}()
138+
139+
report.Database = opts.Checker.Database(ctx, &DatabaseReportOptions{
140+
DB: opts.DB,
141+
})
142+
}()
143+
119144
wg.Wait()
120145
report.Time = time.Now()
121146
if !report.DERP.Healthy {
@@ -127,6 +152,9 @@ func Run(ctx context.Context, opts *ReportOptions) *Report {
127152
if !report.Websocket.Healthy {
128153
report.FailingSections = append(report.FailingSections, SectionWebsocket)
129154
}
155+
if !report.Database.Healthy {
156+
report.FailingSections = append(report.FailingSections, SectionDatabase)
157+
}
130158

131159
report.Healthy = len(report.FailingSections) == 0
132160
return &report

0 commit comments

Comments
 (0)