Skip to content

Commit

Permalink
Add a Redis Sentinel backend
Browse files Browse the repository at this point in the history
This new backend allows using a Redis Sentinel cluster to reliably store objects in the cache.

With the redundancy and automatic master switching in a Redis Sentinel cluster, it ensures that the service and data stays available even if some of the storage nodes go down.

Please note that due to the delay in Redis replication, some of the most recent data inserted in the current master can be lost if this host goes down before having a chance to replicate the change. It also takes some time for Sentinel to detect a host going down and elect a new master, so a short outage may occur when the current master fails.

Redis Sentinel documentation (https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/) recommends a minimum of 3 nodes and gives several architecture examples.
  • Loading branch information
sebmil-daily committed Apr 26, 2024
1 parent 84a0690 commit 0f2eca0
Show file tree
Hide file tree
Showing 9 changed files with 381 additions and 19 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ backend:
### Aerospike
Prebid Cache makes use of an Aerospike Go client that requires Aerospike server version 4.9+ and will not work properly with older versions. Full documentation of the Aerospike Go client can be found [here](https://github.com/aerospike/aerospike-client-go/tree/v6).
| Configuration field | Type | Description |
| --- | --- | --- |
| host | string | aerospike server URI |
Expand All @@ -229,6 +230,7 @@ Prebid Cache makes use of an Aerospike Go client that requires Aerospike server
### Cassandra
Prebid Cache makes use of a Cassandra client that supports latest 3 major releases of Cassandra (2.1.x, 2.2.x, and 3.x.x). Full documentation of the Cassandra Go client can be found [here](https://github.com/gocql/gocql).
| Configuration field | Type | Description |
| --- | --- | --- |
| hosts | string | Cassandra server URI |
Expand All @@ -243,6 +245,7 @@ Prebid Cache makes use of a Cassandra client that supports latest 3 major releas
### Redis:
Prebid Cache makes use of a Redis Go client compatible with Redis 6. Full documentation of the Redis Go client Prebid Cache uses can be found [here](https://github.com/go-redis/redis).
| Configuration field | Type | Description |
| --- | --- | --- |
| host | string | Redis server URI |
Expand All @@ -252,6 +255,19 @@ Prebid Cache makes use of a Redis Go client compatible with Redis 6. Full docume
| expiration | integer | Availability in the Redis system in Minutes |
| tls | field | Subfields: <br> `enabled`: whether or not pass the InsecureSkipVerify value to the Redis client's TLS config <br> `insecure_skip_verify`: In Redis, InsecureSkipVerify controls whether a client verifies the server's certificate chain and host name. If InsecureSkipVerify is true, crypto/t |

### Redis Sentinel:
Prebid Cache makes use of a Redis Go client compatible with Redis Sentinel. Full documentation of the Redis Go client can be found [here](https://github.com/go-redis/redis).

| Configuration field | Type | Description |
|---------------------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| sentinel_addrs | string array | List of Sentinel nodes (host:port) managing the same master |
| master_name | string | Name of the Sentinel master (as declared in the `sentinel monitor` line of your Sentinel configurations) |
| password | string | Redis password |
| db | integer | Database to be selected after connecting to the server |
| expiration | integer | Availability in the Redis system in Minutes |
| tls | field | Subfields: <br> `enabled`: whether or not pass the InsecureSkipVerify value to the Redis client's TLS config <br> `insecure_skip_verify`: In Redis, InsecureSkipVerify controls whether a client verifies the server's certificate chain and host name. If InsecureSkipVerify is true, crypto/t |


Sample configuration file `config/configtest/sample_full_config.yaml` shown below:
```yaml
port: 9000
Expand Down Expand Up @@ -291,6 +307,15 @@ backend:
tls:
enabled: false
insecure_skip_verify: false
redis_sentinel:
sentinel_addrs: [ "127.0.0.1:26379", "127.0.0.1:26380", "127.0.0.1:26381" ]
master_name: "mymaster"
password: ""
db: 1
expiration: 10 # in Minutes
tls:
enabled: false
insecure_skip_verify: false
compression:
type: "snappy"
metrics:
Expand Down
2 changes: 2 additions & 0 deletions backends/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ func newBaseBackend(cfg config.Backend, appMetrics *metrics.Metrics) backends.Ba
return backends.NewAerospikeBackend(cfg.Aerospike, appMetrics)
case config.BackendRedis:
return backends.NewRedisBackend(cfg.Redis, ctx)
case config.BackendRedisSentinel:
return backends.NewRedisSentinelBackend(cfg.RedisSentinel, ctx)
case config.BackendIgnite:
return backends.NewIgniteBackend(cfg.Ignite)
default:
Expand Down
98 changes: 98 additions & 0 deletions backends/redis-sentinel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package backends

import (
"context"
"crypto/tls"
"time"

"github.com/prebid/prebid-cache/config"
"github.com/prebid/prebid-cache/utils"
"github.com/redis/go-redis/v9"
log "github.com/sirupsen/logrus"
)

// RedisSentinelDB is an interface that helps us communicate with an instance of a
// Redis Sentinel database. Its implementation is intended to use the "github.com/redis/go-redis"
// client
type RedisSentinelDB interface {
Get(ctx context.Context, key string) (string, error)
Put(ctx context.Context, key string, value string, ttlSeconds int) (bool, error)
}

// RedisSentinelDBClient is a wrapper for the Redis client that implements the RedisSentinelDB interface
type RedisSentinelDBClient struct {
client *redis.Client
}

// Get returns the value associated with the provided `key` parameter
func (db RedisSentinelDBClient) Get(ctx context.Context, key string) (string, error) {
return db.client.Get(ctx, key).Result()
}

// Put will set 'key' to hold string 'value' if 'key' does not exist in the redis storage.
// When key already holds a value, no operation is performed. That's the reason this adapter
// uses the 'github.com/go-redis/redis's library SetNX. SetNX is short for "SET if Not eXists".
func (db RedisSentinelDBClient) Put(ctx context.Context, key, value string, ttlSeconds int) (bool, error) {
return db.client.SetNX(ctx, key, value, time.Duration(ttlSeconds)*time.Second).Result()
}

// RedisSentinelBackend when initialized will instantiate and configure the Redis client. It implements
// the Backend interface.
type RedisSentinelBackend struct {
cfg config.RedisSentinel
client RedisDB
}

// NewRedisSentinelBackend initializes the Redis Sentinel client and pings to make sure connection was successful
func NewRedisSentinelBackend(cfg config.RedisSentinel, ctx context.Context) *RedisSentinelBackend {
options := &redis.FailoverOptions{
MasterName: cfg.MasterName,
SentinelAddrs: cfg.SentinelAddrs,
Password: cfg.Password,
DB: cfg.Db,
}

if cfg.TLS.Enabled {
options.TLSConfig = &tls.Config{InsecureSkipVerify: cfg.TLS.InsecureSkipVerify}
}

client := RedisSentinelDBClient{client: redis.NewFailoverClient(options)}

_, err := client.client.Ping(ctx).Result()
if err != nil {
log.Fatalf("Error creating Redis Sentinel backend: %v", err)
}
log.Infof("Connected to Redis Sentinels at %v", cfg.SentinelAddrs)

return &RedisSentinelBackend{
cfg: cfg,
client: client,
}
}

// Get calls the Redis Sentinel client to return the value associated with the provided `key`
// parameter and interprets its response. A `Nil` error reply of the Redis client means
// the `key` does not exist.
func (b *RedisSentinelBackend) Get(ctx context.Context, key string) (string, error) {
res, err := b.client.Get(ctx, key)
if err == redis.Nil {
err = utils.NewPBCError(utils.KEY_NOT_FOUND)
}

return res, err
}

// Put writes the `value` under the provided `key` in the Redis Sentinel storage server. Because the backend
// implementation of Put calls SetNX(item *Item), a `false` return value is interpreted as the data
// not being written because the `key` already holds a value, and a RecordExistsError is returned
func (b *RedisSentinelBackend) Put(ctx context.Context, key string, value string, ttlSeconds int) error {
success, err := b.client.Put(ctx, key, value, ttlSeconds)
if err != nil && err != redis.Nil {
return err
}
if !success {
return utils.NewPBCError(utils.RECORD_EXISTS)
}

return nil
}
195 changes: 195 additions & 0 deletions backends/redis-sentinel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package backends

import (
"context"
"errors"
"testing"

"github.com/prebid/prebid-cache/utils"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
)

func TestRedisSentinelClientGet(t *testing.T) {
redisSentinelBackend := &RedisSentinelBackend{}

type testInput struct {
client RedisDB
key string
}

type testExpectedValues struct {
value string
err error
}

testCases := []struct {
desc string
in testInput
expected testExpectedValues
}{
{
desc: "RedisSentinelBackend.Get() throws a redis.Nil error",
in: testInput{
client: FakeRedisClient{
Success: false,
ServerError: redis.Nil,
},
key: "someKeyThatWontBeFound",
},
expected: testExpectedValues{
value: "",
err: utils.NewPBCError(utils.KEY_NOT_FOUND),
},
},
{
desc: "RedisBackend.Get() throws an error different from redis.Nil",
in: testInput{
client: FakeRedisClient{
Success: false,
ServerError: errors.New("some other get error"),
},
key: "someKey",
},
expected: testExpectedValues{
value: "",
err: errors.New("some other get error"),
},
},
{
desc: "RedisBackend.Get() doesn't throw an error",
in: testInput{
client: FakeRedisClient{
Success: true,
StoredData: map[string]string{"defaultKey": "aValue"},
},
key: "defaultKey",
},
expected: testExpectedValues{
value: "aValue",
err: nil,
},
},
}

for _, tt := range testCases {
redisSentinelBackend.client = tt.in.client

// Run test
actualValue, actualErr := redisSentinelBackend.Get(context.Background(), tt.in.key)

// Assertions
assert.Equal(t, tt.expected.value, actualValue, tt.desc)
assert.Equal(t, tt.expected.err, actualErr, tt.desc)
}
}

func TestRedisSentinelClientPut(t *testing.T) {
redisSentinelBackend := &RedisSentinelBackend{}

type testInput struct {
redisSentinelClient RedisDB
key string
valueToStore string
ttl int
}

type testExpectedValues struct {
writtenValue string
redisClientErr error
}

testCases := []struct {
desc string
in testInput
expected testExpectedValues
}{
{
desc: "Try to overwrite already existing key. From redis client documentation, SetNX returns 'false' because no operation is performed",
in: testInput{
redisSentinelClient: FakeRedisClient{
Success: false,
StoredData: map[string]string{"key": "original value"},
ServerError: redis.Nil,
},
key: "key",
valueToStore: "overwrite value",
ttl: 10,
},
expected: testExpectedValues{
redisClientErr: utils.NewPBCError(utils.RECORD_EXISTS),
writtenValue: "original value",
},
},
{
desc: "When key does not exist, redis.Nil is returned. Other errors should be interpreted as a server side error. Expect error.",
in: testInput{
redisSentinelClient: FakeRedisClient{
Success: true,
StoredData: map[string]string{},
ServerError: errors.New("A Redis client side error"),
},
key: "someKey",
valueToStore: "someValue",
ttl: 10,
},
expected: testExpectedValues{
redisClientErr: errors.New("A Redis client side error"),
},
},
{
desc: "In Redis, a zero ttl value means no expiration. Expect value to be successfully set",
in: testInput{
redisSentinelClient: FakeRedisClient{
StoredData: map[string]string{},
Success: true,
ServerError: redis.Nil,
},
key: "defaultKey",
valueToStore: "aValue",
ttl: 0,
},
expected: testExpectedValues{
writtenValue: "aValue",
},
},
{
desc: "RedisBackend.Put() successful, no need to set defaultTTL because ttl is greater than zero",
in: testInput{
redisSentinelClient: FakeRedisClient{
StoredData: map[string]string{},
Success: true,
ServerError: redis.Nil,
},
key: "defaultKey",
valueToStore: "aValue",
ttl: 1,
},
expected: testExpectedValues{
writtenValue: "aValue",
},
},
}

for _, tt := range testCases {
// Assign redis backend client
redisSentinelBackend.client = tt.in.redisSentinelClient

// Run test
actualErr := redisSentinelBackend.Put(context.Background(), tt.in.key, tt.in.valueToStore, tt.in.ttl)

// Assertions
assert.Equal(t, tt.expected.redisClientErr, actualErr, tt.desc)

// Put error
assert.Equal(t, tt.expected.redisClientErr, actualErr, tt.desc)

if actualErr == nil || actualErr == utils.NewPBCError(utils.RECORD_EXISTS) {
// Either a value was inserted successfully or the record already existed.
// Assert data in the backend
storage, ok := tt.in.redisSentinelClient.(FakeRedisClient)
assert.True(t, ok, tt.desc)
assert.Equal(t, tt.expected.writtenValue, storage.StoredData[tt.in.key], tt.desc)
}
}
}
11 changes: 10 additions & 1 deletion config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ backend:
# memcache:
# config_host: "" # Configuration endpoint for auto discovery. Replaced at docker build.
# poll_interval_seconds: 30 # Node change polling interval when auto discovery is used
# hosts: "10.0.0.1:11211" # List of nodes when not using auto discovery. Can also use an array for multiple hosts.
# hosts: "10.0.0.1:11211" # List of nodes when not using auto discovery. Can also use an array for multiple hosts.
# redis:
# host: "127.0.0.1"
# port: 6379
Expand All @@ -32,6 +32,15 @@ backend:
# tls:
# enabled: false
# insecure_skip_verify: false
# redis_sentinel:
# sentinel_addrs: [ "127.0.0.1:26379", "127.0.0.1:26380", "127.0.0.1:26381" ]
# master_name: "mymaster"
# password: ""
# db: 1
# expiration: 10 # in Minutes
# tls:
# enabled: false
# insecure_skip_verify: false
# ignite:
# scheme: "http"
# host: "127.0.0.1"
Expand Down
Loading

0 comments on commit 0f2eca0

Please sign in to comment.