Skip to content

Commit

Permalink
feat: [GH-177] Custom Cache Set Options (#337)
Browse files Browse the repository at this point in the history
To be able to support custom key value caches that have additional cache set
options, add an optional generic argument `KeyValueCacheSetOptions` to be
able to specify a custom cache set option type.

Co-authored-by: Hisham Ali <hisham@otofacts.com>
Co-authored-by: Trevor Scheer <trevor.scheer@gmail.com>
  • Loading branch information
3 people committed Sep 14, 2023
1 parent d92a68f commit e02f708
Show file tree
Hide file tree
Showing 11 changed files with 149 additions and 36 deletions.
6 changes: 6 additions & 0 deletions .changeset/yellow-sloths-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@apollo/utils.keyvaluecache": minor
"@apollo/utils.keyvadapter": minor
---

Updated the KeyValueCache.KeyValueCacheSetOptions type to be configurable, to be able to support custom key value caches that require additional cache set options.
7 changes: 5 additions & 2 deletions packages/keyValueCache/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# KeyValueCache interface

```ts
export interface KeyValueCache<V = string> {
export interface KeyValueCache<
V = string,
SO extends KeyValueCacheSetOptions = KeyValueCacheSetOptions,
> {
get(key: string): Promise<V | undefined>;
set(key: string, value: V, options?: KeyValueCacheSetOptions): Promise<void>;
set(key: string, value: V, options?: SO): Promise<void>;
delete(key: string): Promise<boolean | void>;
}
```
Expand Down
12 changes: 8 additions & 4 deletions packages/keyValueCache/src/ErrorsAreMissesCache.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type { KeyValueCache } from "./KeyValueCache";
import type { KeyValueCache, KeyValueCacheSetOptions } from "./KeyValueCache";
import type { Logger } from "@apollo/utils.logger";

/**
* This cache wraps a KeyValueCache and returns undefined (a cache miss) for any
* errors thrown by the underlying cache. You can also provide a logger to
* capture these errors rather than just swallow them.
*/
export class ErrorsAreMissesCache<V = string> implements KeyValueCache<V> {
export class ErrorsAreMissesCache<
V = string,
SO extends KeyValueCacheSetOptions = KeyValueCacheSetOptions,
> implements KeyValueCache<V, SO>
{
constructor(
private cache: KeyValueCache<V>,
private cache: KeyValueCache<V, SO>,
private logger?: Logger,
) {}

Expand All @@ -27,7 +31,7 @@ export class ErrorsAreMissesCache<V = string> implements KeyValueCache<V> {
}
}

async set(key: string, value: V, opts?: { ttl?: number }): Promise<void> {
async set(key: string, value: V, opts?: SO): Promise<void> {
return this.cache.set(key, value, opts);
}

Expand Down
24 changes: 16 additions & 8 deletions packages/keyValueCache/src/InMemoryLRUCache.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { LRUCache } from "lru-cache";
import type { KeyValueCache, KeyValueCacheSetOptions } from "./KeyValueCache";

export type InMemoryLRUCacheSetOptions<
V extends {} = string,
FC = unknown,
> = Omit<LRUCache.SetOptions<string, V, FC>, "ttl"> & KeyValueCacheSetOptions;

// LRUCache wrapper to implement the KeyValueCache interface.
export class InMemoryLRUCache<V extends {} = string>
implements KeyValueCache<V>
export class InMemoryLRUCache<
V extends {} = string,
SO extends InMemoryLRUCacheSetOptions<V> = InMemoryLRUCacheSetOptions<V>,
> implements KeyValueCache<V, SO>
{
private cache: LRUCache<string, V>;

Expand Down Expand Up @@ -32,12 +39,13 @@ export class InMemoryLRUCache<V extends {} = string>
return 1;
}

async set(key: string, value: V, options?: KeyValueCacheSetOptions) {
if (options?.ttl) {
this.cache.set(key, value, { ttl: options.ttl * 1000 });
} else {
this.cache.set(key, value);
}
async set(key: string, value: V, options?: SO) {
// If a TTL in seconds is provided, convert it to milliseconds.
// Otherwise, default it to 0 to indicate "no TTL".
const lruOptions = options
? { ...options, ttl: options.ttl ? options.ttl * 1000 : 0 }
: undefined;
this.cache.set(key, value, lruOptions);
}

async get(key: string) {
Expand Down
7 changes: 5 additions & 2 deletions packages/keyValueCache/src/KeyValueCache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export interface KeyValueCache<V = string> {
export interface KeyValueCache<
V = string,
SO extends KeyValueCacheSetOptions = KeyValueCacheSetOptions,
> {
get(key: string): Promise<V | undefined>;
set(key: string, value: V, options?: KeyValueCacheSetOptions): Promise<void>;
set(key: string, value: V, options?: SO): Promise<void>;
delete(key: string): Promise<boolean | void>;
}

Expand Down
34 changes: 21 additions & 13 deletions packages/keyValueCache/src/PrefixingKeyValueCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ const prefixesAreUnnecessaryForIsolationSymbol = Symbol(
// send a simple command that wipes the entire backend cache system, which
// wouldn't support "only wipe the part of the cache with this prefix", so
// trying to provide a flush() method here could be confusingly dangerous.
export class PrefixingKeyValueCache<V = string> implements KeyValueCache<V> {
export class PrefixingKeyValueCache<
V = string,
SO extends KeyValueCacheSetOptions = KeyValueCacheSetOptions,
> implements KeyValueCache<V, SO>
{
private prefix: string;
[prefixesAreUnnecessaryForIsolationSymbol]?: true;

constructor(
private wrapped: KeyValueCache<V>,
private wrapped: KeyValueCache<V, SO>,
prefix: string,
) {
if (PrefixingKeyValueCache.prefixesAreUnnecessaryForIsolation(wrapped)) {
Expand All @@ -37,7 +41,7 @@ export class PrefixingKeyValueCache<V = string> implements KeyValueCache<V> {
get(key: string) {
return this.wrapped.get(this.prefix + key);
}
set(key: string, value: V, options?: KeyValueCacheSetOptions) {
set(key: string, value: V, options?: SO) {
return this.wrapped.set(this.prefix + key, value, options);
}
delete(key: string) {
Expand All @@ -47,32 +51,36 @@ export class PrefixingKeyValueCache<V = string> implements KeyValueCache<V> {
// Checks to see if a cache is a PrefixesAreUnnecessaryForIsolationCache,
// without using instanceof (so that installing multiple copies of this
// package doesn't break things).
static prefixesAreUnnecessaryForIsolation<V = string>(
c: KeyValueCache<V>,
): boolean {
static prefixesAreUnnecessaryForIsolation<
V = string,
SO extends KeyValueCacheSetOptions = KeyValueCacheSetOptions,
>(c: KeyValueCache<V, SO>): boolean {
return prefixesAreUnnecessaryForIsolationSymbol in c;
}

static cacheDangerouslyDoesNotNeedPrefixesForIsolation<V = string>(
c: KeyValueCache<V>,
): KeyValueCache<V> {
static cacheDangerouslyDoesNotNeedPrefixesForIsolation<
V = string,
SO extends KeyValueCacheSetOptions = KeyValueCacheSetOptions,
>(c: KeyValueCache<V, SO>): KeyValueCache<V, SO> {
return new PrefixesAreUnnecessaryForIsolationCache(c);
}
}

// This class lets you opt a cache out of the prefixing provided by
// PrefixingKeyValueCache. See the README for details.
class PrefixesAreUnnecessaryForIsolationCache<V = string>
implements KeyValueCache<V>
class PrefixesAreUnnecessaryForIsolationCache<
V = string,
SO extends KeyValueCacheSetOptions = KeyValueCacheSetOptions,
> implements KeyValueCache<V, SO>
{
[prefixesAreUnnecessaryForIsolationSymbol] = true;

constructor(private wrapped: KeyValueCache<V>) {}
constructor(private wrapped: KeyValueCache<V, SO>) {}

get(key: string) {
return this.wrapped.get(key);
}
set(key: string, value: V, options?: KeyValueCacheSetOptions) {
set(key: string, value: V, options?: SO) {
return this.wrapped.set(key, value, options);
}
delete(key: string) {
Expand Down
19 changes: 15 additions & 4 deletions packages/keyValueCache/src/__tests__/ErrorsAreMissesCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { Logger } from "@apollo/utils.logger";
import { ErrorsAreMissesCache } from "../ErrorsAreMissesCache";
import type { KeyValueCache } from "../KeyValueCache";
import type { KeyValueCache, KeyValueCacheSetOptions } from "../KeyValueCache";

interface CustomKeyValueCacheSetOptions extends KeyValueCacheSetOptions {
noDisposeOnSet?: boolean;
noUpdateTTL?: boolean;
}

describe("ErrorsAreMissesCache", () => {
const knownErrorMessage = "Service is down";
Expand Down Expand Up @@ -35,7 +40,7 @@ describe("ErrorsAreMissesCache", () => {
});

it("passes through calls to the underlying cache", async () => {
const mockCache: KeyValueCache = {
const mockCache: KeyValueCache<string, CustomKeyValueCacheSetOptions> = {
get: jest.fn(async () => "foo"),
set: jest.fn(),
delete: jest.fn(),
Expand All @@ -47,9 +52,15 @@ describe("ErrorsAreMissesCache", () => {

await errorsAreMisses.set("key", "foo");
expect(mockCache.set).toHaveBeenCalledWith("key", "foo", undefined);
await errorsAreMisses.set("keyWithTTL", "foo", { ttl: 1000 });
expect(mockCache.set).toHaveBeenLastCalledWith("keyWithTTL", "foo", {
await errorsAreMisses.set("keyWithOptions", "foo", {
ttl: 1000,
noDisposeOnSet: true,
noUpdateTTL: true,
});
expect(mockCache.set).toHaveBeenLastCalledWith("keyWithOptions", "foo", {
ttl: 1000,
noDisposeOnSet: true,
noUpdateTTL: true,
});

await errorsAreMisses.delete("key");
Expand Down
34 changes: 34 additions & 0 deletions packages/keyValueCache/src/__tests__/InMemoryLRUCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { LRUCache } from "lru-cache";
import { InMemoryLRUCache } from "..";

interface CustomKeyValueCacheSetOptions
extends LRUCache.SetOptions<string, string, Record<string, string>> {
tags?: string[];
}

describe("InMemoryLRUCache", () => {
const cache = new InMemoryLRUCache();

Expand Down Expand Up @@ -40,6 +46,34 @@ describe("InMemoryLRUCache", () => {
await sleep(1000);
expect(await cache.get("forever")).toEqual("yours");
});

it("with custom extended options", async () => {
const customCache = new InMemoryLRUCache<
string,
CustomKeyValueCacheSetOptions
>();
const spyOnCacheSet = jest.spyOn((customCache as any).cache, "set");

await customCache.set("key", "foo");
expect(spyOnCacheSet).toBeCalledWith("key", "foo", undefined);

expect(await customCache.get("key")).toBe("foo");
await customCache.delete("key");
expect(await customCache.get("key")).toBe(undefined);

await customCache.set("keyWithOptions", "bar", {
ttl: 1000,
tags: ["tag1", "tag2"],
});
expect(spyOnCacheSet).toBeCalledWith("keyWithOptions", "bar", {
ttl: 1000000,
tags: ["tag1", "tag2"],
});

expect(await customCache.get("keyWithOptions")).toBe("bar");
await customCache.delete("keyWithOptions");
expect(await customCache.get("keyWithOptions")).toBe(undefined);
});
});

async function sleep(ms: number) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { InMemoryLRUCache } from "..";
import { InMemoryLRUCache, type InMemoryLRUCacheSetOptions } from "..";
import { PrefixingKeyValueCache } from "../PrefixingKeyValueCache";

interface CustomKeyValueCacheSetOptions extends InMemoryLRUCacheSetOptions {
tags?: string[];
}

describe("PrefixingKeyValueCache", () => {
it("prefixes", async () => {
const inner = new InMemoryLRUCache();
Expand All @@ -11,6 +15,7 @@ describe("PrefixingKeyValueCache", () => {
await prefixing.delete("foo");
expect(await prefixing.get("foo")).toBe(undefined);
});

it("PrefixesAreUnnecessaryForIsolationCache", async () => {
const inner = new InMemoryLRUCache();
const prefixesAreUnnecessaryForIsolationCache =
Expand Down Expand Up @@ -45,4 +50,32 @@ describe("PrefixingKeyValueCache", () => {
PrefixingKeyValueCache.prefixesAreUnnecessaryForIsolation(prefixing),
).toBe(true);
});

it("prefixes with custom extended options", async () => {
const inner = new InMemoryLRUCache<string, CustomKeyValueCacheSetOptions>();
const spyOnCacheSet = jest.spyOn(inner, "set");
const prefixing = new PrefixingKeyValueCache(inner, "prefix:");

await prefixing.set("key", "foo");
expect(spyOnCacheSet).toBeCalledWith("prefix:key", "foo", undefined);

expect(await prefixing.get("key")).toBe("foo");
expect(await inner.get("prefix:key")).toBe("foo");
await prefixing.delete("key");
expect(await prefixing.get("key")).toBe(undefined);

await prefixing.set("keyWithOptions", "bar", {
ttl: 1000,
tags: ["tag1", "tag2"],
});
expect(spyOnCacheSet).toBeCalledWith("prefix:keyWithOptions", "bar", {
ttl: 1000,
tags: ["tag1", "tag2"],
});

expect(await prefixing.get("keyWithOptions")).toBe("bar");
expect(await inner.get("prefix:keyWithOptions")).toBe("bar");
await prefixing.delete("keyWithOptions");
expect(await prefixing.get("keyWithOptions")).toBe(undefined);
});
});
5 changes: 4 additions & 1 deletion packages/keyValueCache/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export type { KeyValueCache, KeyValueCacheSetOptions } from "./KeyValueCache";
export { PrefixingKeyValueCache } from "./PrefixingKeyValueCache";
export { InMemoryLRUCache } from "./InMemoryLRUCache";
export {
InMemoryLRUCache,
type InMemoryLRUCacheSetOptions,
} from "./InMemoryLRUCache";
export { ErrorsAreMissesCache } from "./ErrorsAreMissesCache";
2 changes: 1 addition & 1 deletion packages/keyvAdapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface KeyvAdapterOptions {
export class KeyvAdapter<
V = string,
O extends Record<string, any> = Record<string, unknown>,
> implements KeyValueCache<V>
> implements KeyValueCache<V, KeyValueCacheSetOptions>
{
private readonly keyv: Keyv<V, O>;
private readonly dataLoader: DataLoader<string, V | undefined> | undefined;
Expand Down

0 comments on commit e02f708

Please sign in to comment.