From 2703cf27616d0274d6c68912e0bc147d62ac63b0 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:52:44 -0700 Subject: [PATCH 01/31] feat(api): soft-delete build layers in DB on user delete Add a 'deleted' build status (mapped to a 'deleted' status_group) and, on template/snapshot delete, mark the template's exclusive builds as deleted in a transaction before the cascade. The env_builds rows and their team/env attribution are preserved so a future GC can reclaim the storage; builds shared with other templates are left untouched. No storage deletion here. --- .../api/internal/handlers/sandbox_kill.go | 6 +- .../api/internal/handlers/template_delete.go | 46 +++++++++-- ...7120000_add_deleted_build_status_group.sql | 33 ++++++++ ..._exclusive_template_builds_deleted_test.go | 79 +++++++++++++++++++ packages/db/pkg/types/types.go | 6 +- ...mark_exclusive_template_builds_deleted.sql | 18 +++++ ...k_exclusive_template_builds_deleted.sql.go | 42 ++++++++++ 7 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 packages/db/migrations/20260627120000_add_deleted_build_status_group.sql create mode 100644 packages/db/pkg/tests/builds/mark_exclusive_template_builds_deleted_test.go create mode 100644 packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql create mode 100644 packages/db/queries/mark_exclusive_template_builds_deleted.sql.go diff --git a/packages/api/internal/handlers/sandbox_kill.go b/packages/api/internal/handlers/sandbox_kill.go index 8c214fc166..4613a3456d 100644 --- a/packages/api/internal/handlers/sandbox_kill.go +++ b/packages/api/internal/handlers/sandbox_kill.go @@ -14,7 +14,6 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/sandbox" "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" - "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -25,10 +24,7 @@ func (a *APIStore) deleteSnapshot(ctx context.Context, sandboxID string, teamID return err } - aliasKeys, dbErr := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ - TeamID: teamID, - TemplateID: snapshot.TemplateID, - }) + aliasKeys, dbErr := a.deleteTemplateAndSoftDeleteBuilds(ctx, teamID, snapshot.TemplateID, "Snapshot deleted by user") if dbErr != nil { return fmt.Errorf("error deleting template from db: %w", dbErr) } diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index 3680c8db64..1c1680d3d5 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -6,16 +6,54 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/sandbox" + dbtypes "github.com/e2b-dev/infra/packages/db/pkg/types" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) +// deleteTemplateAndSoftDeleteBuilds soft-deletes the template's exclusive build +// layers (status='deleted', preserving the env_builds rows and their +// team/env attribution for later storage GC) and then deletes the template, in +// a single transaction. Returns the alias cache keys captured before cascade +// deletion. Build artifacts are intentionally NOT removed from storage here. +func (a *APIStore) deleteTemplateAndSoftDeleteBuilds(ctx context.Context, teamID uuid.UUID, templateID, reason string) ([]string, error) { + txDB, tx, err := a.sqlcDB.WithTx(ctx) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + + // Must run before DeleteTemplate: the exclusivity check reads + // env_build_assignments, which DeleteTemplate cascades away. + if err := txDB.MarkExclusiveTemplateBuildsDeleted(ctx, queries.MarkExclusiveTemplateBuildsDeletedParams{ + TemplateID: templateID, + Reason: dbtypes.BuildReason{Message: reason}, + }); err != nil { + return nil, fmt.Errorf("mark builds deleted: %w", err) + } + + aliasKeys, err := txDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ + TemplateID: templateID, + TeamID: teamID, + }) + if err != nil { + return nil, fmt.Errorf("delete template: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return aliasKeys, nil +} + // DeleteTemplatesTemplateID serves to delete a template (e.g. in CLI) func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID api.TemplateID) { ctx := c.Request.Context() @@ -80,15 +118,13 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a return } - // Delete the template from DB (cascades to env_build_assignments, env_aliases, snapshot_templates). + // Soft-delete the template's exclusive build layers and delete the template + // (cascades to env_build_assignments, env_aliases, snapshot_templates). // Returns alias cache keys captured before cascade deletion for cache invalidation. // Build artifacts are intentionally NOT deleted from storage here because builds are layered diffs // that may be referenced by other builds' header mappings. // [ENG-3477] a future GC mechanism will handle orphaned storage. - aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ - TemplateID: templateID, - TeamID: team.ID, - }) + aliasKeys, err := a.deleteTemplateAndSoftDeleteBuilds(ctx, team.ID, templateID, "Template deleted by user") if err != nil { telemetry.ReportCriticalError(ctx, "error when deleting template from db", err) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when deleting template") diff --git a/packages/db/migrations/20260627120000_add_deleted_build_status_group.sql b/packages/db/migrations/20260627120000_add_deleted_build_status_group.sql new file mode 100644 index 0000000000..8e96125f8b --- /dev/null +++ b/packages/db/migrations/20260627120000_add_deleted_build_status_group.sql @@ -0,0 +1,33 @@ +-- +goose Up +-- Map the new 'deleted' build status to a dedicated 'deleted' status group so +-- soft-deleted build layers are distinguishable from 'failed'. The trigger +-- already references this function by name; replacing it is sufficient. +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION compute_status_group() RETURNS TRIGGER AS $$ +BEGIN + NEW.status_group := CASE + WHEN NEW.status IN ('pending', 'waiting') THEN 'pending' + WHEN NEW.status IN ('in_progress', 'building', 'snapshotting') THEN 'in_progress' + WHEN NEW.status IN ('ready', 'uploaded', 'success') THEN 'ready' + WHEN NEW.status = 'deleted' THEN 'deleted' + ELSE 'failed' + END; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION compute_status_group() RETURNS TRIGGER AS $$ +BEGIN + NEW.status_group := CASE + WHEN NEW.status IN ('pending', 'waiting') THEN 'pending' + WHEN NEW.status IN ('in_progress', 'building', 'snapshotting') THEN 'in_progress' + WHEN NEW.status IN ('ready', 'uploaded', 'success') THEN 'ready' + ELSE 'failed' + END; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd diff --git a/packages/db/pkg/tests/builds/mark_exclusive_template_builds_deleted_test.go b/packages/db/pkg/tests/builds/mark_exclusive_template_builds_deleted_test.go new file mode 100644 index 0000000000..cfa5a1cbe7 --- /dev/null +++ b/packages/db/pkg/tests/builds/mark_exclusive_template_builds_deleted_test.go @@ -0,0 +1,79 @@ +package builds + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/db/pkg/testutils" + "github.com/e2b-dev/infra/packages/db/pkg/types" + "github.com/e2b-dev/infra/packages/db/queries" +) + +func TestMarkExclusiveTemplateBuildsDeleted_MarksExclusiveBuild(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "default") + + err := db.SqlcClient.MarkExclusiveTemplateBuildsDeleted(ctx, queries.MarkExclusiveTemplateBuildsDeletedParams{ + TemplateID: templateID, + Reason: types.BuildReason{Message: "Template deleted by user"}, + }) + require.NoError(t, err) + + assert.Equal(t, "deleted", testutils.GetBuildStatus(t, ctx, db, buildID), "exclusive build should be soft-deleted") +} + +func TestMarkExclusiveTemplateBuildsDeleted_LeavesSharedBuild(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + template1ID := testutils.CreateTestTemplate(t, db, teamID) + template2ID := testutils.CreateTestTemplate(t, db, teamID) + + // One build assigned to both templates (shared). + sharedBuildID := testutils.CreateTestBuild(t, ctx, db, template1ID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, template1ID, sharedBuildID, "default") + testutils.CreateTestBuildAssignment(t, ctx, db, template2ID, sharedBuildID, "default") + + err := db.SqlcClient.MarkExclusiveTemplateBuildsDeleted(ctx, queries.MarkExclusiveTemplateBuildsDeletedParams{ + TemplateID: template1ID, + Reason: types.BuildReason{Message: "Template deleted by user"}, + }) + require.NoError(t, err) + + assert.Equal(t, "success", testutils.GetBuildStatus(t, ctx, db, sharedBuildID), "build shared with another template must not be soft-deleted") +} + +func TestMarkExclusiveTemplateBuildsDeleted_DoesNotAffectOtherTemplates(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + template1ID := testutils.CreateTestTemplate(t, db, teamID) + template2ID := testutils.CreateTestTemplate(t, db, teamID) + + build1ID := testutils.CreateTestBuild(t, ctx, db, template1ID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, template1ID, build1ID, "default") + + build2ID := testutils.CreateTestBuild(t, ctx, db, template2ID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, template2ID, build2ID, "default") + + err := db.SqlcClient.MarkExclusiveTemplateBuildsDeleted(ctx, queries.MarkExclusiveTemplateBuildsDeletedParams{ + TemplateID: template1ID, + Reason: types.BuildReason{Message: "Template deleted by user"}, + }) + require.NoError(t, err) + + assert.Equal(t, "deleted", testutils.GetBuildStatus(t, ctx, db, build1ID), "template1 build should be soft-deleted") + assert.Equal(t, "success", testutils.GetBuildStatus(t, ctx, db, build2ID), "template2 build should not be affected") +} diff --git a/packages/db/pkg/types/types.go b/packages/db/pkg/types/types.go index 04747238f0..be5422edbe 100644 --- a/packages/db/pkg/types/types.go +++ b/packages/db/pkg/types/types.go @@ -150,6 +150,9 @@ const ( BuildStatusUploaded BuildStatus = "uploaded" BuildStatusSuccess BuildStatus = "success" BuildStatusFailed BuildStatus = "failed" + // BuildStatusDeleted marks a build whose layers were soft-deleted on user + // delete. The env_builds row (and its team/env attribution) is preserved. + BuildStatusDeleted BuildStatus = "deleted" ) // BuildStatusGroup represents the normalized status from the status_group @@ -161,8 +164,9 @@ const ( BuildStatusGroupInProgress BuildStatusGroup = "in_progress" BuildStatusGroupReady BuildStatusGroup = "ready" BuildStatusGroupFailed BuildStatusGroup = "failed" + BuildStatusGroupDeleted BuildStatusGroup = "deleted" ) func (g BuildStatusGroup) IsTerminal() bool { - return g == BuildStatusGroupReady || g == BuildStatusGroupFailed + return g == BuildStatusGroupReady || g == BuildStatusGroupFailed || g == BuildStatusGroupDeleted } diff --git a/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql b/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql new file mode 100644 index 0000000000..a1da18c727 --- /dev/null +++ b/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql @@ -0,0 +1,18 @@ +-- name: MarkExclusiveTemplateBuildsDeleted :exec +-- Soft-deletes builds that are ONLY assigned to this template by setting their +-- status to 'deleted'. The env_builds rows (and their team/env attribution) are +-- preserved so a future GC can reclaim the storage. Builds shared with other +-- templates, and builds already marked deleted, are left untouched. +UPDATE "public"."env_builds" eb +SET status = 'deleted', + reason = @reason, + updated_at = NOW(), + finished_at = NOW() +FROM "public"."env_build_assignments" eba +WHERE eba.build_id = eb.id + AND eba.env_id = @template_id + AND eb.status_group <> 'deleted' + AND NOT EXISTS ( + SELECT 1 FROM "public"."env_build_assignments" other_eba + WHERE other_eba.build_id = eb.id AND other_eba.env_id != @template_id + ); diff --git a/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go b/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go new file mode 100644 index 0000000000..c70cf31fbe --- /dev/null +++ b/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: mark_exclusive_template_builds_deleted.sql + +package queries + +import ( + "context" + + "github.com/e2b-dev/infra/packages/db/pkg/types" +) + +const markExclusiveTemplateBuildsDeleted = `-- name: MarkExclusiveTemplateBuildsDeleted :exec +UPDATE "public"."env_builds" eb +SET status = 'deleted', + reason = $1, + updated_at = NOW(), + finished_at = NOW() +FROM "public"."env_build_assignments" eba +WHERE eba.build_id = eb.id + AND eba.env_id = $2 + AND eb.status_group <> 'deleted' + AND NOT EXISTS ( + SELECT 1 FROM "public"."env_build_assignments" other_eba + WHERE other_eba.build_id = eb.id AND other_eba.env_id != $2 + ) +` + +type MarkExclusiveTemplateBuildsDeletedParams struct { + Reason types.BuildReason + TemplateID string +} + +// Soft-deletes builds that are ONLY assigned to this template by setting their +// status to 'deleted'. The env_builds rows (and their team/env attribution) are +// preserved so a future GC can reclaim the storage. Builds shared with other +// templates, and builds already marked deleted, are left untouched. +func (q *Queries) MarkExclusiveTemplateBuildsDeleted(ctx context.Context, arg MarkExclusiveTemplateBuildsDeletedParams) error { + _, err := q.db.Exec(ctx, markExclusiveTemplateBuildsDeleted, arg.Reason, arg.TemplateID) + return err +} From f76f755c7e1e012f8b8e6fac84568d22dbdadd81 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:11:44 -0700 Subject: [PATCH 02/31] fix(db): guard soft-deleted builds from in-flight status overwrites Add status_group <> 'deleted' guard to the build status update queries so a concurrent template-manager sync cannot resurrect a soft-deleted build, and preserve original finished_at/reason when marking builds deleted. --- packages/db/queries/builds/finish_template_build.sql | 3 ++- .../builds/mark_exclusive_template_builds_deleted.sql | 4 ++-- packages/db/queries/finish_template_build.sql.go | 1 + .../queries/mark_exclusive_template_builds_deleted.sql.go | 4 ++-- .../db/queries/templates/update_template_build_status.sql | 6 ++++-- packages/db/queries/update_template_build_status.sql.go | 2 ++ 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/db/queries/builds/finish_template_build.sql b/packages/db/queries/builds/finish_template_build.sql index 4d7be6d56f..10e1e4623f 100644 --- a/packages/db/queries/builds/finish_template_build.sql +++ b/packages/db/queries/builds/finish_template_build.sql @@ -16,4 +16,5 @@ SET kernel_version = COALESCE(NULLIF(@kernel_version::text, ''), kernel_version), firecracker_version = COALESCE(NULLIF(@firecracker_version::text, ''), firecracker_version) WHERE - id = @build_id; + id = @build_id + AND status_group <> 'deleted'; diff --git a/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql b/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql index a1da18c727..a0f7c2c213 100644 --- a/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql +++ b/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql @@ -5,9 +5,9 @@ -- templates, and builds already marked deleted, are left untouched. UPDATE "public"."env_builds" eb SET status = 'deleted', - reason = @reason, + reason = COALESCE(NULLIF(eb.reason, '{}'::jsonb), @reason), updated_at = NOW(), - finished_at = NOW() + finished_at = COALESCE(eb.finished_at, NOW()) FROM "public"."env_build_assignments" eba WHERE eba.build_id = eb.id AND eba.env_id = @template_id diff --git a/packages/db/queries/finish_template_build.sql.go b/packages/db/queries/finish_template_build.sql.go index 117fd4f9b6..dabacd41fa 100644 --- a/packages/db/queries/finish_template_build.sql.go +++ b/packages/db/queries/finish_template_build.sql.go @@ -26,6 +26,7 @@ SET firecracker_version = COALESCE(NULLIF($5::text, ''), firecracker_version) WHERE id = $6 + AND status_group <> 'deleted' ` type FinishTemplateBuildParams struct { diff --git a/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go b/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go index c70cf31fbe..df3b868e7c 100644 --- a/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go +++ b/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go @@ -14,9 +14,9 @@ import ( const markExclusiveTemplateBuildsDeleted = `-- name: MarkExclusiveTemplateBuildsDeleted :exec UPDATE "public"."env_builds" eb SET status = 'deleted', - reason = $1, + reason = COALESCE(NULLIF(eb.reason, '{}'::jsonb), $1), updated_at = NOW(), - finished_at = NOW() + finished_at = COALESCE(eb.finished_at, NOW()) FROM "public"."env_build_assignments" eba WHERE eba.build_id = eb.id AND eba.env_id = $2 diff --git a/packages/db/queries/templates/update_template_build_status.sql b/packages/db/queries/templates/update_template_build_status.sql index 89353d430b..80254950c1 100644 --- a/packages/db/queries/templates/update_template_build_status.sql +++ b/packages/db/queries/templates/update_template_build_status.sql @@ -4,7 +4,8 @@ SET status = @status, finished_at = @finished_at, reason = sqlc.narg(reason), version = @version -WHERE id = @build_id; +WHERE id = @build_id + AND status_group <> 'deleted'; -- name: FailTemplateBuildAndDeactivate :exec WITH deactivated AS ( @@ -15,4 +16,5 @@ SET status = @status, finished_at = @finished_at, reason = sqlc.narg(reason), version = @version -WHERE id = @build_id; +WHERE id = @build_id + AND status_group <> 'deleted'; diff --git a/packages/db/queries/update_template_build_status.sql.go b/packages/db/queries/update_template_build_status.sql.go index 5bac88b0ad..773c5e761e 100644 --- a/packages/db/queries/update_template_build_status.sql.go +++ b/packages/db/queries/update_template_build_status.sql.go @@ -23,6 +23,7 @@ SET status = $1, reason = $3, version = $4 WHERE id = $5 + AND status_group <> 'deleted' ` type FailTemplateBuildAndDeactivateParams struct { @@ -51,6 +52,7 @@ SET status = $1, reason = $3, version = $4 WHERE id = $5 + AND status_group <> 'deleted' ` type UpdateEnvBuildStatusParams struct { From 33c1e65f442d52c982303dcf8a13de9da8b09689 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:38:51 -0700 Subject: [PATCH 03/31] refactor(db): soft-delete templates/snapshots at the env level Replace the build-level soft-delete with an env-level one: deleting a template, paused sandbox, or snapshot marks envs.status='deleted' instead of hard-deleting the env, preserving env_builds, env_build_assignments, and snapshots rows (sandbox_id/base_env_id) so build lineage stays traceable for a later storage GC. Aliases are released so names can be reused. Template/snapshot/build reads in the API and dashboard filter out deleted envs. --- .../api/internal/handlers/sandbox_kill.go | 2 +- .../api/internal/handlers/template_delete.go | 43 ++------- ...7120000_add_deleted_build_status_group.sql | 33 ------- .../20260628120000_add_env_status.sql | 9 ++ .../queries/get_default_templates.sql.go | 2 +- .../queries/list_team_templates.sql.go | 8 ++ .../templates/get_default_templates.sql | 2 +- .../templates/list_team_templates.sql | 8 ++ ..._exclusive_template_builds_deleted_test.go | 79 ---------------- .../tests/templates/delete_template_test.go | 93 +++++++++++++++++++ packages/db/pkg/testutils/queries/models.go | 1 + packages/db/pkg/types/types.go | 6 +- .../queries/builds/finish_template_build.sql | 3 +- packages/db/queries/builds/get_build_info.sql | 1 + .../queries/builds/get_builds_paginated.sql | 4 + .../db/queries/builds/get_template_build.sql | 3 +- ...mark_exclusive_template_builds_deleted.sql | 18 ---- packages/db/queries/delete_template.sql.go | 31 ++++--- .../queries/exists_template_snapshots.sql.go | 6 +- .../db/queries/finish_template_build.sql.go | 1 - packages/db/queries/get_build_info.sql.go | 1 + .../db/queries/get_builds_paginated.sql.go | 4 + .../db/queries/get_inprogress_builds.sql.go | 3 +- packages/db/queries/get_last_snapshot.sql.go | 1 + .../db/queries/get_snapshots_with_cursor.sql | 1 + .../queries/get_snapshots_with_cursor.sql.go | 1 + packages/db/queries/get_team_template.sql | 3 +- packages/db/queries/get_team_template.sql.go | 4 +- packages/db/queries/get_team_templates.sql | 1 + packages/db/queries/get_team_templates.sql.go | 4 +- .../get_team_templates_with_cursor.sql | 1 + .../get_team_templates_with_cursor.sql.go | 1 + packages/db/queries/get_template.sql.go | 2 + packages/db/queries/get_template_build.sql.go | 4 +- packages/db/queries/get_template_by_id.sql.go | 9 +- .../get_template_with_build_by_tag.sql.go | 4 +- .../list_team_snapshot_templates.sql.go | 1 + ...k_exclusive_template_builds_deleted.sql.go | 42 --------- packages/db/queries/models.go | 1 + .../queries/snapshots/get_last_snapshot.sql | 1 + .../list_team_snapshot_templates.sql | 1 + .../db/queries/templates/delete_template.sql | 31 ++++--- .../templates/exists_template_snapshots.sql | 6 +- .../db/queries/templates/get_template.sql | 6 +- .../queries/templates/get_template_by_id.sql | 6 +- .../get_template_with_build_by_tag.sql | 1 + .../db/queries/templates/update_template.sql | 1 + .../update_template_build_status.sql | 6 +- packages/db/queries/update_template.sql.go | 1 + .../update_template_build_status.sql.go | 2 - 50 files changed, 240 insertions(+), 263 deletions(-) delete mode 100644 packages/db/migrations/20260627120000_add_deleted_build_status_group.sql create mode 100644 packages/db/migrations/20260628120000_add_env_status.sql delete mode 100644 packages/db/pkg/tests/builds/mark_exclusive_template_builds_deleted_test.go create mode 100644 packages/db/pkg/tests/templates/delete_template_test.go delete mode 100644 packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql delete mode 100644 packages/db/queries/mark_exclusive_template_builds_deleted.sql.go diff --git a/packages/api/internal/handlers/sandbox_kill.go b/packages/api/internal/handlers/sandbox_kill.go index 4613a3456d..b30ac6b5a9 100644 --- a/packages/api/internal/handlers/sandbox_kill.go +++ b/packages/api/internal/handlers/sandbox_kill.go @@ -24,7 +24,7 @@ func (a *APIStore) deleteSnapshot(ctx context.Context, sandboxID string, teamID return err } - aliasKeys, dbErr := a.deleteTemplateAndSoftDeleteBuilds(ctx, teamID, snapshot.TemplateID, "Snapshot deleted by user") + aliasKeys, dbErr := a.softDeleteTemplate(ctx, teamID, snapshot.TemplateID) if dbErr != nil { return fmt.Errorf("error deleting template from db: %w", dbErr) } diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index 1c1680d3d5..d9e426a7da 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -11,35 +11,18 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/sandbox" - dbtypes "github.com/e2b-dev/infra/packages/db/pkg/types" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) -// deleteTemplateAndSoftDeleteBuilds soft-deletes the template's exclusive build -// layers (status='deleted', preserving the env_builds rows and their -// team/env attribution for later storage GC) and then deletes the template, in -// a single transaction. Returns the alias cache keys captured before cascade -// deletion. Build artifacts are intentionally NOT removed from storage here. -func (a *APIStore) deleteTemplateAndSoftDeleteBuilds(ctx context.Context, teamID uuid.UUID, templateID, reason string) ([]string, error) { - txDB, tx, err := a.sqlcDB.WithTx(ctx) - if err != nil { - return nil, fmt.Errorf("begin tx: %w", err) - } - defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() - - // Must run before DeleteTemplate: the exclusivity check reads - // env_build_assignments, which DeleteTemplate cascades away. - if err := txDB.MarkExclusiveTemplateBuildsDeleted(ctx, queries.MarkExclusiveTemplateBuildsDeletedParams{ - TemplateID: templateID, - Reason: dbtypes.BuildReason{Message: reason}, - }); err != nil { - return nil, fmt.Errorf("mark builds deleted: %w", err) - } - - aliasKeys, err := txDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ +// softDeleteTemplate marks the template's env status='deleted', preserving the +// env row, its build assignments, and any snapshot rows so the build lineage +// stays traceable for a later storage GC. Aliases are released so the name can +// be reused. Returns the released alias cache keys for cache invalidation. +func (a *APIStore) softDeleteTemplate(ctx context.Context, teamID uuid.UUID, templateID string) ([]string, error) { + aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ TemplateID: templateID, TeamID: teamID, }) @@ -47,10 +30,6 @@ func (a *APIStore) deleteTemplateAndSoftDeleteBuilds(ctx context.Context, teamID return nil, fmt.Errorf("delete template: %w", err) } - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit: %w", err) - } - return aliasKeys, nil } @@ -118,13 +97,11 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a return } - // Soft-delete the template's exclusive build layers and delete the template - // (cascades to env_build_assignments, env_aliases, snapshot_templates). - // Returns alias cache keys captured before cascade deletion for cache invalidation. - // Build artifacts are intentionally NOT deleted from storage here because builds are layered diffs - // that may be referenced by other builds' header mappings. + // Soft-delete the template: mark its env deleted and release aliases, keeping + // the env_builds rows, assignments, and snapshot rows for lineage and a later + // storage GC. Returns alias cache keys for cache invalidation. // [ENG-3477] a future GC mechanism will handle orphaned storage. - aliasKeys, err := a.deleteTemplateAndSoftDeleteBuilds(ctx, team.ID, templateID, "Template deleted by user") + aliasKeys, err := a.softDeleteTemplate(ctx, team.ID, templateID) if err != nil { telemetry.ReportCriticalError(ctx, "error when deleting template from db", err) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when deleting template") diff --git a/packages/db/migrations/20260627120000_add_deleted_build_status_group.sql b/packages/db/migrations/20260627120000_add_deleted_build_status_group.sql deleted file mode 100644 index 8e96125f8b..0000000000 --- a/packages/db/migrations/20260627120000_add_deleted_build_status_group.sql +++ /dev/null @@ -1,33 +0,0 @@ --- +goose Up --- Map the new 'deleted' build status to a dedicated 'deleted' status group so --- soft-deleted build layers are distinguishable from 'failed'. The trigger --- already references this function by name; replacing it is sufficient. --- +goose StatementBegin -CREATE OR REPLACE FUNCTION compute_status_group() RETURNS TRIGGER AS $$ -BEGIN - NEW.status_group := CASE - WHEN NEW.status IN ('pending', 'waiting') THEN 'pending' - WHEN NEW.status IN ('in_progress', 'building', 'snapshotting') THEN 'in_progress' - WHEN NEW.status IN ('ready', 'uploaded', 'success') THEN 'ready' - WHEN NEW.status = 'deleted' THEN 'deleted' - ELSE 'failed' - END; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -CREATE OR REPLACE FUNCTION compute_status_group() RETURNS TRIGGER AS $$ -BEGIN - NEW.status_group := CASE - WHEN NEW.status IN ('pending', 'waiting') THEN 'pending' - WHEN NEW.status IN ('in_progress', 'building', 'snapshotting') THEN 'in_progress' - WHEN NEW.status IN ('ready', 'uploaded', 'success') THEN 'ready' - ELSE 'failed' - END; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; --- +goose StatementEnd diff --git a/packages/db/migrations/20260628120000_add_env_status.sql b/packages/db/migrations/20260628120000_add_env_status.sql new file mode 100644 index 0000000000..09baa00f71 --- /dev/null +++ b/packages/db/migrations/20260628120000_add_env_status.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Soft-delete marker for envs (templates, snapshots, snapshot_templates). On +-- user delete the env is marked 'deleted' instead of being removed, so its +-- build assignments and snapshot rows survive and the build lineage stays +-- traceable. Reads filter out status='deleted'. +ALTER TABLE "public"."envs" ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'active'; + +-- +goose Down +ALTER TABLE "public"."envs" DROP COLUMN IF EXISTS status; diff --git a/packages/db/pkg/dashboard/queries/get_default_templates.sql.go b/packages/db/pkg/dashboard/queries/get_default_templates.sql.go index c777a60b22..28380ba637 100644 --- a/packages/db/pkg/dashboard/queries/get_default_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/get_default_templates.sql.go @@ -25,7 +25,7 @@ SELECT b.total_disk_size_mb, b.envd_version FROM public.env_defaults ed -JOIN public.envs e ON e.id = ed.env_id +JOIN public.envs e ON e.id = ed.env_id AND e.status <> 'deleted' JOIN LATERAL ( SELECT a.build_id FROM public.env_build_assignments a diff --git a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go index e758b2e674..918d18bb1d 100644 --- a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go @@ -30,6 +30,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -63,6 +64,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean + AND e.status <> 'deleted' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -243,6 +245,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -276,6 +279,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean + AND e.status <> 'deleted' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -440,6 +444,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -473,6 +478,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean + AND e.status <> 'deleted' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -637,6 +643,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -670,6 +677,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean + AND e.status <> 'deleted' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' diff --git a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql index 943914bd5f..2882832861 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql @@ -11,7 +11,7 @@ SELECT b.total_disk_size_mb, b.envd_version FROM public.env_defaults ed -JOIN public.envs e ON e.id = ed.env_id +JOIN public.envs e ON e.id = ed.env_id AND e.status <> 'deleted' JOIN LATERAL ( SELECT a.build_id FROM public.env_build_assignments a diff --git a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql index 4beb374761..6aad0a64c8 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql @@ -32,6 +32,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -65,6 +66,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean + AND e.status <> 'deleted' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -150,6 +152,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -183,6 +186,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean + AND e.status <> 'deleted' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -268,6 +272,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -301,6 +306,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean + AND e.status <> 'deleted' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -386,6 +392,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -419,6 +426,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean + AND e.status <> 'deleted' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' diff --git a/packages/db/pkg/tests/builds/mark_exclusive_template_builds_deleted_test.go b/packages/db/pkg/tests/builds/mark_exclusive_template_builds_deleted_test.go deleted file mode 100644 index cfa5a1cbe7..0000000000 --- a/packages/db/pkg/tests/builds/mark_exclusive_template_builds_deleted_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package builds - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/e2b-dev/infra/packages/db/pkg/testutils" - "github.com/e2b-dev/infra/packages/db/pkg/types" - "github.com/e2b-dev/infra/packages/db/queries" -) - -func TestMarkExclusiveTemplateBuildsDeleted_MarksExclusiveBuild(t *testing.T) { - t.Parallel() - db := testutils.SetupDatabase(t) - ctx := t.Context() - - teamID := testutils.CreateTestTeam(t, db) - templateID := testutils.CreateTestTemplate(t, db, teamID) - buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "success") - testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "default") - - err := db.SqlcClient.MarkExclusiveTemplateBuildsDeleted(ctx, queries.MarkExclusiveTemplateBuildsDeletedParams{ - TemplateID: templateID, - Reason: types.BuildReason{Message: "Template deleted by user"}, - }) - require.NoError(t, err) - - assert.Equal(t, "deleted", testutils.GetBuildStatus(t, ctx, db, buildID), "exclusive build should be soft-deleted") -} - -func TestMarkExclusiveTemplateBuildsDeleted_LeavesSharedBuild(t *testing.T) { - t.Parallel() - db := testutils.SetupDatabase(t) - ctx := t.Context() - - teamID := testutils.CreateTestTeam(t, db) - template1ID := testutils.CreateTestTemplate(t, db, teamID) - template2ID := testutils.CreateTestTemplate(t, db, teamID) - - // One build assigned to both templates (shared). - sharedBuildID := testutils.CreateTestBuild(t, ctx, db, template1ID, "success") - testutils.CreateTestBuildAssignment(t, ctx, db, template1ID, sharedBuildID, "default") - testutils.CreateTestBuildAssignment(t, ctx, db, template2ID, sharedBuildID, "default") - - err := db.SqlcClient.MarkExclusiveTemplateBuildsDeleted(ctx, queries.MarkExclusiveTemplateBuildsDeletedParams{ - TemplateID: template1ID, - Reason: types.BuildReason{Message: "Template deleted by user"}, - }) - require.NoError(t, err) - - assert.Equal(t, "success", testutils.GetBuildStatus(t, ctx, db, sharedBuildID), "build shared with another template must not be soft-deleted") -} - -func TestMarkExclusiveTemplateBuildsDeleted_DoesNotAffectOtherTemplates(t *testing.T) { - t.Parallel() - db := testutils.SetupDatabase(t) - ctx := t.Context() - - teamID := testutils.CreateTestTeam(t, db) - template1ID := testutils.CreateTestTemplate(t, db, teamID) - template2ID := testutils.CreateTestTemplate(t, db, teamID) - - build1ID := testutils.CreateTestBuild(t, ctx, db, template1ID, "success") - testutils.CreateTestBuildAssignment(t, ctx, db, template1ID, build1ID, "default") - - build2ID := testutils.CreateTestBuild(t, ctx, db, template2ID, "success") - testutils.CreateTestBuildAssignment(t, ctx, db, template2ID, build2ID, "default") - - err := db.SqlcClient.MarkExclusiveTemplateBuildsDeleted(ctx, queries.MarkExclusiveTemplateBuildsDeletedParams{ - TemplateID: template1ID, - Reason: types.BuildReason{Message: "Template deleted by user"}, - }) - require.NoError(t, err) - - assert.Equal(t, "deleted", testutils.GetBuildStatus(t, ctx, db, build1ID), "template1 build should be soft-deleted") - assert.Equal(t, "success", testutils.GetBuildStatus(t, ctx, db, build2ID), "template2 build should not be affected") -} diff --git a/packages/db/pkg/tests/templates/delete_template_test.go b/packages/db/pkg/tests/templates/delete_template_test.go new file mode 100644 index 0000000000..5f917a6e90 --- /dev/null +++ b/packages/db/pkg/tests/templates/delete_template_test.go @@ -0,0 +1,93 @@ +package templates + +import ( + "testing" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/db/pkg/testutils" + "github.com/e2b-dev/infra/packages/db/queries" +) + +func envStatus(t *testing.T, db *testutils.Database, envID string) string { + t.Helper() + var status string + err := db.SqlcClient.TestsRawSQLQuery(t.Context(), + "SELECT status FROM public.envs WHERE id = $1", + func(rows pgx.Rows) error { + if rows.Next() { + return rows.Scan(&status) + } + + return nil + }, + envID, + ) + require.NoError(t, err) + + return status +} + +func TestDeleteTemplate_SoftDeletesEnvAndPreservesStructure(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "default") + alias := testutils.CreateTestTemplateAlias(t, db, templateID) + + _, err := db.SqlcClient.DeleteTemplate(ctx, queries.DeleteTemplateParams{TemplateID: templateID, TeamID: teamID}) + require.NoError(t, err) + + assert.True(t, testutils.GetEnvByID(t, ctx, db, templateID), "env row must be preserved") + assert.Equal(t, "deleted", envStatus(t, db, templateID), "env must be soft-deleted") + assert.True(t, testutils.GetEnvBuildByID(t, ctx, db, buildID), "build row must be preserved") + assert.NotEmpty(t, testutils.GetBuildAssignments(t, ctx, db, templateID), "build assignment must be preserved") + + templates, err := db.SqlcClient.GetTeamTemplates(ctx, teamID) + require.NoError(t, err) + assert.Empty(t, templates, "soft-deleted template must not be listed") + + var aliasCount int + require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, + "SELECT COUNT(*) FROM public.env_aliases WHERE alias = $1", + func(rows pgx.Rows) error { + if rows.Next() { + return rows.Scan(&aliasCount) + } + + return nil + }, + alias, + )) + assert.Zero(t, aliasCount, "alias must be released for reuse") +} + +func TestDeleteTemplate_KeepsBuildSharedWithActiveTemplate(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + deletedID := testutils.CreateTestTemplate(t, db, teamID) + activeID := testutils.CreateTestTemplate(t, db, teamID) + + buildID := testutils.CreateTestBuild(t, ctx, db, deletedID, "uploaded") + testutils.CreateTestBuildAssignment(t, ctx, db, deletedID, buildID, "default") + testutils.CreateTestBuildAssignment(t, ctx, db, activeID, buildID, "default") + + _, err := db.SqlcClient.DeleteTemplate(ctx, queries.DeleteTemplateParams{TemplateID: deletedID, TeamID: teamID}) + require.NoError(t, err) + + assert.True(t, testutils.GetEnvBuildByID(t, ctx, db, buildID), "shared build must survive") + assert.Equal(t, "active", envStatus(t, db, activeID), "other template must be unaffected") + + got, err := db.SqlcClient.GetTeamTemplate(ctx, queries.GetTeamTemplateParams{ID: activeID, TeamID: teamID}) + require.NoError(t, err) + assert.Equal(t, activeID, got.Env.ID, "active template sharing the build must still resolve") +} diff --git a/packages/db/pkg/testutils/queries/models.go b/packages/db/pkg/testutils/queries/models.go index 803fb55f9a..1c7cecc75b 100644 --- a/packages/db/pkg/testutils/queries/models.go +++ b/packages/db/pkg/testutils/queries/models.go @@ -92,6 +92,7 @@ type Env struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string + Status string } type EnvAlias struct { diff --git a/packages/db/pkg/types/types.go b/packages/db/pkg/types/types.go index be5422edbe..04747238f0 100644 --- a/packages/db/pkg/types/types.go +++ b/packages/db/pkg/types/types.go @@ -150,9 +150,6 @@ const ( BuildStatusUploaded BuildStatus = "uploaded" BuildStatusSuccess BuildStatus = "success" BuildStatusFailed BuildStatus = "failed" - // BuildStatusDeleted marks a build whose layers were soft-deleted on user - // delete. The env_builds row (and its team/env attribution) is preserved. - BuildStatusDeleted BuildStatus = "deleted" ) // BuildStatusGroup represents the normalized status from the status_group @@ -164,9 +161,8 @@ const ( BuildStatusGroupInProgress BuildStatusGroup = "in_progress" BuildStatusGroupReady BuildStatusGroup = "ready" BuildStatusGroupFailed BuildStatusGroup = "failed" - BuildStatusGroupDeleted BuildStatusGroup = "deleted" ) func (g BuildStatusGroup) IsTerminal() bool { - return g == BuildStatusGroupReady || g == BuildStatusGroupFailed || g == BuildStatusGroupDeleted + return g == BuildStatusGroupReady || g == BuildStatusGroupFailed } diff --git a/packages/db/queries/builds/finish_template_build.sql b/packages/db/queries/builds/finish_template_build.sql index 10e1e4623f..4d7be6d56f 100644 --- a/packages/db/queries/builds/finish_template_build.sql +++ b/packages/db/queries/builds/finish_template_build.sql @@ -16,5 +16,4 @@ SET kernel_version = COALESCE(NULLIF(@kernel_version::text, ''), kernel_version), firecracker_version = COALESCE(NULLIF(@firecracker_version::text, ''), firecracker_version) WHERE - id = @build_id - AND status_group <> 'deleted'; + id = @build_id; diff --git a/packages/db/queries/builds/get_build_info.sql b/packages/db/queries/builds/get_build_info.sql index 341a9308d5..97a7a31749 100644 --- a/packages/db/queries/builds/get_build_info.sql +++ b/packages/db/queries/builds/get_build_info.sql @@ -12,6 +12,7 @@ JOIN LATERAL ( JOIN public.envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = sqlc.arg(team_id)::uuid + AND e.status <> 'deleted' ORDER BY a.created_at DESC, a.id DESC LIMIT 1 ) assignment ON TRUE diff --git a/packages/db/queries/builds/get_builds_paginated.sql b/packages/db/queries/builds/get_builds_paginated.sql index 830fca09ab..00740c1ce0 100644 --- a/packages/db/queries/builds/get_builds_paginated.sql +++ b/packages/db/queries/builds/get_builds_paginated.sql @@ -15,6 +15,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -52,6 +53,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -90,6 +92,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' WHERE a.build_id = b.id AND a.env_id = sqlc.arg(template_id)::text ORDER BY a.created_at DESC, a.id DESC @@ -128,6 +131,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' WHERE a.build_id = b.id AND EXISTS ( SELECT 1 diff --git a/packages/db/queries/builds/get_template_build.sql b/packages/db/queries/builds/get_template_build.sql index d6a7bf1791..678e1d8da7 100644 --- a/packages/db/queries/builds/get_template_build.sql +++ b/packages/db/queries/builds/get_template_build.sql @@ -4,4 +4,5 @@ FROM "public"."envs" e JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = sqlc.arg(template_id) AND eb.id = sqlc.arg(build_id) - AND e.source = 'template'; + AND e.source = 'template' + AND e.status <> 'deleted'; diff --git a/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql b/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql deleted file mode 100644 index a0f7c2c213..0000000000 --- a/packages/db/queries/builds/mark_exclusive_template_builds_deleted.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: MarkExclusiveTemplateBuildsDeleted :exec --- Soft-deletes builds that are ONLY assigned to this template by setting their --- status to 'deleted'. The env_builds rows (and their team/env attribution) are --- preserved so a future GC can reclaim the storage. Builds shared with other --- templates, and builds already marked deleted, are left untouched. -UPDATE "public"."env_builds" eb -SET status = 'deleted', - reason = COALESCE(NULLIF(eb.reason, '{}'::jsonb), @reason), - updated_at = NOW(), - finished_at = COALESCE(eb.finished_at, NOW()) -FROM "public"."env_build_assignments" eba -WHERE eba.build_id = eb.id - AND eba.env_id = @template_id - AND eb.status_group <> 'deleted' - AND NOT EXISTS ( - SELECT 1 FROM "public"."env_build_assignments" other_eba - WHERE other_eba.build_id = eb.id AND other_eba.env_id != @template_id - ); diff --git a/packages/db/queries/delete_template.sql.go b/packages/db/queries/delete_template.sql.go index e7f52dfdb6..7cc0359a09 100644 --- a/packages/db/queries/delete_template.sql.go +++ b/packages/db/queries/delete_template.sql.go @@ -14,19 +14,24 @@ import ( const deleteTemplate = `-- name: DeleteTemplate :many WITH alias_keys AS ( SELECT CASE - WHEN namespace IS NOT NULL THEN namespace || '/' || alias - ELSE alias + WHEN ea.namespace IS NOT NULL THEN ea.namespace || '/' || ea.alias + ELSE ea.alias END::text AS alias_key - FROM public.env_aliases - WHERE env_id = $1 -), deleted AS ( - DELETE FROM "public"."envs" - WHERE id = $1 - AND team_id = $2 - RETURNING id + FROM public.env_aliases ea + WHERE ea.env_id = $1 +), released AS ( + DELETE FROM public.env_aliases ea + WHERE ea.env_id = $1 +), updated AS ( + UPDATE public.envs e + SET status = 'deleted', updated_at = NOW() + WHERE e.id = $1 + AND e.team_id = $2 + AND e.status <> 'deleted' + RETURNING e.id ) SELECT alias_key FROM alias_keys -WHERE EXISTS (SELECT 1 FROM deleted) +WHERE EXISTS (SELECT 1 FROM updated) ` type DeleteTemplateParams struct { @@ -34,8 +39,10 @@ type DeleteTemplateParams struct { TeamID uuid.UUID } -// Deletes a template and returns its alias cache keys for cache invalidation. -// Alias keys are captured via CTE before the cascade delete removes them. +// Soft-deletes a template by marking its env status='deleted'. The env row, its +// build assignments, and any snapshot rows are preserved so the build lineage +// stays traceable for a future storage GC. Aliases are released (deleted) so the +// name can be reused. Returns the released alias cache keys for cache invalidation. func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) ([]string, error) { rows, err := q.db.Query(ctx, deleteTemplate, arg.TemplateID, arg.TeamID) if err != nil { diff --git a/packages/db/queries/exists_template_snapshots.sql.go b/packages/db/queries/exists_template_snapshots.sql.go index e8b7d21463..76da2fe279 100644 --- a/packages/db/queries/exists_template_snapshots.sql.go +++ b/packages/db/queries/exists_template_snapshots.sql.go @@ -12,8 +12,10 @@ import ( const existsTemplateSnapshots = `-- name: ExistsTemplateSnapshots :one SELECT EXISTS( SELECT 1 - FROM "public"."snapshots" - WHERE base_env_id = $1 + FROM "public"."snapshots" s + JOIN "public"."envs" e ON e.id = s.env_id + WHERE s.base_env_id = $1 + AND e.status <> 'deleted' ) ` diff --git a/packages/db/queries/finish_template_build.sql.go b/packages/db/queries/finish_template_build.sql.go index dabacd41fa..117fd4f9b6 100644 --- a/packages/db/queries/finish_template_build.sql.go +++ b/packages/db/queries/finish_template_build.sql.go @@ -26,7 +26,6 @@ SET firecracker_version = COALESCE(NULLIF($5::text, ''), firecracker_version) WHERE id = $6 - AND status_group <> 'deleted' ` type FinishTemplateBuildParams struct { diff --git a/packages/db/queries/get_build_info.sql.go b/packages/db/queries/get_build_info.sql.go index b374608be4..c3976f7d4f 100644 --- a/packages/db/queries/get_build_info.sql.go +++ b/packages/db/queries/get_build_info.sql.go @@ -27,6 +27,7 @@ JOIN LATERAL ( JOIN public.envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = $1::uuid + AND e.status <> 'deleted' ORDER BY a.created_at DESC, a.id DESC LIMIT 1 ) assignment ON TRUE diff --git a/packages/db/queries/get_builds_paginated.sql.go b/packages/db/queries/get_builds_paginated.sql.go index 04a8afe005..7d307462f3 100644 --- a/packages/db/queries/get_builds_paginated.sql.go +++ b/packages/db/queries/get_builds_paginated.sql.go @@ -30,6 +30,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -128,6 +129,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -229,6 +231,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' WHERE a.build_id = b.id AND EXISTS ( SELECT 1 @@ -335,6 +338,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' WHERE a.build_id = b.id AND a.env_id = $1::text ORDER BY a.created_at DESC, a.id DESC diff --git a/packages/db/queries/get_inprogress_builds.sql.go b/packages/db/queries/get_inprogress_builds.sql.go index ec595b58f1..a8450601fd 100644 --- a/packages/db/queries/get_inprogress_builds.sql.go +++ b/packages/db/queries/get_inprogress_builds.sql.go @@ -55,7 +55,7 @@ func (q *Queries) GetCancellableTemplateBuildsByTeam(ctx context.Context, teamID } const getInProgressTemplateBuilds = `-- name: GetInProgressTemplateBuilds :many -SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id +SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id FROM public.env_builds b JOIN public.env_build_assignments eba ON eba.build_id = b.id JOIN public.envs e ON e.id = eba.env_id @@ -93,6 +93,7 @@ func (q *Queries) GetInProgressTemplateBuilds(ctx context.Context) ([]GetInProgr &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, + &i.Env.Status, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_last_snapshot.sql.go b/packages/db/queries/get_last_snapshot.sql.go index 76fe6599a2..152d1eeeb4 100644 --- a/packages/db/queries/get_last_snapshot.sql.go +++ b/packages/db/queries/get_last_snapshot.sql.go @@ -12,6 +12,7 @@ import ( const getLastSnapshot = `-- name: GetLastSnapshot :one SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, s.created_at, s.env_id, s.sandbox_id, s.id, s.metadata, s.base_env_id, s.sandbox_started_at, s.env_secure, s.origin_node_id, s.allow_internet_access, s.auto_pause, s.team_id, s.config, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id FROM "public"."snapshots" s +JOIN "public"."envs" e ON e.id = s.env_id AND e.status <> 'deleted' JOIN LATERAL ( SELECT eba.build_id FROM "public"."env_build_assignments" eba diff --git a/packages/db/queries/get_snapshots_with_cursor.sql b/packages/db/queries/get_snapshots_with_cursor.sql index 5b93757b8d..07f572a46e 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql +++ b/packages/db/queries/get_snapshots_with_cursor.sql @@ -8,6 +8,7 @@ SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.nam eb.envd_version AS build_envd_version, eb.created_at AS build_created_at FROM "public"."snapshots" s +JOIN "public"."envs" e ON e.id = s.env_id AND e.status <> 'deleted' LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_snapshots_with_cursor.sql.go b/packages/db/queries/get_snapshots_with_cursor.sql.go index 96440390c6..da94606923 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql.go +++ b/packages/db/queries/get_snapshots_with_cursor.sql.go @@ -24,6 +24,7 @@ SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.nam eb.envd_version AS build_envd_version, eb.created_at AS build_created_at FROM "public"."snapshots" s +JOIN "public"."envs" e ON e.id = s.env_id AND e.status <> 'deleted' LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_team_template.sql b/packages/db/queries/get_team_template.sql index b1ac187c3b..c4bdc050a2 100644 --- a/packages/db/queries/get_team_template.sql +++ b/packages/db/queries/get_team_template.sql @@ -36,4 +36,5 @@ LEFT JOIN LATERAL ( WHERE e.id = $1 AND e.team_id = $2 - AND e.source = 'template'; + AND e.source = 'template' + AND e.status <> 'deleted'; diff --git a/packages/db/queries/get_team_template.sql.go b/packages/db/queries/get_team_template.sql.go index d234141cec..2cc5267b8c 100644 --- a/packages/db/queries/get_team_template.sql.go +++ b/packages/db/queries/get_team_template.sql.go @@ -13,7 +13,7 @@ import ( ) const getTeamTemplate = `-- name: GetTeamTemplate :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) as build_id, COALESCE(eb.vcpu, 0) as build_vcpu, COALESCE(eb.ram_mb, 0) as build_ram_mb, @@ -51,6 +51,7 @@ WHERE e.id = $1 AND e.team_id = $2 AND e.source = 'template' + AND e.status <> 'deleted' ` type GetTeamTemplateParams struct { @@ -86,6 +87,7 @@ func (q *Queries) GetTeamTemplate(ctx context.Context, arg GetTeamTemplateParams &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, + &i.Env.Status, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates.sql b/packages/db/queries/get_team_templates.sql index beddb276a3..0b5fe2f0ef 100644 --- a/packages/db/queries/get_team_templates.sql +++ b/packages/db/queries/get_team_templates.sql @@ -37,4 +37,5 @@ LEFT JOIN LATERAL ( WHERE e.team_id = $1 AND e.source = 'template' + AND e.status <> 'deleted' ORDER BY e.created_at ASC; diff --git a/packages/db/queries/get_team_templates.sql.go b/packages/db/queries/get_team_templates.sql.go index 491bc95c70..2c8d4da27e 100644 --- a/packages/db/queries/get_team_templates.sql.go +++ b/packages/db/queries/get_team_templates.sql.go @@ -13,7 +13,7 @@ import ( ) const getTeamTemplates = `-- name: GetTeamTemplates :many -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) as build_id, COALESCE(eb.vcpu, 0) as build_vcpu, COALESCE(eb.ram_mb, 0) as build_ram_mb, @@ -51,6 +51,7 @@ LEFT JOIN LATERAL ( WHERE e.team_id = $1 AND e.source = 'template' + AND e.status <> 'deleted' ORDER BY e.created_at ASC ` @@ -89,6 +90,7 @@ func (q *Queries) GetTeamTemplates(ctx context.Context, teamID uuid.UUID) ([]Get &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, + &i.Env.Status, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates_with_cursor.sql b/packages/db/queries/get_team_templates_with_cursor.sql index 537c75c444..1cc47116b5 100644 --- a/packages/db/queries/get_team_templates_with_cursor.sql +++ b/packages/db/queries/get_team_templates_with_cursor.sql @@ -49,6 +49,7 @@ LEFT JOIN LATERAL ( ) eb ON TRUE WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND (e.created_at, e.id) < (sqlc.arg(cursor_created_at)::timestamptz, sqlc.arg(cursor_id)::text) ORDER BY e.created_at DESC, e.id DESC LIMIT sqlc.arg(limit_plus_one)::int; diff --git a/packages/db/queries/get_team_templates_with_cursor.sql.go b/packages/db/queries/get_team_templates_with_cursor.sql.go index 1edbac2d6c..2806dc2ef3 100644 --- a/packages/db/queries/get_team_templates_with_cursor.sql.go +++ b/packages/db/queries/get_team_templates_with_cursor.sql.go @@ -58,6 +58,7 @@ LEFT JOIN LATERAL ( ) eb ON TRUE WHERE e.team_id = $1::uuid AND e.source = 'template' + AND e.status <> 'deleted' AND (e.created_at, e.id) < ($2::timestamptz, $3::text) ORDER BY e.created_at DESC, e.id DESC LIMIT $4::int diff --git a/packages/db/queries/get_template.sql.go b/packages/db/queries/get_template.sql.go index fcc6f45e0d..7dd3c9fcbb 100644 --- a/packages/db/queries/get_template.sql.go +++ b/packages/db/queries/get_template.sql.go @@ -18,6 +18,7 @@ JOIN public.envs AS e ON e.id = ea.env_id WHERE ea.alias = $1 AND ea.namespace IS NOT DISTINCT FROM $2::text AND e.source IN ('template', 'snapshot_template') + AND e.status <> 'deleted' ` type GetTemplateByAliasParams struct { @@ -46,6 +47,7 @@ SELECT e.id, e.team_id, e.public, e.cluster_id FROM public.envs AS e WHERE e.id = $1 AND e.source IN ('template', 'snapshot_template') + AND e.status <> 'deleted' ` type GetTemplateByIdRow struct { diff --git a/packages/db/queries/get_template_build.sql.go b/packages/db/queries/get_template_build.sql.go index a4493db19a..9b459c1825 100644 --- a/packages/db/queries/get_template_build.sql.go +++ b/packages/db/queries/get_template_build.sql.go @@ -12,12 +12,13 @@ import ( ) const getTemplateBuildWithTemplate = `-- name: GetTemplateBuildWithTemplate :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id FROM "public"."envs" e JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = $1 AND eb.id = $2 AND e.source = 'template' + AND e.status <> 'deleted' ` type GetTemplateBuildWithTemplateParams struct { @@ -45,6 +46,7 @@ func (q *Queries) GetTemplateBuildWithTemplate(ctx context.Context, arg GetTempl &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, + &i.Env.Status, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_template_by_id.sql.go b/packages/db/queries/get_template_by_id.sql.go index b8850a7cad..6dd801e98b 100644 --- a/packages/db/queries/get_template_by_id.sql.go +++ b/packages/db/queries/get_template_by_id.sql.go @@ -13,10 +13,11 @@ import ( ) const getTemplateByID = `-- name: GetTemplateByID :one -SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source +SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source, t.status FROM "public"."envs" t WHERE t.id = $1 AND t.source IN ('template', 'snapshot_template') + AND t.status <> 'deleted' ` func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { @@ -34,12 +35,13 @@ func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { &i.CreatedBy, &i.ClusterID, &i.Source, + &i.Status, ) return i, err } const getTemplateByIDWithAliases = `-- name: GetTemplateByIDWithAliases :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, al.aliases, al.names +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, al.aliases, al.names FROM "public"."envs" e CROSS JOIN LATERAL ( SELECT @@ -50,6 +52,7 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = $1 AND e.source IN ('template', 'snapshot_template') + AND e.status <> 'deleted' ` type GetTemplateByIDWithAliasesRow struct { @@ -64,6 +67,7 @@ type GetTemplateByIDWithAliasesRow struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string + Status string Aliases []string Names []string } @@ -83,6 +87,7 @@ func (q *Queries) GetTemplateByIDWithAliases(ctx context.Context, id string) (Ge &i.CreatedBy, &i.ClusterID, &i.Source, + &i.Status, &i.Aliases, &i.Names, ) diff --git a/packages/db/queries/get_template_with_build_by_tag.sql.go b/packages/db/queries/get_template_with_build_by_tag.sql.go index 63450198a0..7036f22aae 100644 --- a/packages/db/queries/get_template_with_build_by_tag.sql.go +++ b/packages/db/queries/get_template_with_build_by_tag.sql.go @@ -10,7 +10,7 @@ import ( ) const getTemplateWithBuildByTag = `-- name: GetTemplateWithBuildByTag :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names FROM public.envs AS e JOIN public.env_build_assignments AS eba ON eba.env_id = e.id AND ( @@ -31,6 +31,7 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = $2 AND e.source IN ('template', 'snapshot_template') + AND e.status <> 'deleted' ORDER BY eba.created_at DESC LIMIT 1 ` @@ -65,6 +66,7 @@ func (q *Queries) GetTemplateWithBuildByTag(ctx context.Context, arg GetTemplate &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, + &i.Env.Status, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/list_team_snapshot_templates.sql.go b/packages/db/queries/list_team_snapshot_templates.sql.go index 47c486ecfe..726b2c69e5 100644 --- a/packages/db/queries/list_team_snapshot_templates.sql.go +++ b/packages/db/queries/list_team_snapshot_templates.sql.go @@ -46,6 +46,7 @@ LEFT JOIN LATERAL ( ) ea ON TRUE WHERE e.team_id = $1 AND e.source = 'snapshot_template' +AND e.status <> 'deleted' AND ( $2::text IS NULL OR st.sandbox_id = $2::text diff --git a/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go b/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go deleted file mode 100644 index df3b868e7c..0000000000 --- a/packages/db/queries/mark_exclusive_template_builds_deleted.sql.go +++ /dev/null @@ -1,42 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: mark_exclusive_template_builds_deleted.sql - -package queries - -import ( - "context" - - "github.com/e2b-dev/infra/packages/db/pkg/types" -) - -const markExclusiveTemplateBuildsDeleted = `-- name: MarkExclusiveTemplateBuildsDeleted :exec -UPDATE "public"."env_builds" eb -SET status = 'deleted', - reason = COALESCE(NULLIF(eb.reason, '{}'::jsonb), $1), - updated_at = NOW(), - finished_at = COALESCE(eb.finished_at, NOW()) -FROM "public"."env_build_assignments" eba -WHERE eba.build_id = eb.id - AND eba.env_id = $2 - AND eb.status_group <> 'deleted' - AND NOT EXISTS ( - SELECT 1 FROM "public"."env_build_assignments" other_eba - WHERE other_eba.build_id = eb.id AND other_eba.env_id != $2 - ) -` - -type MarkExclusiveTemplateBuildsDeletedParams struct { - Reason types.BuildReason - TemplateID string -} - -// Soft-deletes builds that are ONLY assigned to this template by setting their -// status to 'deleted'. The env_builds rows (and their team/env attribution) are -// preserved so a future GC can reclaim the storage. Builds shared with other -// templates, and builds already marked deleted, are left untouched. -func (q *Queries) MarkExclusiveTemplateBuildsDeleted(ctx context.Context, arg MarkExclusiveTemplateBuildsDeletedParams) error { - _, err := q.db.Exec(ctx, markExclusiveTemplateBuildsDeleted, arg.Reason, arg.TemplateID) - return err -} diff --git a/packages/db/queries/models.go b/packages/db/queries/models.go index 242ff92cea..8a4c13a0d3 100644 --- a/packages/db/queries/models.go +++ b/packages/db/queries/models.go @@ -36,6 +36,7 @@ type Env struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string + Status string } type EnvAlias struct { diff --git a/packages/db/queries/snapshots/get_last_snapshot.sql b/packages/db/queries/snapshots/get_last_snapshot.sql index e835fe2d13..62675931f5 100644 --- a/packages/db/queries/snapshots/get_last_snapshot.sql +++ b/packages/db/queries/snapshots/get_last_snapshot.sql @@ -1,6 +1,7 @@ -- name: GetLastSnapshot :one SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, sqlc.embed(s), sqlc.embed(eb) FROM "public"."snapshots" s +JOIN "public"."envs" e ON e.id = s.env_id AND e.status <> 'deleted' JOIN LATERAL ( SELECT eba.build_id FROM "public"."env_build_assignments" eba diff --git a/packages/db/queries/snapshots/list_team_snapshot_templates.sql b/packages/db/queries/snapshots/list_team_snapshot_templates.sql index 11c7551b73..f680adb004 100644 --- a/packages/db/queries/snapshots/list_team_snapshot_templates.sql +++ b/packages/db/queries/snapshots/list_team_snapshot_templates.sql @@ -34,6 +34,7 @@ LEFT JOIN LATERAL ( ) ea ON TRUE WHERE e.team_id = @team_id AND e.source = 'snapshot_template' +AND e.status <> 'deleted' AND ( sqlc.narg(sandbox_id)::text IS NULL OR st.sandbox_id = sqlc.narg(sandbox_id)::text diff --git a/packages/db/queries/templates/delete_template.sql b/packages/db/queries/templates/delete_template.sql index abd7a3a873..c1fffcf6d3 100644 --- a/packages/db/queries/templates/delete_template.sql +++ b/packages/db/queries/templates/delete_template.sql @@ -1,18 +1,25 @@ -- name: DeleteTemplate :many --- Deletes a template and returns its alias cache keys for cache invalidation. --- Alias keys are captured via CTE before the cascade delete removes them. +-- Soft-deletes a template by marking its env status='deleted'. The env row, its +-- build assignments, and any snapshot rows are preserved so the build lineage +-- stays traceable for a future storage GC. Aliases are released (deleted) so the +-- name can be reused. Returns the released alias cache keys for cache invalidation. WITH alias_keys AS ( SELECT CASE - WHEN namespace IS NOT NULL THEN namespace || '/' || alias - ELSE alias + WHEN ea.namespace IS NOT NULL THEN ea.namespace || '/' || ea.alias + ELSE ea.alias END::text AS alias_key - FROM public.env_aliases - WHERE env_id = @template_id -), deleted AS ( - DELETE FROM "public"."envs" - WHERE id = @template_id - AND team_id = @team_id - RETURNING id + FROM public.env_aliases ea + WHERE ea.env_id = @template_id +), released AS ( + DELETE FROM public.env_aliases ea + WHERE ea.env_id = @template_id +), updated AS ( + UPDATE public.envs e + SET status = 'deleted', updated_at = NOW() + WHERE e.id = @template_id + AND e.team_id = @team_id + AND e.status <> 'deleted' + RETURNING e.id ) SELECT alias_key FROM alias_keys -WHERE EXISTS (SELECT 1 FROM deleted); \ No newline at end of file +WHERE EXISTS (SELECT 1 FROM updated); diff --git a/packages/db/queries/templates/exists_template_snapshots.sql b/packages/db/queries/templates/exists_template_snapshots.sql index 9cf474b9d2..843420651a 100644 --- a/packages/db/queries/templates/exists_template_snapshots.sql +++ b/packages/db/queries/templates/exists_template_snapshots.sql @@ -1,6 +1,8 @@ -- name: ExistsTemplateSnapshots :one SELECT EXISTS( SELECT 1 - FROM "public"."snapshots" - WHERE base_env_id = @env_id + FROM "public"."snapshots" s + JOIN "public"."envs" e ON e.id = s.env_id + WHERE s.base_env_id = @env_id + AND e.status <> 'deleted' ); \ No newline at end of file diff --git a/packages/db/queries/templates/get_template.sql b/packages/db/queries/templates/get_template.sql index 0652750c7e..54a9315332 100644 --- a/packages/db/queries/templates/get_template.sql +++ b/packages/db/queries/templates/get_template.sql @@ -7,7 +7,8 @@ FROM public.env_aliases AS ea JOIN public.envs AS e ON e.id = ea.env_id WHERE ea.alias = @alias AND ea.namespace IS NOT DISTINCT FROM sqlc.narg(namespace)::text - AND e.source IN ('template', 'snapshot_template'); + AND e.source IN ('template', 'snapshot_template') + AND e.status <> 'deleted'; -- name: GetTemplateById :one -- Looks up a template by its ID directly @@ -15,4 +16,5 @@ WHERE ea.alias = @alias SELECT e.id, e.team_id, e.public, e.cluster_id FROM public.envs AS e WHERE e.id = @template_id - AND e.source IN ('template', 'snapshot_template'); + AND e.source IN ('template', 'snapshot_template') + AND e.status <> 'deleted'; diff --git a/packages/db/queries/templates/get_template_by_id.sql b/packages/db/queries/templates/get_template_by_id.sql index bb6009268e..8181429209 100644 --- a/packages/db/queries/templates/get_template_by_id.sql +++ b/packages/db/queries/templates/get_template_by_id.sql @@ -2,7 +2,8 @@ SELECT t.* FROM "public"."envs" t WHERE t.id = $1 - AND t.source IN ('template', 'snapshot_template'); + AND t.source IN ('template', 'snapshot_template') + AND t.status <> 'deleted'; -- name: GetTemplateByIDWithAliases :one SELECT e.*, al.aliases, al.names @@ -15,4 +16,5 @@ CROSS JOIN LATERAL ( WHERE env_id = e.id ) AS al WHERE e.id = $1 - AND e.source IN ('template', 'snapshot_template'); + AND e.source IN ('template', 'snapshot_template') + AND e.status <> 'deleted'; diff --git a/packages/db/queries/templates/get_template_with_build_by_tag.sql b/packages/db/queries/templates/get_template_with_build_by_tag.sql index 6f0b1915cd..67d72c8a57 100644 --- a/packages/db/queries/templates/get_template_with_build_by_tag.sql +++ b/packages/db/queries/templates/get_template_with_build_by_tag.sql @@ -23,5 +23,6 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = @template_id AND e.source IN ('template', 'snapshot_template') + AND e.status <> 'deleted' ORDER BY eba.created_at DESC LIMIT 1; diff --git a/packages/db/queries/templates/update_template.sql b/packages/db/queries/templates/update_template.sql index 587de52864..89df89ae73 100644 --- a/packages/db/queries/templates/update_template.sql +++ b/packages/db/queries/templates/update_template.sql @@ -6,6 +6,7 @@ WHERE id IN ( LEFT JOIN "public"."env_aliases" ea ON ea.env_id = e.id WHERE e.team_id = @team_id AND e.source = 'template' + AND e.status <> 'deleted' AND (e.id = @template_id_or_alias OR ea.alias = @template_id_or_alias) ) RETURNING e.id; diff --git a/packages/db/queries/templates/update_template_build_status.sql b/packages/db/queries/templates/update_template_build_status.sql index 80254950c1..89353d430b 100644 --- a/packages/db/queries/templates/update_template_build_status.sql +++ b/packages/db/queries/templates/update_template_build_status.sql @@ -4,8 +4,7 @@ SET status = @status, finished_at = @finished_at, reason = sqlc.narg(reason), version = @version -WHERE id = @build_id - AND status_group <> 'deleted'; +WHERE id = @build_id; -- name: FailTemplateBuildAndDeactivate :exec WITH deactivated AS ( @@ -16,5 +15,4 @@ SET status = @status, finished_at = @finished_at, reason = sqlc.narg(reason), version = @version -WHERE id = @build_id - AND status_group <> 'deleted'; +WHERE id = @build_id; diff --git a/packages/db/queries/update_template.sql.go b/packages/db/queries/update_template.sql.go index 906c824d77..a044f55434 100644 --- a/packages/db/queries/update_template.sql.go +++ b/packages/db/queries/update_template.sql.go @@ -19,6 +19,7 @@ WHERE id IN ( LEFT JOIN "public"."env_aliases" ea ON ea.env_id = e.id WHERE e.team_id = $2 AND e.source = 'template' + AND e.status <> 'deleted' AND (e.id = $3 OR ea.alias = $3) ) RETURNING e.id diff --git a/packages/db/queries/update_template_build_status.sql.go b/packages/db/queries/update_template_build_status.sql.go index 773c5e761e..5bac88b0ad 100644 --- a/packages/db/queries/update_template_build_status.sql.go +++ b/packages/db/queries/update_template_build_status.sql.go @@ -23,7 +23,6 @@ SET status = $1, reason = $3, version = $4 WHERE id = $5 - AND status_group <> 'deleted' ` type FailTemplateBuildAndDeactivateParams struct { @@ -52,7 +51,6 @@ SET status = $1, reason = $3, version = $4 WHERE id = $5 - AND status_group <> 'deleted' ` type UpdateEnvBuildStatusParams struct { From d2b3c9563375cdf036e7db50c721405f11664422 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:45:48 -0700 Subject: [PATCH 04/31] fix(db): clear active_template_builds and skip deleted envs on soft-delete Soft-delete no longer removes the env row, so active_template_builds (was ON DELETE CASCADE) lingered, inflating team concurrency counts and keeping the template-manager sync busy. DeleteTemplate now clears those rows, and GetInProgressTemplateBuilds ignores deleted envs. --- .../db/queries/builds/get_inprogress_builds.sql | 1 + packages/db/queries/delete_template.sql.go | 13 +++++++++---- packages/db/queries/get_inprogress_builds.sql.go | 1 + packages/db/queries/templates/delete_template.sql | 13 +++++++++---- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/db/queries/builds/get_inprogress_builds.sql b/packages/db/queries/builds/get_inprogress_builds.sql index 1df4603a12..72f3dfb93d 100644 --- a/packages/db/queries/builds/get_inprogress_builds.sql +++ b/packages/db/queries/builds/get_inprogress_builds.sql @@ -6,6 +6,7 @@ JOIN public.envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' + AND e.status <> 'deleted' ORDER BY b.id, b.created_at DESC; -- name: GetInProgressTemplateBuildsByTeam :one diff --git a/packages/db/queries/delete_template.sql.go b/packages/db/queries/delete_template.sql.go index 7cc0359a09..1c84716be5 100644 --- a/packages/db/queries/delete_template.sql.go +++ b/packages/db/queries/delete_template.sql.go @@ -19,9 +19,6 @@ WITH alias_keys AS ( END::text AS alias_key FROM public.env_aliases ea WHERE ea.env_id = $1 -), released AS ( - DELETE FROM public.env_aliases ea - WHERE ea.env_id = $1 ), updated AS ( UPDATE public.envs e SET status = 'deleted', updated_at = NOW() @@ -29,6 +26,12 @@ WITH alias_keys AS ( AND e.team_id = $2 AND e.status <> 'deleted' RETURNING e.id +), released AS ( + DELETE FROM public.env_aliases ea + WHERE ea.env_id IN (SELECT id FROM updated) +), deactivated AS ( + DELETE FROM public.active_template_builds atb + WHERE atb.template_id IN (SELECT id FROM updated) ) SELECT alias_key FROM alias_keys WHERE EXISTS (SELECT 1 FROM updated) @@ -42,7 +45,9 @@ type DeleteTemplateParams struct { // Soft-deletes a template by marking its env status='deleted'. The env row, its // build assignments, and any snapshot rows are preserved so the build lineage // stays traceable for a future storage GC. Aliases are released (deleted) so the -// name can be reused. Returns the released alias cache keys for cache invalidation. +// name can be reused, and active_template_builds rows are cleared (mirroring the +// old env cascade) so in-flight build tracking and concurrency counts don't +// linger. Returns the released alias cache keys for cache invalidation. func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) ([]string, error) { rows, err := q.db.Query(ctx, deleteTemplate, arg.TemplateID, arg.TeamID) if err != nil { diff --git a/packages/db/queries/get_inprogress_builds.sql.go b/packages/db/queries/get_inprogress_builds.sql.go index a8450601fd..e6731f5f1c 100644 --- a/packages/db/queries/get_inprogress_builds.sql.go +++ b/packages/db/queries/get_inprogress_builds.sql.go @@ -62,6 +62,7 @@ JOIN public.envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' + AND e.status <> 'deleted' ORDER BY b.id, b.created_at DESC ` diff --git a/packages/db/queries/templates/delete_template.sql b/packages/db/queries/templates/delete_template.sql index c1fffcf6d3..50cbc4ade2 100644 --- a/packages/db/queries/templates/delete_template.sql +++ b/packages/db/queries/templates/delete_template.sql @@ -2,7 +2,9 @@ -- Soft-deletes a template by marking its env status='deleted'. The env row, its -- build assignments, and any snapshot rows are preserved so the build lineage -- stays traceable for a future storage GC. Aliases are released (deleted) so the --- name can be reused. Returns the released alias cache keys for cache invalidation. +-- name can be reused, and active_template_builds rows are cleared (mirroring the +-- old env cascade) so in-flight build tracking and concurrency counts don't +-- linger. Returns the released alias cache keys for cache invalidation. WITH alias_keys AS ( SELECT CASE WHEN ea.namespace IS NOT NULL THEN ea.namespace || '/' || ea.alias @@ -10,9 +12,6 @@ WITH alias_keys AS ( END::text AS alias_key FROM public.env_aliases ea WHERE ea.env_id = @template_id -), released AS ( - DELETE FROM public.env_aliases ea - WHERE ea.env_id = @template_id ), updated AS ( UPDATE public.envs e SET status = 'deleted', updated_at = NOW() @@ -20,6 +19,12 @@ WITH alias_keys AS ( AND e.team_id = @team_id AND e.status <> 'deleted' RETURNING e.id +), released AS ( + DELETE FROM public.env_aliases ea + WHERE ea.env_id IN (SELECT id FROM updated) +), deactivated AS ( + DELETE FROM public.active_template_builds atb + WHERE atb.template_id IN (SELECT id FROM updated) ) SELECT alias_key FROM alias_keys WHERE EXISTS (SELECT 1 FROM updated); From ccc8a14c26f35ea24be7e434b64694b588052d38 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:52:13 -0700 Subject: [PATCH 05/31] fix(db): reactivate env on rebuild and ignore deleted envs in alias conflict Soft-delete keeps the env row, so rebuilding into the same template id must reset status to 'active' (CreateOrUpdateTemplate ON CONFLICT), and the alias/template-id conflict check must ignore deleted envs so a released name stays reusable. --- packages/db/queries/check_alias_exists.sql.go | 1 + packages/db/queries/create_template.sql.go | 1 + packages/db/queries/template_aliases/check_alias_exists.sql | 1 + packages/db/queries/templates/create_template.sql | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/db/queries/check_alias_exists.sql.go b/packages/db/queries/check_alias_exists.sql.go index 07f34988b5..71e528db7f 100644 --- a/packages/db/queries/check_alias_exists.sql.go +++ b/packages/db/queries/check_alias_exists.sql.go @@ -14,6 +14,7 @@ SELECT EXISTS( SELECT 1 FROM "public"."envs" WHERE id = $1 + AND status <> 'deleted' ) ` diff --git a/packages/db/queries/create_template.sql.go b/packages/db/queries/create_template.sql.go index 0e25984ac4..4027b759f9 100644 --- a/packages/db/queries/create_template.sql.go +++ b/packages/db/queries/create_template.sql.go @@ -17,6 +17,7 @@ INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster VALUES ($1, $2, $3, NOW(), FALSE, $4, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), + status = 'active', build_count = envs.build_count + 1 ` diff --git a/packages/db/queries/template_aliases/check_alias_exists.sql b/packages/db/queries/template_aliases/check_alias_exists.sql index 6c15871648..811c9248fa 100644 --- a/packages/db/queries/template_aliases/check_alias_exists.sql +++ b/packages/db/queries/template_aliases/check_alias_exists.sql @@ -3,6 +3,7 @@ SELECT EXISTS( SELECT 1 FROM "public"."envs" WHERE id = @alias + AND status <> 'deleted' ); -- name: CheckAliasExistsInNamespace :one diff --git a/packages/db/queries/templates/create_template.sql b/packages/db/queries/templates/create_template.sql index 38ec530b0e..c0525c1f5f 100644 --- a/packages/db/queries/templates/create_template.sql +++ b/packages/db/queries/templates/create_template.sql @@ -3,6 +3,7 @@ INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster VALUES (@template_id, @team_id, @created_by, NOW(), FALSE, @cluster_id, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), + status = 'active', build_count = envs.build_count + 1; -- name: InvalidateUnstartedTemplateBuilds :exec From e37b771b395b51c89ae98b2797f2ae7fbbee03ef Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:57:01 -0700 Subject: [PATCH 06/31] refactor(db): use boolean envs.deleted for soft-delete marker Replace the envs.status text column with a boolean envs.deleted, since the state is strictly binary. Reads filter deleted = false; delete sets it true. --- .../20260628120000_add_env_deleted.sql | 8 ++++++++ .../migrations/20260628120000_add_env_status.sql | 9 --------- .../queries/get_default_templates.sql.go | 2 +- .../dashboard/queries/list_team_templates.sql.go | 16 ++++++++-------- .../templates/get_default_templates.sql | 2 +- .../templates/list_team_templates.sql | 16 ++++++++-------- .../pkg/tests/templates/delete_template_test.go | 14 +++++++------- packages/db/pkg/testutils/queries/models.go | 2 +- packages/db/queries/builds/get_build_info.sql | 2 +- .../db/queries/builds/get_builds_paginated.sql | 8 ++++---- .../db/queries/builds/get_inprogress_builds.sql | 2 +- .../db/queries/builds/get_template_build.sql | 2 +- packages/db/queries/check_alias_exists.sql.go | 2 +- packages/db/queries/create_template.sql.go | 2 +- packages/db/queries/delete_template.sql.go | 14 ++++++-------- .../db/queries/exists_template_snapshots.sql.go | 2 +- packages/db/queries/get_build_info.sql.go | 2 +- packages/db/queries/get_builds_paginated.sql.go | 8 ++++---- packages/db/queries/get_inprogress_builds.sql.go | 6 +++--- packages/db/queries/get_last_snapshot.sql.go | 2 +- .../db/queries/get_snapshots_with_cursor.sql | 2 +- .../db/queries/get_snapshots_with_cursor.sql.go | 2 +- packages/db/queries/get_team_template.sql | 2 +- packages/db/queries/get_team_template.sql.go | 6 +++--- packages/db/queries/get_team_templates.sql | 2 +- packages/db/queries/get_team_templates.sql.go | 6 +++--- .../queries/get_team_templates_with_cursor.sql | 2 +- .../get_team_templates_with_cursor.sql.go | 2 +- packages/db/queries/get_template.sql.go | 4 ++-- packages/db/queries/get_template_build.sql.go | 6 +++--- packages/db/queries/get_template_by_id.sql.go | 14 +++++++------- .../get_template_with_build_by_tag.sql.go | 6 +++--- .../queries/list_team_snapshot_templates.sql.go | 2 +- packages/db/queries/models.go | 2 +- .../db/queries/snapshots/get_last_snapshot.sql | 2 +- .../snapshots/list_team_snapshot_templates.sql | 2 +- .../template_aliases/check_alias_exists.sql | 2 +- .../db/queries/templates/create_template.sql | 2 +- .../db/queries/templates/delete_template.sql | 14 ++++++-------- .../templates/exists_template_snapshots.sql | 2 +- packages/db/queries/templates/get_template.sql | 4 ++-- .../db/queries/templates/get_template_by_id.sql | 4 ++-- .../templates/get_template_with_build_by_tag.sql | 2 +- .../db/queries/templates/update_template.sql | 2 +- packages/db/queries/update_template.sql.go | 2 +- 45 files changed, 106 insertions(+), 111 deletions(-) create mode 100644 packages/db/migrations/20260628120000_add_env_deleted.sql delete mode 100644 packages/db/migrations/20260628120000_add_env_status.sql diff --git a/packages/db/migrations/20260628120000_add_env_deleted.sql b/packages/db/migrations/20260628120000_add_env_deleted.sql new file mode 100644 index 0000000000..b23ea32242 --- /dev/null +++ b/packages/db/migrations/20260628120000_add_env_deleted.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Soft-delete marker for envs (templates, snapshots, snapshot_templates). On +-- user delete the env is flagged deleted instead of being removed, so its build +-- assignments and snapshot rows survive and the build lineage stays traceable. +ALTER TABLE "public"."envs" ADD COLUMN IF NOT EXISTS deleted boolean NOT NULL DEFAULT false; + +-- +goose Down +ALTER TABLE "public"."envs" DROP COLUMN IF EXISTS deleted; diff --git a/packages/db/migrations/20260628120000_add_env_status.sql b/packages/db/migrations/20260628120000_add_env_status.sql deleted file mode 100644 index 09baa00f71..0000000000 --- a/packages/db/migrations/20260628120000_add_env_status.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- Soft-delete marker for envs (templates, snapshots, snapshot_templates). On --- user delete the env is marked 'deleted' instead of being removed, so its --- build assignments and snapshot rows survive and the build lineage stays --- traceable. Reads filter out status='deleted'. -ALTER TABLE "public"."envs" ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'active'; - --- +goose Down -ALTER TABLE "public"."envs" DROP COLUMN IF EXISTS status; diff --git a/packages/db/pkg/dashboard/queries/get_default_templates.sql.go b/packages/db/pkg/dashboard/queries/get_default_templates.sql.go index 28380ba637..4dc5591a83 100644 --- a/packages/db/pkg/dashboard/queries/get_default_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/get_default_templates.sql.go @@ -25,7 +25,7 @@ SELECT b.total_disk_size_mb, b.envd_version FROM public.env_defaults ed -JOIN public.envs e ON e.id = ed.env_id AND e.status <> 'deleted' +JOIN public.envs e ON e.id = ed.env_id AND e.deleted = false JOIN LATERAL ( SELECT a.build_id FROM public.env_build_assignments a diff --git a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go index 918d18bb1d..db6a839b99 100644 --- a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go @@ -30,7 +30,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -64,7 +64,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean - AND e.status <> 'deleted' + AND e.deleted = false AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -245,7 +245,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -279,7 +279,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean - AND e.status <> 'deleted' + AND e.deleted = false AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -444,7 +444,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -478,7 +478,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean - AND e.status <> 'deleted' + AND e.deleted = false AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -643,7 +643,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' @@ -677,7 +677,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean - AND e.status <> 'deleted' + AND e.deleted = false AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' diff --git a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql index 2882832861..0cc2cd5794 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql @@ -11,7 +11,7 @@ SELECT b.total_disk_size_mb, b.envd_version FROM public.env_defaults ed -JOIN public.envs e ON e.id = ed.env_id AND e.status <> 'deleted' +JOIN public.envs e ON e.id = ed.env_id AND e.deleted = false JOIN LATERAL ( SELECT a.build_id FROM public.env_build_assignments a diff --git a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql index 6aad0a64c8..67a18ddadb 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql @@ -32,7 +32,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -66,7 +66,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean - AND e.status <> 'deleted' + AND e.deleted = false AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -152,7 +152,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -186,7 +186,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean - AND e.status <> 'deleted' + AND e.deleted = false AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -272,7 +272,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -306,7 +306,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean - AND e.status <> 'deleted' + AND e.deleted = false AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -392,7 +392,7 @@ WITH team_templates AS ( LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' @@ -426,7 +426,7 @@ default_templates AS ( JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean - AND e.status <> 'deleted' + AND e.deleted = false AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' diff --git a/packages/db/pkg/tests/templates/delete_template_test.go b/packages/db/pkg/tests/templates/delete_template_test.go index 5f917a6e90..38c2569686 100644 --- a/packages/db/pkg/tests/templates/delete_template_test.go +++ b/packages/db/pkg/tests/templates/delete_template_test.go @@ -11,14 +11,14 @@ import ( "github.com/e2b-dev/infra/packages/db/queries" ) -func envStatus(t *testing.T, db *testutils.Database, envID string) string { +func envDeleted(t *testing.T, db *testutils.Database, envID string) bool { t.Helper() - var status string + var deleted bool err := db.SqlcClient.TestsRawSQLQuery(t.Context(), - "SELECT status FROM public.envs WHERE id = $1", + "SELECT deleted FROM public.envs WHERE id = $1", func(rows pgx.Rows) error { if rows.Next() { - return rows.Scan(&status) + return rows.Scan(&deleted) } return nil @@ -27,7 +27,7 @@ func envStatus(t *testing.T, db *testutils.Database, envID string) string { ) require.NoError(t, err) - return status + return deleted } func TestDeleteTemplate_SoftDeletesEnvAndPreservesStructure(t *testing.T) { @@ -45,7 +45,7 @@ func TestDeleteTemplate_SoftDeletesEnvAndPreservesStructure(t *testing.T) { require.NoError(t, err) assert.True(t, testutils.GetEnvByID(t, ctx, db, templateID), "env row must be preserved") - assert.Equal(t, "deleted", envStatus(t, db, templateID), "env must be soft-deleted") + assert.True(t, envDeleted(t, db, templateID), "env must be soft-deleted") assert.True(t, testutils.GetEnvBuildByID(t, ctx, db, buildID), "build row must be preserved") assert.NotEmpty(t, testutils.GetBuildAssignments(t, ctx, db, templateID), "build assignment must be preserved") @@ -85,7 +85,7 @@ func TestDeleteTemplate_KeepsBuildSharedWithActiveTemplate(t *testing.T) { require.NoError(t, err) assert.True(t, testutils.GetEnvBuildByID(t, ctx, db, buildID), "shared build must survive") - assert.Equal(t, "active", envStatus(t, db, activeID), "other template must be unaffected") + assert.False(t, envDeleted(t, db, activeID), "other template must be unaffected") got, err := db.SqlcClient.GetTeamTemplate(ctx, queries.GetTeamTemplateParams{ID: activeID, TeamID: teamID}) require.NoError(t, err) diff --git a/packages/db/pkg/testutils/queries/models.go b/packages/db/pkg/testutils/queries/models.go index 1c7cecc75b..2e65b8e76b 100644 --- a/packages/db/pkg/testutils/queries/models.go +++ b/packages/db/pkg/testutils/queries/models.go @@ -92,7 +92,7 @@ type Env struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string - Status string + Deleted bool } type EnvAlias struct { diff --git a/packages/db/queries/builds/get_build_info.sql b/packages/db/queries/builds/get_build_info.sql index 97a7a31749..e61bcfb549 100644 --- a/packages/db/queries/builds/get_build_info.sql +++ b/packages/db/queries/builds/get_build_info.sql @@ -12,7 +12,7 @@ JOIN LATERAL ( JOIN public.envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = sqlc.arg(team_id)::uuid - AND e.status <> 'deleted' + AND e.deleted = false ORDER BY a.created_at DESC, a.id DESC LIMIT 1 ) assignment ON TRUE diff --git a/packages/db/queries/builds/get_builds_paginated.sql b/packages/db/queries/builds/get_builds_paginated.sql index 00740c1ce0..27f0894c5d 100644 --- a/packages/db/queries/builds/get_builds_paginated.sql +++ b/packages/db/queries/builds/get_builds_paginated.sql @@ -15,7 +15,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' + JOIN public.envs e ON e.id = a.env_id AND e.deleted = false WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -53,7 +53,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' + JOIN public.envs e ON e.id = a.env_id AND e.deleted = false WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -92,7 +92,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' + JOIN public.envs e ON e.id = a.env_id AND e.deleted = false WHERE a.build_id = b.id AND a.env_id = sqlc.arg(template_id)::text ORDER BY a.created_at DESC, a.id DESC @@ -131,7 +131,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' + JOIN public.envs e ON e.id = a.env_id AND e.deleted = false WHERE a.build_id = b.id AND EXISTS ( SELECT 1 diff --git a/packages/db/queries/builds/get_inprogress_builds.sql b/packages/db/queries/builds/get_inprogress_builds.sql index 72f3dfb93d..717c3f0cb7 100644 --- a/packages/db/queries/builds/get_inprogress_builds.sql +++ b/packages/db/queries/builds/get_inprogress_builds.sql @@ -6,7 +6,7 @@ JOIN public.envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false ORDER BY b.id, b.created_at DESC; -- name: GetInProgressTemplateBuildsByTeam :one diff --git a/packages/db/queries/builds/get_template_build.sql b/packages/db/queries/builds/get_template_build.sql index 678e1d8da7..7de1a4db13 100644 --- a/packages/db/queries/builds/get_template_build.sql +++ b/packages/db/queries/builds/get_template_build.sql @@ -5,4 +5,4 @@ JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = sqlc.arg(template_id) AND eb.id = sqlc.arg(build_id) AND e.source = 'template' - AND e.status <> 'deleted'; + AND e.deleted = false; diff --git a/packages/db/queries/check_alias_exists.sql.go b/packages/db/queries/check_alias_exists.sql.go index 71e528db7f..7f2a6fcb3b 100644 --- a/packages/db/queries/check_alias_exists.sql.go +++ b/packages/db/queries/check_alias_exists.sql.go @@ -14,7 +14,7 @@ SELECT EXISTS( SELECT 1 FROM "public"."envs" WHERE id = $1 - AND status <> 'deleted' + AND deleted = false ) ` diff --git a/packages/db/queries/create_template.sql.go b/packages/db/queries/create_template.sql.go index 4027b759f9..58ef8518f9 100644 --- a/packages/db/queries/create_template.sql.go +++ b/packages/db/queries/create_template.sql.go @@ -17,7 +17,7 @@ INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster VALUES ($1, $2, $3, NOW(), FALSE, $4, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), - status = 'active', + deleted = false, build_count = envs.build_count + 1 ` diff --git a/packages/db/queries/delete_template.sql.go b/packages/db/queries/delete_template.sql.go index 1c84716be5..c4fe11cd58 100644 --- a/packages/db/queries/delete_template.sql.go +++ b/packages/db/queries/delete_template.sql.go @@ -21,10 +21,10 @@ WITH alias_keys AS ( WHERE ea.env_id = $1 ), updated AS ( UPDATE public.envs e - SET status = 'deleted', updated_at = NOW() + SET deleted = true, updated_at = NOW() WHERE e.id = $1 AND e.team_id = $2 - AND e.status <> 'deleted' + AND e.deleted = false RETURNING e.id ), released AS ( DELETE FROM public.env_aliases ea @@ -42,12 +42,10 @@ type DeleteTemplateParams struct { TeamID uuid.UUID } -// Soft-deletes a template by marking its env status='deleted'. The env row, its -// build assignments, and any snapshot rows are preserved so the build lineage -// stays traceable for a future storage GC. Aliases are released (deleted) so the -// name can be reused, and active_template_builds rows are cleared (mirroring the -// old env cascade) so in-flight build tracking and concurrency counts don't -// linger. Returns the released alias cache keys for cache invalidation. +// Soft-deletes a template: flags its env deleted instead of removing the row, so +// env_builds, env_build_assignments, and snapshot rows survive for lineage and a +// future storage GC. Aliases are released and active_template_builds cleared +// (mirroring the old env cascade). Returns released alias cache keys. func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) ([]string, error) { rows, err := q.db.Query(ctx, deleteTemplate, arg.TemplateID, arg.TeamID) if err != nil { diff --git a/packages/db/queries/exists_template_snapshots.sql.go b/packages/db/queries/exists_template_snapshots.sql.go index 76da2fe279..d4ddb5d6a9 100644 --- a/packages/db/queries/exists_template_snapshots.sql.go +++ b/packages/db/queries/exists_template_snapshots.sql.go @@ -15,7 +15,7 @@ SELECT EXISTS( FROM "public"."snapshots" s JOIN "public"."envs" e ON e.id = s.env_id WHERE s.base_env_id = $1 - AND e.status <> 'deleted' + AND e.deleted = false ) ` diff --git a/packages/db/queries/get_build_info.sql.go b/packages/db/queries/get_build_info.sql.go index c3976f7d4f..d2ca60e052 100644 --- a/packages/db/queries/get_build_info.sql.go +++ b/packages/db/queries/get_build_info.sql.go @@ -27,7 +27,7 @@ JOIN LATERAL ( JOIN public.envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = $1::uuid - AND e.status <> 'deleted' + AND e.deleted = false ORDER BY a.created_at DESC, a.id DESC LIMIT 1 ) assignment ON TRUE diff --git a/packages/db/queries/get_builds_paginated.sql.go b/packages/db/queries/get_builds_paginated.sql.go index 7d307462f3..8d067e5a48 100644 --- a/packages/db/queries/get_builds_paginated.sql.go +++ b/packages/db/queries/get_builds_paginated.sql.go @@ -30,7 +30,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' + JOIN public.envs e ON e.id = a.env_id AND e.deleted = false WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -129,7 +129,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' + JOIN public.envs e ON e.id = a.env_id AND e.deleted = false WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -231,7 +231,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' + JOIN public.envs e ON e.id = a.env_id AND e.deleted = false WHERE a.build_id = b.id AND EXISTS ( SELECT 1 @@ -338,7 +338,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.status <> 'deleted' + JOIN public.envs e ON e.id = a.env_id AND e.deleted = false WHERE a.build_id = b.id AND a.env_id = $1::text ORDER BY a.created_at DESC, a.id DESC diff --git a/packages/db/queries/get_inprogress_builds.sql.go b/packages/db/queries/get_inprogress_builds.sql.go index e6731f5f1c..3e57e11eee 100644 --- a/packages/db/queries/get_inprogress_builds.sql.go +++ b/packages/db/queries/get_inprogress_builds.sql.go @@ -55,14 +55,14 @@ func (q *Queries) GetCancellableTemplateBuildsByTeam(ctx context.Context, teamID } const getInProgressTemplateBuilds = `-- name: GetInProgressTemplateBuilds :many -SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id +SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id FROM public.env_builds b JOIN public.env_build_assignments eba ON eba.build_id = b.id JOIN public.envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false ORDER BY b.id, b.created_at DESC ` @@ -94,7 +94,7 @@ func (q *Queries) GetInProgressTemplateBuilds(ctx context.Context) ([]GetInProgr &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Status, + &i.Env.Deleted, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_last_snapshot.sql.go b/packages/db/queries/get_last_snapshot.sql.go index 152d1eeeb4..795c63468d 100644 --- a/packages/db/queries/get_last_snapshot.sql.go +++ b/packages/db/queries/get_last_snapshot.sql.go @@ -12,7 +12,7 @@ import ( const getLastSnapshot = `-- name: GetLastSnapshot :one SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, s.created_at, s.env_id, s.sandbox_id, s.id, s.metadata, s.base_env_id, s.sandbox_started_at, s.env_secure, s.origin_node_id, s.allow_internet_access, s.auto_pause, s.team_id, s.config, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.status <> 'deleted' +JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false JOIN LATERAL ( SELECT eba.build_id FROM "public"."env_build_assignments" eba diff --git a/packages/db/queries/get_snapshots_with_cursor.sql b/packages/db/queries/get_snapshots_with_cursor.sql index 07f572a46e..eb2cf37318 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql +++ b/packages/db/queries/get_snapshots_with_cursor.sql @@ -8,7 +8,7 @@ SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.nam eb.envd_version AS build_envd_version, eb.created_at AS build_created_at FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.status <> 'deleted' +JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_snapshots_with_cursor.sql.go b/packages/db/queries/get_snapshots_with_cursor.sql.go index da94606923..006cd55f30 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql.go +++ b/packages/db/queries/get_snapshots_with_cursor.sql.go @@ -24,7 +24,7 @@ SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.nam eb.envd_version AS build_envd_version, eb.created_at AS build_created_at FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.status <> 'deleted' +JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_team_template.sql b/packages/db/queries/get_team_template.sql index c4bdc050a2..a3f34390b1 100644 --- a/packages/db/queries/get_team_template.sql +++ b/packages/db/queries/get_team_template.sql @@ -37,4 +37,4 @@ WHERE e.id = $1 AND e.team_id = $2 AND e.source = 'template' - AND e.status <> 'deleted'; + AND e.deleted = false; diff --git a/packages/db/queries/get_team_template.sql.go b/packages/db/queries/get_team_template.sql.go index 2cc5267b8c..67e339baec 100644 --- a/packages/db/queries/get_team_template.sql.go +++ b/packages/db/queries/get_team_template.sql.go @@ -13,7 +13,7 @@ import ( ) const getTeamTemplate = `-- name: GetTeamTemplate :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) as build_id, COALESCE(eb.vcpu, 0) as build_vcpu, COALESCE(eb.ram_mb, 0) as build_ram_mb, @@ -51,7 +51,7 @@ WHERE e.id = $1 AND e.team_id = $2 AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false ` type GetTeamTemplateParams struct { @@ -87,7 +87,7 @@ func (q *Queries) GetTeamTemplate(ctx context.Context, arg GetTeamTemplateParams &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Status, + &i.Env.Deleted, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates.sql b/packages/db/queries/get_team_templates.sql index 0b5fe2f0ef..73b52503d1 100644 --- a/packages/db/queries/get_team_templates.sql +++ b/packages/db/queries/get_team_templates.sql @@ -37,5 +37,5 @@ LEFT JOIN LATERAL ( WHERE e.team_id = $1 AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false ORDER BY e.created_at ASC; diff --git a/packages/db/queries/get_team_templates.sql.go b/packages/db/queries/get_team_templates.sql.go index 2c8d4da27e..92f745d68e 100644 --- a/packages/db/queries/get_team_templates.sql.go +++ b/packages/db/queries/get_team_templates.sql.go @@ -13,7 +13,7 @@ import ( ) const getTeamTemplates = `-- name: GetTeamTemplates :many -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) as build_id, COALESCE(eb.vcpu, 0) as build_vcpu, COALESCE(eb.ram_mb, 0) as build_ram_mb, @@ -51,7 +51,7 @@ LEFT JOIN LATERAL ( WHERE e.team_id = $1 AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false ORDER BY e.created_at ASC ` @@ -90,7 +90,7 @@ func (q *Queries) GetTeamTemplates(ctx context.Context, teamID uuid.UUID) ([]Get &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Status, + &i.Env.Deleted, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates_with_cursor.sql b/packages/db/queries/get_team_templates_with_cursor.sql index 1cc47116b5..3226cab702 100644 --- a/packages/db/queries/get_team_templates_with_cursor.sql +++ b/packages/db/queries/get_team_templates_with_cursor.sql @@ -49,7 +49,7 @@ LEFT JOIN LATERAL ( ) eb ON TRUE WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND (e.created_at, e.id) < (sqlc.arg(cursor_created_at)::timestamptz, sqlc.arg(cursor_id)::text) ORDER BY e.created_at DESC, e.id DESC LIMIT sqlc.arg(limit_plus_one)::int; diff --git a/packages/db/queries/get_team_templates_with_cursor.sql.go b/packages/db/queries/get_team_templates_with_cursor.sql.go index 2806dc2ef3..d273978c5a 100644 --- a/packages/db/queries/get_team_templates_with_cursor.sql.go +++ b/packages/db/queries/get_team_templates_with_cursor.sql.go @@ -58,7 +58,7 @@ LEFT JOIN LATERAL ( ) eb ON TRUE WHERE e.team_id = $1::uuid AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND (e.created_at, e.id) < ($2::timestamptz, $3::text) ORDER BY e.created_at DESC, e.id DESC LIMIT $4::int diff --git a/packages/db/queries/get_template.sql.go b/packages/db/queries/get_template.sql.go index 7dd3c9fcbb..99cabcc9ff 100644 --- a/packages/db/queries/get_template.sql.go +++ b/packages/db/queries/get_template.sql.go @@ -18,7 +18,7 @@ JOIN public.envs AS e ON e.id = ea.env_id WHERE ea.alias = $1 AND ea.namespace IS NOT DISTINCT FROM $2::text AND e.source IN ('template', 'snapshot_template') - AND e.status <> 'deleted' + AND e.deleted = false ` type GetTemplateByAliasParams struct { @@ -47,7 +47,7 @@ SELECT e.id, e.team_id, e.public, e.cluster_id FROM public.envs AS e WHERE e.id = $1 AND e.source IN ('template', 'snapshot_template') - AND e.status <> 'deleted' + AND e.deleted = false ` type GetTemplateByIdRow struct { diff --git a/packages/db/queries/get_template_build.sql.go b/packages/db/queries/get_template_build.sql.go index 9b459c1825..444f88b2f5 100644 --- a/packages/db/queries/get_template_build.sql.go +++ b/packages/db/queries/get_template_build.sql.go @@ -12,13 +12,13 @@ import ( ) const getTemplateBuildWithTemplate = `-- name: GetTemplateBuildWithTemplate :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id FROM "public"."envs" e JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = $1 AND eb.id = $2 AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false ` type GetTemplateBuildWithTemplateParams struct { @@ -46,7 +46,7 @@ func (q *Queries) GetTemplateBuildWithTemplate(ctx context.Context, arg GetTempl &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Status, + &i.Env.Deleted, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_template_by_id.sql.go b/packages/db/queries/get_template_by_id.sql.go index 6dd801e98b..d15cb729ec 100644 --- a/packages/db/queries/get_template_by_id.sql.go +++ b/packages/db/queries/get_template_by_id.sql.go @@ -13,11 +13,11 @@ import ( ) const getTemplateByID = `-- name: GetTemplateByID :one -SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source, t.status +SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source, t.deleted FROM "public"."envs" t WHERE t.id = $1 AND t.source IN ('template', 'snapshot_template') - AND t.status <> 'deleted' + AND t.deleted = false ` func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { @@ -35,13 +35,13 @@ func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { &i.CreatedBy, &i.ClusterID, &i.Source, - &i.Status, + &i.Deleted, ) return i, err } const getTemplateByIDWithAliases = `-- name: GetTemplateByIDWithAliases :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, al.aliases, al.names +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, al.aliases, al.names FROM "public"."envs" e CROSS JOIN LATERAL ( SELECT @@ -52,7 +52,7 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = $1 AND e.source IN ('template', 'snapshot_template') - AND e.status <> 'deleted' + AND e.deleted = false ` type GetTemplateByIDWithAliasesRow struct { @@ -67,7 +67,7 @@ type GetTemplateByIDWithAliasesRow struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string - Status string + Deleted bool Aliases []string Names []string } @@ -87,7 +87,7 @@ func (q *Queries) GetTemplateByIDWithAliases(ctx context.Context, id string) (Ge &i.CreatedBy, &i.ClusterID, &i.Source, - &i.Status, + &i.Deleted, &i.Aliases, &i.Names, ) diff --git a/packages/db/queries/get_template_with_build_by_tag.sql.go b/packages/db/queries/get_template_with_build_by_tag.sql.go index 7036f22aae..381530a0d9 100644 --- a/packages/db/queries/get_template_with_build_by_tag.sql.go +++ b/packages/db/queries/get_template_with_build_by_tag.sql.go @@ -10,7 +10,7 @@ import ( ) const getTemplateWithBuildByTag = `-- name: GetTemplateWithBuildByTag :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.status, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names FROM public.envs AS e JOIN public.env_build_assignments AS eba ON eba.env_id = e.id AND ( @@ -31,7 +31,7 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = $2 AND e.source IN ('template', 'snapshot_template') - AND e.status <> 'deleted' + AND e.deleted = false ORDER BY eba.created_at DESC LIMIT 1 ` @@ -66,7 +66,7 @@ func (q *Queries) GetTemplateWithBuildByTag(ctx context.Context, arg GetTemplate &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Status, + &i.Env.Deleted, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/list_team_snapshot_templates.sql.go b/packages/db/queries/list_team_snapshot_templates.sql.go index 726b2c69e5..7eebf5efd1 100644 --- a/packages/db/queries/list_team_snapshot_templates.sql.go +++ b/packages/db/queries/list_team_snapshot_templates.sql.go @@ -46,7 +46,7 @@ LEFT JOIN LATERAL ( ) ea ON TRUE WHERE e.team_id = $1 AND e.source = 'snapshot_template' -AND e.status <> 'deleted' +AND e.deleted = false AND ( $2::text IS NULL OR st.sandbox_id = $2::text diff --git a/packages/db/queries/models.go b/packages/db/queries/models.go index 8a4c13a0d3..3b0fb9c9ff 100644 --- a/packages/db/queries/models.go +++ b/packages/db/queries/models.go @@ -36,7 +36,7 @@ type Env struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string - Status string + Deleted bool } type EnvAlias struct { diff --git a/packages/db/queries/snapshots/get_last_snapshot.sql b/packages/db/queries/snapshots/get_last_snapshot.sql index 62675931f5..05a760c101 100644 --- a/packages/db/queries/snapshots/get_last_snapshot.sql +++ b/packages/db/queries/snapshots/get_last_snapshot.sql @@ -1,7 +1,7 @@ -- name: GetLastSnapshot :one SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, sqlc.embed(s), sqlc.embed(eb) FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.status <> 'deleted' +JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false JOIN LATERAL ( SELECT eba.build_id FROM "public"."env_build_assignments" eba diff --git a/packages/db/queries/snapshots/list_team_snapshot_templates.sql b/packages/db/queries/snapshots/list_team_snapshot_templates.sql index f680adb004..f8cdcd9274 100644 --- a/packages/db/queries/snapshots/list_team_snapshot_templates.sql +++ b/packages/db/queries/snapshots/list_team_snapshot_templates.sql @@ -34,7 +34,7 @@ LEFT JOIN LATERAL ( ) ea ON TRUE WHERE e.team_id = @team_id AND e.source = 'snapshot_template' -AND e.status <> 'deleted' +AND e.deleted = false AND ( sqlc.narg(sandbox_id)::text IS NULL OR st.sandbox_id = sqlc.narg(sandbox_id)::text diff --git a/packages/db/queries/template_aliases/check_alias_exists.sql b/packages/db/queries/template_aliases/check_alias_exists.sql index 811c9248fa..f4d0546537 100644 --- a/packages/db/queries/template_aliases/check_alias_exists.sql +++ b/packages/db/queries/template_aliases/check_alias_exists.sql @@ -3,7 +3,7 @@ SELECT EXISTS( SELECT 1 FROM "public"."envs" WHERE id = @alias - AND status <> 'deleted' + AND deleted = false ); -- name: CheckAliasExistsInNamespace :one diff --git a/packages/db/queries/templates/create_template.sql b/packages/db/queries/templates/create_template.sql index c0525c1f5f..8a669b881b 100644 --- a/packages/db/queries/templates/create_template.sql +++ b/packages/db/queries/templates/create_template.sql @@ -3,7 +3,7 @@ INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster VALUES (@template_id, @team_id, @created_by, NOW(), FALSE, @cluster_id, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), - status = 'active', + deleted = false, build_count = envs.build_count + 1; -- name: InvalidateUnstartedTemplateBuilds :exec diff --git a/packages/db/queries/templates/delete_template.sql b/packages/db/queries/templates/delete_template.sql index 50cbc4ade2..cefb0f23a0 100644 --- a/packages/db/queries/templates/delete_template.sql +++ b/packages/db/queries/templates/delete_template.sql @@ -1,10 +1,8 @@ -- name: DeleteTemplate :many --- Soft-deletes a template by marking its env status='deleted'. The env row, its --- build assignments, and any snapshot rows are preserved so the build lineage --- stays traceable for a future storage GC. Aliases are released (deleted) so the --- name can be reused, and active_template_builds rows are cleared (mirroring the --- old env cascade) so in-flight build tracking and concurrency counts don't --- linger. Returns the released alias cache keys for cache invalidation. +-- Soft-deletes a template: flags its env deleted instead of removing the row, so +-- env_builds, env_build_assignments, and snapshot rows survive for lineage and a +-- future storage GC. Aliases are released and active_template_builds cleared +-- (mirroring the old env cascade). Returns released alias cache keys. WITH alias_keys AS ( SELECT CASE WHEN ea.namespace IS NOT NULL THEN ea.namespace || '/' || ea.alias @@ -14,10 +12,10 @@ WITH alias_keys AS ( WHERE ea.env_id = @template_id ), updated AS ( UPDATE public.envs e - SET status = 'deleted', updated_at = NOW() + SET deleted = true, updated_at = NOW() WHERE e.id = @template_id AND e.team_id = @team_id - AND e.status <> 'deleted' + AND e.deleted = false RETURNING e.id ), released AS ( DELETE FROM public.env_aliases ea diff --git a/packages/db/queries/templates/exists_template_snapshots.sql b/packages/db/queries/templates/exists_template_snapshots.sql index 843420651a..3b3d667565 100644 --- a/packages/db/queries/templates/exists_template_snapshots.sql +++ b/packages/db/queries/templates/exists_template_snapshots.sql @@ -4,5 +4,5 @@ SELECT EXISTS( FROM "public"."snapshots" s JOIN "public"."envs" e ON e.id = s.env_id WHERE s.base_env_id = @env_id - AND e.status <> 'deleted' + AND e.deleted = false ); \ No newline at end of file diff --git a/packages/db/queries/templates/get_template.sql b/packages/db/queries/templates/get_template.sql index 54a9315332..827910757f 100644 --- a/packages/db/queries/templates/get_template.sql +++ b/packages/db/queries/templates/get_template.sql @@ -8,7 +8,7 @@ JOIN public.envs AS e ON e.id = ea.env_id WHERE ea.alias = @alias AND ea.namespace IS NOT DISTINCT FROM sqlc.narg(namespace)::text AND e.source IN ('template', 'snapshot_template') - AND e.status <> 'deleted'; + AND e.deleted = false; -- name: GetTemplateById :one -- Looks up a template by its ID directly @@ -17,4 +17,4 @@ SELECT e.id, e.team_id, e.public, e.cluster_id FROM public.envs AS e WHERE e.id = @template_id AND e.source IN ('template', 'snapshot_template') - AND e.status <> 'deleted'; + AND e.deleted = false; diff --git a/packages/db/queries/templates/get_template_by_id.sql b/packages/db/queries/templates/get_template_by_id.sql index 8181429209..1fde521fb0 100644 --- a/packages/db/queries/templates/get_template_by_id.sql +++ b/packages/db/queries/templates/get_template_by_id.sql @@ -3,7 +3,7 @@ SELECT t.* FROM "public"."envs" t WHERE t.id = $1 AND t.source IN ('template', 'snapshot_template') - AND t.status <> 'deleted'; + AND t.deleted = false; -- name: GetTemplateByIDWithAliases :one SELECT e.*, al.aliases, al.names @@ -17,4 +17,4 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = $1 AND e.source IN ('template', 'snapshot_template') - AND e.status <> 'deleted'; + AND e.deleted = false; diff --git a/packages/db/queries/templates/get_template_with_build_by_tag.sql b/packages/db/queries/templates/get_template_with_build_by_tag.sql index 67d72c8a57..666db0c2a1 100644 --- a/packages/db/queries/templates/get_template_with_build_by_tag.sql +++ b/packages/db/queries/templates/get_template_with_build_by_tag.sql @@ -23,6 +23,6 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = @template_id AND e.source IN ('template', 'snapshot_template') - AND e.status <> 'deleted' + AND e.deleted = false ORDER BY eba.created_at DESC LIMIT 1; diff --git a/packages/db/queries/templates/update_template.sql b/packages/db/queries/templates/update_template.sql index 89df89ae73..10980305e6 100644 --- a/packages/db/queries/templates/update_template.sql +++ b/packages/db/queries/templates/update_template.sql @@ -6,7 +6,7 @@ WHERE id IN ( LEFT JOIN "public"."env_aliases" ea ON ea.env_id = e.id WHERE e.team_id = @team_id AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND (e.id = @template_id_or_alias OR ea.alias = @template_id_or_alias) ) RETURNING e.id; diff --git a/packages/db/queries/update_template.sql.go b/packages/db/queries/update_template.sql.go index a044f55434..26c6a26ed2 100644 --- a/packages/db/queries/update_template.sql.go +++ b/packages/db/queries/update_template.sql.go @@ -19,7 +19,7 @@ WHERE id IN ( LEFT JOIN "public"."env_aliases" ea ON ea.env_id = e.id WHERE e.team_id = $2 AND e.source = 'template' - AND e.status <> 'deleted' + AND e.deleted = false AND (e.id = $3 OR ea.alias = $3) ) RETURNING e.id From 0aab1a503a663cddd9f1b1cf1c4bafa408283da2 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:03:14 -0700 Subject: [PATCH 07/31] fix(db): block deleted-template uploads and reactivate reused snapshot env ExistsWaitingTemplateBuild now requires e.deleted = false so the docker reverse proxy can't authorize layer uploads for a deleted template's pending build. UpsertSnapshot reactivates the env when re-pausing reuses a previously soft-deleted snapshot env. --- packages/db/queries/builds/validate_build.sql | 1 + packages/db/queries/create_new_snapshot.sql.go | 8 ++++++++ packages/db/queries/snapshots/create_new_snapshot.sql | 8 ++++++++ packages/db/queries/validate_build.sql.go | 1 + 4 files changed, 18 insertions(+) diff --git a/packages/db/queries/builds/validate_build.sql b/packages/db/queries/builds/validate_build.sql index b87b765dc1..078497df34 100644 --- a/packages/db/queries/builds/validate_build.sql +++ b/packages/db/queries/builds/validate_build.sql @@ -8,5 +8,6 @@ SELECT EXISTS ( JOIN env_builds eb ON eb.id = eba.build_id WHERE at.access_token_hash = @access_token_hash AND e.id = @template_id + AND e.deleted = false AND eb.status_group = 'pending' ) AS valid; \ No newline at end of file diff --git a/packages/db/queries/create_new_snapshot.sql.go b/packages/db/queries/create_new_snapshot.sql.go index a42c362fb5..344e98bb7e 100644 --- a/packages/db/queries/create_new_snapshot.sql.go +++ b/packages/db/queries/create_new_snapshot.sql.go @@ -64,6 +64,12 @@ snapshot as ( RETURNING env_id as template_id ), +reactivate as ( + UPDATE "public"."envs" + SET deleted = false + WHERE id = (SELECT template_id FROM snapshot) AND deleted = true +), + new_build as ( INSERT INTO "public"."env_builds" ( vcpu, @@ -145,6 +151,8 @@ type UpsertSnapshotRow struct { } // Create a new snapshot or update an existing one +// Reusing an existing snapshot env (ON CONFLICT) may hit one that was +// soft-deleted by a prior delete; re-pausing makes it live again. // Create a new build for the snapshot, copying CPU info from the source build so // a pause keeps the snapshot's CPU compatibility pinned to the original build // instead of the node the pause happened to run on. Scalar subqueries are used so diff --git a/packages/db/queries/snapshots/create_new_snapshot.sql b/packages/db/queries/snapshots/create_new_snapshot.sql index 68dbed9ea5..a32afca8b7 100644 --- a/packages/db/queries/snapshots/create_new_snapshot.sql +++ b/packages/db/queries/snapshots/create_new_snapshot.sql @@ -50,6 +50,14 @@ snapshot as ( RETURNING env_id as template_id ), +-- Reusing an existing snapshot env (ON CONFLICT) may hit one that was +-- soft-deleted by a prior delete; re-pausing makes it live again. +reactivate as ( + UPDATE "public"."envs" + SET deleted = false + WHERE id = (SELECT template_id FROM snapshot) AND deleted = true +), + -- Create a new build for the snapshot, copying CPU info from the source build so -- a pause keeps the snapshot's CPU compatibility pinned to the original build -- instead of the node the pause happened to run on. Scalar subqueries are used so diff --git a/packages/db/queries/validate_build.sql.go b/packages/db/queries/validate_build.sql.go index 099a62776f..5c99fd86b6 100644 --- a/packages/db/queries/validate_build.sql.go +++ b/packages/db/queries/validate_build.sql.go @@ -19,6 +19,7 @@ SELECT EXISTS ( JOIN env_builds eb ON eb.id = eba.build_id WHERE at.access_token_hash = $1 AND e.id = $2 + AND e.deleted = false AND eb.status_group = 'pending' ) AS valid ` From fd043287b94b82cc73a55f53cf52f75ba3e64cfb Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:14:06 -0700 Subject: [PATCH 08/31] fix(db): exclude deleted snapshot envs from GetSnapshotBuilds A retry of DELETE /sandboxes/{id} on an already soft-deleted snapshot returned 204 instead of 404 because GetSnapshotBuilds still matched the soft-deleted env. Filter e.deleted = false to restore not-found behavior, and correct the softDeleteTemplate doc comment. --- packages/api/internal/handlers/template_delete.go | 2 +- packages/db/queries/get_snapshot_builds.sql.go | 1 + packages/db/queries/snapshots/get_snapshot_builds.sql | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index d9e426a7da..bc0cd3c600 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -17,7 +17,7 @@ import ( "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) -// softDeleteTemplate marks the template's env status='deleted', preserving the +// softDeleteTemplate flags the template's env deleted=true, preserving the // env row, its build assignments, and any snapshot rows so the build lineage // stays traceable for a later storage GC. Aliases are released so the name can // be reused. Returns the released alias cache keys for cache invalidation. diff --git a/packages/db/queries/get_snapshot_builds.sql.go b/packages/db/queries/get_snapshot_builds.sql.go index 042dc580b8..57f6be3ba1 100644 --- a/packages/db/queries/get_snapshot_builds.sql.go +++ b/packages/db/queries/get_snapshot_builds.sql.go @@ -13,6 +13,7 @@ import ( const getSnapshotBuilds = `-- name: GetSnapshotBuilds :many SELECT s.env_id as template_id, eb.id as build_id, eb.cluster_node_id as build_cluster_node_id FROM "public"."snapshots" s +JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false LEFT JOIN "public"."env_build_assignments" eba ON eba.env_id = s.env_id AND eba.tag = 'default' LEFT JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE s.sandbox_id = $1 diff --git a/packages/db/queries/snapshots/get_snapshot_builds.sql b/packages/db/queries/snapshots/get_snapshot_builds.sql index 939053cf12..def316f20f 100644 --- a/packages/db/queries/snapshots/get_snapshot_builds.sql +++ b/packages/db/queries/snapshots/get_snapshot_builds.sql @@ -1,5 +1,6 @@ -- name: GetSnapshotBuilds :many SELECT s.env_id as template_id, eb.id as build_id, eb.cluster_node_id as build_cluster_node_id FROM "public"."snapshots" s +JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false LEFT JOIN "public"."env_build_assignments" eba ON eba.env_id = s.env_id AND eba.tag = 'default' LEFT JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE s.sandbox_id = @sandbox_id From 3d3ed8d1f994361401019ca123b3cd3c58704e4d Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:19:35 -0700 Subject: [PATCH 09/31] refactor(api): inline DeleteTemplate call, drop needless wrapper Soft-delete logic lives in the DeleteTemplate query; the handlers just call it directly like before, keeping the diff minimal. --- .../api/internal/handlers/sandbox_kill.go | 6 +++- .../api/internal/handlers/template_delete.go | 28 ++++--------------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/api/internal/handlers/sandbox_kill.go b/packages/api/internal/handlers/sandbox_kill.go index b30ac6b5a9..8c214fc166 100644 --- a/packages/api/internal/handlers/sandbox_kill.go +++ b/packages/api/internal/handlers/sandbox_kill.go @@ -14,6 +14,7 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/sandbox" "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -24,7 +25,10 @@ func (a *APIStore) deleteSnapshot(ctx context.Context, sandboxID string, teamID return err } - aliasKeys, dbErr := a.softDeleteTemplate(ctx, teamID, snapshot.TemplateID) + aliasKeys, dbErr := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ + TeamID: teamID, + TemplateID: snapshot.TemplateID, + }) if dbErr != nil { return fmt.Errorf("error deleting template from db: %w", dbErr) } diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index bc0cd3c600..7fc213e9ef 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -6,7 +6,6 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/api/internal/api" @@ -17,22 +16,6 @@ import ( "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) -// softDeleteTemplate flags the template's env deleted=true, preserving the -// env row, its build assignments, and any snapshot rows so the build lineage -// stays traceable for a later storage GC. Aliases are released so the name can -// be reused. Returns the released alias cache keys for cache invalidation. -func (a *APIStore) softDeleteTemplate(ctx context.Context, teamID uuid.UUID, templateID string) ([]string, error) { - aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ - TemplateID: templateID, - TeamID: teamID, - }) - if err != nil { - return nil, fmt.Errorf("delete template: %w", err) - } - - return aliasKeys, nil -} - // DeleteTemplatesTemplateID serves to delete a template (e.g. in CLI) func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID api.TemplateID) { ctx := c.Request.Context() @@ -97,11 +80,12 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a return } - // Soft-delete the template: mark its env deleted and release aliases, keeping - // the env_builds rows, assignments, and snapshot rows for lineage and a later - // storage GC. Returns alias cache keys for cache invalidation. - // [ENG-3477] a future GC mechanism will handle orphaned storage. - aliasKeys, err := a.softDeleteTemplate(ctx, team.ID, templateID) + // Soft-deletes the env (keeps build assignments/snapshots for lineage) and + // releases aliases. [ENG-3477] orphan storage GC is future work. + aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ + TemplateID: templateID, + TeamID: team.ID, + }) if err != nil { telemetry.ReportCriticalError(ctx, "error when deleting template from db", err) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when deleting template") From bdcdfed1422bb899999c2d021db86326a08f009e Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:21:47 -0700 Subject: [PATCH 10/31] chore(db): trim soft-delete comments and drop trivial test --- .../api/internal/handlers/template_delete.go | 3 +-- .../20260628120000_add_env_deleted.sql | 5 ++-- .../tests/templates/delete_template_test.go | 24 ------------------- .../db/queries/templates/delete_template.sql | 6 ++--- 4 files changed, 5 insertions(+), 33 deletions(-) diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index 7fc213e9ef..535f065e8c 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -80,8 +80,7 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a return } - // Soft-deletes the env (keeps build assignments/snapshots for lineage) and - // releases aliases. [ENG-3477] orphan storage GC is future work. + // Soft-deletes the env and releases aliases. [ENG-3477] storage GC is future work. aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ TemplateID: templateID, TeamID: team.ID, diff --git a/packages/db/migrations/20260628120000_add_env_deleted.sql b/packages/db/migrations/20260628120000_add_env_deleted.sql index b23ea32242..1a400100bf 100644 --- a/packages/db/migrations/20260628120000_add_env_deleted.sql +++ b/packages/db/migrations/20260628120000_add_env_deleted.sql @@ -1,7 +1,6 @@ -- +goose Up --- Soft-delete marker for envs (templates, snapshots, snapshot_templates). On --- user delete the env is flagged deleted instead of being removed, so its build --- assignments and snapshot rows survive and the build lineage stays traceable. +-- Soft-delete marker for envs, so deleting a template/snapshot keeps its build +-- assignments and snapshot rows for lineage instead of cascading them away. ALTER TABLE "public"."envs" ADD COLUMN IF NOT EXISTS deleted boolean NOT NULL DEFAULT false; -- +goose Down diff --git a/packages/db/pkg/tests/templates/delete_template_test.go b/packages/db/pkg/tests/templates/delete_template_test.go index 38c2569686..6f194afd4a 100644 --- a/packages/db/pkg/tests/templates/delete_template_test.go +++ b/packages/db/pkg/tests/templates/delete_template_test.go @@ -67,27 +67,3 @@ func TestDeleteTemplate_SoftDeletesEnvAndPreservesStructure(t *testing.T) { )) assert.Zero(t, aliasCount, "alias must be released for reuse") } - -func TestDeleteTemplate_KeepsBuildSharedWithActiveTemplate(t *testing.T) { - t.Parallel() - db := testutils.SetupDatabase(t) - ctx := t.Context() - - teamID := testutils.CreateTestTeam(t, db) - deletedID := testutils.CreateTestTemplate(t, db, teamID) - activeID := testutils.CreateTestTemplate(t, db, teamID) - - buildID := testutils.CreateTestBuild(t, ctx, db, deletedID, "uploaded") - testutils.CreateTestBuildAssignment(t, ctx, db, deletedID, buildID, "default") - testutils.CreateTestBuildAssignment(t, ctx, db, activeID, buildID, "default") - - _, err := db.SqlcClient.DeleteTemplate(ctx, queries.DeleteTemplateParams{TemplateID: deletedID, TeamID: teamID}) - require.NoError(t, err) - - assert.True(t, testutils.GetEnvBuildByID(t, ctx, db, buildID), "shared build must survive") - assert.False(t, envDeleted(t, db, activeID), "other template must be unaffected") - - got, err := db.SqlcClient.GetTeamTemplate(ctx, queries.GetTeamTemplateParams{ID: activeID, TeamID: teamID}) - require.NoError(t, err) - assert.Equal(t, activeID, got.Env.ID, "active template sharing the build must still resolve") -} diff --git a/packages/db/queries/templates/delete_template.sql b/packages/db/queries/templates/delete_template.sql index cefb0f23a0..12d17b2b69 100644 --- a/packages/db/queries/templates/delete_template.sql +++ b/packages/db/queries/templates/delete_template.sql @@ -1,8 +1,6 @@ -- name: DeleteTemplate :many --- Soft-deletes a template: flags its env deleted instead of removing the row, so --- env_builds, env_build_assignments, and snapshot rows survive for lineage and a --- future storage GC. Aliases are released and active_template_builds cleared --- (mirroring the old env cascade). Returns released alias cache keys. +-- Soft-deletes the env (keeps env_builds/assignments/snapshots for lineage), +-- releases aliases, and clears active_template_builds. Returns alias cache keys. WITH alias_keys AS ( SELECT CASE WHEN ea.namespace IS NOT NULL THEN ea.namespace || '/' || ea.alias From 6ff93befe0e1440ee03567a91eab8a669d3cd176 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:22:49 -0700 Subject: [PATCH 11/31] chore: drop linear issue and future-work mentions from comments --- packages/api/internal/handlers/template_delete.go | 2 +- packages/db/queries/delete_template.sql.go | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index 535f065e8c..cc1c46434b 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -80,7 +80,7 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a return } - // Soft-deletes the env and releases aliases. [ENG-3477] storage GC is future work. + // Soft-deletes the env and releases aliases. aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ TemplateID: templateID, TeamID: team.ID, diff --git a/packages/db/queries/delete_template.sql.go b/packages/db/queries/delete_template.sql.go index c4fe11cd58..4fabe969af 100644 --- a/packages/db/queries/delete_template.sql.go +++ b/packages/db/queries/delete_template.sql.go @@ -42,10 +42,8 @@ type DeleteTemplateParams struct { TeamID uuid.UUID } -// Soft-deletes a template: flags its env deleted instead of removing the row, so -// env_builds, env_build_assignments, and snapshot rows survive for lineage and a -// future storage GC. Aliases are released and active_template_builds cleared -// (mirroring the old env cascade). Returns released alias cache keys. +// Soft-deletes the env (keeps env_builds/assignments/snapshots for lineage), +// releases aliases, and clears active_template_builds. Returns alias cache keys. func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) ([]string, error) { rows, err := q.db.Query(ctx, deleteTemplate, arg.TemplateID, arg.TeamID) if err != nil { From 88c39d7a18db97bed4c1e7b9c3333b792260b872 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:30:27 -0700 Subject: [PATCH 12/31] fix(db): let GetTemplateByID see soft-deleted envs so rebuilds reactivate --- packages/db/queries/get_template_by_id.sql.go | 3 ++- packages/db/queries/templates/get_template_by_id.sql | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/db/queries/get_template_by_id.sql.go b/packages/db/queries/get_template_by_id.sql.go index d15cb729ec..4b6c844ca7 100644 --- a/packages/db/queries/get_template_by_id.sql.go +++ b/packages/db/queries/get_template_by_id.sql.go @@ -17,9 +17,10 @@ SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, FROM "public"."envs" t WHERE t.id = $1 AND t.source IN ('template', 'snapshot_template') - AND t.deleted = false ` +// No deleted filter: the build/rebuild path resolves by id here and must find a +// soft-deleted env so CreateOrUpdateTemplate can reactivate it. func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { row := q.db.QueryRow(ctx, getTemplateByID, id) var i Env diff --git a/packages/db/queries/templates/get_template_by_id.sql b/packages/db/queries/templates/get_template_by_id.sql index 1fde521fb0..b31615de4d 100644 --- a/packages/db/queries/templates/get_template_by_id.sql +++ b/packages/db/queries/templates/get_template_by_id.sql @@ -1,9 +1,10 @@ -- name: GetTemplateByID :one +-- No deleted filter: the build/rebuild path resolves by id here and must find a +-- soft-deleted env so CreateOrUpdateTemplate can reactivate it. SELECT t.* FROM "public"."envs" t WHERE t.id = $1 - AND t.source IN ('template', 'snapshot_template') - AND t.deleted = false; + AND t.source IN ('template', 'snapshot_template'); -- name: GetTemplateByIDWithAliases :one SELECT e.*, al.aliases, al.names From d47800b6bd9fd584d6e125f212a5deffa6930bda Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:34:24 -0700 Subject: [PATCH 13/31] fix(api): reject layer uploads for soft-deleted templates GetTemplateByID returns soft-deleted envs so rebuild can reactivate them, but the files-hash upload handler must not serve a deleted template; guard on templateDB.Deleted. --- .../api/internal/handlers/template_layer_files_upload.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index 637d28a9f3..45a6810e7a 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -35,6 +35,14 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap return } + // GetTemplateByID intentionally returns soft-deleted envs (for rebuild + // reactivation); layer uploads must not be served for a deleted template. + if templateDB.Deleted { + a.sendAPIStoreError(c, http.StatusNotFound, "Template not found") + + return + } + dbTeamID := templateDB.TeamID.String() team, apiErr := a.GetTeam(ctx, c, &dbTeamID) if apiErr != nil { From a81f7ffd6253cc7da445181ea919822264d7ee8f Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:36:32 -0700 Subject: [PATCH 14/31] chore: drop lineage wording from soft-delete comments --- packages/db/migrations/20260628120000_add_env_deleted.sql | 2 +- packages/db/queries/delete_template.sql.go | 4 ++-- packages/db/queries/templates/delete_template.sql | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/db/migrations/20260628120000_add_env_deleted.sql b/packages/db/migrations/20260628120000_add_env_deleted.sql index 1a400100bf..5e059399ba 100644 --- a/packages/db/migrations/20260628120000_add_env_deleted.sql +++ b/packages/db/migrations/20260628120000_add_env_deleted.sql @@ -1,6 +1,6 @@ -- +goose Up -- Soft-delete marker for envs, so deleting a template/snapshot keeps its build --- assignments and snapshot rows for lineage instead of cascading them away. +-- assignments and snapshot rows instead of cascading them away. ALTER TABLE "public"."envs" ADD COLUMN IF NOT EXISTS deleted boolean NOT NULL DEFAULT false; -- +goose Down diff --git a/packages/db/queries/delete_template.sql.go b/packages/db/queries/delete_template.sql.go index 4fabe969af..77e903d2b6 100644 --- a/packages/db/queries/delete_template.sql.go +++ b/packages/db/queries/delete_template.sql.go @@ -42,8 +42,8 @@ type DeleteTemplateParams struct { TeamID uuid.UUID } -// Soft-deletes the env (keeps env_builds/assignments/snapshots for lineage), -// releases aliases, and clears active_template_builds. Returns alias cache keys. +// Soft-deletes the env (keeps env_builds/assignments/snapshots), releases +// aliases, and clears active_template_builds. Returns alias cache keys. func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) ([]string, error) { rows, err := q.db.Query(ctx, deleteTemplate, arg.TemplateID, arg.TeamID) if err != nil { diff --git a/packages/db/queries/templates/delete_template.sql b/packages/db/queries/templates/delete_template.sql index 12d17b2b69..75ebc0b6f4 100644 --- a/packages/db/queries/templates/delete_template.sql +++ b/packages/db/queries/templates/delete_template.sql @@ -1,6 +1,6 @@ -- name: DeleteTemplate :many --- Soft-deletes the env (keeps env_builds/assignments/snapshots for lineage), --- releases aliases, and clears active_template_builds. Returns alias cache keys. +-- Soft-deletes the env (keeps env_builds/assignments/snapshots), releases +-- aliases, and clears active_template_builds. Returns alias cache keys. WITH alias_keys AS ( SELECT CASE WHEN ea.namespace IS NOT NULL THEN ea.namespace || '/' || ea.alias From 51f4782d2446759806e4197d1f8fe7e36a0c5e77 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:30:04 -0700 Subject: [PATCH 15/31] refactor(db): soft-delete via deleted_at timestamp behind an active_envs view Replace the envs.deleted boolean with a deleted_at timestamptz (auditability and time-based GC), and route non-deleted reads through a new active_envs view instead of repeating the filter (sqlc.embed reads keep an inline deleted_at IS NULL to preserve the Env model). Drop the snapshot re-pause reactivation so reusing a soft-deleted snapshot env no longer resurrects its build layers. --- .../handlers/template_layer_files_upload.go | 2 +- .../20260628120000_add_env_deleted.sql | 7 --- .../20260628120000_add_env_deleted_at.sql | 26 ++++++++++ .../queries/get_default_templates.sql.go | 2 +- .../queries/list_team_templates.sql.go | 48 +++++++------------ .../templates/get_default_templates.sql | 2 +- .../templates/list_team_templates.sql | 48 +++++++------------ .../tests/templates/delete_template_test.go | 2 +- packages/db/pkg/testutils/queries/models.go | 17 ++++++- packages/db/queries/builds/get_build_info.sql | 3 +- .../queries/builds/get_builds_paginated.sql | 8 ++-- .../queries/builds/get_inprogress_builds.sql | 2 +- .../db/queries/builds/get_template_build.sql | 2 +- packages/db/queries/builds/validate_build.sql | 3 +- packages/db/queries/check_alias_exists.sql.go | 3 +- .../db/queries/create_new_snapshot.sql.go | 8 ---- packages/db/queries/create_template.sql.go | 2 +- packages/db/queries/delete_template.sql.go | 4 +- .../queries/exists_template_snapshots.sql.go | 3 +- packages/db/queries/get_build_info.sql.go | 3 +- .../db/queries/get_builds_paginated.sql.go | 8 ++-- .../db/queries/get_inprogress_builds.sql.go | 6 +-- packages/db/queries/get_last_snapshot.sql.go | 2 +- .../db/queries/get_snapshot_builds.sql.go | 2 +- .../db/queries/get_snapshots_with_cursor.sql | 2 +- .../queries/get_snapshots_with_cursor.sql.go | 2 +- packages/db/queries/get_team_template.sql | 2 +- packages/db/queries/get_team_template.sql.go | 6 +-- packages/db/queries/get_team_templates.sql | 2 +- packages/db/queries/get_team_templates.sql.go | 6 +-- .../get_team_templates_with_cursor.sql | 3 +- .../get_team_templates_with_cursor.sql.go | 3 +- packages/db/queries/get_template.sql.go | 6 +-- packages/db/queries/get_template_build.sql.go | 6 +-- packages/db/queries/get_template_by_id.sql.go | 13 +++-- .../get_template_with_build_by_tag.sql.go | 6 +-- .../list_team_snapshot_templates.sql.go | 3 +- packages/db/queries/models.go | 2 +- .../queries/snapshots/create_new_snapshot.sql | 8 ---- .../queries/snapshots/get_last_snapshot.sql | 2 +- .../queries/snapshots/get_snapshot_builds.sql | 2 +- .../list_team_snapshot_templates.sql | 3 +- .../template_aliases/check_alias_exists.sql | 3 +- .../db/queries/templates/create_template.sql | 2 +- .../db/queries/templates/delete_template.sql | 4 +- .../templates/exists_template_snapshots.sql | 3 +- .../db/queries/templates/get_template.sql | 10 ++-- .../queries/templates/get_template_by_id.sql | 5 +- .../get_template_with_build_by_tag.sql | 2 +- .../db/queries/templates/update_template.sql | 3 +- packages/db/queries/update_template.sql.go | 3 +- packages/db/queries/validate_build.sql.go | 3 +- 52 files changed, 147 insertions(+), 181 deletions(-) delete mode 100644 packages/db/migrations/20260628120000_add_env_deleted.sql create mode 100644 packages/db/migrations/20260628120000_add_env_deleted_at.sql diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index 45a6810e7a..4e8f8666ea 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -37,7 +37,7 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap // GetTemplateByID intentionally returns soft-deleted envs (for rebuild // reactivation); layer uploads must not be served for a deleted template. - if templateDB.Deleted { + if templateDB.DeletedAt != nil { a.sendAPIStoreError(c, http.StatusNotFound, "Template not found") return diff --git a/packages/db/migrations/20260628120000_add_env_deleted.sql b/packages/db/migrations/20260628120000_add_env_deleted.sql deleted file mode 100644 index 5e059399ba..0000000000 --- a/packages/db/migrations/20260628120000_add_env_deleted.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up --- Soft-delete marker for envs, so deleting a template/snapshot keeps its build --- assignments and snapshot rows instead of cascading them away. -ALTER TABLE "public"."envs" ADD COLUMN IF NOT EXISTS deleted boolean NOT NULL DEFAULT false; - --- +goose Down -ALTER TABLE "public"."envs" DROP COLUMN IF EXISTS deleted; diff --git a/packages/db/migrations/20260628120000_add_env_deleted_at.sql b/packages/db/migrations/20260628120000_add_env_deleted_at.sql new file mode 100644 index 0000000000..8b390276e2 --- /dev/null +++ b/packages/db/migrations/20260628120000_add_env_deleted_at.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Soft-delete marker for envs: deleting a template/snapshot stamps deleted_at +-- instead of cascading its rows away. The active_envs view is the canonical +-- read path so callers don't repeat the deleted_at IS NULL filter. +ALTER TABLE "public"."envs" ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL; + +CREATE OR REPLACE VIEW "public"."active_envs" AS +SELECT + id, + created_at, + updated_at, + public, + build_count, + spawn_count, + last_spawned_at, + team_id, + created_by, + cluster_id, + source, + deleted_at +FROM "public"."envs" +WHERE deleted_at IS NULL; + +-- +goose Down +DROP VIEW IF EXISTS "public"."active_envs"; +ALTER TABLE "public"."envs" DROP COLUMN IF EXISTS deleted_at; diff --git a/packages/db/pkg/dashboard/queries/get_default_templates.sql.go b/packages/db/pkg/dashboard/queries/get_default_templates.sql.go index 4dc5591a83..031c2ea7d2 100644 --- a/packages/db/pkg/dashboard/queries/get_default_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/get_default_templates.sql.go @@ -25,7 +25,7 @@ SELECT b.total_disk_size_mb, b.envd_version FROM public.env_defaults ed -JOIN public.envs e ON e.id = ed.env_id AND e.deleted = false +JOIN public.active_envs e ON e.id = ed.env_id JOIN LATERAL ( SELECT a.build_id FROM public.env_build_assignments a diff --git a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go index db6a839b99..60d4a447ab 100644 --- a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go @@ -26,12 +26,10 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = $1::uuid AND e.source = 'template' - AND e.deleted = false - AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + e.team_id = $1::uuid AND e.source = 'template' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -60,12 +58,10 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - $7::boolean - AND e.deleted = false - AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + $7::boolean AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -241,12 +237,10 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = $1::uuid AND e.source = 'template' - AND e.deleted = false - AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + e.team_id = $1::uuid AND e.source = 'template' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -275,12 +269,10 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - $7::boolean - AND e.deleted = false - AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + $7::boolean AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -440,12 +432,10 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = $1::uuid AND e.source = 'template' - AND e.deleted = false - AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + e.team_id = $1::uuid AND e.source = 'template' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -474,12 +464,10 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - $7::boolean - AND e.deleted = false - AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + $7::boolean AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -639,12 +627,10 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = $1::uuid AND e.source = 'template' - AND e.deleted = false - AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + e.team_id = $1::uuid AND e.source = 'template' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -673,12 +659,10 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - $7::boolean - AND e.deleted = false - AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + $7::boolean AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' diff --git a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql index 0cc2cd5794..3d34cb6d4f 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql @@ -11,7 +11,7 @@ SELECT b.total_disk_size_mb, b.envd_version FROM public.env_defaults ed -JOIN public.envs e ON e.id = ed.env_id AND e.deleted = false +JOIN public.active_envs e ON e.id = ed.env_id JOIN LATERAL ( SELECT a.build_id FROM public.env_build_assignments a diff --git a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql index 67a18ddadb..5041f780db 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql @@ -28,12 +28,10 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.deleted = false - AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -62,12 +60,10 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - sqlc.arg(include_defaults)::boolean - AND e.deleted = false - AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + sqlc.arg(include_defaults)::boolean AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -148,12 +144,10 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.deleted = false - AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -182,12 +176,10 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - sqlc.arg(include_defaults)::boolean - AND e.deleted = false - AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + sqlc.arg(include_defaults)::boolean AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -268,12 +260,10 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.deleted = false - AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -302,12 +292,10 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - sqlc.arg(include_defaults)::boolean - AND e.deleted = false - AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + sqlc.arg(include_defaults)::boolean AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -388,12 +376,10 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.deleted = false - AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -422,12 +408,10 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - sqlc.arg(include_defaults)::boolean - AND e.deleted = false - AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + sqlc.arg(include_defaults)::boolean AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' diff --git a/packages/db/pkg/tests/templates/delete_template_test.go b/packages/db/pkg/tests/templates/delete_template_test.go index 6f194afd4a..9da42a58b2 100644 --- a/packages/db/pkg/tests/templates/delete_template_test.go +++ b/packages/db/pkg/tests/templates/delete_template_test.go @@ -15,7 +15,7 @@ func envDeleted(t *testing.T, db *testutils.Database, envID string) bool { t.Helper() var deleted bool err := db.SqlcClient.TestsRawSQLQuery(t.Context(), - "SELECT deleted FROM public.envs WHERE id = $1", + "SELECT deleted_at IS NOT NULL FROM public.envs WHERE id = $1", func(rows pgx.Rows) error { if rows.Next() { return rows.Scan(&deleted) diff --git a/packages/db/pkg/testutils/queries/models.go b/packages/db/pkg/testutils/queries/models.go index 2e65b8e76b..8de9cf8b44 100644 --- a/packages/db/pkg/testutils/queries/models.go +++ b/packages/db/pkg/testutils/queries/models.go @@ -24,6 +24,21 @@ type AccessToken struct { AccessTokenMaskSuffix string } +type ActiveEnv struct { + ID string + CreatedAt time.Time + UpdatedAt time.Time + Public bool + BuildCount int32 + SpawnCount int64 + LastSpawnedAt *time.Time + TeamID uuid.UUID + CreatedBy *uuid.UUID + ClusterID *uuid.UUID + Source string + DeletedAt *time.Time +} + type ActiveTemplateBuild struct { BuildID uuid.UUID TeamID uuid.UUID @@ -92,7 +107,7 @@ type Env struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string - Deleted bool + DeletedAt *time.Time } type EnvAlias struct { diff --git a/packages/db/queries/builds/get_build_info.sql b/packages/db/queries/builds/get_build_info.sql index e61bcfb549..34d01eeed9 100644 --- a/packages/db/queries/builds/get_build_info.sql +++ b/packages/db/queries/builds/get_build_info.sql @@ -9,10 +9,9 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = sqlc.arg(team_id)::uuid - AND e.deleted = false ORDER BY a.created_at DESC, a.id DESC LIMIT 1 ) assignment ON TRUE diff --git a/packages/db/queries/builds/get_builds_paginated.sql b/packages/db/queries/builds/get_builds_paginated.sql index 27f0894c5d..5b4c2b1e47 100644 --- a/packages/db/queries/builds/get_builds_paginated.sql +++ b/packages/db/queries/builds/get_builds_paginated.sql @@ -15,7 +15,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.deleted = false + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -53,7 +53,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.deleted = false + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -92,7 +92,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.deleted = false + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND a.env_id = sqlc.arg(template_id)::text ORDER BY a.created_at DESC, a.id DESC @@ -131,7 +131,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.deleted = false + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND EXISTS ( SELECT 1 diff --git a/packages/db/queries/builds/get_inprogress_builds.sql b/packages/db/queries/builds/get_inprogress_builds.sql index 717c3f0cb7..63786008e5 100644 --- a/packages/db/queries/builds/get_inprogress_builds.sql +++ b/packages/db/queries/builds/get_inprogress_builds.sql @@ -6,7 +6,7 @@ JOIN public.envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' - AND e.deleted = false + AND e.deleted_at IS NULL ORDER BY b.id, b.created_at DESC; -- name: GetInProgressTemplateBuildsByTeam :one diff --git a/packages/db/queries/builds/get_template_build.sql b/packages/db/queries/builds/get_template_build.sql index 7de1a4db13..688fe63895 100644 --- a/packages/db/queries/builds/get_template_build.sql +++ b/packages/db/queries/builds/get_template_build.sql @@ -5,4 +5,4 @@ JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = sqlc.arg(template_id) AND eb.id = sqlc.arg(build_id) AND e.source = 'template' - AND e.deleted = false; + AND e.deleted_at IS NULL; diff --git a/packages/db/queries/builds/validate_build.sql b/packages/db/queries/builds/validate_build.sql index 078497df34..5bf82543b6 100644 --- a/packages/db/queries/builds/validate_build.sql +++ b/packages/db/queries/builds/validate_build.sql @@ -1,13 +1,12 @@ -- name: ExistsWaitingTemplateBuild :one SELECT EXISTS ( SELECT 1 - FROM envs e + FROM active_envs e JOIN users_teams ut ON ut.team_id = e.team_id JOIN access_tokens at ON at.user_id = ut.user_id JOIN env_build_assignments eba ON eba.env_id = e.id JOIN env_builds eb ON eb.id = eba.build_id WHERE at.access_token_hash = @access_token_hash AND e.id = @template_id - AND e.deleted = false AND eb.status_group = 'pending' ) AS valid; \ No newline at end of file diff --git a/packages/db/queries/check_alias_exists.sql.go b/packages/db/queries/check_alias_exists.sql.go index 7f2a6fcb3b..9ad60048f8 100644 --- a/packages/db/queries/check_alias_exists.sql.go +++ b/packages/db/queries/check_alias_exists.sql.go @@ -12,9 +12,8 @@ import ( const checkAliasConflictsWithTemplateID = `-- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( SELECT 1 - FROM "public"."envs" + FROM "public"."active_envs" WHERE id = $1 - AND deleted = false ) ` diff --git a/packages/db/queries/create_new_snapshot.sql.go b/packages/db/queries/create_new_snapshot.sql.go index 344e98bb7e..a42c362fb5 100644 --- a/packages/db/queries/create_new_snapshot.sql.go +++ b/packages/db/queries/create_new_snapshot.sql.go @@ -64,12 +64,6 @@ snapshot as ( RETURNING env_id as template_id ), -reactivate as ( - UPDATE "public"."envs" - SET deleted = false - WHERE id = (SELECT template_id FROM snapshot) AND deleted = true -), - new_build as ( INSERT INTO "public"."env_builds" ( vcpu, @@ -151,8 +145,6 @@ type UpsertSnapshotRow struct { } // Create a new snapshot or update an existing one -// Reusing an existing snapshot env (ON CONFLICT) may hit one that was -// soft-deleted by a prior delete; re-pausing makes it live again. // Create a new build for the snapshot, copying CPU info from the source build so // a pause keeps the snapshot's CPU compatibility pinned to the original build // instead of the node the pause happened to run on. Scalar subqueries are used so diff --git a/packages/db/queries/create_template.sql.go b/packages/db/queries/create_template.sql.go index 58ef8518f9..071a9dba05 100644 --- a/packages/db/queries/create_template.sql.go +++ b/packages/db/queries/create_template.sql.go @@ -17,7 +17,7 @@ INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster VALUES ($1, $2, $3, NOW(), FALSE, $4, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), - deleted = false, + deleted_at = NULL, build_count = envs.build_count + 1 ` diff --git a/packages/db/queries/delete_template.sql.go b/packages/db/queries/delete_template.sql.go index 77e903d2b6..3d9efc7036 100644 --- a/packages/db/queries/delete_template.sql.go +++ b/packages/db/queries/delete_template.sql.go @@ -21,10 +21,10 @@ WITH alias_keys AS ( WHERE ea.env_id = $1 ), updated AS ( UPDATE public.envs e - SET deleted = true, updated_at = NOW() + SET deleted_at = NOW(), updated_at = NOW() WHERE e.id = $1 AND e.team_id = $2 - AND e.deleted = false + AND e.deleted_at IS NULL RETURNING e.id ), released AS ( DELETE FROM public.env_aliases ea diff --git a/packages/db/queries/exists_template_snapshots.sql.go b/packages/db/queries/exists_template_snapshots.sql.go index d4ddb5d6a9..c4701d2433 100644 --- a/packages/db/queries/exists_template_snapshots.sql.go +++ b/packages/db/queries/exists_template_snapshots.sql.go @@ -13,9 +13,8 @@ const existsTemplateSnapshots = `-- name: ExistsTemplateSnapshots :one SELECT EXISTS( SELECT 1 FROM "public"."snapshots" s - JOIN "public"."envs" e ON e.id = s.env_id + JOIN "public"."active_envs" e ON e.id = s.env_id WHERE s.base_env_id = $1 - AND e.deleted = false ) ` diff --git a/packages/db/queries/get_build_info.sql.go b/packages/db/queries/get_build_info.sql.go index d2ca60e052..647a2aa6ba 100644 --- a/packages/db/queries/get_build_info.sql.go +++ b/packages/db/queries/get_build_info.sql.go @@ -24,10 +24,9 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = $1::uuid - AND e.deleted = false ORDER BY a.created_at DESC, a.id DESC LIMIT 1 ) assignment ON TRUE diff --git a/packages/db/queries/get_builds_paginated.sql.go b/packages/db/queries/get_builds_paginated.sql.go index 8d067e5a48..083e22d7f9 100644 --- a/packages/db/queries/get_builds_paginated.sql.go +++ b/packages/db/queries/get_builds_paginated.sql.go @@ -30,7 +30,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.deleted = false + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -129,7 +129,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.deleted = false + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -231,7 +231,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.deleted = false + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND EXISTS ( SELECT 1 @@ -338,7 +338,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id AND e.deleted = false + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND a.env_id = $1::text ORDER BY a.created_at DESC, a.id DESC diff --git a/packages/db/queries/get_inprogress_builds.sql.go b/packages/db/queries/get_inprogress_builds.sql.go index 3e57e11eee..3aacfe5d64 100644 --- a/packages/db/queries/get_inprogress_builds.sql.go +++ b/packages/db/queries/get_inprogress_builds.sql.go @@ -55,14 +55,14 @@ func (q *Queries) GetCancellableTemplateBuildsByTeam(ctx context.Context, teamID } const getInProgressTemplateBuilds = `-- name: GetInProgressTemplateBuilds :many -SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id +SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id FROM public.env_builds b JOIN public.env_build_assignments eba ON eba.build_id = b.id JOIN public.envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' - AND e.deleted = false + AND e.deleted_at IS NULL ORDER BY b.id, b.created_at DESC ` @@ -94,7 +94,7 @@ func (q *Queries) GetInProgressTemplateBuilds(ctx context.Context) ([]GetInProgr &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Deleted, + &i.Env.DeletedAt, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_last_snapshot.sql.go b/packages/db/queries/get_last_snapshot.sql.go index 795c63468d..5aaf38360a 100644 --- a/packages/db/queries/get_last_snapshot.sql.go +++ b/packages/db/queries/get_last_snapshot.sql.go @@ -12,7 +12,7 @@ import ( const getLastSnapshot = `-- name: GetLastSnapshot :one SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, s.created_at, s.env_id, s.sandbox_id, s.id, s.metadata, s.base_env_id, s.sandbox_started_at, s.env_secure, s.origin_node_id, s.allow_internet_access, s.auto_pause, s.team_id, s.config, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false +JOIN "public"."active_envs" e ON e.id = s.env_id JOIN LATERAL ( SELECT eba.build_id FROM "public"."env_build_assignments" eba diff --git a/packages/db/queries/get_snapshot_builds.sql.go b/packages/db/queries/get_snapshot_builds.sql.go index 57f6be3ba1..ad01844055 100644 --- a/packages/db/queries/get_snapshot_builds.sql.go +++ b/packages/db/queries/get_snapshot_builds.sql.go @@ -13,7 +13,7 @@ import ( const getSnapshotBuilds = `-- name: GetSnapshotBuilds :many SELECT s.env_id as template_id, eb.id as build_id, eb.cluster_node_id as build_cluster_node_id FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false +JOIN "public"."active_envs" e ON e.id = s.env_id LEFT JOIN "public"."env_build_assignments" eba ON eba.env_id = s.env_id AND eba.tag = 'default' LEFT JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE s.sandbox_id = $1 diff --git a/packages/db/queries/get_snapshots_with_cursor.sql b/packages/db/queries/get_snapshots_with_cursor.sql index eb2cf37318..f6fb005ebf 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql +++ b/packages/db/queries/get_snapshots_with_cursor.sql @@ -8,7 +8,7 @@ SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.nam eb.envd_version AS build_envd_version, eb.created_at AS build_created_at FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false +JOIN "public"."active_envs" e ON e.id = s.env_id LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_snapshots_with_cursor.sql.go b/packages/db/queries/get_snapshots_with_cursor.sql.go index 006cd55f30..3604d7aad5 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql.go +++ b/packages/db/queries/get_snapshots_with_cursor.sql.go @@ -24,7 +24,7 @@ SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.nam eb.envd_version AS build_envd_version, eb.created_at AS build_created_at FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false +JOIN "public"."active_envs" e ON e.id = s.env_id LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_team_template.sql b/packages/db/queries/get_team_template.sql index a3f34390b1..89c2ef12e3 100644 --- a/packages/db/queries/get_team_template.sql +++ b/packages/db/queries/get_team_template.sql @@ -37,4 +37,4 @@ WHERE e.id = $1 AND e.team_id = $2 AND e.source = 'template' - AND e.deleted = false; + AND e.deleted_at IS NULL; diff --git a/packages/db/queries/get_team_template.sql.go b/packages/db/queries/get_team_template.sql.go index 67e339baec..dd61d6a3ba 100644 --- a/packages/db/queries/get_team_template.sql.go +++ b/packages/db/queries/get_team_template.sql.go @@ -13,7 +13,7 @@ import ( ) const getTeamTemplate = `-- name: GetTeamTemplate :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) as build_id, COALESCE(eb.vcpu, 0) as build_vcpu, COALESCE(eb.ram_mb, 0) as build_ram_mb, @@ -51,7 +51,7 @@ WHERE e.id = $1 AND e.team_id = $2 AND e.source = 'template' - AND e.deleted = false + AND e.deleted_at IS NULL ` type GetTeamTemplateParams struct { @@ -87,7 +87,7 @@ func (q *Queries) GetTeamTemplate(ctx context.Context, arg GetTeamTemplateParams &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Deleted, + &i.Env.DeletedAt, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates.sql b/packages/db/queries/get_team_templates.sql index 73b52503d1..d7a2717c4f 100644 --- a/packages/db/queries/get_team_templates.sql +++ b/packages/db/queries/get_team_templates.sql @@ -37,5 +37,5 @@ LEFT JOIN LATERAL ( WHERE e.team_id = $1 AND e.source = 'template' - AND e.deleted = false + AND e.deleted_at IS NULL ORDER BY e.created_at ASC; diff --git a/packages/db/queries/get_team_templates.sql.go b/packages/db/queries/get_team_templates.sql.go index 92f745d68e..489570a77d 100644 --- a/packages/db/queries/get_team_templates.sql.go +++ b/packages/db/queries/get_team_templates.sql.go @@ -13,7 +13,7 @@ import ( ) const getTeamTemplates = `-- name: GetTeamTemplates :many -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) as build_id, COALESCE(eb.vcpu, 0) as build_vcpu, COALESCE(eb.ram_mb, 0) as build_ram_mb, @@ -51,7 +51,7 @@ LEFT JOIN LATERAL ( WHERE e.team_id = $1 AND e.source = 'template' - AND e.deleted = false + AND e.deleted_at IS NULL ORDER BY e.created_at ASC ` @@ -90,7 +90,7 @@ func (q *Queries) GetTeamTemplates(ctx context.Context, teamID uuid.UUID) ([]Get &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Deleted, + &i.Env.DeletedAt, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates_with_cursor.sql b/packages/db/queries/get_team_templates_with_cursor.sql index 3226cab702..fe9a082475 100644 --- a/packages/db/queries/get_team_templates_with_cursor.sql +++ b/packages/db/queries/get_team_templates_with_cursor.sql @@ -23,7 +23,7 @@ SELECT COALESCE(latest_build.status_group, 'pending') AS build_status, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, @@ -49,7 +49,6 @@ LEFT JOIN LATERAL ( ) eb ON TRUE WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' - AND e.deleted = false AND (e.created_at, e.id) < (sqlc.arg(cursor_created_at)::timestamptz, sqlc.arg(cursor_id)::text) ORDER BY e.created_at DESC, e.id DESC LIMIT sqlc.arg(limit_plus_one)::int; diff --git a/packages/db/queries/get_team_templates_with_cursor.sql.go b/packages/db/queries/get_team_templates_with_cursor.sql.go index d273978c5a..7c321a5167 100644 --- a/packages/db/queries/get_team_templates_with_cursor.sql.go +++ b/packages/db/queries/get_team_templates_with_cursor.sql.go @@ -32,7 +32,7 @@ SELECT COALESCE(latest_build.status_group, 'pending') AS build_status, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, @@ -58,7 +58,6 @@ LEFT JOIN LATERAL ( ) eb ON TRUE WHERE e.team_id = $1::uuid AND e.source = 'template' - AND e.deleted = false AND (e.created_at, e.id) < ($2::timestamptz, $3::text) ORDER BY e.created_at DESC, e.id DESC LIMIT $4::int diff --git a/packages/db/queries/get_template.sql.go b/packages/db/queries/get_template.sql.go index 99cabcc9ff..0d5690d484 100644 --- a/packages/db/queries/get_template.sql.go +++ b/packages/db/queries/get_template.sql.go @@ -14,11 +14,10 @@ import ( const getTemplateByAlias = `-- name: GetTemplateByAlias :one SELECT e.id, e.team_id, e.public FROM public.env_aliases AS ea -JOIN public.envs AS e ON e.id = ea.env_id +JOIN public.active_envs AS e ON e.id = ea.env_id WHERE ea.alias = $1 AND ea.namespace IS NOT DISTINCT FROM $2::text AND e.source IN ('template', 'snapshot_template') - AND e.deleted = false ` type GetTemplateByAliasParams struct { @@ -44,10 +43,9 @@ func (q *Queries) GetTemplateByAlias(ctx context.Context, arg GetTemplateByAlias const getTemplateById = `-- name: GetTemplateById :one SELECT e.id, e.team_id, e.public, e.cluster_id -FROM public.envs AS e +FROM public.active_envs AS e WHERE e.id = $1 AND e.source IN ('template', 'snapshot_template') - AND e.deleted = false ` type GetTemplateByIdRow struct { diff --git a/packages/db/queries/get_template_build.sql.go b/packages/db/queries/get_template_build.sql.go index 444f88b2f5..97f3f63aaa 100644 --- a/packages/db/queries/get_template_build.sql.go +++ b/packages/db/queries/get_template_build.sql.go @@ -12,13 +12,13 @@ import ( ) const getTemplateBuildWithTemplate = `-- name: GetTemplateBuildWithTemplate :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id FROM "public"."envs" e JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = $1 AND eb.id = $2 AND e.source = 'template' - AND e.deleted = false + AND e.deleted_at IS NULL ` type GetTemplateBuildWithTemplateParams struct { @@ -46,7 +46,7 @@ func (q *Queries) GetTemplateBuildWithTemplate(ctx context.Context, arg GetTempl &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Deleted, + &i.Env.DeletedAt, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_template_by_id.sql.go b/packages/db/queries/get_template_by_id.sql.go index 4b6c844ca7..5270bee512 100644 --- a/packages/db/queries/get_template_by_id.sql.go +++ b/packages/db/queries/get_template_by_id.sql.go @@ -13,7 +13,7 @@ import ( ) const getTemplateByID = `-- name: GetTemplateByID :one -SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source, t.deleted +SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source, t.deleted_at FROM "public"."envs" t WHERE t.id = $1 AND t.source IN ('template', 'snapshot_template') @@ -36,14 +36,14 @@ func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { &i.CreatedBy, &i.ClusterID, &i.Source, - &i.Deleted, + &i.DeletedAt, ) return i, err } const getTemplateByIDWithAliases = `-- name: GetTemplateByIDWithAliases :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, al.aliases, al.names -FROM "public"."envs" e +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, al.aliases, al.names +FROM "public"."active_envs" e CROSS JOIN LATERAL ( SELECT COALESCE(array_agg(alias), '{}')::text[] AS aliases, @@ -53,7 +53,6 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = $1 AND e.source IN ('template', 'snapshot_template') - AND e.deleted = false ` type GetTemplateByIDWithAliasesRow struct { @@ -68,7 +67,7 @@ type GetTemplateByIDWithAliasesRow struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string - Deleted bool + DeletedAt *time.Time Aliases []string Names []string } @@ -88,7 +87,7 @@ func (q *Queries) GetTemplateByIDWithAliases(ctx context.Context, id string) (Ge &i.CreatedBy, &i.ClusterID, &i.Source, - &i.Deleted, + &i.DeletedAt, &i.Aliases, &i.Names, ) diff --git a/packages/db/queries/get_template_with_build_by_tag.sql.go b/packages/db/queries/get_template_with_build_by_tag.sql.go index 381530a0d9..430e92b5d8 100644 --- a/packages/db/queries/get_template_with_build_by_tag.sql.go +++ b/packages/db/queries/get_template_with_build_by_tag.sql.go @@ -10,7 +10,7 @@ import ( ) const getTemplateWithBuildByTag = `-- name: GetTemplateWithBuildByTag :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names FROM public.envs AS e JOIN public.env_build_assignments AS eba ON eba.env_id = e.id AND ( @@ -31,7 +31,7 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = $2 AND e.source IN ('template', 'snapshot_template') - AND e.deleted = false + AND e.deleted_at IS NULL ORDER BY eba.created_at DESC LIMIT 1 ` @@ -66,7 +66,7 @@ func (q *Queries) GetTemplateWithBuildByTag(ctx context.Context, arg GetTemplate &i.Env.CreatedBy, &i.Env.ClusterID, &i.Env.Source, - &i.Env.Deleted, + &i.Env.DeletedAt, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/list_team_snapshot_templates.sql.go b/packages/db/queries/list_team_snapshot_templates.sql.go index 7eebf5efd1..b3e9936238 100644 --- a/packages/db/queries/list_team_snapshot_templates.sql.go +++ b/packages/db/queries/list_team_snapshot_templates.sql.go @@ -28,7 +28,7 @@ SELECT eb.cluster_node_id, eb.tag, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM "public"."envs" e +FROM "public"."active_envs" e JOIN "public"."snapshot_templates" st ON st.env_id = e.id JOIN LATERAL ( SELECT b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id, ba.tag @@ -46,7 +46,6 @@ LEFT JOIN LATERAL ( ) ea ON TRUE WHERE e.team_id = $1 AND e.source = 'snapshot_template' -AND e.deleted = false AND ( $2::text IS NULL OR st.sandbox_id = $2::text diff --git a/packages/db/queries/models.go b/packages/db/queries/models.go index 3b0fb9c9ff..6f57c9c8cd 100644 --- a/packages/db/queries/models.go +++ b/packages/db/queries/models.go @@ -36,7 +36,7 @@ type Env struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string - Deleted bool + DeletedAt *time.Time } type EnvAlias struct { diff --git a/packages/db/queries/snapshots/create_new_snapshot.sql b/packages/db/queries/snapshots/create_new_snapshot.sql index a32afca8b7..68dbed9ea5 100644 --- a/packages/db/queries/snapshots/create_new_snapshot.sql +++ b/packages/db/queries/snapshots/create_new_snapshot.sql @@ -50,14 +50,6 @@ snapshot as ( RETURNING env_id as template_id ), --- Reusing an existing snapshot env (ON CONFLICT) may hit one that was --- soft-deleted by a prior delete; re-pausing makes it live again. -reactivate as ( - UPDATE "public"."envs" - SET deleted = false - WHERE id = (SELECT template_id FROM snapshot) AND deleted = true -), - -- Create a new build for the snapshot, copying CPU info from the source build so -- a pause keeps the snapshot's CPU compatibility pinned to the original build -- instead of the node the pause happened to run on. Scalar subqueries are used so diff --git a/packages/db/queries/snapshots/get_last_snapshot.sql b/packages/db/queries/snapshots/get_last_snapshot.sql index 05a760c101..603fc6d8a6 100644 --- a/packages/db/queries/snapshots/get_last_snapshot.sql +++ b/packages/db/queries/snapshots/get_last_snapshot.sql @@ -1,7 +1,7 @@ -- name: GetLastSnapshot :one SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, sqlc.embed(s), sqlc.embed(eb) FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false +JOIN "public"."active_envs" e ON e.id = s.env_id JOIN LATERAL ( SELECT eba.build_id FROM "public"."env_build_assignments" eba diff --git a/packages/db/queries/snapshots/get_snapshot_builds.sql b/packages/db/queries/snapshots/get_snapshot_builds.sql index def316f20f..41fa6ba8e6 100644 --- a/packages/db/queries/snapshots/get_snapshot_builds.sql +++ b/packages/db/queries/snapshots/get_snapshot_builds.sql @@ -1,6 +1,6 @@ -- name: GetSnapshotBuilds :many SELECT s.env_id as template_id, eb.id as build_id, eb.cluster_node_id as build_cluster_node_id FROM "public"."snapshots" s -JOIN "public"."envs" e ON e.id = s.env_id AND e.deleted = false +JOIN "public"."active_envs" e ON e.id = s.env_id LEFT JOIN "public"."env_build_assignments" eba ON eba.env_id = s.env_id AND eba.tag = 'default' LEFT JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE s.sandbox_id = @sandbox_id diff --git a/packages/db/queries/snapshots/list_team_snapshot_templates.sql b/packages/db/queries/snapshots/list_team_snapshot_templates.sql index f8cdcd9274..b985886b25 100644 --- a/packages/db/queries/snapshots/list_team_snapshot_templates.sql +++ b/packages/db/queries/snapshots/list_team_snapshot_templates.sql @@ -16,7 +16,7 @@ SELECT eb.cluster_node_id, eb.tag, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM "public"."envs" e +FROM "public"."active_envs" e JOIN "public"."snapshot_templates" st ON st.env_id = e.id JOIN LATERAL ( SELECT b.*, ba.tag @@ -34,7 +34,6 @@ LEFT JOIN LATERAL ( ) ea ON TRUE WHERE e.team_id = @team_id AND e.source = 'snapshot_template' -AND e.deleted = false AND ( sqlc.narg(sandbox_id)::text IS NULL OR st.sandbox_id = sqlc.narg(sandbox_id)::text diff --git a/packages/db/queries/template_aliases/check_alias_exists.sql b/packages/db/queries/template_aliases/check_alias_exists.sql index f4d0546537..8e7dd3dacb 100644 --- a/packages/db/queries/template_aliases/check_alias_exists.sql +++ b/packages/db/queries/template_aliases/check_alias_exists.sql @@ -1,9 +1,8 @@ -- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( SELECT 1 - FROM "public"."envs" + FROM "public"."active_envs" WHERE id = @alias - AND deleted = false ); -- name: CheckAliasExistsInNamespace :one diff --git a/packages/db/queries/templates/create_template.sql b/packages/db/queries/templates/create_template.sql index 8a669b881b..3d46c9e5ea 100644 --- a/packages/db/queries/templates/create_template.sql +++ b/packages/db/queries/templates/create_template.sql @@ -3,7 +3,7 @@ INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster VALUES (@template_id, @team_id, @created_by, NOW(), FALSE, @cluster_id, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), - deleted = false, + deleted_at = NULL, build_count = envs.build_count + 1; -- name: InvalidateUnstartedTemplateBuilds :exec diff --git a/packages/db/queries/templates/delete_template.sql b/packages/db/queries/templates/delete_template.sql index 75ebc0b6f4..5366837d6d 100644 --- a/packages/db/queries/templates/delete_template.sql +++ b/packages/db/queries/templates/delete_template.sql @@ -10,10 +10,10 @@ WITH alias_keys AS ( WHERE ea.env_id = @template_id ), updated AS ( UPDATE public.envs e - SET deleted = true, updated_at = NOW() + SET deleted_at = NOW(), updated_at = NOW() WHERE e.id = @template_id AND e.team_id = @team_id - AND e.deleted = false + AND e.deleted_at IS NULL RETURNING e.id ), released AS ( DELETE FROM public.env_aliases ea diff --git a/packages/db/queries/templates/exists_template_snapshots.sql b/packages/db/queries/templates/exists_template_snapshots.sql index 3b3d667565..9d8792ac0e 100644 --- a/packages/db/queries/templates/exists_template_snapshots.sql +++ b/packages/db/queries/templates/exists_template_snapshots.sql @@ -2,7 +2,6 @@ SELECT EXISTS( SELECT 1 FROM "public"."snapshots" s - JOIN "public"."envs" e ON e.id = s.env_id + JOIN "public"."active_envs" e ON e.id = s.env_id WHERE s.base_env_id = @env_id - AND e.deleted = false ); \ No newline at end of file diff --git a/packages/db/queries/templates/get_template.sql b/packages/db/queries/templates/get_template.sql index 827910757f..9dd5eca0fc 100644 --- a/packages/db/queries/templates/get_template.sql +++ b/packages/db/queries/templates/get_template.sql @@ -4,17 +4,15 @@ -- @namespace: the namespace to scope the lookup (NULL for promoted templates) SELECT e.id, e.team_id, e.public FROM public.env_aliases AS ea -JOIN public.envs AS e ON e.id = ea.env_id +JOIN public.active_envs AS e ON e.id = ea.env_id WHERE ea.alias = @alias AND ea.namespace IS NOT DISTINCT FROM sqlc.narg(namespace)::text - AND e.source IN ('template', 'snapshot_template') - AND e.deleted = false; + AND e.source IN ('template', 'snapshot_template'); -- name: GetTemplateById :one -- Looks up a template by its ID directly -- @template_id: the template ID to look up SELECT e.id, e.team_id, e.public, e.cluster_id -FROM public.envs AS e +FROM public.active_envs AS e WHERE e.id = @template_id - AND e.source IN ('template', 'snapshot_template') - AND e.deleted = false; + AND e.source IN ('template', 'snapshot_template'); diff --git a/packages/db/queries/templates/get_template_by_id.sql b/packages/db/queries/templates/get_template_by_id.sql index b31615de4d..659700d915 100644 --- a/packages/db/queries/templates/get_template_by_id.sql +++ b/packages/db/queries/templates/get_template_by_id.sql @@ -8,7 +8,7 @@ WHERE t.id = $1 -- name: GetTemplateByIDWithAliases :one SELECT e.*, al.aliases, al.names -FROM "public"."envs" e +FROM "public"."active_envs" e CROSS JOIN LATERAL ( SELECT COALESCE(array_agg(alias), '{}')::text[] AS aliases, @@ -17,5 +17,4 @@ CROSS JOIN LATERAL ( WHERE env_id = e.id ) AS al WHERE e.id = $1 - AND e.source IN ('template', 'snapshot_template') - AND e.deleted = false; + AND e.source IN ('template', 'snapshot_template'); diff --git a/packages/db/queries/templates/get_template_with_build_by_tag.sql b/packages/db/queries/templates/get_template_with_build_by_tag.sql index 666db0c2a1..a38c52ab80 100644 --- a/packages/db/queries/templates/get_template_with_build_by_tag.sql +++ b/packages/db/queries/templates/get_template_with_build_by_tag.sql @@ -23,6 +23,6 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = @template_id AND e.source IN ('template', 'snapshot_template') - AND e.deleted = false + AND e.deleted_at IS NULL ORDER BY eba.created_at DESC LIMIT 1; diff --git a/packages/db/queries/templates/update_template.sql b/packages/db/queries/templates/update_template.sql index 10980305e6..077e686edd 100644 --- a/packages/db/queries/templates/update_template.sql +++ b/packages/db/queries/templates/update_template.sql @@ -2,11 +2,10 @@ UPDATE "public"."envs" e SET public = @public WHERE id IN ( - SELECT e.id FROM "public"."envs" e + SELECT e.id FROM "public"."active_envs" e LEFT JOIN "public"."env_aliases" ea ON ea.env_id = e.id WHERE e.team_id = @team_id AND e.source = 'template' - AND e.deleted = false AND (e.id = @template_id_or_alias OR ea.alias = @template_id_or_alias) ) RETURNING e.id; diff --git a/packages/db/queries/update_template.sql.go b/packages/db/queries/update_template.sql.go index 26c6a26ed2..7d5d85de73 100644 --- a/packages/db/queries/update_template.sql.go +++ b/packages/db/queries/update_template.sql.go @@ -15,11 +15,10 @@ const updateTemplate = `-- name: UpdateTemplate :one UPDATE "public"."envs" e SET public = $1 WHERE id IN ( - SELECT e.id FROM "public"."envs" e + SELECT e.id FROM "public"."active_envs" e LEFT JOIN "public"."env_aliases" ea ON ea.env_id = e.id WHERE e.team_id = $2 AND e.source = 'template' - AND e.deleted = false AND (e.id = $3 OR ea.alias = $3) ) RETURNING e.id diff --git a/packages/db/queries/validate_build.sql.go b/packages/db/queries/validate_build.sql.go index 5c99fd86b6..56b403c3ef 100644 --- a/packages/db/queries/validate_build.sql.go +++ b/packages/db/queries/validate_build.sql.go @@ -12,14 +12,13 @@ import ( const existsWaitingTemplateBuild = `-- name: ExistsWaitingTemplateBuild :one SELECT EXISTS ( SELECT 1 - FROM envs e + FROM active_envs e JOIN users_teams ut ON ut.team_id = e.team_id JOIN access_tokens at ON at.user_id = ut.user_id JOIN env_build_assignments eba ON eba.env_id = e.id JOIN env_builds eb ON eb.id = eba.build_id WHERE at.access_token_hash = $1 AND e.id = $2 - AND e.deleted = false AND eb.status_group = 'pending' ) AS valid ` From c0720be414f387533aa67f2cdcc53abdf0c8c078 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:41:32 -0700 Subject: [PATCH 16/31] refactor(db): resolve layer upload via active_envs view; trim view columns Layer-file upload now resolves the template through the active_envs-backed GetTemplateById, so a soft-deleted template is simply not found (404) instead of needing an explicit DeletedAt check. Drop deleted_at from the active_envs view since it is always NULL there. --- .../handlers/template_layer_files_upload.go | 13 +++---------- .../20260628120000_add_env_deleted_at.sql | 3 +-- packages/db/pkg/testutils/queries/models.go | 1 - packages/db/queries/get_template_by_id.sql.go | 4 +--- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index 4e8f8666ea..f0ae19afd0 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -26,8 +26,9 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap return } - // Check if the user has access to the template - templateDB, err := a.sqlcDB.GetTemplateByID(ctx, templateID) + // Resolve via the active-envs view so a soft-deleted template is simply not + // found (rebuild uses the unfiltered GetTemplateByID instead). + templateDB, err := a.sqlcDB.GetTemplateById(ctx, templateID) if err != nil { a.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("Error when getting template: %s", err)) telemetry.ReportCriticalError(ctx, "error when getting env", err, telemetry.WithTemplateID(templateID)) @@ -35,14 +36,6 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap return } - // GetTemplateByID intentionally returns soft-deleted envs (for rebuild - // reactivation); layer uploads must not be served for a deleted template. - if templateDB.DeletedAt != nil { - a.sendAPIStoreError(c, http.StatusNotFound, "Template not found") - - return - } - dbTeamID := templateDB.TeamID.String() team, apiErr := a.GetTeam(ctx, c, &dbTeamID) if apiErr != nil { diff --git a/packages/db/migrations/20260628120000_add_env_deleted_at.sql b/packages/db/migrations/20260628120000_add_env_deleted_at.sql index 8b390276e2..53c6dff36d 100644 --- a/packages/db/migrations/20260628120000_add_env_deleted_at.sql +++ b/packages/db/migrations/20260628120000_add_env_deleted_at.sql @@ -16,8 +16,7 @@ SELECT team_id, created_by, cluster_id, - source, - deleted_at + source FROM "public"."envs" WHERE deleted_at IS NULL; diff --git a/packages/db/pkg/testutils/queries/models.go b/packages/db/pkg/testutils/queries/models.go index 8de9cf8b44..0aa9f4d063 100644 --- a/packages/db/pkg/testutils/queries/models.go +++ b/packages/db/pkg/testutils/queries/models.go @@ -36,7 +36,6 @@ type ActiveEnv struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string - DeletedAt *time.Time } type ActiveTemplateBuild struct { diff --git a/packages/db/queries/get_template_by_id.sql.go b/packages/db/queries/get_template_by_id.sql.go index 5270bee512..18f1a5b860 100644 --- a/packages/db/queries/get_template_by_id.sql.go +++ b/packages/db/queries/get_template_by_id.sql.go @@ -42,7 +42,7 @@ func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { } const getTemplateByIDWithAliases = `-- name: GetTemplateByIDWithAliases :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, al.aliases, al.names +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, al.aliases, al.names FROM "public"."active_envs" e CROSS JOIN LATERAL ( SELECT @@ -67,7 +67,6 @@ type GetTemplateByIDWithAliasesRow struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string - DeletedAt *time.Time Aliases []string Names []string } @@ -87,7 +86,6 @@ func (q *Queries) GetTemplateByIDWithAliases(ctx context.Context, id string) (Ge &i.CreatedBy, &i.ClusterID, &i.Source, - &i.DeletedAt, &i.Aliases, &i.Names, ) From 69ee75806e37c426ff2d4192c125f027092379f1 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:45:37 -0700 Subject: [PATCH 17/31] docs(db): note active_envs join filters soft-deleted envs in build listings --- packages/db/queries/builds/get_build_info.sql | 1 + packages/db/queries/builds/get_builds_paginated.sql | 4 ++++ packages/db/queries/get_build_info.sql.go | 1 + packages/db/queries/get_builds_paginated.sql.go | 4 ++++ 4 files changed, 10 insertions(+) diff --git a/packages/db/queries/builds/get_build_info.sql b/packages/db/queries/builds/get_build_info.sql index 34d01eeed9..faf2d0d2a7 100644 --- a/packages/db/queries/builds/get_build_info.sql +++ b/packages/db/queries/builds/get_build_info.sql @@ -9,6 +9,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = sqlc.arg(team_id)::uuid diff --git a/packages/db/queries/builds/get_builds_paginated.sql b/packages/db/queries/builds/get_builds_paginated.sql index 5b4c2b1e47..dfbe5bb229 100644 --- a/packages/db/queries/builds/get_builds_paginated.sql +++ b/packages/db/queries/builds/get_builds_paginated.sql @@ -15,6 +15,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC @@ -53,6 +54,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC @@ -92,6 +94,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND a.env_id = sqlc.arg(template_id)::text @@ -131,6 +134,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND EXISTS ( diff --git a/packages/db/queries/get_build_info.sql.go b/packages/db/queries/get_build_info.sql.go index 647a2aa6ba..c15efcc515 100644 --- a/packages/db/queries/get_build_info.sql.go +++ b/packages/db/queries/get_build_info.sql.go @@ -24,6 +24,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = $1::uuid diff --git a/packages/db/queries/get_builds_paginated.sql.go b/packages/db/queries/get_builds_paginated.sql.go index 083e22d7f9..00aab3370f 100644 --- a/packages/db/queries/get_builds_paginated.sql.go +++ b/packages/db/queries/get_builds_paginated.sql.go @@ -30,6 +30,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC @@ -129,6 +130,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC @@ -231,6 +233,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND EXISTS ( @@ -338,6 +341,7 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND a.env_id = $1::text From c6b9858523d32c7c58e123c433a6413d9db54617 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:49:20 -0700 Subject: [PATCH 18/31] refactor(db): route all template reads through active_envs view Move the remaining sqlc.embed(e) reads onto the active_envs view too, so the soft-delete filter lives only in the view and the write paths. The embedded field is now ActiveEnv (consumers updated). GetTemplateByID stays on envs unfiltered so rebuild can reactivate a soft-deleted template. --- .../api/internal/cache/templates/cache.go | 2 +- .../cache/templates/template_build.go | 6 ++-- .../deprecated_template_start_build.go | 2 +- .../handlers/template_start_build_v2.go | 4 +-- .../api/internal/handlers/template_tags.go | 2 +- .../api/internal/handlers/templates_list.go | 14 ++++---- .../template-manager/template_manager.go | 2 +- .../internal/handlers/template_get.go | 14 ++++---- .../queries/builds/get_inprogress_builds.sql | 3 +- .../db/queries/builds/get_template_build.sql | 5 ++- .../db/queries/get_inprogress_builds.sql.go | 30 ++++++++-------- packages/db/queries/get_team_template.sql | 5 ++- packages/db/queries/get_team_template.sql.go | 30 ++++++++-------- packages/db/queries/get_team_templates.sql | 3 +- packages/db/queries/get_team_templates.sql.go | 30 ++++++++-------- packages/db/queries/get_template_build.sql.go | 32 ++++++++--------- .../get_template_with_build_by_tag.sql.go | 36 +++++++++---------- packages/db/queries/models.go | 14 ++++++++ .../get_template_with_build_by_tag.sql | 3 +- 19 files changed, 118 insertions(+), 119 deletions(-) diff --git a/packages/api/internal/cache/templates/cache.go b/packages/api/internal/cache/templates/cache.go index b3f8a87df4..beb48e94ee 100644 --- a/packages/api/internal/cache/templates/cache.go +++ b/packages/api/internal/cache/templates/cache.go @@ -175,7 +175,7 @@ func (c *TemplateCache) fetchTemplateWithBuild(templateID string, tag *string) f } build := &result.EnvBuild - template := result.Env + template := result.ActiveEnv clusterID := clusters.WithClusterFallback(template.ClusterID) tagValue := sharedUtils.DerefOrDefault(tag, id.DefaultTag) diff --git a/packages/api/internal/cache/templates/template_build.go b/packages/api/internal/cache/templates/template_build.go index 31589aab75..c1c185fb93 100644 --- a/packages/api/internal/cache/templates/template_build.go +++ b/packages/api/internal/cache/templates/template_build.go @@ -84,12 +84,12 @@ func (c *TemplatesBuildCache) fetchFromDB(templateID string, buildID uuid.UUID) } return TemplateBuildInfo{ - TeamID: result.Env.TeamID, - TemplateID: result.Env.ID, + TeamID: result.ActiveEnv.TeamID, + TemplateID: result.ActiveEnv.ID, BuildStatus: result.EnvBuild.StatusGroup, Reason: result.EnvBuild.Reason, Version: result.EnvBuild.Version, - ClusterID: clusters.WithClusterFallback(result.Env.ClusterID), + ClusterID: clusters.WithClusterFallback(result.ActiveEnv.ClusterID), NodeID: result.EnvBuild.ClusterNodeID, }, nil } diff --git a/packages/api/internal/handlers/deprecated_template_start_build.go b/packages/api/internal/handlers/deprecated_template_start_build.go index 0b9cacef56..034445242e 100644 --- a/packages/api/internal/handlers/deprecated_template_start_build.go +++ b/packages/api/internal/handlers/deprecated_template_start_build.go @@ -120,7 +120,7 @@ func (a *APIStore) PostTemplatesTemplateIDBuildsBuildID(c *gin.Context, template var team *types.Team // Check if the user has access to the template for _, t := range teams { - if t.Team.ID == templateBuildDB.Env.TeamID { + if t.Team.ID == templateBuildDB.ActiveEnv.TeamID { team = t.Team break diff --git a/packages/api/internal/handlers/template_start_build_v2.go b/packages/api/internal/handlers/template_start_build_v2.go index b9b31095a3..f671ce6935 100644 --- a/packages/api/internal/handlers/template_start_build_v2.go +++ b/packages/api/internal/handlers/template_start_build_v2.go @@ -71,7 +71,7 @@ func (a *APIStore) PostV2TemplatesTemplateIDBuildsBuildID(c *gin.Context, templa return } - dbTeamID := templateBuildDB.Env.TeamID.String() + dbTeamID := templateBuildDB.ActiveEnv.TeamID.String() team, apiErr := a.GetTeam(ctx, c, &dbTeamID) if apiErr != nil { a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg) @@ -80,7 +80,7 @@ func (a *APIStore) PostV2TemplatesTemplateIDBuildsBuildID(c *gin.Context, templa return } - if team.ID != templateBuildDB.Env.TeamID { + if team.ID != templateBuildDB.ActiveEnv.TeamID { a.sendAPIStoreError(c, http.StatusForbidden, "User does not have access to the template") telemetry.ReportCriticalError(ctx, "user does not have access to the template", err, telemetry.WithTemplateID(templateID)) diff --git a/packages/api/internal/handlers/template_tags.go b/packages/api/internal/handlers/template_tags.go index 1e43e6bc7f..951583b7ec 100644 --- a/packages/api/internal/handlers/template_tags.go +++ b/packages/api/internal/handlers/template_tags.go @@ -103,7 +103,7 @@ func (a *APIStore) PostTemplatesTags(c *gin.Context) { return } - template := result.Env + template := result.ActiveEnv buildID := result.EnvBuild.ID telemetry.SetAttributes(ctx, diff --git a/packages/api/internal/handlers/templates_list.go b/packages/api/internal/handlers/templates_list.go index 0e35b136fc..e9ad345870 100644 --- a/packages/api/internal/handlers/templates_list.go +++ b/packages/api/internal/handlers/templates_list.go @@ -69,19 +69,19 @@ func (a *APIStore) GetTemplates(c *gin.Context, params api.GetTemplatesParams) { } templates = append(templates, &api.Template{ - TemplateID: item.Env.ID, + TemplateID: item.ActiveEnv.ID, BuildID: item.BuildID.String(), CpuCount: api.CPUCount(item.BuildVcpu), MemoryMB: api.MemoryMB(item.BuildRamMb), DiskSizeMB: api.DiskSizeMB(diskMB), - Public: item.Env.Public, + Public: item.ActiveEnv.Public, Aliases: item.Aliases, Names: item.Names, - CreatedAt: item.Env.CreatedAt, - UpdatedAt: item.Env.UpdatedAt, - LastSpawnedAt: item.Env.LastSpawnedAt, - SpawnCount: item.Env.SpawnCount, - BuildCount: item.Env.BuildCount, + CreatedAt: item.ActiveEnv.CreatedAt, + UpdatedAt: item.ActiveEnv.UpdatedAt, + LastSpawnedAt: item.ActiveEnv.LastSpawnedAt, + SpawnCount: item.ActiveEnv.SpawnCount, + BuildCount: item.ActiveEnv.BuildCount, BuildStatus: getCorrespondingTemplateBuildStatus(ctx, item.BuildStatus), CreatedBy: createdBy, EnvdVersion: envdVersion, diff --git a/packages/api/internal/template-manager/template_manager.go b/packages/api/internal/template-manager/template_manager.go index 3ad7b1434f..6a79b233b6 100644 --- a/packages/api/internal/template-manager/template_manager.go +++ b/packages/api/internal/template-manager/template_manager.go @@ -96,7 +96,7 @@ func (tm *TemplateManager) BuildsStatusPeriodicalSync(ctx context.Context) { logger.L().Info(ctx, "Running periodical sync of builds statuses", zap.Int("count", len(buildsRunning))) for _, b := range buildsRunning { go func(b queries.GetInProgressTemplateBuildsRow) { - err := tm.BuildStatusSync(ctx, b.EnvBuild.ID, b.Env.ID, clustersshared.WithClusterFallback(b.TeamClusterID), b.EnvBuild.ClusterNodeID) + err := tm.BuildStatusSync(ctx, b.EnvBuild.ID, b.ActiveEnv.ID, clustersshared.WithClusterFallback(b.TeamClusterID), b.EnvBuild.ClusterNodeID) if err != nil { logger.L().Error(ctx, "Error syncing build status", zap.Error(err), zap.String("buildID", b.EnvBuild.ID.String())) } diff --git a/packages/dashboard-api/internal/handlers/template_get.go b/packages/dashboard-api/internal/handlers/template_get.go index 794982d5bc..2b7ef28525 100644 --- a/packages/dashboard-api/internal/handlers/template_get.go +++ b/packages/dashboard-api/internal/handlers/template_get.go @@ -47,16 +47,16 @@ func (s *APIStore) GetTemplatesTemplateID(c *gin.Context, templateID api.Templat } resp := api.TemplateDetail{ - TemplateID: row.Env.ID, + TemplateID: row.ActiveEnv.ID, BuildID: row.BuildID.String(), - Public: row.Env.Public, + Public: row.ActiveEnv.Public, Aliases: row.Aliases, Names: row.Names, - CreatedAt: row.Env.CreatedAt, - UpdatedAt: row.Env.UpdatedAt, - LastSpawnedAt: row.Env.LastSpawnedAt, - SpawnCount: row.Env.SpawnCount, - BuildCount: row.Env.BuildCount, + CreatedAt: row.ActiveEnv.CreatedAt, + UpdatedAt: row.ActiveEnv.UpdatedAt, + LastSpawnedAt: row.ActiveEnv.LastSpawnedAt, + SpawnCount: row.ActiveEnv.SpawnCount, + BuildCount: row.ActiveEnv.BuildCount, } if row.BuildID != uuid.Nil { diff --git a/packages/db/queries/builds/get_inprogress_builds.sql b/packages/db/queries/builds/get_inprogress_builds.sql index 63786008e5..1ad99bf22f 100644 --- a/packages/db/queries/builds/get_inprogress_builds.sql +++ b/packages/db/queries/builds/get_inprogress_builds.sql @@ -2,11 +2,10 @@ SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, sqlc.embed(e), sqlc.embed(b) FROM public.env_builds b JOIN public.env_build_assignments eba ON eba.build_id = b.id -JOIN public.envs e ON e.id = eba.env_id +JOIN public.active_envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' - AND e.deleted_at IS NULL ORDER BY b.id, b.created_at DESC; -- name: GetInProgressTemplateBuildsByTeam :one diff --git a/packages/db/queries/builds/get_template_build.sql b/packages/db/queries/builds/get_template_build.sql index 688fe63895..02ca6a113f 100644 --- a/packages/db/queries/builds/get_template_build.sql +++ b/packages/db/queries/builds/get_template_build.sql @@ -1,8 +1,7 @@ -- name: GetTemplateBuildWithTemplate :one SELECT sqlc.embed(e), sqlc.embed(eb) -FROM "public"."envs" e +FROM "public"."active_envs" e JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = sqlc.arg(template_id) AND eb.id = sqlc.arg(build_id) - AND e.source = 'template' - AND e.deleted_at IS NULL; + AND e.source = 'template'; diff --git a/packages/db/queries/get_inprogress_builds.sql.go b/packages/db/queries/get_inprogress_builds.sql.go index 3aacfe5d64..e1d70ac760 100644 --- a/packages/db/queries/get_inprogress_builds.sql.go +++ b/packages/db/queries/get_inprogress_builds.sql.go @@ -55,20 +55,19 @@ func (q *Queries) GetCancellableTemplateBuildsByTeam(ctx context.Context, teamID } const getInProgressTemplateBuilds = `-- name: GetInProgressTemplateBuilds :many -SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id +SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id FROM public.env_builds b JOIN public.env_build_assignments eba ON eba.build_id = b.id -JOIN public.envs e ON e.id = eba.env_id +JOIN public.active_envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' - AND e.deleted_at IS NULL ORDER BY b.id, b.created_at DESC ` type GetInProgressTemplateBuildsRow struct { TeamClusterID *uuid.UUID - Env Env + ActiveEnv ActiveEnv EnvBuild EnvBuild } @@ -83,18 +82,17 @@ func (q *Queries) GetInProgressTemplateBuilds(ctx context.Context) ([]GetInProgr var i GetInProgressTemplateBuildsRow if err := rows.Scan( &i.TeamClusterID, - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, - &i.Env.DeletedAt, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_team_template.sql b/packages/db/queries/get_team_template.sql index 89c2ef12e3..86d904aca8 100644 --- a/packages/db/queries/get_team_template.sql +++ b/packages/db/queries/get_team_template.sql @@ -9,7 +9,7 @@ SELECT sqlc.embed(e), COALESCE(latest_build.status_group, 'pending') as build_status, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, @@ -36,5 +36,4 @@ LEFT JOIN LATERAL ( WHERE e.id = $1 AND e.team_id = $2 - AND e.source = 'template' - AND e.deleted_at IS NULL; + AND e.source = 'template'; diff --git a/packages/db/queries/get_team_template.sql.go b/packages/db/queries/get_team_template.sql.go index dd61d6a3ba..a05dd33761 100644 --- a/packages/db/queries/get_team_template.sql.go +++ b/packages/db/queries/get_team_template.sql.go @@ -13,7 +13,7 @@ import ( ) const getTeamTemplate = `-- name: GetTeamTemplate :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) as build_id, COALESCE(eb.vcpu, 0) as build_vcpu, COALESCE(eb.ram_mb, 0) as build_ram_mb, @@ -23,7 +23,7 @@ SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, COALESCE(latest_build.status_group, 'pending') as build_status, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, @@ -51,7 +51,6 @@ WHERE e.id = $1 AND e.team_id = $2 AND e.source = 'template' - AND e.deleted_at IS NULL ` type GetTeamTemplateParams struct { @@ -60,7 +59,7 @@ type GetTeamTemplateParams struct { } type GetTeamTemplateRow struct { - Env Env + ActiveEnv ActiveEnv BuildID uuid.UUID BuildVcpu int64 BuildRamMb int64 @@ -76,18 +75,17 @@ func (q *Queries) GetTeamTemplate(ctx context.Context, arg GetTeamTemplateParams row := q.db.QueryRow(ctx, getTeamTemplate, arg.ID, arg.TeamID) var i GetTeamTemplateRow err := row.Scan( - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, - &i.Env.DeletedAt, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates.sql b/packages/db/queries/get_team_templates.sql index d7a2717c4f..e80164e1a1 100644 --- a/packages/db/queries/get_team_templates.sql +++ b/packages/db/queries/get_team_templates.sql @@ -10,7 +10,7 @@ SELECT sqlc.embed(e), e.created_by as creator_id, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, @@ -37,5 +37,4 @@ LEFT JOIN LATERAL ( WHERE e.team_id = $1 AND e.source = 'template' - AND e.deleted_at IS NULL ORDER BY e.created_at ASC; diff --git a/packages/db/queries/get_team_templates.sql.go b/packages/db/queries/get_team_templates.sql.go index 489570a77d..c19d769d57 100644 --- a/packages/db/queries/get_team_templates.sql.go +++ b/packages/db/queries/get_team_templates.sql.go @@ -13,7 +13,7 @@ import ( ) const getTeamTemplates = `-- name: GetTeamTemplates :many -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) as build_id, COALESCE(eb.vcpu, 0) as build_vcpu, COALESCE(eb.ram_mb, 0) as build_ram_mb, @@ -24,7 +24,7 @@ SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.created_by as creator_id, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, @@ -51,12 +51,11 @@ LEFT JOIN LATERAL ( WHERE e.team_id = $1 AND e.source = 'template' - AND e.deleted_at IS NULL ORDER BY e.created_at ASC ` type GetTeamTemplatesRow struct { - Env Env + ActiveEnv ActiveEnv BuildID uuid.UUID BuildVcpu int64 BuildRamMb int64 @@ -79,18 +78,17 @@ func (q *Queries) GetTeamTemplates(ctx context.Context, teamID uuid.UUID) ([]Get for rows.Next() { var i GetTeamTemplatesRow if err := rows.Scan( - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, - &i.Env.DeletedAt, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_template_build.sql.go b/packages/db/queries/get_template_build.sql.go index 97f3f63aaa..9506c76323 100644 --- a/packages/db/queries/get_template_build.sql.go +++ b/packages/db/queries/get_template_build.sql.go @@ -12,13 +12,12 @@ import ( ) const getTemplateBuildWithTemplate = `-- name: GetTemplateBuildWithTemplate :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id -FROM "public"."envs" e +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id +FROM "public"."active_envs" e JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = $1 AND eb.id = $2 AND e.source = 'template' - AND e.deleted_at IS NULL ` type GetTemplateBuildWithTemplateParams struct { @@ -27,26 +26,25 @@ type GetTemplateBuildWithTemplateParams struct { } type GetTemplateBuildWithTemplateRow struct { - Env Env - EnvBuild EnvBuild + ActiveEnv ActiveEnv + EnvBuild EnvBuild } func (q *Queries) GetTemplateBuildWithTemplate(ctx context.Context, arg GetTemplateBuildWithTemplateParams) (GetTemplateBuildWithTemplateRow, error) { row := q.db.QueryRow(ctx, getTemplateBuildWithTemplate, arg.TemplateID, arg.BuildID) var i GetTemplateBuildWithTemplateRow err := row.Scan( - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, - &i.Env.DeletedAt, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_template_with_build_by_tag.sql.go b/packages/db/queries/get_template_with_build_by_tag.sql.go index 430e92b5d8..d75cf142e0 100644 --- a/packages/db/queries/get_template_with_build_by_tag.sql.go +++ b/packages/db/queries/get_template_with_build_by_tag.sql.go @@ -10,8 +10,8 @@ import ( ) const getTemplateWithBuildByTag = `-- name: GetTemplateWithBuildByTag :one -SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, e.deleted_at, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names -FROM public.envs AS e +SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names +FROM public.active_envs AS e JOIN public.env_build_assignments AS eba ON eba.env_id = e.id AND ( -- Match by tag @@ -31,7 +31,6 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = $2 AND e.source IN ('template', 'snapshot_template') - AND e.deleted_at IS NULL ORDER BY eba.created_at DESC LIMIT 1 ` @@ -42,10 +41,10 @@ type GetTemplateWithBuildByTagParams struct { } type GetTemplateWithBuildByTagRow struct { - Env Env - EnvBuild EnvBuild - Aliases []string - Names []string + ActiveEnv ActiveEnv + EnvBuild EnvBuild + Aliases []string + Names []string } // Fetches a template with its build by template ID and tag. @@ -55,18 +54,17 @@ func (q *Queries) GetTemplateWithBuildByTag(ctx context.Context, arg GetTemplate row := q.db.QueryRow(ctx, getTemplateWithBuildByTag, arg.Tag, arg.TemplateID) var i GetTemplateWithBuildByTagRow err := row.Scan( - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, - &i.Env.DeletedAt, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/models.go b/packages/db/queries/models.go index 6f57c9c8cd..ed5f40d513 100644 --- a/packages/db/queries/models.go +++ b/packages/db/queries/models.go @@ -12,6 +12,20 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type ActiveEnv struct { + ID string + CreatedAt time.Time + UpdatedAt time.Time + Public bool + BuildCount int32 + SpawnCount int64 + LastSpawnedAt *time.Time + TeamID uuid.UUID + CreatedBy *uuid.UUID + ClusterID *uuid.UUID + Source string +} + type Cluster struct { ID uuid.UUID Endpoint string diff --git a/packages/db/queries/templates/get_template_with_build_by_tag.sql b/packages/db/queries/templates/get_template_with_build_by_tag.sql index a38c52ab80..61457ace8f 100644 --- a/packages/db/queries/templates/get_template_with_build_by_tag.sql +++ b/packages/db/queries/templates/get_template_with_build_by_tag.sql @@ -3,7 +3,7 @@ -- @template_id: the template ID to look up -- @tag: defaults to 'default' if not provided SELECT sqlc.embed(e), sqlc.embed(eb), aliases, names -FROM public.envs AS e +FROM public.active_envs AS e JOIN public.env_build_assignments AS eba ON eba.env_id = e.id AND ( -- Match by tag @@ -23,6 +23,5 @@ CROSS JOIN LATERAL ( ) AS al WHERE e.id = @template_id AND e.source IN ('template', 'snapshot_template') - AND e.deleted_at IS NULL ORDER BY eba.created_at DESC LIMIT 1; From 015ba60c046a63139d39d38af4674b81b9a1e5e9 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:57:09 -0700 Subject: [PATCH 19/31] fix(db): scope rebuild reactivation to templates; keep deleted ids reserved CreateOrUpdateTemplate only clears deleted_at for source='template', so a template build registration can't resurrect a soft-deleted snapshot env. CheckAliasConflictsWithTemplateID checks envs (not active_envs) so a soft-deleted (reactivatable) env id still blocks an aliasing collision. --- packages/db/queries/check_alias_exists.sql.go | 4 +++- packages/db/queries/create_template.sql.go | 2 ++ packages/db/queries/template_aliases/check_alias_exists.sql | 4 +++- packages/db/queries/templates/create_template.sql | 4 +++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/db/queries/check_alias_exists.sql.go b/packages/db/queries/check_alias_exists.sql.go index 9ad60048f8..1ef8cc14fd 100644 --- a/packages/db/queries/check_alias_exists.sql.go +++ b/packages/db/queries/check_alias_exists.sql.go @@ -11,8 +11,10 @@ import ( const checkAliasConflictsWithTemplateID = `-- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( + -- envs, not active_envs: a soft-deleted env id stays reserved (it can be + -- reactivated on rebuild), so an alias must not shadow it. SELECT 1 - FROM "public"."active_envs" + FROM "public"."envs" WHERE id = $1 ) ` diff --git a/packages/db/queries/create_template.sql.go b/packages/db/queries/create_template.sql.go index 071a9dba05..4d21e6b6a5 100644 --- a/packages/db/queries/create_template.sql.go +++ b/packages/db/queries/create_template.sql.go @@ -19,6 +19,7 @@ ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), deleted_at = NULL, build_count = envs.build_count + 1 +WHERE envs.source = 'template' ` type CreateOrUpdateTemplateParams struct { @@ -28,6 +29,7 @@ type CreateOrUpdateTemplateParams struct { ClusterID *uuid.UUID } +// Only reactivate template envs; never resurrect a soft-deleted snapshot env. func (q *Queries) CreateOrUpdateTemplate(ctx context.Context, arg CreateOrUpdateTemplateParams) error { _, err := q.db.Exec(ctx, createOrUpdateTemplate, arg.TemplateID, diff --git a/packages/db/queries/template_aliases/check_alias_exists.sql b/packages/db/queries/template_aliases/check_alias_exists.sql index 8e7dd3dacb..177a93a82e 100644 --- a/packages/db/queries/template_aliases/check_alias_exists.sql +++ b/packages/db/queries/template_aliases/check_alias_exists.sql @@ -1,7 +1,9 @@ -- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( + -- envs, not active_envs: a soft-deleted env id stays reserved (it can be + -- reactivated on rebuild), so an alias must not shadow it. SELECT 1 - FROM "public"."active_envs" + FROM "public"."envs" WHERE id = @alias ); diff --git a/packages/db/queries/templates/create_template.sql b/packages/db/queries/templates/create_template.sql index 3d46c9e5ea..ac5fca2d06 100644 --- a/packages/db/queries/templates/create_template.sql +++ b/packages/db/queries/templates/create_template.sql @@ -4,7 +4,9 @@ VALUES (@template_id, @team_id, @created_by, NOW(), FALSE, @cluster_id, 'templat ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), deleted_at = NULL, - build_count = envs.build_count + 1; + build_count = envs.build_count + 1 +-- Only reactivate template envs; never resurrect a soft-deleted snapshot env. +WHERE envs.source = 'template'; -- name: InvalidateUnstartedTemplateBuilds :exec WITH invalidated AS ( From c753fedc82f97ddfc9ee15e0da4bf41098db8d05 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:03:06 -0700 Subject: [PATCH 20/31] fix(db): reactivate snapshot_template envs on rebuild too (exclude only snapshot) --- packages/db/queries/create_template.sql.go | 5 +++-- packages/db/queries/templates/create_template.sql | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/db/queries/create_template.sql.go b/packages/db/queries/create_template.sql.go index 4d21e6b6a5..53404d1294 100644 --- a/packages/db/queries/create_template.sql.go +++ b/packages/db/queries/create_template.sql.go @@ -19,7 +19,7 @@ ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), deleted_at = NULL, build_count = envs.build_count + 1 -WHERE envs.source = 'template' +WHERE envs.source IN ('template', 'snapshot_template') ` type CreateOrUpdateTemplateParams struct { @@ -29,7 +29,8 @@ type CreateOrUpdateTemplateParams struct { ClusterID *uuid.UUID } -// Only reactivate template envs; never resurrect a soft-deleted snapshot env. +// Reactivate only rebuildable envs (template / snapshot_template); never +// resurrect a paused-sandbox 'snapshot' env. func (q *Queries) CreateOrUpdateTemplate(ctx context.Context, arg CreateOrUpdateTemplateParams) error { _, err := q.db.Exec(ctx, createOrUpdateTemplate, arg.TemplateID, diff --git a/packages/db/queries/templates/create_template.sql b/packages/db/queries/templates/create_template.sql index ac5fca2d06..44c98f4d57 100644 --- a/packages/db/queries/templates/create_template.sql +++ b/packages/db/queries/templates/create_template.sql @@ -5,8 +5,9 @@ ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), deleted_at = NULL, build_count = envs.build_count + 1 --- Only reactivate template envs; never resurrect a soft-deleted snapshot env. -WHERE envs.source = 'template'; +-- Reactivate only rebuildable envs (template / snapshot_template); never +-- resurrect a paused-sandbox 'snapshot' env. +WHERE envs.source IN ('template', 'snapshot_template'); -- name: InvalidateUnstartedTemplateBuilds :exec WITH invalidated AS ( From 43901233be195fa898b5f658719355c1c6832fda Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:14:48 -0700 Subject: [PATCH 21/31] refactor(db): rename unfiltered lookup to GetTemplateByIDIncludingDeleted Disambiguates from the active-envs GetTemplateById (case-only difference was a footgun now that one includes soft-deleted envs for rebuild and the other doesn't). --- .../handlers/deprecated_template_request_build.go | 2 +- .../internal/handlers/template_layer_files_upload.go | 2 +- packages/db/queries/get_template_by_id.sql.go | 11 ++++++----- packages/db/queries/templates/get_template_by_id.sql | 7 ++++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/api/internal/handlers/deprecated_template_request_build.go b/packages/api/internal/handlers/deprecated_template_request_build.go index 844ed2cca7..78f3d0754b 100644 --- a/packages/api/internal/handlers/deprecated_template_request_build.go +++ b/packages/api/internal/handlers/deprecated_template_request_build.go @@ -107,7 +107,7 @@ func (a *APIStore) PostTemplatesTemplateID(c *gin.Context, rawTemplateID api.Tem return } - templateDB, err := a.sqlcDB.GetTemplateByID(ctx, templateID) + templateDB, err := a.sqlcDB.GetTemplateByIDIncludingDeleted(ctx, templateID) switch { case err == nil: if templateDB.TeamID != team.ID { diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index f0ae19afd0..12371bce28 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -27,7 +27,7 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap } // Resolve via the active-envs view so a soft-deleted template is simply not - // found (rebuild uses the unfiltered GetTemplateByID instead). + // found (rebuild uses GetTemplateByIDIncludingDeleted instead). templateDB, err := a.sqlcDB.GetTemplateById(ctx, templateID) if err != nil { a.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("Error when getting template: %s", err)) diff --git a/packages/db/queries/get_template_by_id.sql.go b/packages/db/queries/get_template_by_id.sql.go index 18f1a5b860..9160e9bc21 100644 --- a/packages/db/queries/get_template_by_id.sql.go +++ b/packages/db/queries/get_template_by_id.sql.go @@ -12,17 +12,18 @@ import ( "github.com/google/uuid" ) -const getTemplateByID = `-- name: GetTemplateByID :one +const getTemplateByIDIncludingDeleted = `-- name: GetTemplateByIDIncludingDeleted :one SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source, t.deleted_at FROM "public"."envs" t WHERE t.id = $1 AND t.source IN ('template', 'snapshot_template') ` -// No deleted filter: the build/rebuild path resolves by id here and must find a -// soft-deleted env so CreateOrUpdateTemplate can reactivate it. -func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { - row := q.db.QueryRow(ctx, getTemplateByID, id) +// No deleted filter (distinct from GetTemplateById): the rebuild path resolves +// by id here and must find a soft-deleted env so CreateOrUpdateTemplate can +// reactivate it. +func (q *Queries) GetTemplateByIDIncludingDeleted(ctx context.Context, id string) (Env, error) { + row := q.db.QueryRow(ctx, getTemplateByIDIncludingDeleted, id) var i Env err := row.Scan( &i.ID, diff --git a/packages/db/queries/templates/get_template_by_id.sql b/packages/db/queries/templates/get_template_by_id.sql index 659700d915..f2d4bffaf6 100644 --- a/packages/db/queries/templates/get_template_by_id.sql +++ b/packages/db/queries/templates/get_template_by_id.sql @@ -1,6 +1,7 @@ --- name: GetTemplateByID :one --- No deleted filter: the build/rebuild path resolves by id here and must find a --- soft-deleted env so CreateOrUpdateTemplate can reactivate it. +-- name: GetTemplateByIDIncludingDeleted :one +-- No deleted filter (distinct from GetTemplateById): the rebuild path resolves +-- by id here and must find a soft-deleted env so CreateOrUpdateTemplate can +-- reactivate it. SELECT t.* FROM "public"."envs" t WHERE t.id = $1 From 7211a3554febfb3471c9f2ab87e6aaab8041d963 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:20:18 -0700 Subject: [PATCH 22/31] fix(db): make soft-delete permanent; rebuilding a deleted template fails Drop the rebuild reactivation: CreateOrUpdateTemplate no longer clears deleted_at and skips the update for a soft-deleted env (returns no row), so RegisterBuild fails with 404 instead of resurrecting it. Removed the unfiltered GetTemplateByIDIncludingDeleted lookup; all resolves go through the active_envs view. Alias/name reuse is unaffected. --- .../deprecated_template_request_build.go | 2 +- .../handlers/template_layer_files_upload.go | 3 +- .../api/internal/template/register_build.go | 13 ++++++-- packages/db/queries/create_template.sql.go | 18 ++++++----- packages/db/queries/get_template_by_id.sql.go | 30 ------------------- packages/db/queries/models.go | 17 ----------- .../db/queries/templates/create_template.sql | 10 +++---- .../queries/templates/get_template_by_id.sql | 9 ------ 8 files changed, 28 insertions(+), 74 deletions(-) diff --git a/packages/api/internal/handlers/deprecated_template_request_build.go b/packages/api/internal/handlers/deprecated_template_request_build.go index 78f3d0754b..bcd6bb69cc 100644 --- a/packages/api/internal/handlers/deprecated_template_request_build.go +++ b/packages/api/internal/handlers/deprecated_template_request_build.go @@ -107,7 +107,7 @@ func (a *APIStore) PostTemplatesTemplateID(c *gin.Context, rawTemplateID api.Tem return } - templateDB, err := a.sqlcDB.GetTemplateByIDIncludingDeleted(ctx, templateID) + templateDB, err := a.sqlcDB.GetTemplateById(ctx, templateID) switch { case err == nil: if templateDB.TeamID != team.ID { diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index 12371bce28..a583cafd11 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -26,8 +26,7 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap return } - // Resolve via the active-envs view so a soft-deleted template is simply not - // found (rebuild uses GetTemplateByIDIncludingDeleted instead). + // Resolve via the active-envs view so a soft-deleted template is not found. templateDB, err := a.sqlcDB.GetTemplateById(ctx, templateID) if err != nil { a.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("Error when getting template: %s", err)) diff --git a/packages/api/internal/template/register_build.go b/packages/api/internal/template/register_build.go index 1b5da21930..3b0ae8728c 100644 --- a/packages/api/internal/template/register_build.go +++ b/packages/api/internal/template/register_build.go @@ -165,14 +165,23 @@ func RegisterBuild( clusterID = &data.ClusterID } - // Create the template / or update the build count - err = client.CreateOrUpdateTemplate(ctx, queries.CreateOrUpdateTemplateParams{ + // Create the template / or update the build count. A soft-deleted template + // is not reactivated: the query returns no row, so the rebuild fails. + _, err = client.CreateOrUpdateTemplate(ctx, queries.CreateOrUpdateTemplateParams{ TemplateID: data.TemplateID, TeamID: data.Team.ID, CreatedBy: data.UserID, ClusterID: clusterID, }) if err != nil { + if dberrors.IsNotFoundError(err) { + return nil, &api.APIError{ + Err: err, + ClientMsg: fmt.Sprintf("Template '%s' has been deleted and cannot be rebuilt", data.TemplateID), + Code: http.StatusNotFound, + } + } + telemetry.ReportCriticalError(ctx, "error when updating env", err) return nil, &api.APIError{ diff --git a/packages/db/queries/create_template.sql.go b/packages/db/queries/create_template.sql.go index 53404d1294..45d624e9df 100644 --- a/packages/db/queries/create_template.sql.go +++ b/packages/db/queries/create_template.sql.go @@ -12,14 +12,14 @@ import ( "github.com/google/uuid" ) -const createOrUpdateTemplate = `-- name: CreateOrUpdateTemplate :exec +const createOrUpdateTemplate = `-- name: CreateOrUpdateTemplate :one INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster_id, source) VALUES ($1, $2, $3, NOW(), FALSE, $4, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), - deleted_at = NULL, build_count = envs.build_count + 1 -WHERE envs.source IN ('template', 'snapshot_template') +WHERE envs.deleted_at IS NULL +RETURNING id ` type CreateOrUpdateTemplateParams struct { @@ -29,16 +29,18 @@ type CreateOrUpdateTemplateParams struct { ClusterID *uuid.UUID } -// Reactivate only rebuildable envs (template / snapshot_template); never -// resurrect a paused-sandbox 'snapshot' env. -func (q *Queries) CreateOrUpdateTemplate(ctx context.Context, arg CreateOrUpdateTemplateParams) error { - _, err := q.db.Exec(ctx, createOrUpdateTemplate, +// Soft-delete is permanent: skip the update for a deleted env so no row is +// returned and the build registration fails instead of resurrecting it. +func (q *Queries) CreateOrUpdateTemplate(ctx context.Context, arg CreateOrUpdateTemplateParams) (string, error) { + row := q.db.QueryRow(ctx, createOrUpdateTemplate, arg.TemplateID, arg.TeamID, arg.CreatedBy, arg.ClusterID, ) - return err + var id string + err := row.Scan(&id) + return id, err } const createTemplateBuild = `-- name: CreateTemplateBuild :exec diff --git a/packages/db/queries/get_template_by_id.sql.go b/packages/db/queries/get_template_by_id.sql.go index 9160e9bc21..037c05444f 100644 --- a/packages/db/queries/get_template_by_id.sql.go +++ b/packages/db/queries/get_template_by_id.sql.go @@ -12,36 +12,6 @@ import ( "github.com/google/uuid" ) -const getTemplateByIDIncludingDeleted = `-- name: GetTemplateByIDIncludingDeleted :one -SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source, t.deleted_at -FROM "public"."envs" t -WHERE t.id = $1 - AND t.source IN ('template', 'snapshot_template') -` - -// No deleted filter (distinct from GetTemplateById): the rebuild path resolves -// by id here and must find a soft-deleted env so CreateOrUpdateTemplate can -// reactivate it. -func (q *Queries) GetTemplateByIDIncludingDeleted(ctx context.Context, id string) (Env, error) { - row := q.db.QueryRow(ctx, getTemplateByIDIncludingDeleted, id) - var i Env - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.Public, - &i.BuildCount, - &i.SpawnCount, - &i.LastSpawnedAt, - &i.TeamID, - &i.CreatedBy, - &i.ClusterID, - &i.Source, - &i.DeletedAt, - ) - return i, err -} - const getTemplateByIDWithAliases = `-- name: GetTemplateByIDWithAliases :one SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, al.aliases, al.names FROM "public"."active_envs" e diff --git a/packages/db/queries/models.go b/packages/db/queries/models.go index ed5f40d513..105acf169d 100644 --- a/packages/db/queries/models.go +++ b/packages/db/queries/models.go @@ -36,23 +36,6 @@ type Cluster struct { Name string } -type Env struct { - ID string - CreatedAt time.Time - UpdatedAt time.Time - Public bool - BuildCount int32 - // Number of times the env was spawned - SpawnCount int64 - // Timestamp of the last time the env was spawned - LastSpawnedAt *time.Time - TeamID uuid.UUID - CreatedBy *uuid.UUID - ClusterID *uuid.UUID - Source string - DeletedAt *time.Time -} - type EnvAlias struct { Alias string IsRenamable bool diff --git a/packages/db/queries/templates/create_template.sql b/packages/db/queries/templates/create_template.sql index 44c98f4d57..6cf6e47ee6 100644 --- a/packages/db/queries/templates/create_template.sql +++ b/packages/db/queries/templates/create_template.sql @@ -1,13 +1,13 @@ --- name: CreateOrUpdateTemplate :exec +-- name: CreateOrUpdateTemplate :one INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster_id, source) VALUES (@template_id, @team_id, @created_by, NOW(), FALSE, @cluster_id, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), - deleted_at = NULL, build_count = envs.build_count + 1 --- Reactivate only rebuildable envs (template / snapshot_template); never --- resurrect a paused-sandbox 'snapshot' env. -WHERE envs.source IN ('template', 'snapshot_template'); +-- Soft-delete is permanent: skip the update for a deleted env so no row is +-- returned and the build registration fails instead of resurrecting it. +WHERE envs.deleted_at IS NULL +RETURNING id; -- name: InvalidateUnstartedTemplateBuilds :exec WITH invalidated AS ( diff --git a/packages/db/queries/templates/get_template_by_id.sql b/packages/db/queries/templates/get_template_by_id.sql index f2d4bffaf6..954125df9c 100644 --- a/packages/db/queries/templates/get_template_by_id.sql +++ b/packages/db/queries/templates/get_template_by_id.sql @@ -1,12 +1,3 @@ --- name: GetTemplateByIDIncludingDeleted :one --- No deleted filter (distinct from GetTemplateById): the rebuild path resolves --- by id here and must find a soft-deleted env so CreateOrUpdateTemplate can --- reactivate it. -SELECT t.* -FROM "public"."envs" t -WHERE t.id = $1 - AND t.source IN ('template', 'snapshot_template'); - -- name: GetTemplateByIDWithAliases :one SELECT e.*, al.aliases, al.names FROM "public"."active_envs" e From e9440cc2d13e1f30d56fa2ee71e5fed9e840f723 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:21:22 -0700 Subject: [PATCH 23/31] refactor(db): route alias-id conflict check through active_envs With soft-delete now permanent (no reactivation), a deleted env id is dead and unresolvable, so the alias/template-id conflict check goes through the view like every other read. --- packages/db/queries/check_alias_exists.sql.go | 4 +--- packages/db/queries/template_aliases/check_alias_exists.sql | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/db/queries/check_alias_exists.sql.go b/packages/db/queries/check_alias_exists.sql.go index 1ef8cc14fd..9ad60048f8 100644 --- a/packages/db/queries/check_alias_exists.sql.go +++ b/packages/db/queries/check_alias_exists.sql.go @@ -11,10 +11,8 @@ import ( const checkAliasConflictsWithTemplateID = `-- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( - -- envs, not active_envs: a soft-deleted env id stays reserved (it can be - -- reactivated on rebuild), so an alias must not shadow it. SELECT 1 - FROM "public"."envs" + FROM "public"."active_envs" WHERE id = $1 ) ` diff --git a/packages/db/queries/template_aliases/check_alias_exists.sql b/packages/db/queries/template_aliases/check_alias_exists.sql index 177a93a82e..8e7dd3dacb 100644 --- a/packages/db/queries/template_aliases/check_alias_exists.sql +++ b/packages/db/queries/template_aliases/check_alias_exists.sql @@ -1,9 +1,7 @@ -- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( - -- envs, not active_envs: a soft-deleted env id stays reserved (it can be - -- reactivated on rebuild), so an alias must not shadow it. SELECT 1 - FROM "public"."envs" + FROM "public"."active_envs" WHERE id = @alias ); From ad233118024f666d1e0c8b5a9344a68ae3314fa9 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:27:56 -0700 Subject: [PATCH 24/31] fix(db): keep alias-id conflict check against all envs (reserved ids) --- packages/db/queries/check_alias_exists.sql.go | 4 +++- packages/db/queries/template_aliases/check_alias_exists.sql | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/db/queries/check_alias_exists.sql.go b/packages/db/queries/check_alias_exists.sql.go index 9ad60048f8..b53b5ba255 100644 --- a/packages/db/queries/check_alias_exists.sql.go +++ b/packages/db/queries/check_alias_exists.sql.go @@ -11,8 +11,10 @@ import ( const checkAliasConflictsWithTemplateID = `-- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( + -- envs, not active_envs: an id stays reserved while its env row exists + -- (even soft-deleted), so an alias must not shadow it. SELECT 1 - FROM "public"."active_envs" + FROM "public"."envs" WHERE id = $1 ) ` diff --git a/packages/db/queries/template_aliases/check_alias_exists.sql b/packages/db/queries/template_aliases/check_alias_exists.sql index 8e7dd3dacb..64a3fbfb55 100644 --- a/packages/db/queries/template_aliases/check_alias_exists.sql +++ b/packages/db/queries/template_aliases/check_alias_exists.sql @@ -1,7 +1,9 @@ -- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( + -- envs, not active_envs: an id stays reserved while its env row exists + -- (even soft-deleted), so an alias must not shadow it. SELECT 1 - FROM "public"."active_envs" + FROM "public"."envs" WHERE id = @alias ); From bdfcfad3c692fe308238b263f9d1935d55f2b74f Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:04:47 -0700 Subject: [PATCH 25/31] fix(db): don't assign builds to soft-deleted envs Soft-delete keeps the envs row, so the FK no longer rejects assigning a build to a concurrently-deleted env. Guard CreateTemplateBuildAssignment on active_envs (now :execrows); snapshot-template and tag-assignment paths treat 0 rows as template-not-found. --- .../api/internal/handlers/template_tags.go | 7 ++++++- .../internal/orchestrator/snapshot_template.go | 5 ++++- .../api/internal/template/register_build.go | 4 +++- .../create_template_build_assignment.sql.go | 18 ++++++++++++------ .../create_template_build_assignment.sql | 10 ++++++---- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/api/internal/handlers/template_tags.go b/packages/api/internal/handlers/template_tags.go index 951583b7ec..68883acedc 100644 --- a/packages/api/internal/handlers/template_tags.go +++ b/packages/api/internal/handlers/template_tags.go @@ -129,7 +129,7 @@ func (a *APIStore) PostTemplatesTags(c *gin.Context) { // Create the tag assignments for _, tag := range tags { - err = client.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ + rows, err := client.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ TemplateID: template.ID, BuildID: buildID, Tag: tag, @@ -138,6 +138,11 @@ func (a *APIStore) PostTemplatesTags(c *gin.Context) { telemetry.ReportCriticalError(ctx, "error when creating tag assignment", err) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error creating tag assignment") + return + } + if rows == 0 { + a.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("Template '%s' not found", template.ID)) + return } } diff --git a/packages/api/internal/orchestrator/snapshot_template.go b/packages/api/internal/orchestrator/snapshot_template.go index fab4f45d0d..7c648e2eb4 100644 --- a/packages/api/internal/orchestrator/snapshot_template.go +++ b/packages/api/internal/orchestrator/snapshot_template.go @@ -152,7 +152,7 @@ func (o *Orchestrator) resolveOrCreateSnapshotTemplate( ) (string, error) { // Existing template — just assign the build if opts.ExistingTemplateID != nil { - err := o.sqlcDB.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ + rows, err := o.sqlcDB.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ TemplateID: *opts.ExistingTemplateID, BuildID: buildID, Tag: opts.Tag, @@ -160,6 +160,9 @@ func (o *Orchestrator) resolveOrCreateSnapshotTemplate( if err != nil { return "", fmt.Errorf("error assigning build to existing template: %w", err) } + if rows == 0 { + return "", fmt.Errorf("template '%s' not found", *opts.ExistingTemplateID) + } return *opts.ExistingTemplateID, nil } diff --git a/packages/api/internal/template/register_build.go b/packages/api/internal/template/register_build.go index 3b0ae8728c..4908f8fb3e 100644 --- a/packages/api/internal/template/register_build.go +++ b/packages/api/internal/template/register_build.go @@ -336,7 +336,9 @@ func RegisterBuild( } for _, tag := range tags { - err = client.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ + // Env is active in this tx (CreateOrUpdateTemplate succeeded above), so + // the active_envs guard always matches here; rows is ignored. + _, err = client.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ TemplateID: data.TemplateID, BuildID: buildID, Tag: tag, diff --git a/packages/db/queries/create_template_build_assignment.sql.go b/packages/db/queries/create_template_build_assignment.sql.go index 4374ed6067..15a47c3793 100644 --- a/packages/db/queries/create_template_build_assignment.sql.go +++ b/packages/db/queries/create_template_build_assignment.sql.go @@ -11,9 +11,10 @@ import ( "github.com/google/uuid" ) -const createTemplateBuildAssignment = `-- name: CreateTemplateBuildAssignment :exec +const createTemplateBuildAssignment = `-- name: CreateTemplateBuildAssignment :execrows INSERT INTO "public"."env_build_assignments" (env_id, build_id, tag) -VALUES ($1, $2, $3::text) +SELECT $1, $2, $3::text +WHERE EXISTS (SELECT 1 FROM "public"."active_envs" WHERE id = $1) ` type CreateTemplateBuildAssignmentParams struct { @@ -22,8 +23,13 @@ type CreateTemplateBuildAssignmentParams struct { Tag string } -// Creates a build assignment to associate a build with a custom tag -func (q *Queries) CreateTemplateBuildAssignment(ctx context.Context, arg CreateTemplateBuildAssignmentParams) error { - _, err := q.db.Exec(ctx, createTemplateBuildAssignment, arg.TemplateID, arg.BuildID, arg.Tag) - return err +// Associates a build with a tag. Guarded on active_envs so a build can't be +// attached to a soft-deleted env (the FK no longer rejects that since the row is +// kept); 0 rows affected means the template is gone. +func (q *Queries) CreateTemplateBuildAssignment(ctx context.Context, arg CreateTemplateBuildAssignmentParams) (int64, error) { + result, err := q.db.Exec(ctx, createTemplateBuildAssignment, arg.TemplateID, arg.BuildID, arg.Tag) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil } diff --git a/packages/db/queries/templates/create_template_build_assignment.sql b/packages/db/queries/templates/create_template_build_assignment.sql index 816b6d1d23..b7df494426 100644 --- a/packages/db/queries/templates/create_template_build_assignment.sql +++ b/packages/db/queries/templates/create_template_build_assignment.sql @@ -1,5 +1,7 @@ --- name: CreateTemplateBuildAssignment :exec --- Creates a build assignment to associate a build with a custom tag +-- name: CreateTemplateBuildAssignment :execrows +-- Associates a build with a tag. Guarded on active_envs so a build can't be +-- attached to a soft-deleted env (the FK no longer rejects that since the row is +-- kept); 0 rows affected means the template is gone. INSERT INTO "public"."env_build_assignments" (env_id, build_id, tag) -VALUES (@template_id, @build_id, @tag::text); - +SELECT @template_id, @build_id, @tag::text +WHERE EXISTS (SELECT 1 FROM "public"."active_envs" WHERE id = @template_id); From 11cfc5472f9ba1b8e450ccecf3f5e10e90f9d732 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:14:24 -0700 Subject: [PATCH 26/31] test(docker-reverse-proxy): handle CreateTemplateBuildAssignment rows return The query is now :execrows; the test setup helper must read the rows value. --- packages/docker-reverse-proxy/internal/auth/validate_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/docker-reverse-proxy/internal/auth/validate_test.go b/packages/docker-reverse-proxy/internal/auth/validate_test.go index caab51598a..ee3becccd7 100644 --- a/packages/docker-reverse-proxy/internal/auth/validate_test.go +++ b/packages/docker-reverse-proxy/internal/auth/validate_test.go @@ -162,10 +162,11 @@ func setupValidateTest(tb testing.TB, db *testutils.Database, userID uuid.UUID, `, buildID, createdEnvStatus, finishedAt) require.NoError(tb, err) - err = db.SqlcClient.CreateTemplateBuildAssignment(tb.Context(), dbqueries.CreateTemplateBuildAssignmentParams{ + rows, err := db.SqlcClient.CreateTemplateBuildAssignment(tb.Context(), dbqueries.CreateTemplateBuildAssignmentParams{ TemplateID: envID, BuildID: buildID, Tag: "default", }) require.NoError(tb, err) + require.EqualValues(tb, 1, rows) } From b6b5a947c6ea0b284913abd29cdb9ae1eaad7896 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:26:51 -0700 Subject: [PATCH 27/31] fix(db): lock env row (FOR SHARE) when assigning builds to avoid delete race --- .../create_template_build_assignment.sql.go | 16 ++++++++++++---- .../create_template_build_assignment.sql | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/db/queries/create_template_build_assignment.sql.go b/packages/db/queries/create_template_build_assignment.sql.go index 15a47c3793..cf86308826 100644 --- a/packages/db/queries/create_template_build_assignment.sql.go +++ b/packages/db/queries/create_template_build_assignment.sql.go @@ -12,9 +12,15 @@ import ( ) const createTemplateBuildAssignment = `-- name: CreateTemplateBuildAssignment :execrows +WITH active AS ( + SELECT id + FROM "public"."envs" + WHERE id = $1 AND deleted_at IS NULL + FOR SHARE +) INSERT INTO "public"."env_build_assignments" (env_id, build_id, tag) SELECT $1, $2, $3::text -WHERE EXISTS (SELECT 1 FROM "public"."active_envs" WHERE id = $1) +WHERE EXISTS (SELECT 1 FROM active) ` type CreateTemplateBuildAssignmentParams struct { @@ -23,9 +29,11 @@ type CreateTemplateBuildAssignmentParams struct { Tag string } -// Associates a build with a tag. Guarded on active_envs so a build can't be -// attached to a soft-deleted env (the FK no longer rejects that since the row is -// kept); 0 rows affected means the template is gone. +// Associates a build with a tag. Locks the env row (FOR SHARE) and re-checks +// deleted_at so a build can't be attached to a template being soft-deleted +// concurrently: a racing DeleteTemplate's UPDATE conflicts with the lock, so the +// check sees the committed deleted_at and inserts nothing (the kept envs row +// means the FK alone no longer blocks this). 0 rows affected means it's gone. func (q *Queries) CreateTemplateBuildAssignment(ctx context.Context, arg CreateTemplateBuildAssignmentParams) (int64, error) { result, err := q.db.Exec(ctx, createTemplateBuildAssignment, arg.TemplateID, arg.BuildID, arg.Tag) if err != nil { diff --git a/packages/db/queries/templates/create_template_build_assignment.sql b/packages/db/queries/templates/create_template_build_assignment.sql index b7df494426..e5a82d8be4 100644 --- a/packages/db/queries/templates/create_template_build_assignment.sql +++ b/packages/db/queries/templates/create_template_build_assignment.sql @@ -1,7 +1,15 @@ -- name: CreateTemplateBuildAssignment :execrows --- Associates a build with a tag. Guarded on active_envs so a build can't be --- attached to a soft-deleted env (the FK no longer rejects that since the row is --- kept); 0 rows affected means the template is gone. +-- Associates a build with a tag. Locks the env row (FOR SHARE) and re-checks +-- deleted_at so a build can't be attached to a template being soft-deleted +-- concurrently: a racing DeleteTemplate's UPDATE conflicts with the lock, so the +-- check sees the committed deleted_at and inserts nothing (the kept envs row +-- means the FK alone no longer blocks this). 0 rows affected means it's gone. +WITH active AS ( + SELECT id + FROM "public"."envs" + WHERE id = @template_id AND deleted_at IS NULL + FOR SHARE +) INSERT INTO "public"."env_build_assignments" (env_id, build_id, tag) SELECT @template_id, @build_id, @tag::text -WHERE EXISTS (SELECT 1 FROM "public"."active_envs" WHERE id = @template_id); +WHERE EXISTS (SELECT 1 FROM active); From e2b07bbe5aadeb825d12eabd8e28d22bc46e33d2 Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:28:25 -0700 Subject: [PATCH 28/31] chore(db): trim assignment-guard comment --- .../db/queries/create_template_build_assignment.sql.go | 7 ++----- .../queries/templates/create_template_build_assignment.sql | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/db/queries/create_template_build_assignment.sql.go b/packages/db/queries/create_template_build_assignment.sql.go index cf86308826..0ecbef59bf 100644 --- a/packages/db/queries/create_template_build_assignment.sql.go +++ b/packages/db/queries/create_template_build_assignment.sql.go @@ -29,11 +29,8 @@ type CreateTemplateBuildAssignmentParams struct { Tag string } -// Associates a build with a tag. Locks the env row (FOR SHARE) and re-checks -// deleted_at so a build can't be attached to a template being soft-deleted -// concurrently: a racing DeleteTemplate's UPDATE conflicts with the lock, so the -// check sees the committed deleted_at and inserts nothing (the kept envs row -// means the FK alone no longer blocks this). 0 rows affected means it's gone. +// FOR SHARE serializes against a concurrent DeleteTemplate so a build can't be +// attached to a soft-deleted env. 0 rows affected means the template is gone. func (q *Queries) CreateTemplateBuildAssignment(ctx context.Context, arg CreateTemplateBuildAssignmentParams) (int64, error) { result, err := q.db.Exec(ctx, createTemplateBuildAssignment, arg.TemplateID, arg.BuildID, arg.Tag) if err != nil { diff --git a/packages/db/queries/templates/create_template_build_assignment.sql b/packages/db/queries/templates/create_template_build_assignment.sql index e5a82d8be4..1d73259049 100644 --- a/packages/db/queries/templates/create_template_build_assignment.sql +++ b/packages/db/queries/templates/create_template_build_assignment.sql @@ -1,9 +1,6 @@ -- name: CreateTemplateBuildAssignment :execrows --- Associates a build with a tag. Locks the env row (FOR SHARE) and re-checks --- deleted_at so a build can't be attached to a template being soft-deleted --- concurrently: a racing DeleteTemplate's UPDATE conflicts with the lock, so the --- check sees the committed deleted_at and inserts nothing (the kept envs row --- means the FK alone no longer blocks this). 0 rows affected means it's gone. +-- FOR SHARE serializes against a concurrent DeleteTemplate so a build can't be +-- attached to a soft-deleted env. 0 rows affected means the template is gone. WITH active AS ( SELECT id FROM "public"."envs" From 57e699184c901dad105c5e9d6d5b654d8271ea0e Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:59:06 -0700 Subject: [PATCH 29/31] style(db): fix run-on whitespace in list_team_templates WHERE clauses --- .../queries/list_team_templates.sql.go | 24 ++++++++++++------- .../templates/list_team_templates.sql | 24 ++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go index 60d4a447ab..9beafd5e10 100644 --- a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go @@ -29,7 +29,8 @@ WITH team_templates AS ( FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = $1::uuid AND e.source = 'template' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + e.team_id = $1::uuid AND e.source = 'template' + AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -61,7 +62,8 @@ default_templates AS ( FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - $7::boolean AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + $7::boolean + AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -240,7 +242,8 @@ WITH team_templates AS ( FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = $1::uuid AND e.source = 'template' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + e.team_id = $1::uuid AND e.source = 'template' + AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -272,7 +275,8 @@ default_templates AS ( FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - $7::boolean AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + $7::boolean + AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -435,7 +439,8 @@ WITH team_templates AS ( FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = $1::uuid AND e.source = 'template' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + e.team_id = $1::uuid AND e.source = 'template' + AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -467,7 +472,8 @@ default_templates AS ( FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - $7::boolean AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + $7::boolean + AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -630,7 +636,8 @@ WITH team_templates AS ( FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = $1::uuid AND e.source = 'template' AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + e.team_id = $1::uuid AND e.source = 'template' + AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' @@ -662,7 +669,8 @@ default_templates AS ( FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - $7::boolean AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) + $7::boolean + AND ($2::smallint = -1 OR e.public = ($2::smallint = 1)) AND ( $3::text = '' OR e.id ILIKE '%' || $3::text || '%' diff --git a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql index 5041f780db..c20bc1e7dd 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql @@ -31,7 +31,8 @@ WITH team_templates AS ( FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -63,7 +64,8 @@ default_templates AS ( FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - sqlc.arg(include_defaults)::boolean AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + sqlc.arg(include_defaults)::boolean + AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -147,7 +149,8 @@ WITH team_templates AS ( FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -179,7 +182,8 @@ default_templates AS ( FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - sqlc.arg(include_defaults)::boolean AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + sqlc.arg(include_defaults)::boolean + AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -263,7 +267,8 @@ WITH team_templates AS ( FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -295,7 +300,8 @@ default_templates AS ( FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - sqlc.arg(include_defaults)::boolean AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + sqlc.arg(include_defaults)::boolean + AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -379,7 +385,8 @@ WITH team_templates AS ( FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' + AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' @@ -411,7 +418,8 @@ default_templates AS ( FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE - sqlc.arg(include_defaults)::boolean AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) + sqlc.arg(include_defaults)::boolean + AND (sqlc.arg(filter_public)::smallint = -1 OR e.public = (sqlc.arg(filter_public)::smallint = 1)) AND ( sqlc.arg(search)::text = '' OR e.id ILIKE '%' || sqlc.arg(search)::text || '%' From ca609442ecbd9930a16a727d4e49998e031ca05d Mon Sep 17 00:00:00 2001 From: ValentaTomas <49156497+ValentaTomas@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:39:04 -0700 Subject: [PATCH 30/31] fix(db): soft-delete in a tx so active-build cleanup beats a racing build DeleteTemplate was a single CTE: when its env-lock UPDATE waited on an in-flight build registration, the active_template_builds DELETE used the stale snapshot and missed the just-committed active row, leaving a deleted template counted toward concurrency. Split into SoftDeleteTemplate + ReleaseTemplateAliases + DeleteActiveTemplateBuilds run in a transaction, so the cleanup statements get fresh snapshots after the env lock is taken. --- .../api/internal/handlers/sandbox_kill.go | 6 +- .../api/internal/handlers/template_delete.go | 45 ++++++++++-- .../tests/templates/delete_template_test.go | 4 +- packages/db/queries/delete_template.sql.go | 72 +++++++++++-------- .../db/queries/templates/delete_template.sql | 48 ++++++------- 5 files changed, 107 insertions(+), 68 deletions(-) diff --git a/packages/api/internal/handlers/sandbox_kill.go b/packages/api/internal/handlers/sandbox_kill.go index 8c214fc166..b30ac6b5a9 100644 --- a/packages/api/internal/handlers/sandbox_kill.go +++ b/packages/api/internal/handlers/sandbox_kill.go @@ -14,7 +14,6 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/sandbox" "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" - "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -25,10 +24,7 @@ func (a *APIStore) deleteSnapshot(ctx context.Context, sandboxID string, teamID return err } - aliasKeys, dbErr := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ - TeamID: teamID, - TemplateID: snapshot.TemplateID, - }) + aliasKeys, dbErr := a.softDeleteTemplate(ctx, teamID, snapshot.TemplateID) if dbErr != nil { return fmt.Errorf("error deleting template from db: %w", dbErr) } diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index cc1c46434b..91162bc5a9 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -8,14 +8,53 @@ import ( "github.com/gin-gonic/gin" "go.opentelemetry.io/otel/attribute" + "github.com/google/uuid" + "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/sandbox" + "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) +// softDeleteTemplate soft-deletes the env, releases its aliases, and clears its +// active build rows, in a transaction. The env-locking soft-delete runs first so +// a concurrent build registration commits before the alias/active-build cleanup, +// whose fresh per-statement snapshots then see those rows. Returns the released +// alias cache keys. +func (a *APIStore) softDeleteTemplate(ctx context.Context, teamID uuid.UUID, templateID string) ([]string, error) { + txDB, tx, err := a.sqlcDB.WithTx(ctx) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + + if _, err := txDB.SoftDeleteTemplate(ctx, queries.SoftDeleteTemplateParams{TemplateID: templateID, TeamID: teamID}); err != nil { + if dberrors.IsNotFoundError(err) { + return nil, nil // already deleted or not owned by the team + } + + return nil, fmt.Errorf("soft delete template: %w", err) + } + + aliasKeys, err := txDB.ReleaseTemplateAliases(ctx, templateID) + if err != nil { + return nil, fmt.Errorf("release aliases: %w", err) + } + + if err := txDB.DeleteActiveTemplateBuilds(ctx, templateID); err != nil { + return nil, fmt.Errorf("delete active builds: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return aliasKeys, nil +} + // DeleteTemplatesTemplateID serves to delete a template (e.g. in CLI) func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID api.TemplateID) { ctx := c.Request.Context() @@ -80,11 +119,7 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a return } - // Soft-deletes the env and releases aliases. - aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ - TemplateID: templateID, - TeamID: team.ID, - }) + aliasKeys, err := a.softDeleteTemplate(ctx, team.ID, templateID) if err != nil { telemetry.ReportCriticalError(ctx, "error when deleting template from db", err) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when deleting template") diff --git a/packages/db/pkg/tests/templates/delete_template_test.go b/packages/db/pkg/tests/templates/delete_template_test.go index 9da42a58b2..6b8984f50c 100644 --- a/packages/db/pkg/tests/templates/delete_template_test.go +++ b/packages/db/pkg/tests/templates/delete_template_test.go @@ -41,7 +41,9 @@ func TestDeleteTemplate_SoftDeletesEnvAndPreservesStructure(t *testing.T) { testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "default") alias := testutils.CreateTestTemplateAlias(t, db, templateID) - _, err := db.SqlcClient.DeleteTemplate(ctx, queries.DeleteTemplateParams{TemplateID: templateID, TeamID: teamID}) + _, err := db.SqlcClient.SoftDeleteTemplate(ctx, queries.SoftDeleteTemplateParams{TemplateID: templateID, TeamID: teamID}) + require.NoError(t, err) + _, err = db.SqlcClient.ReleaseTemplateAliases(ctx, templateID) require.NoError(t, err) assert.True(t, testutils.GetEnvByID(t, ctx, db, templateID), "env row must be preserved") diff --git a/packages/db/queries/delete_template.sql.go b/packages/db/queries/delete_template.sql.go index 3d9efc7036..ecda84795d 100644 --- a/packages/db/queries/delete_template.sql.go +++ b/packages/db/queries/delete_template.sql.go @@ -11,41 +11,27 @@ import ( "github.com/google/uuid" ) -const deleteTemplate = `-- name: DeleteTemplate :many -WITH alias_keys AS ( - SELECT CASE - WHEN ea.namespace IS NOT NULL THEN ea.namespace || '/' || ea.alias - ELSE ea.alias - END::text AS alias_key - FROM public.env_aliases ea - WHERE ea.env_id = $1 -), updated AS ( - UPDATE public.envs e - SET deleted_at = NOW(), updated_at = NOW() - WHERE e.id = $1 - AND e.team_id = $2 - AND e.deleted_at IS NULL - RETURNING e.id -), released AS ( - DELETE FROM public.env_aliases ea - WHERE ea.env_id IN (SELECT id FROM updated) -), deactivated AS ( - DELETE FROM public.active_template_builds atb - WHERE atb.template_id IN (SELECT id FROM updated) -) -SELECT alias_key FROM alias_keys -WHERE EXISTS (SELECT 1 FROM updated) +const deleteActiveTemplateBuilds = `-- name: DeleteActiveTemplateBuilds :exec +DELETE FROM public.active_template_builds WHERE template_id = $1 ` -type DeleteTemplateParams struct { - TemplateID string - TeamID uuid.UUID +// Clears in-flight build tracking (active_template_builds no longer cascades +// since the env row is kept) so a deleted template stops counting toward the +// team build concurrency limit. +func (q *Queries) DeleteActiveTemplateBuilds(ctx context.Context, templateID string) error { + _, err := q.db.Exec(ctx, deleteActiveTemplateBuilds, templateID) + return err } -// Soft-deletes the env (keeps env_builds/assignments/snapshots), releases -// aliases, and clears active_template_builds. Returns alias cache keys. -func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) ([]string, error) { - rows, err := q.db.Query(ctx, deleteTemplate, arg.TemplateID, arg.TeamID) +const releaseTemplateAliases = `-- name: ReleaseTemplateAliases :many +DELETE FROM public.env_aliases +WHERE env_id = $1 +RETURNING (CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END)::text AS alias_key +` + +// Releases the env's aliases (so the name is reusable) and returns their cache keys. +func (q *Queries) ReleaseTemplateAliases(ctx context.Context, templateID string) ([]string, error) { + rows, err := q.db.Query(ctx, releaseTemplateAliases, templateID) if err != nil { return nil, err } @@ -63,3 +49,27 @@ func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) } return items, nil } + +const softDeleteTemplate = `-- name: SoftDeleteTemplate :one +UPDATE public.envs +SET deleted_at = NOW(), updated_at = NOW() +WHERE id = $1 AND team_id = $2 AND deleted_at IS NULL +RETURNING id +` + +type SoftDeleteTemplateParams struct { + TemplateID string + TeamID uuid.UUID +} + +// Step 1 of soft delete (run in a tx). The UPDATE locks the env row, so a +// concurrent build registration holding that lock commits first; this then +// soft-deletes. Returns the id (no row if already deleted or not owned). The +// alias/active-build cleanup runs as separate statements afterwards so their +// fresh snapshots see rows that racing registration committed during the wait. +func (q *Queries) SoftDeleteTemplate(ctx context.Context, arg SoftDeleteTemplateParams) (string, error) { + row := q.db.QueryRow(ctx, softDeleteTemplate, arg.TemplateID, arg.TeamID) + var id string + err := row.Scan(&id) + return id, err +} diff --git a/packages/db/queries/templates/delete_template.sql b/packages/db/queries/templates/delete_template.sql index 5366837d6d..f481b86137 100644 --- a/packages/db/queries/templates/delete_template.sql +++ b/packages/db/queries/templates/delete_template.sql @@ -1,26 +1,22 @@ --- name: DeleteTemplate :many --- Soft-deletes the env (keeps env_builds/assignments/snapshots), releases --- aliases, and clears active_template_builds. Returns alias cache keys. -WITH alias_keys AS ( - SELECT CASE - WHEN ea.namespace IS NOT NULL THEN ea.namespace || '/' || ea.alias - ELSE ea.alias - END::text AS alias_key - FROM public.env_aliases ea - WHERE ea.env_id = @template_id -), updated AS ( - UPDATE public.envs e - SET deleted_at = NOW(), updated_at = NOW() - WHERE e.id = @template_id - AND e.team_id = @team_id - AND e.deleted_at IS NULL - RETURNING e.id -), released AS ( - DELETE FROM public.env_aliases ea - WHERE ea.env_id IN (SELECT id FROM updated) -), deactivated AS ( - DELETE FROM public.active_template_builds atb - WHERE atb.template_id IN (SELECT id FROM updated) -) -SELECT alias_key FROM alias_keys -WHERE EXISTS (SELECT 1 FROM updated); +-- name: SoftDeleteTemplate :one +-- Step 1 of soft delete (run in a tx). The UPDATE locks the env row, so a +-- concurrent build registration holding that lock commits first; this then +-- soft-deletes. Returns the id (no row if already deleted or not owned). The +-- alias/active-build cleanup runs as separate statements afterwards so their +-- fresh snapshots see rows that racing registration committed during the wait. +UPDATE public.envs +SET deleted_at = NOW(), updated_at = NOW() +WHERE id = @template_id AND team_id = @team_id AND deleted_at IS NULL +RETURNING id; + +-- name: ReleaseTemplateAliases :many +-- Releases the env's aliases (so the name is reusable) and returns their cache keys. +DELETE FROM public.env_aliases +WHERE env_id = @template_id +RETURNING (CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END)::text AS alias_key; + +-- name: DeleteActiveTemplateBuilds :exec +-- Clears in-flight build tracking (active_template_builds no longer cascades +-- since the env row is kept) so a deleted template stops counting toward the +-- team build concurrency limit. +DELETE FROM public.active_template_builds WHERE template_id = @template_id; From 42e23c73d31f0edbc2a2e86c78450b992b936aa6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 30 Jun 2026 19:41:11 +0000 Subject: [PATCH 31/31] chore: auto-commit generated changes --- packages/api/internal/handlers/template_delete.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index 91162bc5a9..ca4738e06a 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -6,9 +6,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "go.opentelemetry.io/otel/attribute" - "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/sandbox"