Skip to content

Commit 2be348d

Browse files
committed
Add fast-hashring library with consistent hashing implementation and documentation
1 parent e609437 commit 2be348d

File tree

5 files changed

+399
-1
lines changed

5 files changed

+399
-1
lines changed

README.md

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,65 @@
1-
# consistent-hash
1+
# fast-hashring
2+
3+
fast-hashring is a lightweight JavaScript library offering an efficient implementation of consistent hashing using virtual nodes. Designed for high performance, it provides fast and scalable key distribution, ideal for load balancing, caching, and data sharding in distributed systems.
4+
5+
## Installation
6+
7+
Install via npm:
8+
9+
```bash
10+
npm install fast-hashring
11+
```
12+
13+
Or using yarn:
14+
15+
```bash
16+
yarn add fast-hashring
17+
```
18+
19+
## Usage
20+
21+
Here’s a basic example of how to integrate fast-hashring into your project:
22+
23+
```js
24+
import ConsistentHash from 'fast-hashring'
25+
26+
const ch = new ConsistentHash({ virtualNodes: 100 })
27+
ch.addNode('server1')
28+
ch.addNode('server2')
29+
30+
const node = ch.getNode('my-key')
31+
console.log(`Key is assigned to node: ${node}`)
32+
```
33+
34+
## Features
35+
36+
- **Consistent Hashing with Virtual Nodes:**
37+
Utilizes virtual nodes to distribute keys evenly across real nodes, minimizing load imbalance during node changes.
38+
39+
- **Binary Search for Rapid Lookups:**
40+
Maintains a sorted hash ring of virtual nodes, enabling quick key lookups using an optimized binary search algorithm.
41+
42+
- **High Performance & Scalability:**
43+
The design focuses on speed and efficiency, ensuring low latency and high throughput even with large-scale deployments.
44+
45+
- **Simple & Intuitive API:**
46+
Easy-to-use methods for adding, removing, and retrieving nodes enable quick integration into projects.
47+
48+
- **TypeScript Support:**
49+
Complete TypeScript definitions are provided, offering strong typing and an improved developer experience.
50+
51+
## Why Choose fast-hashring?
52+
53+
fast-hashring delivers superior performance by leveraging virtual nodes and binary search, providing a more balanced and efficient key distribution compared to traditional hashing methods. Whether you're scaling a distributed cache, load balancer, or sharded database, fast-hashring minimizes remapping and disruption, ensuring high availability and smooth performance.
54+
55+
## Testing
56+
57+
The library is tested using Bun's testing framework. To run the tests, execute:
58+
59+
```bash
60+
bun test
61+
```
62+
63+
## License
64+
65+
MIT

consistent-hash.d.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* ConsistentHash provides an implementation of consistent hashing using virtual nodes
3+
* for improved key distribution. It maintains a ring structure of hashed virtual node values,
4+
* mapping them to their corresponding real nodes.
5+
*
6+
* @module consistent-hash
7+
*/
8+
declare class ConsistentHash {
9+
/**
10+
* Create a new ConsistentHash instance.
11+
*
12+
* @param options - Configuration options.
13+
* @param options.virtualNodes - Number of virtual nodes per real node (default is 100).
14+
*/
15+
constructor(options?: { virtualNodes?: number });
16+
17+
/**
18+
* Add a new node to the hash ring.
19+
*
20+
* @param node - The node identifier.
21+
* @throws Will throw an error if the node is not a non-empty string or already exists.
22+
*/
23+
addNode(node: string): void;
24+
25+
/**
26+
* Remove a node and its associated virtual nodes from the hash ring.
27+
*
28+
* @param node - The node identifier.
29+
* @throws Will throw an error if the node does not exist.
30+
*/
31+
removeNode(node: string): void;
32+
33+
/**
34+
* Get the node responsible for a given key.
35+
* Returns null if no nodes are present.
36+
*
37+
* @param key - The key to look up.
38+
* @returns The node responsible for the key, or null if none exists.
39+
* @throws Will throw an error if the key is not a non-empty string.
40+
*/
41+
getNode(key: string): string | null;
42+
43+
/**
44+
* Get the number of real nodes in the hash ring.
45+
*
46+
* @returns The count of real nodes.
47+
*/
48+
size(): number;
49+
}
50+
51+
export default ConsistentHash;

