Approach summary:- Use consistent hashing (rendezvous/hash-ring) with virtual nodes (vnodes) to preserve cache locality.- Gradually add vnodes owned by the canary to increase its traffic weight; keep vnode IDs for stable mapping so most keys still map to same cache shard (minimize cache misses).- Avoid global cache flush: only new requests shifted to canary will populate its caches; keep origin servers' caches intact.- Rollback by decreasing/removing canary vnodes (stop new traffic) and letting TTLs/backfill restore locality.Code (pseudocode / Python-like) — core operations: build ring, shift weight by adding vnodes, query routing, graceful rollback.python
import hashlib
import bisect
from collections import defaultdict
def hash_fn(key):
return int(hashlib.sha256(key.encode()).hexdigest(), 16)
class ConsistentRing:
def __init__(self):
self.positions=[] # sorted vnode hashes
self.vnode_map={} # position -> backend_id
def add_vnode(self, backend_id, vnode_idx):
pos = hash_fn(f"{backend_id}:{vnode_idx}") % (2**64)
if pos in self.vnode_map: return
bisect.insort(self.positions, pos)
self.vnode_map[pos] = backend_id
def remove_vnode(self, backend_id, vnode_idx):
pos = hash_fn(f"{backend_id}:{vnode_idx}") % (2**64)
if pos in self.vnode_map:
i = bisect.bisect_left(self.positions, pos)
del self.positions[i]
del self.vnode_map[pos]
def get_backend(self, key):
if not self.positions: return None
pos = hash_fn(key) % (2**64)
i = bisect.bisect_right(self.positions, pos) % len(self.positions)
return self.vnode_map[self.positions[i]]
# Weighted shifting by vnode counts:
def set_weight(ring, backend_id, target_weight, base_vnodes=100):
# target_weight in [0,1] fraction of total traffic
# total_vnodes proportional to weight
total_vnodes = int(base_vnodes * (target_weight))
# Use deterministic vnode indices to preserve mapping when toggling weight
existing = [idx for idx in range(base_vnodes) if hash_fn(f"{backend_id}:{idx}") % (2**64) in ring.vnode_map and ring.vnode_map.get(hash_fn(f"{backend_id}:{idx}") % (2**64))==backend_id]
# add missing up to total_vnodes
for idx in range(total_vnodes):
ring.add_vnode(backend_id, idx)
# remove extras
for idx in range(total_vnodes, base_vnodes):
ring.remove_vnode(backend_id, idx)
# Example usage:
ring = ConsistentRing()
# initial production backends p1..p3 with full vnodes
for b in ["p1","p2","p3"]:
for i in range(100):
ring.add_vnode(b, i)
# Gradually introduce canary "c1" to 1%,5%,10%:
for frac in [0.01, 0.05, 0.10]:
set_weight(ring, "c1", frac, base_vnodes=100)
# wait for metrics/stability window before increasing
# Routing:
backend = ring.get_backend("customer:1234:resource")
Key reasoning and safeguards:- Using deterministic vnode indices ensures adding/removing vnodes changes only a bounded set of keys (proportional to vnode count), keeping cache locality.- Gradual increase: incremental vnode adds (e.g., 1% → 5% → 10%) avoids sudden cache miss spikes.- No global cache flush: only keys remapped move; others remain in original caches.Rollback steps:1. Immediately stop further weight increases.2. Rapid rollback: remove canary vnodes (set_weight(c1, 0)) so new requests stop routing to canary.3. Monitor error/slo/latency; if degraded, failover is immediate as consistent hash redirects to original backends.4. Allow canary caches to cool (let TTLs expire) — no global flush needed.5. If partial state change requires, run targeted backfills/migration for hot keys.Operational notes:- Use health checks before increasing weight; automate canary promotion with SLO-based gates.- Use sticky cookies or client-side affinity for session consistency if needed.- For highly-hot keys, consider pinning them on origin or using a small migration window to warm canary caches first.