Add template build time stats (#4557)

- Expose time in Template pages
- Show progress bar when building a workspace
This commit is contained in:
Ammar Bandukwala
2022-10-15 15:36:50 -05:00
committed by GitHub
parent 5b7d204b9d
commit e456799f1a
18 changed files with 445 additions and 12 deletions

View File

@ -235,6 +235,35 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) (
return rs, nil
}
func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (float64, error) {
var times []float64
for _, wb := range q.workspaceBuilds {
if wb.Transition != database.WorkspaceTransitionStart {
continue
}
version, err := q.GetTemplateVersionByID(ctx, wb.TemplateVersionID)
if err != nil {
return -1, err
}
if version.TemplateID != arg.TemplateID {
continue
}
job, err := q.GetProvisionerJobByID(ctx, wb.JobID)
if err != nil {
return -1, err
}
if job.CompletedAt.Valid {
times = append(times, job.CompletedAt.Time.Sub(job.StartedAt.Time).Seconds())
}
}
sort.Float64s(times)
if len(times) == 0 {
return -1, nil
}
return times[len(times)/2], nil
}
func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database.ParameterValue, error) {
q.mutex.Lock()
defer q.mutex.Unlock()

View File

@ -67,6 +67,7 @@ type sqlcQuerier interface {
GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error)
GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error)
GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error)
GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (float64, error)
GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error)
GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error)
GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error)

View File

@ -2597,6 +2597,42 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error
return err
}
const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one
WITH build_times AS (
SELECT
EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec
FROM
workspace_builds
JOIN template_versions ON
workspace_builds.template_version_id = template_versions.id
JOIN provisioner_jobs pj ON
workspace_builds.job_id = pj.id
WHERE
template_versions.template_id = $1 AND
(workspace_builds.transition = 'start') AND
(pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND
(pj.started_at > $2) AND
(pj.canceled_at IS NULL) AND
((pj.error IS NULL) OR (pj.error = ''))
ORDER BY
workspace_builds.created_at DESC
)
SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT
FROM build_times
`
type GetTemplateAverageBuildTimeParams struct {
TemplateID uuid.NullUUID `db:"template_id" json:"template_id"`
StartTime sql.NullTime `db:"start_time" json:"start_time"`
}
func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (float64, error) {
row := q.db.QueryRowContext(ctx, getTemplateAverageBuildTime, arg.TemplateID, arg.StartTime)
var column_1 float64
err := row.Scan(&column_1)
return column_1, err
}
const getTemplateByID = `-- name: GetTemplateByID :one
SELECT
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl

View File

@ -105,3 +105,27 @@ WHERE
id = $1
RETURNING
*;
-- name: GetTemplateAverageBuildTime :one
WITH build_times AS (
SELECT
EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec
FROM
workspace_builds
JOIN template_versions ON
workspace_builds.template_version_id = template_versions.id
JOIN provisioner_jobs pj ON
workspace_builds.job_id = pj.id
WHERE
template_versions.template_id = @template_id AND
(workspace_builds.transition = 'start') AND
(pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND
(pj.started_at > @start_time) AND
(pj.canceled_at IS NULL) AND
((pj.error IS NULL) OR (pj.error = ''))
ORDER BY
workspace_builds.created_at DESC
)
SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT
FROM build_times
;

View File

@ -2,6 +2,7 @@ package metricscache
import (
"context"
"database/sql"
"sync/atomic"
"time"
@ -17,7 +18,7 @@ import (
"github.com/coder/retry"
)
// Cache holds the template DAU cache.
// Cache holds the template metrics.
// The aggregation queries responsible for these values can take up to a minute
// on large deployments. Even in small deployments, aggregation queries can
// take a few hundred milliseconds, which would ruin page load times and
@ -26,8 +27,9 @@ type Cache struct {
database database.Store
log slog.Logger
templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse]
templateUniqueUsers atomic.Pointer[map[uuid.UUID]int]
templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse]
templateUniqueUsers atomic.Pointer[map[uuid.UUID]int]
templateAverageBuildTime atomic.Pointer[map[uuid.UUID]time.Duration]
done chan struct{}
cancel func()
@ -128,8 +130,9 @@ func (c *Cache) refresh(ctx context.Context) error {
}
var (
templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates))
templateUniqueUsers = make(map[uuid.UUID]int)
templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates))
templateUniqueUsers = make(map[uuid.UUID]int)
templateAverageBuildTimeSec = make(map[uuid.UUID]time.Duration)
)
for _, template := range templates {
rows, err := c.database.GetTemplateDAUs(ctx, template.ID)
@ -138,9 +141,24 @@ func (c *Cache) refresh(ctx context.Context) error {
}
templateDAUs[template.ID] = convertDAUResponse(rows)
templateUniqueUsers[template.ID] = countUniqueUsers(rows)
templateAvgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{
TemplateID: uuid.NullUUID{
UUID: template.ID,
Valid: true,
},
StartTime: sql.NullTime{
Time: database.Time(time.Now().AddDate(0, -30, 0)),
Valid: true,
},
})
if err != nil {
return err
}
templateAverageBuildTimeSec[template.ID] = time.Duration(float64(time.Second) * templateAvgBuildTime)
}
c.templateDAUResponses.Store(&templateDAUs)
c.templateUniqueUsers.Store(&templateUniqueUsers)
c.templateAverageBuildTime.Store(&templateAverageBuildTimeSec)
return nil
}
@ -220,3 +238,18 @@ func (c *Cache) TemplateUniqueUsers(id uuid.UUID) (int, bool) {
}
return resp, true
}
func (c *Cache) TemplateAverageBuildTime(id uuid.UUID) (time.Duration, bool) {
m := c.templateAverageBuildTime.Load()
if m == nil {
// Data loading.
return -1, false
}
resp, ok := (*m)[id]
if !ok || resp <= 0 {
// No data or not enough builds.
return -1, false
}
return resp, true
}

