Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2703cf2
feat(api): soft-delete build layers in DB on user delete
ValentaTomas Jun 28, 2026
f76f755
fix(db): guard soft-deleted builds from in-flight status overwrites
ValentaTomas Jun 29, 2026
33c1e65
refactor(db): soft-delete templates/snapshots at the env level
ValentaTomas Jun 29, 2026
d2b3c95
fix(db): clear active_template_builds and skip deleted envs on soft-d…
ValentaTomas Jun 29, 2026
ccc8a14
fix(db): reactivate env on rebuild and ignore deleted envs in alias c…
ValentaTomas Jun 29, 2026
e37b771
refactor(db): use boolean envs.deleted for soft-delete marker
ValentaTomas Jun 29, 2026
0aab1a5
fix(db): block deleted-template uploads and reactivate reused snapsho…
ValentaTomas Jun 29, 2026
fd04328
fix(db): exclude deleted snapshot envs from GetSnapshotBuilds
ValentaTomas Jun 29, 2026
3d3ed8d
refactor(api): inline DeleteTemplate call, drop needless wrapper
ValentaTomas Jun 29, 2026
bdcdfed
chore(db): trim soft-delete comments and drop trivial test
ValentaTomas Jun 29, 2026
6ff93be
chore: drop linear issue and future-work mentions from comments
ValentaTomas Jun 29, 2026
88c39d7
fix(db): let GetTemplateByID see soft-deleted envs so rebuilds reacti…
ValentaTomas Jun 29, 2026
d47800b
fix(api): reject layer uploads for soft-deleted templates
ValentaTomas Jun 29, 2026
a81f7ff
chore: drop lineage wording from soft-delete comments
ValentaTomas Jun 29, 2026
51f4782
refactor(db): soft-delete via deleted_at timestamp behind an active_e…
ValentaTomas Jun 29, 2026
c0720be
refactor(db): resolve layer upload via active_envs view; trim view co…
ValentaTomas Jun 29, 2026
69ee758
docs(db): note active_envs join filters soft-deleted envs in build li…
ValentaTomas Jun 29, 2026
c6b9858
refactor(db): route all template reads through active_envs view
ValentaTomas Jun 29, 2026
015ba60
fix(db): scope rebuild reactivation to templates; keep deleted ids re…
ValentaTomas Jun 29, 2026
c753fed
fix(db): reactivate snapshot_template envs on rebuild too (exclude on…
ValentaTomas Jun 29, 2026
4390123
refactor(db): rename unfiltered lookup to GetTemplateByIDIncludingDel…
ValentaTomas Jun 29, 2026
7211a35
fix(db): make soft-delete permanent; rebuilding a deleted template fails
ValentaTomas Jun 29, 2026
e9440cc
refactor(db): route alias-id conflict check through active_envs
ValentaTomas Jun 29, 2026
ad23311
fix(db): keep alias-id conflict check against all envs (reserved ids)
ValentaTomas Jun 29, 2026
bdfcfad
fix(db): don't assign builds to soft-deleted envs
ValentaTomas Jun 30, 2026
11cfc54
test(docker-reverse-proxy): handle CreateTemplateBuildAssignment rows…
ValentaTomas Jun 30, 2026
b6b5a94
fix(db): lock env row (FOR SHARE) when assigning builds to avoid dele…
ValentaTomas Jun 30, 2026
e2b07bb
chore(db): trim assignment-guard comment
ValentaTomas Jun 30, 2026
57e6991
style(db): fix run-on whitespace in list_team_templates WHERE clauses
ValentaTomas Jun 30, 2026
ca60944
fix(db): soft-delete in a tx so active-build cleanup beats a racing b…
ValentaTomas Jun 30, 2026
42e23c7
chore: auto-commit generated changes
github-actions[bot] Jun 30, 2026
c4aa658
Merge branch 'main' into pr/db-soft-delete-builds
ValentaTomas Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/api/internal/cache/templates/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions packages/api/internal/cache/templates/template_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.GetTemplateById(ctx, templateID)
switch {
case err == nil:
if templateDB.TeamID != team.ID {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 1 addition & 5 deletions packages/api/internal/handlers/template_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,7 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a
return
}

// Delete the template from DB (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.
// Soft-deletes the env and releases aliases.
aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{
TemplateID: templateID,
TeamID: team.ID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ 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 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))
telemetry.ReportCriticalError(ctx, "error when getting env", err, telemetry.WithTemplateID(templateID))
Expand Down
4 changes: 2 additions & 2 deletions packages/api/internal/handlers/template_start_build_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down
9 changes: 7 additions & 2 deletions packages/api/internal/handlers/template_tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
}
}
Expand Down
14 changes: 7 additions & 7 deletions packages/api/internal/handlers/templates_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/api/internal/orchestrator/snapshot_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,17 @@ 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,
})
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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
Expand Down
17 changes: 14 additions & 3 deletions packages/api/internal/template/register_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -327,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,
Expand Down
14 changes: 7 additions & 7 deletions packages/dashboard-api/internal/handlers/template_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions packages/db/migrations/20260628120000_add_env_deleted_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- +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
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;

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 16 additions & 24 deletions packages/db/pkg/dashboard/queries/list_team_templates.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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.active_envs e ON e.id = ed.env_id
JOIN LATERAL (
SELECT a.build_id
FROM public.env_build_assignments a
Expand Down
Loading
Loading