diff --git a/packages/sdk/src/contracts/ERC1271ContractFacade.ts b/packages/sdk/src/contracts/ERC1271ContractFacade.ts index 83e6e0959e..7a79976467 100644 --- a/packages/sdk/src/contracts/ERC1271ContractFacade.ts +++ b/packages/sdk/src/contracts/ERC1271ContractFacade.ts @@ -19,14 +19,14 @@ function formCacheKey(contractAddress: EthereumAddress, signerUserId: UserID): C @scoped(Lifecycle.ContainerScoped) export class ERC1271ContractFacade { - private readonly contractsByAddress: Mapping<[EthereumAddress], ERC1271Contract> + private readonly contractsByAddress: Mapping private readonly publisherCache = new MapWithTtl(() => CACHE_TTL) constructor( contractFactory: ContractFactory, rpcProviderSource: RpcProviderSource ) { - this.contractsByAddress = createLazyMap<[EthereumAddress], ERC1271Contract>({ + this.contractsByAddress = createLazyMap({ valueFactory: async (address) => { return contractFactory.createReadContract( address, diff --git a/packages/sdk/src/contracts/StreamRegistry.ts b/packages/sdk/src/contracts/StreamRegistry.ts index 7417419892..c509c6104f 100644 --- a/packages/sdk/src/contracts/StreamRegistry.ts +++ b/packages/sdk/src/contracts/StreamRegistry.ts @@ -40,9 +40,9 @@ import { isPublicPermissionQuery, streamPermissionToSolidityType } from '../permission' -import { CachingMap } from '../utils/CachingMap' import { filter, map } from '../utils/GeneratorUtils' import { LoggerFactory } from '../utils/LoggerFactory' +import { createCacheMap, Mapping } from '../utils/Mapping' import { ChainEventPoller } from './ChainEventPoller' import { ContractFactory } from './ContractFactory' import { ObservableContract, initContractEventGateway, waitForTx } from './contract' @@ -102,13 +102,14 @@ const streamContractErrorProcessor = (err: any, streamId: StreamID, registry: st } } -const formCacheKeyPrefix = (streamId: StreamID): string => { - return `${streamId}|` -} - -const invalidateCache = (cache: CachingMap, streamId: StreamID): void => { - const matchTarget = (s: string) => s.startsWith(formCacheKeyPrefix(streamId)) - cache.invalidate(matchTarget) +const invalidateCache = ( + cache: { invalidate: (predicate: (key: StreamID | [StreamID, ...any[]]) => boolean) => void }, + streamId: StreamID +): void => { + cache.invalidate((key) => { + const cachedStreamId = Array.isArray(key) ? key[0] : key + return cachedStreamId === streamId + }) } @scoped(Lifecycle.ContainerScoped) @@ -124,10 +125,10 @@ export class StreamRegistry { private readonly config: Pick private readonly authentication: Authentication private readonly logger: Logger - private readonly metadataCache: CachingMap - private readonly publisherCache: CachingMap - private readonly subscriberCache: CachingMap - private readonly publicSubscribePermissionCache: CachingMap + private readonly metadataCache: Mapping + private readonly publisherCache: Mapping<[StreamID, UserID], boolean> + private readonly subscriberCache: Mapping<[StreamID, UserID], boolean> + private readonly publicSubscribePermissionCache: Mapping /** @internal */ constructor( @@ -168,33 +169,33 @@ export class StreamRegistry { }), loggerFactory }) - this.metadataCache = new CachingMap((streamId: StreamID) => { - return this.getStreamMetadata_nonCached(streamId) - }, { - ...config.cache, - cacheKey: ([streamId]) => formCacheKeyPrefix(streamId) + this.metadataCache = createCacheMap({ + valueFactory: (streamId) => { + return this.getStreamMetadata_nonCached(streamId) + }, + ...config.cache }) - this.publisherCache = new CachingMap((streamId: StreamID, userId: UserID) => { - return this.isStreamPublisherOrSubscriber_nonCached(streamId, userId, StreamPermission.PUBLISH) - }, { - ...config.cache, - cacheKey: ([streamId, userId]) =>`${formCacheKeyPrefix(streamId)}${userId}` + this.publisherCache = createCacheMap({ + valueFactory: ([streamId, userId]) => { + return this.isStreamPublisherOrSubscriber_nonCached(streamId, userId, StreamPermission.PUBLISH) + }, + ...config.cache }) - this.subscriberCache = new CachingMap((streamId: StreamID, userId: UserID) => { - return this.isStreamPublisherOrSubscriber_nonCached(streamId, userId, StreamPermission.SUBSCRIBE) - }, { - ...config.cache, - cacheKey: ([streamId, userId]) =>`${formCacheKeyPrefix(streamId)}${userId}` + this.subscriberCache = createCacheMap({ + valueFactory: ([streamId, userId]) => { + return this.isStreamPublisherOrSubscriber_nonCached(streamId, userId, StreamPermission.SUBSCRIBE) + }, + ...config.cache }) - this.publicSubscribePermissionCache = new CachingMap((streamId: StreamID) => { - return this.hasPermission({ - streamId, - public: true, - permission: StreamPermission.SUBSCRIBE - }) - }, { - ...config.cache, - cacheKey: ([streamId]) => formCacheKeyPrefix(streamId) + this.publicSubscribePermissionCache = createCacheMap({ + valueFactory: (streamId) => { + return this.hasPermission({ + streamId, + public: true, + permission: StreamPermission.SUBSCRIBE + }) + }, + ...config.cache }) } @@ -522,11 +523,11 @@ export class StreamRegistry { } isStreamPublisher(streamId: StreamID, userId: UserID): Promise { - return this.publisherCache.get(streamId, userId) + return this.publisherCache.get([streamId, userId]) } isStreamSubscriber(streamId: StreamID, userId: UserID): Promise { - return this.subscriberCache.get(streamId, userId) + return this.subscriberCache.get([streamId, userId]) } hasPublicSubscribePermission(streamId: StreamID): Promise { @@ -534,7 +535,7 @@ export class StreamRegistry { } populateMetadataCache(streamId: StreamID, metadata: StreamMetadata): void { - this.metadataCache.set([streamId], metadata) + this.metadataCache.set(streamId, metadata) } invalidatePermissionCaches(streamId: StreamID): void { diff --git a/packages/sdk/src/contracts/StreamStorageRegistry.ts b/packages/sdk/src/contracts/StreamStorageRegistry.ts index 25595f4c07..9bb3e9aa99 100644 --- a/packages/sdk/src/contracts/StreamStorageRegistry.ts +++ b/packages/sdk/src/contracts/StreamStorageRegistry.ts @@ -14,7 +14,7 @@ import { LoggerFactory } from '../utils/LoggerFactory' import { ChainEventPoller } from './ChainEventPoller' import { ContractFactory } from './ContractFactory' import { initContractEventGateway, waitForTx } from './contract' -import { CachingMap } from '../utils/CachingMap' +import { createCacheMap, Mapping } from '../utils/Mapping' export interface StorageNodeAssignmentEvent { readonly streamId: StreamID @@ -45,7 +45,7 @@ export class StreamStorageRegistry { private readonly config: Pick private readonly authentication: Authentication private readonly logger: Logger - private readonly getStorageNodes_cached: CachingMap + private readonly storageNodesCache: Mapping constructor( streamIdBuilder: StreamIDBuilder, @@ -78,13 +78,11 @@ export class StreamStorageRegistry { ) }), config.contracts.pollInterval) this.initStreamAssignmentEventListeners(eventEmitter, chainEventPoller, loggerFactory) - this.getStorageNodes_cached = new CachingMap((streamIdOrPath?: string) => { - return this.getStorageNodes_nonCached(streamIdOrPath) - }, { - ...config.cache, - cacheKey: ([streamIdOrPath]): string | typeof GET_ALL_STORAGE_NODES => { - return streamIdOrPath ?? GET_ALL_STORAGE_NODES - } + this.storageNodesCache = createCacheMap({ + valueFactory: (query) => { + return this.getStorageNodes_nonCached(query) + }, + ...config.cache }) } @@ -135,7 +133,7 @@ export class StreamStorageRegistry { await this.connectToContract() const ethersOverrides = await getEthersOverrides(this.rpcProviderSource, this.config) await waitForTx(this.streamStorageRegistryContract!.addStorageNode(streamId, nodeAddress, ethersOverrides)) - this.getStorageNodes_cached.invalidate((key) => key === streamId) + this.storageNodesCache.invalidate((key) => key === streamId) } async removeStreamFromStorageNode(streamIdOrPath: string, nodeAddress: EthereumAddress): Promise { @@ -144,7 +142,7 @@ export class StreamStorageRegistry { await this.connectToContract() const ethersOverrides = await getEthersOverrides(this.rpcProviderSource, this.config) await waitForTx(this.streamStorageRegistryContract!.removeStorageNode(streamId, nodeAddress, ethersOverrides)) - this.getStorageNodes_cached.invalidate((key) => key === streamId) + this.storageNodesCache.invalidate((key) => key === streamId) } async isStoredStream(streamIdOrPath: string, nodeAddress: EthereumAddress): Promise { @@ -192,13 +190,14 @@ export class StreamStorageRegistry { } async getStorageNodes(streamIdOrPath?: string): Promise { - return this.getStorageNodes_cached.get(streamIdOrPath) + const query = (streamIdOrPath !== undefined) ? await this.streamIdBuilder.toStreamID(streamIdOrPath) : GET_ALL_STORAGE_NODES + return this.storageNodesCache.get(query) } - private async getStorageNodes_nonCached(streamIdOrPath?: string): Promise { + private async getStorageNodes_nonCached(query: StreamID | typeof GET_ALL_STORAGE_NODES): Promise { let queryResults: NodeQueryResult[] - if (streamIdOrPath !== undefined) { - const streamId = await this.streamIdBuilder.toStreamID(streamIdOrPath) + if (query !== GET_ALL_STORAGE_NODES) { + const streamId = query this.logger.debug('Get storage nodes of stream', { streamId }) queryResults = await collect(this.theGraphClient.queryEntities( (lastId: string, pageSize: number) => { diff --git a/packages/sdk/src/publish/MessageFactory.ts b/packages/sdk/src/publish/MessageFactory.ts index ea980ce682..b02b780e3e 100644 --- a/packages/sdk/src/publish/MessageFactory.ts +++ b/packages/sdk/src/publish/MessageFactory.ts @@ -17,7 +17,7 @@ import { } from '../protocol/StreamMessage' import { MessageSigner } from '../signature/MessageSigner' import { SignatureValidator } from '../signature/SignatureValidator' -import { Mapping } from '../utils/Mapping' +import { createLazyMap, Mapping } from '../utils/Mapping' import { formLookupKey } from '../utils/utils' import { GroupKeyQueue } from './GroupKeyQueue' import { PublishMetadata } from './Publisher' @@ -37,7 +37,7 @@ export class MessageFactory { private readonly streamId: StreamID private readonly authentication: Authentication private defaultPartition: number | undefined - private readonly defaultMessageChainIds: Mapping<[partition: number], string> + private readonly defaultMessageChainIds: Mapping private readonly prevMsgRefs: Map = new Map() // eslint-disable-next-line max-len private readonly streamRegistry: Pick @@ -53,7 +53,7 @@ export class MessageFactory { this.groupKeyQueue = opts.groupKeyQueue this.signatureValidator = opts.signatureValidator this.messageSigner = opts.messageSigner - this.defaultMessageChainIds = new Mapping({ + this.defaultMessageChainIds = createLazyMap({ valueFactory: async () => { return createRandomMsgChainId() } @@ -90,7 +90,7 @@ export class MessageFactory { } const msgChainId = metadata.msgChainId ?? await this.defaultMessageChainIds.get(partition) - const msgChainKey = formLookupKey(partition, msgChainId) + const msgChainKey = formLookupKey([partition, msgChainId]) const prevMsgRef = this.prevMsgRefs.get(msgChainKey) const msgRef = createMessageRef(metadata.timestamp, prevMsgRef) this.prevMsgRefs.set(msgChainKey, msgRef) diff --git a/packages/sdk/src/publish/Publisher.ts b/packages/sdk/src/publish/Publisher.ts index 7d47d2e46b..48a8452b2c 100644 --- a/packages/sdk/src/publish/Publisher.ts +++ b/packages/sdk/src/publish/Publisher.ts @@ -1,7 +1,7 @@ import { StreamID } from '@streamr/utils' import isString from 'lodash/isString' import pLimit from 'p-limit' -import { Lifecycle, inject, scoped } from 'tsyringe' +import { inject, Lifecycle, scoped } from 'tsyringe' import { Authentication, AuthenticationInjectionToken } from '../Authentication' import { NetworkNodeFacade } from '../NetworkNodeFacade' import { StreamIDBuilder } from '../StreamIDBuilder' @@ -43,8 +43,8 @@ const parseTimestamp = (metadata?: PublishMetadata): number => { @scoped(Lifecycle.ContainerScoped) export class Publisher { - private readonly messageFactories: Mapping<[streamId: StreamID], MessageFactory> - private readonly groupKeyQueues: Mapping<[streamId: StreamID], GroupKeyQueue> + private readonly messageFactories: Mapping + private readonly groupKeyQueues: Mapping private readonly concurrencyLimit = pLimit(1) private readonly node: NetworkNodeFacade private readonly streamRegistry: StreamRegistry @@ -69,12 +69,12 @@ export class Publisher { this.signatureValidator = signatureValidator this.messageSigner = messageSigner this.messageFactories = createLazyMap({ - valueFactory: async (streamId: StreamID) => { + valueFactory: async (streamId) => { return this.createMessageFactory(streamId) } }) this.groupKeyQueues = createLazyMap({ - valueFactory: async (streamId: StreamID) => { + valueFactory: async (streamId) => { return GroupKeyQueue.createInstance(streamId, this.authentication, groupKeyManager) } }) diff --git a/packages/sdk/src/subscribe/ordering/OrderMessages.ts b/packages/sdk/src/subscribe/ordering/OrderMessages.ts index 1ed9076a6a..09b940cde6 100644 --- a/packages/sdk/src/subscribe/ordering/OrderMessages.ts +++ b/packages/sdk/src/subscribe/ordering/OrderMessages.ts @@ -63,7 +63,7 @@ export class OrderMessages { config: Pick ) { this.chains = createLazyMap({ - valueFactory: async (publisherId: UserID, msgChainId: string) => { + valueFactory: async ([publisherId, msgChainId]) => { const chain = createMessageChain( { streamPartId, @@ -99,10 +99,10 @@ export class OrderMessages { if (this.abortController.signal.aborted) { return } - const chain = await this.chains.get(msg.getPublisherId(), msg.getMsgChainId()) + const chain = await this.chains.get([msg.getPublisherId(), msg.getMsgChainId()]) chain.addMessage(msg) } - await Promise.all(this.chains.values().map((chain) => chain.waitUntilIdle())) + await Promise.all([...this.chains.values()].map((chain) => chain.waitUntilIdle())) this.outBuffer.endWrite() } catch (err) { this.outBuffer.endWrite(err) diff --git a/packages/sdk/src/utils/Mapping.ts b/packages/sdk/src/utils/Mapping.ts index 3c81246ece..2d2263685b 100644 --- a/packages/sdk/src/utils/Mapping.ts +++ b/packages/sdk/src/utils/Mapping.ts @@ -1,23 +1,21 @@ -import { formLookupKey } from './utils' +import { formLookupKey, LookupKeyType } from './utils' import LRU from '../../vendor/quick-lru' -import { MarkRequired } from 'ts-essentials' +import { MarkRequired } from 'ts-essentials' -type KeyType = (string | number)[] - -interface BaseOptions { - valueFactory: (...args: K) => Promise +interface BaseOptions { + valueFactory: (key: K) => Promise isCacheableValue?: (value: V) => boolean } -interface CacheMapOptions extends BaseOptions { +interface CacheMapOptions extends BaseOptions { maxSize: number maxAge?: number } -type LazyMapOptions = BaseOptions +type LazyMapOptions = BaseOptions -// an wrapper object is used so that we can store undefined values -interface ValueWrapper { +interface Item { + key: K value: V } @@ -34,9 +32,9 @@ interface ValueWrapper { * `get()` calls were cache misses, i.e. affecting significantly cases where the * `isCacheableValue()` returns `true`.) */ -export class Mapping { +export class Mapping { - private readonly delegate: Map> + private readonly delegate: Map> private readonly pendingPromises: Map> = new Map() private readonly opts: MarkRequired | LazyMapOptions, 'isCacheableValue'> @@ -47,12 +45,12 @@ export class Mapping { **/ constructor(opts: CacheMapOptions | LazyMapOptions) { if ('maxSize' in opts) { - this.delegate = new LRU>({ + this.delegate = new LRU>({ maxSize: opts.maxSize, maxAge: opts.maxAge }) } else { - this.delegate = new Map>() + this.delegate = new Map>() } this.opts = { isCacheableValue: () => true, @@ -60,44 +58,54 @@ export class Mapping { } } - async get(...args: K): Promise { - const key = formLookupKey(...args) - const pendingPromise = this.pendingPromises.get(key) + async get(key: K): Promise { + const lookupKey = formLookupKey(key) + const pendingPromise = this.pendingPromises.get(lookupKey) if (pendingPromise !== undefined) { return await pendingPromise } else { - let valueWrapper = this.delegate.get(key) - if (valueWrapper === undefined) { - const promise = this.opts.valueFactory(...args) - this.pendingPromises.set(key, promise) + let item = this.delegate.get(lookupKey) + if (item === undefined) { + const promise = this.opts.valueFactory(key) + this.pendingPromises.set(lookupKey, promise) let value try { value = await promise } finally { - this.pendingPromises.delete(key) + this.pendingPromises.delete(lookupKey) } - valueWrapper = { value } + item = { key, value } if (this.opts.isCacheableValue(value)) { - this.delegate.set(key, valueWrapper) + this.delegate.set(lookupKey, item) } } - return valueWrapper.value + return item.value + } + } + + set(key: K, value: V): void { + this.delegate.set(formLookupKey(key), { key, value }) + } + + invalidate(predicate: (key: K) => boolean): void { + for (const [lookupKey, item] of this.delegate.entries()) { + if (predicate(item.key)) { + this.delegate.delete(lookupKey) + } } } - values(): V[] { - const result: V[] = [] - for (const wrapper of this.delegate.values()) { - result.push(wrapper.value) + *values(): IterableIterator { + for (const item of this.delegate.values()) { + yield item.value } - return result } } -export const createCacheMap = (opts: CacheMapOptions): Mapping => { +export const createCacheMap = (opts: CacheMapOptions): Mapping => { return new Mapping(opts) } -export const createLazyMap = (opts: LazyMapOptions): Mapping => { +export const createLazyMap = (opts: LazyMapOptions): Mapping => { return new Mapping(opts) } diff --git a/packages/sdk/src/utils/utils.ts b/packages/sdk/src/utils/utils.ts index deaf95c2ea..ce9ea09046 100644 --- a/packages/sdk/src/utils/utils.ts +++ b/packages/sdk/src/utils/utils.ts @@ -154,10 +154,14 @@ export function generateClientId(): string { return counterId(process.pid ? `${process.pid}` : randomString(4), '/') } +export type LookupKeyType = (string | number | symbol) | (string | number | symbol)[] + // A unique internal identifier to some list of primitive values. Useful // e.g. as a map key or a cache key. -export const formLookupKey = (...args: K): string => { - return args.join('|') +export const formLookupKey = (key: K): string => { + return Array.isArray(key) + ? key.map((a) => a.toString()).join('|') + : key.toString() } /** @internal */ diff --git a/packages/sdk/test/end-to-end/contract-call-cache.test.ts b/packages/sdk/test/end-to-end/contract-call-cache.test.ts index 9359589a46..992fa3b8f1 100644 --- a/packages/sdk/test/end-to-end/contract-call-cache.test.ts +++ b/packages/sdk/test/end-to-end/contract-call-cache.test.ts @@ -90,6 +90,16 @@ describe('contract call cache', () => { expect(getMethodCalls()).toHaveLength(0) }) + it('is not in cache after calling deleteStream()', async () => { + const stream = await client.createStream(createRelativeTestStreamId(module)) + await client.deleteStream(stream.id) + const otherClient = createTestClient(authenticatedUser.privateKey) + await otherClient.createStream(stream.id) + await otherClient.destroy() + await client.getStreamMetadata(stream.id) + expect(getMethodCalls()).toHaveLength(1) + }) + it('cache updated when calling setStreamMetatadata()', async () => { const NEW_METADATA = { foo: Date.now() } await client.setStreamMetadata(existingStreamId, NEW_METADATA) diff --git a/packages/sdk/test/unit/Mapping.test.ts b/packages/sdk/test/unit/Mapping.test.ts index b2d40730a5..4ca6c94ba6 100644 --- a/packages/sdk/test/unit/Mapping.test.ts +++ b/packages/sdk/test/unit/Mapping.test.ts @@ -5,17 +5,17 @@ import { range } from 'lodash' describe('Mapping', () => { it('create', async () => { - const mapping = createLazyMap({ - valueFactory: async (p1: string, p2: number) => `${p1}${p2}` + const mapping = createLazyMap<[string, number], string>({ + valueFactory: async ([p1, p2]: [string, number]) => `${p1}${p2}` }) - expect(await mapping.get('foo', 1)).toBe('foo1') - expect(await mapping.get('bar', 2)).toBe('bar2') + expect(await mapping.get(['foo', 1])).toBe('foo1') + expect(await mapping.get(['bar', 2])).toBe('bar2') }) it('memorize', async () => { let evaluationIndex = 0 - const mapping = createLazyMap({ - valueFactory: async (_p: string) => { + const mapping = createLazyMap({ + valueFactory: async (_p) => { const result = evaluationIndex evaluationIndex++ return result @@ -31,60 +31,60 @@ describe('Mapping', () => { it('undefined', async () => { const valueFactory = jest.fn().mockResolvedValue(undefined) const mapping = createLazyMap({ valueFactory }) - expect(await mapping.get('foo')).toBe(undefined) - expect(await mapping.get('foo')).toBe(undefined) + expect(await mapping.get(['foo'])).toBe(undefined) + expect(await mapping.get(['foo'])).toBe(undefined) expect(valueFactory).toHaveBeenCalledTimes(1) }) it('rejections are not cached', async () => { - const valueFactory = jest.fn().mockImplementation(async (p1: string, p2: number) => { + const valueFactory = jest.fn().mockImplementation(async ([p1, p2]: [string, number]) => { throw new Error(`error ${p1}-${p2}`) }) const mapping = createLazyMap({ valueFactory }) - await expect(mapping.get('foo', 1)).rejects.toEqual(new Error('error foo-1')) - await expect(mapping.get('foo', 1)).rejects.toEqual(new Error('error foo-1')) + await expect(mapping.get(['foo', 1])).rejects.toEqual(new Error('error foo-1')) + await expect(mapping.get(['foo', 1])).rejects.toEqual(new Error('error foo-1')) expect(valueFactory).toHaveBeenCalledTimes(2) }) it('throws are not cached', async () => { - const valueFactory = jest.fn().mockImplementation((p1: string, p2: number) => { + const valueFactory = jest.fn().mockImplementation(([p1, p2]: [string, number]) => { throw new Error(`error ${p1}-${p2}`) }) const mapping = createLazyMap({ valueFactory }) - await expect(mapping.get('foo', 1)).rejects.toEqual(new Error('error foo-1')) - await expect(mapping.get('foo', 1)).rejects.toEqual(new Error('error foo-1')) + await expect(mapping.get(['foo', 1])).rejects.toEqual(new Error('error foo-1')) + await expect(mapping.get(['foo', 1])).rejects.toEqual(new Error('error foo-1')) expect(valueFactory).toHaveBeenCalledTimes(2) }) it('isCacheableValue', async () => { - const valueFactory = jest.fn().mockImplementation(async (p1: string, p2: number) => { + const valueFactory = jest.fn().mockImplementation(async ([p1, p2]: [string, number]) => { return `${p1}${p2}` }) const mapping = createLazyMap({ valueFactory, isCacheableValue: (value: string) => value === 'foo1' }) - const result1 = await mapping.get('foo', 1) - const result2 = await mapping.get('foo', 1) + const result1 = await mapping.get(['foo', 1]) + const result2 = await mapping.get(['foo', 1]) expect(result1).toBe('foo1') expect(result2).toBe('foo1') expect(valueFactory).toHaveBeenCalledTimes(1) - const result3 = await mapping.get('foo', 2) - const result4 = await mapping.get('foo', 2) + const result3 = await mapping.get(['foo', 2]) + const result4 = await mapping.get(['foo', 2]) expect(result3).toBe('foo2') expect(result4).toBe('foo2') expect(valueFactory).toHaveBeenCalledTimes(1 + 2) // two additional calls as neither of the new calls was cached }) it('concurrency', async () => { - const valueFactory = jest.fn().mockImplementation(async (p1: string, p2: number) => { + const valueFactory = jest.fn().mockImplementation(async ([p1, p2]: [string, number]) => { await wait(50) return `${p1}${p2}` }) const mapping = createLazyMap({ valueFactory }) const results = await Promise.all([ - mapping.get('foo', 1), - mapping.get('foo', 2), - mapping.get('foo', 2), - mapping.get('foo', 1), - mapping.get('foo', 1) + mapping.get(['foo', 1]), + mapping.get(['foo', 2]), + mapping.get(['foo', 2]), + mapping.get(['foo', 1]), + mapping.get(['foo', 1]) ]) expect(valueFactory).toHaveBeenCalledTimes(2) expect(results).toEqual([ @@ -98,22 +98,22 @@ describe('Mapping', () => { it('max size', async () => { const SIZE = 3 - const valueFactory = jest.fn().mockImplementation(async (p1: string, p2: number) => { + const valueFactory = jest.fn().mockImplementation(async ([p1, p2]: [string, number]) => { return `${p1}${p2}` }) const mapping = createCacheMap({ valueFactory, maxSize: 3 }) const ids = range(SIZE) for (const id of ids) { - await mapping.get('foo', id) + await mapping.get(['foo', id]) } expect(valueFactory).toHaveBeenCalledTimes(3) // add a value which is not in cache - await mapping.get('foo', -1) + await mapping.get(['foo', -1]) expect(valueFactory).toHaveBeenCalledTimes(4) // one of the items was removed from cache when -1 was added, now we is re-add that // (we don't know which item it was) for (const id of ids) { - await mapping.get('foo', id) + await mapping.get(['foo', id]) } expect(valueFactory).toHaveBeenCalledTimes(5) }) @@ -121,13 +121,26 @@ describe('Mapping', () => { it('max age', async () => { const MAX_AGE = 100 const JITTER = 50 - const valueFactory = jest.fn().mockImplementation(async (p1: string, p2: number) => { + const valueFactory = jest.fn().mockImplementation(async ([p1, p2]: [string, number]) => { return `${p1}${p2}` }) const mapping = createCacheMap({ valueFactory, maxSize: 999999, maxAge: MAX_AGE }) - await mapping.get('foo', 1) + await mapping.get(['foo', 1]) await wait(MAX_AGE + JITTER) - await mapping.get('foo', 1) + await mapping.get(['foo', 1]) expect(valueFactory).toHaveBeenCalledTimes(2) }) + + it('invalidate', async () => { + const mapping = createLazyMap<[string, number], string>({ + valueFactory: async ([p1, p2]: [string, number]) => `${p1}${p2}`, + }) + mapping.set(['foo', 1], 'foo1') + mapping.set(['bar', 1], 'bar1') + await mapping.get(['foo', 2]) + await mapping.get(['bar', 2]) + expect([...mapping.values()]).toIncludeSameMembers(['foo1', 'bar1', 'foo2', 'bar2']) + mapping.invalidate(([p1]) => (p1 === 'bar')) + expect([...mapping.values()]).toIncludeSameMembers(['foo1', 'foo2']) + }) })