lemon-core
Version:
Lemon Serverless Micro-Service Platform
391 lines • 14.8 kB
JavaScript
"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(` 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(` is required.`);
if (!model)
throw new Error(` 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(` is required.`);
if (!model)
throw new Error(` 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(` is required.`);
if (!update)
throw new Error(` 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(` is required.`);
if (!increment)
throw new Error(` 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(` 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