From 993206ea1eb6499368bf4ad673cdc71a050fd9c3 Mon Sep 17 00:00:00 2001 From: akrishna Date: Wed, 9 Mar 2022 02:33:13 -0500 Subject: [PATCH 1/4] add rendezvous hash scorer --- conjure-go-client/httpclient/client.go | 25 +++++- .../httpclient/internal/balanced_scorer.go | 4 +- .../internal/balanced_scorer_test.go | 4 +- .../httpclient/internal/random_scorer.go | 16 ++-- .../httpclient/internal/random_scorer_test.go | 5 +- .../internal/rendezvous_hash_scorer.go | 80 +++++++++++++++++++ 6 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 conjure-go-client/httpclient/internal/rendezvous_hash_scorer.go diff --git a/conjure-go-client/httpclient/client.go b/conjure-go-client/httpclient/client.go index 136b9752..eb9714a6 100644 --- a/conjure-go-client/httpclient/client.go +++ b/conjure-go-client/httpclient/client.go @@ -82,7 +82,11 @@ func (c *clientImpl) Delete(ctx context.Context, params ...RequestParam) (*http. } func (c *clientImpl) Do(ctx context.Context, params ...RequestParam) (*http.Response, error) { - uris := c.uriScorer.CurrentURIScoringMiddleware().GetURIsInOrderOfIncreasingScore() + headers, err := getHeadersFromRequestParams(params...) + if err != nil { + return nil, err + } + uris := c.uriScorer.CurrentURIScoringMiddleware().GetURIsInOrderOfIncreasingScore(headers) if len(uris) == 0 { return nil, werror.ErrorWithContextParams(ctx, "no base URIs are configured") } @@ -94,7 +98,6 @@ func (c *clientImpl) Do(ctx context.Context, params ...RequestParam) (*http.Resp } } - var err error var resp *http.Response retrier := internal.NewRequestRetrier(uris, c.backoffOptions.CurrentRetryParams().Start(ctx), attempts) @@ -114,6 +117,24 @@ func (c *clientImpl) Do(ctx context.Context, params ...RequestParam) (*http.Resp return resp, nil } +func getHeadersFromRequestParams(params ...RequestParam) (http.Header, error) { + b := &requestBuilder{ + headers: make(http.Header), + query: make(url.Values), + bodyMiddleware: &bodyMiddleware{}, + } + + for _, p := range params { + if p == nil { + continue + } + if err := p.apply(b); err != nil { + return nil, err + } + } + return b.headers, nil +} + func (c *clientImpl) doOnce( ctx context.Context, baseURI string, diff --git a/conjure-go-client/httpclient/internal/balanced_scorer.go b/conjure-go-client/httpclient/internal/balanced_scorer.go index cff197eb..c2d9953e 100644 --- a/conjure-go-client/httpclient/internal/balanced_scorer.go +++ b/conjure-go-client/httpclient/internal/balanced_scorer.go @@ -30,7 +30,7 @@ const ( ) type URIScoringMiddleware interface { - GetURIsInOrderOfIncreasingScore() []string + GetURIsInOrderOfIncreasingScore(header http.Header) []string RoundTrip(req *http.Request, next http.RoundTripper) (*http.Response, error) } @@ -60,7 +60,7 @@ func NewBalancedURIScoringMiddleware(uris []string, nanoClock func() int64) URIS return &balancedScorer{uriInfos} } -func (u *balancedScorer) GetURIsInOrderOfIncreasingScore() []string { +func (u *balancedScorer) GetURIsInOrderOfIncreasingScore(header http.Header) []string { uris := make([]string, 0, len(u.uriInfos)) scores := make(map[string]int32, len(u.uriInfos)) for uri, info := range u.uriInfos { diff --git a/conjure-go-client/httpclient/internal/balanced_scorer_test.go b/conjure-go-client/httpclient/internal/balanced_scorer_test.go index 665ece5a..e2f0063e 100644 --- a/conjure-go-client/httpclient/internal/balanced_scorer_test.go +++ b/conjure-go-client/httpclient/internal/balanced_scorer_test.go @@ -25,7 +25,7 @@ import ( func TestBalancedScorerRandomizesWithNoneInflight(t *testing.T) { uris := []string{"uri1", "uri2", "uri3", "uri4", "uri5"} scorer := NewBalancedURIScoringMiddleware(uris, func() int64 { return 0 }) - scoredUris := scorer.GetURIsInOrderOfIncreasingScore() + scoredUris := scorer.GetURIsInOrderOfIncreasingScore(http.Header{}) assert.ElementsMatch(t, scoredUris, uris) assert.NotEqual(t, scoredUris, uris) } @@ -53,6 +53,6 @@ func TestBalancedScoring(t *testing.T) { assert.NoError(t, err) } } - scoredUris := scorer.GetURIsInOrderOfIncreasingScore() + scoredUris := scorer.GetURIsInOrderOfIncreasingScore(http.Header{}) assert.Equal(t, []string{server200.URL, server429.URL, server503.URL}, scoredUris) } diff --git a/conjure-go-client/httpclient/internal/random_scorer.go b/conjure-go-client/httpclient/internal/random_scorer.go index 63ebc1d6..86502cd5 100644 --- a/conjure-go-client/httpclient/internal/random_scorer.go +++ b/conjure-go-client/httpclient/internal/random_scorer.go @@ -24,13 +24,17 @@ type randomScorer struct { nanoClock func() int64 } -func (n *randomScorer) GetURIsInOrderOfIncreasingScore() []string { - uris := make([]string, len(n.uris)) - copy(uris, n.uris) - rand.New(rand.NewSource(n.nanoClock())).Shuffle(len(uris), func(i, j int) { - uris[i], uris[j] = uris[j], uris[i] +func (n *randomScorer) GetURIsInOrderOfIncreasingScore(header http.Header) []string { + return getURIsInRandomOrder(n.uris, n.nanoClock()) +} + +func getURIsInRandomOrder(uris []string, seed int64) []string { + randomizedUris := make([]string, len(uris)) + copy(randomizedUris, uris) + rand.New(rand.NewSource(seed)).Shuffle(len(randomizedUris), func(i, j int) { + randomizedUris[i], randomizedUris[j] = randomizedUris[j], randomizedUris[i] }) - return uris + return randomizedUris } func (n *randomScorer) RoundTrip(req *http.Request, next http.RoundTripper) (*http.Response, error) { diff --git a/conjure-go-client/httpclient/internal/random_scorer_test.go b/conjure-go-client/httpclient/internal/random_scorer_test.go index 43c332e4..8f1f75fa 100644 --- a/conjure-go-client/httpclient/internal/random_scorer_test.go +++ b/conjure-go-client/httpclient/internal/random_scorer_test.go @@ -15,6 +15,7 @@ package internal import ( + "net/http" "testing" "time" @@ -24,8 +25,8 @@ import ( func TestRandomScorerGetURIsRandomizes(t *testing.T) { uris := []string{"uri1", "uri2", "uri3", "uri4", "uri5"} scorer := NewRandomURIScoringMiddleware(uris, func() int64 { return time.Now().UnixNano() }) - scoredUris1 := scorer.GetURIsInOrderOfIncreasingScore() - scoredUris2 := scorer.GetURIsInOrderOfIncreasingScore() + scoredUris1 := scorer.GetURIsInOrderOfIncreasingScore(http.Header{}) + scoredUris2 := scorer.GetURIsInOrderOfIncreasingScore(http.Header{}) assert.ElementsMatch(t, scoredUris1, scoredUris2) assert.NotEqual(t, scoredUris1, scoredUris2) } diff --git a/conjure-go-client/httpclient/internal/rendezvous_hash_scorer.go b/conjure-go-client/httpclient/internal/rendezvous_hash_scorer.go new file mode 100644 index 00000000..05ba87ac --- /dev/null +++ b/conjure-go-client/httpclient/internal/rendezvous_hash_scorer.go @@ -0,0 +1,80 @@ +// Copyright (c) 2021 Palantir Technologies. 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "hash/fnv" + "net/http" + "sort" +) + +type rendezvousHashScorer struct { + uris []string + hashHeaderKey string + nanoClock func() int64 +} + +func (r *rendezvousHashScorer) GetURIsInOrderOfIncreasingScore(header http.Header) []string { + hashHeaderValues, ok := header[r.hashHeaderKey] + if !ok || len(hashHeaderValues) == 0 { + return getURIsInRandomOrder(r.uris, r.nanoClock()) + } + fnv.New64a() + uris := make([]string, 0, len(r.uris)) + scores := make(map[string]uint32, len(r.uris)) + hash := fnv.New32() + for _, uri := range r.uris { + hash.Reset() + if _, err := hash.Write([]byte(uri)); err != nil { + return nil + } + for _, value := range hashHeaderValues { + if _, err := hash.Write([]byte(value)); err != nil { + return nil + } + } + uris = append(uris, uri) + scores[uri] = hash.Sum32() + } + sort.Slice(uris, func(i, j int) bool { + return scores[uris[i]] < scores[uris[j]] + }) + return uris +} + +func (r *rendezvousHashScorer) RoundTrip(req *http.Request, next http.RoundTripper) (*http.Response, error) { + return next.RoundTrip(req) +} + +// NewRendezvousHashScoringMiddleware returns a URI scorer that generates a deterministic ordering of the URIs +// based on the value of a header. The scorer hashes the header value along with the URI and sorts the URIs based on +// the value of the hash. +// +// The intent of this scoring strategy is to provide server-side read and write locality for clients - by providing the +// configured header based on a key from content in the request, clients can expect that requests for the same key are +// generally routed to the same URI. It is important clients do not rely on always reaching the same URIs for +// correctness as requests will be retried with other URIs in the case of failures. +// +// When the header is not present, the scorer randomizes the order of URIs by using a rand.Rand +// seeded by the nanoClock function. +// +// The middleware no-ops on each request. +func NewRendezvousHashScoringMiddleware(uris []string, hashHeader string, nanoClock func() int64) URIScoringMiddleware { + return &rendezvousHashScorer{ + uris: uris, + hashHeaderKey: hashHeader, + nanoClock: nanoClock, + } +} From c29b003a4939b9036864cce167e90953c9fc6ed1 Mon Sep 17 00:00:00 2001 From: akrishna Date: Wed, 9 Mar 2022 08:37:36 -0500 Subject: [PATCH 2/4] add param and tests --- conjure-go-client/httpclient/client_params.go | 12 +++++ .../internal/rendezvous_hash_scorer.go | 4 +- .../internal/rendezvous_hash_scorer_test.go | 53 +++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 conjure-go-client/httpclient/internal/rendezvous_hash_scorer_test.go diff --git a/conjure-go-client/httpclient/client_params.go b/conjure-go-client/httpclient/client_params.go index e0cef56f..e196ce0e 100644 --- a/conjure-go-client/httpclient/client_params.go +++ b/conjure-go-client/httpclient/client_params.go @@ -546,6 +546,18 @@ func WithBalancedURIScoring() ClientParam { }) } +// WithRendezvousHashURIScoring adds middleware that deterministically routes to URIs based on a header. +func WithRendezvousHashURIScoring(hashHeader string) ClientParam { + return clientParamFunc(func(b *clientBuilder) error { + b.URIScorerBuilder = func(uris []string) internal.URIScoringMiddleware { + return internal.NewRendezvousHashURIScoringMiddleware(uris, hashHeader, func() int64 { + return time.Now().UnixNano() + }) + } + return nil + }) +} + func setBasicAuth(h http.Header, username, password string) { basicAuthBytes := []byte(username + ":" + password) h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString(basicAuthBytes)) diff --git a/conjure-go-client/httpclient/internal/rendezvous_hash_scorer.go b/conjure-go-client/httpclient/internal/rendezvous_hash_scorer.go index 05ba87ac..98a35634 100644 --- a/conjure-go-client/httpclient/internal/rendezvous_hash_scorer.go +++ b/conjure-go-client/httpclient/internal/rendezvous_hash_scorer.go @@ -58,7 +58,7 @@ func (r *rendezvousHashScorer) RoundTrip(req *http.Request, next http.RoundTripp return next.RoundTrip(req) } -// NewRendezvousHashScoringMiddleware returns a URI scorer that generates a deterministic ordering of the URIs +// NewRendezvousHashURIScoringMiddleware returns a URI scorer that generates a deterministic ordering of the URIs // based on the value of a header. The scorer hashes the header value along with the URI and sorts the URIs based on // the value of the hash. // @@ -71,7 +71,7 @@ func (r *rendezvousHashScorer) RoundTrip(req *http.Request, next http.RoundTripp // seeded by the nanoClock function. // // The middleware no-ops on each request. -func NewRendezvousHashScoringMiddleware(uris []string, hashHeader string, nanoClock func() int64) URIScoringMiddleware { +func NewRendezvousHashURIScoringMiddleware(uris []string, hashHeader string, nanoClock func() int64) URIScoringMiddleware { return &rendezvousHashScorer{ uris: uris, hashHeaderKey: hashHeader, diff --git a/conjure-go-client/httpclient/internal/rendezvous_hash_scorer_test.go b/conjure-go-client/httpclient/internal/rendezvous_hash_scorer_test.go new file mode 100644 index 00000000..cfe115b3 --- /dev/null +++ b/conjure-go-client/httpclient/internal/rendezvous_hash_scorer_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2021 Palantir Technologies. 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + hashHeader = "X-Route-Hash" +) + +func TestRendezvousHashScorerRandomizesWithoutHeader(t *testing.T) { + uris := []string{"uri1", "uri2", "uri3", "uri4", "uri5"} + scorer := NewRendezvousHashURIScoringMiddleware(uris, hashHeader, func() int64 { return time.Now().UnixNano() }) + scoredUris1 := scorer.GetURIsInOrderOfIncreasingScore(http.Header{}) + scoredUris2 := scorer.GetURIsInOrderOfIncreasingScore(http.Header{}) + assert.ElementsMatch(t, scoredUris1, scoredUris2) + assert.NotEqual(t, scoredUris1, scoredUris2) +} + +func TestRendezvousHashScorerSortsUrisDeterministically(t *testing.T) { + uris := []string{"uri1", "uri2", "uri3", "uri4", "uri5"} + scorer := NewRendezvousHashURIScoringMiddleware(uris, hashHeader, func() int64 { return time.Now().UnixNano() }) + scoredUris1 := scorer.GetURIsInOrderOfIncreasingScore(http.Header{hashHeader: []string{"foo"}}) + scoredUris2 := scorer.GetURIsInOrderOfIncreasingScore(http.Header{hashHeader: []string{"foo"}}) + assert.Equal(t, scoredUris1, scoredUris2) +} + +func TestRendezvousHashScorerMultipleHashHeaders(t *testing.T) { + uris := []string{"uri1", "uri2", "uri3", "uri4", "uri5"} + scorer := NewRendezvousHashURIScoringMiddleware(uris, hashHeader, func() int64 { return time.Now().UnixNano() }) + scoredUris1 := scorer.GetURIsInOrderOfIncreasingScore(http.Header{hashHeader: []string{"hash1", "hash2"}}) + scoredUris2 := scorer.GetURIsInOrderOfIncreasingScore(http.Header{hashHeader: []string{"hash1", "hash2", "hash3"}}) + assert.ElementsMatch(t, scoredUris1, scoredUris2) + assert.NotEqual(t, scoredUris1, scoredUris2) +} From 603835d72382ae78ccd6b9c8e8f6162955040286 Mon Sep 17 00:00:00 2001 From: svc-changelog Date: Wed, 9 Mar 2022 13:38:19 +0000 Subject: [PATCH 3/4] Add generated changelog entries --- changelog/@unreleased/pr-268.v2.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog/@unreleased/pr-268.v2.yml diff --git a/changelog/@unreleased/pr-268.v2.yml b/changelog/@unreleased/pr-268.v2.yml new file mode 100644 index 00000000..30ba76a0 --- /dev/null +++ b/changelog/@unreleased/pr-268.v2.yml @@ -0,0 +1,6 @@ +type: improvement +improvement: + description: Add rendezvous hash URI scorer to deterministically balance requests + across URIs + links: + - https://github.com/palantir/conjure-go-runtime/pull/268 From a81d79b698a88544a8e5f06a3cf6d55b29117212 Mon Sep 17 00:00:00 2001 From: Devin Trejo Date: Wed, 8 Feb 2023 15:59:23 -0500 Subject: [PATCH 4/4] Bump circle for flaky test. --- changelog/@unreleased/pr-268.v2.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/@unreleased/pr-268.v2.yml b/changelog/@unreleased/pr-268.v2.yml index 30ba76a0..61e2432c 100644 --- a/changelog/@unreleased/pr-268.v2.yml +++ b/changelog/@unreleased/pr-268.v2.yml @@ -1,6 +1,6 @@ type: improvement improvement: description: Add rendezvous hash URI scorer to deterministically balance requests - across URIs + across URIs links: - https://github.com/palantir/conjure-go-runtime/pull/268