From 0974f9700c1d526aef2fbd9215c283ad21e231ce Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Thu, 8 Oct 2026 11:04:30 +0200 Subject: [PATCH 1/2] Support read replicas via play.ebean.readOnlyDatasources Ebean can run queries that are executed outside of a transaction against a separate read-only DataSource, typically a read replica. However, play-ebean only ever provided the main DataSource to Ebean, so there was no way to set up a read replica via Play's configuration. The new play.ebean.readOnlyDatasources setting maps an Ebean server to a Play database, whose DataSource then becomes the read-only DataSource of that Ebean server, e.g.: db.replica.url = "jdbc:postgresql://replica-host/app" play.ebean.readOnlyDatasources.default = "replica" This way Play manages the read replica's connection pool like that of any other database. Also run the tests sequentially, because Ebean registers databases JVM-wide by name and the tests start applications using the same names. --- build.sbt | 4 +- .../working/javaGuide/main/sql/JavaEbean.md | 19 +++++ .../play/db/ebean/DefaultEbeanConfig.java | 23 +++++ .../java/play/db/ebean/EbeanParsedConfig.java | 32 ++++++- play-ebean/src/main/resources/reference.conf | 5 ++ .../play/db/ebean/EbeanParsedConfigTest.java | 9 ++ .../db/ebean/EbeanReadOnlyDatasourceTest.java | 84 +++++++++++++++++++ 7 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java diff --git a/build.sbt b/build.sbt index 098c7dc2..929fdf2a 100644 --- a/build.sbt +++ b/build.sbt @@ -57,7 +57,9 @@ lazy val core = project crossScalaVersions := Seq(scala213, scala3), Dependencies.ebean, mimaSettings, - Compile / compile := enhanceEbeanClasses( + // Ebean registers databases JVM-wide by name, so tests starting applications must not run in parallel + Test / parallelExecution := false, + Compile / compile := enhanceEbeanClasses( (Compile / dependencyClasspath).value, (Compile / compile).value, (Compile / classDirectory).value, diff --git a/docs/manual/working/javaGuide/main/sql/JavaEbean.md b/docs/manual/working/javaGuide/main/sql/JavaEbean.md index 373964e4..a3c2a634 100644 --- a/docs/manual/working/javaGuide/main/sql/JavaEbean.md +++ b/docs/manual/working/javaGuide/main/sql/JavaEbean.md @@ -45,6 +45,25 @@ As an example, the fairly common problem of reducing the sequence batch size in Note that Ebean will also make use of a `conf/orm.xml` file (if present), to configure ``. +#### Using a read replica + +Ebean can run queries that are executed outside of a transaction against a separate, read-only data source, typically a [read replica](https://ebean.io/docs/read-replicas/). Queries inside of transactions and all writes keep using the main data source. + +To use a read replica, configure it as an additional Play database and map it to the Ebean server via `play.ebean.readOnlyDatasources`: + +```properties +db.default.url = "jdbc:postgresql://primary-host/app" +db.replica.url = "jdbc:postgresql://replica-host/app" +db.replica.hikaricp.readOnly = true + +ebean.default = ["models.*"] +play.ebean.readOnlyDatasources.default = "replica" +``` + +The key is the name of the Ebean server (here `default`) and the value is the name of the Play database used for its read-only queries. Play manages the connection pool of the read replica like that of any other database, so all the usual `db` settings apply. + +Ebean supports one read-only data source per Ebean server. To spread the read-only queries across multiple read replicas, use a JDBC URL that lists all of them (if supported by your JDBC driver, e.g. the PostgreSQL driver's `loadBalanceHosts` and `targetServerType` parameters), or point the URL to a load balancer or the reader endpoint of your database service. + > For more information about Ebean, see the [Ebean documentation](https://ebean-orm.github.io/docs). #### Configuring the sbt plugin diff --git a/play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java b/play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java index 4e53e275..eef195eb 100644 --- a/play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java +++ b/play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java @@ -91,6 +91,13 @@ public EbeanConfig parse() { EbeanParsedConfig ebeanConfig = EbeanParsedConfig.parseFromConfig(config); + for (String key : ebeanConfig.getReadOnlyDatasources().keySet()) { + if (!ebeanConfig.getDatasourceModels().containsKey(key)) { + throw new ConfigException.BadValue( + "play.ebean.readOnlyDatasources." + key, "There is no Ebean server '" + key + "'"); + } + } + Map serverConfigs = new HashMap<>(); for (Map.Entry> entry : ebeanConfig.getDatasourceModels().entrySet()) { @@ -109,6 +116,8 @@ public EbeanConfig parse() { serverConfig.loadFromProperties(); setServerConfigDataSource(key, serverConfig); + setServerConfigReadOnlyDataSource( + key, serverConfig, ebeanConfig.getReadOnlyDatasources().get(key)); if (!ebeanConfig.getDefaultDatasource().equals(key)) { serverConfig.setDefaultServer(false); @@ -134,6 +143,20 @@ private void setServerConfigDataSource(String key, DatabaseConfig serverConfig) } } + private void setServerConfigReadOnlyDataSource( + String key, DatabaseConfig serverConfig, String readOnlyDatasource) { + if (readOnlyDatasource == null) { + return; + } + if (dbApi.getDatabase(readOnlyDatasource) == null) { + throw new ConfigException.BadValue( + "play.ebean.readOnlyDatasources." + key, + "There is no Play database '" + readOnlyDatasource + "'"); + } + // Not wrapped: Ebean's implicit read-only transactions work best with auto-commit + serverConfig.setReadOnlyDataSource(dbApi.getDatabase(readOnlyDatasource).getDataSource()); + } + private void addModelClassesToServerConfig( String key, DatabaseConfig serverConfig, Set classes) { for (String clazz : classes) { diff --git a/play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java b/play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java index 941e7bf0..69e6c615 100644 --- a/play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java +++ b/play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java @@ -7,6 +7,7 @@ import com.typesafe.config.Config; import com.typesafe.config.ConfigValueType; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -23,13 +24,24 @@ public class EbeanParsedConfig { private final boolean generateEvolutionsScripts; + private final Map readOnlyDatasources; + public EbeanParsedConfig( String defaultDatasource, Map> datasourceModels, - boolean generateEvolutionsScripts) { + boolean generateEvolutionsScripts, + Map readOnlyDatasources) { this.defaultDatasource = defaultDatasource; this.datasourceModels = datasourceModels; this.generateEvolutionsScripts = generateEvolutionsScripts; + this.readOnlyDatasources = readOnlyDatasources; + } + + public EbeanParsedConfig( + String defaultDatasource, + Map> datasourceModels, + boolean generateEvolutionsScripts) { + this(defaultDatasource, datasourceModels, generateEvolutionsScripts, Collections.emptyMap()); } public EbeanParsedConfig(String defaultDatasource, Map> datasourceModels) { @@ -48,6 +60,14 @@ public boolean generateEvolutionsScripts() { return generateEvolutionsScripts; } + /** + * @return the names of the Play databases the Ebean servers use for read-only queries, keyed by + * Ebean server name. + */ + public Map getReadOnlyDatasources() { + return readOnlyDatasources; + } + /** * Parse a play configuration. * @@ -80,6 +100,14 @@ public static EbeanParsedConfig parseFromConfig(Config config) { datasourceModels.put(key, models); }); } - return new EbeanParsedConfig(defaultDatasource, datasourceModels, generateEvolutionsScripts); + Map readOnlyDatasources = new HashMap<>(); + Config readOnlyDatasourcesConfig = playEbeanConfig.getConfig("readOnlyDatasources"); + readOnlyDatasourcesConfig + .root() + .keySet() + .forEach(key -> readOnlyDatasources.put(key, readOnlyDatasourcesConfig.getString(key))); + + return new EbeanParsedConfig( + defaultDatasource, datasourceModels, generateEvolutionsScripts, readOnlyDatasources); } } diff --git a/play-ebean/src/main/resources/reference.conf b/play-ebean/src/main/resources/reference.conf index dfce38b9..03a2e294 100644 --- a/play-ebean/src/main/resources/reference.conf +++ b/play-ebean/src/main/resources/reference.conf @@ -12,5 +12,10 @@ play { # The key to control the generation of the Evolutions scripts generateEvolutionsScripts = true + + # Maps Ebean servers to the Play databases their read-only queries should use (e.g. a read + # replica). For example, `default = "replica"` makes the default Ebean server run queries + # outside of transactions against the `replica` database. + readOnlyDatasources {} } } diff --git a/play-ebean/src/test/java/play/db/ebean/EbeanParsedConfigTest.java b/play-ebean/src/test/java/play/db/ebean/EbeanParsedConfigTest.java index fd899695..f277b916 100644 --- a/play-ebean/src/test/java/play/db/ebean/EbeanParsedConfigTest.java +++ b/play-ebean/src/test/java/play/db/ebean/EbeanParsedConfigTest.java @@ -27,6 +27,7 @@ public void defaultConfig() { assertThat(config.getDefaultDatasource(), equalTo("default")); assertThat(config.getDatasourceModels().size(), equalTo(0)); assertThat(config.generateEvolutionsScripts(), equalTo(Boolean.TRUE)); + assertThat(config.getReadOnlyDatasources().size(), equalTo(0)); } @Test @@ -60,6 +61,14 @@ public void disableGenerateEvolutionsScripts() { assertThat(config.generateEvolutionsScripts(), equalTo(Boolean.FALSE)); } + @Test + public void readOnlyDatasources() { + EbeanParsedConfig config = + parse(ImmutableMap.of("play.ebean.readOnlyDatasources.default", "replica")); + assertThat(config.getReadOnlyDatasources().size(), equalTo(1)); + assertThat(config.getReadOnlyDatasources().get("default"), equalTo("replica")); + } + @Test public void customConfig() { EbeanParsedConfig config = diff --git a/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java b/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java new file mode 100644 index 00000000..f386c8af --- /dev/null +++ b/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.db.ebean; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import io.ebean.DB; +import io.ebean.Database; +import io.ebean.Transaction; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import play.Application; +import play.inject.guice.GuiceApplicationBuilder; +import play.test.Helpers; + +public class EbeanReadOnlyDatasourceTest { + + private static GuiceApplicationBuilder appBuilder(Map config) { + return new GuiceApplicationBuilder() + .configure( + Map.of( + "db.default.driver", "org.h2.Driver", + "db.default.url", "jdbc:h2:mem:play-ebean-primary", + "db.replica.driver", "org.h2.Driver", + "db.replica.url", "jdbc:h2:mem:play-ebean-replica", + "ebean.default", List.of())) + .configure(config); + } + + private static String currentDatabase(Database database) { + return database.sqlQuery("select database()").mapToScalar(String.class).findOne(); + } + + @Test + public void queriesOutsideOfTransactionsUseReadOnlyDatasource() { + Application app = + appBuilder(Map.of("play.ebean.readOnlyDatasources.default", "replica")).build(); + Helpers.start(app); + try { + Database database = DB.getDefault(); + assertEquals("PLAY-EBEAN-REPLICA", currentDatabase(database)); + try (Transaction transaction = database.beginTransaction()) { + assertEquals("PLAY-EBEAN-PRIMARY", currentDatabase(database)); + } + } finally { + Helpers.stop(app); + } + } + + @Test + public void queriesUseMainDatasourceByDefault() { + Application app = appBuilder(Map.of()).build(); + Helpers.start(app); + try { + assertEquals("PLAY-EBEAN-PRIMARY", currentDatabase(DB.getDefault())); + } finally { + Helpers.stop(app); + } + } + + @Test + public void failsForUnknownPlayDatabase() { + GuiceApplicationBuilder builder = + appBuilder(Map.of("play.ebean.readOnlyDatasources.default", "unknown")); + Exception e = assertThrows(Exception.class, builder::build); + assertThat(e.getMessage(), containsString("play.ebean.readOnlyDatasources.default")); + assertThat(e.getMessage(), containsString("There is no Play database 'unknown'")); + } + + @Test + public void failsForUnknownEbeanServer() { + GuiceApplicationBuilder builder = + appBuilder(Map.of("play.ebean.readOnlyDatasources.unknown", "replica")); + Exception e = assertThrows(Exception.class, builder::build); + assertThat(e.getMessage(), containsString("play.ebean.readOnlyDatasources.unknown")); + assertThat(e.getMessage(), containsString("There is no Ebean server 'unknown'")); + } +} From 284cc01b8f6ec8f79d1ee7f975936becbcd9160c Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Thu, 8 Oct 2026 11:27:43 +0200 Subject: [PATCH 2/2] Mention read-only transactions in the read replica docs Explicitly read-only transactions, e.g. TxScope.required().setReadOnly(true), use the read-only DataSource as well. Say so in the docs and cover it in the test. --- docs/manual/working/javaGuide/main/sql/JavaEbean.md | 2 +- .../test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/manual/working/javaGuide/main/sql/JavaEbean.md b/docs/manual/working/javaGuide/main/sql/JavaEbean.md index a3c2a634..8ecb2284 100644 --- a/docs/manual/working/javaGuide/main/sql/JavaEbean.md +++ b/docs/manual/working/javaGuide/main/sql/JavaEbean.md @@ -47,7 +47,7 @@ Note that Ebean will also make use of a `conf/orm.xml` file (if present), to con #### Using a read replica -Ebean can run queries that are executed outside of a transaction against a separate, read-only data source, typically a [read replica](https://ebean.io/docs/read-replicas/). Queries inside of transactions and all writes keep using the main data source. +Ebean can run queries against a separate, read-only data source, typically a [read replica](https://ebean.io/docs/read-replicas/). This applies to queries executed outside of a transaction and to explicitly read-only transactions (e.g. `TxScope.required().setReadOnly(true)`). All writes and all queries inside of other transactions, including the ones of `@Transactional` actions, keep using the main data source. To use a read replica, configure it as an additional Play database and map it to the Ebean server via `play.ebean.readOnlyDatasources`: diff --git a/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java b/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java index f386c8af..488e8972 100644 --- a/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java +++ b/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java @@ -12,6 +12,7 @@ import io.ebean.DB; import io.ebean.Database; import io.ebean.Transaction; +import io.ebean.TxScope; import java.util.List; import java.util.Map; import org.junit.Test; @@ -48,6 +49,9 @@ public void queriesOutsideOfTransactionsUseReadOnlyDatasource() { try (Transaction transaction = database.beginTransaction()) { assertEquals("PLAY-EBEAN-PRIMARY", currentDatabase(database)); } + database.execute( + TxScope.required().setReadOnly(true), + () -> assertEquals("PLAY-EBEAN-REPLICA", currentDatabase(database))); } finally { Helpers.stop(app); }