Skip to content

Commit

Permalink
Support RedisClientMasterSlaves (#295)
Browse files Browse the repository at this point in the history
Co-authored-by: Alex Mihailov <avmikhaylov@fil-it.ru>
Co-authored-by: Karel Cemus <karel.cemus@gmail.com>
  • Loading branch information
3 people authored Apr 11, 2024
1 parent 9944296 commit dd8c5e9
Show file tree
Hide file tree
Showing 9 changed files with 396 additions and 17 deletions.
70 changes: 57 additions & 13 deletions doc/20-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ There are several features supported in the configuration, they are discussed be

This implementation supports both standalone and cluster instances. By default, the standalone mode is enabled. It is configured like this:

```
```hocon
play.cache.redis {
host: localhost
# redis server: port
Expand All @@ -26,7 +26,7 @@ This implementation supports both standalone and cluster instances. By default,

To enable cluster mode instead, use `source` property. Valid values are `standalone` (default), `cluster`, `connection-string`, and `custom`. For more details, see below. Example of cluster settings:

```
```hocon
play.cache.redis {
# enable cluster mode
source: cluster
Expand All @@ -52,7 +52,7 @@ play.cache.redis {
Some platforms such as Amazon AWS use a single DNS record to define a whole cluster. Such
a domain name resolves to multiple IP addresses, which are nodes of a cluster.

```
```hocon
play.cache.redis {
instances {
play {
Expand All @@ -68,7 +68,7 @@ play.cache.redis {
Use `source: sentinel` to enable sentinel mode. Required parameters are
`master_group_name: ...` and `sentinels: []`. An example of sentinel settings:

```
```hocon
play.cache.redis {
source: sentinel
Expand Down Expand Up @@ -101,6 +101,50 @@ play.cache.redis {
}
```

## Master-Slaves

Use `source: master-slaves` to enable master-slaves mode.
In this mode write only to the master node and read from one of slaves node.
Required parameters are `master: {...}` and `slaves: []`. An example of master-slaves settings:

```hocon
play.cache.redis {
source: master-slaves
# username to your redis hosts (optional)
username: some-username
# password to your redis hosts, use if not specified for a specific node (optional)
password: "my-password"
# number of your redis database, use if not specified for a specific node (optional)
database: 1
# master node
master: {
host: "localhost"
port: 6380
# number of your redis database on master (optional)
database: 1
# username on master host (optional)
username: some-username
# password on master host (optional)
password: something
}
# slave nodes
slaves: [
{
host: "localhost"
port: 6381
# number of your redis database on slave (optional)
database: 1
# username on slave host (optional)
username: some-username
# password on slave host (optional)
password: something
}
]
}
```

## Named caches

Play framework supports [named caches](https://www.playframework.com/documentation/2.6.x/ScalaCache#Accessing-different-caches) through a qualifier. For a simplicity, the default cache is also exposed without a qualifier to ease the access. This feature can be disabled by `bind-default` property, which defaults to true. The name of the default cache is defined in `default-cache` property, which defaults to `play` to keep consistency with Play framework.
Expand Down Expand Up @@ -307,7 +351,7 @@ each instance uses `lazy` policy.

## Running in different environments

This module can run in various environments, from the localhost through the Heroku to your own premise. Each of these has a possibly different configuration. For this purpose, there is a `source` property accepting 4 values: `standalone` (default), `cluster`, `connection-string`, and `custom`.
This module can run in various environments, from the localhost through the Heroku to your own premise. Each of these has a possibly different configuration. For this purpose, there is a `source` property accepting 4 values: `standalone` (default), `cluster`, `connection-string`, `master-slaves` and `custom`.

The `standalone` and `cluster` options are already explained. The latter two simplify the use in environments, where the connection cannot be written into the configuration file up front.

Expand Down Expand Up @@ -366,11 +410,11 @@ configuration, see the [official Pekko documentation](https://pekko.apache.org/d

### Instance-specific (can be locally overridden)

| Key | Type | Default | Description |
|----------------------------------------------------------|---------:|-------------------------------------:|-------------------------------------------------------------------------------------------------------------------------|
| [play.cache.redis.source](#standalone-vs-cluster) | String | `standalone` | Defines the source of the configuration. Accepted values are `standalone`, `cluster`, `connection-string`, and `custom` |
| [play.cache.redis.sync-timeout](#timeout) | Duration | `1s` | conversion timeout applied by `SyncAPI` to convert `Future[T]` to `T` |
| [play.cache.redis.redis-timeout](#timeout) | Duration | `null` | waiting for the response from redis server |
| [play.cache.redis.prefix](#namespace-prefix) | String | `null` | optional namespace, i.e., key prefix |
| play.cache.redis.dispatcher | String | `pekko.actor.default-dispatcher` | Pekko actor |
| [play.cache.redis.recovery](#recovery-policy) | String | `log-and-default` | Defines behavior when command execution fails. For accepted values and more see |
| Key | Type | Default | Description |
|----------------------------------------------------------|---------:|-------------------------------------:|-----------------------------------------------------------------------------------------------------------------------------------------|
| [play.cache.redis.source](#standalone-vs-cluster) | String | `standalone` | Defines the source of the configuration. Accepted values are `standalone`, `cluster`, `connection-string`, `master-slaves` and `custom` |
| [play.cache.redis.sync-timeout](#timeout) | Duration | `1s` | conversion timeout applied by `SyncAPI` to convert `Future[T]` to `T` |
| [play.cache.redis.redis-timeout](#timeout) | Duration | `null` | waiting for the response from redis server |
| [play.cache.redis.prefix](#namespace-prefix) | String | `null` | optional namespace, i.e., key prefix |
| play.cache.redis.dispatcher | String | `pekko.actor.default-dispatcher` | Pekko actor |
| [play.cache.redis.recovery](#recovery-policy) | String | `log-and-default` | Defines behavior when command execution fails. For accepted values and more see |
46 changes: 45 additions & 1 deletion src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,49 @@ play.cache.redis {
# # note: To consider a 'connection-string' variable, set 'source'
# # to 'connection-string'.
# source: connection-string
# }
#
# ##########################
# # Master-Slaves mode
# ##########################
#
# # master node, required config
# master: {
# # required string, defining a host the master is running on
# host: localhost
# # required integer, defining a port the master is running on
# port: 6379
# # number of your redis database on master (optional)
# database: 1
# # optional string, defines a username to use with "redis" as a fallback
# username: null
# # password on master host (optional)
# password: something
# }
#
# # list of slaves nodes is either [].
# slaves: [
# {
# # required string, defining a host the slave is running on
# host: localhost
# # required integer, defining a port the slave is running on
# port: 6379
# # number of your redis database on slave (optional)
# database: 1
# # optional string, defines a username to use with "redis" as a fallback
# username: null
# # password on slave host (optional)
# password: something
# }
# ]
#
# # number of your redis database, use if not specified for a node (optional)
# database: 1
# # optional string, defines a username to use with "redis" as a fallback
# username: null
# # password to your redis hosts, use if not specified for a node (optional)
# password: something
# # to enable the master-slaves, set 'source' variable to 'master-slaves'
# source: master-slaves
# }

# configuration source. This library supports multiple types of
Expand All @@ -163,6 +205,8 @@ play.cache.redis {
# - 'sentinel' mode indicates use of 'sentinels' variable defining nodes
# - 'connection-string' mode is usually used with PaaS as setup by the
# environment. It consideres 'connection-string' property.
# - 'master-slaves' master-slave mode is used to write only to the master node
# and read from one of slaves node. It consideres 'master-slaves' property.
# - 'custom' indicates that the user supplies his own RedisInstance configuration
#
# Default value is 'standalone'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,43 @@ object RedisSentinel {
}

}

/**
* Type of Redis Instance - a master-slaves. It encapsulates common settings of
* the master and slaves nodes.
*/
sealed trait RedisMasterSlaves extends RedisInstance {

def master: RedisHost
def slaves: List[RedisHost]
def username: Option[String]
def password: Option[String]
def database: Option[Int]

override def equals(obj: scala.Any): Boolean = obj match {
case that: RedisMasterSlaves => equalsAsInstance(that) && this.master === master && this.slaves === that.slaves
case _ => false
}

/** to string */
override def toString: String = s"MasterSlaves[master=$master, slaves=${slaves mkString ","}]"
}

object RedisMasterSlaves {

def apply(name: String, master: RedisHost, slaves: List[RedisHost], settings: RedisSettings, username: Option[String] = None, password: Option[String] = None, database: Option[Int] = None): RedisMasterSlaves with RedisDelegatingSettings =
create(name, master, slaves, username, password, database, settings)

@inline
private def create(_name: String, _master: RedisHost, _slaves: List[RedisHost], _username: Option[String], _password: Option[String], _database: Option[Int], _settings: RedisSettings) =
new RedisMasterSlaves with RedisDelegatingSettings {
override val name: String = _name
override val master: RedisHost = _master
override val slaves: List[RedisHost] = _slaves
override val username: Option[String] = _username
override val password: Option[String] = _password
override val database: Option[Int] = _database
override val settings: RedisSettings = _settings
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ private[configuration] object RedisInstanceProvider extends RedisConfigInstanceL
case "aws-cluster" => RedisInstanceAwsCluster
// required static configuration of the sentinel using application.conf
case "sentinel" => RedisInstanceSentinel
// required static configuration of the master-slaves using application.conf
case "master-slaves" => RedisInstanceMasterSlaves
// required possibly environmental configuration of the standalone instance
case "connection-string" => RedisInstanceEnvironmental
// supplied custom configuration
Expand Down Expand Up @@ -161,6 +163,26 @@ private[configuration] object RedisInstanceSentinel extends RedisConfigInstanceL

}

/** Statically configures redis master-slaves. */
private[configuration] object RedisInstanceMasterSlaves extends RedisConfigInstanceLoader[RedisInstanceProvider] {

import JavaCompatibilityBase._
import RedisConfigLoader._

def load(config: Config, path: String, instanceName: String)(implicit defaults: RedisSettings) = new ResolvedRedisInstance(
RedisMasterSlaves.apply(
name = instanceName,
master = RedisHost.load(config.getConfig(path / "master")),
slaves = config.getConfigList(path / "slaves").asScala.map(config => RedisHost.load(config)).toList,
username = config.getOption(path / "username", _.getString),
password = config.getOption(path / "password", _.getString),
database = config.getOption(path / "database", _.getInt),
settings = RedisSettings.withFallback(defaults).load(config, path),
),
)

}

/**
* This binder indicates that the user provides his own configuration of this
* named cache.
Expand Down
60 changes: 57 additions & 3 deletions src/main/scala/play/api/cache/redis/connector/RedisCommands.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import scala.concurrent.duration.FiniteDuration
private[connector] class RedisCommandsProvider(instance: RedisInstance)(implicit system: ActorSystem, lifecycle: ApplicationLifecycle) extends Provider[RedisCommands] {

lazy val get: RedisCommands = instance match {
case cluster: RedisCluster => new RedisCommandsCluster(cluster).get
case standalone: RedisStandalone => new RedisCommandsStandalone(standalone).get
case sentinel: RedisSentinel => new RedisCommandsSentinel(sentinel).get
case cluster: RedisCluster => new RedisCommandsCluster(cluster).get
case standalone: RedisStandalone => new RedisCommandsStandalone(standalone).get
case sentinel: RedisSentinel => new RedisCommandsSentinel(sentinel).get
case masterSlaves: RedisMasterSlaves => new RedisCommandsMasterSlaves(masterSlaves).get
}

}
Expand Down Expand Up @@ -184,3 +185,56 @@ private[connector] class RedisCommandsSentinel(configuration: RedisSentinel)(imp
// $COVERAGE-ON$

}

/**
* Creates a connection to master and slaves nodes.
*
* @param lifecycle
* application lifecycle to trigger on stop hook
* @param configuration
* configures master-slaves
* @param system
* actor system
*/
private[connector] class RedisCommandsMasterSlaves(configuration: RedisMasterSlaves)(implicit system: ActorSystem, val lifecycle: ApplicationLifecycle) extends Provider[RedisCommands] with AbstractRedisCommands {
import HostnameResolver._

val client: RedisClientMasterSlaves with RedisRequestTimeout = new RedisClientMasterSlaves(
master = RedisServer(
host = configuration.master.host.resolvedIpAddress,
port = configuration.master.port,
username = if (configuration.master.username.isEmpty) configuration.username else configuration.master.username,
password = if (configuration.master.password.isEmpty) configuration.password else configuration.master.password,
db = if (configuration.master.database.isEmpty) configuration.database else configuration.master.database,
),
slaves = configuration.slaves.map { case RedisHost(host, port, db, username, password) =>
RedisServer(
host.resolvedIpAddress,
port,
if (username.isEmpty) configuration.username else username,
if (password.isEmpty) configuration.password else password,
if (db.isEmpty) configuration.database else db,
)
},
) with RedisRequestTimeout {

protected val timeout: Option[FiniteDuration] = configuration.timeout.redis

implicit protected val scheduler: Scheduler = system.scheduler

override def send[T](redisCommand: RedisCommand[? <: protocol.RedisReply, T]): Future[T] = super.send(redisCommand)
}

// $COVERAGE-OFF$
def start(): Unit =
log.info(s"Redis master-slaves cache actor started. It is connected to ${configuration.toString}")

def stop(): Future[Unit] = Future successful {
log.info("Stopping the redis master-slaves cache actor ...")
client.masterClient.stop()
client.slavesClients.stop()
log.info("Redis master-slaves cache stopped.")
}
// $COVERAGE-ON$

}
14 changes: 14 additions & 0 deletions src/test/resources/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: '3.7'
services:
redis-master:
image: redis:latest
hostname: redis-master
ports:
- '6379:6379'

redis-slave:
image: redis:latest
hostname: redis-slave
ports:
- '6479:6379'
command: redis-server --slaveof redis-master 6379
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,41 @@ class RedisInstanceManagerSpec extends UnitSpec with ImplicitOptionMaterializati

}

"master-slaves mode" in new TestCase {

override protected def hocon: String =
"""
|play.cache.redis {
| instances {
| play {
| master: { host: "localhost", port: 6380 }
| slaves: [
| { host: "localhost", port: 6381 }
| { host: "localhost", port: 6382 }
| ]
| password: "my-password"
| database: 1
| source: master-slaves
| }
| }
|}
"""

private def node(port: Int) = RedisHost(localhost, port)

manager mustEqual RedisInstanceManagerTest(defaultCacheName)(
RedisMasterSlaves(
name = defaultCacheName,
master = node(6380),
slaves = node(6381) :: node(6382) :: Nil,
settings = defaultsSettings.copy(source = "master-slaves"),
password = "my-password",
database = 1,
),
)

}

"custom mode" in new TestCase {

override protected def hocon: String =
Expand Down
Loading

0 comments on commit dd8c5e9

Please sign in to comment.