From 2013ff250afa3f1a26a7610694fe881b232b976f Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Fri, 7 Apr 2023 11:32:31 +0200 Subject: [PATCH] feat(idempotency): add local cache to `BasePersistenceLayer` (#1396) --- packages/idempotency/src/IdempotencyConfig.ts | 2 + .../src/persistence/BasePersistenceLayer.ts | 82 +++++- .../src/persistence/IdempotencyRecord.ts | 4 +- .../idempotency/src/persistence/LRUCache.ts | 242 ++++++++++++++++++ .../src/types/IdempotencyOptions.ts | 12 +- packages/idempotency/src/types/LRUCache.ts | 10 + packages/idempotency/src/types/index.ts | 3 +- .../persistence/BasePersistenceLayer.test.ts | 156 +++++++++-- .../tests/unit/persistence/LRUCache.test.ts | 198 ++++++++++++++ 9 files changed, 673 insertions(+), 36 deletions(-) create mode 100644 packages/idempotency/src/persistence/LRUCache.ts create mode 100644 packages/idempotency/src/types/LRUCache.ts create mode 100644 packages/idempotency/tests/unit/persistence/LRUCache.test.ts diff --git a/packages/idempotency/src/IdempotencyConfig.ts b/packages/idempotency/src/IdempotencyConfig.ts index db154da116..d6a078e7e3 100644 --- a/packages/idempotency/src/IdempotencyConfig.ts +++ b/packages/idempotency/src/IdempotencyConfig.ts @@ -6,6 +6,7 @@ class IdempotencyConfig { public expiresAfterSeconds: number; public hashFunction: string; public lambdaContext?: Context; + public maxLocalCacheSize: number; public payloadValidationJmesPath?: string; public throwOnNoIdempotencyKey: boolean; public useLocalCache: boolean; @@ -16,6 +17,7 @@ class IdempotencyConfig { this.throwOnNoIdempotencyKey = config.throwOnNoIdempotencyKey ?? false; this.expiresAfterSeconds = config.expiresAfterSeconds ?? 3600; // 1 hour default this.useLocalCache = config.useLocalCache ?? false; + this.maxLocalCacheSize = config.maxLocalCacheSize ?? 1000; this.hashFunction = config.hashFunction ?? 'md5'; this.lambdaContext = config.lambdaContext; } diff --git a/packages/idempotency/src/persistence/BasePersistenceLayer.ts b/packages/idempotency/src/persistence/BasePersistenceLayer.ts index d8c68bb5d4..39f29a3a07 100644 --- a/packages/idempotency/src/persistence/BasePersistenceLayer.ts +++ b/packages/idempotency/src/persistence/BasePersistenceLayer.ts @@ -10,10 +10,12 @@ import type { import { EnvironmentVariablesService } from '../config'; import { IdempotencyRecord } from './IdempotencyRecord'; import { BasePersistenceLayerInterface } from './BasePersistenceLayerInterface'; -import { IdempotencyValidationError } from '../Exceptions'; +import { IdempotencyItemAlreadyExistsError, IdempotencyValidationError } from '../Exceptions'; +import { LRUCache } from './LRUCache'; abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { public idempotencyKeyPrefix: string; + private cache?: LRUCache; private configured: boolean = false; // envVarsService is always initialized in the constructor private envVarsService!: EnvironmentVariablesService; @@ -25,7 +27,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { private useLocalCache: boolean = false; private validationKeyJmesPath?: string; - public constructor() { + public constructor() { this.envVarsService = new EnvironmentVariablesService(); this.idempotencyKeyPrefix = this.getEnvVarsService().getFunctionName(); } @@ -55,7 +57,10 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { this.throwOnNoIdempotencyKey = idempotencyConfig?.throwOnNoIdempotencyKey || false; this.eventKeyJmesPath = idempotencyConfig.eventKeyJmesPath; this.expiresAfterSeconds = idempotencyConfig.expiresAfterSeconds; // 1 hour default - // TODO: Add support for local cache + this.useLocalCache = idempotencyConfig.useLocalCache; + if (this.useLocalCache) { + this.cache = new LRUCache({ maxSize: idempotencyConfig.maxLocalCacheSize }); + } this.hashFunction = idempotencyConfig.hashFunction; } @@ -64,13 +69,15 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { * * @param data - the data payload that will be hashed to create the hash portion of the idempotency key */ - public async deleteRecord(data: Record): Promise { - const idempotencyRecord = new IdempotencyRecord({ + public async deleteRecord(data: Record): Promise { + const idempotencyRecord = new IdempotencyRecord({ idempotencyKey: this.getHashedIdempotencyKey(data), status: IdempotencyRecordStatus.EXPIRED }); - + await this._deleteRecord(idempotencyRecord); + + this.deleteFromCache(idempotencyRecord.idempotencyKey); } /** @@ -81,7 +88,15 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { public async getRecord(data: Record): Promise { const idempotencyKey = this.getHashedIdempotencyKey(data); + const cachedRecord = this.getFromCache(idempotencyKey); + if (cachedRecord) { + this.validatePayload(data, cachedRecord); + + return cachedRecord; + } + const record = await this._getRecord(idempotencyKey); + this.saveToCache(record); this.validatePayload(data, record); return record; @@ -97,7 +112,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { * @param data - the data payload that will be hashed to create the hash portion of the idempotency key * @param remainingTimeInMillis - the remaining time left in the lambda execution context */ - public async saveInProgress(data: Record, remainingTimeInMillis?: number): Promise { + public async saveInProgress(data: Record, remainingTimeInMillis?: number): Promise { const idempotencyRecord = new IdempotencyRecord({ idempotencyKey: this.getHashedIdempotencyKey(data), status: IdempotencyRecordStatus.INPROGRESS, @@ -113,6 +128,10 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { ); } + if (this.getFromCache(idempotencyRecord.idempotencyKey)) { + throw new IdempotencyItemAlreadyExistsError(); + } + await this._putRecord(idempotencyRecord); } @@ -123,7 +142,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { * @param data - the data payload that will be hashed to create the hash portion of the idempotency key * @param result - the result of the successfully completed function */ - public async saveSuccess(data: Record, result: Record): Promise { + public async saveSuccess(data: Record, result: Record): Promise { const idempotencyRecord = new IdempotencyRecord({ idempotencyKey: this.getHashedIdempotencyKey(data), status: IdempotencyRecordStatus.COMPLETED, @@ -133,6 +152,8 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { }); await this._updateRecord(idempotencyRecord); + + this.saveToCache(idempotencyRecord); } protected abstract _deleteRecord(record: IdempotencyRecord): Promise; @@ -140,16 +161,24 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { protected abstract _putRecord(record: IdempotencyRecord): Promise; protected abstract _updateRecord(record: IdempotencyRecord): Promise; + private deleteFromCache(idempotencyKey: string): void { + if (!this.useLocalCache) return; + // Delete from local cache if it exists + if (this.cache?.has(idempotencyKey)) { + this.cache?.remove(idempotencyKey); + } + } + /** * Generates a hash of the data and returns the digest of that hash * * @param data the data payload that will generate the hash * @returns the digest of the generated hash */ - private generateHash(data: string): string{ + private generateHash(data: string): string { const hash: Hash = createHash(this.hashFunction); hash.update(data); - + return hash.digest('base64'); } @@ -168,10 +197,21 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { */ private getExpiryTimestamp(): number { const currentTime: number = Date.now() / 1000; - + return currentTime + this.expiresAfterSeconds; } + private getFromCache(idempotencyKey: string): IdempotencyRecord | undefined { + if (!this.useLocalCache) return undefined; + const cachedRecord = this.cache?.get(idempotencyKey); + if (cachedRecord) { + // if record is not expired, return it + if (!cachedRecord.isExpired()) return cachedRecord; + // if record is expired, delete it from cache + this.deleteFromCache(idempotencyKey); + } + } + /** * Generates the idempotency key used to identify records in the persistence store. * @@ -182,14 +222,14 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { if (this.eventKeyJmesPath) { data = search(data, this.eventKeyJmesPath); } - + if (BasePersistenceLayer.isMissingIdempotencyKey(data)) { if (this.throwOnNoIdempotencyKey) { throw new Error('No data found to create a hashed idempotency_key'); } console.warn(`No value found for idempotency_key. jmespath: ${this.eventKeyJmesPath}`); } - + return `${this.idempotencyKeyPrefix}#${this.generateHash(JSON.stringify(data))}`; } @@ -204,7 +244,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { // Therefore, the assertion is safe. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion data = search(data, this.validationKeyJmesPath!); - + return this.generateHash(JSON.stringify(data)); } @@ -223,6 +263,20 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { return !data; } + /** + * Save record to local cache except for when status is `INPROGRESS`. + * + * We can't cache `INPROGRESS` records because we have no way to reflect updates + * that might happen to the record outside of the execution context of the function. + * + * @param record - record to save + */ + private saveToCache(record: IdempotencyRecord): void { + if (!this.useLocalCache) return; + if (record.getStatus() === IdempotencyRecordStatus.INPROGRESS) return; + this.cache?.add(record.idempotencyKey, record); + } + private validatePayload(data: Record, record: IdempotencyRecord): void { if (this.payloadValidationEnabled) { const hashedPayload: string = this.getHashedPayload(data); diff --git a/packages/idempotency/src/persistence/IdempotencyRecord.ts b/packages/idempotency/src/persistence/IdempotencyRecord.ts index f59839273c..54fab0e93d 100644 --- a/packages/idempotency/src/persistence/IdempotencyRecord.ts +++ b/packages/idempotency/src/persistence/IdempotencyRecord.ts @@ -15,7 +15,7 @@ class IdempotencyRecord { public responseData?: Record; private status: IdempotencyRecordStatus; - public constructor(config: IdempotencyRecordOptions) { + public constructor(config: IdempotencyRecordOptions) { this.idempotencyKey = config.idempotencyKey; this.expiryTimestamp = config.expiryTimestamp; this.inProgressExpiryTimestamp = config.inProgressExpiryTimestamp; @@ -38,7 +38,7 @@ class IdempotencyRecord { } } - private isExpired(): boolean { + public isExpired(): boolean { return this.expiryTimestamp !== undefined && ((Date.now() / 1000) > this.expiryTimestamp); } } diff --git a/packages/idempotency/src/persistence/LRUCache.ts b/packages/idempotency/src/persistence/LRUCache.ts new file mode 100644 index 0000000000..98f2c66d54 --- /dev/null +++ b/packages/idempotency/src/persistence/LRUCache.ts @@ -0,0 +1,242 @@ +import type { LRUCacheOptions } from '../types'; + +const DEFAULT_MAX_SIZE = 100; +const NEWER = Symbol('newer'); +const OLDER = Symbol('older'); + +class Item{ + public readonly key: K; + public value: V; + private [NEWER]: Item | undefined; + private [OLDER]: Item | undefined; + + public constructor(key: K, value: V) { + this.key = key; + this.value = value; + this[NEWER] = undefined; + this[OLDER] = undefined; + } +} + +/** + * A simple LRU cache implementation that uses a doubly linked list to track the order of items in + * an hash map. + * + * Illustration of the design: + *```text + * oldest newest + * entry entry entry entry + * ______ ______ ______ ______ + * | head |.newer => | |.newer => | |.newer => | tail | + * | A | | B | | C | | D | + * |______| <= older.|______| <= older.|______| <= older.|______| + * + * removed <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- added + * ``` + * + * Items are added to the cache using the `add()` method. When an item is added, it's marked + * as the most recently used item. If the cache is full, the oldest item is removed from the + * cache. + * + * Each item also tracks the item that was added before it, and the item that was added after + * it. This allows us to quickly remove the oldest item from the cache without having to + * iterate through the entire cache. + * + * **Note**: This implementation is loosely based on the implementation found in the lru_map package + * which is licensed under the MIT license and [recommends users to copy the code into their + * own projects](https://github.com/rsms/js-lru/tree/master#usage). + * + * @typeparam K - The type of the key + * @typeparam V - The type of the value + */ +class LRUCache{ + private leastRecentlyUsed?: Item; + private readonly map: Map>; + private readonly maxSize: number; + private mostRecentlyUsed?: Item; + + /** + * A simple LRU cache implementation that uses a doubly linked list to track the order of items in + * an hash map. + * + * When instatiating the cache, you can optionally specify the type of the key and value, as well + * as the maximum size of the cache. If no maximum size is specified, the cache will default to + * a size of 100. + * + * @example + * ```typescript + * const cache = new LRUCache({ maxSize: 100 }); + * // or + * // const cache = new LRUCache(); + * + * cache.add('a', 1); + * cache.add('b', 2); + * + * cache.get('a'); + * + * console.log(cache.size()); // 2 + * ``` + * + * @param config - The configuration options for the cache + */ + public constructor(config?: LRUCacheOptions) { + this.maxSize = config?.maxSize !== undefined ? + config.maxSize : + DEFAULT_MAX_SIZE; + this.map = new Map(); + } + + /** + * Adds a new item to the cache. + * + * If the key already exists, it updates the value and marks the item as the most recently used. + * If inserting the new item would exceed the max size, the oldest item is removed from the cache. + * + * @param key - The key to add to the cache + * @param value - The value to add to the cache + */ + public add(key: K, value: V): void { + // If the key already exists, we just update the value and mark it as the most recently used + if (this.map.has(key)) { + // At this point, we know that the key exists in the map, so we can safely use the non-null + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const item = this.map.get(key)!; + item.value = value; + this.trackItemUse(item); + + return; + } + + // If the key doesn't exist, we add it to the map + const item = new Item(key, value); + this.map.set(key, item); + + // If there's an existing newest item, link it to the new item + if (this.mostRecentlyUsed) { + this.mostRecentlyUsed[NEWER] = item; + item[OLDER] = this.mostRecentlyUsed; + // If there's no existing newest item, this is the first item (oldest and newest) + } else { + this.leastRecentlyUsed = item; + } + + // The new item is now the newest item + this.mostRecentlyUsed = item; + + // If the map is full, we remove the oldest entry + if (this.map.size > this.maxSize) { + this.shift(); + } + } + + /** + * Returns a value from the cache, or undefined if it's not in the cache. + * + * When a value is returned, it's marked as the most recently used item in the cache. + * + * @param key - The key to retrieve from the cache + */ + public get(key: K): V | undefined { + const item = this.map.get(key); + if (!item) return; + this.trackItemUse(item); + + return item.value; + } + + /** + * Returns `true` if the key exists in the cache, `false` otherwise. + * + * @param key - The key to check for in the cache + */ + public has(key: K): boolean { + return this.map.has(key); + } + + /** + * Removes an item from the cache, while doing so it also reconciles the linked list. + * + * @param key - The key to remove from the cache + */ + public remove(key: K): void { + const item = this.map.get(key); + if (!item) return; + + this.map.delete(key); + if (item[NEWER] && item[OLDER]) { + // relink the older entry with the newer entry + item[OLDER][NEWER] = item[NEWER]; + item[NEWER][OLDER] = item[OLDER]; + } else if (item[NEWER]) { + // remove the link to us + item[NEWER][OLDER] = undefined; + // link the newer entry to head + this.leastRecentlyUsed = item[NEWER]; + } else if (item[OLDER]) { + // remove the link to us + item[OLDER][NEWER] = undefined; + // link the newer entry to head + this.mostRecentlyUsed = item[OLDER]; + } else { + this.leastRecentlyUsed = this.mostRecentlyUsed = undefined; + } + } + + /** + * Returns the current size of the cache. + */ + public size(): number { + return this.map.size; + } + + /** + * Removes the oldest item from the cache and unlinks it from the linked list. + */ + private shift(): void { + // If this function is called, we know that the least recently used item exists + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const item = this.leastRecentlyUsed!; + + // If there's a newer item, make it the oldest + if (item[NEWER]) { + this.leastRecentlyUsed = item[NEWER]; + this.leastRecentlyUsed[OLDER] = undefined; + } + + // Remove the item from the map + this.map.delete(item.key); + item[NEWER] = undefined; + item[OLDER] = undefined; + } + + /** + * Marks an item as the most recently used item in the cache. + * + * @param item - The item to mark as the most recently used + */ + private trackItemUse(item: Item): void { + // If the item is already the newest, we don't need to do anything + if (this.mostRecentlyUsed === item) return; // TODO: check this + + // If the item is not the newest, we need to mark it as the newest + if (item[NEWER]) { + if (item === this.leastRecentlyUsed) { + this.leastRecentlyUsed = item[NEWER]; + } + item[NEWER][OLDER] = item[OLDER]; + } + if (item[OLDER]) { + item[OLDER][NEWER] = item[NEWER]; + } + item[NEWER] = undefined; + item[OLDER] = this.mostRecentlyUsed; + if (this.mostRecentlyUsed) { + this.mostRecentlyUsed[NEWER] = item; + } + this.mostRecentlyUsed = item; + } +} + +export { + LRUCache, +}; \ No newline at end of file diff --git a/packages/idempotency/src/types/IdempotencyOptions.ts b/packages/idempotency/src/types/IdempotencyOptions.ts index cc468aff7a..6f53f8efcd 100644 --- a/packages/idempotency/src/types/IdempotencyOptions.ts +++ b/packages/idempotency/src/types/IdempotencyOptions.ts @@ -19,19 +19,23 @@ type IdempotencyConfigOptions = { */ payloadValidationJmesPath?: string /** - * Throw an error if no idempotency key was found in the request, defaults to false + * Throw an error if no idempotency key was found in the request, defaults to `false` */ throwOnNoIdempotencyKey?: boolean /** - * The number of seconds to wait before a record is expired, defaults to 3600 (1 hour) + * The number of seconds to wait before a record is expired, defaults to `3600` (1 hour) */ expiresAfterSeconds?: number /** - * Wheter to locally cache idempotency results, defaults to false + * Wheter to locally cache idempotency results, defaults to `false` */ useLocalCache?: boolean /** - * Function to use for calculating hashes, defaults to md5 + * Number of records to keep in the local cache, defaults to `256` + */ + maxLocalCacheSize?: number + /** + * Function to use for calculating hashes, defaults to `md5` */ hashFunction?: string /** diff --git a/packages/idempotency/src/types/LRUCache.ts b/packages/idempotency/src/types/LRUCache.ts new file mode 100644 index 0000000000..3821befe28 --- /dev/null +++ b/packages/idempotency/src/types/LRUCache.ts @@ -0,0 +1,10 @@ +type LRUCacheOptions = { + /** + * The maximum number of items to store in the cache. + */ + maxSize: number +}; + +export { + LRUCacheOptions +}; \ No newline at end of file diff --git a/packages/idempotency/src/types/index.ts b/packages/idempotency/src/types/index.ts index e643466963..36579ac56e 100644 --- a/packages/idempotency/src/types/index.ts +++ b/packages/idempotency/src/types/index.ts @@ -2,4 +2,5 @@ export * from './AnyFunction'; export * from './IdempotencyRecord'; export * from './BasePersistenceLayer'; export * from './IdempotencyOptions'; -export * from './DynamoDBPersistence'; \ No newline at end of file +export * from './DynamoDBPersistence'; +export * from './LRUCache'; \ No newline at end of file diff --git a/packages/idempotency/tests/unit/persistence/BasePersistenceLayer.test.ts b/packages/idempotency/tests/unit/persistence/BasePersistenceLayer.test.ts index fecf1c3914..d63388c285 100644 --- a/packages/idempotency/tests/unit/persistence/BasePersistenceLayer.test.ts +++ b/packages/idempotency/tests/unit/persistence/BasePersistenceLayer.test.ts @@ -6,7 +6,7 @@ import { ContextExamples as dummyContext } from '@aws-lambda-powertools/commons'; -import { +import { IdempotencyConfig, } from '../../../src/IdempotencyConfig'; import { @@ -14,6 +14,7 @@ import { BasePersistenceLayer } from '../../../src/persistence'; import { + IdempotencyItemAlreadyExistsError, IdempotencyValidationError, } from '../../../src/Exceptions'; import type { @@ -41,7 +42,7 @@ describe('Class: BasePersistenceLayer', () => { public _deleteRecord = jest.fn(); public _getRecord = jest.fn(); - public _putRecord = jest.fn(); + public _putRecord = jest.fn(); public _updateRecord = jest.fn(); } @@ -105,7 +106,7 @@ describe('Class: BasePersistenceLayer', () => { // Prepare const config = new IdempotencyConfig({}); const persistenceLayer = new PersistenceLayerTestClass(); - + // Act persistenceLayer.configure({ config }); @@ -132,6 +133,7 @@ describe('Class: BasePersistenceLayer', () => { throwOnNoIdempotencyKey: true, expiresAfterSeconds: 100, useLocalCache: true, + maxLocalCacheSize: 300, hashFunction: 'hashFunction', lambdaContext: context, }; @@ -149,7 +151,7 @@ describe('Class: BasePersistenceLayer', () => { payloadValidationEnabled: true, throwOnNoIdempotencyKey: true, expiresAfterSeconds: 100, - useLocalCache: false, + useLocalCache: true, hashFunction: 'hashFunction', })); @@ -191,7 +193,37 @@ describe('Class: BasePersistenceLayer', () => { status: IdempotencyRecordStatus.EXPIRED, }); const deleteRecordSpy = jest.spyOn(persistenceLayer, '_deleteRecord'); - + + // Act + await persistenceLayer.deleteRecord({ foo: 'bar' }); + + // Assess + expect(deleteRecordSpy).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseIdempotencyRecord, + idempotencyKey: 'my-lambda-function#mocked-hash', + status: IdempotencyRecordStatus.EXPIRED + }), + ); + + }); + + test('when called, it deletes the record from the local cache', async () => { + + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + persistenceLayer.configure({ + config: new IdempotencyConfig({ + useLocalCache: true, + }), + }); + const baseIdempotencyRecord = new IdempotencyRecord({ + idempotencyKey: 'idempotencyKey', + status: IdempotencyRecordStatus.EXPIRED, + }); + await persistenceLayer.saveSuccess({ foo: 'bar' }, { bar: 'baz' }); + const deleteRecordSpy = jest.spyOn(persistenceLayer, '_deleteRecord'); + // Act await persistenceLayer.deleteRecord({ foo: 'bar' }); @@ -216,19 +248,19 @@ describe('Class: BasePersistenceLayer', () => { const persistenceLayer = new PersistenceLayerTestClass(); persistenceLayer.configure({ config: new IdempotencyConfig({ - eventKeyJmesPath: 'foo', + eventKeyJmesPath: 'foo', }), }); const getRecordSpy = jest.spyOn(persistenceLayer, '_getRecord'); - + // Act await persistenceLayer.getRecord({ foo: 'bar' }); // Assess expect(getRecordSpy).toHaveBeenCalledWith('my-lambda-function#mocked-hash'); - + }); - + test('when called and payload validation fails due to hash mismatch, it throws an IdempotencyValidationError', async () => { // Prepare @@ -243,7 +275,7 @@ describe('Class: BasePersistenceLayer', () => { status: IdempotencyRecordStatus.INPROGRESS, payloadHash: 'different-hash', })); - + // Act & Assess await expect(persistenceLayer.getRecord({ foo: 'bar' })).rejects.toThrow( new IdempotencyValidationError( @@ -260,7 +292,7 @@ describe('Class: BasePersistenceLayer', () => { persistenceLayer.configure({ config: new IdempotencyConfig({ // This will cause the hash generation to fail because the event does not have a bar property - eventKeyJmesPath: 'bar', + eventKeyJmesPath: 'bar', }), }); const logWarningSpy = jest.spyOn(console, 'warn').mockImplementation(); @@ -283,7 +315,7 @@ describe('Class: BasePersistenceLayer', () => { config: new IdempotencyConfig({ throwOnNoIdempotencyKey: true, // This will cause the hash generation to fail because the JMESPath expression will return an empty array - eventKeyJmesPath: 'foo.bar', + eventKeyJmesPath: 'foo.bar', }), }); @@ -294,6 +326,55 @@ describe('Class: BasePersistenceLayer', () => { }); + test('when called twice with the same payload, it retrieves the record from the local cache', async () => { + + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + persistenceLayer.configure({ + config: new IdempotencyConfig({ + useLocalCache: true, + }), + }); + const getRecordSpy = jest.spyOn(persistenceLayer, '_getRecord').mockReturnValue(new IdempotencyRecord({ + idempotencyKey: 'my-lambda-function#mocked-hash', + status: IdempotencyRecordStatus.COMPLETED, + payloadHash: 'different-hash', + })); + + // Act + await persistenceLayer.getRecord({ foo: 'bar' }); + await persistenceLayer.getRecord({ foo: 'bar' }); + + // Assess + expect(getRecordSpy).toHaveBeenCalledTimes(1); + + }); + + test('when called twice with the same payload, if it founds an expired record in the local cache, it retrieves it', async () => { + + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + persistenceLayer.configure({ + config: new IdempotencyConfig({ + useLocalCache: true, + }), + }); + const getRecordSpy = jest.spyOn(persistenceLayer, '_getRecord').mockReturnValue(new IdempotencyRecord({ + idempotencyKey: 'my-lambda-function#mocked-hash', + status: IdempotencyRecordStatus.EXPIRED, + payloadHash: 'different-hash', + expiryTimestamp: Date.now() / 1000 - 1, + })); + + // Act + await persistenceLayer.getRecord({ foo: 'bar' }); + await persistenceLayer.getRecord({ foo: 'bar' }); + + // Assess + expect(getRecordSpy).toHaveBeenCalledTimes(2); + + }); + }); describe('Method: saveInProgress', () => { @@ -304,7 +385,7 @@ describe('Class: BasePersistenceLayer', () => { const persistenceLayer = new PersistenceLayerTestClass(); const putRecordSpy = jest.spyOn(persistenceLayer, '_putRecord'); const remainingTimeInMs = 2000; - + // Act await persistenceLayer.saveInProgress({ foo: 'bar' }, remainingTimeInMs); @@ -328,7 +409,7 @@ describe('Class: BasePersistenceLayer', () => { const persistenceLayer = new PersistenceLayerTestClass(); const putRecordSpy = jest.spyOn(persistenceLayer, '_putRecord'); const logWarningSpy = jest.spyOn(console, 'warn').mockImplementation(() => ({})); - + // Act await persistenceLayer.saveInProgress({ foo: 'bar' }); @@ -340,6 +421,51 @@ describe('Class: BasePersistenceLayer', () => { }); + test('when called and there is already a completed record in the cache, it throws an IdempotencyItemAlreadyExistsError', async () => { + + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + persistenceLayer.configure({ + config: new IdempotencyConfig({ + useLocalCache: true, + }), + }); + const putRecordSpy = jest.spyOn(persistenceLayer, '_putRecord'); + await persistenceLayer.saveSuccess({ foo: 'bar' }, { bar: 'baz' }); + + // Act & Assess + await expect(persistenceLayer.saveInProgress({ foo: 'bar' })).rejects.toThrow( + new IdempotencyItemAlreadyExistsError() + ); + expect(putRecordSpy).toHaveBeenCalledTimes(0); + + }); + + test('when called and there is an in-progress record in the cache, it returns', async () => { + + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + persistenceLayer.configure({ + config: new IdempotencyConfig({ + useLocalCache: true, + }), + }); + jest.spyOn(persistenceLayer, '_getRecord').mockImplementationOnce( + () => new IdempotencyRecord({ + idempotencyKey: 'my-lambda-function#mocked-hash', + status: IdempotencyRecordStatus.INPROGRESS, + payloadHash: 'different-hash', + expiryTimestamp: Date.now() / 1000 + 3600, + inProgressExpiryTimestamp: Date.now() + 2000, + }) + ); + await persistenceLayer.getRecord({ foo: 'bar' }); + + // Act & Assess + await expect(persistenceLayer.saveInProgress({ foo: 'bar' })).resolves.toBeUndefined(); + + }); + }); describe('Method: saveSuccess', () => { @@ -350,7 +476,7 @@ describe('Class: BasePersistenceLayer', () => { const persistenceLayer = new PersistenceLayerTestClass(); const updateRecordSpy = jest.spyOn(persistenceLayer, '_updateRecord'); const result = { bar: 'baz' }; - + // Act await persistenceLayer.saveSuccess({ foo: 'bar' }, result); diff --git a/packages/idempotency/tests/unit/persistence/LRUCache.test.ts b/packages/idempotency/tests/unit/persistence/LRUCache.test.ts new file mode 100644 index 0000000000..238083a406 --- /dev/null +++ b/packages/idempotency/tests/unit/persistence/LRUCache.test.ts @@ -0,0 +1,198 @@ +/** + * Test LRUCache class + * + * @group unit/idempotency/persistence/lru-cache + */ +import { + LRUCache, +} from '../../../src/persistence/LRUCache'; + +describe('Class: LRUMap', () => { + + describe('Method: add', () => { + + test('when called it adds items to the cache', () => { + + // Prepare + const cache = new LRUCache(); + + // Act + cache.add('a', 1); + cache.add('b', 2); + + // Assess + expect(cache.size()).toBe(2); + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBe(2); + + }); + + test('when called it updates the value of an existing key', () => { + + // Prepare + const cache = new LRUCache(); + cache.add('a', 1); + + // Act + cache.add('a', 2); + + // Assess + expect(cache.size()).toBe(1); + expect(cache.get('a')).toBe(2); + + }); + + test('when called it removes the oldest item when the cache is full', () => { + + // Prepare + const cache = new LRUCache({ maxSize: 2 }); + cache.add('a', 1); + cache.add('b', 2); + + // Act + cache.add('c', 3); + + // Assess + expect(cache.size()).toBe(2); + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe(2); + expect(cache.get('c')).toBe(3); + + }); + + test('when called and maxSize is 0, it skips cache', () => { + + // Prepare + const cache = new LRUCache({ maxSize: 0 }); + + // Act + cache.add('a', 1); + + // Assess + expect(cache.size()).toBe(0); + + }); + + }); + + describe('Method: get', () => { + + test('when called it returns the value of an existing key', () => { + + // Prepare + const cache = new LRUCache(); + cache.add('a', 1); + + // Act + const value = cache.get('a'); + + // Assess + expect(value).toBe(1); + + }); + + test('when called it returns undefined for a non-existing key', () => { + + // Prepare + const cache = new LRUCache(); + + // Act + const value = cache.get('a'); + + // Assess + expect(value).toBeUndefined(); + + }); + + test('when called it marks the item as the most recently used', () => { + + // Prepare + const cache = new LRUCache(); + cache.add('a', 1); + cache.add('b', 2); + cache.add('c', 3); + + // Act + cache.get('b'); + + // Assess + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBe(2); + expect(cache.get('c')).toBe(3); + + }); + + }); + + describe('Method: has', () => { + + test('when called it returns true for an existing key', () => { + + // Prepare + const cache = new LRUCache(); + cache.add('a', 1); + + // Act + const hasKey = cache.has('a'); + + // Assess + expect(hasKey).toBe(true); + + }); + + test('when called it returns false for a non-existing key', () => { + + // Prepare + const cache = new LRUCache(); + + // Act + const hasKey = cache.has('a'); + + // Assess + expect(hasKey).toBe(false); + + }); + + }); + + describe('Method: remove', () => { + + test('when called it removes the item from the cache', () => { + + // Prepare + const cache = new LRUCache(); + cache.add('a', 1); + cache.add('b', 2); + cache.add('c', 3); + + // Act + cache.remove('b'); + cache.remove('c'); + cache.remove('a'); + + // Assess + expect(cache.size()).toBe(0); + expect(cache.get('a')).toBeUndefined(); + + }); + + test('when called on an empty cache it does nothing', () => { + + // Prepare + const cache = new LRUCache(); + cache.add('a', 1); + cache.add('b', 2); + + // Act + cache.remove('a'); + cache.remove('b'); + cache.remove('a'); + + // Assess + expect(cache.size()).toBe(0); + + }); + + }); + +}); \ No newline at end of file