View File

@ -2,6 +2,7 @@ package metricscache_test
import (
"context"
"database/sql"
"testing"
"time"
@ -20,7 +21,7 @@ func date(year, month, day int) time.Time {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}
func TestCache(t *testing.T) {
func TestCache_TemplateUsers(t *testing.T) {
t.Parallel()
var (
@ -197,3 +198,153 @@ func TestCache(t *testing.T) {
})
}
}
func clockTime(t time.Time, hour, minute, sec int) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), hour, minute, sec, t.Nanosecond(), t.Location())
}
func TestCache_BuildTime(t *testing.T) {
t.Parallel()
someDay := date(2022, 10, 1)
type jobParams struct {
startedAt time.Time
completedAt time.Time
}
type args struct {
rows []jobParams
}
type want struct {
buildTime time.Duration
}
tests := []struct {
name string
args args
want want
}{
{"empty", args{}, want{-1}},
{"one", args{
rows: []jobParams{
{
startedAt: clockTime(someDay, 10, 1, 0),
completedAt: clockTime(someDay, 10, 1, 10),
},
},
}, want{time.Second * 10},
},
{"two", args{
rows: []jobParams{
{
startedAt: clockTime(someDay, 10, 1, 0),
completedAt: clockTime(someDay, 10, 1, 10),
},
{
startedAt: clockTime(someDay, 10, 1, 0),
completedAt: clockTime(someDay, 10, 1, 50),
},
},
}, want{time.Second * 50},
},
{"three", args{
rows: []jobParams{
{
startedAt: clockTime(someDay, 10, 1, 0),
completedAt: clockTime(someDay, 10, 1, 10),
},
{
startedAt: clockTime(someDay, 10, 1, 0),
completedAt: clockTime(someDay, 10, 1, 50),
}, {
startedAt: clockTime(someDay, 10, 1, 0),
completedAt: clockTime(someDay, 10, 1, 20),
},
},
}, want{time.Second * 20},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
var (
db = databasefake.New()
cache = metricscache.New(db, slogtest.Make(t, nil), testutil.IntervalFast)
)
defer cache.Close()
template, err := db.InsertTemplate(ctx, database.InsertTemplateParams{
ID: uuid.New(),
})
require.NoError(t, err)
templateVersion, err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{
ID: uuid.New(),
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
})
require.NoError(t, err)
gotBuildTime, ok := cache.TemplateAverageBuildTime(template.ID)
require.False(t, ok, "template shouldn't have loaded yet")
require.EqualValues(t, -1, gotBuildTime)
for _, row := range tt.args.rows {
_, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
ID: uuid.New(),
Provisioner: database.ProvisionerTypeEcho,
})
require.NoError(t, err)
job, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
StartedAt: sql.NullTime{Time: row.startedAt, Valid: true},
Types: []database.ProvisionerType{
database.ProvisionerTypeEcho,
},
})
require.NoError(t, err)
_, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
TemplateVersionID: templateVersion.ID,
JobID: job.ID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
ID: job.ID,
CompletedAt: sql.NullTime{Time: row.completedAt, Valid: true},
})
require.NoError(t, err)
}
if tt.want.buildTime > 0 {
require.Eventuallyf(t, func() bool {
_, ok := cache.TemplateAverageBuildTime(template.ID)
return ok
}, testutil.WaitShort, testutil.IntervalMedium,
"TemplateDAUs never populated",
)
gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID)
require.True(t, ok)
require.Equal(t, tt.want.buildTime, gotBuildTime)
} else {
require.Never(t, func() bool {
_, ok := cache.TemplateAverageBuildTime(template.ID)
return ok
}, testutil.WaitShort/2, testutil.IntervalMedium,
"TemplateDAUs never populated",
)
gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID)
require.False(t, ok)
require.Less(t, gotBuildTime, time.Duration(0))
}
})
}
}

View File

@ -773,6 +773,15 @@ func (api *API) convertTemplate(
template database.Template, workspaceOwnerCount uint32, createdByName string,
) codersdk.Template {
activeCount, _ := api.metricsCache.TemplateUniqueUsers(template.ID)
var averageBuildTimeMillis int64
averageBuildTime, ok := api.metricsCache.TemplateAverageBuildTime(template.ID)
if !ok {
averageBuildTimeMillis = -1
} else {
averageBuildTimeMillis = int64(averageBuildTime / time.Millisecond)
}
return codersdk.Template{
ID: template.ID,
CreatedAt: template.CreatedAt,
@ -783,6 +792,7 @@ func (api *API) convertTemplate(
ActiveVersionID: template.ActiveVersionID,
WorkspaceOwnerCount: workspaceOwnerCount,
ActiveUserCount: activeCount,
AverageBuildTimeMillis: averageBuildTimeMillis,
Description: template.Description,
Icon: template.Icon,
MaxTTLMillis: time.Duration(template.MaxTtl).Milliseconds(),

View File

@ -561,7 +561,7 @@ func TestDeleteTemplate(t *testing.T) {
})
}
func TestTemplateDAUs(t *testing.T) {
func TestTemplateMetrics(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
@ -594,6 +594,7 @@ func TestTemplateDAUs(t *testing.T) {
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Equal(t, -1, template.ActiveUserCount)
require.EqualValues(t, -1, template.AverageBuildTimeMillis)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
@ -660,6 +661,7 @@ func TestTemplateDAUs(t *testing.T) {
template, err = client.Template(ctx, template.ID)
require.NoError(t, err)
require.Equal(t, 1, template.ActiveUserCount)
require.Greater(t, template.AverageBuildTimeMillis, int64(1))
workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err)