Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions packages/api/internal/cache/templates/alias_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"

"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
Expand All @@ -16,6 +17,13 @@ import (
"github.com/e2b-dev/infra/packages/shared/pkg/id"
)

const (
templateInfoExpiration = 5 * time.Minute
refreshInterval = 1 * time.Minute
refreshTimeout = 30 * time.Second
callbackTimeout = 30 * time.Second
)

// AliasInfo holds resolved alias information
type AliasInfo struct {
TemplateID string
Expand Down
132 changes: 66 additions & 66 deletions packages/api/internal/cache/templates/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"

"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel"

"github.com/e2b-dev/infra/packages/api/internal/api"
Expand All @@ -21,48 +21,54 @@ import (
)

const (
templateInfoExpiration = 5 * time.Minute
refreshInterval = 1 * time.Minute
refreshTimeout = 30 * time.Second
callbackTimeout = 30 * time.Second
templateCacheTTL = 5 * time.Minute
templateCacheRefreshInterval = 1 * time.Minute
templateCacheTimeout = 2 * time.Second

templateCacheKeyPrefix = "template:info"
)

var tracer = otel.Tracer("github.com/e2b-dev/infra/packages/api/internal/cache/templates")

func buildCacheKey(templateID, tag string) string {
return templateID + id.TagSeparator + tag
// Wrap templateID in {} so it becomes a Redis hash tag — all keys for the
// same template land on the same hash slot in Redis Cluster, enabling
// pipelined prefix deletion in InvalidateAllTags.
return fmt.Sprintf("{%s}:%s", templateID, tag)
}

// TemplateInfo holds cached template with build information
type TemplateInfo struct {
Template *api.Template
TeamID uuid.UUID
ClusterID uuid.UUID
Build *queries.EnvBuild
Tag string
Template *api.Template `json:"template"`
TeamID uuid.UUID `json:"team_id"`
ClusterID uuid.UUID `json:"cluster_id"`
Build *queries.EnvBuild `json:"build"`
Tag string `json:"tag"`
}

// TemplateCache caches template+build by templateID:tag.
// This is a simple lookup layer - resolution happens in AliasCache.
type TemplateCache struct {
cache *cache.MemoryCache[*TemplateInfo]
cache *cache.RedisCache[*TemplateInfo]
db *sqlcdb.Client
aliasCache *AliasCache
}

func NewTemplateCache(db *sqlcdb.Client) *TemplateCache {
config := cache.Config[*TemplateInfo]{
TTL: templateInfoExpiration,
RefreshInterval: refreshInterval,
RefreshTimeout: refreshTimeout,
CallbackTimeout: callbackTimeout,
func NewTemplateCache(db *sqlcdb.Client, redisClient redis.UniversalClient) *TemplateCache {
redisCache := cache.NewRedisCache[*TemplateInfo](cache.RedisConfig[*TemplateInfo]{
TTL: templateCacheTTL,
RefreshInterval: templateCacheRefreshInterval,
RedisTimeout: templateCacheTimeout,
RedisClient: redisClient,
RedisPrefix: templateCacheKeyPrefix,

ExtractKeyFunc: func(value *TemplateInfo) string {
return buildCacheKey(value.Template.TemplateID, value.Tag)
},
}
})

return &TemplateCache{
cache: cache.NewMemoryCache(config),
cache: redisCache,
db: db,
aliasCache: NewAliasCache(db),
}
Expand Down Expand Up @@ -114,72 +120,66 @@ func (c *TemplateCache) getByID(ctx context.Context, templateID string, tag *str
}
cacheKey := buildCacheKey(templateID, tagValue)

info, err := c.cache.GetOrSet(ctx, cacheKey, func(ctx context.Context, _ string) (*TemplateInfo, error) {
return c.fetchTemplateWithBuild(ctx, templateID, tag)
})
info, err := c.cache.GetOrSet(ctx, cacheKey, c.fetchTemplateWithBuild(templateID, tag))
if err != nil {
return nil, err
}

return info, nil
}

func (c *TemplateCache) fetchTemplateWithBuild(ctx context.Context, templateID string, tag *string) (*TemplateInfo, error) {
ctx, span := tracer.Start(ctx, "fetch template with build")
defer span.End()

result, err := c.db.GetTemplateWithBuildByTag(ctx, queries.GetTemplateWithBuildByTagParams{
TemplateID: templateID,
Tag: tag,
})
if err != nil {
if dberrors.IsNotFoundError(err) {
return nil, ErrTemplateNotFound
func (c *TemplateCache) fetchTemplateWithBuild(templateID string, tag *string) func(context.Context, string) (*TemplateInfo, error) {
return func(ctx context.Context, _ string) (*TemplateInfo, error) {
ctx, span := tracer.Start(ctx, "fetch template with build")
defer span.End()

result, err := c.db.GetTemplateWithBuildByTag(ctx, queries.GetTemplateWithBuildByTagParams{
TemplateID: templateID,
Tag: tag,
})
if err != nil {
if dberrors.IsNotFoundError(err) {
return nil, ErrTemplateNotFound
}

return nil, fmt.Errorf("fetching template with build: %w", err)
}

return nil, fmt.Errorf("fetching template with build: %w", err)
build := &result.EnvBuild
template := result.Env
clusterID := clusters.WithClusterFallback(template.ClusterID)

tagValue := sharedUtils.DerefOrDefault(tag, id.DefaultTag)

return &TemplateInfo{
Template: &api.Template{
TemplateID: template.ID,
BuildID: build.ID.String(),
Public: template.Public,
Aliases: result.Aliases,
Names: result.Names,
},
TeamID: template.TeamID,
ClusterID: clusterID,
Build: build,
Tag: tagValue,
}, nil
}

build := &result.EnvBuild
template := result.Env
clusterID := clusters.WithClusterFallback(template.ClusterID)

tagValue := sharedUtils.DerefOrDefault(tag, id.DefaultTag)

return &TemplateInfo{
Template: &api.Template{
TemplateID: template.ID,
BuildID: build.ID.String(),
Public: template.Public,
Aliases: result.Aliases,
Names: result.Names,
},
TeamID: template.TeamID,
ClusterID: clusterID,
Build: build,
Tag: tagValue,
}, nil
}

func (c *TemplateCache) Invalidate(templateID string, tag *string) {
func (c *TemplateCache) Invalidate(ctx context.Context, templateID string, tag *string) {
tagValue := id.DefaultTag
if tag != nil {
tagValue = *tag
}
c.cache.Delete(buildCacheKey(templateID, tagValue))
cacheKey := buildCacheKey(templateID, tagValue)
c.cache.Delete(ctx, cacheKey)
}

// InvalidateAllTags invalidates the cache for the given templateID across all tags
func (c *TemplateCache) InvalidateAllTags(templateID string) []string {
keys := make([]string, 0)
func (c *TemplateCache) InvalidateAllTags(ctx context.Context, templateID string) []string {
pattern := buildCacheKey(templateID, "")

for _, key := range c.cache.Keys() {
if strings.HasPrefix(key, pattern) {
keys = append(keys, key)
c.cache.Delete(key)
}
}
keys := c.cache.DeleteByPrefix(ctx, pattern)

c.aliasCache.InvalidateByTemplateID(templateID)

Expand Down
49 changes: 45 additions & 4 deletions packages/api/internal/cache/templates/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"github.com/stretchr/testify/require"

"github.com/e2b-dev/infra/packages/db/pkg/testutils"
"github.com/e2b-dev/infra/packages/shared/pkg/consts"
redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis"
)

// TestAliasCacheResolve_BareAliasInTeamNamespace tests that a bare alias
Expand Down Expand Up @@ -361,6 +363,7 @@ func TestAliasCache_InvalidateByTemplateID(t *testing.T) {
func TestTemplateCache_InvalidateDoesNotInvalidateAliases(t *testing.T) {
t.Parallel()
db := testutils.SetupDatabase(t)
redis := redis_utils.SetupInstance(t)
ctx := t.Context()

teamID := testutils.CreateTestTeam(t, db)
Expand All @@ -369,7 +372,7 @@ func TestTemplateCache_InvalidateDoesNotInvalidateAliases(t *testing.T) {

testutils.CreateTestTemplateAliasWithName(t, db, templateID, "alias-for-template", &teamSlug)

cache := NewTemplateCache(db.SqlcClient)
cache := NewTemplateCache(db.SqlcClient, redis)
defer cache.Close(ctx)

// Resolve alias to populate alias cache
Expand All @@ -378,7 +381,7 @@ func TestTemplateCache_InvalidateDoesNotInvalidateAliases(t *testing.T) {
require.NotNil(t, info1)

// Invalidate the template (should NOT invalidate alias cache)
cache.Invalidate(templateID, nil)
cache.Invalidate(ctx, templateID, nil)

// Next resolve should return the same cached pointer
info2, err := cache.ResolveAlias(ctx, "alias-for-template", teamSlug)
Expand All @@ -387,11 +390,49 @@ func TestTemplateCache_InvalidateDoesNotInvalidateAliases(t *testing.T) {
assert.Same(t, info1, info2)
}

// TestTemplateCache_InvalidateAllTagsDeletesRedisEntries tests that
// InvalidateAllTags deletes Redis entries.
func TestTemplateCache_InvalidateAllTagsDeletesRedisEntries(t *testing.T) {
t.Parallel()
db := testutils.SetupDatabase(t)
redisClient := redis_utils.SetupInstance(t)
ctx := t.Context()

teamID := testutils.CreateTestTeam(t, db)
templateID := testutils.CreateTestTemplate(t, db, teamID)
buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "ready")
testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "default")

tc := NewTemplateCache(db.SqlcClient, redisClient)
defer tc.Close(ctx)

// Populate the cache (this backfills into Redis via the callback)
_, _, err := tc.Get(ctx, templateID, nil, teamID, consts.LocalClusterID)
require.NoError(t, err)

// Verify the entry exists in Redis
cacheKey := buildCacheKey(templateID, "default")
redisKey := tc.cache.RedisKey(cacheKey)
val, err := redisClient.Get(ctx, redisKey).Result()
require.NoError(t, err)
require.NotEmpty(t, val)

// InvalidateAllTags should delete the entry from Redis
keys := tc.InvalidateAllTags(ctx, templateID)
require.NotEmpty(t, keys, "should have deleted at least one key")

// Verify Redis entry is gone
exists, err := redisClient.Exists(ctx, redisKey).Result()
require.NoError(t, err)
assert.Equal(t, int64(0), exists, "Redis entry should be deleted after InvalidateAllTags")
}

// TestTemplateCache_InvalidateAllTagsAlsoInvalidatesAliases tests that
// TemplateCache.InvalidateAllTags also invalidates the alias cache entries
func TestTemplateCache_InvalidateAllTagsAlsoInvalidatesAliases(t *testing.T) {
t.Parallel()
db := testutils.SetupDatabase(t)
redis := redis_utils.SetupInstance(t)
ctx := t.Context()

teamID := testutils.CreateTestTeam(t, db)
Expand All @@ -400,7 +441,7 @@ func TestTemplateCache_InvalidateAllTagsAlsoInvalidatesAliases(t *testing.T) {

testutils.CreateTestTemplateAliasWithName(t, db, templateID, "alias-all-tags", &teamSlug)

cache := NewTemplateCache(db.SqlcClient)
cache := NewTemplateCache(db.SqlcClient, redis)
defer cache.Close(ctx)

// Resolve alias to populate alias cache
Expand All @@ -409,7 +450,7 @@ func TestTemplateCache_InvalidateAllTagsAlsoInvalidatesAliases(t *testing.T) {
require.NotNil(t, info1)

// Invalidate all tags (should also invalidate alias cache)
cache.InvalidateAllTags(templateID)
cache.InvalidateAllTags(ctx, templateID)

// Next resolve should return a different pointer (fresh fetch)
info2, err := cache.ResolveAlias(ctx, "alias-all-tags", teamSlug)
Expand Down
2 changes: 1 addition & 1 deletion packages/api/internal/cache/templates/template_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ type TemplatesBuildCache struct {
}

func NewTemplateBuildCache(db *sqlcdb.Client, redisClient redis.UniversalClient) *TemplatesBuildCache {
rc := cache.NewRedisCache[TemplateBuildInfo](cache.RedisConfig{
rc := cache.NewRedisCache[TemplateBuildInfo](cache.RedisConfig[TemplateBuildInfo]{
TTL: buildCacheTTL,
RefreshInterval: buildCacheRefreshInterval,
RedisTimeout: buildCacheTimeout,
Expand Down
2 changes: 1 addition & 1 deletion packages/api/internal/handlers/sandbox_kill.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (a *APIStore) deleteSnapshot(ctx context.Context, sandboxID string, teamID
}
}(context.WithoutCancel(ctx))

a.templateCache.InvalidateAllTags(snapshot.TemplateID)
a.templateCache.InvalidateAllTags(context.WithoutCancel(ctx), snapshot.TemplateID)

return nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handlers

import (
"context"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -127,7 +128,7 @@ func (a *APIStore) PostSandboxesSandboxIDSnapshots(c *gin.Context, sandboxID api
a.templateCache.InvalidateAlias(opts.Namespace, *opts.Alias)
}

a.templateCache.Invalidate(result.TemplateID, &opts.Tag)
a.templateCache.Invalidate(context.WithoutCancel(ctx), result.TemplateID, &opts.Tag)

// Use namespace/alias when a name was provided, otherwise fall back to the raw template ID
snapshotID := id.WithTag(result.TemplateID, opts.Tag)
Expand Down
2 changes: 1 addition & 1 deletion packages/api/internal/handlers/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func NewAPIStore(ctx context.Context, tel *telemetry.Client, config cfg.Config)
}

authCache := authcache.NewTeamAuthCache()
templateCache := templatecache.NewTemplateCache(sqlcDB)
templateCache := templatecache.NewTemplateCache(sqlcDB, redisClient)
templateSpawnCounter := utils.NewTemplateSpawnCounter(ctx, time.Minute, sqlcDB)

templateBuildsCache := templatecache.NewTemplateBuildCache(sqlcDB, redisClient)
Expand Down
Loading