UNPKG

hamok

Version:

Lightweight Distributed Object Storage on RAFT consensus algorithm

388 lines (387 loc) 16.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.HamokRecord = void 0; const events_1 = require("events"); const logger_1 = require("../common/logger"); const Collections = __importStar(require("../common/Collections")); const logger = (0, logger_1.createLogger)('HamokMap'); const UPDATE_IF_RESPONSE_KEY = 'update-if-response-key'; /** * Replicated storage replicates all entries on all distributed storages */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging class HamokRecord extends events_1.EventEmitter { connection; _payloadsCodec; _closed = false; equalValues; _object; _initializing; constructor(connection, setup) { super(); this.connection = connection; this.setMaxListeners(Infinity); this.equalValues = setup?.equalValues ?? ((a, b) => JSON.stringify(a) === JSON.stringify(b)); this._object = {}; this._payloadsCodec = setup?.payloadsCodec; this.connection .on('ClearEntriesRequest', (request) => { this._object = {}; if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond('ClearEntriesResponse', request.createResponse(), request.sourceEndpointId); } this.emit('clear'); }) .on('DeleteEntriesRequest', (request) => { const removedEntries = new Map(); for (const key of request.keys) { const value = this._object[key]; if (value === undefined) continue; delete this._object[key]; this.emit('remove', { key, value, }); const encodedValue = this._encodeValue(key, value); removedEntries.set(key, encodedValue); } if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond('DeleteEntriesResponse', request.createResponse(new Set(removedEntries.keys())), request.sourceEndpointId); } }) .on('InsertEntriesRequest', (request) => { const existingEntries = new Map(); for (const [key, encodedValue] of request.entries) { const value = this._object[key]; if (value !== undefined) { existingEntries.set(key, this._encodeValue(key, value)); continue; } const decodedValue = this._decodeValue(key, encodedValue); this._object[key] = decodedValue; this.emit('insert', { key, value: decodedValue, }); } if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond('InsertEntriesResponse', request.createResponse(existingEntries), request.sourceEndpointId); } }) .on('UpdateEntriesRequest', (request) => { if (!request.prevValue) return; // the other listener will handle this const updatedEntries = []; const insertedEntries = []; const prevValue = JSON.parse(request.prevValue); logger.warn('UpdateEntriesRequest prevValue %o, prevValue: %o', [...request.entries].map(([k, v]) => `key: ${k}, value: ${v}`).join(', '), prevValue); // check let ok = true; for (const [key, value] of Object.entries(prevValue)) { logger.warn('UpdateEntriesRequest prevValue %o, checking equality between: %s === %s', key, value, this._object[key]); // eslint-disable-next-line @typescript-eslint/no-explicit-any if (this.equalValues(this._object[key], value)) continue; ok = false; break; } logger.warn('Apply update %o', [...request.entries].map(([k, v]) => `key: ${k}, value: ${v}`).join(', ')); if (!ok) { // respond false if (request.sourceEndpointId === this.connection.grid.localPeerId) { // some special response because this is used in updateIf this.connection.respond('UpdateEntriesResponse', request.createResponse(Collections.mapOf([UPDATE_IF_RESPONSE_KEY, 'false'])), request.sourceEndpointId); } return; } for (const [key, encodedValue] of request.entries) { const newValue = this._decodeValue(key, encodedValue); const existingValue = this._object[key]; this._object[key] = newValue; if (existingValue) updatedEntries.push([key, existingValue, newValue]); else insertedEntries.push([key, newValue]); } if (request.sourceEndpointId === this.connection.grid.localPeerId) { // some special response because this is used in updateIf this.connection.respond('UpdateEntriesResponse', request.createResponse(Collections.mapOf([UPDATE_IF_RESPONSE_KEY, 'true'])), request.sourceEndpointId); } insertedEntries.forEach(([key, value]) => this.emit('insert', { key, value })); updatedEntries.forEach(([key, oldValue, newValue]) => this.emit('update', { key, oldValue, newValue })); }) .on('UpdateEntriesRequest', (request) => { if (request.prevValue) return; const updatedEntries = []; const insertedEntries = []; for (const [key, encodedValue] of request.entries) { const existingValue = this._object[key]; const decodedNewValue = this._decodeValue(key, encodedValue); if (existingValue === undefined) { insertedEntries.push([key, this._decodeValue(key, encodedValue)]); } else { updatedEntries.push([key, existingValue, decodedNewValue]); } this._object[key] = decodedNewValue; } if (request.sourceEndpointId === this.connection.grid.localPeerId) { this.connection.respond('UpdateEntriesResponse', request.createResponse(new Map(updatedEntries.map(([key, oldValue]) => [key, this._encodeValue(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); this._import(snapshot, // emit events if we are not initializing Boolean(this._initializing) === false); } catch (err) { logger.error(`Failed to import to record ${this.id}. Error: ${err}`); } finally { done(); } }) .once('close', () => this.close()); this._initializing = new Promise((resolve) => setTimeout(resolve, 20)) .then(() => this.connection.join()) .then(async () => { if (setup?.initalObject === undefined) return this; const initalObject = setup.initalObject; logger.debug('%s Initializing record %d', this.connection.localPeerId, this.id); const entries = new Map(); for (const [key, value] of Object.entries(initalObject)) { const encodedValue = this._encodeValue(key, value); entries.set(key, encodedValue); } 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; }); } get id() { return this.connection.config.storageId; } get ready() { return this._initializing ?? this.connection.grid.waitUntilCommitHead().then(() => this); } get closed() { return this._closed; } get instance() { return { ...this._object }; } close() { if (this._closed) return; this._closed = true; this.connection.close(); this.emit('close'); this.removeAllListeners(); } async clear() { if (this._closed) throw new Error(`Cannot clear a closed storage (${this.id})`); await this._initializing; return this.connection.requestClearEntries(); } get(key) { const result = this._object[key]; if (result === undefined) return; return Object.freeze(result); } async set(key, value) { if (this._closed) throw new Error(`Cannot set an entry on a closed storage (${this.id})`); await this._initializing; const entries = new Map([ [key, this._encodeValue(key, value)] ]); const respondedValue = (await this.connection.requestUpdateEntries(entries)).get(key); if (!respondedValue) return; return this._decodeValue(key, respondedValue); } // public async addToList<K extends keyof T, V extends T[K] extends ArrayLike<unknown> ? T[K][number] : never>(key: K, item: V): Promise<void> { // key; // item; // await this.ready; // } // public async removeFromList<K extends keyof T, V extends T[K] extends ArrayLike<unknown> ? T[K][number] : never>(key: K, item: V): Promise<void> { // key; // item; // await this.ready; // } // public async changeNumBy<K extends keyof T, V extends T[K] & number>(key: K, byValue: V): Promise<void> { // key; // byValue; // await this.ready; // } async insert(key, value) { if (this._closed) throw new Error(`Cannot set an entry on a closed storage (${this.id})`); await this._initializing; const entries = new Map([ [key, this._encodeValue(key, value)] ]); const respondedValue = (await this.connection.requestInsertEntries(entries)).get(key); if (!respondedValue) return; return this._decodeValue(key, respondedValue); } async insertInstance(instance) { if (this._closed) throw new Error(`Cannot set an entry on a closed storage (${this.id})`); await this._initializing; const entries = new Map(); for (const [key, value] of Object.entries(instance)) { entries.set(key, this._encodeValue(key, value)); } const respondedValue = (await this.connection.requestInsertEntries(entries)); if (!respondedValue || respondedValue.size < 1) return; const respondedInstance = {}; for (const [key, value] of Object.entries(respondedValue)) { respondedInstance[key] = this._decodeValue(key, value); } return respondedInstance; } async updateIf(key, value, oldValue) { 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); const newValue = {}; const prevValue = {}; newValue[key] = value; prevValue[key] = oldValue; return this.updateInstanceIf(newValue, prevValue); } async updateInstanceIf(newValue, oldValue) { if (this._closed) throw new Error(`Cannot update an entry on a closed storage (${this.id})`); await this._initializing; const entries = new Map(); for (const [key, value] of Object.entries(newValue)) { entries.set(key, this._encodeValue(key, value)); } logger.trace('%s UpdateIf: %s, %s, %s', this.connection.grid.localPeerId, [...entries].map(([k, v]) => `key: ${k}, value: ${v}`).join(','), oldValue); return (await this.connection.requestUpdateEntries(entries, undefined, JSON.stringify(oldValue))).get(UPDATE_IF_RESPONSE_KEY) === 'true'; } async delete(key) { return (await this.connection.requestDeleteEntries(Collections.setOf(key))).has(key); } /** * Exports the storage data */ export() { if (this._closed) { throw new Error(`Cannot export data on a closed storage (${this.id})`); } const entries = new Map(); for (const [key, value] of Object.entries(this._object)) { const encodedValue = this._encodeValue(key, value); entries.set(key, encodedValue); } const [keys, values] = this.connection.codec.encodeEntries(entries); const result = { recordId: this.id, keys, values }; return result; } import(data, eventing) { if (data.recordId !== this.id) { throw new Error(`Cannot import data from a different storage: ${data.recordId} !== ${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); } _import(snapshot, eventing) { const entries = this.connection.codec.decodeEntries(snapshot.keys, snapshot.values); try { for (const [key, encodedValue] of entries) { const newValue = this._decodeValue(key, encodedValue); const oldValue = this._object[key]; this._object[key] = newValue; if (oldValue !== undefined) eventing && this.emit('update', { key: key, oldValue: oldValue, newValue: newValue, }); else eventing && this.emit('insert', { key, value: newValue, }); } } catch (err) { logger.error(`Failed to import to record ${this.id}. Error: ${err}`); } } _encodeValue(key, value) { return this._payloadsCodec?.get(key)?.encode(value) ?? JSON.stringify(value); } _decodeValue(key, value) { return this._payloadsCodec?.get(key) ?? JSON.parse(value); } } exports.HamokRecord = HamokRecord;