consistent-hash.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import crypto from 'crypto'
2+
3+
/**
4+
* 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.
7+
*
8+
* This module is designed to be published on NPM and used as a standalone library.
9+
*
10+
* Example usage:
11+
* import ConsistentHash from 'consistent-hash'
12+
* const ch = new ConsistentHash({ virtualNodes: 100 })
13+
* ch.addNode('server1')
14+
* const node = ch.getNode('my-key')
15+
*
16+
* @module consistent-hash
17+
*/
18+
class ConsistentHash {
19+
/**
20+
* Create a new ConsistentHash instance.
21+
*
22+
* @param {Object} [options={}] - Configuration options.
23+
* @param {number} [options.virtualNodes=100] - Number of virtual nodes per real node.
24+
*/
25+
constructor(options = {}) {
26+
this.virtualNodes = options.virtualNodes || 100;
27+
this.ring = []; // Sorted array of virtual node hashes.
28+
this.nodes = new Set(); // Set of real node identifiers.
29+
this.virtualToReal = new Map(); // Maps virtual node hash to real node.
30+
}
31+
32+
/**
33+
* Generate an MD5 hash for a given key.
34+
*
35+
* @private
36+
* @param {string} key - The key to hash.
37+
* @returns {string} The hexadecimal hash.
38+
*/
39+
_hash(key) {
40+
return crypto.createHash('md5').update(key).digest('hex');
41+
}
42+
43+
/**
44+
* Add a new node to the hash ring.
45+
*
46+
* @param {string} node - The node identifier.
47+
* @throws {Error} If node is not a non-empty string or already exists.
48+
*/
49+
addNode(node) {
50+
if (!node || typeof node !== 'string') {
51+
throw new Error('Node must be a non-empty string');
52+
}
53+
if (this.nodes.has(node)) {
54+
throw new Error('Node already exists');
55+
}
56+
this.nodes.add(node);
57+
58+
// Create virtual nodes for the real node.
59+
for (let i = 0; i < this.virtualNodes; i++) {
60+
const virtualNode = `${node}-vn-${i}`;
61+
const hash = this._hash(virtualNode);
62+
this.ring.push(hash);
63+
this.virtualToReal.set(hash, node);
64+
}
65+
// Keep the ring sorted for efficient binary search.
66+
this.ring.sort();
67+
}
68+
69+
/**
70+
* Remove a node and its associated virtual nodes from the hash ring.
71+
*
72+
* @param {string} node - The node identifier.
73+
* @throws {Error} If the node does not exist.
74+
*/
75+
removeNode(node) {
76+
if (!this.nodes.has(node)) {
77+
throw new Error('Node does not exist');
78+
}
79+
this.nodes.delete(node);
80+
81+
// Remove all virtual nodes associated with the given node.
82+
for (let i = 0; i < this.virtualNodes; i++) {
83+
const virtualNode = `${node}-vn-${i}`;
84+
const hash = this._hash(virtualNode);
85+
this.virtualToReal.delete(hash);
86+
}
87+
// Rebuild the ring with remaining virtual nodes.
88+
this.ring = this.ring.filter((hash) => this.virtualToReal.has(hash));
89+
}
90+
91+
/**
92+
* Get the node responsible for a given key.
93+
* Returns null if no nodes are present.
94+
*
95+
* @param {string} key - The key to look up.
96+
* @returns {string|null} The node responsible for the key, or null if none exists.
97+
* @throws {Error} If the key is not a non-empty string.
98+
*/
99+
getNode(key) {
100+
if (!key || typeof key !== 'string') {
101+
throw new Error('Key must be a non-empty string');
102+
}
103+
if (this.ring.length === 0) {
104+
return null;
105+
}
106+
const hash = this._hash(key);
107+
108+
// 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;
110+
while (low < high) {
111+
const mid = Math.floor((low + high) / 2);
112+
if (this.ring[mid] < hash) {
113+
low = mid + 1;
114+
} else {
115+
high = mid;
116+
}
117+
}
118+
const index = low % this.ring.length; // Wrap around if needed.
119+
return this.virtualToReal.get(this.ring[index]);
120+
}
121+
122+
/**
123+
* Get the number of real nodes in the hash ring.
124+
*
125+
* @returns {number} The count of real nodes.
126+
*/
127+
size() {
128+
return this.nodes.size;
129+
}
130+
}
131+
132+
export default ConsistentHash

