memcache 0.2.0 → 1.0.0
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/.github/ISSUE_TEMPLATE/bug_report.md +14 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +14 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +6 -0
- package/.github/workflows/code-coverage.yaml +41 -0
- package/.github/workflows/codeql.yaml +75 -0
- package/.github/workflows/release.yaml +41 -0
- package/.github/workflows/tests.yaml +40 -0
- package/.nvmrc +1 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/CONTRIBUTING.md +27 -0
- package/LICENSE +21 -0
- package/README.md +369 -71
- package/SECURITY.md +3 -0
- package/biome.json +35 -0
- package/dist/index.cjs +1502 -0
- package/dist/index.d.cts +501 -0
- package/dist/index.d.ts +501 -0
- package/dist/index.js +1475 -0
- package/docker-compose.yml +24 -0
- package/package.json +38 -17
- package/pnpm-workspace.yaml +2 -0
- package/site/favicon.ico +0 -0
- package/site/logo.ai +7222 -37
- package/site/logo.png +0 -0
- package/site/logo.svg +7 -0
- package/site/logo.webp +0 -0
- package/site/logo_medium.png +0 -0
- package/site/logo_small.png +0 -0
- package/src/index.ts +1130 -0
- package/src/ketama.ts +449 -0
- package/src/node.ts +488 -0
- package/test/index.test.ts +2734 -0
- package/test/ketama.test.ts +526 -0
- package/test/memcache-node-instances.test.ts +102 -0
- package/test/node.test.ts +809 -0
- package/tsconfig.json +29 -0
- package/vitest.config.ts +16 -0
- package/.gitignore +0 -2
- package/Makefile +0 -13
- package/example.js +0 -68
- package/index.js +0 -1
- package/lib/memcache.js +0 -344
- package/test/test-memcache.js +0 -238
package/src/index.ts
ADDED
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
import { Hookified } from "hookified";
|
|
2
|
+
import { KetamaHash } from "./ketama.js";
|
|
3
|
+
import { createNode, MemcacheNode } from "./node.js";
|
|
4
|
+
|
|
5
|
+
export enum MemcacheEvents {
|
|
6
|
+
CONNECT = "connect",
|
|
7
|
+
QUIT = "quit",
|
|
8
|
+
HIT = "hit",
|
|
9
|
+
MISS = "miss",
|
|
10
|
+
ERROR = "error",
|
|
11
|
+
WARN = "warn",
|
|
12
|
+
INFO = "info",
|
|
13
|
+
TIMEOUT = "timeout",
|
|
14
|
+
CLOSE = "close",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HashProvider {
|
|
18
|
+
name: string;
|
|
19
|
+
nodes: Array<MemcacheNode>;
|
|
20
|
+
addNode: (node: MemcacheNode) => void;
|
|
21
|
+
removeNode: (id: string) => void;
|
|
22
|
+
getNode: (id: string) => MemcacheNode | undefined;
|
|
23
|
+
getNodesByKey: (key: string) => Array<MemcacheNode>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MemcacheOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Array of node URIs or MemcacheNode instances to add to the consistent hashing ring.
|
|
29
|
+
* Examples: ["localhost:11211", "memcache://192.168.1.100:11212", "server3:11213"]
|
|
30
|
+
* Can also pass MemcacheNode instances directly: [createNode("localhost", 11211), createNode("server2", 11211)]
|
|
31
|
+
*/
|
|
32
|
+
nodes?: (string | MemcacheNode)[];
|
|
33
|
+
/**
|
|
34
|
+
* The timeout for Memcache operations.
|
|
35
|
+
* @default 5000
|
|
36
|
+
*/
|
|
37
|
+
timeout?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Whether to keep the connection alive.
|
|
40
|
+
* @default true
|
|
41
|
+
*/
|
|
42
|
+
keepAlive?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The delay before the connection is kept alive.
|
|
45
|
+
* @default 1000
|
|
46
|
+
*/
|
|
47
|
+
keepAliveDelay?: number;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The hash provider used to determine the distribution on each item is placed based
|
|
51
|
+
* on the number of nodes and hashing. By default it uses KetamaHash as the provider
|
|
52
|
+
*/
|
|
53
|
+
hash?: HashProvider;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MemcacheStats {
|
|
57
|
+
[key: string]: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class Memcache extends Hookified {
|
|
61
|
+
private _nodes: Array<MemcacheNode> = [];
|
|
62
|
+
private _timeout: number;
|
|
63
|
+
private _keepAlive: boolean;
|
|
64
|
+
private _keepAliveDelay: number;
|
|
65
|
+
private _hash: HashProvider;
|
|
66
|
+
|
|
67
|
+
constructor(options?: MemcacheOptions) {
|
|
68
|
+
super();
|
|
69
|
+
|
|
70
|
+
this._hash = new KetamaHash();
|
|
71
|
+
|
|
72
|
+
this._timeout = options?.timeout || 5000;
|
|
73
|
+
this._keepAlive = options?.keepAlive !== false;
|
|
74
|
+
this._keepAliveDelay = options?.keepAliveDelay || 1000;
|
|
75
|
+
|
|
76
|
+
// Add nodes if provided, otherwise add default node
|
|
77
|
+
const nodeUris = options?.nodes || ["localhost:11211"];
|
|
78
|
+
for (const nodeUri of nodeUris) {
|
|
79
|
+
this.addNode(nodeUri);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the list of nodes
|
|
85
|
+
* @returns {MemcacheNode[]} Array of MemcacheNode
|
|
86
|
+
*/
|
|
87
|
+
public get nodes(): MemcacheNode[] {
|
|
88
|
+
return this._nodes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the list of node IDs (e.g., ["localhost:11211", "127.0.0.1:11212"])
|
|
93
|
+
* @returns {string[]} Array of node ID strings
|
|
94
|
+
*/
|
|
95
|
+
public get nodeIds(): string[] {
|
|
96
|
+
return this._nodes.map((node) => node.id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the hash provider used for consistent hashing distribution.
|
|
101
|
+
* @returns {HashProvider} The current hash provider instance
|
|
102
|
+
* @default KetamaHash
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```typescript
|
|
106
|
+
* const client = new Memcache();
|
|
107
|
+
* const hashProvider = client.hash;
|
|
108
|
+
* console.log(hashProvider.name); // "ketama"
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
public get hash(): HashProvider {
|
|
112
|
+
return this._hash;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Set the hash provider used for consistent hashing distribution.
|
|
117
|
+
* This allows you to customize the hashing strategy for distributing keys across nodes.
|
|
118
|
+
* @param {HashProvider} hash - The hash provider instance to use
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```typescript
|
|
122
|
+
* const client = new Memcache();
|
|
123
|
+
* const customHashProvider = new KetamaHash();
|
|
124
|
+
* client.hash = customHashProvider;
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
public set hash(hash: HashProvider) {
|
|
128
|
+
this._hash = hash;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get the timeout for Memcache operations.
|
|
133
|
+
* @returns {number}
|
|
134
|
+
* @default 5000
|
|
135
|
+
*/
|
|
136
|
+
public get timeout(): number {
|
|
137
|
+
return this._timeout;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Set the timeout for Memcache operations.
|
|
142
|
+
* @param {number} value
|
|
143
|
+
* @default 5000
|
|
144
|
+
*/
|
|
145
|
+
public set timeout(value: number) {
|
|
146
|
+
this._timeout = value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get the keepAlive setting for the Memcache connection.
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
* @default true
|
|
153
|
+
*/
|
|
154
|
+
public get keepAlive(): boolean {
|
|
155
|
+
return this._keepAlive;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Set the keepAlive setting for the Memcache connection.
|
|
160
|
+
* Updates all existing nodes with the new value.
|
|
161
|
+
* Note: To apply the new value, you need to call reconnect() on the nodes.
|
|
162
|
+
* @param {boolean} value
|
|
163
|
+
* @default true
|
|
164
|
+
*/
|
|
165
|
+
public set keepAlive(value: boolean) {
|
|
166
|
+
this._keepAlive = value;
|
|
167
|
+
// Update all existing nodes
|
|
168
|
+
this.updateNodes();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the delay before the connection is kept alive.
|
|
173
|
+
* @returns {number}
|
|
174
|
+
* @default 1000
|
|
175
|
+
*/
|
|
176
|
+
public get keepAliveDelay(): number {
|
|
177
|
+
return this._keepAliveDelay;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Set the delay before the connection is kept alive.
|
|
182
|
+
* Updates all existing nodes with the new value.
|
|
183
|
+
* Note: To apply the new value, you need to call reconnect() on the nodes.
|
|
184
|
+
* @param {number} value
|
|
185
|
+
* @default 1000
|
|
186
|
+
*/
|
|
187
|
+
public set keepAliveDelay(value: number) {
|
|
188
|
+
this._keepAliveDelay = value;
|
|
189
|
+
// Update all existing nodes
|
|
190
|
+
this.updateNodes();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get an array of all MemcacheNode instances
|
|
195
|
+
* @returns {MemcacheNode[]}
|
|
196
|
+
*/
|
|
197
|
+
public getNodes(): MemcacheNode[] {
|
|
198
|
+
return [...this._nodes];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get a specific node by its ID
|
|
203
|
+
* @param {string} id - The node ID (e.g., "localhost:11211")
|
|
204
|
+
* @returns {MemcacheNode | undefined}
|
|
205
|
+
*/
|
|
206
|
+
public getNode(id: string): MemcacheNode | undefined {
|
|
207
|
+
return this._nodes.find((n) => n.id === id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Add a new node to the cluster
|
|
212
|
+
* @param {string | MemcacheNode} uri - Node URI (e.g., "localhost:11212") or a MemcacheNode instance
|
|
213
|
+
* @param {number} weight - Optional weight for consistent hashing (only used for string URIs)
|
|
214
|
+
*/
|
|
215
|
+
public async addNode(
|
|
216
|
+
uri: string | MemcacheNode,
|
|
217
|
+
weight?: number,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
let node: MemcacheNode;
|
|
220
|
+
let nodeKey: string;
|
|
221
|
+
|
|
222
|
+
if (typeof uri === "string") {
|
|
223
|
+
// Handle string URI
|
|
224
|
+
const { host, port } = this.parseUri(uri);
|
|
225
|
+
nodeKey = port === 0 ? host : `${host}:${port}`;
|
|
226
|
+
|
|
227
|
+
if (this._nodes.some((n) => n.id === nodeKey)) {
|
|
228
|
+
throw new Error(`Node ${nodeKey} already exists`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Create and connect node
|
|
232
|
+
node = new MemcacheNode(host, port, {
|
|
233
|
+
timeout: this._timeout,
|
|
234
|
+
keepAlive: this._keepAlive,
|
|
235
|
+
keepAliveDelay: this._keepAliveDelay,
|
|
236
|
+
weight,
|
|
237
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
// Handle MemcacheNode instance
|
|
240
|
+
node = uri;
|
|
241
|
+
nodeKey = node.id;
|
|
242
|
+
|
|
243
|
+
if (this._nodes.some((n) => n.id === nodeKey)) {
|
|
244
|
+
throw new Error(`Node ${nodeKey} already exists`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.forwardNodeEvents(node);
|
|
249
|
+
this._nodes.push(node);
|
|
250
|
+
|
|
251
|
+
this._hash.addNode(node);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Remove a node from the cluster
|
|
256
|
+
* @param {string} uri - Node URI (e.g., "localhost:11212")
|
|
257
|
+
*/
|
|
258
|
+
public async removeNode(uri: string): Promise<void> {
|
|
259
|
+
const { host, port } = this.parseUri(uri);
|
|
260
|
+
const nodeKey = port === 0 ? host : `${host}:${port}`;
|
|
261
|
+
|
|
262
|
+
const node = this._nodes.find((n) => n.id === nodeKey);
|
|
263
|
+
if (!node) return;
|
|
264
|
+
|
|
265
|
+
// Disconnect and remove
|
|
266
|
+
await node.disconnect();
|
|
267
|
+
this._nodes = this._nodes.filter((n) => n.id !== nodeKey);
|
|
268
|
+
this._hash.removeNode(node.id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Parse a URI string into host and port.
|
|
273
|
+
* Supports multiple formats:
|
|
274
|
+
* - Simple: "localhost:11211" or "localhost"
|
|
275
|
+
* - Protocol: "memcache://localhost:11211", "memcached://localhost:11211", "tcp://localhost:11211"
|
|
276
|
+
* - IPv6: "[::1]:11211" or "memcache://[2001:db8::1]:11212"
|
|
277
|
+
* - Unix socket: "/var/run/memcached.sock" or "unix:///var/run/memcached.sock"
|
|
278
|
+
*
|
|
279
|
+
* @param {string} uri - URI string
|
|
280
|
+
* @returns {{ host: string; port: number }} Object containing host and port (port is 0 for Unix sockets)
|
|
281
|
+
* @throws {Error} If URI format is invalid
|
|
282
|
+
*/
|
|
283
|
+
public parseUri(uri: string): { host: string; port: number } {
|
|
284
|
+
// Handle Unix domain sockets
|
|
285
|
+
if (uri.startsWith("unix://")) {
|
|
286
|
+
return { host: uri.slice(7), port: 0 };
|
|
287
|
+
}
|
|
288
|
+
if (uri.startsWith("/")) {
|
|
289
|
+
return { host: uri, port: 0 };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Remove protocol if present
|
|
293
|
+
let cleanUri = uri;
|
|
294
|
+
if (uri.includes("://")) {
|
|
295
|
+
const protocolParts = uri.split("://");
|
|
296
|
+
const protocol = protocolParts[0];
|
|
297
|
+
if (!["memcache", "memcached", "tcp"].includes(protocol)) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`Invalid protocol '${protocol}'. Supported protocols: memcache://, memcached://, tcp://, unix://`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
cleanUri = protocolParts[1];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Handle IPv6 addresses with brackets [::1]:11211
|
|
306
|
+
if (cleanUri.startsWith("[")) {
|
|
307
|
+
const bracketEnd = cleanUri.indexOf("]");
|
|
308
|
+
if (bracketEnd === -1) {
|
|
309
|
+
throw new Error("Invalid IPv6 format: missing closing bracket");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const host = cleanUri.slice(1, bracketEnd);
|
|
313
|
+
if (!host) {
|
|
314
|
+
throw new Error("Invalid URI format: host is required");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Check if there's a port after the bracket
|
|
318
|
+
const remainder = cleanUri.slice(bracketEnd + 1);
|
|
319
|
+
if (remainder === "") {
|
|
320
|
+
return { host, port: 11211 };
|
|
321
|
+
}
|
|
322
|
+
if (!remainder.startsWith(":")) {
|
|
323
|
+
throw new Error("Invalid IPv6 format: expected ':' after bracket");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const portStr = remainder.slice(1);
|
|
327
|
+
const port = Number.parseInt(portStr, 10);
|
|
328
|
+
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
|
329
|
+
throw new Error("Invalid port number");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { host, port };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Parse host and port for regular format
|
|
336
|
+
const parts = cleanUri.split(":");
|
|
337
|
+
if (parts.length === 0 || parts.length > 2) {
|
|
338
|
+
throw new Error("Invalid URI format");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const host = parts[0];
|
|
342
|
+
if (!host) {
|
|
343
|
+
throw new Error("Invalid URI format: host is required");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const port = parts.length === 2 ? Number.parseInt(parts[1], 10) : 11211;
|
|
347
|
+
if (Number.isNaN(port) || port < 0 || port > 65535) {
|
|
348
|
+
throw new Error("Invalid port number");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Port 0 is only valid for Unix sockets (already handled above)
|
|
352
|
+
if (port === 0) {
|
|
353
|
+
throw new Error("Invalid port number");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { host, port };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Connect to all Memcache servers or a specific node.
|
|
361
|
+
* @param {string} nodeId - Optional node ID to connect to (e.g., "localhost:11211")
|
|
362
|
+
* @returns {Promise<void>}
|
|
363
|
+
*/
|
|
364
|
+
public async connect(nodeId?: string): Promise<void> {
|
|
365
|
+
if (nodeId) {
|
|
366
|
+
const node = this._nodes.find((n) => n.id === nodeId);
|
|
367
|
+
/* v8 ignore next -- @preserve */
|
|
368
|
+
if (!node) throw new Error(`Node ${nodeId} not found`);
|
|
369
|
+
/* v8 ignore next -- @preserve */
|
|
370
|
+
await node.connect();
|
|
371
|
+
/* v8 ignore next -- @preserve */
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Connect to all nodes
|
|
376
|
+
await Promise.all(this._nodes.map((node) => node.connect()));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Get a value from the Memcache server.
|
|
381
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
382
|
+
* queries all nodes and returns the first successful result.
|
|
383
|
+
* @param {string} key
|
|
384
|
+
* @returns {Promise<string | undefined>}
|
|
385
|
+
*/
|
|
386
|
+
public async get(key: string): Promise<string | undefined> {
|
|
387
|
+
await this.beforeHook("get", { key });
|
|
388
|
+
|
|
389
|
+
this.validateKey(key);
|
|
390
|
+
|
|
391
|
+
const nodes = await this.getNodesByKey(key);
|
|
392
|
+
|
|
393
|
+
// Query all nodes (supports replication strategies)
|
|
394
|
+
const promises = nodes.map(async (node) => {
|
|
395
|
+
try {
|
|
396
|
+
const result = await node.command(`get ${key}`, {
|
|
397
|
+
isMultiline: true,
|
|
398
|
+
requestedKeys: [key],
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
if (result?.values && result.values.length > 0) {
|
|
402
|
+
return result.values[0];
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
} catch {
|
|
406
|
+
// If one node fails, try the others
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Wait for all nodes to respond
|
|
412
|
+
const results = await Promise.all(promises);
|
|
413
|
+
|
|
414
|
+
// Return the first successful result
|
|
415
|
+
const value = results.find((v) => v !== undefined);
|
|
416
|
+
|
|
417
|
+
await this.afterHook("get", { key, value });
|
|
418
|
+
|
|
419
|
+
return value;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Get multiple values from the Memcache server.
|
|
424
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
425
|
+
* queries all replica nodes and returns the first successful result for each key.
|
|
426
|
+
* @param keys {string[]}
|
|
427
|
+
* @returns {Promise<Map<string, string>>}
|
|
428
|
+
*/
|
|
429
|
+
public async gets(keys: string[]): Promise<Map<string, string>> {
|
|
430
|
+
await this.beforeHook("gets", { keys });
|
|
431
|
+
|
|
432
|
+
// Validate all keys
|
|
433
|
+
for (const key of keys) {
|
|
434
|
+
this.validateKey(key);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Group keys by all their replica nodes
|
|
438
|
+
const keysByNode = new Map<MemcacheNode, string[]>();
|
|
439
|
+
|
|
440
|
+
for (const key of keys) {
|
|
441
|
+
const nodes = this._hash.getNodesByKey(key);
|
|
442
|
+
if (nodes.length === 0) {
|
|
443
|
+
/* v8 ignore next -- @preserve */
|
|
444
|
+
throw new Error(`No node available for key: ${key}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Add key to all replica nodes
|
|
448
|
+
for (const node of nodes) {
|
|
449
|
+
if (!keysByNode.has(node)) {
|
|
450
|
+
keysByNode.set(node, []);
|
|
451
|
+
}
|
|
452
|
+
// biome-ignore lint/style/noNonNullAssertion: we just set it
|
|
453
|
+
keysByNode.get(node)!.push(key);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Execute commands in parallel across all nodes (including replicas)
|
|
458
|
+
const promises = Array.from(keysByNode.entries()).map(
|
|
459
|
+
async ([node, nodeKeys]) => {
|
|
460
|
+
try {
|
|
461
|
+
if (!node.isConnected()) await node.connect();
|
|
462
|
+
|
|
463
|
+
const keysStr = nodeKeys.join(" ");
|
|
464
|
+
const result = await node.command(`get ${keysStr}`, {
|
|
465
|
+
isMultiline: true,
|
|
466
|
+
requestedKeys: nodeKeys,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return result;
|
|
470
|
+
} catch {
|
|
471
|
+
// If one node fails, continue with others
|
|
472
|
+
/* v8 ignore next -- @preserve */
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const results = await Promise.all(promises);
|
|
479
|
+
|
|
480
|
+
// Merge results into Map (first successful value for each key wins)
|
|
481
|
+
const map = new Map<string, string>();
|
|
482
|
+
|
|
483
|
+
for (const result of results) {
|
|
484
|
+
if (result?.foundKeys && result.values) {
|
|
485
|
+
// Map found keys to their values
|
|
486
|
+
for (let i = 0; i < result.foundKeys.length; i++) {
|
|
487
|
+
if (result.values[i] !== undefined) {
|
|
488
|
+
// Only set if key doesn't exist yet (first successful result wins)
|
|
489
|
+
if (!map.has(result.foundKeys[i])) {
|
|
490
|
+
map.set(result.foundKeys[i], result.values[i]);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
await this.afterHook("gets", { keys, values: map });
|
|
498
|
+
|
|
499
|
+
return map;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Check-And-Set: Store a value only if it hasn't been modified since last fetch.
|
|
504
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
505
|
+
* executes on all nodes and returns true only if all succeed.
|
|
506
|
+
* @param key {string}
|
|
507
|
+
* @param value {string}
|
|
508
|
+
* @param casToken {string}
|
|
509
|
+
* @param exptime {number}
|
|
510
|
+
* @param flags {number}
|
|
511
|
+
* @returns {Promise<boolean>}
|
|
512
|
+
*/
|
|
513
|
+
public async cas(
|
|
514
|
+
key: string,
|
|
515
|
+
value: string,
|
|
516
|
+
casToken: string,
|
|
517
|
+
exptime: number = 0,
|
|
518
|
+
flags: number = 0,
|
|
519
|
+
): Promise<boolean> {
|
|
520
|
+
await this.beforeHook("cas", { key, value, casToken, exptime, flags });
|
|
521
|
+
|
|
522
|
+
this.validateKey(key);
|
|
523
|
+
const valueStr = String(value);
|
|
524
|
+
const bytes = Buffer.byteLength(valueStr);
|
|
525
|
+
const command = `cas ${key} ${flags} ${exptime} ${bytes} ${casToken}\r\n${valueStr}`;
|
|
526
|
+
|
|
527
|
+
const nodes = await this.getNodesByKey(key);
|
|
528
|
+
|
|
529
|
+
// Execute CAS on all replica nodes
|
|
530
|
+
const promises = nodes.map(async (node) => {
|
|
531
|
+
try {
|
|
532
|
+
const result = await node.command(command);
|
|
533
|
+
return result === "STORED";
|
|
534
|
+
} catch {
|
|
535
|
+
// If one node fails, the entire operation fails
|
|
536
|
+
/* v8 ignore next -- @preserve */
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const results = await Promise.all(promises);
|
|
542
|
+
|
|
543
|
+
// All nodes must succeed for CAS to be considered successful
|
|
544
|
+
const success = results.every((result) => result === true);
|
|
545
|
+
|
|
546
|
+
await this.afterHook("cas", {
|
|
547
|
+
key,
|
|
548
|
+
value,
|
|
549
|
+
casToken,
|
|
550
|
+
exptime,
|
|
551
|
+
flags,
|
|
552
|
+
success,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
return success;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Set a value in the Memcache server.
|
|
560
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
561
|
+
* executes on all nodes and returns true only if all succeed.
|
|
562
|
+
* @param key {string}
|
|
563
|
+
* @param value {string}
|
|
564
|
+
* @param exptime {number}
|
|
565
|
+
* @param flags {number}
|
|
566
|
+
* @returns {Promise<boolean>}
|
|
567
|
+
*/
|
|
568
|
+
public async set(
|
|
569
|
+
key: string,
|
|
570
|
+
value: string,
|
|
571
|
+
exptime: number = 0,
|
|
572
|
+
flags: number = 0,
|
|
573
|
+
): Promise<boolean> {
|
|
574
|
+
await this.beforeHook("set", { key, value, exptime, flags });
|
|
575
|
+
|
|
576
|
+
this.validateKey(key);
|
|
577
|
+
const valueStr = String(value);
|
|
578
|
+
const bytes = Buffer.byteLength(valueStr);
|
|
579
|
+
const command = `set ${key} ${flags} ${exptime} ${bytes}\r\n${valueStr}`;
|
|
580
|
+
|
|
581
|
+
const nodes = await this.getNodesByKey(key);
|
|
582
|
+
|
|
583
|
+
// Execute SET on all replica nodes
|
|
584
|
+
const promises = nodes.map(async (node) => {
|
|
585
|
+
try {
|
|
586
|
+
const result = await node.command(command);
|
|
587
|
+
return result === "STORED";
|
|
588
|
+
} catch {
|
|
589
|
+
// If one node fails, the entire operation fails
|
|
590
|
+
/* v8 ignore next -- @preserve */
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const results = await Promise.all(promises);
|
|
596
|
+
|
|
597
|
+
// All nodes must succeed for SET to be considered successful
|
|
598
|
+
const success = results.every((result) => result === true);
|
|
599
|
+
|
|
600
|
+
await this.afterHook("set", { key, value, exptime, flags, success });
|
|
601
|
+
|
|
602
|
+
return success;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Add a value to the Memcache server (only if key doesn't exist).
|
|
607
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
608
|
+
* executes on all nodes and returns true only if all succeed.
|
|
609
|
+
* @param key {string}
|
|
610
|
+
* @param value {string}
|
|
611
|
+
* @param exptime {number}
|
|
612
|
+
* @param flags {number}
|
|
613
|
+
* @returns {Promise<boolean>}
|
|
614
|
+
*/
|
|
615
|
+
public async add(
|
|
616
|
+
key: string,
|
|
617
|
+
value: string,
|
|
618
|
+
exptime: number = 0,
|
|
619
|
+
flags: number = 0,
|
|
620
|
+
): Promise<boolean> {
|
|
621
|
+
await this.beforeHook("add", { key, value, exptime, flags });
|
|
622
|
+
|
|
623
|
+
this.validateKey(key);
|
|
624
|
+
const valueStr = String(value);
|
|
625
|
+
const bytes = Buffer.byteLength(valueStr);
|
|
626
|
+
const command = `add ${key} ${flags} ${exptime} ${bytes}\r\n${valueStr}`;
|
|
627
|
+
|
|
628
|
+
const nodes = await this.getNodesByKey(key);
|
|
629
|
+
|
|
630
|
+
// Execute ADD on all replica nodes
|
|
631
|
+
const promises = nodes.map(async (node) => {
|
|
632
|
+
try {
|
|
633
|
+
const result = await node.command(command);
|
|
634
|
+
return result === "STORED";
|
|
635
|
+
} catch {
|
|
636
|
+
// If one node fails, the entire operation fails
|
|
637
|
+
/* v8 ignore next -- @preserve */
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const results = await Promise.all(promises);
|
|
643
|
+
|
|
644
|
+
// All nodes must succeed for ADD to be considered successful
|
|
645
|
+
const success = results.every((result) => result === true);
|
|
646
|
+
|
|
647
|
+
await this.afterHook("add", { key, value, exptime, flags, success });
|
|
648
|
+
|
|
649
|
+
return success;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Replace a value in the Memcache server (only if key exists).
|
|
654
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
655
|
+
* executes on all nodes and returns true only if all succeed.
|
|
656
|
+
* @param key {string}
|
|
657
|
+
* @param value {string}
|
|
658
|
+
* @param exptime {number}
|
|
659
|
+
* @param flags {number}
|
|
660
|
+
* @returns {Promise<boolean>}
|
|
661
|
+
*/
|
|
662
|
+
public async replace(
|
|
663
|
+
key: string,
|
|
664
|
+
value: string,
|
|
665
|
+
exptime: number = 0,
|
|
666
|
+
flags: number = 0,
|
|
667
|
+
): Promise<boolean> {
|
|
668
|
+
await this.beforeHook("replace", { key, value, exptime, flags });
|
|
669
|
+
|
|
670
|
+
this.validateKey(key);
|
|
671
|
+
const valueStr = String(value);
|
|
672
|
+
const bytes = Buffer.byteLength(valueStr);
|
|
673
|
+
const command = `replace ${key} ${flags} ${exptime} ${bytes}\r\n${valueStr}`;
|
|
674
|
+
|
|
675
|
+
const nodes = await this.getNodesByKey(key);
|
|
676
|
+
|
|
677
|
+
// Execute REPLACE on all replica nodes
|
|
678
|
+
const promises = nodes.map(async (node) => {
|
|
679
|
+
try {
|
|
680
|
+
const result = await node.command(command);
|
|
681
|
+
return result === "STORED";
|
|
682
|
+
} catch {
|
|
683
|
+
// If one node fails, the entire operation fails
|
|
684
|
+
/* v8 ignore next -- @preserve */
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const results = await Promise.all(promises);
|
|
690
|
+
|
|
691
|
+
// All nodes must succeed for REPLACE to be considered successful
|
|
692
|
+
const success = results.every((result) => result === true);
|
|
693
|
+
|
|
694
|
+
await this.afterHook("replace", { key, value, exptime, flags, success });
|
|
695
|
+
|
|
696
|
+
return success;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Append a value to an existing key in the Memcache server.
|
|
701
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
702
|
+
* executes on all nodes and returns true only if all succeed.
|
|
703
|
+
* @param key {string}
|
|
704
|
+
* @param value {string}
|
|
705
|
+
* @returns {Promise<boolean>}
|
|
706
|
+
*/
|
|
707
|
+
public async append(key: string, value: string): Promise<boolean> {
|
|
708
|
+
await this.beforeHook("append", { key, value });
|
|
709
|
+
|
|
710
|
+
this.validateKey(key);
|
|
711
|
+
const valueStr = String(value);
|
|
712
|
+
const bytes = Buffer.byteLength(valueStr);
|
|
713
|
+
const command = `append ${key} 0 0 ${bytes}\r\n${valueStr}`;
|
|
714
|
+
|
|
715
|
+
const nodes = await this.getNodesByKey(key);
|
|
716
|
+
|
|
717
|
+
// Execute APPEND on all replica nodes
|
|
718
|
+
const promises = nodes.map(async (node) => {
|
|
719
|
+
try {
|
|
720
|
+
const result = await node.command(command);
|
|
721
|
+
return result === "STORED";
|
|
722
|
+
} catch {
|
|
723
|
+
// If one node fails, the entire operation fails
|
|
724
|
+
/* v8 ignore next -- @preserve */
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const results = await Promise.all(promises);
|
|
730
|
+
|
|
731
|
+
// All nodes must succeed for APPEND to be considered successful
|
|
732
|
+
const success = results.every((result) => result === true);
|
|
733
|
+
|
|
734
|
+
await this.afterHook("append", { key, value, success });
|
|
735
|
+
|
|
736
|
+
return success;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Prepend a value to an existing key in the Memcache server.
|
|
741
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
742
|
+
* executes on all nodes and returns true only if all succeed.
|
|
743
|
+
* @param key {string}
|
|
744
|
+
* @param value {string}
|
|
745
|
+
* @returns {Promise<boolean>}
|
|
746
|
+
*/
|
|
747
|
+
public async prepend(key: string, value: string): Promise<boolean> {
|
|
748
|
+
await this.beforeHook("prepend", { key, value });
|
|
749
|
+
|
|
750
|
+
this.validateKey(key);
|
|
751
|
+
const valueStr = String(value);
|
|
752
|
+
const bytes = Buffer.byteLength(valueStr);
|
|
753
|
+
const command = `prepend ${key} 0 0 ${bytes}\r\n${valueStr}`;
|
|
754
|
+
|
|
755
|
+
const nodes = await this.getNodesByKey(key);
|
|
756
|
+
|
|
757
|
+
// Execute PREPEND on all replica nodes
|
|
758
|
+
const promises = nodes.map(async (node) => {
|
|
759
|
+
try {
|
|
760
|
+
const result = await node.command(command);
|
|
761
|
+
return result === "STORED";
|
|
762
|
+
} catch {
|
|
763
|
+
// If one node fails, the entire operation fails
|
|
764
|
+
/* v8 ignore next -- @preserve */
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const results = await Promise.all(promises);
|
|
770
|
+
|
|
771
|
+
// All nodes must succeed for PREPEND to be considered successful
|
|
772
|
+
const success = results.every((result) => result === true);
|
|
773
|
+
|
|
774
|
+
await this.afterHook("prepend", { key, value, success });
|
|
775
|
+
|
|
776
|
+
return success;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Delete a value from the Memcache server.
|
|
781
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
782
|
+
* executes on all nodes and returns true only if all succeed.
|
|
783
|
+
* @param key {string}
|
|
784
|
+
* @returns {Promise<boolean>}
|
|
785
|
+
*/
|
|
786
|
+
public async delete(key: string): Promise<boolean> {
|
|
787
|
+
await this.beforeHook("delete", { key });
|
|
788
|
+
|
|
789
|
+
this.validateKey(key);
|
|
790
|
+
|
|
791
|
+
const nodes = await this.getNodesByKey(key);
|
|
792
|
+
|
|
793
|
+
// Execute DELETE on all replica nodes
|
|
794
|
+
const promises = nodes.map(async (node) => {
|
|
795
|
+
try {
|
|
796
|
+
const result = await node.command(`delete ${key}`);
|
|
797
|
+
return result === "DELETED";
|
|
798
|
+
} catch {
|
|
799
|
+
// If one node fails, the entire operation fails
|
|
800
|
+
/* v8 ignore next -- @preserve */
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
const results = await Promise.all(promises);
|
|
806
|
+
|
|
807
|
+
// All nodes must succeed for DELETE to be considered successful
|
|
808
|
+
const success = results.every((result) => result === true);
|
|
809
|
+
|
|
810
|
+
await this.afterHook("delete", { key, success });
|
|
811
|
+
|
|
812
|
+
return success;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Increment a value in the Memcache server.
|
|
817
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
818
|
+
* executes on all nodes and returns the first successful result.
|
|
819
|
+
* @param key {string}
|
|
820
|
+
* @param value {number}
|
|
821
|
+
* @returns {Promise<number | undefined>}
|
|
822
|
+
*/
|
|
823
|
+
public async incr(
|
|
824
|
+
key: string,
|
|
825
|
+
value: number = 1,
|
|
826
|
+
): Promise<number | undefined> {
|
|
827
|
+
await this.beforeHook("incr", { key, value });
|
|
828
|
+
|
|
829
|
+
this.validateKey(key);
|
|
830
|
+
|
|
831
|
+
const nodes = await this.getNodesByKey(key);
|
|
832
|
+
|
|
833
|
+
// Execute INCR on all replica nodes
|
|
834
|
+
const promises = nodes.map(async (node) => {
|
|
835
|
+
try {
|
|
836
|
+
const result = await node.command(`incr ${key} ${value}`);
|
|
837
|
+
return typeof result === "number" ? result : undefined;
|
|
838
|
+
} catch {
|
|
839
|
+
// If one node fails, continue with others
|
|
840
|
+
/* v8 ignore next -- @preserve */
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const results = await Promise.all(promises);
|
|
846
|
+
|
|
847
|
+
// Return the first successful result
|
|
848
|
+
const newValue = results.find((v) => v !== undefined);
|
|
849
|
+
|
|
850
|
+
await this.afterHook("incr", { key, value, newValue });
|
|
851
|
+
|
|
852
|
+
return newValue;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Decrement a value in the Memcache server.
|
|
857
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
858
|
+
* executes on all nodes and returns the first successful result.
|
|
859
|
+
* @param key {string}
|
|
860
|
+
* @param value {number}
|
|
861
|
+
* @returns {Promise<number | undefined>}
|
|
862
|
+
*/
|
|
863
|
+
public async decr(
|
|
864
|
+
key: string,
|
|
865
|
+
value: number = 1,
|
|
866
|
+
): Promise<number | undefined> {
|
|
867
|
+
await this.beforeHook("decr", { key, value });
|
|
868
|
+
|
|
869
|
+
this.validateKey(key);
|
|
870
|
+
|
|
871
|
+
const nodes = await this.getNodesByKey(key);
|
|
872
|
+
|
|
873
|
+
// Execute DECR on all replica nodes
|
|
874
|
+
const promises = nodes.map(async (node) => {
|
|
875
|
+
try {
|
|
876
|
+
const result = await node.command(`decr ${key} ${value}`);
|
|
877
|
+
return typeof result === "number" ? result : undefined;
|
|
878
|
+
} catch {
|
|
879
|
+
// If one node fails, continue with others
|
|
880
|
+
/* v8 ignore next -- @preserve */
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const results = await Promise.all(promises);
|
|
886
|
+
|
|
887
|
+
// Return the first successful result
|
|
888
|
+
const newValue = results.find((v) => v !== undefined);
|
|
889
|
+
|
|
890
|
+
await this.afterHook("decr", { key, value, newValue });
|
|
891
|
+
|
|
892
|
+
return newValue;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Touch a value in the Memcache server (update expiration time).
|
|
897
|
+
* When multiple nodes are returned by the hash provider (for replication),
|
|
898
|
+
* executes on all nodes and returns true only if all succeed.
|
|
899
|
+
* @param key {string}
|
|
900
|
+
* @param exptime {number}
|
|
901
|
+
* @returns {Promise<boolean>}
|
|
902
|
+
*/
|
|
903
|
+
public async touch(key: string, exptime: number): Promise<boolean> {
|
|
904
|
+
await this.beforeHook("touch", { key, exptime });
|
|
905
|
+
|
|
906
|
+
this.validateKey(key);
|
|
907
|
+
|
|
908
|
+
const nodes = await this.getNodesByKey(key);
|
|
909
|
+
|
|
910
|
+
// Execute TOUCH on all replica nodes
|
|
911
|
+
const promises = nodes.map(async (node) => {
|
|
912
|
+
try {
|
|
913
|
+
const result = await node.command(`touch ${key} ${exptime}`);
|
|
914
|
+
return result === "TOUCHED";
|
|
915
|
+
} catch {
|
|
916
|
+
// If one node fails, the entire operation fails
|
|
917
|
+
/* v8 ignore next -- @preserve */
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
const results = await Promise.all(promises);
|
|
923
|
+
|
|
924
|
+
// All nodes must succeed for TOUCH to be considered successful
|
|
925
|
+
const success = results.every((result) => result === true);
|
|
926
|
+
|
|
927
|
+
await this.afterHook("touch", { key, exptime, success });
|
|
928
|
+
|
|
929
|
+
return success;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Flush all values from all Memcache servers.
|
|
934
|
+
* @param delay {number}
|
|
935
|
+
* @returns {Promise<boolean>}
|
|
936
|
+
*/
|
|
937
|
+
public async flush(delay?: number): Promise<boolean> {
|
|
938
|
+
let command = "flush_all";
|
|
939
|
+
|
|
940
|
+
// If a delay is specified, use the delayed flush command
|
|
941
|
+
if (delay !== undefined) {
|
|
942
|
+
command += ` ${delay}`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Execute on ALL nodes
|
|
946
|
+
const results = await Promise.all(
|
|
947
|
+
this._nodes.map(async (node) => {
|
|
948
|
+
/* v8 ignore next -- @preserve */
|
|
949
|
+
if (!node.isConnected()) {
|
|
950
|
+
await node.connect();
|
|
951
|
+
}
|
|
952
|
+
return node.command(command);
|
|
953
|
+
}),
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
// All must return OK
|
|
957
|
+
return results.every((r) => r === "OK");
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Get statistics from all Memcache servers.
|
|
962
|
+
* @param type {string}
|
|
963
|
+
* @returns {Promise<Map<string, MemcacheStats>>}
|
|
964
|
+
*/
|
|
965
|
+
public async stats(type?: string): Promise<Map<string, MemcacheStats>> {
|
|
966
|
+
const command = type ? `stats ${type}` : "stats";
|
|
967
|
+
|
|
968
|
+
// Get stats from ALL nodes
|
|
969
|
+
const results = new Map<string, MemcacheStats>();
|
|
970
|
+
|
|
971
|
+
await Promise.all(
|
|
972
|
+
/* v8 ignore next -- @preserve */
|
|
973
|
+
this._nodes.map(async (node) => {
|
|
974
|
+
if (!node.isConnected()) {
|
|
975
|
+
await node.connect();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const stats = await node.command(command, { isStats: true });
|
|
979
|
+
results.set(node.id, stats as MemcacheStats);
|
|
980
|
+
}),
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
return results;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Get the Memcache server version from all nodes.
|
|
988
|
+
* @returns {Promise<Map<string, string>>} Map of node IDs to version strings
|
|
989
|
+
*/
|
|
990
|
+
public async version(): Promise<Map<string, string>> {
|
|
991
|
+
// Get version from all nodes
|
|
992
|
+
const results = new Map<string, string>();
|
|
993
|
+
|
|
994
|
+
await Promise.all(
|
|
995
|
+
/* v8 ignore next -- @preserve */
|
|
996
|
+
this._nodes.map(async (node) => {
|
|
997
|
+
if (!node.isConnected()) {
|
|
998
|
+
await node.connect();
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const version = await node.command("version");
|
|
1002
|
+
results.set(node.id, version);
|
|
1003
|
+
}),
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
return results;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Quit all connections gracefully.
|
|
1011
|
+
* @returns {Promise<void>}
|
|
1012
|
+
*/
|
|
1013
|
+
public async quit(): Promise<void> {
|
|
1014
|
+
await Promise.all(
|
|
1015
|
+
this._nodes.map(async (node) => {
|
|
1016
|
+
if (node.isConnected()) {
|
|
1017
|
+
await node.quit();
|
|
1018
|
+
}
|
|
1019
|
+
}),
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Disconnect all connections.
|
|
1025
|
+
* @returns {Promise<void>}
|
|
1026
|
+
*/
|
|
1027
|
+
public async disconnect(): Promise<void> {
|
|
1028
|
+
await Promise.all(this._nodes.map((node) => node.disconnect()));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Reconnect all nodes by disconnecting and connecting them again.
|
|
1033
|
+
* @returns {Promise<void>}
|
|
1034
|
+
*/
|
|
1035
|
+
public async reconnect(): Promise<void> {
|
|
1036
|
+
await Promise.all(this._nodes.map((node) => node.reconnect()));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Check if any node is connected to a Memcache server.
|
|
1041
|
+
* @returns {boolean}
|
|
1042
|
+
*/
|
|
1043
|
+
public isConnected(): boolean {
|
|
1044
|
+
return this._nodes.some((node) => node.isConnected());
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Get the nodes for a given key using consistent hashing, with lazy connection.
|
|
1049
|
+
* This method will automatically connect to the nodes if they're not already connected.
|
|
1050
|
+
* Returns an array to support replication strategies.
|
|
1051
|
+
* @param {string} key - The cache key
|
|
1052
|
+
* @returns {Promise<Array<MemcacheNode>>} The nodes responsible for this key
|
|
1053
|
+
* @throws {Error} If no nodes are available for the key
|
|
1054
|
+
*/
|
|
1055
|
+
public async getNodesByKey(key: string): Promise<Array<MemcacheNode>> {
|
|
1056
|
+
const nodes = this._hash.getNodesByKey(key);
|
|
1057
|
+
/* v8 ignore next -- @preserve */
|
|
1058
|
+
if (nodes.length === 0) {
|
|
1059
|
+
throw new Error(`No node available for key: ${key}`);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Lazy connect if not connected
|
|
1063
|
+
for (const node of nodes) {
|
|
1064
|
+
if (!node.isConnected()) {
|
|
1065
|
+
await node.connect();
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return nodes;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Validates a Memcache key according to protocol requirements.
|
|
1074
|
+
* @param {string} key - The key to validate
|
|
1075
|
+
* @throws {Error} If the key is empty, exceeds 250 characters, or contains invalid characters
|
|
1076
|
+
*
|
|
1077
|
+
* @example
|
|
1078
|
+
* ```typescript
|
|
1079
|
+
* client.validateKey("valid-key"); // OK
|
|
1080
|
+
* client.validateKey(""); // Throws: Key cannot be empty
|
|
1081
|
+
* client.validateKey("a".repeat(251)); // Throws: Key length cannot exceed 250 characters
|
|
1082
|
+
* client.validateKey("key with spaces"); // Throws: Key cannot contain spaces, newlines, or null characters
|
|
1083
|
+
* ```
|
|
1084
|
+
*/
|
|
1085
|
+
public validateKey(key: string): void {
|
|
1086
|
+
if (!key || key.length === 0) {
|
|
1087
|
+
throw new Error("Key cannot be empty");
|
|
1088
|
+
}
|
|
1089
|
+
if (key.length > 250) {
|
|
1090
|
+
throw new Error("Key length cannot exceed 250 characters");
|
|
1091
|
+
}
|
|
1092
|
+
if (/[\s\r\n\0]/.test(key)) {
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
"Key cannot contain spaces, newlines, or null characters",
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Private methods
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Update all nodes with current keepAlive settings
|
|
1103
|
+
*/
|
|
1104
|
+
private updateNodes(): void {
|
|
1105
|
+
// Update all nodes with the current keepAlive settings
|
|
1106
|
+
for (const node of this._nodes) {
|
|
1107
|
+
node.keepAlive = this._keepAlive;
|
|
1108
|
+
node.keepAliveDelay = this._keepAliveDelay;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Forward events from a MemcacheNode to the Memcache instance
|
|
1114
|
+
*/
|
|
1115
|
+
private forwardNodeEvents(node: MemcacheNode): void {
|
|
1116
|
+
node.on("connect", () => this.emit(MemcacheEvents.CONNECT, node.id));
|
|
1117
|
+
node.on("close", () => this.emit(MemcacheEvents.CLOSE, node.id));
|
|
1118
|
+
node.on("error", (err: Error) =>
|
|
1119
|
+
this.emit(MemcacheEvents.ERROR, node.id, err),
|
|
1120
|
+
);
|
|
1121
|
+
node.on("timeout", () => this.emit(MemcacheEvents.TIMEOUT, node.id));
|
|
1122
|
+
node.on("hit", (key: string, value: string) =>
|
|
1123
|
+
this.emit(MemcacheEvents.HIT, key, value),
|
|
1124
|
+
);
|
|
1125
|
+
node.on("miss", (key: string) => this.emit(MemcacheEvents.MISS, key));
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
export { createNode };
|
|
1130
|
+
export default Memcache;
|