Skip to content

Commit cda9208

Browse files
authored
test: add ReconcileAll tests for multiple actions on expired prebuilds (#18265)
## Description Adds tests for `ReconcileAll` to verify the full reconciliation flow when handling expired prebuilds. This complements existing lower-level tests by checking multiple reconciliation actions (delete + create) at the higher reconciliation cycle level. Related with comment: #17996 (comment)
1 parent 5df70a6 commit cda9208

File tree

1 file changed

+295
-4
lines changed

1 file changed

+295
-4
lines changed

enterprise/coderd/prebuilds/reconcile_test.go

Lines changed: 295 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"database/sql"
66
"fmt"
7+
"sort"
78
"sync"
89
"testing"
910
"time"
@@ -1429,6 +1430,244 @@ func TestTrackResourceReplacement(t *testing.T) {
14291430
require.EqualValues(t, 1, metric.GetCounter().GetValue())
14301431
}
14311432

1433+
func TestExpiredPrebuildsMultipleActions(t *testing.T) {
1434+
t.Parallel()
1435+
1436+
if !dbtestutil.WillUsePostgres() {
1437+
t.Skip("This test requires postgres")
1438+
}
1439+
1440+
testCases := []struct {
1441+
name string
1442+
running int
1443+
desired int32
1444+
expired int
1445+
extraneous int
1446+
created int
1447+
}{
1448+
// With 2 running prebuilds, none of which are expired, and the desired count is met,
1449+
// no deletions or creations should occur.
1450+
{
1451+
name: "no expired prebuilds - no actions taken",
1452+
running: 2,
1453+
desired: 2,
1454+
expired: 0,
1455+
extraneous: 0,
1456+
created: 0,
1457+
},
1458+
// With 2 running prebuilds, 1 of which is expired, the expired prebuild should be deleted,
1459+
// and one new prebuild should be created to maintain the desired count.
1460+
{
1461+
name: "one expired prebuild – deleted and replaced",
1462+
running: 2,
1463+
desired: 2,
1464+
expired: 1,
1465+
extraneous: 0,
1466+
created: 1,
1467+
},
1468+
// With 2 running prebuilds, both expired, both should be deleted,
1469+
// and 2 new prebuilds created to match the desired count.
1470+
{
1471+
name: "all prebuilds expired – all deleted and recreated",
1472+
running: 2,
1473+
desired: 2,
1474+
expired: 2,
1475+
extraneous: 0,
1476+
created: 2,
1477+
},
1478+
// With 4 running prebuilds, 2 of which are expired, and the desired count is 2,
1479+
// the expired prebuilds should be deleted. No new creations are needed
1480+
// since removing the expired ones brings actual = desired.
1481+
{
1482+
name: "expired prebuilds deleted to reach desired count",
1483+
running: 4,
1484+
desired: 2,
1485+
expired: 2,
1486+
extraneous: 0,
1487+
created: 0,
1488+
},
1489+
// With 4 running prebuilds (1 expired), and the desired count is 2,
1490+
// the first action should delete the expired one,
1491+
// and the second action should delete one additional (non-expired) prebuild
1492+
// to eliminate the remaining excess.
1493+
{
1494+
name: "expired prebuild deleted first, then extraneous",
1495+
running: 4,
1496+
desired: 2,
1497+
expired: 1,
1498+
extraneous: 1,
1499+
created: 0,
1500+
},
1501+
}
1502+
1503+
for _, tc := range testCases {
1504+
t.Run(tc.name, func(t *testing.T) {
1505+
t.Parallel()
1506+
1507+
clock := quartz.NewMock(t)
1508+
ctx := testutil.Context(t, testutil.WaitLong)
1509+
cfg := codersdk.PrebuildsConfig{}
1510+
logger := slogtest.Make(
1511+
t, &slogtest.Options{IgnoreErrors: true},
1512+
).Leveled(slog.LevelDebug)
1513+
db, pubSub := dbtestutil.NewDB(t)
1514+
fakeEnqueuer := newFakeEnqueuer()
1515+
registry := prometheus.NewRegistry()
1516+
controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, registry, fakeEnqueuer)
1517+
1518+
// Set up test environment with a template, version, and preset
1519+
ownerID := uuid.New()
1520+
dbgen.User(t, db, database.User{
1521+
ID: ownerID,
1522+
})
1523+
org, template := setupTestDBTemplate(t, db, ownerID, false)
1524+
templateVersionID := setupTestDBTemplateVersion(ctx, t, clock, db, pubSub, org.ID, ownerID, template.ID)
1525+
1526+
ttlDuration := muchEarlier - time.Hour
1527+
ttl := int32(-ttlDuration.Seconds())
1528+
preset := setupTestDBPreset(t, db, templateVersionID, tc.desired, "b0rked", withTTL(ttl))
1529+
1530+
// The implementation uses time.Since(prebuild.CreatedAt) > ttl to check a prebuild expiration.
1531+
// Since our mock clock defaults to a fixed time, we must align it with the current time
1532+
// to ensure time-based logic works correctly in tests.
1533+
clock.Set(time.Now())
1534+
1535+
runningWorkspaces := make(map[string]database.WorkspaceTable)
1536+
nonExpiredWorkspaces := make([]database.WorkspaceTable, 0, tc.running-tc.expired)
1537+
expiredWorkspaces := make([]database.WorkspaceTable, 0, tc.expired)
1538+
expiredCount := 0
1539+
for r := range tc.running {
1540+
// Space out createdAt timestamps by 1 second to ensure deterministic ordering.
1541+
// This lets the test verify that the correct (oldest) extraneous prebuilds are deleted.
1542+
createdAt := muchEarlier + time.Duration(r)*time.Second
1543+
isExpired := false
1544+
if tc.expired > expiredCount {
1545+
// Set createdAt far enough in the past so that time.Since(createdAt) > TTL,
1546+
// ensuring the prebuild is treated as expired in the test.
1547+
createdAt = ttlDuration - 1*time.Minute
1548+
isExpired = true
1549+
expiredCount++
1550+
}
1551+
1552+
workspace, _ := setupTestDBPrebuild(
1553+
t,
1554+
clock,
1555+
db,
1556+
pubSub,
1557+
database.WorkspaceTransitionStart,
1558+
database.ProvisionerJobStatusSucceeded,
1559+
org.ID,
1560+
preset,
1561+
template.ID,
1562+
templateVersionID,
1563+
withCreatedAt(clock.Now().Add(createdAt)),
1564+
)
1565+
if isExpired {
1566+
expiredWorkspaces = append(expiredWorkspaces, workspace)
1567+
} else {
1568+
nonExpiredWorkspaces = append(nonExpiredWorkspaces, workspace)
1569+
}
1570+
runningWorkspaces[workspace.ID.String()] = workspace
1571+
}
1572+
1573+
getJobStatusMap := func(workspaces []database.WorkspaceTable) map[database.ProvisionerJobStatus]int {
1574+
jobStatusMap := make(map[database.ProvisionerJobStatus]int)
1575+
for _, workspace := range workspaces {
1576+
workspaceBuilds, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{
1577+
WorkspaceID: workspace.ID,
1578+
})
1579+
require.NoError(t, err)
1580+
1581+
for _, workspaceBuild := range workspaceBuilds {
1582+
job, err := db.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
1583+
require.NoError(t, err)
1584+
jobStatusMap[job.JobStatus]++
1585+
}
1586+
}
1587+
return jobStatusMap
1588+
}
1589+
1590+
// Assert that the build associated with the given workspace has a 'start' transition status.
1591+
isWorkspaceStarted := func(workspace database.WorkspaceTable) {
1592+
workspaceBuilds, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{
1593+
WorkspaceID: workspace.ID,
1594+
})
1595+
require.NoError(t, err)
1596+
require.Equal(t, 1, len(workspaceBuilds))
1597+
require.Equal(t, database.WorkspaceTransitionStart, workspaceBuilds[0].Transition)
1598+
}
1599+
1600+
// Assert that the workspace build history includes a 'start' followed by a 'delete' transition status.
1601+
isWorkspaceDeleted := func(workspace database.WorkspaceTable) {
1602+
workspaceBuilds, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{
1603+
WorkspaceID: workspace.ID,
1604+
})
1605+
require.NoError(t, err)
1606+
require.Equal(t, 2, len(workspaceBuilds))
1607+
require.Equal(t, database.WorkspaceTransitionDelete, workspaceBuilds[0].Transition)
1608+
require.Equal(t, database.WorkspaceTransitionStart, workspaceBuilds[1].Transition)
1609+
}
1610+
1611+
// Verify that all running workspaces, whether expired or not, have successfully started.
1612+
workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID)
1613+
require.NoError(t, err)
1614+
require.Equal(t, tc.running, len(workspaces))
1615+
jobStatusMap := getJobStatusMap(workspaces)
1616+
require.Len(t, workspaces, tc.running)
1617+
require.Len(t, jobStatusMap, 1)
1618+
require.Equal(t, tc.running, jobStatusMap[database.ProvisionerJobStatusSucceeded])
1619+
1620+
// Assert that all running workspaces (expired and non-expired) have a 'start' transition state.
1621+
for _, workspace := range runningWorkspaces {
1622+
isWorkspaceStarted(workspace)
1623+
}
1624+
1625+
// Trigger reconciliation to process expired prebuilds and enforce desired state.
1626+
require.NoError(t, controller.ReconcileAll(ctx))
1627+
1628+
// Sort non-expired workspaces by CreatedAt in ascending order (oldest first)
1629+
sort.Slice(nonExpiredWorkspaces, func(i, j int) bool {
1630+
return nonExpiredWorkspaces[i].CreatedAt.Before(nonExpiredWorkspaces[j].CreatedAt)
1631+
})
1632+
1633+
// Verify the status of each non-expired workspace:
1634+
// - the oldest `tc.extraneous` should have been deleted (i.e., have a 'delete' transition),
1635+
// - while the remaining newer ones should still be running (i.e., have a 'start' transition).
1636+
extraneousCount := 0
1637+
for _, running := range nonExpiredWorkspaces {
1638+
if extraneousCount < tc.extraneous {
1639+
isWorkspaceDeleted(running)
1640+
extraneousCount++
1641+
} else {
1642+
isWorkspaceStarted(running)
1643+
}
1644+
}
1645+
require.Equal(t, tc.extraneous, extraneousCount)
1646+
1647+
// Verify that each expired workspace has a 'delete' transition recorded,
1648+
// confirming it was properly marked for cleanup after reconciliation.
1649+
for _, expired := range expiredWorkspaces {
1650+
isWorkspaceDeleted(expired)
1651+
}
1652+
1653+
// After handling expired prebuilds, if running < desired, new prebuilds should be created.
1654+
// Verify that the correct number of new prebuild workspaces were created and started.
1655+
allWorkspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID)
1656+
require.NoError(t, err)
1657+
1658+
createdCount := 0
1659+
for _, workspace := range allWorkspaces {
1660+
if _, ok := runningWorkspaces[workspace.ID.String()]; !ok {
1661+
// Count and verify only the newly created workspaces (i.e., not part of the original running set)
1662+
isWorkspaceStarted(workspace)
1663+
createdCount++
1664+
}
1665+
}
1666+
require.Equal(t, tc.created, createdCount)
1667+
})
1668+
}
1669+
}
1670+
14321671
func newNoopEnqueuer() *notifications.NoopEnqueuer {
14331672
return notifications.NewNoopEnqueuer()
14341673
}
@@ -1538,22 +1777,42 @@ func setupTestDBTemplateVersion(
15381777
return templateVersion.ID
15391778
}
15401779

