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
5 changes: 5 additions & 0 deletions .changeset/unlucky-pillows-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envelop/response-cache': minor
---

Accept a factory function to `cache` that takes the context and returns the cache implementation
37 changes: 37 additions & 0 deletions .changeset/young-bears-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@envelop/response-cache-cloudflare-kv': minor
---

BREAKING: Now the cache implementation does not require the `ExecutionContext` or `KVNamespace`
instance but only the name of the namespace

```ts
import { createSchema, createYoga, YogaInitialContext } from 'graphql-yoga'
import { useResponseCache } from '@envelop/response-cache'
import { createKvCache } from '@envelop/response-cache-cloudflare-kv'
import { resolvers } from './graphql-schema/resolvers.generated'
import { typeDefs } from './graphql-schema/typeDefs.generated'

export type Env = {
GRAPHQL_RESPONSE_CACHE: KVNamespace
}

const graphqlServer = createYoga<Env & ExecutionContext>({
schema: createSchema({ typeDefs, resolvers }),
plugins: [
useResponseCache({
cache: createKvCache({
KVName: 'GRAPHQL_RESPONSE_CACHE',
keyPrefix: 'graphql' // optional
}),
session: () => null,
includeExtensionMetadata: true,
ttl: 1000 * 10 // 10 seconds
})
]
})

export default {
fetch: graphqlServer
}
```
36 changes: 15 additions & 21 deletions packages/plugins/response-cache-cloudflare-kv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,23 @@ import { typeDefs } from './graphql-schema/typeDefs.generated'
export type Env = {
GRAPHQL_RESPONSE_CACHE: KVNamespace
}
export type GraphQLContext = YogaInitialContext & Env & ExecutionContext

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const kvCache = createKvCache({
KV: env.GRAPHQL_RESPONSE_CACHE,
ctx,
keyPrefix: 'graphql' // optional
})

