Skip to content

Show template average build #4545

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions coderd/database/queries/templates.sql
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,26 @@ WHERE
id = $1
RETURNING
*;

-- name: GetTemplateAverageBuildTime :one
WITH build_times AS (
SELECT
EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at)) 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 CAST(PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) AS FLOAT) FROM build_times
;
43 changes: 38 additions & 5 deletions coderd/metricscache/metricscache.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package metricscache

import (
"context"
"database/sql"
"sync/atomic"
"time"

Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -138,9 +141,24 @@ func (c *Cache) refresh(ctx context.Context) error {
}
templateDAUs[template.ID] = convertDAUResponse(rows)
templateUniqueUsers[template.ID] = countUniqueUsers(rows)
avgBuildTime, 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) * avgBuildTime)
}
c.templateDAUResponses.Store(&templateDAUs)
c.templateUniqueUsers.Store(&templateUniqueUsers)
c.templateAverageBuildTime.Store(&templateAverageBuildTimeSec)

return nil
}
Expand Down Expand Up @@ -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
}
153 changes: 152 additions & 1 deletion coderd/metricscache/metricscache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package metricscache_test

import (
"context"
"database/sql"
"testing"
"time"

Expand All @@ -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 (
Expand Down Expand Up @@ -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))
}
})
}
}
10 changes: 10 additions & 0 deletions coderd/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
Loading