Skip to content

Commit a0753b9

Browse files
committed
feat: allow m n builds with tags
1 parent f2d6e6c commit a0753b9

File tree

15 files changed

+462
-30
lines changed

15 files changed

+462
-30
lines changed

packages/api/internal/cache/templates/cache.go

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ package templatecache
22

33
import (
44
"context"
5-
"database/sql"
65
"errors"
76
"fmt"
87
"net/http"
8+
"strings"
99
"time"
1010

1111
"github.com/google/uuid"
@@ -14,8 +14,10 @@ import (
1414
"github.com/e2b-dev/infra/packages/api/internal/api"
1515
"github.com/e2b-dev/infra/packages/api/internal/utils"
1616
sqlcdb "github.com/e2b-dev/infra/packages/db/client"
17+
"github.com/e2b-dev/infra/packages/db/dberrors"
1718
"github.com/e2b-dev/infra/packages/db/queries"
1819
"github.com/e2b-dev/infra/packages/shared/pkg/cache"
20+
"github.com/e2b-dev/infra/packages/shared/pkg/id"
1921
)
2022

2123
const (
@@ -29,6 +31,7 @@ type TemplateInfo struct {
2931
teamID uuid.UUID
3032
clusterID uuid.UUID
3133
build *queries.EnvBuild
34+
tag *string
3235
}
3336

3437
type AliasCache struct {
@@ -72,7 +75,7 @@ func NewTemplateCache(db *sqlcdb.Client) *TemplateCache {
7275
RefreshTimeout: refreshTimeout,
7376
// With this we can use alias for getting template info without having it as a key in the cache
7477
ExtractKeyFunc: func(value *TemplateInfo) string {
75-
return value.template.TemplateID
78+
return buildCacheKey(value.template.TemplateID, value.tag)
7679
},
7780
}
7881
aliasCache := NewAliasCache()
@@ -84,15 +87,25 @@ func NewTemplateCache(db *sqlcdb.Client) *TemplateCache {
8487
}
8588
}
8689

87-
func (c *TemplateCache) Get(ctx context.Context, aliasOrEnvID string, teamID uuid.UUID, clusterID uuid.UUID, public bool) (*api.Template, *queries.EnvBuild, *api.APIError) {
90+
func buildCacheKey(templateID string, tag *string) string {
91+
if tag == nil {
92+
return templateID + ":" + id.DefaultTag
93+
}
94+
95+
return templateID + ":" + *tag
96+
}
97+
98+
func (c *TemplateCache) Get(ctx context.Context, aliasOrEnvID string, tag *string, teamID uuid.UUID, clusterID uuid.UUID, public bool) (*api.Template, *queries.EnvBuild, *api.APIError) {
8899
// Resolve alias to template ID if needed
89100
templateID, found := c.aliasCache.Get(aliasOrEnvID)
90101
if !found {
91102
templateID = aliasOrEnvID
92103
}
93104

105+
cacheKey := buildCacheKey(templateID, tag)
106+
94107
// Fetch or get from cache with automatic refresh
95-
templateInfo, err := c.cache.GetOrSet(ctx, templateID, c.fetchTemplateInfo)
108+
templateInfo, err := c.cache.GetOrSet(ctx, cacheKey, c.fetchTemplateInfo)
96109
if err != nil {
97110
var apiErr *api.APIError
98111
if errors.As(err, &apiErr) {
@@ -115,11 +128,24 @@ func (c *TemplateCache) Get(ctx context.Context, aliasOrEnvID string, teamID uui
115128
}
116129

117130
// fetchTemplateInfo fetches template info from the database
118-
func (c *TemplateCache) fetchTemplateInfo(ctx context.Context, aliasOrEnvID string) (*TemplateInfo, error) {
119-
result, err := c.db.GetTemplateWithBuild(ctx, aliasOrEnvID)
131+
func (c *TemplateCache) fetchTemplateInfo(ctx context.Context, cacheKey string) (*TemplateInfo, error) {
132+
aliasOrEnvID, tag, err := id.ParseTemplateIDOrAliasWithTag(cacheKey)
120133
if err != nil {
121-
if errors.Is(err, sql.ErrNoRows) {
122-
return nil, &api.APIError{Code: http.StatusNotFound, ClientMsg: fmt.Sprintf("template '%s' not found", aliasOrEnvID), Err: err}
134+
return nil, &api.APIError{Code: http.StatusBadRequest, ClientMsg: fmt.Sprintf("invalid template ID: %s", err), Err: err}
135+
}
136+
137+
result, err := c.db.GetTemplateWithBuildByTag(ctx, queries.GetTemplateWithBuildByTagParams{
138+
AliasOrEnvID: aliasOrEnvID,
139+
Tag: tag,
140+
})
141+
if err != nil {
142+
if dberrors.IsNotFoundError(err) {
143+
tagMsg := ""
144+
if tag != nil {
145+
tagMsg = fmt.Sprintf(" with tag '%s'", *tag)
146+
}
147+
148+
return nil, &api.APIError{Code: http.StatusNotFound, ClientMsg: fmt.Sprintf("template '%s'%s not found", aliasOrEnvID, tagMsg), Err: err}
123149
}
124150

125151
return nil, &api.APIError{Code: http.StatusInternalServerError, ClientMsg: fmt.Sprintf("error while getting template: %v", err), Err: err}
@@ -129,7 +155,7 @@ func (c *TemplateCache) fetchTemplateInfo(ctx context.Context, aliasOrEnvID stri
129155
template := result.Env
130156
clusterID := utils.WithClusterFallback(template.ClusterID)
131157

132-
// Update alias cache
158+
// Update alias cache (without tag, as aliases map to template IDs)
133159
c.aliasCache.Set(template.ID, template.ID)
134160
for _, alias := range result.Aliases {
135161
c.aliasCache.Set(alias, template.ID)
@@ -145,12 +171,22 @@ func (c *TemplateCache) fetchTemplateInfo(ctx context.Context, aliasOrEnvID stri
145171
teamID: template.TeamID,
146172
clusterID: clusterID,
147173
build: build,
174+
tag: tag,
148175
}, nil
149176
}
150177

151-
// Invalidate invalidates the cache for the given templateID
152-
func (c *TemplateCache) Invalidate(templateID string) {
153-
c.cache.Delete(templateID)
178+
func (c *TemplateCache) Invalidate(templateID string, tag *string) {
179+
c.cache.Delete(buildCacheKey(templateID, tag))
180+
}
181+
182+
// Invalidate invalidates the cache for the given templateID across all tags
183+
func (c *TemplateCache) InvalidateAllTags(templateID string) {
184+
templateIDKey := templateID + ":"
185+
for _, key := range c.cache.Keys() {
186+
if strings.HasPrefix(key, templateIDKey) {
187+
c.cache.Delete(key)
188+
}
189+
}
154190
}
155191

156192
func (c *TemplateCache) Close(ctx context.Context) error {

packages/api/internal/handlers/deprecated_template_request_build.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func (a *APIStore) PostTemplatesTemplateID(c *gin.Context, rawTemplateID api.Tem
8484
return
8585
}
8686

87-
templateID, err := id.CleanTemplateID(rawTemplateID)
87+
templateID, _, err := id.ParseTemplateIDOrAliasWithTag(rawTemplateID)
8888
if err != nil {
8989
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid template ID: %s", rawTemplateID))
9090
telemetry.ReportCriticalError(c.Request.Context(), "invalid template ID", err)

packages/api/internal/handlers/sandbox_create.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,23 +66,24 @@ func (a *APIStore) PostSandboxes(c *gin.Context) {
6666

6767
telemetry.ReportEvent(ctx, "Parsed body")
6868

69-
cleanedAliasOrEnvID, err := id.CleanTemplateID(body.TemplateID)
69+
// Parse template ID and optional tag in the format "templateID:tag"
70+
cleanedAliasOrEnvID, tag, err := id.ParseTemplateIDOrAliasWithTag(body.TemplateID)
7071
if err != nil {
71-
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid environment ID: %s", err))
72+
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid template ID: %s", err))
7273

73-
telemetry.ReportCriticalError(ctx, "error when cleaning env ID", err)
74+
telemetry.ReportCriticalError(ctx, "error when parsing template ID", err)
7475

7576
return
7677
}
7778

78-
telemetry.ReportEvent(ctx, "Cleaned template ID")
79+
telemetry.ReportEvent(ctx, "Parsed template ID and tag")
7980

8081
_, templateSpan := tracer.Start(ctx, "get-template")
8182
defer templateSpan.End()
8283

8384
// Check if team has access to the environment
8485
clusterID := utils.WithClusterFallback(teamInfo.Team.ClusterID)
85-
env, build, checkErr := a.templateCache.Get(ctx, cleanedAliasOrEnvID, teamInfo.Team.ID, clusterID, true)
86+
env, build, checkErr := a.templateCache.Get(ctx, cleanedAliasOrEnvID, tag, teamInfo.Team.ID, clusterID, true)
8687
if checkErr != nil {
8788
telemetry.ReportCriticalError(ctx, "error when getting template", checkErr.Err)
8889
a.sendAPIStoreError(c, checkErr.Code, checkErr.ClientMsg)

packages/api/internal/handlers/sandbox_kill.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func (a *APIStore) deleteSnapshot(ctx context.Context, sandboxID string, teamID
6666
}
6767
}(context.WithoutCancel(ctx))
6868

69-
a.templateCache.Invalidate(snapshot.TemplateID)
69+
a.templateCache.Invalidate(snapshot.TemplateID, nil)
7070

7171
return nil
7272
}

packages/api/internal/handlers/template_delete.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID api.TemplateID) {
2121
ctx := c.Request.Context()
2222

23-
cleanedAliasOrTemplateID, err := id.CleanTemplateID(aliasOrTemplateID)
23+
cleanedAliasOrTemplateID, _, err := id.ParseTemplateIDOrAliasWithTag(aliasOrTemplateID)
2424
if err != nil {
2525
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid template ID: %s", aliasOrTemplateID))
2626

@@ -119,7 +119,7 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a
119119
telemetry.ReportEvent(ctx, "deleted template from storage")
120120
}
121121

122-
a.templateCache.Invalidate(templateID)
122+
a.templateCache.InvalidateAllTags(templateID)
123123

124124
telemetry.ReportEvent(ctx, "deleted template from db")
125125

packages/api/internal/handlers/template_update.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func (a *APIStore) PatchTemplatesTemplateID(c *gin.Context, aliasOrTemplateID ap
2828
return
2929
}
3030

31-
cleanedAliasOrTemplateID, err := id.CleanTemplateID(aliasOrTemplateID)
31+
cleanedAliasOrTemplateID, tag, err := id.ParseTemplateIDOrAliasWithTag(aliasOrTemplateID)
3232
if err != nil {
3333
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid template ID: %s", aliasOrTemplateID))
3434

@@ -95,7 +95,7 @@ func (a *APIStore) PatchTemplatesTemplateID(c *gin.Context, aliasOrTemplateID ap
9595
}
9696
}
9797

98-
a.templateCache.Invalidate(template.ID)
98+
a.templateCache.Invalidate(template.ID, tag)
9999

100100
telemetry.ReportEvent(ctx, "updated template")
101101

packages/api/internal/template-manager/create_template.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ func (tm *TemplateManager) CreateTemplate(
189189
}
190190

191191
// Invalidate the cache
192-
tm.templateCache.Invalidate(templateID)
192+
tm.templateCache.Invalidate(templateID, nil)
193193
}(context.WithoutCancel(ctx))
194194

195195
return nil

packages/api/internal/template/register_build.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func RegisterBuild(
128128

129129
var alias string
130130
if data.Alias != nil {
131-
alias, err = id.CleanTemplateID(*data.Alias)
131+
alias, _, err = id.ParseTemplateIDOrAliasWithTag(*data.Alias)
132132
if err != nil {
133133
telemetry.ReportCriticalError(ctx, "invalid alias", err)
134134

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
-- 1. Create the new env_build_assignments table
4+
CREATE TABLE env_build_assignments (
5+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6+
env_id TEXT NOT NULL,
7+
build_id UUID NOT NULL,
8+
tag TEXT,
9+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
10+
11+
-- Add foreign key constraints
12+
CONSTRAINT fk_env_build_assignments_env
13+
FOREIGN KEY (env_id)
14+
REFERENCES envs(id)
15+
ON DELETE CASCADE,
16+
17+
CONSTRAINT fk_env_build_assignments_build
18+
FOREIGN KEY (build_id)
19+
REFERENCES env_builds(id)
20+
ON DELETE CASCADE,
21+
22+
-- Add unique constraint to prevent duplicate assignments for the same build+tag combination
23+
-- This ensures data consistency during migration and normal operations
24+
CONSTRAINT uq_env_build_assignments_build_tag
25+
UNIQUE (build_id, tag, created_at)
26+
);
27+
ALTER TABLE "public"."env_build_assignments" ENABLE ROW LEVEL SECURITY;
28+
-- +goose StatementEnd
29+
30+
-- +goose StatementBegin
31+
-- 2. Create trigger function to sync env_builds.env_id changes to env_build_assignments
32+
-- This table is append-only for 'latest' tag. Queries use ORDER BY created_at DESC LIMIT 1 to get current assignment.
33+
CREATE OR REPLACE FUNCTION sync_env_build_assignment()
34+
RETURNS TRIGGER AS $$
35+
BEGIN
36+
-- On INSERT or UPDATE, if env_id is set, append new assignment with 'latest' tag
37+
IF NEW.env_id IS NOT NULL THEN
38+
-- Check if this is actually a change (for UPDATE operations)
39+
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND (OLD.env_id IS NULL OR OLD.env_id != NEW.env_id)) THEN
40+
-- Append new assignment with 'latest' tag (append-only)
41+
-- Use ON CONFLICT DO NOTHING to handle race conditions during migration
42+
INSERT INTO env_build_assignments (env_id, build_id, tag, created_at)
43+
VALUES (NEW.env_id, NEW.id, 'latest', CURRENT_TIMESTAMP)
44+
ON CONFLICT (build_id, tag, created_at) DO NOTHING;
45+
END IF;
46+
END IF;
47+
48+
RETURN NEW;
49+
END;
50+
$$ LANGUAGE plpgsql;
51+
-- +goose StatementEnd
52+
53+
-- +goose StatementBegin
54+
-- 3. Create trigger to automatically sync changes (BEFORE data migration to prevent race conditions)
55+
CREATE TRIGGER trigger_sync_env_build_assignment
56+
AFTER INSERT OR UPDATE ON env_builds
57+
FOR EACH ROW
58+
EXECUTE FUNCTION sync_env_build_assignment();
59+
-- +goose StatementEnd
60+
61+
-- +goose StatementBegin
62+
-- 4. Migrate existing data from direct relationship
63+
-- The env_builds table has env_id column pointing to envs
64+
-- The trigger is already active, so ON CONFLICT handles any concurrent inserts
65+
INSERT INTO env_build_assignments (env_id, build_id, tag, created_at)
66+
SELECT
67+
env_id,
68+
id as build_id,
69+
'latest' as tag,
70+
created_at
71+
FROM env_builds
72+
WHERE env_id IS NOT NULL
73+
ON CONFLICT (build_id, tag, created_at) DO NOTHING;
74+
-- +goose StatementEnd
75+
76+
-- +goose Down
77+
-- +goose StatementBegin
78+
-- 1. Drop the trigger
79+
DROP TRIGGER IF EXISTS trigger_sync_env_build_assignment ON env_builds;
80+
-- +goose StatementEnd
81+
82+
-- +goose StatementBegin
83+
-- 2. Drop the trigger function
84+
DROP FUNCTION IF EXISTS sync_env_build_assignment();
85+
-- +goose StatementEnd
86+
87+
-- +goose StatementBegin
88+
-- 3. Drop the assignments table
89+
DROP TABLE IF EXISTS env_build_assignments;
90+
-- +goose StatementEnd
91+

0 commit comments

Comments
 (0)