Skip to content

Commit 4bce8f5

Browse files
Milinda Diasclaude
authored andcommitted
feat(router): entity caching with L1/L2, shadow mode, and per-request cache controls
Extracted from jensneuse/entity-caching-v2 (PR #2777) — router layer, stacked on the demo PR. Entity cache OTEL/Prometheus metrics are split into the next PR. - router/pkg/entitycache: in-memory L1, Redis L2, circuit breaker - router/core: factoryresolver mapping of proto cache config to planner metadata, graph server wiring, per-request cache-control headers (incl. WS subscriptions), EntityCacheKeyInterceptor module hook - graphql-go-tools pinned to 63fa1c88 (PR #1259 + v2.4.4 merged via entity-caching-v244-merge branch) — this also moves astjson to the caching arena rework (MergeValuesWithPath now returns 2 values; flushwriter adapted) - router-tests/entity_caching: integration suite consuming demo's cachetest subgraphs; config composed via wgc (composition-go was removed on main), testdata/config.json regenerated with main's composition - subscription-overhaul adaptation shims from the old base were dropped in favor of main's versions (websocket, flushwriter, executor, updater, mocks) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 49e0e69 commit 4bce8f5

42 files changed

Lines changed: 7865 additions & 57 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
package integration
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/wundergraph/cosmo/router-tests/testenv"
9+
"github.com/wundergraph/cosmo/router/core"
10+
nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1"
11+
"github.com/wundergraph/cosmo/router/pkg/config"
12+
"github.com/wundergraph/cosmo/router/pkg/entitycache"
13+
"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve"
14+
)
15+
16+
func newEntityMemoryCache(t *testing.T) *entitycache.MemoryEntityCache {
17+
t.Helper()
18+
c, err := entitycache.NewMemoryEntityCache(10 * 1024 * 1024) // 10MB for tests
19+
require.NoError(t, err)
20+
t.Cleanup(func() { _ = c.Close() })
21+
return c
22+
}
23+
24+
// entityCachingConfig returns RouterOptions that enable entity caching with
25+
// the given MemoryEntityCache as the default L2 cache.
26+
func entityCachingConfig(cache *entitycache.MemoryEntityCache) []core.Option {
27+
return []core.Option{
28+
core.WithEntityCaching(config.EntityCachingConfiguration{
29+
Enabled: true,
30+
L1: config.EntityCachingL1Configuration{
31+
Enabled: true,
32+
},
33+
L2: config.EntityCachingL2Configuration{
34+
Enabled: true,
35+
},
36+
}),
37+
core.WithEntityCacheInstances(map[string]resolve.LoaderCache{
38+
"default": cache,
39+
}),
40+
}
41+
}
42+
43+
// addEntityCacheConfig adds entity cache configuration to all datasources
44+
// in the router config with the given TTL in seconds.
45+
func addEntityCacheConfig(routerConfig *nodev1.RouterConfig, ttlSeconds int64) {
46+
for _, ds := range routerConfig.EngineConfig.DatasourceConfigurations {
47+
for _, key := range ds.Keys {
48+
if key.DisableEntityResolver {
49+
continue
50+
}
51+
ds.EntityCacheConfigurations = append(ds.EntityCacheConfigurations, &nodev1.EntityCacheConfiguration{
52+
TypeName: key.TypeName,
53+
MaxAgeSeconds: ttlSeconds,
54+
})
55+
}
56+
}
57+
}
58+
59+
func TestEntityCaching(t *testing.T) {
60+
t.Parallel()
61+
62+
// Cross-subgraph query: employee root from employees subgraph,
63+
// products field resolved by products subgraph via _entities.
64+
// Entity caching intercepts the _entities call.
65+
const crossSubgraphQuery = `{ employee(id: 1) { id products } }`
66+
67+
t.Run("basic L2 miss then hit", func(t *testing.T) {
68+
t.Parallel()
69+
70+
cache := newEntityMemoryCache(t)
71+
testenv.Run(t, &testenv.Config{
72+
RouterOptions: entityCachingConfig(cache),
73+
ModifyRouterConfig: func(routerConfig *nodev1.RouterConfig) {
74+
addEntityCacheConfig(routerConfig, 300)
75+
},
76+
}, func(t *testing.T, xEnv *testenv.Environment) {
77+
// First request: cache miss, both employees and products subgraphs called
78+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: crossSubgraphQuery})
79+
require.Contains(t, res.Body, `"products"`)
80+
81+
productsCountAfterFirst := xEnv.SubgraphRequestCount.Products.Load()
82+
require.Equal(t, int64(1), productsCountAfterFirst)
83+
84+
// Second request: entity cache hit, products subgraph NOT called again
85+
res2 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: crossSubgraphQuery})
86+
require.Equal(t, res.Body, res2.Body)
87+
require.Equal(t, int64(1), xEnv.SubgraphRequestCount.Products.Load())
88+
})
89+
})
90+
91+
t.Run("different entities produce separate cache entries", func(t *testing.T) {
92+
t.Parallel()
93+
94+
cache := newEntityMemoryCache(t)
95+
testenv.Run(t, &testenv.Config{
96+
RouterOptions: entityCachingConfig(cache),
97+
ModifyRouterConfig: func(routerConfig *nodev1.RouterConfig) {
98+
addEntityCacheConfig(routerConfig, 300)
99+
},
100+
}, func(t *testing.T, xEnv *testenv.Environment) {
101+
// Fetch employee 1 products
102+
res1 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
103+
Query: `{ employee(id: 1) { id products } }`,
104+
})
105+
require.Contains(t, res1.Body, `"products"`)
106+
107+
// Fetch employee 3 products (different entity — cache miss)
108+
res2 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
109+
Query: `{ employee(id: 3) { id products } }`,
110+
})
111+
require.Contains(t, res2.Body, `"products"`)
112+
113+
// Products subgraph called twice (once per distinct employee)
114+
require.Equal(t, int64(2), xEnv.SubgraphRequestCount.Products.Load())
115+
116+
// Now re-fetch employee 1 — should be cached
117+
res3 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
118+
Query: `{ employee(id: 1) { id products } }`,
119+
})
120+
require.Equal(t, res1.Body, res3.Body)
121+
require.Equal(t, int64(2), xEnv.SubgraphRequestCount.Products.Load())
122+
})
123+
})
124+
125+
t.Run("multi-subgraph entity caching", func(t *testing.T) {
126+
t.Parallel()
127+
128+
cache := newEntityMemoryCache(t)
129+
testenv.Run(t, &testenv.Config{
130+
RouterOptions: entityCachingConfig(cache),
131+
ModifyRouterConfig: func(routerConfig *nodev1.RouterConfig) {
132+
addEntityCacheConfig(routerConfig, 300)
133+
},
134+
}, func(t *testing.T, xEnv *testenv.Environment) {
135+
// First query hits products subgraph via _entities
136+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
137+
Query: `{ employee(id: 1) { id products } }`,
138+
})
139+
require.Contains(t, res.Body, `"products"`)
140+
require.Equal(t, int64(1), xEnv.SubgraphRequestCount.Products.Load())
141+
142+
// Second query hits availability subgraph via _entities
143+
res2 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
144+
Query: `{ employee(id: 1) { id isAvailable } }`,
145+
})
146+
require.Contains(t, res2.Body, `"isAvailable"`)
147+
require.Equal(t, int64(1), xEnv.SubgraphRequestCount.Availability.Load())
148+
149+
// Re-fetch both: products and availability should be cached
150+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
151+
Query: `{ employee(id: 1) { id products } }`,
152+
})
153+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
154+
Query: `{ employee(id: 1) { id isAvailable } }`,
155+
})
156+
require.Equal(t, int64(1), xEnv.SubgraphRequestCount.Products.Load())
157+
require.Equal(t, int64(1), xEnv.SubgraphRequestCount.Availability.Load())
158+
})
159+
})
160+
161+
t.Run("per-subgraph cache name routes to separate instances", func(t *testing.T) {
162+
t.Parallel()
163+
164+
defaultCache := newEntityMemoryCache(t)
165+
customCache := newEntityMemoryCache(t)
166+
167+
testenv.Run(t, &testenv.Config{
168+
RouterOptions: []core.Option{
169+
core.WithEntityCaching(config.EntityCachingConfiguration{
170+
Enabled: true,
171+
L1: config.EntityCachingL1Configuration{Enabled: true},
172+
L2: config.EntityCachingL2Configuration{Enabled: true},
173+
SubgraphCacheOverrides: []config.EntityCachingSubgraphCacheOverride{
174+
{
175+
Name: "products",
176+
Entities: []config.EntityCachingEntityConfig{
177+
{Type: "Employee", StorageProviderID: "custom"},
178+
},
179+
},
180+
},
181+
}),
182+
core.WithEntityCacheInstances(map[string]resolve.LoaderCache{
183+
"default": defaultCache,
184+
"custom": customCache,
185+
}),
186+
},
187+
ModifyRouterConfig: func(routerConfig *nodev1.RouterConfig) {
188+
addEntityCacheConfig(routerConfig, 300)
189+
},
190+
}, func(t *testing.T, xEnv *testenv.Environment) {
191+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
192+
Query: crossSubgraphQuery,
193+
})
194+
require.Contains(t, res.Body, `"products"`)
195+
196+
// The custom cache should have entries (Employee on products routed to "custom")
197+
require.Equal(t, 1, customCache.Len())
198+
})
199+
})
200+
201+
t.Run("shadow mode always fetches from subgraph", func(t *testing.T) {
202+
t.Parallel()
203+
204+
cache := newEntityMemoryCache(t)
205+
testenv.Run(t, &testenv.Config{
206+
RouterOptions: entityCachingConfig(cache),
207+
ModifyRouterConfig: func(routerConfig *nodev1.RouterConfig) {
208+
for _, ds := range routerConfig.EngineConfig.DatasourceConfigurations {
209+
for _, key := range ds.Keys {
210+
if key.DisableEntityResolver {
211+
continue
212+
}
213+
ds.EntityCacheConfigurations = append(ds.EntityCacheConfigurations, &nodev1.EntityCacheConfiguration{
214+
TypeName: key.TypeName,
215+
MaxAgeSeconds: 300,
216+
ShadowMode: true,
217+
})
218+
}
219+
}
220+
},
221+
}, func(t *testing.T, xEnv *testenv.Environment) {
222+
// First request
223+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: crossSubgraphQuery})
224+
require.Contains(t, res.Body, `"products"`)
225+
productsFirst := xEnv.SubgraphRequestCount.Products.Load()
226+
227+
// Second request: in shadow mode, subgraph ALWAYS called
228+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: crossSubgraphQuery})
229+
require.Equal(t, productsFirst+1, xEnv.SubgraphRequestCount.Products.Load())
230+
})
231+
})
232+
233+
t.Run("list query with caching", func(t *testing.T) {
234+
t.Parallel()
235+
236+
cache := newEntityMemoryCache(t)
237+
testenv.Run(t, &testenv.Config{
238+
RouterOptions: entityCachingConfig(cache),
239+
ModifyRouterConfig: func(routerConfig *nodev1.RouterConfig) {
240+
addEntityCacheConfig(routerConfig, 300)
241+
},
242+
}, func(t *testing.T, xEnv *testenv.Environment) {
243+
// List query that fetches multiple employees with cross-subgraph products
244+
query := `{ employees { id products } }`
245+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: query})
246+
require.Contains(t, res.Body, `"employees"`)
247+
productsFirst := xEnv.SubgraphRequestCount.Products.Load()
248+
require.Equal(t, int64(1), productsFirst)
249+
250+
// Second list query: all _entities calls should be cached
251+
res2 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: query})
252+
require.Equal(t, res.Body, res2.Body)
253+
require.Equal(t, int64(1), xEnv.SubgraphRequestCount.Products.Load())
254+
})
255+
})
256+
257+
t.Run("disabled caching does not cache", func(t *testing.T) {
258+
t.Parallel()
259+
260+
testenv.Run(t, &testenv.Config{
261+
// No entity caching options
262+
}, func(t *testing.T, xEnv *testenv.Environment) {
263+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: crossSubgraphQuery})
264+
require.Contains(t, res.Body, `"products"`)
265+
productsFirst := xEnv.SubgraphRequestCount.Products.Load()
266+
267+
// Second request: products subgraph called again (no caching)
268+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: crossSubgraphQuery})
269+
require.Equal(t, productsFirst+1, xEnv.SubgraphRequestCount.Products.Load())
270+
})
271+
})
272+
273+
t.Run("cache entries written to L2", func(t *testing.T) {
274+
t.Parallel()
275+
276+
cache := newEntityMemoryCache(t)
277+
testenv.Run(t, &testenv.Config{
278+
RouterOptions: entityCachingConfig(cache),
279+
ModifyRouterConfig: func(routerConfig *nodev1.RouterConfig) {
280+
addEntityCacheConfig(routerConfig, 300)
281+
},
282+
}, func(t *testing.T, xEnv *testenv.Environment) {
283+
require.Equal(t, 0, cache.Len())
284+
285+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: crossSubgraphQuery})
286+
287+
// After first request, cache should have entries
288+
require.Equal(t, 1, cache.Len())
289+
})
290+
})
291+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.PHONY: compose
2+
3+
# Composes the entity caching test config via wgc (composition-go was removed
4+
# from the repo; wgc is the single compose path now).
5+
compose:
6+
pnpm dlx tsx ../../cli/src/index.ts router compose -i graph.yaml -o testdata/config.json
7+
jq . testdata/config.json > testdata/config.json.tmp
8+
mv testdata/config.json.tmp testdata/config.json

0 commit comments

Comments
 (0)