@@ -57,6 +57,24 @@ type StoreReconciler struct {
5757
5858var _ prebuilds.ReconciliationOrchestrator = & StoreReconciler {}
5959
60+ type DeprovisionMode int
61+
62+ const (
63+ DeprovisionModeNormal DeprovisionMode = iota
64+ DeprovisionModeOrphan
65+ )
66+
67+ func (d DeprovisionMode )String ()string {
68+ switch d {
69+ case DeprovisionModeOrphan :
70+ return "orphan"
71+ case DeprovisionModeNormal :
72+ return "normal"
73+ default :
74+ return "unknown"
75+ }
76+ }
77+
6078func NewStoreReconciler (store database.Store ,
6179ps pubsub.Pubsub ,
6280fileCache * files.Cache ,
@@ -642,34 +660,7 @@ func (c *StoreReconciler) executeReconciliationAction(ctx context.Context, logge
642660return multiErr .ErrorOrNil ()
643661
644662case 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
663+ return c .cancelAndOrphanDeletePendingPrebuilds (ctx ,ps .Preset .TemplateID ,ps .Preset .TemplateVersionID ,ps .Preset .ID )
673664
674665default :
675666return xerrors .Errorf ("unknown action type: %v" ,action .ActionType )
@@ -717,33 +708,100 @@ func (c *StoreReconciler) createPrebuiltWorkspace(ctx context.Context, prebuiltW
717708c .logger .Info (ctx ,"attempting to create prebuild" ,slog .F ("name" ,name ),
718709slog .F ("workspace_id" ,prebuiltWorkspaceID .String ()),slog .F ("preset_id" ,presetID .String ()))
719710
720- return c .provision (ctx ,db ,prebuiltWorkspaceID ,template ,presetID ,database .WorkspaceTransitionStart ,workspace )
711+ return c .provision (ctx ,db ,prebuiltWorkspaceID ,template ,presetID ,database .WorkspaceTransitionStart ,workspace , DeprovisionModeNormal )
721712},& database.TxOptions {
722713Isolation :sql .LevelRepeatableRead ,
723714ReadOnly :false ,
724715})
725716}
726717
727- func (c * StoreReconciler )deletePrebuiltWorkspace (ctx context.Context ,prebuiltWorkspaceID uuid.UUID ,templateID uuid.UUID ,presetID uuid.UUID )error {
718+ // provisionDelete provisions a delete transition for a prebuilt workspace.
719+ //
720+ // If mode is DeprovisionModeOrphan, the builder will not send Terraform state to the provisioner.
721+ // This allows the workspace to be deleted even when no provisioners are available, and is safe
722+ // when no Terraform resources were actually created (e.g., for pending prebuilds that were canceled
723+ // before provisioning started).
724+ //
725+ // IMPORTANT: This function must be called within a database transaction. It does not create its own transaction.
726+ // The caller is responsible for managing the transaction boundary via db.InTx().
727+ func (c * StoreReconciler )provisionDelete (ctx context.Context ,db database.Store ,workspaceID uuid.UUID ,templateID uuid.UUID ,presetID uuid.UUID ,mode DeprovisionMode )error {
728+ workspace ,err := db .GetWorkspaceByID (ctx ,workspaceID )
729+ if err != nil {
730+ return xerrors .Errorf ("get workspace by ID: %w" ,err )
731+ }
732+
733+ template ,err := db .GetTemplateByID (ctx ,templateID )
734+ if err != nil {
735+ return xerrors .Errorf ("failed to get template: %w" ,err )
736+ }
737+
738+ if workspace .OwnerID != database .PrebuildsSystemUserID {
739+ return xerrors .Errorf ("prebuilt workspace is not owned by prebuild user anymore, probably it was claimed" )
740+ }
741+
742+ c .logger .Info (ctx ,"attempting to delete prebuild" ,slog .F ("orphan" ,mode .String ()),
743+ slog .F ("name" ,workspace .Name ),slog .F ("workspace_id" ,workspaceID .String ()),slog .F ("preset_id" ,presetID .String ()))
744+
745+ return c .provision (ctx ,db ,workspaceID ,template ,presetID ,
746+ database .WorkspaceTransitionDelete ,workspace ,mode )
747+ }
748+
749+ // cancelAndOrphanDeletePendingPrebuilds cancels pending prebuild jobs from inactive template versions
750+ // and orphan-deletes their associated workspaces.
751+ //
752+ // The cancel operation uses a criteria-based update to ensure only jobs that are still pending at
753+ // execution time are canceled, avoiding race conditions where jobs may have transitioned to running.
754+ //
755+ // Since these jobs were never processed by a provisioner, no Terraform resources were created,
756+ // making it safe to orphan-delete the workspaces (skipping Terraform destroy).
757+ func (c * StoreReconciler )cancelAndOrphanDeletePendingPrebuilds (ctx context.Context ,templateID uuid.UUID ,templateVersionID uuid.UUID ,presetID uuid.UUID )error {
728758return c .store .InTx (func (db database.Store )error {
729- workspace ,err := db .GetWorkspaceByID (ctx ,prebuiltWorkspaceID )
759+ canceledJobs ,err := db .UpdatePrebuildProvisionerJobWithCancel (
760+ ctx ,
761+ database.UpdatePrebuildProvisionerJobWithCancelParams {
762+ Now :c .clock .Now (),
763+ PresetID : uuid.NullUUID {
764+ UUID :presetID ,
765+ Valid :true ,
766+ },
767+ })
730768if err != nil {
731- return xerrors .Errorf ("get workspace by ID: %w" ,err )
769+ c .logger .Error (ctx ,"failed to cancel pending prebuild jobs" ,
770+ slog .F ("template_id" ,templateID .String ()),
771+ slog .F ("template_version_id" ,templateVersionID .String ()),
772+ slog .F ("preset_id" ,presetID .String ()),
773+ slog .Error (err ))
774+ return err
732775}
733776
734- template ,err := db .GetTemplateByID (ctx ,templateID )
735- if err != nil {
736- return xerrors .Errorf ("failed to get template: %w" ,err )
777+ if len (canceledJobs )> 0 {
778+ c .logger .Info (ctx ,"canceled pending prebuild jobs for inactive version" ,
779+ slog .F ("template_id" ,templateID .String ()),
780+ slog .F ("template_version_id" ,templateVersionID .String ()),
781+ slog .F ("preset_id" ,presetID .String ()),
782+ slog .F ("count" ,len (canceledJobs )))
737783}
738784
739- if workspace .OwnerID != database .PrebuildsSystemUserID {
740- return xerrors .Errorf ("prebuilt workspace is not owned by prebuild user anymore, probably it was claimed" )
785+ var multiErr multierror.Error
786+ for _ ,job := range canceledJobs {
787+ err = c .provisionDelete (ctx ,db ,job .WorkspaceID ,job .TemplateID ,presetID ,DeprovisionModeOrphan )
788+ if err != nil {
789+ c .logger .Error (ctx ,"failed to orphan delete canceled prebuild" ,
790+ slog .F ("workspace_id" ,job .WorkspaceID .String ()),slog .Error (err ))
791+ multiErr .Errors = append (multiErr .Errors ,err )
792+ }
741793}
742794
743- c .logger .Info (ctx ,"attempting to delete prebuild" ,
744- slog .F ("workspace_id" ,prebuiltWorkspaceID .String ()),slog .F ("preset_id" ,presetID .String ()))
795+ return multiErr .ErrorOrNil ()
796+ },& database.TxOptions {
797+ Isolation :sql .LevelRepeatableRead ,
798+ ReadOnly :false ,
799+ })
800+ }
745801
746- return c .provision (ctx ,db ,prebuiltWorkspaceID ,template ,presetID ,database .WorkspaceTransitionDelete ,workspace )
802+ func (c * StoreReconciler )deletePrebuiltWorkspace (ctx context.Context ,prebuiltWorkspaceID uuid.UUID ,templateID uuid.UUID ,presetID uuid.UUID )error {
803+ return c .store .InTx (func (db database.Store )error {
804+ return c .provisionDelete (ctx ,db ,prebuiltWorkspaceID ,templateID ,presetID ,DeprovisionModeNormal )
747805},& database.TxOptions {
748806Isolation :sql .LevelRepeatableRead ,
749807ReadOnly :false ,
@@ -758,6 +816,7 @@ func (c *StoreReconciler) provision(
758816presetID uuid.UUID ,
759817transition database.WorkspaceTransition ,
760818workspace database.Workspace ,
819+ mode DeprovisionMode ,
761820)error {
762821tvp ,err := db .GetPresetParametersByTemplateVersionID (ctx ,template .ActiveVersionID )
763822if err != nil {
@@ -795,6 +854,11 @@ func (c *StoreReconciler) provision(
795854builder = builder .RichParameterValues (params )
796855}
797856
857+ // Use orphan mode for deletes when no Terraform resources exist
858+ if transition == database .WorkspaceTransitionDelete && mode == DeprovisionModeOrphan {
859+ builder = builder .Orphan ()
860+ }
861+
798862_ ,provisionerJob ,_ ,err := builder .Build (
799863ctx ,
800864db ,