We started with a traditional approach: a central coordinator assigning replicas to nodes. It worked for a three-node cluster — until it didn’t.
The Real Problem
The breaking point came during a network partition. The coordinator became a single point of failure. When it went down, no new objects could be placed — the entire cluster stopped accepting writes. Worse, when the coordinator recovered, its view of the cluster was stale. It assigned replicas to nodes that had already failed, quietly sending data into a hole.
We asked: what if every node could compute placement independently, with zero coordination?
The insight came from the storage design itself. Because momo is content-addressed (004), every object already has a SHA-256 content hash. What if that hash is the placement key? That would make placement a pure function — same hash and same cluster map, same replica set, on every node, with no network call.
Here, placement means deciding which nodes hold which replicas of an object, and a failure domain is a group of machines likely to fail together (a rack, a zone, a power feed). Keeping replicas in distinct failure domains is what prevents a single rack outage from taking out every copy.
Why Obvious Solutions Failed
Central coordinator with heartbeat
| |
The coordinator becomes a bottleneck and a single point of failure. Heartbeats add latency and still do not resolve split-brain.
Consistent hashing (ketama/ring)
| |
Standard consistent hashing treats every node as equal. Our nodes have different capacities — disk, network, zone — so we needed weighted placement.
Full RADOS CRUSH
| |
Ceph’s CRUSH models enormous hierarchical failure domains. We needed a lite version: a flat topology, deterministic output, constant CPU per lookup.
The Solution — Weighted Rendezvous Hashing
Core insight: every node computes the same replica list from the object hash — no coordinator, no RPC, no central state. The technique is weighted rendezvous hashing: score each candidate node with a hash of the object key combined with the node’s identity, multiply by the node’s weight, and take the highest scores.
Overview
Detail: Scoring Algorithm
| |
Key annotations:
hash(key ⊗ node.ID): combining the content hash with the node ID makes the score deterministic and uniformly distributed. Every node computes the same number.fold52(): folds the 64-bit hash down to a 52-bit value so it fits exactly in a float64 mantissa, avoiding the precision loss that flipped replica order before this fix (#873).sort.SliceStable: when two nodes score exactly equal, a stable sort keeps their original order so different nodes do not disagree about the ranking (#872).node.Weight: capacity-aware scoring — a node with twice the weight tends to receive proportionally more replicas.
Failure Domain Filter
After selecting the top N by score, we enforce failure-domain diversity so two replicas do not land in the same rack or zone:
| |
Principle Callout
Pattern: Content-Hash-Based Placement Use the object’s content hash as the placement key. Score each node with
hash(key ⊗ node.id) × weight, sort, take the top N. Zero coordination.Applies when: content-addressed storage, deterministic placement is required, node capacities are heterogeneous. Doesn’t apply: mutable objects, or when placement must be controlled by an external policy.
1 2 3 4 5// ✅ GOOD: Content-addressed, deterministic func place(key BlobID) []Node { return score(key, nodes) } // ❌ AVOID: Mutable objects, external placement policy func place(key string, policy PlacementPolicy) []Node { ... }
Verification
| Property | Test | Result |
|---|---|---|
| Determinism | Same input → same output across nodes | ✅ |
| Weight proportionality | 2× weight → ~2× replicas | ✅ |
| Stability | Tied scores never flip order | ✅ (fixed #872) |
| Float precision | 52-bit mantissa, no drift | ✅ (fixed #873) |
| Failure domains | Replicas in different racks/zones | ✅ |
Production: under 100k req/s, placement adds under 50 µs of latency. Zero coordinator overhead.
Failure Modes
| Risk | Mitigation |
|---|---|
| Hash collision (SHA-256) | 2^256 space — practically impossible; monitor for research breaks |
| Zero-weight nodes | Explicit skip in scoring loop |
| Tied scores | sort.SliceStable preserves insertion order |
| Float drift | fold52() limits to a 52-bit mantissa |
| Split-brain | Failure domain filter ensures diversity |
When NOT to Use This
- Mutable objects — a content change means a new hash and therefore a new placement; use versioning instead.
- External placement policy — when placement must be controlled by an operator or scheduler outside the storage layer.
- Non-content-addressed storage — there is no content hash to key on.
See docs/CORE/STANDARDS.md for the ⚡ Bolt / 🛡 Sentinel mindsets.
References / Dig deeper
- Design doc: docs/REFERENCE/CRUSH.md.
- Spec:
openspec/changes/r1-failure-domains. - Pull requests: #872 (stable sort), #873 (52-bit fold).
- Upstream: 004. Downstream: 019, 024.