Sign in

    hashwheel

    Consistent hashing hash ring, a MoonBit port of hashwheel

    consistent hashing
    hash ring
    hashwheel
    distribution
    Download zip
    Author
    Version
    0.1.2
    License
    Apache-2.0
    Last updated
    9 hours ago
    Downloads
    4

    #hashwheel (MoonBit)

    A consistent hashing hash ring for MoonBit, ported from the JavaScript hashwheel package (MurmurHash3 plus a control point ring). It has no dependencies beyond the MoonBit standard library (moonbitlang/core/random).

    A hash ring maps resource names (strings) to nodes in a stable way. When a node is added or removed, only the resources that node owned are reassigned; every other mapping stays where it was, which is exactly what sharded caches, database sharding and load balancing need.

    let hr : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new()
    ignore(hr.add("server1"))
    ignore(hr.add("server2"))

    let node = hr.get("resourceName") // Some("server1") or Some("server2")

    #Features

    • Bit-for-bit compatible with the JavaScript version: string hashes, the uniform control points and the resulting resource mappings are identical to the JavaScript hashwheel implementation (verified input by input).
    • Hash: MurmurHash3 (x86 32-bit) over UTF-16 code units, matching JavaScript string semantics, returning an unsigned 32-bit value.
    • O(n log n) insertion: adding any number of nodes never degrades to O(n²).
    • Fast lookup: control points are kept sorted and searched with an "approximate binary" search (binary search down to 25 entries, then a linear scan); the hot path only touches one sorted array and one parallel node array.
    • Two control point layouts:
      • Random: scatter points and detect collisions (default);
      • Uniform: interleave evenly spaced points, fully deterministic — the same nodes in the same order always produce the same ring.
    • Weights: weight decides how many control points a node owns, and therefore how much of the resource space it serves (nodes weighted 1:2:3 serve 1/6, 1/3 and 1/2 of the resources).
    • Explicit control points: pass a points array for low-level control over the resource distribution.
    • Optional LRU lookup cache: cache=n caches get() results (the cache is dropped whenever a node is added or removed).
    • Multi node lookup: get_many(name, count) returns the count nearest distinct nodes starting at the resource, for replica selection.
    • Generic nodes: the node type is a generic T (requires Eq, usually just String); String, Int or your own type all work.
    • Reproducible: Uniform distribution is deterministic by construction, and Random distribution accepts a 32-byte seed to reproduce the same ring.
    • Pure library: no third-party packages, builds for native, wasm, wasm-gc and js targets.

    #How it works

    • The ring: range positions (default 100003; an odd range, preferably a prime relatively prime to the number of nodes, distributes better). The ring capacity is the range of hash(name) % range.
    • Control points (tokens): each node owns weight control points around the ring. Giving a node more weight means giving it more control points.
    • Lookup: point = murmur3(name) % range; the node owning the first control point at or after point handles the resource. If point is larger than every control point, the search wraps around to the first control point.
    • Random placement: Random mode creates control points during add(), probing for free positions with an occupancy set. Each point is retried at most 100 times; random probing fills roughly 90% of a ring.
    • Uniform placement: Uniform mode assigns points on first use (first lookup or get_points) using step = range / (nodes * weight), interleaving the control points of consecutive nodes around the ring.
    • Capacity: the theoretical node count is range / weight (2500 with the defaults), which is a completely full ring; in practice about 90% can be filled. For more nodes use a wider range (such as 1000003) or a smaller weight (such as 4).

    #Build and test

    Use the MoonBit toolchain from the module root:

    moon check # type check moon test # run all tests moon info # refresh pkg.generated.mbti moon fmt # format the sources

    To depend on this module, declare it in the import section of your moon.mod.

    #API

    Every option is a labelled argument (name=value) and may be omitted.

    #ConsistentHash::new(...)

    Creates a hash ring.

    ParameterTypeDefaultDescription
    rangeInt100003Ring capacity (control point modulo); a prime is best
    weightInt40Default number of control points per node
    distributionDistributionRandomRandom scatter or Uniform interleave
    order_nodes(Array[T]) -> Array[T]noneUniform only: the order in which pending nodes get their points
    cacheInt0LRU cache size for get() results, 0 disables it
    nodesArray[T]noneNodes to add right away (same as calling add in order)
    seedBytesnone32-byte random seed to reproduce a Random ring

    // uniform distribution, 40 control points per node, 100-entry lookup cache
    ///|
    let hr : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new(
    distribution=Uniform,
    cache=100,
    )

    // a small ring, easy to inspect: range=24, 4 control points per node

    ///|
    let small : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new(
    range=24,
    weight=4,
    nodes=["a", "b", "c"],
    )

    #hr.add(node, weight?, points?) -> Self

    Adds a node to the ring (adding the same node again increases its weight). Returns the ring, so calls can be chained.

    • weight: number of control points for this node, defaults to the ring weight;
    • points: an explicit control point array; when given, no random or uniform points are generated for this node.

    ignore(hr.add("server1")) // default weight
    ignore(hr.add("server2", weight=120)) // heavier, serves more resources
    ignore(hr.add("server3", points=[10, 20, 30]))

    #hr.remove(node) -> Self

    Removes all entries of the node and frees its control points; the freed positions may be reused by nodes added later. Removing a node that is not on the ring does nothing. Returns the ring.

    ignore(hr.remove("server1"))

    #hr.get(name) -> T?

    Returns the node that handles name, or None if the ring is empty. This is the hot path; it uses the cache when one is enabled.

    match hr.get("user:42") {
    Some(node) => println("handled by \{node}")
    None => println("no nodes on the ring")
    }

    #hr.get_many(name, count) -> Array[T]?

    Returns up to count distinct nodes, starting with the node that handles name and continuing to the nearest following nodes around the ring. Returns None if the ring is empty, and all nodes when there are fewer than count of them. It never uses the lookup cache.

    ///|
    let replicas = hr.get_many("user:42", 3) // primary plus two fallbacks

    #Inspection

    MethodDescription
    hr.get_nodes() -> Array[T]All nodes currently on the ring, in insertion order
    hr.get_points(node) -> Array[Int]?The control points of the node, None if it is not on the ring
    hr.node_countNumber of nodes on the ring
    hr.key_countNumber of control points around the ring

    #Package level

    ItemDescription
    murmur3(name) -> UIntThe MurmurHash3 hash (32-bit unsigned) used for resource names
    default_rangeDefault ring capacity, 100003
    default_weightDefault control points per node, 40
    DistributionRandom / Uniform

    #API mapping from JavaScript

    JavaScript (hashwheel)MoonBit
    new ConsistentHash({ range, weight, distribution, orderNodes, cache, nodes })ConsistentHash::new(range=, weight=, distribution=, order_nodes=, cache=, nodes=) (plus seed=)
    hr.add(node, n, points)hr.add(node, weight=, points=)
    hr.remove(node)hr.remove(node)
    hr.get(name)hr.get(name)
    hr.get(name, count)hr.get_many(name, count)
    hr.getNodes()hr.get_nodes()
    hr.getPoints(node)hr.get_points(node)
    hr.nodeCount / hr.keyCounthr.node_count / hr.key_count

    #Behaviour and caveats

    • The node type must implement Eq (remove, get_points and get_many use it to find and de-duplicate nodes by value).
    • get / get_many return None on an empty ring (the JavaScript version returns null).
    • With Uniform distribution every node uses the ring's weight, so a per-call weight argument is ignored (same as JavaScript); uniform control points are only materialised on first use.
    • Adding the same node several times increases its weight.
    • If the ring is too full (100 retries per point all collide) the program aborts (JavaScript throws an exception); use a larger range or a smaller weight.
    • A Random ring differs from run to run. For reproducibility use Uniform, or pass the same seed.

    #Tests

    moon test

    52 tests in total:

    • hashwheel_test.mbt (blackbox): constructors, adding and removing nodes, weights, uniform and random distribution, multi node lookup, cache invalidation, lookup distribution fairness, filling 80% of a ring with 2000 nodes, and more;
    • hashwheel_wbtest.mbt (whitebox): the approximate binary search, hashes matching the JavaScript values, sorted index construction, uniform point coordinates, LRU eviction;
    • the mbt check examples in the source comments also run as tests.

    For coverage:

    moon coverage analyze > uncovered.log

    #Layout

    moon.mod module definition moon.pkg package dependencies (moonbitlang/core/random) consistent_hash.mbt the ring: ConsistentHash, add/remove/get/get_many/... hash.mbt MurmurHash3 lru_cache.mbt optional LRU lookup cache hashwheel_test.mbt blackbox tests hashwheel_wbtest.mbt whitebox tests pkg.generated.mbti generated public interface (moon info) README.mbt.md this file LICENSE Apache-2.0

    #License

    Apache-2.0, copyright 2026 tomasky.

    The algorithm, default parameters and observable behaviour are ported from the JavaScript hashwheel package by Andras Radics (Apache-2.0), which in turn derives from his PHP ConsistentHash implementation.

    ConsistentHash

    pub struct ConsistentHash[T] {
    node_count : Int
    key_count : Int
    // private fields
    }

    A consistent hashing ring of nodes of type T.

    T must implement Eq so that a node can be found again by remove and de-duplicated by get_many; String is the usual choice.

    Nodes are stored in nodes in insertion order; each node owns one array of control points in node_keys. key_map resolves a control point to its node. The sorted arrays keys and nodes_sorted are the lookup index and are lazily (re)built by add, remove and get.

    test {
    let hr : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new()
    ignore(hr.add("server1").add("server2"))
    let node = hr.get("resourceName")
    assert_true(node is Some("server1" | "server2"))
    }

    ConsistentHash::add

    fn[T] ConsistentHash::add(self : ConsistentHash[T], node : T, weight? : Int, points? : Array[Int]) -> ConsistentHash[T]

    Register node as also managing resources, with weight control points (or an explicit points array).

    The node's share of the resources is proportionate to its weight; the default is the ring's weight. Adding the same node twice increases its weight. Returns the ring, so calls can be chained.

    test {
    let hr : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new(
    range=24,
    weight=4,
    )
    ignore(hr.add("a").add("b", weight=8))
    assert_eq(hr.key_count, 12)
    }

    ConsistentHash::get

    fn[T] ConsistentHash::get(self : ConsistentHash[T], name : String) -> T?

    Return the node that handles name, or None if the ring is empty.

    test {
    let hr : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new(nodes=[
    "n1",
    ])
    assert_eq(hr.get("anything"), Some("n1"))
    }

    ConsistentHash::get_many

    fn[T : Eq] ConsistentHash::get_many(self : ConsistentHash[T], name : String, count : Int) -> Array[T]?

    Return up to count distinct nodes, starting with the node that handles name and continuing to the nearest following nodes around the ring.

    Returns None if the ring is empty. If fewer distinct nodes than count are on the ring, all of them are returned. Never uses the lookup cache.

    test {
    let hr : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new(nodes=[
    "a", "b", "c",
    ])
    let nodes = hr.get_many("resource", 2)
    assert_eq(nodes.unwrap().length(), 2)
    }

    ConsistentHash::get_nodes

    fn[T] ConsistentHash::get_nodes(self : ConsistentHash[T]) -> Array[T]

    All nodes currently on the ring, in insertion order.

    ConsistentHash::get_points

    fn[T : Eq] ConsistentHash::get_points(self : ConsistentHash[T], node : T) -> Array[Int]?

    The control points assigned to node, or None if the node is not on the ring.

    ConsistentHash::new

    fn[T] ConsistentHash::new(range? : Int, weight? : Int, distribution? : Distribution, order_nodes? : (Array[T]) -> Array[T], cache? : Int, nodes? : Array[T], seed? : Bytes) -> ConsistentHash[T]

    Create an empty ring.

    Parameters (all optional, pass by label):

    • range : hash ring capacity, the modulus of the sorted control points. Smaller values distribute better; the default is 100003. Prefer an odd range relatively prime to the number of nodes.
    • weight : default number of control points per node, default 40. Three nodes added with weights 1, 2 and 3 handle 1/6, 1/3 and 1/2 of the resources each.
    • distribution : Random (default) or Uniform.
    • order_nodes : called with the nodes that still need uniformly distributed control points; returns them in the order to assign points in. Only used by Uniform.
    • cache : size of an optional LRU cache of get results, 0 disables it. The cache is dropped whenever a node is added or removed.
    • nodes : nodes to add right away.
    • seed : a 32-byte seed for the control point generator; the same seed reproduces the same random ring.

    test {
    let hr : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new(nodes=[
    "a", "b", "c",
    ])
    assert_eq(hr.node_count, 3)
    assert_eq(hr.get_nodes(), ["a", "b", "c"])
    }

    ConsistentHash::remove

    fn[T : Eq] ConsistentHash::remove(self : ConsistentHash[T], node : T) -> ConsistentHash[T]

    Remove all entries of node from the ring and free its control points. Freed points may be handed to nodes added later. Removing a node that is not on the ring does nothing. Returns the ring.

    test {
    let hr : @hashwheel.ConsistentHash[String] = @hashwheel.ConsistentHash::new(nodes=[
    "a", "b", "a",
    ])
    assert_eq(hr.node_count, 3)
    ignore(hr.remove("a"))
    assert_eq(hr.node_count, 1)
    assert_eq(hr.get_nodes(), ["b"])
    }

    Distribution

    pub(all) enum Distribution {
    Random
    Uniform
    }

    How control points are placed when add is not given an explicit points array.

    LruCache

    type LruCache[T]

    default_range

    let default_range : Int

    Default hash ring capacity: the control point modulo.

    default_weight

    let default_weight : Int

    Default number of control points created per node.

    murmur3

    fn murmur3(s : String) -> UInt

    MurmurHash3 (x86 32-bit) of the UTF-16 code units of s, as an unsigned 32-bit value.

    Two code units (four bytes) are consumed per round, the remainder is mixed in as a tail, and the finalization mix spreads the bits out. The hash does not have to be perfect, just well distributed; the ring takes it modulo range.

    The values below are the ones the javascript hashwheel package returns for the same inputs:

    test {
    inspect(@hashwheel.murmur3(""), content="0")
    inspect(@hashwheel.murmur3("a"), content="1009084850")
    inspect(@hashwheel.murmur3("abc"), content="1968171120")
    inspect(@hashwheel.murmur3("resourceName"), content="2023144987")
    inspect(@hashwheel.murmur3("🌟"), content="2007303233")
    }

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    Ā© 2026 mooncakes.io