Regions
Running one Redelay app in several regions — local reads and writes per region, what each datastore needs, and the framework pieces that make it work.
A Redelay app can serve, say, Europe and North America from nodes in both, with each member's reads and writes staying in their own region. This page is the design: the one rule every datastore follows, what the framework gives you, and what is still to come. Node placement and Swarm details are in Multi-server Swarm.
The overview with diagrams and measured round trips is the Multi-region page.
The rule: a write waits for its majority partner
Every replicated store (MongoDB, Kafka, ClickHouse Keeper, Redis Cluster failover) commits a write once a majority has it. The write is as fast as the nearest member of that majority, so:
- put a write's majority inside the region that writes it;
- never let a write wait for a member across an ocean;
- add one tie-breaker vote at a third site (non-electable) so a region keeps a majority when one of its nodes is down — it never slows normal writes, because a majority of three is the primary plus the fastest acknowledgement.
Measured on a two-region deployment (Europe ↔ North America, ~120 ms apart):
| one replica set, primary in EU | regional shards | |
|---|---|---|
| NA member: 2 reads + 3 majority writes | ~700 ms | ~13 ms |
| EU member: same request | ~85 ms | ~96 ms |
Kafka publish, acks=all, a replica across the ocean | ~1 s | 35 ms (replicas in one region) |
MongoDB — regional shards (runtime/mongo, runtime/region)
Member-owned collections are zone-sharded on {home_region: 1, user_id: 1};
each zone's shard has its primary and write majority in its region. Global data
(catalogue, CMS, settings) stays unsharded on the primary shard and is read
from local secondaries with readPreference=nearest&maxStalenessSeconds=90.
Public rows in a mixed collection use home_region: "global".
| Piece | Since | Use |
|---|---|---|
region.ForCountry, region.Validate | go-framework v0.19.0 | assign a member's home region once, at sign-up (HOME_REGIONS) |
rtmongo.WithHomeRegion / HomeRegionMiddleware | v0.18.0 | carry the member's region on the request |
rtmongo.Own(ctx, coll, filter) | v0.18.0 | read the member's own data from their home shard's primary |
rtmongo.StampRegionStrict(ctx, doc) | v0.18.0 | set home_region on inserts (an explicit value wins) |
region.Home(stored) | v0.25.0 | the region to use for a member: the stored one, or the default for members from before home regions |
rtmongo.OwnFilter(ctx, filter) | v0.25.0 | write filters (update, delete, upsert) of the member's own data — the region is added only with MONGO_HOME_REGION_FILTER=true |
rtmongo.BackfillRegion(ctx, spec) | v0.25.0 | migration body: home_region on every row from its member (ObjectID or hex ids); members without one and orphans → the default; safe to run again |
rtmongo.NewRegionLookup(users).With(ctx, uid) | v0.26.0 | writes outside the member's request (workers, links in emails, migrations, ops synced later): stamp with the owner's region; cached 10 min, Forget after moving a member |
MONGO_HOME_REGION_FILTER=true | v0.18.0 | add the region to filters — only after the backfill |
rtmongo.Nearest(ctx) (single-document reads since v0.16.6) + recent-write marker | v0.17.0 | local reads that still see the member's own fresh writes |
mongotest.AssertTargeted | v0.18.0 | tests prove a query hits one shard (through a real mongos) |
rtmongo.W1(coll) (last write majority) | v0.24.0 | see below |
Rollout order per collection: (1) stamp new rows (StampRegionStrict, write
filters through OwnFilter); (2) backfill (BackfillRegion in a migration) and
create the shard-key index {home_region: 1, user_id: 1}; (3) run the backfill
again, then MONGO_HOME_REGION_FILTER=true — from here every filter carries the
shard key, which upserts on a sharded collection need; (4) rebuild unique indexes
with the shard key first, shardCollection, zone ranges. Turning the switch on
before the backfill would make upserts miss older rows and insert copies. Apps
connect through mongos before step 4: once chunks move, a client connected
directly to one shard sees only part of the data (MongoDB 8.0 refuses direct
operations on a shard unless the user has directShardOperations).
Settings that matter: settings.chainingAllowed: false on every replica set (a
secondary syncing through a tie-breaker doubled majority-write time in our
benchmark), identity (email, password, sessions) unsharded (a unique index on
a sharded collection must start with the shard key, so a global unique email
cannot live there), and backups per shard plus config servers.
Last write majority {#last-write-majority}
Writes to one replica set are applied in order. A request that writes several times only needs to wait for the last one: once it is majority-committed, every earlier write on that primary is too.
fast := rtmongo.W1(orders) // w:1 — no wait for the majority
_, _ = fast.InsertOne(ctx, line1)
_, _ = fast.UpdateOne(ctx, f, u)
_, err := orders.InsertOne(ctx, summary) // the collection's own concern (majority): waits once
Three majority waits of ~30 ms become one. Only within ONE replica set (one
shard): writes that land on different shards each need their own majority
write. If the primary fails before the last write is acknowledged, the earlier
w:1 writes may be rolled back with it — the request fails either way, as it
would have with three majority writes. Prefer BulkWrite when the writes are to
one collection.
Adopting home regions in an app {#adopting}
Any number of regions: HOME_REGIONS="na=US,CA;apac=JP,AU,SG" plus the default
gives three, each its own zone (one shard can hold several zones until a region
gets its own); nothing in the framework assumes two (tested with eu/na/apac).
Home regions are optional. A single-region project sets nothing: with
HOME_REGIONS unset, region.Enabled() is false and every helper below is a
no-op (no users lookup, no home_region field, no index), so framework
modules can call them unconditionally. A multi-region project turns them on
with HOME_REGIONS (+ HOME_REGION_DEFAULT); HOME_REGIONS_ENABLED=false
turns them off again.
Helper (runtime/mongo, go-framework ≥ v0.28.0) | Use |
|---|---|
Owner(ctx, coll, uid) | ctx with the row owner's region — in their request, a worker, an email link, an op synced later; never the caller's. A failed lookup is an error |
StampOwner(ctx, coll, uid, doc) / OwnerRegion | the owner's region on an insert / $setOnInsert / a struct field |
Regions(db) | the shared, cached RegionLookup behind them (USERS_COLLECTION, default users) |
OwnFilter(ctx, filter) | upsert and update filters: the region only once MONGO_HOME_REGION_FILTER=true |
PrepareRegionCollection(ctx, coll, owner) | migration body: backfill from users + the {home_region, owner} index |
ReplaceUniqueIndex(ctx, coll, want, old) | migration body: a unique index keyed home_region first (created before the old one is dropped) |
CheckShardable(ctx, coll, owner) | every reason the collection cannot be sharded yet (ErrNotShardable) |
ShardByRegion(ctx, coll, owner, zones…) | operator step: shardCollection + one zone range per region, idempotent |
mongotest.ShardedDB(t) / mongotest.ShardByRegion(t, …) / AssertTargeted | tests on a real sharded cluster |
crud.NewMongoCRUD(…).WithOwnerRegion(func(T) string) | v0.29.0: Create stamps the owner's region (opt-in per store) |
crud.NewUserSingleton(…).WithHomeRegion() | v0.29.0: seed/replace stamp the user's region; every filter on the user's row through OwnFilter |
| users module | v0.29.0: User.HomeRegion set at creation — explicit UserCreateInput.HomeRegion (internal), else the request's country (region.CountryMiddleware, mounted by the server when home regions are on), else the default; migration users-20261017-home-region backfills the default — registered only with home regions on, so the ledger never marks it applied before a deployment opts in; an explicit region that is not configured is an error |
| auth module | v0.29.0: the access token carries hr (the member's home region); AuthMiddleware / OptionalAuth put it on the request (WithHomeRegion). A token without hr (issued before) adds nothing: reads stay unscoped, writes still look up the owner. After a member is moved, hr is stale for at most one access-token TTL — fine, a move is a data move done in a window |
recentwrite.FromEnv() / InstallFromEnv() | v0.29.0: the per-user recent-write marker from REDIS_SHARED_* (then rtmongo.Request / ForUser / RecentWriteMiddleware route a member's own reads: primary right after their write, nearest otherwise) |
Order, per app (each step its own release; verify by behaviour):
- Expand. Members get
home_regionat sign-up (region.ForCountry) and a backfill (none → default). Every write of a member collection stamps the owner's region (Owner+StampRegionStrict, orStampOwner) and its upsert filters go throughOwnFilter. Migrations:PrepareRegionCollectionper collection. - Recheck + filter. The same migration under a new id (rows the previous
image wrote during the roll), then
MONGO_HOME_REGION_FILTER=true. Run the test suite with the flag on first: a row without the region makes an upsert miss it and fail on a unique index. - Shard-ready. Member collections use the simple collation (store
names lowercased): a non-simple collation on a collection sharded on a
string key goes to every shard. Unique indexes become
{home_region, owner, …}with simple collation (ReplaceUniqueIndex) — MongoDB refuses to shard otherwise.CheckShardablemust return nil for each collection. - Shard. Through
mongosonly (every client of the database must connect through mongos first; MongoDB 8 refuses direct operations on a shard withoutdirectShardOperations).ShardByRegionwith the zones whose shards should hold data now — add a region's range only when it has members: an empty range on a remote shard turns every query without the shard key into a cross-region round trip. - Move members of a region (add its range; the balancer moves chunks).
Per collection, check: owner field and type (ObjectID or hex string — both
match); every insert path, including background and late writers (stamp from
the parent row's owner: a journal op from its session's owner); every upsert
filter; unique indexes; queries without the shard key (fine, but they reach
every shard holding chunks); TTL indexes (fine). Identity (users, sessions,
tokens) stays unsharded: a global unique email cannot live on a sharded
collection. Catalogue rows in a mixed collection get home_region: "global"
in their own migration first.
Testing on a sharded cluster {#testing-sharded}
docker network create shtest
docker run -d --name sht-cfg --network shtest mongo:8 --configsvr --replSet cfgrs --port 27019 --bind_ip_all
docker run -d --name sht-a --network shtest mongo:8 --shardsvr --replSet rsa --port 27018 --bind_ip_all
docker run -d --name sht-b --network shtest mongo:8 --shardsvr --replSet rsb --port 27018 --bind_ip_all
docker exec sht-cfg mongosh --port 27019 --eval "rs.initiate({_id:'cfgrs',configsvr:true,members:[{_id:0,host:'sht-cfg:27019'}]})"
docker exec sht-a mongosh --port 27018 --eval "rs.initiate({_id:'rsa',members:[{_id:0,host:'sht-a:27018'}]})"
docker exec sht-b mongosh --port 27018 --eval "rs.initiate({_id:'rsb',members:[{_id:0,host:'sht-b:27018'}]})"
docker run -d --name sht-mongos --network shtest -p 27100:27017 mongo:8 mongos --configdb cfgrs/sht-cfg:27019 --bind_ip_all --port 27017
docker exec sht-mongos mongosh --eval "sh.addShard('rsa/sht-a:27018'); sh.addShard('rsb/sht-b:27018'); sh.addShardToZone('rsa','eu'); sh.addShardToZone('rsa','global'); sh.addShardToZone('rsb','na')"
REDELAY_TEST_MONGO_SHARDED_URI='mongodb://localhost:27100' go test ./...
Kafka — replicas stay in one region (go-events)
acks=all waits for every in-sync replica, not min.insync.replicas. One
replica across an ocean makes every publish ~1 s. Keep a topic's replicas in one
region (RF 3 with a third-site broker, or RF 2 in-region); producers in the other
region pay one round trip. KAFKA_TOPIC_RACK (go-events ≥ v0.7.3) creates new
topics only on brokers of that broker.rack, so a topic created by an app can't
land in the other region. Planned: EVENTS_REGION with a region prefix for
topics produced and consumed in one region, a consumer scope (local | all),
and the outbox for publishes made inside a request so a request never waits for
Kafka.
ClickHouse — nearest Keeper, batched inserts
Replicating the data is asynchronous; an insert waits for ~8 Keeper writes, each
a Keeper majority round. Batch inserts (or async_insert=1) and give each
server its nearest Keeper first: zookeeper_load_balancing=in_order with a
per-server node order — the default is random, and one session on a Keeper
across the ocean made 9 ms inserts take 800 ms in our benchmark.
Redis — shared cluster with replicas and an ACL user
The shared cluster (locks, rate limits, coordination, event dedupe) needs a
replica per master on another node so one node down is not CLUSTERDOWN. Turn
auth on with a named ACL user (REDIS_SHARED_USERNAME /
REDIS_SHARED_PASSWORD, go-modules ≥ v0.68.0; go-events ≥ v0.7.2 for the dedupe
store): a password sent to a server without one is an error, so clients log in
as the user first and the default user is switched off last. Code that opens its
own client on the cluster uses SharedClusterOptions(). The recent-write marker
(runtime/mongo/recentwrite) reads this node's own master; on a node that runs
only a replica (yul, a replica of amer's master) it reads the master with the
lowest measured round trip (go-framework ≥ v0.27.0) instead of whichever master
holds the user's key.
Still to come
| Piece | Where |
|---|---|
| go-events: region prefix, consumer scope (rack placement shipped in v0.7.3) | go-events |
| Leader-only scheduler gauges (so the process exporting them can run 2 replicas) | go-modules scheduler |
| A per-region Redis for rate limits and NA-local locks | config + go-modules |