diff --git a/README.md b/README.md index 714fdf6..0162863 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. diff --git a/demos/consistent-hash.ts b/demos/consistent-hash.ts new file mode 100644 index 0000000..f947a12 --- /dev/null +++ b/demos/consistent-hash.ts @@ -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}`); diff --git a/demos/jump-consistent-hash.js b/demos/jump-consistent-hash.js new file mode 100644 index 0000000..b51c62c --- /dev/null +++ b/demos/jump-consistent-hash.js @@ -0,0 +1,6 @@ +const JumpConsistentHash = require("../jump-hash.js").default; + +const jch = new JumpConsistentHash(16); +const idx = jch.getIndex("user-123"); + +console.log(`Index for 'user-123': ${idx}`); diff --git a/demos/jump-consistent-hash.ts b/demos/jump-consistent-hash.ts new file mode 100644 index 0000000..afc7015 --- /dev/null +++ b/demos/jump-consistent-hash.ts @@ -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}`); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..579344c --- /dev/null +++ b/index.d.ts @@ -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; diff --git a/jump-hash.d.ts b/jump-hash.d.ts new file mode 100644 index 0000000..77b3376 --- /dev/null +++ b/jump-hash.d.ts @@ -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; diff --git a/jump-hash.js b/jump-hash.js new file mode 100644 index 0000000..688aeea --- /dev/null +++ b/jump-hash.js @@ -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(); + 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; diff --git a/jump-hash.test.js b/jump-hash.test.js new file mode 100644 index 0000000..3586610 --- /dev/null +++ b/jump-hash.test.js @@ -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); + } + }); + }); +}); diff --git a/package.json b/package.json index ab9f9db..b509938 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "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", @@ -13,12 +13,15 @@ "virtual-nodes", "npm-module" ], - "types": "consistent-hash.d.ts", + "types": "index.d.ts", "author": "Rolando Santamaria Maso ", "license": "MIT", "files": [ "consistent-hash.js", "consistent-hash.d.ts", + "jump-hash.js", + "jump-hash.d.ts", + "index.d.ts", "README.md", "LICENSE" ]