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

fix: register reloadable config variables #108

Merged
merged 38 commits into from
Sep 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d1c6d23
fix: register atomic int
fracasula Aug 30, 2023
93344b0
chore: TestAtomicHotReload
fracasula Aug 30, 2023
c86a069
chore: RegisterAtomicDurationVar
fracasula Aug 30, 2023
c391e90
chore: updating default functions
fracasula Aug 30, 2023
3255c4f
chore: Load nil check
fracasula Aug 31, 2023
636baf4
chore: using mutex to avoid panics
fracasula Aug 31, 2023
ea3f7d1
chore: atomic benchmark
fracasula Aug 31, 2023
3d28be5
chore: RegisterAtomicBoolVar
fracasula Aug 31, 2023
146caa4
chore: swapHotReloadableConfig
fracasula Aug 31, 2023
1abc15c
chore: adding benchmarks
fracasula Aug 31, 2023
1a5d914
chore: adding float64 support
fracasula Aug 31, 2023
a2b4c8b
chore: adding int64 support
fracasula Aug 31, 2023
ab2293f
chore: adding string support
fracasula Aug 31, 2023
7763e15
chore: adding []string support
fracasula Aug 31, 2023
0e1c9df
chore: adding map[string]interface{} support
fracasula Aug 31, 2023
02ff5f6
chore: unexporting store
fracasula Sep 1, 2023
8fed589
chore: RegisterAtomicIntVar returns pointer
fracasula Sep 1, 2023
b2760f0
chore: RegisterAtomicBoolVar returns pointer
fracasula Sep 1, 2023
25ca1ad
chore: more pointers
fracasula Sep 1, 2023
787068c
chore: more pointers
fracasula Sep 1, 2023
ca60cd4
chore: getOrCreatePointer tests
fracasula Sep 1, 2023
955dc45
chore: updating panic message
fracasula Sep 1, 2023
9a19508
chore: making keys more readable
fracasula Sep 1, 2023
3f7ac17
chore: removing pointers to maps
fracasula Sep 4, 2023
8406f2e
chore: atomic -> reloadable
fracasula Sep 11, 2023
063d7f8
chore: order of keys
fracasula Sep 11, 2023
3191fc8
chore: always return ptr from new API
fracasula Sep 11, 2023
e1478e5
chore: make fmt
fracasula Sep 11, 2023
1b0bbc0
chore: increasing test coverage
fracasula Sep 11, 2023
64d707a
chore: spacing deprecated
fracasula Sep 12, 2023
d7a6b97
chore: beefing up warning
fracasula Sep 12, 2023
87eaad3
chore: non-reloadable new API to return values
fracasula Sep 12, 2023
1598e4c
chore: updating swap comment
fracasula Sep 12, 2023
0010948
chore: updating logger factory
fracasula Sep 12, 2023
a2f21f4
chore: sorting imports
fracasula Sep 12, 2023
755abbe
chore: warning in separate paragraph
fracasula Sep 12, 2023
6e01bd3
chore: get in favour of register for new api
fracasula Sep 12, 2023
562b3e7
Merge branch 'main' into fix.configHotReload
fracasula Sep 13, 2023
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
61 changes: 59 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@
// 1. camelCase is converted to snake_case, e.g. someVariable -> some_variable
// 2. dots (.) are replaced with underscores (_), e.g. some.variable -> some_variable
// 3. the resulting string is uppercased and prefixed with ${PREFIX}_ (default RSERVER_), e.g. some_variable -> RSERVER_SOME_VARIABLE
//
// Order of keys:
//
// When registering a variable with multiple keys, the order of the keys is important as it determines the
// hierarchical order of the keys.
// The first key is the most important one, and the last key is the least important one.
// Example:
// config.RegisterDurationConfigVariable(90, &cmdTimeout, true, time.Second,
// "JobsDB.Router.CommandRequestTimeout",
// "JobsDB.CommandRequestTimeout",
// )
//
// In the above example "JobsDB.Router.CommandRequestTimeout" is checked first. If it doesn't exist then
// JobsDB.CommandRequestTimeout is checked.
//
// WARNING: for this reason, registering with the same keys but in a different order is going to return two
// different variables.
package config

