Partition manager and map table
A partition map tells the frontend where to send an object request. When ownership changes, that answer must stay connected to two facts: the new owner has the accepted data, and the former owner can no longer change it.
We'll separate the manager's responsibilities from normal request routing, then follow one planned transfer through its final write and a stale-client retry.
Who maintains the assignment?
| Component | Responsibility |
|---|---|
| Partition map | Durably record each key range, its serving owner and ownership generation. |
| Partition manager | Assign and move ranges, split busy ranges, merge cold neighbors, and arrange recovery after failures. |
| Partition server | Serve its assigned ranges; one server can own several. |
| Coordination authority | Serialize manager decisions and establish valid ownership. |
An assignment carries an epoch, a generation that changes on handoff. The receiver must enforce that authority where a write takes effect. A cached address is only a routing hint; publishing a new map cannot stop an old process by itself.
For this design, a range has at most one active write owner. A transfer may briefly leave it with none. Allowing an unavailable interval is safer than accepting independent changes at two owners without a reconciliation protocol.
A planned move
A owns epoch 1. Its append-only file contains r1: first and a newline, six bytes. We copy those bytes to B while A remains active. Then A accepts r2: second and a newline, another seven bytes.
B is now behind. Before switching owners, pause new writes at A, wait for any admitted effects to finish, copy the missing tail, and verify the destination. Only then activate B at epoch 2.
In the object-storage example, the append and cutover use the same Python lock. A request cannot pass its authority check, wait through cutover, and then append with stale permission. The check and effect occur inside one guarded section.
python3 m13-object-store.py transfer
copy snapshot: 6 bytes; A epoch=1 remains owner
r2 during transfer: accepted
pause, copy tail: 7 bytes; equal=True; publish B epoch=2
delayed r3 at A epoch=1: stale or paused
refreshed r3 at B epoch=2: accepted
accepted: ["r1","r2","r3"] ; B bytes: b'first\nsecond\nthird\n'
authority and cutover lock are in memory; files are real
The delayed r3 attempt adds nothing at A. Retrying against B with epoch 2 appends the third record, bringing B to 19 bytes. If the copied files differ before cutover, the example raises an error and leaves the assignment paused.
This runs a chosen sequence of calls, not concurrent threads. The files are real, but authority and accepted-operation history live in memory. It has no durable transfer journal or power-loss recovery protocol.
How much must we pause?
Pausing for the whole transfer is simpler: finish earlier writes, copy a fixed source, verify it, then switch. The cost is an unavailable write interval covering the entire copy.
Copying while writes continue needs a snapshot boundary and a retained change history. Our append-only file uses a byte offset; a mutable database needs an equivalent agreement between snapshot and log. The final pause accounts for everything accepted after the snapshot. Measure that pause rather than assuming a small example proves acceptable downtime.
Not every assignment change requires copying object bytes. With a separate durable storage layer, a new partition server can load the existing checkpoint and replay its log. The requirement is still to recover the accepted state and establish exclusive authority before serving writes.
Failure is different from a planned move
A failed health check means the owner is suspected, not that its writes are impossible. The manager must prevent the old authority from taking effect even if that process resumes. Fencing supplies this boundary only when the resource receiving the writes enforces it.
The replacement also needs recoverable state outside the failed server. If A held the only copy, changing the map to B cannot recover its data. When replicas or shared storage exist, recover from them and the retained log before making B active.
Persist transfer progress and assignments through the coordination authority. After a manager restart, it must determine whether B already accepted writes before deciding to resume or abandon a move. Reopening A's older file after B became active would discard accepted history. Electing one manager helps serialize decisions; it does not replace durable state or write enforcement.
Handling an old map
Return an explicit stale-assignment response so the frontend can refresh and retry within its original deadline. Keep the operation identity. If the first attempt succeeded but its reply was lost, the new owner needs the corresponding receipt or another deduplication rule; the rejected-r3 example does not cover that case.
Those receipts belong with the transferred metadata. The next lesson makes the object metadata and operation results persistent together.
Under the Hood: Coordinating Range-Partitioned Tablets
In range-partitioned storage engines (like Google Bigtable, CockroachDB, and Apache HBase), data is divided into contiguous dynamic ranges called Tablets or Ranges. As tablets grow through writes, they must split and migrate across the server fleet. The Partition Manager and Routing Map Table orchestrate this distributed metadata.
Three-Level Routing Hierarchy (Bigtable Model)
Under the Hood: The Tablet Split
When a user tablet exceeds a size threshold (e.g. 256MB):
- The tablet server hosting the range picks a split row key (typically near the middle of its data) and splits the tablet into two child ranges locally.
- Because underlying SSTable data files are immutable, the split is instantaneous: child tablets reference the parent's SSTables with restricted start/end key boundaries (zero data copying).
- The tablet server commits the split by recording the new tablet in the METADATA table, then notifies the master (Bigtable describes no two-phase transaction here).
- Clients with stale cached locations discover them on a miss (the server no longer serves that row range), then invalidate the entry and walk back up the METADATA hierarchy for a fresh location.
Worked example: Bigtable's location hierarchy capacity
The Bigtable paper stores each tablet's location as a METADATA row of about 1 KB, and caps METADATA tablets at 128 MB.
- One METADATA tablet holds 128 MB / 1 KB, about 2^17 (131,072) locations.
- The root tablet (never split) points at up to 2^17 METADATA tablets, which together point at about 2^17 x 2^17 = 2^34 user tablets.
- At 128 MB per user tablet, that addresses 2^61 bytes, far beyond any deployment.
Lookup cost from the paper: with an empty client cache, locating a tablet takes three network round trips (Chubby file, root tablet, METADATA tablet). With a stale cache, it can take up to six, because stale entries are discovered only on misses.
How systems implement the map
| System | Range unit and default size | Map location | Who decides placement |
|---|---|---|---|
| Bigtable | Tablet, roughly 100-200 MB in the paper | Chubby file, root tablet, METADATA table | Master (single, with Chubby lock) |
| HBase | Region, split around 10 GB by default | hbase:meta table, location in ZooKeeper | HMaster |
| CockroachDB | Range, up to 512 MiB by default | Two-level meta1 and meta2 ranges | Distributed per-node allocators and replicate queues |
| TiKV | Region | Placement Driver (PD) | PD cluster |
A split, step by step (CockroachDB style)
- A range exceeds its size threshold; the leaseholder picks a split key.
- A distributed transaction updates the range descriptors and the meta2 addressing records for both halves atomically.
- Both halves initially live on the same replicas; the rebalancer later moves one half elsewhere.
- Clients sending to the old descriptor get a range-key-mismatch error, refresh their cache from meta2 and retry.