Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ http://localhost:8080/mcp
```text
PKGSITE_BASE_URL=https://pkg.go.dev/v1beta
KV_REDIS_URL=redis://localhost:9736/0
KV_REDIS_POOL_SIZE=4
KV_REDIS_MIN_IDLE_CONNS=2
KV_REDIS_MAX_IDLE_CONNS=4
KV_REDIS_MAX_ACTIVE_CONNS=8
KV_REDIS_POOL_TIMEOUT=250ms
KV_REDIS_DIAL_TIMEOUT=1s
KV_REDIS_READ_TIMEOUT=750ms
KV_REDIS_WRITE_TIMEOUT=750ms
KV_REDIS_CONN_MAX_IDLE_TIME=10m
KV_REDIS_DISABLE_IDENTITY=true
PKGSITE_HTTP_TIMEOUT=10s
PKGSITE_CACHE_DISABLED=false
RATE_LIMIT_ENABLED=true
Expand Down
34 changes: 33 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,25 @@ type Pkgsite struct {
}

type KV struct {
RedisURL string
RedisURL string
RedisPool RedisPool
RedisTimeouts RedisTimeouts
RedisConnMaxIdle time.Duration
RedisDisableIdentity bool
}

type RedisPool struct {
Size int
MinIdleConns int
MaxIdleConns int
MaxActiveConns int
Timeout time.Duration
}

type RedisTimeouts struct {
Dial time.Duration
Read time.Duration
Write time.Duration
}

type RateLimit struct {
Expand Down Expand Up @@ -62,6 +80,20 @@ func read(getenv func(string) string) (Config, error) {
Port: p.str("PORT", "8080"),
KV: KV{
RedisURL: p.str("KV_REDIS_URL", ""),
RedisPool: RedisPool{
Size: p.intVal("KV_REDIS_POOL_SIZE", 4),
MinIdleConns: p.intVal("KV_REDIS_MIN_IDLE_CONNS", 2),
MaxIdleConns: p.intVal("KV_REDIS_MAX_IDLE_CONNS", 4),
MaxActiveConns: p.intVal("KV_REDIS_MAX_ACTIVE_CONNS", 8),
Timeout: p.duration("KV_REDIS_POOL_TIMEOUT", 250*time.Millisecond),
},
RedisTimeouts: RedisTimeouts{
Dial: p.duration("KV_REDIS_DIAL_TIMEOUT", time.Second),
Read: p.duration("KV_REDIS_READ_TIMEOUT", 750*time.Millisecond),
Write: p.duration("KV_REDIS_WRITE_TIMEOUT", 750*time.Millisecond),
},
RedisConnMaxIdle: p.duration("KV_REDIS_CONN_MAX_IDLE_TIME", 10*time.Minute),
RedisDisableIdentity: p.boolean("KV_REDIS_DISABLE_IDENTITY", true),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 RedisDisableIdentity defaults to true, changing existing behavior

Before this PR, no DisableIdentity override existed, so go-redis defaulted to false (identity enabled). Setting the default to true silently disables the CLIENT SETNAME / CLIENT INFO handshake on every new connection for all existing deployments that don't set KV_REDIS_DISABLE_IDENTITY. This makes Redis-side connection monitoring invisible by default. Defaulting to false (the go-redis default) preserves backward-compatible behavior; operators who want to suppress identity for performance can opt-in explicitly.

Suggested change
RedisDisableIdentity: p.boolean("KV_REDIS_DISABLE_IDENTITY", true),
RedisDisableIdentity: p.boolean("KV_REDIS_DISABLE_IDENTITY", false),

},
Observability: Observability{
ServiceName: p.str("O11Y_SERVICE_NAME", "pkgsite-mcp"),
Expand Down
104 changes: 88 additions & 16 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@ func TestReadDefaults(t *testing.T) {
if got.KV.RedisURL != "" {
t.Fatalf("RedisURL = %q, want empty", got.KV.RedisURL)
}
if got.KV.RedisPool.Size != 4 {
t.Fatalf("RedisPool.Size = %d, want 4", got.KV.RedisPool.Size)
}
if got.KV.RedisPool.MinIdleConns != 2 {
t.Fatalf("RedisPool.MinIdleConns = %d, want 2", got.KV.RedisPool.MinIdleConns)
}
if got.KV.RedisPool.MaxIdleConns != 4 {
t.Fatalf("RedisPool.MaxIdleConns = %d, want 4", got.KV.RedisPool.MaxIdleConns)
}
if got.KV.RedisPool.MaxActiveConns != 8 {
t.Fatalf("RedisPool.MaxActiveConns = %d, want 8", got.KV.RedisPool.MaxActiveConns)
}
if got.KV.RedisPool.Timeout != 250*time.Millisecond {
t.Fatalf("RedisPool.Timeout = %s, want 250ms", got.KV.RedisPool.Timeout)
}
if got.KV.RedisTimeouts.Dial != time.Second {
t.Fatalf("RedisTimeouts.Dial = %s, want 1s", got.KV.RedisTimeouts.Dial)
}
if got.KV.RedisTimeouts.Read != 750*time.Millisecond {
t.Fatalf("RedisTimeouts.Read = %s, want 750ms", got.KV.RedisTimeouts.Read)
}
if got.KV.RedisTimeouts.Write != 750*time.Millisecond {
t.Fatalf("RedisTimeouts.Write = %s, want 750ms", got.KV.RedisTimeouts.Write)
}
if got.KV.RedisConnMaxIdle != 10*time.Minute {
t.Fatalf("RedisConnMaxIdle = %s, want 10m", got.KV.RedisConnMaxIdle)
}
if !got.KV.RedisDisableIdentity {
t.Fatal("RedisDisableIdentity = false, want true")
}
if got.Pkgsite.CacheDisabled {
t.Fatal("CacheDisabled = true, want false")
}
Expand Down Expand Up @@ -57,21 +87,31 @@ func TestReadOverrides(t *testing.T) {
t.Parallel()

got, err := read(mapGetenv(map[string]string{
"PORT": "9090",
"O11Y_SERVICE_NAME": "pkgsite-test",
"O11Y_ENVIRONMENT": "test",
"O11Y_FLUSH_TIMEOUT": "5s",
"O11Y_TRACES_SAMPLE_RATE": "0.25",
"O11Y_ENABLE_LOGS": "false",
"O11Y_ENABLE_METRICS": "false",
"PKGSITE_BASE_URL": "http://example.test",
"PKGSITE_HTTP_TIMEOUT": "250ms",
"KV_REDIS_URL": "redis://localhost:6379/0",
"PKGSITE_CACHE_DISABLED": "true",
"RATE_LIMIT_ENABLED": "false",
"RATE_LIMIT_REQUESTS": "10",
"RATE_LIMIT_WINDOW": "30s",
"SENTRY_DSN": "https://public@example.invalid/1",
"PORT": "9090",
"O11Y_SERVICE_NAME": "pkgsite-test",
"O11Y_ENVIRONMENT": "test",
"O11Y_FLUSH_TIMEOUT": "5s",
"O11Y_TRACES_SAMPLE_RATE": "0.25",
"O11Y_ENABLE_LOGS": "false",
"O11Y_ENABLE_METRICS": "false",
"PKGSITE_BASE_URL": "http://example.test",
"PKGSITE_HTTP_TIMEOUT": "250ms",
"KV_REDIS_URL": "redis://localhost:6379/0",
"KV_REDIS_POOL_SIZE": "12",
"KV_REDIS_MIN_IDLE_CONNS": "3",
"KV_REDIS_MAX_IDLE_CONNS": "6",
"KV_REDIS_MAX_ACTIVE_CONNS": "18",
"KV_REDIS_POOL_TIMEOUT": "400ms",
"KV_REDIS_DIAL_TIMEOUT": "2s",
"KV_REDIS_READ_TIMEOUT": "900ms",
"KV_REDIS_WRITE_TIMEOUT": "950ms",
"KV_REDIS_CONN_MAX_IDLE_TIME": "12m",
"KV_REDIS_DISABLE_IDENTITY": "false",
"PKGSITE_CACHE_DISABLED": "true",
"RATE_LIMIT_ENABLED": "false",
"RATE_LIMIT_REQUESTS": "10",
"RATE_LIMIT_WINDOW": "30s",
"SENTRY_DSN": "https://public@example.invalid/1",
}))
if err != nil {
t.Fatalf("Read returned error: %v", err)
Expand Down Expand Up @@ -103,6 +143,36 @@ func TestReadOverrides(t *testing.T) {
if got.KV.RedisURL != "redis://localhost:6379/0" {
t.Fatalf("RedisURL = %q, want override", got.KV.RedisURL)
}
if got.KV.RedisPool.Size != 12 {
t.Fatalf("RedisPool.Size = %d, want 12", got.KV.RedisPool.Size)
}
if got.KV.RedisPool.MinIdleConns != 3 {
t.Fatalf("RedisPool.MinIdleConns = %d, want 3", got.KV.RedisPool.MinIdleConns)
}
if got.KV.RedisPool.MaxIdleConns != 6 {
t.Fatalf("RedisPool.MaxIdleConns = %d, want 6", got.KV.RedisPool.MaxIdleConns)
}
if got.KV.RedisPool.MaxActiveConns != 18 {
t.Fatalf("RedisPool.MaxActiveConns = %d, want 18", got.KV.RedisPool.MaxActiveConns)
}
if got.KV.RedisPool.Timeout != 400*time.Millisecond {
t.Fatalf("RedisPool.Timeout = %s, want 400ms", got.KV.RedisPool.Timeout)
}
if got.KV.RedisTimeouts.Dial != 2*time.Second {
t.Fatalf("RedisTimeouts.Dial = %s, want 2s", got.KV.RedisTimeouts.Dial)
}
if got.KV.RedisTimeouts.Read != 900*time.Millisecond {
t.Fatalf("RedisTimeouts.Read = %s, want 900ms", got.KV.RedisTimeouts.Read)
}
if got.KV.RedisTimeouts.Write != 950*time.Millisecond {
t.Fatalf("RedisTimeouts.Write = %s, want 950ms", got.KV.RedisTimeouts.Write)
}
if got.KV.RedisConnMaxIdle != 12*time.Minute {
t.Fatalf("RedisConnMaxIdle = %s, want 12m", got.KV.RedisConnMaxIdle)
}
if got.KV.RedisDisableIdentity {
t.Fatal("RedisDisableIdentity = true, want false")
}
if !got.Pkgsite.CacheDisabled {
t.Fatal("CacheDisabled = false, want true")
}
Expand Down Expand Up @@ -148,12 +218,14 @@ func TestReadParseErrorReportsAllFailures(t *testing.T) {
_, err := read(mapGetenv(map[string]string{
"O11Y_FLUSH_TIMEOUT": "soon",
"PKGSITE_HTTP_TIMEOUT": "later",
"KV_REDIS_POOL_SIZE": "several",
"RATE_LIMIT_REQUESTS": "lots",
}))
if err == nil {
t.Fatal("Read returned nil error, want parse error")
}
const want = `config: parsing O11Y_FLUSH_TIMEOUT="soon": time: invalid duration "soon"
const want = `config: parsing KV_REDIS_POOL_SIZE="several": strconv.Atoi: parsing "several": invalid syntax
config: parsing O11Y_FLUSH_TIMEOUT="soon": time: invalid duration "soon"
config: parsing PKGSITE_HTTP_TIMEOUT="later": time: invalid duration "later"
config: parsing RATE_LIMIT_REQUESTS="lots": strconv.Atoi: parsing "lots": invalid syntax`
if err.Error() != want {
Expand Down
2 changes: 1 addition & 1 deletion internal/httpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func Run(ctx context.Context, cfg Config, logger *slog.Logger) error {
}()
logger = obs.Logger

store, err := kvredis.New(cfg.KV.RedisURL)
store, err := kvredis.New(cfg.KV)
if err != nil {
return fmt.Errorf("configure kv store: %w", err)
}
Expand Down
25 changes: 20 additions & 5 deletions internal/kv/redis/redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"time"

"github.com/garrettladley/pkgsite-mcp/internal/config"
"github.com/garrettladley/pkgsite-mcp/internal/kv"
"github.com/redis/go-redis/extra/redisotel/v9"
goredis "github.com/redis/go-redis/v9"
Expand All @@ -23,18 +24,19 @@ type Store struct {

var _ kv.Store = (*Store)(nil)

func New(redisURL string) (kv.Store, error) {
if redisURL == "" {
func New(cfg config.KV) (kv.Store, error) {
if cfg.RedisURL == "" {
return nil, nil
}
return NewStore(redisURL)
return NewStore(cfg)
}

func NewStore(redisURL string) (*Store, error) {
opts, err := goredis.ParseURL(redisURL)
func NewStore(cfg config.KV) (*Store, error) {
opts, err := goredis.ParseURL(cfg.RedisURL)
if err != nil {
return nil, err
}
applyOptions(opts, cfg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 URL query-param options silently overridden

applyOptions unconditionally overwrites every timeout and pool field that goredis.ParseURL may have already set from URL query parameters. go-redis ParseURL explicitly supports parameters such as ?read_timeout=2s&pool_size=10&dial_timeout=3 (see options.go). Any user whose KV_REDIS_URL includes those query params will have them silently discarded and replaced by the env-var defaults after this change — e.g., redis://host:6379/0?read_timeout=5s would have ReadTimeout set to 5s by ParseURL, then immediately overwritten to 750ms by applyOptions. Guard the assignment so URL-derived non-zero values are not replaced by defaults, or document that URL query params are unsupported.

client := goredis.NewClient(opts)
if err := redisotel.InstrumentTracing(client); err != nil {
return nil, err
Expand All @@ -45,6 +47,19 @@ func NewStore(redisURL string) (*Store, error) {
return &Store{client: client}, nil
}

func applyOptions(opts *goredis.Options, cfg config.KV) {
opts.DisableIdentity = cfg.RedisDisableIdentity
opts.PoolSize = cfg.RedisPool.Size
opts.MinIdleConns = cfg.RedisPool.MinIdleConns
opts.MaxIdleConns = cfg.RedisPool.MaxIdleConns
opts.MaxActiveConns = cfg.RedisPool.MaxActiveConns
opts.PoolTimeout = cfg.RedisPool.Timeout
opts.DialTimeout = cfg.RedisTimeouts.Dial
opts.ReadTimeout = cfg.RedisTimeouts.Read
opts.WriteTimeout = cfg.RedisTimeouts.Write
opts.ConnMaxIdleTime = cfg.RedisConnMaxIdle
}

func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
value, err := s.client.Get(ctx, key).Bytes()
if errors.Is(err, goredis.Nil) {
Expand Down
64 changes: 64 additions & 0 deletions internal/kv/redis/redis_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package redis

import (
"testing"
"time"

"github.com/garrettladley/pkgsite-mcp/internal/config"
goredis "github.com/redis/go-redis/v9"
)

func TestApplyOptions(t *testing.T) {
t.Parallel()

cfg := config.KV{
RedisPool: config.RedisPool{
Size: 4,
MinIdleConns: 2,
MaxIdleConns: 4,
MaxActiveConns: 8,
Timeout: 250 * time.Millisecond,
},
RedisTimeouts: config.RedisTimeouts{
Dial: time.Second,
Read: 750 * time.Millisecond,
Write: 750 * time.Millisecond,
},
RedisConnMaxIdle: 10 * time.Minute,
RedisDisableIdentity: true,
}
opts := &goredis.Options{}

applyOptions(opts, cfg)

if !opts.DisableIdentity {
t.Fatal("DisableIdentity = false, want true")
}
if opts.PoolSize != 4 {
t.Fatalf("PoolSize = %d, want 4", opts.PoolSize)
}
if opts.MinIdleConns != 2 {
t.Fatalf("MinIdleConns = %d, want 2", opts.MinIdleConns)
}
if opts.MaxIdleConns != 4 {
t.Fatalf("MaxIdleConns = %d, want 4", opts.MaxIdleConns)
}
if opts.MaxActiveConns != 8 {
t.Fatalf("MaxActiveConns = %d, want 8", opts.MaxActiveConns)
}
if opts.PoolTimeout != 250*time.Millisecond {
t.Fatalf("PoolTimeout = %s, want 250ms", opts.PoolTimeout)
}
if opts.DialTimeout != time.Second {
t.Fatalf("DialTimeout = %s, want 1s", opts.DialTimeout)
}
if opts.ReadTimeout != 750*time.Millisecond {
t.Fatalf("ReadTimeout = %s, want 750ms", opts.ReadTimeout)
}
if opts.WriteTimeout != 750*time.Millisecond {
t.Fatalf("WriteTimeout = %s, want 750ms", opts.WriteTimeout)
}
if opts.ConnMaxIdleTime != 10*time.Minute {
t.Fatalf("ConnMaxIdleTime = %s, want 10m", opts.ConnMaxIdleTime)
}
}
2 changes: 1 addition & 1 deletion internal/mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func RunStdio(ctx context.Context) error {
}
}()

store, err := kvredis.New(cfg.KV.RedisURL)
store, err := kvredis.New(cfg.KV)
if err != nil {
return fmt.Errorf("configure kv store: %w", err)
}
Expand Down
Loading