import (
Expand All @@ -35,7 +52,7 @@ var camelCaseMatch = regexp.MustCompile("([a-z0-9])([A-Z])")
// regular expression matching uppercase letters contained in environment variable names
var upperCaseMatch = regexp.MustCompile("^[A-Z0-9_]+$")

// default, singleton config instance
// Default is the singleton config instance
var Default *Config

func init() {
Expand All @@ -60,7 +77,9 @@ func WithEnvPrefix(prefix string) Opt {
// New creates a new config instance
func New(opts ...Opt) *Config {
c := &Config{
envPrefix: DefaultEnvPrefix,
envPrefix: DefaultEnvPrefix,
reloadableVars: make(map[string]any),
reloadableVarsMisuses: make(map[string]string),
}
for _, opt := range opts {
opt(c)
Expand All @@ -78,6 +97,9 @@ type Config struct {
envsLock sync.RWMutex // protects the envs map below
envs map[string]string
envPrefix string // prefix for environment variables
reloadableVars map[string]any
reloadableVarsMisuses map[string]string
reloadableVarsLock sync.RWMutex // used to protect both the reloadableVars and reloadableVarsMisuses maps
}

// GetBool gets bool value from config
Expand Down Expand Up @@ -270,6 +292,41 @@ func (c *Config) Set(key string, value interface{}) {
c.onConfigChange()
Sidddddarth marked this conversation as resolved.
Show resolved Hide resolved
}

func getReloadableMapKeys[T configTypes](v T, orderedKeys ...string) (string, string) {
k := fmt.Sprintf("%T:%s", v, strings.Join(orderedKeys, ","))
return k, fmt.Sprintf("%s:%v", k, v)
}

func getOrCreatePointer[T configTypes](
m map[string]any, dvs map[string]string, // this function MUST receive maps that are already initialized
lock *sync.RWMutex, defaultValue T, orderedKeys ...string,
) *Reloadable[T] {
key, dvKey := getReloadableMapKeys(defaultValue, orderedKeys...)

lock.Lock()
defer lock.Unlock()

defer func() {
if _, ok := dvs[key]; !ok {
dvs[key] = dvKey
}
if dvs[key] != dvKey {
panic(fmt.Errorf(
"Detected misuse of config variable registered with different default values %+v - %+v\n",
dvs[key], dvKey,
))
}
}()

if p, ok := m[key]; ok {
return p.(*Reloadable[T])
}

p := &Reloadable[T]{}
m[key] = p
return p
}

// bindEnv handles rudder server's unique snake case replacement by registering
// the environment variables to viper, that would otherwise be ignored.
// Viper uppercases keys before sending them to its EnvKeyReplacer, thus
Expand Down
266 changes: 266 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"sync"
"testing"
"time"

Expand Down Expand Up @@ -213,6 +214,271 @@ func TestCheckAndHotReloadConfig(t *testing.T) {
})
}

func TestNewReloadableAPI(t *testing.T) {
t.Run("non reloadable", func(t *testing.T) {
t.Run("int", func(t *testing.T) {
c := New()
v := c.GetIntVar(5, 1, t.Name())
require.Equal(t, 5, v)
})
t.Run("int64", func(t *testing.T) {
c := New()
v := c.GetInt64Var(5, 1, t.Name())
require.EqualValues(t, 5, v)
})
t.Run("bool", func(t *testing.T) {
c := New()
v := c.GetBoolVar(true, t.Name())
require.True(t, v)
})
t.Run("float64", func(t *testing.T) {
c := New()
v := c.GetFloat64Var(0.123, t.Name())
require.EqualValues(t, 0.123, v)
})
t.Run("string", func(t *testing.T) {
c := New()
v := c.GetStringVar("foo", t.Name())
require.Equal(t, "foo", v)
})
t.Run("duration", func(t *testing.T) {
c := New()
v := c.GetDurationVar(123, time.Second, t.Name())
require.Equal(t, 123*time.Second, v)
})
t.Run("[]string", func(t *testing.T) {
c := New()
v := c.GetStringSliceVar([]string{"a", "b"}, t.Name())
require.NotNil(t, v)
require.Equal(t, []string{"a", "b"}, v)

c.Set(t.Name(), []string{"c", "d"})
require.Equal(t, []string{"a", "b"}, v, "variable is not reloadable")
})
t.Run("map[string]interface{}", func(t *testing.T) {
c := New()
v := c.GetStringMapVar(map[string]interface{}{"a": 1, "b": 2}, t.Name())
require.NotNil(t, v)
require.Equal(t, map[string]interface{}{"a": 1, "b": 2}, v)

c.Set(t.Name(), map[string]interface{}{"c": 3, "d": 4})
require.Equal(t, map[string]interface{}{"a": 1, "b": 2}, v, "variable is not reloadable")
})
})
t.Run("reloadable", func(t *testing.T) {
t.Run("int", func(t *testing.T) {
c := New()
v := c.GetReloadableIntVar(5, 1, t.Name())
require.Equal(t, 5, v.Load())

c.Set(t.Name(), 10)
require.Equal(t, 10, v.Load())

c.Set(t.Name(), 10)
require.Equal(t, 10, v.Load(), "value should not change")

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"int:TestNewReloadableAPI/reloadable/int:5 - "+
"int:TestNewReloadableAPI/reloadable/int:10\n",
func() {
// changing just the valueScale also changes the default value
_ = c.GetReloadableIntVar(5, 2, t.Name())
},
)
})
t.Run("int64", func(t *testing.T) {
c := New()
v := c.GetReloadableInt64Var(5, 1, t.Name())
require.EqualValues(t, 5, v.Load())

c.Set(t.Name(), 10)
require.EqualValues(t, 10, v.Load())

c.Set(t.Name(), 10)
require.EqualValues(t, 10, v.Load(), "value should not change")

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"int64:TestNewReloadableAPI/reloadable/int64:5 - "+
"int64:TestNewReloadableAPI/reloadable/int64:10\n",
func() {
// changing just the valueScale also changes the default value
_ = c.GetReloadableInt64Var(5, 2, t.Name())
},
)
})
t.Run("bool", func(t *testing.T) {
c := New()
v := c.GetReloadableBoolVar(true, t.Name())
require.True(t, v.Load())

c.Set(t.Name(), false)
require.False(t, v.Load())

c.Set(t.Name(), false)
require.False(t, v.Load(), "value should not change")

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"bool:TestNewReloadableAPI/reloadable/bool:true - "+
"bool:TestNewReloadableAPI/reloadable/bool:false\n",
func() {
_ = c.GetReloadableBoolVar(false, t.Name())
},
)
})
t.Run("float64", func(t *testing.T) {
c := New()
v := c.GetReloadableFloat64Var(0.123, t.Name())
require.EqualValues(t, 0.123, v.Load())

c.Set(t.Name(), 4.567)
require.EqualValues(t, 4.567, v.Load())

c.Set(t.Name(), 4.567)
require.EqualValues(t, 4.567, v.Load(), "value should not change")

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"float64:TestNewReloadableAPI/reloadable/float64:0.123 - "+
"float64:TestNewReloadableAPI/reloadable/float64:0.1234\n",
func() {
_ = c.GetReloadableFloat64Var(0.1234, t.Name())
},
)
})
t.Run("string", func(t *testing.T) {
c := New()
v := c.GetReloadableStringVar("foo", t.Name())
require.Equal(t, "foo", v.Load())

c.Set(t.Name(), "bar")
require.EqualValues(t, "bar", v.Load())

c.Set(t.Name(), "bar")
require.EqualValues(t, "bar", v.Load(), "value should not change")

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"string:TestNewReloadableAPI/reloadable/string:foo - "+
"string:TestNewReloadableAPI/reloadable/string:qux\n",
func() {
_ = c.GetReloadableStringVar("qux", t.Name())
},
)
})
t.Run("duration", func(t *testing.T) {
c := New()
v := c.GetReloadableDurationVar(123, time.Nanosecond, t.Name())
require.Equal(t, 123*time.Nanosecond, v.Load())

c.Set(t.Name(), 456*time.Millisecond)
require.Equal(t, 456*time.Millisecond, v.Load())

c.Set(t.Name(), 456*time.Millisecond)
require.Equal(t, 456*time.Millisecond, v.Load(), "value should not change")

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"time.Duration:TestNewReloadableAPI/reloadable/duration:123ns - "+
"time.Duration:TestNewReloadableAPI/reloadable/duration:2m3s\n",
func() {
_ = c.GetReloadableDurationVar(123, time.Second, t.Name())
},
)
})
t.Run("[]string", func(t *testing.T) {
c := New()
v := c.GetReloadableStringSliceVar([]string{"a", "b"}, t.Name())
require.Equal(t, []string{"a", "b"}, v.Load())

c.Set(t.Name(), []string{"c", "d"})
require.Equal(t, []string{"c", "d"}, v.Load())

c.Set(t.Name(), []string{"c", "d"})
require.Equal(t, []string{"c", "d"}, v.Load(), "value should not change")

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"[]string:TestNewReloadableAPI/reloadable/[]string:[a b] - "+
"[]string:TestNewReloadableAPI/reloadable/[]string:[a b c]\n",
func() {
_ = c.GetReloadableStringSliceVar([]string{"a", "b", "c"}, t.Name())
},
)
})
t.Run("map[string]interface{}", func(t *testing.T) {
c := New()
v := c.GetReloadableStringMapVar(map[string]interface{}{"a": 1, "b": 2}, t.Name())
require.Equal(t, map[string]interface{}{"a": 1, "b": 2}, v.Load())

c.Set(t.Name(), map[string]interface{}{"c": 3, "d": 4})
require.Equal(t, map[string]interface{}{"c": 3, "d": 4}, v.Load())

c.Set(t.Name(), map[string]interface{}{"c": 3, "d": 4})
require.Equal(t, map[string]interface{}{"c": 3, "d": 4}, v.Load(), "value should not change")

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"map[string]interface {}:TestNewReloadableAPI/reloadable/map[string]interface{}:map[a:1 b:2] - "+
"map[string]interface {}:TestNewReloadableAPI/reloadable/map[string]interface{}:map[a:2 b:1]\n",
func() {
_ = c.GetReloadableStringMapVar(map[string]interface{}{"a": 2, "b": 1}, t.Name())
},
)
})
})
}

