feat: add prebuild invalidation via last_invalidated_at timestamp (#20582)

Updates #17917
This commit is contained in:
Marcin Tojek
2025-11-20 17:12:25 +01:00
committed by GitHub
parent c2319e5b4e
commit d004710a74
27 changed files with 637 additions and 23 deletions
+21 -12
View File
@@ -125,20 +125,29 @@ func (s GlobalSnapshot) IsHardLimited(presetID uuid.UUID) bool {
}
// filterExpiredWorkspaces splits running workspaces into expired and non-expired
// based on the preset's TTL.
// If TTL is missing or zero, all workspaces are considered non-expired.
// based on the preset's TTL and last_invalidated_at timestamp.
// A prebuild is considered expired if:
// 1. The preset has been invalidated (last_invalidated_at is set), OR
// 2. It exceeds the preset's TTL (if TTL is set)
// If TTL is missing or zero, only last_invalidated_at is checked.
func filterExpiredWorkspaces(preset database.GetTemplatePresetsWithPrebuildsRow, runningWorkspaces []database.GetRunningPrebuiltWorkspacesRow) (nonExpired []database.GetRunningPrebuiltWorkspacesRow, expired []database.GetRunningPrebuiltWorkspacesRow) {
if !preset.Ttl.Valid {
return runningWorkspaces, expired
}
ttl := time.Duration(preset.Ttl.Int32) * time.Second
if ttl <= 0 {
return runningWorkspaces, expired
}
for _, prebuild := range runningWorkspaces {
if time.Since(prebuild.CreatedAt) > ttl {
isExpired := false
// Check if prebuild was created before last invalidation
if preset.LastInvalidatedAt.Valid && prebuild.CreatedAt.Before(preset.LastInvalidatedAt.Time) {
isExpired = true
}
// Check TTL expiration if set
if !isExpired && preset.Ttl.Valid {
ttl := time.Duration(preset.Ttl.Int32) * time.Second
if ttl > 0 && time.Since(prebuild.CreatedAt) > ttl {
isExpired = true
}
}
if isExpired {
expired = append(expired, prebuild)
} else {
nonExpired = append(nonExpired, prebuild)
+71 -1
View File
@@ -600,6 +600,9 @@ func TestExpiredPrebuilds(t *testing.T) {
running int32
desired int32
expired int32
invalidated int32
checkFn func(runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow, state prebuilds.ReconciliationState, actions []*prebuilds.ReconciliationActions)
}{
// With 2 running prebuilds, none of which are expired, and the desired count is met,
@@ -708,6 +711,52 @@ func TestExpiredPrebuilds(t *testing.T) {
},
}
validateState(t, expectedState, state)
validateActions(t, expectedActions, actions)
},
},
{
name: "preset has been invalidated - both instances expired",
running: 2,
desired: 2,
expired: 0,
invalidated: 2,
checkFn: func(runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow, state prebuilds.ReconciliationState, actions []*prebuilds.ReconciliationActions) {
expectedState := prebuilds.ReconciliationState{Actual: 2, Desired: 2, Expired: 2}
expectedActions := []*prebuilds.ReconciliationActions{
{
ActionType: prebuilds.ActionTypeDelete,
DeleteIDs: []uuid.UUID{runningPrebuilds[0].ID, runningPrebuilds[1].ID},
},
{
ActionType: prebuilds.ActionTypeCreate,
Create: 2,
},
}
validateState(t, expectedState, state)
validateActions(t, expectedActions, actions)
},
},
{
name: "preset has been invalidated, but one prebuild instance is newer",
running: 2,
desired: 2,
expired: 0,
invalidated: 1,
checkFn: func(runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow, state prebuilds.ReconciliationState, actions []*prebuilds.ReconciliationActions) {
expectedState := prebuilds.ReconciliationState{Actual: 2, Desired: 2, Expired: 1}
expectedActions := []*prebuilds.ReconciliationActions{
{
ActionType: prebuilds.ActionTypeDelete,
DeleteIDs: []uuid.UUID{runningPrebuilds[0].ID},
},
{
ActionType: prebuilds.ActionTypeCreate,
Create: 1,
},
}
validateState(t, expectedState, state)
validateActions(t, expectedActions, actions)
},
@@ -719,7 +768,17 @@ func TestExpiredPrebuilds(t *testing.T) {
t.Parallel()
// GIVEN: a preset.
defaultPreset := preset(true, tc.desired, current)
now := time.Now()
invalidatedAt := now.Add(1 * time.Minute)
var muts []func(row database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow
if tc.invalidated > 0 {
muts = append(muts, func(row database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow {
row.LastInvalidatedAt = sql.NullTime{Valid: true, Time: invalidatedAt}
return row
})
}
defaultPreset := preset(true, tc.desired, current, muts...)
presets := []database.GetTemplatePresetsWithPrebuildsRow{
defaultPreset,
}
@@ -727,11 +786,22 @@ func TestExpiredPrebuilds(t *testing.T) {
// GIVEN: running prebuilt workspaces for the preset.
running := make([]database.GetRunningPrebuiltWorkspacesRow, 0, tc.running)
expiredCount := 0
invalidatedCount := 0
ttlDuration := time.Duration(defaultPreset.Ttl.Int32)
for range tc.running {
name, err := prebuilds.GenerateName()
require.NoError(t, err)
prebuildCreateAt := time.Now()
if int(tc.invalidated) > invalidatedCount {
prebuildCreateAt = prebuildCreateAt.Add(-ttlDuration - 10*time.Second)
invalidatedCount++
} else if invalidatedCount > 0 {
// Only `tc.invalidated` instances have been invalidated,
// so the next instance is assumed to be created after `invalidatedAt`.
prebuildCreateAt = invalidatedAt.Add(1 * time.Minute)
}
if int(tc.expired) > expiredCount {
// Update the prebuild workspace createdAt to exceed its TTL (5 seconds)
prebuildCreateAt = prebuildCreateAt.Add(-ttlDuration - 10*time.Second)