Read replicas near the client
How to answer a client's reads from a replica in its own region, while its writes go to the tenant's home.
Read replicas
A tenant lives in one region, and a client on another continent pays the trip between them on every request — Frankfurt to Singapore is 158 ms. Give the tenant a read replica in the client's region: a follower of its primary there, which the router of that region answers the client's reads from, while its writes go home.
# a read replica of acme in ap, on the node of ap holding the least
curl -X PUT router.eu.internal:8090/_shard/tenants/acme/read-replicas/ap \
-H "Authorization: Bearer $ROOT"
{"region":"ap","node":"ap1","follows":"http://10.1.0.11:8081"}
# ap's router stands in ap: a marked read of a tenant with a replica there goes to it
fenec-shard --listen 0.0.0.0:8090 --token $ROOT --directory standby.fenec \
--replication-token $DIR --replica-of http://router.eu.internal:8090 --near apimport { connect } from '@fenecdb/web/client';
// Through ap's router: reads near, writes home.
const db = connect('https://ap.example.com/t/acme', { token: 'a token', reads: 'nearest' });
await db.from('orders').insert({ number: 'SG-1' }); // home, in eu
const mine = await db.from('orders').rows(); // ap's replica, after that write- A read never comes from before the client's own write. A client
reading near sends each read after the furthest position home named to
it — the change and the history it is a change of
(
Fenec-After,Fenec-History) — and the replica answers once it holds that change of that history; one it cannot in the wait goes home. A failover that loses a write does not fool it: the copy left on the old branch holds a change of that number, of another history. - A replica answers nothing before it has caught up. One placed a moment ago, its image still to come — or reopened, or pointed at a new primary, until that primary has said hello — sends the read home.
- Only a read the client marks goes near
(
Fenec-Read: replica): the router parses no statement. The SDKs mark the builder's reads, and a text the caller says reads; every other request goes home, as it always did. - A read replica is never promoted. A failover of the tenant's home promotes its standby or its replica, in its region; the read replica follows the new primary.
- Cut off from home, it serves what it last applied — stale, to a read that names no write — and catches up once the link is back; reopened or pointed at a new primary meanwhile, it serves nothing until that primary has said hello.
Behind the global entry (one URL for every region) a marked read goes to the region of the client's continent first, whatever the tenant's home, and its router answers it from the read replica there; every other request goes to the home the entry has learned.
Placing, routing and failing over a read replica
Placing one. PUT
/_shard/tenants/<t>/read-replicas/<region> makes the tenant
on the node of that region holding the fewest tenants and copies — never a
standby — has it follow the tenant's primary node, as a replica of its own
does, and records it once it follows; 201 with the node,
200 where the region has one already, 502 where the
node would not follow, the file it made there deleted again. One a region,
and none in the tenant's own region (409: its standby and its
replica are there), none in a region with no node (409). It is
held to the tenant's quota, as the tenant's standby and replica are: sent it
as it is placed, and each quota set after — handed at once to a sender of
its node's by the directory setting it, the quota request answered once the
directory records it, and within a look by a router keeping a read replica in
another region's directory, which reads the quota with the tenant's
placement. One sender a node, on a thread of its own: a node that does not
answer holds up its own replicas' quotas alone, never a look or a quota
request, and each replica is sent its quotas in the order they were set, an
older one never after a newer. DELETE takes it out of the directory and, once
the standby routers have been sent that, off its node; a node that does not
take it off keeps it recorded (502) — a copy following its
primary unrecorded would never be stopped — and
DELETE ...?forget lets go of the record of a node gone for good.
A node holding a read replica is not removed (409) until it is
taken off. GET /_shard/tenants/<t>/read-replicas lists
them, {tenant, read_replicas: [{region, node, follows}], failed,
changing}, changing whether a change of one is under way.
Every node shares --replication-token: the replica's node asks
the primary's feed with its own. The record is kept by the directory that holds the replica's
node — the global one, or the region's where it keeps its own — so that region's router places
it, leases its node, routes to it, and goes on doing so cut off from the
tenant's home. Asked of another router, the request is relayed there; the
list is gathered from every directory by the global router, a region not
reached named in failed.
Routing. A router stands in a region: --near ap, or the
region whose directory it keeps (--region). Asked for a request
marked Fenec-Read: replica of a tenant with a read replica in
that region — and not living in it — it forwards the request to the replica;
every other request goes where it went. A marked read the replica does not
answer — its node not reached, or the request not written or answered by
the wait its Fenec-After asks, a /_changes page's
own wait for a write, and --replica-timeout past
them (2 s); not caught up with its primary yet, or going down (503);
still behind the position it was sent after at the end of the wait
(504); its file gone from the node (a 404 with
Fenec-Tenant: absent) — is sent home, so the client is answered
either way and from nothing older than its write; and so is one whose answer
breaks off before its end, the router reading a replica's answer whole
before any of it goes to the client (a stream's goes on as it comes). A
replica that did not answer is let be for 5 s, its reads going home at once
meanwhile: the node's, every tenant's, where it was not reached or took no
whole request. A tenant's copy that answered 504 could not
reach the position in the wait it was given, and asked again at every read,
a replica cut off from its primary held each one that long — every run of a
live query reading near came the wait late for as long as the partition
lasted: for 5 s the tenant's reads sent after a position, with no longer a
wait, go home at once through that router, the trip home a region away in
place of a wait. A read naming no position, which the copy answers as it
stands, and one willing to wait longer still go to the copy, home out of
reach or not. So a client holding a token of the tenant can send its
positioned reads through that router home: a Fenec-After past
the tenant's last change, with a wait of a second or more, once every 5 s —
as a failover that lost writes does on its own, its clients' positions on the
lost branch. What that costs is the trip home, never a wrong answer. Where it took one and did not answer in time, the tenant's
copy is let be at once and the read goes home, while the router asks the
node's /_health — answered before any token, lock or routing —
on a connection of its own, within 500 ms (--replica-timeout
where shorter), one question a node at a time. Answered, the copy alone is
let be, since its answer comes once the query has run and a slow read is
that read's, not the node's: no tenant's slow reads, nor several tenants',
let the node be. Not answered, the node is too — it takes connections and
answers nothing, stopped or deadlocked — and no other tenant waits it
out. A node that answers its health while every query on it stalls — its
disk stalled under the queries — costs each tenant with a copy there one
timeout every 5 s for as long as that lasts.
fenec_router_replica_reads_total counts marked reads by where
they were answered: outcome="replica",
"fallback", "home". Every server runs a marked
request read-only on every route — a statement, REST, /batch, an
MCP tool, a change-stream consumer's commit — home as much as a replica, so a
write marked by mistake is refused with 403 the day it is
written, never landed; a value the header does not take is 400.
A tenant frozen for a move, or on a node whose lease has lapsed, answers a
marked read, which can only read.
Read your writes. A write's answer carries Fenec-Seq,
the change it left the tenant at, and Fenec-History,
<term>-<id>: the history that change is of, which a
failover forks where the copy promoted stood, one term more. A replica numbers
the primary's changes as the primary did, and a read sent with
Fenec-After: n and that Fenec-History is answered
once the replica holds change n of that history, 5 s at most
unless Fenec-Wait says. The history is what makes it hold across a
failover: a write the old primary acknowledged and the promoted copy never
got is lost, the new primary's next write takes its number, and a replica
still on the old branch holds a change of that number — the lost one. It
answers no read after the new write until it is pointed at the new primary
and takes its image. A client reading near keeps the furthest position an
answer from home named — a write's, a read-only /batch's; of a
later term, or further in one, so an answer arriving late is not the last —
and sends it with every marked read, whose own answers move nothing: it reads
its own writes and every write before them, and another client's writes after
its own may or may not be there yet. A marked /query read's
answer names where the copy that answered it stood as it read
(Fenec-Seq, Fenec-History), replica or home; the
SDKs take nothing of it as the client's, since a replica may be behind home
or on a branch a failover left, and a JavaScript live query sends its next run
after it (below). A subscription and a
/_changes page sent so wait for it as a read does, before their
head, and go home on the replica's 504: a stream is seeded with
the client's write, never from the copy as it stood behind it, and a page's
Fenec-Seq is never behind it. Go's Read() and
.NET's AsRead() copies send the position on a subscription and
/_changes too, and take a stream's 504 as a
read's. A marked stream open on a copy that refuses its primary — the
tenant deleted and made again under its name — ends as the copy deletes
itself, as the reads after it are sent home. A client that has written
nothing reads the replica as it stands. Where the failover lost the client's last
acknowledged write, no copy holds its position: home answers
504 at once, naming where it stands and of what history, the SDK
takes that as the client's position — only over the position that read was
sent after, so a write answered meanwhile is never set back, and never an
earlier term of the same lineage, which only a fenced old primary names — and
asks home once more with no mark. A history's lineage is the top half of its
id: every fork of the tenant keeps it, and a tenant deleted and made again
under its name, which starts again at term 1, has another — whose position
the client takes, from a 504 or any answer from home, its term lower or
not. A node from before lineages keeps none: a promotion or a restore it
makes starts a fresh one, so while a deployment is upgraded node by node a
router lagging a failover can again set a client back onto the old
branch.
Failovers and moves. Every router looks once a second at where the
primary of each tenant it keeps a read replica of is — its own directory, its
copy of the global one, or, for a tenant of another region keeping its own
directory, that region's router, asked once for all of its tenants — and
re-points the ones that follow another address, on a worker a node the
look waits for none of, each call to a node bounded by 10 s and no lock held
across one, so a node that does not answer holds up its own replicas alone.
A failover —
by hand or on a lease — or a move it makes has that look come at once, on a
thread of its own: the failover waits on no replica's node, which may be a
region away or cut off. So a region keeping its own directory follows a
failover in the tenant's home within about a second; a marked read sent after
a position meanwhile waits on the replica, and goes home past the wait. The
replica goes on from where it stood where the new primary's history passed
through it, and is sent an image otherwise; either way it answers again once
the new primary has said hello. Every 10 s, on a thread apart from the looks,
each router asks each replica's node it keeps which tenants it holds, and
makes again a copy gone — a node wiped, a file deleted by hand — which a
marked read meanwhile finds absent and sends home; a node out of reach holds
up that check alone, never a re-pointing. A tenant deleted takes its read
replicas with it, at once in its own directory — a change of one under way
waited out, 15 s at most and with no claim on the tenant held, so none is
left when the delete answers, and one whose node does not take it off keeps
the tenant (502), as DELETE .../read-replicas keeps
a record — and at the next look in another; the name is not made again
(409) while any directory keeps one of it, or is changing one:
made again, the look would point the old copy at another owner's tenant, in
a region nobody chose. A region keeping its own directory tells the global
router as it places one, before any copy exists, and that it keeps none
once the last is taken off; a create asks the regions so named for the
name alone, all at once, each within 10 s, and one not reached — or answered
by a router following the region's, which cannot see what the one it
follows has under way — refuses that name (503) and no
other. A name no region named asks none, so a region out of reach holds up
no create anywhere else. For a region gone for good, DELETE
/_shard/tenants/<t>/traces/<region> on the global router
lets go of its trace, and the name is made again. That cannot copy the new
tenant into the region: each read replica is bound to its tenant's
lineage — the top half of its history's id, which a failover and a
move keep and a tenant made again does not, recorded by the directory as
the tenant's node makes it or takes its image in — and its copy is told
it with every follow and keeps it across restarts. A copy
whose primary turns out to be of another lineage takes nothing from it and
deletes itself, file and .follows, so the deleted tenant's
rows do not outlive it on the node either
(fenec_copies_refused_total counts them); a look finding a
replica's tenant of another lineage takes it off rather than point it
anywhere. So does every other way a record outlives its tenant —
?forget of a node that comes back, a router lost between a
follow and the record. A primary of another lineage than the
directory's, on the node the directory places its tenant on, is the
tenant: another file of it promoted by force, an older file restored, a
file put in place by hand. (A copy of another lineage, such as a standby
that never took its first image, is promoted only by force: its node
refuses it, and the tenant stays where its rows are.) A failover records
the lineage the promotion answers, and a read replica's copy lost has the
primary's node asked which lineage it is of; the directory takes its
word, keeping the lineage before as one the tenant was, and the read
replicas bound to that one are bound to the new one rather than taken
off. A copy lost three times in a row anyway is made again no more —
"given_up": true in its list, counted in
fenec_router_read_replicas_given_up — until its primary or
lineage changes or it is placed again; its region's marked reads pass it
by meanwhile, rather than each meet its node's 404 and go home.
The router keeps that in memory alone: started again, it makes the copy
again up to three times more, and a standby router knows nothing of it. A tenant made before lineages were
kept has its lineage asked of its node at the next check, and each of its
read replicas is bound to it where its copy's node says the copy holds
its primary's history and is of it — taken off where the copy is another
tenant's, asked again later where it has not caught up. Mixed versions
protect less and break nothing: a node from before lineages ignores
"lineage" in a follow and has no
/_admin/tenants/<t>/lineage, and a router from before
them ignores lineages — no copy refuses on their word, until
every node and router runs a build that keeps them.
A marked read goes to no read replica of a tenant no directory places. One
moved into a region holding its read replica takes that replica
off — the tenant's own copies are nearer still — a change of it under way
waited out, and the replica held until the tenant is placed there, so no
look's follow lands on the node it moved to.
A partition. The replica's region cut off from the tenant's home:
writes from that region are answered 502, as a node out of reach
is; a marked read naming no position is answered by the replica from the state
it last applied — as old as the cut, and, cut off across a failover of home,
on the branch the failover left — and one naming a position the replica does
not hold waits, goes home, and is answered 502. Nothing is
answered from before a write the client was told landed. Once healed, the
replica's feed reconnects and it catches up. Reads that tolerate a bounded staleness —
a replica answering while it is at most so far behind, without a write of
the client's to wait for — are not offered.
SDKs. JavaScript connect(url, { reads: 'nearest' })
and { read: true } on run and batch
(a batch of builder queries alone is marked on its own); Python
Client(url, reads="nearest") and read=True; Go
fenecdb.WithReadsNearest() and db.Read(); .NET
ReadsNearest = true and db.AsRead(); Swift
Fenec.connect(url:reads: .nearest), Kotlin
Fenec.connect(url, reads = FenecRemote.Reads.NEAREST) and Dart
Fenec.connect(url, reads: Reads.nearest), each with
db.asRead() (Without a
file). Each marks the builder's reads and the copy's requests alone,
and keeps one position for every copy of the client it makes. A
subscription opened through a Go Read() or a .NET
AsRead() copy, and its /_changes reads, are marked
and sent after that position too, and a server holds a stream for
Fenec-After as it holds a read: the read replica seeds it once
it has the client's own writes, and where it cannot in the wait its
504 sends the stream home, so no stream is seeded from behind
them. JavaScript's live keeps its streams home, unmarked, and
sends each of its runs near: after the furthest of the client's position and
the one its streams named — a stream's head says where home stood as it
opened, Fenec-Seq and Fenec-History, and each
event's seq is a change of that history; a stream opened again
after a failover names the later term, and never sets the position back. A
run waits a second on the replica (Fenec-Wait: 1000), not five:
the replica has a write once home's fsync covers it — 283 ms behind at most
under --sync 250 — and a link later, so one later than that is
behind, and home answers in a region's round trip; the router then lets the
copy be, so a replica cut off from home costs one second every 5 s, not one
every change. The second assumes that: a primary syncing its file every
second or more slowly (--sync 1000 and up), or a link to the
replica slower than about half a second, has a replica take a write past a
run's second, and every run of a live query near it misses it — answered
from home, fenec_router_replica_reads_total{outcome="fallback"}
rising where "replica" does not, the rows a round trip home
later, a second later once every 5 s: right, never stale, but slower than a
client reading home (reads: 'home'), which such a deployment's
live queries are better made with. The read replica answers a run
once it holds the change that
ran it and the router sends one it cannot hold home; each answer names where
it read, and the next run goes after that too, so rows a run answered from
home showed — a change whose event is still on its way — never go back. A
504 from home — the change lost in a failover — has the live query
take home's position and the run asked near again after it, home unmarked if
that fails too; answered home unmarked, the run names no position, so a run
after it may again show rows from before it — only after a failover that
lost writes, whose lost write the rows lose anyway. A stream whose head names
no history — a server from before heads named one — has the runs go home
until every stream's head names one again: its positions would pass none of
the client's. The rows never come from before a change the streams told of,
and the streams tell of every change home takes. Its polls
({ poll }) go home: sent after its tag's change, a replica would
judge the tag as home does up to where it stands, but nothing in a poll names
a change home took since, so a replica behind — or cut off from home — would
answer 304 while home holds a write the query reads. Its
subscribe streams go home. A synced replica's streams, in the
browser and in the native libraries, go home; Python's client and the Swift,
Kotlin and Dart remotes open none.
What it costs. A marked read through the router of the replica's
region costs what one sent home does: 67.0 to 67.8 µs p50 against 66.2 to
66.9, three runs, with each node behind a loopback proxy standing for its
link, and 67.6 to 68.3 with a Fenec-After the replica holds
already (make shard-bench ARGS=reads, nodes and router in one
process). A client's read of the write it was just answered took 368 to 379
µs p50 on the replica — its wait for the write included — against 341 to 342
at home, both right after a durable write: under --sync always
the primary answers a write once its replicas' streams are sent it, and the
replica's part is applying it. A request no client marks pays a header looked
for: the router added 20.55 µs p50 to a request over the node's own against
20.7 for the code before read replicas, sixteen runs a side in ABBA turns,
both writing the node's answer in one writev, which took the
router from 23.6 µs (make shard-bench ARGS=latency). Between
regions the read near saves the round trip to the tenant's home, every
read.