fix(apollo): fix l2 cache config and add waitUntil support (#7462)

This commit is contained in:
Adam Benhassen 2026-01-07 14:45:10 +02:00 committed by GitHub
parent d00dd221ec
commit 60133a41a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 101 additions and 11 deletions

View file

@ -0,0 +1,55 @@
---
'@graphql-hive/core': minor
'@graphql-hive/yoga': minor
'@graphql-hive/apollo': minor
---
Add Layer 2 (L2) cache support for persisted documents.
This feature adds a second layer of caching between the in-memory cache (L1) and the CDN for persisted documents. This is particularly useful for:
- **Serverless environments**: Where in-memory cache is lost between invocations
- **Multi-instance deployments**: To share cached documents across server instances
- **Reducing CDN calls**: By caching documents in Redis or similar external caches
The lookup flow is: L1 (memory) -> L2 (Redis/external) -> CDN
**Example with GraphQL Yoga:**
```typescript
import { createYoga } from 'graphql-yoga'
import { useHive } from '@graphql-hive/yoga'
import { createClient } from 'redis'
const redis = createClient({ url: 'redis://localhost:6379' })
await redis.connect()
const yoga = createYoga({
plugins: [
useHive({
experimental__persistedDocuments: {
cdn: {
endpoint: 'https://cdn.graphql-hive.com/artifacts/v1/<target_id>',
accessToken: '<cdn_access_token>'
},
layer2Cache: {
cache: {
get: (key) => redis.get(`hive:pd:${key}`),
set: (key, value, opts) =>
redis.set(`hive:pd:${key}`, value, opts?.ttl ? { EX: opts.ttl } : {})
},
ttlSeconds: 3600, // 1 hour for found documents
notFoundTtlSeconds: 60 // 1 minute for not-found (negative caching)
}
}
})
]
})
```
**Features:**
- Configurable TTL for found documents (`ttlSeconds`)
- Configurable TTL for negative caching (`notFoundTtlSeconds`)
- Graceful fallback to CDN if L2 cache fails
- Support for `waitUntil` in serverless environments
- Apollo Server integration auto-uses context cache if available

View file

@ -193,13 +193,16 @@ export function createHive(clientOrOptions: HivePluginOptions, ctx?: GraphQLServ
experimental__persistedDocuments: clientOrOptions.experimental__persistedDocuments
? {
...clientOrOptions.experimental__persistedDocuments,
layer2Cache:
persistedDocumentsCache || clientOrOptions.experimental__persistedDocuments.layer2Cache
? {
cache: persistedDocumentsCache!,
...(clientOrOptions.experimental__persistedDocuments.layer2Cache || {}),
}
: undefined,
layer2Cache: (() => {
const userL2Config = clientOrOptions.experimental__persistedDocuments?.layer2Cache;
if (persistedDocumentsCache) {
return {
cache: persistedDocumentsCache,
...(userL2Config || {}),
};
}
return userL2Config;
})(),
}
: undefined,
});
@ -312,8 +315,13 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo
) {
persistedDocumentHash = context.request.http.body.documentId;
try {
// Pass waitUntil from context if available for serverless environments
const contextValue = isLegacyV3
? (context as any).context
: (context as any).contextValue;
const document = await hive.experimental__persistedDocuments.resolve(
context.request.http.body.documentId,
{ waitUntil: contextValue?.waitUntil },
);
if (document) {

View file

@ -112,10 +112,27 @@ export function createPersistedDocuments(
// L2
const layer2Cache: PersistedDocumentsCache | undefined = config.layer2Cache?.cache;
const layer2TtlSeconds = config.layer2Cache?.ttlSeconds;
const layer2NotFoundTtlSeconds = config.layer2Cache?.notFoundTtlSeconds ?? 60;
let layer2TtlSeconds = config.layer2Cache?.ttlSeconds;
let layer2NotFoundTtlSeconds: number | undefined = config.layer2Cache?.notFoundTtlSeconds ?? 60;
const layer2KeyPrefix = config.layer2Cache?.keyPrefix ?? '';
const layer2WaitUntil = config.layer2Cache?.waitUntil;
// Validate L2 cache options
if (layer2TtlSeconds !== undefined && layer2TtlSeconds < 0) {
config.logger.warn(
'Negative ttlSeconds (%d) provided for L2 cache; treating as no expiration',
layer2TtlSeconds,
);
layer2TtlSeconds = undefined;
}
if (layer2NotFoundTtlSeconds !== undefined && layer2NotFoundTtlSeconds < 0) {
config.logger.warn(
'Negative notFoundTtlSeconds (%d) provided for L2 cache; treating as no expiration',
layer2NotFoundTtlSeconds,
);
layer2NotFoundTtlSeconds = undefined;
}
let allowArbitraryDocuments: (context: { headers?: HeadersObject }) => PromiseOrValue<boolean>;
if (typeof config.allowArbitraryDocuments === 'boolean') {
@ -178,7 +195,7 @@ export function createPersistedDocuments(
let cached: string | typeof PERSISTED_DOCUMENT_NOT_FOUND | null;
try {
cached = await layer2Cache.get(documentId);
cached = await layer2Cache.get(layer2KeyPrefix + documentId);
} catch (error) {
// L2 cache failure should not break the request
config.logger.warn('L2 cache get failed for document %s: %O', documentId, error);
@ -220,7 +237,11 @@ export function createPersistedDocuments(
const ttl = value === null ? layer2NotFoundTtlSeconds : layer2TtlSeconds;
// Fire-and-forget. don't await, don't block
const setPromise = layer2Cache.set(documentId, cacheValue, ttl ? { ttl } : undefined);
const setPromise = layer2Cache.set(
layer2KeyPrefix + documentId,
cacheValue,
ttl ? { ttl } : undefined,
);
if (setPromise) {
const handledPromise: Promise<void> = Promise.resolve(setPromise).then(
() => {

View file

@ -378,6 +378,12 @@ export type Layer2CacheConfiguration = {
*/
notFoundTtlSeconds?: number;
/**
* Key prefix for cached persisted documents.
* @default "" (no prefix)
*/
keyPrefix?: string;
/**
* Optional function to register background work in serverless environments if not available in context.
*/