From 161265cbe253d304a2c29890a682efc9819d4ecc Mon Sep 17 00:00:00 2001 From: Forbes Lindesay Date: Mon, 19 Feb 2024 15:02:05 +0000 Subject: [PATCH] feat(cache): support deleting multiple keys at a time (#317) --- docs/cache.md | 45 ++++++- packages/cache/src/__tests__/index.test.ts | 62 ++++++++++ packages/cache/src/index.ts | 132 +++++++++++++++++---- 3 files changed, 216 insertions(+), 23 deletions(-) diff --git a/docs/cache.md b/docs/cache.md index 1d612ac0..afda04ea 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -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. @@ -322,7 +323,8 @@ interface Cache { 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; } @@ -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() diff --git a/packages/cache/src/__tests__/index.test.ts b/packages/cache/src/__tests__/index.test.ts index 222c930d..78fe8a16 100644 --- a/packages/cache/src/__tests__/index.test.ts +++ b/packages/cache/src/__tests__/index.test.ts @@ -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({ + name: `MyCache`, + mapKey: (key) => key.join(`:`), + }); + const cacheB = realmB.createCache({ + 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`); }); diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts index e5932f01..35e2f087 100644 --- a/packages/cache/src/index.ts +++ b/packages/cache/src/index.ts @@ -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 { /** @@ -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. @@ -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; @@ -155,10 +182,18 @@ export interface Cache { 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 @@ -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; @@ -252,6 +284,7 @@ export default function createCacheRealm( onReplicationEvent, onCacheCreate, onClear, + onDeletePrefix, onDelete, onGet, onSet, @@ -304,7 +337,10 @@ export default function createCacheRealm( const caches = new Map< string, - Pick, '_delete' | '_clear'> + Pick< + CacheImplementation, + '_deletePrefix' | '_delete' | '_clear' + > >(); class CacheImplementation implements Cache { @@ -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); @@ -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(); + 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 { @@ -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; } }