diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index fa8f3517..094fb9bf 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -41,6 +41,13 @@ jobs: - name: available platforms run: echo ${{ steps.buildx.outputs.platforms }} + - name: free disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + docker system prune -af + - name: build docker image without pushing (only outside master) if: ${{ github.ref != 'refs/heads/master' }} run: | diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go index 186c7e50..f72c6b56 100644 --- a/backend/app/notify/email.go +++ b/backend/app/notify/email.go @@ -10,7 +10,7 @@ import ( log "github.com/go-pkgz/lgr" ntf "github.com/go-pkgz/notify" - "github.com/go-pkgz/repeater" + "github.com/go-pkgz/repeater/v2" "github.com/hashicorp/go-multierror" "github.com/umputun/remark42/backend/app/templates" @@ -161,7 +161,7 @@ func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email stri return err } - return repeater.NewDefault(5, time.Millisecond*250).Do( + return repeater.NewFixed(5, time.Millisecond*250).Do( ctx, func() error { return e.Email.Send( @@ -196,7 +196,7 @@ func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) e return err } - return repeater.NewDefault(5, time.Millisecond*250).Do( + return repeater.NewFixed(5, time.Millisecond*250).Do( ctx, func() error { return e.Email.Send( diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 0abdd98a..ef3ee4ac 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -15,8 +15,8 @@ import ( "sync" "time" - "github.com/didip/tollbooth/v7" - "github.com/didip/tollbooth_chi" + "github.com/didip/tollbooth/v8" + "github.com/didip/tollbooth/v8/limiter" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" @@ -234,14 +234,14 @@ func (s *Rest) routes() chi.Router { router.Group(func(r chi.Router) { r.Use(middleware.Timeout(5 * time.Second)) - r.Use(logInfoWithBody, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(2, nil)), middleware.NoCache) + r.Use(logInfoWithBody, rateLimiter(2), middleware.NoCache) r.Use(validEmailAuth()) // reject suspicious email logins r.Mount("/auth", authHandler) }) router.Group(func(r chi.Router) { r.Use(middleware.Timeout(5 * time.Second)) - r.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil))) + r.Use(rateLimiter(100)) r.Mount("/avatar", avatarHandler) }) @@ -251,14 +251,14 @@ func (s *Rest) routes() chi.Router { router.Route("/api/v1", func(rapi chi.Router) { rapi.Group(func(rava chi.Router) { rava.Use(middleware.Timeout(5 * time.Second)) - rava.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil))) + rava.Use(rateLimiter(100)) rava.Mount("/avatar", avatarHandler) }) // open routes rapi.Group(func(ropen chi.Router) { ropen.Use(middleware.Timeout(30 * time.Second)) - ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.openRouteLimiter, nil))) + ropen.Use(rateLimiter(s.openRouteLimiter)) ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody) ropen.Get("/config", s.configCtrl) ropen.Get("/find", s.pubRest.findCommentsCtrl) @@ -281,7 +281,7 @@ func (s *Rest) routes() chi.Router { // open routes, cached rapi.Group(func(ropen chi.Router) { ropen.Use(middleware.Timeout(30 * time.Second)) - ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) + ropen.Use(rateLimiter(10)) ropen.Use(authMiddleware.Trace, logInfoWithBody) ropen.Get("/picture/{user}/{id}", s.pubRest.loadPictureCtrl) ropen.Get("/qr/telegram", s.pubRest.telegramQrCtrl) @@ -290,7 +290,7 @@ func (s *Rest) routes() chi.Router { // protected routes, require auth rapi.Group(func(rauth chi.Router) { rauth.Use(middleware.Timeout(30 * time.Second)) - rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) + rauth.Use(rateLimiter(10)) rauth.Use(authMiddleware.Auth, matchSiteID, middleware.NoCache, logInfoWithBody) rauth.Get("/user", s.privRest.userInfoCtrl) rauth.Get("/userdata", s.privRest.userAllDataCtrl) @@ -299,7 +299,7 @@ func (s *Rest) routes() chi.Router { // admin routes, require auth and admin users only rapi.Route("/admin", func(radmin chi.Router) { radmin.Use(middleware.Timeout(30 * time.Second)) - radmin.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) + radmin.Use(rateLimiter(10)) radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID) radmin.Use(middleware.NoCache, logInfoWithBody) @@ -325,7 +325,7 @@ func (s *Rest) routes() chi.Router { // protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param rapi.Group(func(rauth chi.Router) { rauth.Use(middleware.Timeout(10 * time.Second)) - rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil))) + rauth.Use(rateLimiter(s.updateLimiter())) rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly)) rauth.Use(middleware.NoCache, logInfoWithBody) @@ -345,7 +345,7 @@ func (s *Rest) routes() chi.Router { // protected routes, anonymous rejected rapi.Group(func(rauth chi.Router) { rauth.Use(middleware.Timeout(10 * time.Second)) - rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil))) + rauth.Use(rateLimiter(s.updateLimiter())) rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID) rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler) rauth.Post("/picture", s.privRest.savePictureCtrl) @@ -355,7 +355,7 @@ func (s *Rest) routes() chi.Router { // open routes on root level router.Group(func(rroot chi.Router) { rroot.Use(middleware.Timeout(10 * time.Second)) - rroot.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil))) + rroot.Use(rateLimiter(50)) rroot.Get("/robots.txt", s.pubRest.robotsCtrl) rroot.Get("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl) rroot.Post("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl) @@ -491,7 +491,7 @@ func addFileServer(r chi.Router, embedFS embed.FS, webRoot, version string) { webFS = http.StripPrefix("/web", webFS) r.Get("/web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP) - r.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(20, nil)), + r.With(rateLimiter(20), middleware.Timeout(10*time.Second), cacheControl(time.Hour, version), ).Get("/web/*", func(w http.ResponseWriter, r *http.Request) { @@ -728,3 +728,16 @@ func parseError(err error, defaultCode int) (code int) { return code } + +// rateLimiter creates a rate limiting middleware with proper IP lookup configuration. +// tollbooth v8 requires explicit IP lookup method to be set. +// uses RemoteAddr which is set by chi's middleware.RealIP to the real client IP +// from X-Forwarded-For, X-Real-IP, or True-Client-IP headers. +func rateLimiter(maxReq float64) func(http.Handler) http.Handler { + lmt := tollbooth.NewLimiter(maxReq, nil) + lmt.SetIPLookup(limiter.IPLookup{ + Name: "RemoteAddr", + IndexFromRight: 0, + }) + return tollbooth.HTTPMiddleware(lmt) +} diff --git a/backend/app/rest/proxy/image.go b/backend/app/rest/proxy/image.go index 4f4690d2..03f8a526 100644 --- a/backend/app/rest/proxy/image.go +++ b/backend/app/rest/proxy/image.go @@ -12,7 +12,7 @@ import ( "github.com/PuerkitoBio/goquery" log "github.com/go-pkgz/lgr" - "github.com/go-pkgz/repeater" + "github.com/go-pkgz/repeater/v2" "github.com/umputun/remark42/backend/app/rest" "github.com/umputun/remark42/backend/app/store/image" @@ -151,7 +151,7 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error) client := http.Client{Timeout: 30 * time.Second} defer client.CloseIdleConnections() var resp *http.Response - err := repeater.NewDefault(5, time.Second).Do(ctx, func() error { + err := repeater.NewFixed(5, time.Second).Do(ctx, func() error { var e error req, e := http.NewRequest("GET", imgURL, http.NoBody) if e != nil { diff --git a/backend/go.mod b/backend/go.mod index c0251031..87cb1f80 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,16 +6,15 @@ require ( github.com/Depado/bfchroma/v2 v2.0.0 github.com/PuerkitoBio/goquery v1.11.0 github.com/alecthomas/chroma/v2 v2.20.0 - github.com/didip/tollbooth/v7 v7.0.2 - github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f + github.com/didip/tollbooth/v8 v8.0.1 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 - github.com/go-pkgz/auth/v2 v2.0.1-0.20250415030422-4f9f2c5e3b0d + github.com/go-pkgz/auth/v2 v2.1.0 github.com/go-pkgz/jrpc v0.4.0 github.com/go-pkgz/lcw/v2 v2.0.0 github.com/go-pkgz/lgr v0.12.1 - github.com/go-pkgz/notify v1.2.0 - github.com/go-pkgz/repeater v1.2.0 + github.com/go-pkgz/notify v1.3.0 + github.com/go-pkgz/repeater/v2 v2.2.0 github.com/go-pkgz/rest v1.20.4 github.com/go-pkgz/syncs v1.3.2 github.com/golang-jwt/jwt/v5 v5.3.0 @@ -37,7 +36,7 @@ require ( ) require ( - cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -45,27 +44,28 @@ require ( github.com/dghubble/oauth1 v0.7.3 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/go-oauth2/oauth2/v4 v4.5.3 // indirect - github.com/go-pkgz/email v0.5.0 // indirect - github.com/go-pkgz/expirable-cache/v3 v3.0.0 // indirect + github.com/go-oauth2/oauth2/v4 v4.5.4 // indirect + github.com/go-pkgz/email v0.6.0 // indirect + github.com/go-pkgz/expirable-cache/v3 v3.1.0 // indirect + github.com/go-pkgz/repeater v1.2.0 // indirect github.com/go-pkgz/routegroup v1.6.0 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/redis/go-redis/v9 v9.7.3 // indirect + github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d // indirect - github.com/slack-go/slack v0.15.0 // indirect + github.com/slack-go/slack v0.17.3 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.mongodb.org/mongo-driver v1.17.3 // indirect - golang.org/x/oauth2 v0.29.0 // indirect + go.mongodb.org/mongo-driver v1.17.6 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 9721cd36..7cb27b45 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,6 +1,5 @@ -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Depado/bfchroma/v2 v2.0.0 h1:IRpN9BPkNwEpR6w1ectIcNWOuhDSLx+8f1pn83fzxx8= github.com/Depado/bfchroma/v2 v2.0.0/go.mod h1:wFwW/Pw8Tnd0irzgO9Zxtxgzp3aPS8qBWlyadxujxmw= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= @@ -27,94 +26,71 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6/go.mod h1:5FoAH5xUHHCMDvQPy1rnj8moqLkLHFaDVBjHhcFwEi0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE= github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/didip/tollbooth/v7 v7.0.0/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4= -github.com/didip/tollbooth/v7 v7.0.2 h1:WYEfusYI6g64cN0qbZgekDrYfuYBZjUZd5+RlWi69p4= -github.com/didip/tollbooth/v7 v7.0.2/go.mod h1:RtRYfEmFGX70+ike5kSndSvLtQ3+F2EAmTI4Un/VXNc= -github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f h1:jtKwihcLmUC9BAhoJ9adCUqdSSZcOdH2KL7mPTUm2aw= -github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f/go.mod h1:q9C80dnsuVRP2dAskjnXRNWdUJqtGgwG9wNrzt0019s= +github.com/didip/tollbooth/v8 v8.0.1 h1:VAAapTo1t4Bn6bbpcHjuovwoa9u3JH++wgjbpWv+rB8= +github.com/didip/tollbooth/v8 v8.0.1/go.mod h1:oEd9l+ep373d7DmvKLc0a5gasPOev2mTewi6KPQBGJ4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-oauth2/oauth2/v4 v4.5.3 h1:lQt7O9KOnu/v4awe166FH7+p8tFUXQyR+no6nctAKU0= -github.com/go-oauth2/oauth2/v4 v4.5.3/go.mod h1:ryzb7zr8fdQBlciD0+tcnEWeOok5B0J8V/DniwYqQ2k= -github.com/go-pkgz/auth/v2 v2.0.1-0.20250415030422-4f9f2c5e3b0d h1:xyTp7JRaHy3/ttpcNiJO/YzxENZcvxcZL8iE3QfssnY= -github.com/go-pkgz/auth/v2 v2.0.1-0.20250415030422-4f9f2c5e3b0d/go.mod h1:YTCEC2cBE2ugyRf0ePew4NZIJE5ozglI06/IokmtiwU= -github.com/go-pkgz/email v0.5.0 h1:fdtMDGJ8NwyBACLR0LYHaCIK/OeUwZHMhH7Q0+oty9U= -github.com/go-pkgz/email v0.5.0/go.mod h1:BdxglsQnymzhfdbnncEE72a6DrucZHy6I+42LK2jLEc= -github.com/go-pkgz/expirable-cache v0.1.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs= -github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw= -github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec= +github.com/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6XmLcW0= +github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ= +github.com/go-pkgz/auth/v2 v2.1.0 h1:ECUVtCWzJG9yDNMVWh1tX/qoDXaUpwSROyZ4VzmoAhI= +github.com/go-pkgz/auth/v2 v2.1.0/go.mod h1:adiVuMWvZ07wgLLWg1xjG+GQIn8wLty8UEXkjEOsZmw= +github.com/go-pkgz/email v0.6.0 h1:snZnXldjeF4PgKSjnx9Fa25mtOgFpAOEeWvnQvrxjLE= +github.com/go-pkgz/email v0.6.0/go.mod h1:+wgi4x7S33IuCzfcCM5euN0GwQG6XvO/PBLxrNffYLI= +github.com/go-pkgz/expirable-cache/v3 v3.1.0 h1:s05P851/O6QJ6Mc+7o2bh9aGtD3romB1SxDTXifdoqc= +github.com/go-pkgz/expirable-cache/v3 v3.1.0/go.mod h1:6pVgNleydKPj0J2/mzrI02/RDo4ivKx5v2XlNmIjhjo= github.com/go-pkgz/jrpc v0.4.0 h1:oD7xiGrzDkndkuCjeHGugQXxbggLSV7O1QmHhoc5pYY= github.com/go-pkgz/jrpc v0.4.0/go.mod h1:JFoY3bRjRyx4M3CbEVDFQStMB1m2gmQ7OjqFK7q3kOo= github.com/go-pkgz/lcw/v2 v2.0.0 h1:gTwXpiJBhQeA1rXuqkRuLcV79uATFna8CckH8ZBBrH0= github.com/go-pkgz/lcw/v2 v2.0.0/go.mod h1:yxJHOn+IbQBQHxUqkCtMrbGjIfdYcsBAZcVCBaL1Va8= github.com/go-pkgz/lgr v0.12.1 h1:8GVfG2rSARq3Eaj5PP158rtBR2LHVGkwioIkQBGbvKg= github.com/go-pkgz/lgr v0.12.1/go.mod h1:A4AxjOthFVFK6jRnVYMeusno5SeDAxcLVHd0kI/lN/Y= -github.com/go-pkgz/notify v1.2.0 h1:jqbsbWkodCDB9ffyI9fCG1bTSgidWJRS5UWD/twjU44= -github.com/go-pkgz/notify v1.2.0/go.mod h1:BTHj9ly7xZpaxg91lBdxCxSmkpVQ9R6q5QviX/upeYo= +github.com/go-pkgz/notify v1.3.0 h1:YxF/ThEoCetdcoghWdyeqaBpCkZ8mvyve7HXbCAOzYU= +github.com/go-pkgz/notify v1.3.0/go.mod h1:qdfi5OsViKlIFPryIOaINHTOtS9GFhOYXPqJmAMlaGU= github.com/go-pkgz/repeater v1.2.0 h1:oJFvjyKdTDd5RCzpzxlzYIZFFj6Zfl17rE1aUfu6UjQ= github.com/go-pkgz/repeater v1.2.0/go.mod h1:vypP6xamA53MFmafnGUucqOmALKk36xgKu2hSG73LHM= +github.com/go-pkgz/repeater/v2 v2.2.0 h1:8nZR/NaknmLfx2YMHbr78u9OL4Xj+8+romm9dz4FpMg= +github.com/go-pkgz/repeater/v2 v2.2.0/go.mod h1:RgX5vUbLKq7PV82QUDP5pFbQS1os4Z+U9XzKymK23A8= github.com/go-pkgz/rest v1.20.4 h1:8ufcP1IqoDhCvIFdXPtvyX4HSS16SM6THBe2a6L0/kg= github.com/go-pkgz/rest v1.20.4/go.mod h1:2/LEZGndSxpVvExsMn48AjUgiTn6kILqjpoaRnl62JU= github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4= github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg= github.com/go-pkgz/syncs v1.3.2 h1:gmioASlJNy3gNosPlgvWOM2QP0Hdjzn2u+/sUShgd8E= github.com/go-pkgz/syncs v1.3.2/go.mod h1:qjgzpp7OpuhDf7BWsW/FHCu9DLjE32NPy6/vXAXT/Cw= -github.com/go-session/session/v3 v3.2.1/go.mod h1:RftEBbyuzqkNCAxIrCLJe+rfBqB/4G11qxq9KYKrx4M= -github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= -github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -126,44 +102,30 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= -github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= -github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d h1:l3+2LWCbVxn5itfvXAfH9n4YL9jh8l1g5zcncbIc1cs= @@ -172,61 +134,42 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0= -github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= +github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0= github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8= -github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI= -github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY= -github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI= +github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= +github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg= github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU= -github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= -github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg= github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q= -github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8= github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ= -github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao= -github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ= github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE= github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= @@ -243,19 +186,17 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= -go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= -go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= @@ -269,16 +210,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -288,13 +221,9 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -303,23 +232,11 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -338,9 +255,7 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -352,32 +267,14 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/vendor/cloud.google.com/go/compute/metadata/CHANGES.md b/backend/vendor/cloud.google.com/go/compute/metadata/CHANGES.md index bcfb5d81..e384683c 100644 --- a/backend/vendor/cloud.google.com/go/compute/metadata/CHANGES.md +++ b/backend/vendor/cloud.google.com/go/compute/metadata/CHANGES.md @@ -1,5 +1,54 @@ # Changes +## [0.9.0](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.8.4...compute/metadata/v0.9.0) (2025-09-24) + + +### Features + +* **compute/metadata:** Retry on HTTP 429 ([#12932](https://github.com/googleapis/google-cloud-go/issues/12932)) ([1e91f5c](https://github.com/googleapis/google-cloud-go/commit/1e91f5c07acacd38ecdd4ff3e83e092b745e0bc2)) + +## [0.8.4](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.8.3...compute/metadata/v0.8.4) (2025-09-18) + + +### Bug Fixes + +* **compute/metadata:** Set subClient for UseDefaultClient case ([#12911](https://github.com/googleapis/google-cloud-go/issues/12911)) ([9e2646b](https://github.com/googleapis/google-cloud-go/commit/9e2646b1821231183fd775bb107c062865eeaccd)) + +## [0.8.3](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.8.2...compute/metadata/v0.8.3) (2025-09-17) + + +### Bug Fixes + +* **compute/metadata:** Disable Client timeouts for subscription client ([#12910](https://github.com/googleapis/google-cloud-go/issues/12910)) ([187a58a](https://github.com/googleapis/google-cloud-go/commit/187a58a540494e1e8562b046325b8cad8cf7af4a)) + +## [0.8.2](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.8.1...compute/metadata/v0.8.2) (2025-09-17) + + +### Bug Fixes + +* **compute/metadata:** Racy test and uninitialized subClient ([#12892](https://github.com/googleapis/google-cloud-go/issues/12892)) ([4943ca2](https://github.com/googleapis/google-cloud-go/commit/4943ca2bf83908a23806247bc4252dfb440d09cc)), refs [#12888](https://github.com/googleapis/google-cloud-go/issues/12888) + +## [0.8.1](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.8.0...compute/metadata/v0.8.1) (2025-09-16) + + +### Bug Fixes + +* **compute/metadata:** Use separate client for subscribe methods ([#12885](https://github.com/googleapis/google-cloud-go/issues/12885)) ([76b80f8](https://github.com/googleapis/google-cloud-go/commit/76b80f8df9bf9339d175407e8c15936fe1ac1c9c)) + +## [0.8.0](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.7.0...compute/metadata/v0.8.0) (2025-08-06) + + +### Features + +* **compute/metadata:** Add Options.UseDefaultClient ([#12657](https://github.com/googleapis/google-cloud-go/issues/12657)) ([1a88209](https://github.com/googleapis/google-cloud-go/commit/1a8820900f20e038291c4bb2c5284a449196e81f)), refs [#11078](https://github.com/googleapis/google-cloud-go/issues/11078) + +## [0.7.0](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.6.0...compute/metadata/v0.7.0) (2025-05-13) + + +### Features + +* **compute/metadata:** Allow canceling GCE detection ([#11786](https://github.com/googleapis/google-cloud-go/issues/11786)) ([78100fe](https://github.com/googleapis/google-cloud-go/commit/78100fe7e28cd30f1e10b47191ac3c9839663b64)) + ## [0.6.0](https://github.com/googleapis/google-cloud-go/compare/compute/metadata/v0.5.2...compute/metadata/v0.6.0) (2024-12-13) diff --git a/backend/vendor/cloud.google.com/go/compute/metadata/metadata.go b/backend/vendor/cloud.google.com/go/compute/metadata/metadata.go index 4c18a383..6bd18916 100644 --- a/backend/vendor/cloud.google.com/go/compute/metadata/metadata.go +++ b/backend/vendor/cloud.google.com/go/compute/metadata/metadata.go @@ -22,6 +22,7 @@ package metadata // import "cloud.google.com/go/compute/metadata" import ( "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -62,21 +63,26 @@ var ( ) var defaultClient = &Client{ - hc: newDefaultHTTPClient(), - logger: slog.New(noOpHandler{}), + hc: newDefaultHTTPClient(true), + subClient: newDefaultHTTPClient(false), + logger: slog.New(noOpHandler{}), } -func newDefaultHTTPClient() *http.Client { - return &http.Client{ - Transport: &http.Transport{ - Dial: (&net.Dialer{ - Timeout: 2 * time.Second, - KeepAlive: 30 * time.Second, - }).Dial, - IdleConnTimeout: 60 * time.Second, - }, - Timeout: 5 * time.Second, +func newDefaultHTTPClient(enableTimeouts bool) *http.Client { + transport := &http.Transport{ + Dial: (&net.Dialer{ + Timeout: 2 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, } + c := &http.Client{ + Transport: transport, + } + if enableTimeouts { + transport.IdleConnTimeout = 60 * time.Second + c.Timeout = 5 * time.Second + } + return c } // NotDefinedError is returned when requested metadata is not defined. @@ -117,82 +123,20 @@ var ( // NOTE: True returned from `OnGCE` does not guarantee that the metadata server // is accessible from this process and have all the metadata defined. func OnGCE() bool { - onGCEOnce.Do(initOnGCE) + return OnGCEWithContext(context.Background()) +} + +// OnGCEWithContext reports whether this process is running on Google Compute Platforms. +// This function's return value is memoized for better performance. +// NOTE: True returned from `OnGCEWithContext` does not guarantee that the metadata server +// is accessible from this process and have all the metadata defined. +func OnGCEWithContext(ctx context.Context) bool { + onGCEOnce.Do(func() { + onGCE = defaultClient.OnGCEWithContext(ctx) + }) return onGCE } -func initOnGCE() { - onGCE = testOnGCE() -} - -func testOnGCE() bool { - // The user explicitly said they're on GCE, so trust them. - if os.Getenv(metadataHostEnv) != "" { - return true - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - resc := make(chan bool, 2) - - // Try two strategies in parallel. - // See https://github.com/googleapis/google-cloud-go/issues/194 - go func() { - req, _ := http.NewRequest("GET", "http://"+metadataIP, nil) - req.Header.Set("User-Agent", userAgent) - res, err := newDefaultHTTPClient().Do(req.WithContext(ctx)) - if err != nil { - resc <- false - return - } - defer res.Body.Close() - resc <- res.Header.Get("Metadata-Flavor") == "Google" - }() - - go func() { - resolver := &net.Resolver{} - addrs, err := resolver.LookupHost(ctx, "metadata.google.internal.") - if err != nil || len(addrs) == 0 { - resc <- false - return - } - resc <- strsContains(addrs, metadataIP) - }() - - tryHarder := systemInfoSuggestsGCE() - if tryHarder { - res := <-resc - if res { - // The first strategy succeeded, so let's use it. - return true - } - // Wait for either the DNS or metadata server probe to - // contradict the other one and say we are running on - // GCE. Give it a lot of time to do so, since the system - // info already suggests we're running on a GCE BIOS. - timer := time.NewTimer(5 * time.Second) - defer timer.Stop() - select { - case res = <-resc: - return res - case <-timer.C: - // Too slow. Who knows what this system is. - return false - } - } - - // There's no hint from the system info that we're running on - // GCE, so use the first probe's result as truth, whether it's - // true or false. The goal here is to optimize for speed for - // users who are NOT running on GCE. We can't assume that - // either a DNS lookup or an HTTP request to a blackholed IP - // address is fast. Worst case this should return when the - // metaClient's Transport.ResponseHeaderTimeout or - // Transport.Dial.Timeout fires (in two seconds). - return <-resc -} - // Subscribe calls Client.SubscribeWithContext on the default client. // // Deprecated: Please use the context aware variant [SubscribeWithContext]. @@ -412,47 +356,161 @@ func strsContains(ss []string, s string) bool { // A Client provides metadata. type Client struct { - hc *http.Client - logger *slog.Logger + hc *http.Client + // subClient by default is a HTTP Client that is only used for subscribe + // methods that should not specify a timeout. If the user specifies a client + // this with be the same as 'hc'. + subClient *http.Client + logger *slog.Logger } // Options for configuring a [Client]. type Options struct { // Client is the HTTP client used to make requests. Optional. + // If UseDefaultClient is true, this field is ignored. + // If this field is nil, a new default http.Client will be created. Client *http.Client // Logger is used to log information about HTTP request and responses. // If not provided, nothing will be logged. Optional. Logger *slog.Logger + // UseDefaultClient specifies that the client should use the same default + // internal http.Client that is used in functions such as GetWithContext. + // This is useful for sharing a single TCP connection pool across requests. + // The difference vs GetWithContext is the ability to use this struct + // to provide a custom logger. If this field is true, the Client + // field is ignored. + UseDefaultClient bool } // NewClient returns a Client that can be used to fetch metadata. // Returns the client that uses the specified http.Client for HTTP requests. -// If nil is specified, returns the default client. +// If nil is specified, returns the default internal Client that is +// also used in functions such as GetWithContext. This is useful for sharing +// a single TCP connection pool across requests. func NewClient(c *http.Client) *Client { - return NewWithOptions(&Options{ - Client: c, - }) + if c == nil { + // Preserve original behavior for nil argument. + return defaultClient + } + // Return a new client with a no-op logger for backward compatibility. + return &Client{hc: c, subClient: c, logger: slog.New(noOpHandler{})} } // NewWithOptions returns a Client that is configured with the provided Options. func NewWithOptions(opts *Options) *Client { + // Preserve original behavior for nil opts. if opts == nil { return defaultClient } + + // Handle explicit request for the internal default http.Client. + if opts.UseDefaultClient { + logger := opts.Logger + if logger == nil { + logger = slog.New(noOpHandler{}) + } + return &Client{hc: defaultClient.hc, subClient: defaultClient.subClient, logger: logger} + } + + // Handle isolated client creation. client := opts.Client + subClient := opts.Client if client == nil { - client = newDefaultHTTPClient() + client = newDefaultHTTPClient(true) + subClient = newDefaultHTTPClient(false) } logger := opts.Logger if logger == nil { logger = slog.New(noOpHandler{}) } - return &Client{hc: client, logger: logger} + return &Client{hc: client, subClient: subClient, logger: logger} +} + +// NOTE: metadataRequestStrategy is assigned to a variable for test stubbing purposes. +var metadataRequestStrategy = func(ctx context.Context, httpClient *http.Client, resc chan bool) { + req, _ := http.NewRequest("GET", "http://"+metadataIP, nil) + req.Header.Set("User-Agent", userAgent) + res, err := httpClient.Do(req.WithContext(ctx)) + if err != nil { + resc <- false + return + } + defer res.Body.Close() + resc <- res.Header.Get("Metadata-Flavor") == "Google" +} + +// NOTE: dnsRequestStrategy is assigned to a variable for test stubbing purposes. +var dnsRequestStrategy = func(ctx context.Context, resc chan bool) { + resolver := &net.Resolver{} + addrs, err := resolver.LookupHost(ctx, "metadata.google.internal.") + if err != nil || len(addrs) == 0 { + resc <- false + return + } + resc <- strsContains(addrs, metadataIP) +} + +// OnGCEWithContext reports whether this process is running on Google Compute Platforms. +// NOTE: True returned from `OnGCEWithContext` does not guarantee that the metadata server +// is accessible from this process and have all the metadata defined. +func (c *Client) OnGCEWithContext(ctx context.Context) bool { + // The user explicitly said they're on GCE, so trust them. + if os.Getenv(metadataHostEnv) != "" { + return true + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + resc := make(chan bool, 2) + + // Try two strategies in parallel. + // See https://github.com/googleapis/google-cloud-go/issues/194 + go metadataRequestStrategy(ctx, c.hc, resc) + go dnsRequestStrategy(ctx, resc) + + tryHarder := systemInfoSuggestsGCE() + if tryHarder { + res := <-resc + if res { + // The first strategy succeeded, so let's use it. + return true + } + + // Wait for either the DNS or metadata server probe to + // contradict the other one and say we are running on + // GCE. Give it a lot of time to do so, since the system + // info already suggests we're running on a GCE BIOS. + // Ensure cancellations from the calling context are respected. + waitContext, cancelWait := context.WithTimeout(ctx, 5*time.Second) + defer cancelWait() + select { + case res = <-resc: + return res + case <-waitContext.Done(): + // Too slow. Who knows what this system is. + return false + } + } + + // There's no hint from the system info that we're running on + // GCE, so use the first probe's result as truth, whether it's + // true or false. The goal here is to optimize for speed for + // users who are NOT running on GCE. We can't assume that + // either a DNS lookup or an HTTP request to a blackholed IP + // address is fast. Worst case this should return when the + // metaClient's Transport.ResponseHeaderTimeout or + // Transport.Dial.Timeout fires (in two seconds). + return <-resc } // getETag returns a value from the metadata service as well as the associated ETag. // This func is otherwise equivalent to Get. func (c *Client) getETag(ctx context.Context, suffix string) (value, etag string, err error) { + return c.getETagWithSubClient(ctx, suffix, false) +} + +func (c *Client) getETagWithSubClient(ctx context.Context, suffix string, enableSubClient bool) (value, etag string, err error) { // Using a fixed IP makes it very difficult to spoof the metadata service in // a container, which is an important use-case for local testing of cloud // deployments. To enable spoofing of the metadata service, the environment @@ -479,9 +537,13 @@ func (c *Client) getETag(ctx context.Context, suffix string) (value, etag string var reqErr error var body []byte retryer := newRetryer() + hc := c.hc + if enableSubClient { + hc = c.subClient + } for { c.logger.DebugContext(ctx, "metadata request", "request", httpRequest(req, nil)) - res, reqErr = c.hc.Do(req) + res, reqErr = hc.Do(req) var code int if res != nil { code = res.StatusCode @@ -827,7 +889,7 @@ func (c *Client) SubscribeWithContext(ctx context.Context, suffix string, fn fun const failedSubscribeSleep = time.Second * 5 // First check to see if the metadata value exists at all. - val, lastETag, err := c.getETag(ctx, suffix) + val, lastETag, err := c.getETagWithSubClient(ctx, suffix, true) if err != nil { return err } @@ -843,8 +905,11 @@ func (c *Client) SubscribeWithContext(ctx context.Context, suffix string, fn fun suffix += "?wait_for_change=true&last_etag=" } for { - val, etag, err := c.getETag(ctx, suffix+url.QueryEscape(lastETag)) + val, etag, err := c.getETagWithSubClient(ctx, suffix+url.QueryEscape(lastETag), true) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } if _, deleted := err.(NotDefinedError); !deleted { time.Sleep(failedSubscribeSleep) continue // Retry on other errors. diff --git a/backend/vendor/cloud.google.com/go/compute/metadata/retry.go b/backend/vendor/cloud.google.com/go/compute/metadata/retry.go index 3d4bc75d..d516f30f 100644 --- a/backend/vendor/cloud.google.com/go/compute/metadata/retry.go +++ b/backend/vendor/cloud.google.com/go/compute/metadata/retry.go @@ -95,6 +95,9 @@ func shouldRetry(status int, err error) bool { if 500 <= status && status <= 599 { return true } + if status == http.StatusTooManyRequests { + return true + } if err == io.ErrUnexpectedEOF { return true } diff --git a/backend/vendor/cloud.google.com/go/compute/metadata/syscheck.go b/backend/vendor/cloud.google.com/go/compute/metadata/syscheck.go index e0704fa6..d57ae1b2 100644 --- a/backend/vendor/cloud.google.com/go/compute/metadata/syscheck.go +++ b/backend/vendor/cloud.google.com/go/compute/metadata/syscheck.go @@ -20,7 +20,9 @@ package metadata // doing network requests) suggests that we're running on GCE. If this // returns true, testOnGCE tries a bit harder to reach its metadata // server. -func systemInfoSuggestsGCE() bool { +// +// NOTE: systemInfoSuggestsGCE is assigned to a varible for test stubbing purposes. +var systemInfoSuggestsGCE = func() bool { // We don't currently have checks for other GOOS return false } diff --git a/backend/vendor/cloud.google.com/go/compute/metadata/syscheck_linux.go b/backend/vendor/cloud.google.com/go/compute/metadata/syscheck_linux.go index 74689acb..17ba5a3a 100644 --- a/backend/vendor/cloud.google.com/go/compute/metadata/syscheck_linux.go +++ b/backend/vendor/cloud.google.com/go/compute/metadata/syscheck_linux.go @@ -21,8 +21,10 @@ import ( "strings" ) -func systemInfoSuggestsGCE() bool { +// NOTE: systemInfoSuggestsGCE is assigned to a varible for test stubbing purposes. +var systemInfoSuggestsGCE = func() bool { b, _ := os.ReadFile("/sys/class/dmi/id/product_name") + name := strings.TrimSpace(string(b)) return name == "Google" || name == "Google Compute Engine" } diff --git a/backend/vendor/cloud.google.com/go/compute/metadata/syscheck_windows.go b/backend/vendor/cloud.google.com/go/compute/metadata/syscheck_windows.go index c0ce6278..f57a5b14 100644 --- a/backend/vendor/cloud.google.com/go/compute/metadata/syscheck_windows.go +++ b/backend/vendor/cloud.google.com/go/compute/metadata/syscheck_windows.go @@ -22,7 +22,8 @@ import ( "golang.org/x/sys/windows/registry" ) -func systemInfoSuggestsGCE() bool { +// NOTE: systemInfoSuggestsGCE is assigned to a varible for test stubbing purposes. +var systemInfoSuggestsGCE = func() bool { k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\HardwareConfig\Current`, registry.QUERY_VALUE) if err != nil { return false diff --git a/backend/vendor/github.com/didip/tollbooth/v7/.gitignore b/backend/vendor/github.com/didip/tollbooth/v8/.gitignore similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/.gitignore rename to backend/vendor/github.com/didip/tollbooth/v8/.gitignore diff --git a/backend/vendor/github.com/didip/tollbooth/v7/.golangci.yml b/backend/vendor/github.com/didip/tollbooth/v8/.golangci.yml similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/.golangci.yml rename to backend/vendor/github.com/didip/tollbooth/v8/.golangci.yml diff --git a/backend/vendor/github.com/didip/tollbooth/v7/LICENSE b/backend/vendor/github.com/didip/tollbooth/v8/LICENSE similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/LICENSE rename to backend/vendor/github.com/didip/tollbooth/v8/LICENSE diff --git a/backend/vendor/github.com/didip/tollbooth/v7/README.md b/backend/vendor/github.com/didip/tollbooth/v8/README.md similarity index 78% rename from backend/vendor/github.com/didip/tollbooth/v7/README.md rename to backend/vendor/github.com/didip/tollbooth/v8/README.md index f96ac2f1..e08fe4a6 100644 --- a/backend/vendor/github.com/didip/tollbooth/v7/README.md +++ b/backend/vendor/github.com/didip/tollbooth/v8/README.md @@ -25,6 +25,9 @@ This is a generic middleware to rate-limit HTTP requests. **v7.x.x:** Replaced `time/rate` with `embedded time/rate` so that we can support more rate limit headers. +**v8.x.x:** Address `RemoteIP` vulnerability concern by replacing `SetIPLookups` with `SetIPLookup`, an explicit way to pick the IP address. + + ## Five Minute Tutorial ```go @@ -33,7 +36,8 @@ package main import ( "net/http" - "github.com/didip/tollbooth/v7" + "github.com/didip/tollbooth/v8" + "github.com/didip/tollbooth/v8/limiter" ) func HelloHandler(w http.ResponseWriter, req *http.Request) { @@ -42,7 +46,15 @@ func HelloHandler(w http.ResponseWriter, req *http.Request) { func main() { // Create a request limiter per handler. - http.Handle("/", tollbooth.LimitFuncHandler(tollbooth.NewLimiter(1, nil), HelloHandler)) + lmt := tollbooth.NewLimiter(1, nil) + + // New in version >= 8, you must explicitly define how to pick the IP address. + lmt.SetIPLookup(limiter.IPLookup{ + Name: "X-Real-IP", + IndexFromRight: 0, + }) + + http.Handle("/", tollbooth.LimitFuncHandler(lmt, HelloHandler)) http.ListenAndServe(":12345", nil) } ``` @@ -54,8 +66,8 @@ func main() { import ( "time" - "github.com/didip/tollbooth/v7" - "github.com/didip/tollbooth/v7/limiter" + "github.com/didip/tollbooth/v8" + "github.com/didip/tollbooth/v8/limiter" ) lmt := tollbooth.NewLimiter(1, nil) @@ -66,10 +78,24 @@ func main() { // every token bucket in it will expire 1 hour after it was initially set. lmt = tollbooth.NewLimiter(1, &limiter.ExpirableOptions{DefaultExpirationTTL: time.Hour}) - // Configure list of places to look for IP address. - // By default it's: "RemoteAddr", "X-Forwarded-For", "X-Real-IP" - // If your application is behind a proxy, set "X-Forwarded-For" first. - lmt.SetIPLookups([]string{"RemoteAddr", "X-Forwarded-For", "X-Real-IP"}) + // New in version >= 8, you must explicitly define how to pick the IP address. + // If IP address cannot be found, rate limiter will not be activated. + lmt.SetIPLookup(limiter.IPLookup{ + // The name of lookup method. + // Possible options are: RemoteAddr, X-Forwarded-For, X-Real-IP, CF-Connecting-IP + // All other headers are considered unknown and will be ignored. + Name: "X-Real-IP", + + // The index position to pick the ip address from a comma separated list. + // The index goes from right to left. + // + // When there are multiple of the same headers, + // we will concat them together in the order of first to last seen. + // And then we pick the IP using this index position. + IndexFromRight: 0, + }) + + // In version >= 8, lmt.SetIPLookups and lmt.GetIPLookups are removed. // Limit only GET and POST requests. lmt.SetMethods([]string{"GET", "POST"}) @@ -89,8 +115,7 @@ func main() { lmt.RemoveHeaderEntries("X-Access-Token", []string{"limitless-token"}) // By the way, the setters are chainable. Example: - lmt.SetIPLookups([]string{"RemoteAddr", "X-Forwarded-For", "X-Real-IP"}). - SetMethods([]string{"GET", "POST"}). + lmt.SetMethods([]string{"GET", "POST"}). SetBasicAuthUsers([]string{"sansa"}). SetBasicAuthUsers([]string{"tyrion"}) ``` @@ -137,6 +162,12 @@ func main() { ```go lmt := tollbooth.NewLimiter(1, nil) + // New in version >= 8, you must explicitly define how to pick the IP address. + lmt.SetIPLookup(limiter.IPLookup{ + Name: "X-Forwarded-For", + IndexFromRight: 0, + }) + // Set a custom message. lmt.SetMessage("You have reached maximum request limit.") diff --git a/backend/vendor/github.com/didip/tollbooth/v7/errors/errors.go b/backend/vendor/github.com/didip/tollbooth/v8/errors/errors.go similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/errors/errors.go rename to backend/vendor/github.com/didip/tollbooth/v8/errors/errors.go diff --git a/backend/vendor/github.com/didip/tollbooth/v7/internal/time/AUTHORS b/backend/vendor/github.com/didip/tollbooth/v8/internal/time/AUTHORS similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/internal/time/AUTHORS rename to backend/vendor/github.com/didip/tollbooth/v8/internal/time/AUTHORS diff --git a/backend/vendor/github.com/didip/tollbooth/v7/internal/time/CONTRIBUTORS b/backend/vendor/github.com/didip/tollbooth/v8/internal/time/CONTRIBUTORS similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/internal/time/CONTRIBUTORS rename to backend/vendor/github.com/didip/tollbooth/v8/internal/time/CONTRIBUTORS diff --git a/backend/vendor/github.com/didip/tollbooth/v7/internal/time/LICENSE b/backend/vendor/github.com/didip/tollbooth/v8/internal/time/LICENSE similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/internal/time/LICENSE rename to backend/vendor/github.com/didip/tollbooth/v8/internal/time/LICENSE diff --git a/backend/vendor/github.com/didip/tollbooth/v7/internal/time/PATENTS b/backend/vendor/github.com/didip/tollbooth/v8/internal/time/PATENTS similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/internal/time/PATENTS rename to backend/vendor/github.com/didip/tollbooth/v8/internal/time/PATENTS diff --git a/backend/vendor/github.com/didip/tollbooth/v7/internal/time/rate/rate.go b/backend/vendor/github.com/didip/tollbooth/v8/internal/time/rate/rate.go similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/internal/time/rate/rate.go rename to backend/vendor/github.com/didip/tollbooth/v8/internal/time/rate/rate.go diff --git a/backend/vendor/github.com/didip/tollbooth/v7/libstring/libstring.go b/backend/vendor/github.com/didip/tollbooth/v8/libstring/libstring.go similarity index 65% rename from backend/vendor/github.com/didip/tollbooth/v7/libstring/libstring.go rename to backend/vendor/github.com/didip/tollbooth/v8/libstring/libstring.go index 730b6549..5ca875f1 100644 --- a/backend/vendor/github.com/didip/tollbooth/v7/libstring/libstring.go +++ b/backend/vendor/github.com/didip/tollbooth/v8/libstring/libstring.go @@ -5,6 +5,8 @@ import ( "net" "net/http" "strings" + + "github.com/didip/tollbooth/v8/limiter" ) // StringInSlice finds needle in a slice of strings. @@ -17,38 +19,35 @@ func StringInSlice(sliceString []string, needle string) bool { return false } -// RemoteIP finds IP Address given http.Request struct. -func RemoteIP(ipLookups []string, forwardedForIndexFromBehind int, r *http.Request) string { - realIP := r.Header.Get("X-Real-IP") - forwardedFor := r.Header.Get("X-Forwarded-For") - - for _, lookup := range ipLookups { - if lookup == "RemoteAddr" { - // 1. Cover the basic use cases for both ipv4 and ipv6 - ip, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - // 2. Upon error, just return the remote addr. - return r.RemoteAddr - } - return ip +// RemoteIPFromIPLookup picks an ip address explicitly from limiter.IPLookup criteria. +// This function is intended to replace RemoteIP function. +func RemoteIPFromIPLookup(ipLookup limiter.IPLookup, r *http.Request) string { + switch ipLookup.Name { + case "RemoteAddr": + // 1. Cover the basic use cases for both ipv4 and ipv6 + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // 2. Upon error, just return the remote addr. + return r.RemoteAddr } - if lookup == "X-Forwarded-For" && forwardedFor != "" { - // X-Forwarded-For is potentially a list of addresses separated with "," - parts := strings.Split(forwardedFor, ",") - for i, p := range parts { - parts[i] = strings.TrimSpace(p) - } + return ip - partIndex := len(parts) - 1 - forwardedForIndexFromBehind - if partIndex < 0 { - partIndex = 0 - } + case "X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP": + ipAddrListCommaSeparated := r.Header.Values(ipLookup.Name) - return parts[partIndex] + ipAddrCommaSeparated := strings.Join(ipAddrListCommaSeparated, ",") + + ips := strings.Split(ipAddrCommaSeparated, ",") + for i, p := range ips { + ips[i] = strings.TrimSpace(p) } - if lookup == "X-Real-IP" && realIP != "" { - return realIP + + ipIndex := len(ips) - 1 - ipLookup.IndexFromRight + if ipIndex < 0 { + ipIndex = 0 } + + return ips[ipIndex] } return "" diff --git a/backend/vendor/github.com/didip/tollbooth/v7/limiter/limiter.go b/backend/vendor/github.com/didip/tollbooth/v8/limiter/limiter.go similarity index 93% rename from backend/vendor/github.com/didip/tollbooth/v7/limiter/limiter.go rename to backend/vendor/github.com/didip/tollbooth/v8/limiter/limiter.go index 0153dc80..b5b56d6f 100644 --- a/backend/vendor/github.com/didip/tollbooth/v7/limiter/limiter.go +++ b/backend/vendor/github.com/didip/tollbooth/v8/limiter/limiter.go @@ -8,7 +8,7 @@ import ( cache "github.com/go-pkgz/expirable-cache/v3" - "github.com/didip/tollbooth/v7/internal/time/rate" + "github.com/didip/tollbooth/v8/internal/time/rate" ) // New is a constructor for Limiter. @@ -19,7 +19,6 @@ func New(generalExpirableOptions *ExpirableOptions) *Limiter { SetMessage("You have reached maximum request limit."). SetStatusCode(429). SetOnLimitReached(nil). - SetIPLookups([]string{"RemoteAddr", "X-Forwarded-For", "X-Real-IP"}). SetForwardedForIndexFromBehind(0). SetHeaders(make(map[string][]string)). SetContextValues(make(map[string][]string)). @@ -43,6 +42,18 @@ func New(generalExpirableOptions *ExpirableOptions) *Limiter { return lmt } +// IPLookup is a config struct to define how users want to pick the remote IP address. +type IPLookup struct { + // The name of lookup method. + // Possible options are: RemoteAddr, X-Forwarded-For, X-Real-IP, CF-Connecting-IP + // All other headers are considered unknown and will be ignored. + Name string + + // The index position to pick the ip address from a comma separated list. + // The index goes from right to left. + IndexFromRight int +} + // Limiter is a config struct to limit a particular request handler. type Limiter struct { // Maximum number of requests to limit per second. @@ -66,10 +77,9 @@ type Limiter struct { // An option to write back what you want upon reaching a limit. overrideDefaultResponseWriter bool - // List of places to look up IP address. - // Default is "RemoteAddr", "X-Forwarded-For", "X-Real-IP". - // You can rearrange the order as you like. - ipLookups []string + // Explicitly define how to look up IP address. + // This is intended to replace ipLookups + explicitIPLookup IPLookup forwardedForIndex int @@ -270,10 +280,12 @@ func (l *Limiter) ExecOnLimitReached(w http.ResponseWriter, r *http.Request) { } // SetOverrideDefaultResponseWriter is a thread-safe way of setting the response writer override variable. -func (l *Limiter) SetOverrideDefaultResponseWriter(override bool) { +func (l *Limiter) SetOverrideDefaultResponseWriter(override bool) *Limiter { l.Lock() l.overrideDefaultResponseWriter = override l.Unlock() + + return l } // GetOverrideDefaultResponseWriter is a thread-safe way of getting the response writer override variable. @@ -283,20 +295,22 @@ func (l *Limiter) GetOverrideDefaultResponseWriter() bool { return l.overrideDefaultResponseWriter } -// SetIPLookups is thread-safe way of setting list of places to look up IP address. -func (l *Limiter) SetIPLookups(ipLookups []string) *Limiter { +// SetIPLookup is thread-safe way of setting an explicit way to look up IP address. +// This method is intended to replace SetIPLookups (version 6 or older). +func (l *Limiter) SetIPLookup(lookup IPLookup) *Limiter { l.Lock() - l.ipLookups = ipLookups + l.explicitIPLookup = lookup l.Unlock() return l } -// GetIPLookups is thread-safe way of getting list of places to look up IP address. -func (l *Limiter) GetIPLookups() []string { +// GetIPLookup is thread-safe way of getting an explicit way to look up IP address. +// This method is intended to replace the old GetIPLookups (version 6 or older). +func (l *Limiter) GetIPLookup() IPLookup { l.RLock() defer l.RUnlock() - return l.ipLookups + return l.explicitIPLookup } // SetIgnoreURL is thread-safe way of setting whenever ignore the URL on rate limit keys diff --git a/backend/vendor/github.com/didip/tollbooth/v7/limiter/limiter_options.go b/backend/vendor/github.com/didip/tollbooth/v8/limiter/limiter_options.go similarity index 100% rename from backend/vendor/github.com/didip/tollbooth/v7/limiter/limiter_options.go rename to backend/vendor/github.com/didip/tollbooth/v8/limiter/limiter_options.go diff --git a/backend/vendor/github.com/didip/tollbooth/v7/tollbooth.go b/backend/vendor/github.com/didip/tollbooth/v8/tollbooth.go similarity index 89% rename from backend/vendor/github.com/didip/tollbooth/v7/tollbooth.go rename to backend/vendor/github.com/didip/tollbooth/v8/tollbooth.go index 0dcf82e0..27e008c4 100644 --- a/backend/vendor/github.com/didip/tollbooth/v7/tollbooth.go +++ b/backend/vendor/github.com/didip/tollbooth/v8/tollbooth.go @@ -7,9 +7,9 @@ import ( "net/http" "strings" - "github.com/didip/tollbooth/v7/errors" - "github.com/didip/tollbooth/v7/libstring" - "github.com/didip/tollbooth/v7/limiter" + "github.com/didip/tollbooth/v8/errors" + "github.com/didip/tollbooth/v8/libstring" + "github.com/didip/tollbooth/v8/limiter" ) // setResponseHeaders configures X-Rate-Limit-Limit and X-Rate-Limit-Duration @@ -37,8 +37,7 @@ func setRateLimitResponseHeaders(lmt *limiter.Limiter, w http.ResponseWriter, to func NewLimiter(max float64, tbOptions *limiter.ExpirableOptions) *limiter.Limiter { return limiter.New(tbOptions). SetMax(max). - SetBurst(int(math.Max(1, max))). - SetIPLookups([]string{"X-Forwarded-For", "X-Real-IP", "RemoteAddr"}) + SetBurst(int(math.Max(1, max))) } // LimitByKeys keeps track number of request made by keys separated by pipe. @@ -63,7 +62,7 @@ func ShouldSkipLimiter(lmt *limiter.Limiter, r *http.Request) bool { // --------------------------------- // Filter by remote ip // If we are unable to find remoteIP, skip limiter - remoteIP := libstring.RemoteIP(lmt.GetIPLookups(), lmt.GetForwardedForIndexFromBehind(), r) + remoteIP := libstring.RemoteIPFromIPLookup(lmt.GetIPLookup(), r) remoteIP = libstring.CanonicalizeIP(remoteIP) if remoteIP == "" { return true @@ -195,7 +194,7 @@ func ShouldSkipLimiter(lmt *limiter.Limiter, r *http.Request) bool { // BuildKeys generates a slice of keys to rate-limit by given limiter and request structs. func BuildKeys(lmt *limiter.Limiter, r *http.Request) [][]string { - remoteIP := libstring.RemoteIP(lmt.GetIPLookups(), lmt.GetForwardedForIndexFromBehind(), r) + remoteIP := libstring.RemoteIPFromIPLookup(lmt.GetIPLookup(), r) remoteIP = libstring.CanonicalizeIP(remoteIP) path := r.URL.Path sliceKeys := make([][]string, 0) @@ -347,3 +346,30 @@ func LimitHandler(lmt *limiter.Limiter, next http.Handler) http.Handler { func LimitFuncHandler(lmt *limiter.Limiter, nextFunc func(http.ResponseWriter, *http.Request)) http.Handler { return LimitHandler(lmt, http.HandlerFunc(nextFunc)) } + +// HTTPMiddleware wraps http.Handler with tollbooth limiter +func HTTPMiddleware(lmt *limiter.Limiter) func(http.Handler) http.Handler { + // // set IP lookup only if not set + if lmt.GetIPLookup().Name == "" { + lmt.SetIPLookup(limiter.IPLookup{Name: "RemoteAddr"}) + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + http.Error(w, "Context was canceled", http.StatusServiceUnavailable) + return + default: + if httpError := LimitByRequest(lmt, w, r); httpError != nil { + lmt.ExecOnLimitReached(w, r) + w.Header().Add("Content-Type", lmt.GetMessageContentType()) + w.WriteHeader(httpError.StatusCode) + w.Write([]byte(httpError.Message)) //nolint:gosec // not much we can do here with failed write + return + } + next.ServeHTTP(w, r) + } + }) + } +} diff --git a/backend/vendor/github.com/didip/tollbooth_chi/README.md b/backend/vendor/github.com/didip/tollbooth_chi/README.md deleted file mode 100644 index d1ba02b1..00000000 --- a/backend/vendor/github.com/didip/tollbooth_chi/README.md +++ /dev/null @@ -1,33 +0,0 @@ -## tollbooth_chi - -[Chi](https://github.com/pressly/chi) middleware for rate limiting HTTP requests. - - -## Five Minutes Tutorial - -``` -package main - -import ( - "github.com/didip/tollbooth" - "github.com/didip/tollbooth_chi" - "github.com/pressly/chi" - "net/http" - "time" -) - -func main() { - // Create a limiter struct. - limiter := tollbooth.NewLimiter(1, nil) - - r := chi.NewRouter() - - r.Use(tollbooth_chi.LimitHandler(limiter)) - - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello, world!")) - }) - - http.ListenAndServe(":12345", r) -} -``` diff --git a/backend/vendor/github.com/didip/tollbooth_chi/tollbooth_chi.go b/backend/vendor/github.com/didip/tollbooth_chi/tollbooth_chi.go deleted file mode 100644 index 7e45c9a6..00000000 --- a/backend/vendor/github.com/didip/tollbooth_chi/tollbooth_chi.go +++ /dev/null @@ -1,45 +0,0 @@ -package tollbooth_chi - -import ( - "net/http" - - "github.com/didip/tollbooth/v7" - "github.com/didip/tollbooth/v7/limiter" -) - -func LimitHandler(lmt *limiter.Limiter) func(http.Handler) http.Handler { - return func(handler http.Handler) http.Handler { - wrapper := &limiterWrapper{ - lmt: lmt, - } - - wrapper.handler = handler - return wrapper - } -} - -type limiterWrapper struct { - lmt *limiter.Limiter - handler http.Handler -} - -func (l *limiterWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - select { - case <-ctx.Done(): - http.Error(w, "Context was canceled", http.StatusServiceUnavailable) - return - - default: - httpError := tollbooth.LimitByRequest(l.lmt, w, r) - if httpError != nil { - l.lmt.ExecOnLimitReached(w, r) - w.Header().Add("Content-Type", l.lmt.GetMessageContentType()) - w.WriteHeader(httpError.StatusCode) - w.Write([]byte(httpError.Message)) - return - } - - l.handler.ServeHTTP(w, r) - } -} diff --git a/backend/vendor/github.com/go-pkgz/auth/v2/provider/service.go b/backend/vendor/github.com/go-pkgz/auth/v2/provider/service.go index f085b5f2..1053c5b9 100644 --- a/backend/vendor/github.com/go-pkgz/auth/v2/provider/service.go +++ b/backend/vendor/github.com/go-pkgz/auth/v2/provider/service.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/go-pkgz/auth/v2/avatar" "github.com/go-pkgz/auth/v2/token" ) @@ -71,15 +72,15 @@ func (p Service) Handler(w http.ResponseWriter, r *http.Request) { // setAvatar saves avatar and puts proxied URL to u.Picture func setAvatar(ava AvatarSaver, u token.User, client *http.Client) (token.User, error) { - if ava != nil { - avatarURL, e := ava.Put(u, client) - if e != nil { - return u, fmt.Errorf("failed to save avatar for: %w", e) - } - u.Picture = avatarURL - return u, nil + if ava == nil || ava == (*avatar.Proxy)(nil) { + return u, nil // empty AvatarSaver ok, just skipped } - return u, nil // empty AvatarSaver ok, just skipped + avatarURL, e := ava.Put(u, client) + if e != nil { + return u, fmt.Errorf("failed to save avatar for: %w", e) + } + u.Picture = avatarURL + return u, nil } func randToken() (string, error) { diff --git a/backend/vendor/github.com/go-pkgz/email/.golangci.yml b/backend/vendor/github.com/go-pkgz/email/.golangci.yml index ed5f8ddd..a283374e 100644 --- a/backend/vendor/github.com/go-pkgz/email/.golangci.yml +++ b/backend/vendor/github.com/go-pkgz/email/.golangci.yml @@ -1,57 +1,53 @@ +version: "2" run: timeout: 5m - output: - format: tab - skip-dirs: - - vendor - -linters-settings: - govet: - check-shadowing: true - maligned: - suggest-new: true - goconst: - min-len: 2 - min-occurrences: 2 - misspell: - locale: US - lll: - line-length: 140 - gocritic: - enabled-tags: - - performance - - style - - experimental - disabled-checks: - - wrapperFunc - - hugeParam - - rangeValCopy - - singleCaseSwitch - - ifElseChain - + concurrency: 4 linters: + default: none enable: - dupl - - exportloopref - - gas - gochecknoinits - gocritic - gocyclo - - gosimple + - gosec - govet - ineffassign - - megacheck - misspell - nakedret - prealloc - revive - - stylecheck - - typecheck + - staticcheck - unconvert - unparam - unused - fast: false - disable-all: true + settings: + goconst: + min-len: 2 + min-occurrences: 2 + gocritic: + disabled-checks: + - wrapperFunc + - hugeParam + - rangeValCopy + - singleCaseSwitch + - ifElseChain + enabled-tags: + - performance + - style + - experimental + govet: + enable-all: true + disable: + - fieldalignment + lll: + line-length: 140 + misspell: + locale: US -issues: - exclude-use-default: false + exclusions: + generated: lax + paths: + - vendor + - third_party + - builtin + - examples diff --git a/backend/vendor/github.com/go-pkgz/email/auth.go b/backend/vendor/github.com/go-pkgz/email/auth.go index b1c8843a..f62eda50 100644 --- a/backend/vendor/github.com/go-pkgz/email/auth.go +++ b/backend/vendor/github.com/go-pkgz/email/auth.go @@ -51,7 +51,7 @@ func (a *loginAuth) Start(server *smtp.ServerInfo) (proto string, toServer []byt return "LOGIN", []byte(a.user), nil } -func (a *loginAuth) Next(fromServer []byte, more bool) (toServer []byte, err error) { +func (a *loginAuth) Next(_ []byte, more bool) (toServer []byte, err error) { if more { return []byte(a.password), nil } diff --git a/backend/vendor/github.com/go-pkgz/email/email.go b/backend/vendor/github.com/go-pkgz/email/email.go index 7ea179a3..f7c7c44e 100644 --- a/backend/vendor/github.com/go-pkgz/email/email.go +++ b/backend/vendor/github.com/go-pkgz/email/email.go @@ -13,10 +13,12 @@ import ( "mime/quotedprintable" "net" "net/http" + "net/mail" "net/smtp" "net/textproto" "os" "path/filepath" + "strconv" "strings" "time" ) @@ -30,10 +32,10 @@ type Sender struct { logger Logger host string // SMTP host port int // SMTP port - contentType string // Content type, optional. Will trigger MIME and Content-Type headers + contentType string // content type, optional. Will trigger MIME and Content-Type headers tls bool // TLS auth - starttls bool // StartTLS - insecureSkipVerify bool // Insecure Skip Verify + starttls bool // startTLS + insecureSkipVerify bool // insecure Skip Verify smtpUserName string // username smtpPassword string // password authMethod authMethod // auth method @@ -44,12 +46,12 @@ type Sender struct { // Params contains all user-defined parameters to send emails type Params struct { - From string // From email field - To []string // From email field - Subject string // Email subject + From string // from email field + To []string // from email field + Subject string // email subject UnsubscribeLink string // POST, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe" - InReplyTo string // Identifier for email group (category), used for email grouping - Attachments []string // Attachments path + InReplyTo string // identifier for email group (category), used for email grouping + Attachments []string // attachments path InlineImages []string // InlineImages images path } @@ -130,12 +132,12 @@ func (em *Sender) Send(text string, params Params) error { } } - if err := client.Mail(params.From); err != nil { + if err := client.Mail(extractEmailAddress(params.From)); err != nil { return fmt.Errorf("bad from address %q: %w", params.From, err) } for _, rcpt := range params.To { - if err := client.Rcpt(rcpt); err != nil { + if err := client.Rcpt(extractEmailAddress(rcpt)); err != nil { return fmt.Errorf("bad to address %q: %w", params.To, err) } } @@ -165,13 +167,24 @@ func (em *Sender) Send(text string, params Params) error { return nil } +// extractEmailAddress extracts the email address from a string that may contain a display name. +// For example, it converts `"John Doe" ` to `john@example.com`. +// If parsing fails, it returns the original string unchanged. +func extractEmailAddress(from string) string { + addr, err := mail.ParseAddress(strings.TrimSpace(from)) + if err != nil { + return from + } + return addr.Address +} + func (em *Sender) String() string { return fmt.Sprintf("smtp://%s:%d, auth:%v, tls:%v, starttls:%v, insecureSkipVerify:%v, timeout:%v, content-type:%q, charset:%q", em.host, em.port, em.smtpUserName != "", em.tls, em.starttls, em.insecureSkipVerify, em.timeOut, em.contentType, em.contentCharset) } func (em *Sender) client() (c *smtp.Client, err error) { - srvAddress := fmt.Sprintf("%s:%d", em.host, em.port) + srvAddress := net.JoinHostPort(em.host, strconv.Itoa(em.port)) // #nosec G402 tlsConf := &tls.Config{ InsecureSkipVerify: em.insecureSkipVerify, // #nosec G402 @@ -366,4 +379,4 @@ func (em *Sender) writeFiles(mp *multipart.Writer, files []string, disposition s type nopLogger struct{} -func (nopLogger) Logf(format string, args ...interface{}) {} +func (nopLogger) Logf(_ string, _ ...interface{}) {} diff --git a/backend/vendor/github.com/go-pkgz/expirable-cache/v3/cache.go b/backend/vendor/github.com/go-pkgz/expirable-cache/v3/cache.go index 5b4642dc..4b6d9b4e 100644 --- a/backend/vendor/github.com/go-pkgz/expirable-cache/v3/cache.go +++ b/backend/vendor/github.com/go-pkgz/expirable-cache/v3/cache.go @@ -94,13 +94,12 @@ func (c *cacheImpl[K, V]) Set(key K, value V, ttl time.Duration) { // Returns false if there was no eviction: the item was already in the cache, // or the size was not exceeded. func (c *cacheImpl[K, V]) addWithTTL(key K, value V, ttl time.Duration) (evicted bool) { - c.Lock() - defer c.Unlock() - now := time.Now() if ttl == 0 { ttl = c.ttl } - + now := time.Now() + c.Lock() + defer c.Unlock() // Check for existing item if ent, ok := c.items[key]; ok { c.evictList.MoveToFront(ent) @@ -117,7 +116,10 @@ func (c *cacheImpl[K, V]) addWithTTL(key K, value V, ttl time.Duration) (evicted // Remove the oldest entry if it is expired, only in case of non-default TTL. if c.ttl != noEvictionTTL || ttl != noEvictionTTL { - c.removeOldestIfExpired() + ent := c.evictList.Back() + if ent != nil && now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { + c.removeElement(ent) + } } evict := c.maxKeys > 0 && len(c.items) > c.maxKeys @@ -197,15 +199,14 @@ func (c *cacheImpl[K, V]) Keys() []K { // Values returns a slice of the values in the cache, from oldest to newest. // Expired entries are filtered out. func (c *cacheImpl[K, V]) Values() []V { - c.Lock() - defer c.Unlock() values := make([]V, 0, len(c.items)) now := time.Now() + c.Lock() + defer c.Unlock() for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { - if now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { - continue + if !now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { + values = append(values, ent.Value.(*cacheItem[K, V]).value) } - values = append(values, ent.Value.(*cacheItem[K, V]).value) } return values } @@ -291,11 +292,14 @@ func (c *cacheImpl[K, V]) GetOldest() (key K, value V, ok bool) { // DeleteExpired clears cache of expired items func (c *cacheImpl[K, V]) DeleteExpired() { + now := time.Now() c.Lock() defer c.Unlock() - for _, key := range c.keys() { - if time.Now().After(c.items[key].Value.(*cacheItem[K, V]).expiresAt) { - c.removeElement(c.items[key]) + var nextEnt *list.Element + for ent := c.evictList.Back(); ent != nil; ent = nextEnt { + nextEnt = ent.Prev() + if now.After(ent.Value.(*cacheItem[K, V]).expiresAt) { + c.removeElement(ent) } } } @@ -344,14 +348,6 @@ func (c *cacheImpl[K, V]) removeOldest() { } } -// removeOldest removes the oldest item from the cache in case it's already expired. Has to be called with lock! -func (c *cacheImpl[K, V]) removeOldestIfExpired() { - ent := c.evictList.Back() - if ent != nil && time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) { - c.removeElement(ent) - } -} - // removeElement is used to remove a given list element from the cache. Has to be called with lock! func (c *cacheImpl[K, V]) removeElement(e *list.Element) { c.evictList.Remove(e) diff --git a/backend/vendor/github.com/go-pkgz/notify/.golangci.yml b/backend/vendor/github.com/go-pkgz/notify/.golangci.yml index f773589f..07dfb712 100644 --- a/backend/vendor/github.com/go-pkgz/notify/.golangci.yml +++ b/backend/vendor/github.com/go-pkgz/notify/.golangci.yml @@ -1,62 +1,68 @@ -run: - timeout: 5m - -linters-settings: - govet: - check-shadowing: true - goconst: - min-len: 2 - min-occurrences: 2 - misspell: - locale: US - lll: - line-length: 140 - gocritic: - enabled-tags: - - performance - - style - - experimental - disabled-checks: - - wrapperFunc - - hugeParam - - rangeValCopy - - singleCaseSwitch - - ifElseChain - +version: "2" linters: + default: none enable: - - revive - - govet - - unconvert - - staticcheck - - gosec - - unused - - gocyclo + - bodyclose + - copyloopvar - dupl - - misspell - - unparam - - typecheck - - ineffassign - - stylecheck - gochecknoinits - - exportloopref + - gocognit - gocritic + - gosec + - govet + - ineffassign + - misspell - nakedret - - gosimple + - nolintlint - prealloc - - whitespace - fast: false - disable-all: true - -issues: - exclude-rules: - - text: "at least one file in a package should have a package comment" - linters: - - stylecheck - - path: _test\.go - linters: - - gosec - - dupl - exclude-use-default: false - exclude-dirs: - - vendor + - revive + - staticcheck + - testifylint + - unconvert + - unparam + - unused + settings: + goconst: + min-len: 2 + min-occurrences: 2 + revive: + enable-all-rules: true + rules: + - name: unused-receiver + disabled: true + - name: line-length-limit + disabled: true + - name: add-constant + disabled: true + - name: cognitive-complexity + disabled: true + - name: function-length + disabled: true + - name: cyclomatic + disabled: true + - name: nested-structs + disabled: true + gocritic: + disabled-checks: + - hugeParam + enabled-tags: + - performance + - style + - experimental + govet: + enable: + - shadow + lll: + line-length: 140 + misspell: + locale: US +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ \ No newline at end of file diff --git a/backend/vendor/github.com/go-pkgz/notify/email.go b/backend/vendor/github.com/go-pkgz/notify/email.go index 233ac525..22ecf702 100644 --- a/backend/vendor/github.com/go-pkgz/notify/email.go +++ b/backend/vendor/github.com/go-pkgz/notify/email.go @@ -13,17 +13,17 @@ import ( // SMTPParams contain settings for smtp server connection type SMTPParams struct { - Host string // SMTP host - Port int // SMTP port - TLS bool // TLS auth - StartTLS bool // StartTLS auth - InsecureSkipVerify bool // skip certificate verification - ContentType string // Content type - Charset string // Character set - LoginAuth bool // LOGIN auth method instead of default PLAIN, needed for Office 365 and outlook.com - Username string // username - Password string // password - TimeOut time.Duration // TCP connection timeout + Host string // SMTP host + Port int // SMTP port + TLS bool // TLS auth + StartTLS bool // StartTLS auth + InsecureSkipVerify bool // skip certificate verification + ContentType string // Content type + Charset string // Character set + LoginAuth bool // LOGIN auth method instead of default PLAIN, needed for Office 365 and outlook.com + Username string // username + Password string // password + TimeOut time.Duration // TCP connection timeout } // Email notifications client diff --git a/backend/vendor/github.com/go-pkgz/notify/slack.go b/backend/vendor/github.com/go-pkgz/notify/slack.go index 52ee3b45..91cac624 100644 --- a/backend/vendor/github.com/go-pkgz/notify/slack.go +++ b/backend/vendor/github.com/go-pkgz/notify/slack.go @@ -93,9 +93,9 @@ func (s *Slack) findChannelIDByName(name string) (string, error) { return "", err } - for _, channel := range channels { - if channel.Name == name { - return channel.ID, nil + for i := range channels { + if channels[i].Name == name { + return channels[i].ID, nil } } diff --git a/backend/vendor/github.com/go-pkgz/notify/telegram.go b/backend/vendor/github.com/go-pkgz/notify/telegram.go index f2704e6c..f6ddb7d9 100644 --- a/backend/vendor/github.com/go-pkgz/notify/telegram.go +++ b/backend/vendor/github.com/go-pkgz/notify/telegram.go @@ -170,23 +170,23 @@ func adjustHTMLTags(htmlText string) string { switch token.Data { case "h1", "h2", "h3": if token.Type == html.StartTagToken { - buff.WriteString("") + _, _ = buff.WriteString("") } if token.Type == html.EndTagToken { - buff.WriteString("") + _, _ = buff.WriteString("") } case "h4", "h5", "h6": if token.Type == html.StartTagToken { - buff.WriteString("") + _, _ = buff.WriteString("") } if token.Type == html.EndTagToken { - buff.WriteString("") + _, _ = buff.WriteString("") } default: - buff.WriteString(token.String()) + _, _ = buff.WriteString(token.String()) } default: - buff.WriteString(token.String()) + _, _ = buff.WriteString(token.String()) } } } @@ -435,7 +435,7 @@ func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) { } // Request makes a request to the Telegram API and return the result -func (t *Telegram) Request(ctx context.Context, method string, b []byte, data interface{}) error { +func (t *Telegram) Request(ctx context.Context, method string, b []byte, data any) error { return repeater.NewDefault(3, time.Millisecond*250).Do(ctx, func() error { url := fmt.Sprintf("%s%s/%s", t.apiPrefix, t.Token, method) diff --git a/backend/vendor/github.com/go-pkgz/repeater/v2/.gitignore b/backend/vendor/github.com/go-pkgz/repeater/v2/.gitignore new file mode 100644 index 00000000..f1c181ec --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/repeater/v2/.gitignore @@ -0,0 +1,12 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out diff --git a/backend/vendor/github.com/go-pkgz/repeater/v2/.golangci.yml b/backend/vendor/github.com/go-pkgz/repeater/v2/.golangci.yml new file mode 100644 index 00000000..bdac9f80 --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/repeater/v2/.golangci.yml @@ -0,0 +1,84 @@ +version: "2" +run: + concurrency: 4 +linters: + default: none + enable: + - contextcheck + - copyloopvar + - decorder + - errorlint + - exptostd + - gochecknoglobals + - gochecknoinits + - gocritic + - gosec + - govet + - ineffassign + - nakedret + - nilerr + - prealloc + - predeclared + - revive + - staticcheck + - testifylint + - thelper + - unconvert + - unparam + - unused + - nestif + - wrapcheck + settings: + goconst: + min-len: 2 + min-occurrences: 2 + gocritic: + disabled-checks: + - wrapperFunc + enabled-tags: + - performance + - style + - experimental + gocyclo: + min-complexity: 15 + govet: + enable-all: true + disable: + - fieldalignment + lll: + line-length: 140 + misspell: + locale: US + exclusions: + generated: lax + rules: + - linters: + - gosec + text: 'G114: Use of net/http serve function that has no support for setting timeouts' + - linters: + - revive + - unparam + path: _test\.go$ + text: unused-parameter + - linters: + - prealloc + path: _test\.go$ + text: Consider pre-allocating + - linters: + - gosec + - intrange + path: _test\.go$ + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/backend/vendor/github.com/go-pkgz/repeater/v2/README.md b/backend/vendor/github.com/go-pkgz/repeater/v2/README.md new file mode 100644 index 00000000..5f768eda --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/repeater/v2/README.md @@ -0,0 +1,259 @@ +# Repeater + +[![Build Status](https://github.com/go-pkgz/repeater/workflows/build/badge.svg)](https://github.com/go-pkgz/repeater/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/repeater)](https://goreportcard.com/report/github.com/go-pkgz/repeater) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/repeater/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/repeater?branch=master) + +Package repeater implements a functional mechanism to repeat operations with different retry strategies. + +## Install and update + +`go get -u github.com/go-pkgz/repeater` + +## Usage + +### Basic Example with Exponential Backoff + +```go +// create repeater with exponential backoff +r := repeater.NewBackoff(5, time.Second) // 5 attempts starting with 1s delay + +err := r.Do(ctx, func() error { +// do something that may fail +return nil +}) +``` + +### Fixed Delay with Critical Error + +```go +// create repeater with fixed delay +r := repeater.NewFixed(3, 100*time.Millisecond) + +criticalErr := errors.New("critical error") + +err := r.Do(ctx, func() error { +// do something that may fail +return fmt.Errorf("temp error") +}, criticalErr) // will stop immediately if criticalErr returned +``` + +### Custom Backoff Strategy + +```go +r := repeater.NewBackoff(5, time.Second, +repeater.WithMaxDelay(10*time.Second), +repeater.WithBackoffType(repeater.BackoffLinear), +repeater.WithJitter(0.1), +) + +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +err := r.Do(ctx, func() error { +// do something that may fail +return nil +}) +``` + +### Stop on Any Error + +```go +r := repeater.NewFixed(3, time.Millisecond) + +err := r.Do(ctx, func() error { +return errors.New("some error") +}, repeater.ErrAny) // will stop on any error +``` + +## Strategies + +The package provides several retry strategies: + +1. **Fixed Delay** - each retry happens after a fixed time interval +2. **Backoff** - delay between retries increases according to the chosen algorithm: + - Constant - same delay between attempts + - Linear - delay increases linearly + - Exponential - delay doubles with each attempt + +Backoff strategy can be customized with: +- Maximum delay cap +- Jitter to prevent thundering herd +- Different backoff types (constant/linear/exponential) + +### Custom Strategies + +You can implement your own retry strategy by implementing the Strategy interface: + +```go +type Strategy interface { + // NextDelay returns delay for the next attempt + // attempt starts from 1 + NextDelay(attempt int) time.Duration +} +``` + +Example of a custom strategy that increases delay by a custom factor: + +```go +// CustomStrategy implements Strategy with custom factor-based delays +type CustomStrategy struct { + Initial time.Duration + Factor float64 +} + +func (s CustomStrategy) NextDelay(attempt int) time.Duration { + if attempt <= 0 { + return 0 + } + delay := time.Duration(float64(s.Initial) * math.Pow(s.Factor, float64(attempt-1))) + return delay +} + +// Usage +strategy := &CustomStrategy{Initial: time.Second, Factor: 1.5} +r := repeater.NewWithStrategy(5, strategy) +err := r.Do(ctx, func() error { + // attempts will be delayed by: 1s, 1.5s, 2.25s, 3.37s, 5.06s + return nil +}) +``` + +## Options + +For backoff strategy, several options are available: + +```go +WithMaxDelay(time.Duration) // set maximum delay between retries +WithBackoffType(BackoffType) // set backoff type (constant/linear/exponential) +WithJitter(float64) // add randomness to delays (0-1.0) +``` + +## Error Handling + +- Stops on context cancellation +- Can stop on specific errors (pass them as additional parameters to Do) +- Special `ErrAny` to stop on any error +- Returns last error if all attempts fail +- Custom error classification via `SetErrorClassifier` + +### Error Classification + +You can provide a custom error classifier function to dynamically determine if an error should trigger a retry or stop immediately. This is particularly useful for API clients where different error types require different handling: + +```go +// Define what errors are retryable +isRetryable := func(err error) bool { + if err == nil { + return false + } + + errStr := strings.ToLower(err.Error()) + + // Retryable patterns + if strings.Contains(errStr, "429") || + strings.Contains(errStr, "rate limit") || + strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "503") { + return true + } + + // Non-retryable patterns + if strings.Contains(errStr, "401") || + strings.Contains(errStr, "authentication") || + strings.Contains(errStr, "token limit") { + return false + } + + return true // default to retry +} + +// Use with any repeater strategy +r := repeater.NewBackoff(5, time.Second) +r.SetErrorClassifier(isRetryable) + +err := r.Do(ctx, func() error { + // API call that might fail + return apiClient.Call() +}) +``` + +When an error classifier is set: +- After each error, the classifier function is called +- If it returns `false`, the operation stops immediately +- If it returns `true`, the retry logic continues +- The classifier takes precedence over the critical errors list + +This feature works with all repeater strategies (NewFixed, NewBackoff, NewWithStrategy). + +## Execution Statistics + +The repeater tracks execution statistics that can be accessed after calling `Do()`: + +```go +r := repeater.NewFixed(5, 100*time.Millisecond) + +err := r.Do(ctx, func() error { + // operation that might fail + return someOperation() +}) + +// Get execution statistics +stats := r.Stats() + +fmt.Printf("Attempts: %d\n", stats.Attempts) +fmt.Printf("Success: %v\n", stats.Success) +fmt.Printf("Total Duration: %v\n", stats.TotalDuration) +fmt.Printf("Work Duration: %v\n", stats.WorkDuration) +fmt.Printf("Delay Duration: %v\n", stats.DelayDuration) +if stats.LastError != nil { + fmt.Printf("Last Error: %v\n", stats.LastError) +} +``` + +### Available Statistics + +The `Stats` struct provides the following information: + +- `Attempts` - Number of attempts made (including successful ones) +- `Success` - Whether the operation eventually succeeded +- `TotalDuration` - Total elapsed time from start to finish +- `WorkDuration` - Time spent executing the function (excluding delays) +- `DelayDuration` - Time spent in delays between attempts +- `LastError` - Last error encountered (nil if succeeded) +- `StartedAt` - When the repeater started +- `FinishedAt` - When the repeater finished + +### Usage Example + +```go +r := repeater.NewBackoff(3, time.Second) + +start := time.Now() +err := r.Do(ctx, func() error { + // Simulate work that takes time + time.Sleep(200 * time.Millisecond) + + // Randomly fail + if rand.Float32() < 0.7 { + return errors.New("temporary error") + } + return nil +}) + +stats := r.Stats() + +// Log detailed statistics +log.Printf("Operation completed in %v with %d attempts", + stats.TotalDuration, stats.Attempts) +log.Printf("Time spent working: %v", stats.WorkDuration) +log.Printf("Time spent waiting: %v", stats.DelayDuration) + +if err != nil { + log.Printf("Failed after %d attempts: %v", stats.Attempts, err) +} else { + log.Printf("Succeeded after %d attempts", stats.Attempts) +} +``` + +### Thread Safety + +Note that the `Repeater` is not thread-safe. Each `Repeater` instance should not be used concurrently for different functions. Create separate `Repeater` instances for concurrent operations. diff --git a/backend/vendor/github.com/go-pkgz/repeater/v2/repeater.go b/backend/vendor/github.com/go-pkgz/repeater/v2/repeater.go new file mode 100644 index 00000000..1ff35dea --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/repeater/v2/repeater.go @@ -0,0 +1,167 @@ +// Package repeater implements retry functionality with different strategies. +// It provides fixed delays and various backoff strategies (constant, linear, exponential) with jitter support. +// The package allows custom retry strategies and error-specific handling. Context-aware implementation +// supports cancellation and timeouts. +package repeater + +import ( + "context" + "errors" + "time" +) + +// ErrAny is a special sentinel error that, when passed as a critical error to Do, +// makes it fail on any error from the function +var ErrAny = errors.New("any error") + +// ErrorClassifier determines if an error should be retried. +// Returns true if the error should trigger a retry, false to stop immediately. +type ErrorClassifier func(error) bool + +// Stats holds execution statistics for a repeater run +type Stats struct { + LastError error // last error encountered (nil if succeeded) + StartedAt time.Time // when the repeater started + FinishedAt time.Time // when the repeater finished + TotalDuration time.Duration // total elapsed time from start to finish + WorkDuration time.Duration // time spent executing the function (excluding delays) + DelayDuration time.Duration // time spent in delays between attempts + Attempts int // number of attempts made (including the successful one) + Success bool // whether the operation eventually succeeded +} + +// Repeater holds configuration for retry operations. +// Note: Repeater is not thread-safe. Each Repeater instance should not be used +// concurrently for different functions. Create separate Repeater instances for +// concurrent operations. +type Repeater struct { + strategy Strategy + stats Stats + attempts int + classifier ErrorClassifier +} + +// NewWithStrategy creates a repeater with a custom retry strategy +func NewWithStrategy(attempts int, strategy Strategy) *Repeater { + if attempts <= 0 { + attempts = 1 + } + if strategy == nil { + strategy = NewFixedDelay(time.Second) + } + return &Repeater{ + attempts: attempts, + strategy: strategy, + } +} + +// NewBackoff creates a repeater with backoff strategy +// Default settings (can be overridden with options): +// - 30s max delay +// - exponential backoff +// - 10% jitter +func NewBackoff(attempts int, initial time.Duration, opts ...backoffOption) *Repeater { + return NewWithStrategy(attempts, newBackoff(initial, opts...)) +} + +// NewFixed creates a repeater with fixed delay strategy +func NewFixed(attempts int, delay time.Duration) *Repeater { + return NewWithStrategy(attempts, NewFixedDelay(delay)) +} + +// Do repeats fun until it succeeds or max attempts reached +// terminates immediately on context cancellation or if err matches any in termErrs. +// if errs contains ErrAny, terminates on any error. +func (r *Repeater) Do(ctx context.Context, fun func() error, termErrs ...error) error { + var lastErr error + + // reset and initialize stats + r.stats = Stats{ + StartedAt: time.Now(), + } + + // finalizeStats updates the stats before returning + finalizeStats := func(attempts int, err error) { + r.stats.Attempts = attempts + r.stats.LastError = err + r.stats.FinishedAt = time.Now() + r.stats.TotalDuration = r.stats.FinishedAt.Sub(r.stats.StartedAt) + } + + inErrors := func(err error) bool { + for _, e := range termErrs { + if errors.Is(e, ErrAny) { + return true + } + if errors.Is(err, e) { + return true + } + } + return false + } + + for attempt := 0; attempt < r.attempts; attempt++ { + // check context before each attempt + if err := ctx.Err(); err != nil { + finalizeStats(attempt, err) + return err //nolint:wrapcheck // context errors are standard and don't need wrapping + } + + workStart := time.Now() + var err error + if err = fun(); err == nil { + r.stats.WorkDuration += time.Since(workStart) + r.stats.Success = true + finalizeStats(attempt+1, nil) + return nil + } + + r.stats.WorkDuration += time.Since(workStart) + + lastErr = err + + // if classifier is set, use it to determine if we should retry + if r.classifier != nil { + if !r.classifier(err) { + finalizeStats(attempt+1, err) + return err + } + } else if inErrors(err) { + // fall back to critical errors list if no classifier + finalizeStats(attempt+1, err) + return err + } + + // don't sleep after the last attempt + if attempt < r.attempts-1 { + delay := r.strategy.NextDelay(attempt + 1) + if delay > 0 { + delayStart := time.Now() + select { + case <-ctx.Done(): + r.stats.DelayDuration += time.Since(delayStart) + finalizeStats(attempt+1, ctx.Err()) + return ctx.Err() //nolint:wrapcheck // context errors are standard and don't need wrapping + case <-time.After(delay): + r.stats.DelayDuration += time.Since(delayStart) + } + } + } + } + + finalizeStats(r.attempts, lastErr) + return lastErr +} + +// SetErrorClassifier sets a function to determine if errors are retryable. +// This can be used with any repeater strategy (NewFixed, NewBackoff, NewWithStrategy). +// When set, the classifier takes precedence over the critical errors list. +// Returns true to retry, false to stop immediately. +func (r *Repeater) SetErrorClassifier(classifier ErrorClassifier) { + r.classifier = classifier +} + +// Stats returns the execution statistics from the last Do() call +func (r *Repeater) Stats() Stats { + return r.stats +} diff --git a/backend/vendor/github.com/go-pkgz/repeater/v2/strategy.go b/backend/vendor/github.com/go-pkgz/repeater/v2/strategy.go new file mode 100644 index 00000000..c049100d --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/repeater/v2/strategy.go @@ -0,0 +1,113 @@ +package repeater + +import ( + "math/rand" + "time" +) + +// Strategy defines how to calculate delays between retries +type Strategy interface { + // NextDelay returns delay for the next attempt, attempt starts from 1 + NextDelay(attempt int) time.Duration +} + +// FixedDelay implements fixed time delay between attempts +type FixedDelay struct { + Delay time.Duration +} + +// NewFixedDelay creates a new FixedDelay strategy +func NewFixedDelay(delay time.Duration) FixedDelay { + return FixedDelay{Delay: delay} +} + +// NextDelay returns fixed delay +func (s FixedDelay) NextDelay(_ int) time.Duration { + return s.Delay +} + +// BackoffType represents the backoff strategy type +type BackoffType int + +const ( + // BackoffConstant keeps delays the same between attempts + BackoffConstant BackoffType = iota + // BackoffLinear increases delays linearly between attempts + BackoffLinear + // BackoffExponential increases delays exponentially between attempts + BackoffExponential +) + +// backoff implements various backoff strategies with optional jitter +type backoff struct { + initial time.Duration + maxDelay time.Duration + btype BackoffType + jitter float64 +} + +type backoffOption func(*backoff) + +// WithMaxDelay sets maximum delay for the backoff strategy +func WithMaxDelay(d time.Duration) backoffOption { //nolint:revive // unexported type is used in the same package + return func(b *backoff) { + b.maxDelay = d + } +} + +// WithBackoffType sets backoff type for the strategy +func WithBackoffType(t BackoffType) backoffOption { //nolint:revive // unexported type is used in the same package + return func(b *backoff) { + b.btype = t + } +} + +// WithJitter sets jitter factor for the backoff strategy +func WithJitter(factor float64) backoffOption { //nolint:revive // unexported type is used in the same package + return func(b *backoff) { + b.jitter = factor + } +} + +func newBackoff(initial time.Duration, opts ...backoffOption) *backoff { + b := &backoff{ + initial: initial, + maxDelay: 30 * time.Second, + btype: BackoffExponential, + jitter: 0.1, + } + + for _, opt := range opts { + opt(b) + } + + return b +} + +// NextDelay returns delay for the next attempt +func (s backoff) NextDelay(attempt int) time.Duration { + if attempt <= 0 { + return 0 + } + + var delay time.Duration + switch s.btype { + case BackoffConstant: + delay = s.initial + case BackoffLinear: + delay = s.initial * time.Duration(attempt) + case BackoffExponential: + delay = s.initial * time.Duration(1<<(attempt-1)) + } + + if s.maxDelay > 0 && delay > s.maxDelay { + delay = s.maxDelay + } + + if s.jitter > 0 { + jitter := float64(delay) * s.jitter + delay = time.Duration(float64(delay) + (rand.Float64()*jitter - jitter/2)) //nolint:gosec // no need for secure random here + } + + return delay +} diff --git a/backend/vendor/github.com/klauspost/compress/README.md b/backend/vendor/github.com/klauspost/compress/README.md index 244ee19c..af2ef639 100644 --- a/backend/vendor/github.com/klauspost/compress/README.md +++ b/backend/vendor/github.com/klauspost/compress/README.md @@ -27,6 +27,16 @@ Use the links above for more information on each. # changelog +* Oct 20, 2025 - [1.18.1](https://github.com/klauspost/compress/releases/tag/v1.18.1) + * zstd: Add simple zstd EncodeTo/DecodeTo functions https://github.com/klauspost/compress/pull/1079 + * zstd: Fix incorrect buffer size in dictionary encodes https://github.com/klauspost/compress/pull/1059 + * s2: check for cap, not len of buffer in EncodeBetter/Best by @vdarulis in https://github.com/klauspost/compress/pull/1080 + * zlib: Avoiding extra allocation in zlib.reader.Reset by @travelpolicy in https://github.com/klauspost/compress/pull/1086 + * gzhttp: remove redundant err check in zstdReader by @ryanfowler in https://github.com/klauspost/compress/pull/1090 + * flate: Faster load+store https://github.com/klauspost/compress/pull/1104 + * flate: Simplify matchlen https://github.com/klauspost/compress/pull/1101 + * flate: Use exact sizes for huffman tables https://github.com/klauspost/compress/pull/1103 + * Feb 19th, 2025 - [1.18.0](https://github.com/klauspost/compress/releases/tag/v1.18.0) * Add unsafe little endian loaders https://github.com/klauspost/compress/pull/1036 * fix: check `r.err != nil` but return a nil value error `err` by @alingse in https://github.com/klauspost/compress/pull/1028 @@ -36,6 +46,9 @@ Use the links above for more information on each. * flate: Fix matchlen L5+L6 https://github.com/klauspost/compress/pull/1049 * flate: Cleanup & reduce casts https://github.com/klauspost/compress/pull/1050 +
+ See changes to v1.17.x + * Oct 11th, 2024 - [1.17.11](https://github.com/klauspost/compress/releases/tag/v1.17.11) * zstd: Fix extra CRC written with multiple Close calls https://github.com/klauspost/compress/pull/1017 * s2: Don't use stack for index tables https://github.com/klauspost/compress/pull/1014 @@ -102,7 +115,8 @@ https://github.com/klauspost/compress/pull/919 https://github.com/klauspost/comp * s2: Do 2 overlapping match checks https://github.com/klauspost/compress/pull/839 * flate: Add amd64 assembly matchlen https://github.com/klauspost/compress/pull/837 * gzip: Copy bufio.Reader on Reset by @thatguystone in https://github.com/klauspost/compress/pull/860 - + +
See changes to v1.16.x @@ -669,3 +683,4 @@ Here are other packages of good quality and pure Go (no cgo wrappers or autoconv # license This code is licensed under the same conditions as the original Go code. See LICENSE file. + diff --git a/backend/vendor/github.com/klauspost/compress/fse/bitwriter.go b/backend/vendor/github.com/klauspost/compress/fse/bitwriter.go index e82fa3bb..d58b3fe4 100644 --- a/backend/vendor/github.com/klauspost/compress/fse/bitwriter.go +++ b/backend/vendor/github.com/klauspost/compress/fse/bitwriter.go @@ -143,7 +143,7 @@ func (b *bitWriter) flush32() { // flushAlign will flush remaining full bytes and align to next byte boundary. func (b *bitWriter) flushAlign() { nbBytes := (b.nBits + 7) >> 3 - for i := uint8(0); i < nbBytes; i++ { + for i := range nbBytes { b.out = append(b.out, byte(b.bitContainer>>(i*8))) } b.nBits = 0 diff --git a/backend/vendor/github.com/klauspost/compress/fse/compress.go b/backend/vendor/github.com/klauspost/compress/fse/compress.go index 074018d8..8c8baa4f 100644 --- a/backend/vendor/github.com/klauspost/compress/fse/compress.go +++ b/backend/vendor/github.com/klauspost/compress/fse/compress.go @@ -396,7 +396,7 @@ func (s *Scratch) buildCTable() error { if v > largeLimit { s.zeroBits = true } - for nbOccurrences := int16(0); nbOccurrences < v; nbOccurrences++ { + for range v { tableSymbol[position] = symbol position = (position + step) & tableMask for position > highThreshold { diff --git a/backend/vendor/github.com/klauspost/compress/huff0/bitwriter.go b/backend/vendor/github.com/klauspost/compress/huff0/bitwriter.go index 0ebc9aaa..41db94cd 100644 --- a/backend/vendor/github.com/klauspost/compress/huff0/bitwriter.go +++ b/backend/vendor/github.com/klauspost/compress/huff0/bitwriter.go @@ -85,7 +85,7 @@ func (b *bitWriter) flush32() { // flushAlign will flush remaining full bytes and align to next byte boundary. func (b *bitWriter) flushAlign() { nbBytes := (b.nBits + 7) >> 3 - for i := uint8(0); i < nbBytes; i++ { + for i := range nbBytes { b.out = append(b.out, byte(b.bitContainer>>(i*8))) } b.nBits = 0 diff --git a/backend/vendor/github.com/klauspost/compress/huff0/compress.go b/backend/vendor/github.com/klauspost/compress/huff0/compress.go index 84aa3d12..a97cf1b5 100644 --- a/backend/vendor/github.com/klauspost/compress/huff0/compress.go +++ b/backend/vendor/github.com/klauspost/compress/huff0/compress.go @@ -276,7 +276,7 @@ func (s *Scratch) compress4X(src []byte) ([]byte, error) { offsetIdx := len(s.Out) s.Out = append(s.Out, sixZeros[:]...) - for i := 0; i < 4; i++ { + for i := range 4 { toDo := src if len(toDo) > segmentSize { toDo = toDo[:segmentSize] @@ -312,7 +312,7 @@ func (s *Scratch) compress4Xp(src []byte) ([]byte, error) { segmentSize := (len(src) + 3) / 4 var wg sync.WaitGroup wg.Add(4) - for i := 0; i < 4; i++ { + for i := range 4 { toDo := src if len(toDo) > segmentSize { toDo = toDo[:segmentSize] @@ -326,7 +326,7 @@ func (s *Scratch) compress4Xp(src []byte) ([]byte, error) { }(i) } wg.Wait() - for i := 0; i < 4; i++ { + for i := range 4 { o := s.tmpOut[i] if len(o) > math.MaxUint16 { // We cannot store the size in the jump table diff --git a/backend/vendor/github.com/klauspost/compress/huff0/decompress.go b/backend/vendor/github.com/klauspost/compress/huff0/decompress.go index 0f56b02d..7d0efa88 100644 --- a/backend/vendor/github.com/klauspost/compress/huff0/decompress.go +++ b/backend/vendor/github.com/klauspost/compress/huff0/decompress.go @@ -626,7 +626,7 @@ func (d *Decoder) decompress4X8bit(dst, src []byte) ([]byte, error) { var br [4]bitReaderBytes start := 6 - for i := 0; i < 3; i++ { + for i := range 3 { length := int(src[i*2]) | (int(src[i*2+1]) << 8) if start+length >= len(src) { return nil, errors.New("truncated input (or invalid offset)") @@ -798,10 +798,7 @@ func (d *Decoder) decompress4X8bit(dst, src []byte) ([]byte, error) { remainBytes := dstEvery - (decoded / 4) for i := range br { offset := dstEvery * i - endsAt := offset + remainBytes - if endsAt > len(out) { - endsAt = len(out) - } + endsAt := min(offset+remainBytes, len(out)) br := &br[i] bitsLeft := br.remaining() for bitsLeft > 0 { @@ -864,7 +861,7 @@ func (d *Decoder) decompress4X8bit(dst, src []byte) ([]byte, error) { func (d *Decoder) decompress4X8bitExactly(dst, src []byte) ([]byte, error) { var br [4]bitReaderBytes start := 6 - for i := 0; i < 3; i++ { + for i := range 3 { length := int(src[i*2]) | (int(src[i*2+1]) << 8) if start+length >= len(src) { return nil, errors.New("truncated input (or invalid offset)") @@ -1035,10 +1032,7 @@ func (d *Decoder) decompress4X8bitExactly(dst, src []byte) ([]byte, error) { remainBytes := dstEvery - (decoded / 4) for i := range br { offset := dstEvery * i - endsAt := offset + remainBytes - if endsAt > len(out) { - endsAt = len(out) - } + endsAt := min(offset+remainBytes, len(out)) br := &br[i] bitsLeft := br.remaining() for bitsLeft > 0 { diff --git a/backend/vendor/github.com/klauspost/compress/huff0/decompress_amd64.go b/backend/vendor/github.com/klauspost/compress/huff0/decompress_amd64.go index ba7e8e6b..99ddd4af 100644 --- a/backend/vendor/github.com/klauspost/compress/huff0/decompress_amd64.go +++ b/backend/vendor/github.com/klauspost/compress/huff0/decompress_amd64.go @@ -58,7 +58,7 @@ func (d *Decoder) Decompress4X(dst, src []byte) ([]byte, error) { var br [4]bitReaderShifted // Decode "jump table" start := 6 - for i := 0; i < 3; i++ { + for i := range 3 { length := int(src[i*2]) | (int(src[i*2+1]) << 8) if start+length >= len(src) { return nil, errors.New("truncated input (or invalid offset)") @@ -109,10 +109,7 @@ func (d *Decoder) Decompress4X(dst, src []byte) ([]byte, error) { remainBytes := dstEvery - (decoded / 4) for i := range br { offset := dstEvery * i - endsAt := offset + remainBytes - if endsAt > len(out) { - endsAt = len(out) - } + endsAt := min(offset+remainBytes, len(out)) br := &br[i] bitsLeft := br.remaining() for bitsLeft > 0 { diff --git a/backend/vendor/github.com/klauspost/compress/huff0/huff0.go b/backend/vendor/github.com/klauspost/compress/huff0/huff0.go index 77ecd68e..67d9e05b 100644 --- a/backend/vendor/github.com/klauspost/compress/huff0/huff0.go +++ b/backend/vendor/github.com/klauspost/compress/huff0/huff0.go @@ -201,7 +201,7 @@ func (c cTable) write(s *Scratch) error { for i := range hist[:16] { hist[i] = 0 } - for n := uint8(0); n < maxSymbolValue; n++ { + for n := range maxSymbolValue { v := bitsToWeight[c[n].nBits] & 15 huffWeight[n] = v hist[v]++ @@ -271,7 +271,7 @@ func (c cTable) estTableSize(s *Scratch) (sz int, err error) { for i := range hist[:16] { hist[i] = 0 } - for n := uint8(0); n < maxSymbolValue; n++ { + for n := range maxSymbolValue { v := bitsToWeight[c[n].nBits] & 15 huffWeight[n] = v hist[v]++ diff --git a/backend/vendor/github.com/klauspost/compress/internal/le/unsafe_disabled.go b/backend/vendor/github.com/klauspost/compress/internal/le/unsafe_disabled.go index 0cfb5c0e..4f2a0d8c 100644 --- a/backend/vendor/github.com/klauspost/compress/internal/le/unsafe_disabled.go +++ b/backend/vendor/github.com/klauspost/compress/internal/le/unsafe_disabled.go @@ -37,6 +37,6 @@ func Store32(b []byte, v uint32) { } // Store64 will store v at b. -func Store64(b []byte, v uint64) { - binary.LittleEndian.PutUint64(b, v) +func Store64[I Indexer](b []byte, i I, v uint64) { + binary.LittleEndian.PutUint64(b[i:], v) } diff --git a/backend/vendor/github.com/klauspost/compress/internal/le/unsafe_enabled.go b/backend/vendor/github.com/klauspost/compress/internal/le/unsafe_enabled.go index ada45cd9..218a38bc 100644 --- a/backend/vendor/github.com/klauspost/compress/internal/le/unsafe_enabled.go +++ b/backend/vendor/github.com/klauspost/compress/internal/le/unsafe_enabled.go @@ -38,18 +38,15 @@ func Load64[I Indexer](b []byte, i I) uint64 { // Store16 will store v at b. func Store16(b []byte, v uint16) { - //binary.LittleEndian.PutUint16(b, v) *(*uint16)(unsafe.Pointer(unsafe.SliceData(b))) = v } // Store32 will store v at b. func Store32(b []byte, v uint32) { - //binary.LittleEndian.PutUint32(b, v) *(*uint32)(unsafe.Pointer(unsafe.SliceData(b))) = v } -// Store64 will store v at b. -func Store64(b []byte, v uint64) { - //binary.LittleEndian.PutUint64(b, v) - *(*uint64)(unsafe.Pointer(unsafe.SliceData(b))) = v +// Store64 will store v at b[i:]. +func Store64[I Indexer](b []byte, i I, v uint64) { + *(*uint64)(unsafe.Add(unsafe.Pointer(unsafe.SliceData(b)), i)) = v } diff --git a/backend/vendor/github.com/klauspost/compress/internal/snapref/decode.go b/backend/vendor/github.com/klauspost/compress/internal/snapref/decode.go index 40796a49..a2c82fcd 100644 --- a/backend/vendor/github.com/klauspost/compress/internal/snapref/decode.go +++ b/backend/vendor/github.com/klauspost/compress/internal/snapref/decode.go @@ -209,7 +209,7 @@ func (r *Reader) fill() error { if !r.readFull(r.buf[:len(magicBody)], false) { return r.err } - for i := 0; i < len(magicBody); i++ { + for i := range len(magicBody) { if r.buf[i] != magicBody[i] { r.err = ErrCorrupt return r.err diff --git a/backend/vendor/github.com/klauspost/compress/internal/snapref/encode.go b/backend/vendor/github.com/klauspost/compress/internal/snapref/encode.go index 13c6040a..860a9941 100644 --- a/backend/vendor/github.com/klauspost/compress/internal/snapref/encode.go +++ b/backend/vendor/github.com/klauspost/compress/internal/snapref/encode.go @@ -20,8 +20,10 @@ import ( func Encode(dst, src []byte) []byte { if n := MaxEncodedLen(len(src)); n < 0 { panic(ErrTooLarge) - } else if len(dst) < n { + } else if cap(dst) < n { dst = make([]byte, n) + } else { + dst = dst[:n] } // The block starts with the varint-encoded length of the decompressed bytes. diff --git a/backend/vendor/github.com/klauspost/compress/zstd/bitwriter.go b/backend/vendor/github.com/klauspost/compress/zstd/bitwriter.go index 1952f175..b22b297e 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/bitwriter.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/bitwriter.go @@ -88,7 +88,7 @@ func (b *bitWriter) flush32() { // flushAlign will flush remaining full bytes and align to next byte boundary. func (b *bitWriter) flushAlign() { nbBytes := (b.nBits + 7) >> 3 - for i := uint8(0); i < nbBytes; i++ { + for i := range nbBytes { b.out = append(b.out, byte(b.bitContainer>>(i*8))) } b.nBits = 0 diff --git a/backend/vendor/github.com/klauspost/compress/zstd/blockdec.go b/backend/vendor/github.com/klauspost/compress/zstd/blockdec.go index 0dd742fd..2329e996 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/blockdec.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/blockdec.go @@ -54,11 +54,11 @@ const ( ) var ( - huffDecoderPool = sync.Pool{New: func() interface{} { + huffDecoderPool = sync.Pool{New: func() any { return &huff0.Scratch{} }} - fseDecoderPool = sync.Pool{New: func() interface{} { + fseDecoderPool = sync.Pool{New: func() any { return &fseDecoder{} }} ) @@ -553,7 +553,7 @@ func (b *blockDec) prepareSequences(in []byte, hist *history) (err error) { if compMode&3 != 0 { return errors.New("corrupt block: reserved bits not zero") } - for i := uint(0); i < 3; i++ { + for i := range uint(3) { mode := seqCompMode((compMode >> (6 - i*2)) & 3) if debugDecoder { println("Table", tableIndex(i), "is", mode) diff --git a/backend/vendor/github.com/klauspost/compress/zstd/decoder.go b/backend/vendor/github.com/klauspost/compress/zstd/decoder.go index ea2a1937..30df5513 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/decoder.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/decoder.go @@ -373,11 +373,9 @@ func (d *Decoder) DecodeAll(input, dst []byte) ([]byte, error) { if cap(dst) == 0 && !d.o.limitToCap { // Allocate len(input) * 2 by default if nothing is provided // and we didn't get frame content size. - size := len(input) * 2 - // Cap to 1 MB. - if size > 1<<20 { - size = 1 << 20 - } + size := min( + // Cap to 1 MB. + len(input)*2, 1<<20) if uint64(size) > d.o.maxDecodedSize { size = int(d.o.maxDecodedSize) } diff --git a/backend/vendor/github.com/klauspost/compress/zstd/dict.go b/backend/vendor/github.com/klauspost/compress/zstd/dict.go index b7b83164..2ffbfdf3 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/dict.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/dict.go @@ -194,17 +194,17 @@ func BuildDict(o BuildDictOptions) ([]byte, error) { hist := o.History contents := o.Contents debug := o.DebugOut != nil - println := func(args ...interface{}) { + println := func(args ...any) { if o.DebugOut != nil { fmt.Fprintln(o.DebugOut, args...) } } - printf := func(s string, args ...interface{}) { + printf := func(s string, args ...any) { if o.DebugOut != nil { fmt.Fprintf(o.DebugOut, s, args...) } } - print := func(args ...interface{}) { + print := func(args ...any) { if o.DebugOut != nil { fmt.Fprint(o.DebugOut, args...) } @@ -424,16 +424,10 @@ func BuildDict(o BuildDictOptions) ([]byte, error) { } // Literal table - avgSize := litTotal - if avgSize > huff0.BlockSizeMax/2 { - avgSize = huff0.BlockSizeMax / 2 - } + avgSize := min(litTotal, huff0.BlockSizeMax/2) huffBuff := make([]byte, 0, avgSize) // Target size - div := litTotal / avgSize - if div < 1 { - div = 1 - } + div := max(litTotal/avgSize, 1) if debug { println("Huffman weights:") } @@ -454,7 +448,7 @@ func BuildDict(o BuildDictOptions) ([]byte, error) { huffBuff = append(huffBuff, 255) } scratch := &huff0.Scratch{TableLog: 11} - for tries := 0; tries < 255; tries++ { + for tries := range 255 { scratch = &huff0.Scratch{TableLog: 11} _, _, err = huff0.Compress1X(huffBuff, scratch) if err == nil { @@ -471,7 +465,7 @@ func BuildDict(o BuildDictOptions) ([]byte, error) { // Bail out.... Just generate something huffBuff = append(huffBuff, bytes.Repeat([]byte{255}, 10000)...) - for i := 0; i < 128; i++ { + for i := range 128 { huffBuff = append(huffBuff, byte(i)) } continue diff --git a/backend/vendor/github.com/klauspost/compress/zstd/enc_base.go b/backend/vendor/github.com/klauspost/compress/zstd/enc_base.go index 7d250c67..c1192ec3 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/enc_base.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/enc_base.go @@ -8,7 +8,7 @@ import ( ) const ( - dictShardBits = 6 + dictShardBits = 7 ) type fastBase struct { @@ -41,11 +41,9 @@ func (e *fastBase) AppendCRC(dst []byte) []byte { // or a window size small enough to contain the input size, if > 0. func (e *fastBase) WindowSize(size int64) int32 { if size > 0 && size < int64(e.maxMatchOff) { - b := int32(1) << uint(bits.Len(uint(size))) - // Keep minimum window. - if b < 1024 { - b = 1024 - } + b := max( + // Keep minimum window. + int32(1)< tMin && s > nextEmit && src[offset-1] == src[s-1] && l < maxMatchLength { s-- offset-- @@ -382,10 +377,7 @@ encodeLoop: nextEmit = s // Index skipped... - end := s - if s > sLimit+4 { - end = sLimit + 4 - } + end := min(s, sLimit+4) off := index0 + e.cur for index0 < end { cv0 := load6432(src, index0) @@ -444,10 +436,7 @@ encodeLoop: nextEmit = s // Index old s + 1 -> s - 1 or sLimit - end := s - if s > sLimit-4 { - end = sLimit - 4 - } + end := min(s, sLimit-4) off := index0 + e.cur for index0 < end { diff --git a/backend/vendor/github.com/klauspost/compress/zstd/enc_better.go b/backend/vendor/github.com/klauspost/compress/zstd/enc_better.go index 84a79fde..85dcd28c 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/enc_better.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/enc_better.go @@ -190,10 +190,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for repIndex > tMin && start > startLimit && src[repIndex-1] == src[start-1] && seq.matchLen < maxMatchLength-zstdMinMatch-1 { repIndex-- start-- @@ -252,10 +249,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for repIndex > tMin && start > startLimit && src[repIndex-1] == src[start-1] && seq.matchLen < maxMatchLength-zstdMinMatch-1 { repIndex-- start-- @@ -480,10 +474,7 @@ encodeLoop: l := matched // Extend backwards - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for t > tMin && s > nextEmit && src[t-1] == src[s-1] && l < maxMatchLength { s-- t-- @@ -719,10 +710,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for repIndex > tMin && start > startLimit && src[repIndex-1] == src[start-1] && seq.matchLen < maxMatchLength-zstdMinMatch-1 { repIndex-- start-- @@ -783,10 +771,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for repIndex > tMin && start > startLimit && src[repIndex-1] == src[start-1] && seq.matchLen < maxMatchLength-zstdMinMatch-1 { repIndex-- start-- @@ -1005,10 +990,7 @@ encodeLoop: l := matched // Extend backwards - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for t > tMin && s > nextEmit && src[t-1] == src[s-1] && l < maxMatchLength { s-- t-- diff --git a/backend/vendor/github.com/klauspost/compress/zstd/enc_dfast.go b/backend/vendor/github.com/klauspost/compress/zstd/enc_dfast.go index d36be7bd..cf8cad00 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/enc_dfast.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/enc_dfast.go @@ -13,7 +13,7 @@ const ( dFastLongLen = 8 // Bytes used for table hash dLongTableShardCnt = 1 << (dFastLongTableBits - dictShardBits) // Number of shards in the table - dLongTableShardSize = dFastLongTableSize / tableShardCnt // Size of an individual shard + dLongTableShardSize = dFastLongTableSize / dLongTableShardCnt // Size of an individual shard dFastShortTableBits = tableBits // Bits used in the short match table dFastShortTableSize = 1 << dFastShortTableBits // Size of the table @@ -149,10 +149,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for repIndex > tMin && start > startLimit && src[repIndex-1] == src[start-1] && seq.matchLen < maxMatchLength-zstdMinMatch-1 { repIndex-- start-- @@ -266,10 +263,7 @@ encodeLoop: l := e.matchlen(s+4, t+4, src) + 4 // Extend backwards - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for t > tMin && s > nextEmit && src[t-1] == src[s-1] && l < maxMatchLength { s-- t-- @@ -462,10 +456,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for repIndex > tMin && start > startLimit && src[repIndex-1] == src[start-1] { repIndex-- start-- @@ -576,10 +567,7 @@ encodeLoop: l := int32(matchLen(src[s+4:], src[t+4:])) + 4 // Extend backwards - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for t > tMin && s > nextEmit && src[t-1] == src[s-1] { s-- t-- @@ -809,10 +797,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for repIndex > tMin && start > startLimit && src[repIndex-1] == src[start-1] && seq.matchLen < maxMatchLength-zstdMinMatch-1 { repIndex-- start-- @@ -927,10 +912,7 @@ encodeLoop: l := e.matchlen(s+4, t+4, src) + 4 // Extend backwards - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for t > tMin && s > nextEmit && src[t-1] == src[s-1] && l < maxMatchLength { s-- t-- diff --git a/backend/vendor/github.com/klauspost/compress/zstd/enc_fast.go b/backend/vendor/github.com/klauspost/compress/zstd/enc_fast.go index f45a3da7..9180a3a5 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/enc_fast.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/enc_fast.go @@ -143,10 +143,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - sMin := s - e.maxMatchOff - if sMin < 0 { - sMin = 0 - } + sMin := max(s-e.maxMatchOff, 0) for repIndex > sMin && start > startLimit && src[repIndex-1] == src[start-1] && seq.matchLen < maxMatchLength-zstdMinMatch { repIndex-- start-- @@ -223,10 +220,7 @@ encodeLoop: l := e.matchlen(s+4, t+4, src) + 4 // Extend backwards - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for t > tMin && s > nextEmit && src[t-1] == src[s-1] && l < maxMatchLength { s-- t-- @@ -387,10 +381,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - sMin := s - e.maxMatchOff - if sMin < 0 { - sMin = 0 - } + sMin := max(s-e.maxMatchOff, 0) for repIndex > sMin && start > startLimit && src[repIndex-1] == src[start-1] { repIndex-- start-- @@ -469,10 +460,7 @@ encodeLoop: l := e.matchlen(s+4, t+4, src) + 4 // Extend backwards - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for t > tMin && s > nextEmit && src[t-1] == src[s-1] { s-- t-- @@ -655,10 +643,7 @@ encodeLoop: // and have to do special offset treatment. startLimit := nextEmit + 1 - sMin := s - e.maxMatchOff - if sMin < 0 { - sMin = 0 - } + sMin := max(s-e.maxMatchOff, 0) for repIndex > sMin && start > startLimit && src[repIndex-1] == src[start-1] && seq.matchLen < maxMatchLength-zstdMinMatch { repIndex-- start-- @@ -735,10 +720,7 @@ encodeLoop: l := e.matchlen(s+4, t+4, src) + 4 // Extend backwards - tMin := s - e.maxMatchOff - if tMin < 0 { - tMin = 0 - } + tMin := max(s-e.maxMatchOff, 0) for t > tMin && s > nextEmit && src[t-1] == src[s-1] && l < maxMatchLength { s-- t-- diff --git a/backend/vendor/github.com/klauspost/compress/zstd/framedec.go b/backend/vendor/github.com/klauspost/compress/zstd/framedec.go index e47af66e..d88f067e 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/framedec.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/framedec.go @@ -238,10 +238,7 @@ func (d *frameDec) reset(br byteBuffer) error { if d.WindowSize == 0 && d.SingleSegment { // We may not need window in this case. - d.WindowSize = d.FrameContentSize - if d.WindowSize < MinWindowSize { - d.WindowSize = MinWindowSize - } + d.WindowSize = max(d.FrameContentSize, MinWindowSize) if d.WindowSize > d.o.maxDecodedSize { if debugDecoder { printf("window size %d > max %d\n", d.WindowSize, d.o.maxWindowSize) diff --git a/backend/vendor/github.com/klauspost/compress/zstd/fse_encoder.go b/backend/vendor/github.com/klauspost/compress/zstd/fse_encoder.go index ab26326a..3a0f4e7f 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/fse_encoder.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/fse_encoder.go @@ -149,7 +149,7 @@ func (s *fseEncoder) buildCTable() error { if v > largeLimit { s.zeroBits = true } - for nbOccurrences := int16(0); nbOccurrences < v; nbOccurrences++ { + for range v { tableSymbol[position] = symbol position = (position + step) & tableMask for position > highThreshold { diff --git a/backend/vendor/github.com/klauspost/compress/zstd/seqdec.go b/backend/vendor/github.com/klauspost/compress/zstd/seqdec.go index 9a7de82f..0bfb0e43 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/seqdec.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/seqdec.go @@ -231,10 +231,7 @@ func (s *sequenceDecs) decodeSync(hist []byte) error { llTable, mlTable, ofTable := s.litLengths.fse.dt[:maxTablesize], s.matchLengths.fse.dt[:maxTablesize], s.offsets.fse.dt[:maxTablesize] llState, mlState, ofState := s.litLengths.state.state, s.matchLengths.state.state, s.offsets.state.state out := s.out - maxBlockSize := maxCompressedBlockSize - if s.windowSize < maxBlockSize { - maxBlockSize = s.windowSize - } + maxBlockSize := min(s.windowSize, maxCompressedBlockSize) if debugDecoder { println("decodeSync: decoding", seqs, "sequences", br.remain(), "bits remain on stream") diff --git a/backend/vendor/github.com/klauspost/compress/zstd/seqdec_amd64.go b/backend/vendor/github.com/klauspost/compress/zstd/seqdec_amd64.go index c59f17e0..1f8c3cec 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/seqdec_amd64.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/seqdec_amd64.go @@ -79,10 +79,7 @@ func (s *sequenceDecs) decodeSyncSimple(hist []byte) (bool, error) { br := s.br - maxBlockSize := maxCompressedBlockSize - if s.windowSize < maxBlockSize { - maxBlockSize = s.windowSize - } + maxBlockSize := min(s.windowSize, maxCompressedBlockSize) ctx := decodeSyncAsmContext{ llTable: s.litLengths.fse.dt[:maxTablesize], @@ -237,10 +234,7 @@ func sequenceDecs_decode_56_bmi2(s *sequenceDecs, br *bitReader, ctx *decodeAsmC func (s *sequenceDecs) decode(seqs []seqVals) error { br := s.br - maxBlockSize := maxCompressedBlockSize - if s.windowSize < maxBlockSize { - maxBlockSize = s.windowSize - } + maxBlockSize := min(s.windowSize, maxCompressedBlockSize) ctx := decodeAsmContext{ llTable: s.litLengths.fse.dt[:maxTablesize], diff --git a/backend/vendor/github.com/klauspost/compress/zstd/simple_go124.go b/backend/vendor/github.com/klauspost/compress/zstd/simple_go124.go new file mode 100644 index 00000000..2efc0497 --- /dev/null +++ b/backend/vendor/github.com/klauspost/compress/zstd/simple_go124.go @@ -0,0 +1,56 @@ +// Copyright 2025+ Klaus Post. All rights reserved. +// License information can be found in the LICENSE file. + +//go:build go1.24 + +package zstd + +import ( + "errors" + "runtime" + "sync" + "weak" +) + +var weakMu sync.Mutex +var simpleEnc weak.Pointer[Encoder] +var simpleDec weak.Pointer[Decoder] + +// EncodeTo appends the encoded data from src to dst. +func EncodeTo(dst []byte, src []byte) []byte { + weakMu.Lock() + enc := simpleEnc.Value() + if enc == nil { + var err error + enc, err = NewWriter(nil, WithEncoderConcurrency(runtime.NumCPU()), WithWindowSize(1<<20), WithLowerEncoderMem(true), WithZeroFrames(true)) + if err != nil { + panic("failed to create simple encoder: " + err.Error()) + } + simpleEnc = weak.Make(enc) + } + weakMu.Unlock() + + return enc.EncodeAll(src, dst) +} + +// DecodeTo appends the decoded data from src to dst. +// The maximum decoded size is 1GiB, +// not including what may already be in dst. +func DecodeTo(dst []byte, src []byte) ([]byte, error) { + weakMu.Lock() + dec := simpleDec.Value() + if dec == nil { + var err error + dec, err = NewReader(nil, WithDecoderConcurrency(runtime.NumCPU()), WithDecoderLowmem(true), WithDecoderMaxMemory(1<<30)) + if err != nil { + weakMu.Unlock() + return nil, errors.New("failed to create simple decoder: " + err.Error()) + } + runtime.SetFinalizer(dec, func(d *Decoder) { + d.Close() + }) + simpleDec = weak.Make(dec) + } + weakMu.Unlock() + return dec.DecodeAll(src, dst) +} diff --git a/backend/vendor/github.com/klauspost/compress/zstd/snappy.go b/backend/vendor/github.com/klauspost/compress/zstd/snappy.go index a17381b8..336c2889 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/snappy.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/snappy.go @@ -257,7 +257,7 @@ func (r *SnappyConverter) Convert(in io.Reader, w io.Writer) (int64, error) { if !r.readFull(r.buf[:len(snappyMagicBody)], false) { return written, r.err } - for i := 0; i < len(snappyMagicBody); i++ { + for i := range len(snappyMagicBody) { if r.buf[i] != snappyMagicBody[i] { println("r.buf[i] != snappyMagicBody[i]", r.buf[i], snappyMagicBody[i], i) r.err = ErrSnappyCorrupt diff --git a/backend/vendor/github.com/klauspost/compress/zstd/zip.go b/backend/vendor/github.com/klauspost/compress/zstd/zip.go index 29c15c8c..3198d718 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/zip.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/zip.go @@ -19,7 +19,7 @@ const ZipMethodWinZip = 93 const ZipMethodPKWare = 20 // zipReaderPool is the default reader pool. -var zipReaderPool = sync.Pool{New: func() interface{} { +var zipReaderPool = sync.Pool{New: func() any { z, err := NewReader(nil, WithDecoderLowmem(true), WithDecoderMaxWindow(128<<20), WithDecoderConcurrency(1)) if err != nil { panic(err) diff --git a/backend/vendor/github.com/klauspost/compress/zstd/zstd.go b/backend/vendor/github.com/klauspost/compress/zstd/zstd.go index 6252b46a..1a869710 100644 --- a/backend/vendor/github.com/klauspost/compress/zstd/zstd.go +++ b/backend/vendor/github.com/klauspost/compress/zstd/zstd.go @@ -98,13 +98,13 @@ var ( ErrDecoderNilInput = errors.New("nil input provided as reader") ) -func println(a ...interface{}) { +func println(a ...any) { if debug || debugDecoder || debugEncoder { log.Println(a...) } } -func printf(format string, a ...interface{}) { +func printf(format string, a ...any) { if debug || debugDecoder || debugEncoder { log.Printf(format, a...) } diff --git a/backend/vendor/github.com/redis/go-redis/v9/.gitignore b/backend/vendor/github.com/redis/go-redis/v9/.gitignore index 6f868895..00710d50 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/.gitignore +++ b/backend/vendor/github.com/redis/go-redis/v9/.gitignore @@ -3,4 +3,13 @@ testdata/* .idea/ .DS_Store *.tar.gz -*.dic \ No newline at end of file +*.dic +redis8tests.sh +coverage.txt +**/coverage.txt +.vscode +tmp/* +*.test + +# maintenanceNotifications upgrade documentation (temporary) +maintenanceNotifications/docs/ diff --git a/backend/vendor/github.com/redis/go-redis/v9/.golangci.yml b/backend/vendor/github.com/redis/go-redis/v9/.golangci.yml index 285aca6b..872454ff 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/.golangci.yml +++ b/backend/vendor/github.com/redis/go-redis/v9/.golangci.yml @@ -1,3 +1,34 @@ +version: "2" run: timeout: 5m tests: false +linters: + settings: + staticcheck: + checks: + - all + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Omit embedded fields from selector expression. + # https://staticcheck.dev/docs/checks/#QF1008 + - -QF1008 + - -ST1003 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/backend/vendor/github.com/redis/go-redis/v9/CHANGELOG.md b/backend/vendor/github.com/redis/go-redis/v9/CHANGELOG.md deleted file mode 100644 index e1652b17..00000000 --- a/backend/vendor/github.com/redis/go-redis/v9/CHANGELOG.md +++ /dev/null @@ -1,133 +0,0 @@ -## Unreleased - -### Changed - -* `go-redis` won't skip span creation if the parent spans is not recording. ([#2980](https://github.com/redis/go-redis/issues/2980)) - Users can use the OpenTelemetry sampler to control the sampling behavior. - For instance, you can use the `ParentBased(NeverSample())` sampler from `go.opentelemetry.io/otel/sdk/trace` to keep - a similar behavior (drop orphan spans) of `go-redis` as before. - -## [9.0.5](https://github.com/redis/go-redis/compare/v9.0.4...v9.0.5) (2023-05-29) - - -### Features - -* Add ACL LOG ([#2536](https://github.com/redis/go-redis/issues/2536)) ([31ba855](https://github.com/redis/go-redis/commit/31ba855ddebc38fbcc69a75d9d4fb769417cf602)) -* add field protocol to setupClusterQueryParams ([#2600](https://github.com/redis/go-redis/issues/2600)) ([840c25c](https://github.com/redis/go-redis/commit/840c25cb6f320501886a82a5e75f47b491e46fbe)) -* add protocol option ([#2598](https://github.com/redis/go-redis/issues/2598)) ([3917988](https://github.com/redis/go-redis/commit/391798880cfb915c4660f6c3ba63e0c1a459e2af)) - - - -## [9.0.4](https://github.com/redis/go-redis/compare/v9.0.3...v9.0.4) (2023-05-01) - - -### Bug Fixes - -* reader float parser ([#2513](https://github.com/redis/go-redis/issues/2513)) ([46f2450](https://github.com/redis/go-redis/commit/46f245075e6e3a8bd8471f9ca67ea95fd675e241)) - - -### Features - -* add client info command ([#2483](https://github.com/redis/go-redis/issues/2483)) ([b8c7317](https://github.com/redis/go-redis/commit/b8c7317cc6af444603731f7017c602347c0ba61e)) -* no longer verify HELLO error messages ([#2515](https://github.com/redis/go-redis/issues/2515)) ([7b4f217](https://github.com/redis/go-redis/commit/7b4f2179cb5dba3d3c6b0c6f10db52b837c912c8)) -* read the structure to increase the judgment of the omitempty op… ([#2529](https://github.com/redis/go-redis/issues/2529)) ([37c057b](https://github.com/redis/go-redis/commit/37c057b8e597c5e8a0e372337f6a8ad27f6030af)) - - - -## [9.0.3](https://github.com/redis/go-redis/compare/v9.0.2...v9.0.3) (2023-04-02) - -### New Features - -- feat(scan): scan time.Time sets the default decoding (#2413) -- Add support for CLUSTER LINKS command (#2504) -- Add support for acl dryrun command (#2502) -- Add support for COMMAND GETKEYS & COMMAND GETKEYSANDFLAGS (#2500) -- Add support for LCS Command (#2480) -- Add support for BZMPOP (#2456) -- Adding support for ZMPOP command (#2408) -- Add support for LMPOP (#2440) -- feat: remove pool unused fields (#2438) -- Expiretime and PExpireTime (#2426) -- Implement `FUNCTION` group of commands (#2475) -- feat(zadd): add ZAddLT and ZAddGT (#2429) -- Add: Support for COMMAND LIST command (#2491) -- Add support for BLMPOP (#2442) -- feat: check pipeline.Do to prevent confusion with Exec (#2517) -- Function stats, function kill, fcall and fcall_ro (#2486) -- feat: Add support for CLUSTER SHARDS command (#2507) -- feat(cmd): support for adding byte,bit parameters to the bitpos command (#2498) - -### Fixed - -- fix: eval api cmd.SetFirstKeyPos (#2501) -- fix: limit the number of connections created (#2441) -- fixed #2462 v9 continue support dragonfly, it's Hello command return "NOAUTH Authentication required" error (#2479) -- Fix for internal/hscan/structmap.go:89:23: undefined: reflect.Pointer (#2458) -- fix: group lag can be null (#2448) - -### Maintenance - -- Updating to the latest version of redis (#2508) -- Allowing for running tests on a port other than the fixed 6380 (#2466) -- redis 7.0.8 in tests (#2450) -- docs: Update redisotel example for v9 (#2425) -- chore: update go mod, Upgrade golang.org/x/net version to 0.7.0 (#2476) -- chore: add Chinese translation (#2436) -- chore(deps): bump github.com/bsm/gomega from 1.20.0 to 1.26.0 (#2421) -- chore(deps): bump github.com/bsm/ginkgo/v2 from 2.5.0 to 2.7.0 (#2420) -- chore(deps): bump actions/setup-go from 3 to 4 (#2495) -- docs: add instructions for the HSet api (#2503) -- docs: add reading lag field comment (#2451) -- test: update go mod before testing(go mod tidy) (#2423) -- docs: fix comment typo (#2505) -- test: remove testify (#2463) -- refactor: change ListElementCmd to KeyValuesCmd. (#2443) -- fix(appendArg): appendArg case special type (#2489) - -## [9.0.2](https://github.com/redis/go-redis/compare/v9.0.1...v9.0.2) (2023-02-01) - -### Features - -* upgrade OpenTelemetry, use the new metrics API. ([#2410](https://github.com/redis/go-redis/issues/2410)) ([e29e42c](https://github.com/redis/go-redis/commit/e29e42cde2755ab910d04185025dc43ce6f59c65)) - -## v9 2023-01-30 - -### Breaking - -- Changed Pipelines to not be thread-safe any more. - -### Added - -- Added support for [RESP3](https://github.com/antirez/RESP3/blob/master/spec.md) protocol. It was - contributed by @monkey92t who has done the majority of work in this release. -- Added `ContextTimeoutEnabled` option that controls whether the client respects context timeouts - and deadlines. See - [Redis Timeouts](https://redis.uptrace.dev/guide/go-redis-debugging.html#timeouts) for details. -- Added `ParseClusterURL` to parse URLs into `ClusterOptions`, for example, - `redis://user:password@localhost:6789?dial_timeout=3&read_timeout=6s&addr=localhost:6790&addr=localhost:6791`. -- Added metrics instrumentation using `redisotel.IstrumentMetrics`. See - [documentation](https://redis.uptrace.dev/guide/go-redis-monitoring.html) -- Added `redis.HasErrorPrefix` to help working with errors. - -### Changed - -- Removed asynchronous cancellation based on the context timeout. It was racy in v8 and is - completely gone in v9. -- Reworked hook interface and added `DialHook`. -- Replaced `redisotel.NewTracingHook` with `redisotel.InstrumentTracing`. See - [example](example/otel) and - [documentation](https://redis.uptrace.dev/guide/go-redis-monitoring.html). -- Replaced `*redis.Z` with `redis.Z` since it is small enough to be passed as value without making - an allocation. -- Renamed the option `MaxConnAge` to `ConnMaxLifetime`. -- Renamed the option `IdleTimeout` to `ConnMaxIdleTime`. -- Removed connection reaper in favor of `MaxIdleConns`. -- Removed `WithContext` since `context.Context` can be passed directly as an arg. -- Removed `Pipeline.Close` since there is no real need to explicitly manage pipeline resources and - it can be safely reused via `sync.Pool` etc. `Pipeline.Discard` is still available if you want to - reset commands for some reason. - -### Fixed - -- Improved and fixed pipeline retries. -- As usually, added support for more commands and fixed some bugs. diff --git a/backend/vendor/github.com/redis/go-redis/v9/CONTRIBUTING.md b/backend/vendor/github.com/redis/go-redis/v9/CONTRIBUTING.md index 90030b89..8c68c522 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/CONTRIBUTING.md +++ b/backend/vendor/github.com/redis/go-redis/v9/CONTRIBUTING.md @@ -32,20 +32,33 @@ Here's how to get started with your code contribution: 1. Create your own fork of go-redis 2. Do the changes in your fork -3. If you need a development environment, run `make test`. Note: this clones and builds the latest release of [redis](https://redis.io). You also need a redis-stack-server docker, in order to run the capabilities tests. This can be started by running: - ```docker run -p 6379:6379 -it redis/redis-stack-server:edge``` -4. While developing, make sure the tests pass by running `make tests` +3. If you need a development environment, run `make docker.start`. + +> Note: this clones and builds the docker containers specified in `docker-compose.yml`, to understand more about +> the infrastructure that will be started you can check the `docker-compose.yml`. You also have the possiblity +> to specify the redis image that will be pulled with the env variable `CLIENT_LIBS_TEST_IMAGE`. +> By default the docker image that will be pulled and started is `redislabs/client-libs-test:8.2.1-pre`. +> If you want to test with newer Redis version, using a newer version of `redislabs/client-libs-test` should work out of the box. + +4. While developing, make sure the tests pass by running `make test` (if you have the docker containers running, `make test.ci` may be sufficient). +> Note: `make test` will try to start all containers, run the tests with `make test.ci` and then stop all containers. 5. If you like the change and think the project could use it, send a pull request To see what else is part of the automation, run `invoke -l` + ## Testing -Call `make test` to run all tests, including linters. +### Setting up Docker +To run the tests, you need to have Docker installed and running. If you are using a host OS that does not support +docker host networks out of the box (e.g. Windows, OSX), you need to set up a docker desktop and enable docker host networks. + +### Running tests +Call `make test` to run all tests. Continuous Integration uses these same wrappers to run all of these -tests against multiple versions of python. Feel free to test your +tests against multiple versions of redis. Feel free to test your changes against all the go versions supported, as declared by the [build.yml](./.github/workflows/build.yml) file. @@ -99,3 +112,7 @@ The core team regularly looks at pull requests. We will provide feedback as soon as possible. After receiving our feedback, please respond within two weeks. After that time, we may close your PR if it isn't showing any activity. + +## Support + +Maintainers can provide limited support to contributors on discord: https://discord.gg/W4txy5AeKM diff --git a/backend/vendor/github.com/redis/go-redis/v9/Makefile b/backend/vendor/github.com/redis/go-redis/v9/Makefile index 1a6bd178..c2264a4e 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/Makefile +++ b/backend/vendor/github.com/redis/go-redis/v9/Makefile @@ -1,42 +1,79 @@ GO_MOD_DIRS := $(shell find . -type f -name 'go.mod' -exec dirname {} \; | sort) +REDIS_VERSION ?= 8.4 +RE_CLUSTER ?= false +RCE_DOCKER ?= true +CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.4.0 -test: testdeps - $(eval GO_VERSION := $(shell go version | cut -d " " -f 3 | cut -d. -f2)) +docker.start: + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + export CLIENT_LIBS_TEST_IMAGE=$(CLIENT_LIBS_TEST_IMAGE) && \ + docker compose --profile all up -d --quiet-pull + +docker.stop: + docker compose --profile all down + +test: + $(MAKE) docker.start + @if [ -z "$(REDIS_VERSION)" ]; then \ + echo "REDIS_VERSION not set, running all tests"; \ + $(MAKE) test.ci; \ + else \ + MAJOR_VERSION=$$(echo "$(REDIS_VERSION)" | cut -d. -f1); \ + if [ "$$MAJOR_VERSION" -ge 8 ]; then \ + echo "REDIS_VERSION $(REDIS_VERSION) >= 8, running all tests"; \ + $(MAKE) test.ci; \ + else \ + echo "REDIS_VERSION $(REDIS_VERSION) < 8, skipping vector_sets tests"; \ + $(MAKE) test.ci.skip-vectorsets; \ + fi; \ + fi + $(MAKE) docker.stop + +test.ci: set -e; for dir in $(GO_MOD_DIRS); do \ - if echo "$${dir}" | grep -q "./example" && [ "$(GO_VERSION)" = "19" ]; then \ - echo "Skipping go test in $${dir} due to Go version 1.19 and dir contains ./example"; \ - continue; \ - fi; \ echo "go test in $${dir}"; \ (cd "$${dir}" && \ + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ go mod tidy -compat=1.18 && \ - go test && \ - go test ./... -short -race && \ - go test ./... -run=NONE -bench=. -benchmem && \ - env GOOS=linux GOARCH=386 go test && \ - go test -coverprofile=coverage.txt -covermode=atomic ./... && \ - go vet); \ + go vet && \ + go test -v -coverprofile=coverage.txt -covermode=atomic ./... -race -skip Example); \ done cd internal/customvet && go build . go vet -vettool ./internal/customvet/customvet -testdeps: testdata/redis/src/redis-server +test.ci.skip-vectorsets: + set -e; for dir in $(GO_MOD_DIRS); do \ + echo "go test in $${dir} (skipping vector sets)"; \ + (cd "$${dir}" && \ + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + go mod tidy -compat=1.18 && \ + go vet && \ + go test -v -coverprofile=coverage.txt -covermode=atomic ./... -race \ + -run '^(?!.*(?:VectorSet|vectorset|ExampleClient_vectorset)).*$$' -skip Example); \ + done + cd internal/customvet && go build . + go vet -vettool ./internal/customvet/customvet -bench: testdeps - go test ./... -test.run=NONE -test.bench=. -test.benchmem +bench: + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + go test ./... -test.run=NONE -test.bench=. -test.benchmem -skip Example -.PHONY: all test testdeps bench fmt +.PHONY: all test test.ci test.ci.skip-vectorsets bench fmt build: + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ go build . -testdata/redis: - mkdir -p $@ - wget -qO- https://download.redis.io/releases/redis-7.4-rc2.tar.gz | tar xvz --strip-components=1 -C $@ - -testdata/redis/src/redis-server: testdata/redis - cd $< && make all - fmt: gofumpt -w ./ goimports -w -local github.com/redis/go-redis ./ diff --git a/backend/vendor/github.com/redis/go-redis/v9/README.md b/backend/vendor/github.com/redis/go-redis/v9/README.md index 9395c652..38bd17b5 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/README.md +++ b/backend/vendor/github.com/redis/go-redis/v9/README.md @@ -2,17 +2,32 @@ [![build workflow](https://github.com/redis/go-redis/actions/workflows/build.yml/badge.svg)](https://github.com/redis/go-redis/actions) [![PkgGoDev](https://pkg.go.dev/badge/github.com/redis/go-redis/v9)](https://pkg.go.dev/github.com/redis/go-redis/v9?tab=doc) -[![Documentation](https://img.shields.io/badge/redis-documentation-informational)](https://redis.uptrace.dev/) +[![Documentation](https://img.shields.io/badge/redis-documentation-informational)](https://redis.io/docs/latest/develop/clients/go/) +[![Go Report Card](https://goreportcard.com/badge/github.com/redis/go-redis/v9)](https://goreportcard.com/report/github.com/redis/go-redis/v9) [![codecov](https://codecov.io/github/redis/go-redis/graph/badge.svg?token=tsrCZKuSSw)](https://codecov.io/github/redis/go-redis) -[![Chat](https://discordapp.com/api/guilds/752070105847955518/widget.png)](https://discord.gg/rWtp5Aj) -> go-redis is brought to you by :star: [**uptrace/uptrace**](https://github.com/uptrace/uptrace). -> Uptrace is an open-source APM tool that supports distributed tracing, metrics, and logs. You can -> use it to monitor applications and set up automatic alerts to receive notifications via email, -> Slack, Telegram, and others. -> -> See [OpenTelemetry](https://github.com/redis/go-redis/tree/master/example/otel) example which -> demonstrates how you can use Uptrace to monitor go-redis. +[![Discord](https://img.shields.io/discord/697882427875393627.svg?style=social&logo=discord)](https://discord.gg/W4txy5AeKM) +[![Twitch](https://img.shields.io/twitch/status/redisinc?style=social)](https://www.twitch.tv/redisinc) +[![YouTube](https://img.shields.io/youtube/channel/views/UCD78lHSwYqMlyetR0_P4Vig?style=social)](https://www.youtube.com/redisinc) +[![Twitter](https://img.shields.io/twitter/follow/redisinc?style=social)](https://twitter.com/redisinc) +[![Stack Exchange questions](https://img.shields.io/stackexchange/stackoverflow/t/go-redis?style=social&logo=stackoverflow&label=Stackoverflow)](https://stackoverflow.com/questions/tagged/go-redis) + +> go-redis is the official Redis client library for the Go programming language. It offers a straightforward interface for interacting with Redis servers. + +## Supported versions + +In `go-redis` we are aiming to support the last three releases of Redis. Currently, this means we do support: +- [Redis 8.0](https://raw.githubusercontent.com/redis/redis/8.0/00-RELEASENOTES) - using Redis CE 8.0 +- [Redis 8.2](https://raw.githubusercontent.com/redis/redis/8.2/00-RELEASENOTES) - using Redis CE 8.2 +- [Redis 8.4](https://raw.githubusercontent.com/redis/redis/8.4/00-RELEASENOTES) - using Redis CE 8.4 + +Although the `go.mod` states it requires at minimum `go 1.18`, our CI is configured to run the tests against all three +versions of Redis and latest two versions of Go ([1.23](https://go.dev/doc/devel/release#go1.23.0), +[1.24](https://go.dev/doc/devel/release#go1.24.0)). We observe that some modules related test may not pass with +Redis Stack 7.2 and some commands are changed with Redis CE 8.0. +Although it is not officially supported, `go-redis/v9` should be able to work with any Redis 7.0+. +Please do refer to the documentation and the tests if you experience any issues. We do plan to update the go version +in the `go.mod` to `go 1.24` in one of the next releases. ## How do I Redis? @@ -28,40 +43,39 @@ [Work at Redis](https://redis.com/company/careers/jobs/) -## Documentation - -- [English](https://redis.uptrace.dev) -- [简体中文](https://redis.uptrace.dev/zh/) ## Resources - [Discussions](https://github.com/redis/go-redis/discussions) -- [Chat](https://discord.gg/rWtp5Aj) +- [Chat](https://discord.gg/W4txy5AeKM) - [Reference](https://pkg.go.dev/github.com/redis/go-redis/v9) - [Examples](https://pkg.go.dev/github.com/redis/go-redis/v9#pkg-examples) +## old documentation + +- [English](https://redis.uptrace.dev) +- [简体中文](https://redis.uptrace.dev/zh/) + ## Ecosystem -- [Redis Mock](https://github.com/go-redis/redismock) +- [Entra ID (Azure AD)](https://github.com/redis/go-redis-entraid) - [Distributed Locks](https://github.com/bsm/redislock) - [Redis Cache](https://github.com/go-redis/cache) - [Rate limiting](https://github.com/go-redis/redis_rate) -This client also works with [Kvrocks](https://github.com/apache/incubator-kvrocks), a distributed -key value NoSQL database that uses RocksDB as storage engine and is compatible with Redis protocol. - ## Features - Redis commands except QUIT and SYNC. - Automatic connection pooling. +- [StreamingCredentialsProvider (e.g. entra id, oauth)](#1-streaming-credentials-provider-highest-priority) (experimental) - [Pub/Sub](https://redis.uptrace.dev/guide/go-redis-pubsub.html). - [Pipelines and transactions](https://redis.uptrace.dev/guide/go-redis-pipelines.html). - [Scripting](https://redis.uptrace.dev/guide/lua-scripting.html). - [Redis Sentinel](https://redis.uptrace.dev/guide/go-redis-sentinel.html). - [Redis Cluster](https://redis.uptrace.dev/guide/go-redis-cluster.html). -- [Redis Ring](https://redis.uptrace.dev/guide/ring.html). - [Redis Performance Monitoring](https://redis.uptrace.dev/guide/redis-performance-monitoring.html). - [Redis Probabilistic [RedisStack]](https://redis.io/docs/data-types/probabilistic/) +- [Customizable read and write buffers size.](#custom-buffer-sizes) ## Installation @@ -122,17 +136,121 @@ func ExampleClient() { } ``` -The above can be modified to specify the version of the RESP protocol by adding the `protocol` -option to the `Options` struct: +### Authentication + +The Redis client supports multiple ways to provide authentication credentials, with a clear priority order. Here are the available options: + +#### 1. Streaming Credentials Provider (Highest Priority) - Experimental feature + +The streaming credentials provider allows for dynamic credential updates during the connection lifetime. This is particularly useful for managed identity services and token-based authentication. ```go - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - Password: "", // no password set - DB: 0, // use default DB - Protocol: 3, // specify 2 for RESP 2 or 3 for RESP 3 - }) +type StreamingCredentialsProvider interface { + Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error) +} +type CredentialsListener interface { + OnNext(credentials Credentials) // Called when credentials are updated + OnError(err error) // Called when an error occurs +} + +type Credentials interface { + BasicAuth() (username string, password string) + RawCredentials() string +} +``` + +Example usage: +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + StreamingCredentialsProvider: &MyCredentialsProvider{}, +}) +``` + +**Note:** The streaming credentials provider can be used with [go-redis-entraid](https://github.com/redis/go-redis-entraid) to enable Entra ID (formerly Azure AD) authentication. This allows for seamless integration with Azure's managed identity services and token-based authentication. + +Example with Entra ID: +```go +import ( + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis-entraid" +) + +// Create an Entra ID credentials provider +provider := entraid.NewDefaultAzureIdentityProvider() + +// Configure Redis client with Entra ID authentication +rdb := redis.NewClient(&redis.Options{ + Addr: "your-redis-server.redis.cache.windows.net:6380", + StreamingCredentialsProvider: provider, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, +}) +``` + +#### 2. Context-based Credentials Provider + +The context-based provider allows credentials to be determined at the time of each operation, using the context. + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + CredentialsProviderContext: func(ctx context.Context) (string, string, error) { + // Return username, password, and any error + return "user", "pass", nil + }, +}) +``` + +#### 3. Regular Credentials Provider + +A simple function-based provider that returns static credentials. + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + CredentialsProvider: func() (string, string) { + // Return username and password + return "user", "pass" + }, +}) +``` + +#### 4. Username/Password Fields (Lowest Priority) + +The most basic way to provide credentials is through the `Username` and `Password` fields in the options. + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Username: "user", + Password: "pass", +}) +``` + +#### Priority Order + +The client will use credentials in the following priority order: +1. Streaming Credentials Provider (if set) +2. Context-based Credentials Provider (if set) +3. Regular Credentials Provider (if set) +4. Username/Password fields (if set) + +If none of these are set, the client will attempt to connect without authentication. + +### Protocol Version + +The client supports both RESP2 and RESP3 protocols. You can specify the protocol version in the options: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password set + DB: 0, // use default DB + Protocol: 3, // specify 2 for RESP 2 or 3 for RESP 3 +}) ``` ### Connecting via a redis url @@ -159,6 +277,36 @@ func ExampleClient() *redis.Client { ``` +### Instrument with OpenTelemetry + +```go +import ( + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/extra/redisotel/v9" + "errors" +) + +func main() { + ... + rdb := redis.NewClient(&redis.Options{...}) + + if err := errors.Join(redisotel.InstrumentTracing(rdb), redisotel.InstrumentMetrics(rdb)); err != nil { + log.Fatal(err) + } +``` + + +### Buffer Size Configuration + +go-redis uses 32KiB read and write buffers by default for optimal performance. For high-throughput applications or large pipelines, you can customize buffer sizes: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + ReadBufferSize: 1024 * 1024, // 1MiB read buffer + WriteBufferSize: 1024 * 1024, // 1MiB write buffer +}) +``` ### Advanced Configuration @@ -203,9 +351,45 @@ res1, err := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptio val1 := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}).RawVal() ``` -## Contributing +#### Redis-Search Default Dialect -Please see [out contributing guidelines](CONTRIBUTING.md) to help us improve this library! +In the Redis-Search module, **the default dialect is 2**. If needed, you can explicitly specify a different dialect using the appropriate configuration in your queries. + +**Important**: Be aware that the query dialect may impact the results returned. If needed, you can revert to a different dialect version by passing the desired dialect in the arguments of the command you want to execute. +For example: +``` + res2, err := rdb.FTSearchWithArgs(ctx, + "idx:bicycle", + "@pickup_zone:[CONTAINS $bike]", + &redis.FTSearchOptions{ + Params: map[string]interface{}{ + "bike": "POINT(-0.1278 51.5074)", + }, + DialectVersion: 3, + }, + ).Result() +``` +You can find further details in the [query dialect documentation](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/dialects/). + +#### Custom buffer sizes +Prior to v9.12, the buffer size was the default go value of 4096 bytes. Starting from v9.12, +go-redis uses 32KiB read and write buffers by default for optimal performance. +For high-throughput applications or large pipelines, you can customize buffer sizes: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + ReadBufferSize: 1024 * 1024, // 1MiB read buffer + WriteBufferSize: 1024 * 1024, // 1MiB write buffer +}) +``` + +**Important**: If you experience any issues with the default buffer sizes, please try setting them to the go default of 4096 bytes. + +## Contributing +We welcome contributions to the go-redis library! If you have a bug fix, feature request, or improvement, please open an issue or pull request on GitHub. +We appreciate your help in making go-redis better for everyone. +If you are interested in contributing to the go-redis library, please check out our [contributing guidelines](CONTRIBUTING.md) for more information on how to get started. ## Look and feel @@ -242,38 +426,150 @@ vals, err := rdb.Eval(ctx, "return {KEYS[1],ARGV[1]}", []string{"key"}, "hello") res, err := rdb.Do(ctx, "set", "key", "value").Result() ``` -## Run the test +## Typed Errors -go-redis will start a redis-server and run the test cases. - -The paths of redis-server bin file and redis config file are defined in `main_test.go`: +go-redis provides typed error checking functions for common Redis errors: ```go -var ( - redisServerBin, _ = filepath.Abs(filepath.Join("testdata", "redis", "src", "redis-server")) - redisServerConf, _ = filepath.Abs(filepath.Join("testdata", "redis", "redis.conf")) -) +// Cluster and replication errors +redis.IsLoadingError(err) // Redis is loading the dataset +redis.IsReadOnlyError(err) // Write to read-only replica +redis.IsClusterDownError(err) // Cluster is down +redis.IsTryAgainError(err) // Command should be retried +redis.IsMasterDownError(err) // Master is down +redis.IsMovedError(err) // Returns (address, true) if key moved +redis.IsAskError(err) // Returns (address, true) if key being migrated + +// Connection and resource errors +redis.IsMaxClientsError(err) // Maximum clients reached +redis.IsAuthError(err) // Authentication failed (NOAUTH, WRONGPASS, unauthenticated) +redis.IsPermissionError(err) // Permission denied (NOPERM) +redis.IsOOMError(err) // Out of memory (OOM) + +// Transaction errors +redis.IsExecAbortError(err) // Transaction aborted (EXECABORT) ``` -For local testing, you can change the variables to refer to your local files, or create a soft link -to the corresponding folder for redis-server and copy the config file to `testdata/redis/`: +### Error Wrapping in Hooks -```shell -ln -s /usr/bin/redis-server ./go-redis/testdata/redis/src -cp ./go-redis/testdata/redis.conf ./go-redis/testdata/redis/ +When wrapping errors in hooks, use custom error types with `Unwrap()` method (preferred) or `fmt.Errorf` with `%w`. Always call `cmd.SetErr()` to preserve error type information: + +```go +// Custom error type (preferred) +type AppError struct { + Code string + RequestID string + Err error +} + +func (e *AppError) Error() string { + return fmt.Sprintf("[%s] request_id=%s: %v", e.Code, e.RequestID, e.Err) +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +// Hook implementation +func (h MyHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook { + return func(ctx context.Context, cmd redis.Cmder) error { + err := next(ctx, cmd) + if err != nil { + // Wrap with custom error type + wrappedErr := &AppError{ + Code: "REDIS_ERROR", + RequestID: getRequestID(ctx), + Err: err, + } + cmd.SetErr(wrappedErr) + return wrappedErr // Return wrapped error to preserve it + } + return nil + } +} + +// Typed error detection works through wrappers +if redis.IsLoadingError(err) { + // Retry logic +} + +// Extract custom error if needed +var appErr *AppError +if errors.As(err, &appErr) { + log.Printf("Request: %s", appErr.RequestID) +} ``` -Lastly, run: - -```shell -go test +Alternatively, use `fmt.Errorf` with `%w`: +```go +wrappedErr := fmt.Errorf("context: %w", err) +cmd.SetErr(wrappedErr) ``` -Another option is to run your specific tests with an already running redis. The example below, tests -against a redis running on port 9999.: +### Pipeline Hook Example +For pipeline operations, use `ProcessPipelineHook`: + +```go +type PipelineLoggingHook struct{} + +func (h PipelineLoggingHook) DialHook(next redis.DialHook) redis.DialHook { + return next +} + +func (h PipelineLoggingHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook { + return next +} + +func (h PipelineLoggingHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook { + return func(ctx context.Context, cmds []redis.Cmder) error { + start := time.Now() + + // Execute the pipeline + err := next(ctx, cmds) + + duration := time.Since(start) + log.Printf("Pipeline executed %d commands in %v", len(cmds), duration) + + // Process individual command errors + // Note: Individual command errors are already set on each cmd by the pipeline execution + for _, cmd := range cmds { + if cmdErr := cmd.Err(); cmdErr != nil { + // Check for specific error types using typed error functions + if redis.IsAuthError(cmdErr) { + log.Printf("Auth error in pipeline command %s: %v", cmd.Name(), cmdErr) + } else if redis.IsPermissionError(cmdErr) { + log.Printf("Permission error in pipeline command %s: %v", cmd.Name(), cmdErr) + } + + // Optionally wrap individual command errors to add context + // The wrapped error preserves type information through errors.As() + wrappedErr := fmt.Errorf("pipeline cmd %s failed: %w", cmd.Name(), cmdErr) + cmd.SetErr(wrappedErr) + } + } + + // Return the pipeline-level error (connection errors, etc.) + // You can wrap it if needed, or return it as-is + return err + } +} + +// Register the hook +rdb.AddHook(PipelineLoggingHook{}) + +// Use pipeline - errors are still properly typed +pipe := rdb.Pipeline() +pipe.Set(ctx, "key1", "value1", 0) +pipe.Get(ctx, "key2") +_, err := pipe.Exec(ctx) +``` + +## Run the test + +Recommended to use Docker, just need to run: ```shell -REDIS_PORT=9999 go test +make test ``` ## See also @@ -285,6 +581,14 @@ REDIS_PORT=9999 go test ## Contributors +> The go-redis project was originally initiated by :star: [**uptrace/uptrace**](https://github.com/uptrace/uptrace). +> Uptrace is an open-source APM tool that supports distributed tracing, metrics, and logs. You can +> use it to monitor applications and set up automatic alerts to receive notifications via email, +> Slack, Telegram, and others. +> +> See [OpenTelemetry](https://github.com/redis/go-redis/tree/master/example/otel) example which +> demonstrates how you can use Uptrace to monitor go-redis. + Thanks to all the people who already contributed! diff --git a/backend/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md b/backend/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md new file mode 100644 index 00000000..e38ade44 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md @@ -0,0 +1,705 @@ +# Release Notes + +# 9.17.2 (2025-12-01) + +## 🐛 Bug Fixes + +- **Connection Pool**: Fixed critical race condition in turn management that could cause connection leaks when dial goroutines complete after request timeout ([#3626](https://github.com/redis/go-redis/pull/3626)) by [@cyningsun](https://github.com/cyningsun) +- **Context Timeout**: Improved context timeout calculation to use minimum of remaining time and DialTimeout, preventing goroutines from waiting longer than necessary ([#3626](https://github.com/redis/go-redis/pull/3626)) by [@cyningsun](https://github.com/cyningsun) + +## 🧰 Maintenance + +- chore(deps): bump rojopolis/spellcheck-github-actions from 0.54.0 to 0.55.0 ([#3627](https://github.com/redis/go-redis/pull/3627)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@cyningsun](https://github.com/cyningsun) and [@ndyakov](https://github.com/ndyakov) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.17.1...v9.17.2 + +# 9.17.1 (2025-11-25) + +## 🐛 Bug Fixes + +- add wait to keyless commands list ([#3615](https://github.com/redis/go-redis/pull/3615)) by [@marcoferrer](https://github.com/marcoferrer) +- fix(time): remove cached time optimization ([#3611](https://github.com/redis/go-redis/pull/3611)) by [@ndyakov](https://github.com/ndyakov) + +## 🧰 Maintenance + +- chore(deps): bump golangci/golangci-lint-action from 9.0.0 to 9.1.0 ([#3609](https://github.com/redis/go-redis/pull/3609)) +- chore(deps): bump actions/checkout from 5 to 6 ([#3610](https://github.com/redis/go-redis/pull/3610)) +- chore(script): fix help call in tag.sh ([#3606](https://github.com/redis/go-redis/pull/3606)) by [@ndyakov](https://github.com/ndyakov) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@marcoferrer](https://github.com/marcoferrer) and [@ndyakov](https://github.com/ndyakov) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.17.0...v9.17.1 + +# 9.17.0 (2025-11-19) + +## 🚀 Highlights + +### Redis 8.4 Support +Added support for Redis 8.4, including new commands and features ([#3572](https://github.com/redis/go-redis/pull/3572)) + +### Typed Errors +Introduced typed errors for better error handling using `errors.As` instead of string checks. Errors can now be wrapped and set to commands in hooks without breaking library functionality ([#3602](https://github.com/redis/go-redis/pull/3602)) + +### New Commands +- **CAS/CAD Commands**: Added support for Compare-And-Set/Compare-And-Delete operations with conditional matching (`IFEQ`, `IFNE`, `IFDEQ`, `IFDNE`) ([#3583](https://github.com/redis/go-redis/pull/3583), [#3595](https://github.com/redis/go-redis/pull/3595)) +- **MSETEX**: Atomically set multiple key-value pairs with expiration options and conditional modes ([#3580](https://github.com/redis/go-redis/pull/3580)) +- **XReadGroup CLAIM**: Consume both incoming and idle pending entries from streams in a single call ([#3578](https://github.com/redis/go-redis/pull/3578)) +- **ACL Commands**: Added `ACLGenPass`, `ACLUsers`, and `ACLWhoAmI` ([#3576](https://github.com/redis/go-redis/pull/3576)) +- **SLOWLOG Commands**: Added `SLOWLOG LEN` and `SLOWLOG RESET` ([#3585](https://github.com/redis/go-redis/pull/3585)) +- **LATENCY Commands**: Added `LATENCY LATEST` and `LATENCY RESET` ([#3584](https://github.com/redis/go-redis/pull/3584)) + +### Search & Vector Improvements +- **Hybrid Search**: Added **EXPERIMENTAL** support for the new `FT.HYBRID` command ([#3573](https://github.com/redis/go-redis/pull/3573)) +- **Vector Range**: Added `VRANGE` command for vector sets ([#3543](https://github.com/redis/go-redis/pull/3543)) +- **FT.INFO Enhancements**: Added vector-specific attributes in FT.INFO response ([#3596](https://github.com/redis/go-redis/pull/3596)) + +### Connection Pool Improvements +- **Improved Connection Success Rate**: Implemented FIFO queue-based fairness and context pattern for connection creation to prevent premature cancellation under high concurrency ([#3518](https://github.com/redis/go-redis/pull/3518)) +- **Connection State Machine**: Resolved race conditions and improved pool performance with proper state tracking ([#3559](https://github.com/redis/go-redis/pull/3559)) +- **Pool Performance**: Significant performance improvements with faster semaphores, lockless hook manager, and reduced allocations (47-67% faster Get/Put operations) ([#3565](https://github.com/redis/go-redis/pull/3565)) + +### Metrics & Observability +- **Canceled Metric Attribute**: Added 'canceled' metrics attribute to distinguish context cancellation errors from other errors ([#3566](https://github.com/redis/go-redis/pull/3566)) + +## ✨ New Features + +- Typed errors with wrapping support ([#3602](https://github.com/redis/go-redis/pull/3602)) by [@ndyakov](https://github.com/ndyakov) +- CAS/CAD commands (marked as experimental) ([#3583](https://github.com/redis/go-redis/pull/3583), [#3595](https://github.com/redis/go-redis/pull/3595)) by [@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis) +- MSETEX command support ([#3580](https://github.com/redis/go-redis/pull/3580)) by [@ofekshenawa](https://github.com/ofekshenawa) +- XReadGroup CLAIM argument ([#3578](https://github.com/redis/go-redis/pull/3578)) by [@ofekshenawa](https://github.com/ofekshenawa) +- ACL commands: GenPass, Users, WhoAmI ([#3576](https://github.com/redis/go-redis/pull/3576)) by [@destinyoooo](https://github.com/destinyoooo) +- SLOWLOG commands: LEN, RESET ([#3585](https://github.com/redis/go-redis/pull/3585)) by [@destinyoooo](https://github.com/destinyoooo) +- LATENCY commands: LATEST, RESET ([#3584](https://github.com/redis/go-redis/pull/3584)) by [@destinyoooo](https://github.com/destinyoooo) +- Hybrid search command (FT.HYBRID) ([#3573](https://github.com/redis/go-redis/pull/3573)) by [@htemelski-redis](https://github.com/htemelski-redis) +- Vector range command (VRANGE) ([#3543](https://github.com/redis/go-redis/pull/3543)) by [@cxljs](https://github.com/cxljs) +- Vector-specific attributes in FT.INFO ([#3596](https://github.com/redis/go-redis/pull/3596)) by [@ndyakov](https://github.com/ndyakov) +- Improved connection pool success rate with FIFO queue ([#3518](https://github.com/redis/go-redis/pull/3518)) by [@cyningsun](https://github.com/cyningsun) +- Canceled metrics attribute for context errors ([#3566](https://github.com/redis/go-redis/pull/3566)) by [@pvragov](https://github.com/pvragov) + +## 🐛 Bug Fixes + +- Fixed Failover Client MaintNotificationsConfig ([#3600](https://github.com/redis/go-redis/pull/3600)) by [@ajax16384](https://github.com/ajax16384) +- Fixed ACLGenPass function to use the bit parameter ([#3597](https://github.com/redis/go-redis/pull/3597)) by [@destinyoooo](https://github.com/destinyoooo) +- Return error instead of panic from commands ([#3568](https://github.com/redis/go-redis/pull/3568)) by [@dragneelfps](https://github.com/dragneelfps) +- Safety harness in `joinErrors` to prevent panic ([#3577](https://github.com/redis/go-redis/pull/3577)) by [@manisharma](https://github.com/manisharma) + +## ⚡ Performance + +- Connection state machine with race condition fixes ([#3559](https://github.com/redis/go-redis/pull/3559)) by [@ndyakov](https://github.com/ndyakov) +- Pool performance improvements: 47-67% faster Get/Put, 33% less memory, 50% fewer allocations ([#3565](https://github.com/redis/go-redis/pull/3565)) by [@ndyakov](https://github.com/ndyakov) + +## 🧪 Testing & Infrastructure + +- Updated to Redis 8.4.0 image ([#3603](https://github.com/redis/go-redis/pull/3603)) by [@ndyakov](https://github.com/ndyakov) +- Added Redis 8.4-RC1-pre to CI ([#3572](https://github.com/redis/go-redis/pull/3572)) by [@ndyakov](https://github.com/ndyakov) +- Refactored tests for idiomatic Go ([#3561](https://github.com/redis/go-redis/pull/3561), [#3562](https://github.com/redis/go-redis/pull/3562), [#3563](https://github.com/redis/go-redis/pull/3563)) by [@12ya](https://github.com/12ya) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@12ya](https://github.com/12ya), [@ajax16384](https://github.com/ajax16384), [@cxljs](https://github.com/cxljs), [@cyningsun](https://github.com/cyningsun), [@destinyoooo](https://github.com/destinyoooo), [@dragneelfps](https://github.com/dragneelfps), [@htemelski-redis](https://github.com/htemelski-redis), [@manisharma](https://github.com/manisharma), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@pvragov](https://github.com/pvragov) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.16.0...v9.17.0 + +# 9.16.0 (2025-10-23) + +## 🚀 Highlights + +### Maintenance Notifications Support + +This release introduces comprehensive support for Redis maintenance notifications, enabling applications to handle server maintenance events gracefully. The new `maintnotifications` package provides: + +- **RESP3 Push Notifications**: Full support for Redis RESP3 protocol push notifications +- **Connection Handoff**: Automatic connection migration during server maintenance with configurable retry policies and circuit breakers +- **Graceful Degradation**: Configurable timeout relaxation during maintenance windows to prevent false failures +- **Event-Driven Architecture**: Background workers with on-demand scaling for efficient handoff processing +- **Production-Ready**: Comprehensive E2E testing framework and monitoring capabilities + +For detailed usage examples and configuration options, see the [maintenance notifications documentation](maintnotifications/README.md). + +## ✨ New Features + +- **Trace Filtering**: Add support for filtering traces for specific commands, including pipeline operations and dial operations ([#3519](https://github.com/redis/go-redis/pull/3519), [#3550](https://github.com/redis/go-redis/pull/3550)) + - New `TraceCmdFilter` option to selectively trace commands + - Reduces overhead by excluding high-frequency or low-value commands from traces + +## 🐛 Bug Fixes + +- **Pipeline Error Handling**: Fix issue where pipeline repeatedly sets the same error ([#3525](https://github.com/redis/go-redis/pull/3525)) +- **Connection Pool**: Ensure re-authentication does not interfere with connection handoff operations ([#3547](https://github.com/redis/go-redis/pull/3547)) + +## 🔧 Improvements + +- **Hash Commands**: Update hash command implementations ([#3523](https://github.com/redis/go-redis/pull/3523)) +- **OpenTelemetry**: Use `metric.WithAttributeSet` to avoid unnecessary attribute copying in redisotel ([#3552](https://github.com/redis/go-redis/pull/3552)) + +## 📚 Documentation + +- **Cluster Client**: Add explanation for why `MaxRetries` is disabled for `ClusterClient` ([#3551](https://github.com/redis/go-redis/pull/3551)) + +## 🧪 Testing & Infrastructure + +- **E2E Testing**: Upgrade E2E testing framework with improved reliability and coverage ([#3541](https://github.com/redis/go-redis/pull/3541)) +- **Release Process**: Improved resiliency of the release process ([#3530](https://github.com/redis/go-redis/pull/3530)) + +## 📦 Dependencies + +- Bump `rojopolis/spellcheck-github-actions` from 0.51.0 to 0.52.0 ([#3520](https://github.com/redis/go-redis/pull/3520)) +- Bump `github/codeql-action` from 3 to 4 ([#3544](https://github.com/redis/go-redis/pull/3544)) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), [@Sovietaced](https://github.com/Sovietaced), [@Udhayarajan](https://github.com/Udhayarajan), [@boekkooi-impossiblecloud](https://github.com/boekkooi-impossiblecloud), [@Pika-Gopher](https://github.com/Pika-Gopher), [@cxljs](https://github.com/cxljs), [@huiyifyj](https://github.com/huiyifyj), [@omid-h70](https://github.com/omid-h70) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.14.0...v9.16.0 + + +# 9.15.0 was accidentally released. Please use version 9.16.0 instead. + +# 9.15.0-beta.3 (2025-09-26) + +## Highlights +This beta release includes a pre-production version of processing push notifications and hitless upgrades. + +# Changes + +- chore: Update hash_commands.go ([#3523](https://github.com/redis/go-redis/pull/3523)) + +## 🚀 New Features + +- feat: RESP3 notifications support & Hitless notifications handling ([#3418](https://github.com/redis/go-redis/pull/3418)) + +## 🐛 Bug Fixes + +- fix: pipeline repeatedly sets the error ([#3525](https://github.com/redis/go-redis/pull/3525)) + +## 🧰 Maintenance + +- chore(deps): bump rojopolis/spellcheck-github-actions from 0.51.0 to 0.52.0 ([#3520](https://github.com/redis/go-redis/pull/3520)) +- feat(e2e-testing): maintnotifications e2e and refactor ([#3526](https://github.com/redis/go-redis/pull/3526)) +- feat(tag.sh): Improved resiliency of the release process ([#3530](https://github.com/redis/go-redis/pull/3530)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@cxljs](https://github.com/cxljs), [@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), and [@omid-h70](https://github.com/omid-h70) + + +# 9.15.0-beta.1 (2025-09-10) + +## Highlights +This beta release includes a pre-production version of processing push notifications and hitless upgrades. + +### Hitless Upgrades +Hitless upgrades is a major new feature that allows for zero-downtime upgrades in Redis clusters. +You can find more information in the [Hitless Upgrades documentation](https://github.com/redis/go-redis/tree/master/hitless). + +# Changes + +## 🚀 New Features +- [CAE-1088] & [CAE-1072] feat: RESP3 notifications support & Hitless notifications handling ([#3418](https://github.com/redis/go-redis/pull/3418)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), [@ofekshenawa](https://github.com/ofekshenawa) + + +# 9.14.0 (2025-09-10) + +## Highlights +- Added batch process method to the pipeline ([#3510](https://github.com/redis/go-redis/pull/3510)) + +# Changes + +## 🚀 New Features + +- Added batch process method to the pipeline ([#3510](https://github.com/redis/go-redis/pull/3510)) + +## 🐛 Bug Fixes + +- fix: SetErr on Cmd if the command cannot be queued correctly in multi/exec ([#3509](https://github.com/redis/go-redis/pull/3509)) + +## 🧰 Maintenance + +- Updates release drafter config to exclude dependabot ([#3511](https://github.com/redis/go-redis/pull/3511)) +- chore(deps): bump actions/setup-go from 5 to 6 ([#3504](https://github.com/redis/go-redis/pull/3504)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@elena-kolevska](https://github.com/elena-kolevksa), [@htemelski-redis](https://github.com/htemelski-redis) and [@ndyakov](https://github.com/ndyakov) + + +# 9.13.0 (2025-09-03) + +## Highlights +- Pipeliner expose queued commands ([#3496](https://github.com/redis/go-redis/pull/3496)) +- Ensure that JSON.GET returns Nil response ([#3470](https://github.com/redis/go-redis/pull/3470)) +- Fixes on Read and Write buffer sizes and UniversalOptions + +## Changes +- Pipeliner expose queued commands ([#3496](https://github.com/redis/go-redis/pull/3496)) +- fix(test): fix a timing issue in pubsub test ([#3498](https://github.com/redis/go-redis/pull/3498)) +- Allow users to enable read-write splitting in failover mode. ([#3482](https://github.com/redis/go-redis/pull/3482)) +- Set the read/write buffer size of the sentinel client to 4KiB ([#3476](https://github.com/redis/go-redis/pull/3476)) + +## 🚀 New Features + +- fix(otel): register wait metrics ([#3499](https://github.com/redis/go-redis/pull/3499)) +- Support subscriptions against cluster slave nodes ([#3480](https://github.com/redis/go-redis/pull/3480)) +- Add wait metrics to otel ([#3493](https://github.com/redis/go-redis/pull/3493)) +- Clean failing timeout implementation ([#3472](https://github.com/redis/go-redis/pull/3472)) + +## 🐛 Bug Fixes + +- Do not assume that all non-IP hosts are loopbacks ([#3085](https://github.com/redis/go-redis/pull/3085)) +- Ensure that JSON.GET returns Nil response ([#3470](https://github.com/redis/go-redis/pull/3470)) + +## 🧰 Maintenance + +- fix(otel): register wait metrics ([#3499](https://github.com/redis/go-redis/pull/3499)) +- fix(make test): Add default env in makefile ([#3491](https://github.com/redis/go-redis/pull/3491)) +- Update the introduction to running tests in README.md ([#3495](https://github.com/redis/go-redis/pull/3495)) +- test: Add comprehensive edge case tests for IncrByFloat command ([#3477](https://github.com/redis/go-redis/pull/3477)) +- Set the default read/write buffer size of Redis connection to 32KiB ([#3483](https://github.com/redis/go-redis/pull/3483)) +- Bumps test image to 8.2.1-pre ([#3478](https://github.com/redis/go-redis/pull/3478)) +- fix UniversalOptions miss ReadBufferSize and WriteBufferSize options ([#3485](https://github.com/redis/go-redis/pull/3485)) +- chore(deps): bump actions/checkout from 4 to 5 ([#3484](https://github.com/redis/go-redis/pull/3484)) +- Removes dry run for stale issues policy ([#3471](https://github.com/redis/go-redis/pull/3471)) +- Update otel metrics URL ([#3474](https://github.com/redis/go-redis/pull/3474)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@LINKIWI](https://github.com/LINKIWI), [@cxljs](https://github.com/cxljs), [@cybersmeashish](https://github.com/cybersmeashish), [@elena-kolevska](https://github.com/elena-kolevska), [@htemelski-redis](https://github.com/htemelski-redis), [@mwhooker](https://github.com/mwhooker), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@suever](https://github.com/suever) + + +# 9.12.1 (2025-08-11) +## 🚀 Highlights +In the last version (9.12.0) the client introduced bigger write and read buffer sized. The default value we set was 512KiB. +However, users reported that this is too big for most use cases and can lead to high memory usage. +In this version the default value is changed to 256KiB. The `README.md` was updated to reflect the +correct default value and include a note that the default value can be changed. + +## 🐛 Bug Fixes + +- fix(options): Add buffer sizes to failover. Update README ([#3468](https://github.com/redis/go-redis/pull/3468)) + +## 🧰 Maintenance + +- fix(options): Add buffer sizes to failover. Update README ([#3468](https://github.com/redis/go-redis/pull/3468)) +- chore: update & fix otel example ([#3466](https://github.com/redis/go-redis/pull/3466)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@ndyakov](https://github.com/ndyakov) and [@vmihailenco](https://github.com/vmihailenco) + +# 9.12.0 (2025-08-05) + +## 🚀 Highlights + +- This release includes support for [Redis 8.2](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.2-release-notes/). +- Introduces an experimental Query Builders for `FTSearch`, `FTAggregate` and other search commands. +- Adds support for `EPSILON` option in `FT.VSIM`. +- Includes bug fixes and improvements contributed by the community related to ring and [redisotel](https://github.com/redis/go-redis/tree/master/extra/redisotel). + +## Changes +- Improve stale issue workflow ([#3458](https://github.com/redis/go-redis/pull/3458)) +- chore(ci): Add 8.2 rc2 pre build for CI ([#3459](https://github.com/redis/go-redis/pull/3459)) +- Added new stream commands ([#3450](https://github.com/redis/go-redis/pull/3450)) +- feat: Add "skip_verify" to Sentinel ([#3428](https://github.com/redis/go-redis/pull/3428)) +- fix: `errors.Join` requires Go 1.20 or later ([#3442](https://github.com/redis/go-redis/pull/3442)) +- DOC-4344 document quickstart examples ([#3426](https://github.com/redis/go-redis/pull/3426)) +- feat(bitop): add support for the new bitop operations ([#3409](https://github.com/redis/go-redis/pull/3409)) + +## 🚀 New Features + +- feat: recover addIdleConn may occur panic ([#2445](https://github.com/redis/go-redis/pull/2445)) +- feat(ring): specify custom health check func via HeartbeatFn option ([#2940](https://github.com/redis/go-redis/pull/2940)) +- Add Query Builder for RediSearch commands ([#3436](https://github.com/redis/go-redis/pull/3436)) +- add configurable buffer sizes for Redis connections ([#3453](https://github.com/redis/go-redis/pull/3453)) +- Add VAMANA vector type to RediSearch ([#3449](https://github.com/redis/go-redis/pull/3449)) +- VSIM add `EPSILON` option ([#3454](https://github.com/redis/go-redis/pull/3454)) +- Add closing support to otel metrics instrumentation ([#3444](https://github.com/redis/go-redis/pull/3444)) + +## 🐛 Bug Fixes + +- fix(redisotel): fix buggy append in reportPoolStats ([#3122](https://github.com/redis/go-redis/pull/3122)) +- fix(search): return results even if doc is empty ([#3457](https://github.com/redis/go-redis/pull/3457)) +- [ISSUE-3402]: Ring.Pipelined return dial timeout error ([#3403](https://github.com/redis/go-redis/pull/3403)) + +## 🧰 Maintenance + +- Merges stale issues jobs into one job with two steps ([#3463](https://github.com/redis/go-redis/pull/3463)) +- improve code readability ([#3446](https://github.com/redis/go-redis/pull/3446)) +- chore(release): 9.12.0-beta.1 ([#3460](https://github.com/redis/go-redis/pull/3460)) +- DOC-5472 time series doc examples ([#3443](https://github.com/redis/go-redis/pull/3443)) +- Add VAMANA compression algorithm tests ([#3461](https://github.com/redis/go-redis/pull/3461)) +- bumped redis 8.2 version used in the CI/CD ([#3451](https://github.com/redis/go-redis/pull/3451)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@andy-stark-redis](https://github.com/andy-stark-redis), [@cxljs](https://github.com/cxljs), [@elena-kolevska](https://github.com/elena-kolevska), [@htemelski-redis](https://github.com/htemelski-redis), [@jouir](https://github.com/jouir), [@monkey92t](https://github.com/monkey92t), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@rokn](https://github.com/rokn), [@smnvdev](https://github.com/smnvdev), [@strobil](https://github.com/strobil) and [@wzy9607](https://github.com/wzy9607) + +## New Contributors +* [@htemelski-redis](https://github.com/htemelski-redis) made their first contribution in [#3409](https://github.com/redis/go-redis/pull/3409) +* [@smnvdev](https://github.com/smnvdev) made their first contribution in [#3403](https://github.com/redis/go-redis/pull/3403) +* [@rokn](https://github.com/rokn) made their first contribution in [#3444](https://github.com/redis/go-redis/pull/3444) + +# 9.11.0 (2025-06-24) + +## 🚀 Highlights + +Fixes TxPipeline to work correctly in cluster scenarios, allowing execution of commands +only in the same slot. + +# Changes + +## 🚀 New Features + +- Set cluster slot for `scan` commands, rather than random ([#2623](https://github.com/redis/go-redis/pull/2623)) +- Add CredentialsProvider field to UniversalOptions ([#2927](https://github.com/redis/go-redis/pull/2927)) +- feat(redisotel): add WithCallerEnabled option ([#3415](https://github.com/redis/go-redis/pull/3415)) + +## 🐛 Bug Fixes + +- fix(txpipeline): keyless commands should take the slot of the keyed ([#3411](https://github.com/redis/go-redis/pull/3411)) +- fix(loading): cache the loaded flag for slave nodes ([#3410](https://github.com/redis/go-redis/pull/3410)) +- fix(txpipeline): should return error on multi/exec on multiple slots ([#3408](https://github.com/redis/go-redis/pull/3408)) +- fix: check if the shard exists to avoid returning nil ([#3396](https://github.com/redis/go-redis/pull/3396)) + +## 🧰 Maintenance + +- feat: optimize connection pool waitTurn ([#3412](https://github.com/redis/go-redis/pull/3412)) +- chore(ci): update CI redis builds ([#3407](https://github.com/redis/go-redis/pull/3407)) +- chore: remove a redundant method from `Ring`, `Client` and `ClusterClient` ([#3401](https://github.com/redis/go-redis/pull/3401)) +- test: refactor TestBasicCredentials using table-driven tests ([#3406](https://github.com/redis/go-redis/pull/3406)) +- perf: reduce unnecessary memory allocation operations ([#3399](https://github.com/redis/go-redis/pull/3399)) +- fix: insert entry during iterating over a map ([#3398](https://github.com/redis/go-redis/pull/3398)) +- DOC-5229 probabilistic data type examples ([#3413](https://github.com/redis/go-redis/pull/3413)) +- chore(deps): bump rojopolis/spellcheck-github-actions from 0.49.0 to 0.51.0 ([#3414](https://github.com/redis/go-redis/pull/3414)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@andy-stark-redis](https://github.com/andy-stark-redis), [@boekkooi-impossiblecloud](https://github.com/boekkooi-impossiblecloud), [@cxljs](https://github.com/cxljs), [@dcherubini](https://github.com/dcherubini), [@dependabot[bot]](https://github.com/apps/dependabot), [@iamamirsalehi](https://github.com/iamamirsalehi), [@ndyakov](https://github.com/ndyakov), [@pete-woods](https://github.com/pete-woods), [@twz915](https://github.com/twz915) and [dependabot[bot]](https://github.com/apps/dependabot) + +# 9.10.0 (2025-06-06) + +## 🚀 Highlights + +`go-redis` now supports [vector sets](https://redis.io/docs/latest/develop/data-types/vector-sets/). This data type is marked +as "in preview" in Redis and its support in `go-redis` is marked as experimental. You can find examples in the documentation and +in the `doctests` folder. + +# Changes + +## 🚀 New Features + +- feat: support vectorset ([#3375](https://github.com/redis/go-redis/pull/3375)) + +## 🧰 Maintenance + +- Add the missing NewFloatSliceResult for testing ([#3393](https://github.com/redis/go-redis/pull/3393)) +- DOC-5078 vector set examples ([#3394](https://github.com/redis/go-redis/pull/3394)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@AndBobsYourUncle](https://github.com/AndBobsYourUncle), [@andy-stark-redis](https://github.com/andy-stark-redis), [@fukua95](https://github.com/fukua95) and [@ndyakov](https://github.com/ndyakov) + + + +# 9.9.0 (2025-05-27) + +## 🚀 Highlights +- **Token-based Authentication**: Added `StreamingCredentialsProvider` for dynamic credential updates (experimental) + - Can be used with [go-redis-entraid](https://github.com/redis/go-redis-entraid) for Azure AD authentication +- **Connection Statistics**: Added connection waiting statistics for better monitoring +- **Failover Improvements**: Added `ParseFailoverURL` for easier failover configuration +- **Ring Client Enhancements**: Added shard access methods for better Pub/Sub management + +## ✨ New Features +- Added `StreamingCredentialsProvider` for token-based authentication ([#3320](https://github.com/redis/go-redis/pull/3320)) + - Supports dynamic credential updates + - Includes connection close hooks + - Note: Currently marked as experimental +- Added `ParseFailoverURL` for parsing failover URLs ([#3362](https://github.com/redis/go-redis/pull/3362)) +- Added connection waiting statistics ([#2804](https://github.com/redis/go-redis/pull/2804)) +- Added new utility functions: + - `ParseFloat` and `MustParseFloat` in public utils package ([#3371](https://github.com/redis/go-redis/pull/3371)) + - Unit tests for `Atoi`, `ParseInt`, `ParseUint`, and `ParseFloat` ([#3377](https://github.com/redis/go-redis/pull/3377)) +- Added Ring client shard access methods: + - `GetShardClients()` to retrieve all active shard clients + - `GetShardClientForKey(key string)` to get the shard client for a specific key ([#3388](https://github.com/redis/go-redis/pull/3388)) + +## 🐛 Bug Fixes +- Fixed routing reads to loading slave nodes ([#3370](https://github.com/redis/go-redis/pull/3370)) +- Added support for nil lag in XINFO GROUPS ([#3369](https://github.com/redis/go-redis/pull/3369)) +- Fixed pool acquisition timeout issues ([#3381](https://github.com/redis/go-redis/pull/3381)) +- Optimized unnecessary copy operations ([#3376](https://github.com/redis/go-redis/pull/3376)) + +## 📚 Documentation +- Updated documentation for XINFO GROUPS with nil lag support ([#3369](https://github.com/redis/go-redis/pull/3369)) +- Added package-level comments for new features + +## ⚡ Performance and Reliability +- Optimized `ReplaceSpaces` function ([#3383](https://github.com/redis/go-redis/pull/3383)) +- Set default value for `Options.Protocol` in `init()` ([#3387](https://github.com/redis/go-redis/pull/3387)) +- Exported pool errors for public consumption ([#3380](https://github.com/redis/go-redis/pull/3380)) + +## 🔧 Dependencies and Infrastructure +- Updated Redis CI to version 8.0.1 ([#3372](https://github.com/redis/go-redis/pull/3372)) +- Updated spellcheck GitHub Actions ([#3389](https://github.com/redis/go-redis/pull/3389)) +- Removed unused parameters ([#3382](https://github.com/redis/go-redis/pull/3382), [#3384](https://github.com/redis/go-redis/pull/3384)) + +## 🧪 Testing +- Added unit tests for pool acquisition timeout ([#3381](https://github.com/redis/go-redis/pull/3381)) +- Added unit tests for utility functions ([#3377](https://github.com/redis/go-redis/pull/3377)) + +## 👥 Contributors + +We would like to thank all the contributors who made this release possible: + +[@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@LINKIWI](https://github.com/LINKIWI), [@iamamirsalehi](https://github.com/iamamirsalehi), [@fukua95](https://github.com/fukua95), [@lzakharov](https://github.com/lzakharov), [@DengY11](https://github.com/DengY11) + +## 📝 Changelog + +For a complete list of changes, see the [full changelog](https://github.com/redis/go-redis/compare/v9.8.0...v9.9.0). + +# 9.8.0 (2025-04-30) + +## 🚀 Highlights +- **Redis 8 Support**: Full compatibility with Redis 8.0, including testing and CI integration +- **Enhanced Hash Operations**: Added support for new hash commands (`HGETDEL`, `HGETEX`, `HSETEX`) and `HSTRLEN` command +- **Search Improvements**: Enabled Search DIALECT 2 by default and added `CountOnly` argument for `FT.Search` + +## ✨ New Features +- Added support for new hash commands: `HGETDEL`, `HGETEX`, `HSETEX` ([#3305](https://github.com/redis/go-redis/pull/3305)) +- Added `HSTRLEN` command for hash operations ([#2843](https://github.com/redis/go-redis/pull/2843)) +- Added `Do` method for raw query by single connection from `pool.Conn()` ([#3182](https://github.com/redis/go-redis/pull/3182)) +- Prevent false-positive marshaling by treating zero time.Time as empty in isEmptyValue ([#3273](https://github.com/redis/go-redis/pull/3273)) +- Added FailoverClusterClient support for Universal client ([#2794](https://github.com/redis/go-redis/pull/2794)) +- Added support for cluster mode with `IsClusterMode` config parameter ([#3255](https://github.com/redis/go-redis/pull/3255)) +- Added client name support in `HELLO` RESP handshake ([#3294](https://github.com/redis/go-redis/pull/3294)) +- **Enabled Search DIALECT 2 by default** ([#3213](https://github.com/redis/go-redis/pull/3213)) +- Added read-only option for failover configurations ([#3281](https://github.com/redis/go-redis/pull/3281)) +- Added `CountOnly` argument for `FT.Search` to use `LIMIT 0 0` ([#3338](https://github.com/redis/go-redis/pull/3338)) +- Added `DB` option support in `NewFailoverClusterClient` ([#3342](https://github.com/redis/go-redis/pull/3342)) +- Added `nil` check for the options when creating a client ([#3363](https://github.com/redis/go-redis/pull/3363)) + +## 🐛 Bug Fixes +- Fixed `PubSub` concurrency safety issues ([#3360](https://github.com/redis/go-redis/pull/3360)) +- Fixed panic caused when argument is `nil` ([#3353](https://github.com/redis/go-redis/pull/3353)) +- Improved error handling when fetching master node from sentinels ([#3349](https://github.com/redis/go-redis/pull/3349)) +- Fixed connection pool timeout issues and increased retries ([#3298](https://github.com/redis/go-redis/pull/3298)) +- Fixed context cancellation error leading to connection spikes on Primary instances ([#3190](https://github.com/redis/go-redis/pull/3190)) +- Fixed RedisCluster client to consider `MASTERDOWN` a retriable error ([#3164](https://github.com/redis/go-redis/pull/3164)) +- Fixed tracing to show complete commands instead of truncated versions ([#3290](https://github.com/redis/go-redis/pull/3290)) +- Fixed OpenTelemetry instrumentation to prevent multiple span reporting ([#3168](https://github.com/redis/go-redis/pull/3168)) +- Fixed `FT.Search` Limit argument and added `CountOnly` argument for limit 0 0 ([#3338](https://github.com/redis/go-redis/pull/3338)) +- Fixed missing command in interface ([#3344](https://github.com/redis/go-redis/pull/3344)) +- Fixed slot calculation for `COUNTKEYSINSLOT` command ([#3327](https://github.com/redis/go-redis/pull/3327)) +- Updated PubSub implementation with correct context ([#3329](https://github.com/redis/go-redis/pull/3329)) + +## 📚 Documentation +- Added hash search examples ([#3357](https://github.com/redis/go-redis/pull/3357)) +- Fixed documentation comments ([#3351](https://github.com/redis/go-redis/pull/3351)) +- Added `CountOnly` search example ([#3345](https://github.com/redis/go-redis/pull/3345)) +- Added examples for list commands: `LLEN`, `LPOP`, `LPUSH`, `LRANGE`, `RPOP`, `RPUSH` ([#3234](https://github.com/redis/go-redis/pull/3234)) +- Added `SADD` and `SMEMBERS` command examples ([#3242](https://github.com/redis/go-redis/pull/3242)) +- Updated `README.md` to use Redis Discord guild ([#3331](https://github.com/redis/go-redis/pull/3331)) +- Updated `HExpire` command documentation ([#3355](https://github.com/redis/go-redis/pull/3355)) +- Featured OpenTelemetry instrumentation more prominently ([#3316](https://github.com/redis/go-redis/pull/3316)) +- Updated `README.md` with additional information ([#310ce55](https://github.com/redis/go-redis/commit/310ce55)) + +## ⚡ Performance and Reliability +- Bound connection pool background dials to configured dial timeout ([#3089](https://github.com/redis/go-redis/pull/3089)) +- Ensured context isn't exhausted via concurrent query ([#3334](https://github.com/redis/go-redis/pull/3334)) + +## 🔧 Dependencies and Infrastructure +- Updated testing image to Redis 8.0-RC2 ([#3361](https://github.com/redis/go-redis/pull/3361)) +- Enabled CI for Redis CE 8.0 ([#3274](https://github.com/redis/go-redis/pull/3274)) +- Updated various dependencies: + - Bumped golangci/golangci-lint-action from 6.5.0 to 7.0.0 ([#3354](https://github.com/redis/go-redis/pull/3354)) + - Bumped rojopolis/spellcheck-github-actions ([#3336](https://github.com/redis/go-redis/pull/3336)) + - Bumped golang.org/x/net in example/otel ([#3308](https://github.com/redis/go-redis/pull/3308)) +- Migrated golangci-lint configuration to v2 format ([#3354](https://github.com/redis/go-redis/pull/3354)) + +## ⚠️ Breaking Changes +- **Enabled Search DIALECT 2 by default** ([#3213](https://github.com/redis/go-redis/pull/3213)) +- Dropped RedisGears (Triggers and Functions) support ([#3321](https://github.com/redis/go-redis/pull/3321)) +- Dropped FT.PROFILE command that was never enabled ([#3323](https://github.com/redis/go-redis/pull/3323)) + +## 🔒 Security +- Fixed network error handling on SETINFO (CVE-2025-29923) ([#3295](https://github.com/redis/go-redis/pull/3295)) + +## 🧪 Testing +- Added integration tests for Redis 8 behavior changes in Redis Search ([#3337](https://github.com/redis/go-redis/pull/3337)) +- Added vector types INT8 and UINT8 tests ([#3299](https://github.com/redis/go-redis/pull/3299)) +- Added test codes for search_commands.go ([#3285](https://github.com/redis/go-redis/pull/3285)) +- Fixed example test sorting ([#3292](https://github.com/redis/go-redis/pull/3292)) + +## 👥 Contributors + +We would like to thank all the contributors who made this release possible: + +[@alexander-menshchikov](https://github.com/alexander-menshchikov), [@EXPEbdodla](https://github.com/EXPEbdodla), [@afti](https://github.com/afti), [@dmaier-redislabs](https://github.com/dmaier-redislabs), [@four_leaf_clover](https://github.com/four_leaf_clover), [@alohaglenn](https://github.com/alohaglenn), [@gh73962](https://github.com/gh73962), [@justinmir](https://github.com/justinmir), [@LINKIWI](https://github.com/LINKIWI), [@liushuangbill](https://github.com/liushuangbill), [@golang88](https://github.com/golang88), [@gnpaone](https://github.com/gnpaone), [@ndyakov](https://github.com/ndyakov), [@nikolaydubina](https://github.com/nikolaydubina), [@oleglacto](https://github.com/oleglacto), [@andy-stark-redis](https://github.com/andy-stark-redis), [@rodneyosodo](https://github.com/rodneyosodo), [@dependabot](https://github.com/dependabot), [@rfyiamcool](https://github.com/rfyiamcool), [@frankxjkuang](https://github.com/frankxjkuang), [@fukua95](https://github.com/fukua95), [@soleymani-milad](https://github.com/soleymani-milad), [@ofekshenawa](https://github.com/ofekshenawa), [@khasanovbi](https://github.com/khasanovbi) + + +# Old Changelog +## Unreleased + +### Changed + +* `go-redis` won't skip span creation if the parent spans is not recording. ([#2980](https://github.com/redis/go-redis/issues/2980)) + Users can use the OpenTelemetry sampler to control the sampling behavior. + For instance, you can use the `ParentBased(NeverSample())` sampler from `go.opentelemetry.io/otel/sdk/trace` to keep + a similar behavior (drop orphan spans) of `go-redis` as before. + +## [9.0.5](https://github.com/redis/go-redis/compare/v9.0.4...v9.0.5) (2023-05-29) + + +### Features + +* Add ACL LOG ([#2536](https://github.com/redis/go-redis/issues/2536)) ([31ba855](https://github.com/redis/go-redis/commit/31ba855ddebc38fbcc69a75d9d4fb769417cf602)) +* add field protocol to setupClusterQueryParams ([#2600](https://github.com/redis/go-redis/issues/2600)) ([840c25c](https://github.com/redis/go-redis/commit/840c25cb6f320501886a82a5e75f47b491e46fbe)) +* add protocol option ([#2598](https://github.com/redis/go-redis/issues/2598)) ([3917988](https://github.com/redis/go-redis/commit/391798880cfb915c4660f6c3ba63e0c1a459e2af)) + + + +## [9.0.4](https://github.com/redis/go-redis/compare/v9.0.3...v9.0.4) (2023-05-01) + + +### Bug Fixes + +* reader float parser ([#2513](https://github.com/redis/go-redis/issues/2513)) ([46f2450](https://github.com/redis/go-redis/commit/46f245075e6e3a8bd8471f9ca67ea95fd675e241)) + + +### Features + +* add client info command ([#2483](https://github.com/redis/go-redis/issues/2483)) ([b8c7317](https://github.com/redis/go-redis/commit/b8c7317cc6af444603731f7017c602347c0ba61e)) +* no longer verify HELLO error messages ([#2515](https://github.com/redis/go-redis/issues/2515)) ([7b4f217](https://github.com/redis/go-redis/commit/7b4f2179cb5dba3d3c6b0c6f10db52b837c912c8)) +* read the structure to increase the judgment of the omitempty op… ([#2529](https://github.com/redis/go-redis/issues/2529)) ([37c057b](https://github.com/redis/go-redis/commit/37c057b8e597c5e8a0e372337f6a8ad27f6030af)) + + + +## [9.0.3](https://github.com/redis/go-redis/compare/v9.0.2...v9.0.3) (2023-04-02) + +### New Features + +- feat(scan): scan time.Time sets the default decoding (#2413) +- Add support for CLUSTER LINKS command (#2504) +- Add support for acl dryrun command (#2502) +- Add support for COMMAND GETKEYS & COMMAND GETKEYSANDFLAGS (#2500) +- Add support for LCS Command (#2480) +- Add support for BZMPOP (#2456) +- Adding support for ZMPOP command (#2408) +- Add support for LMPOP (#2440) +- feat: remove pool unused fields (#2438) +- Expiretime and PExpireTime (#2426) +- Implement `FUNCTION` group of commands (#2475) +- feat(zadd): add ZAddLT and ZAddGT (#2429) +- Add: Support for COMMAND LIST command (#2491) +- Add support for BLMPOP (#2442) +- feat: check pipeline.Do to prevent confusion with Exec (#2517) +- Function stats, function kill, fcall and fcall_ro (#2486) +- feat: Add support for CLUSTER SHARDS command (#2507) +- feat(cmd): support for adding byte,bit parameters to the bitpos command (#2498) + +### Fixed + +- fix: eval api cmd.SetFirstKeyPos (#2501) +- fix: limit the number of connections created (#2441) +- fixed #2462 v9 continue support dragonfly, it's Hello command return "NOAUTH Authentication required" error (#2479) +- Fix for internal/hscan/structmap.go:89:23: undefined: reflect.Pointer (#2458) +- fix: group lag can be null (#2448) + +### Maintenance + +- Updating to the latest version of redis (#2508) +- Allowing for running tests on a port other than the fixed 6380 (#2466) +- redis 7.0.8 in tests (#2450) +- docs: Update redisotel example for v9 (#2425) +- chore: update go mod, Upgrade golang.org/x/net version to 0.7.0 (#2476) +- chore: add Chinese translation (#2436) +- chore(deps): bump github.com/bsm/gomega from 1.20.0 to 1.26.0 (#2421) +- chore(deps): bump github.com/bsm/ginkgo/v2 from 2.5.0 to 2.7.0 (#2420) +- chore(deps): bump actions/setup-go from 3 to 4 (#2495) +- docs: add instructions for the HSet api (#2503) +- docs: add reading lag field comment (#2451) +- test: update go mod before testing(go mod tidy) (#2423) +- docs: fix comment typo (#2505) +- test: remove testify (#2463) +- refactor: change ListElementCmd to KeyValuesCmd. (#2443) +- fix(appendArg): appendArg case special type (#2489) + +## [9.0.2](https://github.com/redis/go-redis/compare/v9.0.1...v9.0.2) (2023-02-01) + +### Features + +* upgrade OpenTelemetry, use the new metrics API. ([#2410](https://github.com/redis/go-redis/issues/2410)) ([e29e42c](https://github.com/redis/go-redis/commit/e29e42cde2755ab910d04185025dc43ce6f59c65)) + +## v9 2023-01-30 + +### Breaking + +- Changed Pipelines to not be thread-safe any more. + +### Added + +- Added support for [RESP3](https://github.com/antirez/RESP3/blob/master/spec.md) protocol. It was + contributed by @monkey92t who has done the majority of work in this release. +- Added `ContextTimeoutEnabled` option that controls whether the client respects context timeouts + and deadlines. See + [Redis Timeouts](https://redis.uptrace.dev/guide/go-redis-debugging.html#timeouts) for details. +- Added `ParseClusterURL` to parse URLs into `ClusterOptions`, for example, + `redis://user:password@localhost:6789?dial_timeout=3&read_timeout=6s&addr=localhost:6790&addr=localhost:6791`. +- Added metrics instrumentation using `redisotel.IstrumentMetrics`. See + [documentation](https://redis.uptrace.dev/guide/go-redis-monitoring.html) +- Added `redis.HasErrorPrefix` to help working with errors. + +### Changed + +- Removed asynchronous cancellation based on the context timeout. It was racy in v8 and is + completely gone in v9. +- Reworked hook interface and added `DialHook`. +- Replaced `redisotel.NewTracingHook` with `redisotel.InstrumentTracing`. See + [example](example/otel) and + [documentation](https://redis.uptrace.dev/guide/go-redis-monitoring.html). +- Replaced `*redis.Z` with `redis.Z` since it is small enough to be passed as value without making + an allocation. +- Renamed the option `MaxConnAge` to `ConnMaxLifetime`. +- Renamed the option `IdleTimeout` to `ConnMaxIdleTime`. +- Removed connection reaper in favor of `MaxIdleConns`. +- Removed `WithContext` since `context.Context` can be passed directly as an arg. +- Removed `Pipeline.Close` since there is no real need to explicitly manage pipeline resources and + it can be safely reused via `sync.Pool` etc. `Pipeline.Discard` is still available if you want to + reset commands for some reason. + +### Fixed + +- Improved and fixed pipeline retries. +- As usually, added support for more commands and fixed some bugs. diff --git a/backend/vendor/github.com/redis/go-redis/v9/acl_commands.go b/backend/vendor/github.com/redis/go-redis/v9/acl_commands.go index 06847be2..0a8a195c 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/acl_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/acl_commands.go @@ -4,8 +4,24 @@ import "context" type ACLCmdable interface { ACLDryRun(ctx context.Context, username string, command ...interface{}) *StringCmd + ACLLog(ctx context.Context, count int64) *ACLLogCmd ACLLogReset(ctx context.Context) *StatusCmd + + ACLGenPass(ctx context.Context, bit int) *StringCmd + + ACLSetUser(ctx context.Context, username string, rules ...string) *StatusCmd + ACLDelUser(ctx context.Context, username string) *IntCmd + ACLUsers(ctx context.Context) *StringSliceCmd + ACLWhoAmI(ctx context.Context) *StringCmd + ACLList(ctx context.Context) *StringSliceCmd + + ACLCat(ctx context.Context) *StringSliceCmd + ACLCatArgs(ctx context.Context, options *ACLCatArgs) *StringSliceCmd +} + +type ACLCatArgs struct { + Category string } func (c cmdable) ACLDryRun(ctx context.Context, username string, command ...interface{}) *StringCmd { @@ -33,3 +49,68 @@ func (c cmdable) ACLLogReset(ctx context.Context) *StatusCmd { _ = c(ctx, cmd) return cmd } + +func (c cmdable) ACLDelUser(ctx context.Context, username string) *IntCmd { + cmd := NewIntCmd(ctx, "acl", "deluser", username) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLSetUser(ctx context.Context, username string, rules ...string) *StatusCmd { + args := make([]interface{}, 3+len(rules)) + args[0] = "acl" + args[1] = "setuser" + args[2] = username + for i, rule := range rules { + args[i+3] = rule + } + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLGenPass(ctx context.Context, bit int) *StringCmd { + args := make([]interface{}, 0, 3) + args = append(args, "acl", "genpass") + if bit > 0 { + args = append(args, bit) + } + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLUsers(ctx context.Context) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "acl", "users") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLWhoAmI(ctx context.Context) *StringCmd { + cmd := NewStringCmd(ctx, "acl", "whoami") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLList(ctx context.Context) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "acl", "list") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLCat(ctx context.Context) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "acl", "cat") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLCatArgs(ctx context.Context, options *ACLCatArgs) *StringSliceCmd { + // if there is a category passed, build new cmd, if there isn't - use the ACLCat method + if options != nil && options.Category != "" { + cmd := NewStringSliceCmd(ctx, "acl", "cat", options.Category) + _ = c(ctx, cmd) + return cmd + } + + return c.ACLCat(ctx) +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/adapters.go b/backend/vendor/github.com/redis/go-redis/v9/adapters.go new file mode 100644 index 00000000..4146153b --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/adapters.go @@ -0,0 +1,111 @@ +package redis + +import ( + "context" + "errors" + "net" + "time" + + "github.com/redis/go-redis/v9/internal/interfaces" + "github.com/redis/go-redis/v9/push" +) + +// ErrInvalidCommand is returned when an invalid command is passed to ExecuteCommand. +var ErrInvalidCommand = errors.New("invalid command type") + +// ErrInvalidPool is returned when the pool type is not supported. +var ErrInvalidPool = errors.New("invalid pool type") + +// newClientAdapter creates a new client adapter for regular Redis clients. +func newClientAdapter(client *baseClient) interfaces.ClientInterface { + return &clientAdapter{client: client} +} + +// clientAdapter adapts a Redis client to implement interfaces.ClientInterface. +type clientAdapter struct { + client *baseClient +} + +// GetOptions returns the client options. +func (ca *clientAdapter) GetOptions() interfaces.OptionsInterface { + return &optionsAdapter{options: ca.client.opt} +} + +// GetPushProcessor returns the client's push notification processor. +func (ca *clientAdapter) GetPushProcessor() interfaces.NotificationProcessor { + return &pushProcessorAdapter{processor: ca.client.pushProcessor} +} + +// optionsAdapter adapts Redis options to implement interfaces.OptionsInterface. +type optionsAdapter struct { + options *Options +} + +// GetReadTimeout returns the read timeout. +func (oa *optionsAdapter) GetReadTimeout() time.Duration { + return oa.options.ReadTimeout +} + +// GetWriteTimeout returns the write timeout. +func (oa *optionsAdapter) GetWriteTimeout() time.Duration { + return oa.options.WriteTimeout +} + +// GetNetwork returns the network type. +func (oa *optionsAdapter) GetNetwork() string { + return oa.options.Network +} + +// GetAddr returns the connection address. +func (oa *optionsAdapter) GetAddr() string { + return oa.options.Addr +} + +// IsTLSEnabled returns true if TLS is enabled. +func (oa *optionsAdapter) IsTLSEnabled() bool { + return oa.options.TLSConfig != nil +} + +// GetProtocol returns the protocol version. +func (oa *optionsAdapter) GetProtocol() int { + return oa.options.Protocol +} + +// GetPoolSize returns the connection pool size. +func (oa *optionsAdapter) GetPoolSize() int { + return oa.options.PoolSize +} + +// NewDialer returns a new dialer function for the connection. +func (oa *optionsAdapter) NewDialer() func(context.Context) (net.Conn, error) { + baseDialer := oa.options.NewDialer() + return func(ctx context.Context) (net.Conn, error) { + // Extract network and address from the options + network := oa.options.Network + addr := oa.options.Addr + return baseDialer(ctx, network, addr) + } +} + +// pushProcessorAdapter adapts a push.NotificationProcessor to implement interfaces.NotificationProcessor. +type pushProcessorAdapter struct { + processor push.NotificationProcessor +} + +// RegisterHandler registers a handler for a specific push notification name. +func (ppa *pushProcessorAdapter) RegisterHandler(pushNotificationName string, handler interface{}, protected bool) error { + if pushHandler, ok := handler.(push.NotificationHandler); ok { + return ppa.processor.RegisterHandler(pushNotificationName, pushHandler, protected) + } + return errors.New("handler must implement push.NotificationHandler") +} + +// UnregisterHandler removes a handler for a specific push notification name. +func (ppa *pushProcessorAdapter) UnregisterHandler(pushNotificationName string) error { + return ppa.processor.UnregisterHandler(pushNotificationName) +} + +// GetHandler returns the handler for a specific push notification name. +func (ppa *pushProcessorAdapter) GetHandler(pushNotificationName string) interface{} { + return ppa.processor.GetHandler(pushNotificationName) +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/auth/auth.go b/backend/vendor/github.com/redis/go-redis/v9/auth/auth.go new file mode 100644 index 00000000..1f5c8022 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/auth/auth.go @@ -0,0 +1,61 @@ +// Package auth package provides authentication-related interfaces and types. +// It also includes a basic implementation of credentials using username and password. +package auth + +// StreamingCredentialsProvider is an interface that defines the methods for a streaming credentials provider. +// It is used to provide credentials for authentication. +// The CredentialsListener is used to receive updates when the credentials change. +type StreamingCredentialsProvider interface { + // Subscribe subscribes to the credentials provider for updates. + // It returns the current credentials, a cancel function to unsubscribe from the provider, + // and an error if any. + // TODO(ndyakov): Should we add context to the Subscribe method? + Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error) +} + +// UnsubscribeFunc is a function that is used to cancel the subscription to the credentials provider. +// It is used to unsubscribe from the provider when the credentials are no longer needed. +type UnsubscribeFunc func() error + +// CredentialsListener is an interface that defines the methods for a credentials listener. +// It is used to receive updates when the credentials change. +// The OnNext method is called when the credentials change. +// The OnError method is called when an error occurs while requesting the credentials. +type CredentialsListener interface { + OnNext(credentials Credentials) + OnError(err error) +} + +// Credentials is an interface that defines the methods for credentials. +// It is used to provide the credentials for authentication. +type Credentials interface { + // BasicAuth returns the username and password for basic authentication. + BasicAuth() (username string, password string) + // RawCredentials returns the raw credentials as a string. + // This can be used to extract the username and password from the raw credentials or + // additional information if present in the token. + RawCredentials() string +} + +type basicAuth struct { + username string + password string +} + +// RawCredentials returns the raw credentials as a string. +func (b *basicAuth) RawCredentials() string { + return b.username + ":" + b.password +} + +// BasicAuth returns the username and password for basic authentication. +func (b *basicAuth) BasicAuth() (username string, password string) { + return b.username, b.password +} + +// NewBasicCredentials creates a new Credentials object from the given username and password. +func NewBasicCredentials(username, password string) Credentials { + return &basicAuth{ + username: username, + password: password, + } +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/auth/reauth_credentials_listener.go b/backend/vendor/github.com/redis/go-redis/v9/auth/reauth_credentials_listener.go new file mode 100644 index 00000000..f4b31983 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/auth/reauth_credentials_listener.go @@ -0,0 +1,47 @@ +package auth + +// ReAuthCredentialsListener is a struct that implements the CredentialsListener interface. +// It is used to re-authenticate the credentials when they are updated. +// It contains: +// - reAuth: a function that takes the new credentials and returns an error if any. +// - onErr: a function that takes an error and handles it. +type ReAuthCredentialsListener struct { + reAuth func(credentials Credentials) error + onErr func(err error) +} + +// OnNext is called when the credentials are updated. +// It calls the reAuth function with the new credentials. +// If the reAuth function returns an error, it calls the onErr function with the error. +func (c *ReAuthCredentialsListener) OnNext(credentials Credentials) { + if c.reAuth == nil { + return + } + + err := c.reAuth(credentials) + if err != nil { + c.OnError(err) + } +} + +// OnError is called when an error occurs. +// It can be called from both the credentials provider and the reAuth function. +func (c *ReAuthCredentialsListener) OnError(err error) { + if c.onErr == nil { + return + } + + c.onErr(err) +} + +// NewReAuthCredentialsListener creates a new ReAuthCredentialsListener. +// Implements the auth.CredentialsListener interface. +func NewReAuthCredentialsListener(reAuth func(credentials Credentials) error, onErr func(err error)) *ReAuthCredentialsListener { + return &ReAuthCredentialsListener{ + reAuth: reAuth, + onErr: onErr, + } +} + +// Ensure ReAuthCredentialsListener implements the CredentialsListener interface. +var _ CredentialsListener = (*ReAuthCredentialsListener)(nil) \ No newline at end of file diff --git a/backend/vendor/github.com/redis/go-redis/v9/bitmap_commands.go b/backend/vendor/github.com/redis/go-redis/v9/bitmap_commands.go index a2155828..86aa9b7e 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/bitmap_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/bitmap_commands.go @@ -12,6 +12,10 @@ type BitMapCmdable interface { BitOpAnd(ctx context.Context, destKey string, keys ...string) *IntCmd BitOpOr(ctx context.Context, destKey string, keys ...string) *IntCmd BitOpXor(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpDiff(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpDiff1(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpAndOr(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpOne(ctx context.Context, destKey string, keys ...string) *IntCmd BitOpNot(ctx context.Context, destKey string, key string) *IntCmd BitPos(ctx context.Context, key string, bit int64, pos ...int64) *IntCmd BitPosSpan(ctx context.Context, key string, bit int8, start, end int64, span string) *IntCmd @@ -78,22 +82,50 @@ func (c cmdable) bitOp(ctx context.Context, op, destKey string, keys ...string) return cmd } +// BitOpAnd creates a new bitmap in which users are members of all given bitmaps func (c cmdable) BitOpAnd(ctx context.Context, destKey string, keys ...string) *IntCmd { return c.bitOp(ctx, "and", destKey, keys...) } +// BitOpOr creates a new bitmap in which users are member of at least one given bitmap func (c cmdable) BitOpOr(ctx context.Context, destKey string, keys ...string) *IntCmd { return c.bitOp(ctx, "or", destKey, keys...) } +// BitOpXor creates a new bitmap in which users are the result of XORing all given bitmaps func (c cmdable) BitOpXor(ctx context.Context, destKey string, keys ...string) *IntCmd { return c.bitOp(ctx, "xor", destKey, keys...) } +// BitOpNot creates a new bitmap in which users are not members of a given bitmap func (c cmdable) BitOpNot(ctx context.Context, destKey string, key string) *IntCmd { return c.bitOp(ctx, "not", destKey, key) } +// BitOpDiff creates a new bitmap in which users are members of bitmap X but not of any of bitmaps Y1, Y2, … +// Introduced with Redis 8.2 +func (c cmdable) BitOpDiff(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "diff", destKey, keys...) +} + +// BitOpDiff1 creates a new bitmap in which users are members of one or more of bitmaps Y1, Y2, … but not members of bitmap X +// Introduced with Redis 8.2 +func (c cmdable) BitOpDiff1(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "diff1", destKey, keys...) +} + +// BitOpAndOr creates a new bitmap in which users are members of bitmap X and also members of one or more of bitmaps Y1, Y2, … +// Introduced with Redis 8.2 +func (c cmdable) BitOpAndOr(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "andor", destKey, keys...) +} + +// BitOpOne creates a new bitmap in which users are members of exactly one of the given bitmaps +// Introduced with Redis 8.2 +func (c cmdable) BitOpOne(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "one", destKey, keys...) +} + // BitPos is an API before Redis version 7.0, cmd: bitpos key bit start end // if you need the `byte | bit` parameter, please use `BitPosSpan`. func (c cmdable) BitPos(ctx context.Context, key string, bit int64, pos ...int64) *IntCmd { @@ -109,7 +141,9 @@ func (c cmdable) BitPos(ctx context.Context, key string, bit int64, pos ...int64 args[3] = pos[0] args[4] = pos[1] default: - panic("too many arguments") + cmd := NewIntCmd(ctx) + cmd.SetErr(errors.New("too many arguments")) + return cmd } cmd := NewIntCmd(ctx, args...) _ = c(ctx, cmd) @@ -150,7 +184,9 @@ func (c cmdable) BitFieldRO(ctx context.Context, key string, values ...interface args[0] = "BITFIELD_RO" args[1] = key if len(values)%2 != 0 { - panic("BitFieldRO: invalid number of arguments, must be even") + c := NewIntSliceCmd(ctx) + c.SetErr(errors.New("BitFieldRO: invalid number of arguments, must be even")) + return c } for i := 0; i < len(values); i += 2 { args = append(args, "GET", values[i], values[i+1]) diff --git a/backend/vendor/github.com/redis/go-redis/v9/cluster_commands.go b/backend/vendor/github.com/redis/go-redis/v9/cluster_commands.go index 0caf0977..4857b01e 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/cluster_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/cluster_commands.go @@ -4,6 +4,7 @@ import "context" type ClusterCmdable interface { ClusterMyShardID(ctx context.Context) *StringCmd + ClusterMyID(ctx context.Context) *StringCmd ClusterSlots(ctx context.Context) *ClusterSlotsCmd ClusterShards(ctx context.Context) *ClusterShardsCmd ClusterLinks(ctx context.Context) *ClusterLinksCmd @@ -35,6 +36,12 @@ func (c cmdable) ClusterMyShardID(ctx context.Context) *StringCmd { return cmd } +func (c cmdable) ClusterMyID(ctx context.Context) *StringCmd { + cmd := NewStringCmd(ctx, "cluster", "myid") + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) ClusterSlots(ctx context.Context) *ClusterSlotsCmd { cmd := NewClusterSlotsCmd(ctx, "cluster", "slots") _ = c(ctx, cmd) diff --git a/backend/vendor/github.com/redis/go-redis/v9/command.go b/backend/vendor/github.com/redis/go-redis/v9/command.go index f3d0e49b..2dbc2ad8 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/command.go +++ b/backend/vendor/github.com/redis/go-redis/v9/command.go @@ -17,6 +17,56 @@ import ( "github.com/redis/go-redis/v9/internal/util" ) +// keylessCommands contains Redis commands that have empty key specifications (9th slot empty) +// Only includes core Redis commands, excludes FT.*, ts.*, timeseries.*, search.* and subcommands +var keylessCommands = map[string]struct{}{ + "acl": {}, + "asking": {}, + "auth": {}, + "bgrewriteaof": {}, + "bgsave": {}, + "client": {}, + "cluster": {}, + "config": {}, + "debug": {}, + "discard": {}, + "echo": {}, + "exec": {}, + "failover": {}, + "function": {}, + "hello": {}, + "latency": {}, + "lolwut": {}, + "module": {}, + "monitor": {}, + "multi": {}, + "pfselftest": {}, + "ping": {}, + "psubscribe": {}, + "psync": {}, + "publish": {}, + "pubsub": {}, + "punsubscribe": {}, + "quit": {}, + "readonly": {}, + "readwrite": {}, + "replconf": {}, + "replicaof": {}, + "role": {}, + "save": {}, + "script": {}, + "select": {}, + "shutdown": {}, + "slaveof": {}, + "slowlog": {}, + "subscribe": {}, + "swapdb": {}, + "sync": {}, + "unsubscribe": {}, + "unwatch": {}, + "wait": {}, +} + type Cmder interface { // command name. // e.g. "set k v ex 10" -> "set", "cluster info" -> "cluster". @@ -75,12 +125,22 @@ func writeCmd(wr *proto.Writer, cmd Cmder) error { return wr.WriteArgs(cmd.Args()) } +// cmdFirstKeyPos returns the position of the first key in the command's arguments. +// If the command does not have a key, it returns 0. +// TODO: Use the data in CommandInfo to determine the first key position. func cmdFirstKeyPos(cmd Cmder) int { if pos := cmd.firstKeyPos(); pos != 0 { return int(pos) } - switch cmd.Name() { + name := cmd.Name() + + // first check if the command is keyless + if _, ok := keylessCommands[name]; ok { + return 0 + } + + switch name { case "eval", "evalsha", "eval_ro", "evalsha_ro": if cmd.stringArg(2) != "0" { return 3 @@ -639,6 +699,68 @@ func (cmd *IntCmd) readReply(rd *proto.Reader) (err error) { //------------------------------------------------------------------------------ +// DigestCmd is a command that returns a uint64 xxh3 hash digest. +// +// This command is specifically designed for the Redis DIGEST command, +// which returns the xxh3 hash of a key's value as a hex string. +// The hex string is automatically parsed to a uint64 value. +// +// The digest can be used for optimistic locking with SetIFDEQ, SetIFDNE, +// and DelExArgs commands. +// +// For examples of client-side digest generation and usage patterns, see: +// example/digest-optimistic-locking/ +// +// Redis 8.4+. See https://redis.io/commands/digest/ +type DigestCmd struct { + baseCmd + + val uint64 +} + +var _ Cmder = (*DigestCmd)(nil) + +func NewDigestCmd(ctx context.Context, args ...interface{}) *DigestCmd { + return &DigestCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *DigestCmd) SetVal(val uint64) { + cmd.val = val +} + +func (cmd *DigestCmd) Val() uint64 { + return cmd.val +} + +func (cmd *DigestCmd) Result() (uint64, error) { + return cmd.val, cmd.err +} + +func (cmd *DigestCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *DigestCmd) readReply(rd *proto.Reader) (err error) { + // Redis DIGEST command returns a hex string (e.g., "a1b2c3d4e5f67890") + // We parse it as a uint64 xxh3 hash value + var hexStr string + hexStr, err = rd.ReadString() + if err != nil { + return err + } + + // Parse hex string to uint64 + cmd.val, err = strconv.ParseUint(hexStr, 16, 64) + return err +} + +//------------------------------------------------------------------------------ + type IntSliceCmd struct { baseCmd @@ -1405,27 +1527,64 @@ func (cmd *MapStringSliceInterfaceCmd) Val() map[string][]interface{} { } func (cmd *MapStringSliceInterfaceCmd) readReply(rd *proto.Reader) (err error) { - n, err := rd.ReadMapLen() + readType, err := rd.PeekReplyType() if err != nil { return err } - cmd.val = make(map[string][]interface{}, n) - for i := 0; i < n; i++ { - k, err := rd.ReadString() + + cmd.val = make(map[string][]interface{}) + + switch readType { + case proto.RespMap: + n, err := rd.ReadMapLen() if err != nil { return err } - nn, err := rd.ReadArrayLen() - if err != nil { - return err - } - cmd.val[k] = make([]interface{}, nn) - for j := 0; j < nn; j++ { - value, err := rd.ReadReply() + for i := 0; i < n; i++ { + k, err := rd.ReadString() if err != nil { return err } - cmd.val[k][j] = value + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val[k] = make([]interface{}, nn) + for j := 0; j < nn; j++ { + value, err := rd.ReadReply() + if err != nil { + return err + } + cmd.val[k][j] = value + } + } + case proto.RespArray: + // RESP2 response + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + for i := 0; i < n; i++ { + // Each entry in this array is itself an array with key details + itemLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + + key, err := rd.ReadString() + if err != nil { + return err + } + cmd.val[key] = make([]interface{}, 0, itemLen-1) + for j := 1; j < itemLen; j++ { + // Read the inner array for timestamp-value pairs + data, err := rd.ReadReply() + if err != nil { + return err + } + cmd.val[key] = append(cmd.val[key], data) + } } } @@ -1489,6 +1648,12 @@ func (cmd *StringStructMapCmd) readReply(rd *proto.Reader) error { type XMessage struct { ID string Values map[string]interface{} + // MillisElapsedFromDelivery is the number of milliseconds since the entry was last delivered. + // Only populated when using XREADGROUP with CLAIM argument for claimed entries. + MillisElapsedFromDelivery int64 + // DeliveredCount is the number of times the entry was delivered. + // Only populated when using XREADGROUP with CLAIM argument for claimed entries. + DeliveredCount int64 } type XMessageSliceCmd struct { @@ -1545,10 +1710,16 @@ func readXMessageSlice(rd *proto.Reader) ([]XMessage, error) { } func readXMessage(rd *proto.Reader) (XMessage, error) { - if err := rd.ReadFixedArrayLen(2); err != nil { + // Read array length can be 2 or 4 (with CLAIM metadata) + n, err := rd.ReadArrayLen() + if err != nil { return XMessage{}, err } + if n != 2 && n != 4 { + return XMessage{}, fmt.Errorf("redis: got %d elements in the XMessage array, expected 2 or 4", n) + } + id, err := rd.ReadString() if err != nil { return XMessage{}, err @@ -1561,10 +1732,24 @@ func readXMessage(rd *proto.Reader) (XMessage, error) { } } - return XMessage{ + msg := XMessage{ ID: id, Values: v, - }, nil + } + + if n == 4 { + msg.MillisElapsedFromDelivery, err = rd.ReadInt() + if err != nil { + return XMessage{}, err + } + + msg.DeliveredCount, err = rd.ReadInt() + if err != nil { + return XMessage{}, err + } + } + + return msg, nil } func stringInterfaceMapParser(rd *proto.Reader) (map[string]interface{}, error) { @@ -2067,7 +2252,9 @@ type XInfoGroup struct { Pending int64 LastDeliveredID string EntriesRead int64 - Lag int64 + // Lag represents the number of pending messages in the stream not yet + // delivered to this consumer group. Returns -1 when the lag cannot be determined. + Lag int64 } var _ Cmder = (*XInfoGroupsCmd)(nil) @@ -2150,8 +2337,11 @@ func (cmd *XInfoGroupsCmd) readReply(rd *proto.Reader) error { // lag: the number of entries in the stream that are still waiting to be delivered // to the group's consumers, or a NULL(Nil) when that number can't be determined. + // In that case, we return -1. if err != nil && err != Nil { return err + } else if err == Nil { + group.Lag = -1 } default: return fmt.Errorf("redis: unexpected key %q in XINFO GROUPS reply", key) @@ -3542,15 +3732,14 @@ func (c *cmdsInfoCache) Get(ctx context.Context) (map[string]*CommandInfo, error return err } + lowerCmds := make(map[string]*CommandInfo, len(cmds)) + // Extensions have cmd names in upper case. Convert them to lower case. for k, v := range cmds { - lower := internal.ToLower(k) - if lower != k { - cmds[lower] = v - } + lowerCmds[internal.ToLower(k)] = v } - c.cmds = cmds + c.cmds = lowerCmds return nil }) return c.cmds, err @@ -3668,6 +3857,83 @@ func (cmd *SlowLogCmd) readReply(rd *proto.Reader) error { //----------------------------------------------------------------------- +type Latency struct { + Name string + Time time.Time + Latest time.Duration + Max time.Duration +} + +type LatencyCmd struct { + baseCmd + val []Latency +} + +var _ Cmder = (*LatencyCmd)(nil) + +func NewLatencyCmd(ctx context.Context, args ...interface{}) *LatencyCmd { + return &LatencyCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *LatencyCmd) SetVal(val []Latency) { + cmd.val = val +} + +func (cmd *LatencyCmd) Val() []Latency { + return cmd.val +} + +func (cmd *LatencyCmd) Result() ([]Latency, error) { + return cmd.val, cmd.err +} + +func (cmd *LatencyCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *LatencyCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]Latency, n) + for i := 0; i < len(cmd.val); i++ { + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + if nn < 3 { + return fmt.Errorf("redis: got %d elements in latency get, expected at least 3", nn) + } + if cmd.val[i].Name, err = rd.ReadString(); err != nil { + return err + } + createdAt, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[i].Time = time.Unix(createdAt, 0) + latest, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[i].Latest = time.Duration(latest) * time.Millisecond + maximum, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[i].Max = time.Duration(maximum) * time.Millisecond + } + return nil +} + +//----------------------------------------------------------------------- + type MapStringInterfaceCmd struct { baseCmd @@ -3795,7 +4061,8 @@ func (cmd *MapStringStringSliceCmd) readReply(rd *proto.Reader) error { } // ----------------------------------------------------------------------- -// MapStringInterfaceCmd represents a command that returns a map of strings to interface{}. + +// MapMapStringInterfaceCmd represents a command that returns a map of strings to interface{}. type MapMapStringInterfaceCmd struct { baseCmd val map[string]interface{} @@ -3826,30 +4093,48 @@ func (cmd *MapMapStringInterfaceCmd) Val() map[string]interface{} { return cmd.val } +// readReply will try to parse the reply from the proto.Reader for both resp2 and resp3 func (cmd *MapMapStringInterfaceCmd) readReply(rd *proto.Reader) (err error) { - n, err := rd.ReadArrayLen() + data, err := rd.ReadReply() if err != nil { return err } + resultMap := map[string]interface{}{} - data := make(map[string]interface{}, n/2) - for i := 0; i < n; i += 2 { - _, err := rd.ReadArrayLen() - if err != nil { - cmd.err = err + switch midResponse := data.(type) { + case map[interface{}]interface{}: // resp3 will return map + for k, v := range midResponse { + stringKey, ok := k.(string) + if !ok { + return fmt.Errorf("redis: invalid map key %#v", k) + } + resultMap[stringKey] = v } - key, err := rd.ReadString() - if err != nil { - cmd.err = err + case []interface{}: // resp2 will return array of arrays + n := len(midResponse) + for i := 0; i < n; i++ { + finalArr, ok := midResponse[i].([]interface{}) // final array that we need to transform to map + if !ok { + return fmt.Errorf("redis: unexpected response %#v", data) + } + m := len(finalArr) + if m%2 != 0 { // since this should be map, keys should be even number + return fmt.Errorf("redis: unexpected response %#v", data) + } + + for j := 0; j < m; j += 2 { + stringKey, ok := finalArr[j].(string) // the first one + if !ok { + return fmt.Errorf("redis: invalid map key %#v", finalArr[i]) + } + resultMap[stringKey] = finalArr[j+1] // second one is value + } } - value, err := rd.ReadString() - if err != nil { - cmd.err = err - } - data[key] = value + default: + return fmt.Errorf("redis: unexpected response %#v", data) } - cmd.val = data + cmd.val = resultMap return nil } @@ -5078,6 +5363,10 @@ type ClientInfo struct { OutputListLength int // oll, output list length (replies are queued in this list when the buffer is full) OutputMemory int // omem, output buffer memory usage TotalMemory int // tot-mem, total memory consumed by this client in its various buffers + TotalNetIn int // tot-net-in, total network input + TotalNetOut int // tot-net-out, total network output + TotalCmds int // tot-cmds, total number of commands processed + IoThread int // io-thread id Events string // file descriptor events (see below) LastCmd string // cmd, last command played User string // the authenticated username of the client @@ -5242,6 +5531,12 @@ func parseClientInfo(txt string) (info *ClientInfo, err error) { info.OutputMemory, err = strconv.Atoi(val) case "tot-mem": info.TotalMemory, err = strconv.Atoi(val) + case "tot-net-in": + info.TotalNetIn, err = strconv.Atoi(val) + case "tot-net-out": + info.TotalNetOut, err = strconv.Atoi(val) + case "tot-cmds": + info.TotalCmds, err = strconv.Atoi(val) case "events": info.Events = val case "cmd": @@ -5256,6 +5551,8 @@ func parseClientInfo(txt string) (info *ClientInfo, err error) { info.LibName = val case "lib-ver": info.LibVer = val + case "io-thread": + info.IoThread, err = strconv.Atoi(val) default: return nil, fmt.Errorf("redis: unexpected client info key(%s)", key) } @@ -5435,8 +5732,6 @@ func (cmd *InfoCmd) readReply(rd *proto.Reader) error { section := "" scanner := bufio.NewScanner(strings.NewReader(val)) - moduleRe := regexp.MustCompile(`module:name=(.+?),(.+)$`) - for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "#") { @@ -5447,6 +5742,7 @@ func (cmd *InfoCmd) readReply(rd *proto.Reader) error { cmd.val[section] = make(map[string]string) } else if line != "" { if section == "Modules" { + moduleRe := regexp.MustCompile(`module:name=(.+?),(.+)$`) kv := moduleRe.FindStringSubmatch(line) if len(kv) == 3 { cmd.val[section][kv[1]] = kv[2] @@ -5557,3 +5853,59 @@ func (cmd *MonitorCmd) Stop() { defer cmd.mu.Unlock() cmd.status = monitorStatusStop } + +type VectorScoreSliceCmd struct { + baseCmd + + val []VectorScore +} + +var _ Cmder = (*VectorScoreSliceCmd)(nil) + +func NewVectorInfoSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCmd { + return &VectorScoreSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *VectorScoreSliceCmd) SetVal(val []VectorScore) { + cmd.val = val +} + +func (cmd *VectorScoreSliceCmd) Val() []VectorScore { + return cmd.val +} + +func (cmd *VectorScoreSliceCmd) Result() ([]VectorScore, error) { + return cmd.val, cmd.err +} + +func (cmd *VectorScoreSliceCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *VectorScoreSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + + cmd.val = make([]VectorScore, n) + for i := 0; i < n; i++ { + name, err := rd.ReadString() + if err != nil { + return err + } + cmd.val[i].Name = name + + score, err := rd.ReadFloat() + if err != nil { + return err + } + cmd.val[i].Score = score + } + return nil +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/commands.go b/backend/vendor/github.com/redis/go-redis/v9/commands.go index 034daa23..daee5505 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/commands.go @@ -81,6 +81,8 @@ func appendArg(dst []interface{}, arg interface{}) []interface{} { return dst case time.Time, time.Duration, encoding.BinaryMarshaler, net.IP: return append(dst, arg) + case nil: + return dst default: // scan struct field v := reflect.ValueOf(arg) @@ -153,6 +155,12 @@ func isEmptyValue(v reflect.Value) bool { return v.Float() == 0 case reflect.Interface, reflect.Pointer: return v.IsNil() + case reflect.Struct: + if v.Type() == reflect.TypeOf(time.Time{}) { + return v.IsZero() + } + // Only supports the struct time.Time, + // subsequent iterations will follow the func Scan support decoder. } return false } @@ -185,6 +193,7 @@ type Cmdable interface { ClientID(ctx context.Context) *IntCmd ClientUnblock(ctx context.Context, id int64) *IntCmd ClientUnblockWithError(ctx context.Context, id int64) *IntCmd + ClientMaintNotifications(ctx context.Context, enabled bool, endpointType string) *StatusCmd ConfigGet(ctx context.Context, parameter string) *MapStringStringCmd ConfigResetStat(ctx context.Context) *StatusCmd ConfigSet(ctx context.Context, parameter, value string) *StatusCmd @@ -202,16 +211,19 @@ type Cmdable interface { ShutdownNoSave(ctx context.Context) *StatusCmd SlaveOf(ctx context.Context, host, port string) *StatusCmd SlowLogGet(ctx context.Context, num int64) *SlowLogCmd + SlowLogLen(ctx context.Context) *IntCmd + SlowLogReset(ctx context.Context) *StatusCmd Time(ctx context.Context) *TimeCmd DebugObject(ctx context.Context, key string) *StringCmd MemoryUsage(ctx context.Context, key string, samples ...int) *IntCmd + Latency(ctx context.Context) *LatencyCmd + LatencyReset(ctx context.Context, events ...interface{}) *StatusCmd ModuleLoadex(ctx context.Context, conf *ModuleLoadexConfig) *StringCmd ACLCmdable BitMapCmdable ClusterCmdable - GearsCmdable GenericCmdable GeoCmdable HashCmdable @@ -227,6 +239,7 @@ type Cmdable interface { StreamCmdable TimeseriesCmdable JSONCmdable + VectorSetCmdable } type StatefulCmdable interface { @@ -245,6 +258,7 @@ var ( _ Cmdable = (*Tx)(nil) _ Cmdable = (*Ring)(nil) _ Cmdable = (*ClusterClient)(nil) + _ Cmdable = (*Pipeline)(nil) ) type cmdable func(ctx context.Context, cmd Cmder) error @@ -331,7 +345,7 @@ func (info LibraryInfo) Validate() error { return nil } -// Hello Set the resp protocol used. +// Hello sets the resp protocol used. func (c statefulCmdable) Hello(ctx context.Context, ver int, username, password, clientName string, ) *MapStringInterfaceCmd { @@ -423,6 +437,12 @@ func (c cmdable) Ping(ctx context.Context) *StatusCmd { return cmd } +func (c cmdable) Do(ctx context.Context, args ...interface{}) *Cmd { + cmd := NewCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) Quit(_ context.Context) *StatusCmd { panic("not implemented") } @@ -504,6 +524,23 @@ func (c cmdable) ClientInfo(ctx context.Context) *ClientInfoCmd { return cmd } +// ClientMaintNotifications enables or disables maintenance notifications for maintenance upgrades. +// When enabled, the client will receive push notifications about Redis maintenance events. +func (c cmdable) ClientMaintNotifications(ctx context.Context, enabled bool, endpointType string) *StatusCmd { + args := []interface{}{"client", "maint_notifications"} + if enabled { + if endpointType == "" { + endpointType = "none" + } + args = append(args, "on", "moving-endpoint-type", endpointType) + } else { + args = append(args, "off") + } + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + // ------------------------------------------------------------------------------------------------ func (c cmdable) ConfigGet(ctx context.Context, parameter string) *MapStringStringCmd { @@ -640,6 +677,34 @@ func (c cmdable) SlowLogGet(ctx context.Context, num int64) *SlowLogCmd { return cmd } +func (c cmdable) SlowLogLen(ctx context.Context) *IntCmd { + cmd := NewIntCmd(ctx, "slowlog", "len") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) SlowLogReset(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "slowlog", "reset") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) Latency(ctx context.Context) *LatencyCmd { + cmd := NewLatencyCmd(ctx, "latency", "latest") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) LatencyReset(ctx context.Context, events ...interface{}) *StatusCmd { + args := make([]interface{}, 2+len(events)) + args[0] = "latency" + args[1] = "reset" + copy(args[2:], events) + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) Sync(_ context.Context) { panic("not implemented") } @@ -660,7 +725,9 @@ func (c cmdable) MemoryUsage(ctx context.Context, key string, samples ...int) *I args := []interface{}{"memory", "usage", key} if len(samples) > 0 { if len(samples) != 1 { - panic("MemoryUsage expects single sample count") + cmd := NewIntCmd(ctx) + cmd.SetErr(errors.New("MemoryUsage expects single sample count")) + return cmd } args = append(args, "SAMPLES", samples[0]) } diff --git a/backend/vendor/github.com/redis/go-redis/v9/docker-compose.yml b/backend/vendor/github.com/redis/go-redis/v9/docker-compose.yml new file mode 100644 index 00000000..5ffedb0a --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/docker-compose.yml @@ -0,0 +1,106 @@ +--- + +services: + redis: + image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0} + platform: linux/amd64 + container_name: redis-standalone + environment: + - TLS_ENABLED=yes + - REDIS_CLUSTER=no + - PORT=6379 + - TLS_PORT=6666 + command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""} + ports: + - 6379:6379 + - 6666:6666 # TLS port + volumes: + - "./dockers/standalone:/redis/work" + profiles: + - standalone + - sentinel + - all-stack + - all + + osscluster: + image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0} + platform: linux/amd64 + container_name: redis-osscluster + environment: + - NODES=6 + - PORT=16600 + command: "--cluster-enabled yes" + ports: + - "16600-16605:16600-16605" + volumes: + - "./dockers/osscluster:/redis/work" + profiles: + - cluster + - all-stack + - all + + sentinel-cluster: + image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0} + platform: linux/amd64 + container_name: redis-sentinel-cluster + network_mode: "host" + environment: + - NODES=3 + - TLS_ENABLED=yes + - REDIS_CLUSTER=no + - PORT=9121 + command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""} + #ports: + # - "9121-9123:9121-9123" + volumes: + - "./dockers/sentinel-cluster:/redis/work" + profiles: + - sentinel + - all-stack + - all + + sentinel: + image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0} + platform: linux/amd64 + container_name: redis-sentinel + depends_on: + - sentinel-cluster + environment: + - NODES=3 + - REDIS_CLUSTER=no + - PORT=26379 + command: ${REDIS_EXTRA_ARGS:---sentinel} + network_mode: "host" + #ports: + # - 26379:26379 + # - 26380:26380 + # - 26381:26381 + volumes: + - "./dockers/sentinel.conf:/redis/config-default/redis.conf" + - "./dockers/sentinel:/redis/work" + profiles: + - sentinel + - all-stack + - all + + ring-cluster: + image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.4.0} + platform: linux/amd64 + container_name: redis-ring-cluster + environment: + - NODES=3 + - TLS_ENABLED=yes + - REDIS_CLUSTER=no + - PORT=6390 + command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""} + ports: + - 6390:6390 + - 6391:6391 + - 6392:6392 + volumes: + - "./dockers/ring:/redis/work" + profiles: + - ring + - cluster + - all-stack + - all diff --git a/backend/vendor/github.com/redis/go-redis/v9/error.go b/backend/vendor/github.com/redis/go-redis/v9/error.go index 9b348193..12b5604d 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/error.go +++ b/backend/vendor/github.com/redis/go-redis/v9/error.go @@ -15,6 +15,19 @@ import ( // ErrClosed performs any operation on the closed client will return this error. var ErrClosed = pool.ErrClosed +// ErrPoolExhausted is returned from a pool connection method +// when the maximum number of database connections in the pool has been reached. +var ErrPoolExhausted = pool.ErrPoolExhausted + +// ErrPoolTimeout timed out waiting to get a connection from the connection pool. +var ErrPoolTimeout = pool.ErrPoolTimeout + +// ErrCrossSlot is returned when keys are used in the same Redis command and +// the keys are not in the same hash slot. This error is returned by Redis +// Cluster and will be returned by the client when TxPipeline or TxPipelined +// is used on a ClusterClient with keys in different slots. +var ErrCrossSlot = proto.RedisError("CROSSSLOT Keys in request don't hash to the same slot") + // HasErrorPrefix checks if the err is a Redis error and the message contains a prefix. func HasErrorPrefix(err error, prefix string) bool { var rErr Error @@ -38,23 +51,83 @@ type Error interface { var _ Error = proto.RedisError("") +func isContextError(err error) bool { + // Check for wrapped context errors using errors.Is + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +// isTimeoutError checks if an error is a timeout error, even if wrapped. +// Returns (isTimeout, shouldRetryOnTimeout) where: +// - isTimeout: true if the error is any kind of timeout error +// - shouldRetryOnTimeout: true if Timeout() method returns true +func isTimeoutError(err error) (isTimeout bool, hasTimeoutFlag bool) { + // Check for timeoutError interface (works with wrapped errors) + var te timeoutError + if errors.As(err, &te) { + return true, te.Timeout() + } + + // Check for net.Error specifically (common case for network timeouts) + var netErr net.Error + if errors.As(err, &netErr) { + return true, netErr.Timeout() + } + + return false, false +} + func shouldRetry(err error, retryTimeout bool) bool { - switch err { - case io.EOF, io.ErrUnexpectedEOF: - return true - case nil, context.Canceled, context.DeadlineExceeded: + if err == nil { return false } - if v, ok := err.(timeoutError); ok { - if v.Timeout() { + // Check for EOF errors (works with wrapped errors) + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + + // Check for context errors (works with wrapped errors) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + + // Check for pool timeout (works with wrapped errors) + if errors.Is(err, pool.ErrPoolTimeout) { + // connection pool timeout, increase retries. #3289 + return true + } + + // Check for timeout errors (works with wrapped errors) + if isTimeout, hasTimeoutFlag := isTimeoutError(err); isTimeout { + if hasTimeoutFlag { return retryTimeout } return true } + // Check for typed Redis errors using errors.As (works with wrapped errors) + if proto.IsMaxClientsError(err) { + return true + } + if proto.IsLoadingError(err) { + return true + } + if proto.IsReadOnlyError(err) { + return true + } + if proto.IsMasterDownError(err) { + return true + } + if proto.IsClusterDownError(err) { + return true + } + if proto.IsTryAgainError(err) { + return true + } + + // Fallback to string checking for backward compatibility with plain errors s := err.Error() - if s == "ERR max number of clients reached" { + if strings.HasPrefix(s, "ERR max number of clients reached") { return true } if strings.HasPrefix(s, "LOADING ") { @@ -69,20 +142,36 @@ func shouldRetry(err error, retryTimeout bool) bool { if strings.HasPrefix(s, "TRYAGAIN ") { return true } + if strings.HasPrefix(s, "MASTERDOWN ") { + return true + } return false } func isRedisError(err error) bool { - _, ok := err.(proto.RedisError) - return ok + // Check if error implements the Error interface (works with wrapped errors) + var redisErr Error + if errors.As(err, &redisErr) { + return true + } + // Also check for proto.RedisError specifically + var protoRedisErr proto.RedisError + return errors.As(err, &protoRedisErr) } func isBadConn(err error, allowTimeout bool, addr string) bool { - switch err { - case nil: + if err == nil { return false - case context.Canceled, context.DeadlineExceeded: + } + + // Check for context errors (works with wrapped errors) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + + // Check for pool timeout errors (works with wrapped errors) + if errors.Is(err, pool.ErrConnUnusableTimeout) { return true } @@ -103,7 +192,9 @@ func isBadConn(err error, allowTimeout bool, addr string) bool { } if allowTimeout { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + // Check for network timeout errors (works with wrapped errors) + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { return false } } @@ -112,44 +203,143 @@ func isBadConn(err error, allowTimeout bool, addr string) bool { } func isMovedError(err error) (moved bool, ask bool, addr string) { - if !isRedisError(err) { - return + // Check for typed MovedError + if movedErr, ok := proto.IsMovedError(err); ok { + addr = movedErr.Addr() + addr = internal.GetAddr(addr) + return true, false, addr } + // Check for typed AskError + if askErr, ok := proto.IsAskError(err); ok { + addr = askErr.Addr() + addr = internal.GetAddr(addr) + return false, true, addr + } + + // Fallback to string checking for backward compatibility s := err.Error() - switch { - case strings.HasPrefix(s, "MOVED "): - moved = true - case strings.HasPrefix(s, "ASK "): - ask = true - default: - return + if strings.HasPrefix(s, "MOVED ") { + // Parse: MOVED 3999 127.0.0.1:6381 + parts := strings.Split(s, " ") + if len(parts) == 3 { + addr = internal.GetAddr(parts[2]) + return true, false, addr + } + } + if strings.HasPrefix(s, "ASK ") { + // Parse: ASK 3999 127.0.0.1:6381 + parts := strings.Split(s, " ") + if len(parts) == 3 { + addr = internal.GetAddr(parts[2]) + return false, true, addr + } } - ind := strings.LastIndex(s, " ") - if ind == -1 { - return false, false, "" - } - - addr = s[ind+1:] - addr = internal.GetAddr(addr) - return + return false, false, "" } func isLoadingError(err error) bool { - return strings.HasPrefix(err.Error(), "LOADING ") + return proto.IsLoadingError(err) } func isReadOnlyError(err error) bool { - return strings.HasPrefix(err.Error(), "READONLY ") + return proto.IsReadOnlyError(err) } func isMovedSameConnAddr(err error, addr string) bool { - redisError := err.Error() - if !strings.HasPrefix(redisError, "MOVED ") { - return false + if movedErr, ok := proto.IsMovedError(err); ok { + return strings.HasSuffix(movedErr.Addr(), addr) } - return strings.HasSuffix(redisError, " "+addr) + return false +} + +//------------------------------------------------------------------------------ + +// Typed error checking functions for public use. +// These functions work correctly even when errors are wrapped in hooks. + +// IsLoadingError checks if an error is a Redis LOADING error, even if wrapped. +// LOADING errors occur when Redis is loading the dataset in memory. +func IsLoadingError(err error) bool { + return proto.IsLoadingError(err) +} + +// IsReadOnlyError checks if an error is a Redis READONLY error, even if wrapped. +// READONLY errors occur when trying to write to a read-only replica. +func IsReadOnlyError(err error) bool { + return proto.IsReadOnlyError(err) +} + +// IsClusterDownError checks if an error is a Redis CLUSTERDOWN error, even if wrapped. +// CLUSTERDOWN errors occur when the cluster is down. +func IsClusterDownError(err error) bool { + return proto.IsClusterDownError(err) +} + +// IsTryAgainError checks if an error is a Redis TRYAGAIN error, even if wrapped. +// TRYAGAIN errors occur when a command cannot be processed and should be retried. +func IsTryAgainError(err error) bool { + return proto.IsTryAgainError(err) +} + +// IsMasterDownError checks if an error is a Redis MASTERDOWN error, even if wrapped. +// MASTERDOWN errors occur when the master is down. +func IsMasterDownError(err error) bool { + return proto.IsMasterDownError(err) +} + +// IsMaxClientsError checks if an error is a Redis max clients error, even if wrapped. +// This error occurs when the maximum number of clients has been reached. +func IsMaxClientsError(err error) bool { + return proto.IsMaxClientsError(err) +} + +// IsMovedError checks if an error is a Redis MOVED error, even if wrapped. +// MOVED errors occur in cluster mode when a key has been moved to a different node. +// Returns the address of the node where the key has been moved and a boolean indicating if it's a MOVED error. +func IsMovedError(err error) (addr string, ok bool) { + if movedErr, isMovedErr := proto.IsMovedError(err); isMovedErr { + return movedErr.Addr(), true + } + return "", false +} + +// IsAskError checks if an error is a Redis ASK error, even if wrapped. +// ASK errors occur in cluster mode when a key is being migrated and the client should ask another node. +// Returns the address of the node to ask and a boolean indicating if it's an ASK error. +func IsAskError(err error) (addr string, ok bool) { + if askErr, isAskErr := proto.IsAskError(err); isAskErr { + return askErr.Addr(), true + } + return "", false +} + +// IsAuthError checks if an error is a Redis authentication error, even if wrapped. +// Authentication errors occur when: +// - NOAUTH: Redis requires authentication but none was provided +// - WRONGPASS: Redis authentication failed due to incorrect password +// - unauthenticated: Error returned when password changed +func IsAuthError(err error) bool { + return proto.IsAuthError(err) +} + +// IsPermissionError checks if an error is a Redis permission error, even if wrapped. +// Permission errors (NOPERM) occur when a user does not have permission to execute a command. +func IsPermissionError(err error) bool { + return proto.IsPermissionError(err) +} + +// IsExecAbortError checks if an error is a Redis EXECABORT error, even if wrapped. +// EXECABORT errors occur when a transaction is aborted. +func IsExecAbortError(err error) bool { + return proto.IsExecAbortError(err) +} + +// IsOOMError checks if an error is a Redis OOM (Out Of Memory) error, even if wrapped. +// OOM errors occur when Redis is out of memory. +func IsOOMError(err error) bool { + return proto.IsOOMError(err) } //------------------------------------------------------------------------------ diff --git a/backend/vendor/github.com/redis/go-redis/v9/gears_commands.go b/backend/vendor/github.com/redis/go-redis/v9/gears_commands.go deleted file mode 100644 index e0d49a6b..00000000 --- a/backend/vendor/github.com/redis/go-redis/v9/gears_commands.go +++ /dev/null @@ -1,149 +0,0 @@ -package redis - -import ( - "context" - "fmt" - "strings" -) - -type GearsCmdable interface { - TFunctionLoad(ctx context.Context, lib string) *StatusCmd - TFunctionLoadArgs(ctx context.Context, lib string, options *TFunctionLoadOptions) *StatusCmd - TFunctionDelete(ctx context.Context, libName string) *StatusCmd - TFunctionList(ctx context.Context) *MapStringInterfaceSliceCmd - TFunctionListArgs(ctx context.Context, options *TFunctionListOptions) *MapStringInterfaceSliceCmd - TFCall(ctx context.Context, libName string, funcName string, numKeys int) *Cmd - TFCallArgs(ctx context.Context, libName string, funcName string, numKeys int, options *TFCallOptions) *Cmd - TFCallASYNC(ctx context.Context, libName string, funcName string, numKeys int) *Cmd - TFCallASYNCArgs(ctx context.Context, libName string, funcName string, numKeys int, options *TFCallOptions) *Cmd -} - -type TFunctionLoadOptions struct { - Replace bool - Config string -} - -type TFunctionListOptions struct { - Withcode bool - Verbose int - Library string -} - -type TFCallOptions struct { - Keys []string - Arguments []string -} - -// TFunctionLoad - load a new JavaScript library into Redis. -// For more information - https://redis.io/commands/tfunction-load/ -func (c cmdable) TFunctionLoad(ctx context.Context, lib string) *StatusCmd { - args := []interface{}{"TFUNCTION", "LOAD", lib} - cmd := NewStatusCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} - -func (c cmdable) TFunctionLoadArgs(ctx context.Context, lib string, options *TFunctionLoadOptions) *StatusCmd { - args := []interface{}{"TFUNCTION", "LOAD"} - if options != nil { - if options.Replace { - args = append(args, "REPLACE") - } - if options.Config != "" { - args = append(args, "CONFIG", options.Config) - } - } - args = append(args, lib) - cmd := NewStatusCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} - -// TFunctionDelete - delete a JavaScript library from Redis. -// For more information - https://redis.io/commands/tfunction-delete/ -func (c cmdable) TFunctionDelete(ctx context.Context, libName string) *StatusCmd { - args := []interface{}{"TFUNCTION", "DELETE", libName} - cmd := NewStatusCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} - -// TFunctionList - list the functions with additional information about each function. -// For more information - https://redis.io/commands/tfunction-list/ -func (c cmdable) TFunctionList(ctx context.Context) *MapStringInterfaceSliceCmd { - args := []interface{}{"TFUNCTION", "LIST"} - cmd := NewMapStringInterfaceSliceCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} - -func (c cmdable) TFunctionListArgs(ctx context.Context, options *TFunctionListOptions) *MapStringInterfaceSliceCmd { - args := []interface{}{"TFUNCTION", "LIST"} - if options != nil { - if options.Withcode { - args = append(args, "WITHCODE") - } - if options.Verbose != 0 { - v := strings.Repeat("v", options.Verbose) - args = append(args, v) - } - if options.Library != "" { - args = append(args, "LIBRARY", options.Library) - } - } - cmd := NewMapStringInterfaceSliceCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} - -// TFCall - invoke a function. -// For more information - https://redis.io/commands/tfcall/ -func (c cmdable) TFCall(ctx context.Context, libName string, funcName string, numKeys int) *Cmd { - lf := libName + "." + funcName - args := []interface{}{"TFCALL", lf, numKeys} - cmd := NewCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} - -func (c cmdable) TFCallArgs(ctx context.Context, libName string, funcName string, numKeys int, options *TFCallOptions) *Cmd { - lf := libName + "." + funcName - args := []interface{}{"TFCALL", lf, numKeys} - if options != nil { - for _, key := range options.Keys { - args = append(args, key) - } - for _, key := range options.Arguments { - args = append(args, key) - } - } - cmd := NewCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} - -// TFCallASYNC - invoke an asynchronous JavaScript function (coroutine). -// For more information - https://redis.io/commands/TFCallASYNC/ -func (c cmdable) TFCallASYNC(ctx context.Context, libName string, funcName string, numKeys int) *Cmd { - lf := fmt.Sprintf("%s.%s", libName, funcName) - args := []interface{}{"TFCALLASYNC", lf, numKeys} - cmd := NewCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} - -func (c cmdable) TFCallASYNCArgs(ctx context.Context, libName string, funcName string, numKeys int, options *TFCallOptions) *Cmd { - lf := fmt.Sprintf("%s.%s", libName, funcName) - args := []interface{}{"TFCALLASYNC", lf, numKeys} - if options != nil { - for _, key := range options.Keys { - args = append(args, key) - } - for _, key := range options.Arguments { - args = append(args, key) - } - } - cmd := NewCmd(ctx, args...) - _ = c(ctx, cmd) - return cmd -} diff --git a/backend/vendor/github.com/redis/go-redis/v9/generic_commands.go b/backend/vendor/github.com/redis/go-redis/v9/generic_commands.go index dc6c3fe0..c7100222 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/generic_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/generic_commands.go @@ -3,6 +3,8 @@ package redis import ( "context" "time" + + "github.com/redis/go-redis/v9/internal/hashtag" ) type GenericCmdable interface { @@ -363,6 +365,9 @@ func (c cmdable) Scan(ctx context.Context, cursor uint64, match string, count in args = append(args, "count", count) } cmd := NewScanCmd(ctx, c, args...) + if hashtag.Present(match) { + cmd.SetFirstKeyPos(3) + } _ = c(ctx, cmd) return cmd } @@ -379,6 +384,9 @@ func (c cmdable) ScanType(ctx context.Context, cursor uint64, match string, coun args = append(args, "type", keyType) } cmd := NewScanCmd(ctx, c, args...) + if hashtag.Present(match) { + cmd.SetFirstKeyPos(3) + } _ = c(ctx, cmd) return cmd } diff --git a/backend/vendor/github.com/redis/go-redis/v9/hash_commands.go b/backend/vendor/github.com/redis/go-redis/v9/hash_commands.go index 6596c6f5..b78860a5 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/hash_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/hash_commands.go @@ -3,6 +3,8 @@ package redis import ( "context" "time" + + "github.com/redis/go-redis/v9/internal/hashtag" ) type HashCmdable interface { @@ -10,6 +12,9 @@ type HashCmdable interface { HExists(ctx context.Context, key, field string) *BoolCmd HGet(ctx context.Context, key, field string) *StringCmd HGetAll(ctx context.Context, key string) *MapStringStringCmd + HGetDel(ctx context.Context, key string, fields ...string) *StringSliceCmd + HGetEX(ctx context.Context, key string, fields ...string) *StringSliceCmd + HGetEXWithArgs(ctx context.Context, key string, options *HGetEXOptions, fields ...string) *StringSliceCmd HIncrBy(ctx context.Context, key, field string, incr int64) *IntCmd HIncrByFloat(ctx context.Context, key, field string, incr float64) *FloatCmd HKeys(ctx context.Context, key string) *StringSliceCmd @@ -17,12 +22,15 @@ type HashCmdable interface { HMGet(ctx context.Context, key string, fields ...string) *SliceCmd HSet(ctx context.Context, key string, values ...interface{}) *IntCmd HMSet(ctx context.Context, key string, values ...interface{}) *BoolCmd + HSetEX(ctx context.Context, key string, fieldsAndValues ...string) *IntCmd + HSetEXWithArgs(ctx context.Context, key string, options *HSetEXOptions, fieldsAndValues ...string) *IntCmd HSetNX(ctx context.Context, key, field string, value interface{}) *BoolCmd HScan(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd HScanNoValues(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd HVals(ctx context.Context, key string) *StringSliceCmd HRandField(ctx context.Context, key string, count int) *StringSliceCmd HRandFieldWithValues(ctx context.Context, key string, count int) *KeyValueSliceCmd + HStrLen(ctx context.Context, key, field string) *IntCmd HExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd HExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd HPExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd @@ -108,16 +116,16 @@ func (c cmdable) HMGet(ctx context.Context, key string, fields ...string) *Slice // HSet accepts values in following formats: // -// - HSet("myhash", "key1", "value1", "key2", "value2") +// - HSet(ctx, "myhash", "key1", "value1", "key2", "value2") // -// - HSet("myhash", []string{"key1", "value1", "key2", "value2"}) +// - HSet(ctx, "myhash", []string{"key1", "value1", "key2", "value2"}) // -// - HSet("myhash", map[string]interface{}{"key1": "value1", "key2": "value2"}) +// - HSet(ctx, "myhash", map[string]interface{}{"key1": "value1", "key2": "value2"}) // // Playing struct With "redis" tag. // type MyHash struct { Key1 string `redis:"key1"`; Key2 int `redis:"key2"` } // -// - HSet("myhash", MyHash{"value1", "value2"}) Warn: redis-server >= 4.0 +// - HSet(ctx, "myhash", MyHash{"value1", "value2"}) Warn: redis-server >= 4.0 // // For struct, can be a structure pointer type, we only parse the field whose tag is redis. // if you don't want the field to be read, you can use the `redis:"-"` flag to ignore it, @@ -186,10 +194,18 @@ func (c cmdable) HScan(ctx context.Context, key string, cursor uint64, match str args = append(args, "count", count) } cmd := NewScanCmd(ctx, c, args...) + if hashtag.Present(match) { + cmd.SetFirstKeyPos(4) + } _ = c(ctx, cmd) return cmd } +func (c cmdable) HStrLen(ctx context.Context, key, field string) *IntCmd { + cmd := NewIntCmd(ctx, "hstrlen", key, field) + _ = c(ctx, cmd) + return cmd +} func (c cmdable) HScanNoValues(ctx context.Context, key string, cursor uint64, match string, count int64) *ScanCmd { args := []interface{}{"hscan", key, cursor} if match != "" { @@ -200,6 +216,9 @@ func (c cmdable) HScanNoValues(ctx context.Context, key string, cursor uint64, m } args = append(args, "novalues") cmd := NewScanCmd(ctx, c, args...) + if hashtag.Present(match) { + cmd.SetFirstKeyPos(4) + } _ = c(ctx, cmd) return cmd } @@ -213,7 +232,10 @@ type HExpireArgs struct { // HExpire - Sets the expiration time for specified fields in a hash in seconds. // The command constructs an argument list starting with "HEXPIRE", followed by the key, duration, any conditional flags, and the specified fields. -// For more information - https://redis.io/commands/hexpire/ +// Available since Redis 7.4 CE. +// For more information refer to [HEXPIRE Documentation]. +// +// [HEXPIRE Documentation]: https://redis.io/commands/hexpire/ func (c cmdable) HExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd { args := []interface{}{"HEXPIRE", key, formatSec(ctx, expiration), "FIELDS", len(fields)} @@ -228,7 +250,10 @@ func (c cmdable) HExpire(ctx context.Context, key string, expiration time.Durati // HExpireWithArgs - Sets the expiration time for specified fields in a hash in seconds. // It requires a key, an expiration duration, a struct with boolean flags for conditional expiration settings (NX, XX, GT, LT), and a list of fields. // The command constructs an argument list starting with "HEXPIRE", followed by the key, duration, any conditional flags, and the specified fields. -// For more information - https://redis.io/commands/hexpire/ +// Available since Redis 7.4 CE. +// For more information refer to [HEXPIRE Documentation]. +// +// [HEXPIRE Documentation]: https://redis.io/commands/hexpire/ func (c cmdable) HExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd { args := []interface{}{"HEXPIRE", key, formatSec(ctx, expiration)} @@ -257,7 +282,10 @@ func (c cmdable) HExpireWithArgs(ctx context.Context, key string, expiration tim // HPExpire - Sets the expiration time for specified fields in a hash in milliseconds. // Similar to HExpire, it accepts a key, an expiration duration in milliseconds, a struct with expiration condition flags, and a list of fields. // The command modifies the standard time.Duration to milliseconds for the Redis command. -// For more information - https://redis.io/commands/hpexpire/ +// Available since Redis 7.4 CE. +// For more information refer to [HPEXPIRE Documentation]. +// +// [HPEXPIRE Documentation]: https://redis.io/commands/hpexpire/ func (c cmdable) HPExpire(ctx context.Context, key string, expiration time.Duration, fields ...string) *IntSliceCmd { args := []interface{}{"HPEXPIRE", key, formatMs(ctx, expiration), "FIELDS", len(fields)} @@ -269,6 +297,13 @@ func (c cmdable) HPExpire(ctx context.Context, key string, expiration time.Durat return cmd } +// HPExpireWithArgs - Sets the expiration time for specified fields in a hash in milliseconds. +// It requires a key, an expiration duration, a struct with boolean flags for conditional expiration settings (NX, XX, GT, LT), and a list of fields. +// The command constructs an argument list starting with "HPEXPIRE", followed by the key, duration, any conditional flags, and the specified fields. +// Available since Redis 7.4 CE. +// For more information refer to [HPEXPIRE Documentation]. +// +// [HPEXPIRE Documentation]: https://redis.io/commands/hpexpire/ func (c cmdable) HPExpireWithArgs(ctx context.Context, key string, expiration time.Duration, expirationArgs HExpireArgs, fields ...string) *IntSliceCmd { args := []interface{}{"HPEXPIRE", key, formatMs(ctx, expiration)} @@ -297,7 +332,10 @@ func (c cmdable) HPExpireWithArgs(ctx context.Context, key string, expiration ti // HExpireAt - Sets the expiration time for specified fields in a hash to a UNIX timestamp in seconds. // Takes a key, a UNIX timestamp, a struct of conditional flags, and a list of fields. // The command sets absolute expiration times based on the UNIX timestamp provided. -// For more information - https://redis.io/commands/hexpireat/ +// Available since Redis 7.4 CE. +// For more information refer to [HExpireAt Documentation]. +// +// [HExpireAt Documentation]: https://redis.io/commands/hexpireat/ func (c cmdable) HExpireAt(ctx context.Context, key string, tm time.Time, fields ...string) *IntSliceCmd { args := []interface{}{"HEXPIREAT", key, tm.Unix(), "FIELDS", len(fields)} @@ -337,7 +375,10 @@ func (c cmdable) HExpireAtWithArgs(ctx context.Context, key string, tm time.Time // HPExpireAt - Sets the expiration time for specified fields in a hash to a UNIX timestamp in milliseconds. // Similar to HExpireAt but for timestamps in milliseconds. It accepts the same parameters and adjusts the UNIX time to milliseconds. -// For more information - https://redis.io/commands/hpexpireat/ +// Available since Redis 7.4 CE. +// For more information refer to [HExpireAt Documentation]. +// +// [HExpireAt Documentation]: https://redis.io/commands/hexpireat/ func (c cmdable) HPExpireAt(ctx context.Context, key string, tm time.Time, fields ...string) *IntSliceCmd { args := []interface{}{"HPEXPIREAT", key, tm.UnixNano() / int64(time.Millisecond), "FIELDS", len(fields)} @@ -377,7 +418,10 @@ func (c cmdable) HPExpireAtWithArgs(ctx context.Context, key string, tm time.Tim // HPersist - Removes the expiration time from specified fields in a hash. // Accepts a key and the fields themselves. // This command ensures that each field specified will have its expiration removed if present. -// For more information - https://redis.io/commands/hpersist/ +// Available since Redis 7.4 CE. +// For more information refer to [HPersist Documentation]. +// +// [HPersist Documentation]: https://redis.io/commands/hpersist/ func (c cmdable) HPersist(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HPERSIST", key, "FIELDS", len(fields)} @@ -392,6 +436,10 @@ func (c cmdable) HPersist(ctx context.Context, key string, fields ...string) *In // HExpireTime - Retrieves the expiration time for specified fields in a hash as a UNIX timestamp in seconds. // Requires a key and the fields themselves to fetch their expiration timestamps. // This command returns the expiration times for each field or error/status codes for each field as specified. +// Available since Redis 7.4 CE. +// For more information refer to [HExpireTime Documentation]. +// +// [HExpireTime Documentation]: https://redis.io/commands/hexpiretime/ // For more information - https://redis.io/commands/hexpiretime/ func (c cmdable) HExpireTime(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HEXPIRETIME", key, "FIELDS", len(fields)} @@ -407,6 +455,10 @@ func (c cmdable) HExpireTime(ctx context.Context, key string, fields ...string) // HPExpireTime - Retrieves the expiration time for specified fields in a hash as a UNIX timestamp in milliseconds. // Similar to HExpireTime, adjusted for timestamps in milliseconds. It requires the same parameters. // Provides the expiration timestamp for each field in milliseconds. +// Available since Redis 7.4 CE. +// For more information refer to [HExpireTime Documentation]. +// +// [HExpireTime Documentation]: https://redis.io/commands/hexpiretime/ // For more information - https://redis.io/commands/hexpiretime/ func (c cmdable) HPExpireTime(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HPEXPIRETIME", key, "FIELDS", len(fields)} @@ -422,7 +474,10 @@ func (c cmdable) HPExpireTime(ctx context.Context, key string, fields ...string) // HTTL - Retrieves the remaining time to live for specified fields in a hash in seconds. // Requires a key and the fields themselves. It returns the TTL for each specified field. // This command fetches the TTL in seconds for each field or returns error/status codes as appropriate. -// For more information - https://redis.io/commands/httl/ +// Available since Redis 7.4 CE. +// For more information refer to [HTTL Documentation]. +// +// [HTTL Documentation]: https://redis.io/commands/httl/ func (c cmdable) HTTL(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HTTL", key, "FIELDS", len(fields)} @@ -437,6 +492,10 @@ func (c cmdable) HTTL(ctx context.Context, key string, fields ...string) *IntSli // HPTTL - Retrieves the remaining time to live for specified fields in a hash in milliseconds. // Similar to HTTL, but returns the TTL in milliseconds. It requires a key and the specified fields. // This command provides the TTL in milliseconds for each field or returns error/status codes as needed. +// Available since Redis 7.4 CE. +// For more information refer to [HPTTL Documentation]. +// +// [HPTTL Documentation]: https://redis.io/commands/hpttl/ // For more information - https://redis.io/commands/hpttl/ func (c cmdable) HPTTL(ctx context.Context, key string, fields ...string) *IntSliceCmd { args := []interface{}{"HPTTL", key, "FIELDS", len(fields)} @@ -448,3 +507,113 @@ func (c cmdable) HPTTL(ctx context.Context, key string, fields ...string) *IntSl _ = c(ctx, cmd) return cmd } + +func (c cmdable) HGetDel(ctx context.Context, key string, fields ...string) *StringSliceCmd { + args := []interface{}{"HGETDEL", key, "FIELDS", len(fields)} + for _, field := range fields { + args = append(args, field) + } + cmd := NewStringSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) HGetEX(ctx context.Context, key string, fields ...string) *StringSliceCmd { + args := []interface{}{"HGETEX", key, "FIELDS", len(fields)} + for _, field := range fields { + args = append(args, field) + } + cmd := NewStringSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// HGetEXExpirationType represents an expiration option for the HGETEX command. +type HGetEXExpirationType string + +const ( + HGetEXExpirationEX HGetEXExpirationType = "EX" + HGetEXExpirationPX HGetEXExpirationType = "PX" + HGetEXExpirationEXAT HGetEXExpirationType = "EXAT" + HGetEXExpirationPXAT HGetEXExpirationType = "PXAT" + HGetEXExpirationPERSIST HGetEXExpirationType = "PERSIST" +) + +type HGetEXOptions struct { + ExpirationType HGetEXExpirationType + ExpirationVal int64 +} + +func (c cmdable) HGetEXWithArgs(ctx context.Context, key string, options *HGetEXOptions, fields ...string) *StringSliceCmd { + args := []interface{}{"HGETEX", key} + if options.ExpirationType != "" { + args = append(args, string(options.ExpirationType)) + if options.ExpirationType != HGetEXExpirationPERSIST { + args = append(args, options.ExpirationVal) + } + } + + args = append(args, "FIELDS", len(fields)) + for _, field := range fields { + args = append(args, field) + } + + cmd := NewStringSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +type HSetEXCondition string + +const ( + HSetEXFNX HSetEXCondition = "FNX" // Only set the fields if none of them already exist. + HSetEXFXX HSetEXCondition = "FXX" // Only set the fields if all already exist. +) + +type HSetEXExpirationType string + +const ( + HSetEXExpirationEX HSetEXExpirationType = "EX" + HSetEXExpirationPX HSetEXExpirationType = "PX" + HSetEXExpirationEXAT HSetEXExpirationType = "EXAT" + HSetEXExpirationPXAT HSetEXExpirationType = "PXAT" + HSetEXExpirationKEEPTTL HSetEXExpirationType = "KEEPTTL" +) + +type HSetEXOptions struct { + Condition HSetEXCondition + ExpirationType HSetEXExpirationType + ExpirationVal int64 +} + +func (c cmdable) HSetEX(ctx context.Context, key string, fieldsAndValues ...string) *IntCmd { + args := []interface{}{"HSETEX", key, "FIELDS", len(fieldsAndValues) / 2} + for _, field := range fieldsAndValues { + args = append(args, field) + } + + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) HSetEXWithArgs(ctx context.Context, key string, options *HSetEXOptions, fieldsAndValues ...string) *IntCmd { + args := []interface{}{"HSETEX", key} + if options.Condition != "" { + args = append(args, string(options.Condition)) + } + if options.ExpirationType != "" { + args = append(args, string(options.ExpirationType)) + if options.ExpirationType != HSetEXExpirationKEEPTTL { + args = append(args, options.ExpirationVal) + } + } + args = append(args, "FIELDS", len(fieldsAndValues)/2) + for _, field := range fieldsAndValues { + args = append(args, field) + } + + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/conn_reauth_credentials_listener.go b/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/conn_reauth_credentials_listener.go new file mode 100644 index 00000000..22bfedf7 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/conn_reauth_credentials_listener.go @@ -0,0 +1,100 @@ +package streaming + +import ( + "github.com/redis/go-redis/v9/auth" + "github.com/redis/go-redis/v9/internal/pool" +) + +// ConnReAuthCredentialsListener is a credentials listener for a specific connection +// that triggers re-authentication when credentials change. +// +// This listener implements the auth.CredentialsListener interface and is subscribed +// to a StreamingCredentialsProvider. When new credentials are received via OnNext, +// it marks the connection for re-authentication through the manager. +// +// The re-authentication is always performed asynchronously to avoid blocking the +// credentials provider and to prevent potential deadlocks with the pool semaphore. +// The actual re-auth happens when the connection is returned to the pool in an idle state. +// +// Lifecycle: +// - Created during connection initialization via Manager.Listener() +// - Subscribed to the StreamingCredentialsProvider +// - Receives credential updates via OnNext() +// - Cleaned up when connection is removed from pool via Manager.RemoveListener() +type ConnReAuthCredentialsListener struct { + // reAuth is the function to re-authenticate the connection with new credentials + reAuth func(conn *pool.Conn, credentials auth.Credentials) error + + // onErr is the function to call when re-authentication or acquisition fails + onErr func(conn *pool.Conn, err error) + + // conn is the connection this listener is associated with + conn *pool.Conn + + // manager is the streaming credentials manager for coordinating re-auth + manager *Manager +} + +// OnNext is called when new credentials are received from the StreamingCredentialsProvider. +// +// This method marks the connection for asynchronous re-authentication. The actual +// re-authentication happens in the background when the connection is returned to the +// pool and is in an idle state. +// +// Asynchronous re-auth is used to: +// - Avoid blocking the credentials provider's notification goroutine +// - Prevent deadlocks with the pool's semaphore (especially with small pool sizes) +// - Ensure re-auth happens when the connection is safe to use (not processing commands) +// +// The reAuthFn callback receives: +// - nil if the connection was successfully acquired for re-auth +// - error if acquisition timed out or failed +// +// Thread-safe: Called by the credentials provider's notification goroutine. +func (c *ConnReAuthCredentialsListener) OnNext(credentials auth.Credentials) { + if c.conn == nil || c.conn.IsClosed() || c.manager == nil || c.reAuth == nil { + return + } + + // Always use async reauth to avoid complex pool semaphore issues + // The synchronous path can cause deadlocks in the pool's semaphore mechanism + // when called from the Subscribe goroutine, especially with small pool sizes. + // The connection pool hook will re-authenticate the connection when it is + // returned to the pool in a clean, idle state. + c.manager.MarkForReAuth(c.conn, func(err error) { + // err is from connection acquisition (timeout, etc.) + if err != nil { + // Log the error + c.OnError(err) + return + } + // err is from reauth command execution + err = c.reAuth(c.conn, credentials) + if err != nil { + // Log the error + c.OnError(err) + return + } + }) +} + +// OnError is called when an error occurs during credential streaming or re-authentication. +// +// This method can be called from: +// - The StreamingCredentialsProvider when there's an error in the credentials stream +// - The re-auth process when connection acquisition times out +// - The re-auth process when the AUTH command fails +// +// The error is delegated to the onErr callback provided during listener creation. +// +// Thread-safe: Can be called from multiple goroutines (provider, re-auth worker). +func (c *ConnReAuthCredentialsListener) OnError(err error) { + if c.onErr == nil { + return + } + + c.onErr(c.conn, err) +} + +// Ensure ConnReAuthCredentialsListener implements the CredentialsListener interface. +var _ auth.CredentialsListener = (*ConnReAuthCredentialsListener)(nil) diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/cred_listeners.go b/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/cred_listeners.go new file mode 100644 index 00000000..66e6eafd --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/cred_listeners.go @@ -0,0 +1,77 @@ +package streaming + +import ( + "sync" + + "github.com/redis/go-redis/v9/auth" +) + +// CredentialsListeners is a thread-safe collection of credentials listeners +// indexed by connection ID. +// +// This collection is used by the Manager to maintain a registry of listeners +// for each connection in the pool. Listeners are reused when connections are +// reinitialized (e.g., after a handoff) to avoid creating duplicate subscriptions +// to the StreamingCredentialsProvider. +// +// The collection supports concurrent access from multiple goroutines during +// connection initialization, credential updates, and connection removal. +type CredentialsListeners struct { + // listeners maps connection ID to credentials listener + listeners map[uint64]auth.CredentialsListener + + // lock protects concurrent access to the listeners map + lock sync.RWMutex +} + +// NewCredentialsListeners creates a new thread-safe credentials listeners collection. +func NewCredentialsListeners() *CredentialsListeners { + return &CredentialsListeners{ + listeners: make(map[uint64]auth.CredentialsListener), + } +} + +// Add adds or updates a credentials listener for a connection. +// +// If a listener already exists for the connection ID, it is replaced. +// This is safe because the old listener should have been unsubscribed +// before the connection was reinitialized. +// +// Thread-safe: Can be called concurrently from multiple goroutines. +func (c *CredentialsListeners) Add(connID uint64, listener auth.CredentialsListener) { + c.lock.Lock() + defer c.lock.Unlock() + if c.listeners == nil { + c.listeners = make(map[uint64]auth.CredentialsListener) + } + c.listeners[connID] = listener +} + +// Get retrieves the credentials listener for a connection. +// +// Returns: +// - listener: The credentials listener for the connection, or nil if not found +// - ok: true if a listener exists for the connection ID, false otherwise +// +// Thread-safe: Can be called concurrently from multiple goroutines. +func (c *CredentialsListeners) Get(connID uint64) (auth.CredentialsListener, bool) { + c.lock.RLock() + defer c.lock.RUnlock() + if len(c.listeners) == 0 { + return nil, false + } + listener, ok := c.listeners[connID] + return listener, ok +} + +// Remove removes the credentials listener for a connection. +// +// This is called when a connection is removed from the pool to prevent +// memory leaks. If no listener exists for the connection ID, this is a no-op. +// +// Thread-safe: Can be called concurrently from multiple goroutines. +func (c *CredentialsListeners) Remove(connID uint64) { + c.lock.Lock() + defer c.lock.Unlock() + delete(c.listeners, connID) +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/manager.go b/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/manager.go new file mode 100644 index 00000000..f785927e --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/manager.go @@ -0,0 +1,137 @@ +package streaming + +import ( + "errors" + "time" + + "github.com/redis/go-redis/v9/auth" + "github.com/redis/go-redis/v9/internal/pool" +) + +// Manager coordinates streaming credentials and re-authentication for a connection pool. +// +// The manager is responsible for: +// - Creating and managing per-connection credentials listeners +// - Providing the pool hook for re-authentication +// - Coordinating between credentials updates and pool operations +// +// When credentials change via a StreamingCredentialsProvider: +// 1. The credentials listener (ConnReAuthCredentialsListener) receives the update +// 2. It calls MarkForReAuth on the manager +// 3. The manager delegates to the pool hook +// 4. The pool hook schedules background re-authentication +// +// The manager maintains a registry of credentials listeners indexed by connection ID, +// allowing listener reuse when connections are reinitialized (e.g., after handoff). +type Manager struct { + // credentialsListeners maps connection ID to credentials listener + credentialsListeners *CredentialsListeners + + // pool is the connection pool being managed + pool pool.Pooler + + // poolHookRef is the re-authentication pool hook + poolHookRef *ReAuthPoolHook +} + +// NewManager creates a new streaming credentials manager. +// +// Parameters: +// - pl: The connection pool to manage +// - reAuthTimeout: Maximum time to wait for acquiring a connection for re-authentication +// +// The manager creates a ReAuthPoolHook sized to match the pool size, ensuring that +// re-auth operations don't exhaust the connection pool. +func NewManager(pl pool.Pooler, reAuthTimeout time.Duration) *Manager { + m := &Manager{ + pool: pl, + poolHookRef: NewReAuthPoolHook(pl.Size(), reAuthTimeout), + credentialsListeners: NewCredentialsListeners(), + } + m.poolHookRef.manager = m + return m +} + +// PoolHook returns the pool hook for re-authentication. +// +// This hook should be registered with the connection pool to enable +// automatic re-authentication when credentials change. +func (m *Manager) PoolHook() pool.PoolHook { + return m.poolHookRef +} + +// Listener returns or creates a credentials listener for a connection. +// +// This method is called during connection initialization to set up the +// credentials listener. If a listener already exists for the connection ID +// (e.g., after a handoff), it is reused. +// +// Parameters: +// - poolCn: The connection to create/get a listener for +// - reAuth: Function to re-authenticate the connection with new credentials +// - onErr: Function to call when re-authentication fails +// +// Returns: +// - auth.CredentialsListener: The listener to subscribe to the credentials provider +// - error: Non-nil if poolCn is nil +// +// Note: The reAuth and onErr callbacks are captured once when the listener is +// created and reused for the connection's lifetime. They should not change. +// +// Thread-safe: Can be called concurrently during connection initialization. +func (m *Manager) Listener( + poolCn *pool.Conn, + reAuth func(*pool.Conn, auth.Credentials) error, + onErr func(*pool.Conn, error), +) (auth.CredentialsListener, error) { + if poolCn == nil { + return nil, errors.New("poolCn cannot be nil") + } + connID := poolCn.GetID() + // if we reconnect the underlying network connection, the streaming credentials listener will continue to work + // so we can get the old listener from the cache and use it. + // subscribing the same (an already subscribed) listener for a StreamingCredentialsProvider SHOULD be a no-op + listener, ok := m.credentialsListeners.Get(connID) + if !ok || listener == nil { + // Create new listener for this connection + // Note: Callbacks (reAuth, onErr) are captured once and reused for the connection's lifetime + newCredListener := &ConnReAuthCredentialsListener{ + conn: poolCn, + reAuth: reAuth, + onErr: onErr, + manager: m, + } + + m.credentialsListeners.Add(connID, newCredListener) + listener = newCredListener + } + return listener, nil +} + +// MarkForReAuth marks a connection for re-authentication. +// +// This method is called by the credentials listener when new credentials are +// received. It delegates to the pool hook to schedule background re-authentication. +// +// Parameters: +// - poolCn: The connection to re-authenticate +// - reAuthFn: Function to call for re-authentication, receives error if acquisition fails +// +// Thread-safe: Called by credentials listeners when credentials change. +func (m *Manager) MarkForReAuth(poolCn *pool.Conn, reAuthFn func(error)) { + connID := poolCn.GetID() + m.poolHookRef.MarkForReAuth(connID, reAuthFn) +} + +// RemoveListener removes the credentials listener for a connection. +// +// This method is called by the pool hook's OnRemove to clean up listeners +// when connections are removed from the pool. +// +// Parameters: +// - connID: The connection ID whose listener should be removed +// +// Thread-safe: Called during connection removal. +func (m *Manager) RemoveListener(connID uint64) { + m.credentialsListeners.Remove(connID) +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/pool_hook.go b/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/pool_hook.go new file mode 100644 index 00000000..aaf4f609 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/auth/streaming/pool_hook.go @@ -0,0 +1,241 @@ +package streaming + +import ( + "context" + "sync" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/pool" +) + +// ReAuthPoolHook is a pool hook that manages background re-authentication of connections +// when credentials change via a streaming credentials provider. +// +// The hook uses a semaphore-based worker pool to limit concurrent re-authentication +// operations and prevent pool exhaustion. When credentials change, connections are +// marked for re-authentication and processed asynchronously in the background. +// +// The re-authentication process: +// 1. OnPut: When a connection is returned to the pool, check if it needs re-auth +// 2. If yes, schedule it for background processing (move from shouldReAuth to scheduledReAuth) +// 3. A worker goroutine acquires the connection (waits until it's not in use) +// 4. Executes the re-auth function while holding the connection +// 5. Releases the connection back to the pool +// +// The hook ensures that: +// - Only one re-auth operation runs per connection at a time +// - Connections are not used for commands during re-authentication +// - Re-auth operations timeout if they can't acquire the connection +// - Resources are properly cleaned up on connection removal +type ReAuthPoolHook struct { + // shouldReAuth maps connection ID to re-auth function + // Connections in this map need re-authentication but haven't been scheduled yet + shouldReAuth map[uint64]func(error) + shouldReAuthLock sync.RWMutex + + // workers is a semaphore limiting concurrent re-auth operations + // Initialized with poolSize tokens to prevent pool exhaustion + // Uses FastSemaphore for better performance with eventual fairness + workers *internal.FastSemaphore + + // reAuthTimeout is the maximum time to wait for acquiring a connection for re-auth + reAuthTimeout time.Duration + + // scheduledReAuth maps connection ID to scheduled status + // Connections in this map have a background worker attempting re-authentication + scheduledReAuth map[uint64]bool + scheduledLock sync.RWMutex + + // manager is a back-reference for cleanup operations + manager *Manager +} + +// NewReAuthPoolHook creates a new re-authentication pool hook. +// +// Parameters: +// - poolSize: Maximum number of concurrent re-auth operations (typically matches pool size) +// - reAuthTimeout: Maximum time to wait for acquiring a connection for re-authentication +// +// The poolSize parameter is used to initialize the worker semaphore, ensuring that +// re-auth operations don't exhaust the connection pool. +func NewReAuthPoolHook(poolSize int, reAuthTimeout time.Duration) *ReAuthPoolHook { + return &ReAuthPoolHook{ + shouldReAuth: make(map[uint64]func(error)), + scheduledReAuth: make(map[uint64]bool), + workers: internal.NewFastSemaphore(int32(poolSize)), + reAuthTimeout: reAuthTimeout, + } +} + +// MarkForReAuth marks a connection for re-authentication. +// +// This method is called when credentials change and a connection needs to be +// re-authenticated. The actual re-authentication happens asynchronously when +// the connection is returned to the pool (in OnPut). +// +// Parameters: +// - connID: The connection ID to mark for re-authentication +// - reAuthFn: Function to call for re-authentication, receives error if acquisition fails +// +// Thread-safe: Can be called concurrently from multiple goroutines. +func (r *ReAuthPoolHook) MarkForReAuth(connID uint64, reAuthFn func(error)) { + r.shouldReAuthLock.Lock() + defer r.shouldReAuthLock.Unlock() + r.shouldReAuth[connID] = reAuthFn +} + +// OnGet is called when a connection is retrieved from the pool. +// +// This hook checks if the connection needs re-authentication or has a scheduled +// re-auth operation. If so, it rejects the connection (returns accept=false), +// causing the pool to try another connection. +// +// Returns: +// - accept: false if connection needs re-auth, true otherwise +// - err: always nil (errors are not used in this hook) +// +// Thread-safe: Called concurrently by multiple goroutines getting connections. +func (r *ReAuthPoolHook) OnGet(_ context.Context, conn *pool.Conn, _ bool) (accept bool, err error) { + connID := conn.GetID() + r.shouldReAuthLock.RLock() + _, shouldReAuth := r.shouldReAuth[connID] + r.shouldReAuthLock.RUnlock() + // This connection was marked for reauth while in the pool, + // reject the connection + if shouldReAuth { + // simply reject the connection, it will be re-authenticated in OnPut + return false, nil + } + r.scheduledLock.RLock() + _, hasScheduled := r.scheduledReAuth[connID] + r.scheduledLock.RUnlock() + // has scheduled reauth, reject the connection + if hasScheduled { + // simply reject the connection, it currently has a reauth scheduled + // and the worker is waiting for slot to execute the reauth + return false, nil + } + return true, nil +} + +// OnPut is called when a connection is returned to the pool. +// +// This hook checks if the connection needs re-authentication. If so, it schedules +// a background goroutine to perform the re-auth asynchronously. The goroutine: +// 1. Waits for a worker slot (semaphore) +// 2. Acquires the connection (waits until not in use) +// 3. Executes the re-auth function +// 4. Releases the connection and worker slot +// +// The connection is always pooled (not removed) since re-auth happens in background. +// +// Returns: +// - shouldPool: always true (connection stays in pool during background re-auth) +// - shouldRemove: always false +// - err: always nil +// +// Thread-safe: Called concurrently by multiple goroutines returning connections. +func (r *ReAuthPoolHook) OnPut(_ context.Context, conn *pool.Conn) (bool, bool, error) { + if conn == nil { + // noop + return true, false, nil + } + connID := conn.GetID() + // Check if reauth is needed and get the function with proper locking + r.shouldReAuthLock.RLock() + reAuthFn, ok := r.shouldReAuth[connID] + r.shouldReAuthLock.RUnlock() + + if ok { + // Acquire both locks to atomically move from shouldReAuth to scheduledReAuth + // This prevents race conditions where OnGet might miss the transition + r.shouldReAuthLock.Lock() + r.scheduledLock.Lock() + r.scheduledReAuth[connID] = true + delete(r.shouldReAuth, connID) + r.scheduledLock.Unlock() + r.shouldReAuthLock.Unlock() + go func() { + r.workers.AcquireBlocking() + // safety first + if conn == nil || (conn != nil && conn.IsClosed()) { + r.workers.Release() + return + } + defer func() { + if rec := recover(); rec != nil { + // once again - safety first + internal.Logger.Printf(context.Background(), "panic in reauth worker: %v", rec) + } + r.scheduledLock.Lock() + delete(r.scheduledReAuth, connID) + r.scheduledLock.Unlock() + r.workers.Release() + }() + + // Create timeout context for connection acquisition + // This prevents indefinite waiting if the connection is stuck + ctx, cancel := context.WithTimeout(context.Background(), r.reAuthTimeout) + defer cancel() + + // Try to acquire the connection for re-authentication + // We need to ensure the connection is IDLE (not IN_USE) before transitioning to UNUSABLE + // This prevents re-authentication from interfering with active commands + // Use AwaitAndTransition to wait for the connection to become IDLE + stateMachine := conn.GetStateMachine() + if stateMachine == nil { + // No state machine - should not happen, but handle gracefully + reAuthFn(pool.ErrConnUnusableTimeout) + return + } + + // Use predefined slice to avoid allocation + _, err := stateMachine.AwaitAndTransition(ctx, pool.ValidFromIdle(), pool.StateUnusable) + if err != nil { + // Timeout or other error occurred, cannot acquire connection + reAuthFn(err) + return + } + + // safety first + if !conn.IsClosed() { + // Successfully acquired the connection, perform reauth + reAuthFn(nil) + } + + // Release the connection: transition from UNUSABLE back to IDLE + stateMachine.Transition(pool.StateIdle) + }() + } + + // the reauth will happen in background, as far as the pool is concerned: + // pool the connection, don't remove it, no error + return true, false, nil +} + +// OnRemove is called when a connection is removed from the pool. +// +// This hook cleans up all state associated with the connection: +// - Removes from shouldReAuth map (pending re-auth) +// - Removes from scheduledReAuth map (active re-auth) +// - Removes credentials listener from manager +// +// This prevents memory leaks and ensures that removed connections don't have +// lingering re-auth operations or listeners. +// +// Thread-safe: Called when connections are removed due to errors, timeouts, or pool closure. +func (r *ReAuthPoolHook) OnRemove(_ context.Context, conn *pool.Conn, _ error) { + connID := conn.GetID() + r.shouldReAuthLock.Lock() + r.scheduledLock.Lock() + delete(r.scheduledReAuth, connID) + delete(r.shouldReAuth, connID) + r.scheduledLock.Unlock() + r.shouldReAuthLock.Unlock() + if r.manager != nil { + r.manager.RemoveListener(connID) + } +} + +var _ pool.PoolHook = (*ReAuthPoolHook)(nil) diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go b/backend/vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go index f13ee816..ea56fd6c 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go @@ -56,6 +56,18 @@ func Key(key string) string { return key } +func Present(key string) bool { + if key == "" { + return false + } + if s := strings.IndexByte(key, '{'); s > -1 { + if e := strings.IndexByte(key[s+1:], '}'); e > 0 { + return true + } + } + return false +} + func RandomSlot() int { return rand.Intn(slotNumber) } diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/interfaces/interfaces.go b/backend/vendor/github.com/redis/go-redis/v9/internal/interfaces/interfaces.go new file mode 100644 index 00000000..17e2a185 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/interfaces/interfaces.go @@ -0,0 +1,54 @@ +// Package interfaces provides shared interfaces used by both the main redis package +// and the maintnotifications upgrade package to avoid circular dependencies. +package interfaces + +import ( + "context" + "net" + "time" +) + +// NotificationProcessor is (most probably) a push.NotificationProcessor +// forward declaration to avoid circular imports +type NotificationProcessor interface { + RegisterHandler(pushNotificationName string, handler interface{}, protected bool) error + UnregisterHandler(pushNotificationName string) error + GetHandler(pushNotificationName string) interface{} +} + +// ClientInterface defines the interface that clients must implement for maintnotifications upgrades. +type ClientInterface interface { + // GetOptions returns the client options. + GetOptions() OptionsInterface + + // GetPushProcessor returns the client's push notification processor. + GetPushProcessor() NotificationProcessor +} + +// OptionsInterface defines the interface for client options. +// Uses an adapter pattern to avoid circular dependencies. +type OptionsInterface interface { + // GetReadTimeout returns the read timeout. + GetReadTimeout() time.Duration + + // GetWriteTimeout returns the write timeout. + GetWriteTimeout() time.Duration + + // GetNetwork returns the network type. + GetNetwork() string + + // GetAddr returns the connection address. + GetAddr() string + + // IsTLSEnabled returns true if TLS is enabled. + IsTLSEnabled() bool + + // GetProtocol returns the protocol version. + GetProtocol() int + + // GetPoolSize returns the connection pool size. + GetPoolSize() int + + // NewDialer returns a new dialer function for the connection. + NewDialer() func(context.Context) (net.Conn, error) +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/log.go b/backend/vendor/github.com/redis/go-redis/v9/internal/log.go index c8b9213d..0bfffc31 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/log.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/log.go @@ -7,20 +7,73 @@ import ( "os" ) +// TODO (ned): Revisit logging +// Add more standardized approach with log levels and configurability + type Logging interface { Printf(ctx context.Context, format string, v ...interface{}) } -type logger struct { +type DefaultLogger struct { log *log.Logger } -func (l *logger) Printf(ctx context.Context, format string, v ...interface{}) { +func (l *DefaultLogger) Printf(ctx context.Context, format string, v ...interface{}) { _ = l.log.Output(2, fmt.Sprintf(format, v...)) } +func NewDefaultLogger() Logging { + return &DefaultLogger{ + log: log.New(os.Stderr, "redis: ", log.LstdFlags|log.Lshortfile), + } +} + // Logger calls Output to print to the stderr. // Arguments are handled in the manner of fmt.Print. -var Logger Logging = &logger{ - log: log.New(os.Stderr, "redis: ", log.LstdFlags|log.Lshortfile), +var Logger Logging = NewDefaultLogger() + +var LogLevel LogLevelT = LogLevelError + +// LogLevelT represents the logging level +type LogLevelT int + +// Log level constants for the entire go-redis library +const ( + LogLevelError LogLevelT = iota // 0 - errors only + LogLevelWarn // 1 - warnings and errors + LogLevelInfo // 2 - info, warnings, and errors + LogLevelDebug // 3 - debug, info, warnings, and errors +) + +// String returns the string representation of the log level +func (l LogLevelT) String() string { + switch l { + case LogLevelError: + return "ERROR" + case LogLevelWarn: + return "WARN" + case LogLevelInfo: + return "INFO" + case LogLevelDebug: + return "DEBUG" + default: + return "UNKNOWN" + } +} + +// IsValid returns true if the log level is valid +func (l LogLevelT) IsValid() bool { + return l >= LogLevelError && l <= LogLevelDebug +} + +func (l LogLevelT) WarnOrAbove() bool { + return l >= LogLevelWarn +} + +func (l LogLevelT) InfoOrAbove() bool { + return l >= LogLevelInfo +} + +func (l LogLevelT) DebugOrAbove() bool { + return l >= LogLevelDebug } diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/maintnotifications/logs/log_messages.go b/backend/vendor/github.com/redis/go-redis/v9/internal/maintnotifications/logs/log_messages.go new file mode 100644 index 00000000..34cb1692 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/maintnotifications/logs/log_messages.go @@ -0,0 +1,625 @@ +package logs + +import ( + "encoding/json" + "fmt" + "regexp" + + "github.com/redis/go-redis/v9/internal" +) + +// appendJSONIfDebug appends JSON data to a message only if the global log level is Debug +func appendJSONIfDebug(message string, data map[string]interface{}) string { + if internal.LogLevel.DebugOrAbove() { + jsonData, _ := json.Marshal(data) + return fmt.Sprintf("%s %s", message, string(jsonData)) + } + return message +} + +const ( + // ======================================== + // CIRCUIT_BREAKER.GO - Circuit breaker management + // ======================================== + CircuitBreakerTransitioningToHalfOpenMessage = "circuit breaker transitioning to half-open" + CircuitBreakerOpenedMessage = "circuit breaker opened" + CircuitBreakerReopenedMessage = "circuit breaker reopened" + CircuitBreakerClosedMessage = "circuit breaker closed" + CircuitBreakerCleanupMessage = "circuit breaker cleanup" + CircuitBreakerOpenMessage = "circuit breaker is open, failing fast" + + // ======================================== + // CONFIG.GO - Configuration and debug + // ======================================== + DebugLoggingEnabledMessage = "debug logging enabled" + ConfigDebugMessage = "config debug" + + // ======================================== + // ERRORS.GO - Error message constants + // ======================================== + InvalidRelaxedTimeoutErrorMessage = "relaxed timeout must be greater than 0" + InvalidHandoffTimeoutErrorMessage = "handoff timeout must be greater than 0" + InvalidHandoffWorkersErrorMessage = "MaxWorkers must be greater than or equal to 0" + InvalidHandoffQueueSizeErrorMessage = "handoff queue size must be greater than 0" + InvalidPostHandoffRelaxedDurationErrorMessage = "post-handoff relaxed duration must be greater than or equal to 0" + InvalidEndpointTypeErrorMessage = "invalid endpoint type" + InvalidMaintNotificationsErrorMessage = "invalid maintenance notifications setting (must be 'disabled', 'enabled', or 'auto')" + InvalidHandoffRetriesErrorMessage = "MaxHandoffRetries must be between 1 and 10" + InvalidClientErrorMessage = "invalid client type" + InvalidNotificationErrorMessage = "invalid notification format" + MaxHandoffRetriesReachedErrorMessage = "max handoff retries reached" + HandoffQueueFullErrorMessage = "handoff queue is full, cannot queue new handoff requests - consider increasing HandoffQueueSize or MaxWorkers in configuration" + InvalidCircuitBreakerFailureThresholdErrorMessage = "circuit breaker failure threshold must be >= 1" + InvalidCircuitBreakerResetTimeoutErrorMessage = "circuit breaker reset timeout must be >= 0" + InvalidCircuitBreakerMaxRequestsErrorMessage = "circuit breaker max requests must be >= 1" + ConnectionMarkedForHandoffErrorMessage = "connection marked for handoff" + ConnectionInvalidHandoffStateErrorMessage = "connection is in invalid state for handoff" + ShutdownErrorMessage = "shutdown" + CircuitBreakerOpenErrorMessage = "circuit breaker is open, failing fast" + + // ======================================== + // EXAMPLE_HOOKS.GO - Example metrics hooks + // ======================================== + MetricsHookProcessingNotificationMessage = "metrics hook processing" + MetricsHookRecordedErrorMessage = "metrics hook recorded error" + + // ======================================== + // HANDOFF_WORKER.GO - Connection handoff processing + // ======================================== + HandoffStartedMessage = "handoff started" + HandoffFailedMessage = "handoff failed" + ConnectionNotMarkedForHandoffMessage = "is not marked for handoff and has no retries" + ConnectionNotMarkedForHandoffErrorMessage = "is not marked for handoff" + HandoffRetryAttemptMessage = "Performing handoff" + CannotQueueHandoffForRetryMessage = "can't queue handoff for retry" + HandoffQueueFullMessage = "handoff queue is full" + FailedToDialNewEndpointMessage = "failed to dial new endpoint" + ApplyingRelaxedTimeoutDueToPostHandoffMessage = "applying relaxed timeout due to post-handoff" + HandoffSuccessMessage = "handoff succeeded" + RemovingConnectionFromPoolMessage = "removing connection from pool" + NoPoolProvidedMessageCannotRemoveMessage = "no pool provided, cannot remove connection, closing it" + WorkerExitingDueToShutdownMessage = "worker exiting due to shutdown" + WorkerExitingDueToShutdownWhileProcessingMessage = "worker exiting due to shutdown while processing request" + WorkerPanicRecoveredMessage = "worker panic recovered" + WorkerExitingDueToInactivityTimeoutMessage = "worker exiting due to inactivity timeout" + ReachedMaxHandoffRetriesMessage = "reached max handoff retries" + + // ======================================== + // MANAGER.GO - Moving operation tracking and handler registration + // ======================================== + DuplicateMovingOperationMessage = "duplicate MOVING operation ignored" + TrackingMovingOperationMessage = "tracking MOVING operation" + UntrackingMovingOperationMessage = "untracking MOVING operation" + OperationNotTrackedMessage = "operation not tracked" + FailedToRegisterHandlerMessage = "failed to register handler" + + // ======================================== + // HOOKS.GO - Notification processing hooks + // ======================================== + ProcessingNotificationMessage = "processing notification started" + ProcessingNotificationFailedMessage = "proccessing notification failed" + ProcessingNotificationSucceededMessage = "processing notification succeeded" + + // ======================================== + // POOL_HOOK.GO - Pool connection management + // ======================================== + FailedToQueueHandoffMessage = "failed to queue handoff" + MarkedForHandoffMessage = "connection marked for handoff" + + // ======================================== + // PUSH_NOTIFICATION_HANDLER.GO - Push notification validation and processing + // ======================================== + InvalidNotificationFormatMessage = "invalid notification format" + InvalidNotificationTypeFormatMessage = "invalid notification type format" + InvalidSeqIDInMovingNotificationMessage = "invalid seqID in MOVING notification" + InvalidTimeSInMovingNotificationMessage = "invalid timeS in MOVING notification" + InvalidNewEndpointInMovingNotificationMessage = "invalid newEndpoint in MOVING notification" + NoConnectionInHandlerContextMessage = "no connection in handler context" + InvalidConnectionTypeInHandlerContextMessage = "invalid connection type in handler context" + SchedulingHandoffToCurrentEndpointMessage = "scheduling handoff to current endpoint" + RelaxedTimeoutDueToNotificationMessage = "applying relaxed timeout due to notification" + UnrelaxedTimeoutMessage = "clearing relaxed timeout" + ManagerNotInitializedMessage = "manager not initialized" + FailedToMarkForHandoffMessage = "failed to mark connection for handoff" + + // ======================================== + // used in pool/conn + // ======================================== + UnrelaxedTimeoutAfterDeadlineMessage = "clearing relaxed timeout after deadline" +) + +func HandoffStarted(connID uint64, newEndpoint string) string { + message := fmt.Sprintf("conn[%d] %s to %s", connID, HandoffStartedMessage, newEndpoint) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "endpoint": newEndpoint, + }) +} + +func HandoffFailed(connID uint64, newEndpoint string, attempt int, maxAttempts int, err error) string { + message := fmt.Sprintf("conn[%d] %s to %s (attempt %d/%d): %v", connID, HandoffFailedMessage, newEndpoint, attempt, maxAttempts, err) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "endpoint": newEndpoint, + "attempt": attempt, + "maxAttempts": maxAttempts, + "error": err.Error(), + }) +} + +func HandoffSucceeded(connID uint64, newEndpoint string) string { + message := fmt.Sprintf("conn[%d] %s to %s", connID, HandoffSuccessMessage, newEndpoint) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "endpoint": newEndpoint, + }) +} + +// Timeout-related log functions +func RelaxedTimeoutDueToNotification(connID uint64, notificationType string, timeout interface{}) string { + message := fmt.Sprintf("conn[%d] %s %s (%v)", connID, RelaxedTimeoutDueToNotificationMessage, notificationType, timeout) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "notificationType": notificationType, + "timeout": fmt.Sprintf("%v", timeout), + }) +} + +func UnrelaxedTimeout(connID uint64) string { + message := fmt.Sprintf("conn[%d] %s", connID, UnrelaxedTimeoutMessage) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + }) +} + +func UnrelaxedTimeoutAfterDeadline(connID uint64) string { + message := fmt.Sprintf("conn[%d] %s", connID, UnrelaxedTimeoutAfterDeadlineMessage) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + }) +} + +// Handoff queue and marking functions +func HandoffQueueFull(queueLen, queueCap int) string { + message := fmt.Sprintf("%s (%d/%d), cannot queue new handoff requests - consider increasing HandoffQueueSize or MaxWorkers in configuration", HandoffQueueFullMessage, queueLen, queueCap) + return appendJSONIfDebug(message, map[string]interface{}{ + "queueLen": queueLen, + "queueCap": queueCap, + }) +} + +func FailedToQueueHandoff(connID uint64, err error) string { + message := fmt.Sprintf("conn[%d] %s: %v", connID, FailedToQueueHandoffMessage, err) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "error": err.Error(), + }) +} + +func FailedToMarkForHandoff(connID uint64, err error) string { + message := fmt.Sprintf("conn[%d] %s: %v", connID, FailedToMarkForHandoffMessage, err) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "error": err.Error(), + }) +} + +func FailedToDialNewEndpoint(connID uint64, endpoint string, err error) string { + message := fmt.Sprintf("conn[%d] %s %s: %v", connID, FailedToDialNewEndpointMessage, endpoint, err) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "endpoint": endpoint, + "error": err.Error(), + }) +} + +func ReachedMaxHandoffRetries(connID uint64, endpoint string, maxRetries int) string { + message := fmt.Sprintf("conn[%d] %s to %s (max retries: %d)", connID, ReachedMaxHandoffRetriesMessage, endpoint, maxRetries) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "endpoint": endpoint, + "maxRetries": maxRetries, + }) +} + +// Notification processing functions +func ProcessingNotification(connID uint64, seqID int64, notificationType string, notification interface{}) string { + message := fmt.Sprintf("conn[%d] seqID[%d] %s %s: %v", connID, seqID, ProcessingNotificationMessage, notificationType, notification) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "seqID": seqID, + "notificationType": notificationType, + "notification": fmt.Sprintf("%v", notification), + }) +} + +func ProcessingNotificationFailed(connID uint64, notificationType string, err error, notification interface{}) string { + message := fmt.Sprintf("conn[%d] %s %s: %v - %v", connID, ProcessingNotificationFailedMessage, notificationType, err, notification) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "notificationType": notificationType, + "error": err.Error(), + "notification": fmt.Sprintf("%v", notification), + }) +} + +func ProcessingNotificationSucceeded(connID uint64, notificationType string) string { + message := fmt.Sprintf("conn[%d] %s %s", connID, ProcessingNotificationSucceededMessage, notificationType) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "notificationType": notificationType, + }) +} + +// Moving operation tracking functions +func DuplicateMovingOperation(connID uint64, endpoint string, seqID int64) string { + message := fmt.Sprintf("conn[%d] %s for %s seqID[%d]", connID, DuplicateMovingOperationMessage, endpoint, seqID) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "endpoint": endpoint, + "seqID": seqID, + }) +} + +func TrackingMovingOperation(connID uint64, endpoint string, seqID int64) string { + message := fmt.Sprintf("conn[%d] %s for %s seqID[%d]", connID, TrackingMovingOperationMessage, endpoint, seqID) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "endpoint": endpoint, + "seqID": seqID, + }) +} + +func UntrackingMovingOperation(connID uint64, seqID int64) string { + message := fmt.Sprintf("conn[%d] %s seqID[%d]", connID, UntrackingMovingOperationMessage, seqID) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "seqID": seqID, + }) +} + +func OperationNotTracked(connID uint64, seqID int64) string { + message := fmt.Sprintf("conn[%d] %s seqID[%d]", connID, OperationNotTrackedMessage, seqID) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "seqID": seqID, + }) +} + +// Connection pool functions +func RemovingConnectionFromPool(connID uint64, reason error) string { + message := fmt.Sprintf("conn[%d] %s due to: %v", connID, RemovingConnectionFromPoolMessage, reason) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "reason": reason.Error(), + }) +} + +func NoPoolProvidedCannotRemove(connID uint64, reason error) string { + message := fmt.Sprintf("conn[%d] %s due to: %v", connID, NoPoolProvidedMessageCannotRemoveMessage, reason) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "reason": reason.Error(), + }) +} + +// Circuit breaker functions +func CircuitBreakerOpen(connID uint64, endpoint string) string { + message := fmt.Sprintf("conn[%d] %s for %s", connID, CircuitBreakerOpenMessage, endpoint) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "endpoint": endpoint, + }) +} + +// Additional handoff functions for specific cases +func ConnectionNotMarkedForHandoff(connID uint64) string { + message := fmt.Sprintf("conn[%d] %s", connID, ConnectionNotMarkedForHandoffMessage) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + }) +} + +func ConnectionNotMarkedForHandoffError(connID uint64) string { + return fmt.Sprintf("conn[%d] %s", connID, ConnectionNotMarkedForHandoffErrorMessage) +} + +func HandoffRetryAttempt(connID uint64, retries int, newEndpoint string, oldEndpoint string) string { + message := fmt.Sprintf("conn[%d] Retry %d: %s to %s(was %s)", connID, retries, HandoffRetryAttemptMessage, newEndpoint, oldEndpoint) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "retries": retries, + "newEndpoint": newEndpoint, + "oldEndpoint": oldEndpoint, + }) +} + +func CannotQueueHandoffForRetry(err error) string { + message := fmt.Sprintf("%s: %v", CannotQueueHandoffForRetryMessage, err) + return appendJSONIfDebug(message, map[string]interface{}{ + "error": err.Error(), + }) +} + +// Validation and error functions +func InvalidNotificationFormat(notification interface{}) string { + message := fmt.Sprintf("%s: %v", InvalidNotificationFormatMessage, notification) + return appendJSONIfDebug(message, map[string]interface{}{ + "notification": fmt.Sprintf("%v", notification), + }) +} + +func InvalidNotificationTypeFormat(notificationType interface{}) string { + message := fmt.Sprintf("%s: %v", InvalidNotificationTypeFormatMessage, notificationType) + return appendJSONIfDebug(message, map[string]interface{}{ + "notificationType": fmt.Sprintf("%v", notificationType), + }) +} + +// InvalidNotification creates a log message for invalid notifications of any type +func InvalidNotification(notificationType string, notification interface{}) string { + message := fmt.Sprintf("invalid %s notification: %v", notificationType, notification) + return appendJSONIfDebug(message, map[string]interface{}{ + "notificationType": notificationType, + "notification": fmt.Sprintf("%v", notification), + }) +} + +func InvalidSeqIDInMovingNotification(seqID interface{}) string { + message := fmt.Sprintf("%s: %v", InvalidSeqIDInMovingNotificationMessage, seqID) + return appendJSONIfDebug(message, map[string]interface{}{ + "seqID": fmt.Sprintf("%v", seqID), + }) +} + +func InvalidTimeSInMovingNotification(timeS interface{}) string { + message := fmt.Sprintf("%s: %v", InvalidTimeSInMovingNotificationMessage, timeS) + return appendJSONIfDebug(message, map[string]interface{}{ + "timeS": fmt.Sprintf("%v", timeS), + }) +} + +func InvalidNewEndpointInMovingNotification(newEndpoint interface{}) string { + message := fmt.Sprintf("%s: %v", InvalidNewEndpointInMovingNotificationMessage, newEndpoint) + return appendJSONIfDebug(message, map[string]interface{}{ + "newEndpoint": fmt.Sprintf("%v", newEndpoint), + }) +} + +func NoConnectionInHandlerContext(notificationType string) string { + message := fmt.Sprintf("%s for %s notification", NoConnectionInHandlerContextMessage, notificationType) + return appendJSONIfDebug(message, map[string]interface{}{ + "notificationType": notificationType, + }) +} + +func InvalidConnectionTypeInHandlerContext(notificationType string, conn interface{}, handlerCtx interface{}) string { + message := fmt.Sprintf("%s for %s notification - %T %#v", InvalidConnectionTypeInHandlerContextMessage, notificationType, conn, handlerCtx) + return appendJSONIfDebug(message, map[string]interface{}{ + "notificationType": notificationType, + "connType": fmt.Sprintf("%T", conn), + }) +} + +func SchedulingHandoffToCurrentEndpoint(connID uint64, seconds float64) string { + message := fmt.Sprintf("conn[%d] %s in %v seconds", connID, SchedulingHandoffToCurrentEndpointMessage, seconds) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "seconds": seconds, + }) +} + +func ManagerNotInitialized() string { + return appendJSONIfDebug(ManagerNotInitializedMessage, map[string]interface{}{}) +} + +func FailedToRegisterHandler(notificationType string, err error) string { + message := fmt.Sprintf("%s for %s: %v", FailedToRegisterHandlerMessage, notificationType, err) + return appendJSONIfDebug(message, map[string]interface{}{ + "notificationType": notificationType, + "error": err.Error(), + }) +} + +func ShutdownError() string { + return appendJSONIfDebug(ShutdownErrorMessage, map[string]interface{}{}) +} + +// Configuration validation error functions +func InvalidRelaxedTimeoutError() string { + return appendJSONIfDebug(InvalidRelaxedTimeoutErrorMessage, map[string]interface{}{}) +} + +func InvalidHandoffTimeoutError() string { + return appendJSONIfDebug(InvalidHandoffTimeoutErrorMessage, map[string]interface{}{}) +} + +func InvalidHandoffWorkersError() string { + return appendJSONIfDebug(InvalidHandoffWorkersErrorMessage, map[string]interface{}{}) +} + +func InvalidHandoffQueueSizeError() string { + return appendJSONIfDebug(InvalidHandoffQueueSizeErrorMessage, map[string]interface{}{}) +} + +func InvalidPostHandoffRelaxedDurationError() string { + return appendJSONIfDebug(InvalidPostHandoffRelaxedDurationErrorMessage, map[string]interface{}{}) +} + +func InvalidEndpointTypeError() string { + return appendJSONIfDebug(InvalidEndpointTypeErrorMessage, map[string]interface{}{}) +} + +func InvalidMaintNotificationsError() string { + return appendJSONIfDebug(InvalidMaintNotificationsErrorMessage, map[string]interface{}{}) +} + +func InvalidHandoffRetriesError() string { + return appendJSONIfDebug(InvalidHandoffRetriesErrorMessage, map[string]interface{}{}) +} + +func InvalidClientError() string { + return appendJSONIfDebug(InvalidClientErrorMessage, map[string]interface{}{}) +} + +func InvalidNotificationError() string { + return appendJSONIfDebug(InvalidNotificationErrorMessage, map[string]interface{}{}) +} + +func MaxHandoffRetriesReachedError() string { + return appendJSONIfDebug(MaxHandoffRetriesReachedErrorMessage, map[string]interface{}{}) +} + +func HandoffQueueFullError() string { + return appendJSONIfDebug(HandoffQueueFullErrorMessage, map[string]interface{}{}) +} + +func InvalidCircuitBreakerFailureThresholdError() string { + return appendJSONIfDebug(InvalidCircuitBreakerFailureThresholdErrorMessage, map[string]interface{}{}) +} + +func InvalidCircuitBreakerResetTimeoutError() string { + return appendJSONIfDebug(InvalidCircuitBreakerResetTimeoutErrorMessage, map[string]interface{}{}) +} + +func InvalidCircuitBreakerMaxRequestsError() string { + return appendJSONIfDebug(InvalidCircuitBreakerMaxRequestsErrorMessage, map[string]interface{}{}) +} + +// Configuration and debug functions +func DebugLoggingEnabled() string { + return appendJSONIfDebug(DebugLoggingEnabledMessage, map[string]interface{}{}) +} + +func ConfigDebug(config interface{}) string { + message := fmt.Sprintf("%s: %+v", ConfigDebugMessage, config) + return appendJSONIfDebug(message, map[string]interface{}{ + "config": fmt.Sprintf("%+v", config), + }) +} + +// Handoff worker functions +func WorkerExitingDueToShutdown() string { + return appendJSONIfDebug(WorkerExitingDueToShutdownMessage, map[string]interface{}{}) +} + +func WorkerExitingDueToShutdownWhileProcessing() string { + return appendJSONIfDebug(WorkerExitingDueToShutdownWhileProcessingMessage, map[string]interface{}{}) +} + +func WorkerPanicRecovered(panicValue interface{}) string { + message := fmt.Sprintf("%s: %v", WorkerPanicRecoveredMessage, panicValue) + return appendJSONIfDebug(message, map[string]interface{}{ + "panic": fmt.Sprintf("%v", panicValue), + }) +} + +func WorkerExitingDueToInactivityTimeout(timeout interface{}) string { + message := fmt.Sprintf("%s (%v)", WorkerExitingDueToInactivityTimeoutMessage, timeout) + return appendJSONIfDebug(message, map[string]interface{}{ + "timeout": fmt.Sprintf("%v", timeout), + }) +} + +func ApplyingRelaxedTimeoutDueToPostHandoff(connID uint64, timeout interface{}, until string) string { + message := fmt.Sprintf("conn[%d] %s (%v) until %s", connID, ApplyingRelaxedTimeoutDueToPostHandoffMessage, timeout, until) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + "timeout": fmt.Sprintf("%v", timeout), + "until": until, + }) +} + +// Example hooks functions +func MetricsHookProcessingNotification(notificationType string, connID uint64) string { + message := fmt.Sprintf("%s %s notification on conn[%d]", MetricsHookProcessingNotificationMessage, notificationType, connID) + return appendJSONIfDebug(message, map[string]interface{}{ + "notificationType": notificationType, + "connID": connID, + }) +} + +func MetricsHookRecordedError(notificationType string, connID uint64, err error) string { + message := fmt.Sprintf("%s for %s notification on conn[%d]: %v", MetricsHookRecordedErrorMessage, notificationType, connID, err) + return appendJSONIfDebug(message, map[string]interface{}{ + "notificationType": notificationType, + "connID": connID, + "error": err.Error(), + }) +} + +// Pool hook functions +func MarkedForHandoff(connID uint64) string { + message := fmt.Sprintf("conn[%d] %s", connID, MarkedForHandoffMessage) + return appendJSONIfDebug(message, map[string]interface{}{ + "connID": connID, + }) +} + +// Circuit breaker additional functions +func CircuitBreakerTransitioningToHalfOpen(endpoint string) string { + message := fmt.Sprintf("%s for %s", CircuitBreakerTransitioningToHalfOpenMessage, endpoint) + return appendJSONIfDebug(message, map[string]interface{}{ + "endpoint": endpoint, + }) +} + +func CircuitBreakerOpened(endpoint string, failures int64) string { + message := fmt.Sprintf("%s for endpoint %s after %d failures", CircuitBreakerOpenedMessage, endpoint, failures) + return appendJSONIfDebug(message, map[string]interface{}{ + "endpoint": endpoint, + "failures": failures, + }) +} + +func CircuitBreakerReopened(endpoint string) string { + message := fmt.Sprintf("%s for endpoint %s due to failure in half-open state", CircuitBreakerReopenedMessage, endpoint) + return appendJSONIfDebug(message, map[string]interface{}{ + "endpoint": endpoint, + }) +} + +func CircuitBreakerClosed(endpoint string, successes int64) string { + message := fmt.Sprintf("%s for endpoint %s after %d successful requests", CircuitBreakerClosedMessage, endpoint, successes) + return appendJSONIfDebug(message, map[string]interface{}{ + "endpoint": endpoint, + "successes": successes, + }) +} + +func CircuitBreakerCleanup(removed int, total int) string { + message := fmt.Sprintf("%s removed %d/%d entries", CircuitBreakerCleanupMessage, removed, total) + return appendJSONIfDebug(message, map[string]interface{}{ + "removed": removed, + "total": total, + }) +} + +// ExtractDataFromLogMessage extracts structured data from maintnotifications log messages +// Returns a map containing the parsed key-value pairs from the structured data section +// Example: "conn[123] handoff started to localhost:6379 {"connID":123,"endpoint":"localhost:6379"}" +// Returns: map[string]interface{}{"connID": 123, "endpoint": "localhost:6379"} +func ExtractDataFromLogMessage(logMessage string) map[string]interface{} { + result := make(map[string]interface{}) + + // Find the JSON data section at the end of the message + re := regexp.MustCompile(`(\{.*\})$`) + matches := re.FindStringSubmatch(logMessage) + if len(matches) < 2 { + return result + } + + jsonStr := matches[1] + if jsonStr == "" { + return result + } + + // Parse the JSON directly + var jsonResult map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &jsonResult); err == nil { + return jsonResult + } + + // If JSON parsing fails, return empty map + return result +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn.go index 7f45bc0b..95d83bfd 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn.go @@ -1,64 +1,803 @@ +// Package pool implements the pool management package pool import ( "bufio" "context" + "errors" + "fmt" "net" + "sync" "sync/atomic" "time" + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" "github.com/redis/go-redis/v9/internal/proto" ) var noDeadline = time.Time{} +// Preallocated errors for hot paths to avoid allocations +var ( + errAlreadyMarkedForHandoff = errors.New("connection is already marked for handoff") + errNotMarkedForHandoff = errors.New("connection was not marked for handoff") + errHandoffStateChanged = errors.New("handoff state changed during marking") + errConnectionNotAvailable = errors.New("redis: connection not available") + errConnNotAvailableForWrite = errors.New("redis: connection not available for write operation") +) + +// getCachedTimeNs returns the current time in nanoseconds. +// This function previously used a global cache updated by a background goroutine, +// but that caused unnecessary CPU usage when the client was idle (ticker waking up +// the scheduler every 50ms). We now use time.Now() directly, which is fast enough +// on modern systems (vDSO on Linux) and only adds ~1-2% overhead in extreme +// high-concurrency benchmarks while eliminating idle CPU usage. +func getCachedTimeNs() int64 { + return time.Now().UnixNano() +} + +// GetCachedTimeNs returns the current time in nanoseconds. +// Exported for use by other packages that need fast time access. +func GetCachedTimeNs() int64 { + return getCachedTimeNs() +} + +// Global atomic counter for connection IDs +var connIDCounter uint64 + +// HandoffState represents the atomic state for connection handoffs +// This struct is stored atomically to prevent race conditions between +// checking handoff status and reading handoff parameters +type HandoffState struct { + ShouldHandoff bool // Whether connection should be handed off + Endpoint string // New endpoint for handoff + SeqID int64 // Sequence ID from MOVING notification +} + +// atomicNetConn is a wrapper to ensure consistent typing in atomic.Value +type atomicNetConn struct { + conn net.Conn +} + +// generateConnID generates a fast unique identifier for a connection with zero allocations +func generateConnID() uint64 { + return atomic.AddUint64(&connIDCounter, 1) +} + type Conn struct { - usedAt int64 // atomic - netConn net.Conn + // Connection identifier for unique tracking + id uint64 + + usedAt atomic.Int64 + lastPutAt atomic.Int64 + + // Lock-free netConn access using atomic.Value + // Contains *atomicNetConn wrapper, accessed atomically for better performance + netConnAtomic atomic.Value // stores *atomicNetConn rd *proto.Reader bw *bufio.Writer wr *proto.Writer - Inited bool + // Lightweight mutex to protect reader operations during handoff + // Only used for the brief period during SetNetConn and HasBufferedData/PeekReplyTypeSafe + readerMu sync.RWMutex + + // State machine for connection state management + // Replaces: usable, Inited, used + // Provides thread-safe state transitions with FIFO waiting queue + // States: CREATED → INITIALIZING → IDLE ⇄ IN_USE + // ↓ + // UNUSABLE (handoff/reauth) + // ↓ + // IDLE/CLOSED + stateMachine *ConnStateMachine + + // Handoff metadata - managed separately from state machine + // These are atomic for lock-free access during handoff operations + handoffStateAtomic atomic.Value // stores *HandoffState + handoffRetriesAtomic atomic.Uint32 // retry counter + pooled bool + pubsub bool + closed atomic.Bool createdAt time.Time + expiresAt time.Time + + // maintenanceNotifications upgrade support: relaxed timeouts during migrations/failovers + + // Using atomic operations for lock-free access to avoid mutex contention + relaxedReadTimeoutNs atomic.Int64 // time.Duration as nanoseconds + relaxedWriteTimeoutNs atomic.Int64 // time.Duration as nanoseconds + relaxedDeadlineNs atomic.Int64 // time.Time as nanoseconds since epoch + + // Counter to track multiple relaxed timeout setters if we have nested calls + // will be decremented when ClearRelaxedTimeout is called or deadline is reached + // if counter reaches 0, we clear the relaxed timeouts + relaxedCounter atomic.Int32 + + // Connection initialization function for reconnections + initConnFunc func(context.Context, *Conn) error + + onClose func() error } func NewConn(netConn net.Conn) *Conn { + return NewConnWithBufferSize(netConn, proto.DefaultBufferSize, proto.DefaultBufferSize) +} + +func NewConnWithBufferSize(netConn net.Conn, readBufSize, writeBufSize int) *Conn { + now := time.Now() cn := &Conn{ - netConn: netConn, - createdAt: time.Now(), + createdAt: now, + id: generateConnID(), // Generate unique ID for this connection + stateMachine: NewConnStateMachine(), } - cn.rd = proto.NewReader(netConn) - cn.bw = bufio.NewWriter(netConn) + + // Use specified buffer sizes, or fall back to 32KiB defaults if 0 + if readBufSize > 0 { + cn.rd = proto.NewReaderSize(netConn, readBufSize) + } else { + cn.rd = proto.NewReader(netConn) // Uses 32KiB default + } + + if writeBufSize > 0 { + cn.bw = bufio.NewWriterSize(netConn, writeBufSize) + } else { + cn.bw = bufio.NewWriterSize(netConn, proto.DefaultBufferSize) + } + + // Store netConn atomically for lock-free access using wrapper + cn.netConnAtomic.Store(&atomicNetConn{conn: netConn}) + cn.wr = proto.NewWriter(cn.bw) - cn.SetUsedAt(time.Now()) + cn.SetUsedAt(now) + // Initialize handoff state atomically + initialHandoffState := &HandoffState{ + ShouldHandoff: false, + Endpoint: "", + SeqID: 0, + } + cn.handoffStateAtomic.Store(initialHandoffState) return cn } func (cn *Conn) UsedAt() time.Time { - unix := atomic.LoadInt64(&cn.usedAt) - return time.Unix(unix, 0) + return time.Unix(0, cn.usedAt.Load()) +} +func (cn *Conn) SetUsedAt(tm time.Time) { + cn.usedAt.Store(tm.UnixNano()) } -func (cn *Conn) SetUsedAt(tm time.Time) { - atomic.StoreInt64(&cn.usedAt, tm.Unix()) +func (cn *Conn) UsedAtNs() int64 { + return cn.usedAt.Load() +} +func (cn *Conn) SetUsedAtNs(ns int64) { + cn.usedAt.Store(ns) +} + +func (cn *Conn) LastPutAtNs() int64 { + return cn.lastPutAt.Load() +} +func (cn *Conn) SetLastPutAtNs(ns int64) { + cn.lastPutAt.Store(ns) +} + +// Backward-compatible wrapper methods for state machine +// These maintain the existing API while using the new state machine internally + +// CompareAndSwapUsable atomically compares and swaps the usable flag (lock-free). +// +// This is used by background operations (handoff, re-auth) to acquire exclusive +// access to a connection. The operation sets usable to false, preventing the pool +// from returning the connection to clients. +// +// Returns true if the swap was successful (old value matched), false otherwise. +// +// Implementation note: This is a compatibility wrapper around the state machine. +// It checks if the current state is "usable" (IDLE or IN_USE) and transitions accordingly. +// Deprecated: Use GetStateMachine().TryTransition() directly for better state management. +func (cn *Conn) CompareAndSwapUsable(old, new bool) bool { + currentState := cn.stateMachine.GetState() + + // Check if current state matches the "old" usable value + currentUsable := (currentState == StateIdle || currentState == StateInUse) + if currentUsable != old { + return false + } + + // If we're trying to set to the same value, succeed immediately + if old == new { + return true + } + + // Transition based on new value + if new { + // Trying to make usable - transition from UNUSABLE to IDLE + // This should only work from UNUSABLE or INITIALIZING states + // Use predefined slice to avoid allocation + _, err := cn.stateMachine.TryTransition( + validFromInitializingOrUnusable, + StateIdle, + ) + return err == nil + } + // Trying to make unusable - transition from IDLE to UNUSABLE + // This is typically for acquiring the connection for background operations + // Use predefined slice to avoid allocation + _, err := cn.stateMachine.TryTransition( + validFromIdle, + StateUnusable, + ) + return err == nil +} + +// IsUsable returns true if the connection is safe to use for new commands (lock-free). +// +// A connection is "usable" when it's in a stable state and can be returned to clients. +// It becomes unusable during: +// - Handoff operations (network connection replacement) +// - Re-authentication (credential updates) +// - Other background operations that need exclusive access +// +// Note: CREATED state is considered usable because new connections need to pass OnGet() hook +// before initialization. The initialization happens after OnGet() in the client code. +func (cn *Conn) IsUsable() bool { + state := cn.stateMachine.GetState() + // CREATED, IDLE, and IN_USE states are considered usable + // CREATED: new connection, not yet initialized (will be initialized by client) + // IDLE: initialized and ready to be acquired + // IN_USE: usable but currently acquired by someone + return state == StateCreated || state == StateIdle || state == StateInUse +} + +// SetUsable sets the usable flag for the connection (lock-free). +// +// Deprecated: Use GetStateMachine().Transition() directly for better state management. +// This method is kept for backwards compatibility. +// +// This should be called to mark a connection as usable after initialization or +// to release it after a background operation completes. +// +// Prefer CompareAndSwapUsable() when acquiring exclusive access to avoid race conditions. +// Deprecated: Use GetStateMachine().Transition() directly for better state management. +func (cn *Conn) SetUsable(usable bool) { + if usable { + // Transition to IDLE state (ready to be acquired) + cn.stateMachine.Transition(StateIdle) + } else { + // Transition to UNUSABLE state (for background operations) + cn.stateMachine.Transition(StateUnusable) + } +} + +// IsInited returns true if the connection has been initialized. +// This is a backward-compatible wrapper around the state machine. +func (cn *Conn) IsInited() bool { + state := cn.stateMachine.GetState() + // Connection is initialized if it's in IDLE or any post-initialization state + return state != StateCreated && state != StateInitializing && state != StateClosed +} + +// Used - State machine based implementation + +// CompareAndSwapUsed atomically compares and swaps the used flag (lock-free). +// This method is kept for backwards compatibility. +// +// This is the preferred method for acquiring a connection from the pool, as it +// ensures that only one goroutine marks the connection as used. +// +// Implementation: Uses state machine transitions IDLE ⇄ IN_USE +// +// Returns true if the swap was successful (old value matched), false otherwise. +// Deprecated: Use GetStateMachine().TryTransition() directly for better state management. +func (cn *Conn) CompareAndSwapUsed(old, new bool) bool { + if old == new { + // No change needed + currentState := cn.stateMachine.GetState() + currentUsed := (currentState == StateInUse) + return currentUsed == old + } + + if !old && new { + // Acquiring: IDLE → IN_USE + // Use predefined slice to avoid allocation + _, err := cn.stateMachine.TryTransition(validFromCreatedOrIdle, StateInUse) + return err == nil + } else { + // Releasing: IN_USE → IDLE + // Use predefined slice to avoid allocation + _, err := cn.stateMachine.TryTransition(validFromInUse, StateIdle) + return err == nil + } +} + +// IsUsed returns true if the connection is currently in use (lock-free). +// +// Deprecated: Use GetStateMachine().GetState() == StateInUse directly for better clarity. +// This method is kept for backwards compatibility. +// +// A connection is "used" when it has been retrieved from the pool and is +// actively processing a command. Background operations (like re-auth) should +// wait until the connection is not used before executing commands. +func (cn *Conn) IsUsed() bool { + return cn.stateMachine.GetState() == StateInUse +} + +// SetUsed sets the used flag for the connection (lock-free). +// +// This should be called when returning a connection to the pool (set to false) +// or when a single-connection pool retrieves its connection (set to true). +// +// Prefer CompareAndSwapUsed() when acquiring from a multi-connection pool to +// avoid race conditions. +// Deprecated: Use GetStateMachine().Transition() directly for better state management. +func (cn *Conn) SetUsed(val bool) { + if val { + cn.stateMachine.Transition(StateInUse) + } else { + cn.stateMachine.Transition(StateIdle) + } +} + +// getNetConn returns the current network connection using atomic load (lock-free). +// This is the fast path for accessing netConn without mutex overhead. +func (cn *Conn) getNetConn() net.Conn { + if v := cn.netConnAtomic.Load(); v != nil { + if wrapper, ok := v.(*atomicNetConn); ok { + return wrapper.conn + } + } + return nil +} + +// setNetConn stores the network connection atomically (lock-free). +// This is used for the fast path of connection replacement. +func (cn *Conn) setNetConn(netConn net.Conn) { + cn.netConnAtomic.Store(&atomicNetConn{conn: netConn}) +} + +// Handoff state management - atomic access to handoff metadata + +// ShouldHandoff returns true if connection needs handoff (lock-free). +func (cn *Conn) ShouldHandoff() bool { + if v := cn.handoffStateAtomic.Load(); v != nil { + return v.(*HandoffState).ShouldHandoff + } + return false +} + +// GetHandoffEndpoint returns the new endpoint for handoff (lock-free). +func (cn *Conn) GetHandoffEndpoint() string { + if v := cn.handoffStateAtomic.Load(); v != nil { + return v.(*HandoffState).Endpoint + } + return "" +} + +// GetMovingSeqID returns the sequence ID from the MOVING notification (lock-free). +func (cn *Conn) GetMovingSeqID() int64 { + if v := cn.handoffStateAtomic.Load(); v != nil { + return v.(*HandoffState).SeqID + } + return 0 +} + +// GetHandoffInfo returns all handoff information atomically (lock-free). +// This method prevents race conditions by returning all handoff state in a single atomic operation. +// Returns (shouldHandoff, endpoint, seqID). +func (cn *Conn) GetHandoffInfo() (bool, string, int64) { + if v := cn.handoffStateAtomic.Load(); v != nil { + state := v.(*HandoffState) + return state.ShouldHandoff, state.Endpoint, state.SeqID + } + return false, "", 0 +} + +// HandoffRetries returns the current handoff retry count (lock-free). +func (cn *Conn) HandoffRetries() int { + return int(cn.handoffRetriesAtomic.Load()) +} + +// IncrementAndGetHandoffRetries atomically increments and returns handoff retries (lock-free). +func (cn *Conn) IncrementAndGetHandoffRetries(n int) int { + return int(cn.handoffRetriesAtomic.Add(uint32(n))) +} + +// IsPooled returns true if the connection is managed by a pool and will be pooled on Put. +func (cn *Conn) IsPooled() bool { + return cn.pooled +} + +// IsPubSub returns true if the connection is used for PubSub. +func (cn *Conn) IsPubSub() bool { + return cn.pubsub +} + +// SetRelaxedTimeout sets relaxed timeouts for this connection during maintenanceNotifications upgrades. +// These timeouts will be used for all subsequent commands until the deadline expires. +// Uses atomic operations for lock-free access. +func (cn *Conn) SetRelaxedTimeout(readTimeout, writeTimeout time.Duration) { + cn.relaxedCounter.Add(1) + cn.relaxedReadTimeoutNs.Store(int64(readTimeout)) + cn.relaxedWriteTimeoutNs.Store(int64(writeTimeout)) +} + +// SetRelaxedTimeoutWithDeadline sets relaxed timeouts with an expiration deadline. +// After the deadline, timeouts automatically revert to normal values. +// Uses atomic operations for lock-free access. +func (cn *Conn) SetRelaxedTimeoutWithDeadline(readTimeout, writeTimeout time.Duration, deadline time.Time) { + cn.SetRelaxedTimeout(readTimeout, writeTimeout) + cn.relaxedDeadlineNs.Store(deadline.UnixNano()) +} + +// ClearRelaxedTimeout removes relaxed timeouts, returning to normal timeout behavior. +// Uses atomic operations for lock-free access. +func (cn *Conn) ClearRelaxedTimeout() { + // Atomically decrement counter and check if we should clear + newCount := cn.relaxedCounter.Add(-1) + deadlineNs := cn.relaxedDeadlineNs.Load() + if newCount <= 0 && (deadlineNs == 0 || time.Now().UnixNano() >= deadlineNs) { + // Use atomic load to get current value for CAS to avoid stale value race + current := cn.relaxedCounter.Load() + if current <= 0 && cn.relaxedCounter.CompareAndSwap(current, 0) { + cn.clearRelaxedTimeout() + } + } +} + +func (cn *Conn) clearRelaxedTimeout() { + cn.relaxedReadTimeoutNs.Store(0) + cn.relaxedWriteTimeoutNs.Store(0) + cn.relaxedDeadlineNs.Store(0) + cn.relaxedCounter.Store(0) +} + +// HasRelaxedTimeout returns true if relaxed timeouts are currently active on this connection. +// This checks both the timeout values and the deadline (if set). +// Uses atomic operations for lock-free access. +func (cn *Conn) HasRelaxedTimeout() bool { + // Fast path: no relaxed timeouts are set + if cn.relaxedCounter.Load() <= 0 { + return false + } + + readTimeoutNs := cn.relaxedReadTimeoutNs.Load() + writeTimeoutNs := cn.relaxedWriteTimeoutNs.Load() + + // If no relaxed timeouts are set, return false + if readTimeoutNs <= 0 && writeTimeoutNs <= 0 { + return false + } + + deadlineNs := cn.relaxedDeadlineNs.Load() + // If no deadline is set, relaxed timeouts are active + if deadlineNs == 0 { + return true + } + + // If deadline is set, check if it's still in the future + return time.Now().UnixNano() < deadlineNs +} + +// getEffectiveReadTimeout returns the timeout to use for read operations. +// If relaxed timeout is set and not expired, it takes precedence over the provided timeout. +// This method automatically clears expired relaxed timeouts using atomic operations. +func (cn *Conn) getEffectiveReadTimeout(normalTimeout time.Duration) time.Duration { + readTimeoutNs := cn.relaxedReadTimeoutNs.Load() + + // Fast path: no relaxed timeout set + if readTimeoutNs <= 0 { + return normalTimeout + } + + deadlineNs := cn.relaxedDeadlineNs.Load() + // If no deadline is set, use relaxed timeout + if deadlineNs == 0 { + return time.Duration(readTimeoutNs) + } + + // Use cached time to avoid expensive syscall (max 50ms staleness is acceptable for timeout checks) + nowNs := getCachedTimeNs() + // Check if deadline has passed + if nowNs < deadlineNs { + // Deadline is in the future, use relaxed timeout + return time.Duration(readTimeoutNs) + } else { + // Deadline has passed, clear relaxed timeouts atomically and use normal timeout + newCount := cn.relaxedCounter.Add(-1) + if newCount <= 0 { + internal.Logger.Printf(context.Background(), logs.UnrelaxedTimeoutAfterDeadline(cn.GetID())) + cn.clearRelaxedTimeout() + } + return normalTimeout + } +} + +// getEffectiveWriteTimeout returns the timeout to use for write operations. +// If relaxed timeout is set and not expired, it takes precedence over the provided timeout. +// This method automatically clears expired relaxed timeouts using atomic operations. +func (cn *Conn) getEffectiveWriteTimeout(normalTimeout time.Duration) time.Duration { + writeTimeoutNs := cn.relaxedWriteTimeoutNs.Load() + + // Fast path: no relaxed timeout set + if writeTimeoutNs <= 0 { + return normalTimeout + } + + deadlineNs := cn.relaxedDeadlineNs.Load() + // If no deadline is set, use relaxed timeout + if deadlineNs == 0 { + return time.Duration(writeTimeoutNs) + } + + // Use cached time to avoid expensive syscall (max 50ms staleness is acceptable for timeout checks) + nowNs := getCachedTimeNs() + // Check if deadline has passed + if nowNs < deadlineNs { + // Deadline is in the future, use relaxed timeout + return time.Duration(writeTimeoutNs) + } else { + // Deadline has passed, clear relaxed timeouts atomically and use normal timeout + newCount := cn.relaxedCounter.Add(-1) + if newCount <= 0 { + internal.Logger.Printf(context.Background(), logs.UnrelaxedTimeoutAfterDeadline(cn.GetID())) + cn.clearRelaxedTimeout() + } + return normalTimeout + } +} + +func (cn *Conn) SetOnClose(fn func() error) { + cn.onClose = fn +} + +// SetInitConnFunc sets the connection initialization function to be called on reconnections. +func (cn *Conn) SetInitConnFunc(fn func(context.Context, *Conn) error) { + cn.initConnFunc = fn +} + +// ExecuteInitConn runs the stored connection initialization function if available. +func (cn *Conn) ExecuteInitConn(ctx context.Context) error { + if cn.initConnFunc != nil { + return cn.initConnFunc(ctx, cn) + } + return fmt.Errorf("redis: no initConnFunc set for conn[%d]", cn.GetID()) } func (cn *Conn) SetNetConn(netConn net.Conn) { - cn.netConn = netConn + // Store the new connection atomically first (lock-free) + cn.setNetConn(netConn) + // Protect reader reset operations to avoid data races + // Use write lock since we're modifying the reader state + cn.readerMu.Lock() cn.rd.Reset(netConn) + cn.readerMu.Unlock() + cn.bw.Reset(netConn) } +// GetNetConn safely returns the current network connection using atomic load (lock-free). +// This method is used by the pool for health checks and provides better performance. +func (cn *Conn) GetNetConn() net.Conn { + return cn.getNetConn() +} + +// SetNetConnAndInitConn replaces the underlying connection and executes the initialization. +// This method ensures only one initialization can happen at a time by using atomic state transitions. +// If another goroutine is currently initializing, this will wait for it to complete. +func (cn *Conn) SetNetConnAndInitConn(ctx context.Context, netConn net.Conn) error { + // Wait for and transition to INITIALIZING state - this prevents concurrent initializations + // Valid from states: CREATED (first init), IDLE (reconnect), UNUSABLE (handoff/reauth) + // If another goroutine is initializing, we'll wait for it to finish + // if the context has a deadline, use that, otherwise use the connection read (relaxed) timeout + // which should be set during handoff. If it is not set, use a 5 second default + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(cn.getEffectiveReadTimeout(5 * time.Second)) + } + waitCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + // Use predefined slice to avoid allocation + finalState, err := cn.stateMachine.AwaitAndTransition( + waitCtx, + validFromCreatedIdleOrUnusable, + StateInitializing, + ) + if err != nil { + return fmt.Errorf("cannot initialize connection from state %s: %w", finalState, err) + } + + // Replace the underlying connection + cn.SetNetConn(netConn) + + // Execute initialization + // NOTE: ExecuteInitConn (via baseClient.initConn) will transition to IDLE on success + // or CLOSED on failure. We don't need to do it here. + // NOTE: Initconn returns conn in IDLE state + initErr := cn.ExecuteInitConn(ctx) + if initErr != nil { + // ExecuteInitConn already transitioned to CLOSED, just return the error + return initErr + } + + // ExecuteInitConn already transitioned to IDLE + return nil +} + +// MarkForHandoff marks the connection for handoff due to MOVING notification. +// Returns an error if the connection is already marked for handoff. +// Note: This only sets metadata - the connection state is not changed until OnPut. +// This allows the current user to finish using the connection before handoff. +func (cn *Conn) MarkForHandoff(newEndpoint string, seqID int64) error { + // Check if already marked for handoff + if cn.ShouldHandoff() { + return errAlreadyMarkedForHandoff + } + + // Set handoff metadata atomically + cn.handoffStateAtomic.Store(&HandoffState{ + ShouldHandoff: true, + Endpoint: newEndpoint, + SeqID: seqID, + }) + return nil +} + +// MarkQueuedForHandoff marks the connection as queued for handoff processing. +// This makes the connection unusable until handoff completes. +// This is called from OnPut hook, where the connection is typically in IN_USE state. +// The pool will preserve the UNUSABLE state and not overwrite it with IDLE. +func (cn *Conn) MarkQueuedForHandoff() error { + // Get current handoff state + currentState := cn.handoffStateAtomic.Load() + if currentState == nil { + return errNotMarkedForHandoff + } + + state := currentState.(*HandoffState) + if !state.ShouldHandoff { + return errNotMarkedForHandoff + } + + // Create new state with ShouldHandoff=false but preserve endpoint and seqID + // This prevents the connection from being queued multiple times while still + // allowing the worker to access the handoff metadata + newState := &HandoffState{ + ShouldHandoff: false, + Endpoint: state.Endpoint, // Preserve endpoint for handoff processing + SeqID: state.SeqID, // Preserve seqID for handoff processing + } + + // Atomic compare-and-swap to update state + if !cn.handoffStateAtomic.CompareAndSwap(currentState, newState) { + // State changed between load and CAS - retry or return error + return errHandoffStateChanged + } + + // Transition to UNUSABLE from IN_USE (normal flow), IDLE (edge cases), or CREATED (tests/uninitialized) + // The connection is typically in IN_USE state when OnPut is called (normal Put flow) + // But in some edge cases or tests, it might be in IDLE or CREATED state + // The pool will detect this state change and preserve it (not overwrite with IDLE) + // Use predefined slice to avoid allocation + finalState, err := cn.stateMachine.TryTransition(validFromCreatedInUseOrIdle, StateUnusable) + if err != nil { + // Check if already in UNUSABLE state (race condition or retry) + // ShouldHandoff should be false now, but check just in case + if finalState == StateUnusable && !cn.ShouldHandoff() { + // Already unusable - this is fine, keep the new handoff state + return nil + } + // Restore the original state if transition fails for other reasons + cn.handoffStateAtomic.Store(currentState) + return fmt.Errorf("failed to mark connection as unusable: %w", err) + } + return nil +} + +// GetID returns the unique identifier for this connection. +func (cn *Conn) GetID() uint64 { + return cn.id +} + +// GetStateMachine returns the connection's state machine for advanced state management. +// This is primarily used by internal packages like maintnotifications for handoff processing. +func (cn *Conn) GetStateMachine() *ConnStateMachine { + return cn.stateMachine +} + +// TryAcquire attempts to acquire the connection for use. +// This is an optimized inline method for the hot path (Get operation). +// +// It tries to transition from IDLE -> IN_USE or CREATED -> CREATED. +// Returns true if the connection was successfully acquired, false otherwise. +// The CREATED->CREATED is done so we can keep the state correct for later +// initialization of the connection in initConn. +// +// Performance: This is faster than calling GetStateMachine() + TryTransitionFast() +// +// NOTE: We directly access cn.stateMachine.state here instead of using the state machine's +// methods. This breaks encapsulation but is necessary for performance. +// The IDLE->IN_USE and CREATED->CREATED transitions don't need +// waiter notification, and benchmarks show 1-3% improvement. If the state machine ever +// needs to notify waiters on these transitions, update this to use TryTransitionFast(). +func (cn *Conn) TryAcquire() bool { + // The || operator short-circuits, so only 1 CAS in the common case + return cn.stateMachine.state.CompareAndSwap(uint32(StateIdle), uint32(StateInUse)) || + cn.stateMachine.state.CompareAndSwap(uint32(StateCreated), uint32(StateCreated)) +} + +// Release releases the connection back to the pool. +// This is an optimized inline method for the hot path (Put operation). +// +// It tries to transition from IN_USE -> IDLE. +// Returns true if the connection was successfully released, false otherwise. +// +// Performance: This is faster than calling GetStateMachine() + TryTransitionFast(). +// +// NOTE: We directly access cn.stateMachine.state here instead of using the state machine's +// methods. This breaks encapsulation but is necessary for performance. +// If the state machine ever needs to notify waiters +// on this transition, update this to use TryTransitionFast(). +func (cn *Conn) Release() bool { + // Inline the hot path - single CAS operation + return cn.stateMachine.state.CompareAndSwap(uint32(StateInUse), uint32(StateIdle)) +} + +// ClearHandoffState clears the handoff state after successful handoff. +// Makes the connection usable again. +func (cn *Conn) ClearHandoffState() { + // Clear handoff metadata + cn.handoffStateAtomic.Store(&HandoffState{ + ShouldHandoff: false, + Endpoint: "", + SeqID: 0, + }) + + // Reset retry counter + cn.handoffRetriesAtomic.Store(0) + + // Mark connection as usable again + // Use state machine directly instead of deprecated SetUsable + // probably done by initConn + cn.stateMachine.Transition(StateIdle) +} + +// HasBufferedData safely checks if the connection has buffered data. +// This method is used to avoid data races when checking for push notifications. +func (cn *Conn) HasBufferedData() bool { + // Use read lock for concurrent access to reader state + cn.readerMu.RLock() + defer cn.readerMu.RUnlock() + return cn.rd.Buffered() > 0 +} + +// PeekReplyTypeSafe safely peeks at the reply type. +// This method is used to avoid data races when checking for push notifications. +func (cn *Conn) PeekReplyTypeSafe() (byte, error) { + // Use read lock for concurrent access to reader state + cn.readerMu.RLock() + defer cn.readerMu.RUnlock() + + if cn.rd.Buffered() <= 0 { + return 0, fmt.Errorf("redis: can't peek reply type, no data available") + } + return cn.rd.PeekReplyType() +} + func (cn *Conn) Write(b []byte) (int, error) { - return cn.netConn.Write(b) + // Lock-free netConn access for better performance + if netConn := cn.getNetConn(); netConn != nil { + return netConn.Write(b) + } + return 0, net.ErrClosed } func (cn *Conn) RemoteAddr() net.Addr { - if cn.netConn != nil { - return cn.netConn.RemoteAddr() + // Lock-free netConn access for better performance + if netConn := cn.getNetConn(); netConn != nil { + return netConn.RemoteAddr() } return nil } @@ -67,7 +806,16 @@ func (cn *Conn) WithReader( ctx context.Context, timeout time.Duration, fn func(rd *proto.Reader) error, ) error { if timeout >= 0 { - if err := cn.netConn.SetReadDeadline(cn.deadline(ctx, timeout)); err != nil { + // Use relaxed timeout if set, otherwise use provided timeout + effectiveTimeout := cn.getEffectiveReadTimeout(timeout) + + // Get the connection directly from atomic storage + netConn := cn.getNetConn() + if netConn == nil { + return errConnectionNotAvailable + } + + if err := netConn.SetReadDeadline(cn.deadline(ctx, effectiveTimeout)); err != nil { return err } } @@ -78,13 +826,25 @@ func (cn *Conn) WithWriter( ctx context.Context, timeout time.Duration, fn func(wr *proto.Writer) error, ) error { if timeout >= 0 { - if err := cn.netConn.SetWriteDeadline(cn.deadline(ctx, timeout)); err != nil { - return err + // Use relaxed timeout if set, otherwise use provided timeout + effectiveTimeout := cn.getEffectiveWriteTimeout(timeout) + + // Set write deadline on the connection + if netConn := cn.getNetConn(); netConn != nil { + if err := netConn.SetWriteDeadline(cn.deadline(ctx, effectiveTimeout)); err != nil { + return err + } + } else { + // Connection is not available - return preallocated error + return errConnNotAvailableForWrite } } + // Reset the buffered writer if needed, should not happen if cn.bw.Buffered() > 0 { - cn.bw.Reset(cn.netConn) + if netConn := cn.getNetConn(); netConn != nil { + cn.bw.Reset(netConn) + } } if err := fn(cn.wr); err != nil { @@ -94,13 +854,47 @@ func (cn *Conn) WithWriter( return cn.bw.Flush() } -func (cn *Conn) Close() error { - return cn.netConn.Close() +func (cn *Conn) IsClosed() bool { + return cn.closed.Load() || cn.stateMachine.GetState() == StateClosed } +func (cn *Conn) Close() error { + cn.closed.Store(true) + + // Transition to CLOSED state + cn.stateMachine.Transition(StateClosed) + + if cn.onClose != nil { + // ignore error + _ = cn.onClose() + } + + // Lock-free netConn access for better performance + if netConn := cn.getNetConn(); netConn != nil { + return netConn.Close() + } + return nil +} + +// MaybeHasData tries to peek at the next byte in the socket without consuming it +// This is used to check if there are push notifications available +// Important: This will work on Linux, but not on Windows +func (cn *Conn) MaybeHasData() bool { + // Lock-free netConn access for better performance + if netConn := cn.getNetConn(); netConn != nil { + return maybeHasData(netConn) + } + return false +} + +// deadline computes the effective deadline time based on context and timeout. +// It updates the usedAt timestamp to now. +// Uses cached time to avoid expensive syscall (max 50ms staleness is acceptable for deadline calculation). func (cn *Conn) deadline(ctx context.Context, timeout time.Duration) time.Time { - tm := time.Now() - cn.SetUsedAt(tm) + // Use cached time for deadline calculation (called 2x per command: read + write) + nowNs := getCachedTimeNs() + cn.SetUsedAtNs(nowNs) + tm := time.Unix(0, nowNs) if timeout > 0 { tm = tm.Add(timeout) diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_check.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_check.go index 83190d39..9e83dd83 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_check.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_check.go @@ -12,6 +12,9 @@ import ( var errUnexpectedRead = errors.New("unexpected read from socket") +// connCheck checks if the connection is still alive and if there is data in the socket +// it will try to peek at the next byte without consuming it since we may want to work with it +// later on (e.g. push notifications) func connCheck(conn net.Conn) error { // Reset previous timeout. _ = conn.SetDeadline(time.Time{}) @@ -29,7 +32,9 @@ func connCheck(conn net.Conn) error { if err := rawConn.Read(func(fd uintptr) bool { var buf [1]byte - n, err := syscall.Read(int(fd), buf[:]) + // Use MSG_PEEK to peek at data without consuming it + n, _, err := syscall.Recvfrom(int(fd), buf[:], syscall.MSG_PEEK|syscall.MSG_DONTWAIT) + switch { case n == 0 && err == nil: sysErr = io.EOF @@ -47,3 +52,8 @@ func connCheck(conn net.Conn) error { return sysErr } + +// maybeHasData checks if there is data in the socket without consuming it +func maybeHasData(conn net.Conn) bool { + return connCheck(conn) == errUnexpectedRead +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_check_dummy.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_check_dummy.go index 295da126..f971d94c 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_check_dummy.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_check_dummy.go @@ -2,8 +2,19 @@ package pool -import "net" +import ( + "errors" + "net" +) -func connCheck(conn net.Conn) error { +// errUnexpectedRead is placeholder error variable for non-unix build constraints +var errUnexpectedRead = errors.New("unexpected read from socket") + +func connCheck(_ net.Conn) error { return nil } + +// since we can't check for data on the socket, we just assume there is some +func maybeHasData(_ net.Conn) bool { + return true +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_state.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_state.go new file mode 100644 index 00000000..2050a742 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/conn_state.go @@ -0,0 +1,343 @@ +package pool + +import ( + "container/list" + "context" + "errors" + "fmt" + "sync" + "sync/atomic" +) + +// ConnState represents the connection state in the state machine. +// States are designed to be lightweight and fast to check. +// +// State Transitions: +// CREATED → INITIALIZING → IDLE ⇄ IN_USE +// ↓ +// UNUSABLE (handoff/reauth) +// ↓ +// IDLE/CLOSED +type ConnState uint32 + +const ( + // StateCreated - Connection just created, not yet initialized + StateCreated ConnState = iota + + // StateInitializing - Connection initialization in progress + StateInitializing + + // StateIdle - Connection initialized and idle in pool, ready to be acquired + StateIdle + + // StateInUse - Connection actively processing a command (retrieved from pool) + StateInUse + + // StateUnusable - Connection temporarily unusable due to background operation + // (handoff, reauth, etc.). Cannot be acquired from pool. + StateUnusable + + // StateClosed - Connection closed + StateClosed +) + +// Predefined state slices to avoid allocations in hot paths +var ( + validFromInUse = []ConnState{StateInUse} + validFromCreatedOrIdle = []ConnState{StateCreated, StateIdle} + validFromCreatedInUseOrIdle = []ConnState{StateCreated, StateInUse, StateIdle} + // For AwaitAndTransition calls + validFromCreatedIdleOrUnusable = []ConnState{StateCreated, StateIdle, StateUnusable} + validFromIdle = []ConnState{StateIdle} + // For CompareAndSwapUsable + validFromInitializingOrUnusable = []ConnState{StateInitializing, StateUnusable} +) + +// Accessor functions for predefined slices to avoid allocations in external packages +// These return the same slice instance, so they're zero-allocation + +// ValidFromIdle returns a predefined slice containing only StateIdle. +// Use this to avoid allocations when calling AwaitAndTransition or TryTransition. +func ValidFromIdle() []ConnState { + return validFromIdle +} + +// ValidFromCreatedIdleOrUnusable returns a predefined slice for initialization transitions. +// Use this to avoid allocations when calling AwaitAndTransition or TryTransition. +func ValidFromCreatedIdleOrUnusable() []ConnState { + return validFromCreatedIdleOrUnusable +} + +// String returns a human-readable string representation of the state. +func (s ConnState) String() string { + switch s { + case StateCreated: + return "CREATED" + case StateInitializing: + return "INITIALIZING" + case StateIdle: + return "IDLE" + case StateInUse: + return "IN_USE" + case StateUnusable: + return "UNUSABLE" + case StateClosed: + return "CLOSED" + default: + return fmt.Sprintf("UNKNOWN(%d)", s) + } +} + +var ( + // ErrInvalidStateTransition is returned when a state transition is not allowed + ErrInvalidStateTransition = errors.New("invalid state transition") + + // ErrStateMachineClosed is returned when operating on a closed state machine + ErrStateMachineClosed = errors.New("state machine is closed") + + // ErrTimeout is returned when a state transition times out + ErrTimeout = errors.New("state transition timeout") +) + +// waiter represents a goroutine waiting for a state transition. +// Designed for minimal allocations and fast processing. +type waiter struct { + validStates map[ConnState]struct{} // States we're waiting for + targetState ConnState // State to transition to + done chan error // Signaled when transition completes or times out +} + +// ConnStateMachine manages connection state transitions with FIFO waiting queue. +// Optimized for: +// - Lock-free reads (hot path) +// - Minimal allocations +// - Fast state transitions +// - FIFO fairness for waiters +// Note: Handoff metadata (endpoint, seqID, retries) is managed separately in the Conn struct. +type ConnStateMachine struct { + // Current state - atomic for lock-free reads + state atomic.Uint32 + + // FIFO queue for waiters - only locked during waiter add/remove/notify + mu sync.Mutex + waiters *list.List // List of *waiter + waiterCount atomic.Int32 // Fast lock-free check for waiters (avoids mutex in hot path) +} + +// NewConnStateMachine creates a new connection state machine. +// Initial state is StateCreated. +func NewConnStateMachine() *ConnStateMachine { + sm := &ConnStateMachine{ + waiters: list.New(), + } + sm.state.Store(uint32(StateCreated)) + return sm +} + +// GetState returns the current state (lock-free read). +// This is the hot path - optimized for zero allocations and minimal overhead. +// Note: Zero allocations applies to state reads; converting the returned state to a string +// (via String()) may allocate if the state is unknown. +func (sm *ConnStateMachine) GetState() ConnState { + return ConnState(sm.state.Load()) +} + +// TryTransitionFast is an optimized version for the hot path (Get/Put operations). +// It only handles simple state transitions without waiter notification. +// This is safe because: +// 1. Get/Put don't need to wait for state changes +// 2. Background operations (handoff/reauth) use UNUSABLE state, which this won't match +// 3. If a background operation is in progress (state is UNUSABLE), this fails fast +// +// Returns true if transition succeeded, false otherwise. +// Use this for performance-critical paths where you don't need error details. +// +// Performance: Single CAS operation - as fast as the old atomic bool! +// For multiple from states, use: sm.TryTransitionFast(State1, Target) || sm.TryTransitionFast(State2, Target) +// The || operator short-circuits, so only 1 CAS is executed in the common case. +func (sm *ConnStateMachine) TryTransitionFast(fromState, targetState ConnState) bool { + return sm.state.CompareAndSwap(uint32(fromState), uint32(targetState)) +} + +// TryTransition attempts an immediate state transition without waiting. +// Returns the current state after the transition attempt and an error if the transition failed. +// The returned state is the CURRENT state (after the attempt), not the previous state. +// This is faster than AwaitAndTransition when you don't need to wait. +// Uses compare-and-swap to atomically transition, preventing concurrent transitions. +// This method does NOT wait - it fails immediately if the transition cannot be performed. +// +// Performance: Zero allocations on success path (hot path). +func (sm *ConnStateMachine) TryTransition(validFromStates []ConnState, targetState ConnState) (ConnState, error) { + // Try each valid from state with CAS + // This ensures only ONE goroutine can successfully transition at a time + for _, fromState := range validFromStates { + // Try to atomically swap from fromState to targetState + // If successful, we won the race and can proceed + if sm.state.CompareAndSwap(uint32(fromState), uint32(targetState)) { + // Success! We transitioned atomically + // Hot path optimization: only check for waiters if transition succeeded + // This avoids atomic load on every Get/Put when no waiters exist + if sm.waiterCount.Load() > 0 { + sm.notifyWaiters() + } + return targetState, nil + } + } + + // All CAS attempts failed - state is not valid for this transition + // Return the current state so caller can decide what to do + // Note: This error path allocates, but it's the exceptional case + currentState := sm.GetState() + return currentState, fmt.Errorf("%w: cannot transition from %s to %s (valid from: %v)", + ErrInvalidStateTransition, currentState, targetState, validFromStates) +} + +// Transition unconditionally transitions to the target state. +// Use with caution - prefer AwaitAndTransition or TryTransition for safety. +// This is useful for error paths or when you know the transition is valid. +func (sm *ConnStateMachine) Transition(targetState ConnState) { + sm.state.Store(uint32(targetState)) + sm.notifyWaiters() +} + +// AwaitAndTransition waits for the connection to reach one of the valid states, +// then atomically transitions to the target state. +// Returns the current state after the transition attempt and an error if the operation failed. +// The returned state is the CURRENT state (after the attempt), not the previous state. +// Returns error if timeout expires or context is cancelled. +// +// This method implements FIFO fairness - the first caller to wait gets priority +// when the state becomes available. +// +// Performance notes: +// - If already in a valid state, this is very fast (no allocation, no waiting) +// - If waiting is required, allocates one waiter struct and one channel +func (sm *ConnStateMachine) AwaitAndTransition( + ctx context.Context, + validFromStates []ConnState, + targetState ConnState, +) (ConnState, error) { + // Fast path: try immediate transition with CAS to prevent race conditions + // BUT: only if there are no waiters in the queue (to maintain FIFO ordering) + if sm.waiterCount.Load() == 0 { + for _, fromState := range validFromStates { + // Check if we're already in target state + if fromState == targetState && sm.GetState() == targetState { + return targetState, nil + } + + // Try to atomically swap from fromState to targetState + if sm.state.CompareAndSwap(uint32(fromState), uint32(targetState)) { + // Success! We transitioned atomically + sm.notifyWaiters() + return targetState, nil + } + } + } + + // Fast path failed - check if we should wait or fail + currentState := sm.GetState() + + // Check if closed + if currentState == StateClosed { + return currentState, ErrStateMachineClosed + } + + // Slow path: need to wait for state change + // Create waiter with valid states map for fast lookup + validStatesMap := make(map[ConnState]struct{}, len(validFromStates)) + for _, s := range validFromStates { + validStatesMap[s] = struct{}{} + } + + w := &waiter{ + validStates: validStatesMap, + targetState: targetState, + done: make(chan error, 1), // Buffered to avoid goroutine leak + } + + // Add to FIFO queue + sm.mu.Lock() + elem := sm.waiters.PushBack(w) + sm.waiterCount.Add(1) + sm.mu.Unlock() + + // Wait for state change or timeout + select { + case <-ctx.Done(): + // Timeout or cancellation - remove from queue + sm.mu.Lock() + sm.waiters.Remove(elem) + sm.waiterCount.Add(-1) + sm.mu.Unlock() + return sm.GetState(), ctx.Err() + case err := <-w.done: + // Transition completed (or failed) + // Note: waiterCount is decremented either in notifyWaiters (when the waiter is notified and removed) + // or here (on timeout/cancellation). + return sm.GetState(), err + } +} + +// notifyWaiters checks if any waiters can proceed and notifies them in FIFO order. +// This is called after every state transition. +func (sm *ConnStateMachine) notifyWaiters() { + // Fast path: check atomic counter without acquiring lock + // This eliminates mutex overhead in the common case (no waiters) + if sm.waiterCount.Load() == 0 { + return + } + + sm.mu.Lock() + defer sm.mu.Unlock() + + // Double-check after acquiring lock (waiters might have been processed) + if sm.waiters.Len() == 0 { + return + } + + // Process waiters in FIFO order until no more can be processed + // We loop instead of recursing to avoid stack overflow and mutex issues + for { + processed := false + + // Find the first waiter that can proceed + for elem := sm.waiters.Front(); elem != nil; elem = elem.Next() { + w := elem.Value.(*waiter) + + // Read current state inside the loop to get the latest value + currentState := sm.GetState() + + // Check if current state is valid for this waiter + if _, valid := w.validStates[currentState]; valid { + // Remove from queue first + sm.waiters.Remove(elem) + sm.waiterCount.Add(-1) + + // Use CAS to ensure state hasn't changed since we checked + // This prevents race condition where another thread changes state + // between our check and our transition + if sm.state.CompareAndSwap(uint32(currentState), uint32(w.targetState)) { + // Successfully transitioned - notify waiter + w.done <- nil + processed = true + break + } else { + // State changed - re-add waiter to front of queue to maintain FIFO ordering + // This waiter was first in line and should retain priority + sm.waiters.PushFront(w) + sm.waiterCount.Add(1) + // Continue to next iteration to re-read state + processed = true + break + } + } + } + + // If we didn't process any waiter, we're done + if !processed { + break + } + } +} + diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/hooks.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/hooks.go new file mode 100644 index 00000000..a26e1976 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/hooks.go @@ -0,0 +1,165 @@ +package pool + +import ( + "context" + "sync" +) + +// PoolHook defines the interface for connection lifecycle hooks. +type PoolHook interface { + // OnGet is called when a connection is retrieved from the pool. + // It can modify the connection or return an error to prevent its use. + // The accept flag can be used to prevent the connection from being used. + // On Accept = false the connection is rejected and returned to the pool. + // The error can be used to prevent the connection from being used and returned to the pool. + // On Errors, the connection is removed from the pool. + // It has isNewConn flag to indicate if this is a new connection (rather than idle from the pool) + // The flag can be used for gathering metrics on pool hit/miss ratio. + OnGet(ctx context.Context, conn *Conn, isNewConn bool) (accept bool, err error) + + // OnPut is called when a connection is returned to the pool. + // It returns whether the connection should be pooled and whether it should be removed. + OnPut(ctx context.Context, conn *Conn) (shouldPool bool, shouldRemove bool, err error) + + // OnRemove is called when a connection is removed from the pool. + // This happens when: + // - Connection fails health check + // - Connection exceeds max lifetime + // - Pool is being closed + // - Connection encounters an error + // Implementations should clean up any per-connection state. + // The reason parameter indicates why the connection was removed. + OnRemove(ctx context.Context, conn *Conn, reason error) +} + +// PoolHookManager manages multiple pool hooks. +type PoolHookManager struct { + hooks []PoolHook + hooksMu sync.RWMutex +} + +// NewPoolHookManager creates a new pool hook manager. +func NewPoolHookManager() *PoolHookManager { + return &PoolHookManager{ + hooks: make([]PoolHook, 0), + } +} + +// AddHook adds a pool hook to the manager. +// Hooks are called in the order they were added. +func (phm *PoolHookManager) AddHook(hook PoolHook) { + phm.hooksMu.Lock() + defer phm.hooksMu.Unlock() + phm.hooks = append(phm.hooks, hook) +} + +// RemoveHook removes a pool hook from the manager. +func (phm *PoolHookManager) RemoveHook(hook PoolHook) { + phm.hooksMu.Lock() + defer phm.hooksMu.Unlock() + + for i, h := range phm.hooks { + if h == hook { + // Remove hook by swapping with last element and truncating + phm.hooks[i] = phm.hooks[len(phm.hooks)-1] + phm.hooks = phm.hooks[:len(phm.hooks)-1] + break + } + } +} + +// ProcessOnGet calls all OnGet hooks in order. +// If any hook returns an error, processing stops and the error is returned. +func (phm *PoolHookManager) ProcessOnGet(ctx context.Context, conn *Conn, isNewConn bool) (acceptConn bool, err error) { + // Copy slice reference while holding lock (fast) + phm.hooksMu.RLock() + hooks := phm.hooks + phm.hooksMu.RUnlock() + + // Call hooks without holding lock (slow operations) + for _, hook := range hooks { + acceptConn, err := hook.OnGet(ctx, conn, isNewConn) + if err != nil { + return false, err + } + + if !acceptConn { + return false, nil + } + } + return true, nil +} + +// ProcessOnPut calls all OnPut hooks in order. +// The first hook that returns shouldRemove=true or shouldPool=false will stop processing. +func (phm *PoolHookManager) ProcessOnPut(ctx context.Context, conn *Conn) (shouldPool bool, shouldRemove bool, err error) { + // Copy slice reference while holding lock (fast) + phm.hooksMu.RLock() + hooks := phm.hooks + phm.hooksMu.RUnlock() + + shouldPool = true // Default to pooling the connection + + // Call hooks without holding lock (slow operations) + for _, hook := range hooks { + hookShouldPool, hookShouldRemove, hookErr := hook.OnPut(ctx, conn) + + if hookErr != nil { + return false, true, hookErr + } + + // If any hook says to remove or not pool, respect that decision + if hookShouldRemove { + return false, true, nil + } + + if !hookShouldPool { + shouldPool = false + } + } + + return shouldPool, false, nil +} + +// ProcessOnRemove calls all OnRemove hooks in order. +func (phm *PoolHookManager) ProcessOnRemove(ctx context.Context, conn *Conn, reason error) { + // Copy slice reference while holding lock (fast) + phm.hooksMu.RLock() + hooks := phm.hooks + phm.hooksMu.RUnlock() + + // Call hooks without holding lock (slow operations) + for _, hook := range hooks { + hook.OnRemove(ctx, conn, reason) + } +} + +// GetHookCount returns the number of registered hooks (for testing). +func (phm *PoolHookManager) GetHookCount() int { + phm.hooksMu.RLock() + defer phm.hooksMu.RUnlock() + return len(phm.hooks) +} + +// GetHooks returns a copy of all registered hooks. +func (phm *PoolHookManager) GetHooks() []PoolHook { + phm.hooksMu.RLock() + defer phm.hooksMu.RUnlock() + + hooks := make([]PoolHook, len(phm.hooks)) + copy(hooks, phm.hooks) + return hooks +} + +// Clone creates a copy of the hook manager with the same hooks. +// This is used for lock-free atomic updates of the hook manager. +func (phm *PoolHookManager) Clone() *PoolHookManager { + phm.hooksMu.RLock() + defer phm.hooksMu.RUnlock() + + newManager := &PoolHookManager{ + hooks: make([]PoolHook, len(phm.hooks)), + } + copy(newManager.hooks, phm.hooks) + return newManager +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool.go index 2125f3e1..d757d1f4 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool.go @@ -9,6 +9,8 @@ import ( "time" "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/proto" + "github.com/redis/go-redis/v9/internal/util" ) var ( @@ -21,25 +23,48 @@ var ( // ErrPoolTimeout timed out waiting to get a connection from the connection pool. ErrPoolTimeout = errors.New("redis: connection pool timeout") -) -var timers = sync.Pool{ - New: func() interface{} { - t := time.NewTimer(time.Hour) - t.Stop() - return t - }, -} + // ErrConnUnusableTimeout is returned when a connection is not usable and we timed out trying to mark it as unusable. + ErrConnUnusableTimeout = errors.New("redis: timed out trying to mark connection as unusable") + + // errHookRequestedRemoval is returned when a hook requests connection removal. + errHookRequestedRemoval = errors.New("hook requested removal") + + // errConnNotPooled is returned when trying to return a non-pooled connection to the pool. + errConnNotPooled = errors.New("connection not pooled") + + // popAttempts is the maximum number of attempts to find a usable connection + // when popping from the idle connection pool. This handles cases where connections + // are temporarily marked as unusable (e.g., during maintenanceNotifications upgrades or network issues). + // Value of 50 provides sufficient resilience without excessive overhead. + // This is capped by the idle connection count, so we won't loop excessively. + popAttempts = 50 + + // getAttempts is the maximum number of attempts to get a connection that passes + // hook validation (e.g., maintenanceNotifications upgrade hooks). This protects against race conditions + // where hooks might temporarily reject connections during cluster transitions. + // Value of 3 balances resilience with performance - most hook rejections resolve quickly. + getAttempts = 3 + + minTime = time.Unix(-2208988800, 0) // Jan 1, 1900 + maxTime = minTime.Add(1<<63 - 1) + noExpiration = maxTime +) // Stats contains pool state information and accumulated stats. type Stats struct { - Hits uint32 // number of times free connection was found in the pool - Misses uint32 // number of times free connection was NOT found in the pool - Timeouts uint32 // number of times a wait timeout occurred + Hits uint32 // number of times free connection was found in the pool + Misses uint32 // number of times free connection was NOT found in the pool + Timeouts uint32 // number of times a wait timeout occurred + WaitCount uint32 // number of times a connection was waited + Unusable uint32 // number of times a connection was found to be unusable + WaitDurationNs int64 // total time spent for waiting a connection in nanoseconds TotalConns uint32 // number of total connections in the pool IdleConns uint32 // number of idle connections in the pool StaleConns uint32 // number of stale connections removed from the pool + + PubSubStats PubSubStats } type Pooler interface { @@ -54,20 +79,46 @@ type Pooler interface { IdleLen() int Stats() *Stats + // Size returns the maximum pool size (capacity). + // This is used by the streaming credentials manager to size the re-auth worker pool. + Size() int + + AddPoolHook(hook PoolHook) + RemovePoolHook(hook PoolHook) + + // RemoveWithoutTurn removes a connection from the pool without freeing a turn. + // This should be used when removing a connection from a context that didn't acquire + // a turn via Get() (e.g., background workers, cleanup tasks). + // For normal removal after Get(), use Remove() instead. + RemoveWithoutTurn(context.Context, *Conn, error) + Close() error } type Options struct { - Dialer func(context.Context) (net.Conn, error) + Dialer func(context.Context) (net.Conn, error) + ReadBufferSize int + WriteBufferSize int - PoolFIFO bool - PoolSize int - PoolTimeout time.Duration - MinIdleConns int - MaxIdleConns int - MaxActiveConns int - ConnMaxIdleTime time.Duration - ConnMaxLifetime time.Duration + PoolFIFO bool + PoolSize int32 + MaxConcurrentDials int + DialTimeout time.Duration + PoolTimeout time.Duration + MinIdleConns int32 + MaxIdleConns int32 + MaxActiveConns int32 + ConnMaxIdleTime time.Duration + ConnMaxLifetime time.Duration + PushNotificationsEnabled bool + + // DialerRetries is the maximum number of retry attempts when dialing fails. + // Default: 5 + DialerRetries int + + // DialerRetryTimeout is the backoff duration between retry attempts. + // Default: 100ms + DialerRetryTimeout time.Duration } type lastDialErrorWrap struct { @@ -80,71 +131,165 @@ type ConnPool struct { dialErrorsNum uint32 // atomic lastDialError atomic.Value - queue chan struct{} + queue chan struct{} + dialsInProgress chan struct{} + dialsQueue *wantConnQueue + // Fast semaphore for connection limiting with eventual fairness + // Uses fast path optimization to avoid timer allocation when tokens are available + semaphore *internal.FastSemaphore connsMu sync.Mutex - conns []*Conn + conns map[uint64]*Conn idleConns []*Conn - poolSize int - idleConnsLen int + poolSize atomic.Int32 + idleConnsLen atomic.Int32 + idleCheckInProgress atomic.Bool + idleCheckNeeded atomic.Bool - stats Stats + stats Stats + waitDurationNs atomic.Int64 _closed uint32 // atomic + + // Pool hooks manager for flexible connection processing + // Using atomic.Pointer for lock-free reads in hot paths (Get/Put) + hookManager atomic.Pointer[PoolHookManager] } var _ Pooler = (*ConnPool)(nil) func NewConnPool(opt *Options) *ConnPool { p := &ConnPool{ - cfg: opt, - - queue: make(chan struct{}, opt.PoolSize), - conns: make([]*Conn, 0, opt.PoolSize), - idleConns: make([]*Conn, 0, opt.PoolSize), + cfg: opt, + semaphore: internal.NewFastSemaphore(opt.PoolSize), + queue: make(chan struct{}, opt.PoolSize), + conns: make(map[uint64]*Conn), + dialsInProgress: make(chan struct{}, opt.MaxConcurrentDials), + dialsQueue: newWantConnQueue(), + idleConns: make([]*Conn, 0, opt.PoolSize), } - p.connsMu.Lock() - p.checkMinIdleConns() - p.connsMu.Unlock() + // Only create MinIdleConns if explicitly requested (> 0) + // This avoids creating connections during pool initialization for tests + if opt.MinIdleConns > 0 { + p.connsMu.Lock() + p.checkMinIdleConns() + p.connsMu.Unlock() + } return p } +// initializeHooks sets up the pool hooks system. +func (p *ConnPool) initializeHooks() { + manager := NewPoolHookManager() + p.hookManager.Store(manager) +} + +// AddPoolHook adds a pool hook to the pool. +func (p *ConnPool) AddPoolHook(hook PoolHook) { + // Lock-free read of current manager + manager := p.hookManager.Load() + if manager == nil { + p.initializeHooks() + manager = p.hookManager.Load() + } + + // Create new manager with added hook + newManager := manager.Clone() + newManager.AddHook(hook) + + // Atomically swap to new manager + p.hookManager.Store(newManager) +} + +// RemovePoolHook removes a pool hook from the pool. +func (p *ConnPool) RemovePoolHook(hook PoolHook) { + manager := p.hookManager.Load() + if manager != nil { + // Create new manager with removed hook + newManager := manager.Clone() + newManager.RemoveHook(hook) + + // Atomically swap to new manager + p.hookManager.Store(newManager) + } +} + func (p *ConnPool) checkMinIdleConns() { - if p.cfg.MinIdleConns == 0 { + // If a check is already in progress, mark that we need another check and return + if !p.idleCheckInProgress.CompareAndSwap(false, true) { + p.idleCheckNeeded.Store(true) return } - for p.poolSize < p.cfg.PoolSize && p.idleConnsLen < p.cfg.MinIdleConns { - select { - case p.queue <- struct{}{}: - p.poolSize++ - p.idleConnsLen++ + if p.cfg.MinIdleConns == 0 { + p.idleCheckInProgress.Store(false) + return + } + + // Keep checking until no more checks are needed + // This handles the case where multiple Remove() calls happen concurrently + for { + // Clear the "check needed" flag before we start + p.idleCheckNeeded.Store(false) + + // Only create idle connections if we haven't reached the total pool size limit + // MinIdleConns should be a subset of PoolSize, not additional connections + for p.poolSize.Load() < p.cfg.PoolSize && p.idleConnsLen.Load() < p.cfg.MinIdleConns { + // Try to acquire a semaphore token + if !p.semaphore.TryAcquire() { + // Semaphore is full, can't create more connections + p.idleCheckInProgress.Store(false) + return + } + + p.poolSize.Add(1) + p.idleConnsLen.Add(1) go func() { + defer func() { + if err := recover(); err != nil { + p.poolSize.Add(-1) + p.idleConnsLen.Add(-1) + + p.freeTurn() + internal.Logger.Printf(context.Background(), "addIdleConn panic: %+v", err) + } + }() + err := p.addIdleConn() if err != nil && err != ErrClosed { - p.connsMu.Lock() - p.poolSize-- - p.idleConnsLen-- - p.connsMu.Unlock() + p.poolSize.Add(-1) + p.idleConnsLen.Add(-1) } - p.freeTurn() }() - default: + } + + // If no one requested another check while we were working, we're done + if !p.idleCheckNeeded.Load() { + p.idleCheckInProgress.Store(false) return } + + // Otherwise, loop again to handle the new requests } } func (p *ConnPool) addIdleConn() error { - cn, err := p.dialConn(context.TODO(), true) + ctx, cancel := context.WithTimeout(context.Background(), p.cfg.DialTimeout) + defer cancel() + + cn, err := p.dialConn(ctx, true) if err != nil { return err } + // NOTE: Connection is in CREATED state and will be initialized by redis.go:initConn() + // when first acquired from the pool. Do NOT transition to IDLE here - that happens + // after initialization completes. + p.connsMu.Lock() defer p.connsMu.Unlock() @@ -154,11 +299,15 @@ func (p *ConnPool) addIdleConn() error { return ErrClosed } - p.conns = append(p.conns, cn) + p.conns[cn.GetID()] = cn p.idleConns = append(p.idleConns, cn) return nil } +// NewConn creates a new connection and returns it to the user. +// This will still obey MaxActiveConns but will not include it in the pool and won't increase the pool size. +// +// NOTE: If you directly get a connection from the pool, it won't be pooled and won't support maintnotifications upgrades. func (p *ConnPool) NewConn(ctx context.Context) (*Conn, error) { return p.newConn(ctx, false) } @@ -168,33 +317,45 @@ func (p *ConnPool) newConn(ctx context.Context, pooled bool) (*Conn, error) { return nil, ErrClosed } - p.connsMu.Lock() - if p.cfg.MaxActiveConns > 0 && p.poolSize >= p.cfg.MaxActiveConns { - p.connsMu.Unlock() + if p.cfg.MaxActiveConns > 0 && p.poolSize.Load() >= p.cfg.MaxActiveConns { return nil, ErrPoolExhausted } - p.connsMu.Unlock() - cn, err := p.dialConn(ctx, pooled) + dialCtx, cancel := context.WithTimeout(ctx, p.cfg.DialTimeout) + defer cancel() + cn, err := p.dialConn(dialCtx, pooled) if err != nil { return nil, err } - p.connsMu.Lock() - defer p.connsMu.Unlock() + // NOTE: Connection is in CREATED state and will be initialized by redis.go:initConn() + // when first used. Do NOT transition to IDLE here - that happens after initialization completes. + // The state machine flow is: CREATED → INITIALIZING (in initConn) → IDLE (after init success) - if p.cfg.MaxActiveConns > 0 && p.poolSize >= p.cfg.MaxActiveConns { + if p.cfg.MaxActiveConns > 0 && p.poolSize.Load() > p.cfg.MaxActiveConns { _ = cn.Close() return nil, ErrPoolExhausted } - p.conns = append(p.conns, cn) + p.connsMu.Lock() + defer p.connsMu.Unlock() + if p.closed() { + _ = cn.Close() + return nil, ErrClosed + } + // Check if pool was closed while we were waiting for the lock + if p.conns == nil { + p.conns = make(map[uint64]*Conn) + } + p.conns[cn.GetID()] = cn + if pooled { // If pool is full remove the cn on next Put. - if p.poolSize >= p.cfg.PoolSize { + currentPoolSize := p.poolSize.Load() + if currentPoolSize >= p.cfg.PoolSize { cn.pooled = false } else { - p.poolSize++ + p.poolSize.Add(1) } } @@ -210,18 +371,58 @@ func (p *ConnPool) dialConn(ctx context.Context, pooled bool) (*Conn, error) { return nil, p.getLastDialError() } - netConn, err := p.cfg.Dialer(ctx) - if err != nil { - p.setLastDialError(err) - if atomic.AddUint32(&p.dialErrorsNum, 1) == uint32(p.cfg.PoolSize) { - go p.tryDial() - } - return nil, err + // Retry dialing with backoff + // the context timeout is already handled by the context passed in + // so we may never reach the max retries, higher values don't hurt + maxRetries := p.cfg.DialerRetries + if maxRetries <= 0 { + maxRetries = 5 // Default value + } + backoffDuration := p.cfg.DialerRetryTimeout + if backoffDuration <= 0 { + backoffDuration = 100 * time.Millisecond // Default value } - cn := NewConn(netConn) - cn.pooled = pooled - return cn, nil + var lastErr error + shouldLoop := true + // when the timeout is reached, we should stop retrying + // but keep the lastErr to return to the caller + // instead of a generic context deadline exceeded error + attempt := 0 + for attempt = 0; (attempt < maxRetries) && shouldLoop; attempt++ { + netConn, err := p.cfg.Dialer(ctx) + if err != nil { + lastErr = err + // Add backoff delay for retry attempts + // (not for the first attempt, do at least one) + select { + case <-ctx.Done(): + shouldLoop = false + case <-time.After(backoffDuration): + // Continue with retry + } + continue + } + + // Success - create connection + cn := NewConnWithBufferSize(netConn, p.cfg.ReadBufferSize, p.cfg.WriteBufferSize) + cn.pooled = pooled + if p.cfg.ConnMaxLifetime > 0 { + cn.expiresAt = time.Now().Add(p.cfg.ConnMaxLifetime) + } else { + cn.expiresAt = noExpiration + } + + return cn, nil + } + + internal.Logger.Printf(ctx, "redis: connection pool: failed to dial after %d attempts: %v", attempt, lastErr) + // All retries failed - handle error tracking + p.setLastDialError(lastErr) + if atomic.AddUint32(&p.dialErrorsNum, 1) == uint32(p.cfg.PoolSize) { + go p.tryDial() + } + return nil, lastErr } func (p *ConnPool) tryDial() { @@ -230,15 +431,19 @@ func (p *ConnPool) tryDial() { return } - conn, err := p.cfg.Dialer(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), p.cfg.DialTimeout) + + conn, err := p.cfg.Dialer(ctx) if err != nil { p.setLastDialError(err) time.Sleep(time.Second) + cancel() continue } atomic.StoreUint32(&p.dialErrorsNum, 0) _ = conn.Close() + cancel() return } } @@ -257,6 +462,14 @@ func (p *ConnPool) getLastDialError() error { // Get returns existed connection from the pool or creates a new one. func (p *ConnPool) Get(ctx context.Context) (*Conn, error) { + return p.getConn(ctx) +} + +// getConn returns a connection from the pool. +func (p *ConnPool) getConn(ctx context.Context) (*Conn, error) { + var cn *Conn + var err error + if p.closed() { return nil, ErrClosed } @@ -265,9 +478,16 @@ func (p *ConnPool) Get(ctx context.Context) (*Conn, error) { return nil, err } - for { + // Use cached time for health checks (max 50ms staleness is acceptable) + nowNs := getCachedTimeNs() + + // Lock-free atomic read - no mutex overhead! + hookManager := p.hookManager.Load() + + for attempts := 0; attempts < getAttempts; attempts++ { + p.connsMu.Lock() - cn, err := p.popIdle() + cn, err = p.popIdle() p.connsMu.Unlock() if err != nil { @@ -279,127 +499,394 @@ func (p *ConnPool) Get(ctx context.Context) (*Conn, error) { break } - if !p.isHealthyConn(cn) { + if !p.isHealthyConn(cn, nowNs) { _ = p.CloseConn(cn) continue } + // Process connection using the hooks system + // Combine error and rejection checks to reduce branches + if hookManager != nil { + acceptConn, err := hookManager.ProcessOnGet(ctx, cn, false) + if err != nil || !acceptConn { + if err != nil { + internal.Logger.Printf(ctx, "redis: connection pool: failed to process idle connection by hook: %v", err) + _ = p.CloseConn(cn) + } else { + internal.Logger.Printf(ctx, "redis: connection pool: conn[%d] rejected by hook, returning to pool", cn.GetID()) + // Return connection to pool without freeing the turn that this Get() call holds. + // We use putConnWithoutTurn() to run all the Put hooks and logic without freeing a turn. + p.putConnWithoutTurn(ctx, cn) + cn = nil + } + continue + } + } + atomic.AddUint32(&p.stats.Hits, 1) return cn, nil } atomic.AddUint32(&p.stats.Misses, 1) - newcn, err := p.newConn(ctx, true) + newcn, err := p.queuedNewConn(ctx) if err != nil { - p.freeTurn() return nil, err } + // Process connection using the hooks system + if hookManager != nil { + acceptConn, err := hookManager.ProcessOnGet(ctx, newcn, true) + // both errors and accept=false mean a hook rejected the connection + // this should not happen with a new connection, but we handle it gracefully + if err != nil || !acceptConn { + // Failed to process connection, discard it + internal.Logger.Printf(ctx, "redis: connection pool: failed to process new connection conn[%d] by hook: accept=%v, err=%v", newcn.GetID(), acceptConn, err) + _ = p.CloseConn(newcn) + return nil, err + } + } return newcn, nil } -func (p *ConnPool) waitTurn(ctx context.Context) error { +func (p *ConnPool) queuedNewConn(ctx context.Context) (*Conn, error) { select { + case p.dialsInProgress <- struct{}{}: + // Got permission, proceed to create connection case <-ctx.Done(): - return ctx.Err() - default: + p.freeTurn() + return nil, ctx.Err() } - select { - case p.queue <- struct{}{}: - return nil - default: - } + dialCtx, cancel := context.WithTimeout(context.Background(), p.cfg.DialTimeout) - timer := timers.Get().(*time.Timer) - timer.Reset(p.cfg.PoolTimeout) + w := &wantConn{ + ctx: dialCtx, + cancelCtx: cancel, + result: make(chan wantConnResult, 1), + } + var err error + defer func() { + if err != nil { + if cn := w.cancel(); cn != nil && p.putIdleConn(ctx, cn) { + p.freeTurn() + } + } + }() + + p.dialsQueue.enqueue(w) + + go func(w *wantConn) { + var freeTurnCalled bool + defer func() { + if err := recover(); err != nil { + if !freeTurnCalled { + p.freeTurn() + } + internal.Logger.Printf(context.Background(), "queuedNewConn panic: %+v", err) + } + }() + + defer w.cancelCtx() + defer func() { <-p.dialsInProgress }() // Release connection creation permission + + dialCtx := w.getCtxForDial() + cn, cnErr := p.newConn(dialCtx, true) + if cnErr != nil { + w.tryDeliver(nil, cnErr) // deliver error to caller, notify connection creation failed + p.freeTurn() + freeTurnCalled = true + return + } + + delivered := w.tryDeliver(cn, cnErr) + if !delivered && p.putIdleConn(dialCtx, cn) { + p.freeTurn() + freeTurnCalled = true + } + }(w) select { case <-ctx.Done(): - if !timer.Stop() { - <-timer.C - } - timers.Put(timer) - return ctx.Err() - case p.queue <- struct{}{}: - if !timer.Stop() { - <-timer.C - } - timers.Put(timer) - return nil - case <-timer.C: - timers.Put(timer) - atomic.AddUint32(&p.stats.Timeouts, 1) - return ErrPoolTimeout + err = ctx.Err() + return nil, err + case result := <-w.result: + err = result.err + return result.cn, err } } +// putIdleConn puts a connection back to the pool or passes it to the next waiting request. +// +// It returns true if the connection was put back to the pool, +// which means the turn needs to be freed directly by the caller, +// or false if the connection was passed to the next waiting request, +// which means the turn will be freed by the waiting goroutine after it returns. +func (p *ConnPool) putIdleConn(ctx context.Context, cn *Conn) bool { + for { + w, ok := p.dialsQueue.dequeue() + if !ok { + break + } + if w.tryDeliver(cn, nil) { + return false + } + } + + p.connsMu.Lock() + defer p.connsMu.Unlock() + + if p.closed() { + _ = cn.Close() + return true + } + + // poolSize is increased in newConn + p.idleConns = append(p.idleConns, cn) + p.idleConnsLen.Add(1) + + return true +} + +func (p *ConnPool) waitTurn(ctx context.Context) error { + // Fast path: check context first + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Fast path: try to acquire without blocking + if p.semaphore.TryAcquire() { + return nil + } + + // Slow path: need to wait + start := time.Now() + err := p.semaphore.Acquire(ctx, p.cfg.PoolTimeout, ErrPoolTimeout) + + switch err { + case nil: + // Successfully acquired after waiting + p.waitDurationNs.Add(time.Now().UnixNano() - start.UnixNano()) + atomic.AddUint32(&p.stats.WaitCount, 1) + case ErrPoolTimeout: + atomic.AddUint32(&p.stats.Timeouts, 1) + } + + return err +} + func (p *ConnPool) freeTurn() { - <-p.queue + p.semaphore.Release() } func (p *ConnPool) popIdle() (*Conn, error) { if p.closed() { return nil, ErrClosed } + defer p.checkMinIdleConns() + n := len(p.idleConns) if n == 0 { return nil, nil } var cn *Conn - if p.cfg.PoolFIFO { - cn = p.idleConns[0] - copy(p.idleConns, p.idleConns[1:]) - p.idleConns = p.idleConns[:n-1] - } else { - idx := n - 1 - cn = p.idleConns[idx] - p.idleConns = p.idleConns[:idx] + attempts := 0 + + maxAttempts := util.Min(popAttempts, n) + for attempts < maxAttempts { + if len(p.idleConns) == 0 { + return nil, nil + } + + if p.cfg.PoolFIFO { + cn = p.idleConns[0] + copy(p.idleConns, p.idleConns[1:]) + p.idleConns = p.idleConns[:len(p.idleConns)-1] + } else { + idx := len(p.idleConns) - 1 + cn = p.idleConns[idx] + p.idleConns = p.idleConns[:idx] + } + attempts++ + + // Hot path optimization: try IDLE → IN_USE or CREATED → IN_USE transition + // Using inline TryAcquire() method for better performance (avoids pointer dereference) + if cn.TryAcquire() { + // Successfully acquired the connection + p.idleConnsLen.Add(-1) + break + } + + // Connection is in UNUSABLE, INITIALIZING, or other state - skip it + + // Connection is not in a valid state (might be UNUSABLE for handoff/re-auth, INITIALIZING, etc.) + // Put it back in the pool and try the next one + if p.cfg.PoolFIFO { + // FIFO: put at end (will be picked up last since we pop from front) + p.idleConns = append(p.idleConns, cn) + } else { + // LIFO: put at beginning (will be picked up last since we pop from end) + p.idleConns = append([]*Conn{cn}, p.idleConns...) + } + cn = nil } - p.idleConnsLen-- - p.checkMinIdleConns() + + // If we exhausted all attempts without finding a usable connection, return nil + if attempts > 1 && attempts >= maxAttempts && int32(attempts) >= p.poolSize.Load() { + internal.Logger.Printf(context.Background(), "redis: connection pool: failed to get a usable connection after %d attempts", attempts) + return nil, nil + } + return cn, nil } func (p *ConnPool) Put(ctx context.Context, cn *Conn) { - if cn.rd.Buffered() > 0 { - internal.Logger.Printf(ctx, "Conn has unread data") - p.Remove(ctx, cn, BadConnError{}) + p.putConn(ctx, cn, true) +} + +// putConnWithoutTurn is an internal method that puts a connection back to the pool +// without freeing a turn. This is used when returning a rejected connection from +// within Get(), where the turn is still held by the Get() call. +func (p *ConnPool) putConnWithoutTurn(ctx context.Context, cn *Conn) { + p.putConn(ctx, cn, false) +} + +// putConn is the internal implementation of Put that optionally frees a turn. +func (p *ConnPool) putConn(ctx context.Context, cn *Conn, freeTurn bool) { + // Process connection using the hooks system + shouldPool := true + shouldRemove := false + var err error + + if cn.HasBufferedData() { + // Peek at the reply type to check if it's a push notification + if replyType, err := cn.PeekReplyTypeSafe(); err != nil || replyType != proto.RespPush { + // Not a push notification or error peeking, remove connection + internal.Logger.Printf(ctx, "Conn has unread data (not push notification), removing it") + p.removeConnInternal(ctx, cn, err, freeTurn) + return + } + // It's a push notification, allow pooling (client will handle it) + } + + // Lock-free atomic read - no mutex overhead! + hookManager := p.hookManager.Load() + + if hookManager != nil { + shouldPool, shouldRemove, err = hookManager.ProcessOnPut(ctx, cn) + if err != nil { + internal.Logger.Printf(ctx, "Connection hook error: %v", err) + p.removeConnInternal(ctx, cn, err, freeTurn) + return + } + } + + // Combine all removal checks into one - reduces branches + if shouldRemove || !shouldPool { + p.removeConnInternal(ctx, cn, errHookRequestedRemoval, freeTurn) return } if !cn.pooled { - p.Remove(ctx, cn, nil) + p.removeConnInternal(ctx, cn, errConnNotPooled, freeTurn) return } var shouldCloseConn bool - p.connsMu.Lock() + if p.cfg.MaxIdleConns == 0 || p.idleConnsLen.Load() < p.cfg.MaxIdleConns { + // Hot path optimization: try fast IN_USE → IDLE transition + // Using inline Release() method for better performance (avoids pointer dereference) + transitionedToIdle := cn.Release() - if p.cfg.MaxIdleConns == 0 || p.idleConnsLen < p.cfg.MaxIdleConns { - p.idleConns = append(p.idleConns, cn) - p.idleConnsLen++ + // Handle unexpected state changes + if !transitionedToIdle { + // Fast path failed - hook might have changed state (e.g., to UNUSABLE for handoff) + // Keep the state set by the hook and pool the connection anyway + currentState := cn.GetStateMachine().GetState() + switch currentState { + case StateUnusable: + // expected state, don't log it + case StateClosed: + internal.Logger.Printf(ctx, "Unexpected conn[%d] state changed by hook to %v, closing it", cn.GetID(), currentState) + shouldCloseConn = true + p.removeConnWithLock(cn) + default: + // Pool as-is + internal.Logger.Printf(ctx, "Unexpected conn[%d] state changed by hook to %v, pooling as-is", cn.GetID(), currentState) + } + } + + // unusable conns are expected to become usable at some point (background process is reconnecting them) + // put them at the opposite end of the queue + // Optimization: if we just transitioned to IDLE, we know it's usable - skip the check + if !transitionedToIdle && !cn.IsUsable() { + if p.cfg.PoolFIFO { + p.connsMu.Lock() + p.idleConns = append(p.idleConns, cn) + p.connsMu.Unlock() + } else { + p.connsMu.Lock() + p.idleConns = append([]*Conn{cn}, p.idleConns...) + p.connsMu.Unlock() + } + p.idleConnsLen.Add(1) + } else if !shouldCloseConn { + p.connsMu.Lock() + p.idleConns = append(p.idleConns, cn) + p.connsMu.Unlock() + p.idleConnsLen.Add(1) + } } else { - p.removeConn(cn) shouldCloseConn = true + p.removeConnWithLock(cn) } - p.connsMu.Unlock() - - p.freeTurn() + if freeTurn { + p.freeTurn() + } if shouldCloseConn { _ = p.closeConn(cn) } + + cn.SetLastPutAtNs(getCachedTimeNs()) } -func (p *ConnPool) Remove(_ context.Context, cn *Conn, reason error) { +func (p *ConnPool) Remove(ctx context.Context, cn *Conn, reason error) { + p.removeConnInternal(ctx, cn, reason, true) +} + +// RemoveWithoutTurn removes a connection from the pool without freeing a turn. +// This should be used when removing a connection from a context that didn't acquire +// a turn via Get() (e.g., background workers, cleanup tasks). +// For normal removal after Get(), use Remove() instead. +func (p *ConnPool) RemoveWithoutTurn(ctx context.Context, cn *Conn, reason error) { + p.removeConnInternal(ctx, cn, reason, false) +} + +// removeConnInternal is the internal implementation of Remove that optionally frees a turn. +func (p *ConnPool) removeConnInternal(ctx context.Context, cn *Conn, reason error, freeTurn bool) { + // Lock-free atomic read - no mutex overhead! + hookManager := p.hookManager.Load() + + if hookManager != nil { + hookManager.ProcessOnRemove(ctx, cn, reason) + } + p.removeConnWithLock(cn) - p.freeTurn() + + if freeTurn { + p.freeTurn() + } + _ = p.closeConn(cn) + + // Check if we need to create new idle connections to maintain MinIdleConns + p.checkMinIdleConns() } func (p *ConnPool) CloseConn(cn *Conn) error { @@ -414,17 +901,22 @@ func (p *ConnPool) removeConnWithLock(cn *Conn) { } func (p *ConnPool) removeConn(cn *Conn) { - for i, c := range p.conns { - if c == cn { - p.conns = append(p.conns[:i], p.conns[i+1:]...) - if cn.pooled { - p.poolSize-- - p.checkMinIdleConns() + cid := cn.GetID() + delete(p.conns, cid) + atomic.AddUint32(&p.stats.StaleConns, 1) + + // Decrement pool size counter when removing a connection + if cn.pooled { + p.poolSize.Add(-1) + // this can be idle conn + for idx, ic := range p.idleConns { + if ic == cn { + p.idleConns = append(p.idleConns[:idx], p.idleConns[idx+1:]...) + p.idleConnsLen.Add(-1) + break } - break } } - atomic.AddUint32(&p.stats.StaleConns, 1) } func (p *ConnPool) closeConn(cn *Conn) error { @@ -442,16 +934,27 @@ func (p *ConnPool) Len() int { // IdleLen returns number of idle connections. func (p *ConnPool) IdleLen() int { p.connsMu.Lock() - n := p.idleConnsLen + n := p.idleConnsLen.Load() p.connsMu.Unlock() - return n + return int(n) +} + +// Size returns the maximum pool size (capacity). +// +// This is used by the streaming credentials manager to size the re-auth worker pool, +// ensuring that re-auth operations don't exhaust the connection pool. +func (p *ConnPool) Size() int { + return int(p.cfg.PoolSize) } func (p *ConnPool) Stats() *Stats { return &Stats{ - Hits: atomic.LoadUint32(&p.stats.Hits), - Misses: atomic.LoadUint32(&p.stats.Misses), - Timeouts: atomic.LoadUint32(&p.stats.Timeouts), + Hits: atomic.LoadUint32(&p.stats.Hits), + Misses: atomic.LoadUint32(&p.stats.Misses), + Timeouts: atomic.LoadUint32(&p.stats.Timeouts), + WaitCount: atomic.LoadUint32(&p.stats.WaitCount), + Unusable: atomic.LoadUint32(&p.stats.Unusable), + WaitDurationNs: p.waitDurationNs.Load(), TotalConns: uint32(p.Len()), IdleConns: uint32(p.IdleLen()), @@ -491,28 +994,62 @@ func (p *ConnPool) Close() error { } } p.conns = nil - p.poolSize = 0 + p.poolSize.Store(0) p.idleConns = nil - p.idleConnsLen = 0 + p.idleConnsLen.Store(0) p.connsMu.Unlock() return firstErr } -func (p *ConnPool) isHealthyConn(cn *Conn) bool { - now := time.Now() +func (p *ConnPool) isHealthyConn(cn *Conn, nowNs int64) bool { + // Performance optimization: check conditions from cheapest to most expensive, + // and from most likely to fail to least likely to fail. - if p.cfg.ConnMaxLifetime > 0 && now.Sub(cn.createdAt) >= p.cfg.ConnMaxLifetime { - return false + // Only fails if ConnMaxLifetime is set AND connection is old. + // Most pools don't set ConnMaxLifetime, so this rarely fails. + if p.cfg.ConnMaxLifetime > 0 { + if cn.expiresAt.UnixNano() < nowNs { + return false // Connection has exceeded max lifetime + } } - if p.cfg.ConnMaxIdleTime > 0 && now.Sub(cn.UsedAt()) >= p.cfg.ConnMaxIdleTime { + + // Most pools set ConnMaxIdleTime, and idle connections are common. + // Checking this first allows us to fail fast without expensive syscalls. + if p.cfg.ConnMaxIdleTime > 0 { + if nowNs-cn.UsedAtNs() >= int64(p.cfg.ConnMaxIdleTime) { + return false // Connection has been idle too long + } + } + + // Only run this if the cheap checks passed. + if err := connCheck(cn.getNetConn()); err != nil { + // If there's unexpected data, it might be push notifications (RESP3) + if p.cfg.PushNotificationsEnabled && err == errUnexpectedRead { + // Peek at the reply type to check if it's a push notification + if replyType, err := cn.rd.PeekReplyType(); err == nil && replyType == proto.RespPush { + // For RESP3 connections with push notifications, we allow some buffered data + // The client will process these notifications before using the connection + internal.Logger.Printf( + context.Background(), + "push: conn[%d] has buffered data, likely push notifications - will be processed by client", + cn.GetID(), + ) + + // Update timestamp for healthy connection + cn.SetUsedAtNs(nowNs) + + // Connection is healthy, client will handle notifications + return true + } + // Not a push notification - treat as unhealthy + return false + } + // Connection failed health check return false } - if connCheck(cn.netConn) != nil { - return false - } - - cn.SetUsedAt(now) + // Only update UsedAt if connection is healthy (avoids unnecessary atomic store) + cn.SetUsedAtNs(nowNs) return true } diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go index 5a3fde19..365219a5 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go @@ -1,7 +1,13 @@ package pool -import "context" +import ( + "context" + "time" +) +// SingleConnPool is a pool that always returns the same connection. +// Note: This pool is not thread-safe. +// It is intended to be used by clients that need a single connection. type SingleConnPool struct { pool Pooler cn *Conn @@ -10,6 +16,12 @@ type SingleConnPool struct { var _ Pooler = (*SingleConnPool)(nil) +// NewSingleConnPool creates a new single connection pool. +// The pool will always return the same connection. +// The pool will not: +// - Close the connection +// - Reconnect the connection +// - Track the connection in any way func NewSingleConnPool(pool Pooler, cn *Conn) *SingleConnPool { return &SingleConnPool{ pool: pool, @@ -25,20 +37,47 @@ func (p *SingleConnPool) CloseConn(cn *Conn) error { return p.pool.CloseConn(cn) } -func (p *SingleConnPool) Get(ctx context.Context) (*Conn, error) { +func (p *SingleConnPool) Get(_ context.Context) (*Conn, error) { if p.stickyErr != nil { return nil, p.stickyErr } + if p.cn == nil { + return nil, ErrClosed + } + + // NOTE: SingleConnPool is NOT thread-safe by design and is used in special scenarios: + // - During initialization (connection is in INITIALIZING state) + // - During re-authentication (connection is in UNUSABLE state) + // - For transactions (connection might be in various states) + // We use SetUsed() which forces the transition, rather than TryTransition() which + // would fail if the connection is not in IDLE/CREATED state. + p.cn.SetUsed(true) + p.cn.SetUsedAt(time.Now()) return p.cn, nil } -func (p *SingleConnPool) Put(ctx context.Context, cn *Conn) {} +func (p *SingleConnPool) Put(_ context.Context, cn *Conn) { + if p.cn == nil { + return + } + if p.cn != cn { + return + } + p.cn.SetUsed(false) +} -func (p *SingleConnPool) Remove(ctx context.Context, cn *Conn, reason error) { +func (p *SingleConnPool) Remove(_ context.Context, cn *Conn, reason error) { + cn.SetUsed(false) p.cn = nil p.stickyErr = reason } +// RemoveWithoutTurn has the same behavior as Remove for SingleConnPool +// since SingleConnPool doesn't use a turn-based queue system. +func (p *SingleConnPool) RemoveWithoutTurn(ctx context.Context, cn *Conn, reason error) { + p.Remove(ctx, cn, reason) +} + func (p *SingleConnPool) Close() error { p.cn = nil p.stickyErr = ErrClosed @@ -53,6 +92,13 @@ func (p *SingleConnPool) IdleLen() int { return 0 } +// Size returns the maximum pool size, which is always 1 for SingleConnPool. +func (p *SingleConnPool) Size() int { return 1 } + func (p *SingleConnPool) Stats() *Stats { return &Stats{} } + +func (p *SingleConnPool) AddPoolHook(_ PoolHook) {} + +func (p *SingleConnPool) RemovePoolHook(_ PoolHook) {} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go index 3adb99bc..be869b56 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go @@ -123,6 +123,12 @@ func (p *StickyConnPool) Remove(ctx context.Context, cn *Conn, reason error) { p.ch <- cn } +// RemoveWithoutTurn has the same behavior as Remove for StickyConnPool +// since StickyConnPool doesn't use a turn-based queue system. +func (p *StickyConnPool) RemoveWithoutTurn(ctx context.Context, cn *Conn, reason error) { + p.Remove(ctx, cn, reason) +} + func (p *StickyConnPool) Close() error { if shared := atomic.AddInt32(&p.shared, -1); shared > 0 { return nil @@ -196,6 +202,13 @@ func (p *StickyConnPool) IdleLen() int { return len(p.ch) } +// Size returns the maximum pool size, which is always 1 for StickyConnPool. +func (p *StickyConnPool) Size() int { return 1 } + func (p *StickyConnPool) Stats() *Stats { return &Stats{} } + +func (p *StickyConnPool) AddPoolHook(hook PoolHook) {} + +func (p *StickyConnPool) RemovePoolHook(hook PoolHook) {} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pubsub.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pubsub.go new file mode 100644 index 00000000..5b29659e --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/pubsub.go @@ -0,0 +1,80 @@ +package pool + +import ( + "context" + "net" + "sync" + "sync/atomic" +) + +type PubSubStats struct { + Created uint32 + Untracked uint32 + Active uint32 +} + +// PubSubPool manages a pool of PubSub connections. +type PubSubPool struct { + opt *Options + netDialer func(ctx context.Context, network, addr string) (net.Conn, error) + + // Map to track active PubSub connections + activeConns sync.Map // map[uint64]*Conn (connID -> conn) + closed atomic.Bool + stats PubSubStats +} + +// NewPubSubPool implements a pool for PubSub connections. +// It intentionally does not implement the Pooler interface +func NewPubSubPool(opt *Options, netDialer func(ctx context.Context, network, addr string) (net.Conn, error)) *PubSubPool { + return &PubSubPool{ + opt: opt, + netDialer: netDialer, + } +} + +func (p *PubSubPool) NewConn(ctx context.Context, network string, addr string, channels []string) (*Conn, error) { + if p.closed.Load() { + return nil, ErrClosed + } + + netConn, err := p.netDialer(ctx, network, addr) + if err != nil { + return nil, err + } + cn := NewConnWithBufferSize(netConn, p.opt.ReadBufferSize, p.opt.WriteBufferSize) + cn.pubsub = true + atomic.AddUint32(&p.stats.Created, 1) + return cn, nil + +} + +func (p *PubSubPool) TrackConn(cn *Conn) { + atomic.AddUint32(&p.stats.Active, 1) + p.activeConns.Store(cn.GetID(), cn) +} + +func (p *PubSubPool) UntrackConn(cn *Conn) { + atomic.AddUint32(&p.stats.Active, ^uint32(0)) + atomic.AddUint32(&p.stats.Untracked, 1) + p.activeConns.Delete(cn.GetID()) +} + +func (p *PubSubPool) Close() error { + p.closed.Store(true) + p.activeConns.Range(func(key, value interface{}) bool { + cn := value.(*Conn) + _ = cn.Close() + return true + }) + return nil +} + +func (p *PubSubPool) Stats() *PubSubStats { + // load stats atomically + return &PubSubStats{ + Created: atomic.LoadUint32(&p.stats.Created), + Untracked: atomic.LoadUint32(&p.stats.Untracked), + Active: atomic.LoadUint32(&p.stats.Active), + } +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/pool/want_conn.go b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/want_conn.go new file mode 100644 index 00000000..6f9e4bfa --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/pool/want_conn.go @@ -0,0 +1,93 @@ +package pool + +import ( + "context" + "sync" +) + +type wantConn struct { + mu sync.Mutex // protects ctx, done and sending of the result + ctx context.Context // context for dial, cleared after delivered or canceled + cancelCtx context.CancelFunc + done bool // true after delivered or canceled + result chan wantConnResult // channel to deliver connection or error +} + +// getCtxForDial returns context for dial or nil if connection was delivered or canceled. +func (w *wantConn) getCtxForDial() context.Context { + w.mu.Lock() + defer w.mu.Unlock() + + return w.ctx +} + +func (w *wantConn) tryDeliver(cn *Conn, err error) bool { + w.mu.Lock() + defer w.mu.Unlock() + if w.done { + return false + } + + w.done = true + w.ctx = nil + + w.result <- wantConnResult{cn: cn, err: err} + close(w.result) + + return true +} + +func (w *wantConn) cancel() *Conn { + w.mu.Lock() + var cn *Conn + if w.done { + select { + case result := <-w.result: + cn = result.cn + default: + } + } else { + close(w.result) + } + + w.done = true + w.ctx = nil + w.mu.Unlock() + + return cn +} + +type wantConnResult struct { + cn *Conn + err error +} + +type wantConnQueue struct { + mu sync.RWMutex + items []*wantConn +} + +func newWantConnQueue() *wantConnQueue { + return &wantConnQueue{ + items: make([]*wantConn, 0), + } +} + +func (q *wantConnQueue) enqueue(w *wantConn) { + q.mu.Lock() + defer q.mu.Unlock() + q.items = append(q.items, w) +} + +func (q *wantConnQueue) dequeue() (*wantConn, bool) { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.items) == 0 { + return nil, false + } + + item := q.items[0] + q.items = q.items[1:] + return item, true +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/proto/reader.go b/backend/vendor/github.com/redis/go-redis/v9/internal/proto/reader.go index 8d23817f..bac68f79 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/proto/reader.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/proto/reader.go @@ -12,6 +12,9 @@ import ( "github.com/redis/go-redis/v9/internal/util" ) +// DefaultBufferSize is the default size for read/write buffers (32 KiB). +const DefaultBufferSize = 32 * 1024 + // redis resp protocol data type. const ( RespStatus = '+' // +\r\n @@ -47,7 +50,8 @@ func (e RedisError) Error() string { return string(e) } func (RedisError) RedisError() {} func ParseErrorReply(line []byte) error { - return RedisError(line[1:]) + msg := string(line[1:]) + return parseTypedRedisError(msg) } //------------------------------------------------------------------------------ @@ -58,7 +62,13 @@ type Reader struct { func NewReader(rd io.Reader) *Reader { return &Reader{ - rd: bufio.NewReader(rd), + rd: bufio.NewReaderSize(rd, DefaultBufferSize), + } +} + +func NewReaderSize(rd io.Reader, size int) *Reader { + return &Reader{ + rd: bufio.NewReaderSize(rd, size), } } @@ -90,6 +100,92 @@ func (r *Reader) PeekReplyType() (byte, error) { return b[0], nil } +func (r *Reader) PeekPushNotificationName() (string, error) { + // "prime" the buffer by peeking at the next byte + c, err := r.Peek(1) + if err != nil { + return "", err + } + if c[0] != RespPush { + return "", fmt.Errorf("redis: can't peek push notification name, next reply is not a push notification") + } + + // peek 36 bytes at most, should be enough to read the push notification name + toPeek := 36 + buffered := r.Buffered() + if buffered == 0 { + return "", fmt.Errorf("redis: can't peek push notification name, no data available") + } + if buffered < toPeek { + toPeek = buffered + } + buf, err := r.rd.Peek(toPeek) + if err != nil { + return "", err + } + if buf[0] != RespPush { + return "", fmt.Errorf("redis: can't parse push notification: %q", buf) + } + + if len(buf) < 3 { + return "", fmt.Errorf("redis: can't parse push notification: %q", buf) + } + + // remove push notification type + buf = buf[1:] + // remove first line - e.g. >2\r\n + for i := 0; i < len(buf)-1; i++ { + if buf[i] == '\r' && buf[i+1] == '\n' { + buf = buf[i+2:] + break + } else { + if buf[i] < '0' || buf[i] > '9' { + return "", fmt.Errorf("redis: can't parse push notification: %q", buf) + } + } + } + if len(buf) < 2 { + return "", fmt.Errorf("redis: can't parse push notification: %q", buf) + } + // next line should be $\r\n or +\r\n + // should have the type of the push notification name and it's length + if buf[0] != RespString && buf[0] != RespStatus { + return "", fmt.Errorf("redis: can't parse push notification name: %q", buf) + } + typeOfName := buf[0] + // remove the type of the push notification name + buf = buf[1:] + if typeOfName == RespString { + // remove the length of the string + if len(buf) < 2 { + return "", fmt.Errorf("redis: can't parse push notification name: %q", buf) + } + for i := 0; i < len(buf)-1; i++ { + if buf[i] == '\r' && buf[i+1] == '\n' { + buf = buf[i+2:] + break + } else { + if buf[i] < '0' || buf[i] > '9' { + return "", fmt.Errorf("redis: can't parse push notification name: %q", buf) + } + } + } + } + + if len(buf) < 2 { + return "", fmt.Errorf("redis: can't parse push notification name: %q", buf) + } + // keep only the notification name + for i := 0; i < len(buf)-1; i++ { + if buf[i] == '\r' && buf[i+1] == '\n' { + buf = buf[:i] + break + } + } + + return util.BytesToString(buf), nil +} + // ReadLine Return a valid reply, it will check the protocol or redis error, // and discard the attribute type. func (r *Reader) ReadLine() ([]byte, error) { @@ -106,7 +202,7 @@ func (r *Reader) ReadLine() ([]byte, error) { var blobErr string blobErr, err = r.readStringReply(line) if err == nil { - err = RedisError(blobErr) + err = parseTypedRedisError(blobErr) } return nil, err case RespAttr: diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/proto/redis_errors.go b/backend/vendor/github.com/redis/go-redis/v9/internal/proto/redis_errors.go new file mode 100644 index 00000000..f553e2f9 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/proto/redis_errors.go @@ -0,0 +1,488 @@ +package proto + +import ( + "errors" + "strings" +) + +// Typed Redis errors for better error handling with wrapping support. +// These errors maintain backward compatibility by keeping the same error messages. + +// LoadingError is returned when Redis is loading the dataset in memory. +type LoadingError struct { + msg string +} + +func (e *LoadingError) Error() string { + return e.msg +} + +func (e *LoadingError) RedisError() {} + +// NewLoadingError creates a new LoadingError with the given message. +func NewLoadingError(msg string) *LoadingError { + return &LoadingError{msg: msg} +} + +// ReadOnlyError is returned when trying to write to a read-only replica. +type ReadOnlyError struct { + msg string +} + +func (e *ReadOnlyError) Error() string { + return e.msg +} + +func (e *ReadOnlyError) RedisError() {} + +// NewReadOnlyError creates a new ReadOnlyError with the given message. +func NewReadOnlyError(msg string) *ReadOnlyError { + return &ReadOnlyError{msg: msg} +} + +// MovedError is returned when a key has been moved to a different node in a cluster. +type MovedError struct { + msg string + addr string +} + +func (e *MovedError) Error() string { + return e.msg +} + +func (e *MovedError) RedisError() {} + +// Addr returns the address of the node where the key has been moved. +func (e *MovedError) Addr() string { + return e.addr +} + +// NewMovedError creates a new MovedError with the given message and address. +func NewMovedError(msg string, addr string) *MovedError { + return &MovedError{msg: msg, addr: addr} +} + +// AskError is returned when a key is being migrated and the client should ask another node. +type AskError struct { + msg string + addr string +} + +func (e *AskError) Error() string { + return e.msg +} + +func (e *AskError) RedisError() {} + +// Addr returns the address of the node to ask. +func (e *AskError) Addr() string { + return e.addr +} + +// NewAskError creates a new AskError with the given message and address. +func NewAskError(msg string, addr string) *AskError { + return &AskError{msg: msg, addr: addr} +} + +// ClusterDownError is returned when the cluster is down. +type ClusterDownError struct { + msg string +} + +func (e *ClusterDownError) Error() string { + return e.msg +} + +func (e *ClusterDownError) RedisError() {} + +// NewClusterDownError creates a new ClusterDownError with the given message. +func NewClusterDownError(msg string) *ClusterDownError { + return &ClusterDownError{msg: msg} +} + +// TryAgainError is returned when a command cannot be processed and should be retried. +type TryAgainError struct { + msg string +} + +func (e *TryAgainError) Error() string { + return e.msg +} + +func (e *TryAgainError) RedisError() {} + +// NewTryAgainError creates a new TryAgainError with the given message. +func NewTryAgainError(msg string) *TryAgainError { + return &TryAgainError{msg: msg} +} + +// MasterDownError is returned when the master is down. +type MasterDownError struct { + msg string +} + +func (e *MasterDownError) Error() string { + return e.msg +} + +func (e *MasterDownError) RedisError() {} + +// NewMasterDownError creates a new MasterDownError with the given message. +func NewMasterDownError(msg string) *MasterDownError { + return &MasterDownError{msg: msg} +} + +// MaxClientsError is returned when the maximum number of clients has been reached. +type MaxClientsError struct { + msg string +} + +func (e *MaxClientsError) Error() string { + return e.msg +} + +func (e *MaxClientsError) RedisError() {} + +// NewMaxClientsError creates a new MaxClientsError with the given message. +func NewMaxClientsError(msg string) *MaxClientsError { + return &MaxClientsError{msg: msg} +} + +// AuthError is returned when authentication fails. +type AuthError struct { + msg string +} + +func (e *AuthError) Error() string { + return e.msg +} + +func (e *AuthError) RedisError() {} + +// NewAuthError creates a new AuthError with the given message. +func NewAuthError(msg string) *AuthError { + return &AuthError{msg: msg} +} + +// PermissionError is returned when a user lacks required permissions. +type PermissionError struct { + msg string +} + +func (e *PermissionError) Error() string { + return e.msg +} + +func (e *PermissionError) RedisError() {} + +// NewPermissionError creates a new PermissionError with the given message. +func NewPermissionError(msg string) *PermissionError { + return &PermissionError{msg: msg} +} + +// ExecAbortError is returned when a transaction is aborted. +type ExecAbortError struct { + msg string +} + +func (e *ExecAbortError) Error() string { + return e.msg +} + +func (e *ExecAbortError) RedisError() {} + +// NewExecAbortError creates a new ExecAbortError with the given message. +func NewExecAbortError(msg string) *ExecAbortError { + return &ExecAbortError{msg: msg} +} + +// OOMError is returned when Redis is out of memory. +type OOMError struct { + msg string +} + +func (e *OOMError) Error() string { + return e.msg +} + +func (e *OOMError) RedisError() {} + +// NewOOMError creates a new OOMError with the given message. +func NewOOMError(msg string) *OOMError { + return &OOMError{msg: msg} +} + +// parseTypedRedisError parses a Redis error message and returns a typed error if applicable. +// This function maintains backward compatibility by keeping the same error messages. +func parseTypedRedisError(msg string) error { + // Check for specific error patterns and return typed errors + switch { + case strings.HasPrefix(msg, "LOADING "): + return NewLoadingError(msg) + case strings.HasPrefix(msg, "READONLY "): + return NewReadOnlyError(msg) + case strings.HasPrefix(msg, "MOVED "): + // Extract address from "MOVED " + addr := extractAddr(msg) + return NewMovedError(msg, addr) + case strings.HasPrefix(msg, "ASK "): + // Extract address from "ASK " + addr := extractAddr(msg) + return NewAskError(msg, addr) + case strings.HasPrefix(msg, "CLUSTERDOWN "): + return NewClusterDownError(msg) + case strings.HasPrefix(msg, "TRYAGAIN "): + return NewTryAgainError(msg) + case strings.HasPrefix(msg, "MASTERDOWN "): + return NewMasterDownError(msg) + case msg == "ERR max number of clients reached": + return NewMaxClientsError(msg) + case strings.HasPrefix(msg, "NOAUTH "), strings.HasPrefix(msg, "WRONGPASS "), strings.Contains(msg, "unauthenticated"): + return NewAuthError(msg) + case strings.HasPrefix(msg, "NOPERM "): + return NewPermissionError(msg) + case strings.HasPrefix(msg, "EXECABORT "): + return NewExecAbortError(msg) + case strings.HasPrefix(msg, "OOM "): + return NewOOMError(msg) + default: + // Return generic RedisError for unknown error types + return RedisError(msg) + } +} + +// extractAddr extracts the address from MOVED/ASK error messages. +// Format: "MOVED " or "ASK " +func extractAddr(msg string) string { + ind := strings.LastIndex(msg, " ") + if ind == -1 { + return "" + } + return msg[ind+1:] +} + +// IsLoadingError checks if an error is a LoadingError, even if wrapped. +func IsLoadingError(err error) bool { + if err == nil { + return false + } + var loadingErr *LoadingError + if errors.As(err, &loadingErr) { + return true + } + // Check if wrapped error is a RedisError with LOADING prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "LOADING ") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "LOADING ") +} + +// IsReadOnlyError checks if an error is a ReadOnlyError, even if wrapped. +func IsReadOnlyError(err error) bool { + if err == nil { + return false + } + var readOnlyErr *ReadOnlyError + if errors.As(err, &readOnlyErr) { + return true + } + // Check if wrapped error is a RedisError with READONLY prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "READONLY ") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "READONLY ") +} + +// IsMovedError checks if an error is a MovedError, even if wrapped. +// Returns the error and a boolean indicating if it's a MovedError. +func IsMovedError(err error) (*MovedError, bool) { + if err == nil { + return nil, false + } + var movedErr *MovedError + if errors.As(err, &movedErr) { + return movedErr, true + } + // Fallback to string checking for backward compatibility + s := err.Error() + if strings.HasPrefix(s, "MOVED ") { + // Parse: MOVED 3999 127.0.0.1:6381 + parts := strings.Split(s, " ") + if len(parts) == 3 { + return &MovedError{msg: s, addr: parts[2]}, true + } + } + return nil, false +} + +// IsAskError checks if an error is an AskError, even if wrapped. +// Returns the error and a boolean indicating if it's an AskError. +func IsAskError(err error) (*AskError, bool) { + if err == nil { + return nil, false + } + var askErr *AskError + if errors.As(err, &askErr) { + return askErr, true + } + // Fallback to string checking for backward compatibility + s := err.Error() + if strings.HasPrefix(s, "ASK ") { + // Parse: ASK 3999 127.0.0.1:6381 + parts := strings.Split(s, " ") + if len(parts) == 3 { + return &AskError{msg: s, addr: parts[2]}, true + } + } + return nil, false +} + +// IsClusterDownError checks if an error is a ClusterDownError, even if wrapped. +func IsClusterDownError(err error) bool { + if err == nil { + return false + } + var clusterDownErr *ClusterDownError + if errors.As(err, &clusterDownErr) { + return true + } + // Check if wrapped error is a RedisError with CLUSTERDOWN prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "CLUSTERDOWN ") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "CLUSTERDOWN ") +} + +// IsTryAgainError checks if an error is a TryAgainError, even if wrapped. +func IsTryAgainError(err error) bool { + if err == nil { + return false + } + var tryAgainErr *TryAgainError + if errors.As(err, &tryAgainErr) { + return true + } + // Check if wrapped error is a RedisError with TRYAGAIN prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "TRYAGAIN ") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "TRYAGAIN ") +} + +// IsMasterDownError checks if an error is a MasterDownError, even if wrapped. +func IsMasterDownError(err error) bool { + if err == nil { + return false + } + var masterDownErr *MasterDownError + if errors.As(err, &masterDownErr) { + return true + } + // Check if wrapped error is a RedisError with MASTERDOWN prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "MASTERDOWN ") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "MASTERDOWN ") +} + +// IsMaxClientsError checks if an error is a MaxClientsError, even if wrapped. +func IsMaxClientsError(err error) bool { + if err == nil { + return false + } + var maxClientsErr *MaxClientsError + if errors.As(err, &maxClientsErr) { + return true + } + // Check if wrapped error is a RedisError with max clients prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "ERR max number of clients reached") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "ERR max number of clients reached") +} + +// IsAuthError checks if an error is an AuthError, even if wrapped. +func IsAuthError(err error) bool { + if err == nil { + return false + } + var authErr *AuthError + if errors.As(err, &authErr) { + return true + } + // Check if wrapped error is a RedisError with auth error prefix + var redisErr RedisError + if errors.As(err, &redisErr) { + s := redisErr.Error() + return strings.HasPrefix(s, "NOAUTH ") || strings.HasPrefix(s, "WRONGPASS ") || strings.Contains(s, "unauthenticated") + } + // Fallback to string checking for backward compatibility + s := err.Error() + return strings.HasPrefix(s, "NOAUTH ") || strings.HasPrefix(s, "WRONGPASS ") || strings.Contains(s, "unauthenticated") +} + +// IsPermissionError checks if an error is a PermissionError, even if wrapped. +func IsPermissionError(err error) bool { + if err == nil { + return false + } + var permErr *PermissionError + if errors.As(err, &permErr) { + return true + } + // Check if wrapped error is a RedisError with NOPERM prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "NOPERM ") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "NOPERM ") +} + +// IsExecAbortError checks if an error is an ExecAbortError, even if wrapped. +func IsExecAbortError(err error) bool { + if err == nil { + return false + } + var execAbortErr *ExecAbortError + if errors.As(err, &execAbortErr) { + return true + } + // Check if wrapped error is a RedisError with EXECABORT prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "EXECABORT ") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "EXECABORT ") +} + +// IsOOMError checks if an error is an OOMError, even if wrapped. +func IsOOMError(err error) bool { + if err == nil { + return false + } + var oomErr *OOMError + if errors.As(err, &oomErr) { + return true + } + // Check if wrapped error is a RedisError with OOM prefix + var redisErr RedisError + if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "OOM ") { + return true + } + // Fallback to string checking for backward compatibility + return strings.HasPrefix(err.Error(), "OOM ") +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/proto/writer.go b/backend/vendor/github.com/redis/go-redis/v9/internal/proto/writer.go index 78595cc4..38e66c68 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/proto/writer.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/proto/writer.go @@ -66,56 +66,95 @@ func (w *Writer) WriteArg(v interface{}) error { case string: return w.string(v) case *string: + if v == nil { + return w.string("") + } return w.string(*v) case []byte: return w.bytes(v) case int: return w.int(int64(v)) case *int: + if v == nil { + return w.int(0) + } return w.int(int64(*v)) case int8: return w.int(int64(v)) case *int8: + if v == nil { + return w.int(0) + } return w.int(int64(*v)) case int16: return w.int(int64(v)) case *int16: + if v == nil { + return w.int(0) + } return w.int(int64(*v)) case int32: return w.int(int64(v)) case *int32: + if v == nil { + return w.int(0) + } return w.int(int64(*v)) case int64: return w.int(v) case *int64: + if v == nil { + return w.int(0) + } return w.int(*v) case uint: return w.uint(uint64(v)) case *uint: + if v == nil { + return w.uint(0) + } return w.uint(uint64(*v)) case uint8: return w.uint(uint64(v)) case *uint8: + if v == nil { + return w.string("") + } return w.uint(uint64(*v)) case uint16: return w.uint(uint64(v)) case *uint16: + if v == nil { + return w.uint(0) + } return w.uint(uint64(*v)) case uint32: return w.uint(uint64(v)) case *uint32: + if v == nil { + return w.uint(0) + } return w.uint(uint64(*v)) case uint64: return w.uint(v) case *uint64: + if v == nil { + return w.uint(0) + } return w.uint(*v) case float32: return w.float(float64(v)) case *float32: + if v == nil { + return w.float(0) + } return w.float(float64(*v)) case float64: return w.float(v) case *float64: + if v == nil { + return w.float(0) + } return w.float(*v) case bool: if v { @@ -123,6 +162,9 @@ func (w *Writer) WriteArg(v interface{}) error { } return w.int(0) case *bool: + if v == nil { + return w.int(0) + } if *v { return w.int(1) } @@ -130,8 +172,19 @@ func (w *Writer) WriteArg(v interface{}) error { case time.Time: w.numBuf = v.AppendFormat(w.numBuf[:0], time.RFC3339Nano) return w.bytes(w.numBuf) + case *time.Time: + if v == nil { + v = &time.Time{} + } + w.numBuf = v.AppendFormat(w.numBuf[:0], time.RFC3339Nano) + return w.bytes(w.numBuf) case time.Duration: return w.int(v.Nanoseconds()) + case *time.Duration: + if v == nil { + return w.int(0) + } + return w.int(v.Nanoseconds()) case encoding.BinaryMarshaler: b, err := v.MarshalBinary() if err != nil { diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/redis.go b/backend/vendor/github.com/redis/go-redis/v9/internal/redis.go new file mode 100644 index 00000000..190bbebe --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/redis.go @@ -0,0 +1,3 @@ +package internal + +const RedisNull = "" diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/semaphore.go b/backend/vendor/github.com/redis/go-redis/v9/internal/semaphore.go new file mode 100644 index 00000000..a1dfca5f --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/semaphore.go @@ -0,0 +1,193 @@ +package internal + +import ( + "context" + "sync" + "time" +) + +var semTimers = sync.Pool{ + New: func() interface{} { + t := time.NewTimer(time.Hour) + t.Stop() + return t + }, +} + +// FastSemaphore is a channel-based semaphore optimized for performance. +// It uses a fast path that avoids timer allocation when tokens are available. +// The channel is pre-filled with tokens: Acquire = receive, Release = send. +// Closing the semaphore unblocks all waiting goroutines. +// +// Performance: ~30 ns/op with zero allocations on fast path. +// Fairness: Eventual fairness (no starvation) but not strict FIFO. +type FastSemaphore struct { + tokens chan struct{} + max int32 +} + +// NewFastSemaphore creates a new fast semaphore with the given capacity. +func NewFastSemaphore(capacity int32) *FastSemaphore { + ch := make(chan struct{}, capacity) + // Pre-fill with tokens + for i := int32(0); i < capacity; i++ { + ch <- struct{}{} + } + return &FastSemaphore{ + tokens: ch, + max: capacity, + } +} + +// TryAcquire attempts to acquire a token without blocking. +// Returns true if successful, false if no tokens available. +func (s *FastSemaphore) TryAcquire() bool { + select { + case <-s.tokens: + return true + default: + return false + } +} + +// Acquire acquires a token, blocking if necessary until one is available. +// Returns an error if the context is cancelled or the timeout expires. +// Uses a fast path to avoid timer allocation when tokens are immediately available. +func (s *FastSemaphore) Acquire(ctx context.Context, timeout time.Duration, timeoutErr error) error { + // Check context first + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Try fast path first (no timer needed) + select { + case <-s.tokens: + return nil + default: + } + + // Slow path: need to wait with timeout + timer := semTimers.Get().(*time.Timer) + defer semTimers.Put(timer) + timer.Reset(timeout) + + select { + case <-s.tokens: + if !timer.Stop() { + <-timer.C + } + return nil + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + return timeoutErr + } +} + +// AcquireBlocking acquires a token, blocking indefinitely until one is available. +func (s *FastSemaphore) AcquireBlocking() { + <-s.tokens +} + +// Release releases a token back to the semaphore. +func (s *FastSemaphore) Release() { + s.tokens <- struct{}{} +} + +// Close closes the semaphore, unblocking all waiting goroutines. +// After close, all Acquire calls will receive a closed channel signal. +func (s *FastSemaphore) Close() { + close(s.tokens) +} + +// Len returns the current number of acquired tokens. +func (s *FastSemaphore) Len() int32 { + return s.max - int32(len(s.tokens)) +} + +// FIFOSemaphore is a channel-based semaphore with strict FIFO ordering. +// Unlike FastSemaphore, this guarantees that threads are served in the exact order they call Acquire(). +// The channel is pre-filled with tokens: Acquire = receive, Release = send. +// Closing the semaphore unblocks all waiting goroutines. +// +// Performance: ~115 ns/op with zero allocations (slower than FastSemaphore due to timer allocation). +// Fairness: Strict FIFO ordering guaranteed by Go runtime. +type FIFOSemaphore struct { + tokens chan struct{} + max int32 +} + +// NewFIFOSemaphore creates a new FIFO semaphore with the given capacity. +func NewFIFOSemaphore(capacity int32) *FIFOSemaphore { + ch := make(chan struct{}, capacity) + // Pre-fill with tokens + for i := int32(0); i < capacity; i++ { + ch <- struct{}{} + } + return &FIFOSemaphore{ + tokens: ch, + max: capacity, + } +} + +// TryAcquire attempts to acquire a token without blocking. +// Returns true if successful, false if no tokens available. +func (s *FIFOSemaphore) TryAcquire() bool { + select { + case <-s.tokens: + return true + default: + return false + } +} + +// Acquire acquires a token, blocking if necessary until one is available. +// Returns an error if the context is cancelled or the timeout expires. +// Always uses timer to guarantee FIFO ordering (no fast path). +func (s *FIFOSemaphore) Acquire(ctx context.Context, timeout time.Duration, timeoutErr error) error { + // No fast path - always use timer to guarantee FIFO + timer := semTimers.Get().(*time.Timer) + defer semTimers.Put(timer) + timer.Reset(timeout) + + select { + case <-s.tokens: + if !timer.Stop() { + <-timer.C + } + return nil + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + return timeoutErr + } +} + +// AcquireBlocking acquires a token, blocking indefinitely until one is available. +func (s *FIFOSemaphore) AcquireBlocking() { + <-s.tokens +} + +// Release releases a token back to the semaphore. +func (s *FIFOSemaphore) Release() { + s.tokens <- struct{}{} +} + +// Close closes the semaphore, unblocking all waiting goroutines. +// After close, all Acquire calls will receive a closed channel signal. +func (s *FIFOSemaphore) Close() { + close(s.tokens) +} + +// Len returns the current number of acquired tokens. +func (s *FIFOSemaphore) Len() int32 { + return s.max - int32(len(s.tokens)) +} \ No newline at end of file diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/util.go b/backend/vendor/github.com/redis/go-redis/v9/internal/util.go index cc1bff24..f77775ff 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/internal/util.go +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/util.go @@ -49,22 +49,7 @@ func isLower(s string) bool { } func ReplaceSpaces(s string) string { - // Pre-allocate a builder with the same length as s to minimize allocations. - // This is a basic optimization; adjust the initial size based on your use case. - var builder strings.Builder - builder.Grow(len(s)) - - for _, char := range s { - if char == ' ' { - // Replace space with a hyphen. - builder.WriteRune('-') - } else { - // Copy the character as-is. - builder.WriteRune(char) - } - } - - return builder.String() + return strings.ReplaceAll(s, " ", "-") } func GetAddr(addr string) string { diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/util/convert.go b/backend/vendor/github.com/redis/go-redis/v9/internal/util/convert.go new file mode 100644 index 00000000..b743a4f0 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/util/convert.go @@ -0,0 +1,41 @@ +package util + +import ( + "fmt" + "math" + "strconv" +) + +// ParseFloat parses a Redis RESP3 float reply into a Go float64, +// handling "inf", "-inf", "nan" per Redis conventions. +func ParseStringToFloat(s string) (float64, error) { + switch s { + case "inf": + return math.Inf(1), nil + case "-inf": + return math.Inf(-1), nil + case "nan", "-nan": + return math.NaN(), nil + } + return strconv.ParseFloat(s, 64) +} + +// MustParseFloat is like ParseFloat but panics on parse errors. +func MustParseFloat(s string) float64 { + f, err := ParseStringToFloat(s) + if err != nil { + panic(fmt.Sprintf("redis: failed to parse float %q: %v", s, err)) + } + return f +} + +// SafeIntToInt32 safely converts an int to int32, returning an error if overflow would occur. +func SafeIntToInt32(value int, fieldName string) (int32, error) { + if value > math.MaxInt32 { + return 0, fmt.Errorf("redis: %s value %d exceeds maximum allowed value %d", fieldName, value, math.MaxInt32) + } + if value < math.MinInt32 { + return 0, fmt.Errorf("redis: %s value %d is below minimum allowed value %d", fieldName, value, math.MinInt32) + } + return int32(value), nil +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/internal/util/math.go b/backend/vendor/github.com/redis/go-redis/v9/internal/util/math.go new file mode 100644 index 00000000..e707c47a --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/internal/util/math.go @@ -0,0 +1,17 @@ +package util + +// Max returns the maximum of two integers +func Max(a, b int) int { + if a > b { + return a + } + return b +} + +// Min returns the minimum of two integers +func Min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/json.go b/backend/vendor/github.com/redis/go-redis/v9/json.go index b3cadf4b..2b9fa527 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/json.go +++ b/backend/vendor/github.com/redis/go-redis/v9/json.go @@ -82,6 +82,7 @@ func (cmd *JSONCmd) SetVal(val string) { cmd.val = val } +// Val returns the result of the JSON.GET command as a string. func (cmd *JSONCmd) Val() string { if len(cmd.val) == 0 && cmd.expanded != nil { val, err := json.Marshal(cmd.expanded) @@ -100,6 +101,7 @@ func (cmd *JSONCmd) Result() (string, error) { return cmd.Val(), cmd.Err() } +// Expanded returns the result of the JSON.GET command as unmarshalled JSON. func (cmd *JSONCmd) Expanded() (interface{}, error) { if len(cmd.val) != 0 && cmd.expanded == nil { err := json.Unmarshal([]byte(cmd.val), &cmd.expanded) @@ -113,11 +115,17 @@ func (cmd *JSONCmd) Expanded() (interface{}, error) { func (cmd *JSONCmd) readReply(rd *proto.Reader) error { // nil response from JSON.(M)GET (cmd.baseCmd.err will be "redis: nil") + // This happens when the key doesn't exist if cmd.baseCmd.Err() == Nil { cmd.val = "" return Nil } + // Handle other base command errors + if cmd.baseCmd.Err() != nil { + return cmd.baseCmd.Err() + } + if readType, err := rd.PeekReplyType(); err != nil { return err } else if readType == proto.RespArray { @@ -127,6 +135,13 @@ func (cmd *JSONCmd) readReply(rd *proto.Reader) error { return err } + // Empty array means no results found for JSON path, but key exists + // This should return "[]", not an error + if size == 0 { + cmd.val = "[]" + return nil + } + expanded := make([]interface{}, size) for i := 0; i < size; i++ { @@ -141,6 +156,7 @@ func (cmd *JSONCmd) readReply(rd *proto.Reader) error { return err } else if str == "" || err == Nil { cmd.val = "" + return Nil } else { cmd.val = str } diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/FEATURES.md b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/FEATURES.md new file mode 100644 index 00000000..caa4f705 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/FEATURES.md @@ -0,0 +1,218 @@ +# Maintenance Notifications - FEATURES + +## Overview + +The Maintenance Notifications feature enables seamless Redis connection handoffs during cluster maintenance operations without dropping active connections. This feature leverages Redis RESP3 push notifications to provide zero-downtime maintenance for Redis Enterprise and compatible Redis deployments. + +## Important + +Using Maintenance Notifications may affect the read and write timeouts by relaxing them during maintenance operations. +This is necessary to prevent false failures due to increased latency during handoffs. The relaxed timeouts are automatically applied and removed as needed. + +## Key Features + +### Seamless Connection Handoffs +- **Zero-Downtime Maintenance**: Automatically handles connection transitions during cluster operations +- **Active Operation Preservation**: Transfers in-flight operations to new connections without interruption +- **Graceful Degradation**: Falls back to standard reconnection if handoff fails + +### Push Notification Support +Supports all Redis Enterprise maintenance notification types: +- **MOVING** - Slot moving to a new node +- **MIGRATING** - Slot in migration state +- **MIGRATED** - Migration completed +- **FAILING_OVER** - Node failing over +- **FAILED_OVER** - Failover completed + +### Circuit Breaker Pattern +- **Endpoint-Specific Failure Tracking**: Prevents repeated connection attempts to failing endpoints +- **Automatic Recovery Testing**: Half-open state allows gradual recovery validation +- **Configurable Thresholds**: Customize failure thresholds and reset timeouts + +### Flexible Configuration +- **Auto-Detection Mode**: Automatically detects server support for maintenance notifications +- **Multiple Endpoint Types**: Support for internal/external IP/FQDN endpoint resolution +- **Auto-Scaling Workers**: Automatically sizes worker pool based on connection pool size +- **Timeout Management**: Separate timeouts for relaxed (during maintenance) and normal operations + +### Extensible Hook System +- **Pre/Post Processing Hooks**: Monitor and customize notification handling +- **Built-in Hooks**: Logging and metrics collection hooks included +- **Custom Hook Support**: Implement custom business logic around maintenance events + +### Comprehensive Monitoring +- **Metrics Collection**: Track notification counts, processing times, and error rates +- **Circuit Breaker Stats**: Monitor endpoint health and circuit breaker states +- **Operation Tracking**: Track active handoff operations and their lifecycle + +## Architecture Highlights + +### Event-Driven Handoff System +- **Asynchronous Processing**: Non-blocking handoff operations using worker pool pattern +- **Queue-Based Architecture**: Configurable queue size with auto-scaling support +- **Retry Mechanism**: Configurable retry attempts with exponential backoff + +### Connection Pool Integration +- **Pool Hook Interface**: Seamless integration with go-redis connection pool +- **Connection State Management**: Atomic flags for connection usability tracking +- **Graceful Shutdown**: Ensures all in-flight handoffs complete before shutdown + +### Thread-Safe Design +- **Lock-Free Operations**: Atomic operations for high-performance state tracking +- **Concurrent-Safe Maps**: sync.Map for tracking active operations +- **Minimal Lock Contention**: Read-write locks only where necessary + +## Configuration Options + +### Operation Modes +- **`ModeDisabled`**: Maintenance notifications completely disabled +- **`ModeEnabled`**: Forcefully enabled (fails if server doesn't support) +- **`ModeAuto`**: Auto-detect server support (recommended default) + +### Endpoint Types +- **`EndpointTypeAuto`**: Auto-detect based on current connection +- **`EndpointTypeInternalIP`**: Use internal IP addresses +- **`EndpointTypeInternalFQDN`**: Use internal fully qualified domain names +- **`EndpointTypeExternalIP`**: Use external IP addresses +- **`EndpointTypeExternalFQDN`**: Use external fully qualified domain names +- **`EndpointTypeNone`**: No endpoint (reconnect with current configuration) + +### Timeout Configuration +- **`RelaxedTimeout`**: Extended timeout during maintenance operations (default: 10s) +- **`HandoffTimeout`**: Maximum time for handoff completion (default: 15s) +- **`PostHandoffRelaxedDuration`**: Relaxed period after handoff (default: 2×RelaxedTimeout) + +### Worker Pool Configuration +- **`MaxWorkers`**: Maximum concurrent handoff workers (auto-calculated if 0) +- **`HandoffQueueSize`**: Handoff queue capacity (auto-calculated if 0) +- **`MaxHandoffRetries`**: Maximum retry attempts for failed handoffs (default: 3) + +### Circuit Breaker Configuration +- **`CircuitBreakerFailureThreshold`**: Failures before opening circuit (default: 5) +- **`CircuitBreakerResetTimeout`**: Time before testing recovery (default: 60s) +- **`CircuitBreakerMaxRequests`**: Max requests in half-open state (default: 3) + +## Auto-Scaling Formulas + +### Worker Pool Sizing +When `MaxWorkers = 0` (auto-calculate): +``` +MaxWorkers = min(PoolSize/2, max(10, PoolSize/3)) +``` + +### Queue Sizing +When `HandoffQueueSize = 0` (auto-calculate): +``` +QueueSize = max(20 × MaxWorkers, PoolSize) +Capped by: min(MaxActiveConns + 1, 5 × PoolSize) +``` + +### Examples +- **Pool Size 100**: 33 workers, 660 queue (capped at 500) +- **Pool Size 100 + MaxActiveConns 150**: 33 workers, 151 queue +- **Pool Size 50**: 16 workers, 320 queue (capped at 250) + +## Performance Characteristics + +### Throughput +- **Non-Blocking Handoffs**: Client operations continue during handoffs +- **Concurrent Processing**: Multiple handoffs processed in parallel +- **Minimal Overhead**: Lock-free atomic operations for state tracking + +### Latency +- **Relaxed Timeouts**: Extended timeouts during maintenance prevent false failures +- **Fast Path**: Connections not undergoing handoff have zero overhead +- **Graceful Degradation**: Failed handoffs fall back to standard reconnection + +### Resource Usage +- **Memory Efficient**: Bounded queue sizes prevent memory exhaustion +- **Worker Pool**: Fixed worker count prevents goroutine explosion +- **Connection Reuse**: Handoff reuses existing connection objects + +## Testing + +### Unit Tests +- Comprehensive unit test coverage for all components +- Mock-based testing for isolation +- Concurrent operation testing + +### Integration Tests +- Pool integration tests with real connection handoffs +- Circuit breaker behavior validation +- Hook system integration testing + +### E2E Tests +- Real Redis Enterprise cluster testing +- Multiple scenario coverage (timeouts, endpoint types, stress tests) +- Fault injection testing +- TLS configuration testing + +## Compatibility + +### Requirements +- **Redis Protocol**: RESP3 required for push notifications +- **Redis Version**: Redis Enterprise or compatible Redis with maintenance notifications +- **Go Version**: Go 1.18+ (uses generics and atomic types) + +### Client Support +#### Currently Supported +- **Standalone Client** (`redis.NewClient`) + +#### Planned Support +- **Cluster Client** (not yet supported) + +#### Will Not Support +- **Failover Client** (no planned support) +- **Ring Client** (no planned support) + +## Migration Guide + +### Enabling Maintenance Notifications + +**Before:** +```go +client := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Protocol: 2, // RESP2 +}) +``` + +**After:** +```go +client := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Protocol: 3, // RESP3 required + MaintNotificationsConfig: &maintnotifications.Config{ + Mode: maintnotifications.ModeAuto, + }, +}) +``` + +### Adding Monitoring + +```go +// Get the manager from the client +manager := client.GetMaintNotificationsManager() +if manager != nil { + // Add logging hook + loggingHook := maintnotifications.NewLoggingHook(2) // Info level + manager.AddNotificationHook(loggingHook) + + // Add metrics hook + metricsHook := maintnotifications.NewMetricsHook() + manager.AddNotificationHook(metricsHook) +} +``` + +## Known Limitations + +1. **Standalone Only**: Currently only supported in standalone Redis clients +2. **RESP3 Required**: Push notifications require RESP3 protocol +3. **Server Support**: Requires Redis Enterprise or compatible Redis with maintenance notifications +4. **Single Connection Commands**: Some commands (MULTI/EXEC, WATCH) may need special handling +5. **No Failover/Ring Client Support**: Failover and Ring clients are not supported and there are no plans to add support + +## Future Enhancements + +- Cluster client support +- Enhanced metrics and observability \ No newline at end of file diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/README.md b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/README.md new file mode 100644 index 00000000..2ac6b9cb --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/README.md @@ -0,0 +1,67 @@ +# Maintenance Notifications + +Seamless Redis connection handoffs during cluster maintenance operations without dropping connections. + +## ⚠️ **Important Note** +**Maintenance notifications are currently supported only in standalone Redis clients.** Cluster clients (ClusterClient, FailoverClient, etc.) do not yet support this functionality. + +## Quick Start + +```go +client := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Protocol: 3, // RESP3 required + MaintNotificationsConfig: &maintnotifications.Config{ + Mode: maintnotifications.ModeEnabled, + }, +}) +``` + +## Modes + +- **`ModeDisabled`** - Maintenance notifications disabled +- **`ModeEnabled`** - Forcefully enabled (fails if server doesn't support) +- **`ModeAuto`** - Auto-detect server support (default) + +## Configuration + +```go +&maintnotifications.Config{ + Mode: maintnotifications.ModeAuto, + EndpointType: maintnotifications.EndpointTypeAuto, + RelaxedTimeout: 10 * time.Second, + HandoffTimeout: 15 * time.Second, + MaxHandoffRetries: 3, + MaxWorkers: 0, // Auto-calculated + HandoffQueueSize: 0, // Auto-calculated + PostHandoffRelaxedDuration: 0, // 2 * RelaxedTimeout +} +``` + +### Endpoint Types + +- **`EndpointTypeAuto`** - Auto-detect based on connection (default) +- **`EndpointTypeInternalIP`** - Internal IP address +- **`EndpointTypeInternalFQDN`** - Internal FQDN +- **`EndpointTypeExternalIP`** - External IP address +- **`EndpointTypeExternalFQDN`** - External FQDN +- **`EndpointTypeNone`** - No endpoint (reconnect with current config) + +### Auto-Scaling + +**Workers**: `min(PoolSize/2, max(10, PoolSize/3))` when auto-calculated +**Queue**: `max(20×Workers, PoolSize)` capped by `MaxActiveConns+1` or `5×PoolSize` + +**Examples:** +- Pool 100: 33 workers, 660 queue (capped at 500) +- Pool 100 + MaxActiveConns 150: 33 workers, 151 queue + +## How It Works + +1. Redis sends push notifications about cluster maintenance operations +2. Client creates new connections to updated endpoints +3. Active operations transfer to new connections +4. Old connections close gracefully + + +## For more information, see [FEATURES](FEATURES.md) diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/circuit_breaker.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/circuit_breaker.go new file mode 100644 index 00000000..cb76b644 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/circuit_breaker.go @@ -0,0 +1,353 @@ +package maintnotifications + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" +) + +// CircuitBreakerState represents the state of a circuit breaker +type CircuitBreakerState int32 + +const ( + // CircuitBreakerClosed - normal operation, requests allowed + CircuitBreakerClosed CircuitBreakerState = iota + // CircuitBreakerOpen - failing fast, requests rejected + CircuitBreakerOpen + // CircuitBreakerHalfOpen - testing if service recovered + CircuitBreakerHalfOpen +) + +func (s CircuitBreakerState) String() string { + switch s { + case CircuitBreakerClosed: + return "closed" + case CircuitBreakerOpen: + return "open" + case CircuitBreakerHalfOpen: + return "half-open" + default: + return "unknown" + } +} + +// CircuitBreaker implements the circuit breaker pattern for endpoint-specific failure handling +type CircuitBreaker struct { + // Configuration + failureThreshold int // Number of failures before opening + resetTimeout time.Duration // How long to stay open before testing + maxRequests int // Max requests allowed in half-open state + + // State tracking (atomic for lock-free access) + state atomic.Int32 // CircuitBreakerState + failures atomic.Int64 // Current failure count + successes atomic.Int64 // Success count in half-open state + requests atomic.Int64 // Request count in half-open state + lastFailureTime atomic.Int64 // Unix timestamp of last failure + lastSuccessTime atomic.Int64 // Unix timestamp of last success + + // Endpoint identification + endpoint string + config *Config +} + +// newCircuitBreaker creates a new circuit breaker for an endpoint +func newCircuitBreaker(endpoint string, config *Config) *CircuitBreaker { + // Use configuration values with sensible defaults + failureThreshold := 5 + resetTimeout := 60 * time.Second + maxRequests := 3 + + if config != nil { + failureThreshold = config.CircuitBreakerFailureThreshold + resetTimeout = config.CircuitBreakerResetTimeout + maxRequests = config.CircuitBreakerMaxRequests + } + + return &CircuitBreaker{ + failureThreshold: failureThreshold, + resetTimeout: resetTimeout, + maxRequests: maxRequests, + endpoint: endpoint, + config: config, + state: atomic.Int32{}, // Defaults to CircuitBreakerClosed (0) + } +} + +// IsOpen returns true if the circuit breaker is open (rejecting requests) +func (cb *CircuitBreaker) IsOpen() bool { + state := CircuitBreakerState(cb.state.Load()) + return state == CircuitBreakerOpen +} + +// shouldAttemptReset checks if enough time has passed to attempt reset +func (cb *CircuitBreaker) shouldAttemptReset() bool { + lastFailure := time.Unix(cb.lastFailureTime.Load(), 0) + return time.Since(lastFailure) >= cb.resetTimeout +} + +// Execute runs the given function with circuit breaker protection +func (cb *CircuitBreaker) Execute(fn func() error) error { + // Single atomic state load for consistency + state := CircuitBreakerState(cb.state.Load()) + + switch state { + case CircuitBreakerOpen: + if cb.shouldAttemptReset() { + // Attempt transition to half-open + if cb.state.CompareAndSwap(int32(CircuitBreakerOpen), int32(CircuitBreakerHalfOpen)) { + cb.requests.Store(0) + cb.successes.Store(0) + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.CircuitBreakerTransitioningToHalfOpen(cb.endpoint)) + } + // Fall through to half-open logic + } else { + return ErrCircuitBreakerOpen + } + } else { + return ErrCircuitBreakerOpen + } + fallthrough + case CircuitBreakerHalfOpen: + requests := cb.requests.Add(1) + if requests > int64(cb.maxRequests) { + cb.requests.Add(-1) // Revert the increment + return ErrCircuitBreakerOpen + } + } + + // Execute the function with consistent state + err := fn() + + if err != nil { + cb.recordFailure() + return err + } + + cb.recordSuccess() + return nil +} + +// recordFailure records a failure and potentially opens the circuit +func (cb *CircuitBreaker) recordFailure() { + cb.lastFailureTime.Store(time.Now().Unix()) + failures := cb.failures.Add(1) + + state := CircuitBreakerState(cb.state.Load()) + + switch state { + case CircuitBreakerClosed: + if failures >= int64(cb.failureThreshold) { + if cb.state.CompareAndSwap(int32(CircuitBreakerClosed), int32(CircuitBreakerOpen)) { + if internal.LogLevel.WarnOrAbove() { + internal.Logger.Printf(context.Background(), logs.CircuitBreakerOpened(cb.endpoint, failures)) + } + } + } + case CircuitBreakerHalfOpen: + // Any failure in half-open state immediately opens the circuit + if cb.state.CompareAndSwap(int32(CircuitBreakerHalfOpen), int32(CircuitBreakerOpen)) { + if internal.LogLevel.WarnOrAbove() { + internal.Logger.Printf(context.Background(), logs.CircuitBreakerReopened(cb.endpoint)) + } + } + } +} + +// recordSuccess records a success and potentially closes the circuit +func (cb *CircuitBreaker) recordSuccess() { + cb.lastSuccessTime.Store(time.Now().Unix()) + + state := CircuitBreakerState(cb.state.Load()) + + switch state { + case CircuitBreakerClosed: + // Reset failure count on success in closed state + cb.failures.Store(0) + case CircuitBreakerHalfOpen: + successes := cb.successes.Add(1) + + // If we've had enough successful requests, close the circuit + if successes >= int64(cb.maxRequests) { + if cb.state.CompareAndSwap(int32(CircuitBreakerHalfOpen), int32(CircuitBreakerClosed)) { + cb.failures.Store(0) + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.CircuitBreakerClosed(cb.endpoint, successes)) + } + } + } + } +} + +// GetState returns the current state of the circuit breaker +func (cb *CircuitBreaker) GetState() CircuitBreakerState { + return CircuitBreakerState(cb.state.Load()) +} + +// GetStats returns current statistics for monitoring +func (cb *CircuitBreaker) GetStats() CircuitBreakerStats { + return CircuitBreakerStats{ + Endpoint: cb.endpoint, + State: cb.GetState(), + Failures: cb.failures.Load(), + Successes: cb.successes.Load(), + Requests: cb.requests.Load(), + LastFailureTime: time.Unix(cb.lastFailureTime.Load(), 0), + LastSuccessTime: time.Unix(cb.lastSuccessTime.Load(), 0), + } +} + +// CircuitBreakerStats provides statistics about a circuit breaker +type CircuitBreakerStats struct { + Endpoint string + State CircuitBreakerState + Failures int64 + Successes int64 + Requests int64 + LastFailureTime time.Time + LastSuccessTime time.Time +} + +// CircuitBreakerEntry wraps a circuit breaker with access tracking +type CircuitBreakerEntry struct { + breaker *CircuitBreaker + lastAccess atomic.Int64 // Unix timestamp + created time.Time +} + +// CircuitBreakerManager manages circuit breakers for multiple endpoints +type CircuitBreakerManager struct { + breakers sync.Map // map[string]*CircuitBreakerEntry + config *Config + cleanupStop chan struct{} + cleanupMu sync.Mutex + lastCleanup atomic.Int64 // Unix timestamp +} + +// newCircuitBreakerManager creates a new circuit breaker manager +func newCircuitBreakerManager(config *Config) *CircuitBreakerManager { + cbm := &CircuitBreakerManager{ + config: config, + cleanupStop: make(chan struct{}), + } + cbm.lastCleanup.Store(time.Now().Unix()) + + // Start background cleanup goroutine + go cbm.cleanupLoop() + + return cbm +} + +// GetCircuitBreaker returns the circuit breaker for an endpoint, creating it if necessary +func (cbm *CircuitBreakerManager) GetCircuitBreaker(endpoint string) *CircuitBreaker { + now := time.Now().Unix() + + if entry, ok := cbm.breakers.Load(endpoint); ok { + cbEntry := entry.(*CircuitBreakerEntry) + cbEntry.lastAccess.Store(now) + return cbEntry.breaker + } + + // Create new circuit breaker with metadata + newBreaker := newCircuitBreaker(endpoint, cbm.config) + newEntry := &CircuitBreakerEntry{ + breaker: newBreaker, + created: time.Now(), + } + newEntry.lastAccess.Store(now) + + actual, _ := cbm.breakers.LoadOrStore(endpoint, newEntry) + return actual.(*CircuitBreakerEntry).breaker +} + +// GetAllStats returns statistics for all circuit breakers +func (cbm *CircuitBreakerManager) GetAllStats() []CircuitBreakerStats { + var stats []CircuitBreakerStats + cbm.breakers.Range(func(key, value interface{}) bool { + entry := value.(*CircuitBreakerEntry) + stats = append(stats, entry.breaker.GetStats()) + return true + }) + return stats +} + +// cleanupLoop runs background cleanup of unused circuit breakers +func (cbm *CircuitBreakerManager) cleanupLoop() { + ticker := time.NewTicker(5 * time.Minute) // Cleanup every 5 minutes + defer ticker.Stop() + + for { + select { + case <-ticker.C: + cbm.cleanup() + case <-cbm.cleanupStop: + return + } + } +} + +// cleanup removes circuit breakers that haven't been accessed recently +func (cbm *CircuitBreakerManager) cleanup() { + // Prevent concurrent cleanups + if !cbm.cleanupMu.TryLock() { + return + } + defer cbm.cleanupMu.Unlock() + + now := time.Now() + cutoff := now.Add(-30 * time.Minute).Unix() // 30 minute TTL + + var toDelete []string + count := 0 + + cbm.breakers.Range(func(key, value interface{}) bool { + endpoint := key.(string) + entry := value.(*CircuitBreakerEntry) + + count++ + + // Remove if not accessed recently + if entry.lastAccess.Load() < cutoff { + toDelete = append(toDelete, endpoint) + } + + return true + }) + + // Delete expired entries + for _, endpoint := range toDelete { + cbm.breakers.Delete(endpoint) + } + + // Log cleanup results + if len(toDelete) > 0 && internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.CircuitBreakerCleanup(len(toDelete), count)) + } + + cbm.lastCleanup.Store(now.Unix()) +} + +// Shutdown stops the cleanup goroutine +func (cbm *CircuitBreakerManager) Shutdown() { + close(cbm.cleanupStop) +} + +// Reset resets all circuit breakers (useful for testing) +func (cbm *CircuitBreakerManager) Reset() { + cbm.breakers.Range(func(key, value interface{}) bool { + entry := value.(*CircuitBreakerEntry) + breaker := entry.breaker + breaker.state.Store(int32(CircuitBreakerClosed)) + breaker.failures.Store(0) + breaker.successes.Store(0) + breaker.requests.Store(0) + breaker.lastFailureTime.Store(0) + breaker.lastSuccessTime.Store(0) + return true + }) +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/config.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/config.go new file mode 100644 index 00000000..cbf4f6b2 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/config.go @@ -0,0 +1,458 @@ +package maintnotifications + +import ( + "context" + "net" + "runtime" + "strings" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" + "github.com/redis/go-redis/v9/internal/util" +) + +// Mode represents the maintenance notifications mode +type Mode string + +// Constants for maintenance push notifications modes +const ( + ModeDisabled Mode = "disabled" // Client doesn't send CLIENT MAINT_NOTIFICATIONS ON command + ModeEnabled Mode = "enabled" // Client forcefully sends command, interrupts connection on error + ModeAuto Mode = "auto" // Client tries to send command, disables feature on error +) + +// IsValid returns true if the maintenance notifications mode is valid +func (m Mode) IsValid() bool { + switch m { + case ModeDisabled, ModeEnabled, ModeAuto: + return true + default: + return false + } +} + +// String returns the string representation of the mode +func (m Mode) String() string { + return string(m) +} + +// EndpointType represents the type of endpoint to request in MOVING notifications +type EndpointType string + +// Constants for endpoint types +const ( + EndpointTypeAuto EndpointType = "auto" // Auto-detect based on connection + EndpointTypeInternalIP EndpointType = "internal-ip" // Internal IP address + EndpointTypeInternalFQDN EndpointType = "internal-fqdn" // Internal FQDN + EndpointTypeExternalIP EndpointType = "external-ip" // External IP address + EndpointTypeExternalFQDN EndpointType = "external-fqdn" // External FQDN + EndpointTypeNone EndpointType = "none" // No endpoint (reconnect with current config) +) + +// IsValid returns true if the endpoint type is valid +func (e EndpointType) IsValid() bool { + switch e { + case EndpointTypeAuto, EndpointTypeInternalIP, EndpointTypeInternalFQDN, + EndpointTypeExternalIP, EndpointTypeExternalFQDN, EndpointTypeNone: + return true + default: + return false + } +} + +// String returns the string representation of the endpoint type +func (e EndpointType) String() string { + return string(e) +} + +// Config provides configuration options for maintenance notifications +type Config struct { + // Mode controls how client maintenance notifications are handled. + // Valid values: ModeDisabled, ModeEnabled, ModeAuto + // Default: ModeAuto + Mode Mode + + // EndpointType specifies the type of endpoint to request in MOVING notifications. + // Valid values: EndpointTypeAuto, EndpointTypeInternalIP, EndpointTypeInternalFQDN, + // EndpointTypeExternalIP, EndpointTypeExternalFQDN, EndpointTypeNone + // Default: EndpointTypeAuto + EndpointType EndpointType + + // RelaxedTimeout is the concrete timeout value to use during + // MIGRATING/FAILING_OVER states to accommodate increased latency. + // This applies to both read and write timeouts. + // Default: 10 seconds + RelaxedTimeout time.Duration + + // HandoffTimeout is the maximum time to wait for connection handoff to complete. + // If handoff takes longer than this, the old connection will be forcibly closed. + // Default: 15 seconds (matches server-side eviction timeout) + HandoffTimeout time.Duration + + // MaxWorkers is the maximum number of worker goroutines for processing handoff requests. + // Workers are created on-demand and automatically cleaned up when idle. + // If zero, defaults to min(10, PoolSize/2) to handle bursts effectively. + // If explicitly set, enforces minimum of PoolSize/2 + // + // Default: min(PoolSize/2, max(10, PoolSize/3)), Minimum when set: PoolSize/2 + MaxWorkers int + + // HandoffQueueSize is the size of the buffered channel used to queue handoff requests. + // If the queue is full, new handoff requests will be rejected. + // Scales with both worker count and pool size for better burst handling. + // + // Default: max(20×MaxWorkers, PoolSize), capped by MaxActiveConns+1 (if set) or 5×PoolSize + // When set: minimum 200, capped by MaxActiveConns+1 (if set) or 5×PoolSize + HandoffQueueSize int + + // PostHandoffRelaxedDuration is how long to keep relaxed timeouts on the new connection + // after a handoff completes. This provides additional resilience during cluster transitions. + // Default: 2 * RelaxedTimeout + PostHandoffRelaxedDuration time.Duration + + // Circuit breaker configuration for endpoint failure handling + // CircuitBreakerFailureThreshold is the number of failures before opening the circuit. + // Default: 5 + CircuitBreakerFailureThreshold int + + // CircuitBreakerResetTimeout is how long to wait before testing if the endpoint recovered. + // Default: 60 seconds + CircuitBreakerResetTimeout time.Duration + + // CircuitBreakerMaxRequests is the maximum number of requests allowed in half-open state. + // Default: 3 + CircuitBreakerMaxRequests int + + // MaxHandoffRetries is the maximum number of times to retry a failed handoff. + // After this many retries, the connection will be removed from the pool. + // Default: 3 + MaxHandoffRetries int +} + +func (c *Config) IsEnabled() bool { + return c != nil && c.Mode != ModeDisabled +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig() *Config { + return &Config{ + Mode: ModeAuto, // Enable by default for Redis Cloud + EndpointType: EndpointTypeAuto, // Auto-detect based on connection + RelaxedTimeout: 10 * time.Second, + HandoffTimeout: 15 * time.Second, + MaxWorkers: 0, // Auto-calculated based on pool size + HandoffQueueSize: 0, // Auto-calculated based on max workers + PostHandoffRelaxedDuration: 0, // Auto-calculated based on relaxed timeout + + // Circuit breaker configuration + CircuitBreakerFailureThreshold: 5, + CircuitBreakerResetTimeout: 60 * time.Second, + CircuitBreakerMaxRequests: 3, + + // Connection Handoff Configuration + MaxHandoffRetries: 3, + } +} + +// Validate checks if the configuration is valid. +func (c *Config) Validate() error { + if c.RelaxedTimeout <= 0 { + return ErrInvalidRelaxedTimeout + } + if c.HandoffTimeout <= 0 { + return ErrInvalidHandoffTimeout + } + // Validate worker configuration + // Allow 0 for auto-calculation, but negative values are invalid + if c.MaxWorkers < 0 { + return ErrInvalidHandoffWorkers + } + // HandoffQueueSize validation - allow 0 for auto-calculation + if c.HandoffQueueSize < 0 { + return ErrInvalidHandoffQueueSize + } + if c.PostHandoffRelaxedDuration < 0 { + return ErrInvalidPostHandoffRelaxedDuration + } + + // Circuit breaker validation + if c.CircuitBreakerFailureThreshold < 1 { + return ErrInvalidCircuitBreakerFailureThreshold + } + if c.CircuitBreakerResetTimeout < 0 { + return ErrInvalidCircuitBreakerResetTimeout + } + if c.CircuitBreakerMaxRequests < 1 { + return ErrInvalidCircuitBreakerMaxRequests + } + + // Validate Mode (maintenance notifications mode) + if !c.Mode.IsValid() { + return ErrInvalidMaintNotifications + } + + // Validate EndpointType + if !c.EndpointType.IsValid() { + return ErrInvalidEndpointType + } + + // Validate configuration fields + if c.MaxHandoffRetries < 1 || c.MaxHandoffRetries > 10 { + return ErrInvalidHandoffRetries + } + + return nil +} + +// ApplyDefaults applies default values to any zero-value fields in the configuration. +// This ensures that partially configured structs get sensible defaults for missing fields. +func (c *Config) ApplyDefaults() *Config { + return c.ApplyDefaultsWithPoolSize(0) +} + +// ApplyDefaultsWithPoolSize applies default values to any zero-value fields in the configuration, +// using the provided pool size to calculate worker defaults. +// This ensures that partially configured structs get sensible defaults for missing fields. +func (c *Config) ApplyDefaultsWithPoolSize(poolSize int) *Config { + return c.ApplyDefaultsWithPoolConfig(poolSize, 0) +} + +// ApplyDefaultsWithPoolConfig applies default values to any zero-value fields in the configuration, +// using the provided pool size and max active connections to calculate worker and queue defaults. +// This ensures that partially configured structs get sensible defaults for missing fields. +func (c *Config) ApplyDefaultsWithPoolConfig(poolSize int, maxActiveConns int) *Config { + if c == nil { + return DefaultConfig().ApplyDefaultsWithPoolSize(poolSize) + } + + defaults := DefaultConfig() + result := &Config{} + + // Apply defaults for enum fields (empty/zero means not set) + result.Mode = defaults.Mode + if c.Mode != "" { + result.Mode = c.Mode + } + + result.EndpointType = defaults.EndpointType + if c.EndpointType != "" { + result.EndpointType = c.EndpointType + } + + // Apply defaults for duration fields (zero means not set) + result.RelaxedTimeout = defaults.RelaxedTimeout + if c.RelaxedTimeout > 0 { + result.RelaxedTimeout = c.RelaxedTimeout + } + + result.HandoffTimeout = defaults.HandoffTimeout + if c.HandoffTimeout > 0 { + result.HandoffTimeout = c.HandoffTimeout + } + + // Copy worker configuration + result.MaxWorkers = c.MaxWorkers + + // Apply worker defaults based on pool size + result.applyWorkerDefaults(poolSize) + + // Apply queue size defaults with new scaling approach + // Default: max(20x workers, PoolSize), capped by maxActiveConns or 5x pool size + workerBasedSize := result.MaxWorkers * 20 + poolBasedSize := poolSize + result.HandoffQueueSize = util.Max(workerBasedSize, poolBasedSize) + if c.HandoffQueueSize > 0 { + // When explicitly set: enforce minimum of 200 + result.HandoffQueueSize = util.Max(200, c.HandoffQueueSize) + } + + // Cap queue size: use maxActiveConns+1 if set, otherwise 5x pool size + var queueCap int + if maxActiveConns > 0 { + queueCap = maxActiveConns + 1 + // Ensure queue cap is at least 2 for very small maxActiveConns + if queueCap < 2 { + queueCap = 2 + } + } else { + queueCap = poolSize * 5 + } + result.HandoffQueueSize = util.Min(result.HandoffQueueSize, queueCap) + + // Ensure minimum queue size of 2 (fallback for very small pools) + if result.HandoffQueueSize < 2 { + result.HandoffQueueSize = 2 + } + + result.PostHandoffRelaxedDuration = result.RelaxedTimeout * 2 + if c.PostHandoffRelaxedDuration > 0 { + result.PostHandoffRelaxedDuration = c.PostHandoffRelaxedDuration + } + + // Apply defaults for configuration fields + result.MaxHandoffRetries = defaults.MaxHandoffRetries + if c.MaxHandoffRetries > 0 { + result.MaxHandoffRetries = c.MaxHandoffRetries + } + + // Circuit breaker configuration + result.CircuitBreakerFailureThreshold = defaults.CircuitBreakerFailureThreshold + if c.CircuitBreakerFailureThreshold > 0 { + result.CircuitBreakerFailureThreshold = c.CircuitBreakerFailureThreshold + } + + result.CircuitBreakerResetTimeout = defaults.CircuitBreakerResetTimeout + if c.CircuitBreakerResetTimeout > 0 { + result.CircuitBreakerResetTimeout = c.CircuitBreakerResetTimeout + } + + result.CircuitBreakerMaxRequests = defaults.CircuitBreakerMaxRequests + if c.CircuitBreakerMaxRequests > 0 { + result.CircuitBreakerMaxRequests = c.CircuitBreakerMaxRequests + } + + if internal.LogLevel.DebugOrAbove() { + internal.Logger.Printf(context.Background(), logs.DebugLoggingEnabled()) + internal.Logger.Printf(context.Background(), logs.ConfigDebug(result)) + } + return result +} + +// Clone creates a deep copy of the configuration. +func (c *Config) Clone() *Config { + if c == nil { + return DefaultConfig() + } + + return &Config{ + Mode: c.Mode, + EndpointType: c.EndpointType, + RelaxedTimeout: c.RelaxedTimeout, + HandoffTimeout: c.HandoffTimeout, + MaxWorkers: c.MaxWorkers, + HandoffQueueSize: c.HandoffQueueSize, + PostHandoffRelaxedDuration: c.PostHandoffRelaxedDuration, + + // Circuit breaker configuration + CircuitBreakerFailureThreshold: c.CircuitBreakerFailureThreshold, + CircuitBreakerResetTimeout: c.CircuitBreakerResetTimeout, + CircuitBreakerMaxRequests: c.CircuitBreakerMaxRequests, + + // Configuration fields + MaxHandoffRetries: c.MaxHandoffRetries, + } +} + +// applyWorkerDefaults calculates and applies worker defaults based on pool size +func (c *Config) applyWorkerDefaults(poolSize int) { + // Calculate defaults based on pool size + if poolSize <= 0 { + poolSize = 10 * runtime.GOMAXPROCS(0) + } + + // When not set: min(poolSize/2, max(10, poolSize/3)) - balanced scaling approach + originalMaxWorkers := c.MaxWorkers + c.MaxWorkers = util.Min(poolSize/2, util.Max(10, poolSize/3)) + if originalMaxWorkers != 0 { + // When explicitly set: max(poolSize/2, set_value) - ensure at least poolSize/2 workers + c.MaxWorkers = util.Max(poolSize/2, originalMaxWorkers) + } + + // Ensure minimum of 1 worker (fallback for very small pools) + if c.MaxWorkers < 1 { + c.MaxWorkers = 1 + } +} + +// DetectEndpointType automatically detects the appropriate endpoint type +// based on the connection address and TLS configuration. +// +// For IP addresses: +// - If TLS is enabled: requests FQDN for proper certificate validation +// - If TLS is disabled: requests IP for better performance +// +// For hostnames: +// - If TLS is enabled: always requests FQDN for proper certificate validation +// - If TLS is disabled: requests IP for better performance +// +// Internal vs External detection: +// - For IPs: uses private IP range detection +// - For hostnames: uses heuristics based on common internal naming patterns +func DetectEndpointType(addr string, tlsEnabled bool) EndpointType { + // Extract host from "host:port" format + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr // Assume no port + } + + // Check if the host is an IP address or hostname + ip := net.ParseIP(host) + isIPAddress := ip != nil + var endpointType EndpointType + + if isIPAddress { + // Address is an IP - determine if it's private or public + isPrivate := ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() + + if tlsEnabled { + // TLS with IP addresses - still prefer FQDN for certificate validation + if isPrivate { + endpointType = EndpointTypeInternalFQDN + } else { + endpointType = EndpointTypeExternalFQDN + } + } else { + // No TLS - can use IP addresses directly + if isPrivate { + endpointType = EndpointTypeInternalIP + } else { + endpointType = EndpointTypeExternalIP + } + } + } else { + // Address is a hostname + isInternalHostname := isInternalHostname(host) + if isInternalHostname { + endpointType = EndpointTypeInternalFQDN + } else { + endpointType = EndpointTypeExternalFQDN + } + } + + return endpointType +} + +// isInternalHostname determines if a hostname appears to be internal/private. +// This is a heuristic based on common naming patterns. +func isInternalHostname(hostname string) bool { + // Convert to lowercase for comparison + hostname = strings.ToLower(hostname) + + // Common internal hostname patterns + internalPatterns := []string{ + "localhost", + ".local", + ".internal", + ".corp", + ".lan", + ".intranet", + ".private", + } + + // Check for exact match or suffix match + for _, pattern := range internalPatterns { + if hostname == pattern || strings.HasSuffix(hostname, pattern) { + return true + } + } + + // Check for RFC 1918 style hostnames (e.g., redis-1, db-server, etc.) + // If hostname doesn't contain dots, it's likely internal + if !strings.Contains(hostname, ".") { + return true + } + + // Default to external for fully qualified domain names + return false +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/errors.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/errors.go new file mode 100644 index 00000000..049656bd --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/errors.go @@ -0,0 +1,76 @@ +package maintnotifications + +import ( + "errors" + + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" +) + +// Configuration errors +var ( + ErrInvalidRelaxedTimeout = errors.New(logs.InvalidRelaxedTimeoutError()) + ErrInvalidHandoffTimeout = errors.New(logs.InvalidHandoffTimeoutError()) + ErrInvalidHandoffWorkers = errors.New(logs.InvalidHandoffWorkersError()) + ErrInvalidHandoffQueueSize = errors.New(logs.InvalidHandoffQueueSizeError()) + ErrInvalidPostHandoffRelaxedDuration = errors.New(logs.InvalidPostHandoffRelaxedDurationError()) + ErrInvalidEndpointType = errors.New(logs.InvalidEndpointTypeError()) + ErrInvalidMaintNotifications = errors.New(logs.InvalidMaintNotificationsError()) + ErrMaxHandoffRetriesReached = errors.New(logs.MaxHandoffRetriesReachedError()) + + // Configuration validation errors + + // ErrInvalidHandoffRetries is returned when the number of handoff retries is invalid + ErrInvalidHandoffRetries = errors.New(logs.InvalidHandoffRetriesError()) +) + +// Integration errors +var ( + // ErrInvalidClient is returned when the client does not support push notifications + ErrInvalidClient = errors.New(logs.InvalidClientError()) +) + +// Handoff errors +var ( + // ErrHandoffQueueFull is returned when the handoff queue is full + ErrHandoffQueueFull = errors.New(logs.HandoffQueueFullError()) +) + +// Notification errors +var ( + // ErrInvalidNotification is returned when a notification is in an invalid format + ErrInvalidNotification = errors.New(logs.InvalidNotificationError()) +) + +// connection handoff errors +var ( + // ErrConnectionMarkedForHandoff is returned when a connection is marked for handoff + // and should not be used until the handoff is complete + ErrConnectionMarkedForHandoff = errors.New(logs.ConnectionMarkedForHandoffErrorMessage) + // ErrConnectionMarkedForHandoffWithState is returned when a connection is marked for handoff + // and should not be used until the handoff is complete + ErrConnectionMarkedForHandoffWithState = errors.New(logs.ConnectionMarkedForHandoffErrorMessage + " with state") + // ErrConnectionInvalidHandoffState is returned when a connection is in an invalid state for handoff + ErrConnectionInvalidHandoffState = errors.New(logs.ConnectionInvalidHandoffStateErrorMessage) +) + +// shutdown errors +var ( + // ErrShutdown is returned when the maintnotifications manager is shutdown + ErrShutdown = errors.New(logs.ShutdownError()) +) + +// circuit breaker errors +var ( + // ErrCircuitBreakerOpen is returned when the circuit breaker is open + ErrCircuitBreakerOpen = errors.New(logs.CircuitBreakerOpenErrorMessage) +) + +// circuit breaker configuration errors +var ( + // ErrInvalidCircuitBreakerFailureThreshold is returned when the circuit breaker failure threshold is invalid + ErrInvalidCircuitBreakerFailureThreshold = errors.New(logs.InvalidCircuitBreakerFailureThresholdError()) + // ErrInvalidCircuitBreakerResetTimeout is returned when the circuit breaker reset timeout is invalid + ErrInvalidCircuitBreakerResetTimeout = errors.New(logs.InvalidCircuitBreakerResetTimeoutError()) + // ErrInvalidCircuitBreakerMaxRequests is returned when the circuit breaker max requests is invalid + ErrInvalidCircuitBreakerMaxRequests = errors.New(logs.InvalidCircuitBreakerMaxRequestsError()) +) diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/example_hooks.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/example_hooks.go new file mode 100644 index 00000000..3a346557 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/example_hooks.go @@ -0,0 +1,101 @@ +package maintnotifications + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" + "github.com/redis/go-redis/v9/internal/pool" + "github.com/redis/go-redis/v9/push" +) + +// contextKey is a custom type for context keys to avoid collisions +type contextKey string + +const ( + startTimeKey contextKey = "maint_notif_start_time" +) + +// MetricsHook collects metrics about notification processing. +type MetricsHook struct { + NotificationCounts map[string]int64 + ProcessingTimes map[string]time.Duration + ErrorCounts map[string]int64 + HandoffCounts int64 // Total handoffs initiated + HandoffSuccesses int64 // Successful handoffs + HandoffFailures int64 // Failed handoffs +} + +// NewMetricsHook creates a new metrics collection hook. +func NewMetricsHook() *MetricsHook { + return &MetricsHook{ + NotificationCounts: make(map[string]int64), + ProcessingTimes: make(map[string]time.Duration), + ErrorCounts: make(map[string]int64), + } +} + +// PreHook records the start time for processing metrics. +func (mh *MetricsHook) PreHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) { + mh.NotificationCounts[notificationType]++ + + // Log connection information if available + if conn, ok := notificationCtx.Conn.(*pool.Conn); ok { + internal.Logger.Printf(ctx, logs.MetricsHookProcessingNotification(notificationType, conn.GetID())) + } + + // Store start time in context for duration calculation + startTime := time.Now() + _ = context.WithValue(ctx, startTimeKey, startTime) // Context not used further + + return notification, true +} + +// PostHook records processing completion and any errors. +func (mh *MetricsHook) PostHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error) { + // Calculate processing duration + if startTime, ok := ctx.Value(startTimeKey).(time.Time); ok { + duration := time.Since(startTime) + mh.ProcessingTimes[notificationType] = duration + } + + // Record errors + if result != nil { + mh.ErrorCounts[notificationType]++ + + // Log error details with connection information + if conn, ok := notificationCtx.Conn.(*pool.Conn); ok { + internal.Logger.Printf(ctx, logs.MetricsHookRecordedError(notificationType, conn.GetID(), result)) + } + } +} + +// GetMetrics returns a summary of collected metrics. +func (mh *MetricsHook) GetMetrics() map[string]interface{} { + return map[string]interface{}{ + "notification_counts": mh.NotificationCounts, + "processing_times": mh.ProcessingTimes, + "error_counts": mh.ErrorCounts, + } +} + +// ExampleCircuitBreakerMonitor demonstrates how to monitor circuit breaker status +func ExampleCircuitBreakerMonitor(poolHook *PoolHook) { + // Get circuit breaker statistics + stats := poolHook.GetCircuitBreakerStats() + + for _, stat := range stats { + fmt.Printf("Circuit Breaker for %s:\n", stat.Endpoint) + fmt.Printf(" State: %s\n", stat.State) + fmt.Printf(" Failures: %d\n", stat.Failures) + fmt.Printf(" Last Failure: %v\n", stat.LastFailureTime) + fmt.Printf(" Last Success: %v\n", stat.LastSuccessTime) + + // Alert if circuit breaker is open + if stat.State.String() == "open" { + fmt.Printf(" ⚠️ ALERT: Circuit breaker is OPEN for %s\n", stat.Endpoint) + } + } +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/handoff_worker.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/handoff_worker.go new file mode 100644 index 00000000..5b60e39b --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/handoff_worker.go @@ -0,0 +1,512 @@ +package maintnotifications + +import ( + "context" + "errors" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" + "github.com/redis/go-redis/v9/internal/pool" +) + +// handoffWorkerManager manages background workers and queue for connection handoffs +type handoffWorkerManager struct { + // Event-driven handoff support + handoffQueue chan HandoffRequest // Queue for handoff requests + shutdown chan struct{} // Shutdown signal + shutdownOnce sync.Once // Ensure clean shutdown + workerWg sync.WaitGroup // Track worker goroutines + + // On-demand worker management + maxWorkers int + activeWorkers atomic.Int32 + workerTimeout time.Duration // How long workers wait for work before exiting + workersScaling atomic.Bool + + // Simple state tracking + pending sync.Map // map[uint64]int64 (connID -> seqID) + + // Configuration for the maintenance notifications + config *Config + + // Pool hook reference for handoff processing + poolHook *PoolHook + + // Circuit breaker manager for endpoint failure handling + circuitBreakerManager *CircuitBreakerManager +} + +// newHandoffWorkerManager creates a new handoff worker manager +func newHandoffWorkerManager(config *Config, poolHook *PoolHook) *handoffWorkerManager { + return &handoffWorkerManager{ + handoffQueue: make(chan HandoffRequest, config.HandoffQueueSize), + shutdown: make(chan struct{}), + maxWorkers: config.MaxWorkers, + activeWorkers: atomic.Int32{}, // Start with no workers - create on demand + workerTimeout: 15 * time.Second, // Workers exit after 15s of inactivity + config: config, + poolHook: poolHook, + circuitBreakerManager: newCircuitBreakerManager(config), + } +} + +// getCurrentWorkers returns the current number of active workers (for testing) +func (hwm *handoffWorkerManager) getCurrentWorkers() int { + return int(hwm.activeWorkers.Load()) +} + +// getPendingMap returns the pending map for testing purposes +func (hwm *handoffWorkerManager) getPendingMap() *sync.Map { + return &hwm.pending +} + +// getMaxWorkers returns the max workers for testing purposes +func (hwm *handoffWorkerManager) getMaxWorkers() int { + return hwm.maxWorkers +} + +// getHandoffQueue returns the handoff queue for testing purposes +func (hwm *handoffWorkerManager) getHandoffQueue() chan HandoffRequest { + return hwm.handoffQueue +} + +// getCircuitBreakerStats returns circuit breaker statistics for monitoring +func (hwm *handoffWorkerManager) getCircuitBreakerStats() []CircuitBreakerStats { + return hwm.circuitBreakerManager.GetAllStats() +} + +// resetCircuitBreakers resets all circuit breakers (useful for testing) +func (hwm *handoffWorkerManager) resetCircuitBreakers() { + hwm.circuitBreakerManager.Reset() +} + +// isHandoffPending returns true if the given connection has a pending handoff +func (hwm *handoffWorkerManager) isHandoffPending(conn *pool.Conn) bool { + _, pending := hwm.pending.Load(conn.GetID()) + return pending +} + +// ensureWorkerAvailable ensures at least one worker is available to process requests +// Creates a new worker if needed and under the max limit +func (hwm *handoffWorkerManager) ensureWorkerAvailable() { + select { + case <-hwm.shutdown: + return + default: + if hwm.workersScaling.CompareAndSwap(false, true) { + defer hwm.workersScaling.Store(false) + // Check if we need a new worker + currentWorkers := hwm.activeWorkers.Load() + workersWas := currentWorkers + for currentWorkers < int32(hwm.maxWorkers) { + hwm.workerWg.Add(1) + go hwm.onDemandWorker() + currentWorkers++ + } + // workersWas is always <= currentWorkers + // currentWorkers will be maxWorkers, but if we have a worker that was closed + // while we were creating new workers, just add the difference between + // the currentWorkers and the number of workers we observed initially (i.e. the number of workers we created) + hwm.activeWorkers.Add(currentWorkers - workersWas) + } + } +} + +// onDemandWorker processes handoff requests and exits when idle +func (hwm *handoffWorkerManager) onDemandWorker() { + defer func() { + // Handle panics to ensure proper cleanup + if r := recover(); r != nil { + internal.Logger.Printf(context.Background(), logs.WorkerPanicRecovered(r)) + } + + // Decrement active worker count when exiting + hwm.activeWorkers.Add(-1) + hwm.workerWg.Done() + }() + + // Create reusable timer to prevent timer leaks + timer := time.NewTimer(hwm.workerTimeout) + defer timer.Stop() + + for { + // Reset timer for next iteration + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(hwm.workerTimeout) + + select { + case <-hwm.shutdown: + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.WorkerExitingDueToShutdown()) + } + return + case <-timer.C: + // Worker has been idle for too long, exit to save resources + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.WorkerExitingDueToInactivityTimeout(hwm.workerTimeout)) + } + return + case request := <-hwm.handoffQueue: + // Check for shutdown before processing + select { + case <-hwm.shutdown: + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.WorkerExitingDueToShutdownWhileProcessing()) + } + // Clean up the request before exiting + hwm.pending.Delete(request.ConnID) + return + default: + // Process the request + hwm.processHandoffRequest(request) + } + } + } +} + +// processHandoffRequest processes a single handoff request +func (hwm *handoffWorkerManager) processHandoffRequest(request HandoffRequest) { + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.HandoffStarted(request.Conn.GetID(), request.Endpoint)) + } + + // Create a context with handoff timeout from config + handoffTimeout := 15 * time.Second // Default timeout + if hwm.config != nil && hwm.config.HandoffTimeout > 0 { + handoffTimeout = hwm.config.HandoffTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), handoffTimeout) + defer cancel() + + // Create a context that also respects the shutdown signal + shutdownCtx, shutdownCancel := context.WithCancel(ctx) + defer shutdownCancel() + + // Monitor shutdown signal in a separate goroutine + go func() { + select { + case <-hwm.shutdown: + shutdownCancel() + case <-shutdownCtx.Done(): + } + }() + + // Perform the handoff with cancellable context + shouldRetry, err := hwm.performConnectionHandoff(shutdownCtx, request.Conn) + minRetryBackoff := 500 * time.Millisecond + if err != nil { + if shouldRetry { + now := time.Now() + deadline, ok := shutdownCtx.Deadline() + thirdOfTimeout := handoffTimeout / 3 + if !ok || deadline.Before(now) { + // wait half the timeout before retrying if no deadline or deadline has passed + deadline = now.Add(thirdOfTimeout) + } + afterTime := deadline.Sub(now) + if afterTime < minRetryBackoff { + afterTime = minRetryBackoff + } + + if internal.LogLevel.InfoOrAbove() { + // Get current retry count for better logging + currentRetries := request.Conn.HandoffRetries() + maxRetries := 3 // Default fallback + if hwm.config != nil { + maxRetries = hwm.config.MaxHandoffRetries + } + internal.Logger.Printf(context.Background(), logs.HandoffFailed(request.ConnID, request.Endpoint, currentRetries, maxRetries, err)) + } + // Schedule retry - keep connection in pending map until retry is queued + time.AfterFunc(afterTime, func() { + if err := hwm.queueHandoff(request.Conn); err != nil { + if internal.LogLevel.WarnOrAbove() { + internal.Logger.Printf(context.Background(), logs.CannotQueueHandoffForRetry(err)) + } + // Failed to queue retry - remove from pending and close connection + hwm.pending.Delete(request.Conn.GetID()) + hwm.closeConnFromRequest(context.Background(), request, err) + } else { + // Successfully queued retry - remove from pending (will be re-added by queueHandoff) + hwm.pending.Delete(request.Conn.GetID()) + } + }) + return + } else { + // Won't retry - remove from pending and close connection + hwm.pending.Delete(request.Conn.GetID()) + go hwm.closeConnFromRequest(ctx, request, err) + } + + // Clear handoff state if not returned for retry + seqID := request.Conn.GetMovingSeqID() + connID := request.Conn.GetID() + if hwm.poolHook.operationsManager != nil { + hwm.poolHook.operationsManager.UntrackOperationWithConnID(seqID, connID) + } + } else { + // Success - remove from pending map + hwm.pending.Delete(request.Conn.GetID()) + } +} + +// queueHandoff queues a handoff request for processing +// if err is returned, connection will be removed from pool +func (hwm *handoffWorkerManager) queueHandoff(conn *pool.Conn) error { + // Get handoff info atomically to prevent race conditions + shouldHandoff, endpoint, seqID := conn.GetHandoffInfo() + + // on retries the connection will not be marked for handoff, but it will have retries > 0 + // if shouldHandoff is false and retries is 0, then we are not retrying and not do a handoff + if !shouldHandoff && conn.HandoffRetries() == 0 { + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.ConnectionNotMarkedForHandoff(conn.GetID())) + } + return errors.New(logs.ConnectionNotMarkedForHandoffError(conn.GetID())) + } + + // Create handoff request with atomically retrieved data + request := HandoffRequest{ + Conn: conn, + ConnID: conn.GetID(), + Endpoint: endpoint, + SeqID: seqID, + Pool: hwm.poolHook.pool, // Include pool for connection removal on failure + } + + select { + // priority to shutdown + case <-hwm.shutdown: + return ErrShutdown + default: + select { + case <-hwm.shutdown: + return ErrShutdown + case hwm.handoffQueue <- request: + // Store in pending map + hwm.pending.Store(request.ConnID, request.SeqID) + // Ensure we have a worker to process this request + hwm.ensureWorkerAvailable() + return nil + default: + select { + case <-hwm.shutdown: + return ErrShutdown + case hwm.handoffQueue <- request: + // Store in pending map + hwm.pending.Store(request.ConnID, request.SeqID) + // Ensure we have a worker to process this request + hwm.ensureWorkerAvailable() + return nil + case <-time.After(100 * time.Millisecond): // give workers a chance to process + // Queue is full - log and attempt scaling + queueLen := len(hwm.handoffQueue) + queueCap := cap(hwm.handoffQueue) + if internal.LogLevel.WarnOrAbove() { + internal.Logger.Printf(context.Background(), logs.HandoffQueueFull(queueLen, queueCap)) + } + } + } + } + + // Ensure we have workers available to handle the load + hwm.ensureWorkerAvailable() + return ErrHandoffQueueFull +} + +// shutdownWorkers gracefully shuts down the worker manager, waiting for workers to complete +func (hwm *handoffWorkerManager) shutdownWorkers(ctx context.Context) error { + hwm.shutdownOnce.Do(func() { + close(hwm.shutdown) + // workers will exit when they finish their current request + + // Shutdown circuit breaker manager cleanup goroutine + if hwm.circuitBreakerManager != nil { + hwm.circuitBreakerManager.Shutdown() + } + }) + + // Wait for workers to complete + done := make(chan struct{}) + go func() { + hwm.workerWg.Wait() + close(done) + }() + + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// performConnectionHandoff performs the actual connection handoff +// When error is returned, the connection handoff should be retried if err is not ErrMaxHandoffRetriesReached +func (hwm *handoffWorkerManager) performConnectionHandoff(ctx context.Context, conn *pool.Conn) (shouldRetry bool, err error) { + // Clear handoff state after successful handoff + connID := conn.GetID() + + newEndpoint := conn.GetHandoffEndpoint() + if newEndpoint == "" { + return false, ErrConnectionInvalidHandoffState + } + + // Use circuit breaker to protect against failing endpoints + circuitBreaker := hwm.circuitBreakerManager.GetCircuitBreaker(newEndpoint) + + // Check if circuit breaker is open before attempting handoff + if circuitBreaker.IsOpen() { + internal.Logger.Printf(ctx, logs.CircuitBreakerOpen(connID, newEndpoint)) + return false, ErrCircuitBreakerOpen // Don't retry when circuit breaker is open + } + + // Perform the handoff + shouldRetry, err = hwm.performHandoffInternal(ctx, conn, newEndpoint, connID) + + // Update circuit breaker based on result + if err != nil { + // Only track dial/network errors in circuit breaker, not initialization errors + if shouldRetry { + circuitBreaker.recordFailure() + } + return shouldRetry, err + } + + // Success - record in circuit breaker + circuitBreaker.recordSuccess() + return false, nil +} + +// performHandoffInternal performs the actual handoff logic (extracted for circuit breaker integration) +func (hwm *handoffWorkerManager) performHandoffInternal( + ctx context.Context, + conn *pool.Conn, + newEndpoint string, + connID uint64, +) (shouldRetry bool, err error) { + retries := conn.IncrementAndGetHandoffRetries(1) + internal.Logger.Printf(ctx, logs.HandoffRetryAttempt(connID, retries, newEndpoint, conn.RemoteAddr().String())) + maxRetries := 3 // Default fallback + if hwm.config != nil { + maxRetries = hwm.config.MaxHandoffRetries + } + + if retries > maxRetries { + if internal.LogLevel.WarnOrAbove() { + internal.Logger.Printf(ctx, logs.ReachedMaxHandoffRetries(connID, newEndpoint, maxRetries)) + } + // won't retry on ErrMaxHandoffRetriesReached + return false, ErrMaxHandoffRetriesReached + } + + // Create endpoint-specific dialer + endpointDialer := hwm.createEndpointDialer(newEndpoint) + + // Create new connection to the new endpoint + newNetConn, err := endpointDialer(ctx) + if err != nil { + internal.Logger.Printf(ctx, logs.FailedToDialNewEndpoint(connID, newEndpoint, err)) + // will retry + // Maybe a network error - retry after a delay + return true, err + } + + // Get the old connection + oldConn := conn.GetNetConn() + + // Apply relaxed timeout to the new connection for the configured post-handoff duration + // This gives the new connection more time to handle operations during cluster transition + // Setting this here (before initing the connection) ensures that the connection is going + // to use the relaxed timeout for the first operation (auth/ACL select) + if hwm.config != nil && hwm.config.PostHandoffRelaxedDuration > 0 { + relaxedTimeout := hwm.config.RelaxedTimeout + // Set relaxed timeout with deadline - no background goroutine needed + deadline := time.Now().Add(hwm.config.PostHandoffRelaxedDuration) + conn.SetRelaxedTimeoutWithDeadline(relaxedTimeout, relaxedTimeout, deadline) + + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(context.Background(), logs.ApplyingRelaxedTimeoutDueToPostHandoff(connID, relaxedTimeout, deadline.Format("15:04:05.000"))) + } + } + + // Replace the connection and execute initialization + err = conn.SetNetConnAndInitConn(ctx, newNetConn) + if err != nil { + // won't retry + // Initialization failed - remove the connection + return false, err + } + defer func() { + if oldConn != nil { + oldConn.Close() + } + }() + + // Clear handoff state will: + // - set the connection as usable again + // - clear the handoff state (shouldHandoff, endpoint, seqID) + // - reset the handoff retries to 0 + // Note: Theoretically there may be a short window where the connection is in the pool + // and IDLE (initConn completed) but still has handoff state set. + conn.ClearHandoffState() + internal.Logger.Printf(ctx, logs.HandoffSucceeded(connID, newEndpoint)) + + // successfully completed the handoff, no retry needed and no error + return false, nil +} + +// createEndpointDialer creates a dialer function that connects to a specific endpoint +func (hwm *handoffWorkerManager) createEndpointDialer(endpoint string) func(context.Context) (net.Conn, error) { + return func(ctx context.Context) (net.Conn, error) { + // Parse endpoint to extract host and port + host, port, err := net.SplitHostPort(endpoint) + if err != nil { + // If no port specified, assume default Redis port + host = endpoint + if port == "" { + port = "6379" + } + } + + // Use the base dialer to connect to the new endpoint + return hwm.poolHook.baseDialer(ctx, hwm.poolHook.network, net.JoinHostPort(host, port)) + } +} + +// closeConnFromRequest closes the connection and logs the reason +func (hwm *handoffWorkerManager) closeConnFromRequest(ctx context.Context, request HandoffRequest, err error) { + pooler := request.Pool + conn := request.Conn + + // Clear handoff state before closing + conn.ClearHandoffState() + + if pooler != nil { + // Use RemoveWithoutTurn instead of Remove to avoid freeing a turn that we don't have. + // The handoff worker doesn't call Get(), so it doesn't have a turn to free. + // Remove() is meant to be called after Get() and frees a turn. + // RemoveWithoutTurn() removes and closes the connection without affecting the queue. + pooler.RemoveWithoutTurn(ctx, conn, err) + if internal.LogLevel.WarnOrAbove() { + internal.Logger.Printf(ctx, logs.RemovingConnectionFromPool(conn.GetID(), err)) + } + } else { + err := conn.Close() // Close the connection if no pool provided + if err != nil { + internal.Logger.Printf(ctx, "redis: failed to close connection: %v", err) + } + if internal.LogLevel.WarnOrAbove() { + internal.Logger.Printf(ctx, logs.NoPoolProvidedCannotRemove(conn.GetID(), err)) + } + } +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/hooks.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/hooks.go new file mode 100644 index 00000000..ee3c3819 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/hooks.go @@ -0,0 +1,60 @@ +package maintnotifications + +import ( + "context" + "slices" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" + "github.com/redis/go-redis/v9/internal/pool" + "github.com/redis/go-redis/v9/push" +) + +// LoggingHook is an example hook implementation that logs all notifications. +type LoggingHook struct { + LogLevel int // 0=Error, 1=Warn, 2=Info, 3=Debug +} + +// PreHook logs the notification before processing and allows modification. +func (lh *LoggingHook) PreHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) { + if lh.LogLevel >= 2 { // Info level + // Log the notification type and content + connID := uint64(0) + if conn, ok := notificationCtx.Conn.(*pool.Conn); ok { + connID = conn.GetID() + } + seqID := int64(0) + if slices.Contains(maintenanceNotificationTypes, notificationType) { + // seqID is the second element in the notification array + if len(notification) > 1 { + if parsedSeqID, ok := notification[1].(int64); !ok { + seqID = 0 + } else { + seqID = parsedSeqID + } + } + + } + internal.Logger.Printf(ctx, logs.ProcessingNotification(connID, seqID, notificationType, notification)) + } + return notification, true // Continue processing with unmodified notification +} + +// PostHook logs the result after processing. +func (lh *LoggingHook) PostHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error) { + connID := uint64(0) + if conn, ok := notificationCtx.Conn.(*pool.Conn); ok { + connID = conn.GetID() + } + if result != nil && lh.LogLevel >= 1 { // Warning level + internal.Logger.Printf(ctx, logs.ProcessingNotificationFailed(connID, notificationType, result, notification)) + } else if lh.LogLevel >= 3 { // Debug level + internal.Logger.Printf(ctx, logs.ProcessingNotificationSucceeded(connID, notificationType)) + } +} + +// NewLoggingHook creates a new logging hook with the specified log level. +// Log levels: 0=Error, 1=Warn, 2=Info, 3=Debug +func NewLoggingHook(logLevel int) *LoggingHook { + return &LoggingHook{LogLevel: logLevel} +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/manager.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/manager.go new file mode 100644 index 00000000..775c163e --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/manager.go @@ -0,0 +1,320 @@ +package maintnotifications + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/interfaces" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" + "github.com/redis/go-redis/v9/internal/pool" + "github.com/redis/go-redis/v9/push" +) + +// Push notification type constants for maintenance +const ( + NotificationMoving = "MOVING" + NotificationMigrating = "MIGRATING" + NotificationMigrated = "MIGRATED" + NotificationFailingOver = "FAILING_OVER" + NotificationFailedOver = "FAILED_OVER" +) + +// maintenanceNotificationTypes contains all notification types that maintenance handles +var maintenanceNotificationTypes = []string{ + NotificationMoving, + NotificationMigrating, + NotificationMigrated, + NotificationFailingOver, + NotificationFailedOver, +} + +// NotificationHook is called before and after notification processing +// PreHook can modify the notification and return false to skip processing +// PostHook is called after successful processing +type NotificationHook interface { + PreHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) + PostHook(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error) +} + +// MovingOperationKey provides a unique key for tracking MOVING operations +// that combines sequence ID with connection identifier to handle duplicate +// sequence IDs across multiple connections to the same node. +type MovingOperationKey struct { + SeqID int64 // Sequence ID from MOVING notification + ConnID uint64 // Unique connection identifier +} + +// String returns a string representation of the key for debugging +func (k MovingOperationKey) String() string { + return fmt.Sprintf("seq:%d-conn:%d", k.SeqID, k.ConnID) +} + +// Manager provides a simplified upgrade functionality with hooks and atomic state. +type Manager struct { + client interfaces.ClientInterface + config *Config + options interfaces.OptionsInterface + pool pool.Pooler + + // MOVING operation tracking - using sync.Map for better concurrent performance + activeMovingOps sync.Map // map[MovingOperationKey]*MovingOperation + + // Atomic state tracking - no locks needed for state queries + activeOperationCount atomic.Int64 // Number of active operations + closed atomic.Bool // Manager closed state + + // Notification hooks for extensibility + hooks []NotificationHook + hooksMu sync.RWMutex // Protects hooks slice + poolHooksRef *PoolHook +} + +// MovingOperation tracks an active MOVING operation. +type MovingOperation struct { + SeqID int64 + NewEndpoint string + StartTime time.Time + Deadline time.Time +} + +// NewManager creates a new simplified manager. +func NewManager(client interfaces.ClientInterface, pool pool.Pooler, config *Config) (*Manager, error) { + if client == nil { + return nil, ErrInvalidClient + } + + hm := &Manager{ + client: client, + pool: pool, + options: client.GetOptions(), + config: config.Clone(), + hooks: make([]NotificationHook, 0), + } + + // Set up push notification handling + if err := hm.setupPushNotifications(); err != nil { + return nil, err + } + + return hm, nil +} + +// GetPoolHook creates a pool hook with a custom dialer. +func (hm *Manager) InitPoolHook(baseDialer func(context.Context, string, string) (net.Conn, error)) { + poolHook := hm.createPoolHook(baseDialer) + hm.pool.AddPoolHook(poolHook) +} + +// setupPushNotifications sets up push notification handling by registering with the client's processor. +func (hm *Manager) setupPushNotifications() error { + processor := hm.client.GetPushProcessor() + if processor == nil { + return ErrInvalidClient // Client doesn't support push notifications + } + + // Create our notification handler + handler := &NotificationHandler{manager: hm, operationsManager: hm} + + // Register handlers for all upgrade notifications with the client's processor + for _, notificationType := range maintenanceNotificationTypes { + if err := processor.RegisterHandler(notificationType, handler, true); err != nil { + return errors.New(logs.FailedToRegisterHandler(notificationType, err)) + } + } + + return nil +} + +// TrackMovingOperationWithConnID starts a new MOVING operation with a specific connection ID. +func (hm *Manager) TrackMovingOperationWithConnID(ctx context.Context, newEndpoint string, deadline time.Time, seqID int64, connID uint64) error { + // Create composite key + key := MovingOperationKey{ + SeqID: seqID, + ConnID: connID, + } + + // Create MOVING operation record + movingOp := &MovingOperation{ + SeqID: seqID, + NewEndpoint: newEndpoint, + StartTime: time.Now(), + Deadline: deadline, + } + + // Use LoadOrStore for atomic check-and-set operation + if _, loaded := hm.activeMovingOps.LoadOrStore(key, movingOp); loaded { + // Duplicate MOVING notification, ignore + if internal.LogLevel.DebugOrAbove() { // Debug level + internal.Logger.Printf(context.Background(), logs.DuplicateMovingOperation(connID, newEndpoint, seqID)) + } + return nil + } + if internal.LogLevel.DebugOrAbove() { // Debug level + internal.Logger.Printf(context.Background(), logs.TrackingMovingOperation(connID, newEndpoint, seqID)) + } + + // Increment active operation count atomically + hm.activeOperationCount.Add(1) + + return nil +} + +// UntrackOperationWithConnID completes a MOVING operation with a specific connection ID. +func (hm *Manager) UntrackOperationWithConnID(seqID int64, connID uint64) { + // Create composite key + key := MovingOperationKey{ + SeqID: seqID, + ConnID: connID, + } + + // Remove from active operations atomically + if _, loaded := hm.activeMovingOps.LoadAndDelete(key); loaded { + if internal.LogLevel.DebugOrAbove() { // Debug level + internal.Logger.Printf(context.Background(), logs.UntrackingMovingOperation(connID, seqID)) + } + // Decrement active operation count only if operation existed + hm.activeOperationCount.Add(-1) + } else { + if internal.LogLevel.DebugOrAbove() { // Debug level + internal.Logger.Printf(context.Background(), logs.OperationNotTracked(connID, seqID)) + } + } +} + +// GetActiveMovingOperations returns active operations with composite keys. +// WARNING: This method creates a new map and copies all operations on every call. +// Use sparingly, especially in hot paths or high-frequency logging. +func (hm *Manager) GetActiveMovingOperations() map[MovingOperationKey]*MovingOperation { + result := make(map[MovingOperationKey]*MovingOperation) + + // Iterate over sync.Map to build result + hm.activeMovingOps.Range(func(key, value interface{}) bool { + k := key.(MovingOperationKey) + op := value.(*MovingOperation) + + // Create a copy to avoid sharing references + result[k] = &MovingOperation{ + SeqID: op.SeqID, + NewEndpoint: op.NewEndpoint, + StartTime: op.StartTime, + Deadline: op.Deadline, + } + return true // Continue iteration + }) + + return result +} + +// IsHandoffInProgress returns true if any handoff is in progress. +// Uses atomic counter for lock-free operation. +func (hm *Manager) IsHandoffInProgress() bool { + return hm.activeOperationCount.Load() > 0 +} + +// GetActiveOperationCount returns the number of active operations. +// Uses atomic counter for lock-free operation. +func (hm *Manager) GetActiveOperationCount() int64 { + return hm.activeOperationCount.Load() +} + +// Close closes the manager. +func (hm *Manager) Close() error { + // Use atomic operation for thread-safe close check + if !hm.closed.CompareAndSwap(false, true) { + return nil // Already closed + } + + // Shutdown the pool hook if it exists + if hm.poolHooksRef != nil { + // Use a timeout to prevent hanging indefinitely + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := hm.poolHooksRef.Shutdown(shutdownCtx) + if err != nil { + // was not able to close pool hook, keep closed state false + hm.closed.Store(false) + return err + } + // Remove the pool hook from the pool + if hm.pool != nil { + hm.pool.RemovePoolHook(hm.poolHooksRef) + } + } + + // Clear all active operations + hm.activeMovingOps.Range(func(key, value interface{}) bool { + hm.activeMovingOps.Delete(key) + return true + }) + + // Reset counter + hm.activeOperationCount.Store(0) + + return nil +} + +// GetState returns current state using atomic counter for lock-free operation. +func (hm *Manager) GetState() State { + if hm.activeOperationCount.Load() > 0 { + return StateMoving + } + return StateIdle +} + +// processPreHooks calls all pre-hooks and returns the modified notification and whether to continue processing. +func (hm *Manager) processPreHooks(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) { + hm.hooksMu.RLock() + defer hm.hooksMu.RUnlock() + + currentNotification := notification + + for _, hook := range hm.hooks { + modifiedNotification, shouldContinue := hook.PreHook(ctx, notificationCtx, notificationType, currentNotification) + if !shouldContinue { + return modifiedNotification, false + } + currentNotification = modifiedNotification + } + + return currentNotification, true +} + +// processPostHooks calls all post-hooks with the processing result. +func (hm *Manager) processPostHooks(ctx context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, result error) { + hm.hooksMu.RLock() + defer hm.hooksMu.RUnlock() + + for _, hook := range hm.hooks { + hook.PostHook(ctx, notificationCtx, notificationType, notification, result) + } +} + +// createPoolHook creates a pool hook with this manager already set. +func (hm *Manager) createPoolHook(baseDialer func(context.Context, string, string) (net.Conn, error)) *PoolHook { + if hm.poolHooksRef != nil { + return hm.poolHooksRef + } + // Get pool size from client options for better worker defaults + poolSize := 0 + if hm.options != nil { + poolSize = hm.options.GetPoolSize() + } + + hm.poolHooksRef = NewPoolHookWithPoolSize(baseDialer, hm.options.GetNetwork(), hm.config, hm, poolSize) + hm.poolHooksRef.SetPool(hm.pool) + + return hm.poolHooksRef +} + +func (hm *Manager) AddNotificationHook(notificationHook NotificationHook) { + hm.hooksMu.Lock() + defer hm.hooksMu.Unlock() + hm.hooks = append(hm.hooks, notificationHook) +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/pool_hook.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/pool_hook.go new file mode 100644 index 00000000..9ea0558b --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/pool_hook.go @@ -0,0 +1,182 @@ +package maintnotifications + +import ( + "context" + "net" + "sync" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" + "github.com/redis/go-redis/v9/internal/pool" +) + +// OperationsManagerInterface defines the interface for completing handoff operations +type OperationsManagerInterface interface { + TrackMovingOperationWithConnID(ctx context.Context, newEndpoint string, deadline time.Time, seqID int64, connID uint64) error + UntrackOperationWithConnID(seqID int64, connID uint64) +} + +// HandoffRequest represents a request to handoff a connection to a new endpoint +type HandoffRequest struct { + Conn *pool.Conn + ConnID uint64 // Unique connection identifier + Endpoint string + SeqID int64 + Pool pool.Pooler // Pool to remove connection from on failure +} + +// PoolHook implements pool.PoolHook for Redis-specific connection handling +// with maintenance notifications support. +type PoolHook struct { + // Base dialer for creating connections to new endpoints during handoffs + // args are network and address + baseDialer func(context.Context, string, string) (net.Conn, error) + + // Network type (e.g., "tcp", "unix") + network string + + // Worker manager for background handoff processing + workerManager *handoffWorkerManager + + // Configuration for the maintenance notifications + config *Config + + // Operations manager interface for operation completion tracking + operationsManager OperationsManagerInterface + + // Pool interface for removing connections on handoff failure + pool pool.Pooler +} + +// NewPoolHook creates a new pool hook +func NewPoolHook(baseDialer func(context.Context, string, string) (net.Conn, error), network string, config *Config, operationsManager OperationsManagerInterface) *PoolHook { + return NewPoolHookWithPoolSize(baseDialer, network, config, operationsManager, 0) +} + +// NewPoolHookWithPoolSize creates a new pool hook with pool size for better worker defaults +func NewPoolHookWithPoolSize(baseDialer func(context.Context, string, string) (net.Conn, error), network string, config *Config, operationsManager OperationsManagerInterface, poolSize int) *PoolHook { + // Apply defaults if config is nil or has zero values + if config == nil { + config = config.ApplyDefaultsWithPoolSize(poolSize) + } + + ph := &PoolHook{ + // baseDialer is used to create connections to new endpoints during handoffs + baseDialer: baseDialer, + network: network, + config: config, + operationsManager: operationsManager, + } + + // Create worker manager + ph.workerManager = newHandoffWorkerManager(config, ph) + + return ph +} + +// SetPool sets the pool interface for removing connections on handoff failure +func (ph *PoolHook) SetPool(pooler pool.Pooler) { + ph.pool = pooler +} + +// GetCurrentWorkers returns the current number of active workers (for testing) +func (ph *PoolHook) GetCurrentWorkers() int { + return ph.workerManager.getCurrentWorkers() +} + +// IsHandoffPending returns true if the given connection has a pending handoff +func (ph *PoolHook) IsHandoffPending(conn *pool.Conn) bool { + return ph.workerManager.isHandoffPending(conn) +} + +// GetPendingMap returns the pending map for testing purposes +func (ph *PoolHook) GetPendingMap() *sync.Map { + return ph.workerManager.getPendingMap() +} + +// GetMaxWorkers returns the max workers for testing purposes +func (ph *PoolHook) GetMaxWorkers() int { + return ph.workerManager.getMaxWorkers() +} + +// GetHandoffQueue returns the handoff queue for testing purposes +func (ph *PoolHook) GetHandoffQueue() chan HandoffRequest { + return ph.workerManager.getHandoffQueue() +} + +// GetCircuitBreakerStats returns circuit breaker statistics for monitoring +func (ph *PoolHook) GetCircuitBreakerStats() []CircuitBreakerStats { + return ph.workerManager.getCircuitBreakerStats() +} + +// ResetCircuitBreakers resets all circuit breakers (useful for testing) +func (ph *PoolHook) ResetCircuitBreakers() { + ph.workerManager.resetCircuitBreakers() +} + +// OnGet is called when a connection is retrieved from the pool +func (ph *PoolHook) OnGet(_ context.Context, conn *pool.Conn, _ bool) (accept bool, err error) { + // Check if connection is marked for handoff + // This prevents using connections that have received MOVING notifications + if conn.ShouldHandoff() { + return false, ErrConnectionMarkedForHandoffWithState + } + + // Check if connection is usable (not in UNUSABLE or CLOSED state) + // This ensures we don't return connections that are currently being handed off or re-authenticated. + if !conn.IsUsable() { + return false, ErrConnectionMarkedForHandoff + } + + return true, nil +} + +// OnPut is called when a connection is returned to the pool +func (ph *PoolHook) OnPut(ctx context.Context, conn *pool.Conn) (shouldPool bool, shouldRemove bool, err error) { + // first check if we should handoff for faster rejection + if !conn.ShouldHandoff() { + // Default behavior (no handoff): pool the connection + return true, false, nil + } + + // check pending handoff to not queue the same connection twice + if ph.workerManager.isHandoffPending(conn) { + // Default behavior (pending handoff): pool the connection + return true, false, nil + } + + if err := ph.workerManager.queueHandoff(conn); err != nil { + // Failed to queue handoff, remove the connection + internal.Logger.Printf(ctx, logs.FailedToQueueHandoff(conn.GetID(), err)) + // Don't pool, remove connection, no error to caller + return false, true, nil + } + + // Check if handoff was already processed by a worker before we can mark it as queued + if !conn.ShouldHandoff() { + // Handoff was already processed - this is normal and the connection should be pooled + return true, false, nil + } + + if err := conn.MarkQueuedForHandoff(); err != nil { + // If marking fails, check if handoff was processed in the meantime + if !conn.ShouldHandoff() { + // Handoff was processed - this is normal, pool the connection + return true, false, nil + } + // Other error - remove the connection + return false, true, nil + } + internal.Logger.Printf(ctx, logs.MarkedForHandoff(conn.GetID())) + return true, false, nil +} + +func (ph *PoolHook) OnRemove(_ context.Context, _ *pool.Conn, _ error) { + // Not used +} + +// Shutdown gracefully shuts down the processor, waiting for workers to complete +func (ph *PoolHook) Shutdown(ctx context.Context) error { + return ph.workerManager.shutdownWorkers(ctx) +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/push_notification_handler.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/push_notification_handler.go new file mode 100644 index 00000000..937b4ae8 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/push_notification_handler.go @@ -0,0 +1,282 @@ +package maintnotifications + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/maintnotifications/logs" + "github.com/redis/go-redis/v9/internal/pool" + "github.com/redis/go-redis/v9/push" +) + +// NotificationHandler handles push notifications for the simplified manager. +type NotificationHandler struct { + manager *Manager + operationsManager OperationsManagerInterface +} + +// HandlePushNotification processes push notifications with hook support. +func (snh *NotificationHandler) HandlePushNotification(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error { + if len(notification) == 0 { + internal.Logger.Printf(ctx, logs.InvalidNotificationFormat(notification)) + return ErrInvalidNotification + } + + notificationType, ok := notification[0].(string) + if !ok { + internal.Logger.Printf(ctx, logs.InvalidNotificationTypeFormat(notification[0])) + return ErrInvalidNotification + } + + // Process pre-hooks - they can modify the notification or skip processing + modifiedNotification, shouldContinue := snh.manager.processPreHooks(ctx, handlerCtx, notificationType, notification) + if !shouldContinue { + return nil // Hooks decided to skip processing + } + + var err error + switch notificationType { + case NotificationMoving: + err = snh.handleMoving(ctx, handlerCtx, modifiedNotification) + case NotificationMigrating: + err = snh.handleMigrating(ctx, handlerCtx, modifiedNotification) + case NotificationMigrated: + err = snh.handleMigrated(ctx, handlerCtx, modifiedNotification) + case NotificationFailingOver: + err = snh.handleFailingOver(ctx, handlerCtx, modifiedNotification) + case NotificationFailedOver: + err = snh.handleFailedOver(ctx, handlerCtx, modifiedNotification) + default: + // Ignore other notification types (e.g., pub/sub messages) + err = nil + } + + // Process post-hooks with the result + snh.manager.processPostHooks(ctx, handlerCtx, notificationType, modifiedNotification, err) + + return err +} + +// handleMoving processes MOVING notifications. +// ["MOVING", seqNum, timeS, endpoint] - per-connection handoff +func (snh *NotificationHandler) handleMoving(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error { + if len(notification) < 3 { + internal.Logger.Printf(ctx, logs.InvalidNotification("MOVING", notification)) + return ErrInvalidNotification + } + seqID, ok := notification[1].(int64) + if !ok { + internal.Logger.Printf(ctx, logs.InvalidSeqIDInMovingNotification(notification[1])) + return ErrInvalidNotification + } + + // Extract timeS + timeS, ok := notification[2].(int64) + if !ok { + internal.Logger.Printf(ctx, logs.InvalidTimeSInMovingNotification(notification[2])) + return ErrInvalidNotification + } + + newEndpoint := "" + if len(notification) > 3 { + // Extract new endpoint + newEndpoint, ok = notification[3].(string) + if !ok { + stringified := fmt.Sprintf("%v", notification[3]) + // this could be which is valid + if notification[3] == nil || stringified == internal.RedisNull { + newEndpoint = "" + } else { + internal.Logger.Printf(ctx, logs.InvalidNewEndpointInMovingNotification(notification[3])) + return ErrInvalidNotification + } + } + } + + // Get the connection that received this notification + conn := handlerCtx.Conn + if conn == nil { + internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("MOVING")) + return ErrInvalidNotification + } + + // Type assert to get the underlying pool connection + var poolConn *pool.Conn + if pc, ok := conn.(*pool.Conn); ok { + poolConn = pc + } else { + internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("MOVING", conn, handlerCtx)) + return ErrInvalidNotification + } + + // If the connection is closed or not pooled, we can ignore the notification + // this connection won't be remembered by the pool and will be garbage collected + // Keep pubsub connections around since they are not pooled but are long-lived + // and should be allowed to handoff (the pubsub instance will reconnect and change + // the underlying *pool.Conn) + if (poolConn.IsClosed() || !poolConn.IsPooled()) && !poolConn.IsPubSub() { + return nil + } + + deadline := time.Now().Add(time.Duration(timeS) * time.Second) + // If newEndpoint is empty, we should schedule a handoff to the current endpoint in timeS/2 seconds + if newEndpoint == "" || newEndpoint == internal.RedisNull { + if internal.LogLevel.DebugOrAbove() { + internal.Logger.Printf(ctx, logs.SchedulingHandoffToCurrentEndpoint(poolConn.GetID(), float64(timeS)/2)) + } + // same as current endpoint + newEndpoint = snh.manager.options.GetAddr() + // delay the handoff for timeS/2 seconds to the same endpoint + // do this in a goroutine to avoid blocking the notification handler + // NOTE: This timer is started while parsing the notification, so the connection is not marked for handoff + // and there should be no possibility of a race condition or double handoff. + time.AfterFunc(time.Duration(timeS/2)*time.Second, func() { + if poolConn == nil || poolConn.IsClosed() { + return + } + if err := snh.markConnForHandoff(poolConn, newEndpoint, seqID, deadline); err != nil { + // Log error but don't fail the goroutine - use background context since original may be cancelled + internal.Logger.Printf(context.Background(), logs.FailedToMarkForHandoff(poolConn.GetID(), err)) + } + }) + return nil + } + + return snh.markConnForHandoff(poolConn, newEndpoint, seqID, deadline) +} + +func (snh *NotificationHandler) markConnForHandoff(conn *pool.Conn, newEndpoint string, seqID int64, deadline time.Time) error { + if err := conn.MarkForHandoff(newEndpoint, seqID); err != nil { + internal.Logger.Printf(context.Background(), logs.FailedToMarkForHandoff(conn.GetID(), err)) + // Connection is already marked for handoff, which is acceptable + // This can happen if multiple MOVING notifications are received for the same connection + return nil + } + // Optionally track in m + if snh.operationsManager != nil { + connID := conn.GetID() + // Track the operation (ignore errors since this is optional) + _ = snh.operationsManager.TrackMovingOperationWithConnID(context.Background(), newEndpoint, deadline, seqID, connID) + } else { + return errors.New(logs.ManagerNotInitialized()) + } + return nil +} + +// handleMigrating processes MIGRATING notifications. +func (snh *NotificationHandler) handleMigrating(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error { + // MIGRATING notifications indicate that a connection is about to be migrated + // Apply relaxed timeouts to the specific connection that received this notification + if len(notification) < 2 { + internal.Logger.Printf(ctx, logs.InvalidNotification("MIGRATING", notification)) + return ErrInvalidNotification + } + + if handlerCtx.Conn == nil { + internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("MIGRATING")) + return ErrInvalidNotification + } + + conn, ok := handlerCtx.Conn.(*pool.Conn) + if !ok { + internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("MIGRATING", handlerCtx.Conn, handlerCtx)) + return ErrInvalidNotification + } + + // Apply relaxed timeout to this specific connection + if internal.LogLevel.InfoOrAbove() { + internal.Logger.Printf(ctx, logs.RelaxedTimeoutDueToNotification(conn.GetID(), "MIGRATING", snh.manager.config.RelaxedTimeout)) + } + conn.SetRelaxedTimeout(snh.manager.config.RelaxedTimeout, snh.manager.config.RelaxedTimeout) + return nil +} + +// handleMigrated processes MIGRATED notifications. +func (snh *NotificationHandler) handleMigrated(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error { + // MIGRATED notifications indicate that a connection migration has completed + // Restore normal timeouts for the specific connection that received this notification + if len(notification) < 2 { + internal.Logger.Printf(ctx, logs.InvalidNotification("MIGRATED", notification)) + return ErrInvalidNotification + } + + if handlerCtx.Conn == nil { + internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("MIGRATED")) + return ErrInvalidNotification + } + + conn, ok := handlerCtx.Conn.(*pool.Conn) + if !ok { + internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("MIGRATED", handlerCtx.Conn, handlerCtx)) + return ErrInvalidNotification + } + + // Clear relaxed timeout for this specific connection + if internal.LogLevel.InfoOrAbove() { + connID := conn.GetID() + internal.Logger.Printf(ctx, logs.UnrelaxedTimeout(connID)) + } + conn.ClearRelaxedTimeout() + return nil +} + +// handleFailingOver processes FAILING_OVER notifications. +func (snh *NotificationHandler) handleFailingOver(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error { + // FAILING_OVER notifications indicate that a connection is about to failover + // Apply relaxed timeouts to the specific connection that received this notification + if len(notification) < 2 { + internal.Logger.Printf(ctx, logs.InvalidNotification("FAILING_OVER", notification)) + return ErrInvalidNotification + } + + if handlerCtx.Conn == nil { + internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("FAILING_OVER")) + return ErrInvalidNotification + } + + conn, ok := handlerCtx.Conn.(*pool.Conn) + if !ok { + internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("FAILING_OVER", handlerCtx.Conn, handlerCtx)) + return ErrInvalidNotification + } + + // Apply relaxed timeout to this specific connection + if internal.LogLevel.InfoOrAbove() { + connID := conn.GetID() + internal.Logger.Printf(ctx, logs.RelaxedTimeoutDueToNotification(connID, "FAILING_OVER", snh.manager.config.RelaxedTimeout)) + } + conn.SetRelaxedTimeout(snh.manager.config.RelaxedTimeout, snh.manager.config.RelaxedTimeout) + return nil +} + +// handleFailedOver processes FAILED_OVER notifications. +func (snh *NotificationHandler) handleFailedOver(ctx context.Context, handlerCtx push.NotificationHandlerContext, notification []interface{}) error { + // FAILED_OVER notifications indicate that a connection failover has completed + // Restore normal timeouts for the specific connection that received this notification + if len(notification) < 2 { + internal.Logger.Printf(ctx, logs.InvalidNotification("FAILED_OVER", notification)) + return ErrInvalidNotification + } + + if handlerCtx.Conn == nil { + internal.Logger.Printf(ctx, logs.NoConnectionInHandlerContext("FAILED_OVER")) + return ErrInvalidNotification + } + + conn, ok := handlerCtx.Conn.(*pool.Conn) + if !ok { + internal.Logger.Printf(ctx, logs.InvalidConnectionTypeInHandlerContext("FAILED_OVER", handlerCtx.Conn, handlerCtx)) + return ErrInvalidNotification + } + + // Clear relaxed timeout for this specific connection + if internal.LogLevel.InfoOrAbove() { + connID := conn.GetID() + internal.Logger.Printf(ctx, logs.UnrelaxedTimeout(connID)) + } + conn.ClearRelaxedTimeout() + return nil +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/state.go b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/state.go new file mode 100644 index 00000000..8180bcd9 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/maintnotifications/state.go @@ -0,0 +1,24 @@ +package maintnotifications + +// State represents the current state of a maintenance operation +type State int + +const ( + // StateIdle indicates no upgrade is in progress + StateIdle State = iota + + // StateHandoff indicates a connection handoff is in progress + StateMoving +) + +// String returns a string representation of the state. +func (s State) String() string { + switch s { + case StateIdle: + return "idle" + case StateMoving: + return "moving" + default: + return "unknown" + } +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/options.go b/backend/vendor/github.com/redis/go-redis/v9/options.go index 53991dc9..9773e86f 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/options.go +++ b/backend/vendor/github.com/redis/go-redis/v9/options.go @@ -13,7 +13,12 @@ import ( "strings" "time" + "github.com/redis/go-redis/v9/auth" "github.com/redis/go-redis/v9/internal/pool" + "github.com/redis/go-redis/v9/internal/proto" + "github.com/redis/go-redis/v9/internal/util" + "github.com/redis/go-redis/v9/maintnotifications" + "github.com/redis/go-redis/v9/push" ) // Limiter is the interface of a rate limiter or a circuit breaker. @@ -29,10 +34,12 @@ type Limiter interface { // Options keeps the settings to set up redis connection. type Options struct { - // The network type, either tcp or unix. - // Default is tcp. + // Network type, either tcp or unix. + // + // default: is tcp. Network string - // host:port address. + + // Addr is the address formated as host:port Addr string // ClientName will execute the `CLIENT SETNAME ClientName` command for each conn. @@ -46,17 +53,21 @@ type Options struct { OnConnect func(ctx context.Context, cn *Conn) error // Protocol 2 or 3. Use the version to negotiate RESP version with redis-server. - // Default is 3. + // + // default: 3. Protocol int - // Use the specified Username to authenticate the current connection + + // Username is used to authenticate the current connection // with one of the connections defined in the ACL list when connecting // to a Redis 6.0 instance, or greater, that is using the Redis ACL system. Username string - // Optional password. Must match the password specified in the - // requirepass server configuration option (if connecting to a Redis 5.0 instance, or lower), + + // Password is an optional password. Must match the password specified in the + // `requirepass` server configuration option (if connecting to a Redis 5.0 instance, or lower), // or the User Password when connecting to a Redis 6.0 instance, or greater, // that is using the Redis ACL system. Password string + // CredentialsProvider allows the username and password to be updated // before reconnecting. It should return the current username and password. CredentialsProvider func() (username string, password string) @@ -67,85 +78,155 @@ type Options struct { // There will be a conflict between them; if CredentialsProviderContext exists, we will ignore CredentialsProvider. CredentialsProviderContext func(ctx context.Context) (username string, password string, err error) - // Database to be selected after connecting to the server. + // StreamingCredentialsProvider is used to retrieve the credentials + // for the connection from an external source. Those credentials may change + // during the connection lifetime. This is useful for managed identity + // scenarios where the credentials are retrieved from an external source. + // + // Currently, this is a placeholder for the future implementation. + StreamingCredentialsProvider auth.StreamingCredentialsProvider + + // DB is the database to be selected after connecting to the server. DB int - // Maximum number of retries before giving up. - // Default is 3 retries; -1 (not 0) disables retries. + // MaxRetries is the maximum number of retries before giving up. + // -1 (not 0) disables retries. + // + // default: 3 retries MaxRetries int - // Minimum backoff between each retry. - // Default is 8 milliseconds; -1 disables backoff. + + // MinRetryBackoff is the minimum backoff between each retry. + // -1 disables backoff. + // + // default: 8 milliseconds MinRetryBackoff time.Duration - // Maximum backoff between each retry. - // Default is 512 milliseconds; -1 disables backoff. + + // MaxRetryBackoff is the maximum backoff between each retry. + // -1 disables backoff. + // default: 512 milliseconds; MaxRetryBackoff time.Duration - // Dial timeout for establishing new connections. - // Default is 5 seconds. + // DialTimeout for establishing new connections. + // + // default: 5 seconds DialTimeout time.Duration - // Timeout for socket reads. If reached, commands will fail + + // DialerRetries is the maximum number of retry attempts when dialing fails. + // + // default: 5 + DialerRetries int + + // DialerRetryTimeout is the backoff duration between retry attempts. + // + // default: 100 milliseconds + DialerRetryTimeout time.Duration + + // ReadTimeout for socket reads. If reached, commands will fail // with a timeout instead of blocking. Supported values: - // - `0` - default timeout (3 seconds). - // - `-1` - no timeout (block indefinitely). - // - `-2` - disables SetReadDeadline calls completely. + // + // - `-1` - no timeout (block indefinitely). + // - `-2` - disables SetReadDeadline calls completely. + // + // default: 3 seconds ReadTimeout time.Duration - // Timeout for socket writes. If reached, commands will fail + + // WriteTimeout for socket writes. If reached, commands will fail // with a timeout instead of blocking. Supported values: - // - `0` - default timeout (3 seconds). - // - `-1` - no timeout (block indefinitely). - // - `-2` - disables SetWriteDeadline calls completely. + // + // - `-1` - no timeout (block indefinitely). + // - `-2` - disables SetWriteDeadline calls completely. + // + // default: 3 seconds WriteTimeout time.Duration + // ContextTimeoutEnabled controls whether the client respects context timeouts and deadlines. // See https://redis.uptrace.dev/guide/go-redis-debugging.html#timeouts ContextTimeoutEnabled bool - // Type of connection pool. - // true for FIFO pool, false for LIFO pool. + // ReadBufferSize is the size of the bufio.Reader buffer for each connection. + // Larger buffers can improve performance for commands that return large responses. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + ReadBufferSize int + + // WriteBufferSize is the size of the bufio.Writer buffer for each connection. + // Larger buffers can improve performance for large pipelines and commands with many arguments. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + WriteBufferSize int + + // PoolFIFO type of connection pool. + // + // - true for FIFO pool + // - false for LIFO pool. + // // Note that FIFO has slightly higher overhead compared to LIFO, // but it helps closing idle connections faster reducing the pool size. + // default: false PoolFIFO bool - // Base number of socket connections. + + // PoolSize is the base number of socket connections. // Default is 10 connections per every available CPU as reported by runtime.GOMAXPROCS. // If there is not enough connections in the pool, new connections will be allocated in excess of PoolSize, // you can limit it through MaxActiveConns + // + // default: 10 * runtime.GOMAXPROCS(0) PoolSize int - // Amount of time client waits for connection if all connections + + // MaxConcurrentDials is the maximum number of concurrent connection creation goroutines. + // If <= 0, defaults to PoolSize. If > PoolSize, it will be capped at PoolSize. + MaxConcurrentDials int + + // PoolTimeout is the amount of time client waits for connection if all connections // are busy before returning an error. - // Default is ReadTimeout + 1 second. + // + // default: ReadTimeout + 1 second PoolTimeout time.Duration - // Minimum number of idle connections which is useful when establishing - // new connection is slow. - // Default is 0. the idle connections are not closed by default. + + // MinIdleConns is the minimum number of idle connections which is useful when establishing + // new connection is slow. The idle connections are not closed by default. + // + // default: 0 MinIdleConns int - // Maximum number of idle connections. - // Default is 0. the idle connections are not closed by default. + + // MaxIdleConns is the maximum number of idle connections. + // The idle connections are not closed by default. + // + // default: 0 MaxIdleConns int - // Maximum number of connections allocated by the pool at a given time. + + // MaxActiveConns is the maximum number of connections allocated by the pool at a given time. // When zero, there is no limit on the number of connections in the pool. + // If the pool is full, the next call to Get() will block until a connection is released. MaxActiveConns int + // ConnMaxIdleTime is the maximum amount of time a connection may be idle. // Should be less than server's timeout. // // Expired connections may be closed lazily before reuse. // If d <= 0, connections are not closed due to a connection's idle time. + // -1 disables idle timeout check. // - // Default is 30 minutes. -1 disables idle timeout check. + // default: 30 minutes ConnMaxIdleTime time.Duration + // ConnMaxLifetime is the maximum amount of time a connection may be reused. // // Expired connections may be closed lazily before reuse. // If <= 0, connections are not closed due to a connection's age. // - // Default is to not close idle connections. + // default: 0 ConnMaxLifetime time.Duration - // TLS Config to use. When set, TLS will be negotiated. + // TLSConfig to use. When set, TLS will be negotiated. TLSConfig *tls.Config // Limiter interface used to implement circuit breaker or rate limiter. Limiter Limiter - // Enables read only queries on slave/follower nodes. + // readOnly enables read only queries on slave/follower nodes. readOnly bool // DisableIndentity - Disable set-lib on connect. @@ -161,10 +242,31 @@ type Options struct { DisableIdentity bool // Add suffix to client name. Default is empty. + // IdentitySuffix - add suffix to client name. IdentitySuffix string // UnstableResp3 enables Unstable mode for Redis Search module with RESP3. + // When unstable mode is enabled, the client will use RESP3 protocol and only be able to use RawResult UnstableResp3 bool + + // Push notifications are always enabled for RESP3 connections (Protocol: 3) + // and are not available for RESP2 connections. No configuration option is needed. + + // PushNotificationProcessor is the processor for handling push notifications. + // If nil, a default processor will be created for RESP3 connections. + PushNotificationProcessor push.NotificationProcessor + + // FailingTimeoutSeconds is the timeout in seconds for marking a cluster node as failing. + // When a node is marked as failing, it will be avoided for this duration. + // Default is 15 seconds. + FailingTimeoutSeconds int + + // MaintNotificationsConfig provides custom configuration for maintnotifications. + // When MaintNotificationsConfig.Mode is not "disabled", the client will handle + // cluster upgrade notifications gracefully and manage connection/pool state + // transitions seamlessly. Requires Protocol: 3 (RESP3) for push notifications. + // If nil, maintnotifications are in "auto" mode and will be enabled if the server supports it. + MaintNotificationsConfig *maintnotifications.Config } func (opt *Options) init() { @@ -178,15 +280,35 @@ func (opt *Options) init() { opt.Network = "tcp" } } + if opt.Protocol < 2 { + opt.Protocol = 3 + } if opt.DialTimeout == 0 { opt.DialTimeout = 5 * time.Second } + if opt.DialerRetries == 0 { + opt.DialerRetries = 5 + } + if opt.DialerRetryTimeout == 0 { + opt.DialerRetryTimeout = 100 * time.Millisecond + } if opt.Dialer == nil { opt.Dialer = NewDialer(opt) } if opt.PoolSize == 0 { opt.PoolSize = 10 * runtime.GOMAXPROCS(0) } + if opt.MaxConcurrentDials <= 0 { + opt.MaxConcurrentDials = opt.PoolSize + } else if opt.MaxConcurrentDials > opt.PoolSize { + opt.MaxConcurrentDials = opt.PoolSize + } + if opt.ReadBufferSize == 0 { + opt.ReadBufferSize = proto.DefaultBufferSize + } + if opt.WriteBufferSize == 0 { + opt.WriteBufferSize = proto.DefaultBufferSize + } switch opt.ReadTimeout { case -2: opt.ReadTimeout = -1 @@ -214,9 +336,10 @@ func (opt *Options) init() { opt.ConnMaxIdleTime = 30 * time.Minute } - if opt.MaxRetries == -1 { + switch opt.MaxRetries { + case -1: opt.MaxRetries = 0 - } else if opt.MaxRetries == 0 { + case 0: opt.MaxRetries = 3 } switch opt.MinRetryBackoff { @@ -231,13 +354,40 @@ func (opt *Options) init() { case 0: opt.MaxRetryBackoff = 512 * time.Millisecond } + + if opt.FailingTimeoutSeconds == 0 { + opt.FailingTimeoutSeconds = 15 + } + + opt.MaintNotificationsConfig = opt.MaintNotificationsConfig.ApplyDefaultsWithPoolConfig(opt.PoolSize, opt.MaxActiveConns) + + // auto-detect endpoint type if not specified + endpointType := opt.MaintNotificationsConfig.EndpointType + if endpointType == "" || endpointType == maintnotifications.EndpointTypeAuto { + // Auto-detect endpoint type if not specified + endpointType = maintnotifications.DetectEndpointType(opt.Addr, opt.TLSConfig != nil) + } + opt.MaintNotificationsConfig.EndpointType = endpointType } func (opt *Options) clone() *Options { clone := *opt + + // Deep clone MaintNotificationsConfig to avoid sharing between clients + if opt.MaintNotificationsConfig != nil { + configClone := *opt.MaintNotificationsConfig + clone.MaintNotificationsConfig = &configClone + } + return &clone } +// NewDialer returns a function that will be used as the default dialer +// when none is specified in Options.Dialer. +func (opt *Options) NewDialer() func(context.Context, string, string) (net.Conn, error) { + return NewDialer(opt) +} + // NewDialer returns a function that will be used as the default dialer // when none is specified in Options.Dialer. func NewDialer(opt *Options) func(context.Context, string, string) (net.Conn, error) { @@ -276,6 +426,7 @@ func NewDialer(opt *Options) func(context.Context, string, string) (net.Conn, er // URL attributes (scheme, host, userinfo, resp.), query parameters using these // names will be treated as unknown parameters // - unknown parameter names will result in an error +// - use "skip_verify=true" to ignore TLS certificate validation // // Examples: // @@ -483,6 +634,7 @@ func setupConnParams(u *url.URL, o *Options) (*Options, error) { o.MinIdleConns = q.int("min_idle_conns") o.MaxIdleConns = q.int("max_idle_conns") o.MaxActiveConns = q.int("max_active_conns") + o.MaxConcurrentDials = q.int("max_concurrent_dials") if q.has("conn_max_idle_time") { o.ConnMaxIdleTime = q.duration("conn_max_idle_time") } else { @@ -496,6 +648,9 @@ func setupConnParams(u *url.URL, o *Options) (*Options, error) { if q.err != nil { return nil, q.err } + if o.TLSConfig != nil && q.has("skip_verify") { + o.TLSConfig.InsecureSkipVerify = q.bool("skip_verify") + } // any parameters left? if r := q.remaining(); len(r) > 0 { @@ -519,18 +674,86 @@ func getUserPassword(u *url.URL) (string, string) { func newConnPool( opt *Options, dialer func(ctx context.Context, network, addr string) (net.Conn, error), -) *pool.ConnPool { +) (*pool.ConnPool, error) { + poolSize, err := util.SafeIntToInt32(opt.PoolSize, "PoolSize") + if err != nil { + return nil, err + } + + minIdleConns, err := util.SafeIntToInt32(opt.MinIdleConns, "MinIdleConns") + if err != nil { + return nil, err + } + + maxIdleConns, err := util.SafeIntToInt32(opt.MaxIdleConns, "MaxIdleConns") + if err != nil { + return nil, err + } + + maxActiveConns, err := util.SafeIntToInt32(opt.MaxActiveConns, "MaxActiveConns") + if err != nil { + return nil, err + } + return pool.NewConnPool(&pool.Options{ Dialer: func(ctx context.Context) (net.Conn, error) { return dialer(ctx, opt.Network, opt.Addr) }, - PoolFIFO: opt.PoolFIFO, - PoolSize: opt.PoolSize, - PoolTimeout: opt.PoolTimeout, - MinIdleConns: opt.MinIdleConns, - MaxIdleConns: opt.MaxIdleConns, - MaxActiveConns: opt.MaxActiveConns, - ConnMaxIdleTime: opt.ConnMaxIdleTime, - ConnMaxLifetime: opt.ConnMaxLifetime, - }) + PoolFIFO: opt.PoolFIFO, + PoolSize: poolSize, + MaxConcurrentDials: opt.MaxConcurrentDials, + PoolTimeout: opt.PoolTimeout, + DialTimeout: opt.DialTimeout, + DialerRetries: opt.DialerRetries, + DialerRetryTimeout: opt.DialerRetryTimeout, + MinIdleConns: minIdleConns, + MaxIdleConns: maxIdleConns, + MaxActiveConns: maxActiveConns, + ConnMaxIdleTime: opt.ConnMaxIdleTime, + ConnMaxLifetime: opt.ConnMaxLifetime, + ReadBufferSize: opt.ReadBufferSize, + WriteBufferSize: opt.WriteBufferSize, + PushNotificationsEnabled: opt.Protocol == 3, + }), nil +} + +func newPubSubPool(opt *Options, dialer func(ctx context.Context, network, addr string) (net.Conn, error), +) (*pool.PubSubPool, error) { + poolSize, err := util.SafeIntToInt32(opt.PoolSize, "PoolSize") + if err != nil { + return nil, err + } + + minIdleConns, err := util.SafeIntToInt32(opt.MinIdleConns, "MinIdleConns") + if err != nil { + return nil, err + } + + maxIdleConns, err := util.SafeIntToInt32(opt.MaxIdleConns, "MaxIdleConns") + if err != nil { + return nil, err + } + + maxActiveConns, err := util.SafeIntToInt32(opt.MaxActiveConns, "MaxActiveConns") + if err != nil { + return nil, err + } + + return pool.NewPubSubPool(&pool.Options{ + PoolFIFO: opt.PoolFIFO, + PoolSize: poolSize, + MaxConcurrentDials: opt.MaxConcurrentDials, + PoolTimeout: opt.PoolTimeout, + DialTimeout: opt.DialTimeout, + DialerRetries: opt.DialerRetries, + DialerRetryTimeout: opt.DialerRetryTimeout, + MinIdleConns: minIdleConns, + MaxIdleConns: maxIdleConns, + MaxActiveConns: maxActiveConns, + ConnMaxIdleTime: opt.ConnMaxIdleTime, + ConnMaxLifetime: opt.ConnMaxLifetime, + ReadBufferSize: 32 * 1024, + WriteBufferSize: 32 * 1024, + PushNotificationsEnabled: opt.Protocol == 3, + }, dialer), nil } diff --git a/backend/vendor/github.com/redis/go-redis/v9/osscluster.go b/backend/vendor/github.com/redis/go-redis/v9/osscluster.go index f5a57684..7925d2c6 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/osscluster.go +++ b/backend/vendor/github.com/redis/go-redis/v9/osscluster.go @@ -14,11 +14,18 @@ import ( "sync/atomic" "time" + "github.com/redis/go-redis/v9/auth" "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/hashtag" "github.com/redis/go-redis/v9/internal/pool" "github.com/redis/go-redis/v9/internal/proto" "github.com/redis/go-redis/v9/internal/rand" + "github.com/redis/go-redis/v9/maintnotifications" + "github.com/redis/go-redis/v9/push" +) + +const ( + minLatencyMeasurementInterval = 10 * time.Second ) var errClusterNoNodes = fmt.Errorf("redis: cluster has no nodes") @@ -33,6 +40,7 @@ type ClusterOptions struct { ClientName string // NewClient creates a cluster node client with provided name and options. + // If NewClient is set by the user, the user is responsible for handling maintnotifications upgrades and push notifications. NewClient func(opt *Options) *Client // The maximum number of retries before giving up. Command is retried @@ -62,12 +70,17 @@ type ClusterOptions struct { OnConnect func(ctx context.Context, cn *Conn) error - Protocol int - Username string - Password string - CredentialsProvider func() (username string, password string) - CredentialsProviderContext func(ctx context.Context) (username string, password string, err error) + Protocol int + Username string + Password string + CredentialsProvider func() (username string, password string) + CredentialsProviderContext func(ctx context.Context) (username string, password string, err error) + StreamingCredentialsProvider auth.StreamingCredentialsProvider + // MaxRetries is the maximum number of retries before giving up. + // For ClusterClient, retries are disabled by default (set to -1), + // because the cluster client handles all kinds of retries internally. + // This is intentional and differs from the standalone Options default. MaxRetries int MinRetryBackoff time.Duration MaxRetryBackoff time.Duration @@ -86,6 +99,20 @@ type ClusterOptions struct { ConnMaxIdleTime time.Duration ConnMaxLifetime time.Duration + // ReadBufferSize is the size of the bufio.Reader buffer for each connection. + // Larger buffers can improve performance for commands that return large responses. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + ReadBufferSize int + + // WriteBufferSize is the size of the bufio.Writer buffer for each connection. + // Larger buffers can improve performance for large pipelines and commands with many arguments. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + WriteBufferSize int + TLSConfig *tls.Config // DisableIndentity - Disable set-lib on connect. @@ -104,12 +131,30 @@ type ClusterOptions struct { // UnstableResp3 enables Unstable mode for Redis Search module with RESP3. UnstableResp3 bool + + // PushNotificationProcessor is the processor for handling push notifications. + // If nil, a default processor will be created for RESP3 connections. + PushNotificationProcessor push.NotificationProcessor + + // FailingTimeoutSeconds is the timeout in seconds for marking a cluster node as failing. + // When a node is marked as failing, it will be avoided for this duration. + // Default is 15 seconds. + FailingTimeoutSeconds int + + // MaintNotificationsConfig provides custom configuration for maintnotifications upgrades. + // When MaintNotificationsConfig.Mode is not "disabled", the client will handle + // cluster upgrade notifications gracefully and manage connection/pool state + // transitions seamlessly. Requires Protocol: 3 (RESP3) for push notifications. + // If nil, maintnotifications upgrades are in "auto" mode and will be enabled if the server supports it. + // The ClusterClient does not directly work with maintnotifications, it is up to the clients in the Nodes map to work with maintnotifications. + MaintNotificationsConfig *maintnotifications.Config } func (opt *ClusterOptions) init() { - if opt.MaxRedirects == -1 { + switch opt.MaxRedirects { + case -1: opt.MaxRedirects = 0 - } else if opt.MaxRedirects == 0 { + case 0: opt.MaxRedirects = 3 } @@ -120,6 +165,12 @@ func (opt *ClusterOptions) init() { if opt.PoolSize == 0 { opt.PoolSize = 5 * runtime.GOMAXPROCS(0) } + if opt.ReadBufferSize == 0 { + opt.ReadBufferSize = proto.DefaultBufferSize + } + if opt.WriteBufferSize == 0 { + opt.WriteBufferSize = proto.DefaultBufferSize + } switch opt.ReadTimeout { case -1: @@ -153,6 +204,10 @@ func (opt *ClusterOptions) init() { if opt.NewClient == nil { opt.NewClient = NewClient } + + if opt.FailingTimeoutSeconds == 0 { + opt.FailingTimeoutSeconds = 15 + } } // ParseClusterURL parses a URL into ClusterOptions that can be used to connect to Redis. @@ -257,6 +312,7 @@ func setupClusterQueryParams(u *url.URL, o *ClusterOptions) (*ClusterOptions, er o.PoolTimeout = q.duration("pool_timeout") o.ConnMaxLifetime = q.duration("conn_max_lifetime") o.ConnMaxIdleTime = q.duration("conn_max_idle_time") + o.FailingTimeoutSeconds = q.int("failing_timeout_seconds") if q.err != nil { return nil, q.err @@ -282,16 +338,24 @@ func setupClusterQueryParams(u *url.URL, o *ClusterOptions) (*ClusterOptions, er } func (opt *ClusterOptions) clientOptions() *Options { + // Clone MaintNotificationsConfig to avoid sharing between cluster node clients + var maintNotificationsConfig *maintnotifications.Config + if opt.MaintNotificationsConfig != nil { + configClone := *opt.MaintNotificationsConfig + maintNotificationsConfig = &configClone + } + return &Options{ ClientName: opt.ClientName, Dialer: opt.Dialer, OnConnect: opt.OnConnect, - Protocol: opt.Protocol, - Username: opt.Username, - Password: opt.Password, - CredentialsProvider: opt.CredentialsProvider, - CredentialsProviderContext: opt.CredentialsProviderContext, + Protocol: opt.Protocol, + Username: opt.Username, + Password: opt.Password, + CredentialsProvider: opt.CredentialsProvider, + CredentialsProviderContext: opt.CredentialsProviderContext, + StreamingCredentialsProvider: opt.StreamingCredentialsProvider, MaxRetries: opt.MaxRetries, MinRetryBackoff: opt.MinRetryBackoff, @@ -302,25 +366,30 @@ func (opt *ClusterOptions) clientOptions() *Options { WriteTimeout: opt.WriteTimeout, ContextTimeoutEnabled: opt.ContextTimeoutEnabled, - PoolFIFO: opt.PoolFIFO, - PoolSize: opt.PoolSize, - PoolTimeout: opt.PoolTimeout, - MinIdleConns: opt.MinIdleConns, - MaxIdleConns: opt.MaxIdleConns, - MaxActiveConns: opt.MaxActiveConns, - ConnMaxIdleTime: opt.ConnMaxIdleTime, - ConnMaxLifetime: opt.ConnMaxLifetime, - DisableIdentity: opt.DisableIdentity, - DisableIndentity: opt.DisableIdentity, - IdentitySuffix: opt.IdentitySuffix, - TLSConfig: opt.TLSConfig, + PoolFIFO: opt.PoolFIFO, + PoolSize: opt.PoolSize, + PoolTimeout: opt.PoolTimeout, + MinIdleConns: opt.MinIdleConns, + MaxIdleConns: opt.MaxIdleConns, + MaxActiveConns: opt.MaxActiveConns, + ConnMaxIdleTime: opt.ConnMaxIdleTime, + ConnMaxLifetime: opt.ConnMaxLifetime, + ReadBufferSize: opt.ReadBufferSize, + WriteBufferSize: opt.WriteBufferSize, + DisableIdentity: opt.DisableIdentity, + DisableIndentity: opt.DisableIdentity, + IdentitySuffix: opt.IdentitySuffix, + FailingTimeoutSeconds: opt.FailingTimeoutSeconds, + TLSConfig: opt.TLSConfig, // If ClusterSlots is populated, then we probably have an artificial // cluster whose nodes are not in clustering mode (otherwise there isn't // much use for ClusterSlots config). This means we cannot execute the // READONLY command against that node -- setting readOnly to false in such // situations in the options below will prevent that from happening. - readOnly: opt.ReadOnly && opt.ClusterSlots == nil, - UnstableResp3: opt.UnstableResp3, + readOnly: opt.ReadOnly && opt.ClusterSlots == nil, + UnstableResp3: opt.UnstableResp3, + MaintNotificationsConfig: maintNotificationsConfig, + PushNotificationProcessor: opt.PushNotificationProcessor, } } @@ -332,6 +401,10 @@ type clusterNode struct { latency uint32 // atomic generation uint32 // atomic failing uint32 // atomic + loaded uint32 // atomic + + // last time the latency measurement was performed for the node, stored in nanoseconds from epoch + lastLatencyMeasurement int64 // atomic } func newClusterNode(clOpt *ClusterOptions, addr string) *clusterNode { @@ -384,6 +457,7 @@ func (n *clusterNode) updateLatency() { latency = float64(dur) / float64(successes) } atomic.StoreUint32(&n.latency, uint32(latency+0.5)) + n.SetLastLatencyMeasurement(time.Now()) } func (n *clusterNode) Latency() time.Duration { @@ -393,10 +467,11 @@ func (n *clusterNode) Latency() time.Duration { func (n *clusterNode) MarkAsFailing() { atomic.StoreUint32(&n.failing, uint32(time.Now().Unix())) + atomic.StoreUint32(&n.loaded, 0) } func (n *clusterNode) Failing() bool { - const timeout = 15 // 15 seconds + timeout := int64(n.Client.opt.FailingTimeoutSeconds) failing := atomic.LoadUint32(&n.failing) if failing == 0 { @@ -413,6 +488,10 @@ func (n *clusterNode) Generation() uint32 { return atomic.LoadUint32(&n.generation) } +func (n *clusterNode) LastLatencyMeasurement() int64 { + return atomic.LoadInt64(&n.lastLatencyMeasurement) +} + func (n *clusterNode) SetGeneration(gen uint32) { for { v := atomic.LoadUint32(&n.generation) @@ -422,6 +501,33 @@ func (n *clusterNode) SetGeneration(gen uint32) { } } +func (n *clusterNode) SetLastLatencyMeasurement(t time.Time) { + for { + v := atomic.LoadInt64(&n.lastLatencyMeasurement) + if t.UnixNano() < v || atomic.CompareAndSwapInt64(&n.lastLatencyMeasurement, v, t.UnixNano()) { + break + } + } +} + +func (n *clusterNode) Loading() bool { + loaded := atomic.LoadUint32(&n.loaded) + if loaded == 1 { + return false + } + + // check if the node is loading + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + err := n.Client.Ping(ctx).Err() + loading := err != nil && isLoadingError(err) + if !loading { + atomic.StoreUint32(&n.loaded, 1) + } + return loading +} + //------------------------------------------------------------------------------ type clusterNodes struct { @@ -434,13 +540,12 @@ type clusterNodes struct { closed bool onNewNode []func(rdb *Client) - _generation uint32 // atomic + generation uint32 // atomic } func newClusterNodes(opt *ClusterOptions) *clusterNodes { return &clusterNodes{ - opt: opt, - + opt: opt, addrs: opt.Addrs, nodes: make(map[string]*clusterNode), } @@ -500,21 +605,21 @@ func (c *clusterNodes) Addrs() ([]string, error) { } func (c *clusterNodes) NextGeneration() uint32 { - return atomic.AddUint32(&c._generation, 1) + return atomic.AddUint32(&c.generation, 1) } // GC removes unused nodes. func (c *clusterNodes) GC(generation uint32) { - //nolint:prealloc var collected []*clusterNode c.mu.Lock() c.activeAddrs = c.activeAddrs[:0] + now := time.Now() for addr, node := range c.nodes { if node.Generation() >= generation { c.activeAddrs = append(c.activeAddrs, addr) - if c.opt.RouteByLatency { + if c.opt.RouteByLatency && node.LastLatencyMeasurement() < now.Add(-minLatencyMeasurementInterval).UnixNano() { go node.updateLatency() } continue @@ -557,23 +662,20 @@ func (c *clusterNodes) GetOrCreate(addr string) (*clusterNode, error) { fn(node.Client) } - c.addrs = appendIfNotExists(c.addrs, addr) + c.addrs = appendIfNotExist(c.addrs, addr) c.nodes[addr] = node return node, nil } func (c *clusterNodes) get(addr string) (*clusterNode, error) { - var node *clusterNode - var err error c.mu.RLock() + defer c.mu.RUnlock() + if c.closed { - err = pool.ErrClosed - } else { - node = c.nodes[addr] + return nil, pool.ErrClosed } - c.mu.RUnlock() - return node, err + return c.nodes[addr], nil } func (c *clusterNodes) All() ([]*clusterNode, error) { @@ -604,8 +706,9 @@ func (c *clusterNodes) Random() (*clusterNode, error) { //------------------------------------------------------------------------------ type clusterSlot struct { - start, end int - nodes []*clusterNode + start int + end int + nodes []*clusterNode } type clusterSlotSlice []*clusterSlot @@ -665,9 +768,9 @@ func newClusterState( nodes = append(nodes, node) if i == 0 { - c.Masters = appendUniqueNode(c.Masters, node) + c.Masters = appendIfNotExist(c.Masters, node) } else { - c.Slaves = appendUniqueNode(c.Slaves, node) + c.Slaves = appendIfNotExist(c.Slaves, node) } } @@ -706,12 +809,25 @@ func replaceLoopbackHost(nodeAddr, originHost string) string { return net.JoinHostPort(originHost, nodePort) } +// isLoopback returns true if the host is a loopback address. +// For IP addresses, it uses net.IP.IsLoopback(). +// For hostnames, it recognizes well-known loopback hostnames like "localhost" +// and Docker-specific loopback patterns like "*.docker.internal". func isLoopback(host string) bool { ip := net.ParseIP(host) - if ip == nil { + if ip != nil { + return ip.IsLoopback() + } + + if strings.ToLower(host) == "localhost" { return true } - return ip.IsLoopback() + + if strings.HasSuffix(strings.ToLower(host), ".docker.internal") { + return true + } + + return false } func (c *clusterState) slotMasterNode(slot int) (*clusterNode, error) { @@ -730,7 +846,8 @@ func (c *clusterState) slotSlaveNode(slot int) (*clusterNode, error) { case 1: return nodes[0], nil case 2: - if slave := nodes[1]; !slave.Failing() { + slave := nodes[1] + if !slave.Failing() && !slave.Loading() { return slave, nil } return nodes[0], nil @@ -739,7 +856,7 @@ func (c *clusterState) slotSlaveNode(slot int) (*clusterNode, error) { for i := 0; i < 10; i++ { n := rand.Intn(len(nodes)-1) + 1 slave = nodes[n] - if !slave.Failing() { + if !slave.Failing() && !slave.Loading() { return slave, nil } } @@ -900,6 +1017,9 @@ type ClusterClient struct { // NewClusterClient returns a Redis Cluster client as described in // http://redis.io/topics/cluster-spec. func NewClusterClient(opt *ClusterOptions) *ClusterClient { + if opt == nil { + panic("redis: NewClusterClient nil options") + } opt.init() c := &ClusterClient{ @@ -940,13 +1060,6 @@ func (c *ClusterClient) Close() error { return c.nodes.Close() } -// Do create a Cmd from the args and processes the cmd. -func (c *ClusterClient) Do(ctx context.Context, args ...interface{}) *Cmd { - cmd := NewCmd(ctx, args...) - _ = c.Process(ctx, cmd) - return cmd -} - func (c *ClusterClient) Process(ctx context.Context, cmd Cmder) error { err := c.processHook(ctx, cmd) cmd.SetErr(err) @@ -954,7 +1067,7 @@ func (c *ClusterClient) Process(ctx context.Context, cmd Cmder) error { } func (c *ClusterClient) process(ctx context.Context, cmd Cmder) error { - slot := c.cmdSlot(ctx, cmd) + slot := c.cmdSlot(cmd, -1) var node *clusterNode var moved bool var ask bool @@ -1229,7 +1342,7 @@ func (c *ClusterClient) loadState(ctx context.Context) (*clusterState, error) { continue } - return newClusterState(c.nodes, slots, node.Client.opt.Addr) + return newClusterState(c.nodes, slots, addr) } /* @@ -1300,9 +1413,13 @@ func (c *ClusterClient) mapCmdsByNode(ctx context.Context, cmdsMap *cmdsMap, cmd return err } + preferredRandomSlot := -1 if c.opt.ReadOnly && c.cmdsAreReadOnly(ctx, cmds) { for _, cmd := range cmds { - slot := c.cmdSlot(ctx, cmd) + slot := c.cmdSlot(cmd, preferredRandomSlot) + if preferredRandomSlot == -1 { + preferredRandomSlot = slot + } node, err := c.slotReadOnlyNode(state, slot) if err != nil { return err @@ -1313,7 +1430,10 @@ func (c *ClusterClient) mapCmdsByNode(ctx context.Context, cmdsMap *cmdsMap, cmd } for _, cmd := range cmds { - slot := c.cmdSlot(ctx, cmd) + slot := c.cmdSlot(cmd, preferredRandomSlot) + if preferredRandomSlot == -1 { + preferredRandomSlot = slot + } node, err := state.slotMasterNode(slot) if err != nil { return err @@ -1339,7 +1459,9 @@ func (c *ClusterClient) processPipelineNode( _ = node.Client.withProcessPipelineHook(ctx, cmds, func(ctx context.Context, cmds []Cmder) error { cn, err := node.Client.getConn(ctx) if err != nil { - node.MarkAsFailing() + if !isContextError(err) { + node.MarkAsFailing() + } _ = c.mapCmdsByNode(ctx, failedCmds, cmds) setCmdsErr(cmds, err) return err @@ -1463,58 +1585,88 @@ func (c *ClusterClient) processTxPipeline(ctx context.Context, cmds []Cmder) err // Trim multi .. exec. cmds = cmds[1 : len(cmds)-1] + if len(cmds) == 0 { + return nil + } + state, err := c.state.Get(ctx) if err != nil { setCmdsErr(cmds, err) return err } - cmdsMap := c.mapCmdsBySlot(ctx, cmds) - for slot, cmds := range cmdsMap { - node, err := state.slotMasterNode(slot) - if err != nil { - setCmdsErr(cmds, err) - continue + keyedCmdsBySlot := c.slottedKeyedCommands(cmds) + slot := -1 + switch len(keyedCmdsBySlot) { + case 0: + slot = hashtag.RandomSlot() + case 1: + for sl := range keyedCmdsBySlot { + slot = sl + break + } + default: + // TxPipeline does not support cross slot transaction. + setCmdsErr(cmds, ErrCrossSlot) + return ErrCrossSlot + } + + node, err := state.slotMasterNode(slot) + if err != nil { + setCmdsErr(cmds, err) + return err + } + + cmdsMap := map[*clusterNode][]Cmder{node: cmds} + for attempt := 0; attempt <= c.opt.MaxRedirects; attempt++ { + if attempt > 0 { + if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil { + setCmdsErr(cmds, err) + return err + } } - cmdsMap := map[*clusterNode][]Cmder{node: cmds} - for attempt := 0; attempt <= c.opt.MaxRedirects; attempt++ { - if attempt > 0 { - if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil { - setCmdsErr(cmds, err) - return err - } - } + failedCmds := newCmdsMap() + var wg sync.WaitGroup - failedCmds := newCmdsMap() - var wg sync.WaitGroup - - for node, cmds := range cmdsMap { - wg.Add(1) - go func(node *clusterNode, cmds []Cmder) { - defer wg.Done() - c.processTxPipelineNode(ctx, node, cmds, failedCmds) - }(node, cmds) - } - - wg.Wait() - if len(failedCmds.m) == 0 { - break - } - cmdsMap = failedCmds.m + for node, cmds := range cmdsMap { + wg.Add(1) + go func(node *clusterNode, cmds []Cmder) { + defer wg.Done() + c.processTxPipelineNode(ctx, node, cmds, failedCmds) + }(node, cmds) } + + wg.Wait() + if len(failedCmds.m) == 0 { + break + } + cmdsMap = failedCmds.m } return cmdsFirstErr(cmds) } -func (c *ClusterClient) mapCmdsBySlot(ctx context.Context, cmds []Cmder) map[int][]Cmder { - cmdsMap := make(map[int][]Cmder) +// slottedKeyedCommands returns a map of slot to commands taking into account +// only commands that have keys. +func (c *ClusterClient) slottedKeyedCommands(cmds []Cmder) map[int][]Cmder { + cmdsSlots := map[int][]Cmder{} + + preferredRandomSlot := -1 for _, cmd := range cmds { - slot := c.cmdSlot(ctx, cmd) - cmdsMap[slot] = append(cmdsMap[slot], cmd) + if cmdFirstKeyPos(cmd) == 0 { + continue + } + + slot := c.cmdSlot(cmd, preferredRandomSlot) + if preferredRandomSlot == -1 { + preferredRandomSlot = slot + } + + cmdsSlots[slot] = append(cmdsSlots[slot], cmd) } - return cmdsMap + + return cmdsSlots } func (c *ClusterClient) processTxPipelineNode( @@ -1558,7 +1710,7 @@ func (c *ClusterClient) processTxPipelineNodeConn( trimmedCmds := cmds[1 : len(cmds)-1] if err := c.txPipelineReadQueued( - ctx, rd, statusCmd, trimmedCmds, failedCmds, + ctx, node, cn, rd, statusCmd, trimmedCmds, failedCmds, ); err != nil { setCmdsErr(cmds, err) @@ -1570,30 +1722,56 @@ func (c *ClusterClient) processTxPipelineNodeConn( return err } - return pipelineReadCmds(rd, trimmedCmds) + return node.Client.pipelineReadCmds(ctx, cn, rd, trimmedCmds) }) } func (c *ClusterClient) txPipelineReadQueued( ctx context.Context, + node *clusterNode, + cn *pool.Conn, rd *proto.Reader, statusCmd *StatusCmd, cmds []Cmder, failedCmds *cmdsMap, ) error { // Parse queued replies. + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := node.Client.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + // Log the error but don't fail the command execution + // Push notification processing errors shouldn't break normal Redis operations + internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err) + } if err := statusCmd.readReply(rd); err != nil { return err } for _, cmd := range cmds { + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := node.Client.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + // Log the error but don't fail the command execution + // Push notification processing errors shouldn't break normal Redis operations + internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err) + } err := statusCmd.readReply(rd) - if err == nil || c.checkMovedErr(ctx, cmd, err, failedCmds) || isRedisError(err) { - continue + if err != nil { + if c.checkMovedErr(ctx, cmd, err, failedCmds) { + // will be processed later + continue + } + cmd.SetErr(err) + if !isRedisError(err) { + return err + } } - return err } + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := node.Client.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + // Log the error but don't fail the command execution + // Push notification processing errors shouldn't break normal Redis operations + internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err) + } // Parse number of replies. line, err := rd.ReadLine() if err != nil { @@ -1699,38 +1877,64 @@ func (c *ClusterClient) Watch(ctx context.Context, fn func(*Tx) error, keys ...s return err } +// maintenance notifications won't work here for now func (c *ClusterClient) pubSub() *PubSub { var node *clusterNode pubsub := &PubSub{ opt: c.opt.clientOptions(), - - newConn: func(ctx context.Context, channels []string) (*pool.Conn, error) { + newConn: func(ctx context.Context, addr string, channels []string) (*pool.Conn, error) { if node != nil { panic("node != nil") } var err error + if len(channels) > 0 { slot := hashtag.Slot(channels[0]) - node, err = c.slotMasterNode(ctx, slot) + + // newConn in PubSub is only used for subscription connections, so it is safe to + // assume that a slave node can always be used when client options specify ReadOnly. + if c.opt.ReadOnly { + state, err := c.state.Get(ctx) + if err != nil { + return nil, err + } + + node, err = c.slotReadOnlyNode(state, slot) + if err != nil { + return nil, err + } + } else { + node, err = c.slotMasterNode(ctx, slot) + if err != nil { + return nil, err + } + } } else { node, err = c.nodes.Random() + if err != nil { + return nil, err + } } - if err != nil { - return nil, err - } - - cn, err := node.Client.newConn(context.TODO()) + cn, err := node.Client.pubSubPool.NewConn(ctx, node.Client.opt.Network, node.Client.opt.Addr, channels) if err != nil { node = nil - return nil, err } - + // will return nil if already initialized + err = node.Client.initConn(ctx, cn) + if err != nil { + _ = cn.Close() + node = nil + return nil, err + } + node.Client.pubSubPool.TrackConn(cn) return cn, nil }, closeConn: func(cn *pool.Conn) error { - err := node.Client.connPool.CloseConn(cn) + // Untrack connection from PubSubPool + node.Client.pubSubPool.UntrackConn(cn) + err := cn.Close() node = nil return err }, @@ -1829,17 +2033,20 @@ func (c *ClusterClient) cmdInfo(ctx context.Context, name string) *CommandInfo { return info } -func (c *ClusterClient) cmdSlot(ctx context.Context, cmd Cmder) int { +func (c *ClusterClient) cmdSlot(cmd Cmder, preferredRandomSlot int) int { args := cmd.Args() - if args[0] == "cluster" && args[1] == "getkeysinslot" { + if args[0] == "cluster" && (args[1] == "getkeysinslot" || args[1] == "countkeysinslot") { return args[2].(int) } - return cmdSlot(cmd, cmdFirstKeyPos(cmd)) + return cmdSlot(cmd, cmdFirstKeyPos(cmd), preferredRandomSlot) } -func cmdSlot(cmd Cmder, pos int) int { +func cmdSlot(cmd Cmder, pos int, preferredRandomSlot int) int { if pos == 0 { + if preferredRandomSlot != -1 { + return preferredRandomSlot + } return hashtag.RandomSlot() } firstKey := cmd.stringArg(pos) @@ -1909,7 +2116,7 @@ func (c *ClusterClient) MasterForKey(ctx context.Context, key string) (*Client, if err != nil { return nil, err } - return node.Client, err + return node.Client, nil } func (c *ClusterClient) context(ctx context.Context) context.Context { @@ -1919,26 +2126,13 @@ func (c *ClusterClient) context(ctx context.Context) context.Context { return context.Background() } -func appendUniqueNode(nodes []*clusterNode, node *clusterNode) []*clusterNode { - for _, n := range nodes { - if n == node { - return nodes +func appendIfNotExist[T comparable](vals []T, newVal T) []T { + for _, v := range vals { + if v == newVal { + return vals } } - return append(nodes, node) -} - -func appendIfNotExists(ss []string, es ...string) []string { -loop: - for _, e := range es { - for _, s := range ss { - if s == e { - continue loop - } - } - ss = append(ss, e) - } - return ss + return append(vals, newVal) } //------------------------------------------------------------------------------ diff --git a/backend/vendor/github.com/redis/go-redis/v9/pipeline.go b/backend/vendor/github.com/redis/go-redis/v9/pipeline.go index 1c114205..567bf121 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/pipeline.go +++ b/backend/vendor/github.com/redis/go-redis/v9/pipeline.go @@ -7,7 +7,7 @@ import ( type pipelineExecer func(context.Context, []Cmder) error -// Pipeliner is an mechanism to realise Redis Pipeline technique. +// Pipeliner is a mechanism to realise Redis Pipeline technique. // // Pipelining is a technique to extremely speed up processing by packing // operations to batches, send them at once to Redis and read a replies in a @@ -23,21 +23,27 @@ type pipelineExecer func(context.Context, []Cmder) error type Pipeliner interface { StatefulCmdable - // Len is to obtain the number of commands in the pipeline that have not yet been executed. + // Len obtains the number of commands in the pipeline that have not yet been executed. Len() int // Do is an API for executing any command. // If a certain Redis command is not yet supported, you can use Do to execute it. Do(ctx context.Context, args ...interface{}) *Cmd - // Process is to put the commands to be executed into the pipeline buffer. + // Process queues the cmd for later execution. Process(ctx context.Context, cmd Cmder) error - // Discard is to discard all commands in the cache that have not yet been executed. + // BatchProcess adds multiple commands to be executed into the pipeline buffer. + BatchProcess(ctx context.Context, cmd ...Cmder) error + + // Discard discards all commands in the pipeline buffer that have not yet been executed. Discard() - // Exec is to send all the commands buffered in the pipeline to the redis-server. + // Exec sends all the commands buffered in the pipeline to the redis server. Exec(ctx context.Context) ([]Cmder, error) + + // Cmds returns the list of queued commands. + Cmds() []Cmder } var _ Pipeliner = (*Pipeline)(nil) @@ -76,7 +82,12 @@ func (c *Pipeline) Do(ctx context.Context, args ...interface{}) *Cmd { // Process queues the cmd for later execution. func (c *Pipeline) Process(ctx context.Context, cmd Cmder) error { - c.cmds = append(c.cmds, cmd) + return c.BatchProcess(ctx, cmd) +} + +// BatchProcess queues multiple cmds for later execution. +func (c *Pipeline) BatchProcess(ctx context.Context, cmd ...Cmder) error { + c.cmds = append(c.cmds, cmd...) return nil } @@ -119,3 +130,7 @@ func (c *Pipeline) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([ func (c *Pipeline) TxPipeline() Pipeliner { return c } + +func (c *Pipeline) Cmds() []Cmder { + return c.cmds +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/probabilistic.go b/backend/vendor/github.com/redis/go-redis/v9/probabilistic.go index 5d5cd1a6..c26e7cad 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/probabilistic.go +++ b/backend/vendor/github.com/redis/go-redis/v9/probabilistic.go @@ -319,37 +319,69 @@ func (cmd *BFInfoCmd) Result() (BFInfo, error) { } func (cmd *BFInfoCmd) readReply(rd *proto.Reader) (err error) { - n, err := rd.ReadMapLen() + result := BFInfo{} + + // Create a mapping from key names to pointers of struct fields + respMapping := map[string]*int64{ + "Capacity": &result.Capacity, + "CAPACITY": &result.Capacity, + "Size": &result.Size, + "SIZE": &result.Size, + "Number of filters": &result.Filters, + "FILTERS": &result.Filters, + "Number of items inserted": &result.ItemsInserted, + "ITEMS": &result.ItemsInserted, + "Expansion rate": &result.ExpansionRate, + "EXPANSION": &result.ExpansionRate, + } + + // Helper function to read and assign a value based on the key + readAndAssignValue := func(key string) error { + fieldPtr, exists := respMapping[key] + if !exists { + return fmt.Errorf("redis: BLOOM.INFO unexpected key %s", key) + } + + // Read the integer and assign to the field via pointer dereferencing + val, err := rd.ReadInt() + if err != nil { + return err + } + *fieldPtr = val + return nil + } + + readType, err := rd.PeekReplyType() if err != nil { return err } - var key string - var result BFInfo - for f := 0; f < n; f++ { - key, err = rd.ReadString() + if len(cmd.args) > 2 && readType == proto.RespArray { + n, err := rd.ReadArrayLen() if err != nil { return err } - - switch key { - case "Capacity": - result.Capacity, err = rd.ReadInt() - case "Size": - result.Size, err = rd.ReadInt() - case "Number of filters": - result.Filters, err = rd.ReadInt() - case "Number of items inserted": - result.ItemsInserted, err = rd.ReadInt() - case "Expansion rate": - result.ExpansionRate, err = rd.ReadInt() - default: - return fmt.Errorf("redis: BLOOM.INFO unexpected key %s", key) + if key, ok := cmd.args[2].(string); ok && n == 1 { + if err := readAndAssignValue(key); err != nil { + return err + } + } else { + return fmt.Errorf("redis: BLOOM.INFO invalid argument key type") } - + } else { + n, err := rd.ReadMapLen() if err != nil { return err } + for i := 0; i < n; i++ { + key, err := rd.ReadString() + if err != nil { + return err + } + if err := readAndAssignValue(key); err != nil { + return err + } + } } cmd.val = result @@ -1084,18 +1116,14 @@ func (c cmdable) TopKListWithCount(ctx context.Context, key string) *MapStringIn // Returns OK on success or an error if the operation could not be completed. // For more information - https://redis.io/commands/tdigest.add/ func (c cmdable) TDigestAdd(ctx context.Context, key string, elements ...float64) *StatusCmd { - args := make([]interface{}, 2, 2+len(elements)) + args := make([]interface{}, 2+len(elements)) args[0] = "TDIGEST.ADD" args[1] = key - // Convert floatSlice to []interface{} - interfaceSlice := make([]interface{}, len(elements)) for i, v := range elements { - interfaceSlice[i] = v + args[2+i] = v } - args = append(args, interfaceSlice...) - cmd := NewStatusCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -1106,18 +1134,14 @@ func (c cmdable) TDigestAdd(ctx context.Context, key string, elements ...float64 // Returns an array of floats representing the values at the specified ranks or an error if the operation could not be completed. // For more information - https://redis.io/commands/tdigest.byrank/ func (c cmdable) TDigestByRank(ctx context.Context, key string, rank ...uint64) *FloatSliceCmd { - args := make([]interface{}, 2, 2+len(rank)) + args := make([]interface{}, 2+len(rank)) args[0] = "TDIGEST.BYRANK" args[1] = key - // Convert uint slice to []interface{} - interfaceSlice := make([]interface{}, len(rank)) - for i, v := range rank { - interfaceSlice[i] = v + for i, r := range rank { + args[2+i] = r } - args = append(args, interfaceSlice...) - cmd := NewFloatSliceCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -1128,18 +1152,14 @@ func (c cmdable) TDigestByRank(ctx context.Context, key string, rank ...uint64) // Returns an array of floats representing the values at the specified ranks or an error if the operation could not be completed. // For more information - https://redis.io/commands/tdigest.byrevrank/ func (c cmdable) TDigestByRevRank(ctx context.Context, key string, rank ...uint64) *FloatSliceCmd { - args := make([]interface{}, 2, 2+len(rank)) + args := make([]interface{}, 2+len(rank)) args[0] = "TDIGEST.BYREVRANK" args[1] = key - // Convert uint slice to []interface{} - interfaceSlice := make([]interface{}, len(rank)) - for i, v := range rank { - interfaceSlice[i] = v + for i, r := range rank { + args[2+i] = r } - args = append(args, interfaceSlice...) - cmd := NewFloatSliceCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -1150,18 +1170,14 @@ func (c cmdable) TDigestByRevRank(ctx context.Context, key string, rank ...uint6 // Returns an array of floats representing the CDF values for each element or an error if the operation could not be completed. // For more information - https://redis.io/commands/tdigest.cdf/ func (c cmdable) TDigestCDF(ctx context.Context, key string, elements ...float64) *FloatSliceCmd { - args := make([]interface{}, 2, 2+len(elements)) + args := make([]interface{}, 2+len(elements)) args[0] = "TDIGEST.CDF" args[1] = key - // Convert floatSlice to []interface{} - interfaceSlice := make([]interface{}, len(elements)) for i, v := range elements { - interfaceSlice[i] = v + args[2+i] = v } - args = append(args, interfaceSlice...) - cmd := NewFloatSliceCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -1344,18 +1360,14 @@ func (c cmdable) TDigestMin(ctx context.Context, key string) *FloatCmd { // Returns an array of floats representing the quantile values for each element or an error if the operation could not be completed. // For more information - https://redis.io/commands/tdigest.quantile/ func (c cmdable) TDigestQuantile(ctx context.Context, key string, elements ...float64) *FloatSliceCmd { - args := make([]interface{}, 2, 2+len(elements)) + args := make([]interface{}, 2+len(elements)) args[0] = "TDIGEST.QUANTILE" args[1] = key - // Convert floatSlice to []interface{} - interfaceSlice := make([]interface{}, len(elements)) for i, v := range elements { - interfaceSlice[i] = v + args[2+i] = v } - args = append(args, interfaceSlice...) - cmd := NewFloatSliceCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -1366,18 +1378,14 @@ func (c cmdable) TDigestQuantile(ctx context.Context, key string, elements ...fl // Returns an array of integers representing the rank values for each element or an error if the operation could not be completed. // For more information - https://redis.io/commands/tdigest.rank/ func (c cmdable) TDigestRank(ctx context.Context, key string, values ...float64) *IntSliceCmd { - args := make([]interface{}, 2, 2+len(values)) + args := make([]interface{}, 2+len(values)) args[0] = "TDIGEST.RANK" args[1] = key - // Convert floatSlice to []interface{} - interfaceSlice := make([]interface{}, len(values)) for i, v := range values { - interfaceSlice[i] = v + args[i+2] = v } - args = append(args, interfaceSlice...) - cmd := NewIntSliceCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -1399,18 +1407,14 @@ func (c cmdable) TDigestReset(ctx context.Context, key string) *StatusCmd { // Returns an array of integers representing the reverse rank values for each element or an error if the operation could not be completed. // For more information - https://redis.io/commands/tdigest.revrank/ func (c cmdable) TDigestRevRank(ctx context.Context, key string, values ...float64) *IntSliceCmd { - args := make([]interface{}, 2, 2+len(values)) + args := make([]interface{}, 2+len(values)) args[0] = "TDIGEST.REVRANK" args[1] = key - // Convert floatSlice to []interface{} - interfaceSlice := make([]interface{}, len(values)) for i, v := range values { - interfaceSlice[i] = v + args[2+i] = v } - args = append(args, interfaceSlice...) - cmd := NewIntSliceCmd(ctx, args...) _ = c(ctx, cmd) return cmd diff --git a/backend/vendor/github.com/redis/go-redis/v9/pubsub.go b/backend/vendor/github.com/redis/go-redis/v9/pubsub.go index 72b18f49..959a5c45 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/pubsub.go +++ b/backend/vendor/github.com/redis/go-redis/v9/pubsub.go @@ -10,6 +10,7 @@ import ( "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/pool" "github.com/redis/go-redis/v9/internal/proto" + "github.com/redis/go-redis/v9/push" ) // PubSub implements Pub/Sub commands as described in @@ -21,7 +22,7 @@ import ( type PubSub struct { opt *Options - newConn func(ctx context.Context, channels []string) (*pool.Conn, error) + newConn func(ctx context.Context, addr string, channels []string) (*pool.Conn, error) closeConn func(*pool.Conn) error mu sync.Mutex @@ -38,6 +39,12 @@ type PubSub struct { chOnce sync.Once msgCh *channel allCh *channel + + // Push notification processor for handling generic push notifications + pushProcessor push.NotificationProcessor + + // Cleanup callback for maintenanceNotifications upgrade tracking + onClose func() } func (c *PubSub) init() { @@ -45,6 +52,9 @@ func (c *PubSub) init() { } func (c *PubSub) String() string { + c.mu.Lock() + defer c.mu.Unlock() + channels := mapKeys(c.channels) channels = append(channels, mapKeys(c.patterns)...) channels = append(channels, mapKeys(c.schannels)...) @@ -66,10 +76,18 @@ func (c *PubSub) conn(ctx context.Context, newChannels []string) (*pool.Conn, er return c.cn, nil } + if c.opt.Addr == "" { + // TODO(maintenanceNotifications): + // this is probably cluster client + // c.newConn will ignore the addr argument + // will be changed when we have maintenanceNotifications upgrades for cluster clients + c.opt.Addr = internal.RedisNull + } + channels := mapKeys(c.channels) channels = append(channels, newChannels...) - cn, err := c.newConn(ctx, channels) + cn, err := c.newConn(ctx, c.opt.Addr, channels) if err != nil { return nil, err } @@ -150,12 +168,31 @@ func (c *PubSub) releaseConn(ctx context.Context, cn *pool.Conn, err error, allo if c.cn != cn { return } + + if !cn.IsUsable() || cn.ShouldHandoff() { + c.reconnect(ctx, fmt.Errorf("pubsub: connection is not usable")) + } + if isBadConn(err, allowTimeout, c.opt.Addr) { c.reconnect(ctx, err) } } func (c *PubSub) reconnect(ctx context.Context, reason error) { + if c.cn != nil && c.cn.ShouldHandoff() { + newEndpoint := c.cn.GetHandoffEndpoint() + // If new endpoint is NULL, use the original address + if newEndpoint == internal.RedisNull { + newEndpoint = c.opt.Addr + } + + if newEndpoint != "" { + // Update the address in the options + oldAddr := c.cn.RemoteAddr().String() + c.opt.Addr = newEndpoint + internal.Logger.Printf(ctx, "pubsub: reconnecting to new endpoint %s (was %s)", newEndpoint, oldAddr) + } + } _ = c.closeTheCn(reason) _, _ = c.conn(ctx, nil) } @@ -164,9 +201,6 @@ func (c *PubSub) closeTheCn(reason error) error { if c.cn == nil { return nil } - if !c.closed { - internal.Logger.Printf(c.getContext(), "redis: discarding bad PubSub connection: %s", reason) - } err := c.closeConn(c.cn) c.cn = nil return err @@ -182,6 +216,11 @@ func (c *PubSub) Close() error { c.closed = true close(c.exit) + // Call cleanup callback if set + if c.onClose != nil { + c.onClose() + } + return c.closeTheCn(pool.ErrClosed) } @@ -426,16 +465,20 @@ func (c *PubSub) ReceiveTimeout(ctx context.Context, timeout time.Duration) (int } // Don't hold the lock to allow subscriptions and pings. - cn, err := c.connWithLock(ctx) if err != nil { return nil, err } - err = cn.WithReader(context.Background(), timeout, func(rd *proto.Reader) error { + err = cn.WithReader(ctx, timeout, func(rd *proto.Reader) error { + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + // Log the error but don't fail the command execution + // Push notification processing errors shouldn't break normal Redis operations + internal.Logger.Printf(ctx, "push: conn[%d] error processing pending notifications before reading reply: %v", cn.GetID(), err) + } return c.cmd.readReply(rd) }) - c.releaseConnWithLock(ctx, cn, err, timeout > 0) if err != nil { @@ -448,6 +491,12 @@ func (c *PubSub) ReceiveTimeout(ctx context.Context, timeout time.Duration) (int // Receive returns a message as a Subscription, Message, Pong or error. // See PubSub example for details. This is low-level API and in most cases // Channel should be used instead. +// Receive returns a message as a Subscription, Message, Pong, or an error. +// See PubSub example for details. This is a low-level API and in most cases +// Channel should be used instead. +// This method blocks until a message is received or an error occurs. +// It may return early with an error if the context is canceled, the connection fails, +// or other internal errors occur. func (c *PubSub) Receive(ctx context.Context) (interface{}, error) { return c.ReceiveTimeout(ctx, 0) } @@ -529,6 +578,27 @@ func (c *PubSub) ChannelWithSubscriptions(opts ...ChannelOption) <-chan interfac return c.allCh.allCh } +func (c *PubSub) processPendingPushNotificationWithReader(ctx context.Context, cn *pool.Conn, rd *proto.Reader) error { + // Only process push notifications for RESP3 connections with a processor + if c.opt.Protocol != 3 || c.pushProcessor == nil { + return nil + } + + // Create handler context with client, connection pool, and connection information + handlerCtx := c.pushNotificationHandlerContext(cn) + return c.pushProcessor.ProcessPendingNotifications(ctx, handlerCtx, rd) +} + +func (c *PubSub) pushNotificationHandlerContext(cn *pool.Conn) push.NotificationHandlerContext { + // PubSub doesn't have a client or connection pool, so we pass nil for those + // PubSub connections are blocking + return push.NotificationHandlerContext{ + PubSub: c, + Conn: cn, + IsBlocking: true, + } +} + type ChannelOption func(c *channel) // WithChannelSize specifies the Go chan size that is used to buffer incoming messages. diff --git a/backend/vendor/github.com/redis/go-redis/v9/push/errors.go b/backend/vendor/github.com/redis/go-redis/v9/push/errors.go new file mode 100644 index 00000000..c10c98aa --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/push/errors.go @@ -0,0 +1,176 @@ +package push + +import ( + "errors" + "fmt" +) + +// Push notification error definitions +// This file contains all error types and messages used by the push notification system + +// Error reason constants +const ( + // HandlerReasons + ReasonHandlerNil = "handler cannot be nil" + ReasonHandlerExists = "cannot overwrite existing handler" + ReasonHandlerProtected = "handler is protected" + + // ProcessorReasons + ReasonPushNotificationsDisabled = "push notifications are disabled" +) + +// ProcessorType represents the type of processor involved in the error +// defined as a custom type for better readability and easier maintenance +type ProcessorType string + +const ( + // ProcessorTypes + ProcessorTypeProcessor = ProcessorType("processor") + ProcessorTypeVoidProcessor = ProcessorType("void_processor") + ProcessorTypeCustom = ProcessorType("custom") +) + +// ProcessorOperation represents the operation being performed by the processor +// defined as a custom type for better readability and easier maintenance +type ProcessorOperation string + +const ( + // ProcessorOperations + ProcessorOperationProcess = ProcessorOperation("process") + ProcessorOperationRegister = ProcessorOperation("register") + ProcessorOperationUnregister = ProcessorOperation("unregister") + ProcessorOperationUnknown = ProcessorOperation("unknown") +) + +// Common error variables for reuse +var ( + // ErrHandlerNil is returned when attempting to register a nil handler + ErrHandlerNil = errors.New(ReasonHandlerNil) +) + +// Registry errors + +// ErrHandlerExists creates an error for when attempting to overwrite an existing handler +func ErrHandlerExists(pushNotificationName string) error { + return NewHandlerError(ProcessorOperationRegister, pushNotificationName, ReasonHandlerExists, nil) +} + +// ErrProtectedHandler creates an error for when attempting to unregister a protected handler +func ErrProtectedHandler(pushNotificationName string) error { + return NewHandlerError(ProcessorOperationUnregister, pushNotificationName, ReasonHandlerProtected, nil) +} + +// VoidProcessor errors + +// ErrVoidProcessorRegister creates an error for when attempting to register a handler on void processor +func ErrVoidProcessorRegister(pushNotificationName string) error { + return NewProcessorError(ProcessorTypeVoidProcessor, ProcessorOperationRegister, pushNotificationName, ReasonPushNotificationsDisabled, nil) +} + +// ErrVoidProcessorUnregister creates an error for when attempting to unregister a handler on void processor +func ErrVoidProcessorUnregister(pushNotificationName string) error { + return NewProcessorError(ProcessorTypeVoidProcessor, ProcessorOperationUnregister, pushNotificationName, ReasonPushNotificationsDisabled, nil) +} + +// Error type definitions for advanced error handling + +// HandlerError represents errors related to handler operations +type HandlerError struct { + Operation ProcessorOperation + PushNotificationName string + Reason string + Err error +} + +func (e *HandlerError) Error() string { + if e.Err != nil { + return fmt.Sprintf("handler %s failed for '%s': %s (%v)", e.Operation, e.PushNotificationName, e.Reason, e.Err) + } + return fmt.Sprintf("handler %s failed for '%s': %s", e.Operation, e.PushNotificationName, e.Reason) +} + +func (e *HandlerError) Unwrap() error { + return e.Err +} + +// NewHandlerError creates a new HandlerError +func NewHandlerError(operation ProcessorOperation, pushNotificationName, reason string, err error) *HandlerError { + return &HandlerError{ + Operation: operation, + PushNotificationName: pushNotificationName, + Reason: reason, + Err: err, + } +} + +// ProcessorError represents errors related to processor operations +type ProcessorError struct { + ProcessorType ProcessorType // "processor", "void_processor" + Operation ProcessorOperation // "process", "register", "unregister" + PushNotificationName string // Name of the push notification involved + Reason string + Err error +} + +func (e *ProcessorError) Error() string { + notifInfo := "" + if e.PushNotificationName != "" { + notifInfo = fmt.Sprintf(" for '%s'", e.PushNotificationName) + } + if e.Err != nil { + return fmt.Sprintf("%s %s failed%s: %s (%v)", e.ProcessorType, e.Operation, notifInfo, e.Reason, e.Err) + } + return fmt.Sprintf("%s %s failed%s: %s", e.ProcessorType, e.Operation, notifInfo, e.Reason) +} + +func (e *ProcessorError) Unwrap() error { + return e.Err +} + +// NewProcessorError creates a new ProcessorError +func NewProcessorError(processorType ProcessorType, operation ProcessorOperation, pushNotificationName, reason string, err error) *ProcessorError { + return &ProcessorError{ + ProcessorType: processorType, + Operation: operation, + PushNotificationName: pushNotificationName, + Reason: reason, + Err: err, + } +} + +// Helper functions for common error scenarios + +// IsHandlerNilError checks if an error is due to a nil handler +func IsHandlerNilError(err error) bool { + return errors.Is(err, ErrHandlerNil) +} + +// IsHandlerExistsError checks if an error is due to attempting to overwrite an existing handler. +// This function works correctly even when the error is wrapped. +func IsHandlerExistsError(err error) bool { + var handlerErr *HandlerError + if errors.As(err, &handlerErr) { + return handlerErr.Operation == ProcessorOperationRegister && handlerErr.Reason == ReasonHandlerExists + } + return false +} + +// IsProtectedHandlerError checks if an error is due to attempting to unregister a protected handler. +// This function works correctly even when the error is wrapped. +func IsProtectedHandlerError(err error) bool { + var handlerErr *HandlerError + if errors.As(err, &handlerErr) { + return handlerErr.Operation == ProcessorOperationUnregister && handlerErr.Reason == ReasonHandlerProtected + } + return false +} + +// IsVoidProcessorError checks if an error is due to void processor operations. +// This function works correctly even when the error is wrapped. +func IsVoidProcessorError(err error) bool { + var procErr *ProcessorError + if errors.As(err, &procErr) { + return procErr.ProcessorType == ProcessorTypeVoidProcessor && procErr.Reason == ReasonPushNotificationsDisabled + } + return false +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/push/handler.go b/backend/vendor/github.com/redis/go-redis/v9/push/handler.go new file mode 100644 index 00000000..815edce3 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/push/handler.go @@ -0,0 +1,14 @@ +package push + +import ( + "context" +) + +// NotificationHandler defines the interface for push notification handlers. +type NotificationHandler interface { + // HandlePushNotification processes a push notification with context information. + // The handlerCtx provides information about the client, connection pool, and connection + // on which the notification was received, allowing handlers to make informed decisions. + // Returns an error if the notification could not be handled. + HandlePushNotification(ctx context.Context, handlerCtx NotificationHandlerContext, notification []interface{}) error +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/push/handler_context.go b/backend/vendor/github.com/redis/go-redis/v9/push/handler_context.go new file mode 100644 index 00000000..c39e186b --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/push/handler_context.go @@ -0,0 +1,44 @@ +package push + +// No imports needed for this file + +// NotificationHandlerContext provides context information about where a push notification was received. +// This struct allows handlers to make informed decisions based on the source of the notification +// with strongly typed access to different client types using concrete types. +type NotificationHandlerContext struct { + // Client is the Redis client instance that received the notification. + // It is interface to both allow for future expansion and to avoid + // circular dependencies. The developer is responsible for type assertion. + // It can be one of the following types: + // - *redis.baseClient + // - *redis.Client + // - *redis.ClusterClient + // - *redis.Conn + Client interface{} + + // ConnPool is the connection pool from which the connection was obtained. + // It is interface to both allow for future expansion and to avoid + // circular dependencies. The developer is responsible for type assertion. + // It can be one of the following types: + // - *pool.ConnPool + // - *pool.SingleConnPool + // - *pool.StickyConnPool + ConnPool interface{} + + // PubSub is the PubSub instance that received the notification. + // It is interface to both allow for future expansion and to avoid + // circular dependencies. The developer is responsible for type assertion. + // It can be one of the following types: + // - *redis.PubSub + PubSub interface{} + + // Conn is the specific connection on which the notification was received. + // It is interface to both allow for future expansion and to avoid + // circular dependencies. The developer is responsible for type assertion. + // It can be one of the following types: + // - *pool.Conn + Conn interface{} + + // IsBlocking indicates if the notification was received on a blocking connection. + IsBlocking bool +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/push/processor.go b/backend/vendor/github.com/redis/go-redis/v9/push/processor.go new file mode 100644 index 00000000..b8112ddc --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/push/processor.go @@ -0,0 +1,203 @@ +package push + +import ( + "context" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/proto" +) + +// NotificationProcessor defines the interface for push notification processors. +type NotificationProcessor interface { + // GetHandler returns the handler for a specific push notification name. + GetHandler(pushNotificationName string) NotificationHandler + // ProcessPendingNotifications checks for and processes any pending push notifications. + // To be used when it is known that there are notifications on the socket. + // It will try to read from the socket and if it is empty - it may block. + ProcessPendingNotifications(ctx context.Context, handlerCtx NotificationHandlerContext, rd *proto.Reader) error + // RegisterHandler registers a handler for a specific push notification name. + RegisterHandler(pushNotificationName string, handler NotificationHandler, protected bool) error + // UnregisterHandler removes a handler for a specific push notification name. + UnregisterHandler(pushNotificationName string) error +} + +// Processor handles push notifications with a registry of handlers +type Processor struct { + registry *Registry +} + +// NewProcessor creates a new push notification processor +func NewProcessor() *Processor { + return &Processor{ + registry: NewRegistry(), + } +} + +// GetHandler returns the handler for a specific push notification name +func (p *Processor) GetHandler(pushNotificationName string) NotificationHandler { + return p.registry.GetHandler(pushNotificationName) +} + +// RegisterHandler registers a handler for a specific push notification name +func (p *Processor) RegisterHandler(pushNotificationName string, handler NotificationHandler, protected bool) error { + return p.registry.RegisterHandler(pushNotificationName, handler, protected) +} + +// UnregisterHandler removes a handler for a specific push notification name +func (p *Processor) UnregisterHandler(pushNotificationName string) error { + return p.registry.UnregisterHandler(pushNotificationName) +} + +// ProcessPendingNotifications checks for and processes any pending push notifications +// This method should be called by the client in WithReader before reading the reply +// It will try to read from the socket and if it is empty - it may block. +func (p *Processor) ProcessPendingNotifications(ctx context.Context, handlerCtx NotificationHandlerContext, rd *proto.Reader) error { + if rd == nil { + return nil + } + + for { + // Check if there's data available to read + replyType, err := rd.PeekReplyType() + if err != nil { + // No more data available or error reading + // if timeout, it will be handled by the caller + break + } + + // Only process push notifications (arrays starting with >) + if replyType != proto.RespPush { + break + } + + // see if we should skip this notification + notificationName, err := rd.PeekPushNotificationName() + if err != nil { + break + } + + if willHandleNotificationInClient(notificationName) { + break + } + + // Read the push notification + reply, err := rd.ReadReply() + if err != nil { + internal.Logger.Printf(ctx, "push: error reading push notification: %v", err) + break + } + + // Convert to slice of interfaces + notification, ok := reply.([]interface{}) + if !ok { + break + } + + // Handle the notification directly + if len(notification) > 0 { + // Extract the notification type (first element) + if notificationType, ok := notification[0].(string); ok { + // Get the handler for this notification type + if handler := p.registry.GetHandler(notificationType); handler != nil { + // Handle the notification + err := handler.HandlePushNotification(ctx, handlerCtx, notification) + if err != nil { + internal.Logger.Printf(ctx, "push: error handling push notification: %v", err) + } + } + } + } + } + + return nil +} + +// VoidProcessor discards all push notifications without processing them +type VoidProcessor struct{} + +// NewVoidProcessor creates a new void push notification processor +func NewVoidProcessor() *VoidProcessor { + return &VoidProcessor{} +} + +// GetHandler returns nil for void processor since it doesn't maintain handlers +func (v *VoidProcessor) GetHandler(_ string) NotificationHandler { + return nil +} + +// RegisterHandler returns an error for void processor since it doesn't maintain handlers +func (v *VoidProcessor) RegisterHandler(pushNotificationName string, _ NotificationHandler, _ bool) error { + return ErrVoidProcessorRegister(pushNotificationName) +} + +// UnregisterHandler returns an error for void processor since it doesn't maintain handlers +func (v *VoidProcessor) UnregisterHandler(pushNotificationName string) error { + return ErrVoidProcessorUnregister(pushNotificationName) +} + +// ProcessPendingNotifications for VoidProcessor does nothing since push notifications +// are only available in RESP3 and this processor is used for RESP2 connections. +// This avoids unnecessary buffer scanning overhead. +// It does however read and discard all push notifications from the buffer to avoid +// them being interpreted as a reply. +// This method should be called by the client in WithReader before reading the reply +// to be sure there are no buffered push notifications. +// It will try to read from the socket and if it is empty - it may block. +func (v *VoidProcessor) ProcessPendingNotifications(_ context.Context, handlerCtx NotificationHandlerContext, rd *proto.Reader) error { + // read and discard all push notifications + if rd == nil { + return nil + } + + for { + // Check if there's data available to read + replyType, err := rd.PeekReplyType() + if err != nil { + // No more data available or error reading + // if timeout, it will be handled by the caller + break + } + + // Only process push notifications (arrays starting with >) + if replyType != proto.RespPush { + break + } + // see if we should skip this notification + notificationName, err := rd.PeekPushNotificationName() + if err != nil { + break + } + + if willHandleNotificationInClient(notificationName) { + break + } + + // Read the push notification + _, err = rd.ReadReply() + if err != nil { + internal.Logger.Printf(context.Background(), "push: error reading push notification: %v", err) + return nil + } + } + return nil +} + +// willHandleNotificationInClient checks if a notification type should be ignored by the push notification +// processor and handled by other specialized systems instead (pub/sub, streams, keyspace, etc.). +func willHandleNotificationInClient(notificationType string) bool { + switch notificationType { + // Pub/Sub notifications - handled by pub/sub system + case "message", // Regular pub/sub message + "pmessage", // Pattern pub/sub message + "subscribe", // Subscription confirmation + "unsubscribe", // Unsubscription confirmation + "psubscribe", // Pattern subscription confirmation + "punsubscribe", // Pattern unsubscription confirmation + "smessage", // Sharded pub/sub message (Redis 7.0+) + "ssubscribe", // Sharded subscription confirmation + "sunsubscribe": // Sharded unsubscription confirmation + return true + default: + return false + } +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/push/push.go b/backend/vendor/github.com/redis/go-redis/v9/push/push.go new file mode 100644 index 00000000..e6adeaa4 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/push/push.go @@ -0,0 +1,7 @@ +// Package push provides push notifications for Redis. +// This is an EXPERIMENTAL API for handling push notifications from Redis. +// It is not yet stable and may change in the future. +// Although this is in a public package, in its current form public use is not advised. +// Pending push notifications should be processed before executing any readReply from the connection +// as per RESP3 specification push notifications can be sent at any time. +package push diff --git a/backend/vendor/github.com/redis/go-redis/v9/push/registry.go b/backend/vendor/github.com/redis/go-redis/v9/push/registry.go new file mode 100644 index 00000000..a265ae92 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/push/registry.go @@ -0,0 +1,61 @@ +package push + +import ( + "sync" +) + +// Registry manages push notification handlers +type Registry struct { + mu sync.RWMutex + handlers map[string]NotificationHandler + protected map[string]bool +} + +// NewRegistry creates a new push notification registry +func NewRegistry() *Registry { + return &Registry{ + handlers: make(map[string]NotificationHandler), + protected: make(map[string]bool), + } +} + +// RegisterHandler registers a handler for a specific push notification name +func (r *Registry) RegisterHandler(pushNotificationName string, handler NotificationHandler, protected bool) error { + if handler == nil { + return ErrHandlerNil + } + + r.mu.Lock() + defer r.mu.Unlock() + + // Check if handler already exists + if _, exists := r.protected[pushNotificationName]; exists { + return ErrHandlerExists(pushNotificationName) + } + + r.handlers[pushNotificationName] = handler + r.protected[pushNotificationName] = protected + return nil +} + +// GetHandler returns the handler for a specific push notification name +func (r *Registry) GetHandler(pushNotificationName string) NotificationHandler { + r.mu.RLock() + defer r.mu.RUnlock() + return r.handlers[pushNotificationName] +} + +// UnregisterHandler removes a handler for a specific push notification name +func (r *Registry) UnregisterHandler(pushNotificationName string) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Check if handler is protected + if protected, exists := r.protected[pushNotificationName]; exists && protected { + return ErrProtectedHandler(pushNotificationName) + } + + delete(r.handlers, pushNotificationName) + delete(r.protected, pushNotificationName) + return nil +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/push_notifications.go b/backend/vendor/github.com/redis/go-redis/v9/push_notifications.go new file mode 100644 index 00000000..572955fe --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/push_notifications.go @@ -0,0 +1,21 @@ +package redis + +import ( + "github.com/redis/go-redis/v9/push" +) + +// NewPushNotificationProcessor creates a new push notification processor +// This processor maintains a registry of handlers and processes push notifications +// It is used for RESP3 connections where push notifications are available +func NewPushNotificationProcessor() push.NotificationProcessor { + return push.NewProcessor() +} + +// NewVoidPushNotificationProcessor creates a new void push notification processor +// This processor does not maintain any handlers and always returns nil for all operations +// It is used for RESP2 connections where push notifications are not available +// It can also be used to disable push notifications for RESP3 connections, where +// it will discard all push notifications without processing them +func NewVoidPushNotificationProcessor() push.NotificationProcessor { + return push.NewVoidProcessor() +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/redis.go b/backend/vendor/github.com/redis/go-redis/v9/redis.go index 73043051..a6a71067 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/redis.go +++ b/backend/vendor/github.com/redis/go-redis/v9/redis.go @@ -9,10 +9,14 @@ import ( "sync/atomic" "time" + "github.com/redis/go-redis/v9/auth" "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/auth/streaming" "github.com/redis/go-redis/v9/internal/hscan" "github.com/redis/go-redis/v9/internal/pool" "github.com/redis/go-redis/v9/internal/proto" + "github.com/redis/go-redis/v9/maintnotifications" + "github.com/redis/go-redis/v9/push" ) // Scanner internal/hscan.Scanner exposed interface. @@ -22,10 +26,16 @@ type Scanner = hscan.Scanner const Nil = proto.Nil // SetLogger set custom log +// Use with VoidLogger to disable logging. func SetLogger(logger internal.Logging) { internal.Logger = logger } +// SetLogLevel sets the log level for the library. +func SetLogLevel(logLevel internal.LogLevelT) { + internal.LogLevel = logLevel +} + //------------------------------------------------------------------------------ type Hook interface { @@ -201,15 +211,39 @@ func (hs *hooksMixin) processTxPipelineHook(ctx context.Context, cmds []Cmder) e //------------------------------------------------------------------------------ type baseClient struct { - opt *Options - connPool pool.Pooler + opt *Options + optLock sync.RWMutex + connPool pool.Pooler + pubSubPool *pool.PubSubPool + hooksMixin onClose func() error // hook called when client is closed + + // Push notification processing + pushProcessor push.NotificationProcessor + + // Maintenance notifications manager + maintNotificationsManager *maintnotifications.Manager + maintNotificationsManagerLock sync.RWMutex + + // streamingCredentialsManager is used to manage streaming credentials + streamingCredentialsManager *streaming.Manager } func (c *baseClient) clone() *baseClient { - clone := *c - return &clone + c.maintNotificationsManagerLock.RLock() + maintNotificationsManager := c.maintNotificationsManager + c.maintNotificationsManagerLock.RUnlock() + + clone := &baseClient{ + opt: c.opt, + connPool: c.connPool, + onClose: c.onClose, + pushProcessor: c.pushProcessor, + maintNotificationsManager: maintNotificationsManager, + streamingCredentialsManager: c.streamingCredentialsManager, + } + return clone } func (c *baseClient) withTimeout(timeout time.Duration) *baseClient { @@ -227,21 +261,6 @@ func (c *baseClient) String() string { return fmt.Sprintf("Redis<%s db:%d>", c.getAddr(), c.opt.DB) } -func (c *baseClient) newConn(ctx context.Context) (*pool.Conn, error) { - cn, err := c.connPool.NewConn(ctx) - if err != nil { - return nil, err - } - - err = c.initConn(ctx, cn) - if err != nil { - _ = c.connPool.CloseConn(cn) - return nil, err - } - - return cn, nil -} - func (c *baseClient) getConn(ctx context.Context) (*pool.Conn, error) { if c.opt.Limiter != nil { err := c.opt.Limiter.Allow() @@ -267,7 +286,7 @@ func (c *baseClient) _getConn(ctx context.Context) (*pool.Conn, error) { return nil, err } - if cn.Inited { + if cn.IsInited() { return cn, nil } @@ -279,40 +298,199 @@ func (c *baseClient) _getConn(ctx context.Context) (*pool.Conn, error) { return nil, err } + // initConn will transition to IDLE state, so we need to acquire it + // before returning it to the user. + if !cn.TryAcquire() { + return nil, fmt.Errorf("redis: connection is not usable") + } + return cn, nil } +func (c *baseClient) reAuthConnection() func(poolCn *pool.Conn, credentials auth.Credentials) error { + return func(poolCn *pool.Conn, credentials auth.Credentials) error { + var err error + username, password := credentials.BasicAuth() + + // Use background context - timeout is handled by ReadTimeout in WithReader/WithWriter + ctx := context.Background() + + connPool := pool.NewSingleConnPool(c.connPool, poolCn) + + // Pass hooks so that reauth commands are recorded/traced + cn := newConn(c.opt, connPool, &c.hooksMixin) + + if username != "" { + err = cn.AuthACL(ctx, username, password).Err() + } else { + err = cn.Auth(ctx, password).Err() + } + + return err + } +} +func (c *baseClient) onAuthenticationErr() func(poolCn *pool.Conn, err error) { + return func(poolCn *pool.Conn, err error) { + if err != nil { + if isBadConn(err, false, c.opt.Addr) { + // Close the connection to force a reconnection. + err := c.connPool.CloseConn(poolCn) + if err != nil { + internal.Logger.Printf(context.Background(), "redis: failed to close connection: %v", err) + // try to close the network connection directly + // so that no resource is leaked + err := poolCn.Close() + if err != nil { + internal.Logger.Printf(context.Background(), "redis: failed to close network connection: %v", err) + } + } + } + internal.Logger.Printf(context.Background(), "redis: re-authentication failed: %v", err) + } + } +} + +func (c *baseClient) wrappedOnClose(newOnClose func() error) func() error { + onClose := c.onClose + return func() error { + var firstErr error + err := newOnClose() + // Even if we have an error we would like to execute the onClose hook + // if it exists. We will return the first error that occurred. + // This is to keep error handling consistent with the rest of the code. + if err != nil { + firstErr = err + } + if onClose != nil { + err = onClose() + if err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr + } +} + func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { - if cn.Inited { + // This function is called in two scenarios: + // 1. First-time init: Connection is in CREATED state (from pool.Get()) + // - We need to transition CREATED → INITIALIZING and do the initialization + // - If another goroutine is already initializing, we WAIT for it to finish + // 2. Re-initialization: Connection is in INITIALIZING state (from SetNetConnAndInitConn()) + // - We're already in INITIALIZING, so just proceed with initialization + + currentState := cn.GetStateMachine().GetState() + + // Fast path: Check if already initialized (IDLE or IN_USE) + if currentState == pool.StateIdle || currentState == pool.StateInUse { return nil } - cn.Inited = true - var err error - username, password := c.opt.Username, c.opt.Password - if c.opt.CredentialsProviderContext != nil { - if username, password, err = c.opt.CredentialsProviderContext(ctx); err != nil { + // If in CREATED state, try to transition to INITIALIZING + if currentState == pool.StateCreated { + finalState, err := cn.GetStateMachine().TryTransition([]pool.ConnState{pool.StateCreated}, pool.StateInitializing) + if err != nil { + // Another goroutine is initializing or connection is in unexpected state + // Check what state we're in now + if finalState == pool.StateIdle || finalState == pool.StateInUse { + // Already initialized by another goroutine + return nil + } + + if finalState == pool.StateInitializing { + // Another goroutine is initializing - WAIT for it to complete + // Use a context with timeout = min(remaining command timeout, DialTimeout) + // This prevents waiting too long while respecting the caller's deadline + var waitCtx context.Context + var cancel context.CancelFunc + dialTimeout := c.opt.DialTimeout + + if cmdDeadline, hasCmdDeadline := ctx.Deadline(); hasCmdDeadline { + // Calculate remaining time until command deadline + remainingTime := time.Until(cmdDeadline) + // Use the minimum of remaining time and DialTimeout + if remainingTime < dialTimeout { + // Command deadline is sooner, use it + waitCtx = ctx + } else { + // DialTimeout is shorter, cap the wait at DialTimeout + waitCtx, cancel = context.WithTimeout(ctx, dialTimeout) + } + } else { + // No command deadline, use DialTimeout to prevent waiting indefinitely + waitCtx, cancel = context.WithTimeout(ctx, dialTimeout) + } + if cancel != nil { + defer cancel() + } + + finalState, err := cn.GetStateMachine().AwaitAndTransition( + waitCtx, + []pool.ConnState{pool.StateIdle, pool.StateInUse}, + pool.StateIdle, // Target is IDLE (but we're already there, so this is a no-op) + ) + if err != nil { + return err + } + // Verify we're now initialized + if finalState == pool.StateIdle || finalState == pool.StateInUse { + return nil + } + // Unexpected state after waiting + return fmt.Errorf("connection in unexpected state after initialization: %s", finalState) + } + + // Unexpected state (CLOSED, UNUSABLE, etc.) return err } + } + + // At this point, we're in INITIALIZING state and we own the initialization + // If we fail, we must transition to CLOSED + var initErr error + connPool := pool.NewSingleConnPool(c.connPool, cn) + conn := newConn(c.opt, connPool, &c.hooksMixin) + + username, password := "", "" + if c.opt.StreamingCredentialsProvider != nil { + credListener, initErr := c.streamingCredentialsManager.Listener( + cn, + c.reAuthConnection(), + c.onAuthenticationErr(), + ) + if initErr != nil { + cn.GetStateMachine().Transition(pool.StateClosed) + return fmt.Errorf("failed to create credentials listener: %w", initErr) + } + + credentials, unsubscribeFromCredentialsProvider, initErr := c.opt.StreamingCredentialsProvider. + Subscribe(credListener) + if initErr != nil { + cn.GetStateMachine().Transition(pool.StateClosed) + return fmt.Errorf("failed to subscribe to streaming credentials: %w", initErr) + } + + c.onClose = c.wrappedOnClose(unsubscribeFromCredentialsProvider) + cn.SetOnClose(unsubscribeFromCredentialsProvider) + + username, password = credentials.BasicAuth() + } else if c.opt.CredentialsProviderContext != nil { + username, password, initErr = c.opt.CredentialsProviderContext(ctx) + if initErr != nil { + cn.GetStateMachine().Transition(pool.StateClosed) + return fmt.Errorf("failed to get credentials from context provider: %w", initErr) + } } else if c.opt.CredentialsProvider != nil { username, password = c.opt.CredentialsProvider() - } - - connPool := pool.NewSingleConnPool(c.connPool, cn) - conn := newConn(c.opt, connPool) - - var auth bool - protocol := c.opt.Protocol - // By default, use RESP3 in current version. - if protocol < 2 { - protocol = 3 + } else if c.opt.Username != "" || c.opt.Password != "" { + username, password = c.opt.Username, c.opt.Password } // for redis-server versions that do not support the HELLO command, // RESP2 will continue to be used. - if err = conn.Hello(ctx, protocol, username, password, "").Err(); err == nil { - auth = true - } else if !isRedisError(err) { + if initErr = conn.Hello(ctx, c.opt.Protocol, username, password, c.opt.ClientName).Err(); initErr == nil { + // Authentication successful with HELLO command + } else if !isRedisError(initErr) { // When the server responds with the RESP protocol and the result is not a normal // execution result of the HELLO command, we consider it to be an indication that // the server does not support the HELLO command. @@ -320,18 +498,22 @@ func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { // or it could be DragonflyDB or a third-party redis-proxy. They all respond // with different error string results for unsupported commands, making it // difficult to rely on error strings to determine all results. - return err + cn.GetStateMachine().Transition(pool.StateClosed) + return initErr + } else if password != "" { + // Try legacy AUTH command if HELLO failed + if username != "" { + initErr = conn.AuthACL(ctx, username, password).Err() + } else { + initErr = conn.Auth(ctx, password).Err() + } + if initErr != nil { + cn.GetStateMachine().Transition(pool.StateClosed) + return fmt.Errorf("failed to authenticate: %w", initErr) + } } - _, err = conn.Pipelined(ctx, func(pipe Pipeliner) error { - if !auth && password != "" { - if username != "" { - pipe.AuthACL(ctx, username, password) - } else { - pipe.Auth(ctx, password) - } - } - + _, initErr = conn.Pipelined(ctx, func(pipe Pipeliner) error { if c.opt.DB > 0 { pipe.Select(ctx, c.opt.DB) } @@ -346,8 +528,58 @@ func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { return nil }) - if err != nil { - return err + if initErr != nil { + cn.GetStateMachine().Transition(pool.StateClosed) + return fmt.Errorf("failed to initialize connection options: %w", initErr) + } + + // Enable maintnotifications if maintnotifications are configured + c.optLock.RLock() + maintNotifEnabled := c.opt.MaintNotificationsConfig != nil && c.opt.MaintNotificationsConfig.Mode != maintnotifications.ModeDisabled + protocol := c.opt.Protocol + endpointType := c.opt.MaintNotificationsConfig.EndpointType + c.optLock.RUnlock() + var maintNotifHandshakeErr error + if maintNotifEnabled && protocol == 3 { + maintNotifHandshakeErr = conn.ClientMaintNotifications( + ctx, + true, + endpointType.String(), + ).Err() + if maintNotifHandshakeErr != nil { + if !isRedisError(maintNotifHandshakeErr) { + // if not redis error, fail the connection + cn.GetStateMachine().Transition(pool.StateClosed) + return maintNotifHandshakeErr + } + c.optLock.Lock() + // handshake failed - check and modify config atomically + switch c.opt.MaintNotificationsConfig.Mode { + case maintnotifications.ModeEnabled: + // enabled mode, fail the connection + c.optLock.Unlock() + cn.GetStateMachine().Transition(pool.StateClosed) + return fmt.Errorf("failed to enable maintnotifications: %w", maintNotifHandshakeErr) + default: // will handle auto and any other + // Disabling logging here as it's too noisy. + // TODO: Enable when we have a better logging solution for log levels + // internal.Logger.Printf(ctx, "auto mode fallback: maintnotifications disabled due to handshake error: %v", maintNotifHandshakeErr) + c.opt.MaintNotificationsConfig.Mode = maintnotifications.ModeDisabled + c.optLock.Unlock() + // auto mode, disable maintnotifications and continue + if initErr := c.disableMaintNotificationsUpgrades(); initErr != nil { + // Log error but continue - auto mode should be resilient + internal.Logger.Printf(ctx, "failed to disable maintnotifications in auto mode: %v", initErr) + } + } + } else { + // handshake was executed successfully + // to make sure that the handshake will be executed on other connections as well if it was successfully + // executed on this connection, we will force the handshake to be executed on all connections + c.optLock.Lock() + c.opt.MaintNotificationsConfig.Mode = maintnotifications.ModeEnabled + c.optLock.Unlock() + } } if !c.opt.DisableIdentity && !c.opt.DisableIndentity { @@ -361,14 +593,33 @@ func (c *baseClient) initConn(ctx context.Context, cn *pool.Conn) error { p.ClientSetInfo(ctx, WithLibraryVersion(libVer)) // Handle network errors (e.g. timeouts) in CLIENT SETINFO to avoid // out of order responses later on. - if _, err = p.Exec(ctx); err != nil && !isRedisError(err) { - return err + if _, initErr = p.Exec(ctx); initErr != nil && !isRedisError(initErr) { + cn.GetStateMachine().Transition(pool.StateClosed) + return initErr } } + // Set the connection initialization function for potential reconnections + // This must be set before transitioning to IDLE so that handoff/reauth can use it + cn.SetInitConnFunc(c.createInitConnFunc()) + + // Initialization succeeded - transition to IDLE state + // This marks the connection as initialized and ready for use + // NOTE: The connection is still owned by the calling goroutine at this point + // and won't be available to other goroutines until it's Put() back into the pool + cn.GetStateMachine().Transition(pool.StateIdle) + + // Call OnConnect hook if configured + // The connection is in IDLE state but still owned by this goroutine + // If OnConnect needs to send commands, it can use the connection safely if c.opt.OnConnect != nil { - return c.opt.OnConnect(ctx, conn) + if initErr = c.opt.OnConnect(ctx, conn); initErr != nil { + // OnConnect failed - transition to closed + cn.GetStateMachine().Transition(pool.StateClosed) + return initErr + } } + return nil } @@ -380,6 +631,10 @@ func (c *baseClient) releaseConn(ctx context.Context, cn *pool.Conn, err error) if isBadConn(err, false, c.opt.Addr) { c.connPool.Remove(ctx, cn, err) } else { + // process any pending push notifications before returning the connection to the pool + if err := c.processPushNotifications(ctx, cn); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before releasing connection: %v", err) + } c.connPool.Put(ctx, cn) } } @@ -421,16 +676,16 @@ func (c *baseClient) process(ctx context.Context, cmd Cmder) error { return lastErr } -func (c *baseClient) assertUnstableCommand(cmd Cmder) bool { +func (c *baseClient) assertUnstableCommand(cmd Cmder) (bool, error) { switch cmd.(type) { case *AggregateCmd, *FTInfoCmd, *FTSpellCheckCmd, *FTSearchCmd, *FTSynDumpCmd: if c.opt.UnstableResp3 { - return true + return true, nil } else { - panic("RESP3 responses for this command are disabled because they may still change. Please set the flag UnstableResp3 . See the [README](https://github.com/redis/go-redis/blob/master/README.md) and the release notes for guidance.") + return false, fmt.Errorf("RESP3 responses for this command are disabled because they may still change. Please set the flag UnstableResp3. See the README and the release notes for guidance") } default: - return false + return false, nil } } @@ -443,6 +698,11 @@ func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int) (bool retryTimeout := uint32(0) if err := c.withConn(ctx, func(ctx context.Context, cn *pool.Conn) error { + // Process any pending push notifications before executing the command + if err := c.processPushNotifications(ctx, cn); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before command: %v", err) + } + if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error { return writeCmd(wr, cmd) }); err != nil { @@ -451,10 +711,22 @@ func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int) (bool } readReplyFunc := cmd.readReply // Apply unstable RESP3 search module. - if c.opt.Protocol != 2 && c.assertUnstableCommand(cmd) { - readReplyFunc = cmd.readRawReply + if c.opt.Protocol != 2 { + useRawReply, err := c.assertUnstableCommand(cmd) + if err != nil { + return err + } + if useRawReply { + readReplyFunc = cmd.readRawReply + } } - if err := cn.WithReader(c.context(ctx), c.cmdTimeout(cmd), readReplyFunc); err != nil { + if err := cn.WithReader(c.context(ctx), c.cmdTimeout(cmd), func(rd *proto.Reader) error { + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err) + } + return readReplyFunc(rd) + }); err != nil { if cmd.readTimeout() == nil { atomic.StoreUint32(&retryTimeout, 1) } else { @@ -487,19 +759,86 @@ func (c *baseClient) cmdTimeout(cmd Cmder) time.Duration { return c.opt.ReadTimeout } +// context returns the context for the current connection. +// If the context timeout is enabled, it returns the original context. +// Otherwise, it returns a new background context. +func (c *baseClient) context(ctx context.Context) context.Context { + if c.opt.ContextTimeoutEnabled { + return ctx + } + return context.Background() +} + +// createInitConnFunc creates a connection initialization function that can be used for reconnections. +func (c *baseClient) createInitConnFunc() func(context.Context, *pool.Conn) error { + return func(ctx context.Context, cn *pool.Conn) error { + return c.initConn(ctx, cn) + } +} + +// enableMaintNotificationsUpgrades initializes the maintnotifications upgrade manager and pool hook. +// This function is called during client initialization. +// will register push notification handlers for all maintenance upgrade events. +// will start background workers for handoff processing in the pool hook. +func (c *baseClient) enableMaintNotificationsUpgrades() error { + // Create client adapter + clientAdapterInstance := newClientAdapter(c) + + // Create maintnotifications manager directly + manager, err := maintnotifications.NewManager(clientAdapterInstance, c.connPool, c.opt.MaintNotificationsConfig) + if err != nil { + return err + } + // Set the manager reference and initialize pool hook + c.maintNotificationsManagerLock.Lock() + c.maintNotificationsManager = manager + c.maintNotificationsManagerLock.Unlock() + + // Initialize pool hook (safe to call without lock since manager is now set) + manager.InitPoolHook(c.dialHook) + return nil +} + +func (c *baseClient) disableMaintNotificationsUpgrades() error { + c.maintNotificationsManagerLock.Lock() + defer c.maintNotificationsManagerLock.Unlock() + + // Close the maintnotifications manager + if c.maintNotificationsManager != nil { + // Closing the manager will also shutdown the pool hook + // and remove it from the pool + c.maintNotificationsManager.Close() + c.maintNotificationsManager = nil + } + return nil +} + // Close closes the client, releasing any open resources. // // It is rare to Close a Client, as the Client is meant to be // long-lived and shared between many goroutines. func (c *baseClient) Close() error { var firstErr error + + // Close maintnotifications manager first + if err := c.disableMaintNotificationsUpgrades(); err != nil { + firstErr = err + } + if c.onClose != nil { - if err := c.onClose(); err != nil { + if err := c.onClose(); err != nil && firstErr == nil { firstErr = err } } - if err := c.connPool.Close(); err != nil && firstErr == nil { - firstErr = err + if c.connPool != nil { + if err := c.connPool.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if c.pubSubPool != nil { + if err := c.pubSubPool.Close(); err != nil && firstErr == nil { + firstErr = err + } } return firstErr } @@ -539,11 +878,19 @@ func (c *baseClient) generalProcessPipeline( // Enable retries by default to retry dial errors returned by withConn. canRetry := true lastErr = c.withConn(ctx, func(ctx context.Context, cn *pool.Conn) error { + // Process any pending push notifications before executing the pipeline + if err := c.processPushNotifications(ctx, cn); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before processing pipeline: %v", err) + } var err error canRetry, err = p(ctx, cn, cmds) return err }) if lastErr == nil || !canRetry || !shouldRetry(lastErr, true) { + // The error should be set here only when failing to obtain the conn. + if !isRedisError(lastErr) { + setCmdsErr(cmds, lastErr) + } return lastErr } } @@ -553,6 +900,11 @@ func (c *baseClient) generalProcessPipeline( func (c *baseClient) pipelineProcessCmds( ctx context.Context, cn *pool.Conn, cmds []Cmder, ) (bool, error) { + // Process any pending push notifications before executing the pipeline + if err := c.processPushNotifications(ctx, cn); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before writing pipeline: %v", err) + } + if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error { return writeCmds(wr, cmds) }); err != nil { @@ -561,7 +913,8 @@ func (c *baseClient) pipelineProcessCmds( } if err := cn.WithReader(c.context(ctx), c.opt.ReadTimeout, func(rd *proto.Reader) error { - return pipelineReadCmds(rd, cmds) + // read all replies + return c.pipelineReadCmds(ctx, cn, rd, cmds) }); err != nil { return true, err } @@ -569,8 +922,12 @@ func (c *baseClient) pipelineProcessCmds( return false, nil } -func pipelineReadCmds(rd *proto.Reader, cmds []Cmder) error { +func (c *baseClient) pipelineReadCmds(ctx context.Context, cn *pool.Conn, rd *proto.Reader, cmds []Cmder) error { for i, cmd := range cmds { + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err) + } err := cmd.readReply(rd) cmd.SetErr(err) if err != nil && !isRedisError(err) { @@ -585,6 +942,11 @@ func pipelineReadCmds(rd *proto.Reader, cmds []Cmder) error { func (c *baseClient) txPipelineProcessCmds( ctx context.Context, cn *pool.Conn, cmds []Cmder, ) (bool, error) { + // Process any pending push notifications before executing the transaction pipeline + if err := c.processPushNotifications(ctx, cn); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before transaction: %v", err) + } + if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error { return writeCmds(wr, cmds) }); err != nil { @@ -597,12 +959,13 @@ func (c *baseClient) txPipelineProcessCmds( // Trim multi and exec. trimmedCmds := cmds[1 : len(cmds)-1] - if err := txPipelineReadQueued(rd, statusCmd, trimmedCmds); err != nil { + if err := c.txPipelineReadQueued(ctx, cn, rd, statusCmd, trimmedCmds); err != nil { setCmdsErr(cmds, err) return err } - return pipelineReadCmds(rd, trimmedCmds) + // Read replies. + return c.pipelineReadCmds(ctx, cn, rd, trimmedCmds) }); err != nil { return false, err } @@ -610,19 +973,36 @@ func (c *baseClient) txPipelineProcessCmds( return false, nil } -func txPipelineReadQueued(rd *proto.Reader, statusCmd *StatusCmd, cmds []Cmder) error { +// txPipelineReadQueued reads queued replies from the Redis server. +// It returns an error if the server returns an error or if the number of replies does not match the number of commands. +func (c *baseClient) txPipelineReadQueued(ctx context.Context, cn *pool.Conn, rd *proto.Reader, statusCmd *StatusCmd, cmds []Cmder) error { + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err) + } // Parse +OK. if err := statusCmd.readReply(rd); err != nil { return err } // Parse +QUEUED. - for range cmds { - if err := statusCmd.readReply(rd); err != nil && !isRedisError(err) { - return err + for _, cmd := range cmds { + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err) + } + if err := statusCmd.readReply(rd); err != nil { + cmd.SetErr(err) + if !isRedisError(err) { + return err + } } } + // To be sure there are no buffered push notifications, we process them before reading the reply + if err := c.processPendingPushNotificationWithReader(ctx, cn, rd); err != nil { + internal.Logger.Printf(ctx, "push: error processing pending notifications before reading reply: %v", err) + } // Parse number of replies. line, err := rd.ReadLine() if err != nil { @@ -639,13 +1019,6 @@ func txPipelineReadQueued(rd *proto.Reader, statusCmd *StatusCmd, cmds []Cmder) return nil } -func (c *baseClient) context(ctx context.Context) context.Context { - if c.opt.ContextTimeoutEnabled { - return ctx - } - return context.Background() -} - //------------------------------------------------------------------------------ // Client is a Redis client representing a pool of zero or more underlying connections. @@ -656,20 +1029,68 @@ func (c *baseClient) context(ctx context.Context) context.Context { type Client struct { *baseClient cmdable - hooksMixin } // NewClient returns a client to the Redis Server specified by Options. func NewClient(opt *Options) *Client { + if opt == nil { + panic("redis: NewClient nil options") + } + // clone to not share options with the caller + opt = opt.clone() opt.init() + // Push notifications are always enabled for RESP3 (cannot be disabled) + c := Client{ baseClient: &baseClient{ opt: opt, }, } c.init() - c.connPool = newConnPool(opt, c.dialHook) + + // Initialize push notification processor using shared helper + // Use void processor for RESP2 connections (push notifications not available) + c.pushProcessor = initializePushProcessor(opt) + // set opt push processor for child clients + c.opt.PushNotificationProcessor = c.pushProcessor + + // Create connection pools + var err error + c.connPool, err = newConnPool(opt, c.dialHook) + if err != nil { + panic(fmt.Errorf("redis: failed to create connection pool: %w", err)) + } + c.pubSubPool, err = newPubSubPool(opt, c.dialHook) + if err != nil { + panic(fmt.Errorf("redis: failed to create pubsub pool: %w", err)) + } + + if opt.StreamingCredentialsProvider != nil { + c.streamingCredentialsManager = streaming.NewManager(c.connPool, c.opt.PoolTimeout) + c.connPool.AddPoolHook(c.streamingCredentialsManager.PoolHook()) + } + + // Initialize maintnotifications first if enabled and protocol is RESP3 + if opt.MaintNotificationsConfig != nil && opt.MaintNotificationsConfig.Mode != maintnotifications.ModeDisabled && opt.Protocol == 3 { + err := c.enableMaintNotificationsUpgrades() + if err != nil { + internal.Logger.Printf(context.Background(), "failed to initialize maintnotifications: %v", err) + if opt.MaintNotificationsConfig.Mode == maintnotifications.ModeEnabled { + /* + Design decision: panic here to fail fast if maintnotifications cannot be enabled when explicitly requested. + We choose to panic instead of returning an error to avoid breaking the existing client API, which does not expect + an error from NewClient. This ensures that misconfiguration or critical initialization failures are surfaced + immediately, rather than allowing the client to continue in a partially initialized or inconsistent state. + Clients relying on maintnotifications should be aware that initialization errors will cause a panic, and should + handle this accordingly (e.g., via recover or by validating configuration before calling NewClient). + This approach is only used when MaintNotificationsConfig.Mode is MaintNotificationsEnabled, indicating that maintnotifications + upgrades are required for correct operation. In other modes, initialization failures are logged but do not panic. + */ + panic(fmt.Errorf("failed to enable maintnotifications: %w", err)) + } + } + } return &c } @@ -692,14 +1113,7 @@ func (c *Client) WithTimeout(timeout time.Duration) *Client { } func (c *Client) Conn() *Conn { - return newConn(c.opt, pool.NewStickyConnPool(c.connPool)) -} - -// Do create a Cmd from the args and processes the cmd. -func (c *Client) Do(ctx context.Context, args ...interface{}) *Cmd { - cmd := NewCmd(ctx, args...) - _ = c.Process(ctx, cmd) - return cmd + return newConn(c.opt, pool.NewStickyConnPool(c.connPool), &c.hooksMixin) } func (c *Client) Process(ctx context.Context, cmd Cmder) error { @@ -713,11 +1127,51 @@ func (c *Client) Options() *Options { return c.opt } +// GetMaintNotificationsManager returns the maintnotifications manager instance for monitoring and control. +// Returns nil if maintnotifications are not enabled. +func (c *Client) GetMaintNotificationsManager() *maintnotifications.Manager { + c.maintNotificationsManagerLock.RLock() + defer c.maintNotificationsManagerLock.RUnlock() + return c.maintNotificationsManager +} + +// initializePushProcessor initializes the push notification processor for any client type. +// This is a shared helper to avoid duplication across NewClient, NewFailoverClient, and NewSentinelClient. +func initializePushProcessor(opt *Options) push.NotificationProcessor { + // Always use custom processor if provided + if opt.PushNotificationProcessor != nil { + return opt.PushNotificationProcessor + } + + // Push notifications are always enabled for RESP3, disabled for RESP2 + if opt.Protocol == 3 { + // Create default processor for RESP3 connections + return NewPushNotificationProcessor() + } + + // Create void processor for RESP2 connections (push notifications not available) + return NewVoidPushNotificationProcessor() +} + +// RegisterPushNotificationHandler registers a handler for a specific push notification name. +// Returns an error if a handler is already registered for this push notification name. +// If protected is true, the handler cannot be unregistered. +func (c *Client) RegisterPushNotificationHandler(pushNotificationName string, handler push.NotificationHandler, protected bool) error { + return c.pushProcessor.RegisterHandler(pushNotificationName, handler, protected) +} + +// GetPushNotificationHandler returns the handler for a specific push notification name. +// Returns nil if no handler is registered for the given name. +func (c *Client) GetPushNotificationHandler(pushNotificationName string) push.NotificationHandler { + return c.pushProcessor.GetHandler(pushNotificationName) +} + type PoolStats pool.Stats // PoolStats returns connection pool stats. func (c *Client) PoolStats() *PoolStats { stats := c.connPool.Stats() + stats.PubSubStats = *(c.pubSubPool.Stats()) return (*PoolStats)(stats) } @@ -752,13 +1206,31 @@ func (c *Client) TxPipeline() Pipeliner { func (c *Client) pubSub() *PubSub { pubsub := &PubSub{ opt: c.opt, - - newConn: func(ctx context.Context, channels []string) (*pool.Conn, error) { - return c.newConn(ctx) + newConn: func(ctx context.Context, addr string, channels []string) (*pool.Conn, error) { + cn, err := c.pubSubPool.NewConn(ctx, c.opt.Network, addr, channels) + if err != nil { + return nil, err + } + // will return nil if already initialized + err = c.initConn(ctx, cn) + if err != nil { + _ = cn.Close() + return nil, err + } + // Track connection in PubSubPool + c.pubSubPool.TrackConn(cn) + return cn, nil }, - closeConn: c.connPool.CloseConn, + closeConn: func(cn *pool.Conn) error { + // Untrack connection from PubSubPool + c.pubSubPool.UntrackConn(cn) + _ = cn.Close() + return nil + }, + pushProcessor: c.pushProcessor, } pubsub.init() + return pubsub } @@ -825,10 +1297,12 @@ type Conn struct { baseClient cmdable statefulCmdable - hooksMixin } -func newConn(opt *Options, connPool pool.Pooler) *Conn { +// newConn is a helper func to create a new Conn instance. +// the Conn instance is not thread-safe and should not be shared between goroutines. +// the parentHooks will be cloned, no need to clone before passing it. +func newConn(opt *Options, connPool pool.Pooler, parentHooks *hooksMixin) *Conn { c := Conn{ baseClient: baseClient{ opt: opt, @@ -836,6 +1310,14 @@ func newConn(opt *Options, connPool pool.Pooler) *Conn { }, } + if parentHooks != nil { + c.hooksMixin = parentHooks.clone() + } + + // Initialize push notification processor using shared helper + // Use void processor for RESP2 connections (push notifications not available) + c.pushProcessor = initializePushProcessor(opt) + c.cmdable = c.Process c.statefulCmdable = c.Process c.initHooks(hooks{ @@ -854,6 +1336,13 @@ func (c *Conn) Process(ctx context.Context, cmd Cmder) error { return err } +// RegisterPushNotificationHandler registers a handler for a specific push notification name. +// Returns an error if a handler is already registered for this push notification name. +// If protected is true, the handler cannot be unregistered. +func (c *Conn) RegisterPushNotificationHandler(pushNotificationName string, handler push.NotificationHandler, protected bool) error { + return c.pushProcessor.RegisterHandler(pushNotificationName, handler, protected) +} + func (c *Conn) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) { return c.Pipeline().Pipelined(ctx, fn) } @@ -881,3 +1370,78 @@ func (c *Conn) TxPipeline() Pipeliner { pipe.init() return &pipe } + +// processPushNotifications processes all pending push notifications on a connection +// This ensures that cluster topology changes are handled immediately before the connection is used +// This method should be called by the client before using WithReader for command execution +// +// Performance optimization: Skip the expensive MaybeHasData() syscall if a health check +// was performed recently (within 5 seconds). The health check already verified the connection +// is healthy and checked for unexpected data (push notifications). +func (c *baseClient) processPushNotifications(ctx context.Context, cn *pool.Conn) error { + // Only process push notifications for RESP3 connections with a processor + if c.opt.Protocol != 3 || c.pushProcessor == nil { + return nil + } + + // Performance optimization: Skip MaybeHasData() syscall if health check was recent + // If the connection was health-checked within the last 5 seconds, we can skip the + // expensive syscall since the health check already verified no unexpected data. + // This is safe because: + // 0. lastHealthCheckNs is set in pool/conn.go:putConn() after a successful health check + // 1. Health check (connCheck) uses the same syscall (Recvfrom with MSG_PEEK) + // 2. If push notifications arrived, they would have been detected by health check + // 3. 5 seconds is short enough that connection state is still fresh + // 4. Push notifications will be processed by the next WithReader call + // used it is set on getConn, so we should use another timer (lastPutAt?) + lastHealthCheckNs := cn.LastPutAtNs() + if lastHealthCheckNs > 0 { + // Use pool's cached time to avoid expensive time.Now() syscall + nowNs := pool.GetCachedTimeNs() + if nowNs-lastHealthCheckNs < int64(5*time.Second) { + // Recent health check confirmed no unexpected data, skip the syscall + return nil + } + } + + // Check if there is any data to read before processing + // This is an optimization on UNIX systems where MaybeHasData is a syscall + // On Windows, MaybeHasData always returns true, so this check is a no-op + if !cn.MaybeHasData() { + return nil + } + + // Use WithReader to access the reader and process push notifications + // This is critical for maintnotifications to work properly + // NOTE: almost no timeouts are set for this read, so it should not block + // longer than necessary, 10us should be plenty of time to read if there are any push notifications + // on the socket. + return cn.WithReader(ctx, 10*time.Microsecond, func(rd *proto.Reader) error { + // Create handler context with client, connection pool, and connection information + handlerCtx := c.pushNotificationHandlerContext(cn) + return c.pushProcessor.ProcessPendingNotifications(ctx, handlerCtx, rd) + }) +} + +// processPendingPushNotificationWithReader processes all pending push notifications on a connection +// This method should be called by the client in WithReader before reading the reply +func (c *baseClient) processPendingPushNotificationWithReader(ctx context.Context, cn *pool.Conn, rd *proto.Reader) error { + // if we have the reader, we don't need to check for data on the socket, we are waiting + // for either a reply or a push notification, so we can block until we get a reply or reach the timeout + if c.opt.Protocol != 3 || c.pushProcessor == nil { + return nil + } + + // Create handler context with client, connection pool, and connection information + handlerCtx := c.pushNotificationHandlerContext(cn) + return c.pushProcessor.ProcessPendingNotifications(ctx, handlerCtx, rd) +} + +// pushNotificationHandlerContext creates a handler context for push notification processing +func (c *baseClient) pushNotificationHandlerContext(cn *pool.Conn) push.NotificationHandlerContext { + return push.NotificationHandlerContext{ + Client: c, + ConnPool: c.connPool, + Conn: cn, // Wrap in adapter for easier interface access + } +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/result.go b/backend/vendor/github.com/redis/go-redis/v9/result.go index cfd4cf92..3e0d0a13 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/result.go +++ b/backend/vendor/github.com/redis/go-redis/v9/result.go @@ -82,6 +82,14 @@ func NewBoolSliceResult(val []bool, err error) *BoolSliceCmd { return &cmd } +// NewFloatSliceResult returns a FloatSliceCmd initialised with val and err for testing. +func NewFloatSliceResult(val []float64, err error) *FloatSliceCmd { + var cmd FloatSliceCmd + cmd.val = val + cmd.SetErr(err) + return &cmd +} + // NewMapStringStringResult returns a MapStringStringCmd initialised with val and err for testing. func NewMapStringStringResult(val map[string]string, err error) *MapStringStringCmd { var cmd MapStringStringCmd diff --git a/backend/vendor/github.com/redis/go-redis/v9/ring.go b/backend/vendor/github.com/redis/go-redis/v9/ring.go index 990a4c88..3381460a 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/ring.go +++ b/backend/vendor/github.com/redis/go-redis/v9/ring.go @@ -13,15 +13,23 @@ import ( "github.com/cespare/xxhash/v2" "github.com/dgryski/go-rendezvous" //nolint + "github.com/redis/go-redis/v9/auth" "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/hashtag" "github.com/redis/go-redis/v9/internal/pool" + "github.com/redis/go-redis/v9/internal/proto" "github.com/redis/go-redis/v9/internal/rand" ) var errRingShardsDown = errors.New("redis: all ring shards are down") +// defaultHeartbeatFn is the default function used to check the shard liveness +var defaultHeartbeatFn = func(ctx context.Context, client *Client) bool { + err := client.Ping(ctx).Err() + return err == nil || err == pool.ErrPoolTimeout +} + //------------------------------------------------------------------------------ type ConsistentHash interface { @@ -54,10 +62,14 @@ type RingOptions struct { // ClientName will execute the `CLIENT SETNAME ClientName` command for each conn. ClientName string - // Frequency of PING commands sent to check shards availability. + // Frequency of executing HeartbeatFn to check shards availability. // Shard is considered down after 3 subsequent failed checks. HeartbeatFrequency time.Duration + // A function used to check the shard liveness + // if not set, defaults to defaultHeartbeatFn + HeartbeatFn func(ctx context.Context, client *Client) bool + // NewConsistentHash returns a consistent hash that is used // to distribute keys across the shards. // @@ -73,7 +85,24 @@ type RingOptions struct { Protocol int Username string Password string - DB int + // CredentialsProvider allows the username and password to be updated + // before reconnecting. It should return the current username and password. + CredentialsProvider func() (username string, password string) + + // CredentialsProviderContext is an enhanced parameter of CredentialsProvider, + // done to maintain API compatibility. In the future, + // there might be a merge between CredentialsProviderContext and CredentialsProvider. + // There will be a conflict between them; if CredentialsProviderContext exists, we will ignore CredentialsProvider. + CredentialsProviderContext func(ctx context.Context) (username string, password string, err error) + + // StreamingCredentialsProvider is used to retrieve the credentials + // for the connection from an external source. Those credentials may change + // during the connection lifetime. This is useful for managed identity + // scenarios where the credentials are retrieved from an external source. + // + // Currently, this is a placeholder for the future implementation. + StreamingCredentialsProvider auth.StreamingCredentialsProvider + DB int MaxRetries int MinRetryBackoff time.Duration @@ -95,6 +124,20 @@ type RingOptions struct { ConnMaxIdleTime time.Duration ConnMaxLifetime time.Duration + // ReadBufferSize is the size of the bufio.Reader buffer for each connection. + // Larger buffers can improve performance for commands that return large responses. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + ReadBufferSize int + + // WriteBufferSize is the size of the bufio.Writer buffer for each connection. + // Larger buffers can improve performance for large pipelines and commands with many arguments. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + WriteBufferSize int + TLSConfig *tls.Config Limiter Limiter @@ -124,13 +167,18 @@ func (opt *RingOptions) init() { opt.HeartbeatFrequency = 500 * time.Millisecond } + if opt.HeartbeatFn == nil { + opt.HeartbeatFn = defaultHeartbeatFn + } + if opt.NewConsistentHash == nil { opt.NewConsistentHash = newRendezvous } - if opt.MaxRetries == -1 { + switch opt.MaxRetries { + case -1: opt.MaxRetries = 0 - } else if opt.MaxRetries == 0 { + case 0: opt.MaxRetries = 3 } switch opt.MinRetryBackoff { @@ -145,6 +193,13 @@ func (opt *RingOptions) init() { case 0: opt.MaxRetryBackoff = 512 * time.Millisecond } + + if opt.ReadBufferSize == 0 { + opt.ReadBufferSize = proto.DefaultBufferSize + } + if opt.WriteBufferSize == 0 { + opt.WriteBufferSize = proto.DefaultBufferSize + } } func (opt *RingOptions) clientOptions() *Options { @@ -153,10 +208,13 @@ func (opt *RingOptions) clientOptions() *Options { Dialer: opt.Dialer, OnConnect: opt.OnConnect, - Protocol: opt.Protocol, - Username: opt.Username, - Password: opt.Password, - DB: opt.DB, + Protocol: opt.Protocol, + Username: opt.Username, + Password: opt.Password, + CredentialsProvider: opt.CredentialsProvider, + CredentialsProviderContext: opt.CredentialsProviderContext, + StreamingCredentialsProvider: opt.StreamingCredentialsProvider, + DB: opt.DB, MaxRetries: -1, @@ -173,6 +231,8 @@ func (opt *RingOptions) clientOptions() *Options { MaxActiveConns: opt.MaxActiveConns, ConnMaxIdleTime: opt.ConnMaxIdleTime, ConnMaxLifetime: opt.ConnMaxLifetime, + ReadBufferSize: opt.ReadBufferSize, + WriteBufferSize: opt.WriteBufferSize, TLSConfig: opt.TLSConfig, Limiter: opt.Limiter, @@ -348,16 +408,16 @@ func (c *ringSharding) newRingShards( return } +// Warning: External exposure of `c.shards.list` may cause data races. +// So keep internal or implement deep copy if exposed. func (c *ringSharding) List() []*ringShard { - var list []*ringShard - c.mu.RLock() - if !c.closed { - list = c.shards.list - } - c.mu.RUnlock() + defer c.mu.RUnlock() - return list + if c.closed { + return nil + } + return c.shards.list } func (c *ringSharding) Hash(key string) string { @@ -404,7 +464,12 @@ func (c *ringSharding) GetByName(shardName string) (*ringShard, error) { c.mu.RLock() defer c.mu.RUnlock() - return c.shards.m[shardName], nil + shard, ok := c.shards.m[shardName] + if !ok { + return nil, errors.New("redis: the shard is not in the ring") + } + + return shard, nil } func (c *ringSharding) Random() (*ringShard, error) { @@ -421,9 +486,9 @@ func (c *ringSharding) Heartbeat(ctx context.Context, frequency time.Duration) { case <-ticker.C: var rebalance bool + // note: `c.List()` return a shadow copy of `[]*ringShard`. for _, shard := range c.List() { - err := shard.Client.Ping(ctx).Err() - isUp := err == nil || err == pool.ErrPoolTimeout + isUp := c.opt.HeartbeatFn(ctx, shard.Client) if shard.Vote(isUp) { internal.Logger.Printf(ctx, "ring shard state changed: %s", shard) rebalance = true @@ -521,6 +586,9 @@ type Ring struct { } func NewRing(opt *RingOptions) *Ring { + if opt == nil { + panic("redis: NewRing nil options") + } opt.init() hbCtx, hbCancel := context.WithCancel(context.Background()) @@ -553,13 +621,6 @@ func (c *Ring) SetAddrs(addrs map[string]string) { c.sharding.SetAddrs(addrs) } -// Do create a Cmd from the args and processes the cmd. -func (c *Ring) Do(ctx context.Context, args ...interface{}) *Cmd { - cmd := NewCmd(ctx, args...) - _ = c.Process(ctx, cmd) - return cmd -} - func (c *Ring) Process(ctx context.Context, cmd Cmder) error { err := c.processHook(ctx, cmd) cmd.SetErr(err) @@ -577,6 +638,7 @@ func (c *Ring) retryBackoff(attempt int) time.Duration { // PoolStats returns accumulated connection pool stats. func (c *Ring) PoolStats() *PoolStats { + // note: `c.List()` return a shadow copy of `[]*ringShard`. shards := c.sharding.List() var acc PoolStats for _, shard := range shards { @@ -646,6 +708,7 @@ func (c *Ring) ForEachShard( ctx context.Context, fn func(ctx context.Context, client *Client) error, ) error { + // note: `c.List()` return a shadow copy of `[]*ringShard`. shards := c.sharding.List() var wg sync.WaitGroup errCh := make(chan error, 1) @@ -677,6 +740,7 @@ func (c *Ring) ForEachShard( } func (c *Ring) cmdsInfo(ctx context.Context) (map[string]*CommandInfo, error) { + // note: `c.List()` return a shadow copy of `[]*ringShard`. shards := c.sharding.List() var firstErr error for _, shard := range shards { @@ -694,7 +758,7 @@ func (c *Ring) cmdsInfo(ctx context.Context) (map[string]*CommandInfo, error) { return nil, firstErr } -func (c *Ring) cmdShard(ctx context.Context, cmd Cmder) (*ringShard, error) { +func (c *Ring) cmdShard(cmd Cmder) (*ringShard, error) { pos := cmdFirstKeyPos(cmd) if pos == 0 { return c.sharding.Random() @@ -712,7 +776,7 @@ func (c *Ring) process(ctx context.Context, cmd Cmder) error { } } - shard, err := c.cmdShard(ctx, cmd) + shard, err := c.cmdShard(cmd) if err != nil { return err } @@ -771,6 +835,8 @@ func (c *Ring) generalProcessPipeline( } var wg sync.WaitGroup + errs := make(chan error, len(cmdsMap)) + for hash, cmds := range cmdsMap { wg.Add(1) go func(hash string, cmds []Cmder) { @@ -783,16 +849,24 @@ func (c *Ring) generalProcessPipeline( return } + hook := shard.Client.processPipelineHook if tx { cmds = wrapMultiExec(ctx, cmds) - _ = shard.Client.processTxPipelineHook(ctx, cmds) - } else { - _ = shard.Client.processPipelineHook(ctx, cmds) + hook = shard.Client.processTxPipelineHook + } + + if err = hook(ctx, cmds); err != nil { + errs <- err } }(hash, cmds) } wg.Wait() + close(errs) + + if err := <-errs; err != nil { + return err + } return cmdsFirstErr(cmds) } @@ -805,7 +879,7 @@ func (c *Ring) Watch(ctx context.Context, fn func(*Tx) error, keys ...string) er for _, key := range keys { if key != "" { - shard, err := c.sharding.GetByKey(hashtag.Key(key)) + shard, err := c.sharding.GetByKey(key) if err != nil { return err } @@ -839,3 +913,26 @@ func (c *Ring) Close() error { return c.sharding.Close() } + +// GetShardClients returns a list of all shard clients in the ring. +// This can be used to create dedicated connections (e.g., PubSub) for each shard. +func (c *Ring) GetShardClients() []*Client { + shards := c.sharding.List() + clients := make([]*Client, 0, len(shards)) + for _, shard := range shards { + if shard.IsUp() { + clients = append(clients, shard.Client) + } + } + return clients +} + +// GetShardClientForKey returns the shard client that would handle the given key. +// This can be used to determine which shard a particular key/channel would be routed to. +func (c *Ring) GetShardClientForKey(key string) (*Client, error) { + shard, err := c.sharding.GetByKey(key) + if err != nil { + return nil, err + } + return shard.Client, nil +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/search_builders.go b/backend/vendor/github.com/redis/go-redis/v9/search_builders.go new file mode 100644 index 00000000..91f06340 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/search_builders.go @@ -0,0 +1,825 @@ +package redis + +import ( + "context" +) + +// ---------------------- +// Search Module Builders +// ---------------------- + +// SearchBuilder provides a fluent API for FT.SEARCH +// (see original FTSearchOptions for all options). +// EXPERIMENTAL: this API is subject to change, use with caution. +type SearchBuilder struct { + c *Client + ctx context.Context + index string + query string + options *FTSearchOptions +} + +// NewSearchBuilder creates a new SearchBuilder for FT.SEARCH commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewSearchBuilder(ctx context.Context, index, query string) *SearchBuilder { + b := &SearchBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTSearchOptions{LimitOffset: -1}} + return b +} + +// WithScores includes WITHSCORES. +func (b *SearchBuilder) WithScores() *SearchBuilder { + b.options.WithScores = true + return b +} + +// NoContent includes NOCONTENT. +func (b *SearchBuilder) NoContent() *SearchBuilder { b.options.NoContent = true; return b } + +// Verbatim includes VERBATIM. +func (b *SearchBuilder) Verbatim() *SearchBuilder { b.options.Verbatim = true; return b } + +// NoStopWords includes NOSTOPWORDS. +func (b *SearchBuilder) NoStopWords() *SearchBuilder { b.options.NoStopWords = true; return b } + +// WithPayloads includes WITHPAYLOADS. +func (b *SearchBuilder) WithPayloads() *SearchBuilder { + b.options.WithPayloads = true + return b +} + +// WithSortKeys includes WITHSORTKEYS. +func (b *SearchBuilder) WithSortKeys() *SearchBuilder { + b.options.WithSortKeys = true + return b +} + +// Filter adds a FILTER clause: FILTER . +func (b *SearchBuilder) Filter(field string, min, max interface{}) *SearchBuilder { + b.options.Filters = append(b.options.Filters, FTSearchFilter{ + FieldName: field, + Min: min, + Max: max, + }) + return b +} + +// GeoFilter adds a GEOFILTER clause: GEOFILTER . +func (b *SearchBuilder) GeoFilter(field string, lon, lat, radius float64, unit string) *SearchBuilder { + b.options.GeoFilter = append(b.options.GeoFilter, FTSearchGeoFilter{ + FieldName: field, + Longitude: lon, + Latitude: lat, + Radius: radius, + Unit: unit, + }) + return b +} + +// InKeys restricts the search to the given keys. +func (b *SearchBuilder) InKeys(keys ...interface{}) *SearchBuilder { + b.options.InKeys = append(b.options.InKeys, keys...) + return b +} + +// InFields restricts the search to the given fields. +func (b *SearchBuilder) InFields(fields ...interface{}) *SearchBuilder { + b.options.InFields = append(b.options.InFields, fields...) + return b +} + +// ReturnFields adds simple RETURN ... +func (b *SearchBuilder) ReturnFields(fields ...string) *SearchBuilder { + for _, f := range fields { + b.options.Return = append(b.options.Return, FTSearchReturn{FieldName: f}) + } + return b +} + +// ReturnAs adds RETURN AS . +func (b *SearchBuilder) ReturnAs(field, alias string) *SearchBuilder { + b.options.Return = append(b.options.Return, FTSearchReturn{FieldName: field, As: alias}) + return b +} + +// Slop adds SLOP . +func (b *SearchBuilder) Slop(slop int) *SearchBuilder { + b.options.Slop = slop + return b +} + +// Timeout adds TIMEOUT . +func (b *SearchBuilder) Timeout(timeout int) *SearchBuilder { + b.options.Timeout = timeout + return b +} + +// InOrder includes INORDER. +func (b *SearchBuilder) InOrder() *SearchBuilder { + b.options.InOrder = true + return b +} + +// Language sets LANGUAGE . +func (b *SearchBuilder) Language(lang string) *SearchBuilder { + b.options.Language = lang + return b +} + +// Expander sets EXPANDER . +func (b *SearchBuilder) Expander(expander string) *SearchBuilder { + b.options.Expander = expander + return b +} + +// Scorer sets SCORER . +func (b *SearchBuilder) Scorer(scorer string) *SearchBuilder { + b.options.Scorer = scorer + return b +} + +// ExplainScore includes EXPLAINSCORE. +func (b *SearchBuilder) ExplainScore() *SearchBuilder { + b.options.ExplainScore = true + return b +} + +// Payload sets PAYLOAD . +func (b *SearchBuilder) Payload(payload string) *SearchBuilder { + b.options.Payload = payload + return b +} + +// SortBy adds SORTBY ASC|DESC. +func (b *SearchBuilder) SortBy(field string, asc bool) *SearchBuilder { + b.options.SortBy = append(b.options.SortBy, FTSearchSortBy{ + FieldName: field, + Asc: asc, + Desc: !asc, + }) + return b +} + +// WithSortByCount includes WITHCOUNT (when used with SortBy). +func (b *SearchBuilder) WithSortByCount() *SearchBuilder { + b.options.SortByWithCount = true + return b +} + +// Param adds a single PARAMS . +func (b *SearchBuilder) Param(key string, value interface{}) *SearchBuilder { + if b.options.Params == nil { + b.options.Params = make(map[string]interface{}, 1) + } + b.options.Params[key] = value + return b +} + +// ParamsMap adds multiple PARAMS at once. +func (b *SearchBuilder) ParamsMap(p map[string]interface{}) *SearchBuilder { + if b.options.Params == nil { + b.options.Params = make(map[string]interface{}, len(p)) + } + for k, v := range p { + b.options.Params[k] = v + } + return b +} + +// Dialect sets DIALECT . +func (b *SearchBuilder) Dialect(version int) *SearchBuilder { + b.options.DialectVersion = version + return b +} + +// Limit sets OFFSET and COUNT. CountOnly uses LIMIT 0 0. +func (b *SearchBuilder) Limit(offset, count int) *SearchBuilder { + b.options.LimitOffset = offset + b.options.Limit = count + return b +} +func (b *SearchBuilder) CountOnly() *SearchBuilder { b.options.CountOnly = true; return b } + +// Run executes FT.SEARCH and returns a typed result. +func (b *SearchBuilder) Run() (FTSearchResult, error) { + cmd := b.c.FTSearchWithArgs(b.ctx, b.index, b.query, b.options) + return cmd.Result() +} + +// ---------------------- +// AggregateBuilder for FT.AGGREGATE +// ---------------------- + +type AggregateBuilder struct { + c *Client + ctx context.Context + index string + query string + options *FTAggregateOptions +} + +// NewAggregateBuilder creates a new AggregateBuilder for FT.AGGREGATE commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewAggregateBuilder(ctx context.Context, index, query string) *AggregateBuilder { + return &AggregateBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTAggregateOptions{LimitOffset: -1}} +} + +// Verbatim includes VERBATIM. +func (b *AggregateBuilder) Verbatim() *AggregateBuilder { b.options.Verbatim = true; return b } + +// AddScores includes ADDSCORES. +func (b *AggregateBuilder) AddScores() *AggregateBuilder { b.options.AddScores = true; return b } + +// Scorer sets SCORER . +func (b *AggregateBuilder) Scorer(s string) *AggregateBuilder { + b.options.Scorer = s + return b +} + +// LoadAll includes LOAD * (mutually exclusive with Load). +func (b *AggregateBuilder) LoadAll() *AggregateBuilder { + b.options.LoadAll = true + return b +} + +// Load adds LOAD [AS alias]... +// You can call it multiple times for multiple fields. +func (b *AggregateBuilder) Load(field string, alias ...string) *AggregateBuilder { + // each Load entry becomes one element in options.Load + l := FTAggregateLoad{Field: field} + if len(alias) > 0 { + l.As = alias[0] + } + b.options.Load = append(b.options.Load, l) + return b +} + +// Timeout sets TIMEOUT . +func (b *AggregateBuilder) Timeout(ms int) *AggregateBuilder { + b.options.Timeout = ms + return b +} + +// Apply adds APPLY [AS alias]. +func (b *AggregateBuilder) Apply(field string, alias ...string) *AggregateBuilder { + a := FTAggregateApply{Field: field} + if len(alias) > 0 { + a.As = alias[0] + } + b.options.Apply = append(b.options.Apply, a) + return b +} + +// GroupBy starts a new GROUPBY clause. +func (b *AggregateBuilder) GroupBy(fields ...interface{}) *AggregateBuilder { + b.options.GroupBy = append(b.options.GroupBy, FTAggregateGroupBy{ + Fields: fields, + }) + return b +} + +// Reduce adds a REDUCE [<#args> ] clause to the *last* GROUPBY. +func (b *AggregateBuilder) Reduce(fn SearchAggregator, args ...interface{}) *AggregateBuilder { + if len(b.options.GroupBy) == 0 { + // no GROUPBY yet — nothing to attach to + return b + } + idx := len(b.options.GroupBy) - 1 + b.options.GroupBy[idx].Reduce = append(b.options.GroupBy[idx].Reduce, FTAggregateReducer{ + Reducer: fn, + Args: args, + }) + return b +} + +// ReduceAs does the same but also sets an alias: REDUCE … AS +func (b *AggregateBuilder) ReduceAs(fn SearchAggregator, alias string, args ...interface{}) *AggregateBuilder { + if len(b.options.GroupBy) == 0 { + return b + } + idx := len(b.options.GroupBy) - 1 + b.options.GroupBy[idx].Reduce = append(b.options.GroupBy[idx].Reduce, FTAggregateReducer{ + Reducer: fn, + Args: args, + As: alias, + }) + return b +} + +// SortBy adds SORTBY ASC|DESC. +func (b *AggregateBuilder) SortBy(field string, asc bool) *AggregateBuilder { + sb := FTAggregateSortBy{FieldName: field, Asc: asc, Desc: !asc} + b.options.SortBy = append(b.options.SortBy, sb) + return b +} + +// SortByMax sets MAX (only if SortBy was called). +func (b *AggregateBuilder) SortByMax(max int) *AggregateBuilder { + b.options.SortByMax = max + return b +} + +// Filter sets FILTER . +func (b *AggregateBuilder) Filter(expr string) *AggregateBuilder { + b.options.Filter = expr + return b +} + +// WithCursor enables WITHCURSOR [COUNT ] [MAXIDLE ]. +func (b *AggregateBuilder) WithCursor(count, maxIdle int) *AggregateBuilder { + b.options.WithCursor = true + if b.options.WithCursorOptions == nil { + b.options.WithCursorOptions = &FTAggregateWithCursor{} + } + b.options.WithCursorOptions.Count = count + b.options.WithCursorOptions.MaxIdle = maxIdle + return b +} + +// Params adds PARAMS pairs. +func (b *AggregateBuilder) Params(p map[string]interface{}) *AggregateBuilder { + if b.options.Params == nil { + b.options.Params = make(map[string]interface{}, len(p)) + } + for k, v := range p { + b.options.Params[k] = v + } + return b +} + +// Dialect sets DIALECT . +func (b *AggregateBuilder) Dialect(version int) *AggregateBuilder { + b.options.DialectVersion = version + return b +} + +// Run executes FT.AGGREGATE and returns a typed result. +func (b *AggregateBuilder) Run() (*FTAggregateResult, error) { + cmd := b.c.FTAggregateWithArgs(b.ctx, b.index, b.query, b.options) + return cmd.Result() +} + +// ---------------------- +// CreateIndexBuilder for FT.CREATE +// ---------------------- +// CreateIndexBuilder is builder for FT.CREATE +// EXPERIMENTAL: this API is subject to change, use with caution. +type CreateIndexBuilder struct { + c *Client + ctx context.Context + index string + options *FTCreateOptions + schema []*FieldSchema +} + +// NewCreateIndexBuilder creates a new CreateIndexBuilder for FT.CREATE commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewCreateIndexBuilder(ctx context.Context, index string) *CreateIndexBuilder { + return &CreateIndexBuilder{c: c, ctx: ctx, index: index, options: &FTCreateOptions{}} +} + +// OnHash sets ON HASH. +func (b *CreateIndexBuilder) OnHash() *CreateIndexBuilder { b.options.OnHash = true; return b } + +// OnJSON sets ON JSON. +func (b *CreateIndexBuilder) OnJSON() *CreateIndexBuilder { b.options.OnJSON = true; return b } + +// Prefix sets PREFIX. +func (b *CreateIndexBuilder) Prefix(prefixes ...interface{}) *CreateIndexBuilder { + b.options.Prefix = prefixes + return b +} + +// Filter sets FILTER. +func (b *CreateIndexBuilder) Filter(filter string) *CreateIndexBuilder { + b.options.Filter = filter + return b +} + +// DefaultLanguage sets LANGUAGE. +func (b *CreateIndexBuilder) DefaultLanguage(lang string) *CreateIndexBuilder { + b.options.DefaultLanguage = lang + return b +} + +// LanguageField sets LANGUAGE_FIELD. +func (b *CreateIndexBuilder) LanguageField(field string) *CreateIndexBuilder { + b.options.LanguageField = field + return b +} + +// Score sets SCORE. +func (b *CreateIndexBuilder) Score(score float64) *CreateIndexBuilder { + b.options.Score = score + return b +} + +// ScoreField sets SCORE_FIELD. +func (b *CreateIndexBuilder) ScoreField(field string) *CreateIndexBuilder { + b.options.ScoreField = field + return b +} + +// PayloadField sets PAYLOAD_FIELD. +func (b *CreateIndexBuilder) PayloadField(field string) *CreateIndexBuilder { + b.options.PayloadField = field + return b +} + +// NoOffsets includes NOOFFSETS. +func (b *CreateIndexBuilder) NoOffsets() *CreateIndexBuilder { b.options.NoOffsets = true; return b } + +// Temporary sets TEMPORARY seconds. +func (b *CreateIndexBuilder) Temporary(sec int) *CreateIndexBuilder { + b.options.Temporary = sec + return b +} + +// NoHL includes NOHL. +func (b *CreateIndexBuilder) NoHL() *CreateIndexBuilder { b.options.NoHL = true; return b } + +// NoFields includes NOFIELDS. +func (b *CreateIndexBuilder) NoFields() *CreateIndexBuilder { b.options.NoFields = true; return b } + +// NoFreqs includes NOFREQS. +func (b *CreateIndexBuilder) NoFreqs() *CreateIndexBuilder { b.options.NoFreqs = true; return b } + +// StopWords sets STOPWORDS. +func (b *CreateIndexBuilder) StopWords(words ...interface{}) *CreateIndexBuilder { + b.options.StopWords = words + return b +} + +// SkipInitialScan includes SKIPINITIALSCAN. +func (b *CreateIndexBuilder) SkipInitialScan() *CreateIndexBuilder { + b.options.SkipInitialScan = true + return b +} + +// Schema adds a FieldSchema. +func (b *CreateIndexBuilder) Schema(field *FieldSchema) *CreateIndexBuilder { + b.schema = append(b.schema, field) + return b +} + +// Run executes FT.CREATE and returns the status. +func (b *CreateIndexBuilder) Run() (string, error) { + cmd := b.c.FTCreate(b.ctx, b.index, b.options, b.schema...) + return cmd.Result() +} + +// ---------------------- +// DropIndexBuilder for FT.DROPINDEX +// ---------------------- +// DropIndexBuilder is a builder for FT.DROPINDEX +// EXPERIMENTAL: this API is subject to change, use with caution. +type DropIndexBuilder struct { + c *Client + ctx context.Context + index string + options *FTDropIndexOptions +} + +// NewDropIndexBuilder creates a new DropIndexBuilder for FT.DROPINDEX commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewDropIndexBuilder(ctx context.Context, index string) *DropIndexBuilder { + return &DropIndexBuilder{c: c, ctx: ctx, index: index} +} + +// DeleteRuncs includes DD. +func (b *DropIndexBuilder) DeleteDocs() *DropIndexBuilder { b.options.DeleteDocs = true; return b } + +// Run executes FT.DROPINDEX. +func (b *DropIndexBuilder) Run() (string, error) { + cmd := b.c.FTDropIndexWithArgs(b.ctx, b.index, b.options) + return cmd.Result() +} + +// ---------------------- +// AliasBuilder for FT.ALIAS* commands +// ---------------------- +// AliasBuilder is builder for FT.ALIAS* commands +// EXPERIMENTAL: this API is subject to change, use with caution. +type AliasBuilder struct { + c *Client + ctx context.Context + alias string + index string + action string // add|del|update +} + +// NewAliasBuilder creates a new AliasBuilder for FT.ALIAS* commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewAliasBuilder(ctx context.Context, alias string) *AliasBuilder { + return &AliasBuilder{c: c, ctx: ctx, alias: alias} +} + +// Action sets the action for the alias builder. +func (b *AliasBuilder) Action(action string) *AliasBuilder { + b.action = action + return b +} + +// Add sets the action to "add" and requires an index. +func (b *AliasBuilder) Add(index string) *AliasBuilder { + b.action = "add" + b.index = index + return b +} + +// Del sets the action to "del". +func (b *AliasBuilder) Del() *AliasBuilder { + b.action = "del" + return b +} + +// Update sets the action to "update" and requires an index. +func (b *AliasBuilder) Update(index string) *AliasBuilder { + b.action = "update" + b.index = index + return b +} + +// Run executes the configured alias command. +func (b *AliasBuilder) Run() (string, error) { + switch b.action { + case "add": + cmd := b.c.FTAliasAdd(b.ctx, b.index, b.alias) + return cmd.Result() + case "del": + cmd := b.c.FTAliasDel(b.ctx, b.alias) + return cmd.Result() + case "update": + cmd := b.c.FTAliasUpdate(b.ctx, b.index, b.alias) + return cmd.Result() + } + return "", nil +} + +// ---------------------- +// ExplainBuilder for FT.EXPLAIN +// ---------------------- +// ExplainBuilder is builder for FT.EXPLAIN +// EXPERIMENTAL: this API is subject to change, use with caution. +type ExplainBuilder struct { + c *Client + ctx context.Context + index string + query string + options *FTExplainOptions +} + +// NewExplainBuilder creates a new ExplainBuilder for FT.EXPLAIN commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewExplainBuilder(ctx context.Context, index, query string) *ExplainBuilder { + return &ExplainBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTExplainOptions{}} +} + +// Dialect sets dialect for EXPLAINCLI. +func (b *ExplainBuilder) Dialect(d string) *ExplainBuilder { b.options.Dialect = d; return b } + +// Run executes FT.EXPLAIN and returns the plan. +func (b *ExplainBuilder) Run() (string, error) { + cmd := b.c.FTExplainWithArgs(b.ctx, b.index, b.query, b.options) + return cmd.Result() +} + +// ---------------------- +// InfoBuilder for FT.INFO +// ---------------------- + +type FTInfoBuilder struct { + c *Client + ctx context.Context + index string +} + +// NewSearchInfoBuilder creates a new FTInfoBuilder for FT.INFO commands. +func (c *Client) NewSearchInfoBuilder(ctx context.Context, index string) *FTInfoBuilder { + return &FTInfoBuilder{c: c, ctx: ctx, index: index} +} + +// Run executes FT.INFO and returns detailed info. +func (b *FTInfoBuilder) Run() (FTInfoResult, error) { + cmd := b.c.FTInfo(b.ctx, b.index) + return cmd.Result() +} + +// ---------------------- +// SpellCheckBuilder for FT.SPELLCHECK +// ---------------------- +// SpellCheckBuilder is builder for FT.SPELLCHECK +// EXPERIMENTAL: this API is subject to change, use with caution. +type SpellCheckBuilder struct { + c *Client + ctx context.Context + index string + query string + options *FTSpellCheckOptions +} + +// NewSpellCheckBuilder creates a new SpellCheckBuilder for FT.SPELLCHECK commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewSpellCheckBuilder(ctx context.Context, index, query string) *SpellCheckBuilder { + return &SpellCheckBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTSpellCheckOptions{}} +} + +// Distance sets MAXDISTANCE. +func (b *SpellCheckBuilder) Distance(d int) *SpellCheckBuilder { b.options.Distance = d; return b } + +// Terms sets INCLUDE or EXCLUDE terms. +func (b *SpellCheckBuilder) Terms(include bool, dictionary string, terms ...interface{}) *SpellCheckBuilder { + if b.options.Terms == nil { + b.options.Terms = &FTSpellCheckTerms{} + } + if include { + b.options.Terms.Inclusion = "INCLUDE" + } else { + b.options.Terms.Inclusion = "EXCLUDE" + } + b.options.Terms.Dictionary = dictionary + b.options.Terms.Terms = terms + return b +} + +// Dialect sets dialect version. +func (b *SpellCheckBuilder) Dialect(d int) *SpellCheckBuilder { b.options.Dialect = d; return b } + +// Run executes FT.SPELLCHECK and returns suggestions. +func (b *SpellCheckBuilder) Run() ([]SpellCheckResult, error) { + cmd := b.c.FTSpellCheckWithArgs(b.ctx, b.index, b.query, b.options) + return cmd.Result() +} + +// ---------------------- +// DictBuilder for FT.DICT* commands +// ---------------------- +// DictBuilder is builder for FT.DICT* commands +// EXPERIMENTAL: this API is subject to change, use with caution. +type DictBuilder struct { + c *Client + ctx context.Context + dict string + terms []interface{} + action string // add|del|dump +} + +// NewDictBuilder creates a new DictBuilder for FT.DICT* commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewDictBuilder(ctx context.Context, dict string) *DictBuilder { + return &DictBuilder{c: c, ctx: ctx, dict: dict} +} + +// Action sets the action for the dictionary builder. +func (b *DictBuilder) Action(action string) *DictBuilder { + b.action = action + return b +} + +// Add sets the action to "add" and requires terms. +func (b *DictBuilder) Add(terms ...interface{}) *DictBuilder { + b.action = "add" + b.terms = terms + return b +} + +// Del sets the action to "del" and requires terms. +func (b *DictBuilder) Del(terms ...interface{}) *DictBuilder { + b.action = "del" + b.terms = terms + return b +} + +// Dump sets the action to "dump". +func (b *DictBuilder) Dump() *DictBuilder { + b.action = "dump" + return b +} + +// Run executes the configured dictionary command. +func (b *DictBuilder) Run() (interface{}, error) { + switch b.action { + case "add": + cmd := b.c.FTDictAdd(b.ctx, b.dict, b.terms...) + return cmd.Result() + case "del": + cmd := b.c.FTDictDel(b.ctx, b.dict, b.terms...) + return cmd.Result() + case "dump": + cmd := b.c.FTDictDump(b.ctx, b.dict) + return cmd.Result() + } + return nil, nil +} + +// ---------------------- +// TagValsBuilder for FT.TAGVALS +// ---------------------- +// TagValsBuilder is builder for FT.TAGVALS +// EXPERIMENTAL: this API is subject to change, use with caution. +type TagValsBuilder struct { + c *Client + ctx context.Context + index string + field string +} + +// NewTagValsBuilder creates a new TagValsBuilder for FT.TAGVALS commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewTagValsBuilder(ctx context.Context, index, field string) *TagValsBuilder { + return &TagValsBuilder{c: c, ctx: ctx, index: index, field: field} +} + +// Run executes FT.TAGVALS and returns tag values. +func (b *TagValsBuilder) Run() ([]string, error) { + cmd := b.c.FTTagVals(b.ctx, b.index, b.field) + return cmd.Result() +} + +// ---------------------- +// CursorBuilder for FT.CURSOR* +// ---------------------- +// CursorBuilder is builder for FT.CURSOR* commands +// EXPERIMENTAL: this API is subject to change, use with caution. +type CursorBuilder struct { + c *Client + ctx context.Context + index string + cursorId int64 + count int + action string // read|del +} + +// NewCursorBuilder creates a new CursorBuilder for FT.CURSOR* commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewCursorBuilder(ctx context.Context, index string, cursorId int64) *CursorBuilder { + return &CursorBuilder{c: c, ctx: ctx, index: index, cursorId: cursorId} +} + +// Action sets the action for the cursor builder. +func (b *CursorBuilder) Action(action string) *CursorBuilder { + b.action = action + return b +} + +// Read sets the action to "read". +func (b *CursorBuilder) Read() *CursorBuilder { + b.action = "read" + return b +} + +// Del sets the action to "del". +func (b *CursorBuilder) Del() *CursorBuilder { + b.action = "del" + return b +} + +// Count for READ. +func (b *CursorBuilder) Count(count int) *CursorBuilder { b.count = count; return b } + +// Run executes the cursor command. +func (b *CursorBuilder) Run() (interface{}, error) { + switch b.action { + case "read": + cmd := b.c.FTCursorRead(b.ctx, b.index, int(b.cursorId), b.count) + return cmd.Result() + case "del": + cmd := b.c.FTCursorDel(b.ctx, b.index, int(b.cursorId)) + return cmd.Result() + } + return nil, nil +} + +// ---------------------- +// SynUpdateBuilder for FT.SYNUPDATE +// ---------------------- +// SyncUpdateBuilder is builder for FT.SYNCUPDATE +// EXPERIMENTAL: this API is subject to change, use with caution. +type SynUpdateBuilder struct { + c *Client + ctx context.Context + index string + groupId interface{} + options *FTSynUpdateOptions + terms []interface{} +} + +// NewSynUpdateBuilder creates a new SynUpdateBuilder for FT.SYNUPDATE commands. +// EXPERIMENTAL: this API is subject to change, use with caution. +func (c *Client) NewSynUpdateBuilder(ctx context.Context, index string, groupId interface{}) *SynUpdateBuilder { + return &SynUpdateBuilder{c: c, ctx: ctx, index: index, groupId: groupId, options: &FTSynUpdateOptions{}} +} + +// SkipInitialScan includes SKIPINITIALSCAN. +func (b *SynUpdateBuilder) SkipInitialScan() *SynUpdateBuilder { + b.options.SkipInitialScan = true + return b +} + +// Terms adds synonyms to the group. +func (b *SynUpdateBuilder) Terms(terms ...interface{}) *SynUpdateBuilder { b.terms = terms; return b } + +// Run executes FT.SYNUPDATE. +func (b *SynUpdateBuilder) Run() (string, error) { + cmd := b.c.FTSynUpdateWithArgs(b.ctx, b.index, b.groupId, b.options, b.terms) + return cmd.Result() +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/search_commands.go b/backend/vendor/github.com/redis/go-redis/v9/search_commands.go index 9359a723..9018b3de 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/search_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/search_commands.go @@ -29,6 +29,8 @@ type SearchCmdable interface { FTDropIndexWithArgs(ctx context.Context, index string, options *FTDropIndexOptions) *StatusCmd FTExplain(ctx context.Context, index string, query string) *StringCmd FTExplainWithArgs(ctx context.Context, index string, query string, options *FTExplainOptions) *StringCmd + FTHybrid(ctx context.Context, index string, searchExpr string, vectorField string, vectorData Vector) *FTHybridCmd + FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd FTInfo(ctx context.Context, index string) *FTInfoCmd FTSpellCheck(ctx context.Context, index string, query string) *FTSpellCheckCmd FTSpellCheckWithArgs(ctx context.Context, index string, query string, options *FTSpellCheckOptions) *FTSpellCheckCmd @@ -80,8 +82,9 @@ type FieldSchema struct { } type FTVectorArgs struct { - FlatOptions *FTFlatOptions - HNSWOptions *FTHNSWOptions + FlatOptions *FTFlatOptions + HNSWOptions *FTHNSWOptions + VamanaOptions *FTVamanaOptions } type FTFlatOptions struct { @@ -103,6 +106,19 @@ type FTHNSWOptions struct { Epsilon float64 } +type FTVamanaOptions struct { + Type string + Dim int + DistanceMetric string + Compression string + ConstructionWindowSize int + GraphMaxDegree int + SearchWindowSize int + Epsilon float64 + TrainingThreshold int + ReduceDim int +} + type FTDropIndexOptions struct { DeleteDocs bool } @@ -114,6 +130,7 @@ type SpellCheckTerms struct { } type FTExplainOptions struct { + // Dialect 1,3 and 4 are deprecated since redis 8.0 Dialect string } @@ -240,14 +257,19 @@ type FTAggregateWithCursor struct { } type FTAggregateOptions struct { - Verbatim bool - LoadAll bool - Load []FTAggregateLoad - Timeout int - GroupBy []FTAggregateGroupBy - SortBy []FTAggregateSortBy - SortByMax int - Scorer string + Verbatim bool + LoadAll bool + Load []FTAggregateLoad + Timeout int + GroupBy []FTAggregateGroupBy + SortBy []FTAggregateSortBy + SortByMax int + // Scorer is used to set scoring function, if not set passed, a default will be used. + // The default scorer depends on the Redis version: + // - `BM25` for Redis >= 8 + // - `TFIDF` for Redis < 8 + Scorer string + // AddScores is available in Redis CE 8 AddScores bool Apply []FTAggregateApply LimitOffset int @@ -256,7 +278,8 @@ type FTAggregateOptions struct { WithCursor bool WithCursorOptions *FTAggregateWithCursor Params map[string]interface{} - DialectVersion int + // Dialect 1,3 and 4 are deprecated since redis 8.0 + DialectVersion int } type FTSearchFilter struct { @@ -284,23 +307,30 @@ type FTSearchSortBy struct { Desc bool } +// FTSearchOptions hold options that can be passed to the FT.SEARCH command. +// More information about the options can be found +// in the documentation for FT.SEARCH https://redis.io/docs/latest/commands/ft.search/ type FTSearchOptions struct { - NoContent bool - Verbatim bool - NoStopWords bool - WithScores bool - WithPayloads bool - WithSortKeys bool - Filters []FTSearchFilter - GeoFilter []FTSearchGeoFilter - InKeys []interface{} - InFields []interface{} - Return []FTSearchReturn - Slop int - Timeout int - InOrder bool - Language string - Expander string + NoContent bool + Verbatim bool + NoStopWords bool + WithScores bool + WithPayloads bool + WithSortKeys bool + Filters []FTSearchFilter + GeoFilter []FTSearchGeoFilter + InKeys []interface{} + InFields []interface{} + Return []FTSearchReturn + Slop int + Timeout int + InOrder bool + Language string + Expander string + // Scorer is used to set scoring function, if not set passed, a default will be used. + // The default scorer depends on the Redis version: + // - `BM25` for Redis >= 8 + // - `TFIDF` for Redis < 8 Scorer string ExplainScore bool Payload string @@ -308,8 +338,98 @@ type FTSearchOptions struct { SortByWithCount bool LimitOffset int Limit int - Params map[string]interface{} - DialectVersion int + // CountOnly sets LIMIT 0 0 to get the count - number of documents in the result set without actually returning the result set. + // When using this option, the Limit and LimitOffset options are ignored. + CountOnly bool + Params map[string]interface{} + // Dialect 1,3 and 4 are deprecated since redis 8.0 + DialectVersion int +} + +// FTHybridCombineMethod represents the fusion method for combining search and vector results +type FTHybridCombineMethod string + +const ( + FTHybridCombineRRF FTHybridCombineMethod = "RRF" + FTHybridCombineLinear FTHybridCombineMethod = "LINEAR" + FTHybridCombineFunction FTHybridCombineMethod = "FUNCTION" +) + +// FTHybridSearchExpression represents a search expression in hybrid search +type FTHybridSearchExpression struct { + Query string + Scorer string + ScorerParams []interface{} + YieldScoreAs string +} + +type FTHybridVectorMethod = string + +const ( + KNN FTHybridCombineMethod = "KNN" + RANGE FTHybridCombineMethod = "RANGE" +) + +// FTHybridVectorExpression represents a vector expression in hybrid search +type FTHybridVectorExpression struct { + VectorField string + VectorData Vector + Method FTHybridVectorMethod + MethodParams []interface{} + Filter string + YieldScoreAs string +} + +// FTHybridCombineOptions represents options for result fusion +type FTHybridCombineOptions struct { + Method FTHybridCombineMethod + Count int + Window int // For RRF + Constant float64 // For RRF + Alpha float64 // For LINEAR + Beta float64 // For LINEAR + YieldScoreAs string +} + +// FTHybridGroupBy represents GROUP BY functionality +type FTHybridGroupBy struct { + Count int + Fields []string + ReduceFunc string + ReduceCount int + ReduceParams []interface{} +} + +// FTHybridApply represents APPLY functionality +type FTHybridApply struct { + Expression string + AsField string +} + +// FTHybridWithCursor represents cursor configuration for hybrid search +type FTHybridWithCursor struct { + Count int // Number of results to return per cursor read + MaxIdle int // Maximum idle time in milliseconds before cursor is automatically deleted +} + +// FTHybridOptions hold options that can be passed to the FT.HYBRID command +type FTHybridOptions struct { + CountExpressions int // Number of search/vector expressions + SearchExpressions []FTHybridSearchExpression // Multiple search expressions + VectorExpressions []FTHybridVectorExpression // Multiple vector expressions + Combine *FTHybridCombineOptions // Fusion step options + Load []string // Projected fields + GroupBy *FTHybridGroupBy // Aggregation grouping + Apply []FTHybridApply // Field transformations + SortBy []FTSearchSortBy // Reuse from FTSearch + Filter string // Post-filter expression + LimitOffset int // Result limiting + Limit int + Params map[string]interface{} // Parameter substitution + ExplainScore bool // Include score explanations + Timeout int // Runtime timeout + WithCursor bool // Enable cursor support for large result sets + WithCursorOptions *FTHybridWithCursor // Cursor configuration options } type FTSynDumpResult struct { @@ -391,6 +511,14 @@ type FTAttribute struct { PhoneticMatcher string CaseSensitive bool WithSuffixtrie bool + + // Vector specific attributes + Algorithm string + DataType string + Dim int + DistanceMetric string + M int + EFConstruction int } type CursorStats struct { @@ -425,7 +553,8 @@ type IndexDefinition struct { type FTSpellCheckOptions struct { Distance int Terms *FTSpellCheckTerms - Dialect int + // Dialect 1,3 and 4 are deprecated since redis 8.0 + Dialect int } type FTSpellCheckTerms struct { @@ -455,6 +584,7 @@ type Document struct { Payload *string SortKey *string Fields map[string]string + Error error } type AggregateQuery []interface{} @@ -479,7 +609,7 @@ func (c cmdable) FTAggregate(ctx context.Context, index string, query string) *M return cmd } -func FTAggregateQuery(query string, options *FTAggregateOptions) AggregateQuery { +func FTAggregateQuery(query string, options *FTAggregateOptions) (AggregateQuery, error) { queryArgs := []interface{}{query} if options != nil { if options.Verbatim { @@ -495,7 +625,7 @@ func FTAggregateQuery(query string, options *FTAggregateOptions) AggregateQuery } if options.LoadAll && options.Load != nil { - panic("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive") + return nil, fmt.Errorf("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive") } if options.LoadAll { queryArgs = append(queryArgs, "LOAD", "*") @@ -551,7 +681,7 @@ func FTAggregateQuery(query string, options *FTAggregateOptions) AggregateQuery for _, sortBy := range options.SortBy { sortByOptions = append(sortByOptions, sortBy.FieldName) if sortBy.Asc && sortBy.Desc { - panic("FT.AGGREGATE: ASC and DESC are mutually exclusive") + return nil, fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive") } if sortBy.Asc { sortByOptions = append(sortByOptions, "ASC") @@ -592,9 +722,11 @@ func FTAggregateQuery(query string, options *FTAggregateOptions) AggregateQuery if options.DialectVersion > 0 { queryArgs = append(queryArgs, "DIALECT", options.DialectVersion) + } else { + queryArgs = append(queryArgs, "DIALECT", 2) } } - return queryArgs + return queryArgs, nil } func ProcessAggregateResult(data []interface{}) (*FTAggregateResult, error) { @@ -696,7 +828,9 @@ func (c cmdable) FTAggregateWithArgs(ctx context.Context, index string, query st args = append(args, "ADDSCORES") } if options.LoadAll && options.Load != nil { - panic("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive") + cmd := NewAggregateCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.AGGREGATE: LOADALL and LOAD are mutually exclusive")) + return cmd } if options.LoadAll { args = append(args, "LOAD", "*") @@ -749,7 +883,9 @@ func (c cmdable) FTAggregateWithArgs(ctx context.Context, index string, query st for _, sortBy := range options.SortBy { sortByOptions = append(sortByOptions, sortBy.FieldName) if sortBy.Asc && sortBy.Desc { - panic("FT.AGGREGATE: ASC and DESC are mutually exclusive") + cmd := NewAggregateCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.AGGREGATE: ASC and DESC are mutually exclusive")) + return cmd } if sortBy.Asc { sortByOptions = append(sortByOptions, "ASC") @@ -789,6 +925,8 @@ func (c cmdable) FTAggregateWithArgs(ctx context.Context, index string, query st } if options.DialectVersion > 0 { args = append(args, "DIALECT", options.DialectVersion) + } else { + args = append(args, "DIALECT", 2) } } @@ -846,20 +984,32 @@ func (c cmdable) FTAlter(ctx context.Context, index string, skipInitialScan bool return cmd } -// FTConfigGet - Retrieves the value of a RediSearch configuration parameter. +// Retrieves the value of a RediSearch configuration parameter. // The 'option' parameter specifies the configuration parameter to retrieve. -// For more information, please refer to the Redis documentation: -// [FT.CONFIG GET]: (https://redis.io/commands/ft.config-get/) +// For more information, please refer to the Redis [FT.CONFIG GET] documentation. +// +// Deprecated: FTConfigGet is deprecated in Redis 8. +// All configuration will be done with the CONFIG GET command. +// For more information check [Client.ConfigGet] and [CONFIG GET Documentation] +// +// [CONFIG GET Documentation]: https://redis.io/commands/config-get/ +// [FT.CONFIG GET]: https://redis.io/commands/ft.config-get/ func (c cmdable) FTConfigGet(ctx context.Context, option string) *MapMapStringInterfaceCmd { cmd := NewMapMapStringInterfaceCmd(ctx, "FT.CONFIG", "GET", option) _ = c(ctx, cmd) return cmd } -// FTConfigSet - Sets the value of a RediSearch configuration parameter. +// Sets the value of a RediSearch configuration parameter. // The 'option' parameter specifies the configuration parameter to set, and the 'value' parameter specifies the new value. -// For more information, please refer to the Redis documentation: -// [FT.CONFIG SET]: (https://redis.io/commands/ft.config-set/) +// For more information, please refer to the Redis [FT.CONFIG SET] documentation. +// +// Deprecated: FTConfigSet is deprecated in Redis 8. +// All configuration will be done with the CONFIG SET command. +// For more information check [Client.ConfigSet] and [CONFIG SET Documentation] +// +// [CONFIG SET Documentation]: https://redis.io/commands/config-set/ +// [FT.CONFIG SET]: https://redis.io/commands/ft.config-set/ func (c cmdable) FTConfigSet(ctx context.Context, option string, value interface{}) *StatusCmd { cmd := NewStatusCmd(ctx, "FT.CONFIG", "SET", option, value) _ = c(ctx, cmd) @@ -883,7 +1033,9 @@ func (c cmdable) FTCreate(ctx context.Context, index string, options *FTCreateOp args = append(args, "ON", "JSON") } if options.OnHash && options.OnJSON { - panic("FT.CREATE: ON HASH and ON JSON are mutually exclusive") + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: ON HASH and ON JSON are mutually exclusive")) + return cmd } if options.Prefix != nil { args = append(args, "PREFIX", len(options.Prefix)) @@ -934,12 +1086,16 @@ func (c cmdable) FTCreate(ctx context.Context, index string, options *FTCreateOp } } if schema == nil { - panic("FT.CREATE: SCHEMA is required") + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA is required")) + return cmd } args = append(args, "SCHEMA") for _, schema := range schema { if schema.FieldName == "" || schema.FieldType == SearchFieldTypeInvalid { - panic("FT.CREATE: SCHEMA FieldName and FieldType are required") + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA FieldName and FieldType are required")) + return cmd } args = append(args, schema.FieldName) if schema.As != "" { @@ -948,15 +1104,32 @@ func (c cmdable) FTCreate(ctx context.Context, index string, options *FTCreateOp args = append(args, schema.FieldType.String()) if schema.VectorArgs != nil { if schema.FieldType != SearchFieldTypeVector { - panic("FT.CREATE: SCHEMA FieldType VECTOR is required for VectorArgs") + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA FieldType VECTOR is required for VectorArgs")) + return cmd } - if schema.VectorArgs.FlatOptions != nil && schema.VectorArgs.HNSWOptions != nil { - panic("FT.CREATE: SCHEMA VectorArgs FlatOptions and HNSWOptions are mutually exclusive") + // Check mutual exclusivity of vector options + optionCount := 0 + if schema.VectorArgs.FlatOptions != nil { + optionCount++ + } + if schema.VectorArgs.HNSWOptions != nil { + optionCount++ + } + if schema.VectorArgs.VamanaOptions != nil { + optionCount++ + } + if optionCount != 1 { + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA VectorArgs must have exactly one of FlatOptions, HNSWOptions, or VamanaOptions")) + return cmd } if schema.VectorArgs.FlatOptions != nil { args = append(args, "FLAT") if schema.VectorArgs.FlatOptions.Type == "" || schema.VectorArgs.FlatOptions.Dim == 0 || schema.VectorArgs.FlatOptions.DistanceMetric == "" { - panic("FT.CREATE: Type, Dim and DistanceMetric are required for VECTOR FLAT") + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: Type, Dim and DistanceMetric are required for VECTOR FLAT")) + return cmd } flatArgs := []interface{}{ "TYPE", schema.VectorArgs.FlatOptions.Type, @@ -975,7 +1148,9 @@ func (c cmdable) FTCreate(ctx context.Context, index string, options *FTCreateOp if schema.VectorArgs.HNSWOptions != nil { args = append(args, "HNSW") if schema.VectorArgs.HNSWOptions.Type == "" || schema.VectorArgs.HNSWOptions.Dim == 0 || schema.VectorArgs.HNSWOptions.DistanceMetric == "" { - panic("FT.CREATE: Type, Dim and DistanceMetric are required for VECTOR HNSW") + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: Type, Dim and DistanceMetric are required for VECTOR HNSW")) + return cmd } hnswArgs := []interface{}{ "TYPE", schema.VectorArgs.HNSWOptions.Type, @@ -1000,10 +1175,48 @@ func (c cmdable) FTCreate(ctx context.Context, index string, options *FTCreateOp args = append(args, len(hnswArgs)) args = append(args, hnswArgs...) } + if schema.VectorArgs.VamanaOptions != nil { + args = append(args, "SVS-VAMANA") + if schema.VectorArgs.VamanaOptions.Type == "" || schema.VectorArgs.VamanaOptions.Dim == 0 || schema.VectorArgs.VamanaOptions.DistanceMetric == "" { + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: Type, Dim and DistanceMetric are required for VECTOR VAMANA")) + return cmd + } + vamanaArgs := []interface{}{ + "TYPE", schema.VectorArgs.VamanaOptions.Type, + "DIM", schema.VectorArgs.VamanaOptions.Dim, + "DISTANCE_METRIC", schema.VectorArgs.VamanaOptions.DistanceMetric, + } + if schema.VectorArgs.VamanaOptions.Compression != "" { + vamanaArgs = append(vamanaArgs, "COMPRESSION", schema.VectorArgs.VamanaOptions.Compression) + } + if schema.VectorArgs.VamanaOptions.ConstructionWindowSize > 0 { + vamanaArgs = append(vamanaArgs, "CONSTRUCTION_WINDOW_SIZE", schema.VectorArgs.VamanaOptions.ConstructionWindowSize) + } + if schema.VectorArgs.VamanaOptions.GraphMaxDegree > 0 { + vamanaArgs = append(vamanaArgs, "GRAPH_MAX_DEGREE", schema.VectorArgs.VamanaOptions.GraphMaxDegree) + } + if schema.VectorArgs.VamanaOptions.SearchWindowSize > 0 { + vamanaArgs = append(vamanaArgs, "SEARCH_WINDOW_SIZE", schema.VectorArgs.VamanaOptions.SearchWindowSize) + } + if schema.VectorArgs.VamanaOptions.Epsilon > 0 { + vamanaArgs = append(vamanaArgs, "EPSILON", schema.VectorArgs.VamanaOptions.Epsilon) + } + if schema.VectorArgs.VamanaOptions.TrainingThreshold > 0 { + vamanaArgs = append(vamanaArgs, "TRAINING_THRESHOLD", schema.VectorArgs.VamanaOptions.TrainingThreshold) + } + if schema.VectorArgs.VamanaOptions.ReduceDim > 0 { + vamanaArgs = append(vamanaArgs, "REDUCE", schema.VectorArgs.VamanaOptions.ReduceDim) + } + args = append(args, len(vamanaArgs)) + args = append(args, vamanaArgs...) + } } if schema.GeoShapeFieldType != "" { if schema.FieldType != SearchFieldTypeGeoShape { - panic("FT.CREATE: SCHEMA FieldType GEOSHAPE is required for GeoShapeFieldType") + cmd := NewStatusCmd(ctx, args...) + cmd.SetErr(fmt.Errorf("FT.CREATE: SCHEMA FieldType GEOSHAPE is required for GeoShapeFieldType")) + return cmd } args = append(args, schema.GeoShapeFieldType) } @@ -1150,6 +1363,8 @@ func (c cmdable) FTExplainWithArgs(ctx context.Context, index string, query stri args := []interface{}{"FT.EXPLAIN", index, query} if options.Dialect != "" { args = append(args, "DIALECT", options.Dialect) + } else { + args = append(args, "DIALECT", 2) } cmd := NewStringCmd(ctx, args...) _ = c(ctx, cmd) @@ -1159,7 +1374,7 @@ func (c cmdable) FTExplainWithArgs(ctx context.Context, index string, query stri // FTExplainCli - Returns the execution plan for a complex query. [Not Implemented] // For more information, see https://redis.io/commands/ft.explaincli/ func (c cmdable) FTExplainCli(ctx context.Context, key, path string) error { - panic("not implemented") + return fmt.Errorf("FTExplainCli is not implemented") } func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { @@ -1177,21 +1392,26 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { for _, attr := range attributes { if attrMap, ok := attr.([]interface{}); ok { att := FTAttribute{} - for i := 0; i < len(attrMap); i++ { - if internal.ToLower(internal.ToString(attrMap[i])) == "attribute" { + attrLen := len(attrMap) + for i := 0; i < attrLen; i++ { + if internal.ToLower(internal.ToString(attrMap[i])) == "attribute" && i+1 < attrLen { att.Attribute = internal.ToString(attrMap[i+1]) + i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "identifier" { + if internal.ToLower(internal.ToString(attrMap[i])) == "identifier" && i+1 < attrLen { att.Identifier = internal.ToString(attrMap[i+1]) + i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "type" { + if internal.ToLower(internal.ToString(attrMap[i])) == "type" && i+1 < attrLen { att.Type = internal.ToString(attrMap[i+1]) + i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "weight" { + if internal.ToLower(internal.ToString(attrMap[i])) == "weight" && i+1 < attrLen { att.Weight = internal.ToFloat(attrMap[i+1]) + i++ continue } if internal.ToLower(internal.ToString(attrMap[i])) == "nostem" { @@ -1210,7 +1430,7 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { att.UNF = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "phonetic" { + if internal.ToLower(internal.ToString(attrMap[i])) == "phonetic" && i+1 < attrLen { att.PhoneticMatcher = internal.ToString(attrMap[i+1]) continue } @@ -1223,6 +1443,38 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { continue } + // vector specific attributes + if internal.ToLower(internal.ToString(attrMap[i])) == "algorithm" && i+1 < attrLen { + att.Algorithm = internal.ToString(attrMap[i+1]) + i++ + continue + } + if internal.ToLower(internal.ToString(attrMap[i])) == "data_type" && i+1 < attrLen { + att.DataType = internal.ToString(attrMap[i+1]) + i++ + continue + } + if internal.ToLower(internal.ToString(attrMap[i])) == "dim" && i+1 < attrLen { + att.Dim = internal.ToInteger(attrMap[i+1]) + i++ + continue + } + if internal.ToLower(internal.ToString(attrMap[i])) == "distance_metric" && i+1 < attrLen { + att.DistanceMetric = internal.ToString(attrMap[i+1]) + i++ + continue + } + if internal.ToLower(internal.ToString(attrMap[i])) == "m" && i+1 < attrLen { + att.M = internal.ToInteger(attrMap[i+1]) + i++ + continue + } + if internal.ToLower(internal.ToString(attrMap[i])) == "ef_construction" && i+1 < attrLen { + att.EFConstruction = internal.ToInteger(attrMap[i+1]) + i++ + continue + } + } ftInfo.Attributes = append(ftInfo.Attributes, att) } @@ -1447,6 +1699,8 @@ func (c cmdable) FTSpellCheckWithArgs(ctx context.Context, index string, query s } if options.Dialect > 0 { args = append(args, "DIALECT", options.Dialect) + } else { + args = append(args, "DIALECT", 2) } } cmd := newFTSpellCheckCmd(ctx, args...) @@ -1615,7 +1869,13 @@ func parseFTSearch(data []interface{}, noContent, withScores, withPayloads, with if i < len(data) { fields, ok := data[i].([]interface{}) if !ok { - return FTSearchResult{}, fmt.Errorf("invalid document fields format") + if data[i] == proto.Nil || data[i] == nil { + doc.Error = proto.Nil + doc.Fields = map[string]string{} + fields = []interface{}{} + } else { + return FTSearchResult{}, fmt.Errorf("invalid document fields format") + } } for j := 0; j < len(fields); j += 2 { @@ -1692,6 +1952,207 @@ func (cmd *FTSearchCmd) readReply(rd *proto.Reader) (err error) { return nil } +// FTHybridResult represents the result of a hybrid search operation +type FTHybridResult struct { + TotalResults int + Results []map[string]interface{} + Warnings []string + ExecutionTime float64 +} + +// FTHybridCursorResult represents cursor result for hybrid search +type FTHybridCursorResult struct { + SearchCursorID int + VsimCursorID int +} + +type FTHybridCmd struct { + baseCmd + val FTHybridResult + cursorVal *FTHybridCursorResult + options *FTHybridOptions + withCursor bool +} + +func newFTHybridCmd(ctx context.Context, options *FTHybridOptions, args ...interface{}) *FTHybridCmd { + var withCursor bool + if options != nil && options.WithCursor { + withCursor = true + } + return &FTHybridCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + options: options, + withCursor: withCursor, + } +} + +func (cmd *FTHybridCmd) String() string { + return cmdString(cmd, cmd.val) +} + +func (cmd *FTHybridCmd) SetVal(val FTHybridResult) { + cmd.val = val +} + +func (cmd *FTHybridCmd) Result() (FTHybridResult, error) { + return cmd.val, cmd.err +} + +func (cmd *FTHybridCmd) CursorResult() (*FTHybridCursorResult, error) { + return cmd.cursorVal, cmd.err +} + +func (cmd *FTHybridCmd) Val() FTHybridResult { + return cmd.val +} + +func (cmd *FTHybridCmd) CursorVal() *FTHybridCursorResult { + return cmd.cursorVal +} + +func (cmd *FTHybridCmd) RawVal() interface{} { + return cmd.rawVal +} + +func (cmd *FTHybridCmd) RawResult() (interface{}, error) { + return cmd.rawVal, cmd.err +} + +func parseFTHybrid(data []interface{}, withCursor bool) (FTHybridResult, *FTHybridCursorResult, error) { + // Convert to map + resultMap := make(map[string]interface{}) + for i := 0; i < len(data); i += 2 { + if i+1 < len(data) { + key, ok := data[i].(string) + if !ok { + return FTHybridResult{}, nil, fmt.Errorf("invalid key type at index %d", i) + } + resultMap[key] = data[i+1] + } + } + + // Handle cursor result + if withCursor { + searchCursorID, ok1 := resultMap["SEARCH"].(int64) + vsimCursorID, ok2 := resultMap["VSIM"].(int64) + if !ok1 || !ok2 { + return FTHybridResult{}, nil, fmt.Errorf("invalid cursor result format") + } + return FTHybridResult{}, &FTHybridCursorResult{ + SearchCursorID: int(searchCursorID), + VsimCursorID: int(vsimCursorID), + }, nil + } + + // Parse regular result + totalResults, ok := resultMap["total_results"].(int64) + if !ok { + return FTHybridResult{}, nil, fmt.Errorf("invalid total_results format") + } + + resultsData, ok := resultMap["results"].([]interface{}) + if !ok { + return FTHybridResult{}, nil, fmt.Errorf("invalid results format") + } + + // Parse each result item + results := make([]map[string]interface{}, 0, len(resultsData)) + for _, item := range resultsData { + // Try parsing as map[string]interface{} first (RESP3 format) + if itemMap, ok := item.(map[string]interface{}); ok { + results = append(results, itemMap) + continue + } + + // Try parsing as map[interface{}]interface{} (alternative RESP3 format) + if rawMap, ok := item.(map[interface{}]interface{}); ok { + itemMap := make(map[string]interface{}) + for k, v := range rawMap { + if keyStr, ok := k.(string); ok { + itemMap[keyStr] = v + } + } + results = append(results, itemMap) + continue + } + + // Fall back to array format (RESP2 format - key-value pairs) + itemData, ok := item.([]interface{}) + if !ok { + return FTHybridResult{}, nil, fmt.Errorf("invalid result item format") + } + + itemMap := make(map[string]interface{}) + for i := 0; i < len(itemData); i += 2 { + if i+1 < len(itemData) { + key, ok := itemData[i].(string) + if !ok { + return FTHybridResult{}, nil, fmt.Errorf("invalid item key format") + } + itemMap[key] = itemData[i+1] + } + } + results = append(results, itemMap) + } + + // Parse warnings (optional field) + var warnings []string + if warningsData, ok := resultMap["warnings"].([]interface{}); ok { + warnings = make([]string, 0, len(warningsData)) + for _, w := range warningsData { + if ws, ok := w.(string); ok { + warnings = append(warnings, ws) + } + } + } + + // Parse execution time (optional field) + var executionTime float64 + if execTimeVal, exists := resultMap["execution_time"]; exists { + switch v := execTimeVal.(type) { + case string: + var err error + executionTime, err = strconv.ParseFloat(v, 64) + if err != nil { + return FTHybridResult{}, nil, fmt.Errorf("invalid execution_time format: %v", err) + } + case float64: + executionTime = v + case int64: + executionTime = float64(v) + } + } + + return FTHybridResult{ + TotalResults: int(totalResults), + Results: results, + Warnings: warnings, + ExecutionTime: executionTime, + }, nil, nil +} + +func (cmd *FTHybridCmd) readReply(rd *proto.Reader) (err error) { + data, err := rd.ReadSlice() + if err != nil { + return err + } + + result, cursorResult, err := parseFTHybrid(data, cmd.withCursor) + if err != nil { + return err + } + + if cmd.withCursor { + cmd.cursorVal = cursorResult + } else { + cmd.val = result + } + return nil +} + // FTSearch - Executes a search query on an index. // The 'index' parameter specifies the index to search, and the 'query' parameter specifies the search query. // For more information, please refer to the Redis documentation about [FT.SEARCH]. @@ -1712,7 +2173,7 @@ type SearchQuery []interface{} // For more information, please refer to the Redis documentation about [FT.SEARCH]. // // [FT.SEARCH]: (https://redis.io/commands/ft.search/) -func FTSearchQuery(query string, options *FTSearchOptions) SearchQuery { +func FTSearchQuery(query string, options *FTSearchOptions) (SearchQuery, error) { queryArgs := []interface{}{query} if options != nil { if options.NoContent { @@ -1792,7 +2253,7 @@ func FTSearchQuery(query string, options *FTSearchOptions) SearchQuery { for _, sortBy := range options.SortBy { queryArgs = append(queryArgs, sortBy.FieldName) if sortBy.Asc && sortBy.Desc { - panic("FT.SEARCH: ASC and DESC are mutually exclusive") + return nil, fmt.Errorf("FT.SEARCH: ASC and DESC are mutually exclusive") } if sortBy.Asc { queryArgs = append(queryArgs, "ASC") @@ -1816,9 +2277,11 @@ func FTSearchQuery(query string, options *FTSearchOptions) SearchQuery { } if options.DialectVersion > 0 { queryArgs = append(queryArgs, "DIALECT", options.DialectVersion) + } else { + queryArgs = append(queryArgs, "DIALECT", 2) } } - return queryArgs + return queryArgs, nil } // FTSearchWithArgs - Executes a search query on an index with additional options. @@ -1907,7 +2370,9 @@ func (c cmdable) FTSearchWithArgs(ctx context.Context, index string, query strin for _, sortBy := range options.SortBy { args = append(args, sortBy.FieldName) if sortBy.Asc && sortBy.Desc { - panic("FT.SEARCH: ASC and DESC are mutually exclusive") + cmd := newFTSearchCmd(ctx, options, args...) + cmd.SetErr(fmt.Errorf("FT.SEARCH: ASC and DESC are mutually exclusive")) + return cmd } if sortBy.Asc { args = append(args, "ASC") @@ -1920,8 +2385,12 @@ func (c cmdable) FTSearchWithArgs(ctx context.Context, index string, query strin args = append(args, "WITHCOUNT") } } - if options.LimitOffset >= 0 && options.Limit > 0 { - args = append(args, "LIMIT", options.LimitOffset, options.Limit) + if options.CountOnly { + args = append(args, "LIMIT", 0, 0) + } else { + if options.LimitOffset >= 0 && options.Limit > 0 || options.LimitOffset > 0 && options.Limit == 0 { + args = append(args, "LIMIT", options.LimitOffset, options.Limit) + } } if options.Params != nil { args = append(args, "PARAMS", len(options.Params)*2) @@ -1931,6 +2400,8 @@ func (c cmdable) FTSearchWithArgs(ctx context.Context, index string, query strin } if options.DialectVersion > 0 { args = append(args, "DIALECT", options.DialectVersion) + } else { + args = append(args, "DIALECT", 2) } } cmd := newFTSearchCmd(ctx, options, args...) @@ -2055,214 +2526,203 @@ func (c cmdable) FTTagVals(ctx context.Context, index string, field string) *Str return cmd } -// type FTProfileResult struct { -// Results []interface{} -// Profile ProfileDetails -// } +// FTHybrid - Executes a hybrid search combining full-text search and vector similarity +// The 'index' parameter specifies the index to search, 'searchExpr' is the search query, +// 'vectorField' is the name of the vector field, and 'vectorData' is the vector to search with. +// FTHybrid is still experimental, the command behaviour and signature may change +func (c cmdable) FTHybrid(ctx context.Context, index string, searchExpr string, vectorField string, vectorData Vector) *FTHybridCmd { + options := &FTHybridOptions{ + CountExpressions: 2, + SearchExpressions: []FTHybridSearchExpression{ + {Query: searchExpr}, + }, + VectorExpressions: []FTHybridVectorExpression{ + {VectorField: vectorField, VectorData: vectorData}, + }, + } + return c.FTHybridWithArgs(ctx, index, options) +} -// type ProfileDetails struct { -// TotalProfileTime string -// ParsingTime string -// PipelineCreationTime string -// Warning string -// IteratorsProfile []IteratorProfile -// ResultProcessorsProfile []ResultProcessorProfile -// } +// FTHybridWithArgs - Executes a hybrid search with advanced options +// FTHybridWithArgs is still experimental, the command behaviour and signature may change +func (c cmdable) FTHybridWithArgs(ctx context.Context, index string, options *FTHybridOptions) *FTHybridCmd { + args := []interface{}{"FT.HYBRID", index} -// type IteratorProfile struct { -// Type string -// QueryType string -// Time interface{} -// Counter int -// Term string -// Size int -// ChildIterators []IteratorProfile -// } + if options != nil { + // Add search expressions + for _, searchExpr := range options.SearchExpressions { + args = append(args, "SEARCH", searchExpr.Query) -// type ResultProcessorProfile struct { -// Type string -// Time interface{} -// Counter int -// } + if searchExpr.Scorer != "" { + args = append(args, "SCORER", searchExpr.Scorer) + if len(searchExpr.ScorerParams) > 0 { + args = append(args, searchExpr.ScorerParams...) + } + } -// func parseFTProfileResult(data []interface{}) (FTProfileResult, error) { -// var result FTProfileResult -// if len(data) < 2 { -// return result, fmt.Errorf("unexpected data length") -// } + if searchExpr.YieldScoreAs != "" { + args = append(args, "YIELD_SCORE_AS", searchExpr.YieldScoreAs) + } + } -// // Parse results -// result.Results = data[0].([]interface{}) + // Add vector expressions + for _, vectorExpr := range options.VectorExpressions { + args = append(args, "VSIM", "@"+vectorExpr.VectorField) -// // Parse profile details -// profileData := data[1].([]interface{}) -// profileDetails := ProfileDetails{} -// for i := 0; i < len(profileData); i += 2 { -// switch profileData[i].(string) { -// case "Total profile time": -// profileDetails.TotalProfileTime = profileData[i+1].(string) -// case "Parsing time": -// profileDetails.ParsingTime = profileData[i+1].(string) -// case "Pipeline creation time": -// profileDetails.PipelineCreationTime = profileData[i+1].(string) -// case "Warning": -// profileDetails.Warning = profileData[i+1].(string) -// case "Iterators profile": -// profileDetails.IteratorsProfile = parseIteratorsProfile(profileData[i+1].([]interface{})) -// case "Result processors profile": -// profileDetails.ResultProcessorsProfile = parseResultProcessorsProfile(profileData[i+1].([]interface{})) -// } -// } + // For FT.HYBRID, we need to send just the raw vector bytes, not the Value() format + // Value() returns [format, data] but FT.HYBRID expects just the blob + vectorValue := vectorExpr.VectorData.Value() + if len(vectorValue) >= 2 { + // vectorValue is [format, data, ...] - we only want the data part + args = append(args, vectorValue[1]) + } else { + // Fallback for unexpected format + args = append(args, vectorValue...) + } -// result.Profile = profileDetails -// return result, nil -// } + if vectorExpr.Method != "" { + args = append(args, vectorExpr.Method) + if len(vectorExpr.MethodParams) > 0 { + // MethodParams should be key-value pairs, count them + args = append(args, len(vectorExpr.MethodParams)) + args = append(args, vectorExpr.MethodParams...) + } + } -// func parseIteratorsProfile(data []interface{}) []IteratorProfile { -// var iterators []IteratorProfile -// for _, item := range data { -// profile := item.([]interface{}) -// iterator := IteratorProfile{} -// for i := 0; i < len(profile); i += 2 { -// switch profile[i].(string) { -// case "Type": -// iterator.Type = profile[i+1].(string) -// case "Query type": -// iterator.QueryType = profile[i+1].(string) -// case "Time": -// iterator.Time = profile[i+1] -// case "Counter": -// iterator.Counter = int(profile[i+1].(int64)) -// case "Term": -// iterator.Term = profile[i+1].(string) -// case "Size": -// iterator.Size = int(profile[i+1].(int64)) -// case "Child iterators": -// iterator.ChildIterators = parseChildIteratorsProfile(profile[i+1].([]interface{})) -// } -// } -// iterators = append(iterators, iterator) -// } -// return iterators -// } + if vectorExpr.Filter != "" { + args = append(args, "FILTER", vectorExpr.Filter) + } -// func parseChildIteratorsProfile(data []interface{}) []IteratorProfile { -// var iterators []IteratorProfile -// for _, item := range data { -// profile := item.([]interface{}) -// iterator := IteratorProfile{} -// for i := 0; i < len(profile); i += 2 { -// switch profile[i].(string) { -// case "Type": -// iterator.Type = profile[i+1].(string) -// case "Query type": -// iterator.QueryType = profile[i+1].(string) -// case "Time": -// iterator.Time = profile[i+1] -// case "Counter": -// iterator.Counter = int(profile[i+1].(int64)) -// case "Term": -// iterator.Term = profile[i+1].(string) -// case "Size": -// iterator.Size = int(profile[i+1].(int64)) -// } -// } -// iterators = append(iterators, iterator) -// } -// return iterators -// } + if vectorExpr.YieldScoreAs != "" { + args = append(args, "YIELD_SCORE_AS", vectorExpr.YieldScoreAs) + } + } -// func parseResultProcessorsProfile(data []interface{}) []ResultProcessorProfile { -// var processors []ResultProcessorProfile -// for _, item := range data { -// profile := item.([]interface{}) -// processor := ResultProcessorProfile{} -// for i := 0; i < len(profile); i += 2 { -// switch profile[i].(string) { -// case "Type": -// processor.Type = profile[i+1].(string) -// case "Time": -// processor.Time = profile[i+1] -// case "Counter": -// processor.Counter = int(profile[i+1].(int64)) -// } -// } -// processors = append(processors, processor) -// } -// return processors -// } + // Add combine/fusion options + if options.Combine != nil { + // Build combine parameters + combineParams := []interface{}{} -// func NewFTProfileCmd(ctx context.Context, args ...interface{}) *FTProfileCmd { -// return &FTProfileCmd{ -// baseCmd: baseCmd{ -// ctx: ctx, -// args: args, -// }, -// } -// } + switch options.Combine.Method { + case FTHybridCombineRRF: + if options.Combine.Window > 0 { + combineParams = append(combineParams, "WINDOW", options.Combine.Window) + } + if options.Combine.Constant > 0 { + combineParams = append(combineParams, "CONSTANT", options.Combine.Constant) + } + case FTHybridCombineLinear: + if options.Combine.Alpha > 0 { + combineParams = append(combineParams, "ALPHA", options.Combine.Alpha) + } + if options.Combine.Beta > 0 { + combineParams = append(combineParams, "BETA", options.Combine.Beta) + } + } -// type FTProfileCmd struct { -// baseCmd -// val FTProfileResult -// } + if options.Combine.YieldScoreAs != "" { + combineParams = append(combineParams, "YIELD_SCORE_AS", options.Combine.YieldScoreAs) + } -// func (cmd *FTProfileCmd) String() string { -// return cmdString(cmd, cmd.val) -// } + // Add COMBINE with method and parameter count + args = append(args, "COMBINE", string(options.Combine.Method)) + if len(combineParams) > 0 { + args = append(args, len(combineParams)) + args = append(args, combineParams...) + } + } -// func (cmd *FTProfileCmd) SetVal(val FTProfileResult) { -// cmd.val = val -// } + // Add LOAD (projected fields) + if len(options.Load) > 0 { + args = append(args, "LOAD", len(options.Load)) + for _, field := range options.Load { + args = append(args, field) + } + } -// func (cmd *FTProfileCmd) Result() (FTProfileResult, error) { -// return cmd.val, cmd.err -// } + // Add GROUPBY + if options.GroupBy != nil { + args = append(args, "GROUPBY", options.GroupBy.Count) + for _, field := range options.GroupBy.Fields { + args = append(args, field) + } + if options.GroupBy.ReduceFunc != "" { + args = append(args, "REDUCE", options.GroupBy.ReduceFunc, options.GroupBy.ReduceCount) + args = append(args, options.GroupBy.ReduceParams...) + } + } -// func (cmd *FTProfileCmd) Val() FTProfileResult { -// return cmd.val -// } + // Add APPLY transformations + for _, apply := range options.Apply { + args = append(args, "APPLY", apply.Expression, "AS", apply.AsField) + } -// func (cmd *FTProfileCmd) readReply(rd *proto.Reader) (err error) { -// data, err := rd.ReadSlice() -// if err != nil { -// return err -// } -// cmd.val, err = parseFTProfileResult(data) -// if err != nil { -// cmd.err = err -// } -// return nil -// } + // Add SORTBY + if len(options.SortBy) > 0 { + sortByOptions := []interface{}{} + for _, sortBy := range options.SortBy { + sortByOptions = append(sortByOptions, sortBy.FieldName) + if sortBy.Asc && sortBy.Desc { + cmd := newFTHybridCmd(ctx, options, args...) + cmd.SetErr(fmt.Errorf("FT.HYBRID: ASC and DESC are mutually exclusive")) + return cmd + } + if sortBy.Asc { + sortByOptions = append(sortByOptions, "ASC") + } + if sortBy.Desc { + sortByOptions = append(sortByOptions, "DESC") + } + } + args = append(args, "SORTBY", len(sortByOptions)) + args = append(args, sortByOptions...) + } -// // FTProfile - Executes a search query and returns a profile of how the query was processed. -// // The 'index' parameter specifies the index to search, the 'limited' parameter specifies whether to limit the results, -// // and the 'query' parameter specifies the search / aggreagte query. Please notice that you must either pass a SearchQuery or an AggregateQuery. -// // For more information, please refer to the Redis documentation: -// // [FT.PROFILE]: (https://redis.io/commands/ft.profile/) -// func (c cmdable) FTProfile(ctx context.Context, index string, limited bool, query interface{}) *FTProfileCmd { -// queryType := "" -// var argsQuery []interface{} + // Add FILTER (post-filter) + if options.Filter != "" { + args = append(args, "FILTER", options.Filter) + } -// switch v := query.(type) { -// case AggregateQuery: -// queryType = "AGGREGATE" -// argsQuery = v -// case SearchQuery: -// queryType = "SEARCH" -// argsQuery = v -// default: -// panic("FT.PROFILE: query must be either AggregateQuery or SearchQuery") -// } + // Add LIMIT + if options.LimitOffset >= 0 && options.Limit > 0 || options.LimitOffset > 0 && options.Limit == 0 { + args = append(args, "LIMIT", options.LimitOffset, options.Limit) + } -// args := []interface{}{"FT.PROFILE", index, queryType} + // Add PARAMS + if len(options.Params) > 0 { + args = append(args, "PARAMS", len(options.Params)*2) + for key, value := range options.Params { + // Parameter keys should already have '$' prefix from the user + // Don't add it again if it's already there + args = append(args, key, value) + } + } -// if limited { -// args = append(args, "LIMITED") -// } -// args = append(args, "QUERY") -// args = append(args, argsQuery...) + // Add EXPLAINSCORE + if options.ExplainScore { + args = append(args, "EXPLAINSCORE") + } -// cmd := NewFTProfileCmd(ctx, args...) -// _ = c(ctx, cmd) -// return cmd -// } + // Add TIMEOUT + if options.Timeout > 0 { + args = append(args, "TIMEOUT", options.Timeout) + } + + // Add WITHCURSOR support + if options.WithCursor { + args = append(args, "WITHCURSOR") + if options.WithCursorOptions != nil { + if options.WithCursorOptions.Count > 0 { + args = append(args, "COUNT", options.WithCursorOptions.Count) + } + if options.WithCursorOptions.MaxIdle > 0 { + args = append(args, "MAXIDLE", options.WithCursorOptions.MaxIdle) + } + } + } + } + + cmd := newFTHybridCmd(ctx, options, args...) + _ = c(ctx, cmd) + return cmd +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/sentinel.go b/backend/vendor/github.com/redis/go-redis/v9/sentinel.go index a4c9f53c..663f7b1a 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/sentinel.go +++ b/backend/vendor/github.com/redis/go-redis/v9/sentinel.go @@ -4,14 +4,21 @@ import ( "context" "crypto/tls" "errors" + "fmt" "net" + "net/url" + "strconv" "strings" "sync" "time" + "github.com/redis/go-redis/v9/auth" "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/pool" "github.com/redis/go-redis/v9/internal/rand" + "github.com/redis/go-redis/v9/internal/util" + "github.com/redis/go-redis/v9/maintnotifications" + "github.com/redis/go-redis/v9/push" ) //------------------------------------------------------------------------------ @@ -57,7 +64,26 @@ type FailoverOptions struct { Protocol int Username string Password string - DB int + + // Push notifications are always enabled for RESP3 connections + // CredentialsProvider allows the username and password to be updated + // before reconnecting. It should return the current username and password. + CredentialsProvider func() (username string, password string) + + // CredentialsProviderContext is an enhanced parameter of CredentialsProvider, + // done to maintain API compatibility. In the future, + // there might be a merge between CredentialsProviderContext and CredentialsProvider. + // There will be a conflict between them; if CredentialsProviderContext exists, we will ignore CredentialsProvider. + CredentialsProviderContext func(ctx context.Context) (username string, password string, err error) + + // StreamingCredentialsProvider is used to retrieve the credentials + // for the connection from an external source. Those credentials may change + // during the connection lifetime. This is useful for managed identity + // scenarios where the credentials are retrieved from an external source. + // + // Currently, this is a placeholder for the future implementation. + StreamingCredentialsProvider auth.StreamingCredentialsProvider + DB int MaxRetries int MinRetryBackoff time.Duration @@ -68,6 +94,20 @@ type FailoverOptions struct { WriteTimeout time.Duration ContextTimeoutEnabled bool + // ReadBufferSize is the size of the bufio.Reader buffer for each connection. + // Larger buffers can improve performance for commands that return large responses. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + ReadBufferSize int + + // WriteBufferSize is the size of the bufio.Writer buffer for each connection. + // Larger buffers can improve performance for large pipelines and commands with many arguments. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + WriteBufferSize int + PoolFIFO bool PoolSize int @@ -93,7 +133,22 @@ type FailoverOptions struct { DisableIdentity bool IdentitySuffix string - UnstableResp3 bool + + // FailingTimeoutSeconds is the timeout in seconds for marking a cluster node as failing. + // When a node is marked as failing, it will be avoided for this duration. + // Only applies to failover cluster clients. Default is 15 seconds. + FailingTimeoutSeconds int + + UnstableResp3 bool + + // MaintNotificationsConfig is not supported for FailoverClients at the moment + // MaintNotificationsConfig provides custom configuration for maintnotifications upgrades. + // When MaintNotificationsConfig.Mode is not "disabled", the client will handle + // upgrade notifications gracefully and manage connection/pool state transitions + // seamlessly. Requires Protocol: 3 (RESP3) for push notifications. + // If nil, maintnotifications upgrades are disabled. + // (however if Mode is nil, it defaults to "auto" - enable if server supports it) + //MaintNotificationsConfig *maintnotifications.Config } func (opt *FailoverOptions) clientOptions() *Options { @@ -104,15 +159,21 @@ func (opt *FailoverOptions) clientOptions() *Options { Dialer: opt.Dialer, OnConnect: opt.OnConnect, - DB: opt.DB, - Protocol: opt.Protocol, - Username: opt.Username, - Password: opt.Password, + DB: opt.DB, + Protocol: opt.Protocol, + Username: opt.Username, + Password: opt.Password, + CredentialsProvider: opt.CredentialsProvider, + CredentialsProviderContext: opt.CredentialsProviderContext, + StreamingCredentialsProvider: opt.StreamingCredentialsProvider, MaxRetries: opt.MaxRetries, MinRetryBackoff: opt.MinRetryBackoff, MaxRetryBackoff: opt.MaxRetryBackoff, + ReadBufferSize: opt.ReadBufferSize, + WriteBufferSize: opt.WriteBufferSize, + DialTimeout: opt.DialTimeout, ReadTimeout: opt.ReadTimeout, WriteTimeout: opt.WriteTimeout, @@ -134,6 +195,10 @@ func (opt *FailoverOptions) clientOptions() *Options { IdentitySuffix: opt.IdentitySuffix, UnstableResp3: opt.UnstableResp3, + + MaintNotificationsConfig: &maintnotifications.Config{ + Mode: maintnotifications.ModeDisabled, + }, } } @@ -153,6 +218,10 @@ func (opt *FailoverOptions) sentinelOptions(addr string) *Options { MinRetryBackoff: opt.MinRetryBackoff, MaxRetryBackoff: opt.MaxRetryBackoff, + // The sentinel client uses a 4KiB read/write buffer size. + ReadBufferSize: 4096, + WriteBufferSize: 4096, + DialTimeout: opt.DialTimeout, ReadTimeout: opt.ReadTimeout, WriteTimeout: opt.WriteTimeout, @@ -174,6 +243,10 @@ func (opt *FailoverOptions) sentinelOptions(addr string) *Options { IdentitySuffix: opt.IdentitySuffix, UnstableResp3: opt.UnstableResp3, + + MaintNotificationsConfig: &maintnotifications.Config{ + Mode: maintnotifications.ModeDisabled, + }, } } @@ -184,18 +257,25 @@ func (opt *FailoverOptions) clusterOptions() *ClusterOptions { Dialer: opt.Dialer, OnConnect: opt.OnConnect, - Protocol: opt.Protocol, - Username: opt.Username, - Password: opt.Password, + Protocol: opt.Protocol, + Username: opt.Username, + Password: opt.Password, + CredentialsProvider: opt.CredentialsProvider, + CredentialsProviderContext: opt.CredentialsProviderContext, + StreamingCredentialsProvider: opt.StreamingCredentialsProvider, MaxRedirects: opt.MaxRetries, + ReadOnly: opt.ReplicaOnly, RouteByLatency: opt.RouteByLatency, RouteRandomly: opt.RouteRandomly, MinRetryBackoff: opt.MinRetryBackoff, MaxRetryBackoff: opt.MaxRetryBackoff, + ReadBufferSize: opt.ReadBufferSize, + WriteBufferSize: opt.WriteBufferSize, + DialTimeout: opt.DialTimeout, ReadTimeout: opt.ReadTimeout, WriteTimeout: opt.WriteTimeout, @@ -212,17 +292,170 @@ func (opt *FailoverOptions) clusterOptions() *ClusterOptions { TLSConfig: opt.TLSConfig, - DisableIdentity: opt.DisableIdentity, - DisableIndentity: opt.DisableIndentity, + DisableIdentity: opt.DisableIdentity, + DisableIndentity: opt.DisableIndentity, + IdentitySuffix: opt.IdentitySuffix, + FailingTimeoutSeconds: opt.FailingTimeoutSeconds, - IdentitySuffix: opt.IdentitySuffix, + MaintNotificationsConfig: &maintnotifications.Config{ + Mode: maintnotifications.ModeDisabled, + }, } } +// ParseFailoverURL parses a URL into FailoverOptions that can be used to connect to Redis. +// The URL must be in the form: +// +// redis://:@:/ +// or +// rediss://:@:/ +// +// To add additional addresses, specify the query parameter, "addr" one or more times. e.g: +// +// redis://:@:/?addr=:&addr=: +// or +// rediss://:@:/?addr=:&addr=: +// +// Most Option fields can be set using query parameters, with the following restrictions: +// - field names are mapped using snake-case conversion: to set MaxRetries, use max_retries +// - only scalar type fields are supported (bool, int, time.Duration) +// - for time.Duration fields, values must be a valid input for time.ParseDuration(); +// additionally a plain integer as value (i.e. without unit) is interpreted as seconds +// - to disable a duration field, use value less than or equal to 0; to use the default +// value, leave the value blank or remove the parameter +// - only the last value is interpreted if a parameter is given multiple times +// - fields "network", "addr", "sentinel_username" and "sentinel_password" can only be set using other +// URL attributes (scheme, host, userinfo, resp.), query parameters using these +// names will be treated as unknown parameters +// - unknown parameter names will result in an error +// - use "skip_verify=true" to ignore TLS certificate validation +// +// Example: +// +// redis://user:password@localhost:6789?master_name=mymaster&dial_timeout=3&read_timeout=6s&addr=localhost:6790&addr=localhost:6791 +// is equivalent to: +// &FailoverOptions{ +// MasterName: "mymaster", +// Addr: ["localhost:6789", "localhost:6790", "localhost:6791"] +// DialTimeout: 3 * time.Second, // no time unit = seconds +// ReadTimeout: 6 * time.Second, +// } +func ParseFailoverURL(redisURL string) (*FailoverOptions, error) { + u, err := url.Parse(redisURL) + if err != nil { + return nil, err + } + return setupFailoverConn(u) +} + +func setupFailoverConn(u *url.URL) (*FailoverOptions, error) { + o := &FailoverOptions{} + + o.SentinelUsername, o.SentinelPassword = getUserPassword(u) + + h, p := getHostPortWithDefaults(u) + o.SentinelAddrs = append(o.SentinelAddrs, net.JoinHostPort(h, p)) + + switch u.Scheme { + case "rediss": + o.TLSConfig = &tls.Config{ServerName: h, MinVersion: tls.VersionTLS12} + case "redis": + o.TLSConfig = nil + default: + return nil, fmt.Errorf("redis: invalid URL scheme: %s", u.Scheme) + } + + f := strings.FieldsFunc(u.Path, func(r rune) bool { + return r == '/' + }) + switch len(f) { + case 0: + o.DB = 0 + case 1: + var err error + if o.DB, err = strconv.Atoi(f[0]); err != nil { + return nil, fmt.Errorf("redis: invalid database number: %q", f[0]) + } + default: + return nil, fmt.Errorf("redis: invalid URL path: %s", u.Path) + } + + return setupFailoverConnParams(u, o) +} + +func setupFailoverConnParams(u *url.URL, o *FailoverOptions) (*FailoverOptions, error) { + q := queryOptions{q: u.Query()} + + o.MasterName = q.string("master_name") + o.ClientName = q.string("client_name") + o.RouteByLatency = q.bool("route_by_latency") + o.RouteRandomly = q.bool("route_randomly") + o.ReplicaOnly = q.bool("replica_only") + o.UseDisconnectedReplicas = q.bool("use_disconnected_replicas") + o.Protocol = q.int("protocol") + o.Username = q.string("username") + o.Password = q.string("password") + o.MaxRetries = q.int("max_retries") + o.MinRetryBackoff = q.duration("min_retry_backoff") + o.MaxRetryBackoff = q.duration("max_retry_backoff") + o.DialTimeout = q.duration("dial_timeout") + o.ReadTimeout = q.duration("read_timeout") + o.WriteTimeout = q.duration("write_timeout") + o.ContextTimeoutEnabled = q.bool("context_timeout_enabled") + o.PoolFIFO = q.bool("pool_fifo") + o.PoolSize = q.int("pool_size") + o.MinIdleConns = q.int("min_idle_conns") + o.MaxIdleConns = q.int("max_idle_conns") + o.MaxActiveConns = q.int("max_active_conns") + o.ConnMaxLifetime = q.duration("conn_max_lifetime") + o.ConnMaxIdleTime = q.duration("conn_max_idle_time") + o.PoolTimeout = q.duration("pool_timeout") + o.DisableIdentity = q.bool("disableIdentity") + o.IdentitySuffix = q.string("identitySuffix") + o.UnstableResp3 = q.bool("unstable_resp3") + + if q.err != nil { + return nil, q.err + } + + if tmp := q.string("db"); tmp != "" { + db, err := strconv.Atoi(tmp) + if err != nil { + return nil, fmt.Errorf("redis: invalid database number: %w", err) + } + o.DB = db + } + + addrs := q.strings("addr") + for _, addr := range addrs { + h, p, err := net.SplitHostPort(addr) + if err != nil || h == "" || p == "" { + return nil, fmt.Errorf("redis: unable to parse addr param: %s", addr) + } + + o.SentinelAddrs = append(o.SentinelAddrs, net.JoinHostPort(h, p)) + } + + if o.TLSConfig != nil && q.has("skip_verify") { + o.TLSConfig.InsecureSkipVerify = q.bool("skip_verify") + } + + // any parameters left? + if r := q.remaining(); len(r) > 0 { + return nil, fmt.Errorf("redis: unexpected option: %s", strings.Join(r, ", ")) + } + + return o, nil +} + // NewFailoverClient returns a Redis client that uses Redis Sentinel // for automatic failover. It's safe for concurrent use by multiple // goroutines. func NewFailoverClient(failoverOpt *FailoverOptions) *Client { + if failoverOpt == nil { + panic("redis: NewFailoverClient nil options") + } + if failoverOpt.RouteByLatency { panic("to route commands by latency, use NewFailoverClusterClient") } @@ -246,8 +479,6 @@ func NewFailoverClient(failoverOpt *FailoverOptions) *Client { opt.Dialer = masterReplicaDialer(failover) opt.init() - var connPool *pool.ConnPool - rdb := &Client{ baseClient: &baseClient{ opt: opt, @@ -255,15 +486,29 @@ func NewFailoverClient(failoverOpt *FailoverOptions) *Client { } rdb.init() - connPool = newConnPool(opt, rdb.dialHook) - rdb.connPool = connPool - rdb.onClose = failover.Close + // Initialize push notification processor using shared helper + // Use void processor by default for RESP2 connections + rdb.pushProcessor = initializePushProcessor(opt) + + var err error + rdb.connPool, err = newConnPool(opt, rdb.dialHook) + if err != nil { + panic(fmt.Errorf("redis: failed to create connection pool: %w", err)) + } + rdb.pubSubPool, err = newPubSubPool(opt, rdb.dialHook) + if err != nil { + panic(fmt.Errorf("redis: failed to create pubsub pool: %w", err)) + } + + rdb.onClose = rdb.wrappedOnClose(failover.Close) failover.mu.Lock() failover.onFailover = func(ctx context.Context, addr string) { - _ = connPool.Filter(func(cn *pool.Conn) bool { - return cn.RemoteAddr().String() != addr - }) + if connPool, ok := rdb.connPool.(*pool.ConnPool); ok { + _ = connPool.Filter(func(cn *pool.Conn) bool { + return cn.RemoteAddr().String() != addr + }) + } } failover.mu.Unlock() @@ -308,10 +553,12 @@ func masterReplicaDialer( // SentinelClient is a client for a Redis Sentinel. type SentinelClient struct { *baseClient - hooksMixin } func NewSentinelClient(opt *Options) *SentinelClient { + if opt == nil { + panic("redis: NewSentinelClient nil options") + } opt.init() c := &SentinelClient{ baseClient: &baseClient{ @@ -319,15 +566,40 @@ func NewSentinelClient(opt *Options) *SentinelClient { }, } + // Initialize push notification processor using shared helper + // Use void processor for Sentinel clients + c.pushProcessor = NewVoidPushNotificationProcessor() + c.initHooks(hooks{ dial: c.baseClient.dial, process: c.baseClient.process, }) - c.connPool = newConnPool(opt, c.dialHook) + var err error + c.connPool, err = newConnPool(opt, c.dialHook) + if err != nil { + panic(fmt.Errorf("redis: failed to create connection pool: %w", err)) + } + c.pubSubPool, err = newPubSubPool(opt, c.dialHook) + if err != nil { + panic(fmt.Errorf("redis: failed to create pubsub pool: %w", err)) + } return c } +// GetPushNotificationHandler returns the handler for a specific push notification name. +// Returns nil if no handler is registered for the given name. +func (c *SentinelClient) GetPushNotificationHandler(pushNotificationName string) push.NotificationHandler { + return c.pushProcessor.GetHandler(pushNotificationName) +} + +// RegisterPushNotificationHandler registers a handler for a specific push notification name. +// Returns an error if a handler is already registered for this push notification name. +// If protected is true, the handler cannot be unregistered. +func (c *SentinelClient) RegisterPushNotificationHandler(pushNotificationName string, handler push.NotificationHandler, protected bool) error { + return c.pushProcessor.RegisterHandler(pushNotificationName, handler, protected) +} + func (c *SentinelClient) Process(ctx context.Context, cmd Cmder) error { err := c.processHook(ctx, cmd) cmd.SetErr(err) @@ -337,13 +609,31 @@ func (c *SentinelClient) Process(ctx context.Context, cmd Cmder) error { func (c *SentinelClient) pubSub() *PubSub { pubsub := &PubSub{ opt: c.opt, - - newConn: func(ctx context.Context, channels []string) (*pool.Conn, error) { - return c.newConn(ctx) + newConn: func(ctx context.Context, addr string, channels []string) (*pool.Conn, error) { + cn, err := c.pubSubPool.NewConn(ctx, c.opt.Network, addr, channels) + if err != nil { + return nil, err + } + // will return nil if already initialized + err = c.initConn(ctx, cn) + if err != nil { + _ = cn.Close() + return nil, err + } + // Track connection in PubSubPool + c.pubSubPool.TrackConn(cn) + return cn, nil }, - closeConn: c.connPool.CloseConn, + closeConn: func(cn *pool.Conn) error { + // Untrack connection from PubSubPool + c.pubSubPool.UntrackConn(cn) + _ = cn.Close() + return nil + }, + pushProcessor: c.pushProcessor, } pubsub.init() + return pubsub } @@ -478,10 +768,10 @@ type sentinelFailover struct { onFailover func(ctx context.Context, addr string) onUpdate func(ctx context.Context) - mu sync.RWMutex - _masterAddr string - sentinel *SentinelClient - pubsub *PubSub + mu sync.RWMutex + masterAddr string + sentinel *SentinelClient + pubsub *PubSub } func (c *sentinelFailover) Close() error { @@ -566,29 +856,70 @@ func (c *sentinelFailover) MasterAddr(ctx context.Context) (string, error) { } } - for i, sentinelAddr := range c.sentinelAddrs { - sentinel := NewSentinelClient(c.opt.sentinelOptions(sentinelAddr)) - - masterAddr, err := sentinel.GetMasterAddrByName(ctx, c.opt.MasterName).Result() - if err != nil { - _ = sentinel.Close() - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return "", err - } - internal.Logger.Printf(ctx, "sentinel: GetMasterAddrByName master=%q failed: %s", - c.opt.MasterName, err) - continue - } - - // Push working sentinel to the top. - c.sentinelAddrs[0], c.sentinelAddrs[i] = c.sentinelAddrs[i], c.sentinelAddrs[0] - c.setSentinel(ctx, sentinel) - - addr := net.JoinHostPort(masterAddr[0], masterAddr[1]) - return addr, nil + // short circuit if no sentinels configured + if len(c.sentinelAddrs) == 0 { + return "", errors.New("redis: no sentinels configured") } - return "", errors.New("redis: all sentinels specified in configuration are unreachable") + var ( + masterAddr string + wg sync.WaitGroup + once sync.Once + errCh = make(chan error, len(c.sentinelAddrs)) + ) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + for i, sentinelAddr := range c.sentinelAddrs { + wg.Add(1) + go func(i int, addr string) { + defer wg.Done() + sentinelCli := NewSentinelClient(c.opt.sentinelOptions(addr)) + addrVal, err := sentinelCli.GetMasterAddrByName(ctx, c.opt.MasterName).Result() + if err != nil { + internal.Logger.Printf(ctx, "sentinel: GetMasterAddrByName addr=%s, master=%q failed: %s", + addr, c.opt.MasterName, err) + _ = sentinelCli.Close() + errCh <- err + return + } + once.Do(func() { + masterAddr = net.JoinHostPort(addrVal[0], addrVal[1]) + // Push working sentinel to the top + c.sentinelAddrs[0], c.sentinelAddrs[i] = c.sentinelAddrs[i], c.sentinelAddrs[0] + c.setSentinel(ctx, sentinelCli) + internal.Logger.Printf(ctx, "sentinel: selected addr=%s masterAddr=%s", addr, masterAddr) + cancel() + }) + }(i, sentinelAddr) + } + + wg.Wait() + close(errCh) + if masterAddr != "" { + return masterAddr, nil + } + errs := make([]error, 0, len(errCh)) + for err := range errCh { + errs = append(errs, err) + } + return "", fmt.Errorf("redis: all sentinels specified in configuration are unreachable: %s", joinErrors(errs)) +} + +func joinErrors(errs []error) string { + if len(errs) == 0 { + return "" + } + if len(errs) == 1 { + return errs[0].Error() + } + b := []byte(errs[0].Error()) + for _, err := range errs[1:] { + b = append(b, '\n') + b = append(b, err.Error()...) + } + return util.BytesToString(b) } func (c *sentinelFailover) replicaAddrs(ctx context.Context, useDisconnected bool) ([]string, error) { @@ -708,7 +1039,7 @@ func parseReplicaAddrs(addrs []map[string]string, keepDisconnected bool) []strin func (c *sentinelFailover) trySwitchMaster(ctx context.Context, addr string) { c.mu.RLock() - currentAddr := c._masterAddr //nolint:ifshort + currentAddr := c.masterAddr //nolint:ifshort c.mu.RUnlock() if addr == currentAddr { @@ -718,10 +1049,10 @@ func (c *sentinelFailover) trySwitchMaster(ctx context.Context, addr string) { c.mu.Lock() defer c.mu.Unlock() - if addr == c._masterAddr { + if addr == c.masterAddr { return } - c._masterAddr = addr + c.masterAddr = addr internal.Logger.Printf(ctx, "sentinel: new master=%q addr=%q", c.opt.MasterName, addr) @@ -806,6 +1137,10 @@ func contains(slice []string, str string) bool { // NewFailoverClusterClient returns a client that supports routing read-only commands // to a replica node. func NewFailoverClusterClient(failoverOpt *FailoverOptions) *ClusterClient { + if failoverOpt == nil { + panic("redis: NewFailoverClusterClient nil options") + } + sentinelAddrs := make([]string, len(failoverOpt.SentinelAddrs)) copy(sentinelAddrs, failoverOpt.SentinelAddrs) @@ -815,6 +1150,22 @@ func NewFailoverClusterClient(failoverOpt *FailoverOptions) *ClusterClient { } opt := failoverOpt.clusterOptions() + if failoverOpt.DB != 0 { + onConnect := opt.OnConnect + + opt.OnConnect = func(ctx context.Context, cn *Conn) error { + if err := cn.Select(ctx, failoverOpt.DB).Err(); err != nil { + return err + } + + if onConnect != nil { + return onConnect(ctx, cn) + } + + return nil + } + } + opt.ClusterSlots = func(ctx context.Context) ([]ClusterSlot, error) { masterAddr, err := failover.MasterAddr(ctx) if err != nil { diff --git a/backend/vendor/github.com/redis/go-redis/v9/set_commands.go b/backend/vendor/github.com/redis/go-redis/v9/set_commands.go index cef8ad6d..79efa6e4 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/set_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/set_commands.go @@ -1,6 +1,10 @@ package redis -import "context" +import ( + "context" + + "github.com/redis/go-redis/v9/internal/hashtag" +) type SetCmdable interface { SAdd(ctx context.Context, key string, members ...interface{}) *IntCmd @@ -78,16 +82,15 @@ func (c cmdable) SInter(ctx context.Context, keys ...string) *StringSliceCmd { } func (c cmdable) SInterCard(ctx context.Context, limit int64, keys ...string) *IntCmd { - args := make([]interface{}, 4+len(keys)) + numKeys := len(keys) + args := make([]interface{}, 4+numKeys) args[0] = "sintercard" - numkeys := int64(0) + args[1] = numKeys for i, key := range keys { args[2+i] = key - numkeys++ } - args[1] = numkeys - args[2+numkeys] = "limit" - args[3+numkeys] = limit + args[2+numKeys] = "limit" + args[3+numKeys] = limit cmd := NewIntCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -212,6 +215,9 @@ func (c cmdable) SScan(ctx context.Context, key string, cursor uint64, match str args = append(args, "count", count) } cmd := NewScanCmd(ctx, c, args...) + if hashtag.Present(match) { + cmd.SetFirstKeyPos(4) + } _ = c(ctx, cmd) return cmd } diff --git a/backend/vendor/github.com/redis/go-redis/v9/sortedset_commands.go b/backend/vendor/github.com/redis/go-redis/v9/sortedset_commands.go index 67014027..7827babc 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/sortedset_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/sortedset_commands.go @@ -2,8 +2,11 @@ package redis import ( "context" + "errors" "strings" "time" + + "github.com/redis/go-redis/v9/internal/hashtag" ) type SortedSetCmdable interface { @@ -257,16 +260,15 @@ func (c cmdable) ZInterWithScores(ctx context.Context, store *ZStore) *ZSliceCmd } func (c cmdable) ZInterCard(ctx context.Context, limit int64, keys ...string) *IntCmd { - args := make([]interface{}, 4+len(keys)) + numKeys := len(keys) + args := make([]interface{}, 4+numKeys) args[0] = "zintercard" - numkeys := int64(0) + args[1] = numKeys for i, key := range keys { args[2+i] = key - numkeys++ } - args[1] = numkeys - args[2+numkeys] = "limit" - args[3+numkeys] = limit + args[2+numKeys] = "limit" + args[3+numKeys] = limit cmd := NewIntCmd(ctx, args...) _ = c(ctx, cmd) return cmd @@ -312,7 +314,9 @@ func (c cmdable) ZPopMax(ctx context.Context, key string, count ...int64) *ZSlic case 1: args = append(args, count[0]) default: - panic("too many arguments") + cmd := NewZSliceCmd(ctx) + cmd.SetErr(errors.New("too many arguments")) + return cmd } cmd := NewZSliceCmd(ctx, args...) @@ -332,7 +336,9 @@ func (c cmdable) ZPopMin(ctx context.Context, key string, count ...int64) *ZSlic case 1: args = append(args, count[0]) default: - panic("too many arguments") + cmd := NewZSliceCmd(ctx) + cmd.SetErr(errors.New("too many arguments")) + return cmd } cmd := NewZSliceCmd(ctx, args...) @@ -720,6 +726,9 @@ func (c cmdable) ZScan(ctx context.Context, key string, cursor uint64, match str args = append(args, "count", count) } cmd := NewScanCmd(ctx, c, args...) + if hashtag.Present(match) { + cmd.SetFirstKeyPos(4) + } _ = c(ctx, cmd) return cmd } diff --git a/backend/vendor/github.com/redis/go-redis/v9/stream_commands.go b/backend/vendor/github.com/redis/go-redis/v9/stream_commands.go index 6d7b2292..5573e48b 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/stream_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/stream_commands.go @@ -7,7 +7,9 @@ import ( type StreamCmdable interface { XAdd(ctx context.Context, a *XAddArgs) *StringCmd + XAckDel(ctx context.Context, stream string, group string, mode string, ids ...string) *SliceCmd XDel(ctx context.Context, stream string, ids ...string) *IntCmd + XDelEx(ctx context.Context, stream string, mode string, ids ...string) *SliceCmd XLen(ctx context.Context, stream string) *IntCmd XRange(ctx context.Context, stream, start, stop string) *XMessageSliceCmd XRangeN(ctx context.Context, stream, start, stop string, count int64) *XMessageSliceCmd @@ -31,8 +33,12 @@ type StreamCmdable interface { XAutoClaimJustID(ctx context.Context, a *XAutoClaimArgs) *XAutoClaimJustIDCmd XTrimMaxLen(ctx context.Context, key string, maxLen int64) *IntCmd XTrimMaxLenApprox(ctx context.Context, key string, maxLen, limit int64) *IntCmd + XTrimMaxLenMode(ctx context.Context, key string, maxLen int64, mode string) *IntCmd + XTrimMaxLenApproxMode(ctx context.Context, key string, maxLen, limit int64, mode string) *IntCmd XTrimMinID(ctx context.Context, key string, minID string) *IntCmd XTrimMinIDApprox(ctx context.Context, key string, minID string, limit int64) *IntCmd + XTrimMinIDMode(ctx context.Context, key string, minID string, mode string) *IntCmd + XTrimMinIDApproxMode(ctx context.Context, key string, minID string, limit int64, mode string) *IntCmd XInfoGroups(ctx context.Context, key string) *XInfoGroupsCmd XInfoStream(ctx context.Context, key string) *XInfoStreamCmd XInfoStreamFull(ctx context.Context, key string, count int) *XInfoStreamFullCmd @@ -54,6 +60,7 @@ type XAddArgs struct { // Approx causes MaxLen and MinID to use "~" matcher (instead of "="). Approx bool Limit int64 + Mode string ID string Values interface{} } @@ -81,6 +88,11 @@ func (c cmdable) XAdd(ctx context.Context, a *XAddArgs) *StringCmd { if a.Limit > 0 { args = append(args, "limit", a.Limit) } + + if a.Mode != "" { + args = append(args, a.Mode) + } + if a.ID != "" { args = append(args, a.ID) } else { @@ -93,6 +105,16 @@ func (c cmdable) XAdd(ctx context.Context, a *XAddArgs) *StringCmd { return cmd } +func (c cmdable) XAckDel(ctx context.Context, stream string, group string, mode string, ids ...string) *SliceCmd { + args := []interface{}{"xackdel", stream, group, mode, "ids", len(ids)} + for _, id := range ids { + args = append(args, id) + } + cmd := NewSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) XDel(ctx context.Context, stream string, ids ...string) *IntCmd { args := []interface{}{"xdel", stream} for _, id := range ids { @@ -103,6 +125,16 @@ func (c cmdable) XDel(ctx context.Context, stream string, ids ...string) *IntCmd return cmd } +func (c cmdable) XDelEx(ctx context.Context, stream string, mode string, ids ...string) *SliceCmd { + args := []interface{}{"xdelex", stream, mode, "ids", len(ids)} + for _, id := range ids { + args = append(args, id) + } + cmd := NewSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) XLen(ctx context.Context, stream string) *IntCmd { cmd := NewIntCmd(ctx, "xlen", stream) _ = c(ctx, cmd) @@ -231,6 +263,7 @@ type XReadGroupArgs struct { Count int64 Block time.Duration NoAck bool + Claim time.Duration // Claim idle pending entries older than this duration } func (c cmdable) XReadGroup(ctx context.Context, a *XReadGroupArgs) *XStreamSliceCmd { @@ -250,6 +283,10 @@ func (c cmdable) XReadGroup(ctx context.Context, a *XReadGroupArgs) *XStreamSlic args = append(args, "noack") keyPos++ } + if a.Claim > 0 { + args = append(args, "claim", int64(a.Claim/time.Millisecond)) + keyPos += 2 + } args = append(args, "streams") keyPos++ for _, s := range a.Streams { @@ -375,6 +412,8 @@ func xClaimArgs(a *XClaimArgs) []interface{} { return args } +// TODO: refactor xTrim, xTrimMode and the wrappers over the functions + // xTrim If approx is true, add the "~" parameter, otherwise it is the default "=" (redis default). // example: // @@ -418,6 +457,42 @@ func (c cmdable) XTrimMinIDApprox(ctx context.Context, key string, minID string, return c.xTrim(ctx, key, "minid", true, minID, limit) } +func (c cmdable) xTrimMode( + ctx context.Context, key, strategy string, + approx bool, threshold interface{}, limit int64, + mode string, +) *IntCmd { + args := make([]interface{}, 0, 7) + args = append(args, "xtrim", key, strategy) + if approx { + args = append(args, "~") + } + args = append(args, threshold) + if limit > 0 { + args = append(args, "limit", limit) + } + args = append(args, mode) + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) XTrimMaxLenMode(ctx context.Context, key string, maxLen int64, mode string) *IntCmd { + return c.xTrimMode(ctx, key, "maxlen", false, maxLen, 0, mode) +} + +func (c cmdable) XTrimMaxLenApproxMode(ctx context.Context, key string, maxLen, limit int64, mode string) *IntCmd { + return c.xTrimMode(ctx, key, "maxlen", true, maxLen, limit, mode) +} + +func (c cmdable) XTrimMinIDMode(ctx context.Context, key string, minID string, mode string) *IntCmd { + return c.xTrimMode(ctx, key, "minid", false, minID, 0, mode) +} + +func (c cmdable) XTrimMinIDApproxMode(ctx context.Context, key string, minID string, limit int64, mode string) *IntCmd { + return c.xTrimMode(ctx, key, "minid", true, minID, limit, mode) +} + func (c cmdable) XInfoConsumers(ctx context.Context, key string, group string) *XInfoConsumersCmd { cmd := NewXInfoConsumersCmd(ctx, key, group) _ = c(ctx, cmd) diff --git a/backend/vendor/github.com/redis/go-redis/v9/string_commands.go b/backend/vendor/github.com/redis/go-redis/v9/string_commands.go index eff5880d..f3c33f4c 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/string_commands.go +++ b/backend/vendor/github.com/redis/go-redis/v9/string_commands.go @@ -2,6 +2,7 @@ package redis import ( "context" + "fmt" "time" ) @@ -9,6 +10,8 @@ type StringCmdable interface { Append(ctx context.Context, key, value string) *IntCmd Decr(ctx context.Context, key string) *IntCmd DecrBy(ctx context.Context, key string, decrement int64) *IntCmd + DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd + Digest(ctx context.Context, key string) *DigestCmd Get(ctx context.Context, key string) *StringCmd GetRange(ctx context.Context, key string, start, end int64) *StringCmd GetSet(ctx context.Context, key string, value interface{}) *StringCmd @@ -21,9 +24,18 @@ type StringCmdable interface { MGet(ctx context.Context, keys ...string) *SliceCmd MSet(ctx context.Context, values ...interface{}) *StatusCmd MSetNX(ctx context.Context, values ...interface{}) *BoolCmd + MSetEX(ctx context.Context, args MSetEXArgs, values ...interface{}) *IntCmd Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd SetArgs(ctx context.Context, key string, value interface{}, a SetArgs) *StatusCmd SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd + SetIFEQ(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd + SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd + SetIFNE(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd + SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd + SetIFDEQ(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StatusCmd + SetIFDEQGet(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StringCmd + SetIFDNE(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StatusCmd + SetIFDNEGet(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StringCmd SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd SetXX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd SetRange(ctx context.Context, key string, offset int64, value string) *IntCmd @@ -48,6 +60,76 @@ func (c cmdable) DecrBy(ctx context.Context, key string, decrement int64) *IntCm return cmd } +// DelExArgs provides arguments for the DelExArgs function. +type DelExArgs struct { + // Mode can be `IFEQ`, `IFNE`, `IFDEQ`, or `IFDNE`. + Mode string + + // MatchValue is used with IFEQ/IFNE modes for compare-and-delete operations. + // - IFEQ: only delete if current value equals MatchValue + // - IFNE: only delete if current value does not equal MatchValue + MatchValue interface{} + + // MatchDigest is used with IFDEQ/IFDNE modes for digest-based compare-and-delete. + // - IFDEQ: only delete if current value's digest equals MatchDigest + // - IFDNE: only delete if current value's digest does not equal MatchDigest + // + // The digest is a uint64 xxh3 hash value. + // + // For examples of client-side digest generation, see: + // example/digest-optimistic-locking/ + MatchDigest uint64 +} + +// DelExArgs Redis `DELEX key [IFEQ|IFNE|IFDEQ|IFDNE] match-value` command. +// Compare-and-delete with flexible conditions. +// +// Returns the number of keys that were removed (0 or 1). +// +// NOTE DelExArgs is still experimental +// it's signature and behaviour may change +func (c cmdable) DelExArgs(ctx context.Context, key string, a DelExArgs) *IntCmd { + args := []interface{}{"delex", key} + + if a.Mode != "" { + args = append(args, a.Mode) + + // Add match value/digest based on mode + switch a.Mode { + case "ifeq", "IFEQ", "ifne", "IFNE": + if a.MatchValue != nil { + args = append(args, a.MatchValue) + } + case "ifdeq", "IFDEQ", "ifdne", "IFDNE": + if a.MatchDigest != 0 { + args = append(args, fmt.Sprintf("%016x", a.MatchDigest)) + } + } + } + + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// Digest returns the xxh3 hash (uint64) of the specified key's value. +// +// The digest is a 64-bit xxh3 hash that can be used for optimistic locking +// with SetIFDEQ, SetIFDNE, and DelExArgs commands. +// +// For examples of client-side digest generation and usage patterns, see: +// example/digest-optimistic-locking/ +// +// Redis 8.4+. See https://redis.io/commands/digest/ +// +// NOTE Digest is still experimental +// it's signature and behaviour may change +func (c cmdable) Digest(ctx context.Context, key string) *DigestCmd { + cmd := NewDigestCmd(ctx, "digest", key) + _ = c(ctx, cmd) + return cmd +} + // Get Redis `GET key` command. It returns redis.Nil error when key does not exist. func (c cmdable) Get(ctx context.Context, key string) *StringCmd { cmd := NewStringCmd(ctx, "get", key) @@ -112,6 +194,35 @@ func (c cmdable) IncrByFloat(ctx context.Context, key string, value float64) *Fl return cmd } +type SetCondition string + +const ( + // NX only set the keys and their expiration if none exist + NX SetCondition = "NX" + // XX only set the keys and their expiration if all already exist + XX SetCondition = "XX" +) + +type ExpirationMode string + +const ( + // EX sets expiration in seconds + EX ExpirationMode = "EX" + // PX sets expiration in milliseconds + PX ExpirationMode = "PX" + // EXAT sets expiration as Unix timestamp in seconds + EXAT ExpirationMode = "EXAT" + // PXAT sets expiration as Unix timestamp in milliseconds + PXAT ExpirationMode = "PXAT" + // KEEPTTL keeps the existing TTL + KEEPTTL ExpirationMode = "KEEPTTL" +) + +type ExpirationOption struct { + Mode ExpirationMode + Value int64 +} + func (c cmdable) LCS(ctx context.Context, q *LCSQuery) *LCSCmd { cmd := NewLCSCmd(ctx, q) _ = c(ctx, cmd) @@ -157,6 +268,49 @@ func (c cmdable) MSetNX(ctx context.Context, values ...interface{}) *BoolCmd { return cmd } +type MSetEXArgs struct { + Condition SetCondition + Expiration *ExpirationOption +} + +// MSetEX sets the given keys to their respective values. +// This command is an extension of the MSETNX that adds expiration and XX options. +// Available since Redis 8.4 +// Important: When this method is used with Cluster clients, all keys +// must be in the same hash slot, otherwise CROSSSLOT error will be returned. +// For more information, see https://redis.io/commands/msetex +func (c cmdable) MSetEX(ctx context.Context, args MSetEXArgs, values ...interface{}) *IntCmd { + expandedArgs := appendArgs([]interface{}{}, values) + numkeys := len(expandedArgs) / 2 + + cmdArgs := make([]interface{}, 0, 2+len(expandedArgs)+3) + cmdArgs = append(cmdArgs, "msetex", numkeys) + cmdArgs = append(cmdArgs, expandedArgs...) + + if args.Condition != "" { + cmdArgs = append(cmdArgs, string(args.Condition)) + } + + if args.Expiration != nil { + switch args.Expiration.Mode { + case EX: + cmdArgs = append(cmdArgs, "ex", args.Expiration.Value) + case PX: + cmdArgs = append(cmdArgs, "px", args.Expiration.Value) + case EXAT: + cmdArgs = append(cmdArgs, "exat", args.Expiration.Value) + case PXAT: + cmdArgs = append(cmdArgs, "pxat", args.Expiration.Value) + case KEEPTTL: + cmdArgs = append(cmdArgs, "keepttl") + } + } + + cmd := NewIntCmd(ctx, cmdArgs...) + _ = c(ctx, cmd) + return cmd +} + // Set Redis `SET key value [expiration]` command. // Use expiration for `SETEx`-like behavior. // @@ -185,9 +339,24 @@ func (c cmdable) Set(ctx context.Context, key string, value interface{}, expirat // SetArgs provides arguments for the SetArgs function. type SetArgs struct { - // Mode can be `NX` or `XX` or empty. + // Mode can be `NX`, `XX`, `IFEQ`, `IFNE`, `IFDEQ`, `IFDNE` or empty. Mode string + // MatchValue is used with IFEQ/IFNE modes for compare-and-set operations. + // - IFEQ: only set if current value equals MatchValue + // - IFNE: only set if current value does not equal MatchValue + MatchValue interface{} + + // MatchDigest is used with IFDEQ/IFDNE modes for digest-based compare-and-set. + // - IFDEQ: only set if current value's digest equals MatchDigest + // - IFDNE: only set if current value's digest does not equal MatchDigest + // + // The digest is a uint64 xxh3 hash value. + // + // For examples of client-side digest generation, see: + // example/digest-optimistic-locking/ + MatchDigest uint64 + // Zero `TTL` or `Expiration` means that the key has no expiration time. TTL time.Duration ExpireAt time.Time @@ -223,6 +392,18 @@ func (c cmdable) SetArgs(ctx context.Context, key string, value interface{}, a S if a.Mode != "" { args = append(args, a.Mode) + + // Add match value/digest for CAS modes + switch a.Mode { + case "ifeq", "IFEQ", "ifne", "IFNE": + if a.MatchValue != nil { + args = append(args, a.MatchValue) + } + case "ifdeq", "IFDEQ", "ifdne", "IFDNE": + if a.MatchDigest != 0 { + args = append(args, fmt.Sprintf("%016x", a.MatchDigest)) + } + } } if a.Get { @@ -290,6 +471,270 @@ func (c cmdable) SetXX(ctx context.Context, key string, value interface{}, expir return cmd } +// SetIFEQ Redis `SET key value [expiration] IFEQ match-value` command. +// Compare-and-set: only sets the value if the current value equals matchValue. +// +// Returns "OK" on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +// +// NOTE SetIFEQ is still experimental +// it's signature and behaviour may change +func (c cmdable) SetIFEQ(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifeq", matchValue) + + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFEQGet Redis `SET key value [expiration] IFEQ match-value GET` command. +// Compare-and-set with GET: only sets the value if the current value equals matchValue, +// and returns the previous value. +// +// Returns the previous value on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +// +// NOTE SetIFEQGet is still experimental +// it's signature and behaviour may change +func (c cmdable) SetIFEQGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifeq", matchValue, "get") + + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFNE Redis `SET key value [expiration] IFNE match-value` command. +// Compare-and-set: only sets the value if the current value does not equal matchValue. +// +// Returns "OK" on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +// +// NOTE SetIFNE is still experimental +// it's signature and behaviour may change +func (c cmdable) SetIFNE(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StatusCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifne", matchValue) + + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFNEGet Redis `SET key value [expiration] IFNE match-value GET` command. +// Compare-and-set with GET: only sets the value if the current value does not equal matchValue, +// and returns the previous value. +// +// Returns the previous value on success. +// Returns nil if the operation was aborted due to condition not matching. +// Zero expiration means the key has no expiration time. +// +// NOTE SetIFNEGet is still experimental +// it's signature and behaviour may change +func (c cmdable) SetIFNEGet(ctx context.Context, key string, value interface{}, matchValue interface{}, expiration time.Duration) *StringCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifne", matchValue, "get") + + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFDEQ sets the value only if the current value's digest equals matchDigest. +// +// This is a compare-and-set operation using xxh3 digest for optimistic locking. +// The matchDigest parameter is a uint64 xxh3 hash value. +// +// Returns "OK" on success. +// Returns redis.Nil if the digest doesn't match (value was modified). +// Zero expiration means the key has no expiration time. +// +// For examples of client-side digest generation and usage patterns, see: +// example/digest-optimistic-locking/ +// +// Redis 8.4+. See https://redis.io/commands/set/ +// +// NOTE SetIFNEQ is still experimental +// it's signature and behaviour may change +func (c cmdable) SetIFDEQ(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StatusCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifdeq", fmt.Sprintf("%016x", matchDigest)) + + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFDEQGet sets the value only if the current value's digest equals matchDigest, +// and returns the previous value. +// +// This is a compare-and-set operation using xxh3 digest for optimistic locking. +// The matchDigest parameter is a uint64 xxh3 hash value. +// +// Returns the previous value on success. +// Returns redis.Nil if the digest doesn't match (value was modified). +// Zero expiration means the key has no expiration time. +// +// For examples of client-side digest generation and usage patterns, see: +// example/digest-optimistic-locking/ +// +// Redis 8.4+. See https://redis.io/commands/set/ +// +// NOTE SetIFNEQGet is still experimental +// it's signature and behaviour may change +func (c cmdable) SetIFDEQGet(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StringCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifdeq", fmt.Sprintf("%016x", matchDigest), "get") + + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFDNE sets the value only if the current value's digest does NOT equal matchDigest. +// +// This is a compare-and-set operation using xxh3 digest for optimistic locking. +// The matchDigest parameter is a uint64 xxh3 hash value. +// +// Returns "OK" on success (digest didn't match, value was set). +// Returns redis.Nil if the digest matches (value was not modified). +// Zero expiration means the key has no expiration time. +// +// For examples of client-side digest generation and usage patterns, see: +// example/digest-optimistic-locking/ +// +// Redis 8.4+. See https://redis.io/commands/set/ +// +// NOTE SetIFDNE is still experimental +// it's signature and behaviour may change +func (c cmdable) SetIFDNE(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StatusCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifdne", fmt.Sprintf("%016x", matchDigest)) + + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// SetIFDNEGet sets the value only if the current value's digest does NOT equal matchDigest, +// and returns the previous value. +// +// This is a compare-and-set operation using xxh3 digest for optimistic locking. +// The matchDigest parameter is a uint64 xxh3 hash value. +// +// Returns the previous value on success (digest didn't match, value was set). +// Returns redis.Nil if the digest matches (value was not modified). +// Zero expiration means the key has no expiration time. +// +// For examples of client-side digest generation and usage patterns, see: +// example/digest-optimistic-locking/ +// +// Redis 8.4+. See https://redis.io/commands/set/ +// +// NOTE SetIFDNEGet is still experimental +// it's signature and behaviour may change +func (c cmdable) SetIFDNEGet(ctx context.Context, key string, value interface{}, matchDigest uint64, expiration time.Duration) *StringCmd { + args := []interface{}{"set", key, value} + + if expiration > 0 { + if usePrecise(expiration) { + args = append(args, "px", formatMs(ctx, expiration)) + } else { + args = append(args, "ex", formatSec(ctx, expiration)) + } + } else if expiration == KeepTTL { + args = append(args, "keepttl") + } + + args = append(args, "ifdne", fmt.Sprintf("%016x", matchDigest), "get") + + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + func (c cmdable) SetRange(ctx context.Context, key string, offset int64, value string) *IntCmd { cmd := NewIntCmd(ctx, "setrange", key, offset, value) _ = c(ctx, cmd) diff --git a/backend/vendor/github.com/redis/go-redis/v9/tx.go b/backend/vendor/github.com/redis/go-redis/v9/tx.go index 039eaf35..40bc1d66 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/tx.go +++ b/backend/vendor/github.com/redis/go-redis/v9/tx.go @@ -19,16 +19,16 @@ type Tx struct { baseClient cmdable statefulCmdable - hooksMixin } func (c *Client) newTx() *Tx { tx := Tx{ baseClient: baseClient{ - opt: c.opt, - connPool: pool.NewStickyConnPool(c.connPool), + opt: c.opt.clone(), // Clone options to avoid sharing mutable state between transaction and parent client + connPool: pool.NewStickyConnPool(c.connPool), + hooksMixin: c.hooksMixin.clone(), + pushProcessor: c.pushProcessor, // Copy push processor from parent client }, - hooksMixin: c.hooksMixin.clone(), } tx.init() return &tx diff --git a/backend/vendor/github.com/redis/go-redis/v9/universal.go b/backend/vendor/github.com/redis/go-redis/v9/universal.go index 483c8112..1dc9764d 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/universal.go +++ b/backend/vendor/github.com/redis/go-redis/v9/universal.go @@ -5,6 +5,9 @@ import ( "crypto/tls" "net" "time" + + "github.com/redis/go-redis/v9/auth" + "github.com/redis/go-redis/v9/maintnotifications" ) // UniversalOptions information is required by UniversalClient to establish @@ -26,9 +29,27 @@ type UniversalOptions struct { Dialer func(ctx context.Context, network, addr string) (net.Conn, error) OnConnect func(ctx context.Context, cn *Conn) error - Protocol int - Username string - Password string + Protocol int + Username string + Password string + // CredentialsProvider allows the username and password to be updated + // before reconnecting. It should return the current username and password. + CredentialsProvider func() (username string, password string) + + // CredentialsProviderContext is an enhanced parameter of CredentialsProvider, + // done to maintain API compatibility. In the future, + // there might be a merge between CredentialsProviderContext and CredentialsProvider. + // There will be a conflict between them; if CredentialsProviderContext exists, we will ignore CredentialsProvider. + CredentialsProviderContext func(ctx context.Context) (username string, password string, err error) + + // StreamingCredentialsProvider is used to retrieve the credentials + // for the connection from an external source. Those credentials may change + // during the connection lifetime. This is useful for managed identity + // scenarios where the credentials are retrieved from an external source. + // + // Currently, this is a placeholder for the future implementation. + StreamingCredentialsProvider auth.StreamingCredentialsProvider + SentinelUsername string SentinelPassword string @@ -41,6 +62,20 @@ type UniversalOptions struct { WriteTimeout time.Duration ContextTimeoutEnabled bool + // ReadBufferSize is the size of the bufio.Reader buffer for each connection. + // Larger buffers can improve performance for commands that return large responses. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + ReadBufferSize int + + // WriteBufferSize is the size of the bufio.Writer buffer for each connection. + // Larger buffers can improve performance for large pipelines and commands with many arguments. + // Smaller buffers can improve memory usage for larger pools. + // + // default: 32KiB (32768 bytes) + WriteBufferSize int + // PoolFIFO uses FIFO mode for each node connection pool GET/PUT (default LIFO). PoolFIFO bool @@ -78,8 +113,19 @@ type UniversalOptions struct { DisableIdentity bool IdentitySuffix string - UnstableResp3 bool + // FailingTimeoutSeconds is the timeout in seconds for marking a cluster node as failing. + // When a node is marked as failing, it will be avoided for this duration. + // Only applies to cluster clients. Default is 15 seconds. + FailingTimeoutSeconds int + + UnstableResp3 bool + + // IsClusterMode can be used when only one Addrs is provided (e.g. Elasticache supports setting up cluster mode with configuration endpoint). + IsClusterMode bool + + // MaintNotificationsConfig provides configuration for maintnotifications upgrades. + MaintNotificationsConfig *maintnotifications.Config } // Cluster returns cluster options created from the universal options. @@ -94,9 +140,12 @@ func (o *UniversalOptions) Cluster() *ClusterOptions { Dialer: o.Dialer, OnConnect: o.OnConnect, - Protocol: o.Protocol, - Username: o.Username, - Password: o.Password, + Protocol: o.Protocol, + Username: o.Username, + Password: o.Password, + CredentialsProvider: o.CredentialsProvider, + CredentialsProviderContext: o.CredentialsProviderContext, + StreamingCredentialsProvider: o.StreamingCredentialsProvider, MaxRedirects: o.MaxRedirects, ReadOnly: o.ReadOnly, @@ -112,6 +161,9 @@ func (o *UniversalOptions) Cluster() *ClusterOptions { WriteTimeout: o.WriteTimeout, ContextTimeoutEnabled: o.ContextTimeoutEnabled, + ReadBufferSize: o.ReadBufferSize, + WriteBufferSize: o.WriteBufferSize, + PoolFIFO: o.PoolFIFO, PoolSize: o.PoolSize, @@ -124,10 +176,12 @@ func (o *UniversalOptions) Cluster() *ClusterOptions { TLSConfig: o.TLSConfig, - DisableIdentity: o.DisableIdentity, - DisableIndentity: o.DisableIndentity, - IdentitySuffix: o.IdentitySuffix, - UnstableResp3: o.UnstableResp3, + DisableIdentity: o.DisableIdentity, + DisableIndentity: o.DisableIndentity, + IdentitySuffix: o.IdentitySuffix, + FailingTimeoutSeconds: o.FailingTimeoutSeconds, + UnstableResp3: o.UnstableResp3, + MaintNotificationsConfig: o.MaintNotificationsConfig, } } @@ -145,13 +199,20 @@ func (o *UniversalOptions) Failover() *FailoverOptions { Dialer: o.Dialer, OnConnect: o.OnConnect, - DB: o.DB, - Protocol: o.Protocol, - Username: o.Username, - Password: o.Password, + DB: o.DB, + Protocol: o.Protocol, + Username: o.Username, + Password: o.Password, + CredentialsProvider: o.CredentialsProvider, + CredentialsProviderContext: o.CredentialsProviderContext, + StreamingCredentialsProvider: o.StreamingCredentialsProvider, + SentinelUsername: o.SentinelUsername, SentinelPassword: o.SentinelPassword, + RouteByLatency: o.RouteByLatency, + RouteRandomly: o.RouteRandomly, + MaxRetries: o.MaxRetries, MinRetryBackoff: o.MinRetryBackoff, MaxRetryBackoff: o.MaxRetryBackoff, @@ -161,6 +222,9 @@ func (o *UniversalOptions) Failover() *FailoverOptions { WriteTimeout: o.WriteTimeout, ContextTimeoutEnabled: o.ContextTimeoutEnabled, + ReadBufferSize: o.ReadBufferSize, + WriteBufferSize: o.WriteBufferSize, + PoolFIFO: o.PoolFIFO, PoolSize: o.PoolSize, PoolTimeout: o.PoolTimeout, @@ -172,10 +236,13 @@ func (o *UniversalOptions) Failover() *FailoverOptions { TLSConfig: o.TLSConfig, + ReplicaOnly: o.ReadOnly, + DisableIdentity: o.DisableIdentity, DisableIndentity: o.DisableIndentity, IdentitySuffix: o.IdentitySuffix, UnstableResp3: o.UnstableResp3, + // Note: MaintNotificationsConfig not supported for FailoverOptions } } @@ -192,10 +259,13 @@ func (o *UniversalOptions) Simple() *Options { Dialer: o.Dialer, OnConnect: o.OnConnect, - DB: o.DB, - Protocol: o.Protocol, - Username: o.Username, - Password: o.Password, + DB: o.DB, + Protocol: o.Protocol, + Username: o.Username, + Password: o.Password, + CredentialsProvider: o.CredentialsProvider, + CredentialsProviderContext: o.CredentialsProviderContext, + StreamingCredentialsProvider: o.StreamingCredentialsProvider, MaxRetries: o.MaxRetries, MinRetryBackoff: o.MinRetryBackoff, @@ -206,6 +276,9 @@ func (o *UniversalOptions) Simple() *Options { WriteTimeout: o.WriteTimeout, ContextTimeoutEnabled: o.ContextTimeoutEnabled, + ReadBufferSize: o.ReadBufferSize, + WriteBufferSize: o.WriteBufferSize, + PoolFIFO: o.PoolFIFO, PoolSize: o.PoolSize, PoolTimeout: o.PoolTimeout, @@ -217,10 +290,11 @@ func (o *UniversalOptions) Simple() *Options { TLSConfig: o.TLSConfig, - DisableIdentity: o.DisableIdentity, - DisableIndentity: o.DisableIndentity, - IdentitySuffix: o.IdentitySuffix, - UnstableResp3: o.UnstableResp3, + DisableIdentity: o.DisableIdentity, + DisableIndentity: o.DisableIndentity, + IdentitySuffix: o.IdentitySuffix, + UnstableResp3: o.UnstableResp3, + MaintNotificationsConfig: o.MaintNotificationsConfig, } } @@ -252,14 +326,26 @@ var ( // NewUniversalClient returns a new multi client. The type of the returned client depends // on the following conditions: // -// 1. If the MasterName option is specified, a sentinel-backed FailoverClient is returned. -// 2. if the number of Addrs is two or more, a ClusterClient is returned. -// 3. Otherwise, a single-node Client is returned. +// 1. If the MasterName option is specified with RouteByLatency, RouteRandomly or IsClusterMode, +// a FailoverClusterClient is returned. +// 2. If the MasterName option is specified without RouteByLatency, RouteRandomly or IsClusterMode, +// a sentinel-backed FailoverClient is returned. +// 3. If the number of Addrs is two or more, or IsClusterMode option is specified, +// a ClusterClient is returned. +// 4. Otherwise, a single-node Client is returned. func NewUniversalClient(opts *UniversalOptions) UniversalClient { - if opts.MasterName != "" { + if opts == nil { + panic("redis: NewUniversalClient nil options") + } + + switch { + case opts.MasterName != "" && (opts.RouteByLatency || opts.RouteRandomly || opts.IsClusterMode): + return NewFailoverClusterClient(opts.Failover()) + case opts.MasterName != "": return NewFailoverClient(opts.Failover()) - } else if len(opts.Addrs) > 1 { + case len(opts.Addrs) > 1 || opts.IsClusterMode: return NewClusterClient(opts.Cluster()) + default: + return NewClient(opts.Simple()) } - return NewClient(opts.Simple()) } diff --git a/backend/vendor/github.com/redis/go-redis/v9/vectorset_commands.go b/backend/vendor/github.com/redis/go-redis/v9/vectorset_commands.go new file mode 100644 index 00000000..8f99de07 --- /dev/null +++ b/backend/vendor/github.com/redis/go-redis/v9/vectorset_commands.go @@ -0,0 +1,358 @@ +package redis + +import ( + "context" + "encoding/json" + "strconv" +) + +// note: the APIs is experimental and may be subject to change. +type VectorSetCmdable interface { + VAdd(ctx context.Context, key, element string, val Vector) *BoolCmd + VAddWithArgs(ctx context.Context, key, element string, val Vector, addArgs *VAddArgs) *BoolCmd + VCard(ctx context.Context, key string) *IntCmd + VDim(ctx context.Context, key string) *IntCmd + VEmb(ctx context.Context, key, element string, raw bool) *SliceCmd + VGetAttr(ctx context.Context, key, element string) *StringCmd + VInfo(ctx context.Context, key string) *MapStringInterfaceCmd + VLinks(ctx context.Context, key, element string) *StringSliceCmd + VLinksWithScores(ctx context.Context, key, element string) *VectorScoreSliceCmd + VRandMember(ctx context.Context, key string) *StringCmd + VRandMemberCount(ctx context.Context, key string, count int) *StringSliceCmd + VRem(ctx context.Context, key, element string) *BoolCmd + VSetAttr(ctx context.Context, key, element string, attr interface{}) *BoolCmd + VClearAttributes(ctx context.Context, key, element string) *BoolCmd + VSim(ctx context.Context, key string, val Vector) *StringSliceCmd + VSimWithScores(ctx context.Context, key string, val Vector) *VectorScoreSliceCmd + VSimWithArgs(ctx context.Context, key string, val Vector, args *VSimArgs) *StringSliceCmd + VSimWithArgsWithScores(ctx context.Context, key string, val Vector, args *VSimArgs) *VectorScoreSliceCmd + VRange(ctx context.Context, key, start, end string, count int64) *StringSliceCmd +} + +type Vector interface { + Value() []any +} + +const ( + vectorFormatFP32 string = "FP32" + vectorFormatValues string = "Values" +) + +type VectorFP32 struct { + Val []byte +} + +func (v *VectorFP32) Value() []any { + return []any{vectorFormatFP32, v.Val} +} + +var _ Vector = (*VectorFP32)(nil) + +type VectorValues struct { + Val []float64 +} + +func (v *VectorValues) Value() []any { + res := make([]any, 2+len(v.Val)) + res[0] = vectorFormatValues + res[1] = len(v.Val) + for i, v := range v.Val { + res[2+i] = v + } + return res +} + +var _ Vector = (*VectorValues)(nil) + +type VectorRef struct { + Name string // the name of the referent vector +} + +func (v *VectorRef) Value() []any { + return []any{"ele", v.Name} +} + +var _ Vector = (*VectorRef)(nil) + +type VectorScore struct { + Name string + Score float64 +} + +// `VADD key (FP32 | VALUES num) vector element` +// note: the API is experimental and may be subject to change. +func (c cmdable) VAdd(ctx context.Context, key, element string, val Vector) *BoolCmd { + return c.VAddWithArgs(ctx, key, element, val, &VAddArgs{}) +} + +type VAddArgs struct { + // the REDUCE option must be passed immediately after the key + Reduce int64 + Cas bool + + // The NoQuant, Q8 and Bin options are mutually exclusive. + NoQuant bool + Q8 bool + Bin bool + + EF int64 + SetAttr string + M int64 +} + +func (v VAddArgs) reduce() int64 { + return v.Reduce +} + +func (v VAddArgs) appendArgs(args []any) []any { + if v.Cas { + args = append(args, "cas") + } + + if v.NoQuant { + args = append(args, "noquant") + } else if v.Q8 { + args = append(args, "q8") + } else if v.Bin { + args = append(args, "bin") + } + + if v.EF > 0 { + args = append(args, "ef", strconv.FormatInt(v.EF, 10)) + } + if len(v.SetAttr) > 0 { + args = append(args, "setattr", v.SetAttr) + } + if v.M > 0 { + args = append(args, "m", strconv.FormatInt(v.M, 10)) + } + return args +} + +// `VADD key [REDUCE dim] (FP32 | VALUES num) vector element [CAS] [NOQUANT | Q8 | BIN] [EF build-exploration-factor] [SETATTR attributes] [M numlinks]` +// note: the API is experimental and may be subject to change. +func (c cmdable) VAddWithArgs(ctx context.Context, key, element string, val Vector, addArgs *VAddArgs) *BoolCmd { + if addArgs == nil { + addArgs = &VAddArgs{} + } + args := []any{"vadd", key} + if addArgs.reduce() > 0 { + args = append(args, "reduce", addArgs.reduce()) + } + args = append(args, val.Value()...) + args = append(args, element) + args = addArgs.appendArgs(args) + cmd := NewBoolCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// `VCARD key` +// note: the API is experimental and may be subject to change. +func (c cmdable) VCard(ctx context.Context, key string) *IntCmd { + cmd := NewIntCmd(ctx, "vcard", key) + _ = c(ctx, cmd) + return cmd +} + +// `VDIM key` +// note: the API is experimental and may be subject to change. +func (c cmdable) VDim(ctx context.Context, key string) *IntCmd { + cmd := NewIntCmd(ctx, "vdim", key) + _ = c(ctx, cmd) + return cmd +} + +// `VEMB key element [RAW]` +// note: the API is experimental and may be subject to change. +func (c cmdable) VEmb(ctx context.Context, key, element string, raw bool) *SliceCmd { + args := []any{"vemb", key, element} + if raw { + args = append(args, "raw") + } + cmd := NewSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// `VGETATTR key element` +// note: the API is experimental and may be subject to change. +func (c cmdable) VGetAttr(ctx context.Context, key, element string) *StringCmd { + cmd := NewStringCmd(ctx, "vgetattr", key, element) + _ = c(ctx, cmd) + return cmd +} + +// `VINFO key` +// note: the API is experimental and may be subject to change. +func (c cmdable) VInfo(ctx context.Context, key string) *MapStringInterfaceCmd { + cmd := NewMapStringInterfaceCmd(ctx, "vinfo", key) + _ = c(ctx, cmd) + return cmd +} + +// `VLINKS key element` +// note: the API is experimental and may be subject to change. +func (c cmdable) VLinks(ctx context.Context, key, element string) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "vlinks", key, element) + _ = c(ctx, cmd) + return cmd +} + +// `VLINKS key element WITHSCORES` +// note: the API is experimental and may be subject to change. +func (c cmdable) VLinksWithScores(ctx context.Context, key, element string) *VectorScoreSliceCmd { + cmd := NewVectorInfoSliceCmd(ctx, "vlinks", key, element, "withscores") + _ = c(ctx, cmd) + return cmd +} + +// `VRANDMEMBER key` +// note: the API is experimental and may be subject to change. +func (c cmdable) VRandMember(ctx context.Context, key string) *StringCmd { + cmd := NewStringCmd(ctx, "vrandmember", key) + _ = c(ctx, cmd) + return cmd +} + +// `VRANDMEMBER key [count]` +// note: the API is experimental and may be subject to change. +func (c cmdable) VRandMemberCount(ctx context.Context, key string, count int) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "vrandmember", key, count) + _ = c(ctx, cmd) + return cmd +} + +// `VREM key element` +// note: the API is experimental and may be subject to change. +func (c cmdable) VRem(ctx context.Context, key, element string) *BoolCmd { + cmd := NewBoolCmd(ctx, "vrem", key, element) + _ = c(ctx, cmd) + return cmd +} + +// `VSETATTR key element "{ JSON obj }"` +// The `attr` must be something that can be marshaled to JSON (using encoding/JSON) unless +// the argument is a string or []byte when we assume that it can be passed directly as JSON. +// +// note: the API is experimental and may be subject to change. +func (c cmdable) VSetAttr(ctx context.Context, key, element string, attr interface{}) *BoolCmd { + var attrStr string + var err error + switch v := attr.(type) { + case string: + attrStr = v + case []byte: + attrStr = string(v) + default: + var bytes []byte + bytes, err = json.Marshal(v) + if err != nil { + // If marshalling fails, create the command and set the error; this command won't be executed. + cmd := NewBoolCmd(ctx, "vsetattr", key, element, "") + cmd.SetErr(err) + return cmd + } + attrStr = string(bytes) + } + cmd := NewBoolCmd(ctx, "vsetattr", key, element, attrStr) + _ = c(ctx, cmd) + return cmd +} + +// `VClearAttributes` clear attributes on a vector set element. +// The implementation of `VClearAttributes` is execute command `VSETATTR key element ""`. +// note: the API is experimental and may be subject to change. +func (c cmdable) VClearAttributes(ctx context.Context, key, element string) *BoolCmd { + cmd := NewBoolCmd(ctx, "vsetattr", key, element, "") + _ = c(ctx, cmd) + return cmd +} + +// `VSIM key (ELE | FP32 | VALUES num) (vector | element)` +// note: the API is experimental and may be subject to change. +func (c cmdable) VSim(ctx context.Context, key string, val Vector) *StringSliceCmd { + return c.VSimWithArgs(ctx, key, val, &VSimArgs{}) +} + +// `VSIM key (ELE | FP32 | VALUES num) (vector | element) WITHSCORES` +// note: the API is experimental and may be subject to change. +func (c cmdable) VSimWithScores(ctx context.Context, key string, val Vector) *VectorScoreSliceCmd { + return c.VSimWithArgsWithScores(ctx, key, val, &VSimArgs{}) +} + +type VSimArgs struct { + Count int64 + EF int64 + Filter string + FilterEF int64 + Truth bool + NoThread bool + Epsilon float64 +} + +func (v VSimArgs) appendArgs(args []any) []any { + if v.Count > 0 { + args = append(args, "count", v.Count) + } + if v.EF > 0 { + args = append(args, "ef", v.EF) + } + if len(v.Filter) > 0 { + args = append(args, "filter", v.Filter) + } + if v.FilterEF > 0 { + args = append(args, "filter-ef", v.FilterEF) + } + if v.Truth { + args = append(args, "truth") + } + if v.NoThread { + args = append(args, "nothread") + } + if v.Epsilon > 0 { + args = append(args, "Epsilon", v.Epsilon) + } + return args +} + +// `VSIM key (ELE | FP32 | VALUES num) (vector | element) [COUNT num] [EPSILON delta] +// [EF search-exploration-factor] [FILTER expression] [FILTER-EF max-filtering-effort] [TRUTH] [NOTHREAD]` +// note: the API is experimental and may be subject to change. +func (c cmdable) VSimWithArgs(ctx context.Context, key string, val Vector, simArgs *VSimArgs) *StringSliceCmd { + if simArgs == nil { + simArgs = &VSimArgs{} + } + args := []any{"vsim", key} + args = append(args, val.Value()...) + args = simArgs.appendArgs(args) + cmd := NewStringSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// `VSIM key (ELE | FP32 | VALUES num) (vector | element) [WITHSCORES] [COUNT num] [EPSILON delta] +// [EF search-exploration-factor] [FILTER expression] [FILTER-EF max-filtering-effort] [TRUTH] [NOTHREAD]` +// note: the API is experimental and may be subject to change. +func (c cmdable) VSimWithArgsWithScores(ctx context.Context, key string, val Vector, simArgs *VSimArgs) *VectorScoreSliceCmd { + if simArgs == nil { + simArgs = &VSimArgs{} + } + args := []any{"vsim", key} + args = append(args, val.Value()...) + args = append(args, "withscores") + args = simArgs.appendArgs(args) + cmd := NewVectorInfoSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// `VRANGE key start end count` +// a negative count means to return all the elements in the vector set. +// note: the API is experimental and may be subject to change. +func (c cmdable) VRange(ctx context.Context, key, start, end string, count int64) *StringSliceCmd { + args := []any{"vrange", key, start, end, count} + cmd := NewStringSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} diff --git a/backend/vendor/github.com/redis/go-redis/v9/version.go b/backend/vendor/github.com/redis/go-redis/v9/version.go index a4832fc1..126fa10b 100644 --- a/backend/vendor/github.com/redis/go-redis/v9/version.go +++ b/backend/vendor/github.com/redis/go-redis/v9/version.go @@ -2,5 +2,5 @@ package redis // Version is the current release version. func Version() string { - return "9.7.3" + return "9.17.2" } diff --git a/backend/vendor/github.com/slack-go/slack/.gitignore b/backend/vendor/github.com/slack-go/slack/.gitignore index 027f4de5..9b3903cd 100644 --- a/backend/vendor/github.com/slack-go/slack/.gitignore +++ b/backend/vendor/github.com/slack-go/slack/.gitignore @@ -1,4 +1,5 @@ *.test *~ .idea/ -/vendor/ \ No newline at end of file +/vendor/ +.env* diff --git a/backend/vendor/github.com/slack-go/slack/.golangci.yml b/backend/vendor/github.com/slack-go/slack/.golangci.yml index c16f5389..0ef0f87e 100644 --- a/backend/vendor/github.com/slack-go/slack/.golangci.yml +++ b/backend/vendor/github.com/slack-go/slack/.golangci.yml @@ -1,14 +1,35 @@ +version: "2" run: timeout: 6m issues-exit-code: 1 linters: - disable-all: true + default: none enable: - - goimports + - gocritic - govet - - interfacer - misspell - - structcheck - unconvert + - unused + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ issues: new: true +formatters: + enable: + - goimports + exclusions: + generated: lax + warn-unused: true + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/backend/vendor/github.com/slack-go/slack/CHANGELOG.md b/backend/vendor/github.com/slack-go/slack/CHANGELOG.md deleted file mode 100644 index 32da687b..00000000 --- a/backend/vendor/github.com/slack-go/slack/CHANGELOG.md +++ /dev/null @@ -1,103 +0,0 @@ -### v0.7.0 - October 2, 2020 -full differences can be viewed using `git log --oneline --decorate --color v0.6.6..v0.7.0` -Thank you for many contributions! - -#### Breaking Changes -- Add ScheduledMessage type ([#753]) -- Add description field to option block object ([#783]) -- Fix wrong conditional branch ([#782]) - - The behavior of the user's application may change.(The current behavior is incorrect) - -#### Highlights -- example: fix to start up a server ([#773]) -- example: Add explanation how the message could be sent in a proper way ([#787]) -- example: fix typo in error log ([#779]) -- refactor: Make GetConversationsParameters.ExcludeArchived optional ([#791]) -- refactor: Unify variables to "config" ([#800]) -- refactor: Rename wrong file name ([#810]) -- feature: Add SetUserRealName for change user's realName([#755]) -- feature: Add response metadata to slack response ([#772]) -- feature: Add response metadata to slack response ([#778]) -- feature: Add select block element conversations filter field ([#790]) -- feature: Add Root field to MessageEvent to support thread_broadcast subtype ([#793]) -- feature: Add bot_profile to messages ([#794]) -- doc: Add logo to README ([#813]) -- doc: Update current project status and Add changelog for v0.7.0 ([#814]) - -[#753]: https://github.com/slack-go/slack/pull/753 -[#755]: https://github.com/slack-go/slack/pull/755 -[#772]: https://github.com/slack-go/slack/pull/772 -[#773]: https://github.com/slack-go/slack/pull/773 -[#778]: https://github.com/slack-go/slack/pull/778 -[#779]: https://github.com/slack-go/slack/pull/779 -[#782]: https://github.com/slack-go/slack/pull/782 -[#783]: https://github.com/slack-go/slack/pull/783 -[#787]: https://github.com/slack-go/slack/pull/787 -[#790]: https://github.com/slack-go/slack/pull/790 -[#791]: https://github.com/slack-go/slack/pull/791 -[#793]: https://github.com/slack-go/slack/pull/793 -[#794]: https://github.com/slack-go/slack/pull/794 -[#800]: https://github.com/slack-go/slack/pull/800 -[#810]: https://github.com/slack-go/slack/pull/810 -[#813]: https://github.com/slack-go/slack/pull/813 -[#814]: https://github.com/slack-go/slack/pull/814 - -### v0.6.0 - August 31, 2019 -full differences can be viewed using `git log --oneline --decorate --color v0.5.0..v0.6.0` -thanks to everyone who has contributed since January! - - -#### Breaking Changes: -- Info struct has had fields removed related to deprecated functionality by slack. -- minor adjustments to some structs. -- some internal default values have changed, usually to be more inline with slack defaults or to correct inability to set a particular value. (Message Parse for example.) - -##### Highlights: -- new slacktest package easy mocking for slack client. use, enjoy, please submit PRs for improvements and default behaviours! shamelessly taken from the [slack-test repo](https://github.com/lusis/slack-test) thank you lusis for letting us use it and bring it into the slack repo. -- blocks, blocks, blocks. -- RTM ManagedConnection has undergone a significant cleanup. -in particular handles backoffs gracefully, removed many deadlocks, -and Disconnect is now much more responsive. - -### v0.5.0 - January 20, 2019 -full differences can be viewed using `git log --oneline --decorate --color v0.4.0..v0.5.0` -- Breaking changes: various old struct fields have been removed or updated to match slack's api. -- deadlock fix in RTM disconnect. - -### v0.4.0 - October 06, 2018 -full differences can be viewed using `git log --oneline --decorate --color v0.3.0..v0.4.0` -- Breaking Change: renamed ApplyMessageOption, to mark it as unsafe, -this means it may break without warning in the future. -- Breaking: Msg structure files field changed to an array. -- General: implementation for new security headers. -- RTM: deadlock fix between connect/disconnect. -- Events: various new fields added. -- Web: various fixes, new fields exposed, new methods added. -- Interactions: minor additions expect breaking changes in next release for dialogs/button clicks. -- Utils: new methods added. - -### v0.3.0 - July 30, 2018 -full differences can be viewed using `git log --oneline --decorate --color v0.2.0..v0.3.0` -- slack events initial support added. (still considered experimental and undergoing changes, stability not promised) -- vendored depedencies using dep, ensure using up to date tooling before filing issues. -- RTM has improved its ability to identify dead connections and reconnect automatically (worth calling out in case it has unintended side effects). -- bug fixes (various timestamp handling, error handling, RTM locking, etc). - -### v0.2.0 - Feb 10, 2018 - -Release adds a bunch of functionality and improvements, mainly to give people a recent version to vendor against. - -Please check [0.2.0](https://github.com/nlopes/slack/releases/tag/v0.2.0) - -### v0.1.0 - May 28, 2017 - -This is released before adding context support. -As the used context package is the one from Go 1.7 this will be the last -compatible with Go < 1.7. - -Please check [0.1.0](https://github.com/nlopes/slack/releases/tag/v0.1.0) - -### v0.0.1 - Jul 26, 2015 - -If you just updated from master and it broke your implementation, please -check [0.0.1](https://github.com/nlopes/slack/releases/tag/v0.0.1) diff --git a/backend/vendor/github.com/slack-go/slack/CONTRIBUTING.md b/backend/vendor/github.com/slack-go/slack/CONTRIBUTING.md new file mode 100644 index 00000000..8b92d353 --- /dev/null +++ b/backend/vendor/github.com/slack-go/slack/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing Guide + +Welcome! We are glad that you want to contribute to our project! 💖 + +There are a just a few small guidelines you ask everyone to follow to make things a bit smoother and more consistent. + +## Opening Pull Requests + +1. It's generally best to start by opening a new issue describing the bug or feature you're intending to fix. Even if you think it's relatively minor, it's helpful to know what people are working on. Mention in the initial issue that you are planning to work on that bug or feature so that it can be assigned to you. + +2. Follow the normal process of [forking](https://help.github.com/articles/fork-a-repo) the project, and set up a new branch to work in. It's important that each group of changes be done in separate branches in order to ensure that a pull request only includes the commits related to that bug or feature. + +3. Any significant changes should almost always be accompanied by tests. The project already has some test coverage, so look at some of the existing tests if you're unsure how to go about it. + +4. Run `make pr-prep` to format your code and check that it passes all tests and linters. + +5. Do your best to have [well-formed commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) for each change. This provides consistency throughout the project, and ensures that commit messages are able to be formatted properly by various git tools. _Pull Request Titles_ should generally follow the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) format to ease the release note process when cutting releases. + +6. Finally, push the commits to your fork and submit a [pull request](https://help.github.com/articles/creating-a-pull-request). NOTE: Please do not use force-push on PRs in this repo, as it makes it more difficult for reviewers to see what has changed since the last code review. We always perform "squash and merge" actions on PRs in this repo, so it doesn't matter how many commits your PR has, as they will end up being a single commit after merging. This is done to make a much cleaner `git log` history and helps to find regressions in the code using existing tools such as `git bisect`. + +## Code Comments + +Every exported method needs to have code comments that follow [Go Doc Comments](https://go.dev/doc/comment). A typical method's comments will look like this: + +```go +// PostMessage sends a message to a channel. +// +// Slack API docs: https://api.dev.slack.com/methods/chat.postMessage +func (api *Client) PostMessage(ctx context.Context, input PostMesssageInput) (PostMesssageOutput, error) { +... +} +``` + +The first line is the name of the method followed by a short description. This could also be a longer description if needed, but there is no need to repeat any details that are documented in Slack's documentation because users are expected to follow the documentation links to learn more. + +After the description comes a link to the Slack API documentation. + +## Other notes on code organization + +Currently, everything is defined in the main `slack` package, with API methods group separate files by the [Slack API Method Groupings](https://api.dev.slack.com/methods). diff --git a/backend/vendor/github.com/slack-go/slack/README.md b/backend/vendor/github.com/slack-go/slack/README.md index 9618aebc..127ae253 100644 --- a/backend/vendor/github.com/slack-go/slack/README.md +++ b/backend/vendor/github.com/slack-go/slack/README.md @@ -1,4 +1,4 @@ -Slack API in Go [![Go Reference](https://pkg.go.dev/badge/github.com/slack-go/slack.svg)](https://pkg.go.dev/github.com/slack-go/slack) +Slack API in Go [![Go Reference](https://pkg.go.dev/badge/github.com/slack-go/slack.svg)](https://pkg.go.dev/github.com/slack-go/slack) [![CI](https://github.com/slack-go/slack/actions/workflows/test.yml/badge.svg)](https://github.com/slack-go/slack/actions/workflows/test.yml) =============== This is the original Slack library for Go created by Norberto Lopes, transferred to a GitHub organization. @@ -15,7 +15,7 @@ a fully managed way. There is currently no major version released. Therefore, minor version releases may include backward incompatible changes. -See [CHANGELOG.md](https://github.com/slack-go/slack/blob/master/CHANGELOG.md) or [Releases](https://github.com/slack-go/slack/releases) for more information about the changes. +See [Releases](https://github.com/slack-go/slack/releases) for more information about the changes. ## Installing diff --git a/backend/vendor/github.com/slack-go/slack/TODO.txt b/backend/vendor/github.com/slack-go/slack/TODO.txt deleted file mode 100644 index 8607960b..00000000 --- a/backend/vendor/github.com/slack-go/slack/TODO.txt +++ /dev/null @@ -1,3 +0,0 @@ -- Add more tests!!! -- Add support to have markdown hints - - See section Message Formatting at https://api.slack.com/docs/formatting diff --git a/backend/vendor/github.com/slack-go/slack/admin_conversations.go b/backend/vendor/github.com/slack-go/slack/admin_conversations.go new file mode 100644 index 00000000..6f76568b --- /dev/null +++ b/backend/vendor/github.com/slack-go/slack/admin_conversations.go @@ -0,0 +1,85 @@ +package slack + +import ( + "context" + "net/url" + "strconv" + "strings" +) + +// AdminConversationsSetTeamsParams contains arguments for AdminConversationsSetTeams +// method calls. +type AdminConversationsSetTeamsParams struct { + ChannelID string + OrgChannel *bool + TargetTeamIDs []string + TeamID *string +} + +// Set the workspaces in an Enterprise Grid organisation that connect to a public or +// private channel. +// See: https://api.slack.com/methods/admin.conversations.setTeams +func (api *Client) AdminConversationsSetTeams(ctx context.Context, params AdminConversationsSetTeamsParams) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {params.ChannelID}, + } + + if params.OrgChannel != nil { + values.Add("org_channel", strconv.FormatBool(*params.OrgChannel)) + } + + if len(params.TargetTeamIDs) > 0 { + values.Add("target_team_ids", strings.Join(params.TargetTeamIDs, ",")) // ["T123", "T456"] - > "T123,T456" + } + + if params.TeamID != nil { + values.Add("team_id", *params.TeamID) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.setTeams", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// ConversationsConvertToPrivate converts a public channel to a private channel. To do +// this, you must have the admin.conversations:write scope. There are other requirements: +// you should read the Slack documentation for more details. +// See: https://api.slack.com/methods/admin.conversations.convertToPrivate +func (api *Client) AdminConversationsConvertToPrivate(ctx context.Context, channelID string) error { + values := url.Values{ + "token": []string{api.token}, + "channel_id": []string{channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.convertToPrivate", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// ConversationsConvertToPublic converts a private channel to a public channel. To do +// this, you must have the admin.conversations:write scope. There are other requirements: +// you should read the Slack documentation for more details. +// See: https://api.slack.com/methods/admin.conversations.convertToPublic +func (api *Client) AdminConversationsConvertToPublic(ctx context.Context, channelID string) error { + values := url.Values{ + "token": []string{api.token}, + "channel_id": []string{channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.convertToPublic", values, response) + if err != nil { + return err + } + + return response.Err() +} diff --git a/backend/vendor/github.com/slack-go/slack/assistant.go b/backend/vendor/github.com/slack-go/slack/assistant.go new file mode 100644 index 00000000..8432f89b --- /dev/null +++ b/backend/vendor/github.com/slack-go/slack/assistant.go @@ -0,0 +1,157 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +// AssistantThreadSetStatusParameters are the parameters for AssistantThreadSetStatus +type AssistantThreadsSetStatusParameters struct { + ChannelID string `json:"channel_id"` + Status string `json:"status"` + ThreadTS string `json:"thread_ts"` +} + +// AssistantThreadSetTitleParameters are the parameters for AssistantThreadSetTitle +type AssistantThreadsSetTitleParameters struct { + ChannelID string `json:"channel_id"` + ThreadTS string `json:"thread_ts"` + Title string `json:"title"` +} + +// AssistantThreadSetSuggestedPromptsParameters are the parameters for AssistantThreadSetSuggestedPrompts +type AssistantThreadsSetSuggestedPromptsParameters struct { + Title string `json:"title"` + ChannelID string `json:"channel_id"` + ThreadTS string `json:"thread_ts"` + Prompts []AssistantThreadsPrompt `json:"prompts"` +} + +// AssistantThreadPrompt is a suggested prompt for a thread +type AssistantThreadsPrompt struct { + Title string `json:"title"` + Message string `json:"message"` +} + +// AssistantThreadSetSuggestedPrompts sets the suggested prompts for a thread +func (p *AssistantThreadsSetSuggestedPromptsParameters) AddPrompt(title, message string) { + p.Prompts = append(p.Prompts, AssistantThreadsPrompt{ + Title: title, + Message: message, + }) +} + +// SetAssistantThreadsSugesstedPrompts sets the suggested prompts for a thread +// @see https://api.slack.com/methods/assistant.threads.setSuggestedPrompts +func (api *Client) SetAssistantThreadsSuggestedPrompts(params AssistantThreadsSetSuggestedPromptsParameters) (err error) { + return api.SetAssistantThreadsSuggestedPromptsContext(context.Background(), params) +} + +// SetAssistantThreadSuggestedPromptsContext sets the suggested prompts for a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setSuggestedPrompts +func (api *Client) SetAssistantThreadsSuggestedPromptsContext(ctx context.Context, params AssistantThreadsSetSuggestedPromptsParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + values.Add("channel_id", params.ChannelID) + + // Send Prompts as JSON + prompts, err := json.Marshal(params.Prompts) + if err != nil { + return err + } + + values.Add("prompts", string(prompts)) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setSuggestedPrompts", values, &response) + if err != nil { + return + } + + return response.Err() +} + +// SetAssistantThreadStatus sets the status of a thread +// @see https://api.slack.com/methods/assistant.threads.setStatus +func (api *Client) SetAssistantThreadsStatus(params AssistantThreadsSetStatusParameters) (err error) { + return api.SetAssistantThreadsStatusContext(context.Background(), params) +} + +// SetAssistantThreadStatusContext sets the status of a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setStatus +func (api *Client) SetAssistantThreadsStatusContext(ctx context.Context, params AssistantThreadsSetStatusParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + values.Add("channel_id", params.ChannelID) + + // Always send the status parameter, if empty, it will clear any existing status + values.Add("status", params.Status) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setStatus", values, &response) + if err != nil { + return + } + + return response.Err() +} + +// SetAssistantThreadsTitle sets the title of a thread +// @see https://api.slack.com/methods/assistant.threads.setTitle +func (api *Client) SetAssistantThreadsTitle(params AssistantThreadsSetTitleParameters) (err error) { + return api.SetAssistantThreadsTitleContext(context.Background(), params) +} + +// SetAssistantThreadsTitleContext sets the title of a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setTitle +func (api *Client) SetAssistantThreadsTitleContext(ctx context.Context, params AssistantThreadsSetTitleParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ChannelID != "" { + values.Add("channel_id", params.ChannelID) + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + if params.Title != "" { + values.Add("title", params.Title) + } + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setTitle", values, &response) + if err != nil { + return + } + + return response.Err() + +} diff --git a/backend/vendor/github.com/slack-go/slack/auth.go b/backend/vendor/github.com/slack-go/slack/auth.go index 7fff1a16..972f59ea 100644 --- a/backend/vendor/github.com/slack-go/slack/auth.go +++ b/backend/vendor/github.com/slack-go/slack/auth.go @@ -3,6 +3,7 @@ package slack import ( "context" "net/url" + "strconv" ) // AuthRevokeResponse contains our Auth response from the auth.revoke endpoint @@ -47,8 +48,9 @@ type listTeamsResponse struct { } type ListTeamsParameters struct { - Limit int - Cursor string + Limit int + Cursor string + IncludeIcon *bool } // ListTeams returns all workspaces a token can access. @@ -66,6 +68,9 @@ func (api *Client) ListTeamsContext(ctx context.Context, params ListTeamsParamet if params.Cursor != "" { values.Add("cursor", params.Cursor) } + if params.IncludeIcon != nil { + values.Add("include_icon", strconv.FormatBool(*params.IncludeIcon)) + } response := &listTeamsResponse{} err := api.postMethod(ctx, "auth.teams.list", values, response) diff --git a/backend/vendor/github.com/slack-go/slack/block.go b/backend/vendor/github.com/slack-go/slack/block.go index a3fb1a0a..7c4f9930 100644 --- a/backend/vendor/github.com/slack-go/slack/block.go +++ b/backend/vendor/github.com/slack-go/slack/block.go @@ -1,9 +1,5 @@ package slack -// @NOTE: Blocks are in beta and subject to change. - -// More Information: https://api.slack.com/block-kit - // MessageBlockType defines a named string type to define each block type // as a constant for use within the package. type MessageBlockType string @@ -20,12 +16,14 @@ const ( MBTRichText MessageBlockType = "rich_text" MBTCall MessageBlockType = "call" MBTVideo MessageBlockType = "video" + MBTMarkdown MessageBlockType = "markdown" ) // Block defines an interface all block types should implement // to ensure consistency between blocks. type Block interface { BlockType() MessageBlockType + ID() string } // Blocks is a convenience struct defined to allow dynamic unmarshalling of @@ -54,12 +52,14 @@ type BlockAction struct { SelectedDate string `json:"selected_date"` SelectedTime string `json:"selected_time"` SelectedDateTime int64 `json:"selected_date_time"` + Timezone string `json:"timezone"` InitialOption OptionBlockObject `json:"initial_option"` InitialUser string `json:"initial_user"` InitialChannel string `json:"initial_channel"` InitialConversation string `json:"initial_conversation"` InitialDate string `json:"initial_date"` InitialTime string `json:"initial_time"` + RichTextValue RichTextBlock `json:"rich_text_value"` } // actionType returns the type of the action diff --git a/backend/vendor/github.com/slack-go/slack/block_action.go b/backend/vendor/github.com/slack-go/slack/block_action.go index c15e4a3f..819c0ef0 100644 --- a/backend/vendor/github.com/slack-go/slack/block_action.go +++ b/backend/vendor/github.com/slack-go/slack/block_action.go @@ -14,6 +14,11 @@ func (s ActionBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s ActionBlock) ID() string { + return s.BlockID +} + // NewActionBlock returns a new instance of an Action Block func NewActionBlock(blockID string, elements ...BlockElement) *ActionBlock { return &ActionBlock{ diff --git a/backend/vendor/github.com/slack-go/slack/block_call.go b/backend/vendor/github.com/slack-go/slack/block_call.go index 98f2c025..c81dcb8b 100644 --- a/backend/vendor/github.com/slack-go/slack/block_call.go +++ b/backend/vendor/github.com/slack-go/slack/block_call.go @@ -14,7 +14,12 @@ func (s CallBlock) BlockType() MessageBlockType { return s.Type } -// NewFileBlock returns a new instance of a file block +// ID returns the ID of the block +func (s CallBlock) ID() string { + return s.BlockID +} + +// NewCallBlock returns a new instance of a call block func NewCallBlock(callID string) *CallBlock { return &CallBlock{ Type: MBTCall, diff --git a/backend/vendor/github.com/slack-go/slack/block_context.go b/backend/vendor/github.com/slack-go/slack/block_context.go index 384fee22..879ee61e 100644 --- a/backend/vendor/github.com/slack-go/slack/block_context.go +++ b/backend/vendor/github.com/slack-go/slack/block_context.go @@ -15,6 +15,11 @@ func (s ContextBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s ContextBlock) ID() string { + return s.BlockID +} + type ContextElements struct { Elements []MixedElement } diff --git a/backend/vendor/github.com/slack-go/slack/block_conv.go b/backend/vendor/github.com/slack-go/slack/block_conv.go index 26c57bbb..7df9b107 100644 --- a/backend/vendor/github.com/slack-go/slack/block_conv.go +++ b/backend/vendor/github.com/slack-go/slack/block_conv.go @@ -63,6 +63,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &ImageBlock{} case "input": block = &InputBlock{} + case "markdown": + block = &MarkdownBlock{} case "rich_text": block = &RichTextBlock{} case "rich_text_input": diff --git a/backend/vendor/github.com/slack-go/slack/block_divider.go b/backend/vendor/github.com/slack-go/slack/block_divider.go index 2d442ba1..e10d7b05 100644 --- a/backend/vendor/github.com/slack-go/slack/block_divider.go +++ b/backend/vendor/github.com/slack-go/slack/block_divider.go @@ -13,10 +13,14 @@ func (s DividerBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s DividerBlock) ID() string { + return s.BlockID +} + // NewDividerBlock returns a new instance of a divider block func NewDividerBlock() *DividerBlock { return &DividerBlock{ Type: MBTDivider, } - } diff --git a/backend/vendor/github.com/slack-go/slack/block_element.go b/backend/vendor/github.com/slack-go/slack/block_element.go index ad3b6700..2b32d331 100644 --- a/backend/vendor/github.com/slack-go/slack/block_element.go +++ b/backend/vendor/github.com/slack-go/slack/block_element.go @@ -117,9 +117,10 @@ func (s UnknownBlockElement) ElementType() MessageElementType { // // More Information: https://api.slack.com/reference/messaging/block-elements#image type ImageBlockElement struct { - Type MessageElementType `json:"type"` - ImageURL string `json:"image_url"` - AltText string `json:"alt_text"` + Type MessageElementType `json:"type"` + ImageURL string `json:"image_url"` + AltText string `json:"alt_text"` + SlackFile *SlackFileObject `json:"slack_file,omitempty"` } // ElementType returns the type of the Element @@ -140,6 +141,16 @@ func NewImageBlockElement(imageURL, altText string) *ImageBlockElement { } } +// NewImageBlockElementSlackFile returns a new instance of an image block element +// TODO: BREAKING CHANGE - This should be combined with the function above +func NewImageBlockElementSlackFile(slackFile *SlackFileObject, altText string) *ImageBlockElement { + return &ImageBlockElement{ + Type: METImage, + SlackFile: slackFile, + AltText: altText, + } +} + // Style is a style of Button element // https://api.slack.com/reference/block-kit/block-elements#button__fields type Style string @@ -318,6 +329,7 @@ type MultiSelectBlockElement struct { InitialUsers []string `json:"initial_users,omitempty"` InitialConversations []string `json:"initial_conversations,omitempty"` InitialChannels []string `json:"initial_channels,omitempty"` + Filter *SelectBlockElementFilter `json:"filter,omitempty"` MinQueryLength *int `json:"min_query_length,omitempty"` MaxSelectedItems *int `json:"max_selected_items,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` @@ -467,6 +479,7 @@ type TimePickerBlockElement struct { Placeholder *TextBlockObject `json:"placeholder,omitempty"` InitialTime string `json:"initial_time,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + Timezone string `json:"timezone,omitempty"` } // ElementType returns the type of the Element @@ -490,6 +503,7 @@ type DateTimePickerBlockElement struct { ActionID string `json:"action_id,omitempty"` InitialDateTime int64 `json:"initial_date_time,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element diff --git a/backend/vendor/github.com/slack-go/slack/block_file.go b/backend/vendor/github.com/slack-go/slack/block_file.go index ac4453f7..2f669b02 100644 --- a/backend/vendor/github.com/slack-go/slack/block_file.go +++ b/backend/vendor/github.com/slack-go/slack/block_file.go @@ -15,6 +15,11 @@ func (s FileBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s FileBlock) ID() string { + return s.BlockID +} + // NewFileBlock returns a new instance of a file block func NewFileBlock(blockID string, externalID string, source string) *FileBlock { return &FileBlock{ diff --git a/backend/vendor/github.com/slack-go/slack/block_header.go b/backend/vendor/github.com/slack-go/slack/block_header.go index 6dff4b88..3afb4c95 100644 --- a/backend/vendor/github.com/slack-go/slack/block_header.go +++ b/backend/vendor/github.com/slack-go/slack/block_header.go @@ -14,6 +14,11 @@ func (s HeaderBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s HeaderBlock) ID() string { + return s.BlockID +} + // HeaderBlockOption allows configuration of options for a new header block type HeaderBlockOption func(*HeaderBlock) diff --git a/backend/vendor/github.com/slack-go/slack/block_image.go b/backend/vendor/github.com/slack-go/slack/block_image.go index b3d2cb8c..2a914e5a 100644 --- a/backend/vendor/github.com/slack-go/slack/block_image.go +++ b/backend/vendor/github.com/slack-go/slack/block_image.go @@ -12,6 +12,11 @@ type ImageBlock struct { SlackFile *SlackFileObject `json:"slack_file,omitempty"` } +// ID returns the ID of the block +func (s ImageBlock) ID() string { + return s.BlockID +} + // SlackFileObject Defines an object containing Slack file information to be used in an // image block or image element. // @@ -36,3 +41,15 @@ func NewImageBlock(imageURL, altText, blockID string, title *TextBlockObject) *I Title: title, } } + +// NewImageBlockSlackFile returns an instance of a new Image Block type +// TODO: BREAKING CHANGE - This should be combined with the function above +func NewImageBlockSlackFile(slackFile *SlackFileObject, altText string, blockID string, title *TextBlockObject) *ImageBlock { + return &ImageBlock{ + Type: MBTImage, + SlackFile: slackFile, + AltText: altText, + BlockID: blockID, + Title: title, + } +} diff --git a/backend/vendor/github.com/slack-go/slack/block_input.go b/backend/vendor/github.com/slack-go/slack/block_input.go index 7c1272a6..f74eda6d 100644 --- a/backend/vendor/github.com/slack-go/slack/block_input.go +++ b/backend/vendor/github.com/slack-go/slack/block_input.go @@ -18,6 +18,11 @@ func (s InputBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s InputBlock) ID() string { + return s.BlockID +} + // NewInputBlock returns a new instance of an input block func NewInputBlock(blockID string, label, hint *TextBlockObject, element BlockElement) *InputBlock { return &InputBlock{ diff --git a/backend/vendor/github.com/slack-go/slack/block_markdown.go b/backend/vendor/github.com/slack-go/slack/block_markdown.go new file mode 100644 index 00000000..e22a0d16 --- /dev/null +++ b/backend/vendor/github.com/slack-go/slack/block_markdown.go @@ -0,0 +1,34 @@ +package slack + +// MarkdownBlock defines a block that lets you use markdown to format your text. +// +// This block can be used with AI apps when you expect a markdown response from an LLM +// that can get lost in translation rendering in Slack. Providing it in a markdown block +// leaves the translating to Slack to ensure your message appears as intended. Note that +// passing a single block may result in multiple blocks after translation. +// +// More Information: https://api.slack.com/reference/block-kit/blocks#markdown +type MarkdownBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Text string `json:"text"` +} + +// BlockType returns the type of the block +func (s MarkdownBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s MarkdownBlock) ID() string { + return s.BlockID +} + +// NewMarkdownBlock returns an instance of a new Markdown Block type +func NewMarkdownBlock(blockID, text string) *MarkdownBlock { + return &MarkdownBlock{ + Type: MBTMarkdown, + BlockID: blockID, + Text: text, + } +} diff --git a/backend/vendor/github.com/slack-go/slack/block_object.go b/backend/vendor/github.com/slack-go/slack/block_object.go index a3e78d4e..fd73b6c4 100644 --- a/backend/vendor/github.com/slack-go/slack/block_object.go +++ b/backend/vendor/github.com/slack-go/slack/block_object.go @@ -122,7 +122,7 @@ func unmarshalBlockObject(r json.RawMessage, object blockObject) (blockObject, e type TextBlockObject struct { Type string `json:"type"` Text string `json:"text"` - Emoji bool `json:"emoji,omitempty"` + Emoji *bool `json:"emoji,omitempty"` Verbatim bool `json:"verbatim,omitempty"` } @@ -142,9 +142,8 @@ func (s TextBlockObject) Validate() error { return errors.New("type must be either of plain_text or mrkdwn") } - // https://github.com/slack-go/slack/issues/881 - if s.Type == "mrkdwn" && s.Emoji { - return errors.New("emoji cannot be true in mrkdown") + if s.Type == "mrkdwn" && s.Emoji != nil { + return errors.New("emoji cannot be set for mrkdwn type") } // https://api.slack.com/reference/block-kit/composition-objects#text__fields @@ -161,11 +160,28 @@ func (s TextBlockObject) Validate() error { } // NewTextBlockObject returns an instance of a new Text Block Object -func NewTextBlockObject(elementType, text string, emoji, verbatim bool) *TextBlockObject { +// +// If you want to create a mrkdwn object, you should set the emoji parameter to false. The +// reason is that Slack doesn't accept emoji in mrkdwn. +func NewTextBlockObject(elementType, text string, emoji bool, verbatim bool) *TextBlockObject { + // If we're trying to build a mrkdwn object, we can't send emoji at all. I think the + // right approach here is to be a bit clever, and not break the function interface. + // + // So, here's the plan: + // 1. If the type is mrkdwn, set emoji to nil, regardless of what the user passed in + // 2. Else, set emoji to the value passed in + var emojiPtr *bool + + if elementType == "mrkdwn" { + emojiPtr = nil + } else { + emojiPtr = &emoji + } + return &TextBlockObject{ Type: elementType, Text: text, - Emoji: emoji, + Emoji: emojiPtr, Verbatim: verbatim, } } diff --git a/backend/vendor/github.com/slack-go/slack/block_rich_text.go b/backend/vendor/github.com/slack-go/slack/block_rich_text.go index c6eb0b1b..73233d23 100644 --- a/backend/vendor/github.com/slack-go/slack/block_rich_text.go +++ b/backend/vendor/github.com/slack-go/slack/block_rich_text.go @@ -16,6 +16,11 @@ func (b RichTextBlock) BlockType() MessageBlockType { return b.Type } +// ID returns the ID of the block +func (s RichTextBlock) ID() string { + return s.BlockID +} + func (e *RichTextBlock) UnmarshalJSON(b []byte) error { var raw struct { Type MessageBlockType `json:"type"` @@ -110,6 +115,8 @@ type RichTextList struct { Elements []RichTextElement `json:"elements"` Style RichTextListElementType `json:"style"` Indent int `json:"indent"` + Border int `json:"border"` + Offset int `json:"offset"` } // NewRichTextList returns a new rich text list element. @@ -132,6 +139,8 @@ func (e *RichTextList) UnmarshalJSON(b []byte) error { RawElements []json.RawMessage `json:"elements"` Style RichTextListElementType `json:"style"` Indent int `json:"indent"` + Border int `json:"border"` + Offset int `json:"offset"` } if string(b) == "{}" { return nil @@ -174,6 +183,8 @@ func (e *RichTextList) UnmarshalJSON(b []byte) error { Elements: elems, Style: raw.Style, Indent: raw.Indent, + Border: raw.Border, + Offset: raw.Offset, } return nil } @@ -190,7 +201,8 @@ func (s RichTextSection) RichTextElementType() RichTextElementType { func (e *RichTextSection) UnmarshalJSON(b []byte) error { var raw struct { - RawElements []json.RawMessage `json:"elements"` + RawElements []json.RawMessage `json:"elements"` + Type RichTextElementType `json:"type"` } if string(b) == "{}" { return nil @@ -240,8 +252,11 @@ func (e *RichTextSection) UnmarshalJSON(b []byte) error { } elems = append(elems, elem) } + if raw.Type == "" { + raw.Type = RTESection + } *e = RichTextSection{ - Type: RTESection, + Type: raw.Type, Elements: elems, } return nil @@ -340,7 +355,7 @@ func NewRichTextSectionUserElement(userID string, style *RichTextSectionTextStyl type RichTextSectionEmojiElement struct { Type RichTextSectionElementType `json:"type"` Name string `json:"name"` - SkinTone int `json:"skin_tone"` + SkinTone int `json:"skin_tone,omitempty"` Unicode string `json:"unicode,omitempty"` Style *RichTextSectionTextStyle `json:"style,omitempty"` } @@ -361,7 +376,7 @@ func NewRichTextSectionEmojiElement(name string, skinTone int, style *RichTextSe type RichTextSectionLinkElement struct { Type RichTextSectionElementType `json:"type"` URL string `json:"url"` - Text string `json:"text"` + Text string `json:"text,omitempty"` Style *RichTextSectionTextStyle `json:"style,omitempty"` } diff --git a/backend/vendor/github.com/slack-go/slack/block_section.go b/backend/vendor/github.com/slack-go/slack/block_section.go index 01ffd5a1..0d2352a0 100644 --- a/backend/vendor/github.com/slack-go/slack/block_section.go +++ b/backend/vendor/github.com/slack-go/slack/block_section.go @@ -9,6 +9,7 @@ type SectionBlock struct { BlockID string `json:"block_id,omitempty"` Fields []*TextBlockObject `json:"fields,omitempty"` Accessory *Accessory `json:"accessory,omitempty"` + Expand bool `json:"expand,omitempty"` } // BlockType returns the type of the block @@ -16,6 +17,11 @@ func (s SectionBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s SectionBlock) ID() string { + return s.BlockID +} + // SectionBlockOption allows configuration of options for a new section block type SectionBlockOption func(*SectionBlock) @@ -25,6 +31,15 @@ func SectionBlockOptionBlockID(blockID string) SectionBlockOption { } } +// SectionBlockOptionExpand allows long text to be auto-expanded when displaying +// +// @see https://api.slack.com/reference/block-kit/blocks#section +func SectionBlockOptionExpand(shouldExpand bool) SectionBlockOption { + return func(block *SectionBlock) { + block.Expand = shouldExpand + } +} + // NewSectionBlock returns a new instance of a section block to be rendered func NewSectionBlock(textObj *TextBlockObject, fields []*TextBlockObject, accessory *Accessory, options ...SectionBlockOption) *SectionBlock { block := SectionBlock{ diff --git a/backend/vendor/github.com/slack-go/slack/block_unknown.go b/backend/vendor/github.com/slack-go/slack/block_unknown.go index 97054c73..7a49a2c8 100644 --- a/backend/vendor/github.com/slack-go/slack/block_unknown.go +++ b/backend/vendor/github.com/slack-go/slack/block_unknown.go @@ -11,3 +11,8 @@ type UnknownBlock struct { func (b UnknownBlock) BlockType() MessageBlockType { return b.Type } + +// ID returns the ID of the block +func (s UnknownBlock) ID() string { + return s.BlockID +} diff --git a/backend/vendor/github.com/slack-go/slack/block_video.go b/backend/vendor/github.com/slack-go/slack/block_video.go index 322c614f..4d6739c4 100644 --- a/backend/vendor/github.com/slack-go/slack/block_video.go +++ b/backend/vendor/github.com/slack-go/slack/block_video.go @@ -22,6 +22,11 @@ func (s VideoBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s VideoBlock) ID() string { + return s.BlockID +} + // NewVideoBlock returns an instance of a new Video Block type func NewVideoBlock(videoURL, thumbnailURL, altText, blockID string, title *TextBlockObject) *VideoBlock { return &VideoBlock{ diff --git a/backend/vendor/github.com/slack-go/slack/chat.go b/backend/vendor/github.com/slack-go/slack/chat.go index 96843d68..85c4848b 100644 --- a/backend/vendor/github.com/slack-go/slack/chat.go +++ b/backend/vendor/github.com/slack-go/slack/chat.go @@ -68,6 +68,9 @@ type PostMessageParameters struct { // chat metadata support MetaData SlackMetadata `json:"metadata"` + + // file_ids support + FileIDs []string `json:"file_ids,omitempty"` } // NewPostMessageParameters provides an instance of PostMessageParameters with all the sane default values set @@ -116,13 +119,13 @@ func (api *Client) ScheduleMessage(channelID, postAt string, options ...MsgOptio // ScheduleMessageContext sends a message to a channel with a custom context. // Slack API docs: https://api.slack.com/methods/chat.scheduleMessage func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt string, options ...MsgOption) (string, string, error) { - respChannel, respTimestamp, _, err := api.SendMessageContext( + respChannel, scheduledMessageId, _, err := api.SendMessageContext( ctx, channelID, MsgOptionSchedule(postAt), MsgOptionCompose(options...), ) - return respChannel, respTimestamp, err + return respChannel, scheduledMessageId, err } // PostMessage sends a message to a channel. @@ -214,7 +217,7 @@ func (api *Client) SendMessage(channel string, options ...MsgOption) (string, st // SendMessageContext more flexible method for configuring messages with a custom context. // Slack API docs: https://api.slack.com/methods/chat.postMessage -func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestamp string, _text string, err error) { +func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestampOrScheduledMessageId string, _text string, err error) { var ( req *http.Request parser func(*chatResponseFull) responseParser @@ -234,11 +237,15 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt api.Debugf("Sending request: %s", redactToken(reqBody)) } - if err = doPost(ctx, api.httpclient, req, parser(&response), api); err != nil { + if err = doPost(api.httpclient, req, parser(&response), api); err != nil { return "", "", "", err } - return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() + if response.ScheduledMessageID != "" { + return response.Channel, response.ScheduledMessageID, response.Text, response.Err() + } else { + return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() + } } func redactToken(b []byte) []byte { @@ -711,6 +718,23 @@ func MsgOptionLinkNames(linkName bool) MsgOption { } } +// MsgOptionFileIDs sets file IDs for the message +func MsgOptionFileIDs(fileIDs []string) MsgOption { + return func(config *sendConfig) error { + if len(fileIDs) == 0 { + return nil + } + + fileIDsBytes, err := json.Marshal(fileIDs) + if err != nil { + return err + } + + config.values.Set("file_ids", string(fileIDsBytes)) + return nil + } +} + // UnsafeMsgOptionEndpoint deliver the message to the specified endpoint. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this Option // will be supported by the library, it is subject to change without notice that @@ -774,6 +798,10 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { config.values.Set("reply_broadcast", "true") } + if len(params.FileIDs) > 0 { + return MsgOptionFileIDs(params.FileIDs)(config) + } + return nil } } diff --git a/backend/vendor/github.com/slack-go/slack/conversation.go b/backend/vendor/github.com/slack-go/slack/conversation.go index 67d28be3..33eb0ff9 100644 --- a/backend/vendor/github.com/slack-go/slack/conversation.go +++ b/backend/vendor/github.com/slack-go/slack/conversation.go @@ -36,9 +36,10 @@ type Conversation struct { ConnectedTeamIDs []string `json:"connected_team_ids,omitempty"` SharedTeamIDs []string `json:"shared_team_ids,omitempty"` InternalTeamIDs []string `json:"internal_team_ids,omitempty"` - - // TODO support pending_shared - // TODO support previous_names + ContextTeamID string `json:"context_team_id,omitempty"` + ConversationHostID string `json:"conversation_host_id,omitempty"` + PreviousNames []string `json:"previous_names,omitempty"` + PendingShared []string `json:"pending_shared,omitempty"` } // GroupConversation is the foundation for Group and Channel @@ -68,7 +69,21 @@ type Purpose struct { // Properties contains the Canvas associated to the channel. type Properties struct { - Canvas Canvas `json:"canvas"` + Canvas Canvas `json:"canvas"` + PostingRestrictedTo RestrictedTo `json:"posting_restricted_to"` + Tabs []Tab `json:"tabs"` + ThreadsRestrictedTo RestrictedTo `json:"threads_restricted_to"` +} + +type RestrictedTo struct { + Type []string `json:"type"` + User []string `json:"user"` +} + +type Tab struct { + ID string `json:"id"` + Label string `json:"label"` + Type string `json:"type"` } type Canvas struct { @@ -329,6 +344,44 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel return response.Channel, response.Err() } +// The following functions are for inviting users to a channel but setting the `force` +// parameter to true. We have added this so that we don't break the existing API. +// +// IMPORTANT: If we ever get here for _another_ parameter, we should consider refactoring +// this to be more flexible. +// +// ForceInviteUsersToConversation invites users to a channel but sets the `force` +// parameter to true. +// +// For more details, see ForceInviteUsersToConversationContext documentation. +func (api *Client) ForceInviteUsersToConversation(channelID string, users ...string) (*Channel, error) { + return api.ForceInviteUsersToConversationContext(context.Background(), channelID, users...) +} + +// ForceInviteUsersToConversationContext invites users to a channel with a custom context +// while setting the `force` argument to true. +// +// Slack API docs: https://api.slack.com/methods/conversations.invite +func (api *Client) ForceInviteUsersToConversationContext(ctx context.Context, channelID string, users ...string) (*Channel, error) { + values := url.Values{ + "token": {api.token}, + "channel": {channelID}, + "users": {strings.Join(users, ",")}, + "force": {"true"}, + } + response := struct { + SlackResponse + Channel *Channel `json:"channel"` + }{} + + err := api.postMethod(ctx, "conversations.invite", values, &response) + if err != nil { + return nil, err + } + + return response.Channel, response.Err() +} + // InviteSharedEmailsToConversation invites users to a shared channels by email. // For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedEmailsToConversation(channelID string, emails ...string) (string, bool, error) { diff --git a/backend/vendor/github.com/slack-go/slack/files.go b/backend/vendor/github.com/slack-go/slack/files.go index b2631714..810c476b 100644 --- a/backend/vendor/github.com/slack-go/slack/files.go +++ b/backend/vendor/github.com/slack-go/slack/files.go @@ -89,6 +89,25 @@ type File struct { NumStars int `json:"num_stars"` IsStarred bool `json:"is_starred"` Shares Share `json:"shares"` + + Subject string `json:"subject"` + To []EmailFileUserInfo `json:"to"` + From []EmailFileUserInfo `json:"from"` + Cc []EmailFileUserInfo `json:"cc"` + Headers EmailHeaders `json:"headers"` +} + +type EmailFileUserInfo struct { + Address string `json:"address"` + Name string `json:"name"` + Original string `json:"original"` +} + +type EmailHeaders struct { + Date string `json:"date"` + InReplyTo string `json:"in_reply_to"` + ReplyTo string `json:"reply_to"` + MessageID string `json:"message_id"` } type Share struct { @@ -156,26 +175,27 @@ type UploadFileV2Parameters struct { Filename string Title string InitialComment string + Blocks Blocks Channel string ThreadTimestamp string AltTxt string - SnippetText string + SnippetType string } -type getUploadURLExternalParameters struct { - altText string - fileSize int - fileName string - snippetText string +type GetUploadURLExternalParameters struct { + AltTxt string + FileSize int + FileName string + SnippetType string } -type getUploadURLExternalResponse struct { +type GetUploadURLExternalResponse struct { UploadURL string `json:"upload_url"` FileID string `json:"file_id"` SlackResponse } -type uploadToURLParameters struct { +type UploadToURLParameters struct { UploadURL string Reader io.Reader File string @@ -188,14 +208,15 @@ type FileSummary struct { Title string `json:"title"` } -type completeUploadExternalParameters struct { - title string - channel string - initialComment string - threadTimestamp string +type CompleteUploadExternalParameters struct { + Files []FileSummary + Blocks Blocks + Channel string + InitialComment string + ThreadTimestamp string } -type completeUploadExternalResponse struct { +type CompleteUploadExternalResponse struct { SlackResponse Files []FileSummary `json:"files"` } @@ -356,7 +377,11 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet // UploadFile uploads a file. // -// Deprecated: Use [Client.UploadFileV2] instead. This will stop functioning on March 11, 2025. +// Deprecated: Use [Client.UploadFileV2] instead. +// +// Per Slack Changelog, specifically [https://api.slack.com/changelog#entry-march_2025_1](this entry), +// this will stop functioning on November 12, 2025. +// // For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) { return api.UploadFileContext(context.Background(), params) @@ -364,7 +389,11 @@ func (api *Client) UploadFile(params FileUploadParameters) (file *File, err erro // UploadFileContext uploads a file and setting a custom context. // -// Deprecated: Use [Client.UploadFileV2Context] instead. This will stop functioning on March 11, 2025. +// Deprecated: Use [Client.UploadFileV2Context] instead. +// +// Per Slack Changelog, specifically [https://api.slack.com/changelog#entry-march_2025_1](this entry), +// this will stop functioning on November 12, 2025. +// // For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *File, err error) { // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More @@ -495,20 +524,28 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) return &response.File, response.Comments, &response.Paging, nil } -// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file. -func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURLExternalParameters) (*getUploadURLExternalResponse, error) { +// GetUploadURLExternalContext gets a URL and fileID from slack which can later be used to upload a file. +// Slack API docs: https://api.slack.com/methods/files.getUploadURLExternal +func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUploadURLExternalParameters) (*GetUploadURLExternalResponse, error) { + if params.FileName == "" { + return nil, fmt.Errorf("FileName cannot be empty") + } + if params.FileSize == 0 { + return nil, fmt.Errorf("FileSize cannot be 0") + } + values := url.Values{ "token": {api.token}, - "filename": {params.fileName}, - "length": {strconv.Itoa(params.fileSize)}, + "filename": {params.FileName}, + "length": {strconv.Itoa(params.FileSize)}, } - if params.altText != "" { - values.Add("initial_comment", params.altText) + if params.AltTxt != "" { + values.Add("alt_txt", params.AltTxt) } - if params.snippetText != "" { - values.Add("thread_ts", params.snippetText) + if params.SnippetType != "" { + values.Add("snippet_type", params.SnippetType) } - response := &getUploadURLExternalResponse{} + response := &GetUploadURLExternalResponse{} err := api.postMethod(ctx, "files.getUploadURLExternal", values, response) if err != nil { return nil, err @@ -517,8 +554,9 @@ func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURL return response, response.Err() } -// uploadToURL uploads the file to the provided URL using post method -func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters) (err error) { +// UploadToURL uploads the file to the provided URL using post method +// This is not a Slack API method, but a helper function to upload files to the URL +func (api *Client) UploadToURL(ctx context.Context, params UploadToURLParameters) (err error) { values := url.Values{} if params.Content != "" { contentReader := strings.NewReader(params.Content) @@ -531,28 +569,36 @@ func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters return err } -// completeUploadExternal once files are uploaded, this completes the upload and shares it to the specified channel -func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params completeUploadExternalParameters) (file *completeUploadExternalResponse, err error) { - request := []FileSummary{{ID: fileID, Title: params.title}} - requestBytes, err := json.Marshal(request) +// CompleteUploadExternalContext once files are uploaded, this completes the upload and shares it to the specified channel +// Slack API docs: https://api.slack.com/methods/files.completeUploadExternal +func (api *Client) CompleteUploadExternalContext(ctx context.Context, params CompleteUploadExternalParameters) (file *CompleteUploadExternalResponse, err error) { + filesBytes, err := json.Marshal(params.Files) if err != nil { return nil, err } + values := url.Values{ "token": {api.token}, - "files": {string(requestBytes)}, + "files": {string(filesBytes)}, } - if params.channel != "" { - values.Add("channel_id", params.channel) + if params.Channel != "" { + values.Add("channel_id", params.Channel) } - if params.initialComment != "" { - values.Add("initial_comment", params.initialComment) + if params.InitialComment != "" { + values.Add("initial_comment", params.InitialComment) } - if params.threadTimestamp != "" { - values.Add("thread_ts", params.threadTimestamp) + if params.Blocks.BlockSet != nil && params.InitialComment == "" { + blocksBytes, err := json.Marshal(params.Blocks) + if err != nil { + return nil, err + } + values.Add("blocks", string(blocksBytes)) } - response := &completeUploadExternalResponse{} + if params.ThreadTimestamp != "" { + values.Add("thread_ts", params.ThreadTimestamp) + } + response := &CompleteUploadExternalResponse{} err = api.postMethod(ctx, "files.completeUploadExternal", values, response) if err != nil { return nil, err @@ -583,17 +629,17 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P return nil, fmt.Errorf("file.upload.v2: file size cannot be 0") } - u, err := api.getUploadURLExternal(ctx, getUploadURLExternalParameters{ - altText: params.AltTxt, - fileName: params.Filename, - fileSize: params.FileSize, - snippetText: params.SnippetText, + u, err := api.GetUploadURLExternalContext(ctx, GetUploadURLExternalParameters{ + AltTxt: params.AltTxt, + FileName: params.Filename, + FileSize: params.FileSize, + SnippetType: params.SnippetType, }) if err != nil { return nil, err } - err = api.uploadToURL(ctx, uploadToURLParameters{ + err = api.UploadToURL(ctx, UploadToURLParameters{ UploadURL: u.UploadURL, Reader: params.Reader, File: params.File, @@ -604,11 +650,15 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P return nil, err } - c, err := api.completeUploadExternal(ctx, u.FileID, completeUploadExternalParameters{ - title: params.Title, - channel: params.Channel, - initialComment: params.InitialComment, - threadTimestamp: params.ThreadTimestamp, + c, err := api.CompleteUploadExternalContext(ctx, CompleteUploadExternalParameters{ + Files: []FileSummary{{ + ID: u.FileID, + Title: params.Title, + }}, + Channel: params.Channel, + InitialComment: params.InitialComment, + ThreadTimestamp: params.ThreadTimestamp, + Blocks: params.Blocks, }) if err != nil { return nil, err diff --git a/backend/vendor/github.com/slack-go/slack/info.go b/backend/vendor/github.com/slack-go/slack/info.go index b06dffdb..a026ab49 100644 --- a/backend/vendor/github.com/slack-go/slack/info.go +++ b/backend/vendor/github.com/slack-go/slack/info.go @@ -428,13 +428,16 @@ type Team struct { ID string `json:"id"` Name string `json:"name"` Domain string `json:"domain"` + Icons *Icons `json:"icon,omitempty"` } // Icons XXX: needs further investigation type Icons struct { - Image36 string `json:"image_36,omitempty"` - Image48 string `json:"image_48,omitempty"` - Image72 string `json:"image_72,omitempty"` + Image36 string `json:"image_36,omitempty"` + Image48 string `json:"image_48,omitempty"` + Image72 string `json:"image_72,omitempty"` + Image132 string `json:"image_132,omitempty"` + Image230 string `json:"image_230,omitempty"` } // Info contains various details about the authenticated user and team. diff --git a/backend/vendor/github.com/slack-go/slack/interactions.go b/backend/vendor/github.com/slack-go/slack/interactions.go index 8c641470..f658a72c 100644 --- a/backend/vendor/github.com/slack-go/slack/interactions.go +++ b/backend/vendor/github.com/slack-go/slack/interactions.go @@ -3,6 +3,8 @@ package slack import ( "bytes" "encoding/json" + "errors" + "net/http" ) // InteractionType type of interactions @@ -33,30 +35,29 @@ const ( // InteractionCallback is sent from slack when a user interactions with a button or dialog. type InteractionCallback struct { - Type InteractionType `json:"type"` - Token string `json:"token"` - CallbackID string `json:"callback_id"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - ActionTs string `json:"action_ts"` - Team Team `json:"team"` - Channel Channel `json:"channel"` - User User `json:"user"` - OriginalMessage Message `json:"original_message"` - Message Message `json:"message"` - Name string `json:"name"` - Value string `json:"value"` - MessageTs string `json:"message_ts"` - AttachmentID string `json:"attachment_id"` - ActionCallback ActionCallbacks `json:"actions"` - View View `json:"view"` - ActionID string `json:"action_id"` - APIAppID string `json:"api_app_id"` - BlockID string `json:"block_id"` - Container Container `json:"container"` - Enterprise Enterprise `json:"enterprise"` - IsEnterpriseInstall bool `json:"is_enterprise_install"` - WorkflowStep InteractionWorkflowStep `json:"workflow_step"` + Type InteractionType `json:"type"` + Token string `json:"token"` + CallbackID string `json:"callback_id"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + ActionTs string `json:"action_ts"` + Team Team `json:"team"` + Channel Channel `json:"channel"` + User User `json:"user"` + OriginalMessage Message `json:"original_message"` + Message Message `json:"message"` + Name string `json:"name"` + Value string `json:"value"` + MessageTs string `json:"message_ts"` + AttachmentID string `json:"attachment_id"` + ActionCallback ActionCallbacks `json:"actions"` + View View `json:"view"` + ActionID string `json:"action_id"` + APIAppID string `json:"api_app_id"` + BlockID string `json:"block_id"` + Container Container `json:"container"` + Enterprise Enterprise `json:"enterprise"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` DialogSubmissionCallback ViewSubmissionCallback ViewClosedCallback @@ -75,6 +76,24 @@ type BlockActionStates struct { Values map[string]map[string]BlockAction `json:"values"` } +// InteractionCallbackParse parses the HTTP form value "payload" from r, unmarshals +// it as JSON into an InteractionCallback, and returns the result. +// It returns an error if the payload is missing or cannot be decoded. +// +// See https://github.com/slack-go/slack/issues/660 for context. +func InteractionCallbackParse(r *http.Request) (InteractionCallback, error) { + payload := r.FormValue("payload") + if len(payload) == 0 { + return InteractionCallback{}, errors.New("payload is empty") + } + + var ic InteractionCallback + if err := json.Unmarshal([]byte(payload), &ic); err != nil { + return InteractionCallback{}, err + } + return ic, nil +} + func (ic *InteractionCallback) MarshalJSON() ([]byte, error) { type alias InteractionCallback tmp := alias(*ic) @@ -137,14 +156,6 @@ type Enterprise struct { Name string `json:"name"` } -type InteractionWorkflowStep struct { - WorkflowStepEditID string `json:"workflow_step_edit_id,omitempty"` - WorkflowID string `json:"workflow_id"` - StepID string `json:"step_id"` - Inputs *WorkflowStepInputs `json:"inputs,omitempty"` - Outputs *[]WorkflowStepOutput `json:"outputs,omitempty"` -} - // ActionCallback is a convenience struct defined to allow dynamic unmarshalling of // the "actions" value in Slack's JSON response, which varies depending on block type type ActionCallbacks struct { diff --git a/backend/vendor/github.com/slack-go/slack/messages.go b/backend/vendor/github.com/slack-go/slack/messages.go index 13a1ede9..c53809fd 100644 --- a/backend/vendor/github.com/slack-go/slack/messages.go +++ b/backend/vendor/github.com/slack-go/slack/messages.go @@ -17,6 +17,10 @@ type Message struct { Msg SubMessage *Msg `json:"message,omitempty"` PreviousMessage *Msg `json:"previous_message,omitempty"` + // Root is the message that was broadcast to the channel when the SubType is + // thread_broadcast. If this is not a thread_broadcast message event, this + // value is nil. + Root *Msg `json:"root,omitempty"` } // Msg SubTypes (https://api.slack.com/events/message) @@ -49,6 +53,7 @@ const ( MsgSubTypeUnpinnedItem = "unpinned_item" // [RTM] An item was unpinned from a channel MsgSubTypeEkmAccessDenied = "ekm_access_denied" // [Events API, RTM] Message content redacted due to Enterprise Key Management (EKM) MsgSubTypeChannelPostingPermissions = "channel_posting_permissions" // [Events API, RTM] The posting permissions for a channel changed + MsgSubTypeAssistantAppThread = "assistant_app_thread" // [Events API, RTM] The message is an app assistant thread ) // Msg contains information about a slack message diff --git a/backend/vendor/github.com/slack-go/slack/migration.go b/backend/vendor/github.com/slack-go/slack/migration.go new file mode 100644 index 00000000..fa6690ee --- /dev/null +++ b/backend/vendor/github.com/slack-go/slack/migration.go @@ -0,0 +1,40 @@ +package slack + +import ( + "context" + "net/url" +) + +type migrationExchangeResponseFull struct { + TeamID string `json:"team_id"` + ToOld bool `json:"to_old"` + EnterpriseID string `json:"enterprise_id"` + UserIDMap map[string]string `json:"user_id_map"` + InvalidUserIDs []string `json:"invalid_user_ids"` + SlackResponse +} + +// MigrationExchange for Enterprise Grid workspaces, map local user IDs to global user IDs +func (api *Client) MigrationExchange(ctx context.Context, teamID string, toOld bool, users []string) (map[string]string, []string, error) { + values := url.Values{ + "users": users, + } + if teamID != "" { + values.Add("team_id", teamID) + } + if toOld { + values.Add("to_old", "true") + } + + response := &migrationExchangeResponseFull{} + err := api.getMethod(ctx, "migration.exchange", api.token, values, response) + if err != nil { + return nil, nil, err + } + + if err := response.Err(); err != nil { + return nil, nil, err + } + + return response.UserIDMap, response.InvalidUserIDs, nil +} diff --git a/backend/vendor/github.com/slack-go/slack/misc.go b/backend/vendor/github.com/slack-go/slack/misc.go index df779c1a..48411ee1 100644 --- a/backend/vendor/github.com/slack-go/slack/misc.go +++ b/backend/vendor/github.com/slack-go/slack/misc.go @@ -19,11 +19,103 @@ import ( "time" ) +// Apps Manifest Create Response Errors ("/apps.manifest.create") +type AppsManifestCreateResponseError struct { + Code string `json:"code,omitempty"` + Message string `json:"message"` + Pointer string `json:"pointer"` + RelatedComponent string `json:"related_component,omitempty"` +} + +// Conversations Invite Response Errors ("/conversations.invite") +type ConversationsInviteResponseError struct { + Error string `json:"error"` + Ok bool `json:"ok"` + User string `json:"user"` +} + +func (t ConversationsInviteResponseError) Err() error { + if !t.Ok { + return fmt.Errorf("conversations invite error (user: %s): %s", t.User, t.Error) + } + return nil +} + +// SlackResponseErrors represents a union type for different error structures +type SlackResponseErrors struct { + AppsManifestCreateResponseError *AppsManifestCreateResponseError `json:"-"` + ConversationsInviteResponseError *ConversationsInviteResponseError `json:"-"` + Message *string `json:"-"` +} + +// MarshalJSON implements custom marshaling for SlackResponseErrors +func (e SlackResponseErrors) MarshalJSON() ([]byte, error) { + if e.AppsManifestCreateResponseError != nil { + return json.Marshal(e.AppsManifestCreateResponseError) + } + if e.ConversationsInviteResponseError != nil { + return json.Marshal(e.ConversationsInviteResponseError) + } + if e.Message != nil { + return json.Marshal(*e.Message) + } + return json.Marshal(nil) +} + +// UnmarshalJSON implements custom unmarshaling for SlackResponseErrors +func (e *SlackResponseErrors) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte("null")) { + return nil + } + + // Try to determine the error type by checking for unique fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + // If we can't unmarshal as object, try as string (fallback case) + // + // For more details on this specific problem look up issue + // https://github.com/slack-go/slack/issues/1446. + var stringError string + if stringErr := json.Unmarshal(data, &stringError); stringErr == nil { + e.Message = &stringError + return nil + } + return err + } + + if _, hasPointer := raw["pointer"]; hasPointer { + if _, hasMessage := raw["message"]; hasMessage { + var amc AppsManifestCreateResponseError + if err := json.Unmarshal(data, &amc); err != nil { + return err + } + e.AppsManifestCreateResponseError = &amc + return nil + } + } + + if _, hasUser := raw["user"]; hasUser { + if _, hasError := raw["error"]; hasError { + if _, hasOk := raw["ok"]; hasOk { + var ci ConversationsInviteResponseError + if err := json.Unmarshal(data, &ci); err != nil { + return err + } + e.ConversationsInviteResponseError = &ci + return nil + } + } + } + + return fmt.Errorf("unknown error structure: %s", string(data)) +} + // SlackResponse handles parsing out errors from the web api. type SlackResponse struct { - Ok bool `json:"ok"` - Error string `json:"error"` - ResponseMetadata ResponseMetadata `json:"response_metadata"` + Ok bool `json:"ok"` + Error string `json:"error"` + Errors []SlackResponseErrors `json:"errors,omitempty"` + ResponseMetadata ResponseMetadata `json:"response_metadata"` } func (t SlackResponse) Err() error { @@ -38,12 +130,13 @@ func (t SlackResponse) Err() error { return nil } - return SlackErrorResponse{Err: t.Error, ResponseMetadata: t.ResponseMetadata} + return SlackErrorResponse{Err: t.Error, Errors: t.Errors, ResponseMetadata: t.ResponseMetadata} } // SlackErrorResponse brings along the metadata of errors returned by the Slack API. type SlackErrorResponse struct { Err string + Errors []SlackResponseErrors ResponseMetadata ResponseMetadata } @@ -124,19 +217,6 @@ func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http. return req, nil } -func parseResponseBody(body io.ReadCloser, intf interface{}, d Debug) error { - response, err := io.ReadAll(body) - if err != nil { - return err - } - - if d.Debug() { - d.Debugln("parseResponseBody", string(response)) - } - - return json.Unmarshal(response, intf) -} - func postLocalWithMultipartResponse(ctx context.Context, client httpClient, method, fpath, fieldname, token string, values url.Values, intf interface{}, d Debug) error { fullpath, err := filepath.Abs(fpath) if err != nil { @@ -220,7 +300,7 @@ func createFormFields(mw *multipart.Writer, values url.Values) error { return nil } -func doPost(ctx context.Context, client httpClient, req *http.Request, parser responseParser, d Debug) error { +func doPost(client httpClient, req *http.Request, parser responseParser, d Debug) error { resp, err := client.Do(req) if err != nil { return err @@ -245,7 +325,7 @@ func postJSON(ctx context.Context, client httpClient, endpoint, token string, js req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - return doPost(ctx, client, req, newJSONParser(intf), d) + return doPost(client, req, newJSONParser(intf), d) } // post a url encoded form. @@ -256,7 +336,7 @@ func postForm(ctx context.Context, client httpClient, endpoint string, values ur return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return doPost(ctx, client, req, newJSONParser(intf), d) + return doPost(client, req, newJSONParser(intf), d) } func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf interface{}, d Debug) error { @@ -269,7 +349,7 @@ func getResource(ctx context.Context, client httpClient, endpoint, token string, req.URL.RawQuery = values.Encode() - return doPost(ctx, client, req, newJSONParser(intf), d) + return doPost(client, req, newJSONParser(intf), d) } func parseAdminResponse(ctx context.Context, client httpClient, method string, teamName string, values url.Values, intf interface{}, d Debug) error { @@ -297,16 +377,8 @@ func okJSONHandler(rw http.ResponseWriter, r *http.Request) { rw.Write(response) } -// timerReset safely reset a timer, see time.Timer.Reset for details. -func timerReset(t *time.Timer, d time.Duration) { - if !t.Stop() { - <-t.C - } - t.Reset(d) -} - func checkStatusCode(resp *http.Response, d Debug) error { - if resp.StatusCode == http.StatusTooManyRequests { + if resp.StatusCode == http.StatusTooManyRequests && resp.Header.Get("Retry-After") != "" { retry, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 64) if err != nil { return err @@ -336,6 +408,10 @@ func newJSONParser(dst interface{}) responseParser { func newTextParser(dst interface{}) responseParser { return func(resp *http.Response) error { + if dst == nil { + return nil + } + b, err := io.ReadAll(resp.Body) if err != nil { return err diff --git a/backend/vendor/github.com/slack-go/slack/rtm.go b/backend/vendor/github.com/slack-go/slack/rtm.go index ef6ba343..9b30eb31 100644 --- a/backend/vendor/github.com/slack-go/slack/rtm.go +++ b/backend/vendor/github.com/slack-go/slack/rtm.go @@ -25,6 +25,9 @@ const ( // StartRTM calls the "rtm.start" endpoint and returns the provided URL and the full Info block. // // To have a fully managed Websocket connection, use `NewRTM`, and call `ManageConnection()` on it. +// +// Deprecated: Use [ConnectRTM] instead. +// For more details, see: https://api.slack.com/changelog/2021-10-rtm-start-to-stop func (api *Client) StartRTM() (info *Info, websocketURL string, err error) { ctx, cancel := context.WithTimeout(context.Background(), websocketDefaultTimeout) defer cancel() @@ -35,6 +38,9 @@ func (api *Client) StartRTM() (info *Info, websocketURL string, err error) { // StartRTMContext calls the "rtm.start" endpoint and returns the provided URL and the full Info block with a custom context. // // To have a fully managed Websocket connection, use `NewRTM`, and call `ManageConnection()` on it. +// +// Deprecated: Use [ConnectRTMContext] instead. +// For more details, see: https://api.slack.com/changelog/2021-10-rtm-start-to-stop func (api *Client) StartRTMContext(ctx context.Context) (info *Info, websocketURL string, err error) { response := &infoResponseFull{} err = api.postMethod(ctx, "rtm.start", url.Values{"token": {api.token}}, response) diff --git a/backend/vendor/github.com/slack-go/slack/search.go b/backend/vendor/github.com/slack-go/slack/search.go index d27497aa..9df43350 100644 --- a/backend/vendor/github.com/slack-go/slack/search.go +++ b/backend/vendor/github.com/slack-go/slack/search.go @@ -89,7 +89,7 @@ func NewSearchParameters() SearchParameters { } } -func (api *Client) _search(ctx context.Context, path, query string, params SearchParameters, files, messages bool) (response *searchResponseFull, error error) { +func (api *Client) _search(ctx context.Context, path, query string, params SearchParameters) (response *searchResponseFull, error error) { values := url.Values{ "token": {api.token}, "query": {query}, @@ -103,7 +103,7 @@ func (api *Client) _search(ctx context.Context, path, query string, params Searc if params.SortDirection != DEFAULT_SEARCH_SORT_DIR { values.Add("sort_dir", params.SortDirection) } - if params.Highlight != DEFAULT_SEARCH_HIGHLIGHT { + if params.Highlight { values.Add("highlight", strconv.Itoa(1)) } if params.Count != DEFAULT_SEARCH_COUNT { @@ -128,7 +128,7 @@ func (api *Client) Search(query string, params SearchParameters) (*SearchMessage } func (api *Client) SearchContext(ctx context.Context, query string, params SearchParameters) (*SearchMessages, *SearchFiles, error) { - response, err := api._search(ctx, "search.all", query, params, true, true) + response, err := api._search(ctx, "search.all", query, params) if err != nil { return nil, nil, err } @@ -140,7 +140,7 @@ func (api *Client) SearchFiles(query string, params SearchParameters) (*SearchFi } func (api *Client) SearchFilesContext(ctx context.Context, query string, params SearchParameters) (*SearchFiles, error) { - response, err := api._search(ctx, "search.files", query, params, true, false) + response, err := api._search(ctx, "search.files", query, params) if err != nil { return nil, err } @@ -152,7 +152,7 @@ func (api *Client) SearchMessages(query string, params SearchParameters) (*Searc } func (api *Client) SearchMessagesContext(ctx context.Context, query string, params SearchParameters) (*SearchMessages, error) { - response, err := api._search(ctx, "search.messages", query, params, false, true) + response, err := api._search(ctx, "search.messages", query, params) if err != nil { return nil, err } diff --git a/backend/vendor/github.com/slack-go/slack/socket_mode.go b/backend/vendor/github.com/slack-go/slack/socket_mode.go index 69e40d99..ea9ea3b7 100644 --- a/backend/vendor/github.com/slack-go/slack/socket_mode.go +++ b/backend/vendor/github.com/slack-go/slack/socket_mode.go @@ -2,6 +2,7 @@ package slack import ( "context" + "net/url" ) // SocketModeConnection contains various details about the SocketMode connection. @@ -30,5 +31,13 @@ func (api *Client) StartSocketModeContext(ctx context.Context) (info *SocketMode api.Debugln("Using URL:", response.SocketModeConnection.URL) } + // According to the API documentation at https://api.slack.com/apis/socket-mode, we + // can add a query parameter `debug_reconnects=true` to the URL to make the connection + // time significantly shorter (360 seconds). + if api.debug { + u, _ := url.Parse(response.SocketModeConnection.URL) + u.Query().Add("debug_reconnects", "true") + response.SocketModeConnection.URL = u.String() + } return &response.SocketModeConnection, response.SocketModeConnection.URL, response.Err() } diff --git a/backend/vendor/github.com/slack-go/slack/team.go b/backend/vendor/github.com/slack-go/slack/team.go index 4e890c2b..35b69927 100644 --- a/backend/vendor/github.com/slack-go/slack/team.go +++ b/backend/vendor/github.com/slack-go/slack/team.go @@ -116,7 +116,7 @@ func (api *Client) accessLogsRequest(ctx context.Context, path string, values ur return response, response.Err() } -func (api *Client) teamProfileRequest(ctx context.Context, client httpClient, path string, values url.Values) (*TeamProfileResponse, error) { +func (api *Client) teamProfileRequest(ctx context.Context, path string, values url.Values) (*TeamProfileResponse, error) { response := &TeamProfileResponse{} err := api.postMethod(ctx, path, values, response) if err != nil { @@ -184,7 +184,7 @@ func (api *Client) GetTeamProfileContext(ctx context.Context, teamID ...string) values["team_id"] = teamID } - response, err := api.teamProfileRequest(ctx, api.httpclient, "team.profile.get", values) + response, err := api.teamProfileRequest(ctx, "team.profile.get", values) if err != nil { return nil, err } diff --git a/backend/vendor/github.com/slack-go/slack/usergroups.go b/backend/vendor/github.com/slack-go/slack/usergroups.go index 0c2ec4c9..616bbf15 100644 --- a/backend/vendor/github.com/slack-go/slack/usergroups.go +++ b/backend/vendor/github.com/slack-go/slack/usergroups.go @@ -3,6 +3,7 @@ package slack import ( "context" "net/url" + "strconv" "strings" ) @@ -50,20 +51,57 @@ func (api *Client) userGroupRequest(ctx context.Context, path string, values url return response, response.Err() } +// createUserGroupParams contains arguments for CreateUserGroup method call +type createUserGroupParams struct { + enableSection bool + includeCount bool +} + +// CreateUserGroupOption options for the CreateUserGroup method call. +type CreateUserGroupOption func(*createUserGroupParams) + +// CreateUserGroupOptionEnableSection enable the section for the user group (default: false) +func CreateUserGroupOptionEnableSection(enableSection bool) CreateUserGroupOption { + return func(params *createUserGroupParams) { + params.enableSection = enableSection + } +} + +// CreateUserGroupOptionIncludeCount include the number of users in each User Group +func CreateUserGroupOptionIncludeCount(includeCount bool) CreateUserGroupOption { + return func(params *createUserGroupParams) { + params.includeCount = includeCount + } +} + // CreateUserGroup creates a new user group. // For more information see the CreateUserGroupContext documentation. -func (api *Client) CreateUserGroup(userGroup UserGroup) (UserGroup, error) { - return api.CreateUserGroupContext(context.Background(), userGroup) +func (api *Client) CreateUserGroup(userGroup UserGroup, options ...CreateUserGroupOption) (UserGroup, error) { + return api.CreateUserGroupContext(context.Background(), userGroup, options...) } // CreateUserGroupContext creates a new user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.create -func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup) (UserGroup, error) { +func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup, options ...CreateUserGroupOption) (UserGroup, error) { + params := createUserGroupParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "name": {userGroup.Name}, } + if params.enableSection { + values["enable_section"] = []string{strconv.FormatBool(params.enableSection)} + } + + if params.includeCount { + values["include_count"] = []string{strconv.FormatBool(params.includeCount)} + } + if userGroup.TeamID != "" { values["team_id"] = []string{userGroup.TeamID} } @@ -87,20 +125,57 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro return response.UserGroup, nil } +// DisableUserGroupParams contains arguments for DisableUserGroup method calls. +type DisableUserGroupParams struct { + IncludeCount bool + TeamID string +} + +// DisableUserGroupOption options for the DisableUserGroup method calls. +type DisableUserGroupOption func(*DisableUserGroupParams) + +// DisableUserGroupOptionIncludeCount include the count of User Groups (default: false) +func DisableUserGroupOptionIncludeCount(b bool) DisableUserGroupOption { + return func(params *DisableUserGroupParams) { + params.IncludeCount = b + } +} + +// DisableUserGroupOptionTeamID include team Id +func DisableUserGroupOptionTeamID(teamID string) DisableUserGroupOption { + return func(params *DisableUserGroupParams) { + params.TeamID = teamID + } +} + // DisableUserGroup disables an existing user group. // For more information see the DisableUserGroupContext documentation. -func (api *Client) DisableUserGroup(userGroup string) (UserGroup, error) { - return api.DisableUserGroupContext(context.Background(), userGroup) +func (api *Client) DisableUserGroup(userGroup string, options ...DisableUserGroupOption) (UserGroup, error) { + return api.DisableUserGroupContext(context.Background(), userGroup, options...) } // DisableUserGroupContext disables an existing user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.disable -func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { +func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string, options ...DisableUserGroupOption) (UserGroup, error) { + params := DisableUserGroupParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "usergroup": {userGroup}, } + if params.IncludeCount { + values.Add("include_count", "true") + } + + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + response, err := api.userGroupRequest(ctx, "usergroups.disable", values) if err != nil { return UserGroup{}, err @@ -108,20 +183,57 @@ func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string return response.UserGroup, nil } +// EnableUserGroupParams contains arguments for EnableUserGroup method calls. +type EnableUserGroupParams struct { + IncludeCount bool + TeamID string +} + +// EnableUserGroupOption options for the EnableUserGroup method calls. +type EnableUserGroupOption func(*EnableUserGroupParams) + +// EnableUserGroupOptionIncludeCount include the count of User Groups (default: false) +func EnableUserGroupOptionIncludeCount(b bool) EnableUserGroupOption { + return func(params *EnableUserGroupParams) { + params.IncludeCount = b + } +} + +// EnableUserGroupOptionTeamID include team Id +func EnableUserGroupOptionTeamID(teamID string) EnableUserGroupOption { + return func(params *EnableUserGroupParams) { + params.TeamID = teamID + } +} + // EnableUserGroup enables an existing user group. // For more information see the EnableUserGroupContext documentation. -func (api *Client) EnableUserGroup(userGroup string) (UserGroup, error) { - return api.EnableUserGroupContext(context.Background(), userGroup) +func (api *Client) EnableUserGroup(userGroup string, options ...EnableUserGroupOption) (UserGroup, error) { + return api.EnableUserGroupContext(context.Background(), userGroup, options...) } // EnableUserGroupContext enables an existing user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.enable -func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { +func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string, options ...EnableUserGroupOption) (UserGroup, error) { + params := EnableUserGroupParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "usergroup": {userGroup}, } + if params.IncludeCount { + values.Add("include_count", "true") + } + + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + response, err := api.userGroupRequest(ctx, "usergroups.enable", values) if err != nil { return UserGroup{}, err @@ -132,7 +244,12 @@ func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) // GetUserGroupsOption options for the GetUserGroups method call. type GetUserGroupsOption func(*GetUserGroupsParams) +// Deprecated: GetUserGroupsOptionWithTeamID is deprecated, use GetUserGroupsOptionTeamID instead func GetUserGroupsOptionWithTeamID(teamID string) GetUserGroupsOption { + return GetUserGroupsOptionTeamID(teamID) +} + +func GetUserGroupsOptionTeamID(teamID string) GetUserGroupsOption { return func(params *GetUserGroupsParams) { params.TeamID = teamID } @@ -236,12 +353,28 @@ func UpdateUserGroupsOptionChannels(channels []string) UpdateUserGroupsOption { } } +// UpdateUserGroupsOptionEnableSection enable the section for the user group (default: false) +func UpdateUserGroupsOptionEnableSection(enableSection bool) UpdateUserGroupsOption { + return func(params *UpdateUserGroupsParams) { + params.EnableSection = enableSection + } +} + +// UpdateUserGroupsOptionTeamID specify the team id for the User Group. (default: nil, so it's no-op) +func UpdateUserGroupsOptionTeamID(teamID string) UpdateUserGroupsOption { + return func(params *UpdateUserGroupsParams) { + params.TeamID = teamID + } +} + // UpdateUserGroupsParams contains arguments for UpdateUserGroup method call type UpdateUserGroupsParams struct { - Name string - Handle string - Description *string - Channels *[]string + Name string + Handle string + Description *string + Channels *[]string + EnableSection bool + TeamID string } // UpdateUserGroup will update an existing user group. @@ -280,6 +413,14 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin values["channels"] = []string{strings.Join(*params.Channels, ",")} } + if params.EnableSection { + values["enable_section"] = []string{strconv.FormatBool(params.EnableSection)} + } + + if params.TeamID != "" { + values["team_id"] = []string{params.TeamID} + } + response, err := api.userGroupRequest(ctx, "usergroups.update", values) if err != nil { return UserGroup{}, err @@ -287,20 +428,57 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin return response.UserGroup, nil } +// GetUserGroupMembersOption options for the GetUserGroupMembers method call. +type GetUserGroupMembersOption func(*GetUserGroupMembersParams) + +// GetUserGroupMembersParams contains arguments for GetUserGroupMembers method call +type GetUserGroupMembersParams struct { + IncludeDisabled bool + TeamID string +} + +// GetUserGroupMembersOptionIncludeDisabled include disabled User Groups (default: false) +func GetUserGroupMembersOptionIncludeDisabled(b bool) GetUserGroupMembersOption { + return func(params *GetUserGroupMembersParams) { + params.IncludeDisabled = b + } +} + +// GetUserGroupMembersOptionTeamID include team Id +func GetUserGroupMembersOptionTeamID(teamID string) GetUserGroupMembersOption { + return func(params *GetUserGroupMembersParams) { + params.TeamID = teamID + } +} + // GetUserGroupMembers will retrieve the current list of users in a group. // For more information see the GetUserGroupMembersContext documentation. -func (api *Client) GetUserGroupMembers(userGroup string) ([]string, error) { - return api.GetUserGroupMembersContext(context.Background(), userGroup) +func (api *Client) GetUserGroupMembers(userGroup string, options ...GetUserGroupMembersOption) ([]string, error) { + return api.GetUserGroupMembersContext(context.Background(), userGroup, options...) } // GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.users.list -func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup string) ([]string, error) { +func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup string, options ...GetUserGroupMembersOption) ([]string, error) { + params := GetUserGroupMembersParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "usergroup": {userGroup}, } + if params.IncludeDisabled { + values.Add("include_disabled", "true") + } + + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + response, err := api.userGroupRequest(ctx, "usergroups.users.list", values) if err != nil { return []string{}, err @@ -308,21 +486,82 @@ func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup str return response.Users, nil } +// UpdateUserGroupMembersOption options for the UpdateUserGroupMembers method call. +type UpdateUserGroupMembersOption func(*UpdateUserGroupMembersParams) + +// UpdateUserGroupMembersParams contains arguments for UpdateUserGroupMembers method call +type UpdateUserGroupMembersParams struct { + AdditionalChannels []string + IncludeCount bool + IsShared bool + TeamID string +} + +// UpdateUserGroupMembersOptionAdditionalChannels include additional channels +func UpdateUserGroupMembersOptionAdditionalChannels(channels []string) UpdateUserGroupMembersOption { + return func(params *UpdateUserGroupMembersParams) { + params.AdditionalChannels = channels + } +} + +// UpdateUserGroupMembersOptionIsShared include the count of User Groups (default: false) +func UpdateUserGroupMembersOptionIsShared(b bool) UpdateUserGroupMembersOption { + return func(params *UpdateUserGroupMembersParams) { + params.IsShared = b + } +} + +// UpdateUserGroupMembersOptionIncludeCount include the count of User Groups (default: false) +func UpdateUserGroupMembersOptionIncludeCount(b bool) UpdateUserGroupMembersOption { + return func(params *UpdateUserGroupMembersParams) { + params.IncludeCount = b + } +} + +// UpdateUserGroupMembersOptionTeamID include team Id +func UpdateUserGroupMembersOptionTeamID(teamID string) UpdateUserGroupMembersOption { + return func(params *UpdateUserGroupMembersParams) { + params.TeamID = teamID + } +} + // UpdateUserGroupMembers will update the members of an existing user group. // For more information see the UpdateUserGroupMembersContext documentation. -func (api *Client) UpdateUserGroupMembers(userGroup string, members string) (UserGroup, error) { - return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members) +func (api *Client) UpdateUserGroupMembers(userGroup string, members string, options ...UpdateUserGroupMembersOption) (UserGroup, error) { + return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members, options...) } // UpdateUserGroupMembersContext will update the members of an existing user group with a custom context. // Slack API docs: https://api.slack.com/methods/usergroups.update -func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup string, members string) (UserGroup, error) { +func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup string, members string, options ...UpdateUserGroupMembersOption) (UserGroup, error) { + params := UpdateUserGroupMembersParams{} + + for _, opt := range options { + opt(¶ms) + } + values := url.Values{ "token": {api.token}, "usergroup": {userGroup}, "users": {members}, } + if params.IncludeCount { + values.Add("include_count", "true") + } + + if params.IsShared { + values.Add("is_shared", "true") + } + + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + + if len(params.AdditionalChannels) > 0 { + values["additional_channels"] = []string{strings.Join(params.AdditionalChannels, ",")} + } + response, err := api.userGroupRequest(ctx, "usergroups.users.update", values) if err != nil { return UserGroup{}, err diff --git a/backend/vendor/github.com/slack-go/slack/users.go b/backend/vendor/github.com/slack-go/slack/users.go index b51b1721..91a9a4c7 100644 --- a/backend/vendor/github.com/slack-go/slack/users.go +++ b/backend/vendor/github.com/slack-go/slack/users.go @@ -130,6 +130,7 @@ type User struct { IsStranger bool `json:"is_stranger"` IsAppUser bool `json:"is_app_user"` IsInvitedUser bool `json:"is_invited_user"` + IsEmailConfirmed bool `json:"is_email_confirmed"` Has2FA bool `json:"has_2fa"` TwoFactorType *string `json:"two_factor_type"` HasFiles bool `json:"has_files"` diff --git a/backend/vendor/github.com/slack-go/slack/views.go b/backend/vendor/github.com/slack-go/slack/views.go index 0822d2cb..5f55b537 100644 --- a/backend/vendor/github.com/slack-go/slack/views.go +++ b/backend/vendor/github.com/slack-go/slack/views.go @@ -110,6 +110,12 @@ type ModalViewRequest struct { ExternalID string `json:"external_id,omitempty"` } +type PublishViewContextRequest struct { + UserID string `json:"user_id"` + View HomeTabViewRequest `json:"view"` + Hash *string `json:"hash,omitempty"` +} + func (v *ModalViewRequest) ViewType() ViewType { return v.Type } @@ -131,12 +137,6 @@ type openViewRequest struct { View ModalViewRequest `json:"view"` } -type publishViewRequest struct { - UserID string `json:"user_id"` - View HomeTabViewRequest `json:"view"` - Hash string `json:"hash,omitempty"` -} - type pushViewRequest struct { TriggerID string `json:"trigger_id"` View ModalViewRequest `json:"view"` @@ -212,25 +212,18 @@ func (api *Client) OpenViewContext( // PublishView publishes a static view for a user. // For more information see the PublishViewContext documentation. func (api *Client) PublishView(userID string, view HomeTabViewRequest, hash string) (*ViewResponse, error) { - return api.PublishViewContext(context.Background(), userID, view, hash) + return api.PublishViewContext(context.Background(), PublishViewContextRequest{UserID: userID, View: view, Hash: &hash}) } // PublishViewContext publishes a static view for a user with a custom context. // Slack API docs: https://api.slack.com/methods/views.publish func (api *Client) PublishViewContext( ctx context.Context, - userID string, - view HomeTabViewRequest, - hash string, + req PublishViewContextRequest, ) (*ViewResponse, error) { - if userID == "" { + if req.UserID == "" { return nil, ErrParametersMissing } - req := publishViewRequest{ - UserID: userID, - View: view, - Hash: hash, - } encoded, err := json.Marshal(req) if err != nil { return nil, err diff --git a/backend/vendor/github.com/slack-go/slack/workflow_step.go b/backend/vendor/github.com/slack-go/slack/workflow_step.go deleted file mode 100644 index 747a5dc0..00000000 --- a/backend/vendor/github.com/slack-go/slack/workflow_step.go +++ /dev/null @@ -1,101 +0,0 @@ -package slack - -import ( - "context" - "encoding/json" -) - -const VTWorkflowStep ViewType = "workflow_step" - -type ( - ConfigurationModalRequest struct { - ModalViewRequest - } - - WorkflowStepCompleteResponse struct { - WorkflowStepEditID string `json:"workflow_step_edit_id"` - Inputs *WorkflowStepInputs `json:"inputs,omitempty"` - Outputs *[]WorkflowStepOutput `json:"outputs,omitempty"` - } - - WorkflowStepInputElement struct { - Value string `json:"value"` - SkipVariableReplacement bool `json:"skip_variable_replacement"` - } - - WorkflowStepInputs map[string]WorkflowStepInputElement - - WorkflowStepOutput struct { - Name string `json:"name"` - Type string `json:"type"` - Label string `json:"label"` - } -) - -func NewConfigurationModalRequest(blocks Blocks, privateMetaData string, externalID string) *ConfigurationModalRequest { - return &ConfigurationModalRequest{ - ModalViewRequest{ - Type: VTWorkflowStep, - Title: nil, // slack configuration modal must not have a title! - Blocks: blocks, - PrivateMetadata: privateMetaData, - ExternalID: externalID, - }, - } -} - -// SaveWorkflowStepConfiguration opens a configuration modal for a workflow step. -// For more information see the SaveWorkflowStepConfigurationContext documentation. -func (api *Client) SaveWorkflowStepConfiguration(workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { - return api.SaveWorkflowStepConfigurationContext(context.Background(), workflowStepEditID, inputs, outputs) -} - -// SaveWorkflowStepConfigurationContext saves the configuration of a workflow step with a custom context. -// Slack API docs: https://api.slack.com/methods/workflows.updateStep -func (api *Client) SaveWorkflowStepConfigurationContext(ctx context.Context, workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { - wscr := WorkflowStepCompleteResponse{ - WorkflowStepEditID: workflowStepEditID, - Inputs: inputs, - Outputs: outputs, - } - - endpoint := api.endpoint + "workflows.updateStep" - jsonData, err := json.Marshal(wscr) - if err != nil { - return err - } - - response := &SlackResponse{} - if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { - return err - } - - if !response.Ok { - return response.Err() - } - - return nil -} - -func GetInitialOptionFromWorkflowStepInput(selection *SelectBlockElement, inputs *WorkflowStepInputs, options []*OptionBlockObject) (*OptionBlockObject, bool) { - if len(*inputs) == 0 { - return &OptionBlockObject{}, false - } - if len(options) == 0 { - return &OptionBlockObject{}, false - } - - if val, ok := (*inputs)[selection.ActionID]; ok { - if val.SkipVariableReplacement { - return &OptionBlockObject{}, false - } - - for _, option := range options { - if option.Value == val.Value { - return option, true - } - } - } - - return &OptionBlockObject{}, false -} diff --git a/backend/vendor/github.com/slack-go/slack/workflow_step_execute.go b/backend/vendor/github.com/slack-go/slack/workflow_step_execute.go deleted file mode 100644 index 32db9ba0..00000000 --- a/backend/vendor/github.com/slack-go/slack/workflow_step_execute.go +++ /dev/null @@ -1,85 +0,0 @@ -package slack - -import ( - "context" - "encoding/json" -) - -type ( - WorkflowStepCompletedRequest struct { - WorkflowStepExecuteID string `json:"workflow_step_execute_id"` - Outputs map[string]string `json:"outputs"` - } - - WorkflowStepFailedRequest struct { - WorkflowStepExecuteID string `json:"workflow_step_execute_id"` - Error struct { - Message string `json:"message"` - } `json:"error"` - } -) - -type WorkflowStepCompletedRequestOption func(opt *WorkflowStepCompletedRequest) error - -func WorkflowStepCompletedRequestOptionOutput(outputs map[string]string) WorkflowStepCompletedRequestOption { - return func(opt *WorkflowStepCompletedRequest) error { - if len(outputs) > 0 { - opt.Outputs = outputs - } - return nil - } -} - -// WorkflowStepCompleted indicates step is completed -func (api *Client) WorkflowStepCompleted(workflowStepExecuteID string, options ...WorkflowStepCompletedRequestOption) error { - // More information: https://api.slack.com/methods/workflows.stepCompleted - r := &WorkflowStepCompletedRequest{ - WorkflowStepExecuteID: workflowStepExecuteID, - } - for _, option := range options { - option(r) - } - - endpoint := api.endpoint + "workflows.stepCompleted" - jsonData, err := json.Marshal(r) - if err != nil { - return err - } - - response := &SlackResponse{} - if err := postJSON(context.Background(), api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { - return err - } - - if !response.Ok { - return response.Err() - } - - return nil -} - -// WorkflowStepFailed indicates step is failed -func (api *Client) WorkflowStepFailed(workflowStepExecuteID string, errorMessage string) error { - // More information: https://api.slack.com/methods/workflows.stepFailed - r := WorkflowStepFailedRequest{ - WorkflowStepExecuteID: workflowStepExecuteID, - } - r.Error.Message = errorMessage - - endpoint := api.endpoint + "workflows.stepFailed" - jsonData, err := json.Marshal(r) - if err != nil { - return err - } - - response := &SlackResponse{} - if err := postJSON(context.Background(), api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { - return err - } - - if !response.Ok { - return response.Err() - } - - return nil -} diff --git a/backend/vendor/github.com/slack-go/slack/workflows_triggers.go b/backend/vendor/github.com/slack-go/slack/workflows_triggers.go new file mode 100644 index 00000000..f9a7cd90 --- /dev/null +++ b/backend/vendor/github.com/slack-go/slack/workflows_triggers.go @@ -0,0 +1,177 @@ +package slack + +import ( + "context" + "encoding/json" + "fmt" +) + +type ( + WorkflowsTriggersPermissionsAddInput struct { + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsAddOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsListInput struct { + TriggerId string `json:"trigger_id"` + } + + WorkflowsTriggersPermissionsListOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsRemoveInput struct { + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsRemoveOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsSetInput struct { + PermissionType string `json:"permission_type"` + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsSetOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } +) + +// WorkflowsTriggersPermissionsAdd allows users to run a trigger that has its permission +// type set to named_entities. +// +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.add +func (api *Client) WorkflowsTriggersPermissionsAdd(ctx context.Context, input *WorkflowsTriggersPermissionsAddInput) (*WorkflowsTriggersPermissionsAddOutput, error) { + response := struct { + SlackResponse + *WorkflowsTriggersPermissionsAddOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsAddInput: %w", err) + } + + err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.add", api.token, jsonPayload, &response, api) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsTriggersPermissionsAddOutput, nil +} + +// WorkflowsTriggersPermissionsList returns the permission type of a trigger and if +// applicable, includes the entities that have been granted access. +// +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.list +func (api *Client) WorkflowsTriggersPermissionsList(ctx context.Context, input *WorkflowsTriggersPermissionsListInput) (*WorkflowsTriggersPermissionsListOutput, error) { + response := struct { + SlackResponse + *WorkflowsTriggersPermissionsListOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsListInput: %w", err) + } + + err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.list", api.token, jsonPayload, &response, api) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsTriggersPermissionsListOutput, nil +} + +// WorkflowsTriggersPermissionsRemove revoke an entity's access to a trigger that has its +// permission type set to named_entities. +// +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.remove +func (api *Client) WorkflowsTriggersPermissionsRemove(ctx context.Context, input *WorkflowsTriggersPermissionsRemoveInput) (*WorkflowsTriggersPermissionsRemoveOutput, error) { + response := struct { + SlackResponse + *WorkflowsTriggersPermissionsRemoveOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsRemoveInput: %w", err) + } + + err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.remove", api.token, jsonPayload, &response, api) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsTriggersPermissionsRemoveOutput, nil +} + +// WorkflowsTriggersPermissionsSet sets the permission type for who can run a trigger. +// +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.set +func (api *Client) WorkflowsTriggersPermissionsSet(ctx context.Context, input *WorkflowsTriggersPermissionsSetInput) (*WorkflowsTriggersPermissionsSetOutput, error) { + response := struct { + SlackResponse + *WorkflowsTriggersPermissionsSetOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsSetInput: %w", err) + } + + err = postJSON(ctx, api.httpclient, api.endpoint+"workflows.triggers.permissions.set", api.token, jsonPayload, &response, api) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsTriggersPermissionsSetOutput, nil +} diff --git a/backend/vendor/github.com/xdg-go/scram/.gitignore b/backend/vendor/github.com/xdg-go/scram/.gitignore index e69de29b..90b2037e 100644 --- a/backend/vendor/github.com/xdg-go/scram/.gitignore +++ b/backend/vendor/github.com/xdg-go/scram/.gitignore @@ -0,0 +1,34 @@ +# Local Claude code settings +.claude/settings.local.json + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work +go.work.sum + +# Dependency directories +vendor/ + +# Build output +bin/ +dist/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store diff --git a/backend/vendor/github.com/xdg-go/scram/CHANGELOG.md b/backend/vendor/github.com/xdg-go/scram/CHANGELOG.md index b833be5e..fc4a1e83 100644 --- a/backend/vendor/github.com/xdg-go/scram/CHANGELOG.md +++ b/backend/vendor/github.com/xdg-go/scram/CHANGELOG.md @@ -1,5 +1,36 @@ # CHANGELOG +## v1.2.0 - 2025-11-24 + +### Added + +- **Channel binding support for SCRAM-PLUS variants** (RFC 5929, RFC 9266) +- `GetStoredCredentialsWithError()` method that returns errors from PBKDF2 + key derivation instead of panicking. +- Support for Go 1.24+ stdlib `crypto/pbkdf2` package, which provides + FIPS 140-3 compliance when using SHA-256 or SHA-512 hash functions. + +### Changed + +- Minimum Go version bumped from 1.11 to 1.18. +- Migrated from `github.com/xdg-go/pbkdf2` to stdlib `crypto/pbkdf2` on + Go 1.24+. Legacy Go versions (<1.24) continue using the external + library via build tags for backward compatibility. +- Internal error handling improved for PBKDF2 key derivation failures. + +### Deprecated + +- `GetStoredCredentials()` is deprecated in favor of + `GetStoredCredentialsWithError()`. The old method panics on PBKDF2 + errors to maintain backward compatibility but will be removed in a + future major version. + +### Notes + +- FIPS 140-3 compliance is available on Go 1.24+ when using SCRAM-SHA-256 + or SCRAM-SHA-512 with appropriate salt lengths (≥16 bytes). SCRAM-SHA-1 + is not FIPS-approved. + ## v1.1.2 - 2022-12-07 - Bump stringprep dependency to v1.0.4 for upstream CVE fix. diff --git a/backend/vendor/github.com/xdg-go/scram/CLAUDE.md b/backend/vendor/github.com/xdg-go/scram/CLAUDE.md new file mode 100644 index 00000000..a19b5130 --- /dev/null +++ b/backend/vendor/github.com/xdg-go/scram/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Go library implementing SCRAM (Salted Challenge Response Authentication Mechanism) per RFC-5802 and RFC-7677. It provides both client and server implementations supporting SHA-1, SHA-256, and SHA-512 hash functions. + +## Development Commands + +**Run tests:** +```bash +go test ./... +``` + +**Run tests with race detection (CI configuration):** +```bash +go test -race ./... +``` + +**Build (module-only, no binaries):** +```bash +go build ./... +``` + +**Run single test:** +```bash +go test -run TestName ./... +``` + +## Architecture + +### Core Components + +**Hash factory pattern:** The `HashGeneratorFcn` type (scram.go:23) is the entry point for creating clients and servers. Package-level variables `SHA1`, `SHA256`, `SHA512` provide pre-configured hash functions. All client/server creation flows through these hash generators. + +**Client (`client.go`):** Holds authentication configuration for a username/password/authzID tuple. Maintains a cache of derived keys (PBKDF2 results) indexed by `KeyFactors` (salt + iteration count). Thread-safe via RWMutex. Creates `ClientConversation` instances for individual auth attempts. + +**Server (`server.go`):** Holds credential lookup callback and nonce generator. Creates `ServerConversation` instances for individual auth attempts. + +**Conversations:** State machines implementing the SCRAM protocol exchange: +- `ClientConversation` (client_conv.go): States are `clientStarting` → `clientFirst` → `clientFinal` → `clientDone` +- `ServerConversation` (server_conv.go): States are `serverFirst` → `serverFinal` → `serverDone` + +Both use a `Step(string) (string, error)` method to advance through protocol stages. + +**Message parsing (`parse.go`):** Parses SCRAM protocol messages into structs. Separate parsers for client-first, server-first, client-final, and server-final messages. + +**Shared utilities (`common.go`):** +- `NonceGeneratorFcn`: Default uses base64-encoded 24 bytes from crypto/rand +- `derivedKeys`: Struct caching ClientKey, StoredKey, ServerKey +- `KeyFactors`: Salt + iteration count, used as cache key +- `StoredCredentials`: What servers must store for each user +- `CredentialLookup`: Callback type servers use to retrieve stored credentials + +### Key Design Patterns + +**Dependency injection:** Server requires a `CredentialLookup` callback, making storage mechanism pluggable. + +**Caching:** Client caches expensive PBKDF2 results in a map keyed by `KeyFactors`. This optimizes reconnection scenarios where salt/iteration count remain constant. + +**Factory methods:** Hash generators provide `.NewClient()` and `.NewServer()` methods that handle SASLprep normalization. Alternative `.NewClientUnprepped()` exists for custom normalization. + +**Configuration via chaining:** Both Client and Server support `.WithNonceGenerator()` for custom nonce generation (primarily for testing). + +### Security Considerations + +- Default minimum PBKDF2 iterations: 4096 (client.go:45) +- All string comparisons use `hmac.Equal()` for constant-time comparison +- SASLprep normalization applied by default via xdg-go/stringprep dependency +- Nonce generation uses crypto/rand + +## Testing + +Tests include conversation state machine tests (client_conv_test.go, server_conv_test.go), integration tests, and examples (doc_test.go). Test data in testdata_test.go. diff --git a/backend/vendor/github.com/xdg-go/scram/README.md b/backend/vendor/github.com/xdg-go/scram/README.md index 3a46f5ce..8f94e33d 100644 --- a/backend/vendor/github.com/xdg-go/scram/README.md +++ b/backend/vendor/github.com/xdg-go/scram/README.md @@ -8,12 +8,19 @@ Package scram provides client and server implementations of the Salted Challenge Response Authentication Mechanism (SCRAM) described in -[RFC-5802](https://tools.ietf.org/html/rfc5802) and -[RFC-7677](https://tools.ietf.org/html/rfc7677). +- [RFC-5802](https://tools.ietf.org/html/rfc5802) +- [RFC-5929](https://tools.ietf.org/html/rfc5929) +- [RFC-7677](https://tools.ietf.org/html/rfc7677) +- [RFC-9266](https://tools.ietf.org/html/rfc9266) It includes both client and server side support. -Channel binding and extensions are not (yet) supported. +Channel binding is supported for SCRAM-PLUS variants, including: +- `tls-unique` (RFC 5929) - insecure, but required +- `tls-server-end-point` (RFC 5929) - works with all TLS versions +- `tls-exporter` (RFC 9266) - recommended for TLS 1.3+ + +SCRAM message extensions are not supported. ## Examples @@ -64,6 +71,41 @@ Channel binding and extensions are not (yet) supported. return "" } +### Client side with channel binding (SCRAM-PLUS) + + package main + + import ( + "crypto/tls" + "github.com/xdg-go/scram" + ) + + func main() { + // Establish TLS connection + tlsConn, err := tls.Dial("tcp", "server:port", &tls.Config{MinVersion: tls.VersionTLS13}) + if err != nil { + panic(err) + } + defer tlsConn.Close() + + // Get Client with username, password + client, err := scram.SHA256.NewClient("mulder", "trustno1", "") + if err != nil { + panic(err) + } + + // Create channel binding from TLS connection (TLS 1.3 example) + // Use NewTLSExporterBinding for TLS 1.3+, NewTLSServerEndpointBinding for all TLS versions + channelBinding, err := scram.NewTLSExporterBinding(&tlsConn.ConnectionState()) + if err != nil { + panic(err) + } + + // Create conversation with channel binding for SCRAM-SHA-256-PLUS + conv := client.NewConversationWithChannelBinding(channelBinding) + // ... rest of authentication conversation + } + ## Copyright and License Copyright 2018 by David A. Golden. All rights reserved. diff --git a/backend/vendor/github.com/xdg-go/scram/channel_binding.go b/backend/vendor/github.com/xdg-go/scram/channel_binding.go new file mode 100644 index 00000000..52b1b0c3 --- /dev/null +++ b/backend/vendor/github.com/xdg-go/scram/channel_binding.go @@ -0,0 +1,144 @@ +// Copyright 2018 by David A. Golden. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +package scram + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "hash" +) + +// ChannelBindingType represents the type of channel binding to use with +// SCRAM-PLUS authentication variants. The type must match one of the +// channel binding types defined in RFC 5056, RFC 5929, or RFC 9266. +type ChannelBindingType string + +const ( + // ChannelBindingNone indicates no channel binding is used. + ChannelBindingNone ChannelBindingType = "" + + // ChannelBindingTLSUnique uses the TLS Finished message from the first + // TLS handshake (RFC 5929). This is considered insecure, but is included + // as required by RFC 5802. + ChannelBindingTLSUnique ChannelBindingType = "tls-unique" + + // ChannelBindingTLSServerEndpoint uses a hash of the server's certificate + // (RFC 5929). This works with all TLS versions including TLS 1.3. + ChannelBindingTLSServerEndpoint ChannelBindingType = "tls-server-end-point" + + // ChannelBindingTLSExporter uses TLS Exported Keying Material with the + // label "EXPORTER-Channel-Binding" (RFC 9266). This is the recommended + // channel binding type for TLS 1.3. + ChannelBindingTLSExporter ChannelBindingType = "tls-exporter" +) + +// ChannelBinding holds the channel binding type and data for SCRAM-PLUS +// authentication. Use constructors to create type-specific bindings. +type ChannelBinding struct { + Type ChannelBindingType + Data []byte +} + +// IsSupported returns true if the channel binding is configured with a +// non-empty type and data. +func (cb ChannelBinding) IsSupported() bool { + return cb.Type != ChannelBindingNone && len(cb.Data) > 0 +} + +// Matches returns true if this channel binding matches another channel +// binding in both type and data. +func (cb ChannelBinding) Matches(other ChannelBinding) bool { + if cb.Type != other.Type { + return false + } + return hmac.Equal(cb.Data, other.Data) +} + +// NewTLSUniqueBinding creates a ChannelBinding for tls-unique channel binding. +// Since Go's standard library doesn't expose the TLS Finished message, +// applications must provide the data directly. +// +// Note: tls-unique is considered insecure and should generally be avoided. +func NewTLSUniqueBinding(data []byte) ChannelBinding { + // Create a defensive copy to prevent caller from modifying the data + cbData := make([]byte, len(data)) + copy(cbData, data) + return ChannelBinding{ + Type: ChannelBindingTLSUnique, + Data: cbData, + } +} + +// NewTLSServerEndpointBinding creates a ChannelBinding for tls-server-end-point +// channel binding per RFC 5929. It extracts the server's certificate from +// the TLS connection state and hashes it using the appropriate hash function +// based on the certificate's signature algorithm. +// +// This works with all TLS versions including TLS 1.3. +func NewTLSServerEndpointBinding(connState *tls.ConnectionState) (ChannelBinding, error) { + if connState == nil { + return ChannelBinding{}, errors.New("connection state is nil") + } + + if len(connState.PeerCertificates) == 0 { + return ChannelBinding{}, errors.New("no peer certificates") + } + + cert := connState.PeerCertificates[0] + + // Determine hash algorithm per RFC 5929 + var h hash.Hash + switch cert.SignatureAlgorithm { + case x509.MD5WithRSA, x509.SHA1WithRSA, x509.DSAWithSHA1, + x509.ECDSAWithSHA1: + h = sha256.New() // Use SHA-256 for MD5/SHA-1 + case x509.SHA256WithRSA, x509.SHA256WithRSAPSS, + x509.ECDSAWithSHA256: + h = sha256.New() + case x509.SHA384WithRSA, x509.SHA384WithRSAPSS, + x509.ECDSAWithSHA384: + h = sha512.New384() + case x509.SHA512WithRSA, x509.SHA512WithRSAPSS, + x509.ECDSAWithSHA512: + h = sha512.New() + default: + return ChannelBinding{}, fmt.Errorf("unsupported signature algorithm: %v", + cert.SignatureAlgorithm) + } + + h.Write(cert.Raw) + return ChannelBinding{ + Type: ChannelBindingTLSServerEndpoint, + Data: h.Sum(nil), + }, nil +} + +// NewTLSExporterBinding creates a ChannelBinding for tls-exporter channel binding +// per RFC 9266. It uses TLS Exported Keying Material with the label +// "EXPORTER-Channel-Binding" and an empty context. +// +// This is the recommended channel binding type for TLS 1.3+. +func NewTLSExporterBinding(connState *tls.ConnectionState) (ChannelBinding, error) { + if connState == nil { + return ChannelBinding{}, errors.New("connection state is nil") + } + + cbData, err := connState.ExportKeyingMaterial("EXPORTER-Channel-Binding", nil, 32) + if err != nil { + return ChannelBinding{}, fmt.Errorf("failed to export keying material: %w", err) + } + + return ChannelBinding{ + Type: ChannelBindingTLSExporter, + Data: cbData, + }, nil +} diff --git a/backend/vendor/github.com/xdg-go/scram/client.go b/backend/vendor/github.com/xdg-go/scram/client.go index 5b53021b..906fe6e5 100644 --- a/backend/vendor/github.com/xdg-go/scram/client.go +++ b/backend/vendor/github.com/xdg-go/scram/client.go @@ -8,8 +8,6 @@ package scram import ( "sync" - - "github.com/xdg-go/pbkdf2" ) // Client implements the client side of SCRAM authentication. It holds @@ -81,38 +79,109 @@ func (c *Client) NewConversation() *ClientConversation { } } -func (c *Client) getDerivedKeys(kf KeyFactors) derivedKeys { +// NewConversationAdvertisingChannelBinding constructs a client-side +// authentication conversation that advertises channel binding support without +// using it. This generates the "y" GS2 flag, indicating the client supports +// channel binding but the server did not advertise a PLUS variant mechanism. +// +// This helps detect downgrade attacks where a MITM strips PLUS mechanism +// advertisements from the server's mechanism list. If the server actually +// advertised PLUS variants, it will reject the "y" flag as a downgrade attack. +// +// Use this when: +// - Your application supports channel binding (has access to TLS connection state) +// - SASL mechanism negotiation showed the server does NOT advertise PLUS variants +// (e.g., server advertised "SCRAM-SHA-256" but not "SCRAM-SHA-256-PLUS") +// +// Conversations cannot be reused, so this must be called for each new +// authentication attempt. +func (c *Client) NewConversationAdvertisingChannelBinding() *ClientConversation { + c.RLock() + defer c.RUnlock() + return &ClientConversation{ + client: c, + nonceGen: c.nonceGen, + hashGen: c.hashGen, + minIters: c.minIters, + advertiseChannelBinding: true, + } +} + +// NewConversationWithChannelBinding constructs a client-side authentication +// conversation with channel binding for SCRAM-PLUS authentication. Channel +// binding is connection-specific, so a new conversation should be created for +// each connection being authenticated. Conversations cannot be reused, so this +// must be called for each new authentication attempt. +func (c *Client) NewConversationWithChannelBinding(cb ChannelBinding) *ClientConversation { + c.RLock() + defer c.RUnlock() + return &ClientConversation{ + client: c, + nonceGen: c.nonceGen, + hashGen: c.hashGen, + minIters: c.minIters, + channelBinding: cb, + } +} + +func (c *Client) getDerivedKeys(kf KeyFactors) (derivedKeys, error) { dk, ok := c.getCache(kf) if !ok { - dk = c.computeKeys(kf) + var err error + dk, err = c.computeKeys(kf) + if err != nil { + return derivedKeys{}, err + } c.setCache(kf, dk) } - return dk + return dk, nil } // GetStoredCredentials takes a salt and iteration count structure and -// provides the values that must be stored by a server to authentication a +// provides the values that must be stored by a server to authenticate a // user. These values are what the Server credential lookup function must // return for a given username. +// +// Deprecated: Use GetStoredCredentialsWithError for proper error handling. +// This method panics if PBKDF2 key derivation fails, which should only +// occur with invalid KeyFactors parameters. func (c *Client) GetStoredCredentials(kf KeyFactors) StoredCredentials { - dk := c.getDerivedKeys(kf) + creds, err := c.GetStoredCredentialsWithError(kf) + if err != nil { + panic("scram: GetStoredCredentials failed: " + err.Error()) + } + return creds +} + +// GetStoredCredentialsWithError takes a salt and iteration count structure and +// provides the values that must be stored by a server to authenticate a +// user. These values are what the Server credential lookup function must +// return for a given username. +// +// Returns an error if PBKDF2 key derivation fails, which can occur with +// invalid parameters in Go 1.24+ (e.g., invalid iteration counts or key lengths). +func (c *Client) GetStoredCredentialsWithError(kf KeyFactors) (StoredCredentials, error) { + dk, err := c.getDerivedKeys(kf) return StoredCredentials{ KeyFactors: kf, StoredKey: dk.StoredKey, ServerKey: dk.ServerKey, - } + }, err } -func (c *Client) computeKeys(kf KeyFactors) derivedKeys { +func (c *Client) computeKeys(kf KeyFactors) (derivedKeys, error) { h := c.hashGen() - saltedPassword := pbkdf2.Key([]byte(c.password), []byte(kf.Salt), kf.Iters, h.Size(), c.hashGen) + saltedPassword, err := pbkdf2Key(c.hashGen, c.password, []byte(kf.Salt), kf.Iters, h.Size()) + if err != nil { + return derivedKeys{}, err + } clientKey := computeHMAC(c.hashGen, saltedPassword, []byte("Client Key")) return derivedKeys{ ClientKey: clientKey, StoredKey: computeHash(c.hashGen, clientKey), ServerKey: computeHMAC(c.hashGen, saltedPassword, []byte("Server Key")), - } + }, nil } func (c *Client) getCache(kf KeyFactors) (derivedKeys, bool) { @@ -126,5 +195,4 @@ func (c *Client) setCache(kf KeyFactors, dk derivedKeys) { c.Lock() defer c.Unlock() c.cache[kf] = dk - return } diff --git a/backend/vendor/github.com/xdg-go/scram/client_conv.go b/backend/vendor/github.com/xdg-go/scram/client_conv.go index 83405688..4da6c241 100644 --- a/backend/vendor/github.com/xdg-go/scram/client_conv.go +++ b/backend/vendor/github.com/xdg-go/scram/client_conv.go @@ -27,16 +27,18 @@ const ( // conversation with a server. A new conversation must be created for // each authentication attempt. type ClientConversation struct { - client *Client - nonceGen NonceGeneratorFcn - hashGen HashGeneratorFcn - minIters int - state clientState - valid bool - gs2 string - nonce string - c1b string - serveSig []byte + client *Client + nonceGen NonceGeneratorFcn + hashGen HashGeneratorFcn + minIters int + state clientState + valid bool + gs2 string + nonce string + c1b string + serveSig []byte + channelBinding ChannelBinding + advertiseChannelBinding bool // if true, use "y" flag instead of "n" or "p" } // Step takes a string provided from a server (or just an empty string for the @@ -99,10 +101,19 @@ func (cc *ClientConversation) finalMsg(s1 string) (string, error) { return "", fmt.Errorf("server requested too few iterations (%d)", msg.iters) } + // Create channel binding data per RFC 5802: + // - For "p" flag: gs2-header + channel-binding-data + // - For "n" or "y" flags: gs2-header only (no channel-binding-data) + cbindData := []byte(cc.gs2) + if cc.channelBinding.IsSupported() { + // Only append channel binding data when actually using it (flag "p") + cbindData = append(cbindData, cc.channelBinding.Data...) + } + // Create client-final-message-without-proof c2wop := fmt.Sprintf( "c=%s,r=%s", - base64.StdEncoding.EncodeToString([]byte(cc.gs2)), + base64.StdEncoding.EncodeToString(cbindData), cc.nonce, ) @@ -110,11 +121,17 @@ func (cc *ClientConversation) finalMsg(s1 string) (string, error) { authMsg := cc.c1b + "," + s1 + "," + c2wop // Get derived keys from client cache - dk := cc.client.getDerivedKeys(KeyFactors{Salt: string(msg.salt), Iters: msg.iters}) + dk, err := cc.client.getDerivedKeys(KeyFactors{Salt: string(msg.salt), Iters: msg.iters}) + if err != nil { + return "", err + } // Create proof as clientkey XOR clientsignature clientSignature := computeHMAC(cc.hashGen, dk.StoredKey, []byte(authMsg)) - clientProof := xorBytes(dk.ClientKey, clientSignature) + clientProof, err := xorBytes(dk.ClientKey, clientSignature) + if err != nil { + return "", err + } proof := base64.StdEncoding.EncodeToString(clientProof) // Cache ServerSignature for later validation @@ -142,8 +159,23 @@ func (cc *ClientConversation) validateServer(s2 string) (string, error) { } func (cc *ClientConversation) gs2Header() string { - if cc.client.authzID == "" { - return "n,," + var cbFlag string + + if cc.channelBinding.IsSupported() { + // Client is using channel binding with specific type + cbFlag = fmt.Sprintf("p=%s", cc.channelBinding.Type) + } else if cc.advertiseChannelBinding { + // Client supports channel binding but server didn't advertise PLUS + cbFlag = "y" + } else { + // Client doesn't support channel binding + cbFlag = "n" } - return fmt.Sprintf("n,%s,", encodeName(cc.client.authzID)) + + authzPart := "" + if cc.client.authzID != "" { + authzPart = "a=" + encodeName(cc.client.authzID) + } + + return fmt.Sprintf("%s,%s,", cbFlag, authzPart) } diff --git a/backend/vendor/github.com/xdg-go/scram/common.go b/backend/vendor/github.com/xdg-go/scram/common.go index cb705cb7..fa0a81b9 100644 --- a/backend/vendor/github.com/xdg-go/scram/common.go +++ b/backend/vendor/github.com/xdg-go/scram/common.go @@ -10,6 +10,7 @@ import ( "crypto/hmac" "crypto/rand" "encoding/base64" + "errors" "strings" ) @@ -58,10 +59,50 @@ type StoredCredentials struct { // StoredCredentials. type CredentialLookup func(string) (StoredCredentials, error) +// Server error values as defined in RFC-5802 and RFC-7677. These are returned +// by the server in error responses as "e=". +const ( + // ErrInvalidEncoding indicates the client message had invalid encoding + ErrInvalidEncoding = "e=invalid-encoding" + + // ErrExtensionsNotSupported indicates unrecognized 'm' value + ErrExtensionsNotSupported = "e=extensions-not-supported" + + // ErrInvalidProof indicates the authentication proof from the client was invalid + ErrInvalidProof = "e=invalid-proof" + + // ErrChannelBindingsDontMatch indicates channel binding data didn't match expected value + ErrChannelBindingsDontMatch = "e=channel-bindings-dont-match" + + // ErrServerDoesSupportChannelBinding indicates server does support channel + // binding. This is returned if a downgrade attack is detected or if the + // client does not support binding and channel binding is required. + ErrServerDoesSupportChannelBinding = "e=server-does-support-channel-binding" + + // ErrChannelBindingNotSupported indicates channel binding is not supported + ErrChannelBindingNotSupported = "e=channel-binding-not-supported" + + // ErrUnsupportedChannelBindingType indicates the requested channel binding type is not supported + ErrUnsupportedChannelBindingType = "e=unsupported-channel-binding-type" + + // ErrUnknownUser indicates the specified user does not exist + ErrUnknownUser = "e=unknown-user" + + // ErrInvalidUsernameEncoding indicates invalid username encoding (invalid UTF-8 or SASLprep failed) + ErrInvalidUsernameEncoding = "e=invalid-username-encoding" + + // ErrNoResources indicates the server is out of resources + ErrNoResources = "e=no-resources" + + // ErrOtherError is a catch-all for unspecified errors. The server may substitute + // the real reason with this error to prevent information disclosure. + ErrOtherError = "e=other-error" +) + func defaultNonceGenerator() string { raw := make([]byte, 24) nonce := make([]byte, base64.StdEncoding.EncodedLen(len(raw))) - rand.Read(raw) + _, _ = rand.Read(raw) base64.StdEncoding.Encode(nonce, raw) return string(nonce) } @@ -70,11 +111,6 @@ func encodeName(s string) string { return strings.Replace(strings.Replace(s, "=", "=3D", -1), ",", "=2C", -1) } -func decodeName(s string) (string, error) { - // TODO Check for = not followed by 2C or 3D - return strings.Replace(strings.Replace(s, "=2C", ",", -1), "=3D", "=", -1), nil -} - func computeHash(hg HashGeneratorFcn, b []byte) []byte { h := hg() h.Write(b) @@ -87,11 +123,13 @@ func computeHMAC(hg HashGeneratorFcn, key, data []byte) []byte { return mac.Sum(nil) } -func xorBytes(a, b []byte) []byte { - // TODO check a & b are same length, or just xor to smallest +func xorBytes(a, b []byte) ([]byte, error) { + if len(a) != len(b) { + return nil, errors.New("internal error: xorBytes arguments must have equal length") + } xor := make([]byte, len(a)) for i := range a { xor[i] = a[i] ^ b[i] } - return xor + return xor, nil } diff --git a/backend/vendor/github.com/xdg-go/scram/doc.go b/backend/vendor/github.com/xdg-go/scram/doc.go index 82e8aeed..3314cc1a 100644 --- a/backend/vendor/github.com/xdg-go/scram/doc.go +++ b/backend/vendor/github.com/xdg-go/scram/doc.go @@ -5,22 +5,55 @@ // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Package scram provides client and server implementations of the Salted -// Challenge Response Authentication Mechanism (SCRAM) described in RFC-5802 -// and RFC-7677. +// Challenge Response Authentication Mechanism (SCRAM) described in RFC-5802, +// RFC-7677, and RFC-9266. // -// Usage +// # Usage // // The scram package provides variables, `SHA1`, `SHA256`, and `SHA512`, that // are used to construct Client or Server objects. // -// clientSHA1, err := scram.SHA1.NewClient(username, password, authID) -// clientSHA256, err := scram.SHA256.NewClient(username, password, authID) -// clientSHA512, err := scram.SHA512.NewClient(username, password, authID) +// clientSHA1, err := scram.SHA1.NewClient(username, password, authID) +// clientSHA256, err := scram.SHA256.NewClient(username, password, authID) +// clientSHA512, err := scram.SHA512.NewClient(username, password, authID) // -// serverSHA1, err := scram.SHA1.NewServer(credentialLookupFcn) -// serverSHA256, err := scram.SHA256.NewServer(credentialLookupFcn) -// serverSHA512, err := scram.SHA512.NewServer(credentialLookupFcn) +// serverSHA1, err := scram.SHA1.NewServer(credentialLookupFcn) +// serverSHA256, err := scram.SHA256.NewServer(credentialLookupFcn) +// serverSHA512, err := scram.SHA512.NewServer(credentialLookupFcn) // // These objects are used to construct ClientConversation or // ServerConversation objects that are used to carry out authentication. +// +// clientConv := client.NewConversation() +// serverConv := server.NewConversation() +// +// # Channel Binding (SCRAM-PLUS) +// +// The scram package supports channel binding for SCRAM-PLUS authentication +// variants as described in RFC-5802, RFC-5929, and RFC-9266. Channel binding +// cryptographically binds the SCRAM authentication to an underlying TLS +// connection, preventing man-in-the-middle attacks. +// +// To use channel binding, create conversations with channel binding data +// obtained from the TLS connection: +// +// // Client example with tls-exporter (TLS 1.3+) +// client, _ := scram.SHA256.NewClient(username, password, "") +// channelBinding, _ := scram.NewTLSExporterBinding(&tlsConn.ConnectionState()) +// clientConv := client.NewConversationWithChannelBinding(channelBinding) +// +// // Server conversation with the same channel binding +// server, _ := scram.SHA256.NewServer(credentialLookupFcn) +// serverConv := server.NewConversationWithChannelBinding(channelBinding) +// +// Helper functions are provided to create ChannelBinding values from TLS connections: +// - NewTLSServerEndpointBinding: Uses server certificate hash (RFC 5929, all TLS versions) +// - NewTLSExporterBinding: Uses exported keying material (RFC 9266, recommended for TLS 1.3+) +// +// Channel binding is configured on conversations rather than clients or servers +// because binding data is connection-specific. +// +// Channel binding type negotiation is not defined by the SCRAM protocol. +// Applications must ensure both client and server agree on the same channel binding +// type. package scram diff --git a/backend/vendor/github.com/xdg-go/scram/parse.go b/backend/vendor/github.com/xdg-go/scram/parse.go index 722f6043..3585c628 100644 --- a/backend/vendor/github.com/xdg-go/scram/parse.go +++ b/backend/vendor/github.com/xdg-go/scram/parse.go @@ -15,11 +15,13 @@ import ( ) type c1Msg struct { - gs2Header string - authzID string - username string - nonce string - c1b string + gs2Header string + gs2BindFlag string // "n", "y", or "p" + channelBinding string // channel binding type name if gs2BindFlag is "p" + authzID string + username string + nonce string + c1b string } type c2Msg struct { @@ -48,16 +50,25 @@ func parseField(s, k string) (string, error) { return t, nil } -func parseGS2Flag(s string) (string, error) { - if s[0] == 'p' { - return "", fmt.Errorf("channel binding requested but not supported") - } - +// parseGS2Flag returns flag, channel binding type, and error. +func parseGS2Flag(s string) (string, string, error) { if s == "n" || s == "y" { - return s, nil + return s, "", nil } - return "", fmt.Errorf("error parsing '%s' for gs2 flag", s) + // If not "n" or "y", must be "p=..." or error. + cbType, err := parseField(s, "p") + if err != nil { + return "", "", fmt.Errorf("error parsing '%s' for gs2 flag", s) + } + + switch ChannelBindingType(cbType) { + case ChannelBindingTLSUnique, ChannelBindingTLSServerEndpoint, ChannelBindingTLSExporter: + // valid channel binding type + default: + return "", "", fmt.Errorf("invalid channel binding type: %s", cbType) + } + return "p", cbType, nil } func parseFieldBase64(s, k string) ([]byte, error) { @@ -68,7 +79,7 @@ func parseFieldBase64(s, k string) ([]byte, error) { dec, err := base64.StdEncoding.DecodeString(raw) if err != nil { - return nil, err + return nil, fmt.Errorf("failed decoding field '%s': %v", k, err) } return dec, nil @@ -89,19 +100,18 @@ func parseFieldInt(s, k string) (int, error) { } func parseClientFirst(c1 string) (msg c1Msg, err error) { - fields := strings.Split(c1, ",") if len(fields) < 4 { err = errors.New("not enough fields in first server message") return } - gs2flag, err := parseGS2Flag(fields[0]) + msg.gs2BindFlag, msg.channelBinding, err = parseGS2Flag(fields[0]) if err != nil { return } - // 'a' field is optional + // authzID content is optional, but the field must be present. if len(fields[1]) > 0 { msg.authzID, err = parseField(fields[1], "a") if err != nil { @@ -109,9 +119,6 @@ func parseClientFirst(c1 string) (msg c1Msg, err error) { } } - // Recombine and save the gs2 header - msg.gs2Header = gs2flag + "," + msg.authzID + "," - // Check for unsupported extensions field "m". if strings.HasPrefix(fields[2], "m=") { err = errors.New("SCRAM message extensions are not supported") @@ -128,6 +135,10 @@ func parseClientFirst(c1 string) (msg c1Msg, err error) { return } + // Recombine the gs2Header: gs2-cbind-flag "," [ authzid ] "," + msg.gs2Header = fields[0] + "," + fields[1] + "," + + // Recombine the client-first-message-bare: username "," nonce msg.c1b = strings.Join(fields[2:], ",") return diff --git a/backend/vendor/github.com/xdg-go/scram/pbkdf2_go124.go b/backend/vendor/github.com/xdg-go/scram/pbkdf2_go124.go new file mode 100644 index 00000000..6708ec4d --- /dev/null +++ b/backend/vendor/github.com/xdg-go/scram/pbkdf2_go124.go @@ -0,0 +1,18 @@ +// Copyright 2025 by David A. Golden. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +//go:build go1.24 + +package scram + +import ( + "crypto/pbkdf2" + "hash" +) + +func pbkdf2Key(h func() hash.Hash, password string, salt []byte, iter, keyLength int) ([]byte, error) { + return pbkdf2.Key(h, password, salt, iter, keyLength) +} diff --git a/backend/vendor/github.com/xdg-go/scram/pbkdf2_legacy.go b/backend/vendor/github.com/xdg-go/scram/pbkdf2_legacy.go new file mode 100644 index 00000000..a81ab417 --- /dev/null +++ b/backend/vendor/github.com/xdg-go/scram/pbkdf2_legacy.go @@ -0,0 +1,19 @@ +// Copyright 2025 by David A. Golden. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +//go:build !go1.24 + +package scram + +import ( + "hash" + + "github.com/xdg-go/pbkdf2" +) + +func pbkdf2Key(h func() hash.Hash, password string, salt []byte, iter, keyLength int) ([]byte, error) { + return pbkdf2.Key([]byte(password), salt, iter, keyLength, h), nil +} diff --git a/backend/vendor/github.com/xdg-go/scram/server.go b/backend/vendor/github.com/xdg-go/scram/server.go index b119b361..e116623e 100644 --- a/backend/vendor/github.com/xdg-go/scram/server.go +++ b/backend/vendor/github.com/xdg-go/scram/server.go @@ -48,3 +48,62 @@ func (s *Server) NewConversation() *ServerConversation { credentialCB: s.credentialCB, } } + +// NewConversationWithChannelBinding constructs a server-side authentication +// conversation with channel binding for SCRAM-PLUS authentication. +// +// This signals that the server advertised PLUS mechanism variants (e.g., +// SCRAM-SHA-256-PLUS) during SASL negotiation, but channel binding is NOT required. +// Clients may authenticate using either the base mechanism (e.g., SCRAM-SHA-256) +// or the PLUS variant (e.g., SCRAM-SHA-256-PLUS). +// +// The server will: +// - Accept clients without channel binding support (using "n" flag) +// - Accept clients with matching channel binding (using "p" flag) +// - Reject downgrade attacks (clients using "y" flag when PLUS was advertised) +// +// Channel binding is connection-specific, so a new conversation should be +// created for each connection being authenticated. +// Conversations cannot be reused, so this must be called for each new +// authentication attempt. +func (s *Server) NewConversationWithChannelBinding(cb ChannelBinding) *ServerConversation { + s.RLock() + defer s.RUnlock() + return &ServerConversation{ + nonceGen: s.nonceGen, + hashGen: s.hashGen, + credentialCB: s.credentialCB, + channelBinding: cb, + } +} + +// NewConversationWithChannelBindingRequired constructs a server-side authentication +// conversation with mandatory channel binding for SCRAM-PLUS authentication. +// +// This signals that the server advertised ONLY SCRAM-PLUS mechanism variants +// (e.g., only SCRAM-SHA-256-PLUS, not the base SCRAM-SHA-256) during SASL negotiation. +// Channel binding is required for all authentication attempts. +// +// The server will: +// - Accept only clients with matching channel binding (using "p" flag) +// - Reject clients without channel binding support (using "n" flag) +// - Reject downgrade attacks (clients using "y" flag when PLUS was advertised) +// +// This is intended for high-security deployments that advertise only SCRAM-PLUS +// variants and want to enforce channel binding as mandatory. +// +// Channel binding is connection-specific, so a new conversation should be +// created for each connection being authenticated. +// Conversations cannot be reused, so this must be called for each new +// authentication attempt. +func (s *Server) NewConversationWithChannelBindingRequired(cb ChannelBinding) *ServerConversation { + s.RLock() + defer s.RUnlock() + return &ServerConversation{ + nonceGen: s.nonceGen, + hashGen: s.hashGen, + credentialCB: s.credentialCB, + channelBinding: cb, + requireChannelBinding: true, + } +} diff --git a/backend/vendor/github.com/xdg-go/scram/server_conv.go b/backend/vendor/github.com/xdg-go/scram/server_conv.go index 9c8838c3..2874888e 100644 --- a/backend/vendor/github.com/xdg-go/scram/server_conv.go +++ b/backend/vendor/github.com/xdg-go/scram/server_conv.go @@ -25,18 +25,22 @@ const ( // conversation with a client. A new conversation must be created for // each authentication attempt. type ServerConversation struct { - nonceGen NonceGeneratorFcn - hashGen HashGeneratorFcn - credentialCB CredentialLookup - state serverState - credential StoredCredentials - valid bool - gs2Header string - username string - authzID string - nonce string - c1b string - s1 string + nonceGen NonceGeneratorFcn + hashGen HashGeneratorFcn + credentialCB CredentialLookup + state serverState + credential StoredCredentials + valid bool + gs2Header string + username string + authzID string + nonce string + c1b string + s1 string + channelBinding ChannelBinding + requireChannelBinding bool + clientCBType string + clientCBFlag string } // Step takes a string provided from a client and attempts to move the @@ -81,6 +85,65 @@ func (sc *ServerConversation) AuthzID() string { return sc.authzID } +// validateChannelBindingFlag validates the client's channel binding flag against +// server configuration. The validation logic follows RFC 5802 section 6, but +// extends those semantics to cover the case of required channel binding. +// +// Client flag validation: +// - "n": Client doesn't support channel binding +// - "y": Client supports channel binding but server didn't advertise PLUS +// - "p": Client requires channel binding with specific type +// +// Returns server error string (empty if validation passes) and error. +func (sc *ServerConversation) validateChannelBindingFlag() (string, error) { + advertised := sc.channelBinding.IsSupported() + + switch sc.clientCBFlag { + case "n": + // Client doesn't support channel binding + if sc.requireChannelBinding { + // Policy violation: server requires channel binding + // Use ErrServerDoesSupportChannelBinding (defined for downgrade attacks) + // as the best available match to signal that server requires channel binding + return ErrServerDoesSupportChannelBinding, + errors.New("server requires channel binding but client doesn't support it") + } + // OK: server either doesn't advertise PLUS or advertises it optionally + return "", nil + + case "y": + // Client supports channel binding but thinks server doesn't advertise PLUS + if advertised { + // Downgrade attack: we advertised PLUS but client didn't see it + return ErrServerDoesSupportChannelBinding, + errors.New("downgrade attack detected: client used 'y' but server advertised PLUS") + } + // OK: we didn't advertise PLUS, client correctly detected this + return "", nil + + case "p": + // Client requires channel binding with specific type + if !advertised { + // Server doesn't support channel binding + return ErrChannelBindingNotSupported, + errors.New("client requires channel binding but server doesn't support it") + } + if ChannelBindingType(sc.clientCBType) != sc.channelBinding.Type { + // Server supports channel binding but not the requested type + return ErrUnsupportedChannelBindingType, + fmt.Errorf("client requested %s but server only supports %s", + sc.clientCBType, sc.channelBinding.Type) + } + // OK: channel binding type matches + return "", nil + + default: + // Invalid flag (should have been caught by parser) + return ErrOtherError, + fmt.Errorf("invalid channel binding flag: %s", sc.clientCBFlag) + } +} + func (sc *ServerConversation) firstMsg(c1 string) (string, error) { msg, err := parseClientFirst(c1) if err != nil { @@ -89,13 +152,21 @@ func (sc *ServerConversation) firstMsg(c1 string) (string, error) { } sc.gs2Header = msg.gs2Header + sc.clientCBFlag = msg.gs2BindFlag + sc.clientCBType = msg.channelBinding sc.username = msg.username sc.authzID = msg.authzID + // Validate channel binding flag against server configuration + if serverErr, err := sc.validateChannelBindingFlag(); err != nil { + sc.state = serverDone + return serverErr, err + } + sc.credential, err = sc.credentialCB(msg.username) if err != nil { sc.state = serverDone - return "e=unknown-user", err + return ErrUnknownUser, err } sc.nonce = msg.nonce + sc.nonceGen() @@ -117,17 +188,25 @@ func (sc *ServerConversation) finalMsg(c2 string) (string, error) { return "", err } - // Check channel binding matches what we expect; in this case, we expect - // just the gs2 header we received as we don't support channel binding - // with a data payload. If we add binding, we need to independently - // compute the header to match here. - if string(msg.cbind) != sc.gs2Header { - return "e=channel-bindings-dont-match", fmt.Errorf("channel binding received '%s' doesn't match expected '%s'", msg.cbind, sc.gs2Header) + // Check channel binding data matches what we expect + var expectedCBind []byte + if sc.clientCBFlag == "p" { + // Client used channel binding - expect gs2 header + channel binding data + expectedCBind = append([]byte(sc.gs2Header), sc.channelBinding.Data...) + } else { + // Client didn't use channel binding - just expect gs2 header + expectedCBind = []byte(sc.gs2Header) + } + + if !hmac.Equal(msg.cbind, expectedCBind) { + return ErrChannelBindingsDontMatch, + fmt.Errorf("channel binding mismatch: expected %x, got %x", + expectedCBind, msg.cbind) } // Check nonce received matches what we sent if msg.nonce != sc.nonce { - return "e=other-error", errors.New("nonce received did not match nonce sent") + return ErrOtherError, errors.New("nonce received did not match nonce sent") } // Create auth message @@ -135,12 +214,15 @@ func (sc *ServerConversation) finalMsg(c2 string) (string, error) { // Retrieve ClientKey from proof and verify it clientSignature := computeHMAC(sc.hashGen, sc.credential.StoredKey, []byte(authMsg)) - clientKey := xorBytes([]byte(msg.proof), clientSignature) + clientKey, err := xorBytes([]byte(msg.proof), clientSignature) + if err != nil { + return ErrOtherError, err + } storedKey := computeHash(sc.hashGen, clientKey) // Compare with constant-time function if !hmac.Equal(storedKey, sc.credential.StoredKey) { - return "e=invalid-proof", errors.New("challenge proof invalid") + return ErrInvalidProof, errors.New("challenge proof invalid") } sc.valid = true diff --git a/backend/vendor/go.mongodb.org/mongo-driver/bson/bsonrw/extjson_writer.go b/backend/vendor/go.mongodb.org/mongo-driver/bson/bsonrw/extjson_writer.go index 57781ff9..86a29357 100644 --- a/backend/vendor/go.mongodb.org/mongo-driver/bson/bsonrw/extjson_writer.go +++ b/backend/vendor/go.mongodb.org/mongo-driver/bson/bsonrw/extjson_writer.go @@ -468,12 +468,13 @@ func (ejvw *extJSONValueWriter) WriteRegex(pattern string, options string) error return err } + options = sortStringAlphebeticAscending(options) var buf bytes.Buffer buf.WriteString(`{"$regularExpression":{"pattern":`) writeStringWithEscapes(pattern, &buf, ejvw.escapeHTML) - buf.WriteString(`,"options":"`) - buf.WriteString(sortStringAlphebeticAscending(options)) - buf.WriteString(`"}},`) + buf.WriteString(`,"options":`) + writeStringWithEscapes(options, &buf, ejvw.escapeHTML) + buf.WriteString(`}},`) ejvw.buf = append(ejvw.buf, buf.Bytes()...) diff --git a/backend/vendor/go.mongodb.org/mongo-driver/mongo/mongointernal.go b/backend/vendor/go.mongodb.org/mongo-driver/mongo/mongointernal.go new file mode 100644 index 00000000..0148756f --- /dev/null +++ b/backend/vendor/go.mongodb.org/mongo-driver/mongo/mongointernal.go @@ -0,0 +1,41 @@ +// Copyright (C) MongoDB, Inc. 2025-present. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +//go:build mongointernal + +package mongo + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/x/bsonx/bsoncore" + "go.mongodb.org/mongo-driver/x/mongo/driver/session" +) + +// NewSessionWithLSID returns a Session with the given sessionID document. The +// sessionID is a BSON document with key "id" containing a 16-byte UUID (binary +// subtype 4). +// +// Sessions returned by NewSessionWithLSID are never added to the driver's +// session pool. Calling "EndSession" or "ClientSession.SetServer" on a Session +// returned by NewSessionWithLSID will panic. +// +// NewSessionWithLSID is intended only for internal use and may be changed or +// removed at any time. +func NewSessionWithLSID(client *Client, sessionID bson.Raw) Session { + return &sessionImpl{ + clientSession: &session.Client{ + Server: &session.Server{ + SessionID: bsoncore.Document(sessionID), + LastUsed: time.Now(), + }, + ClientID: client.id, + }, + client: client, + deployment: client.deployment, + } +} diff --git a/backend/vendor/go.mongodb.org/mongo-driver/version/version.go b/backend/vendor/go.mongodb.org/mongo-driver/version/version.go index 8992fada..ace11008 100644 --- a/backend/vendor/go.mongodb.org/mongo-driver/version/version.go +++ b/backend/vendor/go.mongodb.org/mongo-driver/version/version.go @@ -11,4 +11,4 @@ package version // Driver is the current version of the driver. -var Driver = "1.17.3" +var Driver = "1.17.6" diff --git a/backend/vendor/go.mongodb.org/mongo-driver/x/mongo/driver/topology/connection.go b/backend/vendor/go.mongodb.org/mongo-driver/x/mongo/driver/topology/connection.go index e00363a5..bad00e74 100644 --- a/backend/vendor/go.mongodb.org/mongo-driver/x/mongo/driver/topology/connection.go +++ b/backend/vendor/go.mongodb.org/mongo-driver/x/mongo/driver/topology/connection.go @@ -14,7 +14,6 @@ import ( "fmt" "io" "net" - "os" "strings" "sync" "sync/atomic" @@ -567,48 +566,6 @@ func (c *connection) closed() bool { return atomic.LoadInt64(&c.state) == connDisconnected } -// isAlive returns true if the connection is alive and ready to be used for an -// operation. -// -// Note that the liveness check can be slow (at least 1ms), so isAlive only -// checks the liveness of the connection if it's been idle for at least 10 -// seconds. For frequently in-use connections, a network error during an -// operation will be the first indication of a dead connection. -func (c *connection) isAlive() bool { - if c.nc == nil { - return false - } - - // If the connection has been idle for less than 10 seconds, skip the - // liveness check. - // - // The 10-seconds idle bypass is based on the liveness check implementation - // in the Python Driver. That implementation uses 1 second as the idle - // threshold, but we chose to be more conservative in the Go Driver because - // this is new behavior with unknown side-effects. See - // https://github.com/mongodb/mongo-python-driver/blob/e6b95f65953e01e435004af069a6976473eaf841/pymongo/synchronous/pool.py#L983-L985 - idleStart, ok := c.idleStart.Load().(time.Time) - if !ok || idleStart.Add(10*time.Second).After(time.Now()) { - return true - } - - // Set a 1ms read deadline and attempt to read 1 byte from the connection. - // Expect it to block for 1ms then return a deadline exceeded error. If it - // returns any other error, the connection is not usable, so return false. - // If it doesn't return an error and actually reads data, the connection is - // also not usable, so return false. - // - // Note that we don't need to un-set the read deadline because the "read" - // and "write" methods always reset the deadlines. - err := c.nc.SetReadDeadline(time.Now().Add(1 * time.Millisecond)) - if err != nil { - return false - } - var b [1]byte - _, err = c.nc.Read(b[:]) - return errors.Is(err, os.ErrDeadlineExceeded) -} - func (c *connection) idleTimeoutExpired() bool { if c.idleTimeout == 0 { return false diff --git a/backend/vendor/go.mongodb.org/mongo-driver/x/mongo/driver/topology/pool.go b/backend/vendor/go.mongodb.org/mongo-driver/x/mongo/driver/topology/pool.go index e9565425..45b0b7d9 100644 --- a/backend/vendor/go.mongodb.org/mongo-driver/x/mongo/driver/topology/pool.go +++ b/backend/vendor/go.mongodb.org/mongo-driver/x/mongo/driver/topology/pool.go @@ -167,11 +167,9 @@ type reason struct { // connectionPerished checks if a given connection is perished and should be removed from the pool. func connectionPerished(conn *connection) (reason, bool) { switch { - case conn.closed() || !conn.isAlive(): + case conn.closed(): // A connection would only be closed if it encountered a network error - // during an operation and closed itself. If a connection is not alive - // (e.g. the connection was closed by the server-side), it's also - // considered a network error. + // during an operation and closed itself. return reason{ loggerConn: logger.ReasonConnClosedError, event: event.ReasonError, diff --git a/backend/vendor/golang.org/x/oauth2/authhandler/authhandler.go b/backend/vendor/golang.org/x/oauth2/authhandler/authhandler.go index 9bc6cd7b..46d1396f 100644 --- a/backend/vendor/golang.org/x/oauth2/authhandler/authhandler.go +++ b/backend/vendor/golang.org/x/oauth2/authhandler/authhandler.go @@ -34,7 +34,7 @@ type PKCEParams struct { // and returns an auth code and state upon approval. type AuthorizationHandler func(authCodeURL string) (code string, state string, err error) -// TokenSourceWithPKCE is an enhanced version of TokenSource with PKCE support. +// TokenSourceWithPKCE is an enhanced version of [oauth2.TokenSource] with PKCE support. // // The pkce parameter supports PKCE flow, which uses code challenge and code verifier // to prevent CSRF attacks. A unique code challenge and code verifier should be generated @@ -43,12 +43,12 @@ func TokenSourceWithPKCE(ctx context.Context, config *oauth2.Config, state strin return oauth2.ReuseTokenSource(nil, authHandlerSource{config: config, ctx: ctx, authHandler: authHandler, state: state, pkce: pkce}) } -// TokenSource returns an oauth2.TokenSource that fetches access tokens +// TokenSource returns an [oauth2.TokenSource] that fetches access tokens // using 3-legged-OAuth flow. // -// The provided context.Context is used for oauth2 Exchange operation. +// The provided [context.Context] is used for oauth2 Exchange operation. // -// The provided oauth2.Config should be a full configuration containing AuthURL, +// The provided [oauth2.Config] should be a full configuration containing AuthURL, // TokenURL, and Scope. // // An environment-specific AuthorizationHandler is used to obtain user consent. diff --git a/backend/vendor/golang.org/x/oauth2/deviceauth.go b/backend/vendor/golang.org/x/oauth2/deviceauth.go index e99c92f3..e783a943 100644 --- a/backend/vendor/golang.org/x/oauth2/deviceauth.go +++ b/backend/vendor/golang.org/x/oauth2/deviceauth.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "net/url" "strings" @@ -116,10 +117,38 @@ func retrieveDeviceAuth(ctx context.Context, c *Config, v url.Values) (*DeviceAu return nil, fmt.Errorf("oauth2: cannot auth device: %v", err) } if code := r.StatusCode; code < 200 || code > 299 { - return nil, &RetrieveError{ + retrieveError := &RetrieveError{ Response: r, Body: body, } + + content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + switch content { + case "application/x-www-form-urlencoded", "text/plain": + // some endpoints return a query string + vals, err := url.ParseQuery(string(body)) + if err != nil { + return nil, retrieveError + } + retrieveError.ErrorCode = vals.Get("error") + retrieveError.ErrorDescription = vals.Get("error_description") + retrieveError.ErrorURI = vals.Get("error_uri") + default: + var tj struct { + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorURI string `json:"error_uri"` + } + if json.Unmarshal(body, &tj) != nil { + return nil, retrieveError + } + retrieveError.ErrorCode = tj.ErrorCode + retrieveError.ErrorDescription = tj.ErrorDescription + retrieveError.ErrorURI = tj.ErrorURI + } + + return nil, retrieveError } da := &DeviceAuthResponse{} diff --git a/backend/vendor/golang.org/x/oauth2/endpoints/endpoints.go b/backend/vendor/golang.org/x/oauth2/endpoints/endpoints.go index d6e575e1..874b1288 100644 --- a/backend/vendor/golang.org/x/oauth2/endpoints/endpoints.go +++ b/backend/vendor/golang.org/x/oauth2/endpoints/endpoints.go @@ -6,7 +6,7 @@ package endpoints import ( - "strings" + "net/url" "golang.org/x/oauth2" ) @@ -17,6 +17,30 @@ var Amazon = oauth2.Endpoint{ TokenURL: "https://api.amazon.com/auth/o2/token", } +// Apple is the endpoint for "Sign in with Apple". +// +// Documentation: https://developer.apple.com/documentation/signinwithapplerestapi +var Apple = oauth2.Endpoint{ + AuthURL: "https://appleid.apple.com/auth/authorize", + TokenURL: "https://appleid.apple.com/auth/token", +} + +// Asana is the endpoint for Asana. +// +// Documentation: https://developers.asana.com/docs/oauth +var Asana = oauth2.Endpoint{ + AuthURL: "https://app.asana.com/-/oauth_authorize", + TokenURL: "https://app.asana.com/-/oauth_token", +} + +// Badgr is the endpoint for Canvas Badges. +// +// Documentation: https://community.canvaslms.com/t5/Canvas-Badges-Credentials/Developers-Build-an-app-that-integrates-with-the-Canvas-Badges/ta-p/528727 +var Badgr = oauth2.Endpoint{ + AuthURL: "https://badgr.com/auth/oauth2/authorize", + TokenURL: "https://api.badgr.io/o/token", +} + // Battlenet is the endpoint for Battlenet. var Battlenet = oauth2.Endpoint{ AuthURL: "https://battle.net/oauth/authorize", @@ -35,16 +59,44 @@ var Cern = oauth2.Endpoint{ TokenURL: "https://oauth.web.cern.ch/OAuth/Token", } +// Coinbase is the endpoint for Coinbase. +// +// Documentation: https://docs.cdp.coinbase.com/coinbase-app/docs/coinbase-app-reference +var Coinbase = oauth2.Endpoint{ + AuthURL: "https://login.coinbase.com/oauth2/auth", + TokenURL: "https://login.coinbase.com/oauth2/token", +} + // Discord is the endpoint for Discord. +// +// Documentation: https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-urls var Discord = oauth2.Endpoint{ AuthURL: "https://discord.com/oauth2/authorize", TokenURL: "https://discord.com/api/oauth2/token", } +// Dropbox is the endpoint for Dropbox. +// +// Documentation: https://developers.dropbox.com/oauth-guide +var Dropbox = oauth2.Endpoint{ + AuthURL: "https://www.dropbox.com/oauth2/authorize", + TokenURL: "https://api.dropboxapi.com/oauth2/token", +} + +// Endpoint is Ebay's OAuth 2.0 endpoint. +// +// Documentation: https://developer.ebay.com/api-docs/static/authorization_guide_landing.html +var Endpoint = oauth2.Endpoint{ + AuthURL: "https://auth.ebay.com/oauth2/authorize", + TokenURL: "https://api.ebay.com/identity/v1/oauth2/token", +} + // Facebook is the endpoint for Facebook. +// +// Documentation: https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow var Facebook = oauth2.Endpoint{ - AuthURL: "https://www.facebook.com/v3.2/dialog/oauth", - TokenURL: "https://graph.facebook.com/v3.2/oauth/access_token", + AuthURL: "https://www.facebook.com/v22.0/dialog/oauth", + TokenURL: "https://graph.facebook.com/v22.0/oauth/access_token", } // Foursquare is the endpoint for Foursquare. @@ -104,6 +156,14 @@ var KaKao = oauth2.Endpoint{ TokenURL: "https://kauth.kakao.com/oauth/token", } +// Line is the endpoint for Line. +// +// Documentation: https://developers.line.biz/en/docs/line-login/integrate-line-login/ +var Line = oauth2.Endpoint{ + AuthURL: "https://access.line.me/oauth2/v2.1/authorize", + TokenURL: "https://api.line.me/oauth2/v2.1/token", +} + // LinkedIn is the endpoint for LinkedIn. var LinkedIn = oauth2.Endpoint{ AuthURL: "https://www.linkedin.com/oauth/v2/authorization", @@ -140,7 +200,17 @@ var Microsoft = oauth2.Endpoint{ TokenURL: "https://login.live.com/oauth20_token.srf", } +// Naver is the endpoint for Naver. +// +// Documentation: https://developers.naver.com/docs/login/devguide/devguide.md +var Naver = oauth2.Endpoint{ + AuthURL: "https://nid.naver.com/oauth2.0/authorize", + TokenURL: "https://nid.naver.com/oauth2.0/token", +} + // NokiaHealth is the endpoint for Nokia Health. +// +// Deprecated: Nokia Health is now Withings. var NokiaHealth = oauth2.Endpoint{ AuthURL: "https://account.health.nokia.com/oauth2_user/authorize2", TokenURL: "https://account.health.nokia.com/oauth2/token", @@ -152,6 +222,14 @@ var Odnoklassniki = oauth2.Endpoint{ TokenURL: "https://api.odnoklassniki.ru/oauth/token.do", } +// OpenStreetMap is the endpoint for OpenStreetMap.org. +// +// Documentation: https://wiki.openstreetmap.org/wiki/OAuth +var OpenStreetMap = oauth2.Endpoint{ + AuthURL: "https://www.openstreetmap.org/oauth2/authorize", + TokenURL: "https://www.openstreetmap.org/oauth2/token", +} + // Patreon is the endpoint for Patreon. var Patreon = oauth2.Endpoint{ AuthURL: "https://www.patreon.com/oauth2/authorize", @@ -170,10 +248,52 @@ var PayPalSandbox = oauth2.Endpoint{ TokenURL: "https://api.sandbox.paypal.com/v1/identity/openidconnect/tokenservice", } +// Pinterest is the endpoint for Pinterest. +// +// Documentation: https://developers.pinterest.com/docs/getting-started/set-up-authentication-and-authorization/ +var Pinterest = oauth2.Endpoint{ + AuthURL: "https://www.pinterest.com/oauth", + TokenURL: "https://api.pinterest.com/v5/oauth/token", +} + +// Pipedrive is the endpoint for Pipedrive. +// +// Documentation: https://developers.pipedrive.com/docs/api/v1/Oauth +var Pipedrive = oauth2.Endpoint{ + AuthURL: "https://oauth.pipedrive.com/oauth/authorize", + TokenURL: "https://oauth.pipedrive.com/oauth/token", +} + +// QQ is the endpoint for QQ. +// +// Documentation: https://wiki.connect.qq.com/%e5%bc%80%e5%8f%91%e6%94%bb%e7%95%a5_server-side +var QQ = oauth2.Endpoint{ + AuthURL: "https://graph.qq.com/oauth2.0/authorize", + TokenURL: "https://graph.qq.com/oauth2.0/token", +} + +// Rakuten is the endpoint for Rakuten. +// +// Documentation: https://webservice.rakuten.co.jp/documentation +var Rakuten = oauth2.Endpoint{ + AuthURL: "https://app.rakuten.co.jp/services/authorize", + TokenURL: "https://app.rakuten.co.jp/services/token", +} + // Slack is the endpoint for Slack. +// +// Documentation: https://api.slack.com/authentication/oauth-v2 var Slack = oauth2.Endpoint{ - AuthURL: "https://slack.com/oauth/authorize", - TokenURL: "https://slack.com/api/oauth.access", + AuthURL: "https://slack.com/oauth/v2/authorize", + TokenURL: "https://slack.com/api/oauth.v2.access", +} + +// Splitwise is the endpoint for Splitwise. +// +// Documentation: https://dev.splitwise.com/ +var Splitwise = oauth2.Endpoint{ + AuthURL: "https://www.splitwise.com/oauth/authorize", + TokenURL: "https://www.splitwise.com/oauth/token", } // Spotify is the endpoint for Spotify. @@ -212,6 +332,22 @@ var Vk = oauth2.Endpoint{ TokenURL: "https://oauth.vk.com/access_token", } +// Withings is the endpoint for Withings. +// +// Documentation: https://account.withings.com/oauth2_user/authorize2 +var Withings = oauth2.Endpoint{ + AuthURL: "https://account.withings.com/oauth2_user/authorize2", + TokenURL: "https://account.withings.com/oauth2/token", +} + +// X is the endpoint for X (Twitter). +// +// Documentation: https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/user-access-token +var X = oauth2.Endpoint{ + AuthURL: "https://x.com/i/oauth2/authorize", + TokenURL: "https://api.x.com/2/oauth2/token", +} + // Yahoo is the endpoint for Yahoo. var Yahoo = oauth2.Endpoint{ AuthURL: "https://api.login.yahoo.com/oauth2/request_auth", @@ -230,6 +366,20 @@ var Zoom = oauth2.Endpoint{ TokenURL: "https://zoom.us/oauth/token", } +// Asgardeo returns a new oauth2.Endpoint for the given tenant. +// +// Documentation: https://wso2.com/asgardeo/docs/guides/authentication/oidc/discover-oidc-configs/ +func AsgardeoEndpoint(tenant string) oauth2.Endpoint { + u := url.URL{ + Scheme: "https", + Host: "api.asgardeo.io", + } + return oauth2.Endpoint{ + AuthURL: u.JoinPath("t", tenant, "/oauth2/authorize").String(), + TokenURL: u.JoinPath("t", tenant, "/oauth2/token").String(), + } +} + // AzureAD returns a new oauth2.Endpoint for the given tenant at Azure Active Directory. // If tenant is empty, it uses the tenant called `common`. // @@ -239,19 +389,29 @@ func AzureAD(tenant string) oauth2.Endpoint { if tenant == "" { tenant = "common" } + u := url.URL{ + Scheme: "https", + Host: "login.microsoftonline.com", + } return oauth2.Endpoint{ - AuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize", - TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", - DeviceAuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/devicecode", + AuthURL: u.JoinPath(tenant, "/oauth2/v2.0/authorize").String(), + TokenURL: u.JoinPath(tenant, "/oauth2/v2.0/token").String(), + DeviceAuthURL: u.JoinPath(tenant, "/oauth2/v2.0/devicecode").String(), } } -// HipChatServer returns a new oauth2.Endpoint for a HipChat Server instance -// running on the given domain or host. -func HipChatServer(host string) oauth2.Endpoint { +// AzureADB2CEndpoint returns a new oauth2.Endpoint for the given tenant and policy at Azure Active Directory B2C. +// policy is the Azure B2C User flow name Example: `B2C_1_SignUpSignIn`. +// +// Documentation: https://docs.microsoft.com/en-us/azure/active-directory-b2c/tokens-overview#endpoints +func AzureADB2CEndpoint(tenant string, policy string) oauth2.Endpoint { + u := url.URL{ + Scheme: "https", + Host: tenant + ".b2clogin.com", + } return oauth2.Endpoint{ - AuthURL: "https://" + host + "/users/authorize", - TokenURL: "https://" + host + "/v2/oauth/token", + AuthURL: u.JoinPath(tenant+".onmicrosoft.com", policy, "/oauth2/v2.0/authorize").String(), + TokenURL: u.JoinPath(tenant+".onmicrosoft.com", policy, "/oauth2/v2.0/token").String(), } } @@ -264,9 +424,42 @@ func HipChatServer(host string) oauth2.Endpoint { // https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-assign-domain.html // https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-userpools-server-contract-reference.html func AWSCognito(domain string) oauth2.Endpoint { - domain = strings.TrimRight(domain, "/") + u, err := url.Parse(domain) + if err != nil || u.Scheme == "" || u.Host == "" { + panic("endpoints: invalid domain" + domain) + } return oauth2.Endpoint{ - AuthURL: domain + "/oauth2/authorize", - TokenURL: domain + "/oauth2/token", + AuthURL: u.JoinPath("/oauth2/authorize").String(), + TokenURL: u.JoinPath("/oauth2/token").String(), + } +} + +// HipChatServer returns a new oauth2.Endpoint for a HipChat Server instance. +// host should be a hostname, without any scheme prefix. +// +// Documentation: https://developer.atlassian.com/server/hipchat/hipchat-rest-api-access-tokens/ +func HipChatServer(host string) oauth2.Endpoint { + u := url.URL{ + Scheme: "https", + Host: host, + } + return oauth2.Endpoint{ + AuthURL: u.JoinPath("/users/authorize").String(), + TokenURL: u.JoinPath("/v2/oauth/token").String(), + } +} + +// Shopify returns a new oauth2.Endpoint for the supplied shop domain name. +// host should be a hostname, without any scheme prefix. +// +// Documentation: https://shopify.dev/docs/apps/auth/oauth +func Shopify(host string) oauth2.Endpoint { + u := url.URL{ + Scheme: "https", + Host: host, + } + return oauth2.Endpoint{ + AuthURL: u.JoinPath("/admin/oauth/authorize").String(), + TokenURL: u.JoinPath("/admin/oauth/access_token").String(), } } diff --git a/backend/vendor/golang.org/x/oauth2/google/externalaccount/aws.go b/backend/vendor/golang.org/x/oauth2/google/externalaccount/aws.go index 55d59999..f62ec99a 100644 --- a/backend/vendor/golang.org/x/oauth2/google/externalaccount/aws.go +++ b/backend/vendor/golang.org/x/oauth2/google/externalaccount/aws.go @@ -5,7 +5,6 @@ package externalaccount import ( - "bytes" "context" "crypto/hmac" "crypto/sha256" @@ -14,7 +13,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "net/http" "net/url" "os" @@ -149,13 +147,13 @@ func canonicalHeaders(req *http.Request) (string, string) { } sort.Strings(headers) - var fullHeaders bytes.Buffer + var fullHeaders strings.Builder for _, header := range headers { headerValue := strings.Join(lowerCaseHeaders[header], ",") fullHeaders.WriteString(header) - fullHeaders.WriteRune(':') + fullHeaders.WriteByte(':') fullHeaders.WriteString(headerValue) - fullHeaders.WriteRune('\n') + fullHeaders.WriteByte('\n') } return strings.Join(headers, ";"), fullHeaders.String() @@ -170,7 +168,7 @@ func requestDataHash(req *http.Request) (string, error) { } defer requestBody.Close() - requestData, err = ioutil.ReadAll(io.LimitReader(requestBody, 1<<20)) + requestData, err = io.ReadAll(io.LimitReader(requestBody, 1<<20)) if err != nil { return "", err } @@ -419,7 +417,7 @@ func (cs *awsCredentialSource) getAWSSessionToken() (string, error) { } defer resp.Body.Close() - respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", err } @@ -462,7 +460,7 @@ func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, err } defer resp.Body.Close() - respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", err } @@ -531,7 +529,7 @@ func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, h } defer resp.Body.Close() - respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return result, err } @@ -564,7 +562,7 @@ func (cs *awsCredentialSource) getMetadataRoleName(headers map[string]string) (s } defer resp.Body.Close() - respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", err } diff --git a/backend/vendor/golang.org/x/oauth2/google/externalaccount/basecredentials.go b/backend/vendor/golang.org/x/oauth2/google/externalaccount/basecredentials.go index aa0bba2e..6f766217 100644 --- a/backend/vendor/golang.org/x/oauth2/google/externalaccount/basecredentials.go +++ b/backend/vendor/golang.org/x/oauth2/google/externalaccount/basecredentials.go @@ -486,11 +486,11 @@ func (ts tokenSource) Token() (*oauth2.Token, error) { ClientID: conf.ClientID, ClientSecret: conf.ClientSecret, } - var options map[string]interface{} + var options map[string]any // Do not pass workforce_pool_user_project when client authentication is used. // The client ID is sufficient for determining the user project. if conf.WorkforcePoolUserProject != "" && conf.ClientID == "" { - options = map[string]interface{}{ + options = map[string]any{ "userProject": conf.WorkforcePoolUserProject, } } diff --git a/backend/vendor/golang.org/x/oauth2/google/externalaccount/executablecredsource.go b/backend/vendor/golang.org/x/oauth2/google/externalaccount/executablecredsource.go index dca5681a..b173c61f 100644 --- a/backend/vendor/golang.org/x/oauth2/google/externalaccount/executablecredsource.go +++ b/backend/vendor/golang.org/x/oauth2/google/externalaccount/executablecredsource.go @@ -11,7 +11,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "os" "os/exec" "regexp" @@ -258,7 +257,7 @@ func (cs executableCredentialSource) getTokenFromOutputFile() (token string, err } defer file.Close() - data, err := ioutil.ReadAll(io.LimitReader(file, 1<<20)) + data, err := io.ReadAll(io.LimitReader(file, 1<<20)) if err != nil || len(data) == 0 { // Cachefile exists, but no data found. Get new credential. return "", nil diff --git a/backend/vendor/golang.org/x/oauth2/google/externalaccount/filecredsource.go b/backend/vendor/golang.org/x/oauth2/google/externalaccount/filecredsource.go index 33766b97..46ebc183 100644 --- a/backend/vendor/golang.org/x/oauth2/google/externalaccount/filecredsource.go +++ b/backend/vendor/golang.org/x/oauth2/google/externalaccount/filecredsource.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "os" ) @@ -29,14 +28,14 @@ func (cs fileCredentialSource) subjectToken() (string, error) { return "", fmt.Errorf("oauth2/google/externalaccount: failed to open credential file %q", cs.File) } defer tokenFile.Close() - tokenBytes, err := ioutil.ReadAll(io.LimitReader(tokenFile, 1<<20)) + tokenBytes, err := io.ReadAll(io.LimitReader(tokenFile, 1<<20)) if err != nil { return "", fmt.Errorf("oauth2/google/externalaccount: failed to read credential file: %v", err) } tokenBytes = bytes.TrimSpace(tokenBytes) switch cs.Format.Type { case "json": - jsonData := make(map[string]interface{}) + jsonData := make(map[string]any) err = json.Unmarshal(tokenBytes, &jsonData) if err != nil { return "", fmt.Errorf("oauth2/google/externalaccount: failed to unmarshal subject token file: %v", err) diff --git a/backend/vendor/golang.org/x/oauth2/google/externalaccount/urlcredsource.go b/backend/vendor/golang.org/x/oauth2/google/externalaccount/urlcredsource.go index 71a7184e..65bfd204 100644 --- a/backend/vendor/golang.org/x/oauth2/google/externalaccount/urlcredsource.go +++ b/backend/vendor/golang.org/x/oauth2/google/externalaccount/urlcredsource.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "net/http" "golang.org/x/oauth2" @@ -44,7 +43,7 @@ func (cs urlCredentialSource) subjectToken() (string, error) { } defer resp.Body.Close() - respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", fmt.Errorf("oauth2/google/externalaccount: invalid body in subject token URL query: %v", err) } @@ -54,7 +53,7 @@ func (cs urlCredentialSource) subjectToken() (string, error) { switch cs.Format.Type { case "json": - jsonData := make(map[string]interface{}) + jsonData := make(map[string]any) err = json.Unmarshal(respBody, &jsonData) if err != nil { return "", fmt.Errorf("oauth2/google/externalaccount: failed to unmarshal subject token file: %v", err) diff --git a/backend/vendor/golang.org/x/oauth2/google/google.go b/backend/vendor/golang.org/x/oauth2/google/google.go index 7b82e7a0..7d1fdd31 100644 --- a/backend/vendor/golang.org/x/oauth2/google/google.go +++ b/backend/vendor/golang.org/x/oauth2/google/google.go @@ -252,7 +252,7 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar // Further information about retrieving access tokens from the GCE metadata // server can be found at https://cloud.google.com/compute/docs/authentication. func ComputeTokenSource(account string, scope ...string) oauth2.TokenSource { - // refresh 3 minutes and 45 seconds early. The shortest MDS cache is currently 4 minutes, so any + // Refresh 3 minutes and 45 seconds early. The shortest MDS cache is currently 4 minutes, so any // refreshes earlier are a waste of compute. earlyExpirySecs := 225 * time.Second return computeTokenSource(account, earlyExpirySecs, scope...) @@ -285,27 +285,23 @@ func (cs computeSource) Token() (*oauth2.Token, error) { if err != nil { return nil, err } - var res struct { - AccessToken string `json:"access_token"` - ExpiresInSec int `json:"expires_in"` - TokenType string `json:"token_type"` - } + var res oauth2.Token err = json.NewDecoder(strings.NewReader(tokenJSON)).Decode(&res) if err != nil { return nil, fmt.Errorf("oauth2/google: invalid token JSON from metadata: %v", err) } - if res.ExpiresInSec == 0 || res.AccessToken == "" { + if res.ExpiresIn == 0 || res.AccessToken == "" { return nil, fmt.Errorf("oauth2/google: incomplete token received from metadata") } tok := &oauth2.Token{ AccessToken: res.AccessToken, TokenType: res.TokenType, - Expiry: time.Now().Add(time.Duration(res.ExpiresInSec) * time.Second), + Expiry: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second), } // NOTE(cbro): add hidden metadata about where the token is from. // This is needed for detection by client libraries to know that credentials come from the metadata server. // This may be removed in a future version of this library. - return tok.WithExtra(map[string]interface{}{ + return tok.WithExtra(map[string]any{ "oauth2.google.tokenSource": "compute-metadata", "oauth2.google.serviceAccount": acct, }), nil diff --git a/backend/vendor/golang.org/x/oauth2/google/internal/impersonate/impersonate.go b/backend/vendor/golang.org/x/oauth2/google/internal/impersonate/impersonate.go index 6bc3af11..eaa8b5c7 100644 --- a/backend/vendor/golang.org/x/oauth2/google/internal/impersonate/impersonate.go +++ b/backend/vendor/golang.org/x/oauth2/google/internal/impersonate/impersonate.go @@ -10,7 +10,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "time" @@ -81,7 +80,7 @@ func (its ImpersonateTokenSource) Token() (*oauth2.Token, error) { return nil, fmt.Errorf("oauth2/google: unable to generate access token: %v", err) } defer resp.Body.Close() - body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("oauth2/google: unable to read body: %v", err) } diff --git a/backend/vendor/golang.org/x/oauth2/google/internal/stsexchange/sts_exchange.go b/backend/vendor/golang.org/x/oauth2/google/internal/stsexchange/sts_exchange.go index 1a0bebd1..edf700e2 100644 --- a/backend/vendor/golang.org/x/oauth2/google/internal/stsexchange/sts_exchange.go +++ b/backend/vendor/golang.org/x/oauth2/google/internal/stsexchange/sts_exchange.go @@ -9,7 +9,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "net/url" "strconv" @@ -28,7 +27,7 @@ func defaultHeader() http.Header { // The first 4 fields are all mandatory. headers can be used to pass additional // headers beyond the bare minimum required by the token exchange. options can // be used to pass additional JSON-structured options to the remote server. -func ExchangeToken(ctx context.Context, endpoint string, request *TokenExchangeRequest, authentication ClientAuthentication, headers http.Header, options map[string]interface{}) (*Response, error) { +func ExchangeToken(ctx context.Context, endpoint string, request *TokenExchangeRequest, authentication ClientAuthentication, headers http.Header, options map[string]any) (*Response, error) { data := url.Values{} data.Set("audience", request.Audience) data.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") @@ -82,7 +81,7 @@ func makeRequest(ctx context.Context, endpoint string, data url.Values, authenti } defer resp.Body.Close() - body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } diff --git a/backend/vendor/golang.org/x/oauth2/internal/doc.go b/backend/vendor/golang.org/x/oauth2/internal/doc.go index 03265e88..8c7c475f 100644 --- a/backend/vendor/golang.org/x/oauth2/internal/doc.go +++ b/backend/vendor/golang.org/x/oauth2/internal/doc.go @@ -2,5 +2,5 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -// Package internal contains support packages for oauth2 package. +// Package internal contains support packages for [golang.org/x/oauth2]. package internal diff --git a/backend/vendor/golang.org/x/oauth2/internal/oauth2.go b/backend/vendor/golang.org/x/oauth2/internal/oauth2.go index 14989bea..71ea6ad1 100644 --- a/backend/vendor/golang.org/x/oauth2/internal/oauth2.go +++ b/backend/vendor/golang.org/x/oauth2/internal/oauth2.go @@ -13,7 +13,7 @@ import ( ) // ParseKey converts the binary contents of a private key file -// to an *rsa.PrivateKey. It detects whether the private key is in a +// to an [*rsa.PrivateKey]. It detects whether the private key is in a // PEM container or not. If so, it extracts the private key // from PEM container before conversion. It only supports PEM // containers with no passphrase. diff --git a/backend/vendor/golang.org/x/oauth2/internal/token.go b/backend/vendor/golang.org/x/oauth2/internal/token.go index e83ddeef..8389f246 100644 --- a/backend/vendor/golang.org/x/oauth2/internal/token.go +++ b/backend/vendor/golang.org/x/oauth2/internal/token.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "math" "mime" "net/http" @@ -26,9 +25,9 @@ import ( // the requests to access protected resources on the OAuth 2.0 // provider's backend. // -// This type is a mirror of oauth2.Token and exists to break +// This type is a mirror of [golang.org/x/oauth2.Token] and exists to break // an otherwise-circular dependency. Other internal packages -// should convert this Token into an oauth2.Token before use. +// should convert this Token into an [golang.org/x/oauth2.Token] before use. type Token struct { // AccessToken is the token that authorizes and authenticates // the requests. @@ -50,9 +49,16 @@ type Token struct { // mechanisms for that TokenSource will not be used. Expiry time.Time + // ExpiresIn is the OAuth2 wire format "expires_in" field, + // which specifies how many seconds later the token expires, + // relative to an unknown time base approximately around "now". + // It is the application's responsibility to populate + // `Expiry` from `ExpiresIn` when required. + ExpiresIn int64 `json:"expires_in,omitempty"` + // Raw optionally contains extra metadata from the server // when updating a token. - Raw interface{} + Raw any } // tokenJSON is the struct representing the HTTP response from OAuth2 @@ -99,14 +105,6 @@ func (e *expirationTime) UnmarshalJSON(b []byte) error { return nil } -// RegisterBrokenAuthHeaderProvider previously did something. It is now a no-op. -// -// Deprecated: this function no longer does anything. Caller code that -// wants to avoid potential extra HTTP requests made during -// auto-probing of the provider's auth style should set -// Endpoint.AuthStyle. -func RegisterBrokenAuthHeaderProvider(tokenURL string) {} - // AuthStyle is a copy of the golang.org/x/oauth2 package's AuthStyle type. type AuthStyle int @@ -143,6 +141,11 @@ func (lc *LazyAuthStyleCache) Get() *AuthStyleCache { return c } +type authStyleCacheKey struct { + url string + clientID string +} + // AuthStyleCache is the set of tokenURLs we've successfully used via // RetrieveToken and which style auth we ended up using. // It's called a cache, but it doesn't (yet?) shrink. It's expected that @@ -150,26 +153,26 @@ func (lc *LazyAuthStyleCache) Get() *AuthStyleCache { // small. type AuthStyleCache struct { mu sync.Mutex - m map[string]AuthStyle // keyed by tokenURL + m map[authStyleCacheKey]AuthStyle } // lookupAuthStyle reports which auth style we last used with tokenURL // when calling RetrieveToken and whether we have ever done so. -func (c *AuthStyleCache) lookupAuthStyle(tokenURL string) (style AuthStyle, ok bool) { +func (c *AuthStyleCache) lookupAuthStyle(tokenURL, clientID string) (style AuthStyle, ok bool) { c.mu.Lock() defer c.mu.Unlock() - style, ok = c.m[tokenURL] + style, ok = c.m[authStyleCacheKey{tokenURL, clientID}] return } // setAuthStyle adds an entry to authStyleCache, documented above. -func (c *AuthStyleCache) setAuthStyle(tokenURL string, v AuthStyle) { +func (c *AuthStyleCache) setAuthStyle(tokenURL, clientID string, v AuthStyle) { c.mu.Lock() defer c.mu.Unlock() if c.m == nil { - c.m = make(map[string]AuthStyle) + c.m = make(map[authStyleCacheKey]AuthStyle) } - c.m[tokenURL] = v + c.m[authStyleCacheKey{tokenURL, clientID}] = v } // newTokenRequest returns a new *http.Request to retrieve a new token @@ -210,9 +213,9 @@ func cloneURLValues(v url.Values) url.Values { } func RetrieveToken(ctx context.Context, clientID, clientSecret, tokenURL string, v url.Values, authStyle AuthStyle, styleCache *AuthStyleCache) (*Token, error) { - needsAuthStyleProbe := authStyle == 0 + needsAuthStyleProbe := authStyle == AuthStyleUnknown if needsAuthStyleProbe { - if style, ok := styleCache.lookupAuthStyle(tokenURL); ok { + if style, ok := styleCache.lookupAuthStyle(tokenURL, clientID); ok { authStyle = style needsAuthStyleProbe = false } else { @@ -242,7 +245,7 @@ func RetrieveToken(ctx context.Context, clientID, clientSecret, tokenURL string, token, err = doTokenRoundTrip(ctx, req) } if needsAuthStyleProbe && err == nil { - styleCache.setAuthStyle(tokenURL, authStyle) + styleCache.setAuthStyle(tokenURL, clientID, authStyle) } // Don't overwrite `RefreshToken` with an empty value // if this was a token refreshing request. @@ -257,7 +260,7 @@ func doTokenRoundTrip(ctx context.Context, req *http.Request) (*Token, error) { if err != nil { return nil, err } - body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1<<20)) + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) r.Body.Close() if err != nil { return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) @@ -312,7 +315,8 @@ func doTokenRoundTrip(ctx context.Context, req *http.Request) (*Token, error) { TokenType: tj.TokenType, RefreshToken: tj.RefreshToken, Expiry: tj.expiry(), - Raw: make(map[string]interface{}), + ExpiresIn: int64(tj.ExpiresIn), + Raw: make(map[string]any), } json.Unmarshal(body, &token.Raw) // no error checks for optional fields } diff --git a/backend/vendor/golang.org/x/oauth2/internal/transport.go b/backend/vendor/golang.org/x/oauth2/internal/transport.go index b9db01dd..afc0aeb2 100644 --- a/backend/vendor/golang.org/x/oauth2/internal/transport.go +++ b/backend/vendor/golang.org/x/oauth2/internal/transport.go @@ -9,8 +9,8 @@ import ( "net/http" ) -// HTTPClient is the context key to use with golang.org/x/net/context's -// WithValue function to associate an *http.Client value with a context. +// HTTPClient is the context key to use with [context.WithValue] +// to associate an [*http.Client] value with a context. var HTTPClient ContextKey // ContextKey is just an empty struct. It exists so HTTPClient can be diff --git a/backend/vendor/golang.org/x/oauth2/jws/jws.go b/backend/vendor/golang.org/x/oauth2/jws/jws.go index 27ab0613..9bc48440 100644 --- a/backend/vendor/golang.org/x/oauth2/jws/jws.go +++ b/backend/vendor/golang.org/x/oauth2/jws/jws.go @@ -4,7 +4,7 @@ // Package jws provides a partial implementation // of JSON Web Signature encoding and decoding. -// It exists to support the golang.org/x/oauth2 package. +// It exists to support the [golang.org/x/oauth2] package. // // See RFC 7515. // @@ -48,7 +48,7 @@ type ClaimSet struct { // See http://tools.ietf.org/html/draft-jones-json-web-token-10#section-4.3 // This array is marshalled using custom code (see (c *ClaimSet) encode()). - PrivateClaims map[string]interface{} `json:"-"` + PrivateClaims map[string]any `json:"-"` } func (c *ClaimSet) encode() (string, error) { @@ -152,7 +152,7 @@ func EncodeWithSigner(header *Header, c *ClaimSet, sg Signer) (string, error) { } // Encode encodes a signed JWS with provided header and claim set. -// This invokes EncodeWithSigner using crypto/rsa.SignPKCS1v15 with the given RSA private key. +// This invokes [EncodeWithSigner] using [crypto/rsa.SignPKCS1v15] with the given RSA private key. func Encode(header *Header, c *ClaimSet, key *rsa.PrivateKey) (string, error) { sg := func(data []byte) (sig []byte, err error) { h := sha256.New() diff --git a/backend/vendor/golang.org/x/oauth2/jwt/jwt.go b/backend/vendor/golang.org/x/oauth2/jwt/jwt.go index b2bf1829..38a92dac 100644 --- a/backend/vendor/golang.org/x/oauth2/jwt/jwt.go +++ b/backend/vendor/golang.org/x/oauth2/jwt/jwt.go @@ -13,7 +13,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "net/url" "strings" @@ -69,7 +68,7 @@ type Config struct { // PrivateClaims optionally specifies custom private claims in the JWT. // See http://tools.ietf.org/html/draft-jones-json-web-token-10#section-4.3 - PrivateClaims map[string]interface{} + PrivateClaims map[string]any // UseIDToken optionally specifies whether ID token should be used instead // of access token when the server returns both. @@ -136,7 +135,7 @@ func (js jwtSource) Token() (*oauth2.Token, error) { return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) } defer resp.Body.Close() - body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) } @@ -148,10 +147,8 @@ func (js jwtSource) Token() (*oauth2.Token, error) { } // tokenRes is the JSON response body. var tokenRes struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - IDToken string `json:"id_token"` - ExpiresIn int64 `json:"expires_in"` // relative seconds from now + oauth2.Token + IDToken string `json:"id_token"` } if err := json.Unmarshal(body, &tokenRes); err != nil { return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) @@ -160,7 +157,7 @@ func (js jwtSource) Token() (*oauth2.Token, error) { AccessToken: tokenRes.AccessToken, TokenType: tokenRes.TokenType, } - raw := make(map[string]interface{}) + raw := make(map[string]any) json.Unmarshal(body, &raw) // no error checks for optional fields token = token.WithExtra(raw) diff --git a/backend/vendor/golang.org/x/oauth2/oauth2.go b/backend/vendor/golang.org/x/oauth2/oauth2.go index eacdd7fd..5c527d31 100644 --- a/backend/vendor/golang.org/x/oauth2/oauth2.go +++ b/backend/vendor/golang.org/x/oauth2/oauth2.go @@ -9,7 +9,6 @@ package oauth2 // import "golang.org/x/oauth2" import ( - "bytes" "context" "errors" "net/http" @@ -22,9 +21,9 @@ import ( ) // NoContext is the default context you should supply if not using -// your own context.Context (see https://golang.org/x/net/context). +// your own [context.Context]. // -// Deprecated: Use context.Background() or context.TODO() instead. +// Deprecated: Use [context.Background] or [context.TODO] instead. var NoContext = context.TODO() // RegisterBrokenAuthHeaderProvider previously did something. It is now a no-op. @@ -37,8 +36,8 @@ func RegisterBrokenAuthHeaderProvider(tokenURL string) {} // Config describes a typical 3-legged OAuth2 flow, with both the // client application information and the server's endpoint URLs. -// For the client credentials 2-legged OAuth2 flow, see the clientcredentials -// package (https://golang.org/x/oauth2/clientcredentials). +// For the client credentials 2-legged OAuth2 flow, see the +// [golang.org/x/oauth2/clientcredentials] package. type Config struct { // ClientID is the application's ID. ClientID string @@ -46,7 +45,7 @@ type Config struct { // ClientSecret is the application's secret. ClientSecret string - // Endpoint contains the resource server's token endpoint + // Endpoint contains the authorization server's token endpoint // URLs. These are constants specific to each server and are // often available via site-specific packages, such as // google.Endpoint or github.Endpoint. @@ -99,7 +98,7 @@ const ( // in the POST body as application/x-www-form-urlencoded parameters. AuthStyleInParams AuthStyle = 1 - // AuthStyleInHeader sends the client_id and client_password + // AuthStyleInHeader sends the client_id and client_secret // using HTTP Basic Authorization. This is an optional style // described in the OAuth2 RFC 6749 section 2.3.1. AuthStyleInHeader AuthStyle = 2 @@ -135,7 +134,7 @@ type setParam struct{ k, v string } func (p setParam) setValue(m url.Values) { m.Set(p.k, p.v) } -// SetAuthURLParam builds an AuthCodeOption which passes key/value parameters +// SetAuthURLParam builds an [AuthCodeOption] which passes key/value parameters // to a provider's authorization endpoint. func SetAuthURLParam(key, value string) AuthCodeOption { return setParam{key, value} @@ -148,8 +147,8 @@ func SetAuthURLParam(key, value string) AuthCodeOption { // request and callback. The authorization server includes this value when // redirecting the user agent back to the client. // -// Opts may include AccessTypeOnline or AccessTypeOffline, as well -// as ApprovalForce. +// Opts may include [AccessTypeOnline] or [AccessTypeOffline], as well +// as [ApprovalForce]. // // To protect against CSRF attacks, opts should include a PKCE challenge // (S256ChallengeOption). Not all servers support PKCE. An alternative is to @@ -158,7 +157,7 @@ func SetAuthURLParam(key, value string) AuthCodeOption { // PKCE), https://www.oauth.com/oauth2-servers/pkce/ and // https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-09.html#name-cross-site-request-forgery (describing both approaches) func (c *Config) AuthCodeURL(state string, opts ...AuthCodeOption) string { - var buf bytes.Buffer + var buf strings.Builder buf.WriteString(c.Endpoint.AuthURL) v := url.Values{ "response_type": {"code"}, @@ -194,7 +193,7 @@ func (c *Config) AuthCodeURL(state string, opts ...AuthCodeOption) string { // and when other authorization grant types are not available." // See https://tools.ietf.org/html/rfc6749#section-4.3 for more info. // -// The provided context optionally controls which HTTP client is used. See the HTTPClient variable. +// The provided context optionally controls which HTTP client is used. See the [HTTPClient] variable. func (c *Config) PasswordCredentialsToken(ctx context.Context, username, password string) (*Token, error) { v := url.Values{ "grant_type": {"password"}, @@ -212,10 +211,10 @@ func (c *Config) PasswordCredentialsToken(ctx context.Context, username, passwor // It is used after a resource provider redirects the user back // to the Redirect URI (the URL obtained from AuthCodeURL). // -// The provided context optionally controls which HTTP client is used. See the HTTPClient variable. +// The provided context optionally controls which HTTP client is used. See the [HTTPClient] variable. // -// The code will be in the *http.Request.FormValue("code"). Before -// calling Exchange, be sure to validate FormValue("state") if you are +// The code will be in the [http.Request.FormValue]("code"). Before +// calling Exchange, be sure to validate [http.Request.FormValue]("state") if you are // using it to protect against CSRF attacks. // // If using PKCE to protect against CSRF attacks, opts should include a @@ -242,10 +241,10 @@ func (c *Config) Client(ctx context.Context, t *Token) *http.Client { return NewClient(ctx, c.TokenSource(ctx, t)) } -// TokenSource returns a TokenSource that returns t until t expires, +// TokenSource returns a [TokenSource] that returns t until t expires, // automatically refreshing it as necessary using the provided context. // -// Most users will use Config.Client instead. +// Most users will use [Config.Client] instead. func (c *Config) TokenSource(ctx context.Context, t *Token) TokenSource { tkr := &tokenRefresher{ ctx: ctx, @@ -260,7 +259,7 @@ func (c *Config) TokenSource(ctx context.Context, t *Token) TokenSource { } } -// tokenRefresher is a TokenSource that makes "grant_type"=="refresh_token" +// tokenRefresher is a TokenSource that makes "grant_type=refresh_token" // HTTP requests to renew a token using a RefreshToken. type tokenRefresher struct { ctx context.Context // used to get HTTP requests @@ -305,8 +304,7 @@ type reuseTokenSource struct { } // Token returns the current token if it's still valid, else will -// refresh the current token (using r.Context for HTTP client -// information) and return the new one. +// refresh the current token and return the new one. func (s *reuseTokenSource) Token() (*Token, error) { s.mu.Lock() defer s.mu.Unlock() @@ -322,7 +320,7 @@ func (s *reuseTokenSource) Token() (*Token, error) { return t, nil } -// StaticTokenSource returns a TokenSource that always returns the same token. +// StaticTokenSource returns a [TokenSource] that always returns the same token. // Because the provided token t is never refreshed, StaticTokenSource is only // useful for tokens that never expire. func StaticTokenSource(t *Token) TokenSource { @@ -338,16 +336,16 @@ func (s staticTokenSource) Token() (*Token, error) { return s.t, nil } -// HTTPClient is the context key to use with golang.org/x/net/context's -// WithValue function to associate an *http.Client value with a context. +// HTTPClient is the context key to use with [context.WithValue] +// to associate a [*http.Client] value with a context. var HTTPClient internal.ContextKey -// NewClient creates an *http.Client from a Context and TokenSource. +// NewClient creates an [*http.Client] from a [context.Context] and [TokenSource]. // The returned client is not valid beyond the lifetime of the context. // -// Note that if a custom *http.Client is provided via the Context it +// Note that if a custom [*http.Client] is provided via the [context.Context] it // is used only for token acquisition and is not used to configure the -// *http.Client returned from NewClient. +// [*http.Client] returned from NewClient. // // As a special case, if src is nil, a non-OAuth2 client is returned // using the provided context. This exists to support related OAuth2 @@ -368,7 +366,7 @@ func NewClient(ctx context.Context, src TokenSource) *http.Client { } } -// ReuseTokenSource returns a TokenSource which repeatedly returns the +// ReuseTokenSource returns a [TokenSource] which repeatedly returns the // same token as long as it's valid, starting with t. // When its cached token is invalid, a new token is obtained from src. // @@ -376,10 +374,10 @@ func NewClient(ctx context.Context, src TokenSource) *http.Client { // (such as a file on disk) between runs of a program, rather than // obtaining new tokens unnecessarily. // -// The initial token t may be nil, in which case the TokenSource is +// The initial token t may be nil, in which case the [TokenSource] is // wrapped in a caching version if it isn't one already. This also // means it's always safe to wrap ReuseTokenSource around any other -// TokenSource without adverse effects. +// [TokenSource] without adverse effects. func ReuseTokenSource(t *Token, src TokenSource) TokenSource { // Don't wrap a reuseTokenSource in itself. That would work, // but cause an unnecessary number of mutex operations. @@ -397,8 +395,8 @@ func ReuseTokenSource(t *Token, src TokenSource) TokenSource { } } -// ReuseTokenSourceWithExpiry returns a TokenSource that acts in the same manner as the -// TokenSource returned by ReuseTokenSource, except the expiry buffer is +// ReuseTokenSourceWithExpiry returns a [TokenSource] that acts in the same manner as the +// [TokenSource] returned by [ReuseTokenSource], except the expiry buffer is // configurable. The expiration time of a token is calculated as // t.Expiry.Add(-earlyExpiry). func ReuseTokenSourceWithExpiry(t *Token, src TokenSource, earlyExpiry time.Duration) TokenSource { diff --git a/backend/vendor/golang.org/x/oauth2/pkce.go b/backend/vendor/golang.org/x/oauth2/pkce.go index 6a95da97..f99384f0 100644 --- a/backend/vendor/golang.org/x/oauth2/pkce.go +++ b/backend/vendor/golang.org/x/oauth2/pkce.go @@ -1,6 +1,7 @@ // Copyright 2023 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. + package oauth2 import ( @@ -20,9 +21,9 @@ const ( // This follows recommendations in RFC 7636. // // A fresh verifier should be generated for each authorization. -// S256ChallengeOption(verifier) should then be passed to Config.AuthCodeURL -// (or Config.DeviceAuth) and VerifierOption(verifier) to Config.Exchange -// (or Config.DeviceAccessToken). +// The resulting verifier should be passed to [Config.AuthCodeURL] or [Config.DeviceAuth] +// with [S256ChallengeOption], and to [Config.Exchange] or [Config.DeviceAccessToken] +// with [VerifierOption]. func GenerateVerifier() string { // "RECOMMENDED that the output of a suitable random number generator be // used to create a 32-octet sequence. The octet sequence is then @@ -36,22 +37,22 @@ func GenerateVerifier() string { return base64.RawURLEncoding.EncodeToString(data) } -// VerifierOption returns a PKCE code verifier AuthCodeOption. It should be -// passed to Config.Exchange or Config.DeviceAccessToken only. +// VerifierOption returns a PKCE code verifier [AuthCodeOption]. It should only be +// passed to [Config.Exchange] or [Config.DeviceAccessToken]. func VerifierOption(verifier string) AuthCodeOption { return setParam{k: codeVerifierKey, v: verifier} } // S256ChallengeFromVerifier returns a PKCE code challenge derived from verifier with method S256. // -// Prefer to use S256ChallengeOption where possible. +// Prefer to use [S256ChallengeOption] where possible. func S256ChallengeFromVerifier(verifier string) string { sha := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sha[:]) } -// S256ChallengeOption derives a PKCE code challenge derived from verifier with -// method S256. It should be passed to Config.AuthCodeURL or Config.DeviceAuth +// S256ChallengeOption derives a PKCE code challenge from the verifier with +// method S256. It should be passed to [Config.AuthCodeURL] or [Config.DeviceAuth] // only. func S256ChallengeOption(verifier string) AuthCodeOption { return challengeOption{ diff --git a/backend/vendor/golang.org/x/oauth2/token.go b/backend/vendor/golang.org/x/oauth2/token.go index 8c31136c..e995eebb 100644 --- a/backend/vendor/golang.org/x/oauth2/token.go +++ b/backend/vendor/golang.org/x/oauth2/token.go @@ -44,7 +44,7 @@ type Token struct { // Expiry is the optional expiration time of the access token. // - // If zero, TokenSource implementations will reuse the same + // If zero, [TokenSource] implementations will reuse the same // token forever and RefreshToken or equivalent // mechanisms for that TokenSource will not be used. Expiry time.Time `json:"expiry,omitempty"` @@ -58,7 +58,7 @@ type Token struct { // raw optionally contains extra metadata from the server // when updating a token. - raw interface{} + raw any // expiryDelta is used to calculate when a token is considered // expired, by subtracting from Expiry. If zero, defaultExpiryDelta @@ -86,16 +86,16 @@ func (t *Token) Type() string { // SetAuthHeader sets the Authorization header to r using the access // token in t. // -// This method is unnecessary when using Transport or an HTTP Client +// This method is unnecessary when using [Transport] or an HTTP Client // returned by this package. func (t *Token) SetAuthHeader(r *http.Request) { r.Header.Set("Authorization", t.Type()+" "+t.AccessToken) } -// WithExtra returns a new Token that's a clone of t, but using the +// WithExtra returns a new [Token] that's a clone of t, but using the // provided raw extra map. This is only intended for use by packages // implementing derivative OAuth2 flows. -func (t *Token) WithExtra(extra interface{}) *Token { +func (t *Token) WithExtra(extra any) *Token { t2 := new(Token) *t2 = *t t2.raw = extra @@ -103,10 +103,10 @@ func (t *Token) WithExtra(extra interface{}) *Token { } // Extra returns an extra field. -// Extra fields are key-value pairs returned by the server as a +// Extra fields are key-value pairs returned by the server as // part of the token retrieval response. -func (t *Token) Extra(key string) interface{} { - if raw, ok := t.raw.(map[string]interface{}); ok { +func (t *Token) Extra(key string) any { + if raw, ok := t.raw.(map[string]any); ok { return raw[key] } @@ -163,6 +163,7 @@ func tokenFromInternal(t *internal.Token) *Token { TokenType: t.TokenType, RefreshToken: t.RefreshToken, Expiry: t.Expiry, + ExpiresIn: t.ExpiresIn, raw: t.Raw, } } diff --git a/backend/vendor/golang.org/x/oauth2/transport.go b/backend/vendor/golang.org/x/oauth2/transport.go index 90657915..9922ec33 100644 --- a/backend/vendor/golang.org/x/oauth2/transport.go +++ b/backend/vendor/golang.org/x/oauth2/transport.go @@ -11,12 +11,12 @@ import ( "sync" ) -// Transport is an http.RoundTripper that makes OAuth 2.0 HTTP requests, -// wrapping a base RoundTripper and adding an Authorization header -// with a token from the supplied Sources. +// Transport is an [http.RoundTripper] that makes OAuth 2.0 HTTP requests, +// wrapping a base [http.RoundTripper] and adding an Authorization header +// with a token from the supplied [TokenSource]. // // Transport is a low-level mechanism. Most code will use the -// higher-level Config.Client method instead. +// higher-level [Config.Client] method instead. type Transport struct { // Source supplies the token to add to outgoing requests' // Authorization headers. @@ -47,7 +47,7 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { return nil, err } - req2 := cloneRequest(req) // per RoundTripper contract + req2 := req.Clone(req.Context()) token.SetAuthHeader(req2) // req.Body is assumed to be closed by the base RoundTripper. @@ -58,7 +58,7 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { var cancelOnce sync.Once // CancelRequest does nothing. It used to be a legacy cancellation mechanism -// but now only it only logs on first use to warn that it's deprecated. +// but now only logs on first use to warn that it's deprecated. // // Deprecated: use contexts for cancellation instead. func (t *Transport) CancelRequest(req *http.Request) { @@ -73,17 +73,3 @@ func (t *Transport) base() http.RoundTripper { } return http.DefaultTransport } - -// cloneRequest returns a clone of the provided *http.Request. -// The clone is a shallow copy of the struct and its Header map. -func cloneRequest(r *http.Request) *http.Request { - // shallow copy of the struct - r2 := new(http.Request) - *r2 = *r - // deep copy of the Header - r2.Header = make(http.Header, len(r.Header)) - for k, s := range r.Header { - r2.Header[k] = append([]string(nil), s...) - } - return r2 -} diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index 2853c6a5..36c5ac3f 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -1,5 +1,5 @@ -# cloud.google.com/go/compute/metadata v0.6.0 -## explicit; go 1.21 +# cloud.google.com/go/compute/metadata v0.9.0 +## explicit; go 1.24.0 cloud.google.com/go/compute/metadata # github.com/Depado/bfchroma/v2 v2.0.0 ## explicit; go 1.18 @@ -33,16 +33,13 @@ github.com/dghubble/oauth1/twitter # github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f ## explicit github.com/dgryski/go-rendezvous -# github.com/didip/tollbooth/v7 v7.0.2 +# github.com/didip/tollbooth/v8 v8.0.1 ## explicit; go 1.19 -github.com/didip/tollbooth/v7 -github.com/didip/tollbooth/v7/errors -github.com/didip/tollbooth/v7/internal/time/rate -github.com/didip/tollbooth/v7/libstring -github.com/didip/tollbooth/v7/limiter -# github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f -## explicit; go 1.14 -github.com/didip/tollbooth_chi +github.com/didip/tollbooth/v8 +github.com/didip/tollbooth/v8/errors +github.com/didip/tollbooth/v8/internal/time/rate +github.com/didip/tollbooth/v8/libstring +github.com/didip/tollbooth/v8/limiter # github.com/dlclark/regexp2 v1.11.5 ## explicit; go 1.13 github.com/dlclark/regexp2 @@ -54,13 +51,13 @@ github.com/go-chi/chi/v5/middleware # github.com/go-chi/cors v1.2.2 ## explicit; go 1.14 github.com/go-chi/cors -# github.com/go-oauth2/oauth2/v4 v4.5.3 -## explicit; go 1.13 +# github.com/go-oauth2/oauth2/v4 v4.5.4 +## explicit; go 1.21 github.com/go-oauth2/oauth2/v4 github.com/go-oauth2/oauth2/v4/errors github.com/go-oauth2/oauth2/v4/server -# github.com/go-pkgz/auth/v2 v2.0.1-0.20250415030422-4f9f2c5e3b0d -## explicit; go 1.23.0 +# github.com/go-pkgz/auth/v2 v2.1.0 +## explicit; go 1.24.0 github.com/go-pkgz/auth/v2 github.com/go-pkgz/auth/v2/avatar github.com/go-pkgz/auth/v2/logger @@ -68,10 +65,10 @@ github.com/go-pkgz/auth/v2/middleware github.com/go-pkgz/auth/v2/provider github.com/go-pkgz/auth/v2/provider/sender github.com/go-pkgz/auth/v2/token -# github.com/go-pkgz/email v0.5.0 +# github.com/go-pkgz/email v0.6.0 ## explicit; go 1.19 github.com/go-pkgz/email -# github.com/go-pkgz/expirable-cache/v3 v3.0.0 +# github.com/go-pkgz/expirable-cache/v3 v3.1.0 ## explicit; go 1.20 github.com/go-pkgz/expirable-cache/v3 # github.com/go-pkgz/jrpc v0.4.0 @@ -84,13 +81,16 @@ github.com/go-pkgz/lcw/v2/eventbus # github.com/go-pkgz/lgr v0.12.1 ## explicit; go 1.21 github.com/go-pkgz/lgr -# github.com/go-pkgz/notify v1.2.0 -## explicit; go 1.21 +# github.com/go-pkgz/notify v1.3.0 +## explicit; go 1.24.0 github.com/go-pkgz/notify # github.com/go-pkgz/repeater v1.2.0 ## explicit; go 1.20 github.com/go-pkgz/repeater github.com/go-pkgz/repeater/strategy +# github.com/go-pkgz/repeater/v2 v2.2.0 +## explicit; go 1.23 +github.com/go-pkgz/repeater/v2 # github.com/go-pkgz/rest v1.20.4 ## explicit; go 1.23.0 github.com/go-pkgz/rest @@ -135,8 +135,8 @@ github.com/hashicorp/golang-lru/v2/simplelru # github.com/jessevdk/go-flags v1.6.1 ## explicit; go 1.20 github.com/jessevdk/go-flags -# github.com/klauspost/compress v1.18.0 -## explicit; go 1.22 +# github.com/klauspost/compress v1.18.2 +## explicit; go 1.23 github.com/klauspost/compress github.com/klauspost/compress/fse github.com/klauspost/compress/huff0 @@ -158,16 +158,22 @@ github.com/montanaflynn/stats # github.com/pmezard/go-difflib v1.0.0 ## explicit github.com/pmezard/go-difflib/difflib -# github.com/redis/go-redis/v9 v9.7.3 +# github.com/redis/go-redis/v9 v9.17.2 ## explicit; go 1.18 github.com/redis/go-redis/v9 +github.com/redis/go-redis/v9/auth github.com/redis/go-redis/v9/internal +github.com/redis/go-redis/v9/internal/auth/streaming github.com/redis/go-redis/v9/internal/hashtag github.com/redis/go-redis/v9/internal/hscan +github.com/redis/go-redis/v9/internal/interfaces +github.com/redis/go-redis/v9/internal/maintnotifications/logs github.com/redis/go-redis/v9/internal/pool github.com/redis/go-redis/v9/internal/proto github.com/redis/go-redis/v9/internal/rand github.com/redis/go-redis/v9/internal/util +github.com/redis/go-redis/v9/maintnotifications +github.com/redis/go-redis/v9/push # github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d ## explicit; go 1.21 github.com/rrivera/identicon @@ -182,8 +188,8 @@ github.com/russross/blackfriday/v2 github.com/skip2/go-qrcode github.com/skip2/go-qrcode/bitset github.com/skip2/go-qrcode/reedsolomon -# github.com/slack-go/slack v0.15.0 -## explicit; go 1.16 +# github.com/slack-go/slack v0.17.3 +## explicit; go 1.22 github.com/slack-go/slack github.com/slack-go/slack/internal/backoff github.com/slack-go/slack/internal/errorsx @@ -197,8 +203,8 @@ github.com/stretchr/testify/require # github.com/xdg-go/pbkdf2 v1.0.0 ## explicit; go 1.9 github.com/xdg-go/pbkdf2 -# github.com/xdg-go/scram v1.1.2 -## explicit; go 1.11 +# github.com/xdg-go/scram v1.2.0 +## explicit; go 1.18 github.com/xdg-go/scram # github.com/xdg-go/stringprep v1.0.4 ## explicit; go 1.11 @@ -212,7 +218,7 @@ go.etcd.io/bbolt go.etcd.io/bbolt/errors go.etcd.io/bbolt/internal/common go.etcd.io/bbolt/internal/freelist -# go.mongodb.org/mongo-driver v1.17.3 +# go.mongodb.org/mongo-driver v1.17.6 ## explicit; go 1.18 go.mongodb.org/mongo-driver/bson go.mongodb.org/mongo-driver/bson/bsoncodec @@ -286,8 +292,8 @@ golang.org/x/image/math/f64 golang.org/x/net/html golang.org/x/net/html/atom golang.org/x/net/idna -# golang.org/x/oauth2 v0.29.0 -## explicit; go 1.23.0 +# golang.org/x/oauth2 v0.33.0 +## explicit; go 1.24.0 golang.org/x/oauth2 golang.org/x/oauth2/authhandler golang.org/x/oauth2/endpoints