consistent-hash.test.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { test, describe, beforeEach, expect } from 'bun:test'
2+
import ConsistentHash from './consistent-hash.js'
3+
4+
describe('ConsistentHash', () => {
5+
let ch
6+
7+
beforeEach(() => {
8+
ch = new ConsistentHash()
9+
})
10+
11+
describe('Constructor', () => {
12+
test('should create instance with default virtual nodes', () => {
13+
expect(ch).toBeInstanceOf(ConsistentHash)
14+
expect(ch.virtualNodes).toBe(100)
15+
})
16+
17+
test('should accept a custom number of virtual nodes', () => {
18+
const customHr = new ConsistentHash({ virtualNodes: 200 })
19+
expect(customHr.virtualNodes).toBe(200)
20+
})
21+
})
22+
23+
describe('addNode()', () => {
24+
test('should add nodes successfully', () => {
25+
ch.addNode('server1')
26+
ch.addNode('server2')
27+
expect(ch.size()).toBe(2)
28+
})
29+
30+
test('should throw error when adding empty node', () => {
31+
expect(() => ch.addNode('')).toThrow('Node must be a non-empty string')
32+
})
33+
34+
test('should throw error when adding duplicate node', () => {
35+
ch.addNode('server1')
36+
expect(() => ch.addNode('server1')).toThrow('Node already exists')
37+
})
38+
39+
test('should allow re-adding a node after it has been removed', () => {
40+
ch.addNode('server1')
41+
ch.removeNode('server1')
42+
expect(ch.size()).toBe(0)
43+
// Re-add removed node should succeed.
44+
ch.addNode('server1')
45+
expect(ch.size()).toBe(1)
46+
expect(ch.getNode('someKey')).toBe('server1')
47+
})
48+
})
49+
50+
describe('removeNode()', () => {
51+
beforeEach(() => {
52+
ch.addNode('server1')
53+
ch.addNode('server2')
54+
})
55+
56+
test('should remove nodes successfully', () => {
57+
ch.removeNode('server1')
58+
expect(ch.size()).toBe(1)
59+
})
60+
61+
test('should throw error when removing non-existent node', () => {
62+
expect(() => ch.removeNode('server3')).toThrow('Node does not exist')
63+
})
64+
65+
test('should remap keys after node removal', () => {
66+
const key = '2348'
67+
const originalServer = ch.getNode(key)
68+
ch.removeNode(originalServer)
69+
const newServer = ch.getNode(key)
70+
expect(newServer).not.toBe(originalServer)
71+
})
72+
})
73+
74+
describe('getNode()', () => {
75+
beforeEach(() => {
76+
ch.addNode('server1')
77+
ch.addNode('server2')
78+
})
79+
80+
test('should return consistent results for the same key', () => {
81+
const key = '12345'
82+
const server1 = ch.getNode(key)
83+
const server2 = ch.getNode(key)
84+
expect(server1).toBe(server2)
85+
})
86+
87+
test('should throw error for empty key', () => {
88+
expect(() => ch.getNode('')).toThrow('Key must be a non-empty string')
89+
})
90+
91+
test('should return null when no nodes exist', () => {
92+
ch.removeNode('server1')
93+
ch.removeNode('server2')
94+
expect(ch.getNode('12345')).toBe(null)
95+
})
96+
97+
test('should distribute keys relatively evenly', () => {
98+
const testKeys = Array.from({ length: 1000 }, (_, i) => `key${i}`)
99+
const distribution = {}
100+
testKeys.forEach((key) => {
101+
const server = ch.getNode(key)
102+
distribution[server] = (distribution[server] || 0) + 1
103+
})
104+
105+
const values = Object.values(distribution)
106+
const total = values.reduce((a, b) => a + b, 0)
107+
const avg = total / values.length
108+
const threshold = avg * 0.2 // 20% tolerance
109+
110+
for (let count of values) {
111+
expect(Math.abs(count - avg)).toBeLessThan(threshold)
112+
}
113+
})
114+
})
115+
116+
describe('size()', () => {
117+
test('should return the correct number of nodes', () => {
118+
expect(ch.size()).toBe(0)
119+
ch.addNode('server1')
120+
expect(ch.size()).toBe(1)
121+
ch.addNode('server2')
122+
expect(ch.size()).toBe(2)
123+
ch.removeNode('server1')
124+
expect(ch.size()).toBe(1)
125+
})
126+
})
127+
})

0 commit comments

Comments
 (0)