coder/coderd/metricscache/metricscache_test.go

533 lines
14 KiB
Go

package metricscache_test
import (
"context"
"database/sql"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmem"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/metricscache"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func dateH(year, month, day, hour int) time.Time {
return time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.UTC)
}
func date(year, month, day int) time.Time {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}
func TestCache_TemplateUsers(t *testing.T) {
t.Parallel()
statRow := func(user uuid.UUID, date time.Time) database.InsertWorkspaceAgentStatParams {
return database.InsertWorkspaceAgentStatParams{
CreatedAt: date,
UserID: user,
}
}
var (
zebra = uuid.UUID{1}
tiger = uuid.UUID{2}
)
type args struct {
rows []database.InsertWorkspaceAgentStatParams
}
type want struct {
entries []codersdk.DAUEntry
uniqueUsers int
}
tests := []struct {
name string
args args
tplWant want
// dauWant is optional
dauWant []codersdk.DAUEntry
tzOffset int
}{
{name: "empty", args: args{}, tplWant: want{nil, 0}},
{
name: "one hole",
args: args{
rows: []database.InsertWorkspaceAgentStatParams{
statRow(zebra, dateH(2022, 8, 27, 0)),
statRow(zebra, dateH(2022, 8, 30, 0)),
},
},
tplWant: want{[]codersdk.DAUEntry{
{
Date: metricscache.OnlyDate(date(2022, 8, 27)),
Amount: 1,
},
{
Date: metricscache.OnlyDate(date(2022, 8, 28)),
Amount: 0,
},
{
Date: metricscache.OnlyDate(date(2022, 8, 29)),
Amount: 0,
},
{
Date: metricscache.OnlyDate(date(2022, 8, 30)),
Amount: 1,
},
}, 1},
},
{
name: "no holes",
args: args{
rows: []database.InsertWorkspaceAgentStatParams{
statRow(zebra, dateH(2022, 8, 27, 0)),
statRow(zebra, dateH(2022, 8, 28, 0)),
statRow(zebra, dateH(2022, 8, 29, 0)),
},
},
tplWant: want{[]codersdk.DAUEntry{
{
Date: metricscache.OnlyDate(date(2022, 8, 27)),
Amount: 1,
},
{
Date: metricscache.OnlyDate(date(2022, 8, 28)),
Amount: 1,
},
{
Date: metricscache.OnlyDate(date(2022, 8, 29)),
Amount: 1,
},
}, 1},
},
{
name: "holes",
args: args{
rows: []database.InsertWorkspaceAgentStatParams{
statRow(zebra, dateH(2022, 1, 1, 0)),
statRow(tiger, dateH(2022, 1, 1, 0)),
statRow(zebra, dateH(2022, 1, 4, 0)),
statRow(zebra, dateH(2022, 1, 7, 0)),
statRow(tiger, dateH(2022, 1, 7, 0)),
},
},
tplWant: want{[]codersdk.DAUEntry{
{
Date: metricscache.OnlyDate(date(2022, 1, 1)),
Amount: 2,
},
{
Date: metricscache.OnlyDate(date(2022, 1, 2)),
Amount: 0,
},
{
Date: metricscache.OnlyDate(date(2022, 1, 3)),
Amount: 0,
},
{
Date: metricscache.OnlyDate(date(2022, 1, 4)),
Amount: 1,
},
{
Date: metricscache.OnlyDate(date(2022, 1, 5)),
Amount: 0,
},
{
Date: metricscache.OnlyDate(date(2022, 1, 6)),
Amount: 0,
},
{
Date: metricscache.OnlyDate(date(2022, 1, 7)),
Amount: 2,
},
}, 2},
},
{
name: "tzOffset",
tzOffset: 3,
args: args{
rows: []database.InsertWorkspaceAgentStatParams{
statRow(zebra, dateH(2022, 1, 2, 3)),
statRow(tiger, dateH(2022, 1, 2, 3)),
// With offset these should be in the previous day
statRow(zebra, dateH(2022, 1, 2, 0)),
statRow(tiger, dateH(2022, 1, 2, 0)),
},
},
tplWant: want{[]codersdk.DAUEntry{
{
Date: metricscache.OnlyDate(date(2022, 1, 2)),
Amount: 2,
},
}, 2},
dauWant: []codersdk.DAUEntry{
{
Date: metricscache.OnlyDate(date(2022, 1, 1)),
Amount: 2,
},
{
Date: metricscache.OnlyDate(date(2022, 1, 2)),
Amount: 2,
},
},
},
{
name: "tzOffsetPreviousDay",
tzOffset: 6,
args: args{
rows: []database.InsertWorkspaceAgentStatParams{
statRow(zebra, dateH(2022, 1, 2, 1)),
statRow(tiger, dateH(2022, 1, 2, 1)),
statRow(zebra, dateH(2022, 1, 2, 0)),
statRow(tiger, dateH(2022, 1, 2, 0)),
},
},
dauWant: []codersdk.DAUEntry{
{
Date: metricscache.OnlyDate(date(2022, 1, 1)),
Amount: 2,
},
},
tplWant: want{[]codersdk.DAUEntry{
{
Date: metricscache.OnlyDate(date(2022, 1, 2)),
Amount: 2,
},
}, 2},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
db = dbmem.New()
cache = metricscache.New(db, slogtest.Make(t, nil), metricscache.Intervals{
TemplateDAUs: testutil.IntervalFast,
})
)
defer cache.Close()
template := dbgen.Template(t, db, database.Template{
Provisioner: database.ProvisionerTypeEcho,
})
for _, row := range tt.args.rows {
row.TemplateID = template.ID
row.ConnectionCount = 1
db.InsertWorkspaceAgentStat(context.Background(), row)
}
require.Eventuallyf(t, func() bool {
_, _, ok := cache.TemplateDAUs(template.ID, tt.tzOffset)
return ok
}, testutil.WaitShort, testutil.IntervalMedium,
"TemplateDAUs never populated",
)
gotUniqueUsers, ok := cache.TemplateUniqueUsers(template.ID)
require.True(t, ok)
if tt.dauWant != nil {
_, dauResponse, ok := cache.DeploymentDAUs(tt.tzOffset)
require.True(t, ok)
require.Equal(t, tt.dauWant, dauResponse.Entries)
}
offset, gotEntries, ok := cache.TemplateDAUs(template.ID, tt.tzOffset)
require.True(t, ok)
// Template only supports 0 offset.
require.Equal(t, 0, offset)
require.Equal(t, tt.tplWant.entries, gotEntries.Entries)
require.Equal(t, tt.tplWant.uniqueUsers, gotUniqueUsers)
})
}
}
func TestCache_TemplateWorkspaceOwners(t *testing.T) {
t.Parallel()
var ()
var (
db = dbmem.New()
cache = metricscache.New(db, slogtest.Make(t, nil), metricscache.Intervals{
TemplateDAUs: testutil.IntervalFast,
})
)
defer cache.Close()
user1 := dbgen.User(t, db, database.User{})
user2 := dbgen.User(t, db, database.User{})
template := dbgen.Template(t, db, database.Template{
Provisioner: database.ProvisionerTypeEcho,
})
require.Eventuallyf(t, func() bool {
count, ok := cache.TemplateWorkspaceOwners(template.ID)
return ok && count == 0
}, testutil.WaitShort, testutil.IntervalMedium,
"TemplateWorkspaceOwners never populated 0 owners",
)
dbgen.Workspace(t, db, database.Workspace{
TemplateID: template.ID,
OwnerID: user1.ID,
})
require.Eventuallyf(t, func() bool {
count, _ := cache.TemplateWorkspaceOwners(template.ID)
return count == 1
}, testutil.WaitShort, testutil.IntervalMedium,
"TemplateWorkspaceOwners never populated 1 owner",
)
workspace2 := dbgen.Workspace(t, db, database.Workspace{
TemplateID: template.ID,
OwnerID: user2.ID,
})
require.Eventuallyf(t, func() bool {
count, _ := cache.TemplateWorkspaceOwners(template.ID)
return count == 2
}, testutil.WaitShort, testutil.IntervalMedium,
"TemplateWorkspaceOwners never populated 2 owners",
)
// 3rd workspace should not be counted since we have the same owner as workspace2.
dbgen.Workspace(t, db, database.Workspace{
TemplateID: template.ID,
OwnerID: user1.ID,
})
db.UpdateWorkspaceDeletedByID(context.Background(), database.UpdateWorkspaceDeletedByIDParams{
ID: workspace2.ID,
Deleted: true,
})
require.Eventuallyf(t, func() bool {
count, _ := cache.TemplateWorkspaceOwners(template.ID)
return count == 1
}, testutil.WaitShort, testutil.IntervalMedium,
"TemplateWorkspaceOwners never populated 1 owner after delete",
)
}
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 requireBuildTimeStatsEmpty(t *testing.T, stats codersdk.TemplateBuildTimeStats) {
require.Empty(t, stats[codersdk.WorkspaceTransitionStart])
require.Empty(t, stats[codersdk.WorkspaceTransitionStop])
require.Empty(t, stats[codersdk.WorkspaceTransitionDelete])
}
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
transition database.WorkspaceTransition
}
type want struct {
buildTimeMs int64
loads bool
}
tests := []struct {
name string
args args
want want
}{
{"empty", args{}, want{-1, false}},
{
"one/start", args{
rows: []jobParams{
{
startedAt: clockTime(someDay, 10, 1, 0),
completedAt: clockTime(someDay, 10, 1, 10),
},
},
transition: database.WorkspaceTransitionStart,
}, want{10 * 1000, true},
},
{
"two/stop", 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),
},
},
transition: database.WorkspaceTransitionStop,
}, want{50 * 1000, true},
},
{
"three/delete", 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),
},
},
transition: database.WorkspaceTransitionDelete,
}, want{20 * 1000, true},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
var (
db = dbmem.New()
cache = metricscache.New(db, slogtest.Make(t, nil), metricscache.Intervals{
TemplateDAUs: testutil.IntervalFast,
})
)
defer cache.Close()
id := uuid.New()
err := db.InsertTemplate(ctx, database.InsertTemplateParams{
ID: id,
Provisioner: database.ProvisionerTypeEcho,
MaxPortSharingLevel: database.AppSharingLevelOwner,
})
require.NoError(t, err)
template, err := db.GetTemplateByID(ctx, id)
require.NoError(t, err)
templateVersionID := uuid.New()
err = db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{
ID: templateVersionID,
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
})
require.NoError(t, err)
gotStats := cache.TemplateBuildTimeStats(template.ID)
requireBuildTimeStatsEmpty(t, gotStats)
for _, row := range tt.args.rows {
_, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
ID: uuid.New(),
Provisioner: database.ProvisionerTypeEcho,
StorageMethod: database.ProvisionerStorageMethodFile,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
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: templateVersionID,
JobID: job.ID,
Transition: tt.args.transition,
Reason: database.BuildReasonInitiator,
})
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.loads {
wantTransition := codersdk.WorkspaceTransition(tt.args.transition)
require.Eventuallyf(t, func() bool {
stats := cache.TemplateBuildTimeStats(template.ID)
return stats[wantTransition] != codersdk.TransitionStats{}
}, testutil.WaitLong, testutil.IntervalMedium,
"BuildTime never populated",
)
gotStats = cache.TemplateBuildTimeStats(template.ID)
for transition, stats := range gotStats {
if transition == wantTransition {
require.Equal(t, tt.want.buildTimeMs, *stats.P50)
} else {
require.Empty(
t, stats, "%v", transition,
)
}
}
} else {
var stats codersdk.TemplateBuildTimeStats
require.Never(t, func() bool {
stats = cache.TemplateBuildTimeStats(template.ID)
requireBuildTimeStatsEmpty(t, stats)
return t.Failed()
}, testutil.WaitShort/2, testutil.IntervalMedium,
"BuildTimeStats populated", stats,
)
}
})
}
}
func TestCache_DeploymentStats(t *testing.T) {
t.Parallel()
db := dbmem.New()
cache := metricscache.New(db, slogtest.Make(t, nil), metricscache.Intervals{
DeploymentStats: testutil.IntervalFast,
})
defer cache.Close()
_, err := db.InsertWorkspaceAgentStat(context.Background(), database.InsertWorkspaceAgentStatParams{
ID: uuid.New(),
AgentID: uuid.New(),
CreatedAt: dbtime.Now(),
ConnectionCount: 1,
RxBytes: 1,
TxBytes: 1,
SessionCountVSCode: 1,
})
require.NoError(t, err)
var stat codersdk.DeploymentStats
require.Eventually(t, func() bool {
var ok bool
stat, ok = cache.DeploymentStats()
return ok
}, testutil.WaitLong, testutil.IntervalMedium)
require.Equal(t, int64(1), stat.SessionCount.VSCode)
}