ts-server-lib 0.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -0
- package/README.md +8 -0
- package/db/TSJournal.d.ts +108 -0
- package/db/TSJournal.js +229 -0
- package/db/TSMongo.d.ts +103 -0
- package/db/TSMongo.js +516 -0
- package/db/TSRQW.d.ts +625 -0
- package/db/TSRQW.js +1204 -0
- package/db/TSRedis.d.ts +530 -0
- package/db/TSRedis.js +1368 -0
- package/db/TSRedisTB.d.ts +80 -0
- package/db/TSRedisTB.js +178 -0
- package/package.json +85 -0
- package/ussd/TSUssdMenu.d.ts +139 -0
- package/ussd/TSUssdMenu.js +368 -0
- package/ussd/TSUssdScreen.d.ts +58 -0
- package/ussd/TSUssdScreen.js +218 -0
- package/ussd/index.d.ts +3 -0
- package/ussd/index.js +19 -0
- package/ussd/providers/AfricasTalking.d.ts +3 -0
- package/ussd/providers/AfricasTalking.js +17 -0
- package/ussd/providers/AirtelDRC.d.ts +9 -0
- package/ussd/providers/AirtelDRC.js +31 -0
- package/ussd/providers/OrangeDRC.d.ts +5 -0
- package/ussd/providers/OrangeDRC.js +213 -0
- package/ussd/providers/VodacomDRC.d.ts +9 -0
- package/ussd/providers/VodacomDRC.js +48 -0
- package/ussd/providers/_.d.ts +55 -0
- package/ussd/providers/_.js +83 -0
- package/ussd/providers/index.d.ts +13 -0
- package/ussd/providers/index.js +56 -0
- package/utils/TSFifo.d.ts +109 -0
- package/utils/TSFifo.js +145 -0
- package/utils/TSFile.d.ts +36 -0
- package/utils/TSFile.js +244 -0
- package/utils/TSHash.d.ts +19 -0
- package/utils/TSHash.js +71 -0
- package/utils/TSRequest.d.ts +248 -0
- package/utils/TSRequest.js +689 -0
- package/utils/TSStub.d.ts +159 -0
- package/utils/TSStub.js +296 -0
- package/utils/abort.d.ts +18 -0
- package/utils/abort.js +97 -0
- package/utils/mime.json +11358 -0
- package/utils/object-keys.d.ts +39 -0
- package/utils/object-keys.js +52 -0
package/db/TSRedis.js
ADDED
|
@@ -0,0 +1,1368 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* TSRedis – Single file for Redis: connection (standalone/Sentinel), events (pub/sub), operations.
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TSRedis = exports.BATCH_COMMAND_CHUNK = exports.PER_KEY_BATCH_CONCURRENCY = void 0;
|
|
7
|
+
exports.errMsg = errMsg;
|
|
8
|
+
exports.isClientOpen = isClientOpen;
|
|
9
|
+
exports.isStaleConnectionError = isStaleConnectionError;
|
|
10
|
+
exports.isActiveClientConnected = isActiveClientConnected;
|
|
11
|
+
exports.isTransientClusterError = isTransientClusterError;
|
|
12
|
+
exports.executeWithClusterRetry = executeWithClusterRetry;
|
|
13
|
+
exports.isCommandTimeoutError = isCommandTimeoutError;
|
|
14
|
+
exports.executeWithCommandRetry = executeWithCommandRetry;
|
|
15
|
+
exports.evalTaggedScript = evalTaggedScript;
|
|
16
|
+
const events_1 = require("events");
|
|
17
|
+
const node_crypto_1 = require("node:crypto");
|
|
18
|
+
const redis_1 = require("redis");
|
|
19
|
+
const TSRequest_1 = require("../utils/TSRequest");
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
21
|
+
// Connection – module state
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
23
|
+
const ERR_PREFIX = 'TSRedis:ERROR';
|
|
24
|
+
function errMsg(e) {
|
|
25
|
+
return e instanceof Error ? e.message : String(e);
|
|
26
|
+
}
|
|
27
|
+
/** True when a node-redis client handle is open/ready (false after max-reconnect exhaustion). */
|
|
28
|
+
function isClientOpen(c) {
|
|
29
|
+
if (!c)
|
|
30
|
+
return false;
|
|
31
|
+
const open = c.isOpen;
|
|
32
|
+
if (typeof open === 'boolean')
|
|
33
|
+
return open;
|
|
34
|
+
const ready = c.isReady;
|
|
35
|
+
if (typeof ready === 'boolean')
|
|
36
|
+
return ready;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* True when an error indicates the caller is using a closed or half-open Redis / cluster client.
|
|
41
|
+
* Used by queue/cron recovery paths after max-reconnect exhaustion or cluster slot churn.
|
|
42
|
+
*/
|
|
43
|
+
function isStaleConnectionError(err) {
|
|
44
|
+
const msg = errMsg(err);
|
|
45
|
+
return /reading 'master'|reading 'replicas'|no Redis client connected|Connection is closed|Socket closed|ECONNRESET|CLUSTERDOWN|Max reconnect attempts reached|The client is closed/i.test(msg);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* node-redis internal abort dispatch: when a queued command is aborted by its AbortSignal
|
|
49
|
+
* (connection torn down / reconnecting while the command was in flight), the rejection is a
|
|
50
|
+
* bare `new Error()` with no message and the generic name "Error" — neither the name/message
|
|
51
|
+
* checks in {@link isCommandTimeoutError} nor the message-substring checks in
|
|
52
|
+
* {@link isStaleConnectionError} can recognize it, even though it is the exact same
|
|
53
|
+
* "command aborted by disconnect/reconnect" class those checks exist to catch. The only
|
|
54
|
+
* identifying signal left is the stack frame from node-redis's own dispatch internals.
|
|
55
|
+
* Deliberately requires an EMPTY message — any error carrying real message text goes through
|
|
56
|
+
* the checks above instead, so this cannot mask a genuine application error that merely
|
|
57
|
+
* happens to call through a Redis client.
|
|
58
|
+
*/
|
|
59
|
+
const NODE_REDIS_ABORT_DISPATCH_STACK_RE = /@redis[\\/]client[\\/]lib[\\/]client[\\/]commands-queue/i;
|
|
60
|
+
function isNodeRedisAbortDispatchError(err) {
|
|
61
|
+
if (!(err instanceof Error) || err.message || !err.stack)
|
|
62
|
+
return false;
|
|
63
|
+
return NODE_REDIS_ABORT_DISPATCH_STACK_RE.test(err.stack);
|
|
64
|
+
}
|
|
65
|
+
let client = null;
|
|
66
|
+
let readClient = null;
|
|
67
|
+
/**
|
|
68
|
+
* Active cluster client. Set ONLY after `clusterClient.connect()` fully resolves —
|
|
69
|
+
* callers that check this are guaranteed to get a ready, slot-mapped client.
|
|
70
|
+
*/
|
|
71
|
+
let clusterClient = null;
|
|
72
|
+
/** Active sentinel client (redis v6 createSentinel). Set only after connect() fully resolves. */
|
|
73
|
+
let sentinelClient = null;
|
|
74
|
+
/** Dedup promise: concurrent calls to connectRedisImpl share one cluster-connect attempt. */
|
|
75
|
+
let _clusterConnectPromise = null;
|
|
76
|
+
const DEFAULT_CONFIG = {
|
|
77
|
+
connectTimeout: 5000,
|
|
78
|
+
socketTimeout: 0,
|
|
79
|
+
autoReconnect: true,
|
|
80
|
+
maxReconnectRetries: 10,
|
|
81
|
+
reconnectDelay: 1000,
|
|
82
|
+
disableOfflineQueue: false
|
|
83
|
+
};
|
|
84
|
+
function parseRedisUrl(url, envPassword) {
|
|
85
|
+
const urlMatch = url.match(/^redis:\/\/:([^@]+)@(.+)$/);
|
|
86
|
+
if (urlMatch) {
|
|
87
|
+
return { url: `redis://${urlMatch[2]}`, password: urlMatch[1] };
|
|
88
|
+
}
|
|
89
|
+
return { url, password: envPassword };
|
|
90
|
+
}
|
|
91
|
+
function buildReconnectStrategy(cfg) {
|
|
92
|
+
if (!cfg.autoReconnect)
|
|
93
|
+
return false;
|
|
94
|
+
return (retries) => {
|
|
95
|
+
if (retries > cfg.maxReconnectRetries) {
|
|
96
|
+
console.error(ERR_PREFIX, 'max reconnect attempts reached');
|
|
97
|
+
return new Error('Max reconnect attempts reached');
|
|
98
|
+
}
|
|
99
|
+
const jitter = Math.floor(Math.random() * 200);
|
|
100
|
+
return Math.min(Math.pow(2, retries) * 50, cfg.reconnectDelay || 2000) + jitter;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Drop singleton slots that hold a closed client so the next connect creates a fresh pool. */
|
|
104
|
+
async function discardStaleClientsIfNeeded() {
|
|
105
|
+
const stale = (client != null && !isClientOpen(client)) ||
|
|
106
|
+
(readClient != null && !isClientOpen(readClient)) ||
|
|
107
|
+
(clusterClient != null && !isClientOpen(clusterClient)) ||
|
|
108
|
+
(sentinelClient != null && !isClientOpen(sentinelClient));
|
|
109
|
+
if (stale) {
|
|
110
|
+
await closeRedisImpl();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function buildStandaloneClientOptions(cfg, redisUrl, password) {
|
|
114
|
+
const clientOptions = {
|
|
115
|
+
url: redisUrl,
|
|
116
|
+
socket: {
|
|
117
|
+
connectTimeout: cfg.connectTimeout,
|
|
118
|
+
socketTimeout: cfg.socketTimeout || undefined,
|
|
119
|
+
keepAlive: true,
|
|
120
|
+
keepAliveInitialDelay: 5000,
|
|
121
|
+
noDelay: true,
|
|
122
|
+
reconnectStrategy: buildReconnectStrategy(cfg)
|
|
123
|
+
},
|
|
124
|
+
disableOfflineQueue: cfg.disableOfflineQueue
|
|
125
|
+
};
|
|
126
|
+
if (cfg.clientName)
|
|
127
|
+
clientOptions.name = cfg.clientName;
|
|
128
|
+
if (cfg.pingInterval)
|
|
129
|
+
clientOptions.pingInterval = cfg.pingInterval;
|
|
130
|
+
if (cfg.commandsQueueMaxLength)
|
|
131
|
+
clientOptions.commandsQueueMaxLength = cfg.commandsQueueMaxLength;
|
|
132
|
+
if (password)
|
|
133
|
+
clientOptions.password = password;
|
|
134
|
+
return clientOptions;
|
|
135
|
+
}
|
|
136
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
137
|
+
// Connection – connect logic (standalone, Sentinel, read replicas)
|
|
138
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
139
|
+
async function connectRedisSentinel(sentinel, cfg) {
|
|
140
|
+
// Master password: embedded in cfg.url as redis://:pass@_ by buildConfigFromDescriptor.
|
|
141
|
+
const masterPassword = cfg.url.match(/^redis:\/\/:([^@]+)@/)?.[1];
|
|
142
|
+
const sc = (0, redis_1.createSentinel)({
|
|
143
|
+
name: sentinel.name,
|
|
144
|
+
sentinelRootNodes: sentinel.hosts,
|
|
145
|
+
nodeClientOptions: {
|
|
146
|
+
socket: { connectTimeout: cfg.connectTimeout, reconnectStrategy: buildReconnectStrategy(cfg) },
|
|
147
|
+
...(masterPassword ? { password: masterPassword } : {})
|
|
148
|
+
},
|
|
149
|
+
// sentinel.password is the sentinel-node auth (rare — only when sentinels have requirepass).
|
|
150
|
+
...(sentinel.password ? { sentinelClientOptions: { password: sentinel.password } } : {}),
|
|
151
|
+
// nodeAddressMap remaps master/replica addresses announced by sentinel to client-reachable
|
|
152
|
+
// addresses (e.g. Docker-internal 172.x → 127.0.0.1:hostPort for local testing).
|
|
153
|
+
...(sentinel.nodeAddressMap !== undefined ? { nodeAddressMap: sentinel.nodeAddressMap } : {})
|
|
154
|
+
});
|
|
155
|
+
sc.on('error', (err) => console.error(ERR_PREFIX, 'sentinel', errMsg(err)));
|
|
156
|
+
await sc.connect();
|
|
157
|
+
sentinelClient = sc;
|
|
158
|
+
}
|
|
159
|
+
async function connectReadReplicas(replicaConfig, cfg) {
|
|
160
|
+
if (!replicaConfig.urls?.length)
|
|
161
|
+
return;
|
|
162
|
+
const { url, password } = parseRedisUrl(replicaConfig.urls[0]);
|
|
163
|
+
const options = {
|
|
164
|
+
url,
|
|
165
|
+
socket: {
|
|
166
|
+
connectTimeout: cfg.connectTimeout,
|
|
167
|
+
reconnectStrategy: buildReconnectStrategy(cfg)
|
|
168
|
+
},
|
|
169
|
+
readonly: true
|
|
170
|
+
};
|
|
171
|
+
if (password)
|
|
172
|
+
options.password = password;
|
|
173
|
+
readClient = (0, redis_1.createClient)(options);
|
|
174
|
+
readClient.on('error', (err) => console.error(ERR_PREFIX, 'read replica', errMsg(err)));
|
|
175
|
+
await readClient.connect();
|
|
176
|
+
}
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
178
|
+
// Connection – internal helpers (used by TSRedis static API)
|
|
179
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
180
|
+
async function connectRedisCluster(clusterConfig, cfg) {
|
|
181
|
+
// Password resolution order: explicit clusterConfig.password → URL-embedded in cfg.url → env var.
|
|
182
|
+
let password = clusterConfig.password;
|
|
183
|
+
if (!password) {
|
|
184
|
+
const urlMatch = cfg.url.match(/^redis:\/\/:([^@]+)@/);
|
|
185
|
+
if (urlMatch)
|
|
186
|
+
password = urlMatch[1];
|
|
187
|
+
else if (process.env.REDIS_PASSWORD)
|
|
188
|
+
password = process.env.REDIS_PASSWORD;
|
|
189
|
+
}
|
|
190
|
+
// `nodeAddressMap` is passed through verbatim so callers (e.g. host-side validators) can
|
|
191
|
+
// rewrite cluster-discovered hostnames to client-reachable addresses without touching docker DNS.
|
|
192
|
+
// Build inline so node-redis's RedisClusterOptions generic stays satisfied (the type
|
|
193
|
+
// inference depends on the literal object shape — extracting to a typed variable widens it).
|
|
194
|
+
// Use a local variable: module-level `clusterClient` is set ONLY after connect() resolves
|
|
195
|
+
// so concurrent callers that check `clusterClient !== null` always get a ready client.
|
|
196
|
+
const cluster = (0, redis_1.createCluster)({
|
|
197
|
+
rootNodes: clusterConfig.nodes.map((n) => ({
|
|
198
|
+
socket: { host: n.host, port: n.port }
|
|
199
|
+
})),
|
|
200
|
+
// Default 16 — raise for resharding / Docker nodeAddressMap churn during dev.
|
|
201
|
+
maxCommandRedirections: clusterConfig.maxCommandRedirections ?? 32,
|
|
202
|
+
useReplicas: clusterConfig.useReplicas ?? false,
|
|
203
|
+
defaults: {
|
|
204
|
+
socket: {
|
|
205
|
+
connectTimeout: cfg.connectTimeout,
|
|
206
|
+
keepAlive: true,
|
|
207
|
+
keepAliveInitialDelay: 5000,
|
|
208
|
+
noDelay: true,
|
|
209
|
+
reconnectStrategy: buildReconnectStrategy(cfg)
|
|
210
|
+
},
|
|
211
|
+
...(password ? { password } : {})
|
|
212
|
+
},
|
|
213
|
+
...(clusterConfig.nodeAddressMap !== undefined
|
|
214
|
+
? { nodeAddressMap: clusterConfig.nodeAddressMap }
|
|
215
|
+
: {})
|
|
216
|
+
});
|
|
217
|
+
cluster.on('error', (err) => console.error(ERR_PREFIX, 'cluster', errMsg(err)));
|
|
218
|
+
await cluster.connect();
|
|
219
|
+
clusterClient = cluster; // assign only after full connection + slot discovery
|
|
220
|
+
return clusterClient;
|
|
221
|
+
}
|
|
222
|
+
async function connectRedisImpl(urlOrConfig) {
|
|
223
|
+
await discardStaleClientsIfNeeded();
|
|
224
|
+
if (client && isClientOpen(client))
|
|
225
|
+
return client;
|
|
226
|
+
if (clusterClient && isClientOpen(clusterClient)) {
|
|
227
|
+
return clusterClient;
|
|
228
|
+
}
|
|
229
|
+
if (sentinelClient && isClientOpen(sentinelClient)) {
|
|
230
|
+
return sentinelClient;
|
|
231
|
+
}
|
|
232
|
+
const config = typeof urlOrConfig === 'string' ? { url: urlOrConfig } : urlOrConfig;
|
|
233
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
234
|
+
if (config.cluster) {
|
|
235
|
+
// Dedup: concurrent callers share one connect attempt; clusterClient is only set after
|
|
236
|
+
// connect() fully resolves so nobody gets a not-yet-ready client.
|
|
237
|
+
if (!_clusterConnectPromise) {
|
|
238
|
+
_clusterConnectPromise = connectRedisCluster(config.cluster, cfg)
|
|
239
|
+
.finally(() => { _clusterConnectPromise = null; });
|
|
240
|
+
}
|
|
241
|
+
await _clusterConnectPromise;
|
|
242
|
+
return clusterClient;
|
|
243
|
+
}
|
|
244
|
+
if (config.sentinel) {
|
|
245
|
+
await connectRedisSentinel(config.sentinel, cfg);
|
|
246
|
+
return sentinelClient;
|
|
247
|
+
}
|
|
248
|
+
const { url: redisUrl, password } = parseRedisUrl(cfg.url, process.env.REDIS_PASSWORD);
|
|
249
|
+
const clientOptions = buildStandaloneClientOptions(cfg, redisUrl, password);
|
|
250
|
+
client = (0, redis_1.createClient)(clientOptions);
|
|
251
|
+
client.on('error', (err) => console.error(ERR_PREFIX, errMsg(err)));
|
|
252
|
+
await client.connect();
|
|
253
|
+
if (config.readReplicas?.enabled && config.readReplicas.urls?.length) {
|
|
254
|
+
await connectReadReplicas(config.readReplicas, cfg);
|
|
255
|
+
}
|
|
256
|
+
return client;
|
|
257
|
+
}
|
|
258
|
+
function getRedisImpl() {
|
|
259
|
+
return client;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
/** Returns the active cluster client when `connectRedisImpl` was called with `config.cluster`, else null. */
|
|
263
|
+
function getRedisClusterImpl() {
|
|
264
|
+
return clusterClient;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Returns whichever client backs the active singleton: standalone, sentinel, or cluster.
|
|
268
|
+
* Use this when the code path is topology-agnostic (e.g. health checks, GET/SET on a single key).
|
|
269
|
+
* For multi-key ops you still need to know cluster mode to enforce hash-tag co-location.
|
|
270
|
+
*/
|
|
271
|
+
function getActiveClientImpl() {
|
|
272
|
+
return clusterClient ?? sentinelClient ?? client;
|
|
273
|
+
}
|
|
274
|
+
/** True when TSRedis holds an active singleton client that is open (not post-max-reconnect closed). */
|
|
275
|
+
function isActiveClientConnected() {
|
|
276
|
+
const c = getActiveClientImpl();
|
|
277
|
+
return c != null && isClientOpen(c);
|
|
278
|
+
}
|
|
279
|
+
function requireActiveClient() {
|
|
280
|
+
const c = getActiveClientImpl();
|
|
281
|
+
if (!c)
|
|
282
|
+
throw new Error('Redis not connected');
|
|
283
|
+
return c;
|
|
284
|
+
}
|
|
285
|
+
function getRedisForReadImpl() {
|
|
286
|
+
return readClient ?? client;
|
|
287
|
+
}
|
|
288
|
+
function hasReadReplicaImpl() {
|
|
289
|
+
return readClient !== null;
|
|
290
|
+
}
|
|
291
|
+
async function checkRedisHealthImpl() {
|
|
292
|
+
const start = Date.now();
|
|
293
|
+
try {
|
|
294
|
+
// Both `RedisClientType.ping()` and `RedisClusterType.ping()` exist; cluster pings one node.
|
|
295
|
+
await requireActiveClient().ping();
|
|
296
|
+
return { healthy: true, latencyMs: Date.now() - start };
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return { healthy: false, latencyMs: -1 };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
303
|
+
// Events – pub/sub (internal)
|
|
304
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
305
|
+
async function publishImpl(channel, message) {
|
|
306
|
+
try {
|
|
307
|
+
// Cluster pub/sub: classic PUBLISH broadcasts to every node (bandwidth amplification).
|
|
308
|
+
// For per-slot delivery use SPUBLISH/SSUBSCRIBE (Redis 7+, sharded pub/sub).
|
|
309
|
+
await requireActiveClient().publish(channel, message);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
console.error(ERR_PREFIX, 'publish failed', channel, errMsg(error));
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function subscribeImpl(channel, handler) {
|
|
318
|
+
requireActiveClient(); // throws if nothing connected
|
|
319
|
+
if (clusterClient) {
|
|
320
|
+
await clusterClient.subscribe(channel, handler);
|
|
321
|
+
return async () => { await clusterClient.unsubscribe(channel); };
|
|
322
|
+
}
|
|
323
|
+
if (sentinelClient) {
|
|
324
|
+
// Sentinel has built-in pub/sub routing — no duplicate() needed.
|
|
325
|
+
await sentinelClient.subscribe(channel, handler);
|
|
326
|
+
return async () => { await sentinelClient.unsubscribe(channel); };
|
|
327
|
+
}
|
|
328
|
+
// Standalone: v6 manages the pub/sub connection internally — subscribe() directly.
|
|
329
|
+
await client.subscribe(channel, handler);
|
|
330
|
+
return async () => { await client.unsubscribe(channel); };
|
|
331
|
+
}
|
|
332
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
333
|
+
// Scan utilities (internal)
|
|
334
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
335
|
+
/** Drain a single node's scanIterator into the generator, respecting maxKeys cap. */
|
|
336
|
+
async function* _yieldBatches(iterator, maxKeys, yielded) {
|
|
337
|
+
for await (const keysBatch of iterator) {
|
|
338
|
+
for (const key of keysBatch) {
|
|
339
|
+
if (maxKeys > 0 && yielded.count >= maxKeys)
|
|
340
|
+
return;
|
|
341
|
+
yield key;
|
|
342
|
+
yielded.count++;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function* _scanCluster(pattern, batchSize, maxKeys, yielded) {
|
|
347
|
+
// Scan each master using its already-connected client directly.
|
|
348
|
+
// Reading master.client avoids nodeClient(master) which creates a new TCP connection
|
|
349
|
+
// when the node is disconnected, hanging indefinitely in k8s.
|
|
350
|
+
// isReady check: skip clients that are connecting/reconnecting.
|
|
351
|
+
for (const master of clusterClient.masters) {
|
|
352
|
+
const nodeClient = master.client;
|
|
353
|
+
if (!nodeClient || !nodeClient.isReady)
|
|
354
|
+
continue;
|
|
355
|
+
try {
|
|
356
|
+
yield* _yieldBatches(nodeClient.scanIterator({ MATCH: pattern, COUNT: batchSize }), maxKeys, yielded);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Shard unreachable — skip; L1 cleared, TTL handles L2 expiry.
|
|
360
|
+
}
|
|
361
|
+
if (maxKeys > 0 && yielded.count >= maxKeys)
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async function* _scanSentinel(pattern, batchSize, maxKeys, yielded) {
|
|
366
|
+
// SCAN is in NON_STICKY_COMMANDS and routes to the master automatically.
|
|
367
|
+
let cursor = '0';
|
|
368
|
+
do {
|
|
369
|
+
const reply = await sentinelClient.scan(cursor, { MATCH: pattern, COUNT: batchSize });
|
|
370
|
+
cursor = String(reply.cursor);
|
|
371
|
+
for (const key of reply.keys) {
|
|
372
|
+
if (maxKeys > 0 && yielded.count >= maxKeys)
|
|
373
|
+
return;
|
|
374
|
+
yield key;
|
|
375
|
+
yielded.count++;
|
|
376
|
+
}
|
|
377
|
+
} while (cursor !== '0' && (maxKeys <= 0 || yielded.count < maxKeys));
|
|
378
|
+
}
|
|
379
|
+
async function* scanKeysIteratorImpl(options = {}) {
|
|
380
|
+
// batchSize is SCAN's COUNT hint: total round-trips ≈ totalKeys / COUNT regardless of how
|
|
381
|
+
// few keys MATCH. The old default (100) turned sparse-match scans over a large keyspace
|
|
382
|
+
// into tens of thousands of round-trips (observed 60-90 s for a ~2-key pattern on an
|
|
383
|
+
// instance holding millions of feed keys). 1000 keeps per-call server work small while
|
|
384
|
+
// cutting round-trips 10x. Callers scanning as part of any latency-sensitive path should
|
|
385
|
+
// not SCAN at all — maintain an index set (see ms repo: cache-invalidation-governance CIG-4).
|
|
386
|
+
const { pattern = '*', maxKeys = 10000, batchSize = 1000 } = options;
|
|
387
|
+
const yielded = { count: 0 };
|
|
388
|
+
if (clusterClient && !client) {
|
|
389
|
+
yield* _scanCluster(pattern, batchSize, maxKeys, yielded);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (sentinelClient && !client) {
|
|
393
|
+
yield* _scanSentinel(pattern, batchSize, maxKeys, yielded);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Standalone: scanIterator yields string[] batches (one array per SCAN cursor step).
|
|
397
|
+
if (!client)
|
|
398
|
+
return;
|
|
399
|
+
yield* _yieldBatches(client.scanIterator({ MATCH: pattern, COUNT: batchSize }), maxKeys, yielded);
|
|
400
|
+
}
|
|
401
|
+
async function batchGetValuesImpl(keys) {
|
|
402
|
+
if (keys.length === 0)
|
|
403
|
+
return {};
|
|
404
|
+
const redis = requireActiveClient();
|
|
405
|
+
// Bounded: one GET per key, so an unbounded fan-out would queue the caller's entire key list
|
|
406
|
+
// ahead of any latency-critical command sharing this client. See PER_KEY_BATCH_CONCURRENCY.
|
|
407
|
+
const values = await mapWithBoundedConcurrency(keys, (k) => executeWithClusterRetry(() => redis.get(k)));
|
|
408
|
+
const result = {};
|
|
409
|
+
for (let i = 0; i < keys.length; i++)
|
|
410
|
+
result[keys[i]] = values[i] ?? null;
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
413
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
414
|
+
// Key-family index — the canonical SCAN replacement
|
|
415
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
416
|
+
//
|
|
417
|
+
// Enumerating a family of keys via SCAN+MATCH costs O(TOTAL keyspace / COUNT) round-trips
|
|
418
|
+
// regardless of how few keys match — on an instance holding a large unrelated dataset a
|
|
419
|
+
// handful-of-matches enumeration takes tens of seconds. Maintain a SET of members instead:
|
|
420
|
+
// `keyIndexAdd` on create (refreshes the index's safety TTL), `keyIndexRemove` on
|
|
421
|
+
// consume/delete, `keyIndexEnumerate` to list (SMEMBERS + per-member GET — per-key on
|
|
422
|
+
// purpose: family members usually hash to different slots, so MGET would raise CROSSSLOT
|
|
423
|
+
// in cluster mode). Members whose value key no longer exists (TTL-expired, deleted
|
|
424
|
+
// out-of-band) are lazily SREM'd during enumeration — the index self-heals, so a missed
|
|
425
|
+
// remove is never permanent. No SCAN backfill by design: pre-index keys age out via their
|
|
426
|
+
// own TTL.
|
|
427
|
+
/** Safety TTL on index sets — far beyond any member key's own TTL; refreshed on every add. */
|
|
428
|
+
const KEY_INDEX_DEFAULT_TTL_S = 7 * 24 * 3600;
|
|
429
|
+
/** Register a member in an index set (idempotent). Refreshes the index's safety TTL. */
|
|
430
|
+
async function keyIndexAddImpl(indexKey, member, opts) {
|
|
431
|
+
const redis = requireActiveClient();
|
|
432
|
+
await executeWithClusterRetry(() => redis.sAdd(indexKey, member));
|
|
433
|
+
await executeWithClusterRetry(() => redis.expire(indexKey, opts?.indexTtlSeconds ?? KEY_INDEX_DEFAULT_TTL_S));
|
|
434
|
+
}
|
|
435
|
+
/** Remove a member from an index set (lazy pruning in enumerate covers a missed remove). */
|
|
436
|
+
async function keyIndexRemoveImpl(indexKey, member) {
|
|
437
|
+
const redis = requireActiveClient();
|
|
438
|
+
await executeWithClusterRetry(() => redis.sRem(indexKey, member));
|
|
439
|
+
}
|
|
440
|
+
/** All members of an index set. */
|
|
441
|
+
async function keyIndexMembersImpl(indexKey) {
|
|
442
|
+
const redis = requireActiveClient();
|
|
443
|
+
return executeWithClusterRetry(() => redis.sMembers(indexKey));
|
|
444
|
+
}
|
|
445
|
+
/** Member count (SCARD). */
|
|
446
|
+
async function keyIndexCountImpl(indexKey) {
|
|
447
|
+
const redis = requireActiveClient();
|
|
448
|
+
return executeWithClusterRetry(() => redis.sCard(indexKey));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Enumerate an index: resolve each member to its value key via `keyOf`, GET it, and lazily
|
|
452
|
+
* `SREM` members whose key no longer exists. Returns only LIVE entries — O(members), never
|
|
453
|
+
* a keyspace SCAN. `withValues: false` skips GET/TTL (EXISTS-only liveness) for callers that
|
|
454
|
+
* only need the live member list.
|
|
455
|
+
*/
|
|
456
|
+
async function keyIndexEnumerateImpl(indexKey, keyOf, opts) {
|
|
457
|
+
const redis = requireActiveClient();
|
|
458
|
+
const entries = [];
|
|
459
|
+
let prunedCount = 0;
|
|
460
|
+
const limit = opts?.limit ?? 10000;
|
|
461
|
+
const withValues = opts?.withValues ?? true;
|
|
462
|
+
const members = await executeWithClusterRetry(() => redis.sMembers(indexKey));
|
|
463
|
+
for (const member of members) {
|
|
464
|
+
if (entries.length >= limit)
|
|
465
|
+
break;
|
|
466
|
+
const key = keyOf(member);
|
|
467
|
+
if (withValues) {
|
|
468
|
+
const value = await executeWithClusterRetry(() => redis.get(key));
|
|
469
|
+
if (value === null || value === undefined) {
|
|
470
|
+
await executeWithClusterRetry(() => redis.sRem(indexKey, member)).catch(() => undefined);
|
|
471
|
+
prunedCount++;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const ttlSeconds = await executeWithClusterRetry(() => redis.ttl(key));
|
|
475
|
+
entries.push({ member, key, value, ttlSeconds });
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
const exists = await executeWithClusterRetry(() => redis.exists(key)).catch(() => 0);
|
|
479
|
+
if (exists !== 1) {
|
|
480
|
+
await executeWithClusterRetry(() => redis.sRem(indexKey, member)).catch(() => undefined);
|
|
481
|
+
prunedCount++;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
entries.push({ member, key, value: null, ttlSeconds: -1 });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return { entries, prunedCount };
|
|
488
|
+
}
|
|
489
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
490
|
+
// Topology-aware multi-key helpers (internal)
|
|
491
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
492
|
+
/** True when `client` is a Redis Cluster client (has a `masters` array). */
|
|
493
|
+
function isClusterImpl(client) {
|
|
494
|
+
return 'masters' in client;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Cluster batch fast-path failures recoverable via per-key commands.
|
|
498
|
+
* CROSSSLOT: keys span slots in MGET/MULTI/EXEC.
|
|
499
|
+
* MOVED/ASK: pipeline cannot follow slot redirects — per-key ops let node-redis route each command.
|
|
500
|
+
* TRYAGAIN: slot migration in progress — batch fails; per-key with retry handles it.
|
|
501
|
+
*/
|
|
502
|
+
const CLUSTER_BATCH_FALLBACK_RE = /CROSSSLOT|cross.?slot|MOVED|ASK|TRYAGAIN/i;
|
|
503
|
+
/** Transient cluster errors — Redis spec: retry the same command after a brief pause. */
|
|
504
|
+
const CLUSTER_TRANSIENT_RETRY_RE = /TRYAGAIN|CLUSTERDOWN|LOADING/i;
|
|
505
|
+
const CLUSTER_RETRY_DELAY_MS = 25;
|
|
506
|
+
const CLUSTER_MAX_RETRIES = 2;
|
|
507
|
+
function shouldFallbackClusterBatch(err) {
|
|
508
|
+
return CLUSTER_BATCH_FALLBACK_RE.test(errMsg(err));
|
|
509
|
+
}
|
|
510
|
+
/** True when Redis returns a transient cluster error that should be retried (TRYAGAIN, CLUSTERDOWN, LOADING). */
|
|
511
|
+
function isTransientClusterError(err) {
|
|
512
|
+
return CLUSTER_TRANSIENT_RETRY_RE.test(errMsg(err));
|
|
513
|
+
}
|
|
514
|
+
function sleepMs(ms) {
|
|
515
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Execute a Redis command with bounded retry on transient cluster errors (TRYAGAIN / CLUSTERDOWN / LOADING).
|
|
519
|
+
* MOVED/ASK are handled by node-redis per-command — no retry needed here.
|
|
520
|
+
*/
|
|
521
|
+
async function executeWithClusterRetry(fn, opts) {
|
|
522
|
+
const maxRetries = opts?.maxRetries ?? CLUSTER_MAX_RETRIES;
|
|
523
|
+
const delayMs = opts?.delayMs ?? CLUSTER_RETRY_DELAY_MS;
|
|
524
|
+
let lastErr;
|
|
525
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
526
|
+
try {
|
|
527
|
+
return await fn();
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
lastErr = err;
|
|
531
|
+
if (!isTransientClusterError(err) || attempt >= maxRetries)
|
|
532
|
+
throw err;
|
|
533
|
+
await sleepMs(delayMs * (attempt + 1));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
throw lastErr;
|
|
537
|
+
}
|
|
538
|
+
/** errno / syscall codes — redis.io nodejs error-handling recommends retry on these. */
|
|
539
|
+
const COMMAND_TIMEOUT_ERR_CODES = new Set(['ETIMEDOUT', 'EAI_AGAIN', 'ESOCKETTIMEDOUT']);
|
|
540
|
+
/** node-redis v6 error class names — packages/client/lib/errors.ts */
|
|
541
|
+
const COMMAND_TIMEOUT_ERR_NAMES = /^(AbortError|ConnectionTimeoutError|SocketTimeoutError|TimeoutError|SocketTimeoutDuringMaintenanceError|CommandTimeoutDuringMaintenanceError)$/;
|
|
542
|
+
/**
|
|
543
|
+
* Message substrings for client-side command / socket timeouts and aborts.
|
|
544
|
+
* @see https://redis.io/docs/latest/develop/clients/nodejs/error-handling/
|
|
545
|
+
* @see https://github.com/redis/node-redis/blob/master/packages/client/lib/errors.ts
|
|
546
|
+
*/
|
|
547
|
+
const COMMAND_TIMEOUT_RETRY_RE = /AbortSignal|The command was aborted|Connection timeout|Socket timeout|Command timeout during maintenance|Socket timeout during maintenance|command timed out|timed out|ETIMEDOUT|ESOCKETTIMEDOUT|read ETIMEDOUT|write ETIMEDOUT/i;
|
|
548
|
+
function errCode(err) {
|
|
549
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
550
|
+
const code = err.code;
|
|
551
|
+
return typeof code === 'string' ? code : undefined;
|
|
552
|
+
}
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
/** True when a Redis command failed due to client timeout or stale connection. */
|
|
556
|
+
function isCommandTimeoutError(err) {
|
|
557
|
+
const code = errCode(err);
|
|
558
|
+
if (code !== undefined && COMMAND_TIMEOUT_ERR_CODES.has(code))
|
|
559
|
+
return true;
|
|
560
|
+
if (err instanceof Error && COMMAND_TIMEOUT_ERR_NAMES.test(err.name))
|
|
561
|
+
return true;
|
|
562
|
+
if (isNodeRedisAbortDispatchError(err))
|
|
563
|
+
return true;
|
|
564
|
+
return COMMAND_TIMEOUT_RETRY_RE.test(errMsg(err)) || isStaleConnectionError(err);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Retry wrapper for feed-hot paths: command timeout / stale connection + cluster TRYAGAIN
|
|
568
|
+
* (via nested executeWithClusterRetry).
|
|
569
|
+
*/
|
|
570
|
+
async function executeWithCommandRetry(fn, opts) {
|
|
571
|
+
const maxRetries = opts?.maxRetries ?? 2;
|
|
572
|
+
const baseDelay = opts?.delayMs ?? CLUSTER_RETRY_DELAY_MS;
|
|
573
|
+
const jitterMs = opts?.jitterMs ?? 50;
|
|
574
|
+
let lastErr;
|
|
575
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
576
|
+
try {
|
|
577
|
+
return await executeWithClusterRetry(fn);
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
lastErr = err;
|
|
581
|
+
const retryable = isCommandTimeoutError(err) || isTransientClusterError(err);
|
|
582
|
+
if (!retryable || attempt >= maxRetries)
|
|
583
|
+
throw err;
|
|
584
|
+
await sleepMs(baseDelay * (attempt + 1) + Math.floor(Math.random() * jitterMs));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
throw lastErr;
|
|
588
|
+
}
|
|
589
|
+
function extractHashTag(key) {
|
|
590
|
+
const start = key.indexOf('{');
|
|
591
|
+
if (start < 0)
|
|
592
|
+
return null;
|
|
593
|
+
const end = key.indexOf('}', start + 1);
|
|
594
|
+
if (end < 0)
|
|
595
|
+
return null;
|
|
596
|
+
return key.slice(start + 1, end);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* SHA1 per script source, computed once.
|
|
600
|
+
*
|
|
601
|
+
* Keyed by the source string itself, so callers keep passing `source` and need no registration step or
|
|
602
|
+
* lifecycle. Scripts are module-level constants, so this map is bounded by the number of distinct
|
|
603
|
+
* scripts in the process (single digits), not by call volume.
|
|
604
|
+
*/
|
|
605
|
+
const scriptShaCache = new Map();
|
|
606
|
+
function scriptSha(source) {
|
|
607
|
+
let sha = scriptShaCache.get(source);
|
|
608
|
+
if (sha === undefined) {
|
|
609
|
+
sha = (0, node_crypto_1.createHash)('sha1').update(source).digest('hex');
|
|
610
|
+
scriptShaCache.set(source, sha);
|
|
611
|
+
}
|
|
612
|
+
return sha;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* True for the error Redis returns when a script is not in that node's cache.
|
|
616
|
+
*
|
|
617
|
+
* Matched on the message rather than a type: node-redis surfaces this as a generic ErrorReply, and the
|
|
618
|
+
* prefix is part of the Redis wire protocol, so it is stable across client versions.
|
|
619
|
+
*/
|
|
620
|
+
function isNoScriptError(e) {
|
|
621
|
+
const message = e?.message;
|
|
622
|
+
return typeof message === 'string' && message.includes('NOSCRIPT');
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* EVAL a Lua script whose KEYS[] share a hash tag (cluster-safe multi-key atomicity).
|
|
626
|
+
*
|
|
627
|
+
* Sends **EVALSHA** (40-byte digest), falling back to EVAL exactly once per node on `NOSCRIPT`.
|
|
628
|
+
*
|
|
629
|
+
* Why this matters: this used to call `eval(source, …)` unconditionally, which transmits the ENTIRE Lua
|
|
630
|
+
* body and makes the server SHA1 it to find the cached script — on every single invocation. Measured on
|
|
631
|
+
* the 2026-07-31 sports-service replay, `eval` accounted for **385,732 calls and 588 s of server CPU**,
|
|
632
|
+
* the single largest Redis cost in the cluster, and every one of those calls re-shipped a ~1 KB script
|
|
633
|
+
* that Redis already had compiled. The digest is constant-size, so the wire and hashing cost stop scaling
|
|
634
|
+
* with body length — which also means script sources may be commented normally again.
|
|
635
|
+
*
|
|
636
|
+
* The `NOSCRIPT` fallback is what makes this safe on cluster and across failover, and why there is no
|
|
637
|
+
* `SCRIPT LOAD` step: a node that has never seen the script (fresh node, restart, `SCRIPT FLUSH`, a
|
|
638
|
+
* MOVED/ASK redirect to a different master, a promoted replica) answers NOSCRIPT, and the EVAL both runs
|
|
639
|
+
* the script and caches it there. Self-healing per node, no coordination, no startup ordering.
|
|
640
|
+
*/
|
|
641
|
+
async function evalTaggedScript(client, opts) {
|
|
642
|
+
const { keys, argv = [], source, validateSameTag = true } = opts;
|
|
643
|
+
if (keys.length === 0)
|
|
644
|
+
throw new Error('evalTaggedScript: at least one key required');
|
|
645
|
+
if (validateSameTag && keys.length > 1) {
|
|
646
|
+
const tags = keys.map(extractHashTag);
|
|
647
|
+
const first = tags[0];
|
|
648
|
+
if (!first || tags.some((t) => t !== first)) {
|
|
649
|
+
throw new Error('evalTaggedScript: all KEYS must share the same hash tag');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const sha = scriptSha(source);
|
|
653
|
+
const c = client;
|
|
654
|
+
return executeWithCommandRetry(async () => {
|
|
655
|
+
try {
|
|
656
|
+
// `return await`, not `return`: the rejection must surface inside this try to reach the catch.
|
|
657
|
+
return await c.evalSha(sha, { keys, arguments: argv });
|
|
658
|
+
}
|
|
659
|
+
catch (e) {
|
|
660
|
+
if (!isNoScriptError(e))
|
|
661
|
+
throw e;
|
|
662
|
+
return c.eval(source, { keys, arguments: argv });
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Max commands in flight on the per-key cluster fallback paths.
|
|
668
|
+
*
|
|
669
|
+
* These paths issue one command PER KEY, so an unbounded `Promise.all` puts the caller's whole key
|
|
670
|
+
* list on the connection at once. Redis serves each of them quickly — a slowlog stays clean — but
|
|
671
|
+
* any latency-critical command issued by another caller on the same client queues behind every
|
|
672
|
+
* pending reply. Observed in a sports feed replay (2026-07-28): a 400-key `mSet` from a background
|
|
673
|
+
* schedule sweep pushed a live odds-book `hmGet` to 1435 ms of wait while the event loop was idle
|
|
674
|
+
* (worst stall 15 ms), GC was negligible (28 ms) and the slowlog's worst entry was 15 ms.
|
|
675
|
+
*
|
|
676
|
+
* A cap converts that from "whole batch, then the read" into "at most this many, then the read".
|
|
677
|
+
* Chosen small because it is a queue-depth budget shared with latency-sensitive traffic, not a
|
|
678
|
+
* throughput knob — node-redis still auto-pipelines each chunk per target node.
|
|
679
|
+
*/
|
|
680
|
+
exports.PER_KEY_BATCH_CONCURRENCY = 25;
|
|
681
|
+
/**
|
|
682
|
+
* Max keys per batched command on the standalone/sentinel fast paths (`MGET`, `MULTI/EXEC`).
|
|
683
|
+
*
|
|
684
|
+
* Separate budget from {@link PER_KEY_BATCH_CONCURRENCY} because the failure mode differs: there the
|
|
685
|
+
* risk is queue depth (many small commands), here it is a single command whose payload and
|
|
686
|
+
* server-side service time scale with the key count. Redis executes one command at a time, so an
|
|
687
|
+
* unbounded `MGET k1 … k10000` blocks every other client on that node for its full duration.
|
|
688
|
+
*
|
|
689
|
+
* 500 matches the chunk convention used by callers for the same reason.
|
|
690
|
+
*/
|
|
691
|
+
exports.BATCH_COMMAND_CHUNK = 500;
|
|
692
|
+
/** Split into fixed-size slices; returns the input as a single slice when already small enough. */
|
|
693
|
+
function chunked(items, size) {
|
|
694
|
+
if (items.length <= size)
|
|
695
|
+
return [items];
|
|
696
|
+
const out = [];
|
|
697
|
+
for (let i = 0; i < items.length; i += size)
|
|
698
|
+
out.push(items.slice(i, i + size));
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
/** Run `task` over `items` with at most {@link PER_KEY_BATCH_CONCURRENCY} in flight, preserving order. */
|
|
702
|
+
async function mapWithBoundedConcurrency(items, task) {
|
|
703
|
+
if (items.length <= exports.PER_KEY_BATCH_CONCURRENCY)
|
|
704
|
+
return Promise.all(items.map(task));
|
|
705
|
+
const out = new Array(items.length);
|
|
706
|
+
for (let i = 0; i < items.length; i += exports.PER_KEY_BATCH_CONCURRENCY) {
|
|
707
|
+
const slice = items.slice(i, i + exports.PER_KEY_BATCH_CONCURRENCY);
|
|
708
|
+
const settled = await Promise.all(slice.map(task));
|
|
709
|
+
for (let j = 0; j < settled.length; j++)
|
|
710
|
+
out[i + j] = settled[j];
|
|
711
|
+
}
|
|
712
|
+
return out;
|
|
713
|
+
}
|
|
714
|
+
async function perKeyMGetImpl(client, keys) {
|
|
715
|
+
return mapWithBoundedConcurrency(keys, (k) => executeWithClusterRetry(() => client.get(k)));
|
|
716
|
+
}
|
|
717
|
+
async function perKeyMSetImpl(client, entries) {
|
|
718
|
+
await mapWithBoundedConcurrency(entries, ({ key, value, ttl }) => executeWithClusterRetry(() => ttl ? client.setEx(key, ttl, value) : client.set(key, value)));
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Topology-aware MGET — standalone/sentinel fast path, cluster per-key path.
|
|
722
|
+
*
|
|
723
|
+
* Cluster: always per-key GET via `Promise.all` — Redis requires same slot for MGET; node-redis
|
|
724
|
+
* auto-pipelines per target node and follows MOVED/ASK per command.
|
|
725
|
+
* Standalone/sentinel: single `mGet` round-trip; falls back to per-key on unexpected batch errors.
|
|
726
|
+
*/
|
|
727
|
+
async function mGetImpl(client, keys) {
|
|
728
|
+
if (keys.length === 0)
|
|
729
|
+
return [];
|
|
730
|
+
if (isClusterImpl(client))
|
|
731
|
+
return perKeyMGetImpl(client, keys);
|
|
732
|
+
try {
|
|
733
|
+
// Chunked: one unbounded MGET would block the node for its whole service time.
|
|
734
|
+
const out = [];
|
|
735
|
+
for (const slice of chunked(keys, exports.BATCH_COMMAND_CHUNK)) {
|
|
736
|
+
out.push(...(await client.mGet(slice)));
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
if (!shouldFallbackClusterBatch(err))
|
|
742
|
+
throw err;
|
|
743
|
+
return perKeyMGetImpl(client, keys);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Topology-aware multi-key SET — standalone/sentinel pipeline, cluster per-key path.
|
|
748
|
+
*
|
|
749
|
+
* Cluster: per-key SETEX/SET via `Promise.all` — avoids CROSSSLOT/MOVED from MULTI/EXEC pipelines.
|
|
750
|
+
* Standalone/sentinel: `multi().exec()` fast path; falls back to per-key on batch errors.
|
|
751
|
+
*
|
|
752
|
+
* entries.value must already be serialised to a string (JSON.stringify beforehand).
|
|
753
|
+
*/
|
|
754
|
+
async function mSetImpl(client, entries) {
|
|
755
|
+
if (entries.length === 0)
|
|
756
|
+
return;
|
|
757
|
+
if (isClusterImpl(client)) {
|
|
758
|
+
await perKeyMSetImpl(client, entries);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
// Chunked: MULTI/EXEC runs as one unit, so an unbounded pipeline blocks the node for the whole
|
|
763
|
+
// batch. Chunking splits atomicity, which is safe for the independent per-key writes this API
|
|
764
|
+
// takes (no caller relies on all-or-nothing across unrelated keys) — see BATCH_COMMAND_CHUNK.
|
|
765
|
+
for (const slice of chunked(entries, exports.BATCH_COMMAND_CHUNK)) {
|
|
766
|
+
const pipeline = client.multi();
|
|
767
|
+
for (const { key, value, ttl } of slice) {
|
|
768
|
+
if (ttl)
|
|
769
|
+
pipeline.setEx(key, ttl, value);
|
|
770
|
+
else
|
|
771
|
+
pipeline.set(key, value);
|
|
772
|
+
}
|
|
773
|
+
await pipeline.exec();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
if (!shouldFallbackClusterBatch(err))
|
|
778
|
+
throw err;
|
|
779
|
+
await perKeyMSetImpl(client, entries);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Topology-aware multi-key DELETE — the single canonical implementation.
|
|
784
|
+
*
|
|
785
|
+
* Standalone / sentinel: single `DEL k1 k2 …` round-trip.
|
|
786
|
+
* Cluster: per-key DEL loop — avoids CROSSSLOT when keys span different hash slots.
|
|
787
|
+
*
|
|
788
|
+
* All callers should use this instead of `client.del(array)` directly.
|
|
789
|
+
*/
|
|
790
|
+
async function mDelImpl(client, keys) {
|
|
791
|
+
if (keys.length === 0)
|
|
792
|
+
return 0;
|
|
793
|
+
if (isClusterImpl(client)) {
|
|
794
|
+
let n = 0;
|
|
795
|
+
for (const k of keys) {
|
|
796
|
+
n += await executeWithClusterRetry(() => client.del(k));
|
|
797
|
+
}
|
|
798
|
+
return n;
|
|
799
|
+
}
|
|
800
|
+
// Chunked for the same reason as MGET: `DEL k1 … kN` is one command whose service time scales with
|
|
801
|
+
// N, and Redis serves it to the exclusion of every other client. The cluster branch above is
|
|
802
|
+
// already bounded (strictly serial, one key in flight).
|
|
803
|
+
const c = client;
|
|
804
|
+
let removed = 0;
|
|
805
|
+
for (const slice of chunked(keys, exports.BATCH_COMMAND_CHUNK)) {
|
|
806
|
+
removed += await c.del(slice);
|
|
807
|
+
}
|
|
808
|
+
return removed;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Topology-aware FLUSHDB — flushes every data store.
|
|
812
|
+
*
|
|
813
|
+
* Standalone / sentinel: single `flushDb()` call.
|
|
814
|
+
* Cluster: `flushDb()` on the cluster client only flushes the node owning the command's
|
|
815
|
+
* hash slot — i.e. one master. This calls `flushDb()` on every master client directly.
|
|
816
|
+
*
|
|
817
|
+
* Use only in tests and dev tooling — never on production data.
|
|
818
|
+
*/
|
|
819
|
+
async function flushAllImpl(client) {
|
|
820
|
+
if (isClusterImpl(client)) {
|
|
821
|
+
await Promise.all(client.masters
|
|
822
|
+
.filter((m) => m.client != null)
|
|
823
|
+
.map((m) => m.client.flushDb()));
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
await client.flushDb();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
830
|
+
// Lifecycle (internal)
|
|
831
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
832
|
+
async function closeRedisImpl() {
|
|
833
|
+
if (readClient) {
|
|
834
|
+
try {
|
|
835
|
+
await readClient.quit();
|
|
836
|
+
}
|
|
837
|
+
catch (e) {
|
|
838
|
+
console.error(ERR_PREFIX, 'close read replica', errMsg(e));
|
|
839
|
+
}
|
|
840
|
+
readClient = null;
|
|
841
|
+
}
|
|
842
|
+
if (clusterClient) {
|
|
843
|
+
try {
|
|
844
|
+
await clusterClient.quit();
|
|
845
|
+
}
|
|
846
|
+
catch (e) {
|
|
847
|
+
console.error(ERR_PREFIX, 'close cluster', errMsg(e));
|
|
848
|
+
}
|
|
849
|
+
clusterClient = null;
|
|
850
|
+
}
|
|
851
|
+
if (sentinelClient) {
|
|
852
|
+
try {
|
|
853
|
+
await sentinelClient.close();
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
console.error(ERR_PREFIX, 'close sentinel', errMsg(e));
|
|
857
|
+
}
|
|
858
|
+
sentinelClient = null;
|
|
859
|
+
}
|
|
860
|
+
if (client) {
|
|
861
|
+
try {
|
|
862
|
+
await client.quit();
|
|
863
|
+
}
|
|
864
|
+
catch (e) {
|
|
865
|
+
console.error(ERR_PREFIX, 'close standalone', errMsg(e));
|
|
866
|
+
}
|
|
867
|
+
client = null;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
function getRedisConnectionStatsImpl() {
|
|
871
|
+
if (clusterClient) {
|
|
872
|
+
// Cluster mode: replica routing is handled internally by node-redis when `useReplicas`
|
|
873
|
+
// is true; we surface `hasReadReplica = true` to keep observability uniform with the
|
|
874
|
+
// standalone+readClient story. Per-node connection counts would require introspecting
|
|
875
|
+
// the cluster's slot map and are intentionally left out of this lightweight snapshot.
|
|
876
|
+
return {
|
|
877
|
+
connected: true,
|
|
878
|
+
mode: 'cluster',
|
|
879
|
+
hasReadReplica: true,
|
|
880
|
+
master: { connected: true },
|
|
881
|
+
replica: { connected: true, count: 1 }
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
if (sentinelClient) {
|
|
885
|
+
return { connected: true, mode: 'sentinel', hasReadReplica: false, master: { connected: true }, replica: { connected: false, count: 0 } };
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
connected: client !== null,
|
|
889
|
+
mode: 'standalone',
|
|
890
|
+
hasReadReplica: readClient !== null,
|
|
891
|
+
master: { connected: client !== null },
|
|
892
|
+
replica: { connected: readClient !== null, count: readClient ? 1 : 0 }
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
896
|
+
// Topology descriptor → RedisConfig translation
|
|
897
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
898
|
+
/**
|
|
899
|
+
* Translate a RedisTopologyDescriptor into the internal RedisConfig shape.
|
|
900
|
+
* Exported as TSRedis.buildConfigFromTopology for callers that need a RedisConfig
|
|
901
|
+
* alongside connectFromTopology (e.g. passing defaultConfig to configureRedisStrategy).
|
|
902
|
+
*/
|
|
903
|
+
function buildConfigFromDescriptor(d) {
|
|
904
|
+
const base = {
|
|
905
|
+
url: d.url ?? '',
|
|
906
|
+
maxReconnectRetries: d.maxReconnectRetries ?? DEFAULT_CONFIG.maxReconnectRetries,
|
|
907
|
+
reconnectDelay: d.reconnectDelay ?? DEFAULT_CONFIG.reconnectDelay,
|
|
908
|
+
connectTimeout: d.connectTimeout ?? DEFAULT_CONFIG.connectTimeout,
|
|
909
|
+
socketTimeout: d.commandTimeoutMs ?? DEFAULT_CONFIG.socketTimeout
|
|
910
|
+
};
|
|
911
|
+
switch (d.mode) {
|
|
912
|
+
case 'sentinel': {
|
|
913
|
+
// Master auth: embed in URL so connectRedisSentinel's urlMatch extracts it into
|
|
914
|
+
// sentinelOptions.password (the master connection password), independently of
|
|
915
|
+
// sentinel node auth. sentinel.password is strictly for sentinel-node requirepass
|
|
916
|
+
// (rare — most setups only password-protect the master, not the sentinel processes).
|
|
917
|
+
const urlWithPass = d.password ? `redis://:${d.password}@_` : (d.url ?? '');
|
|
918
|
+
return {
|
|
919
|
+
...base,
|
|
920
|
+
url: urlWithPass,
|
|
921
|
+
sentinel: {
|
|
922
|
+
hosts: d.sentinelNodes ?? [],
|
|
923
|
+
name: d.sentinelMaster ?? 'mymaster',
|
|
924
|
+
...(d.sentinelPassword ? { password: d.sentinelPassword } : {}),
|
|
925
|
+
...(d.sentinelNodeAddressMap !== undefined ? { nodeAddressMap: d.sentinelNodeAddressMap } : {})
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
case 'cluster':
|
|
930
|
+
return {
|
|
931
|
+
...base,
|
|
932
|
+
cluster: {
|
|
933
|
+
nodes: d.clusterNodes ?? [],
|
|
934
|
+
...(d.password ? { password: d.password } : {}),
|
|
935
|
+
...(d.clusterNodeAddressMap !== undefined ? { nodeAddressMap: d.clusterNodeAddressMap } : {})
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
default: // 'single'
|
|
939
|
+
return base;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Connect using a flat topology descriptor — the preferred entry point for all callers.
|
|
944
|
+
*
|
|
945
|
+
* Handles all three topologies (single / sentinel / cluster) from one call.
|
|
946
|
+
* No environment-variable reads; every value comes explicitly from the descriptor.
|
|
947
|
+
* Idempotent: if a client for this topology is already connected, this is a no-op.
|
|
948
|
+
* Stale closed clients (max-reconnect exhausted) are discarded and reconnected.
|
|
949
|
+
*/
|
|
950
|
+
async function connectFromTopologyImpl(descriptor) {
|
|
951
|
+
await discardStaleClientsIfNeeded();
|
|
952
|
+
const active = getActiveClientImpl();
|
|
953
|
+
if (active && isClientOpen(active))
|
|
954
|
+
return;
|
|
955
|
+
await connectRedisImpl(buildConfigFromDescriptor(descriptor));
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Build a RedisTopologyDescriptor from standard Redis environment variables.
|
|
959
|
+
*
|
|
960
|
+
* Detection priority:
|
|
961
|
+
* 1. REDIS_SENTINEL_NODES (comma-separated host:port) → sentinel
|
|
962
|
+
* 2. REDIS_CLUSTER_NODES (comma-separated host:port) → cluster
|
|
963
|
+
* 3. REDIS_URL → single
|
|
964
|
+
* 4. Default → cluster on 127.0.0.1:7000-7005 (local dev default)
|
|
965
|
+
*
|
|
966
|
+
* Env vars consumed:
|
|
967
|
+
* REDIS_SENTINEL_NODES e.g. "127.0.0.1:26380,127.0.0.1:26381,127.0.0.1:26382"
|
|
968
|
+
* REDIS_SENTINEL_MASTER sentinel master name (default: "mymaster")
|
|
969
|
+
* REDIS_CLUSTER_NODES e.g. "127.0.0.1:7000,...,127.0.0.1:7005"
|
|
970
|
+
* REDIS_CLUSTER_HOST_REWRITE host to rewrite cluster-announced addresses to (default: "127.0.0.1")
|
|
971
|
+
* REDIS_URL e.g. "redis://localhost:6379"
|
|
972
|
+
* REDIS_PASSWORD auth password (applied to all modes)
|
|
973
|
+
*
|
|
974
|
+
* For cluster mode the nodeAddressMap handles three redirect scenarios automatically:
|
|
975
|
+
* - Port-matched rewrites (Docker host-port-forward)
|
|
976
|
+
* - k8s FQDN MOVED redirects (redis-cluster-N.* → localhost:startPort+N)
|
|
977
|
+
* - Dynamic Docker pod-IP MOVED redirect mapping
|
|
978
|
+
* When all cluster nodes are non-loopback hosts (direct k8s pod routing), no rewrite is applied.
|
|
979
|
+
*/
|
|
980
|
+
function buildTopologyDescriptorFromEnvImpl() {
|
|
981
|
+
const password = process.env.REDIS_PASSWORD;
|
|
982
|
+
const sentinelRaw = process.env.REDIS_SENTINEL_NODES;
|
|
983
|
+
const clusterRaw = process.env.REDIS_CLUSTER_NODES;
|
|
984
|
+
const redisUrl = process.env.REDIS_URL;
|
|
985
|
+
if (sentinelRaw) {
|
|
986
|
+
const nodes = sentinelRaw.split(',').map(s => {
|
|
987
|
+
const [host, port] = s.trim().split(':');
|
|
988
|
+
return { host: host, port: Number(port) };
|
|
989
|
+
});
|
|
990
|
+
// REDIS_SENTINEL_MASTER_HOST + REDIS_SENTINEL_MASTER_PORT: remap Docker/k8s-internal
|
|
991
|
+
// master/replica addresses to a client-reachable address.
|
|
992
|
+
// Example: sentinel announces 172.21.0.2:6379 → remap to 127.0.0.1:6381.
|
|
993
|
+
// Loopback addresses (sentinel nodes themselves) are never remapped.
|
|
994
|
+
const addrHost = process.env.REDIS_SENTINEL_MASTER_HOST;
|
|
995
|
+
const addrPort = process.env.REDIS_SENTINEL_MASTER_PORT;
|
|
996
|
+
const sentinelNodeAddressMap = addrHost && addrPort
|
|
997
|
+
? (address) => {
|
|
998
|
+
// Only remap standard Redis master/replica port (6379).
|
|
999
|
+
// Sentinel root nodes (28380+) and sentinel peer ports (26379) must not be remapped —
|
|
1000
|
+
// nodeAddressMap is applied to ALL node connections, not just master connections.
|
|
1001
|
+
const colonIdx = address.lastIndexOf(':');
|
|
1002
|
+
const reportedPort = colonIdx >= 0 ? Number(address.slice(colonIdx + 1)) : 6379;
|
|
1003
|
+
if (reportedPort !== 6379)
|
|
1004
|
+
return undefined;
|
|
1005
|
+
return { host: addrHost, port: Number(addrPort) };
|
|
1006
|
+
}
|
|
1007
|
+
: undefined;
|
|
1008
|
+
return {
|
|
1009
|
+
mode: 'sentinel',
|
|
1010
|
+
password,
|
|
1011
|
+
sentinelNodes: nodes,
|
|
1012
|
+
sentinelMaster: process.env.REDIS_SENTINEL_MASTER ?? 'mymaster',
|
|
1013
|
+
...(sentinelNodeAddressMap ? { sentinelNodeAddressMap } : {})
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
if (clusterRaw || !redisUrl) {
|
|
1017
|
+
const raw = clusterRaw
|
|
1018
|
+
?? '127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002,127.0.0.1:7003,127.0.0.1:7004,127.0.0.1:7005';
|
|
1019
|
+
const nodes = raw.split(',').map(s => {
|
|
1020
|
+
const [host, port] = s.trim().split(':');
|
|
1021
|
+
return { host: host, port: Number(port) };
|
|
1022
|
+
});
|
|
1023
|
+
const hostRewrite = process.env.REDIS_CLUSTER_HOST_REWRITE ?? '127.0.0.1';
|
|
1024
|
+
const knownPorts = new Set(nodes.map(n => n.port));
|
|
1025
|
+
const startPort = Math.min(...nodes.map(n => n.port));
|
|
1026
|
+
// Direct k8s: real pod hostnames, cluster is routable without any rewrite.
|
|
1027
|
+
const isDirectK8s = nodes.every(n => n.host !== '127.0.0.1' && n.host !== 'localhost');
|
|
1028
|
+
const k8sDynamicMap = new Map();
|
|
1029
|
+
let k8sNextPort = startPort;
|
|
1030
|
+
const clusterNodeAddressMap = isDirectK8s
|
|
1031
|
+
? undefined
|
|
1032
|
+
: (address) => {
|
|
1033
|
+
const m = address.match(/:(\d+)$/);
|
|
1034
|
+
if (!m)
|
|
1035
|
+
return undefined;
|
|
1036
|
+
const port = Number(m[1]);
|
|
1037
|
+
if (knownPorts.has(port))
|
|
1038
|
+
return { host: hostRewrite, port };
|
|
1039
|
+
// k8s FQDN MOVED redirect: redis-cluster-N.* → localhost:startPort+N
|
|
1040
|
+
const fqdn = address.match(/redis-cluster-(\d+)\./);
|
|
1041
|
+
if (fqdn)
|
|
1042
|
+
return { host: '127.0.0.1', port: startPort + Number(fqdn[1]) };
|
|
1043
|
+
// Docker pod-IP MOVED redirect: dynamic sequential assignment
|
|
1044
|
+
if (!k8sDynamicMap.has(address) && k8sNextPort < startPort + nodes.length) {
|
|
1045
|
+
k8sDynamicMap.set(address, k8sNextPort++);
|
|
1046
|
+
}
|
|
1047
|
+
const mapped = k8sDynamicMap.get(address);
|
|
1048
|
+
return mapped !== undefined ? { host: '127.0.0.1', port: mapped } : undefined;
|
|
1049
|
+
};
|
|
1050
|
+
return {
|
|
1051
|
+
mode: 'cluster',
|
|
1052
|
+
password,
|
|
1053
|
+
url: `redis://${nodes[0].host}:${nodes[0].port}`,
|
|
1054
|
+
clusterNodes: nodes,
|
|
1055
|
+
clusterNodeAddressMap
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
return { mode: 'single', url: redisUrl, password };
|
|
1059
|
+
}
|
|
1060
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1061
|
+
// TSRedis – static API (connect, pub/sub, scan) + instance ops
|
|
1062
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1063
|
+
class TSRedis extends events_1.EventEmitter {
|
|
1064
|
+
redis;
|
|
1065
|
+
constructor(instance) {
|
|
1066
|
+
super();
|
|
1067
|
+
this.redis = instance;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Connect from a flat topology descriptor — the preferred entry point.
|
|
1071
|
+
* Handles single / sentinel / cluster from one call; no env-var reads inside.
|
|
1072
|
+
* Idempotent: already-connected clients are reused.
|
|
1073
|
+
* Stale closed clients (max-reconnect exhausted) are discarded and reconnected.
|
|
1074
|
+
*/
|
|
1075
|
+
static connectFromTopology = connectFromTopologyImpl;
|
|
1076
|
+
/**
|
|
1077
|
+
* Build a RedisTopologyDescriptor from standard Redis env vars.
|
|
1078
|
+
* Use with connectFromTopology for zero-config topology-aware connections:
|
|
1079
|
+
* await TSRedis.connectFromTopology(TSRedis.buildTopologyDescriptorFromEnv())
|
|
1080
|
+
*/
|
|
1081
|
+
static buildTopologyDescriptorFromEnv = buildTopologyDescriptorFromEnvImpl;
|
|
1082
|
+
/**
|
|
1083
|
+
* Translate a RedisTopologyDescriptor into a RedisConfig.
|
|
1084
|
+
* Use when another API (e.g. configureRedisStrategy) still takes RedisConfig directly.
|
|
1085
|
+
*/
|
|
1086
|
+
static buildConfigFromTopology = buildConfigFromDescriptor;
|
|
1087
|
+
/** Connect (standalone, Sentinel, Cluster, or with read replicas). Cluster is selected
|
|
1088
|
+
* when the supplied `RedisConfig.cluster` is set; otherwise sentinel / standalone per
|
|
1089
|
+
* the existing precedence rules. */
|
|
1090
|
+
static connect = connectRedisImpl;
|
|
1091
|
+
/** Standalone / sentinel client singleton. Returns null in cluster mode — use {@link getCluster}. */
|
|
1092
|
+
static getClient = getRedisImpl;
|
|
1093
|
+
/** Active cluster client. Returns null when not in cluster mode. */
|
|
1094
|
+
static getCluster = getRedisClusterImpl;
|
|
1095
|
+
/** Topology-agnostic accessor — returns the active client (standalone / sentinel / cluster). */
|
|
1096
|
+
static getActiveClient = getActiveClientImpl;
|
|
1097
|
+
static getClientForRead = getRedisForReadImpl;
|
|
1098
|
+
static hasReadReplica = hasReadReplicaImpl;
|
|
1099
|
+
static close = closeRedisImpl;
|
|
1100
|
+
static checkHealth = checkRedisHealthImpl;
|
|
1101
|
+
static getConnectionStats = getRedisConnectionStatsImpl;
|
|
1102
|
+
static publish = publishImpl;
|
|
1103
|
+
static subscribe = subscribeImpl;
|
|
1104
|
+
/**
|
|
1105
|
+
* STREAMING key scan — the primitive to reach for whenever the match-set size is not already known.
|
|
1106
|
+
*
|
|
1107
|
+
* Preferred over {@link TSRedis.scanr} for one structural reason: an iterator cannot misreport completeness.
|
|
1108
|
+
* The caller drains it or does not, and either way there is no single value that claims to be "all the keys".
|
|
1109
|
+
* `scanr` materialises, so it needs a ceiling and a policy for hitting it — see `TSRedisScan.onLimit`.
|
|
1110
|
+
*
|
|
1111
|
+
* `maxKeys` still bounds it (default 10,000) and reaching that bound ends iteration SILENTLY, so if
|
|
1112
|
+
* completeness matters here too, pass a `maxKeys` you are prepared to treat as an error and check the count
|
|
1113
|
+
* yourself. Best of all: do not scan on a hot path at all — keep an index set (ms repo: CIG-4).
|
|
1114
|
+
*/
|
|
1115
|
+
static scanKeysIterator = scanKeysIteratorImpl;
|
|
1116
|
+
static batchGetValues = batchGetValuesImpl;
|
|
1117
|
+
/**
|
|
1118
|
+
* Key-family index — the canonical SCAN replacement for enumerating a family of keys.
|
|
1119
|
+
* O(members) SMEMBERS + per-member GET (CROSSSLOT-safe), lazy pruning of dead members.
|
|
1120
|
+
* Prefer these over scanKeysIterator for ANY latency-sensitive or repeated enumeration.
|
|
1121
|
+
*/
|
|
1122
|
+
static keyIndexAdd = keyIndexAddImpl;
|
|
1123
|
+
static keyIndexRemove = keyIndexRemoveImpl;
|
|
1124
|
+
static keyIndexMembers = keyIndexMembersImpl;
|
|
1125
|
+
static keyIndexCount = keyIndexCountImpl;
|
|
1126
|
+
static keyIndexEnumerate = keyIndexEnumerateImpl;
|
|
1127
|
+
/**
|
|
1128
|
+
* True when `client` is a Redis Cluster client.
|
|
1129
|
+
* Prefer the topology-aware helpers (mGet, mSet, mDel, flushAll) over manual branching.
|
|
1130
|
+
*/
|
|
1131
|
+
static isCluster = isClusterImpl;
|
|
1132
|
+
/**
|
|
1133
|
+
* Topology-aware MGET: tries a single mGet round-trip; on cluster CROSSSLOT/MOVED/ASK falls back
|
|
1134
|
+
* to per-key GETs via Promise.all. Safe for keys that span different hash slots.
|
|
1135
|
+
*/
|
|
1136
|
+
static mGet = mGetImpl;
|
|
1137
|
+
/**
|
|
1138
|
+
* Topology-aware multi-key SET: tries a multi().exec() pipeline; on cluster CROSSSLOT/MOVED/ASK
|
|
1139
|
+
* falls back to per-key SETEX/SET via Promise.all.
|
|
1140
|
+
* entries.value must be pre-serialised (string).
|
|
1141
|
+
*/
|
|
1142
|
+
static mSet = mSetImpl;
|
|
1143
|
+
/**
|
|
1144
|
+
* Topology-aware multi-key DELETE: per-key loop in cluster (avoids CROSSSLOT),
|
|
1145
|
+
* batch DEL in standalone/sentinel (single round-trip).
|
|
1146
|
+
*/
|
|
1147
|
+
static mDel = mDelImpl;
|
|
1148
|
+
static executeWithCommandRetry = executeWithCommandRetry;
|
|
1149
|
+
static evalTaggedScript = evalTaggedScript;
|
|
1150
|
+
static isCommandTimeoutError = isCommandTimeoutError;
|
|
1151
|
+
/**
|
|
1152
|
+
* Topology-aware FLUSHDB: flushes every master in cluster mode, single flushDb()
|
|
1153
|
+
* otherwise. Test/dev tooling only — never call on production data.
|
|
1154
|
+
*/
|
|
1155
|
+
static flushAll = flushAllImpl;
|
|
1156
|
+
async cachedRequest(options, defaultValue = {}, debug) {
|
|
1157
|
+
if (!options.getData) {
|
|
1158
|
+
options.getData = (response) => response;
|
|
1159
|
+
}
|
|
1160
|
+
const { url, key, ...requestOptions } = options;
|
|
1161
|
+
const result = await this.redis.get(key);
|
|
1162
|
+
if (result) {
|
|
1163
|
+
return JSON.parse(result);
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
const response = await TSRequest_1.TSRequest.json(url, { ...requestOptions, method: (options.method || 'post') }, debug);
|
|
1167
|
+
const data = options.getData(response);
|
|
1168
|
+
if (data) {
|
|
1169
|
+
let value = this.pathToStore(options.pathValue || [], data);
|
|
1170
|
+
value = options.format && typeof options.format === 'function'
|
|
1171
|
+
? options.format(value)
|
|
1172
|
+
: value;
|
|
1173
|
+
if (value) {
|
|
1174
|
+
await this.redis.setEx(key, options.expire || 300, JSON.stringify(value));
|
|
1175
|
+
return value;
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
return defaultValue;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
console.error(response);
|
|
1183
|
+
return defaultValue;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
pathToStore(keys = [], object) {
|
|
1188
|
+
let current = object;
|
|
1189
|
+
let value = object;
|
|
1190
|
+
for (const i in keys) {
|
|
1191
|
+
if (Object.prototype.hasOwnProperty.call(keys, i) && current && typeof current === 'object' && current[keys[i]]) {
|
|
1192
|
+
value = current[keys[i]];
|
|
1193
|
+
current = value;
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
value = undefined;
|
|
1197
|
+
break;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return value;
|
|
1201
|
+
}
|
|
1202
|
+
cached = async (path, options) => {
|
|
1203
|
+
const result = await this.redis.get(path);
|
|
1204
|
+
if (result)
|
|
1205
|
+
return result;
|
|
1206
|
+
if (options?.value) {
|
|
1207
|
+
await this.redis.setEx(path, options.expire || 3600, options.value);
|
|
1208
|
+
return options.value;
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
load = async (entity) => {
|
|
1212
|
+
const result = await this.redis.hGetAll(entity);
|
|
1213
|
+
const mappedResult = [];
|
|
1214
|
+
Object.keys(result || {}).map((k) => mappedResult.push(JSON.parse(result[k])));
|
|
1215
|
+
return mappedResult;
|
|
1216
|
+
};
|
|
1217
|
+
get = async (key, cb) => {
|
|
1218
|
+
const result = await this.redis.get(key);
|
|
1219
|
+
return cb ? cb(result) : result;
|
|
1220
|
+
};
|
|
1221
|
+
set = async (key, data) => (await this.redis.set(key, String(data))) === 'OK';
|
|
1222
|
+
hget = async (key, field) => {
|
|
1223
|
+
const result = await this.redis.hGet(key, field);
|
|
1224
|
+
return result ? JSON.parse(result) : null;
|
|
1225
|
+
};
|
|
1226
|
+
getM = async (entity, keys) => this.redis.hmGet(entity, keys);
|
|
1227
|
+
hgetall = async (key, cb) => {
|
|
1228
|
+
const result = await this.redis.hGetAll(key);
|
|
1229
|
+
return cb ? cb(result) : result;
|
|
1230
|
+
};
|
|
1231
|
+
hset = async (key, field, data) => {
|
|
1232
|
+
if (typeof data !== 'undefined' && data !== null && String(data).length > 0) {
|
|
1233
|
+
return (await this.redis.hSet(key, field, JSON.stringify(data))) >= 0;
|
|
1234
|
+
}
|
|
1235
|
+
return false;
|
|
1236
|
+
};
|
|
1237
|
+
hdel = async (key, fields) => (await this.redis.hDel(key, fields)) > 0;
|
|
1238
|
+
del = async (keys) => {
|
|
1239
|
+
const ks = Array.isArray(keys) ? keys : [keys];
|
|
1240
|
+
if (ks.length === 0)
|
|
1241
|
+
return false;
|
|
1242
|
+
// Per-key DEL: cluster mode rejects multi-key DEL across slots (CROSSSLOT).
|
|
1243
|
+
const counts = await Promise.all(ks.map(k => this.redis.del(k)));
|
|
1244
|
+
return counts.some(c => c > 0);
|
|
1245
|
+
};
|
|
1246
|
+
loadLists = async (pattern = '*') => {
|
|
1247
|
+
const list = await this.scanr({ pattern });
|
|
1248
|
+
const result = {};
|
|
1249
|
+
if (list.length === 0)
|
|
1250
|
+
return result;
|
|
1251
|
+
// Per-key TYPE: cluster multi() across different slots causes CROSSSLOT.
|
|
1252
|
+
// NOT bounded by PER_KEY_BATCH_CONCURRENCY on purpose: `list` is a whole scan result, so chunking
|
|
1253
|
+
// it turns one wide fan-out into thousands of sequential round-trip batches (measured: 55k keys
|
|
1254
|
+
// → ~2.2k chunks, slow enough to time out). This is a scan-everything tooling API, not a path
|
|
1255
|
+
// shared with latency-critical traffic — callers needing queue-depth safety should use mGet/mSet.
|
|
1256
|
+
const types = await Promise.all(list.map(k => this.redis.type(k).catch(() => 'none')));
|
|
1257
|
+
const hashKeys = [];
|
|
1258
|
+
const hashScopes = [];
|
|
1259
|
+
for (let i = 0; i < list.length; i++) {
|
|
1260
|
+
if (String(types[i]) === 'hash') {
|
|
1261
|
+
hashKeys.push(list[i]);
|
|
1262
|
+
hashScopes.push(list[i].split(':').pop());
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (hashKeys.length === 0)
|
|
1266
|
+
return result;
|
|
1267
|
+
// Per-key hGetAll: cluster multi() across slots causes CROSSSLOT. Unbounded for the same reason
|
|
1268
|
+
// as the TYPE fan-out above.
|
|
1269
|
+
const hashResults = await Promise.all(hashKeys.map(k => this.redis.hGetAll(k).catch(() => ({}))));
|
|
1270
|
+
for (let j = 0; j < hashScopes.length; j++) {
|
|
1271
|
+
const val = hashResults[j];
|
|
1272
|
+
result[hashScopes[j]] = (val != null && typeof val === 'object') ? val : {};
|
|
1273
|
+
}
|
|
1274
|
+
return result;
|
|
1275
|
+
};
|
|
1276
|
+
hscanr = async (entity, { pattern, COUNT = 200 }, cb) => {
|
|
1277
|
+
const MATCH = this.match(pattern ?? '*');
|
|
1278
|
+
const results = {};
|
|
1279
|
+
let cursor = '0';
|
|
1280
|
+
do {
|
|
1281
|
+
const scanned = await this.redis.hScan(entity, cursor, { COUNT, MATCH });
|
|
1282
|
+
cursor = scanned.cursor;
|
|
1283
|
+
for (let i = 0; i < scanned.entries.length; i++) {
|
|
1284
|
+
const entry = scanned.entries[i];
|
|
1285
|
+
if (this.check(entry.value, pattern ?? '*')) {
|
|
1286
|
+
if (cb)
|
|
1287
|
+
cb(JSON.parse(entry.value), entry.field);
|
|
1288
|
+
else
|
|
1289
|
+
results[entry.field] = JSON.parse(entry.value);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
} while (cursor !== '0');
|
|
1293
|
+
return results;
|
|
1294
|
+
};
|
|
1295
|
+
zscanr = async (entity, { pattern, COUNT = 200 }, cb = (value, score) => ({ value, score })) => {
|
|
1296
|
+
const MATCH = this.match(pattern ?? '*');
|
|
1297
|
+
const results = [];
|
|
1298
|
+
let cursor = '0';
|
|
1299
|
+
do {
|
|
1300
|
+
const scanned = await this.redis.zScan(entity, cursor, { COUNT, MATCH });
|
|
1301
|
+
cursor = scanned.cursor;
|
|
1302
|
+
for (let i = 0; i < scanned.members.length; i++) {
|
|
1303
|
+
const member = scanned.members[i];
|
|
1304
|
+
if (this.check(member.value, pattern ?? '*')) {
|
|
1305
|
+
const data = cb(member.value, String(member.score));
|
|
1306
|
+
if (typeof data !== 'undefined')
|
|
1307
|
+
results.push(data);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
} while (cursor !== '0');
|
|
1311
|
+
return results;
|
|
1312
|
+
};
|
|
1313
|
+
/**
|
|
1314
|
+
* Materialise every key matching `pattern`, across all shards.
|
|
1315
|
+
*
|
|
1316
|
+
* ⚠ This BUILDS A LIST. For anything whose size you do not already know, prefer
|
|
1317
|
+
* {@link TSRedis.scanKeysIterator} — an iterator cannot misreport completeness, because the caller either
|
|
1318
|
+
* drains it or does not. This wrapper exists for bounded, known-small patterns.
|
|
1319
|
+
*
|
|
1320
|
+
* Exceeding `maxKeys` THROWS by default; pass `onLimit: 'truncate'` to opt into a prefix. See
|
|
1321
|
+
* {@link TSRedisScan.onLimit} for why the default is loud.
|
|
1322
|
+
*
|
|
1323
|
+
* And note what this costs even when it succeeds: `SCAN` walks the entire keyspace regardless of how few keys
|
|
1324
|
+
* match, so a sparse pattern over a large instance is expensive. On any latency-sensitive path, do not scan —
|
|
1325
|
+
* address exact keys or maintain an index set (ms repo: cache-invalidation-governance CIG-4).
|
|
1326
|
+
*/
|
|
1327
|
+
scanr = async ({ pattern, COUNT = 200, maxKeys, onLimit = 'throw' }, cb) => {
|
|
1328
|
+
const limit = maxKeys ?? COUNT * 500;
|
|
1329
|
+
const results = [];
|
|
1330
|
+
let seen = 0;
|
|
1331
|
+
// Over-fetch by ONE. Stopping exactly at `limit` is indistinguishable from a match set of exactly `limit`,
|
|
1332
|
+
// so the extra key is what turns "we hit the ceiling" into an observable fact rather than an inference.
|
|
1333
|
+
for await (const key of scanKeysIteratorImpl({
|
|
1334
|
+
pattern: this.match(pattern ?? '*'),
|
|
1335
|
+
maxKeys: limit + 1
|
|
1336
|
+
})) {
|
|
1337
|
+
if (!this.check(key, pattern ?? '*'))
|
|
1338
|
+
continue;
|
|
1339
|
+
seen++;
|
|
1340
|
+
if (seen > limit) {
|
|
1341
|
+
if (onLimit === 'throw') {
|
|
1342
|
+
throw new Error(`TSRedis.scanr: pattern '${this.match(pattern ?? '*')}' matched more than the ${limit}-key ceiling. ` +
|
|
1343
|
+
'Narrow the pattern, raise maxKeys, use scanKeysIterator to stream, or pass onLimit:"truncate" to ' +
|
|
1344
|
+
'accept a prefix deliberately.');
|
|
1345
|
+
}
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
if (cb)
|
|
1349
|
+
cb([key]);
|
|
1350
|
+
else
|
|
1351
|
+
results.push(key);
|
|
1352
|
+
}
|
|
1353
|
+
return results;
|
|
1354
|
+
};
|
|
1355
|
+
static _regexCache = new Map();
|
|
1356
|
+
check = (value, pattern) => {
|
|
1357
|
+
if (typeof pattern !== 'object' || !pattern.text)
|
|
1358
|
+
return true;
|
|
1359
|
+
let re = TSRedis._regexCache.get(pattern.text);
|
|
1360
|
+
if (!re) {
|
|
1361
|
+
re = new RegExp(pattern.text, 'i');
|
|
1362
|
+
TSRedis._regexCache.set(pattern.text, re);
|
|
1363
|
+
}
|
|
1364
|
+
return value.search(re) > -1;
|
|
1365
|
+
};
|
|
1366
|
+
match = (pattern) => (typeof pattern === 'string' ? pattern : (pattern.match ?? '*'));
|
|
1367
|
+
}
|
|
1368
|
+
exports.TSRedis = TSRedis;
|