When scaling PostgreSQL you may want to setup read replicas to improve performance and availability. This can be achieved by configuring multiple repositories in your application.
Setup Read Replicas
Following the ecto docs, change your Repo configuration:
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
@replicas [
MyApp.Repo.Replica1,
MyApp.Repo.Replica2,
MyApp.Repo.Replica3,
MyApp.Repo.Replica4
]
def replica do
case Process.get(:replica) do
nil ->
replica = Enum.random(@replicas)
Process.put(:replica, replica)
replica
replica ->
replica
end
end
for repo <- @replicas do
defmodule repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres,
read_only: true
end
end
endConfigure AshPostgres
Now change the repo argument for your postgres block as such:
defmodule MyApp.MyDomain.MyResource do
use Ash.Resource,
date_layer: AshPostgres.DataLayer
postgres do
table "my_resources"
repo fn
_resource, :read -> MyApp.Repo.replica()
_resource, :mutate -> MyApp.Repo
end
end
endThe repo function must answer the same way for a whole query
The function is called every time a repo is needed, and separate calls are compared
against each other. AshPostgres.DataLayer.can?(resource, {:join, other_resource}) requires
the two resources to share a data layer and repo(resource, :read) to equal
repo(other_resource, :read), while can?(resource, {:lateral_join, resources}) compares
every resource in the list against repo(resource, :read). Nothing memoizes those lookups,
so a function that picks at random per call answers differently within a single query, and
any expression that reaches through a relationship fails:
** (Ash.Error.Query.InvalidExpression) cannot access multiple resources for a data layer
that can't be joined from within a single expressionLateral joins are chosen from the same comparison, and there the source resource is itself part of the list being checked — so even loading a plain relationship asks about one resource twice and compares the two answers. When they disagree, related records are loaded with a different query plan, and there is no error to show for it.
This is why the example above remembers its choice in the process dictionary rather than
calling Enum.random/1 each time. Two further points for a real application:
A process that has just written should read from the primary until replication catches up. The
:readbranch of therepofunction is where that belongs — returnMyApp.Reporather than a replica for a short while after a mutation.A long-lived process (a
Phoenix.LiveView, a channel, aGenServer) keeps the repo it first chose. Clear it between units of work — at the start of a LiveView callback, or per request — so it can go back to a replica:Process.delete(:replica)Never clear it while a query is being built.