@splitsoftware/splitio-commons
Version:
Split JavaScript SDK common components
193 lines (192 loc) • 9.43 kB
JavaScript
import { __extends } from "tslib";
import ioredis from 'ioredis';
import { merge, isString } from '../../utils/lang';
import { thenable } from '../../utils/promise/thenable';
import { timeout } from '../../utils/promise/timeout';
import { setToArray } from '../../utils/lang/sets';
var LOG_PREFIX = 'storage:redis-adapter: ';
// If we ever decide to fully wrap every method, there's a Commander.getBuiltinCommands from ioredis.
var METHODS_TO_PROMISE_WRAP = ['set', 'exec', 'del', 'get', 'keys', 'sadd', 'srem', 'sismember', 'smembers', 'incr', 'rpush', 'expire', 'mget', 'lrange', 'ltrim', 'hset', 'hincrby', 'popNRaw'];
var METHODS_TO_PROMISE_WRAP_EXEC = ['pipeline'];
// Not part of the settings since it'll vary on each storage. We should be removing storage specific logic from elsewhere.
var DEFAULT_OPTIONS = {
connectionTimeout: 10000,
operationTimeout: 5000
};
// Library specifics.
var DEFAULT_LIBRARY_OPTIONS = {
enableOfflineQueue: false,
connectTimeout: DEFAULT_OPTIONS.connectionTimeout,
lazyConnect: false
};
/**
* Redis adapter on top of the library of choice (written with ioredis) for some extra control.
*/
var RedisAdapter = /** @class */ (function (_super) {
__extends(RedisAdapter, _super);
function RedisAdapter(log, storageSettings) {
if (storageSettings === void 0) { storageSettings = {}; }
var _this = this;
var options = RedisAdapter._defineOptions(storageSettings);
// Call the ioredis constructor
_this = _super.apply(this, RedisAdapter._defineLibrarySettings(options)) || this;
_this.log = log;
_this._options = options;
_this._notReadyCommandsQueue = [];
_this._runningCommands = new Set();
_this._listenToEvents();
_this._setTimeoutWrappers();
_this._setDisconnectWrapper();
return _this;
}
RedisAdapter.prototype._listenToEvents = function () {
var _this = this;
this.once('ready', function () {
var commandsCount = _this._notReadyCommandsQueue ? _this._notReadyCommandsQueue.length : 0;
_this.log.info(LOG_PREFIX + ("Redis connection established. Queued commands: " + commandsCount + "."));
_this._notReadyCommandsQueue && _this._notReadyCommandsQueue.forEach(function (queued) {
_this.log.info(LOG_PREFIX + ("Executing queued " + queued.name + " command."));
queued.command().then(queued.resolve).catch(queued.reject);
});
// After the SDK is ready for the first time we'll stop queueing commands. This is just so we can keep handling BUR for them.
_this._notReadyCommandsQueue = undefined;
});
this.once('close', function () {
_this.log.info(LOG_PREFIX + 'Redis connection closed.');
});
};
RedisAdapter.prototype._setTimeoutWrappers = function () {
var instance = this;
var wrapCommand = function (originalMethod, methodName) {
// The value of "this" in this function should be the instance actually executing the method. It might be the instance referred (the base one)
// or it can be the instance of a Pipeline object.
return function () {
var params = arguments;
var caller = this;
function commandWrapper() {
instance.log.debug(LOG_PREFIX + "Executing " + methodName + ".");
var result = originalMethod.apply(caller, params);
if (thenable(result)) {
// For handling pending commands on disconnect, add to the set and remove once finished.
// On sync commands there's no need, only thenables.
instance._runningCommands.add(result);
var cleanUpRunningCommandsCb = function () {
instance._runningCommands.delete(result);
};
// Both success and error remove from queue.
result.then(cleanUpRunningCommandsCb, cleanUpRunningCommandsCb);
return timeout(instance._options.operationTimeout, result).catch(function (err) {
instance.log.error("" + LOG_PREFIX + methodName + " operation threw an error or exceeded configured timeout of " + instance._options.operationTimeout + "ms. Message: " + err);
// Handling is not the adapter responsibility.
throw err;
});
}
return result;
}
if (instance._notReadyCommandsQueue) {
return new Promise(function (resolve, reject) {
instance._notReadyCommandsQueue.unshift({
resolve: resolve,
reject: reject,
command: commandWrapper,
name: methodName.toUpperCase()
});
});
}
else {
return commandWrapper();
}
};
};
// Wrap regular async methods to track timeouts and queue when Redis is not yet executing commands.
METHODS_TO_PROMISE_WRAP.forEach(function (methodName) {
var originalFn = instance[methodName];
instance[methodName] = wrapCommand(originalFn, methodName);
});
// Special handling for pipeline~like methods. We need to wrap the async trigger, which is exec, but return the Pipeline right away.
METHODS_TO_PROMISE_WRAP_EXEC.forEach(function (methodName) {
var originalFn = instance[methodName];
// "First level wrapper" to handle the sync execution and wrap async, queueing later if applicable.
instance[methodName] = function () {
var res = originalFn.apply(instance, arguments);
var originalExec = res.exec;
res.exec = wrapCommand(originalExec, methodName + '.exec').bind(res);
return res;
};
});
};
RedisAdapter.prototype._setDisconnectWrapper = function () {
var instance = this;
var originalMethod = instance.disconnect;
instance.disconnect = function disconnect() {
var params = [];
for (var _i = 0; _i < arguments.length; _i++) {
params[_i] = arguments[_i];
}
setTimeout(function deferredDisconnect() {
if (instance._runningCommands.size > 0) {
instance.log.info(LOG_PREFIX + ("Attempting to disconnect but there are " + instance._runningCommands.size + " commands still waiting for resolution. Defering disconnection until those finish."));
Promise.all(setToArray(instance._runningCommands))
.then(function () {
instance.log.debug(LOG_PREFIX + 'Pending commands finished successfully, disconnecting.');
originalMethod.apply(instance, params);
})
.catch(function (e) {
instance.log.warn(LOG_PREFIX + ("Pending commands finished with error: " + e + ". Proceeding with disconnection."));
originalMethod.apply(instance, params);
});
}
else {
instance.log.debug(LOG_PREFIX + 'No commands pending execution, disconnect.');
// Nothing pending, just proceed.
originalMethod.apply(instance, params);
}
}, 10);
};
};
/**
* Receives the options and returns an array of parameters for the ioredis constructor.
* Keeping both redis setup options for backwards compatibility.
*/
RedisAdapter._defineLibrarySettings = function (options) {
var opts = merge({}, DEFAULT_LIBRARY_OPTIONS);
var result = [opts];
if (!isString(options.url)) {
merge(opts, {
host: options.host,
port: options.port,
db: options.db,
password: options.pass
});
}
else { // If it IS the string URL, that'll be the first param for ioredis.
result.unshift(options.url);
}
if (options.connectionTimeout) {
merge(opts, { connectTimeout: options.connectionTimeout });
}
if (options.tls) {
merge(opts, { tls: options.tls });
}
return result;
};
/**
* Parses the options into what we care about.
*/
RedisAdapter._defineOptions = function (_a) {
var connectionTimeout = _a.connectionTimeout, operationTimeout = _a.operationTimeout, url = _a.url, host = _a.host, port = _a.port, db = _a.db, pass = _a.pass, tls = _a.tls;
var parsedOptions = {
connectionTimeout: connectionTimeout,
operationTimeout: operationTimeout,
url: url,
host: host,
port: port,
db: db,
pass: pass,
tls: tls
};
return merge({}, DEFAULT_OPTIONS, parsedOptions);
};
return RedisAdapter;
}(ioredis));
export { RedisAdapter };