Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a Redis Sentinel backend #175

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
24 changes: 24 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,18 @@ 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 |
| 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 +306,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
99 changes: 99 additions & 0 deletions backends/redis-sentinel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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,
SentinelPassword: cfg.Password,
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)
}
}
}
10 changes: 9 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,14 @@ 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" ]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if we consolidate redis-sentinel.go and redis.go, I think we can keep the configuration logic as is: one for redis sentinel, and the other for redis cluster. I agree with the current approach here, let's just use the same adapter (redis.go) for both regular redis or redis_sentinel.

# master_name: "mymaster"
# password: ""
# db: 1
# tls:
# enabled: false
# insecure_skip_verify: false
# ignite:
# scheme: "http"
# host: "127.0.0.1"
Expand Down
Loading
Loading