Skip to content

Commit e456799

Browse files
authored
Add template build time stats (#4557)
- Expose time in Template pages - Show progress bar when building a workspace
1 parent 5b7d204 commit e456799

File tree

18 files changed

+445
-12
lines changed

18 files changed

+445
-12
lines changed

coderd/database/databasefake/databasefake.go

+29
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,35 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) (
235235
return rs, nil
236236
}
237237

238+
func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (float64, error) {
239+
var times []float64
240+
for _, wb := range q.workspaceBuilds {
241+
if wb.Transition != database.WorkspaceTransitionStart {
242+
continue
243+
}
244+
version, err := q.GetTemplateVersionByID(ctx, wb.TemplateVersionID)
245+
if err != nil {
246+
return -1, err
247+
}
248+
if version.TemplateID != arg.TemplateID {
249+
continue
250+
}
251+
252+
job, err := q.GetProvisionerJobByID(ctx, wb.JobID)
253+
if err != nil {
254+
return -1, err
255+
}
256+
if job.CompletedAt.Valid {
257+
times = append(times, job.CompletedAt.Time.Sub(job.StartedAt.Time).Seconds())
258+
}
259+
}
260+
sort.Float64s(times)
261+
if len(times) == 0 {
262+
return -1, nil
263+
}
264+
return times[len(times)/2], nil
265+
}
266+
238267
func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database.ParameterValue, error) {
239268
q.mutex.Lock()
240269
defer q.mutex.Unlock()

coderd/database/querier.go

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

coderd/database/queries.sql.go

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

coderd/database/queries/templates.sql

+24
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,27 @@ WHERE
105105
id = $1
106106
RETURNING
107107
*;
108+
109+
-- name: GetTemplateAverageBuildTime :one
110+
WITH build_times AS (
111+
SELECT
112+
EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec
113+
FROM
114+
workspace_builds
115+
JOIN template_versions ON
116+
workspace_builds.template_version_id = template_versions.id
117+
JOIN provisioner_jobs pj ON
118+
workspace_builds.job_id = pj.id
119+
WHERE
120+
template_versions.template_id = @template_id AND
121+
(workspace_builds.transition = 'start') AND
122+
(pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND
123+
(pj.started_at > @start_time) AND
124+
(pj.canceled_at IS NULL) AND
125+
((pj.error IS NULL) OR (pj.error = ''))
126+
ORDER BY
127+
workspace_builds.created_at DESC
128+
)
129+
SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT
130+
FROM build_times
131+
;

coderd/metricscache/metricscache.go

+38-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package metricscache
22

33
import (
44
"context"
5+
"database/sql"
56
"sync/atomic"
67
"time"
78

@@ -17,7 +18,7 @@ import (
1718
"github.com/coder/retry"
1819
)
1920

20-
// Cache holds the template DAU cache.
21+
// Cache holds the template metrics.
2122
// The aggregation queries responsible for these values can take up to a minute
2223
// on large deployments. Even in small deployments, aggregation queries can
2324
// take a few hundred milliseconds, which would ruin page load times and
@@ -26,8 +27,9 @@ type Cache struct {
2627
database database.Store
2728
log slog.Logger
2829

29-
templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse]
30-
templateUniqueUsers atomic.Pointer[map[uuid.UUID]int]
30+
templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse]
31+
templateUniqueUsers atomic.Pointer[map[uuid.UUID]int]
32+
templateAverageBuildTime atomic.Pointer[map[uuid.UUID]time.Duration]
3133

3234
done chan struct{}
3335
cancel func()
@@ -128,8 +130,9 @@ func (c *Cache) refresh(ctx context.Context) error {
128130
}
129131

130132
var (
131-
templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates))
132-
templateUniqueUsers = make(map[uuid.UUID]int)
133+
templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates))
134+
templateUniqueUsers = make(map[uuid.UUID]int)
135+
templateAverageBuildTimeSec = make(map[uuid.UUID]time.Duration)
133136
)
134137
for _, template := range templates {
135138
rows, err := c.database.GetTemplateDAUs(ctx, template.ID)
@@ -138,9 +141,24 @@ func (c *Cache) refresh(ctx context.Context) error {
138141
}
139142
templateDAUs[template.ID] = convertDAUResponse(rows)
140143
templateUniqueUsers[template.ID] = countUniqueUsers(rows)
144+
templateAvgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{
145+
TemplateID: uuid.NullUUID{
146+
UUID: template.ID,
147+
Valid: true,
148+
},
149+
StartTime: sql.NullTime{
150+
Time: database.Time(time.Now().AddDate(0, -30, 0)),
151+
Valid: true,
152+
},
153+
})
154+
if err != nil {
155+
return err
156+
}
157+
templateAverageBuildTimeSec[template.ID] = time.Duration(float64(time.Second) * templateAvgBuildTime)
141158
}
142159
c.templateDAUResponses.Store(&templateDAUs)
143160
c.templateUniqueUsers.Store(&templateUniqueUsers)
161+
c.templateAverageBuildTime.Store(&templateAverageBuildTimeSec)
144162

