~bigbes/core-go

e1b63989b2361920a243c8c051044451c3fafab9 — Conrad Hoffmann 1 year, 9 months ago 59aa639
redis: use UniveralClient, support sentinel mode

This commit switches the redis client to using a UniversalClient [1],
which can operate in standard, cluster, or failover (sentinel) mode.

It also implements basic support for having (multiple)
`redis+sentinel://` URLs as connection URL in the config (e.g. for
`redis-host`).

This is the Go equivalent of the proposed Python patch [2]. It supports
the same examples given there.

[1] https://pkg.go.dev/github.com/go-redis/redis/v8#UniversalClient
[2] https://lists.sr.ht/~sircmpwn/sr.ht-dev/patches/55521
4 files changed, 112 insertions(+), 8 deletions(-)

M go.mod
M redis/middleware.go
A redis/url.go
M server/server.go
M go.mod => go.mod +1 -1
@@ 1,6 1,6 @@
module git.sr.ht/~sircmpwn/core-go

go 1.17
go 1.18

require (
	git.sr.ht/~sircmpwn/dowork v0.0.0-20221010085743-46c4299d76a1

M redis/middleware.go => redis/middleware.go +4 -4
@@ 13,7 13,7 @@ type contextKey struct {
	name string
}

func Middleware(client *redis.Client) func(http.Handler) http.Handler {
func Middleware(client redis.UniversalClient) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := Context(r.Context(), client)


@@ 23,12 23,12 @@ func Middleware(client *redis.Client) func(http.Handler) http.Handler {
	}
}

func Context(ctx context.Context, client *redis.Client) context.Context {
func Context(ctx context.Context, client redis.UniversalClient) context.Context {
	return context.WithValue(ctx, redisCtxKey, client)
}

func ForContext(ctx context.Context) *redis.Client {
	raw, ok := ctx.Value(redisCtxKey).(*redis.Client)
func ForContext(ctx context.Context) redis.UniversalClient {
	raw, ok := ctx.Value(redisCtxKey).(redis.UniversalClient)
	if !ok {
		panic("Invalid redis context")
	}

A redis/url.go => redis/url.go +104 -0
@@ 0,0 1,104 @@
package redis

import (
	"crypto/tls"
	"fmt"
	"net/url"
	"slices"
	"strconv"
	"strings"

	"github.com/go-redis/redis/v8"
)

func parseSentinelURLs(urls []*url.URL) (*redis.UniversalOptions, error) {
	uopts := redis.UniversalOptions{}

	var schemes []string
	var usernames []string
	var passwords []string

	for _, u := range urls {
		if !slices.Contains(schemes, u.Scheme) {
			schemes = append(schemes, u.Scheme)
		}
		if !slices.Contains(usernames, u.User.Username()) {
			usernames = append(usernames, u.User.Username())
		}
		password, _ := u.User.Password()
		if !slices.Contains(passwords, password) {
			passwords = append(passwords, password)
		}
		if u.Scheme != "redis+sentinel" && u.Scheme != "rediss+sentinel" {
			return nil, fmt.Errorf("invalid connection URL scheme: %s", u.Scheme)
		}
		uopts.Addrs = append(uopts.Addrs, u.Host)
	}

	// For global options, force uniformity
	if len(schemes) > 1 {
		return nil, fmt.Errorf("connection URLs must have uniform scheme")
	}
	if len(usernames) > 1 {
		return nil, fmt.Errorf("connection URLs must have uniform password")
	}
	if len(passwords) > 1 {
		return nil, fmt.Errorf("connection URLs must have uniform password")
	}

	u := urls[0]
	path := strings.Split(u.Path[1:], "/")
	uopts.MasterName = path[0]
	if len(path) == 2 {
		db, err := strconv.Atoi(path[1])
		if err != nil {
			return nil, err
		}
		uopts.DB = db
	} else if len(path) > 2 {
		return nil, fmt.Errorf("invalid connection URL path: %s", u.Path)
	}
	uopts.Username = u.User.Username()
	uopts.SentinelUsername = u.User.Username()
	uopts.Password, _ = u.User.Password()
	uopts.SentinelPassword, _ = u.User.Password()
	if u.Scheme == "rediss+sentinel" {
		uopts.TLSConfig = &tls.Config{ServerName: u.Hostname()}
	}
	return &uopts, nil
}

func ParseURL(raw string) (*redis.UniversalOptions, error) {
	// Support multiple URLs for sentinel connections
	var schemes []string
	var urls []*url.URL

	for _, r := range strings.Split(raw, ",") {
		u, err := url.Parse(r)
		if err != nil {
			return nil, err
		}
		urls = append(urls, u)
		if !slices.Contains(schemes, u.Scheme) {
			schemes = append(schemes, u.Scheme)
		}
	}

	if len(urls) == 1 {
		if urls[0].Scheme == "redis" || urls[0].Scheme == "rediss" {
			opts, err := redis.ParseURL(raw)
			if err != nil {
				return nil, err
			}
			return &redis.UniversalOptions{
				Addrs: []string{opts.Addr},
				// TODO
			}, nil
		}
		if urls[0].Scheme != "redis+sentinel" && urls[0].Scheme != "rediss+sentinel" {
			return nil, fmt.Errorf("invalid connection URL scheme: %s", urls[0].Scheme)
		}
		// a single sentinel URL, fall through to parsing that
	}
	return parseSentinelURLs(urls)
}

M server/server.go => server/server.go +3 -3
@@ 52,7 52,7 @@ type Server struct {

	conf    ini.File
	db      *sql.DB
	redis   *goRedis.Client
	redis   goRedis.UniversalClient
	root    chi.Router
	router  chi.Router
	service string


@@ 174,11 174,11 @@ func (server *Server) WithDefaultMiddleware() *Server {
	if !ok {
		rcs = "redis://"
	}
	ropts, err := goRedis.ParseURL(rcs)
	ropts, err := redis.ParseURL(rcs)
	if err != nil {
		log.Fatalf("Invalid sr.ht::redis-host in config.ini: %v", err)
	}
	rc := goRedis.NewClient(ropts)
	rc := goRedis.NewUniversalClient(ropts)
	server.redis = rc

	apiconf := fmt.Sprintf("%s::api", server.service)