UNPKG

@linkedmink/multilevel-aging-cache

Version:

Package provides an interface to cache and persist data to Redis, MongoDB, memory

322 lines (286 loc) 10.7 kB
import { IStorageProvider } from './IStorageProvider'; import { AgedCompareFunc, compareAscending, IAgedValue } from '../queue/IAgedQueue'; import { Logger } from '../shared/Logger'; import { IDisposable } from '../shared/IDisposable'; import { IStorageHierarchy, StorageHierarchyUpdatePolicy, IStorageHierarchyWrite, } from './IStorageHierarchy'; import { StorageProviderUpdateHandler, isISubscribableStorageProvider, } from './ISubscribableStorageProvider'; type SubscriberUpdateHandler<TKey, TValue> = ( key: TKey, value?: IAgedValue<TValue> ) => Promise<boolean>; /** * The default storage hierarchy implementation relying on IStorageProvider for actual data access */ export class StorageHierarchy<TKey, TValue> implements IStorageHierarchy<TKey, TValue>, IDisposable { readonly totalLevels = this.levels.length; readonly isPersistable = this.levels[this.totalLevels - 1].isPersistable; private readonly logger = Logger.get(StorageHierarchy.name); private readonly storageChangedHandlers = new Map< number, StorageProviderUpdateHandler<TKey, TValue> >(); private readonly pendingUpdates: Set<Promise<void>> = new Set(); private readonly topLevel = this.levels[this.totalLevels - 1]; private readonly publishLevel?: number; /** * @param levels The levels in the hierarchy with index 0 being the lowest level (first to read) * @param updatePolicy How updates from subscribed higher level storage providers should be handled */ constructor( private readonly levels: IStorageProvider<TKey, TValue>[], private readonly updatePolicy: StorageHierarchyUpdatePolicy = StorageHierarchyUpdatePolicy.OnlyIfKeyExist, private readonly ageCompareFunc: AgedCompareFunc = compareAscending ) { if (levels.length < 2) { throw new Error('StorageHierarchy must have at least 2 storage provider'); } this.logger.info(`Created storage hierarchy with levels: ${this.totalLevels}`); this.publishLevel = this.subscribeAtLevel(this.totalLevels - 1); } /** * Clean up the object when it's no longer used. After a dispose(), an object * is no longer guaranteed to be usable. */ public dispose(): Promise<void> { this.storageChangedHandlers.forEach((handler, level) => { const currentLevel = this.levels[level]; if (isISubscribableStorageProvider(currentLevel)) { currentLevel.unsubscribe(handler); } this.storageChangedHandlers.delete(level); }); return Promise.all(this.pendingUpdates).then(() => undefined); } /** * @param key The key to retrieve * @param level The level at which to retrieve the key * @param isAscending To go up the hierarchy (true) or down (false) from level * @returns The value if it's in the hierarchy from the level going up/down or null */ public getAtLevel( key: TKey, level?: number, isAscending = true ): Promise<IAgedValue<TValue> | null> { const rLevel = this.getCurrentLevelOrNull(isAscending, level); if (rLevel === null) { return Promise.resolve(null); } return this.levels[rLevel] .get(key) .then(agedValue => { if (agedValue) { return agedValue; } else { this.logger.debug(`Cache miss: level=${rLevel}, key=${key}`); return this.getAtLevel(key, isAscending ? rLevel + 1 : rLevel - 1, isAscending); } }) .catch(error => { this.logger.debug(`Failed to Get: level=${rLevel}, key=${key}, error=${error}`); return this.getAtLevel(key, isAscending ? rLevel + 1 : rLevel - 1, isAscending); }); } /** * @param key The key to set * @param value The value to set * @param level The level at which to set the key * @param isAscending To go up the hierarchy (true) or down (false) from level * @returns If the write succeeded to all levels going up/down or the error condition */ public setAtLevel( key: TKey, value: IAgedValue<TValue>, level?: number, isAscending = false ): Promise<IStorageHierarchyWrite<TValue>> { const rLevel = this.getCurrentLevelOrNull(isAscending, level); if (rLevel === null) { return Promise.resolve(this.getFullWriteStatus(value)); } return this.levels[rLevel] .set(key, value) .then(valueWritten => { if (valueWritten !== null) { return this.setAtLevel( key, valueWritten, isAscending ? rLevel + 1 : rLevel - 1, isAscending ); } return this.getPartialWriteStatus(isAscending, rLevel, value); }) .catch(error => { this.logger.warn(`Error setting: level=${rLevel}, key=${key}, error=${error}`); return this.getPartialWriteStatus(isAscending, rLevel, value); }); } /** * @param key The key to delete * @param level The level at which to delete the key * @param isAscending To go up the hierarchy (true) or down (false) from level * @returns If the write succeeded to all levels going up/down or the error condition */ public deleteAtLevel( key: TKey, level?: number, isAscending = false, writtenValue?: IAgedValue<TValue> ): Promise<IStorageHierarchyWrite<TValue>> { const rLevel = this.getCurrentLevelOrNull(isAscending, level); if (rLevel === null) { return Promise.resolve(this.getFullWriteStatus(writtenValue)); } return this.levels[rLevel] .delete(key) .then(isSuccessful => { if (isSuccessful) { return this.deleteAtLevel(key, isAscending ? rLevel + 1 : rLevel - 1, isAscending); } return this.getPartialWriteStatus(isAscending, rLevel, writtenValue); }) .catch(error => { this.logger.warn(`Error deleting: level=${rLevel}, key=${key}, error=${error}`); return this.getPartialWriteStatus(isAscending, rLevel, writtenValue); }); } /** * @param level The level at which to search * @return The number of keys at the specified level */ public getSizeAtLevel(level: number): Promise<number> { return this.levels[level].size(); } /** * @returns The keys a the top level (should be all keys across the entire hierarchy) */ public getKeysAtTopLevel(): Promise<TKey[]> { return this.topLevel.keys(); } /** * @param key The key to retrieve * @returns The value at the top level only or null */ public getValueAtTopLevel(key: TKey): Promise<IAgedValue<TValue> | null> { return this.getAtLevel(key, this.totalLevels - 1); } /** * @param key The key to retrieve * @returns The value at the bottom level only or null */ public getValueAtBottomLevel(key: TKey): Promise<IAgedValue<TValue> | null> { return this.getAtLevel(key, 0, false); } /** * Set only the levels below the top level (for refresing from the top level for instance) * @param key The key to set * @param value The value to set * @returns If the write succeeded to all levels going up/down or the error condition */ public setBelowTopLevel( key: TKey, value: IAgedValue<TValue> ): Promise<IStorageHierarchyWrite<TValue>> { return this.setAtLevel(key, value, this.totalLevels - 2); } private subscribeAtLevel(level: number, publishLevel?: number): number | undefined { if (level <= 0) { return publishLevel; } const nextLevel = level - 1; const currentLevel = this.levels[level]; if (isISubscribableStorageProvider(currentLevel)) { this.logger.debug(`subscribe to level: ${level}`); let handler = this.getUpdateHandlerAlways(nextLevel); if (this.updatePolicy === StorageHierarchyUpdatePolicy.OnlyIfKeyExist) { handler = this.getUpdateHandlerOnlyIfKeyExist(nextLevel, handler); } const wrappedHandler = this.getManagedPromiseSubscribe(handler); currentLevel.subscribe(wrappedHandler); this.storageChangedHandlers.set(level, wrappedHandler); if (!publishLevel) { publishLevel = level; } } return this.subscribeAtLevel(nextLevel, publishLevel); } private getCurrentLevelOrNull(isAscending: boolean, level?: number): number | null { level = level === undefined ? (isAscending ? 0 : this.totalLevels - 1) : level; if (isAscending && level >= this.totalLevels) { return null; } else if (!isAscending && level < 0) { return null; } else { return level; } } private getUpdateHandlerAlways(updateLevel: number) { return (key: TKey, value?: IAgedValue<TValue>): Promise<boolean> => { if (value) { return this.setAtLevel(key, value, updateLevel).then( s => s.writtenLevels === this.totalLevels ); } else { return this.deleteAtLevel(key, updateLevel).then(s => s.writtenLevels === this.totalLevels); } }; } private getUpdateHandlerOnlyIfKeyExist( updateLevel: number, updateUnconditionally: SubscriberUpdateHandler<TKey, TValue> ) { return (key: TKey, value?: IAgedValue<TValue>): Promise<boolean> => { return this.getAtLevel(key, updateLevel, false).then(agedValue => { if (agedValue) { if (value !== undefined && this.ageCompareFunc(agedValue.age, value.age) >= 0) { return Promise.resolve(true); } return updateUnconditionally(key, value); } this.logger.debug(`Key doesn't exist, ignoring subscribed update: ${key}`); return Promise.resolve(false); }); }; } private getManagedPromiseSubscribe(func: SubscriberUpdateHandler<TKey, TValue>) { return (key: TKey, value?: IAgedValue<TValue>): void => { const promise = func(key, value).then(() => { this.pendingUpdates.delete(promise); }); this.pendingUpdates.add(promise); }; } private getPartialWriteStatus( isAscending: boolean, level: number, value?: IAgedValue<TValue> ): IStorageHierarchyWrite<TValue> { const writtenLevels = isAscending ? level : this.totalLevels - level - 1; return { isPersisted: !isAscending && level <= this.totalLevels - 2 && this.topLevel.isPersistable, isPublished: this.publishLevel !== undefined && (isAscending ? level > this.publishLevel : level < this.publishLevel), writtenLevels, writtenValue: writtenLevels !== 0 ? value : undefined, }; } private getFullWriteStatus(value?: IAgedValue<TValue>): IStorageHierarchyWrite<TValue> { return { isPersisted: this.topLevel.isPersistable, isPublished: this.publishLevel !== undefined, writtenLevels: this.totalLevels, writtenValue: value, }; } }