diff --git a/.changeset/warm-melons-argue.md b/.changeset/warm-melons-argue.md new file mode 100644 index 00000000000..adfcd8d367f --- /dev/null +++ b/.changeset/warm-melons-argue.md @@ -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/', + accessToken: '' + }, + 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 diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index 45085471a2a..ca6e76653d3 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -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) { diff --git a/packages/libraries/core/src/client/persisted-documents.ts b/packages/libraries/core/src/client/persisted-documents.ts index 16492f0bdfa..98d2fb1e2ae 100644 --- a/packages/libraries/core/src/client/persisted-documents.ts +++ b/packages/libraries/core/src/client/persisted-documents.ts @@ -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; 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 = Promise.resolve(setPromise).then( () => { diff --git a/packages/libraries/core/src/client/types.ts b/packages/libraries/core/src/client/types.ts index eb4a97b936b..13b515c7231 100644 --- a/packages/libraries/core/src/client/types.ts +++ b/packages/libraries/core/src/client/types.ts @@ -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. */