Skip to content

Commit b2d9376

Browse files
committed
feat: Configure redis cache
1 parent 5130deb commit b2d9376

File tree

3 files changed

+355
-0
lines changed

3 files changed

+355
-0
lines changed

pkg/cache/config.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package cache
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
"time"
8+
9+
cbuilder "github.com/scribd/go-sdk/internal/pkg/configuration/builder"
10+
)
11+
12+
type (
13+
StoreType int
14+
15+
// Redis provides configuration for redis cache.
16+
Redis struct {
17+
// URL into Redis ClusterOptions that can be used to connect to Redis
18+
URL string `mapstructure:"url"`
19+
20+
// Either a single address or a seed list of host:port addresses
21+
// of cluster/sentinel nodes.
22+
Addrs []string `mapstructure:"addrs"`
23+
24+
// ClientName will execute the `CLIENT SETNAME ClientName` command for each conn.
25+
ClientName string `mapstructure:"client_name"`
26+
27+
// Database to be selected after connecting to the server.
28+
// Only single-node and failover clients.
29+
DB int `mapstructure:"db"`
30+
31+
// Protocol 2 or 3. Use the version to negotiate RESP version with redis-server.
32+
Protocol int `mapstructure:"protocol"`
33+
// Use the specified Username to authenticate the current connection
34+
// with one of the connections defined in the ACL list when connecting
35+
// to a Redis 6.0 instance, or greater, that is using the Redis ACL system.
36+
Username string `mapstructure:"username"`
37+
// Optional password. Must match the password specified in the
38+
// requirepass server configuration option (if connecting to a Redis 5.0 instance, or lower),
39+
// or the User Password when connecting to a Redis 6.0 instance, or greater,
40+
// that is using the Redis ACL system.
41+
Password string `mapstructure:"password"`
42+
43+
// If specified with SentinelPassword, enables ACL-based authentication (via
44+
// AUTH <user> <pass>).
45+
SentinelUsername string `mapstructure:"sentinel_username"`
46+
// Sentinel password from "requirepass <password>" (if enabled) in Sentinel
47+
// configuration, or, if SentinelUsername is also supplied, used for ACL-based
48+
// authentication.
49+
SentinelPassword string `mapstructure:"sentinel_password"`
50+
51+
// Maximum number of retries before giving up.
52+
MaxRetries int `mapstructure:"max_retries"`
53+
// Minimum backoff between each retry.
54+
MinRetryBackoff time.Duration `mapstructure:"min_retry_backoff"`
55+
// Maximum backoff between each retry.
56+
MaxRetryBackoff time.Duration `mapstructure:"max_retry_backoff"`
57+
58+
// Dial timeout for establishing new connections.
59+
DialTimeout time.Duration `mapstructure:"dial_timeout"`
60+
// Timeout for socket reads. If reached, commands will fail
61+
// with a timeout instead of blocking. Supported values:
62+
// - `0` - default timeout (3 seconds).
63+
// - `-1` - no timeout (block indefinitely).
64+
// - `-2` - disables SetReadDeadline calls completely.
65+
ReadTimeout time.Duration `mapstructure:"read_timeout"`
66+
// Timeout for socket writes. If reached, commands will fail
67+
// with a timeout instead of blocking. Supported values:
68+
// - `0` - default timeout (3 seconds).
69+
// - `-1` - no timeout (block indefinitely).
70+
// - `-2` - disables SetWriteDeadline calls completely.
71+
WriteTimeout time.Duration `mapstructure:"write_timeout"`
72+
// ContextTimeoutEnabled controls whether the client respects context timeouts and deadlines.
73+
// See https://redis.uptrace.dev/guide/go-redis-debugging.html#timeouts
74+
ContextTimeoutEnabled bool `mapstructure:"context_timeout_enabled"`
75+
76+
// Base number of socket connections.
77+
// If there is not enough connections in the pool, new connections will be allocated in excess of PoolSize,
78+
// you can limit it through MaxActiveConns
79+
PoolSize int `mapstructure:"pool_size"`
80+
// Amount of time client waits for connection if all connections
81+
// are busy before returning an error.
82+
PoolTimeout time.Duration `mapstructure:"pool_timeout"`
83+
// Maximum number of idle connections.
84+
MaxIdleConns int `mapstructure:"max_idle_conns"`
85+
// Minimum number of idle connections which is useful when establishing
86+
// new connection is slow.
87+
MinIdleConns int `mapstructure:"min_idle_conns"`
88+
// Maximum number of connections allocated by the pool at a given time.
89+
// When zero, there is no limit on the number of connections in the pool.
90+
MaxActiveConns int `mapstructure:"max_active_conns"`
91+
// ConnMaxIdleTime is the maximum amount of time a connection may be idle.
92+
// Should be less than server's timeout.
93+
//
94+
// Expired connections may be closed lazily before reuse.
95+
// If d <= 0, connections are not closed due to a connection's idle time.
96+
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"`
97+
// ConnMaxLifetime is the maximum amount of time a connection may be reused.
98+
//
99+
// Expired connections may be closed lazily before reuse.
100+
// If <= 0, connections are not closed due to a connection's age.
101+
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
102+
103+
// Only cluster clients.
104+
105+
// The maximum number of retries before giving up. Command is retried
106+
// on network errors and MOVED/ASK redirects.
107+
MaxRedirects int `mapstructure:"max_redirects"`
108+
// Enables read-only commands on slave nodes.
109+
ReadOnly bool `mapstructure:"read_only"`
110+
// Allows routing read-only commands to the closest master or slave node.
111+
// It automatically enables ReadOnly.
112+
RouteByLatency bool `mapstructure:"route_by_latency"`
113+
// Allows routing read-only commands to the random master or slave node.
114+
// It automatically enables ReadOnly.
115+
RouteRandomly bool `mapstructure:"route_randomly"`
116+
117+
// The sentinel master name.
118+
// Only failover clients.
119+
120+
// The master name.
121+
MasterName string `mapstructure:"master_name"`
122+
123+
// Disable set-lib on connect.
124+
DisableIndentity bool `mapstructure:"disable_indentity"`
125+
126+
// Add suffix to client name.
127+
IdentitySuffix string `mapstructure:"identity_suffix"`
128+
129+
// TLS configuration
130+
TLS TLS `mapstructure:"tls"`
131+
}
132+
133+
TLS struct {
134+
// Enabled whether the TLS connection is enabled or not
135+
Enabled bool `mapstructure:"enabled"`
136+
137+
// Ca Root CA certificate
138+
Ca string `mapstructure:"ca"`
139+
// Cert is a PEM certificate string
140+
Cert string `mapstructure:"cert_pem"`
141+
// CertKey is a PEM key certificate string
142+
CertKey string `mapstructure:"cert_pem_key"`
143+
// Passphrase is used in case the private key needs to be decrypted
144+
Passphrase string `mapstructure:"passphrase"`
145+
// InsecureSkipVerify whether to skip TLS verification or not
146+
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
147+
}
148+
149+
// Config provides configuration for cache.
150+
Config struct {
151+
Store string `mapstructure:"store"`
152+
Redis Redis `mapstructure:"redis"`
153+
}
154+
)
155+
156+
const (
157+
storeTypeRedisName = "redis"
158+
)
159+
160+
func NewConfig() (*Config, error) {
161+
config := &Config{}
162+
viperBuilder := cbuilder.New("cache")
163+
164+
appName := strings.ReplaceAll(os.Getenv("APP_SETTINGS_NAME"), "-", "_")
165+
viperBuilder.SetDefault("cache", fmt.Sprintf("%s_%s", appName, os.Getenv("APP_ENV")))
166+
167+
vConf, err := viperBuilder.Build()
168+
if err != nil {
169+
return config, err
170+
}
171+
172+
if err = vConf.Unmarshal(config); err != nil {
173+
return config, fmt.Errorf("unable to decode into struct: %s", err.Error())
174+
}
175+
176+
config.Redis.Addrs = vConf.GetStringSlice("redis.addrs")
177+
178+
if err := config.validate(); err != nil {
179+
return config, err
180+
}
181+
182+
return config, nil
183+
}
184+
185+
func (c *Config) validate() error {
186+
if c.Store == "" {
187+
return fmt.Errorf("store is required")
188+
}
189+
190+
switch c.Store {
191+
case storeTypeRedisName:
192+
if c.Redis.URL == "" && len(c.Redis.Addrs) == 0 {
193+
return fmt.Errorf("url or addrs is required for redis")
194+
}
195+
default:
196+
return fmt.Errorf("store %s is not supported", c.Store)
197+
}
198+
199+
return nil
200+
}