const graphqlServer = createYoga<GraphQLContext>({
schema: createSchema({ typeDefs, resolvers }),
plugins: [
useResponseCache({
cache: kvCache,
session: () => null,
includeExtensionMetadata: true,
ttl: 1000 * 10 // 10 seconds
})
]
const graphqlServer = createYoga<Env & ExecutionContext>({
schema: createSchema({ typeDefs, resolvers }),
plugins: [
useResponseCache({
cache: createKvCache({
KVName: 'GRAPHQL_RESPONSE_CACHE',
keyPrefix: 'graphql' // optional
}),
session: () => null,
includeExtensionMetadata: true,
ttl: 1000 * 10 // 10 seconds
})
]
})

return graphqlServer.fetch(request, env, ctx)
}
export default {
fetch: graphqlServer
}
```
114 changes: 76 additions & 38 deletions packages/plugins/response-cache-cloudflare-kv/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import type { ExecutionResult } from 'graphql';
import type { ExecutionContext, KVNamespace } from '@cloudflare/workers-types';
import type { KVNamespace } from '@cloudflare/workers-types';
import type { Cache, CacheEntityRecord } from '@envelop/response-cache';
import { buildOperationKey } from './cache-key.js';
import { invalidate } from './invalidate.js';
import { set } from './set.js';

export type KvCacheConfig = {
export type KvCacheConfig<TKVNamespaceName extends string> = {
/**
* The Cloudflare KV namespace that should be used to store the cache
* The name of the Cloudflare KV namespace that should be used to store the cache
*/
KV: KVNamespace;
/**
* The Cloudflare worker execution context. Used to perform non-blocking actions like cache storage and invalidation.
*/
ctx: ExecutionContext;
KVName: TKVNamespaceName;
/**
* Defines the length of time in milliseconds that a KV result is cached in the global network location it is accessed from.
*
Expand All @@ -35,7 +31,14 @@ export type KvCacheConfig = {
* @param config Modify the behavior of the cache as it pertains to Cloudflare KV
* @returns A cache object that can be passed to envelop's `useResponseCache` plugin
*/
export function createKvCache(config: KvCacheConfig): Cache {
export function createKvCache<
TKVNamespaceName extends string,
TServerContext extends {
[TKey in TKVNamespaceName]: KVNamespace;
} & {
waitUntil(promise: Promise<unknown>): void;
},
>(config: KvCacheConfig<TKVNamespaceName>) {
if (config.cacheReadTTL && config.cacheReadTTL < 60000) {
// eslint-disable-next-line no-console
console.warn(
Expand All @@ -44,36 +47,71 @@ export function createKvCache(config: KvCacheConfig): Cache {
}
const computedTtlInSeconds = Math.max(Math.floor((config.cacheReadTTL ?? 60000) / 1000), 60); // KV TTL must be at least 60 seconds

const cache: Cache = {
async get(id: string) {
const kvResponse = await config.KV.get(buildOperationKey(id, config.keyPrefix), {
type: 'text',
cacheTtl: computedTtlInSeconds,
});
if (kvResponse) {
return JSON.parse(kvResponse) as ExecutionResult;
}
return undefined;
},
return function KVCacheFactory(ctx: TServerContext): Cache {
Copy link
Collaborator

@EmrysMyrddin EmrysMyrddin May 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably even warn only once at the start of the function isn't it ?

Suggested change
return function KVCacheFactory(ctx: TServerContext): Cache {
return function KVCacheFactory(ctx: TServerContext): Cache {
if (!ctx[config.KVName]) {
// eslint-disable-next-line no-console
console.warn(
`Cloudflare KV namespace ${config.KVName} is not available in the server context, cache will be skipped.`,
);
return {
get: () => undefined,
set: () => undefined,
invalidate: () => undefined,
};
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know the lifecycle of CF Workers, so I thought it might be added later on after startup. I preferred to check it lazily.

return {
get(id: string) {
if (!ctx[config.KVName]) {
// eslint-disable-next-line no-console
console.warn(
`Cloudflare KV namespace ${config.KVName} is not available in the server context, skipping cache read.`,
);
return;
}
const operationKey = buildOperationKey(id, config.keyPrefix);
return ctx[config.KVName].get(operationKey, {
type: 'json',
cacheTtl: computedTtlInSeconds,
});
},

set(
/** id/hash of the operation */
id: string,
/** the result that should be cached */
data: ExecutionResult,
/** array of entity records that were collected during execution */
entities: Iterable<CacheEntityRecord>,
/** how long the operation should be cached (in milliseconds) */
ttl: number,
) {
// Do not block execution of the worker while caching the result
config.ctx.waitUntil(set(id, data, entities, ttl, config));
},
set(
/** id/hash of the operation */
id: string,
/** the result that should be cached */
data: ExecutionResult,
/** array of entity records that were collected during execution */
entities: Iterable<CacheEntityRecord>,
/** how long the operation should be cached (in milliseconds) */
ttl: number,
): void | Promise<void> {
if (!ctx[config.KVName]) {
// eslint-disable-next-line no-console
console.warn(
`Cloudflare KV namespace ${config.KVName} is not available in the server context, skipping cache write.`,
);
return;
}
const setPromise = set(id, data, entities, ttl, ctx[config.KVName], config.keyPrefix);
if (!ctx.waitUntil) {
// eslint-disable-next-line no-console
console.warn(
'The server context does not have a waitUntil method. This means that the cache write will not be non-blocking.',
);
return setPromise;
}
// Do not block execution of the worker while caching the result
ctx.waitUntil(setPromise);
},

invalidate(entities: Iterable<CacheEntityRecord>) {
// Do not block execution of the worker while invalidating the cache
config.ctx.waitUntil(invalidate(entities, config));
},
invalidate(entities: Iterable<CacheEntityRecord>): void | Promise<void> {
if (!ctx[config.KVName]) {
// eslint-disable-next-line no-console
console.warn(
`Cloudflare KV namespace ${config.KVName} is not available in the server context, skipping cache invalidate.`,
);
return;
}
const invalidatePromise = invalidate(entities, ctx[config.KVName], config.keyPrefix);
if (!ctx.waitUntil) {
// eslint-disable-next-line no-console
console.warn(
'The server context does not have a waitUntil method. This means that the cache invalidation will not be non-blocking.',
);
return invalidatePromise;
}
// Do not block execution of the worker while invalidating the cache
ctx.waitUntil(invalidatePromise);
},
};
};
return cache;
}
22 changes: 12 additions & 10 deletions packages/plugins/response-cache-cloudflare-kv/src/invalidate.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { KVNamespace } from '@cloudflare/workers-types';
import type { CacheEntityRecord } from '@envelop/response-cache';
import { buildEntityKey } from './cache-key.js';
import type { KvCacheConfig } from './index.js';

export async function invalidate(
entities: Iterable<CacheEntityRecord>,
config: KvCacheConfig,
KV: KVNamespace,
keyPrefix?: string,
): Promise<void> {
const kvPromises: Promise<unknown>[] = []; // Collecting all the KV operations so we can await them all at once
const entityInvalidationPromises: Promise<unknown>[] = []; // Parallelize invalidation of each entity

for (const entity of entities) {
entityInvalidationPromises.push(invalidateCacheEntityRecord(entity, kvPromises, config));
entityInvalidationPromises.push(invalidateCacheEntityRecord(entity, kvPromises, KV, keyPrefix));
}
await Promise.allSettled(entityInvalidationPromises);
await Promise.allSettled(kvPromises);
Expand All @@ -20,24 +21,25 @@ export async function invalidateCacheEntityRecord(
entity: CacheEntityRecord,
/** Collect all inner promises to batch await all async operations outside the function */
kvPromiseCollection: Promise<unknown>[],
config: KvCacheConfig,
KV: KVNamespace,
keyPrefix?: string,
) {
const entityKey = buildEntityKey(entity.typename, entity.id, config.keyPrefix);
const entityKey = buildEntityKey(entity.typename, entity.id, keyPrefix);

for await (const kvKey of getAllKvKeysForPrefix(entityKey, config)) {
for await (const kvKey of getAllKvKeysForPrefix(entityKey, KV)) {
if (kvKey.metadata?.operationKey) {
kvPromiseCollection.push(config.KV.delete(kvKey.metadata?.operationKey));
kvPromiseCollection.push(config.KV.delete(kvKey.name));
kvPromiseCollection.push(KV.delete(kvKey.metadata?.operationKey));
kvPromiseCollection.push(KV.delete(kvKey.name));
}
}
}

export async function* getAllKvKeysForPrefix(prefix: string, config: KvCacheConfig) {
export async function* getAllKvKeysForPrefix(prefix: string, KV: KVNamespace) {
let keyListComplete = false;
let cursor: string | undefined;

do {
const kvListResponse = await config.KV.list<{ operationKey: string }>({
const kvListResponse = await KV.list<{ operationKey: string }>({
prefix,
cursor,
});
Expand Down
13 changes: 7 additions & 6 deletions packages/plugins/response-cache-cloudflare-kv/src/set.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ExecutionResult } from 'graphql';
import { KVNamespace } from '@cloudflare/workers-types';
import type { CacheEntityRecord } from '@envelop/response-cache';
import { buildEntityKey, buildOperationKey } from './cache-key.js';
import type { KvCacheConfig } from './index.js';

export async function set(
/** id/hash of the operation */
Expand All @@ -12,15 +12,16 @@ export async function set(
entities: Iterable<CacheEntityRecord>,
/** how long the operation should be cached (in milliseconds) */
ttl: number,
config: KvCacheConfig,
KV: KVNamespace,
keyPrefix?: string,
): Promise<void> {
const ttlInSeconds = Math.max(Math.floor(ttl / 1000), 60); // KV TTL must be at least 60 seconds
const operationKey = buildOperationKey(id, config.keyPrefix);
const operationKey = buildOperationKey(id, keyPrefix);
const operationKeyWithoutPrefix = buildOperationKey(id);
const kvPromises: Promise<unknown>[] = []; // Collecting all the KV operations so we can await them all at once

kvPromises.push(
config.KV.put(operationKey, JSON.stringify(data), {
KV.put(operationKey, JSON.stringify(data), {
expirationTtl: ttlInSeconds,
metadata: { operationKey },
}),
Expand All @@ -29,9 +30,9 @@ export async function set(
// Store connections between the entities and the operation key
// E.g if the entities are User:1 and User:2, we need to know that the operation key is connected to both of them
for (const entity of entities) {
const entityKey = buildEntityKey(entity.typename, entity.id, config.keyPrefix);
const entityKey = buildEntityKey(entity.typename, entity.id, keyPrefix);
kvPromises.push(
config.KV.put(`${entityKey}:${operationKeyWithoutPrefix}`, operationKey, {
KV.put(`${entityKey}:${operationKeyWithoutPrefix}`, operationKey, {
expirationTtl: ttlInSeconds,
metadata: { operationKey },
}),
Expand Down
33 changes: 18 additions & 15 deletions packages/plugins/response-cache-cloudflare-kv/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,33 @@ type Env = {
};

describe('@envelop/response-cache-cloudflare-kv integration tests', () => {
let env: Env;
let config: KvCacheConfig;
let maxTtl: number;
let executionContext: ExecutionContext;
let cache: Cache;
const dataValue: ExecutionResult<{ key: string }, { extensions: string }> = {
errors: [],
data: { key: 'value' },
extensions: { extensions: 'value' },
};
const dataKey = '1B9502F92EFA53AFF0AC650794AA79891E4B6900';
let KV: KVNamespace;
let executionContext: ExecutionContext;
const keyPrefix = 'vitest';
const KVName = 'GRAPHQL_RESPONSE_CACHE';

beforeEach(() => {
// @ts-expect-error - Unable to get jest-environment-miniflare/globals working the test/build setup
env = getMiniflareBindings<Env>();
const env = getMiniflareBindings<Env>();
// @ts-expect-error - Unable to get jest-environment-miniflare/globals working the test/build setup
executionContext = new ExecutionContext();
config = {
KV: env.GRAPHQL_RESPONSE_CACHE,
ctx: executionContext,
keyPrefix: 'vitest',
};
KV = env[KVName];
maxTtl = 60 * 1000; // 1 minute
cache = createKvCache(config);
cache = createKvCache({
KVName,
keyPrefix,
})({
GRAPHQL_RESPONSE_CACHE: KV,
waitUntil: executionContext.waitUntil.bind(executionContext),
});
});

test('should work with a basic set() and get()', async () => {
Expand All @@ -49,15 +52,15 @@ describe('@envelop/response-cache-cloudflare-kv integration tests', () => {
const result = await cache.get(dataKey);
expect(result).toEqual(dataValue);

const operationKey = buildOperationKey(dataKey, config.keyPrefix);
const operationValue = await env.GRAPHQL_RESPONSE_CACHE.get(operationKey, 'text');
const operationKey = buildOperationKey(dataKey, keyPrefix);
const operationValue = await KV.get(operationKey, 'text');
expect(operationValue).toBeTruthy();
expect(JSON.parse(operationValue!)).toEqual(dataValue);
});

test('should return null when calling get() on a non-existent key', async () => {
const result = await cache.get(dataKey);
expect(result).toBeUndefined();
expect(result).toBeFalsy();
});

test('should return null when calling get() on an invalidated key', async () => {
Expand All @@ -75,9 +78,9 @@ describe('@envelop/response-cache-cloudflare-kv integration tests', () => {
await getMiniflareWaitUntil(executionContext);

const result = await cache.get(dataKey);
expect(result).toBeUndefined();
expect(result).toBeFalsy();

const allKeys = await config.KV.list();
const allKeys = await KV.list();
expect(allKeys.keys.length).toEqual(0);
});
});
Loading