Building a Shard Directory Service
Hash routing decides placement with a function; a directory decides it with a row. That difference is what lets one tenant move to dedicated hardware, one customer’s data stay in a region, and one hot shard split without touching anything else. This guide builds the directory, the cache that makes it fast, and the version check that makes staleness safe. It extends Application-Level Sharding Logic inside Cross-Partition Querying & Aggregation Strategies.
Prerequisites
Step 1 — Model the mapping explicitly
CREATE TABLE shard_directory (
entity_type text NOT NULL, -- 'tenant', 'account', 'region'
entity_id text NOT NULL,
shard_name text NOT NULL,
region text,
state text NOT NULL DEFAULT 'active'
CHECK (state IN ('active','migrating','read_only')),
version bigint NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by text NOT NULL DEFAULT current_user,
PRIMARY KEY (entity_type, entity_id)
);
CREATE INDEX shard_directory_shard_idx ON shard_directory (shard_name)
WHERE state <> 'active';
-- one global version, bumped on every change, used for cache invalidation
CREATE TABLE shard_directory_version (
id bool PRIMARY KEY DEFAULT true CHECK (id),
version bigint NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
Operational note: The state column is what makes a migration expressible. migrating tells the router to dual-read; read_only tells it to reject writes during a cutover. Without it, every move needs a code change.
DBA tip: Keep updated_by and a history table. “Which shard was this tenant on in March?” is a question that gets asked during incident review, and only a history answers it.
Step 2 — Cache with a version, not with a timeout alone
# directory.py — in-process cache invalidated by a version watcher
class ShardDirectory:
def __init__(self, store, watcher):
self._entries: dict[tuple[str, str], Entry] = {}
self._version = store.current_version()
self._store = store
watcher.on_change(self._invalidate) # LISTEN/NOTIFY or an etcd watch
def _invalidate(self, new_version: int) -> None:
self._entries.clear()
self._version = new_version
def lookup(self, entity_type: str, entity_id: str) -> Entry:
key = (entity_type, entity_id)
entry = self._entries.get(key)
if entry is None:
entry = self._store.fetch(entity_type, entity_id)
self._entries[key] = entry
return entry
@property
def version(self) -> int:
return self._version
-- the notification, emitted by the same transaction that changes the mapping
CREATE OR REPLACE FUNCTION notify_directory_change() RETURNS trigger AS $$
BEGIN
UPDATE shard_directory_version SET version = version + 1, updated_at = now();
PERFORM pg_notify('shard_directory', (SELECT version::text FROM shard_directory_version));
RETURN NULL;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER shard_directory_changed
AFTER INSERT OR UPDATE OR DELETE ON shard_directory
FOR EACH STATEMENT EXECUTE FUNCTION notify_directory_change();
Operational note: pg_notify is best-effort and not delivered to disconnected listeners. Pair it with a slow poll — every thirty seconds — so a listener that missed a notification still converges.
DBA tip: Bump one global version rather than per-entry versions. It makes the cache invalidation trivially correct at the cost of clearing entries that did not change, which for a directory of thousands of rows is irrelevant.
Step 3 — Send the version with every request and let the shard check it
Caching is only safe if a stale entry is detectable:
def execute_on_shard(directory, entity_id, sql, params):
entry = directory.lookup("tenant", entity_id)
conn = pool_for(entry.shard_name).acquire()
conn.execute("SET LOCAL app.directory_version = %s", (directory.version,))
return conn.execute(sql, params)
-- on each shard: reject work routed with an outdated view of the world
CREATE OR REPLACE FUNCTION assert_directory_version() RETURNS void AS $$
DECLARE client_version bigint := current_setting('app.directory_version', true)::bigint;
BEGIN
IF client_version IS NULL OR client_version < (SELECT min_accepted FROM shard_settings) THEN
RAISE EXCEPTION 'stale shard directory (client %, minimum %)',
client_version, (SELECT min_accepted FROM shard_settings)
USING ERRCODE = '40001'; -- serialization_failure: clients already retry this
END IF;
END $$ LANGUAGE plpgsql;
Operational note: min_accepted is raised on a shard only after a migration touching it completes. Raising it globally would reject every client until they all refresh, which turns a safety mechanism into an outage.
SRE tip: Count version-mismatch rejections as a metric. A steady trickle is normal during migrations; a sustained rate means some instance is not receiving invalidations at all.
Step 4 — Decide what happens when the directory is down
DIRECTORY_UNAVAILABLE_POLICY = "serve_from_cache" # or "fail_closed"
def lookup_with_policy(directory, entity_type, entity_id):
try:
return directory.lookup(entity_type, entity_id)
except DirectoryUnavailable:
cached = directory.stale_entry(entity_type, entity_id)
if cached and DIRECTORY_UNAVAILABLE_POLICY == "serve_from_cache":
metrics.increment("directory.serving_stale")
return cached
raise
Operational note: Serving from a stale cache during a directory outage is usually right — placements change rarely, and the alternative is a total outage. It is only wrong while a migration is in flight, which is exactly when state = 'migrating' should make the router fail closed for those entities.
DBA tip: Test the outage. Block access to the directory in staging and confirm the application keeps serving; most implementations discover a hard dependency they did not intend.
Verification
Confirm the cache, the invalidation and the rejection all work:
# 1. move a tenant and watch every instance converge
psql -c "UPDATE shard_directory SET shard_name='shard_5', version=version+1
WHERE entity_type='tenant' AND entity_id='8842'"
# 2. every application instance should report the new version within a second
for pod in $(kubectl get pods -l app=api -o name); do
kubectl exec "$pod" -- curl -s localhost:9000/debug/directory | jq .version
done
42
42
42
42
Then confirm a deliberately stale client is rejected rather than served, and that the rejection metric increments.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
| Rows written to a shard that no longer owns the tenant | a client cached the mapping with no version and never refreshed | send the directory version with every request; have shards reject stale versions with a retryable error |
| Directory becomes a single point of failure | the routing path calls it synchronously on every request with no cache | cache in-process with push invalidation; define an explicit stale-serving policy and test it by blocking the directory |
| Invalidation missed by one instance | pg_notify was not delivered to a listener that had reconnected |
pair notifications with a slow poll so listeners converge even after a missed message; alert on version skew across instances |
FAQ
When is a directory better than hashing?
When placement must be a decision rather than a function: moving one large tenant to dedicated hardware, keeping a customer’s data in a specific region, or splitting a hot shard without moving anything else. Hashing gives even distribution and no control; a directory gives control at the cost of a lookup and a cache to keep honest.
Where should the directory live?
In a small, highly available store that is not one of the shards — a dedicated database, or a coordination service such as etcd. Putting it on a shard makes that shard a dependency of every request in the system, including requests destined for other shards.
How stale is a cached entry allowed to be?
For reads, as stale as your tolerance for a wrong-shard miss, which is usually seconds. For writes, not at all — which is why the version is sent with each request and the shard rejects anything carrying an older one. That check turns an unbounded staleness problem into a bounded, retryable error.
Related
- Application-Level Sharding Logic — the parent topic, including the shard-map cache and fan-out machinery
- Migrating a Tenant Between List Partitions — a move that the directory’s
statecolumn makes expressible - Proxy Routing vs Application-Level Sharding: Decision Guide — where this directory sits relative to a proxy tier