Skip to content

Configuration

SageConfig contains all connection settings. The same SageClient and commands work with a standalone server, a cluster, or a master-replica deployment. Only the configuration changes.

scala
val config = SageConfig(
  topology = Topology.Standalone(Endpoint("localhost", 6379))
)

SageConfig() uses the defaults and connects to a local standalone server. The following sections describe the connection fields. Connection tuning lists the runtime settings.

Standalone

The default topology. A single endpoint, and optionally a logical database:

scala
val config = SageConfig(
  topology = Topology.Standalone(Endpoint("localhost", 6379)),
  database = 0
)

The database is selected at connection setup and fixed for the client's lifetime. There is no runtime SELECT, because it would move the keyspace under every fiber sharing the connection.

Cluster

Give the cluster seeds. Sage discovers the full topology from them, routes each command to the node owning its key's slot, and follows MOVED/ASK redirects transparently:

scala
val config = SageConfig(
  topology = Topology.Cluster(
    Vector(Endpoint("localhost", 7000), Endpoint("localhost", 7001))
  ),
  database = 0
)

Seeds bootstrap discovery only. Once the topology is known, Sage routes to the nodes the cluster reports; any one seed answering is enough.

A non-zero database in cluster mode needs Valkey 9+ with a large enough cluster-databases setting. Redis Cluster and older Valkey versions reject the connection.

Hash tags

Redis Cluster hashes the bytes inside the first non-empty {...} pair instead of the whole key. Use the same tag in every key that must live in one slot, for example user:{42}:profile and user:{42}:settings. Transactions require all keys to share one slot.

Supported cross-slot commands

mGet, mSet, exists, del, unlink, and touch may span slots. Sage groups their keys by slot and sends one subcommand per slot. It then combines the replies. mGet restores request order, including missing and repeated positions. exists, del, unlink, and touch sum their counts. mSet succeeds only if every group returns OK. These commands also work inside a pipeline.

Each slot's subcommand is atomic on its own, but the call as a whole is not. A cross-slot mGet is not a point-in-time snapshot, and a failing mSet, del, or unlink may already have written to the groups that succeeded. If any group fails, the whole call fails. Use a common hash tag when the operation must be atomic.

mSetNx is never split, since that would break its all-or-nothing condition, and no cross-slot command is allowed inside a transaction, which must stay pinned to one slot.

Commands that run on every master

Each node sees only part of the keyspace. Script and function caches are also local to a node, as is pub/sub subscription information. For this reason, scriptLoad, scriptExists, scriptFlush, the function* mutations, flushAll, flushDb, keys, dbSize, waitReplicas, waitAof, memoryPurge, and the pubsub* introspection forms run on every slot-owning master, and their replies are folded into one: keys returns the whole keyspace, dbSize the cluster total, pubsubChannels every active channel, pubsubNumSub the summed subscriber count. If any master fails, the whole call fails.

These commands cannot run in a pipeline because a pipeline batches per node. Run them directly on the client. Sage also rejects dbSize inside a transaction because a transaction stays on one node. Sage queries only masters. As a result, pubsubNumSub does not count a subscriber connected through a replica, and pubsubNumPat counts a pattern once for each master that holds it.

A single master handles the keyless administrative commands info, configGet, configSet, slowLog*, latency*, commandLog*, and functionDump. configSet therefore changes one master, not the whole cluster. One node also handles a keyless read such as randomKey. Keyless reads follow the read routing policy and may run on a replica.

This is also the one case where Sage does not retry a -CLUSTERDOWN for you; see Refusals Sage retries for you.

Topology refresh

Sage re-reads the slot map whenever a command shows it is out of date: a MOVED or ASK redirect, a slot no known node covers, a node that is unreachable or no longer a master. Reshardings and failovers therefore need no configuration.

Adding a replica does not produce a redirect or connection failure, so Sage has no reason to refresh the topology. Reads under ReadFrom.Replica or ReadFrom.ReplicaPreferred continue using the replicas already known. Set topologyRefreshInterval to check for new replicas on a timer:

scala
val config = SageConfig(
  topology = Topology.Cluster(
    Vector(Endpoint("localhost", 7000)),
    ClusterConfig(topologyRefreshInterval = Some(30.seconds))
  ),
  readFrom = ReadFrom.Replica
)

Sage refreshes on a timer only when you configure this setting. Each refresh costs one CLUSTER SLOTS, and ticks arriving within minRefreshInterval of the last refresh are skipped, so a short interval cannot flood the cluster. MasterReplicaConfig has the same setting.

Master-replica

Select Topology.MasterReplica with seed endpoints. Sage discovers the nodes' roles, sends writes to the master, and routes reads per the read policy:

scala
val config = SageConfig(
  topology = Topology.MasterReplica(
    Vector(Endpoint("localhost", 6379), Endpoint("localhost", 6380))
  ),
  readFrom = ReadFrom.ReplicaPreferred
)

The number of endpoints controls where Sage may connect:

SeedsNodes Sage dials
Severalonly the supplied endpoints, each classified by its own ROLE. Addresses that ROLE advertises are ignored, and a replica is used once it reports connected
Onethe supplied endpoint and the master or replicas discovered from its ROLE reply

Use several endpoints for managed deployments whose stable primary and reader names differ from the per-node addresses Redis advertises.

At connect time, Sage leaves out an unreachable endpoint or a replica that is still synchronizing. The client can therefore start against a partially available deployment. Sage adds the endpoint later when a command reports a topology change. Adding another replica does not produce such a signal, so set topologyRefreshInterval when you add readers. See Topology refresh.

Read routing

readFrom controls which node may run a read-only command. The same setting applies to cluster and master-replica deployments:

ReadFromReads go to
Master (default)the master, always
MasterPreferredthe master, falling back to a replica
Replicaa replica, failing if none is reachable
ReplicaPreferreda replica, falling back to the master

Only read-only commands can use this setting. Writes, and any command not marked read-only, always go to the master. A replica may return older data than the master. This is an expected trade-off when reading from replicas.

TLS and ACL

Both are configured on the same client. tls selects the trust source, and auth sets the ACL username and password:

scala
val config = SageConfig(
  topology = Topology.Standalone(Endpoint("localhost", 6380)),
  tls = Some(TlsConfig(TrustSource.System)),
  auth = Some(AuthConfig(username = "app", password = "app-secret"))
)

TrustSource.System uses the system trust store. Use TrustSource.Pem or TrustSource.TrustStore for a private CA. Use TrustSource.Custom(sslContext) to supply your own SSLContext, including for mutual TLS. AuthConfig redacts its password in logs and in any printed SageConfig.

WARNING

TrustSource.Insecure is for local development only. It trusts every certificate and skips hostname verification, leaving the connection open to machine-in-the-middle attacks. Never use it in production.

Connection tuning

The remaining fields control connection lifetime, pooling, and observability. Each config type has defaults, so set only the fields you need.

FieldTunesDefaults
connectTimeouteach socket connect, TLS handshake, and connection-setup command10.seconds
reconnect (BackoffConfig)exponential reconnect backoff with full jitter50.millis to 5.seconds, ×2
watchdog (WatchdogConfig)connection liveness checks for pending commands and idle connectionsping every 60.seconds, 30.seconds timeout
closeTimeouthow long close waits for in-flight commands to finish (blocking commands and transactions are closed at once)5.seconds
dedicatedPool (DedicatedPoolConfig)the pool behind blocking commands, transactions, and lock replication checks, per nodemax 8, acquire 5.seconds, idle 30.seconds
pubsub (PubSubConfig)per-subscription message buffer size128
clientCache (CacheConfig)whether client-side caching is enabled and its size limitenabled, 64 MB
clientNameCLIENT SETNAME, shown in CLIENT LIST / CLIENT INFOnone
listenersobservers of runtime events (SageListener)none
tracerdistributed-tracing spans on the command path (CommandTracer)none

dedicatedPool.maxConnections applies to each node. A blocking command runs on the node that holds its keys, so every node has a separate pool. Connections open on demand, and Sage removes idle connections. When setting the limit, account for both the node count and the server's maxclients setting.

Each lock write and its replication check have a budget of at most one second, including pool acquisition. The pool wait ends at the earlier of dedicatedPool.acquireTimeout and the remaining lock budget. See Distributed locks for contention and timeout behavior.

For example, a cluster client with a shorter connect timeout, a larger blocking-command pool, a more frequent watchdog, and a name:

scala
import scala.concurrent.duration.*

val config = SageConfig(
  topology = Topology.Cluster(Vector(Endpoint("localhost", 7000))),
  connectTimeout = 5.seconds,
  dedicatedPool = DedicatedPoolConfig(maxConnections = 16),
  watchdog = WatchdogConfig(pingInterval = 30.seconds),
  clientName = Some("orders-service")
)

Disable client-side caching when a proxy or ACL permits ordinary commands but denies CLIENT TRACKING. A cached read then runs without caching, so the same call works against both server configurations.

scala
val config = SageConfig(
  topology = Topology.Standalone(Endpoint("localhost", 6379)),
  clientCache = CacheConfig(enabled = false)
)

From a connection URI

For common configurations, parse a redis:// or rediss:// URI instead of constructing each field. rediss selects TLS with system trust. User information becomes the ACL credentials, a /<db> path sets the database, and comma-separated hosts become cluster seeds. fromUri returns an error in Left instead of throwing. A URI cannot select insecure TLS.

scala
// fromUri returns Either: a Left describes the problem, a Right is the config
val parsed = SageConfig.fromUri("rediss://app:app-secret@localhost:6380/0")
// Further tuning stays programmatic:
//   SageConfig.fromUri(uri).map(_.copy(readFrom = ReadFrom.ReplicaPreferred))