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
124 changes: 124 additions & 0 deletions execution/engine/federation_caching_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4654,6 +4654,130 @@ func TestL1CacheRootFieldEntityListPopulation(t *testing.T) {
})
}

func TestL1CacheRootFieldNonEntityWithNestedEntities(t *testing.T) {
// This test verifies L1 cache behavior when a root field returns a NON-entity type
// (Review) that contains nested entities (User via authorWithoutProvides).
//
// Key difference from TestL1CacheRootFieldEntityListPopulation:
// - That test starts with topProducts -> [Product] where Product IS an entity (@key(fields: "upc"))
// - This test starts with topReviews -> [Review] where Review is NOT an entity (no @key)
// - Both prove L1 entity caching works for nested User entities
//
// Query flow:
// 1. topReviews -> reviews subgraph (root query, returns [Review] — NOT an entity)
// 2. authorWithoutProvides -> accounts subgraph (entity fetch for Users, stored in L1)
// 3. sameUserReviewers -> reviews subgraph (after username resolved via @requires)
// 4. Entity resolution for sameUserReviewers -> accounts subgraph
// - All Users are 100% L1 HITs (already fetched in step 2)
// - THE ENTIRE ACCOUNTS CALL IS SKIPPED!

query := `query {
topReviews {
body
authorWithoutProvides {
id
username
sameUserReviewers {
id
username
}
}
}
}`

expectedResponse := `{"data":{"topReviews":[{"body":"A highly effective form of birth control.","authorWithoutProvides":{"id":"1234","username":"Me","sameUserReviewers":[{"id":"1234","username":"Me"}]}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","authorWithoutProvides":{"id":"1234","username":"Me","sameUserReviewers":[{"id":"1234","username":"Me"}]}},{"body":"This is the last straw. Hat you will wear. 11/10","authorWithoutProvides":{"id":"7777","username":"User 7777","sameUserReviewers":[{"id":"7777","username":"User 7777"}]}},{"body":"Perfect summer hat.","authorWithoutProvides":{"id":"5678","username":"User 5678","sameUserReviewers":[{"id":"5678","username":"User 5678"}]}},{"body":"A bit too fancy for my taste.","authorWithoutProvides":{"id":"8888","username":"User 8888","sameUserReviewers":[{"id":"8888","username":"User 8888"}]}}]}}`

t.Run("L1 enabled - sameUserReviewers fetch skipped via L1 cache", func(t *testing.T) {
tracker := newSubgraphCallTracker(http.DefaultTransport)
trackingClient := &http.Client{Transport: tracker}

cachingOpts := resolve.CachingOptions{
EnableL1Cache: true,
EnableL2Cache: false,
}

setup := federationtesting.NewFederationSetup(addCachingGateway(
withCachingEnableART(false),
withHTTPClient(trackingClient),
withCachingOptionsFunc(cachingOpts),
))
t.Cleanup(setup.Close)

gqlClient := NewGraphqlClient(http.DefaultClient)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

// Extract hostnames
reviewsURLParsed, _ := url.Parse(setup.ReviewsUpstreamServer.URL)
accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL)
reviewsHost := reviewsURLParsed.Host
accountsHost := accountsURLParsed.Host

tracker.Reset()
out, _ := gqlClient.QueryStringWithHeaders(ctx, setup.GatewayServer.URL, query, nil, t)

assert.Equal(t, expectedResponse, string(out))

// Query flow with L1 enabled:
// 1. reviews subgraph: topReviews root query (Review is NOT an entity)
// 2. accounts subgraph: User entity fetch for authorWithoutProvides (Users stored in L1)
// 3. reviews subgraph: sameUserReviewers (returns [User] references)
// 4. sameUserReviewers entity resolution: all Users are L1 HITs → accounts call SKIPPED!
reviewsCalls := tracker.GetCount(reviewsHost)
accountsCalls := tracker.GetCount(accountsHost)

assert.Equal(t, 2, reviewsCalls, "Should call reviews subgraph twice (topReviews + sameUserReviewers)")
// KEY ASSERTION: Only 1 accounts call! sameUserReviewers entity resolution skipped via L1.
assert.Equal(t, 1, accountsCalls,
"With L1 enabled: only 1 accounts call (sameUserReviewers entity fetch skipped via L1)")
})

