Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# fast-hashring

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.
fast-hashring is a lightweight JavaScript library offering efficient implementations of consistent hashing:

- A classic consistent hash ring with virtual nodes (balanced distribution, minimal remapping)
- Jump Consistent Hash ([Lamping & Veach](https://arxiv.org/abs/1406.2294)) mapping keys directly to bucket indexes with O(1) memory

Designed for high performance, it provides fast and scalable key distribution, ideal for load balancing, caching, and data sharding in distributed systems.

## Installation

Expand All @@ -21,14 +26,37 @@ yarn add fast-hashring
Here’s a basic example of how to integrate fast-hashring into your project:

```js
import ConsistentHash from 'fast-hashring'
import ConsistentHash from "fast-hashring";

const ch = new ConsistentHash({ virtualNodes: 100 })
ch.addNode('server1')
ch.addNode('server2')
const ch = new ConsistentHash({ virtualNodes: 100 });
ch.addNode("server1");
ch.addNode("server2");

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

### Jump Consistent Hash (no ring, O(1) memory)

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.

```js
// Note: runtime import path uses the file path; default export remains ConsistentHash
import JumpConsistentHash from "fast-hashring/jump-hash.js";

const jch = new JumpConsistentHash(16); // 16 buckets
const idx = jch.getIndex("user-123"); // deterministic value in 0..15
```

TypeScript note: types for both classes are exposed via the package types entry.

```ts
// Values
import ConsistentHash from "fast-hashring";
import JumpConsistentHash from "fast-hashring/jump-hash.js";

// Types (optional)
import type { ConsistentHash as ConsistentHashType, JumpConsistentHash as JumpConsistentHashType } from "fast-hashring";
```

## Features
Expand All @@ -39,6 +67,9 @@ console.log(`Key is assigned to node: ${node}`)
- **Binary Search for Rapid Lookups:**
Maintains a sorted hash ring of virtual nodes, enabling quick key lookups using an optimized binary search algorithm.

- **Jump Consistent Hash (Lamping & Veach):**
Minimal-memory O(1) approach that maps keys directly to bucket indexes in [0, N), great for sharding. Buckets must be numbered 0..N-1.

- **High Performance & Scalability:**
The design focuses on speed and efficiency, ensuring low latency and high throughput even with large-scale deployments.

Expand Down
8 changes: 8 additions & 0 deletions demos/consistent-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ConsistentHash from "../";

const ch = new ConsistentHash({ virtualNodes: 100 });
ch.addNode("server1");
ch.addNode("server2");

const node = ch.getNode("my-key");
console.log(`Key is assigned to node: ${node}`);
6 changes: 6 additions & 0 deletions demos/jump-consistent-hash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const JumpConsistentHash = require("../jump-hash.js").default;
Comment thread
jkyberneees marked this conversation as resolved.
Comment thread
jkyberneees marked this conversation as resolved.

const jch = new JumpConsistentHash(16);
const idx = jch.getIndex("user-123");

console.log(`Index for 'user-123': ${idx}`);
6 changes: 6 additions & 0 deletions demos/jump-consistent-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import JumpConsistentHash from "../jump-hash.js";

const jch = new JumpConsistentHash(16);
const idx = jch.getIndex("user-123");

console.log(`Index for 'user-123': ${idx}`);
6 changes: 6 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { default as ConsistentHash } from "./consistent-hash.js";
export { default as JumpConsistentHash } from "./jump-hash.js";

// Expose the default export to match runtime default export (ConsistentHash)
import ConsistentHashDefault from "./consistent-hash.js";
export default ConsistentHashDefault;
34 changes: 34 additions & 0 deletions jump-hash.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* JumpConsistentHash maps string keys to an integer index in [0, N)
* using the Jump Consistent Hash algorithm by Lamping & Veach.
*/
declare class JumpConsistentHash {
/**
* Create a new JumpConsistentHash instance.
*
* @param indexes - Total number of indexes (buckets), must be a positive integer.
*/
constructor(indexes: number);

/**
* Update number of indexes (buckets).
*
* @param indexes - Positive integer number of buckets.
*/
setIndexes(indexes: number): void;

/**
* Get current number of indexes (buckets).
*/
size(): number;

/**
* Compute stable index in [0, indexes) for the given key.
*
* @param key - Non-empty string key.
* @returns Index between 0 (inclusive) and indexes (exclusive).
*/
getIndex(key: string): number;
}

export default JumpConsistentHash;
88 changes: 88 additions & 0 deletions jump-hash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import crypto from "crypto";

/**
* JumpConsistentHash maps string keys to an integer index in [0, N)
* using the Jump Consistent Hash algorithm by Lamping & Veach.
*
* Usage:
* const jch = new JumpConsistentHash(16)
* const idx = jch.getIndex('some-key') // 0..15
*/
class JumpConsistentHash {
/**
* @param {number} indexes - Total number of indexes (buckets), must be >= 1 integer
*/
constructor(indexes) {
this.setIndexes(indexes);
}

/**
* Update number of indexes (buckets).
* @param {number} indexes
*/
setIndexes(indexes) {
if (!Number.isInteger(indexes) || indexes <= 0) {
throw new Error("Indexes must be a positive integer");
}
this.indexes = indexes;
}

/**
* @returns {number} Current number of indexes (buckets)
*/
size() {
return this.indexes;
}

/**
* Compute stable index in [0, indexes) for the given key.
* @param {string} key
* @returns {number}
*/
getIndex(key) {
if (!key || typeof key !== "string") {
throw new Error("Key must be a non-empty string");
}
const k = this.#hash64(key);
return this.#jumpHash(k, this.indexes);
}

/**
* Hash string to unsigned 64-bit BigInt using SHA-1 (first 8 bytes).
* Endianness choice is arbitrary but consistent (little-endian).
* @param {string} key
* @returns {bigint}
*/
#hash64(key) {
const digest = crypto.createHash("sha1").update(key).digest();
Comment thread
jkyberneees marked this conversation as resolved.
let x = 0n;
const len = Math.min(8, digest.length);
for (let i = 0; i < len; i++) {
x |= BigInt(digest[i]) << BigInt(8 * i);
}
return x;
}

/**
* Jump Consistent Hash (Lamping & Veach) implemented with 64-bit arithmetic.
* @param {bigint} key - 64-bit unsigned key
* @param {number} buckets - number of buckets (indexes)
* @returns {number}
*/
#jumpHash(key, buckets) {
// Constants per paper
const mul = 2862933555777941757n;
let b = -1;
let j = 0;
while (j < buckets) {
b = j;
key = (key * mul + 1n) & ((1n << 64n) - 1n); // mod 2^64
// (key >> 33) fits in 31 bits => safe to convert to Number
const r = Number(key >> 33n) + 1;
j = Math.floor((b + 1) * (2147483648 / r));
}
return b;
}
}

export default JumpConsistentHash;
84 changes: 84 additions & 0 deletions jump-hash.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, test, expect } from "bun:test";
import JumpConsistentHash from "./jump-hash.js";

describe("JumpConsistentHash", () => {
describe("constructor & size()", () => {
test("initializes with provided number of indexes", () => {
const jch = new JumpConsistentHash(16);
expect(jch.size()).toBe(16);
});

test("throws for invalid indexes in constructor", () => {
expect(() => new JumpConsistentHash()).toThrow("Indexes must be a positive integer");
expect(() => new JumpConsistentHash(0)).toThrow("Indexes must be a positive integer");
expect(() => new JumpConsistentHash(-1)).toThrow("Indexes must be a positive integer");
expect(() => new JumpConsistentHash(1.2)).toThrow("Indexes must be a positive integer");
});

test("setIndexes() updates and validates", () => {
const jch = new JumpConsistentHash(4);
expect(jch.size()).toBe(4);
jch.setIndexes(32);
expect(jch.size()).toBe(32);
expect(() => jch.setIndexes(0)).toThrow("Indexes must be a positive integer");
});
});

describe("getIndex()", () => {
test("throws for empty key", () => {
const jch = new JumpConsistentHash(8);
expect(() => jch.getIndex("")).toThrow("Key must be a non-empty string");
});

test("returns index within range", () => {
const jch = new JumpConsistentHash(8);
const idx = jch.getIndex("alpha");
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeLessThan(8);
});

test("deterministic for same key", () => {
const jch = new JumpConsistentHash(8);
const k = "user-12345";
const a = jch.getIndex(k);
const b = jch.getIndex(k);
expect(a).toBe(b);
});

test("one bucket always maps to 0", () => {
const jch = new JumpConsistentHash(1);
const keys = ["a", "b", "c", "d", "e"];
for (const k of keys) {
expect(jch.getIndex(k)).toBe(0);
}
});

test("changes reflect with updated bucket count", () => {
const jch = new JumpConsistentHash(4);
const k = "remap-key";
const before = jch.getIndex(k);
expect(before).toBeGreaterThanOrEqual(0);
expect(before).toBeLessThan(4);
jch.setIndexes(9);
const after = jch.getIndex(k);
expect(after).toBeGreaterThanOrEqual(0);
expect(after).toBeLessThan(9);
});

test("distributes keys relatively evenly", () => {
const buckets = 8;
const jch = new JumpConsistentHash(buckets);
const totalKeys = 10000;
const counts = new Array(buckets).fill(0);
for (let i = 0; i < totalKeys; i++) {
const idx = jch.getIndex(`key-${i}`);
counts[idx]++;
}
const avg = totalKeys / buckets;
const tolerance = avg * 0.25; // 25% tolerance
for (const c of counts) {
expect(Math.abs(c - avg)).toBeLessThanOrEqual(tolerance);
}
});
});
});
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
{
"name": "fast-hashring",
"version": "1.0.1",
"description": "A library for consistent hashing using virtual nodes for improved key distribution.",
"description": "A library for consistent hashing.",
"main": "consistent-hash.js",
"type": "module",
"scripts": {
"test": "bun test"
"test": "bun test --coverage"
},
"keywords": [
"consistent-hashing",
"hashing",
"virtual-nodes",
"npm-module"
],
"types": "consistent-hash.d.ts",
"types": "index.d.ts",
Comment thread
jkyberneees marked this conversation as resolved.
"author": "Rolando Santamaria Maso <kyberneees@gmail.com>",
"license": "MIT",
"files": [
"consistent-hash.js",
"consistent-hash.d.ts",
"jump-hash.js",
"jump-hash.d.ts",
"index.d.ts",
"README.md",
"LICENSE"
]
Expand Down