func TestGetOrCreatePointer(t *testing.T) {
var (
m = make(map[string]any)
dvs = make(map[string]string)
rwm sync.RWMutex
)
p1 := getOrCreatePointer(m, dvs, &rwm, 123, "foo", "bar")
require.NotNil(t, p1)

p2 := getOrCreatePointer(m, dvs, &rwm, 123, "foo", "bar")
require.True(t, p1 == p2)

p3 := getOrCreatePointer(m, dvs, &rwm, 123, "bar", "foo")
require.True(t, p1 != p3)

p4 := getOrCreatePointer(m, dvs, &rwm, 123, "bar", "foo", "qux")
require.True(t, p1 != p4)

require.PanicsWithError(t,
"Detected misuse of config variable registered with different default values "+
"int:bar,foo,qux:123 - int:bar,foo,qux:456\n",
func() {
getOrCreatePointer(m, dvs, &rwm, 456, "bar", "foo", "qux")
},
)
}

func TestReloadable(t *testing.T) {
t.Run("scalar", func(t *testing.T) {
var v Reloadable[int]
v.store(123)
require.Equal(t, 123, v.Load())
})
t.Run("nullable", func(t *testing.T) {
var v Reloadable[[]string]
require.Nil(t, v.Load())

v.store(nil)
require.Nil(t, v.Load())

v.store([]string{"foo", "bar"})
require.Equal(t, v.Load(), []string{"foo", "bar"})

v.store(nil)
require.Nil(t, v.Load())
})
}

func TestConfigKeyToEnv(t *testing.T) {
expected := "RSERVER_KEY_VAR1_VAR2"
require.Equal(t, expected, ConfigKeyToEnv(DefaultEnvPrefix, "Key.Var1.Var2"))
Expand Down
Loading
Loading