UNPKG

hamok

Version:

Lightweight Distributed Object Storage on RAFT consensus algorithm

478 lines (377 loc) 14.3 kB
import { EventEmitter } from 'events'; import { createLogger } from '../common/logger'; import { HamokConnection } from './HamokConnection'; import { BaseMap } from './BaseMap'; import * as Collections from '../common/Collections'; import { HamokMapSnapshot } from '../HamokSnapshot'; import { createHamokCodec } from '../common/HamokCodec'; const logger = createLogger('HamokMap'); export type HamokMapEventMap<K, V> = { 'insert': [key: K, value: V], 'update': [key: K, oldValue: V, newValue: V], 'remove': [key: K, value: V], 'clear': [], 'close': [], } export declare interface HamokMap<K, V> { on<U extends keyof HamokMapEventMap<K, V>>(event: U, listener: (...args: HamokMapEventMap<K, V>[U]) => void): this; off<U extends keyof HamokMapEventMap<K, V>>(event: U, listener: (...args: HamokMapEventMap<K, V>[U]) => void): this; once<U extends keyof HamokMapEventMap<K, V>>(event: U, listener: (...args: HamokMapEventMap<K, V>[U]) => void): this; emit<U extends keyof HamokMapEventMap<K, V>>(event: U, ...args: HamokMapEventMap<K, V>[U]): boolean; } /** * Replicated storage replicates all entries on all distributed storages */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class HamokMap<K, V> extends EventEmitter { private _closed = false; public equalValues: (a: V, b: V) => boolean; private _initializing?: Promise<this>; public constructor( public readonly connection: HamokConnection<K, V>, public readonly baseMap: BaseMap<K, V>, equalValues?: (a: V, b: V) => boolean, ) { super(); this.setMaxListeners(Infinity); this.equalValues = equalValues ?? ((a, b) => { // logger.info('Comparing values: %o (%s), %o (%s)', a, b, JSON.stringify(a), JSON.stringify(b)); return JSON.stringify(a) === JSON.stringify(b); }); this.connection .on('ClearEntriesRequest', (request) => { this.baseMap.clear(); if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond( 'ClearEntriesResponse', request.createResponse(), request.sourceEndpointId ); } this.emit('clear'); }) .on('DeleteEntriesRequest', (request) => { const removedEntries = this.baseMap.removeAll(request.keys.values()); if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond( 'DeleteEntriesResponse', request.createResponse( new Set(removedEntries.keys()) ), request.sourceEndpointId ); } removedEntries.forEach((v, k) => this.emit('remove', k, v)); }) .on('GetEntriesRequest', (request) => { // only requested by the sync process when the storage enters to the grid const foundEntries = this.baseMap.getAll(request.keys.values()); this.connection.respond( 'GetEntriesResponse', request.createResponse(foundEntries), request.sourceEndpointId ); }) .on('InsertEntriesRequest', (request) => { logger.debug('%s InsertEntriesRequest: %o, %s', this.connection.grid.localPeerId, request, [ ...request.entries ].join(', ')); const existingEntries = this.baseMap.insertAll(request.entries); if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond( 'InsertEntriesResponse', request.createResponse(existingEntries), request.sourceEndpointId ); } request.entries.forEach((v, k) => existingEntries.has(k) || this.emit('insert', k, v)); }) .on('RemoveEntriesRequest', (request) => { if (request.prevValue !== undefined) { // this is a conditional remove if (request.keys.size !== 1) { // we let the request to timeout return logger.warn('Conditional remove request must have only one entry: %o', request); } const key = [ ...request.keys ][0]; const existingValue = this.baseMap.get(key); logger.trace('Conditional remove request: %s, %s, %s', key, existingValue, request.prevValue); if (!existingValue || !this.equalValues(existingValue, request.prevValue as V)) { return; } } const removedEntries = this.baseMap.removeAll(request.keys.values()); if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond( 'RemoveEntriesResponse', request.createResponse( removedEntries ), request.sourceEndpointId ); } removedEntries.forEach((v, k) => this.emit('remove', k, v)); }) .on('UpdateEntriesRequest', (request) => { logger.trace('%s UpdateEntriesRequest: %o, %s', this.connection.grid.localPeerId, request, [ ...request.entries ].join(', ')); const updatedEntries: [K, V, V][] = []; const insertedEntries: [K, V][] = []; if (request.prevValue !== undefined) { // this is a conditional update if (request.entries.size !== 1) { // we let the request to timeout return logger.trace('Conditional update request must have only one entry: %o', request); } const [ key, value ] = [ ...request.entries ][0]; const existingValue = this.baseMap.get(key); logger.trace('Conditional update request: %s, %s, %s, %s', key, value, existingValue, request.prevValue); if (existingValue && this.equalValues(existingValue, request.prevValue)) { this.baseMap.set(key, value); updatedEntries.push([ key, existingValue, value ]); } } else { this.baseMap.setAll(request.entries, ({ inserted, updated }) => { insertedEntries.push(...inserted); updatedEntries.push(...updated); }); } if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond( 'UpdateEntriesResponse', request.createResponse(new Map<K, V>(updatedEntries.map(([ key, oldValue ]) => [ key, oldValue ]))), request.sourceEndpointId ); } insertedEntries.forEach(([ key, value ]) => this.emit('insert', key, value)); updatedEntries.forEach(([ key, oldValue, newValue ]) => this.emit('update', key, oldValue, newValue)); }) .on('StorageHelloNotification', (notification) => { // every storage needs to respond with its snapshot and the highest applied index they have try { const snapshot = this.export(); const serializedSnapshot = JSON.stringify(snapshot); this.connection.notifyStorageState( serializedSnapshot, this.connection.highestSeenCommitIndex, notification.sourceEndpointId, ); } catch (err) { logger.error('Failed to send snapshot', err); } }) .on('remote-snapshot', (serializedSnapshot, done) => { try { const snapshot = JSON.parse(serializedSnapshot) as HamokMapSnapshot; this._import( snapshot, // emit events if we are not initializing Boolean(this._initializing) === false, ); } catch (err) { logger.error(`Failed to import to map ${this.id}. Error: ${err}`); } finally { done(); } }) .once('close', () => this.close()) ; const entries = new Map([ ...this.baseMap.entries() ]); // clear the initial entries this.baseMap.clear(); this._initializing = new Promise((resolve) => setTimeout(resolve, 20)) .then(() => this.connection.join()) .then(async () => { // initializing logger.debug('%s Initializing record %d', this.connection.localPeerId, this.id); if (entries.size < 1) return this; await this.connection.requestInsertEntries(entries).then(() => void 0); logger.debug('%s Initialization for record %d is complete', this.connection.localPeerId, this.id); return this; }) .catch((err) => { logger.error('Failed to initialize record %s %o', this.id, err); return this; }) .finally(() => { this._initializing = undefined; }); } public get id(): string { return this.connection.config.storageId; } public get ready(): Promise<this> { return this._initializing ?? this.connection.grid.waitUntilCommitHead().then(() => this); } public get closed() { return this._closed; } public close(): void { if (this._closed) return; this._closed = true; this.connection.close(); this.emit('close'); this.removeAllListeners(); } public get size() { return this.baseMap.size; } public get isEmpty() { return this.baseMap.size === 0; } public keys() { return this.baseMap.keys(); } public async clear(): Promise<void> { if (this._closed) throw new Error(`Cannot clear a closed storage (${this.id})`); await this._initializing; return this.connection.requestClearEntries(); } public get(key: K): V | undefined { return this.baseMap.get(key); } public getAll(keys: IterableIterator<K> | K[]): ReadonlyMap<K, V> { if (this._closed) throw new Error(`Cannot get entries from a closed storage (${this.id})`); if (Array.isArray(keys)) return this.baseMap.getAll(keys.values()); else return this.baseMap.getAll(keys); } public async set(key: K, value: V): Promise<V | undefined> { if (this._closed) throw new Error(`Cannot set an entry on a closed storage (${this.id})`); const result = await this.setAll( Collections.mapOf([ key, value ]) ); return result.get(key); } public async setAll(entries: ReadonlyMap<K, V>): Promise<ReadonlyMap<K, V>> { if (this._closed) throw new Error(`Cannot set entries on a closed storage (${this.id})`); await this._initializing; if (entries.size < 1) { return Collections.emptyMap<K, V>(); } return this.connection.requestUpdateEntries(entries); } public async insert(key: K, value: V): Promise<V | undefined> { const result = await this.insertAll( Collections.mapOf([ key, value ]) ); return result.get(key); } public async insertAll(entries: ReadonlyMap<K, V> | [K, V][]): Promise<ReadonlyMap<K, V>> { if (this._closed) throw new Error(`Cannot insert entries on a closed storage (${this.id})`); await this._initializing; if (Array.isArray(entries)) { if (entries.length < 1) return Collections.emptyMap<K, V>(); entries = Collections.mapOf(...entries); } if (entries.size < 1) { return Collections.emptyMap<K, V>(); } return this.connection.requestInsertEntries(entries); } public async delete(key: K): Promise<boolean> { const result = await this.deleteAll( Collections.setOf(key) ); return result.has(key); } public async deleteAll(keys: ReadonlySet<K> | K[]): Promise<ReadonlySet<K>> { if (this._closed) throw new Error(`Cannot delete entries on a closed storage (${this.id})`); await this._initializing; if (Array.isArray(keys)) { if (keys.length < 1) return Collections.emptySet<K>(); keys = Collections.setOf(...keys); } if (keys.size < 1) { return Collections.emptySet<K>(); } return this.connection.requestDeleteEntries(keys); } public async removeIf(key: K, oldValue: V): Promise<boolean> { if (this._closed) throw new Error(`Cannot update an entry on a closed storage (${this.id})`); await this._initializing; logger.trace('%s RemoveIf: %s, %s, %s', this.connection.grid.localPeerId, key, oldValue, oldValue); return (await this.connection.requestRemoveEntries( Collections.setOf(key), undefined, oldValue, )).get(key) !== undefined; } public async remove(key: K): Promise<boolean> { const result = await this.removeAll( Collections.setOf(key) ); return result.has(key); } public async removeAll(keys: ReadonlySet<K> | K[]): Promise<ReadonlyMap<K, V>> { if (this._closed) throw new Error(`Cannot remove entries on a closed storage (${this.id})`); await this._initializing; if (Array.isArray(keys)) { if (keys.length < 1) return Collections.emptyMap<K, V>(); keys = Collections.setOf(...keys); } if (keys.size < 1) { return Collections.emptyMap<K, V>(); } return this.connection.requestRemoveEntries(keys); } public async updateIf(key: K, value: V, oldValue: V): Promise<boolean> { if (this._closed) throw new Error(`Cannot update an entry on a closed storage (${this.id})`); await this._initializing; logger.trace('%s UpdateIf: %s, %s, %s', this.connection.grid.localPeerId, key, value, oldValue); return (await this.connection.requestUpdateEntries( Collections.mapOf([ key, value ]), undefined, oldValue )).get(key) !== undefined; } public [Symbol.iterator](): IterableIterator<[K, V]> { return this.baseMap[Symbol.iterator](); } public entries(): IterableIterator<[K, V]> { return this.baseMap.entries(); } public values(): IterableIterator<V> { return this.baseMap.values(); } /** * Exports the storage data */ public export(): HamokMapSnapshot { const [ keys, values ] = this.connection.codec.encodeEntries(this.baseMap); const result: HamokMapSnapshot = { mapId: this.id, keys: HamokMap.uint8ArrayToStringCodec.encode(keys), values: HamokMap.uint8ArrayToStringCodec.encode(values), }; return result; } public import(data: HamokMapSnapshot, eventing?: boolean) { if (data.mapId !== this.id) { throw new Error(`Cannot import data from a different storage: ${data.mapId} !== ${this.id}`); } else if (this.connection.connected) { throw new Error('Cannot import data while connected'); } else if (this._closed) { throw new Error(`Cannot import data on a closed storage (${this.id})`); } this._import(data, eventing); } private _import(data: HamokMapSnapshot, eventing?: boolean) { const keys = HamokMap.uint8ArrayToStringCodec.decode(data.keys); const values = HamokMap.uint8ArrayToStringCodec.decode(data.values); const entries = this.connection.codec.decodeEntries( keys, values ); this.baseMap.setAll(entries, ({ inserted, updated }) => { if (eventing) { inserted.forEach(([ key, value ]) => this.emit('insert', key, value)); updated.forEach(([ key, oldValue, newValue ]) => this.emit('update', key, oldValue, newValue)); } }); } public static uint8ArrayToStringCodec = createHamokCodec<Uint8Array[], string[]>( (array) => { return array.map((item) => Buffer.from(item).toString('utf8')); }, (array) => { return array.map((item) => Buffer.from(item, 'utf8')); } ); }