| /** | |
| * Assigns string inputs to nodes with weighted consistent hashing. | |
| * | |
| * A hash ring minimizes remapping when nodes are added, removed, or reweighted. | |
| * This makes it useful for routing requests, partitioning keys, and | |
| * distributing shards across service instances or storage backends. This module | |
| * can create rings, add or remove nodes by `PrimaryKey`, route an input string | |
| * to a node, and compute shard assignments. | |
| * | |
| * @since 4.0.0 | |
| */ | |
| import { dual } from "./Function.ts" | |
| import * as Hash from "./Hash.ts" | |
| import { PipeInspectableProto } from "./internal/core.ts" | |
| import * as Iterable from "./Iterable.ts" | |
| import type { Pipeable } from "./Pipeable.ts" | |
| import { hasProperty } from "./Predicate.ts" | |
| import * as PrimaryKey from "./PrimaryKey.ts" | |
| const TypeId = "~effect/cluster/HashRing" as const | |
| /** | |
| * A weighted consistent-hashing ring for assigning inputs to nodes with stable | |
| * remapping as nodes are added or removed. | |
| * | |
| * **When to use** | |
| * | |
| * Use to maintain a mutable weighted hash ring for routing keys or shards to | |
| * nodes identified by `PrimaryKey`. | |
| * | |
| * **Details** | |
| * | |
| * Nodes are identified by their `PrimaryKey` value and can be iterated from the | |
| * ring. | |
| * | |
| * @category models | |
| * @since 3.19.0 | |
| */ | |
| export interface HashRing<A extends PrimaryKey.PrimaryKey> extends Pipeable, Iterable<A> { | |
| readonly [TypeId]: typeof TypeId | |
| readonly baseWeight: number | |
| totalWeightCache: number | |
| readonly nodes: Map<string, [node: A, weight: number]> | |
| ring: Array<[hash: number, node: string]> | |
| } | |
| /** | |
| * Checks whether a value is a `HashRing`. | |
| * | |
| * **When to use** | |
| * | |
| * Use to narrow an `unknown` value before treating it as a `HashRing`, such as | |
| * values crossing an untyped boundary. | |
| * | |
| * **Details** | |
| * | |
| * The guard checks for the module's internal `TypeId` property and narrows to | |
| * `HashRing<any>`. | |
| * | |
| * **Gotchas** | |
| * | |
| * This is a structural type-id check; it does not validate the ring's `nodes`, | |
| * `ring`, or weight state. | |
| * | |
| * @see {@link HashRing} for the type narrowed by this guard | |
| * @see {@link make} for creating an empty `HashRing` | |
| * | |
| * @category guards | |
| * @since 3.19.0 | |
| */ | |
| export const isHashRing = (u: unknown): u is HashRing<any> => hasProperty(u, TypeId) | |
| /** | |
| * Creates an empty `HashRing`. | |
| * | |
| * **When to use** | |
| * | |
| * Use to create an empty weighted consistent-hashing ring with the default or | |
| * custom virtual-point density. | |
| * | |
| * **Details** | |
| * | |
| * `baseWeight` controls how many virtual points are added for a node with | |
| * weight `1`; it defaults to `128` and is clamped to at least `1`. | |
| * | |
| * @see {@link add} for registering one node after creation | |
| * @see {@link addMany} for registering several nodes after creation | |
| * | |
| * @category constructors | |
| * @since 3.19.0 | |
| */ | |
| export const make = <A extends PrimaryKey.PrimaryKey>(options?: { | |
| readonly baseWeight?: number | undefined | |
| }): HashRing<A> => { | |
| const self = Object.create(Proto) | |
| self.baseWeight = Math.max(options?.baseWeight ?? 128, 1) | |
| self.totalWeightCache = 0 | |
| self.nodes = new Map() | |
| self.ring = [] | |
| return self | |
| } | |
| const Proto = { | |
| ...PipeInspectableProto, | |
| [TypeId]: TypeId, | |
| [Symbol.iterator]<A extends PrimaryKey.PrimaryKey>(this: HashRing<A>): Iterator<A> { | |
| return Iterable.map(this.nodes.values(), ([n]) => n)[Symbol.iterator]() | |
| }, | |
| toJSON(this: HashRing<any>) { | |
| return { | |
| _id: "HashRing", | |
| baseWeight: this.baseWeight, | |
| nodes: this.ring.map(([, n]) => this.nodes.get(n)![0]) | |
| } | |
| } | |
| } | |
| /** | |
| * Adds new nodes to the ring. If a node already exists in the ring, it | |
| * will be updated. For example, you can use this to update the node's weight. | |
| * | |
| * **When to use** | |
| * | |
| * Use to register or update several nodes in a `HashRing` at the same weight. | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| export const addMany: { | |
| /** | |
| * Adds new nodes to the ring. If a node already exists in the ring, it | |
| * will be updated. For example, you can use this to update the node's weight. | |
| * | |
| * **When to use** | |
| * | |
| * Use to register or update several nodes in a `HashRing` at the same weight. | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| <A extends PrimaryKey.PrimaryKey>( | |
| nodes: Iterable<A>, | |
| options?: { | |
| readonly weight?: number | undefined | |
| } | |
| ): (self: HashRing<A>) => HashRing<A> | |
| /** | |
| * Adds new nodes to the ring. If a node already exists in the ring, it | |
| * will be updated. For example, you can use this to update the node's weight. | |
| * | |
| * **When to use** | |
| * | |
| * Use to register or update several nodes in a `HashRing` at the same weight. | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| <A extends PrimaryKey.PrimaryKey>( | |
| self: HashRing<A>, | |
| nodes: Iterable<A>, | |
| options?: { | |
| readonly weight?: number | undefined | |
| } | |
| ): HashRing<A> | |
| } = dual( | |
| (args) => isHashRing(args[0]), | |
| <A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, nodes: Iterable<A>, options?: { | |
| readonly weight?: number | undefined | |
| }): HashRing<A> => { | |
| const weight = Math.max(options?.weight ?? 1, 0.1) | |
| const keys: Array<string> = [] | |
| let toRemove: Set<string> | undefined | |
| for (const node of nodes) { | |
| const key = PrimaryKey.value(node) | |
| const entry = self.nodes.get(key) | |
| if (entry) { | |
| if (entry[1] === weight) continue | |
| toRemove ??= new Set() | |
| toRemove.add(key) | |
| self.totalWeightCache -= entry[1] | |
| self.totalWeightCache += weight | |
| entry[1] = weight | |
| } else { | |
| self.nodes.set(key, [node, weight]) | |
| self.totalWeightCache += weight | |
| } | |
| keys.push(key) | |
| } | |
| if (toRemove) { | |
| self.ring = self.ring.filter(([, n]) => !toRemove.has(n)) | |
| } | |
| addNodesToRing(self, keys, Math.round(weight * self.baseWeight)) | |
| return self | |
| } | |
| ) | |
| function addNodesToRing<A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, keys: Array<string>, weight: number) { | |
| for (let i = weight; i > 0; i--) { | |
| for (let j = 0; j < keys.length; j++) { | |
| const key = keys[j] | |
| self.ring.push([ | |
| Hash.string(`${key}:${i}`), | |
| key | |
| ]) | |
| } | |
| } | |
| self.ring.sort((a, b) => a[0] - b[0]) | |
| } | |
| /** | |
| * Adds a new node to the ring. If the node already exists in the ring, it | |
| * will be updated. For example, you can use this to update the node's weight. | |
| * | |
| * **When to use** | |
| * | |
| * Use to register one node in a `HashRing` so lookups and shard assignments can | |
| * return it, or update that node's weight. | |
| * | |
| * **Details** | |
| * | |
| * Nodes are matched by `PrimaryKey.value`. The weight defaults to `1` and is | |
| * clamped to at least `0.1`. | |
| * | |
| * **Gotchas** | |
| * | |
| * This mutates and returns the same ring instance. | |
| * | |
| * @see {@link addMany} for adding or updating several nodes | |
| * @see {@link remove} for unregistering a node | |
| * @see {@link has} for checking primary-key membership | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| export const add: { | |
| /** | |
| * Adds a new node to the ring. If the node already exists in the ring, it | |
| * will be updated. For example, you can use this to update the node's weight. | |
| * | |
| * **When to use** | |
| * | |
| * Use to register one node in a `HashRing` so lookups and shard assignments can | |
| * return it, or update that node's weight. | |
| * | |
| * **Details** | |
| * | |
| * Nodes are matched by `PrimaryKey.value`. The weight defaults to `1` and is | |
| * clamped to at least `0.1`. | |
| * | |
| * **Gotchas** | |
| * | |
| * This mutates and returns the same ring instance. | |
| * | |
| * @see {@link addMany} for adding or updating several nodes | |
| * @see {@link remove} for unregistering a node | |
| * @see {@link has} for checking primary-key membership | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| <A extends PrimaryKey.PrimaryKey>( | |
| node: A, | |
| options?: { | |
| readonly weight?: number | undefined | |
| } | |
| ): (self: HashRing<A>) => HashRing<A> | |
| /** | |
| * Adds a new node to the ring. If the node already exists in the ring, it | |
| * will be updated. For example, you can use this to update the node's weight. | |
| * | |
| * **When to use** | |
| * | |
| * Use to register one node in a `HashRing` so lookups and shard assignments can | |
| * return it, or update that node's weight. | |
| * | |
| * **Details** | |
| * | |
| * Nodes are matched by `PrimaryKey.value`. The weight defaults to `1` and is | |
| * clamped to at least `0.1`. | |
| * | |
| * **Gotchas** | |
| * | |
| * This mutates and returns the same ring instance. | |
| * | |
| * @see {@link addMany} for adding or updating several nodes | |
| * @see {@link remove} for unregistering a node | |
| * @see {@link has} for checking primary-key membership | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| <A extends PrimaryKey.PrimaryKey>( | |
| self: HashRing<A>, | |
| node: A, | |
| options?: { | |
| readonly weight?: number | undefined | |
| } | |
| ): HashRing<A> | |
| } = dual((args) => isHashRing(args[0]), <A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, node: A, options?: { | |
| readonly weight?: number | undefined | |
| }): HashRing<A> => addMany(self, [node], options)) | |
| /** | |
| * Removes the node from the ring. No-op's if the node does not exist. | |
| * | |
| * **When to use** | |
| * | |
| * Use to remove a node that has left the pool so future lookups and shard | |
| * assignments stop returning it. | |
| * | |
| * **Details** | |
| * | |
| * Removal matches by `PrimaryKey.value`, so any value with the same primary key | |
| * removes the same ring member. | |
| * | |
| * **Gotchas** | |
| * | |
| * This mutates and returns the same ring instance. | |
| * | |
| * @see {@link add} for registering or updating a node | |
| * @see {@link has} for checking membership by primary key | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| export const remove: { | |
| /** | |
| * Removes the node from the ring. No-op's if the node does not exist. | |
| * | |
| * **When to use** | |
| * | |
| * Use to remove a node that has left the pool so future lookups and shard | |
| * assignments stop returning it. | |
| * | |
| * **Details** | |
| * | |
| * Removal matches by `PrimaryKey.value`, so any value with the same primary key | |
| * removes the same ring member. | |
| * | |
| * **Gotchas** | |
| * | |
| * This mutates and returns the same ring instance. | |
| * | |
| * @see {@link add} for registering or updating a node | |
| * @see {@link has} for checking membership by primary key | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| <A extends PrimaryKey.PrimaryKey>(node: A): (self: HashRing<A>) => HashRing<A> | |
| /** | |
| * Removes the node from the ring. No-op's if the node does not exist. | |
| * | |
| * **When to use** | |
| * | |
| * Use to remove a node that has left the pool so future lookups and shard | |
| * assignments stop returning it. | |
| * | |
| * **Details** | |
| * | |
| * Removal matches by `PrimaryKey.value`, so any value with the same primary key | |
| * removes the same ring member. | |
| * | |
| * **Gotchas** | |
| * | |
| * This mutates and returns the same ring instance. | |
| * | |
| * @see {@link add} for registering or updating a node | |
| * @see {@link has} for checking membership by primary key | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| <A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, node: A): HashRing<A> | |
| } = dual(2, <A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, node: A): HashRing<A> => { | |
| const key = PrimaryKey.value(node) | |
| const entry = self.nodes.get(key) | |
| if (entry) { | |
| self.nodes.delete(key) | |
| self.ring = self.ring.filter(([, n]) => n !== key) | |
| self.totalWeightCache -= entry[1] | |
| } | |
| return self | |
| }) | |
| /** | |
| * Checks whether the ring contains a node with the same `PrimaryKey` value. | |
| * | |
| * **When to use** | |
| * | |
| * Use when you need to know whether registering a node would update an existing | |
| * ring member because another node already has the same primary-key identity. | |
| * | |
| * **Details** | |
| * | |
| * Membership is checked with `self.nodes.has(PrimaryKey.value(node))`, so | |
| * matching is by primary key, not object identity or weight. | |
| * | |
| * @see {@link add} for registering or updating nodes | |
| * @see {@link remove} for removing nodes by the same primary-key identity | |
| * @see {@link get} for routing an input string to a node | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| export const has: { | |
| /** | |
| * Checks whether the ring contains a node with the same `PrimaryKey` value. | |
| * | |
| * **When to use** | |
| * | |
| * Use when you need to know whether registering a node would update an existing | |
| * ring member because another node already has the same primary-key identity. | |
| * | |
| * **Details** | |
| * | |
| * Membership is checked with `self.nodes.has(PrimaryKey.value(node))`, so | |
| * matching is by primary key, not object identity or weight. | |
| * | |
| * @see {@link add} for registering or updating nodes | |
| * @see {@link remove} for removing nodes by the same primary-key identity | |
| * @see {@link get} for routing an input string to a node | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| <A extends PrimaryKey.PrimaryKey>(node: A): (self: HashRing<A>) => boolean | |
| /** | |
| * Checks whether the ring contains a node with the same `PrimaryKey` value. | |
| * | |
| * **When to use** | |
| * | |
| * Use when you need to know whether registering a node would update an existing | |
| * ring member because another node already has the same primary-key identity. | |
| * | |
| * **Details** | |
| * | |
| * Membership is checked with `self.nodes.has(PrimaryKey.value(node))`, so | |
| * matching is by primary key, not object identity or weight. | |
| * | |
| * @see {@link add} for registering or updating nodes | |
| * @see {@link remove} for removing nodes by the same primary-key identity | |
| * @see {@link get} for routing an input string to a node | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| <A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, node: A): boolean | |
| } = dual( | |
| 2, | |
| <A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, node: A): boolean => self.nodes.has(PrimaryKey.value(node)) | |
| ) | |
| /** | |
| * Gets the node which should handle the given input. Returns undefined if | |
| * the hashring has no elements with weight. | |
| * | |
| * **When to use** | |
| * | |
| * Use to route a single string input key to the current ring member responsible | |
| * for that key. | |
| * | |
| * @see {@link getShards} for assigning fixed shard indexes instead of routing | |
| * one input string at a time | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| export const get = <A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, input: string): A | undefined => { | |
| if (self.ring.length === 0) { | |
| return undefined | |
| } | |
| const index = getIndexForInput(self, Hash.string(input))[0] | |
| const node = self.ring[index][1]! | |
| return self.nodes.get(node)![0] | |
| } | |
| /** | |
| * Computes a balanced shard distribution across the nodes in the ring. | |
| * | |
| * **When to use** | |
| * | |
| * Use to precompute ownership for a fixed number of shard indexes across the | |
| * current ring members. | |
| * | |
| * @category combinators | |
| * @since 3.19.0 | |
| */ | |
| export const getShards = <A extends PrimaryKey.PrimaryKey>(self: HashRing<A>, count: number): Array<A> | undefined => { | |
| if (self.ring.length === 0) { | |
| return undefined | |
| } | |
| const shards = new Array<A>(count) | |
| // for tracking how many shards have been allocated to each node | |
| const allocations = new Map<string, number>() | |
| // for tracking which shards still need to be allocated | |
| const remaining = new Set<number>() | |
| // for tracking which nodes have reached the max allocation | |
| const exclude = new Set<string>() | |
| // First pass - allocate the closest nodes, skipping nodes that have reached | |
| // max | |
| const distances = new Array<[shard: number, node: string, distance: number]>(count) | |
| for (let shard = 0; shard < count; shard++) { | |
| const hash = (shardHashes[shard] ??= Hash.string(`shard-${shard}`)) | |
| const [index, distance] = getIndexForInput(self, hash) | |
| const node = self.ring[index][1]! | |
| distances[shard] = [shard, node, distance] | |
| remaining.add(shard) | |
| } | |
| distances.sort((a, b) => a[2] - b[2]) | |
| for (let i = 0; i < count; i++) { | |
| const [shard, node] = distances[i] | |
| if (exclude.has(node)) continue | |
| const [value, weight] = self.nodes.get(node)! | |
| shards[shard] = value | |
| remaining.delete(shard) | |
| const nodeCount = (allocations.get(node) ?? 0) + 1 | |
| allocations.set(node, nodeCount) | |
| const maxPerNode = Math.max(1, Math.floor(count * (weight / self.totalWeightCache))) | |
| if (nodeCount >= maxPerNode) { | |
| exclude.add(node) | |
| } | |
| } | |
| // Second pass - allocate any remaining shards, skipping nodes that have | |
| // reached max | |
| let allAtMax = exclude.size === self.nodes.size | |
| remaining.forEach((shard) => { | |
| const index = getIndexForInput(self, shardHashes[shard], allAtMax ? undefined : exclude)[0] | |
| const node = self.ring[index][1] | |
| const [value, weight] = self.nodes.get(node)! | |
| shards[shard] = value | |
| if (allAtMax) return | |
| const nodeCount = (allocations.get(node) ?? 0) + 1 | |
| allocations.set(node, nodeCount) | |
| const maxPerNode = Math.max(1, Math.floor(count * (weight / self.totalWeightCache))) | |
| if (nodeCount >= maxPerNode) { | |
| exclude.add(node) | |
| if (exclude.size === self.nodes.size) { | |
| allAtMax = true | |
| } | |
| } | |
| }) | |
| return shards | |
| } | |
| const shardHashes: Array<number> = [] | |
| function getIndexForInput<A extends PrimaryKey.PrimaryKey>( | |
| self: HashRing<A>, | |
| hash: number, | |
| exclude?: ReadonlySet<string> | undefined | |
| ): readonly [index: number, distance: number] { | |
| const ring = self.ring | |
| const len = ring.length | |
| let mid: number | |
| let lo = 0 | |
| let hi = len - 1 | |
| while (lo <= hi) { | |
| mid = ((lo + hi) / 2) >>> 0 | |
| if (ring[mid][0] >= hash) { | |
| hi = mid - 1 | |
| } else { | |
| lo = mid + 1 | |
| } | |
| } | |
| const a = lo === len ? lo - 1 : lo | |
| const distA = Math.abs(ring[a][0] - hash) | |
| if (exclude === undefined) { | |
| const b = lo - 1 | |
| if (b < 0) { | |
| return [a, distA] | |
| } | |
| const distB = Math.abs(ring[b][0] - hash) | |
| return distA <= distB ? [a, distA] : [b, distB] | |
| } else if (!exclude.has(ring[a][1])) { | |
| return [a, distA] | |
| } | |
| const range = Math.max(lo, len - lo) | |
| for (let i = 1; i < range; i++) { | |
| let index = lo - i | |
| if (index >= 0 && index < len && !exclude.has(ring[index][1])) { | |
| return [index, Math.abs(ring[index][0] - hash)] | |
| } | |
| index = lo + i | |
| if (index >= 0 && index < len && !exclude.has(ring[index][1])) { | |
| return [index, Math.abs(ring[index][0] - hash)] | |
| } | |
| } | |
| return [a, distA] | |
| } | |
Xet Storage Details
- Size:
- 18.1 kB
- Xet hash:
- 73b719207562e75b47841a254c54ffb3245d16e74b9f7057455ef01f573524b2
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.