Scale-out storage: PostgreSQL and MySQL read replicas (ST-5 to ST-15)

A data store with readReplicas becomes a replicated store. Writes,
operator-written SQL and everything outside a read scope go to the
primary. JMAP reads before a request's first write, IMAP LIST, STATUS,
SEARCH, SORT and FETCH, POP3 RETR and TOP, DAV GET, PROPFIND and REPORT,
and blob downloads run in a read scope. Only account data (properties,
indexes, change logs, counters, ACLs, blobs, the search index) is read
from a replica; the registry, in-memory values, the task queue and the
rest stay on the primary.

In a scope, the first read picks a replica round-robin among those up
and under the lag limit, and only if it has every change this node has
written or heard of for the scope's accounts: marks come from write
results, the cluster's state-change broadcasts, a sinceState the client
presents, and, with more than one node, Redis. A write inside the scope
sends the rest of it to the primary. A miss on a replica is looked up on
the primary, and a replica error retries the read there and marks the
replica down.

Each node samples lag every second (WAL positions on PostgreSQL; GTID
sets or Seconds_Behind_Source on MySQL), stops reading from a replica
over 5 s and starts again under 2.5 s, and probes a down replica every
10 s. At startup a replica is left out if it's the primary, isn't
read-only, applies out of commit order, or doesn't show a marker written
to the primary within six tries.

replica_tests (postgres, STORE=PostgreSqlReplicated) runs a primary and a
streaming hot standby in containers: tests 9, 10, 12, 13, 14 and 15 pass.
This commit is contained in:
2026-09-19 14:05:59 -07:00
parent a635b490ec
commit 1518c69033
24 changed files with 1722 additions and 45 deletions
+45
View File
@@ -201,6 +201,25 @@ impl SearchStore {
filters: &[SearchFilter],
sort: &[SearchComparator],
) -> trc::Result<Vec<u32>> {
// inbuxa: ST-6, ST-12: a replica answers search queries in a read scope
if let SearchStore::Store(Store::Replicated(store)) = self {
return match store.read_target(crate::SUBSPACE_SEARCH_INDEX).await {
Some(replica) => match crate::sql_backend!(
&store.replicas[replica].store,
db => db.query(index, filters, sort).await
) {
Ok(ids) => {
store.served(replica);
Ok(ids)
}
Err(err) => {
store.failed(replica, err);
crate::sql_backend!(&store.primary, db => db.query(index, filters, sort).await)
}
},
None => crate::sql_backend!(&store.primary, db => db.query(index, filters, sort).await),
};
}
match self {
SearchStore::Store(store) => match store {
#[cfg(feature = "postgres")]
@@ -215,6 +234,13 @@ impl SearchStore {
}
pub async fn query_global(&self, query: SearchQuery) -> trc::Result<Vec<u64>> {
// inbuxa: ST-5: global queries are maintenance, on the primary
if let SearchStore::Store(Store::Replicated(store)) = self {
return crate::sql_backend!(
&store.primary,
db => db.query(query.index, &query.filters, &query.comparators).await
);
}
match self {
SearchStore::Store(store) => match store {
#[cfg(feature = "postgres")]
@@ -245,6 +271,9 @@ impl SearchStore {
}
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
if let SearchStore::Store(Store::Replicated(store)) = self {
return crate::sql_backend!(&store.primary, db => db.index(documents).await);
}
match self {
SearchStore::Store(store) => match store {
#[cfg(feature = "postgres")]
@@ -259,6 +288,9 @@ impl SearchStore {
}
pub async fn unindex(&self, query: SearchQuery) -> trc::Result<u64> {
if let SearchStore::Store(Store::Replicated(store)) = self {
return crate::sql_backend!(&store.primary, db => db.unindex(query).await);
}
match self {
SearchStore::Store(store) => match store {
#[cfg(feature = "postgres")]
@@ -279,6 +311,8 @@ impl SearchStore {
Store::PostgreSQL(_) => None,
#[cfg(feature = "mysql")]
Store::MySQL(_) => None,
// inbuxa: ST-3: as its primary
Store::Replicated(replicated) if replicated.primary.is_pg_or_mysql() => None,
store => Some(store),
},
_ => None,
@@ -289,6 +323,10 @@ impl SearchStore {
match self {
#[cfg(feature = "mysql")]
SearchStore::Store(Store::MySQL(_)) => true,
#[cfg(feature = "mysql")]
SearchStore::Store(Store::Replicated(store)) => {
matches!(store.primary, Store::MySQL(_))
}
_ => false,
}
}
@@ -297,6 +335,10 @@ impl SearchStore {
match self {
#[cfg(feature = "postgres")]
SearchStore::Store(Store::PostgreSQL(_)) => true,
#[cfg(feature = "postgres")]
SearchStore::Store(Store::Replicated(store)) => {
matches!(store.primary, Store::PostgreSQL(_))
}
_ => false,
}
}
@@ -316,6 +358,9 @@ impl SearchStore {
Store::PostgreSQL(store) => store.create_search_tables().await,
#[cfg(feature = "mysql")]
Store::MySQL(store) => store.create_search_tables().await,
Store::Replicated(store) => {
crate::sql_backend!(&store.primary, db => db.create_search_tables().await)
}
_ => Ok(()),
},
SearchStore::ElasticSearch(store) => store.create_indexes().await,