t.Run("L1 disabled - more accounts calls without L1 optimization", func(t *testing.T) {
tracker := newSubgraphCallTracker(http.DefaultTransport)
trackingClient := &http.Client{Transport: tracker}

cachingOpts := resolve.CachingOptions{
EnableL1Cache: false,
EnableL2Cache: false,
}

setup := federationtesting.NewFederationSetup(addCachingGateway(
withCachingEnableART(false),
withHTTPClient(trackingClient),
withCachingOptionsFunc(cachingOpts),
))
t.Cleanup(setup.Close)

gqlClient := NewGraphqlClient(http.DefaultClient)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

// Extract hostnames
reviewsURLParsed, _ := url.Parse(setup.ReviewsUpstreamServer.URL)
accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL)
reviewsHost := reviewsURLParsed.Host
accountsHost := accountsURLParsed.Host

tracker.Reset()
out, _ := gqlClient.QueryStringWithHeaders(ctx, setup.GatewayServer.URL, query, nil, t)

assert.Equal(t, expectedResponse, string(out))

// Query flow with L1 disabled:
// 1. reviews subgraph: topReviews root query
// 2. accounts subgraph: User entity fetch for authorWithoutProvides
// 3. reviews subgraph: sameUserReviewers
// 4. accounts subgraph: User entity fetch for sameUserReviewers (no L1 → must fetch again!)
reviewsCalls := tracker.GetCount(reviewsHost)
accountsCalls := tracker.GetCount(accountsHost)

assert.Equal(t, 2, reviewsCalls, "Should call reviews subgraph twice")
// KEY ASSERTION: 2 accounts calls without L1 optimization
assert.Equal(t, 2, accountsCalls,
"With L1 disabled: 2 accounts calls (sameUserReviewers requires separate fetch)")
})
}

// =============================================================================
// CACHE ERROR HANDLING TESTS
// =============================================================================
Expand Down

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

4 changes: 4 additions & 0 deletions execution/federationtesting/reviews/graph/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ type Query {
# reviewWithError returns a review whose author (error-user) triggers an error in accounts subgraph.
# Used for testing cache error handling - caches should NOT be populated on errors.
reviewWithError: Review
# topReviews returns all reviews. Review is NOT an entity (no @key),
# but contains entities (author: User, product: Product).
# Used for testing L1 cache with non-entity root fields containing nested entities.
topReviews: [Review]
}

