UNPKG

hamok

Version:

Lightweight Distributed Object Storage on RAFT consensus algorithm

357 lines (282 loc) 9.66 kB
import { HamokConnection } from './HamokConnection'; import { BaseMap } from './BaseMap'; import { HamokQueueSnapshot } from '../HamokSnapshot'; import { EventEmitter } from 'events'; import { createLogger } from '../common/logger'; import * as Collections from '../common/Collections'; import { createHamokCodec } from '../common/HamokCodec'; const logger = createLogger('HamokQueue'); export type HamokQueueEventMap<T> = { 'empty': []; 'not-empty': []; 'close': []; 'add': [T]; 'remove': [T]; } export type HamokQueueConfig = { queueId: string; maxQueueSize?: number; } function *iterator<T>(first: number, last: number, baseMap: BaseMap<number, T>): IterableIterator<T> { if (last < first) throw new Error('Invalid iterator parameters. first > last'); if (first === last) return; for (let i = first; i < last; i++) { const item = baseMap.get(i); if (item === undefined) { throw new Error('Invalid iterator parameters. Item is undefined'); } yield item; } } export declare interface HamokQueue<T> { on<U extends keyof HamokQueueEventMap<T>>(event: U, listener: (...args: HamokQueueEventMap<T>[U]) => void): this; off<U extends keyof HamokQueueEventMap<T>>(event: U, listener: (...args: HamokQueueEventMap<T>[U]) => void): this; once<U extends keyof HamokQueueEventMap<T>>(event: U, listener: (...args: HamokQueueEventMap<T>[U]) => void): this; emit<U extends keyof HamokQueueEventMap<T>>(event: U, ...args: HamokQueueEventMap<T>[U]): boolean; } // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class HamokQueue<T> extends EventEmitter { private _head = 0; private _tail = 0; private _closed = false; private _initializing?: Promise<this>; public constructor( public readonly connection: HamokConnection<number, T>, public readonly baseMap: BaseMap<number, T>, ) { super(); this.setMaxListeners(Infinity); this.connection .on('ClearEntriesRequest', (request) => { this.baseMap.clear(); if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond( 'ClearEntriesResponse', request.createResponse(), request.sourceEndpointId ); } this._head = this._tail; this.emit('empty'); }) .on('InsertEntriesRequest', (request) => { const wasEmpty = this.empty; logger.trace('%s Inserting entries %o', this.connection.localPeerId, [ ...request.entries ].map(([ key, value ]) => `${key}:${value}`).join(', ')); for (const value of request.entries.values()) { this.baseMap.set(this._tail, value); ++this._tail; this.emit('add', value); } if (wasEmpty) { this.emit('not-empty'); } if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond( 'InsertEntriesResponse', request.createResponse(Collections.EMPTY_MAP), request.sourceEndpointId ); } }) .on('RemoveEntriesRequest', (request) => { const removedEntries = new Map<number, T>(); for (const key of request.keys.values()) { if (key !== this._head) { continue; } const value = this._pop(); if (value == undefined) { continue; } removedEntries.set(key, value); } if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond( 'RemoveEntriesResponse', request.createResponse( removedEntries ), request.sourceEndpointId ); } removedEntries.forEach((v) => this.emit('remove', v)); }) .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); this._import( snapshot, // emit events if we are not initializing Boolean(this._initializing) === false, ); } catch (err) { logger.error(`Failed to import to queue ${this.id}. Error: ${err}, snapshot: ${serializedSnapshot}`); } finally { done(); } }) .once('close', () => this.close()) ; logger.trace('Queue %s is created', this.id); this._initializing = new Promise((resolve) => setTimeout(resolve, 20)) .then(() => this.connection.join()) .then(() => this) .catch((err) => { logger.error('Error while initializing queue', err); return this; }) .finally(() => (this._initializing = undefined)) ; } public get id(): string { return this.connection.config.storageId; } public get empty() { return this._head === this._tail; } public get size() { return this._tail - this._head; } public get closed() { return this._closed; } public get ready(): Promise<this> { return this._initializing ?? this.connection.grid.waitUntilCommitHead().then(() => this); } public async push(...values: T[]): Promise<void> { if (this._closed) throw new Error('Cannot push on a closed queue'); await this._initializing; const entries: [number, T][] = []; values.forEach((value, index) => entries.push([ index, value ])); return this.connection.requestInsertEntries( Collections.mapOf<number, T>( ...entries, ) ).then(() => void 0); } public async pop(): Promise<T | undefined> { if (this._closed) throw new Error('Cannot pop on a closed queue'); await this._initializing; while (!this.empty) { const head = this._head; const result = (await this.connection.requestRemoveEntries(Collections.setOf(head))).get(head); if (result !== undefined) return result; } } public peek(): T | undefined { if (this._closed) throw new Error('Cannot peek on a closed queue'); return this.baseMap.get(this._head); } public async clear(): Promise<void> { if (this._closed) throw new Error('Cannot clear a closed queue'); if (this.empty) return; await this._initializing; return this.connection.requestClearEntries().then(() => void 0); } public [Symbol.iterator](): IterableIterator<T> { return iterator(this._head, this._tail, this.baseMap); } public close() { if (this._closed) return; this._closed = true; this.connection.close(); this.baseMap.clear(); this.emit('close'); } public export(): HamokQueueSnapshot { if (this._closed) throw new Error('Cannot export data on a closed queue'); const sortedEntries = [ ...this.baseMap ].sort(([ a ], [ b ]) => a - b); logger.trace('Queue %s exporting entries: %o', this.id, sortedEntries); const [ keys, values ] = this.connection.codec.encodeEntries( new Map(sortedEntries) ); return { queueId: this.id, keys: HamokQueue.uint8ArrayToStringCodec.encode(keys), values: HamokQueue.uint8ArrayToStringCodec.encode(values), }; } public import(snapshot: HamokQueueSnapshot, eventing = false): void { if (snapshot.queueId !== this.id) { throw new Error(`Cannot import data from a different queue: ${snapshot.queueId} !== ${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 queue'); } this._import(snapshot, eventing); } private _import(snapshot: HamokQueueSnapshot, eventing = false): void { this.baseMap.clear(); logger.trace('Queue %s importing entries keys: %o, \n values: %o', this.id, snapshot.keys.constructor.name, snapshot.keys, snapshot.values); const entries = this.connection.codec.decodeEntries( HamokQueue.uint8ArrayToStringCodec.decode(snapshot.keys), HamokQueue.uint8ArrayToStringCodec.decode(snapshot.values), ); logger.trace('Queue %s decoded entries: %o', this.id, [ ...entries ].map(([ key, value ]) => `${key}:${value}`).join(', ') ); this.baseMap.setAll(entries, (updateResult) => { if (eventing) { updateResult.inserted.forEach(([ , value ]) => this.emit('add', value)); updateResult.updated.forEach(([ , value ]) => this.emit('add', value)); } }); let newHead: undefined | number; let newTail: undefined | number; for (const key of entries.keys()) { if (newHead === undefined || key < newHead) { newHead = key; } if (newTail === undefined || newTail < key) { newTail = key; } } this._head = newHead ?? 0; this._tail = newTail ?? 0; if (this._head !== this._tail) { ++this._tail; } logger.info('Queue %s imported entries: %d. new head: %d, new tail: %d', this.id, this.baseMap.size, this._head, this._tail, ); logger.debug('Imported entries for queue %s: %o', this.id, [ ...this.baseMap ].map(([ key, value ]) => `${key}:${value}`).join(', ')); } private _pop(): T | undefined { if (this.empty) return undefined; const value = this.baseMap.get(this._head); if (value === undefined) return undefined; this.baseMap.delete(this._head); ++this._head; if (this.empty) { this.emit('empty'); } return value; } 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')); } ); }