Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions docs/manual/working/javaGuide/main/sql/JavaEbean.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<entity-mappings>`.

#### Using a read replica

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`:

```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
Expand Down
23 changes: 23 additions & 0 deletions play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, DatabaseConfig> serverConfigs = new HashMap<>();

for (Map.Entry<String, List<String>> entry : ebeanConfig.getDatasourceModels().entrySet()) {
Expand All @@ -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);
Expand All @@ -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<String> classes) {
for (String clazz : classes) {
Expand Down
32 changes: 30 additions & 2 deletions play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,13 +24,24 @@ public class EbeanParsedConfig {

private final boolean generateEvolutionsScripts;

private final Map<String, String> readOnlyDatasources;

public EbeanParsedConfig(
String defaultDatasource,
Map<String, List<String>> datasourceModels,
boolean generateEvolutionsScripts) {
boolean generateEvolutionsScripts,
Map<String, String> readOnlyDatasources) {
this.defaultDatasource = defaultDatasource;
this.datasourceModels = datasourceModels;
this.generateEvolutionsScripts = generateEvolutionsScripts;
this.readOnlyDatasources = readOnlyDatasources;
}

public EbeanParsedConfig(
String defaultDatasource,
Map<String, List<String>> datasourceModels,
boolean generateEvolutionsScripts) {
this(defaultDatasource, datasourceModels, generateEvolutionsScripts, Collections.emptyMap());
}

public EbeanParsedConfig(String defaultDatasource, Map<String, List<String>> datasourceModels) {
Expand All @@ -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<String, String> getReadOnlyDatasources() {
return readOnlyDatasources;
}

/**
* Parse a play configuration.
*
Expand Down Expand Up @@ -80,6 +100,14 @@ public static EbeanParsedConfig parseFromConfig(Config config) {
datasourceModels.put(key, models);
});
}
return new EbeanParsedConfig(defaultDatasource, datasourceModels, generateEvolutionsScripts);
Map<String, String> 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);
}
}
5 changes: 5 additions & 0 deletions play-ebean/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (C) from 2022 The Play Framework Contributors <https://github.com/playframework>, 2011-2021 Lightbend Inc. <https://www.lightbend.com>
*/

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 io.ebean.TxScope;
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<String, Object> 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));
}
database.execute(
TxScope.required().setReadOnly(true),
() -> assertEquals("PLAY-EBEAN-REPLICA", 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'"));
}
}