generic settings overrider

This commit is contained in:
pascal
2026-04-21 18:31:53 +02:00
parent 8a7d78ddf3
commit 92e53d6319
7 changed files with 346 additions and 16 deletions

View File

@@ -0,0 +1,120 @@
package settingoverrider
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
log "github.com/sirupsen/logrus"
)
const (
DefaultInterval = 5 * time.Minute
)
// ApplyFunc is called with the raw Redis string value whenever it changes.
// The function is responsible for parsing and applying the value.
// Return an error to log a warning without stopping the polling loop.
type ApplyFunc func(value string) error
// Overrider holds a shared Redis connection and allows registering
// individual settings that are polled independently.
type Overrider struct {
client *redis.Client
cancel context.CancelFunc
ctx context.Context
noop bool
}
// New creates an Overrider by connecting to Redis at the given address.
// The address should follow the Redis URL format (e.g. "redis://localhost:6379").
func New(ctx context.Context, redisAddr string) (*Overrider, error) {
if redisAddr == "" {
return nil, fmt.Errorf("redis address is empty")
}
options, err := redis.ParseURL(redisAddr)
if err != nil {
return nil, fmt.Errorf("parsing redis address: %w", err)
}
client := redis.NewClient(options)
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if _, err := client.Ping(pingCtx).Result(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("connecting to redis: %w", err)
}
oCtx, oCancel := context.WithCancel(ctx)
return &Overrider{client: client, cancel: oCancel, ctx: oCtx}, nil
}
// NewNoop returns an Overrider that does nothing.
// Poll calls are silently ignored and Close is a no-op.
func NewNoop() *Overrider {
return &Overrider{noop: true}
}
// Close stops all polling goroutines and closes the underlying Redis client.
func (o *Overrider) Close() error {
if o.noop {
return nil
}
o.cancel()
return o.client.Close()
}
// Poll starts a background goroutine that polls a single Redis key at the given interval
// and calls apply whenever the value changes. The goroutine stops when the Overrider is closed.
func (o *Overrider) Poll(interval time.Duration, redisKey string, apply ApplyFunc) {
if o.noop {
return
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
var lastSeen *string
for {
select {
case <-o.ctx.Done():
log.WithContext(o.ctx).Infof("Stopping settings overrider for key %q", redisKey)
return
case <-ticker.C:
getCtx, cancel := context.WithTimeout(o.ctx, 5*time.Second)
val, err := o.client.Get(getCtx, redisKey).Result()
cancel()
if errors.Is(err, redis.Nil) || val == "" {
continue
}
if err != nil {
if o.ctx.Err() != nil {
return
}
log.WithContext(o.ctx).Errorf("Unable to get setting %q from Redis: %v", redisKey, err)
continue
}
if lastSeen != nil && *lastSeen == val {
continue
}
if err := apply(val); err != nil {
log.WithContext(o.ctx).Warnf("Failed to apply setting %q with value %q: %v", redisKey, val, err)
continue
}
lastSeen = &val
}
}
}()
}

View File

@@ -0,0 +1,111 @@
package settingoverrider
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestPoll_AppliesSettingFromRedis(t *testing.T) {
o, client := setupOverrider(t)
key := "test-setting-key"
require.NoError(t, client.Set(context.Background(), key, "hello", 0).Err())
var applied atomic.Value
o.Poll(100*time.Millisecond, key, func(value string) error {
applied.Store(value)
return nil
})
assert.Eventually(t, func() bool {
v := applied.Load()
return v != nil && v.(string) == "hello"
}, 5*time.Second, 50*time.Millisecond)
}
func TestPoll_IndependentSettings(t *testing.T) {
o, client := setupOverrider(t)
require.NoError(t, client.Set(context.Background(), "key-a", "val-a", 0).Err())
require.NoError(t, client.Set(context.Background(), "key-b", "val-b", 0).Err())
var gotA, gotB atomic.Value
o.Poll(100*time.Millisecond, "key-a", func(v string) error { gotA.Store(v); return nil })
o.Poll(100*time.Millisecond, "key-b", func(v string) error { gotB.Store(v); return nil })
assert.Eventually(t, func() bool {
a, b := gotA.Load(), gotB.Load()
return a != nil && a.(string) == "val-a" && b != nil && b.(string) == "val-b"
}, 5*time.Second, 50*time.Millisecond)
}
func TestPoll_SkipsDuplicateValues(t *testing.T) {
o, client := setupOverrider(t)
key := "test-dedup"
require.NoError(t, client.Set(context.Background(), key, "same", 0).Err())
var count atomic.Int32
o.Poll(100*time.Millisecond, key, func(string) error {
count.Add(1)
return nil
})
// wait for a few ticks
time.Sleep(600 * time.Millisecond)
assert.Equal(t, int32(1), count.Load(), "Apply should be called only once for unchanged value")
}
func setupOverrider(t *testing.T) (*Overrider, *redis.Client) {
t.Helper()
ctx := context.Background()
redisContainer, err := testcontainersredis.RunContainer(ctx,
testcontainers.WithImage("redis:7"),
testcontainers.WithWaitStrategy(
wait.ForListeningPort("6379/tcp"),
),
)
require.NoError(t, err, "Failed to create redis test container")
t.Cleanup(func() {
if err := redisContainer.Terminate(ctx); err != nil {
t.Logf("failed to terminate redis container: %s", err)
}
})
redisURL, err := redisContainer.ConnectionString(ctx)
require.NoError(t, err)
o, err := New(ctx, redisURL)
require.NoError(t, err)
t.Cleanup(func() {
if err := o.Close(); err != nil {
t.Logf("failed to close overrider: %s", err)
}
})
// separate client for test setup (setting keys)
options, err := redis.ParseURL(redisURL)
require.NoError(t, err)
client := redis.NewClient(options)
t.Cleanup(func() {
if err := client.Close(); err != nil {
t.Logf("failed to close redis client: %s", err)
}
})
return o, client
}