Skip to content

Commit 1d5f40c

Browse files
authored
Merge pull request #2 from BackendStack21/fix/top-5-improvements
fix: harden ring, add exports map, batch addNodes, numeric hashes (v1.1.1)
2 parents 6bc9007 + 0b1c9f2 commit 1d5f40c

6 files changed

Lines changed: 252 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@ import ConsistentHash from "fast-hashring";
3232
const ch = new ConsistentHash({ virtualNodes: 100 });
3333
ch.addNode("server1");
3434
ch.addNode("server2");
35+
// Or add multiple nodes at once (ring is sorted once, not per node):
36+
ch.addNodes(["server3", "server4"]);
3537

3638
const node = ch.getNode("my-key");
3739
console.log(`Key is assigned to node: ${node}`);
3840
```
3941

42+
Notes:
43+
- `virtualNodes` must be a positive integer (validated in the constructor).
44+
- `getNode` returns `null` when the ring is empty (no nodes added, or all removed).
45+
4046
### Jump Consistent Hash (no ring, O(1) memory)
4147

4248
Jump Consistent Hash maps a string key to a stable index in [0, N). This is useful when you just need a bucket index (e.g., shard number) without maintaining a ring of nodes.

‎consistent-hash.fix.test.js‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import ConsistentHash from './consistent-hash.js'
3+
4+
// ── Item 1: constructor validates virtualNodes ──────────────────────────────
5+
describe('ConsistentHash constructor validation', () => {
6+
test('throws on virtualNodes = 0', () => {
7+
expect(() => new ConsistentHash({ virtualNodes: 0 })).toThrow()
8+
})
9+
10+
test('throws on negative virtualNodes', () => {
11+
expect(() => new ConsistentHash({ virtualNodes: -5 })).toThrow()
12+
})
13+
14+
test('throws on fractional virtualNodes', () => {
15+
expect(() => new ConsistentHash({ virtualNodes: 10.5 })).toThrow()
16+
})
17+
18+
test('throws on non-numeric virtualNodes', () => {
19+
expect(() => new ConsistentHash({ virtualNodes: '100' })).toThrow()
20+
})
21+
22+
test('accepts a positive integer', () => {
23+
const ch = new ConsistentHash({ virtualNodes: 4 })
24+
expect(ch.virtualNodes).toBe(4)
25+
})
26+
27+
test('default is 100', () => {
28+
const ch = new ConsistentHash()
29+
expect(ch.virtualNodes).toBe(100)
30+
})
31+
})
32+
33+
// ── Item 2: ring uses 32-bit numeric hashes (numeric compare, no strings) ───
34+
describe('numeric ring representation', () => {
35+
test('ring entries are 32-bit numbers, not hex strings', () => {
36+
const ch = new ConsistentHash({ virtualNodes: 8 })
37+
ch.addNode('a')
38+
expect(ch.ring.length).toBe(8)
39+
for (const h of ch.ring) {
40+
expect(typeof h).toBe('number')
41+
expect(Number.isInteger(h)).toBe(true)
42+
expect(h).toBeGreaterThanOrEqual(0)
43+
expect(h).toBeLessThanOrEqual(0xffffffff)
44+
}
45+
})
46+
47+
test('ring is sorted numerically ascending', () => {
48+
const ch = new ConsistentHash({ virtualNodes: 32 })
49+
for (const n of ['a', 'b', 'c']) ch.addNode(n)
50+
for (let i = 1; i < ch.ring.length; i++) {
51+
expect(ch.ring[i - 1] <= ch.ring[i]).toBe(true)
52+
}
53+
})
54+
})
55+
56+
// ── Item 4: batch addNodes sorts once ────────────────────────────────────────
57+
describe('addNodes batch API', () => {
58+
test('addNodes accepts a list and registers all nodes', () => {
59+
const ch = new ConsistentHash({ virtualNodes: 16 })
60+
ch.addNodes(['a', 'b', 'c'])
61+
expect(ch.size()).toBe(3)
62+
expect(ch.getNode('key-1')).toBeDefined()
63+
})
64+
65+
test('addNodes rejects an empty or invalid list', () => {
66+
const ch = new ConsistentHash({ virtualNodes: 4 })
67+
expect(() => ch.addNodes([])).toThrow()
68+
expect(() => ch.addNodes('a')).toThrow()
69+
})
70+
71+
test('addNodes rejects duplicate nodes within the batch', () => {
72+
const ch = new ConsistentHash({ virtualNodes: 4 })
73+
expect(() => ch.addNodes(['a', 'a'])).toThrow()
74+
})
75+
})
76+
77+
// ── Item 5: ring invariants ──────────────────────────────────────────────────
78+
describe('ring invariants', () => {
79+
const keys = Array.from({ length: 2000 }, (_, i) => `key-${i}`)
80+
81+
test('getNode returns null on an empty ring', () => {
82+
const ch = new ConsistentHash({ virtualNodes: 8 })
83+
expect(ch.getNode('any-key')).toBeNull()
84+
})
85+
86+
test('getNode output is deterministic', () => {
87+
const ch = new ConsistentHash({ virtualNodes: 16 })
88+
ch.addNode('a')
89+
ch.addNode('b')
90+
for (const k of keys) {
91+
expect(ch.getNode(k)).toBe(ch.getNode(k))
92+
}
93+
})
94+
95+
test('monotonicity: removing a node only remaps its keys', () => {
96+
const ch = new ConsistentHash({ virtualNodes: 64 })
97+
ch.addNodes(['a', 'b', 'c'])
98+
const before = new Map(keys.map((k) => [k, ch.getNode(k)]))
99+
100+
ch.removeNode('b')
101+
const moved = keys.filter((k) => ch.getNode(k) !== before.get(k))
102+
// Every moved key must now land on a surviving node.
103+
for (const k of moved) {
104+
expect(['a', 'c']).toContain(ch.getNode(k))
105+
}
106+
// Minimal disruption: removed node owned ~1/3 of keys; moved set must be
107+
// exactly the set previously owned by 'b' (ring + virtual nodes guarantee).
108+
const movedFraction = moved.length / keys.length
109+
expect(movedFraction).toBeGreaterThan(0.2)
110+
expect(movedFraction).toBeLessThan(0.45)
111+
})
112+
113+
test('monotonicity: adding a node only remaps a minority of keys', () => {
114+
const ch = new ConsistentHash({ virtualNodes: 64 })
115+
ch.addNodes(['a', 'b'])
116+
const before = new Map(keys.map((k) => [k, ch.getNode(k)]))
117+
ch.addNode('c')
118+
const moved = keys.filter((k) => ch.getNode(k) !== before.get(k)).length / keys.length
119+
expect(moved).toBeGreaterThan(0.1) // should win ~1/3 of keys
120+
expect(moved).toBeLessThan(0.5)
121+
})
122+
123+
test('only node removal leaves an empty ring and getNode returns null', () => {
124+
const ch = new ConsistentHash({ virtualNodes: 8 })
125+
ch.addNode('a')
126+
ch.removeNode('a')
127+
expect(ch.size()).toBe(0)
128+
expect(ch.getNode('x')).toBeNull()
129+
})
130+
131+
test('distribution across nodes is balanced within 25%', () => {
132+
const ch = new ConsistentHash({ virtualNodes: 100 })
133+
ch.addNodes(['a', 'b', 'c'])
134+
const counts = { a: 0, b: 0, c: 0 }
135+
for (const k of keys) counts[ch.getNode(k)]++
136+
const avg = keys.length / 3
137+
for (const c of Object.values(counts)) {
138+
expect(Math.abs(c - avg)).toBeLessThanOrEqual(avg * 0.25)
139+
}
140+
})
141+
})

‎consistent-hash.js‎

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,49 @@
1-
import crypto from 'crypto'
1+
import crypto from 'node:crypto'
22

33
/**
44
* ConsistentHash provides an implementation of consistent hashing using virtual nodes
5-
* for improved key distribution. It maintains a ring structure of hashed virtual node values,
6-
* mapping them to their corresponding real nodes.
5+
* for improved key distribution. It maintains a ring structure of 32-bit hashed virtual
6+
* node values, mapping them to their corresponding real nodes.
77
*
88
* This module is designed to be published on NPM and used as a standalone library.
99
*
1010
* Example usage:
11-
* import ConsistentHash from 'consistent-hash'
11+
* import ConsistentHash from 'fast-hashring'
1212
* const ch = new ConsistentHash({ virtualNodes: 100 })
1313
* ch.addNode('server1')
1414
* const node = ch.getNode('my-key')
1515
*
1616
* @module consistent-hash
1717
*/
18+
const UINT32_MAX = 0xffffffff
19+
1820
class ConsistentHash {
1921
/**
2022
* Create a new ConsistentHash instance.
2123
*
2224
* @param {Object} [options={}] - Configuration options.
23-
* @param {number} [options.virtualNodes=100] - Number of virtual nodes per real node.
25+
* @param {number} [options.virtualNodes=100] - Number of virtual nodes per real node (positive integer).
2426
*/
2527
constructor(options = {}) {
26-
this.virtualNodes = options.virtualNodes || 100;
27-
this.ring = []; // Sorted array of virtual node hashes.
28+
const virtualNodes = options.virtualNodes ?? 100;
29+
if (!Number.isInteger(virtualNodes) || virtualNodes < 1) {
30+
throw new Error('virtualNodes must be a positive integer');
31+
}
32+
this.virtualNodes = virtualNodes;
33+
this.ring = []; // Sorted array of 32-bit virtual node hashes.
2834
this.nodes = new Set(); // Set of real node identifiers.
29-
this.virtualToReal = new Map(); // Maps virtual node hash to real node.
35+
this.virtualToReal = new Map(); // Maps 32-bit virtual node hash to real node.
3036
}
3137

3238
/**
33-
* Generate an MD5 hash for a given key.
39+
* Generate a 32-bit hash for a given key (first 4 bytes of MD5).
3440
*
3541
* @private
3642
* @param {string} key - The key to hash.
37-
* @returns {string} The hexadecimal hash.
43+
* @returns {number} The unsigned 32-bit hash.
3844
*/
3945
_hash(key) {
40-
return crypto.createHash('md5').update(key).digest('hex');
46+
return crypto.createHash('md5').update(key).digest().readUInt32BE(0);
4147
}
4248

4349
/**
@@ -57,13 +63,41 @@ class ConsistentHash {
5763

5864
// Create virtual nodes for the real node.
5965
for (let i = 0; i < this.virtualNodes; i++) {
60-
const virtualNode = `${node}-vn-${i}`;
61-
const hash = this._hash(virtualNode);
66+
const hash = this._hash(`${node}-vn-${i}`);
6267
this.ring.push(hash);
6368
this.virtualToReal.set(hash, node);
6469
}
65-
// Keep the ring sorted for efficient binary search.
66-
this.ring.sort();
70+
// Keep the ring sorted for efficient binary search (numeric comparator).
71+
this.ring.sort((a, b) => a - b);
72+
}
73+
74+
/**
75+
* Add multiple nodes to the hash ring, sorting the ring only once.
76+
*
77+
* @param {string[]} nodes - The node identifiers.
78+
* @throws {Error} If nodes is not a non-empty array, or any node is invalid/duplicated.
79+
*/
80+
addNodes(nodes) {
81+
if (!Array.isArray(nodes) || nodes.length === 0) {
82+
throw new Error('Nodes must be a non-empty array');
83+
}
84+
for (const node of nodes) {
85+
if (!node || typeof node !== 'string') {
86+
throw new Error('Node must be a non-empty string');
87+
}
88+
if (this.nodes.has(node) || nodes.indexOf(node) !== nodes.lastIndexOf(node)) {
89+
throw new Error('Node already exists');
90+
}
91+
this.nodes.add(node);
92+
}
93+
for (const node of nodes) {
94+
for (let i = 0; i < this.virtualNodes; i++) {
95+
const hash = this._hash(`${node}-vn-${i}`);
96+
this.ring.push(hash);
97+
this.virtualToReal.set(hash, node);
98+
}
99+
}
100+
this.ring.sort((a, b) => a - b);
67101
}
68102

69103
/**
@@ -79,13 +113,14 @@ class ConsistentHash {
79113
this.nodes.delete(node);
80114

81115
// Remove all virtual nodes associated with the given node.
116+
const removed = new Set();
82117
for (let i = 0; i < this.virtualNodes; i++) {
83-
const virtualNode = `${node}-vn-${i}`;
84-
const hash = this._hash(virtualNode);
118+
const hash = this._hash(`${node}-vn-${i}`);
85119
this.virtualToReal.delete(hash);
120+
removed.add(hash);
86121
}
87-
// Rebuild the ring with remaining virtual nodes.
88-
this.ring = this.ring.filter((hash) => this.virtualToReal.has(hash));
122+
// Rebuild the ring without the removed virtual nodes.
123+
this.ring = this.ring.filter((hash) => !removed.has(hash));
89124
}
90125

91126
/**
@@ -104,19 +139,20 @@ class ConsistentHash {
104139
return null;
105140
}
106141
const hash = this._hash(key);
142+
const ring = this.ring;
107143

108144
// Binary search to find the first virtual node hash greater than or equal to the key hash.
109-
let low = 0, high = this.ring.length;
145+
let low = 0, high = ring.length;
110146
while (low < high) {
111-
const mid = Math.floor((low + high) / 2);
112-
if (this.ring[mid] < hash) {
147+
const mid = (low + high) >>> 1;
148+
if (ring[mid] < hash) {
113149
low = mid + 1;
114150
} else {
115151
high = mid;
116152
}
117153
}
118-
const index = low % this.ring.length; // Wrap around if needed.
119-
return this.virtualToReal.get(this.ring[index]);
154+
const index = low % ring.length; // Wrap around if needed.
155+
return this.virtualToReal.get(ring[index]);
120156
}
121157

122158
/**

‎jump-hash.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import crypto from "crypto";
1+
import crypto from "node:crypto";
22

33
/**
44
* JumpConsistentHash maps string keys to an integer index in [0, N)

‎package.json‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
{
22
"name": "fast-hashring",
3-
"version": "1.1.0",
4-
"description": "A library for consistent hashing.",
3+
"version": "1.1.1",
4+
"description": "Fast consistent hashing: classic ring with virtual nodes + Jump Consistent Hash (O(1) memory).",
55
"main": "consistent-hash.js",
66
"type": "module",
7+
"exports": {
8+
".": {
9+
"types": "./index.d.ts",
10+
"import": "./consistent-hash.js"
11+
},
12+
"./jump-hash.js": {
13+
"types": "./jump-hash.d.ts",
14+
"import": "./jump-hash.js"
15+
}
16+
},
717
"scripts": {
818
"test": "bun test --coverage"
919
},

‎package.test.js‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import fs from 'node:fs'
3+
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
7+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'))
8+
9+
// Item 3: package must expose an exports map covering the documented subpath
10+
// import `fast-hashring/jump-hash.js` (README), with types.
11+
describe('package exports map', () => {
12+
test('defines "." export with default, import and types', () => {
13+
expect(pkg.exports).toBeDefined()
14+
expect(pkg.exports['.']).toBeDefined()
15+
expect(pkg.exports['.'].import).toBe('./consistent-hash.js')
16+
expect(pkg.exports['.'].types).toBeDefined()
17+
})
18+
19+
test('defines "./jump-hash.js" subpath export (documented in README)', () => {
20+
expect(pkg.exports['./jump-hash.js']).toBeDefined()
21+
expect(pkg.exports['./jump-hash.js'].import).toBe('./jump-hash.js')
22+
expect(pkg.exports['./jump-hash.js'].types).toBeDefined()
23+
})
24+
25+
test('uses node: protocol for builtin imports in sources', () => {
26+
for (const f of ['consistent-hash.js', 'jump-hash.js']) {
27+
const src = fs.readFileSync(path.join(__dirname, f), 'utf8')
28+
expect(src).not.toMatch(/from ['"]crypto['"]/)
29+
expect(src).toMatch(/from ['"]node:crypto['"]/)
30+
}
31+
})
32+
})

0 commit comments

Comments
 (0)