diff --git a/README.md b/README.md index 41b4983..104e29e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ services: - adapter: redis config: url: redis://default@redis-a.example.com:6379 + namespace: keepalive - adapter: mongodb config: @@ -43,7 +44,7 @@ services: | `adapter` | Driver | `config` keys | | ------------ | ----------------------------------- | ------------- | -| `redis` | `github.com/redis/go-redis/v9` | `url` | +| `redis` | `github.com/redis/go-redis/v9` | `url`, optional `namespace` | | `valkey` | `github.com/valkey-io/valkey-go` | `url` | | `postgresql` | `github.com/lib/pq` | `url` | | `mysql` | `github.com/go-sql-driver/mysql` | `dsn` | @@ -88,7 +89,8 @@ go run . On startup each adapter initializes the minimum resource it owns, then every tick performs the cheapest write that proves the cluster is alive. `counter_key` selects the key/doc ID and defaults to `counter`. -- **Redis/Valkey** — initialize with `SETNX key 0`, then `INCR key` +- **Redis** — initialize with `SETNX key 0`, then `INCR key`. When `namespace` is empty, the key is `counter`; when `namespace: keepalive`, the key is `keepalive:counter`. +- **Valkey** — initialize with `SETNX key 0`, then `INCR key` - **PostgreSQL** — `CREATE TABLE IF NOT EXISTS keepalive`, seed `key`, then `UPDATE ... RETURNING` - **MySQL** — `CREATE TABLE IF NOT EXISTS keepalive`, seed `key`, then `UPDATE` + `SELECT` - **MongoDB** — upsert `{_id: key, count: 0}` on connect, then `FindOneAndUpdate({_id: key}, {$inc: {count: 1}}, upsert)` diff --git a/adapter/redis.go b/adapter/redis.go index 6a53de0..93f1878 100644 --- a/adapter/redis.go +++ b/adapter/redis.go @@ -2,6 +2,7 @@ package adapter import ( "context" + "strings" "github.com/redis/go-redis/v9" ) @@ -14,7 +15,7 @@ func init() { } return &redisAdapter{ url: url, - key: cfg.Optional("counter_key", "counter"), + key: redisCounterKey(cfg.Optional("counter_key", "counter"), cfg.Optional("namespace", "")), }, nil } } @@ -52,3 +53,11 @@ func (a *redisAdapter) Close(_ context.Context) error { } return a.client.Close() } + +func redisCounterKey(counterKey, namespace string) string { + namespace = strings.TrimSpace(namespace) + if namespace == "" { + return counterKey + } + return namespace + ":" + counterKey +} diff --git a/adapter/redis_test.go b/adapter/redis_test.go new file mode 100644 index 0000000..ce5ba9c --- /dev/null +++ b/adapter/redis_test.go @@ -0,0 +1,52 @@ +package adapter + +import "testing" + +func TestRedisFactoryUsesRootCounterKeyWithoutNamespace(t *testing.T) { + got := newRedisAdapterForTest(t, Config{ + "url": "redis://cache.example.com:6379", + "counter_key": "counter", + }) + + if got.key != "counter" { + t.Fatalf("key = %q, want %q", got.key, "counter") + } +} + +func TestRedisFactoryPrefixesCounterKeyWithNamespace(t *testing.T) { + got := newRedisAdapterForTest(t, Config{ + "url": "redis://cache.example.com:6379", + "counter_key": "counter", + "namespace": "keepalive", + }) + + if got.key != "keepalive:counter" { + t.Fatalf("key = %q, want %q", got.key, "keepalive:counter") + } +} + +func TestRedisFactoryTreatsBlankNamespaceAsRoot(t *testing.T) { + got := newRedisAdapterForTest(t, Config{ + "url": "redis://cache.example.com:6379", + "counter_key": "counter", + "namespace": " ", + }) + + if got.key != "counter" { + t.Fatalf("key = %q, want %q", got.key, "counter") + } +} + +func newRedisAdapterForTest(t *testing.T, cfg Config) *redisAdapter { + t.Helper() + + adapterValue, err := Registry["redis"](cfg) + if err != nil { + t.Fatalf("redis factory returned error: %v", err) + } + got, ok := adapterValue.(*redisAdapter) + if !ok { + t.Fatalf("redis factory returned %T, want *redisAdapter", adapterValue) + } + return got +} diff --git a/config.example.yml b/config.example.yml index b42c381..98c6003 100644 --- a/config.example.yml +++ b/config.example.yml @@ -6,6 +6,7 @@ services: - adapter: redis config: url: redis://default@redis-a.example.com:6379 + namespace: keepalive - adapter: redis config: