import collections
import hashlib
import random
NODE_COUNTS = (4, 8)
SAMPLE = 20_000
SEED = 7
def corpus(n=SAMPLE):
random.seed(SEED)
out = []
quarter = n // 4
for _ in range(quarter):
first = random.choice([192, 10, 172, 185, 45, 91, 104])
out.append(
f"{first}.{random.randint(0, 255)}."
f"{random.randint(0, 255)}.{random.randint(1, 254)}"
)
for _ in range(quarter):
host = random.choice(["www", "mail", "cdn", "api", "ns1", ""])
label = "".join(random.choice("abcdefghijklmnop") for _ in range(random.randint(5, 11)))
out.append(f"{host + '.' if host else ''}{label}.com")
for _ in range(quarter):
label = "".join(random.choice("abcdefghijklmnop") for _ in range(10))
out.append(f"http://{label}.example/path")
for _ in range(quarter):
out.append("".join(random.choice("0123456789abcdef") for _ in range(64)))
return out
def fnv1a(text):
h = 0xCBF29CE484222325
for byte in text.encode():
h ^= byte
h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
return h
def sha256_64(text):
return int.from_bytes(hashlib.sha256(text.encode()).digest()[:8], "big")
def modulo(value, mirrors):
return fnv1a(value) % mirrors
def rendezvous(value, mirrors, hashf=sha256_64):
return max(mirrors, key=lambda name: hashf(f"{value}:{name}"))
def spread(values):
print("Read spread across mirrors")
print("==========================")
print(f"{len(values)} values shaped like a real feed.\n")
for mirrors in NODE_COUNTS:
ideal = 100 / mirrors
names = [f"m{i}" for i in range(mirrors)]
counts = collections.Counter(rendezvous(v, names) for v in values)
shares = [100 * counts.get(n, 0) / len(values) for n in names]
print(
f" {mirrors} mirrors, ideal {ideal:.1f}% each:"
f" min {min(shares):5.1f}% max {max(shares):5.1f}%"
f" imbalance {max(shares) / ideal:.2f}x"
)
print()
def rebalancing(values):
print("Repointing")
print("==========")
print("Share of values whose chosen mirror changes when one is added.")
print("No data moves: this is read routing only.\n")
print(f" {'change':<10} {'mod-n':>9} {'HRW+FNV':>9} {'HRW+SHA':>9} {'minimum':>9}")
for old, new in [(3, 4), (4, 5), (8, 9), (8, 16)]:
before = [f"n{i}" for i in range(old)]
after = [f"n{i}" for i in range(new)]
moved_mod = sum(1 for v in values if modulo(v, old) != modulo(v, new))
moved_fnv = sum(
1 for v in values if rendezvous(v, before, fnv1a) != rendezvous(v, after, fnv1a)
)
moved_sha = sum(
1 for v in values if rendezvous(v, before, sha256_64) != rendezvous(v, after, sha256_64)
)
n = len(values)
print(
f" {f'{old} -> {new}':<10}"
f" {100 * moved_mod / n:8.1f}%"
f" {100 * moved_fnv / n:8.1f}%"
f" {100 * moved_sha / n:8.1f}%"
f" {100 * (new - old) / new:8.1f}%"
)
print()
print(" mod-n repoints most of the values, because changing the divisor")
print(" renumbers nearly everything. Doubling is its one lucky case:")
print(" hash % 8 and hash % 16 agree whenever the fourth bit is clear,")
print(" so exactly half stay put. Gaining one mirror is the bad case,")
print(" and gaining one mirror is what actually happens.")
print()
print(" HRW+FNV is the trap: FNV-1a spreads values perfectly well (see")
print(" above) but makes a poor rendezvous weight, because appending")
print(" ':n0' / ':n1' perturbs it in a structured way and the comparison")
print(" between nodes becomes correlated. It only reaches the minimum by")
print(" luck, at 3 -> 4. Rendezvous needs real avalanche in the hash.")
print()
print(" Placement must also be STABLE across releases, so never reach")
print(" for std's DefaultHasher here: it is explicitly not stable, and a")
print(" toolchain upgrade would silently repoint every read.")
if __name__ == "__main__":
values = corpus()
spread(values)
rebalancing(values)