Skip to content

Commit

Permalink
feat(cache): support deleting multiple keys at a time (#317)
Browse files Browse the repository at this point in the history
  • Loading branch information
ForbesLindesay authored Feb 19, 2024
1 parent e4d27b9 commit 161265c
Show file tree
Hide file tree
Showing 3 changed files with 216 additions and 23 deletions.
45 changes: 42 additions & 3 deletions docs/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ Events:

- `onCacheCreate` - Called when a new cache is created
- `onClear` - Called when `cache.clear()` is called.
- `onDeletePrefix` - Called when `cache.deletePrefix()` is called.
- `onDelete` - Called when `cache.delete()` is called.
- `onGet` - Called when `cache.get()` is called. Use `event.isCacheHit` to determine if the entry was found in the cache or not.
- `onSet` - Called when `cache.set()` is called.
Expand Down Expand Up @@ -322,7 +323,8 @@ interface Cache<TKey, TValue> {
name: string;
get(key: TKey): TValue | undefined;
set(key: TKey, value: TValue): TValue;
delete(key: TKey): void;
deletePrefix(prefix: string): void;
delete(...keys: TKey[]): void;
clear(): void;
dispose(): void;
}
Expand All @@ -346,9 +348,46 @@ Otherwise, a new item will be added to the cache and put at the back of the evic

If the cache realm is full, the least recently used item will be evicted.

#### Cache.delete(key)
#### Cache.deletePrefix(prefix)

Delete an item from the cache and remove it from the eviction queue.
Deletes all items from the cache where the serialized key starts with prefix. This will throw an error if any of the serialized keys are not strings.

```typescript
const cache = createCache<{hostname: string; path: string}, WebPage>({
name: `WebPages`,
mapKey: ({hostname, path}) => `${hostname}${path}`,
});

// Set cache entries like:
// cache.set({hostname: `example.com`, path: `/a`}, pageA);

// Call this to delete all pages from the cache for a given hostname.
function onWebsiteUpdated(hostname: string) {
cache.deletePrefix(hostname);
}
```

#### Cache.delete(...keys)

Delete items from the cache and remove them from the eviction queue.

You can call `delete` with a single ID:

```typescript
myCache.delete(42);
```

You can call `delete` with multiple IDs:

```typescript
myCache.delete(1, 2, 3);
```

You can also call `delete` with an array of IDs using spread:

```typescript
myCache.delete(...updatedRecords.map((r) => r.id));
```

#### Cache.clear()

Expand Down
62 changes: 62 additions & 0 deletions packages/cache/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,66 @@ test(`Can replicate deletes`, () => {
cacheB.clear();
expect(cacheA.get(1)).toBe(undefined);
expect(cacheB.get(1)).toBe(undefined);

// We can replicate multiple deletes in one message
cacheA.set(1, `value`);
cacheB.set(1, `valueB`);
cacheA.set(2, `value2`);
cacheB.set(2, `value2B`);
cacheA.set(3, `value3`);
cacheB.set(3, `value3B`);
cacheB.delete(1, 2);
expect(cacheA.get(1)).toBe(undefined);
expect(cacheB.get(1)).toBe(undefined);
expect(cacheA.get(2)).toBe(undefined);
expect(cacheB.get(2)).toBe(undefined);
expect(cacheA.get(3)).toBe(`value3`);
expect(cacheB.get(3)).toBe(`value3B`);
});

test(`Can replicate prefix deletes`, () => {
const realmA = createCacheRealm({
maximumSize: 1_000,
onReplicationEvent(e) {
realmB.writeReplicationEvent(e);
},
});
const realmB = createCacheRealm({
maximumSize: 1_000,
onReplicationEvent(e) {
realmA.writeReplicationEvent(e);
},
});

const cacheA = realmA.createCache<string[], string>({
name: `MyCache`,
mapKey: (key) => key.join(`:`),
});
const cacheB = realmB.createCache<string[], string>({
name: `MyCache`,
mapKey: (key) => key.join(`:`),
});

// We can replicate multiple deletes in one message
cacheA.set([`a`, `1`], `value`);
cacheB.set([`a`, `1`], `valueB`);
cacheA.set([`a`, `2`], `value2`);
cacheB.set([`a`, `2`], `value2B`);
cacheA.set([`b`, `3`], `value3`);
cacheB.set([`b`, `3`], `value3B`);

expect(cacheA.get([`a`, `1`])).toBe(`value`);
expect(cacheB.get([`a`, `1`])).toBe(`valueB`);
expect(cacheA.get([`a`, `2`])).toBe(`value2`);
expect(cacheB.get([`a`, `2`])).toBe(`value2B`);
expect(cacheA.get([`b`, `3`])).toBe(`value3`);
expect(cacheB.get([`b`, `3`])).toBe(`value3B`);

cacheB.deletePrefix(`a:`);
expect(cacheA.get([`a`, `1`])).toBe(undefined);
expect(cacheB.get([`a`, `1`])).toBe(undefined);
expect(cacheA.get([`a`, `2`])).toBe(undefined);
expect(cacheB.get([`a`, `2`])).toBe(undefined);
expect(cacheA.get([`b`, `3`])).toBe(`value3`);
expect(cacheB.get([`b`, `3`])).toBe(`value3B`);
});
132 changes: 112 additions & 20 deletions packages/cache/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,29 @@ export interface ReplicationDeleteEvent {
readonly key: unknown;
}

export type ReplicationEvent = ReplicationClearEvent | ReplicationDeleteEvent;
export interface ReplicationDeleteMultipleEvent {
readonly kind: 'DELETE_MULTIPLE';
readonly name: string;
readonly keys: unknown[];
}

export interface ReplicationDeletePrefixEvent {
readonly kind: 'DELETE_PREFIX';
readonly name: string;
readonly prefix: string;
}

export type ReplicationEvent =
| ReplicationClearEvent
| ReplicationDeleteEvent
| ReplicationDeleteMultipleEvent
| ReplicationDeletePrefixEvent;

type ReplicationEventInternal =
| ReplicationClearEvent
| {kind: 'DELETE'; name: string; key: SerializedKey}
| {kind: 'DELETE_MULTIPLE'; name: string; keys: SerializedKey[]}
| ReplicationDeletePrefixEvent;

export interface CacheEvent {
/**
Expand All @@ -27,6 +49,10 @@ export interface CacheKeyEvent extends CacheEvent {
readonly key: unknown;
}

export interface CachePrefixEvent extends CacheEvent {
readonly prefix: string;
}

export interface CacheGetEvent extends CacheKeyEvent {
/**
* True if the entry was found in the cache.
Expand Down Expand Up @@ -64,6 +90,7 @@ export interface CacheRealmOptions {
readonly onReplicationEvent?: (event: ReplicationEvent) => void;
readonly onCacheCreate?: (event: CacheEvent) => void;
readonly onClear?: (event: CacheEvent) => void;
readonly onDeletePrefix?: (event: CachePrefixEvent) => void;
readonly onDelete?: (event: CacheKeyEvent) => void;
readonly onGet?: (event: CacheGetEvent) => void;
readonly onSet?: (event: CacheKeyEvent) => void;
Expand Down Expand Up @@ -155,10 +182,18 @@ export interface Cache<TKey, TValue> {
set(key: TKey, value: TValue): TValue;

/**
* Delete an item from the cache and remove it from the
* Delete items from the cache and remove them from the
* eviction queue.
*/
delete(key: TKey): void;
delete(...keys: TKey[]): void;

/**
* Delete items where the serialized key has a given prefix.
*
* This will throw a runtime error if any keys are not
* serialized to strings.
*/
deletePrefix(prefix: string): void;

/**
* Clear all items from the cache and remove them from the
Expand All @@ -181,13 +216,10 @@ interface InternalCacheKeyEvent extends CacheEvent {
interface InternalCacheRealmOptions {
readonly maximumSize: number;
readonly getTime?: () => number;
readonly onReplicationEvent?: (
event:
| {kind: 'CLEAR'; name: string}
| {kind: 'DELETE'; name: string; key: SerializedKey},
) => void;
readonly onReplicationEvent?: (event: ReplicationEventInternal) => void;
readonly onCacheCreate?: (event: CacheEvent) => void;
readonly onClear?: (event: CacheEvent) => void;
readonly onDeletePrefix?: (event: CachePrefixEvent) => void;
readonly onDelete?: (event: InternalCacheKeyEvent) => void;
readonly onGet?: (event: CacheGetEvent) => void;
readonly onSet?: (event: InternalCacheKeyEvent) => void;
Expand Down Expand Up @@ -252,6 +284,7 @@ export default function createCacheRealm(
onReplicationEvent,
onCacheCreate,
onClear,
onDeletePrefix,
onDelete,
onGet,
onSet,
Expand Down Expand Up @@ -304,7 +337,10 @@ export default function createCacheRealm(

const caches = new Map<
string,
Pick<CacheImplementation<unknown, unknown>, '_delete' | '_clear'>
Pick<
CacheImplementation<unknown, unknown>,
'_deletePrefix' | '_delete' | '_clear'
>
>();

class CacheImplementation<TKey, TValue> implements Cache<TKey, TValue> {
Expand Down Expand Up @@ -348,6 +384,32 @@ export default function createCacheRealm(
this._clear();
}

_deletePrefix(prefix: string): void {
for (const [key, item] of this._items) {
const k: unknown = key;
if (typeof k !== 'string') {
throw new Error(
`Cache.deletePrefix was called on a cache with non-string keys. You may want to pass the "mapKey" option to createCache to convert the keys into strings.`,
);
}
if (k.startsWith(prefix)) {
removeItemFromEvictionQueue(item);
this._items.delete(key);
usedSize -= item.size;
}
}
}
deletePrefix(prefix: string): void {
this._assertNotDisposed();
this._deletePrefix(prefix);
if (onDeletePrefix) {
onDeletePrefix({name: this.name, prefix});
}
if (onReplicationEvent) {
onReplicationEvent({kind: 'DELETE_PREFIX', name: this.name, prefix});
}
}

_delete(k: SerializedKey): void {
const item = this._items.get(k);

Expand All @@ -357,16 +419,37 @@ export default function createCacheRealm(
usedSize -= item.size;
}
}
delete(key: TKey): void {
delete(...keys: TKey[]): void {
this._assertNotDisposed();
const k = this._serializeKey(key);
if (onDelete) {
onDelete({name: this.name, key: k});
}
if (onReplicationEvent) {
onReplicationEvent({kind: 'DELETE', name: this.name, key: k});
if (keys.length === 1) {
const key = keys[0];
const k = this._serializeKey(key);
if (onDelete) {
onDelete({name: this.name, key: k});
}
if (onReplicationEvent) {
onReplicationEvent({kind: 'DELETE', name: this.name, key: k});
}
this._delete(k);
} else {
const serializedKeys = new Set<SerializedKey>();
for (const key of keys) {
const k = this._serializeKey(key);
if (serializedKeys.has(k)) continue;
serializedKeys.add(k);
if (onDelete) {
onDelete({name: this.name, key: k});
}
this._delete(k);
}
if (onReplicationEvent) {
onReplicationEvent({
kind: 'DELETE_MULTIPLE',
name: this.name,
keys: [...serializedKeys],
});
}
}
this._delete(k);
}

get(key: TKey): TValue | undefined {
Expand Down Expand Up @@ -489,14 +572,23 @@ export default function createCacheRealm(
}

function writeReplicationEvent(event: ReplicationEvent) {
const cache = caches.get(event.name);
const e = event as ReplicationEventInternal;
const cache = caches.get(e.name);
if (cache) {
switch (event.kind) {
switch (e.kind) {
case 'CLEAR':
cache._clear();
break;
case 'DELETE':
cache._delete(event.key as SerializedKey);
cache._delete(e.key);
break;
case 'DELETE_MULTIPLE':
for (const key of e.keys) {
cache._delete(key);
}
break;
case 'DELETE_PREFIX':
cache._deletePrefix(e.prefix);
break;
}
}
Expand Down

0 comments on commit 161265c

Please sign in to comment.