4
4
"context"
5
5
"database/sql"
6
6
"fmt"
7
+ "sort"
7
8
"sync"
8
9
"testing"
9
10
"time"
@@ -1429,6 +1430,244 @@ func TestTrackResourceReplacement(t *testing.T) {
1429
1430
require .EqualValues (t , 1 , metric .GetCounter ().GetValue ())
1430
1431
}
1431
1432
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
+
1432
1671
func newNoopEnqueuer () * notifications.NoopEnqueuer {
1433
1672
return notifications .NewNoopEnqueuer ()
1434
1673
}
@@ -1538,22 +1777,42 @@ func setupTestDBTemplateVersion(
1538
1777
return templateVersion .ID
1539
1778
}
1540
1779
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
+
1541
1791
func setupTestDBPreset (
1542
1792
t * testing.T ,
1543
1793
db database.Store ,
1544
1794
templateVersionID uuid.UUID ,
1545
1795
desiredInstances int32 ,
1546
1796
presetName string ,
1797
+ opts ... presetOptions ,
1547
1798
) database.TemplateVersionPreset {
1548
1799
t .Helper ()
1549
- preset := dbgen . Preset ( t , db , database.InsertPresetParams {
1800
+ insertPresetParams := database.InsertPresetParams {
1550
1801
TemplateVersionID : templateVersionID ,
1551
1802
Name : presetName ,
1552
1803
DesiredInstances : sql.NullInt32 {
1553
1804
Valid : true ,
1554
1805
Int32 : desiredInstances ,
1555
1806
},
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
+
1557
1816
dbgen .PresetParameter (t , db , database.InsertPresetParametersParams {
1558
1817
TemplateVersionPresetID : preset .ID ,
1559
1818
Names : []string {"test" },
@@ -1562,6 +1821,21 @@ func setupTestDBPreset(
1562
1821
return preset
1563
1822
}
1564
1823
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
+
1565
1839
func setupTestDBPrebuild (
1566
1840
t * testing.T ,
1567
1841
clock quartz.Clock ,
@@ -1573,9 +1847,10 @@ func setupTestDBPrebuild(
1573
1847
preset database.TemplateVersionPreset ,
1574
1848
templateID uuid.UUID ,
1575
1849
templateVersionID uuid.UUID ,
1850
+ opts ... prebuildOption ,
1576
1851
) (database.WorkspaceTable , database.WorkspaceBuild ) {
1577
1852
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 ... )
1579
1854
}
1580
1855
1581
1856
func setupTestDBWorkspace (
@@ -1591,6 +1866,7 @@ func setupTestDBWorkspace(
1591
1866
templateVersionID uuid.UUID ,
1592
1867
initiatorID uuid.UUID ,
1593
1868
ownerID uuid.UUID ,
1869
+ opts ... prebuildOption ,
1594
1870
) (database.WorkspaceTable , database.WorkspaceBuild ) {
1595
1871
t .Helper ()
1596
1872
cancelledAt := sql.NullTime {}
@@ -1618,15 +1894,30 @@ func setupTestDBWorkspace(
1618
1894
default :
1619
1895
}
1620
1896
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
+
1621
1911
workspace := dbgen .Workspace (t , db , database.WorkspaceTable {
1622
1912
TemplateID : templateID ,
1623
1913
OrganizationID : orgID ,
1624
1914
OwnerID : ownerID ,
1625
1915
Deleted : false ,
1916
+ CreatedAt : createdAt ,
1626
1917
})
1627
1918
job := dbgen .ProvisionerJob (t , db , ps , database.ProvisionerJob {
1628
1919
InitiatorID : initiatorID ,
1629
- CreatedAt : clock . Now (). Add ( muchEarlier ) ,
1920
+ CreatedAt : createdAt ,
1630
1921
StartedAt : startedAt ,
1631
1922
CompletedAt : completedAt ,
1632
1923
CanceledAt : cancelledAt ,
0 commit comments