hamok
Version:
Lightweight Distributed Object Storage on RAFT consensus algorithm
388 lines (387 loc) • 16.8 kB
JavaScript
"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;