@@ -57,6 +57,13 @@ type StoreReconciler struct {
5757
5858var _ prebuilds.ReconciliationOrchestrator = & StoreReconciler {}
5959
60+ type DeprovisionMode int
61+
62+ const (
63+ DeprovisionModeNormal DeprovisionMode = iota
64+ DeprovisionModeOrphan
65+ )
66+
6067func NewStoreReconciler (store database.Store ,
6168ps pubsub.Pubsub ,
6269fileCache * files.Cache ,
@@ -642,34 +649,7 @@ func (c *StoreReconciler) executeReconciliationAction(ctx context.Context, logge
642649return multiErr .ErrorOrNil ()
643650
644651case prebuilds .ActionTypeCancelPending :
645- // Cancel pending prebuild jobs from non-active template versions to avoid
646- // provisioning obsolete workspaces that would immediately be deprovisioned.
647- // This uses a criteria-based update to ensure only jobs that are still pending
648- // at execution time are canceled, avoiding race conditions where jobs may have
649- // transitioned to running status between query and update.
650- canceledJobs ,err := c .store .UpdatePrebuildProvisionerJobWithCancel (
651- ctx ,
652- database.UpdatePrebuildProvisionerJobWithCancelParams {
653- Now :c .clock .Now (),
654- PresetID : uuid.NullUUID {
655- UUID :ps .Preset .ID ,
656- Valid :true ,
657- },
658- })
659- if err != nil {
660- logger .Error (ctx ,"failed to cancel pending prebuild jobs" ,
661- slog .F ("template_version_id" ,ps .Preset .TemplateVersionID .String ()),
662- slog .F ("preset_id" ,ps .Preset .ID ),
663- slog .Error (err ))
664- return err
665- }
666- if len (canceledJobs )> 0 {
667- logger .Info (ctx ,"canceled pending prebuild jobs for inactive version" ,
668- slog .F ("template_version_id" ,ps .Preset .TemplateVersionID .String ()),
669- slog .F ("preset_id" ,ps .Preset .ID ),
670- slog .F ("count" ,len (canceledJobs )))
671- }
672- return nil
652+ return c .cancelAndOrphanDeletePendingPrebuilds (ctx ,ps .Preset .TemplateID ,ps .Preset .TemplateVersionID ,ps .Preset .ID )
673653
674654default :
675655return xerrors .Errorf ("unknown action type: %v" ,action .ActionType )
@@ -717,33 +697,100 @@ func (c *StoreReconciler) createPrebuiltWorkspace(ctx context.Context, prebuiltW
717697c .logger .Info (ctx ,"attempting to create prebuild" ,slog .F ("name" ,name ),
718698slog .F ("workspace_id" ,prebuiltWorkspaceID .String ()),slog .F ("preset_id" ,presetID .String ()))
719699
720- return c .provision (ctx ,db ,prebuiltWorkspaceID ,template ,presetID ,database .WorkspaceTransitionStart ,workspace )
700+ return c .provision (ctx ,db ,prebuiltWorkspaceID ,template ,presetID ,database .WorkspaceTransitionStart ,workspace , DeprovisionModeNormal )
721701},& database.TxOptions {
722702Isolation :sql .LevelRepeatableRead ,
723703ReadOnly :false ,
724704})
725705}
726706
727- func (c * StoreReconciler )deletePrebuiltWorkspace (ctx context.Context ,prebuiltWorkspaceID uuid.UUID ,templateID uuid.UUID ,presetID uuid.UUID )error {
707+ // provisionDelete provisions a delete transition for a prebuilt workspace.
708+ //
709+ // If mode is DeprovisionModeOrphan, the builder will not send Terraform state to the provisioner.
710+ // This allows the workspace to be deleted even when no provisioners are available, and is safe
711+ // when no Terraform resources were actually created (e.g., for pending prebuilds that were canceled
712+ // before provisioning started).
713+ //
714+ // IMPORTANT: This function must be called within a database transaction. It does not create its own transaction.
715+ // The caller is responsible for managing the transaction boundary via db.InTx().
716+ func (c * StoreReconciler )provisionDelete (ctx context.Context ,db database.Store ,workspaceID uuid.UUID ,templateID uuid.UUID ,presetID uuid.UUID ,mode DeprovisionMode )error {
717+ workspace ,err := db .GetWorkspaceByID (ctx ,workspaceID )
718+ if err != nil {
719+ return xerrors .Errorf ("get workspace by ID: %w" ,err )
720+ }
721+
722+ template ,err := db .GetTemplateByID (ctx ,templateID )
723+ if err != nil {
724+ return xerrors .Errorf ("failed to get template: %w" ,err )
725+ }
726+
727+ if workspace .OwnerID != database .PrebuildsSystemUserID {
728+ return xerrors .Errorf ("prebuilt workspace is not owned by prebuild user anymore, probably it was claimed" )
729+ }
730+
731+ c .logger .Info (ctx ,"attempting to delete prebuild" ,
732+ slog .F ("workspace_id" ,workspaceID .String ()),slog .F ("preset_id" ,presetID .String ()))
733+
734+ return c .provision (ctx ,db ,workspaceID ,template ,presetID ,
735+ database .WorkspaceTransitionDelete ,workspace ,mode )
736+ }
737+
738+ // cancelAndOrphanDeletePendingPrebuilds cancels pending prebuild jobs from inactive template versions
739+ // and orphan-deletes their associated workspaces.
740+ //
741+ // The cancel operation uses a criteria-based update to ensure only jobs that are still pending at
742+ // execution time are canceled, avoiding race conditions where jobs may have transitioned to running.
743+ //
744+ // Since these jobs were never processed by a provisioner, no Terraform resources were created,
745+ // making it safe to orphan-delete the workspaces (skipping Terraform destroy).
746+ func (c * StoreReconciler )cancelAndOrphanDeletePendingPrebuilds (ctx context.Context ,templateID uuid.UUID ,templateVersionID uuid.UUID ,presetID uuid.UUID )error {
728747return c .store .InTx (func (db database.Store )error {
729- workspace ,err := db .GetWorkspaceByID (ctx ,prebuiltWorkspaceID )
748+ canceledJobs ,err := db .UpdatePrebuildProvisionerJobWithCancel (
749+ ctx ,
750+ database.UpdatePrebuildProvisionerJobWithCancelParams {
751+ Now :c .clock .Now (),
752+ PresetID : uuid.NullUUID {
753+ UUID :presetID ,
754+ Valid :true ,
755+ },
756+ })
730757if err != nil {
731- return xerrors .Errorf ("get workspace by ID: %w" ,err )
758+ c .logger .Error (ctx ,"failed to cancel pending prebuild jobs" ,
759+ slog .F ("template_id" ,templateID .String ()),
760+ slog .F ("template_version_id" ,templateVersionID .String ()),
761+ slog .F ("preset_id" ,presetID .String ()),
762+ slog .Error (err ))
763+ return err
732764}
733765
734- template ,err := db .GetTemplateByID (ctx ,templateID )
735- if err != nil {
736- return xerrors .Errorf ("failed to get template: %w" ,err )
766+ if len (canceledJobs )> 0 {
767+ c .logger .Info (ctx ,"canceled pending prebuild jobs for inactive version" ,
768+ slog .F ("template_id" ,templateID .String ()),
769+ slog .F ("template_version_id" ,templateVersionID .String ()),
770+ slog .F ("preset_id" ,presetID .String ()),
771+ slog .F ("count" ,len (canceledJobs )))
737772}
738773
739- if workspace .OwnerID != database .PrebuildsSystemUserID {
740- return xerrors .Errorf ("prebuilt workspace is not owned by prebuild user anymore, probably it was claimed" )
774+ var multiErr multierror.Error
775+ for _ ,job := range canceledJobs {
776+ err = c .provisionDelete (ctx ,db ,job .WorkspaceID ,job .TemplateID ,presetID ,DeprovisionModeOrphan )
777+ if err != nil {
778+ c .logger .Error (ctx ,"failed to orphan delete canceled prebuild" ,
779+ slog .F ("workspace_id" ,job .WorkspaceID .String ()),slog .Error (err ))
780+ multiErr .Errors = append (multiErr .Errors ,err )
781+ }
741782}
742783
743- c .logger .Info (ctx ,"attempting to delete prebuild" ,
744- slog .F ("workspace_id" ,prebuiltWorkspaceID .String ()),slog .F ("preset_id" ,presetID .String ()))
784+ return multiErr .ErrorOrNil ()
785+ },& database.TxOptions {
786+ Isolation :sql .LevelRepeatableRead ,
787+ ReadOnly :false ,
788+ })
789+ }
745790
746- return c .provision (ctx ,db ,prebuiltWorkspaceID ,template ,presetID ,database .WorkspaceTransitionDelete ,workspace )
791+ func (c * StoreReconciler )deletePrebuiltWorkspace (ctx context.Context ,prebuiltWorkspaceID uuid.UUID ,templateID uuid.UUID ,presetID uuid.UUID )error {
792+ return c .store .InTx (func (db database.Store )error {
793+ return c .provisionDelete (ctx ,db ,prebuiltWorkspaceID ,templateID ,presetID ,DeprovisionModeNormal )
747794},& database.TxOptions {
748795Isolation :sql .LevelRepeatableRead ,
749796ReadOnly :false ,
@@ -758,6 +805,7 @@ func (c *StoreReconciler) provision(
758805presetID uuid.UUID ,
759806transition database.WorkspaceTransition ,
760807workspace database.Workspace ,
808+ mode DeprovisionMode ,
761809)error {
762810tvp ,err := db .GetPresetParametersByTemplateVersionID (ctx ,template .ActiveVersionID )
763811if err != nil {
@@ -795,6 +843,11 @@ func (c *StoreReconciler) provision(
795843builder = builder .RichParameterValues (params )
796844}
797845
846+ // Use orphan mode for deletes when no Terraform resources exist
847+ if transition == database .WorkspaceTransitionDelete && mode == DeprovisionModeOrphan {
848+ builder = builder .Orphan ()
849+ }
850+
798851_ ,provisionerJob ,_ ,err := builder .Build (
799852ctx ,
800853db ,