-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathRedisAdapter.ts
More file actions
209 lines (180 loc) · 7.9 KB
/
Copy pathRedisAdapter.ts
File metadata and controls
209 lines (180 loc) · 7.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
import ioredis, { Pipeline } from 'ioredis';
import { ILogger } from '../../logger/types';
import { merge, isString } from '../../utils/lang';
import { thenable } from '../../utils/promise/thenable';
import { timeout } from '../../utils/promise/timeout';
import { setToArray } from '../../utils/lang/sets';
const LOG_PREFIX = 'storage:redis-adapter: ';
// If we ever decide to fully wrap every method, there's a Commander.getBuiltinCommands from ioredis.
const METHODS_TO_PROMISE_WRAP = ['set', 'exec', 'del', 'get', 'keys', 'sadd', 'srem', 'sismember', 'smembers', 'incr', 'rpush', 'expire', 'mget', 'lrange', 'ltrim', 'hset', 'hincrby', 'popNRaw'];
const 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.
const DEFAULT_OPTIONS = {
connectionTimeout: 10000,
operationTimeout: 5000
};
// Library specifics.
const DEFAULT_LIBRARY_OPTIONS = {
enableOfflineQueue: false,
connectTimeout: DEFAULT_OPTIONS.connectionTimeout,
lazyConnect: false // @TODO true to avoid side-effects on instantiation
};
interface IRedisCommand {
resolve: () => void,
reject: (err?: any) => void,
command: () => Promise<void>,
name: string
}
/**
* Redis adapter on top of the library of choice (written with ioredis) for some extra control.
*/
export class RedisAdapter extends ioredis {
private readonly log: ILogger;
private _options: object;
private _notReadyCommandsQueue?: IRedisCommand[];
private _runningCommands: Set<Promise<any>>;
constructor(log: ILogger, storageSettings: Record<string, any> = {}) {
const options = RedisAdapter._defineOptions(storageSettings);
// Call the ioredis constructor
super(...RedisAdapter._defineLibrarySettings(options));
this.log = log;
this._options = options;
this._notReadyCommandsQueue = [];
this._runningCommands = new Set();
this._listenToEvents();
this._setTimeoutWrappers();
this._setDisconnectWrapper();
}
_listenToEvents() {
this.once('ready', () => {
const commandsCount = this._notReadyCommandsQueue ? this._notReadyCommandsQueue.length : 0;
this.log.info(LOG_PREFIX + `Redis connection established. Queued commands: ${commandsCount}.`);
this._notReadyCommandsQueue && this._notReadyCommandsQueue.forEach(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', () => {
this.log.info(LOG_PREFIX + 'Redis connection closed.');
});
}
_setTimeoutWrappers() {
const instance: Record<string, any> = this;
const wrapCommand = (originalMethod: Function, methodName: string) => {
// 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 (this: RedisAdapter | Pipeline) {
const params = arguments;
const caller = this;
function commandWrapper() {
instance.log.debug(`${LOG_PREFIX}Executing ${methodName}.`);
const 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);
const cleanUpRunningCommandsCb = function () {
instance._runningCommands.delete(result);
};
// Both success and error remove from queue.
result.then(cleanUpRunningCommandsCb, cleanUpRunningCommandsCb);
return timeout(instance._options.operationTimeout, result).catch(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((resolve, reject) => {
instance._notReadyCommandsQueue.unshift({
resolve,
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(methodName => {
const 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(methodName => {
const originalFn = instance[methodName];
// "First level wrapper" to handle the sync execution and wrap async, queueing later if applicable.
instance[methodName] = function () {
const res = originalFn.apply(instance, arguments);
const originalExec = res.exec;
res.exec = wrapCommand(originalExec, methodName + '.exec').bind(res);
return res;
};
});
}
_setDisconnectWrapper() {
const instance = this;
const originalMethod = instance.disconnect;
instance.disconnect = function disconnect(...params: []) {
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(() => {
instance.log.debug(LOG_PREFIX + 'Pending commands finished successfully, disconnecting.');
originalMethod.apply(instance, params);
})
.catch(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.
*/
static _defineLibrarySettings(options: Record<string, any>) {
const opts = merge({}, DEFAULT_LIBRARY_OPTIONS);
const result: any[] = [opts];
if (!isString(options.url)) {
merge(opts, { // If it's not the string URL, merge the params separately.
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.
*/
static _defineOptions({ connectionTimeout, operationTimeout, url, host, port, db, pass, tls }: Record<string, any>) {
const parsedOptions = {
connectionTimeout, operationTimeout, url, host, port, db, pass, tls
};
return merge({}, DEFAULT_OPTIONS, parsedOptions);
}
}