type Cat {
Expand Down

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

2 changes: 1 addition & 1 deletion execution/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/sebdah/goldie/v2 v2.7.1
github.com/stretchr/testify v1.11.1
github.com/vektah/gqlparser/v2 v2.5.30
github.com/wundergraph/astjson v1.0.0
github.com/wundergraph/astjson v1.1.0
github.com/wundergraph/cosmo/composition-go v0.0.0-20241020204711-78f240a77c99
github.com/wundergraph/cosmo/router v0.0.0-20251013094319-c611abf26b17
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.231
Expand Down
4 changes: 2 additions & 2 deletions execution/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE=
github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/wundergraph/astjson v1.0.0 h1:rETLJuQkMWWW03HCF6WBttEBOu8gi5vznj5KEUPVV2Q=
github.com/wundergraph/astjson v1.0.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLVL040=
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/cosmo/composition-go v0.0.0-20241020204711-78f240a77c99 h1:TGXDYfDhwFLFTuNuCwkuqXT5aXGz47zcurXLfTBS9w4=
github.com/wundergraph/cosmo/composition-go v0.0.0-20241020204711-78f240a77c99/go.mod h1:fUuOAUAXUFB/mlSkAaImGeE4A841AKR5dTMWhV4ibxI=
github.com/wundergraph/cosmo/router v0.0.0-20251013094319-c611abf26b17 h1:GjO2E8LTf3U5JiQJCY4MmlRcAjVt7IvAbWFSgEjQdl8=
Expand Down
29 changes: 16 additions & 13 deletions v2/pkg/engine/resolve/caching.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,23 @@ func (r *RootQueryCacheKeyTemplate) RenderCacheKeys(a arena.Arena, ctx *Context,
if len(r.RootFields) == 0 {
return nil, nil
}
// Estimate capacity: one CacheKey per item
cacheKeys := arena.AllocateSlice[*CacheKey](a, 0, len(items))
// Use heap slices for pointer-containing types (*CacheKey, string) because
// arena memory is backed by []byte (noscan) — GC cannot trace pointers stored
// in arena memory, which can cause premature collection of heap objects.
cacheKeys := make([]*CacheKey, 0, len(items))
jsonBytes := arena.AllocateSlice[byte](a, 0, 64)

for _, item := range items {
// Create KeyEntry for each root field
keyEntries := arena.AllocateSlice[string](a, 0, len(r.RootFields))
keyEntries := make([]string, 0, len(r.RootFields))
for _, field := range r.RootFields {
if len(r.EntityKeyMappings) > 0 {
// Entity key mapping configured: use entity key format INSTEAD of root field key
for _, mapping := range r.EntityKeyMappings {
entityKey, jsonBytesOut := r.renderDerivedEntityKey(a, ctx, jsonBytes, mapping, prefix)
jsonBytes = jsonBytesOut
if entityKey != "" {
keyEntries = arena.SliceAppend(a, keyEntries, entityKey)
keyEntries = append(keyEntries, entityKey)
}
// If entityKey is empty (missing arg), keyEntries stays empty → no caching
}
Expand All @@ -86,12 +88,12 @@ func (r *RootQueryCacheKeyTemplate) RenderCacheKeys(a arena.Arena, ctx *Context,
tmp = arena.SliceAppend(a, tmp, unsafebytes.StringToBytes(prefix)...)
tmp = arena.SliceAppend(a, tmp, []byte(`:`)...)
tmp = arena.SliceAppend(a, tmp, unsafebytes.StringToBytes(key)...)
key = unsafebytes.BytesToString(tmp)
key = string(tmp)
}
keyEntries = arena.SliceAppend(a, keyEntries, key)
keyEntries = append(keyEntries, key)
}
}
cacheKeys = arena.SliceAppend(a, cacheKeys, &CacheKey{
cacheKeys = append(cacheKeys, &CacheKey{
Item: item,
Keys: keyEntries,
})
Expand Down Expand Up @@ -138,7 +140,7 @@ func (r *RootQueryCacheKeyTemplate) renderDerivedEntityKey(a arena.Arena, ctx *C
slice = arena.SliceAppend(a, slice, []byte(`:`)...)
}
slice = arena.SliceAppend(a, slice, jsonBytes...)
return unsafebytes.BytesToString(slice), jsonBytes
return string(slice), jsonBytes
}

// renderField renders a single field cache key as JSON
Expand Down Expand Up @@ -202,7 +204,7 @@ func (r *RootQueryCacheKeyTemplate) renderField(a arena.Arena, ctx *Context, ite
jsonBytes = keyObj.MarshalTo(jsonBytes[:0])
slice := arena.AllocateSlice[byte](a, len(jsonBytes), len(jsonBytes))
copy(slice, jsonBytes)
return unsafebytes.BytesToString(slice), jsonBytes
return string(slice), jsonBytes
}

type EntityQueryCacheKeyTemplate struct {
Expand Down Expand Up @@ -252,7 +254,9 @@ func (e *EntityQueryCacheKeyTemplate) RenderCacheKeys(a arena.Arena, ctx *Contex
// Returns one cache key per item for entity queries with keys nested under "key".
func (e *EntityQueryCacheKeyTemplate) renderCacheKeys(a arena.Arena, ctx *Context, items []*astjson.Value, keysTemplate *ResolvableObjectVariable, prefix string) ([]*CacheKey, error) {
jsonBytes := arena.AllocateSlice[byte](a, 0, 64)
cacheKeys := arena.AllocateSlice[*CacheKey](a, 0, len(items))
// Use heap slices for pointer-containing types — arena memory is noscan,
// so GC cannot trace pointers stored there, risking premature collection.
cacheKeys := make([]*CacheKey, 0, len(items))

for _, item := range items {
if item == nil {
Expand Down Expand Up @@ -308,10 +312,9 @@ func (e *EntityQueryCacheKeyTemplate) renderCacheKeys(a arena.Arena, ctx *Contex
slice = arena.SliceAppend(a, slice, jsonBytes...)

// Create KeyEntry with empty path for entity queries
keyEntries := arena.AllocateSlice[string](a, 0, 1)
keyEntries = arena.SliceAppend(a, keyEntries, unsafebytes.BytesToString(slice))
keyEntries := []string{string(slice)}

cacheKeys = arena.SliceAppend(a, cacheKeys, &CacheKey{
cacheKeys = append(cacheKeys, &CacheKey{
Item: item,
Keys: keyEntries,
})
Expand Down
Loading