From 467c8bbd6b40425d1f33a6bec286f0d19ca55426 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 6 Jan 2026 16:26:28 +0000 Subject: [PATCH] fix: prevent notification for dormant delete on dormant-removal (#21427) Ensure we do not send "Marked for deletion" notifications when disabling dormancy deletion --- enterprise/coderd/schedule/template.go | 49 ++++++------ enterprise/coderd/schedule/template_test.go | 86 +++++++++++++++++++++ 2 files changed, 112 insertions(+), 23 deletions(-) diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index ed21b8160e..2d791926cf 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -140,8 +140,8 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S } var ( - template database.Template - markedForDeletion []database.WorkspaceTable + template database.Template + dormantWorkspacesUpdated []database.WorkspaceTable ) err = db.InTx(func(tx database.Store) error { ctx, span := tracing.StartSpanWithName(ctx, "(*schedule.EnterpriseTemplateScheduleStore).Set()-InTx()") @@ -176,7 +176,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S // to ensure workspaces are being cleaned up correctly. Similarly if we are // disabling it (by passing 0), then we want to delete nullify the deleting_at // fields of all the template workspaces. - markedForDeletion, err = tx.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams{ + dormantWorkspacesUpdated, err = tx.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams{ TemplateID: tpl.ID, TimeTilDormantAutodeleteMs: opts.TimeTilDormantAutoDelete.Milliseconds(), DormantAt: dormantAt, @@ -267,27 +267,30 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S } } - for _, ws := range markedForDeletion { + if opts.TimeTilDormantAutoDelete > 0 { dormantTime := s.now().Add(opts.TimeTilDormantAutoDelete) - _, err = s.enqueuer.Enqueue( - // nolint:gocritic // Need actor to enqueue notification - dbauthz.AsNotifier(ctx), - ws.OwnerID, - notifications.TemplateWorkspaceMarkedForDeletion, - map[string]string{ - "name": ws.Name, - "reason": "an update to the template's dormancy", - "timeTilDormant": humanize.Time(dormantTime), - }, - "scheduletemplate", - // Associate this notification with all the related entities. - ws.ID, - ws.OwnerID, - ws.TemplateID, - ws.OrganizationID, - ) - if err != nil { - s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", ws.ID)) + + for _, ws := range dormantWorkspacesUpdated { + _, err = s.enqueuer.Enqueue( + // nolint:gocritic // Need actor to enqueue notification + dbauthz.AsNotifier(ctx), + ws.OwnerID, + notifications.TemplateWorkspaceMarkedForDeletion, + map[string]string{ + "name": ws.Name, + "reason": "an update to the template's dormancy", + "timeTilDormant": humanize.Time(dormantTime), + }, + "scheduletemplate", + // Associate this notification with all the related entities. + ws.ID, + ws.OwnerID, + ws.TemplateID, + ws.OrganizationID, + ) + if err != nil { + s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", ws.ID)) + } } } diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index e9d30cdb5d..60aeb44840 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -836,6 +836,92 @@ func TestNotifications(t *testing.T) { "deleted workspace should not receive notifications") } }) + + // Disabling dormancy auto-deletion should not send "marked for deletion" notifications. + t.Run("DisablingAutoDeleteSendsNoNotifications", func(t *testing.T) { + t.Parallel() + + var ( + db, _ = dbtestutil.NewDB(t) + user = dbgen.User(t, db, database.User{}) + file = dbgen.File(t, db, database.File{ + CreatedBy: user.ID, + }) + templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{ + "foo": "bar", + }, + }) + timeTilDormant = time.Minute * 2 + timeTilDormantAutoDelete = time.Minute * 4 + templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + JobID: templateJob.ID, + OrganizationID: templateJob.OrganizationID, + }) + template = dbgen.Template(t, db, database.Template{ + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + OrganizationID: templateJob.OrganizationID, + }) + ) + + // Given: Dormancy auto deletion is enabled + ctx := testutil.Context(t, testutil.WaitShort) + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: dbtime.Now(), + TimeTilDormant: int64(timeTilDormant), + TimeTilDormantAutoDelete: int64(timeTilDormantAutoDelete), + }) + require.NoError(t, err) + + // Given: A workspace that is marked as dormant + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + TemplateID: template.ID, + OrganizationID: templateJob.OrganizationID, + LastUsedAt: time.Now().Add(-time.Hour), + }) + dormantAt := workspace.LastUsedAt.Add(timeTilDormant) + workspace, err = db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ + ID: workspace.ID, + DormantAt: sql.NullTime{ + Time: dormantAt, + Valid: true, + }, + }) + require.NoError(t, err) + require.True(t, workspace.DeletingAt.Valid, "deleting_at should be set when marking workspace dormant") + + // Setup dependencies + notifyEnq := notificationstest.NewFakeEnqueuer() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) + require.NoError(t, err) + userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + userQuietHoursStorePtr.Store(&userQuietHoursStore) + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil) + + // When: We disable dormancy auto-delete + _, err = templateScheduleStore.Set(dbauthz.AsNotifier(ctx), db, template, agplschedule.TemplateScheduleOptions{ + TimeTilDormant: timeTilDormant, + TimeTilDormantAutoDelete: 0, + }) + require.NoError(t, err) + + // Then: We expect deleting_at to be removed + updated, err := db.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.False(t, updated.DeletingAt.Valid, "deleting_at should be cleared when auto-deletion is disabled") + + // Then: We expect no notifications to have been sent + sent := notifyEnq.Sent() + require.Len(t, sent, 0, "no notifications should be sent when disabling dormancy auto-deletion") + }) } func TestTemplateTTL(t *testing.T) {