Skip to content

Commit e7dbbcd

Browse files
fix: do not notify marked for deletion for deleted workspaces (#20937)
Closes #20913 I've ran the test without the fix, verified the test caught the issue, then applied the fix, and confirmed the issue no longer happens. --- 🤖 PR was initially written by Claude Opus 4.5 Thinking using Claude Code and then review by a human 👩
1 parent bbf7b13 commit e7dbbcd

File tree

3 files changed

+101
-0
lines changed

3 files changed

+101
-0
lines changed

coderd/database/queries.sql.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspaces.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,7 @@ SET
846846
WHERE
847847
template_id = @template_id
848848
AND dormant_at IS NOT NULL
849+
AND deleted = false
849850
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
850851
-- should not have their dormant or deleting at set, as these are handled by the
851852
-- prebuilds reconciliation loop.

enterprise/coderd/schedule/template_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,105 @@ func TestNotifications(t *testing.T) {
737737
require.Contains(t, sent[i].Targets, dormantWs.OwnerID)
738738
}
739739
})
740+
741+
// Regression test for https://github.com/coder/coder/issues/20913
742+
// Deleted workspaces should not receive dormancy notifications.
743+
t.Run("DeletedWorkspacesNotNotified", func(t *testing.T) {
744+
t.Parallel()
745+
746+
var (
747+
db, _ = dbtestutil.NewDB(t)
748+
ctx = testutil.Context(t, testutil.WaitLong)
749+
user = dbgen.User(t, db, database.User{})
750+
file = dbgen.File(t, db, database.File{
751+
CreatedBy: user.ID,
752+
})
753+
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
754+
FileID: file.ID,
755+
InitiatorID: user.ID,
756+
Tags: database.StringMap{
757+
"foo": "bar",
758+
},
759+
})
760+
timeTilDormant = time.Minute * 2
761+
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
762+
CreatedBy: user.ID,
763+
JobID: templateJob.ID,
764+
OrganizationID: templateJob.OrganizationID,
765+
})
766+
template = dbgen.Template(t, db, database.Template{
767+
ActiveVersionID: templateVersion.ID,
768+
CreatedBy: user.ID,
769+
OrganizationID: templateJob.OrganizationID,
770+
TimeTilDormant: int64(timeTilDormant),
771+
TimeTilDormantAutoDelete: int64(timeTilDormant),
772+
})
773+
)
774+
775+
// Create a dormant workspace that is NOT deleted.
776+
activeDormantWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
777+
OwnerID: user.ID,
778+
TemplateID: template.ID,
779+
OrganizationID: templateJob.OrganizationID,
780+
LastUsedAt: time.Now().Add(-time.Hour),
781+
})
782+
_, err := db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
783+
ID: activeDormantWorkspace.ID,
784+
DormantAt: sql.NullTime{
785+
Time: activeDormantWorkspace.LastUsedAt.Add(timeTilDormant),
786+
Valid: true,
787+
},
788+
})
789+
require.NoError(t, err)
790+
791+
// Create a dormant workspace that IS deleted.
792+
deletedDormantWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
793+
OwnerID: user.ID,
794+
TemplateID: template.ID,
795+
OrganizationID: templateJob.OrganizationID,
796+
LastUsedAt: time.Now().Add(-time.Hour),
797+
Deleted: true, // Mark as deleted
798+
})
799+
_, err = db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
800+
ID: deletedDormantWorkspace.ID,
801+
DormantAt: sql.NullTime{
802+
Time: deletedDormantWorkspace.LastUsedAt.Add(timeTilDormant),
803+
Valid: true,
804+
},
805+
})
806+
require.NoError(t, err)
807+
808+
// Setup dependencies
809+
notifyEnq := notificationstest.NewFakeEnqueuer()
810+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
811+
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
812+
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
813+
require.NoError(t, err)
814+
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
815+
userQuietHoursStorePtr.Store(&userQuietHoursStore)
816+
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil)
817+
818+
// Lower the dormancy TTL to ensure the schedule recalculates deadlines and
819+
// triggers notifications.
820+
_, err = templateScheduleStore.Set(dbauthz.AsNotifier(ctx), db, template, agplschedule.TemplateScheduleOptions{
821+
TimeTilDormant: timeTilDormant / 2,
822+
TimeTilDormantAutoDelete: timeTilDormant / 2,
823+
})
824+
require.NoError(t, err)
825+
826+
// We should only receive a notification for the non-deleted dormant workspace.
827+
sent := notifyEnq.Sent()
828+
require.Len(t, sent, 1, "expected exactly 1 notification for the non-deleted workspace")
829+
require.Equal(t, sent[0].UserID, activeDormantWorkspace.OwnerID)
830+
require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceMarkedForDeletion)
831+
require.Contains(t, sent[0].Targets, activeDormantWorkspace.ID)
832+
833+
// Ensure the deleted workspace was NOT notified
834+
for _, notification := range sent {
835+
require.NotContains(t, notification.Targets, deletedDormantWorkspace.ID,
836+
"deleted workspace should not receive notifications")
837+
}
838+
})
740839
}
741840

742841
func TestTemplateTTL(t *testing.T) {

0 commit comments

Comments
 (0)