Configure Read/Write Splitting
Evidence status: Java 1.525-RELEASE routing interfaces and runtime dispatch
were verified from released JAR signatures/bytecode. No primary/replica
integration run is retained, and the current generated Java baseline is blocked
before execution. This recipe therefore defines an implementation and test
boundary, not a supported topology. No equivalent Rust routing contract is
claimed here.
Problem
Send bounded, consistency-tolerant queries to a read replica while keeping mutations, transactions, read-after-write, permission-sensitive reads, and schema operations on the primary database.
Current Java Dispatch Boundary
Runtime 1.525-RELEASE resolves an entity's generated dataService name, or
default when it is empty, through DataServiceRegistry:
public interface DataServiceRegistry {
DataServiceExecutor resolve(String name);
QueryExecutor resolveQueryExecutor(String name);
MutationExecutor resolveMutationExecutor(String name);
Optional<TransactionExecutor> resolveTransactionExecutor(String name);
}
QueryExecutor.query(...), MutationExecutor.mutate(...), and
TransactionExecutor.executeInTransaction(...) all receive UserContext.
That is the request-scoped routing input.
The default registry stores one executor per route name. Registering two
executors under default does not create a primary/replica pair. Use a custom
registry that returns:
- a routing
QueryExecutorfor reads; - a primary
MutationExecutorfor writes; - a primary
TransactionExecutorfor transactions.
Define the Consistency Policy
Start with explicit policy states:
| Operation/state | Route |
|---|---|
| Ordinary list/report query with accepted lag | Replica |
| Read after a successful write in the same request | Primary |
| Read inside a write transaction | Primary |
| Authorization, permission, lock, or uniqueness decision | Primary |
| Mutation, delete, recover, schema, or ID-space update | Primary |
| Replica unhealthy or lag above threshold | Explicit application policy; normally primary or fail closed |
Define an accepted replication-lag budget for every replica-eligible endpoint. “Eventually consistent” without a measurable bound is not a routing policy.
Implement a Context-Aware Query Executor
This skeleton uses only released interface methods. The health/lag decision is application infrastructure:
public final class ReadRoutingQueryExecutor implements QueryExecutor {
public static final String FORCE_PRIMARY =
"io.teaql.routing.force-primary";
private final QueryExecutor primary;
private final QueryExecutor replica;
private final ReplicaHealth health;
public ReadRoutingQueryExecutor(
QueryExecutor primary,
QueryExecutor replica,
ReplicaHealth health) {
this.primary = primary;
this.replica = replica;
this.health = health;
}
@Override
public String name() {
return primary.name();
}
@Override
public DataServiceCapabilities capabilities() {
return primary.capabilities();
}
@Override
public QueryResult query(UserContext ctx, QueryRequest request) {
boolean forcePrimary = Boolean.TRUE.equals(ctx.extension(FORCE_PRIMARY));
QueryExecutor selected = forcePrimary || !health.replicaIsUsable()
? primary
: replica;
return selected.query(ctx, request);
}
}
ReplicaHealth is a project interface. It should include connectivity and a
measured lag threshold, not merely “connection opened successfully.”
Make Writes Sticky to Primary
Wrap the primary mutation executor so a successful write marks the request context before any later query:
public final class StickyPrimaryMutationExecutor implements MutationExecutor {
private final MutationExecutor primary;
public StickyPrimaryMutationExecutor(MutationExecutor primary) {
this.primary = primary;
}
@Override
public String name() {
return primary.name();
}
@Override
public DataServiceCapabilities capabilities() {
return primary.capabilities();
}
@Override
public MutationResult mutate(UserContext ctx, MutationRequest request) {
MutationResult result = primary.mutate(ctx, request);
ctx.put(ReadRoutingQueryExecutor.FORCE_PRIMARY, Boolean.TRUE);
return result;
}
}
Mark only after successful mutation. Also force primary when entering a write transaction; clear request-scoped state when the context lifecycle ends, not between operations in the same request.
Register the Split
Implement DataServiceRegistry so the same generated route name resolves to
the routing query executor and primary write/transaction executors, then mount
it through the current builder:
TeaQLRuntime runtime = TeaQLRuntime.builder()
.metadata(metaFactory)
.registry(readWriteRegistry)
.build();
The registry implementation must reject unknown routes and must not return a replica transaction executor. If an executor serves several tenants or regions, validate that routing key before choosing either data source.
Application Query and Mutation
Business code keeps the normal generated API:
SmartList<Order> orders = Q.orders()
.filterByMerchant(ctx.getMerchant())
.orderByCreateTimeDescending()
.comment("Query merchant orders")
.purpose("Render a replica-tolerant order report")
.executeForList(ctx);
order.updateStatusToConfirmed();
order.auditAs("Confirm customer order").save(ctx);
Order reloaded = Q.orders()
.filterById(order.getId())
.comment("Reload confirmed order")
.purpose("Return the committed order state")
.executeForOne(ctx);
The entity, update, filter, and ordering methods are illustrative. Inspect the generated entity/request sources. The sticky mutation wrapper is what keeps the second query on primary.
Verification
Test against distinct primary and replica endpoints with controllable lag:
- An eligible read reaches the replica, proven by connection/query telemetry.
- Every insert/update/delete/recover reaches only primary.
- A read following a successful mutation in one context reaches primary.
- Reads inside write transactions reach primary and share the intended transaction/connection behavior.
- Permission, uniqueness, and lock decisions never use stale replicas.
- Replica lag above the configured budget triggers the documented fallback or failure policy.
- Replica outage, primary outage, reconnect, timeout, and partial network failure do not cause writes to reach replica.
- Tenant/region routing cannot select another tenant's data source.
- Metrics record selected route, lag, fallback reason, and failure without exposing credentials or sensitive query values.
Record runtime/provider/database/driver versions, topology, replication mode,
lag threshold, commands, and telemetry before changing this page to verified.
Failure Modes
| Symptom | Most likely cause | Smallest credible fix |
|---|---|---|
| Newly written row is missing | Later query went to a lagging replica. | Mark successful mutations and write transactions as primary-sticky. |
| Both executors resolve to the same route | Default registry overwrote one registration. | Use a custom registry with separate query/mutation resolution. |
| Permission decision is stale | Security-sensitive query was replica-eligible. | Force primary before the decision and test revocation latency. |
| Write appears on replica connection | Mutation/transaction registry method returns the wrong executor. | Make primary-only resolution an invariant and add connection-level assertions. |
| Replica fallback overloads primary | Health policy falls back without capacity controls. | Add admission control, lag/traffic metrics, and an explicit fail-closed option. |
| Generated method is missing | Example names do not match the model. | Read generated source; do not patch generated output. |