145163
return nil
146164
}
@@ -220,3 +238,18 @@ func (c *Cache) TemplateUniqueUsers(id uuid.UUID) (int, bool) {
220238
}
221239
return resp, true
222240
}
241+
242+
func (c *Cache) TemplateAverageBuildTime(id uuid.UUID) (time.Duration, bool) {
243+
m := c.templateAverageBuildTime.Load()
244+
if m == nil {
245+
// Data loading.
246+
return -1, false
247+
}
248+
249+
resp, ok := (*m)[id]
250+
if !ok || resp <= 0 {
251+
// No data or not enough builds.
252+
return -1, false
253+
}
254+
return resp, true
255+
}

coderd/metricscache/metricscache_test.go

+152-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package metricscache_test
22

33
import (
44
"context"
5+
"database/sql"
56
"testing"
67
"time"
78

@@ -20,7 +21,7 @@ func date(year, month, day int) time.Time {
2021
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
2122
}
2223

23-
func TestCache(t *testing.T) {
24+
func TestCache_TemplateUsers(t *testing.T) {
2425
t.Parallel()
2526

2627
var (
@@ -197,3 +198,153 @@ func TestCache(t *testing.T) {
197198
})
198199
}
199200
}
201+
202+
func clockTime(t time.Time, hour, minute, sec int) time.Time {
203+
return time.Date(t.Year(), t.Month(), t.Day(), hour, minute, sec, t.Nanosecond(), t.Location())
204+
}
205+
206+
func TestCache_BuildTime(t *testing.T) {
207+
t.Parallel()
208+
209+
someDay := date(2022, 10, 1)
210+
211+
type jobParams struct {
212+
startedAt time.Time
213+
completedAt time.Time
214+
}
215+
216+
type args struct {
217+
rows []jobParams
218+
}
219+
type want struct {
220+
buildTime time.Duration
221+
}
222+
tests := []struct {
223+
name string
224+
args args
225+
want want
226+
}{
227+
{"empty", args{}, want{-1}},
228+
{"one", args{
229+
rows: []jobParams{
230+
{
231+
startedAt: clockTime(someDay, 10, 1, 0),
232+
completedAt: clockTime(someDay, 10, 1, 10),
233+
},
234+
},
235+
}, want{time.Second * 10},
236+
},
237+
{"two", args{
238+
rows: []jobParams{
239+
{
240+
startedAt: clockTime(someDay, 10, 1, 0),
241+
completedAt: clockTime(someDay, 10, 1, 10),
242+
},
243+
{
244+
startedAt: clockTime(someDay, 10, 1, 0),
245+
completedAt: clockTime(someDay, 10, 1, 50),
246+
},
247+
},
248+
}, want{time.Second * 50},
249+
},
250+
{"three", args{
251+
rows: []jobParams{
252+
{
253+
startedAt: clockTime(someDay, 10, 1, 0),
254+
completedAt: clockTime(someDay, 10, 1, 10),
255+
},
256+
{
257+
startedAt: clockTime(someDay, 10, 1, 0),
258+
completedAt: clockTime(someDay, 10, 1, 50),
259+
}, {
260+
startedAt: clockTime(someDay, 10, 1, 0),
261+
completedAt: clockTime(someDay, 10, 1, 20),
262+
},
263+
},
264+
}, want{time.Second * 20},
265+
},
266+
}
267+
268+
for _, tt := range tests {
269+
tt := tt
270+
t.Run(tt.name, func(t *testing.T) {
271+
t.Parallel()
272+
ctx := context.Background()
273+
274+
var (
275+
db = databasefake.New()
276+
cache = metricscache.New(db, slogtest.Make(t, nil), testutil.IntervalFast)
277+
)
278+
279+
defer cache.Close()
280+
281+
template, err := db.InsertTemplate(ctx, database.InsertTemplateParams{
282+
ID: uuid.New(),
283+
})
284+
require.NoError(t, err)
285+
286+
templateVersion, err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{
287+
ID: uuid.New(),
288+
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
289+
})
290+
require.NoError(t, err)
291+
292+
gotBuildTime, ok := cache.TemplateAverageBuildTime(template.ID)
293+
require.False(t, ok, "template shouldn't have loaded yet")
294+
require.EqualValues(t, -1, gotBuildTime)
295+
296+
for _, row := range tt.args.rows {
297+
_, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
298+
ID: uuid.New(),
299+
Provisioner: database.ProvisionerTypeEcho,
300+
})
301+
require.NoError(t, err)
302+
303+
job, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
304+
StartedAt: sql.NullTime{Time: row.startedAt, Valid: true},
305+
Types: []database.ProvisionerType{
306+
database.ProvisionerTypeEcho,
307+
},
308+
})
309+
require.NoError(t, err)
310+
311+
_, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
312+
TemplateVersionID: templateVersion.ID,
313+
JobID: job.ID,
314+
Transition: database.WorkspaceTransitionStart,
315+
})
316+
require.NoError(t, err)
317+
318+
err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
319+
ID: job.ID,
320+
CompletedAt: sql.NullTime{Time: row.completedAt, Valid: true},
321+
})
322+
require.NoError(t, err)
323+
}
324+
325+
if tt.want.buildTime > 0 {
326+
require.Eventuallyf(t, func() bool {
327+
_, ok := cache.TemplateAverageBuildTime(template.ID)
328+
return ok
329+
}, testutil.WaitShort, testutil.IntervalMedium,
330+
"TemplateDAUs never populated",
331+
)
332+
333+
gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID)
334+
require.True(t, ok)
335+
require.Equal(t, tt.want.buildTime, gotBuildTime)
336+
} else {
337+
require.Never(t, func() bool {
338+
_, ok := cache.TemplateAverageBuildTime(template.ID)
339+
return ok
340+
}, testutil.WaitShort/2, testutil.IntervalMedium,
341+
"TemplateDAUs never populated",
342+
)
343+
344+
gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID)
345+
require.False(t, ok)
346+
require.Less(t, gotBuildTime, time.Duration(0))
347+
}
348+
})
349+
}
350+
}

coderd/templates.go

+10
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,15 @@ func (api *API) convertTemplate(
773773
template database.Template, workspaceOwnerCount uint32, createdByName string,
774774
) codersdk.Template {
775775
activeCount, _ := api.metricsCache.TemplateUniqueUsers(template.ID)
776+
777+
var averageBuildTimeMillis int64
778+
averageBuildTime, ok := api.metricsCache.TemplateAverageBuildTime(template.ID)
779+
if !ok {
780+
averageBuildTimeMillis = -1
781+
} else {
782+
averageBuildTimeMillis = int64(averageBuildTime / time.Millisecond)
783+
}
784+
776785
return codersdk.Template{
777786
ID: template.ID,
778787
CreatedAt: template.CreatedAt,
@@ -783,6 +792,7 @@ func (api *API) convertTemplate(
783792
ActiveVersionID: template.ActiveVersionID,
784793
WorkspaceOwnerCount: workspaceOwnerCount,
785794
ActiveUserCount: activeCount,
795+
AverageBuildTimeMillis: averageBuildTimeMillis,
786796
Description: template.Description,
787797
Icon: template.Icon,
788798
MaxTTLMillis: time.Duration(template.MaxTtl).Milliseconds(),

0 commit comments

Comments
 (0)