Skip to content

Commit de679b2

Browse files
committed
test: add tests for ReconcileAll covering multiple reconciliation actions on expired prebuilds
1 parent a12429e commit de679b2

File tree

1 file changed

+296
-4
lines changed

1 file changed

+296
-4
lines changed

enterprise/coderd/prebuilds/reconcile_test.go

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

1781+
// Preset optional parameters.
1782+
// presetOptions defines a function type for modifying InsertPresetParams.
1783+
type presetOptions func(*database.InsertPresetParams)
1784+
1785+
// withTTL returns a presetOptions function that sets the invalidate_after_secs (TTL) field in InsertPresetParams.
1786+
func withTTL(ttl int32) presetOptions {
1787+
return func(p *database.InsertPresetParams) {
1788+
p.InvalidateAfterSecs = sql.NullInt32{Valid: true, Int32: ttl}
1789+
}
1790+
}
1791+
15411792
func setupTestDBPreset(
15421793
t *testing.T,
15431794
db database.Store,
15441795
templateVersionID uuid.UUID,
15451796
desiredInstances int32,
15461797
presetName string,
1798+
opts ...presetOptions,
15471799
) database.TemplateVersionPreset {
15481800
t.Helper()
1549-
preset := dbgen.Preset(t, db, database.InsertPresetParams{
1801+
insertPresetParams := database.InsertPresetParams{
15501802
TemplateVersionID: templateVersionID,
15511803
Name: presetName,
15521804
DesiredInstances: sql.NullInt32{
15531805
Valid: true,
15541806
Int32: desiredInstances,
15551807
},
1556-
})
1808+
}
1809+
1810+
// Apply optional parameters to insertPresetParams (e.g., TTL).
1811+
for _, opt := range opts {
1812+
opt(&insertPresetParams)
1813+
}
1814+
1815+
preset := dbgen.Preset(t, db, insertPresetParams)
1816+
15571817
dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{
15581818
TemplateVersionPresetID: preset.ID,
15591819
Names: []string{"test"},
@@ -1562,6 +1822,21 @@ func setupTestDBPreset(
15621822
return preset
15631823
}
15641824

1825+
// prebuildOptions holds optional parameters for creating a prebuild workspace.
1826+
type prebuildOptions struct {
1827+
createdAt *time.Time
1828+
}
1829+
1830+
// prebuildOption defines a function type to apply optional settings to prebuildOptions.
1831+
type prebuildOption func(*prebuildOptions)
1832+
1833+
// withCreatedAt returns a prebuildOption that sets the CreatedAt timestamp.
1834+
func withCreatedAt(createdAt time.Time) prebuildOption {
1835+
return func(opts *prebuildOptions) {
1836+
opts.createdAt = &createdAt
1837+
}
1838+
}
1839+
15651840
func setupTestDBPrebuild(
15661841
t *testing.T,
15671842
clock quartz.Clock,
@@ -1573,9 +1848,10 @@ func setupTestDBPrebuild(
15731848
preset database.TemplateVersionPreset,
15741849
templateID uuid.UUID,
15751850
templateVersionID uuid.UUID,
1851+
opts ...prebuildOption,
15761852
) (database.WorkspaceTable, database.WorkspaceBuild) {
15771853
t.Helper()
1578-
return setupTestDBWorkspace(t, clock, db, ps, transition, prebuildStatus, orgID, preset, templateID, templateVersionID, agplprebuilds.SystemUserID, agplprebuilds.SystemUserID)
1854+
return setupTestDBWorkspace(t, clock, db, ps, transition, prebuildStatus, orgID, preset, templateID, templateVersionID, agplprebuilds.SystemUserID, agplprebuilds.SystemUserID, opts...)
15791855
}
15801856

15811857
func setupTestDBWorkspace(
@@ -1591,6 +1867,7 @@ func setupTestDBWorkspace(
15911867
templateVersionID uuid.UUID,
15921868
initiatorID uuid.UUID,
15931869
ownerID uuid.UUID,
1870+
opts ...prebuildOption,
15941871
) (database.WorkspaceTable, database.WorkspaceBuild) {
15951872
t.Helper()
15961873
cancelledAt := sql.NullTime{}
@@ -1618,15 +1895,30 @@ func setupTestDBWorkspace(
16181895
default:
16191896
}
16201897

1898+
// Apply all provided prebuild options.
1899+
prebuiltOptions := &prebuildOptions{}
1900+
for _, opt := range opts {
1901+
opt(prebuiltOptions)
1902+
}
1903+
1904+
// Set createdAt to default value if not overridden by options.
1905+
createdAt := clock.Now().Add(muchEarlier)
1906+
if prebuiltOptions.createdAt != nil {
1907+
createdAt = *prebuiltOptions.createdAt
1908+
// Ensure startedAt matches createdAt for consistency.
1909+
startedAt = sql.NullTime{Time: createdAt, Valid: true}
1910+
}
1911+
16211912
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
16221913
TemplateID: templateID,
16231914
OrganizationID: orgID,
16241915
OwnerID: ownerID,
16251916
Deleted: false,
1917+
CreatedAt: createdAt,
16261918
})
16271919
job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
16281920
InitiatorID: initiatorID,
1629-
CreatedAt: clock.Now().Add(muchEarlier),
1921+
CreatedAt: createdAt,
16301922
StartedAt: startedAt,
16311923
CompletedAt: completedAt,
16321924
CanceledAt: cancelledAt,

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy