44"context"
55"database/sql"
66"fmt"
7+ "sort"
78"sync"
89"testing"
910"time"
@@ -1429,6 +1430,244 @@ func TestTrackResourceReplacement(t *testing.T) {
14291430require .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+
14321671func newNoopEnqueuer ()* notifications.NoopEnqueuer {
14331672return notifications .NewNoopEnqueuer ()
14341673}
@@ -1538,22 +1777,42 @@ func setupTestDBTemplateVersion(
15381777return 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+
15411791func setupTestDBPreset (
15421792t * testing.T ,
15431793db database.Store ,
15441794templateVersionID uuid.UUID ,
15451795desiredInstances int32 ,
15461796presetName string ,
1797+ opts ... presetOptions ,
15471798) database.TemplateVersionPreset {
15481799t .Helper ()
1549- preset := dbgen . Preset ( t , db , database.InsertPresetParams {
1800+ insertPresetParams := database.InsertPresetParams {
15501801TemplateVersionID :templateVersionID ,
15511802Name :presetName ,
15521803DesiredInstances : sql.NullInt32 {
15531804Valid :true ,
15541805Int32 :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+
15571816dbgen .PresetParameter (t ,db , database.InsertPresetParametersParams {
15581817TemplateVersionPresetID :preset .ID ,
15591818Names : []string {"test" },
@@ -1562,6 +1821,21 @@ func setupTestDBPreset(
15621821return 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+
15651839func setupTestDBPrebuild (
15661840t * testing.T ,
15671841clock quartz.Clock ,
@@ -1573,9 +1847,10 @@ func setupTestDBPrebuild(
15731847preset database.TemplateVersionPreset ,
15741848templateID uuid.UUID ,
15751849templateVersionID uuid.UUID ,
1850+ opts ... prebuildOption ,
15761851) (database.WorkspaceTable , database.WorkspaceBuild ) {
15771852t .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
15811856func setupTestDBWorkspace (
@@ -1591,6 +1866,7 @@ func setupTestDBWorkspace(
15911866templateVersionID uuid.UUID ,
15921867initiatorID uuid.UUID ,
15931868ownerID uuid.UUID ,
1869+ opts ... prebuildOption ,
15941870) (database.WorkspaceTable , database.WorkspaceBuild ) {
15951871t .Helper ()
15961872cancelledAt := sql.NullTime {}
@@ -1618,15 +1894,30 @@ func setupTestDBWorkspace(
16181894default :
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+
16211911workspace := dbgen .Workspace (t ,db , database.WorkspaceTable {
16221912TemplateID :templateID ,
16231913OrganizationID :orgID ,
16241914OwnerID :ownerID ,
16251915Deleted :false ,
1916+ CreatedAt :createdAt ,
16261917})
16271918job := dbgen .ProvisionerJob (t ,db ,ps , database.ProvisionerJob {
16281919InitiatorID :initiatorID ,
1629- CreatedAt :clock . Now (). Add ( muchEarlier ) ,
1920+ CreatedAt :createdAt ,
16301921StartedAt :startedAt ,
16311922CompletedAt :completedAt ,
16321923CanceledAt :cancelledAt ,