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/src/ketama.ts ADDED
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Orginal Work is from https://github.com/connor4312/ketama
3
+ * Maintained in project for bug fixes and also configuration
4
+ * Thanks connor4312!
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import type { HashProvider } from "./index.js";
8
+ import type { MemcacheNode } from "./node.js";
9
+
10
+ /**
11
+ * Function that returns an int32 hash of the input (a number between
12
+ * -2147483648 and 2147483647). If your hashing library gives you a Buffer
13
+ * back, a convenient way to get this is `buf.readInt32BE()`.
14
+ */
15
+ export type HashFunction = (input: Buffer) => number;
16
+
17
+ /**
18
+ * Creates a hash function using a built-in Node.js crypto algorithm.
19
+ * @param algorithm - The name of the hashing algorithm (e.g., "sha1", "md5")
20
+ * @returns A HashFunction that uses the specified algorithm
21
+ */
22
+ const hashFunctionForBuiltin =
23
+ (algorithm: string): HashFunction =>
24
+ (value) =>
25
+ createHash(algorithm).update(value).digest().readInt32BE();
26
+
27
+ /**
28
+ * Extracts the key from a node, whether it's a string or an object with a key property.
29
+ * @param node - The node to extract the key from
30
+ * @returns The key as a string
31
+ */
32
+ const keyFor = (node: string | { key: string }) =>
33
+ typeof node === "string" ? node : node.key;
34
+
35
+ /**
36
+ * Represents the hash clock, which is an array of [hash, node key] tuples sorted by hash value.
37
+ * This forms the consistent hashing ring where each tuple represents a virtual node position.
38
+ */
39
+ type HashClock = [hash: number, node: string][];
40
+
41
+ /**
42
+ * A consistent hashing implementation using the Ketama algorithm.
43
+ * This provides a way to distribute keys across nodes in a way that minimizes
44
+ * redistribution when nodes are added or removed.
45
+ *
46
+ * @template TNode - The type of nodes in the ring (string or object with key property)
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * // Create a ring with string nodes
51
+ * const ring = new HashRing(['server1', 'server2', 'server3']);
52
+ * const node = ring.getNode('my-key'); // Returns the node responsible for 'my-key'
53
+ *
54
+ * // Create a ring with weighted nodes
55
+ * const weightedRing = new HashRing([
56
+ * { node: 'server1', weight: 2 },
57
+ * { node: 'server2', weight: 1 }
58
+ * ]);
59
+ *
60
+ * // Create a ring with object nodes
61
+ * const objRing = new HashRing([
62
+ * { key: 'server1', host: 'localhost', port: 11211 },
63
+ * { key: 'server2', host: 'localhost', port: 11212 }
64
+ * ]);
65
+ * ```
66
+ */
67
+ export class HashRing<TNode extends string | { key: string } = string> {
68
+ /**
69
+ * Base weight of each node in the hash ring. Having a base weight of 1 is
70
+ * not very desirable, since then, due to the ketama-style "clock", it's
71
+ * possible to end up with a clock that's very skewed when dealing with a
72
+ * small number of nodes. Setting to 50 nodes seems to give a better
73
+ * distribution, so that load is spread roughly evenly to +/- 5%.
74
+ */
75
+ public static baseWeight = 50;
76
+
77
+ /** The hash function used to compute node positions on the ring */
78
+ private readonly hashFn: HashFunction;
79
+
80
+ /** The sorted array of [hash, node key] tuples representing virtual nodes on the ring */
81
+ private _clock: HashClock = [];
82
+
83
+ /** Map of node keys to actual node objects */
84
+ private _nodes = new Map<string, TNode>();
85
+
86
+ /**
87
+ * Gets the sorted array of [hash, node key] tuples representing virtual nodes on the ring.
88
+ * @returns The hash clock array
89
+ */
90
+ public get clock(): HashClock {
91
+ return this._clock;
92
+ }
93
+
94
+ /**
95
+ * Gets the map of node keys to actual node objects.
96
+ * @returns The nodes map
97
+ */
98
+ public get nodes(): ReadonlyMap<string, TNode> {
99
+ return this._nodes;
100
+ }
101
+
102
+ /**
103
+ * Creates a new HashRing instance.
104
+ *
105
+ * @param initialNodes - Array of nodes to add to the ring, optionally with weights
106
+ * @param hashFn - Hash function to use (string algorithm name or custom function, defaults to "sha1")
107
+ *
108
+ * @example
109
+ * ```typescript
110
+ * // Simple ring with default SHA-1 hashing
111
+ * const ring = new HashRing(['node1', 'node2']);
112
+ *
113
+ * // Ring with custom hash function
114
+ * const customRing = new HashRing(['node1', 'node2'], 'md5');
115
+ *
116
+ * // Ring with weighted nodes
117
+ * const weightedRing = new HashRing([
118
+ * { node: 'heavy-server', weight: 3 },
119
+ * { node: 'light-server', weight: 1 }
120
+ * ]);
121
+ * ```
122
+ */
123
+ constructor(
124
+ initialNodes: ReadonlyArray<TNode | { weight: number; node: TNode }> = [],
125
+ hashFn: string | HashFunction = "sha1",
126
+ ) {
127
+ this.hashFn =
128
+ typeof hashFn === "string" ? hashFunctionForBuiltin(hashFn) : hashFn;
129
+ for (const node of initialNodes) {
130
+ if (typeof node === "object" && "weight" in node && "node" in node) {
131
+ this.addNode(node.node, node.weight);
132
+ } else {
133
+ this.addNode(node);
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Add a new node to the ring. If the node already exists in the ring, it
140
+ * will be updated. For example, you can use this to update the node's weight.
141
+ *
142
+ * @param node - The node to add to the ring
143
+ * @param weight - The relative weight of this node (default: 1). Higher weights mean more keys will be assigned to this node. A weight of 0 removes the node.
144
+ * @throws {RangeError} If weight is negative
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * const ring = new HashRing();
149
+ * ring.addNode('server1'); // Add with default weight of 1
150
+ * ring.addNode('server2', 2); // Add with weight of 2 (will handle ~2x more keys)
151
+ * ring.addNode('server1', 3); // Update server1's weight to 3
152
+ * ring.addNode('server2', 0); // Remove server2
153
+ * ```
154
+ */
155
+ public addNode(node: TNode, weight = 1) {
156
+ if (weight === 0) {
157
+ this.removeNode(node);
158
+ } else if (weight < 0) {
159
+ throw new RangeError("Cannot add a node to the hashring with weight < 0");
160
+ } else {
161
+ this.removeNode(node);
162
+ const key = keyFor(node);
163
+ this._nodes.set(key, node);
164
+ this.addNodeToClock(key, Math.round(weight * HashRing.baseWeight));
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Removes the node from the ring. No-op if the node does not exist.
170
+ *
171
+ * @param node - The node to remove from the ring
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * const ring = new HashRing(['server1', 'server2']);
176
+ * ring.removeNode('server1'); // Removes server1 from the ring
177
+ * ring.removeNode('nonexistent'); // Safe to call with non-existent node
178
+ * ```
179
+ */
180
+ public removeNode(node: TNode) {
181
+ const key = keyFor(node);
182
+ if (this._nodes.delete(key)) {
183
+ this._clock = this._clock.filter(([, n]) => n !== key);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Gets the node which should handle the given input key. Returns undefined if
189
+ * the hashring has no nodes.
190
+ *
191
+ * Uses consistent hashing to ensure the same input always maps to the same node,
192
+ * and minimizes redistribution when nodes are added or removed.
193
+ *
194
+ * @param input - The key to find the responsible node for (string or Buffer)
195
+ * @returns The node responsible for this key, or undefined if ring is empty
196
+ *
197
+ * @example
198
+ * ```typescript
199
+ * const ring = new HashRing(['server1', 'server2', 'server3']);
200
+ * const node = ring.getNode('user:123'); // Returns e.g., 'server2'
201
+ * const sameNode = ring.getNode('user:123'); // Always returns 'server2'
202
+ *
203
+ * // Also accepts Buffer input
204
+ * const bufferNode = ring.getNode(Buffer.from('user:123'));
205
+ * ```
206
+ */
207
+ public getNode(input: string | Buffer): TNode | undefined {
208
+ if (this._clock.length === 0) {
209
+ return undefined;
210
+ }
211
+
212
+ const index = this.getIndexForInput(input);
213
+ const key =
214
+ index === this._clock.length ? this._clock[0][1] : this._clock[index][1];
215
+
216
+ return this._nodes.get(key);
217
+ }
218
+
219
+ /**
220
+ * Finds the index in the clock for the given input by hashing it and performing binary search.
221
+ *
222
+ * @param input - The input to find the clock position for
223
+ * @returns The index in the clock array
224
+ */
225
+ private getIndexForInput(input: string | Buffer) {
226
+ const hash = this.hashFn(
227
+ typeof input === "string" ? Buffer.from(input) : input,
228
+ );
229
+ return binarySearchRing(this._clock, hash);
230
+ }
231
+
232
+ /**
233
+ * Gets multiple replica nodes that should handle the given input. Useful for
234
+ * implementing replication strategies where you want to store data on multiple nodes.
235
+ *
236
+ * The returned array will contain unique nodes in the order they appear on the ring
237
+ * starting from the primary node. If there are fewer nodes than replicas requested,
238
+ * all nodes are returned.
239
+ *
240
+ * @param input - The key to find replica nodes for (string or Buffer)
241
+ * @param replicas - The number of replica nodes to return
242
+ * @returns Array of nodes that should handle this key (length ≤ replicas)
243
+ *
244
+ * @example
245
+ * ```typescript
246
+ * const ring = new HashRing(['server1', 'server2', 'server3', 'server4']);
247
+ *
248
+ * // Get 3 replicas for a key
249
+ * const replicas = ring.getNodes('user:123', 3);
250
+ * // Returns e.g., ['server2', 'server4', 'server1']
251
+ *
252
+ * // If requesting more replicas than nodes, returns all nodes
253
+ * const allNodes = ring.getNodes('user:123', 10);
254
+ * // Returns ['server1', 'server2', 'server3', 'server4']
255
+ * ```
256
+ */
257
+ public getNodes(input: string | Buffer, replicas: number): TNode[] {
258
+ if (this._clock.length === 0) {
259
+ return [];
260
+ }
261
+
262
+ if (replicas >= this._nodes.size) {
263
+ return [...this._nodes.values()];
264
+ }
265
+
266
+ const chosen = new Set<string>();
267
+
268
+ // We know this will terminate, since we know there are at least as many
269
+ // unique nodes to be chosen as there are replicas
270
+ for (let i = this.getIndexForInput(input); chosen.size < replicas; i++) {
271
+ chosen.add(this._clock[i % this._clock.length][1]);
272
+ }
273
+
274
+ return [...chosen].map((c) => this._nodes.get(c) as TNode);
275
+ }
276
+
277
+ /**
278
+ * Adds virtual nodes to the clock for the given node key.
279
+ * Creates multiple positions on the ring for better distribution.
280
+ *
281
+ * @param key - The node key to add to the clock
282
+ * @param weight - The number of virtual nodes to create (weight * baseWeight)
283
+ */
284
+ private addNodeToClock(key: string, weight: number) {
285
+ for (let i = weight; i > 0; i--) {
286
+ const hash = this.hashFn(Buffer.from(`${key}\0${i}`));
287
+ this._clock.push([hash, key]);
288
+ }
289
+
290
+ this._clock.sort((a, b) => a[0] - b[0]);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * A distribution hash implementation using the Ketama consistent hashing algorithm.
296
+ * This class wraps the HashRing to implement the DistributionHash interface for use with Memcache.
297
+ *
298
+ * @example
299
+ * ```typescript
300
+ * const distribution = new KetamaDistributionHash();
301
+ * distribution.addNode(node1);
302
+ * distribution.addNode(node2);
303
+ * const targetNode = distribution.getNodesByKey('my-key')[0];
304
+ * ```
305
+ */
306
+ export class KetamaHash implements HashProvider {
307
+ /** The name of this distribution strategy */
308
+ public readonly name = "ketama";
309
+
310
+ /** Internal hash ring for consistent hashing */
311
+ private hashRing: HashRing<string>;
312
+
313
+ /** Map of node IDs to MemcacheNode instances */
314
+ private nodeMap: Map<string, MemcacheNode>;
315
+
316
+ /**
317
+ * Creates a new KetamaDistributionHash instance.
318
+ *
319
+ * @param hashFn - Hash function to use (string algorithm name or custom function, defaults to "sha1")
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * // Use default SHA-1 hashing
324
+ * const distribution = new KetamaDistributionHash();
325
+ *
326
+ * // Use MD5 hashing
327
+ * const distribution = new KetamaDistributionHash('md5');
328
+ * ```
329
+ */
330
+ constructor(hashFn?: string | HashFunction) {
331
+ this.hashRing = new HashRing<string>([], hashFn);
332
+ this.nodeMap = new Map();
333
+ }
334
+
335
+ /**
336
+ * Gets all nodes in the distribution.
337
+ * @returns Array of all MemcacheNode instances
338
+ */
339
+ public get nodes(): Array<MemcacheNode> {
340
+ return Array.from(this.nodeMap.values());
341
+ }
342
+
343
+ /**
344
+ * Adds a node to the distribution with its weight for consistent hashing.
345
+ *
346
+ * @param node - The MemcacheNode to add
347
+ *
348
+ * @example
349
+ * ```typescript
350
+ * const node = new MemcacheNode('localhost', 11211, { weight: 2 });
351
+ * distribution.addNode(node);
352
+ * ```
353
+ */
354
+ public addNode(node: MemcacheNode): void {
355
+ // Add to internal map for lookups
356
+ this.nodeMap.set(node.id, node);
357
+ // Add to hash ring with weight
358
+ this.hashRing.addNode(node.id, node.weight);
359
+ }
360
+
361
+ /**
362
+ * Removes a node from the distribution by its ID.
363
+ *
364
+ * @param id - The node ID (e.g., "localhost:11211")
365
+ *
366
+ * @example
367
+ * ```typescript
368
+ * distribution.removeNode('localhost:11211');
369
+ * ```
370
+ */
371
+ public removeNode(id: string): void {
372
+ // Remove from internal map
373
+ this.nodeMap.delete(id);
374
+ // Remove from hash ring
375
+ this.hashRing.removeNode(id);
376
+ }
377
+
378
+ /**
379
+ * Gets a specific node by its ID.
380
+ *
381
+ * @param id - The node ID (e.g., "localhost:11211")
382
+ * @returns The MemcacheNode if found, undefined otherwise
383
+ *
384
+ * @example
385
+ * ```typescript
386
+ * const node = distribution.getNode('localhost:11211');
387
+ * if (node) {
388
+ * console.log(`Found node: ${node.uri}`);
389
+ * }
390
+ * ```
391
+ */
392
+ public getNode(id: string): MemcacheNode | undefined {
393
+ return this.nodeMap.get(id);
394
+ }
395
+
396
+ /**
397
+ * Gets the nodes responsible for a given key using consistent hashing.
398
+ * Currently returns a single node (the primary node for the key).
399
+ *
400
+ * @param key - The cache key to find the responsible node for
401
+ * @returns Array containing the responsible node(s), empty if no nodes available
402
+ *
403
+ * @example
404
+ * ```typescript
405
+ * const nodes = distribution.getNodesByKey('user:123');
406
+ * if (nodes.length > 0) {
407
+ * console.log(`Key will be stored on: ${nodes[0].id}`);
408
+ * }
409
+ * ```
410
+ */
411
+ public getNodesByKey(key: string): Array<MemcacheNode> {
412
+ // Get the node from hash ring
413
+ const nodeId = this.hashRing.getNode(key);
414
+ if (!nodeId) {
415
+ return [];
416
+ }
417
+
418
+ // Map back to MemcacheNode
419
+ const node = this.nodeMap.get(nodeId);
420
+ return node ? [node] : [];
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Performs binary search on the hash ring to find the appropriate position for a given hash.
426
+ * Returns the index of the first virtual node with a hash value >= the input hash.
427
+ * If no such node exists, returns the length of the ring (wraps to beginning).
428
+ *
429
+ * @param ring - The sorted array of [hash, node] tuples
430
+ * @param hash - The hash value to search for
431
+ * @returns The index where the hash should be inserted or the next node position
432
+ */
433
+ function binarySearchRing(ring: HashClock, hash: number) {
434
+ let mid: number;
435
+ let lo = 0;
436
+ let hi = ring.length - 1;
437
+
438
+ while (lo <= hi) {
439
+ mid = Math.floor((lo + hi) / 2);
440
+
441
+ if (ring[mid][0] >= hash) {
442
+ hi = mid - 1;
443
+ } else {
444
+ lo = mid + 1;
445
+ }
446
+ }
447
+
448
+ return lo;
449
+ }