Topic: The transfer computation in add_node may over-count when multiple virtual nodes for the new node land in the same existing arc; worth verifying against the tests

Date: 2026-05-29

Time: 12:58

The Over-Counting Concern in add_node Transfers

What the code does

In consistenthashing.py:27-44, addnode inserts virtual nodes one at a time in a loop and computes a transfer map — which arcs of the ring move from an existing owner to the new node:


for i in range(vnode_count):
    pos = _hash(f"{node_id}:{i}")
    idx = bisect.bisect_left(self._ring_positions, pos)
    if self._ring_positions:
        succ_idx = idx % len(self._ring_positions)
        old_owner = self._ring_nodes[succ_idx]
        if old_owner != node_id:
            prev_idx = (idx - 1) % len(self._ring_positions)
            arc_start = (self._ring_positions[prev_idx] + 1) % RING_SIZE
            transfers[(arc_start, pos)] = (old_owner, node_id)
    self._ring_positions.insert(idx, pos)  # ring mutated HERE
    self._ring_nodes.insert(idx, node_id)

The critical detail: the ring is mutated at the bottom of each iteration (lines 43-44). Each subsequent vnode sees the ring with all previously-inserted vnodes already in place.

Why the concern arises

Imagine node A owns the arc from position 100 to 500, and new node B gets two vnodes at positions 200 and 300 — both landing in that same arc. The worry: do we record two overlapping transfers that together claim more ring space than actually moved?

Why it's actually correct

The incremental insertion prevents overlap. Walk through the two orderings:

If 200 is processed first:

1. Successor of 200 is 500 (A). Predecessor is 100 (A). Transfer: (101, 200) → A to B. Insert 200.

2. Successor of 300 is 500 (A). Predecessor is now 200 (B) — already in the ring. Transfer: (201, 300) → A to B. Insert 300.

Result: two non-overlapping arcs (101,200) and (201,300), total size 200. Correct.

If 300 is processed first:

1. Successor of 300 is 500 (A). Predecessor is 100 (A). Transfer: (101, 300) → A to B. Insert 300.

2. Successor of 200 is 300 (B). oldowner == nodeidskipped by the guard on line 38. Insert 200.

Result: one arc (101,300), total size 200. Also correct — the first transfer already covered the full range.

The guard if oldowner != nodeid (line 38) is doing real work here: when a vnode's successor is another vnode of the same new node, that range was already claimed by the earlier insertion. No transfer is needed.

What the tests actually verify

The relevant test at testconsistenthashing.py:139-146:


def test_add_node_returns_transfers():
    ring = ConsistentHashRing(num_vnodes=10)
    ring.add_node("A")
    transfers = ring.add_node("B")
    assert len(transfers) > 0
    for (start, end), (from_node, to_node) in transfers.items():
        assert from_node == "A"
        assert to_node == "B"

This test checks that transfers exist and flow from A to B. It does not verify:

So the concern is right to say "worth verifying against the tests" — the code is correct, but the tests are too weak to prove it. A test that sums the arc sizes in the transfer map and compares against B's actual load (via getloaddistribution) would close this gap.

The testminimalredistribution test at line 63 provides indirect validation — it checks that adding node D moves roughly 25% of 10,000 keys — but it tests routing correctness, not transfer-map correctness.

Topics to Explore

Beliefs