UNPKG

lemon-core

Version:
391 lines 14.8 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DummyRedisStorageService = exports.RedisStorageService = void 0; /** * `redis-storage-services.ts` * - storage implementation on redis backend * * @author Tim Hong <tim@lemoncloud.io> * @date 2020-12-08 initial version * * @copyright (C) lemoncloud.io 2020 - All Rights Reserved. */ const ioredis_1 = __importDefault(require("ioredis")); const engine_1 = require("../../engine"); // Log namespace const NS = engine_1.$U.NS('RSTR', 'blue'); /** ******************************************************************************************************************** * Exported Class ** ********************************************************************************************************************/ /** * class `RedisStorageService` */ class RedisStorageService { /** * Public constructor * * @options redis options */ constructor(options) { const defTimeout = engine_1.$U.N(options === null || options === void 0 ? void 0 : options.defTimeout, 0); if (typeof options.tableName === 'number') { // For dummy: open in non-default database (db index > 0) this.redis = new ioredis_1.default(options.endpoint, { db: options.tableName }); this.tableName = 'dummy'; } else { // For normal usage: open in default database (db index = 0) w/ virtual table const endpoint = options.endpoint || engine_1.$U.env(RedisStorageService.ENV_REDIS_ENDPOINT); const tableName = options.tableName; if (!endpoint) throw new Error(`.endpoint (URL) is required.`); if (!tableName) throw new Error(`.tableName (string) is required.`); this.redis = new ioredis_1.default(endpoint, { keyPrefix: tableName }); this.tableName = tableName; } this.ttl = defTimeout; (0, engine_1._inf)(NS, `RedisStorageService constructed.`); (0, engine_1._inf)(NS, ` > tableName =`, this.tableName); (0, engine_1._inf)(NS, ` > default TTL =`, this.ttl); } /** * Say hello */ hello() { return `redis-storage-service:${this.tableName}`; } /** * Disconnect from redis */ quit() { return __awaiter(this, void 0, void 0, function* () { yield this.redis.quit(); }); } /** * Read model by id * * @param id */ read(id) { return __awaiter(this, void 0, void 0, function* () { if (!id) throw new Error(`@id is required.`); const key = this.asKey(id); const data = yield this.redis.hgetall(key); // {} if the key does not exist if (Object.keys(data).length > 0) { const ret = this.deserialize(data); (0, engine_1._log)(NS, `> read[${id}].ret =`, engine_1.$U.json(ret)); return ret; } throw new Error(`404 NOT FOUND - ${this.tableName}/${id}`); }); } /** * Read model or create if id does not exist * * @param id * @param model */ readOrCreate(id, model) { return __awaiter(this, void 0, void 0, function* () { if (!id) throw new Error(`@id is required.`); if (!model) throw new Error(`@model is required.`); const key = this.asKey(id); // check-and-save w/ retries to avoid race conditions for (let retry = 0; retry < RedisStorageService.CAS_MAX_RETRIES; yield sleep(10), retry++) { yield this.redis.watch(key); // Lock let data = yield this.redis.hgetall(key); // {} if the key does not exist // 1. Return if a model found if (Object.keys(data).length > 0) { yield this.redis.unwatch(); // Unlock const ret = this.deserialize(data); (0, engine_1._log)(NS, `> readOrCreate[${id}(read)].ret =`, engine_1.$U.json(ret)); return ret; } // 2. Otherwise try to create a new model data = this.serialize(Object.assign(Object.assign({}, model), { id })); const pipeline = this.redis.multi().hset(key, data); if (this.ttl > 0) pipeline.expire(key, this.ttl); const results = yield pipeline.exec(); // Unlock, null if the key has been changed if (results) { RedisStorageService.throwIfTransactionError(results); const ret = this.deserialize(data); (0, engine_1._log)(NS, `> readOrCreate[${id}(created)].ret =`, engine_1.$U.json(ret)); return ret; } } const message = `transaction max retries exceeded.`; (0, engine_1._err)(NS, `> readOrCreate[${id}].err =`, message); throw new Error(`redis transaction error: ${message}`); }); } /** * Create model and overwrite if id exists * * @param id * @param model */ save(id, model) { return __awaiter(this, void 0, void 0, function* () { if (!id) throw new Error(`@id is required.`); if (!model) throw new Error(`@model is required.`); const key = this.asKey(id); const data = this.serialize(Object.assign(Object.assign({}, model), { id })); // Create transaction pipeline const pipeline = this.redis .multi() .del(key) // TODO: 만약 save의 overwrite 정책이 기존 존재하는 키는 유지하는 것이라면 del()은 제거해야 함 .hset(key, data); if (this.ttl > 0) pipeline.expire(key, this.ttl); // Execute transaction const results = yield pipeline.exec(); RedisStorageService.throwIfTransactionError(results); const ret = this.deserialize(data); (0, engine_1._log)(NS, `> save[${id}].ret =`, engine_1.$U.json(ret)); return ret; }); } /** * Update existing model and create if id does not exist * @param id * @param update model to update * @param increment (optional) model to increment */ update(id, update, increment) { return __awaiter(this, void 0, void 0, function* () { if (!id) throw new Error(`@id is required.`); if (!update) throw new Error(`@update is required.`); const ret = yield this.updateCAS(id, update, increment); (0, engine_1._log)(NS, `> update[${id}].ret =`, engine_1.$U.json(ret)); return ret; }); } /** * Increment the integer value of a key * * @param id * @param increment model to increment * @param update (optional) model to update */ increment(id, increment, update) { return __awaiter(this, void 0, void 0, function* () { if (!id) throw new Error(`@id is required.`); if (!increment) throw new Error(`@increment is required.`); const ret = yield this.updateCAS(id, update, increment); (0, engine_1._log)(NS, `> increment[${id}].ret =`, engine_1.$U.json(ret)); return ret; }); } /** * Delete a key * * @param id * @return true on success */ delete(id) { return __awaiter(this, void 0, void 0, function* () { if (!id) throw new Error(`@key is required.`); const key = this.asKey(id); // Execute transaction const results = yield this.redis .multi() .hgetall(key) // Read .del(key) // And delete .exec(); RedisStorageService.throwIfTransactionError(results); const data = results[0][1]; if (data && Object.keys(data).length > 0) { const ret = this.deserialize(data); (0, engine_1._log)(NS, `> delete[${id}].ret =`, engine_1.$U.json(ret)); return ret; } throw new Error(`404 NOT FOUND - ${this.tableName}/${id}`); }); } /** * Get redis key from id * @param id * @protected */ asKey(id) { return `${this.tableName}::${id}`; } /** * Serialize model into internal data * @param model * @protected */ serialize(model) { return Object.entries(model).reduce((data, [key, val]) => { if (val !== undefined) data[key] = JSON.stringify(val); return data; }, {}); } /** * Deserialize internal data into model * @param data * @protected */ deserialize(data) { return Object.entries(data).reduce((model, [field, value]) => { model[field] = JSON.parse(value); return model; }, {}); } /** * Update key w/ check-and-save behavior and retries * @param id * @param update (optional) model to update * @param increment (optional) model to increment * @private */ updateCAS(id, update, increment) { return __awaiter(this, void 0, void 0, function* () { const key = this.asKey(id); // Use watch and transaction to avoid race conditions for (let retry = 0; retry < RedisStorageService.CAS_MAX_RETRIES; yield sleep(10), retry++) { yield this.redis.watch(key); // Lock try { // Evaluate new model to store const curData = yield this.redis.hgetall(key); // {} if the key does not exist const curModel = this.deserialize(curData); const newModel = this.prepareUpdatedModel(curModel, update, increment); // Create transaction pipeline const data = this.serialize(Object.assign(Object.assign({}, newModel), { id })); const pipeline = this.redis.multi().hset(key, data); if (this.ttl > 0) pipeline.expire(key, this.ttl); // Execute transaction const results = yield pipeline.exec(); // Unlock, null if the key has been changed if (results) { RedisStorageService.throwIfTransactionError(results); return this.deserialize(data); } // Retry until max retry count reached } catch (e) { yield this.redis.unwatch(); // Unlock explicitly throw e; // Rethrow } } const message = `transaction max retries exceeded.`; (0, engine_1._err)(NS, `> updateCAS[${id}].err =`, message); throw new Error(`redis error: ${message}`); }); } /** * Prepare new model - original model + update + increment * @param orig * @param update * @param increment * @private */ prepareUpdatedModel(orig, update, increment) { const updated = Object.assign(orig, update); if (increment) { for (const [field, value] of Object.entries(increment)) { const key = field; const oldVal = updated[key] || orig[key] || 0; if (typeof oldVal !== 'number') { throw new Error(`.${key} is non-numeric field and cannot be incremented.`); } updated[key] = oldVal + value; } } return updated; } /** * Check transaction results and throw if error occurred * @param results transaction pipeline execution results * @private */ static throwIfTransactionError(results) { if (!results) throw new Error(`redis transaction failed: transaction aborted by key modification.`); const err = results.map(result => result[0]).find(err => err !== null); if (err) throw new Error(`redis transaction failed: ${err.message}`); } } exports.RedisStorageService = RedisStorageService; /** * Environment variable name for redis server endpoint * @static */ RedisStorageService.ENV_REDIS_ENDPOINT = 'MY_REDIS_ENDPOINT'; /** * Maximum retry count of check-and-save behavior * @static */ RedisStorageService.CAS_MAX_RETRIES = 5; /** * class `DummyRedisStorageService` * - Use local redis server and non-default logical database */ class DummyRedisStorageService extends RedisStorageService { /** * Public constructor */ constructor() { super({ endpoint: 'localhost:6379', tableName: ++DummyRedisStorageService.dbIndex, // Open non-default DB }); } /** * Say hello */ hello() { return `dummy-redis-storage-service`; } /** * Delete all data in database */ truncate() { return __awaiter(this, void 0, void 0, function* () { yield this.redis.flushdb(); }); } } exports.DummyRedisStorageService = DummyRedisStorageService; /** * Database index. Each DummyRedisStorageService uses different logical database. * @private */ DummyRedisStorageService.dbIndex = 0; /** * function `sleep` * @param ms duration in milliseconds */ function sleep(ms) { return __awaiter(this, void 0, void 0, function* () { return new Promise(resolve => setTimeout(resolve, ms)); }); } //# sourceMappingURL=redis-storage-service.js.map