1780+
// Preset optional parameters.
1781+
// presetOptions defines a function type for modifying InsertPresetParams.
1782+
type presetOptions func(*database.InsertPresetParams)
1783+
1784+
// withTTL returns a presetOptions function that sets the invalidate_after_secs (TTL) field in InsertPresetParams.
1785+
func withTTL(ttl int32) presetOptions {
1786+
return func(p *database.InsertPresetParams) {
1787+
p.InvalidateAfterSecs = sql.NullInt32{Valid: true, Int32: ttl}
1788+
}
1789+
}
1790+
15411791
func setupTestDBPreset(
15421792
t *testing.T,
15431793
db database.Store,
15441794
templateVersionID uuid.UUID,
15451795
desiredInstances int32,
15461796
presetName string,
1797+
opts ...presetOptions,
15471798
) database.TemplateVersionPreset {
15481799
t.Helper()
1549-
preset := dbgen.Preset(t, db, database.InsertPresetParams{
1800+
insertPresetParams := database.InsertPresetParams{
15501801
TemplateVersionID: templateVersionID,
15511802
Name: presetName,
15521803
DesiredInstances: sql.NullInt32{
15531804
Valid: true,
15541805
Int32: desiredInstances,
15551806
},
1556-
})
1807+
}
1808+
1809+
// Apply optional parameters to insertPresetParams (e.g., TTL).
1810+
for _, opt := range opts {
1811+
opt(&insertPresetParams)
1812+
}
1813+
1814+
preset := dbgen.Preset(t, db, insertPresetParams)
1815+
15571816
dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{
15581817
TemplateVersionPresetID: preset.ID,
15591818
Names: []string{"test"},
@@ -1562,6 +1821,21 @@ func setupTestDBPreset(
15621821
return preset
15631822
}
15641823

1824+
// prebuildOptions holds optional parameters for creating a prebuild workspace.
1825+
type prebuildOptions struct {
1826+
createdAt *time.Time
1827+
}
1828+
1829+
// prebuildOption defines a function type to apply optional settings to prebuildOptions.
1830+
type prebuildOption func(*prebuildOptions)
1831+
1832+
// withCreatedAt returns a prebuildOption that sets the CreatedAt timestamp.
1833+
func withCreatedAt(createdAt time.Time) prebuildOption {
1834+
return func(opts *prebuildOptions) {
1835+
opts.createdAt = &createdAt
1836+
}
1837+
}
1838+
15651839
func setupTestDBPrebuild(
15661840
t *testing.T,
15671841
clock quartz.Clock,
@@ -1573,9 +1847,10 @@ func setupTestDBPrebuild(
15731847
preset database.TemplateVersionPreset,
15741848
templateID uuid.UUID,
15751849
templateVersionID uuid.UUID,
1850+
opts ...prebuildOption,
15761851
) (database.WorkspaceTable, database.WorkspaceBuild) {
15771852
t.Helper()
1578-
return setupTestDBWorkspace(t, clock, db, ps, transition, prebuildStatus, orgID, preset, templateID, templateVersionID, agplprebuilds.SystemUserID, agplprebuilds.SystemUserID)
1853+
return setupTestDBWorkspace(t, clock, db, ps, transition, prebuildStatus, orgID, preset, templateID, templateVersionID, agplprebuilds.SystemUserID, agplprebuilds.SystemUserID, opts...)
15791854
}
15801855

15811856
func setupTestDBWorkspace(
@@ -1591,6 +1866,7 @@ func setupTestDBWorkspace(
15911866
templateVersionID uuid.UUID,
15921867
initiatorID uuid.UUID,
15931868
ownerID uuid.UUID,
1869+
opts ...prebuildOption,
15941870
) (database.WorkspaceTable, database.WorkspaceBuild) {
15951871
t.Helper()
15961872
cancelledAt := sql.NullTime{}
@@ -1618,15 +1894,30 @@ func setupTestDBWorkspace(
16181894
default:
16191895
}
16201896

1897+
// Apply all provided prebuild options.
1898+
prebuiltOptions := &prebuildOptions{}
1899+
for _, opt := range opts {
1900+
opt(prebuiltOptions)
1901+
}
1902+
1903+
// Set createdAt to default value if not overridden by options.
1904+
createdAt := clock.Now().Add(muchEarlier)
1905+
if prebuiltOptions.createdAt != nil {
1906+
createdAt = *prebuiltOptions.createdAt
1907+
// Ensure startedAt matches createdAt for consistency.
1908+
startedAt = sql.NullTime{Time: createdAt, Valid: true}
1909+
}
1910+
16211911
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
16221912
TemplateID: templateID,
16231913
OrganizationID: orgID,
16241914
OwnerID: ownerID,
16251915
Deleted: false,
1916+
CreatedAt: createdAt,
16261917
})
16271918
job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
16281919
InitiatorID: initiatorID,
1629-
CreatedAt: clock.Now().Add(muchEarlier),
1920+
CreatedAt: createdAt,
16301921
StartedAt: startedAt,
16311922
CompletedAt: completedAt,
16321923
CanceledAt: cancelledAt,

0 commit comments

Comments
 (0)