pkg/cache/config_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package cache
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestNewConfig(t *testing.T) {
14+
testCases := []struct {
15+
name string
16+
wantError bool
17+
}{
18+
{
19+
name: "NewWithoutConfigFileFails",
20+
wantError: true,
21+
},
22+
}
23+
24+
for _, tc := range testCases {
25+
t.Run(tc.name, func(t *testing.T) {
26+
_, err := NewConfig()
27+
28+
gotError := err != nil
29+
assert.Equal(t, gotError, tc.wantError)
30+
})
31+
}
32+
}
33+
34+
func TestNewConfigWithAppRoot(t *testing.T) {
35+
testCases := []struct {
36+
name string
37+
env string
38+
cfg *Config
39+
wantErr bool
40+
41+
envOverrides [][]string
42+
}{
43+
{
44+
name: "NewWithConfigFileWorks",
45+
env: "test",
46+
cfg: &Config{
47+
Store: "redis",
48+
Redis: Redis{
49+
Addrs: []string{"localhost:6379"},
50+
Username: "test",
51+
Password: "test",
52+
},
53+
},
54+
},
55+
{
56+
name: "NewWithConfigFileWorks, URL set",
57+
env: "test",
58+
cfg: &Config{
59+
Store: "redis",
60+
Redis: Redis{
61+
Addrs: []string{},
62+
URL: "redis://user:password@localhost:6379/0?protocol=3",
63+
Username: "test",
64+
Password: "test",
65+
},
66+
},
67+
envOverrides: [][]string{
68+
{"APP_CACHE_REDIS_ADDRS", " "},
69+
{"APP_CACHE_REDIS_URL", "redis://user:password@localhost:6379/0?protocol=3"},
70+
},
71+
},
72+
{
73+
name: "NewWithConfigFileWorks, incorrect store",
74+
env: "test",
75+
cfg: &Config{
76+
Store: "memcached",
77+
Redis: Redis{
78+
Addrs: []string{"localhost:6379"},
79+
Username: "test",
80+
Password: "test",
81+
},
82+
},
83+
wantErr: true,
84+
envOverrides: [][]string{{"APP_CACHE_STORE", "memcached"}},
85+
},
86+
{
87+
name: "NewWithConfigFileWorks, neither URL nor Addrs set",
88+
env: "test",
89+
cfg: &Config{
90+
Store: "redis",
91+
Redis: Redis{
92+
Addrs: []string{},
93+
Username: "test",
94+
Password: "test",
95+
},
96+
},
97+
wantErr: true,
98+
envOverrides: [][]string{{"APP_CACHE_REDIS_ADDRS", " "}, {"APP_CACHE_REDIS_URL", ""}},
99+
},
100+
}
101+
102+
currentAppRoot := os.Getenv("APP_ROOT")
103+
defer os.Setenv("APP_ROOT", currentAppRoot)
104+
105+
for _, tc := range testCases {
106+
t.Run(tc.name, func(t *testing.T) {
107+
var envVariables [][]string
108+
109+
if len(tc.envOverrides) > 0 {
110+
for _, o := range tc.envOverrides {
111+
currentVal := os.Getenv(o[0])
112+
envVariables = append(envVariables, []string{o[0], currentVal})
113+
114+
os.Setenv(o[0], o[1])
115+
}
116+
}
117+
118+
_, filename, _, _ := runtime.Caller(0)
119+
tmpRootParent := filepath.Dir(filename)
120+
os.Setenv("APP_ROOT", filepath.Join(tmpRootParent, "testdata"))
121+
122+
c, err := NewConfig()
123+
if tc.wantErr {
124+
require.NotNil(t, err)
125+
} else {
126+
require.Nil(t, err)
127+
}
128+
129+
assert.Equal(t, tc.cfg, c)
130+
131+
// teardown
132+
if len(envVariables) > 0 {
133+
for _, o := range envVariables {
134+
os.Clearenv()
135+
os.Setenv(o[0], o[1])
136+
}
137+
}
138+
})
139+
}
140+
}

pkg/cache/testdata/config/cache.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
common: &common
2+
redis:
3+
4+
test: &test
5+
<<: *common
6+
store: redis
7+
redis:
8+
url: ""
9+
addrs:
10+
- "localhost:6379"
11+
username: "test"
12+
password: "test"
13+
14+
development:
15+
<<: *test

0 commit comments

Comments
 (0)