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
55 changes: 55 additions & 0 deletions .changeset/warm-melons-argue.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 15 additions & 7 deletions packages/libraries/apollo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 25 additions & 4 deletions packages/libraries/core/src/client/persisted-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
() => {
Expand Down
6 changes: 6 additions & 0 deletions packages/libraries/core/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading