Skip to content

Commit 0ee521d

Browse files
a18ehoffmaenb1tamaraSoha-Albaghdady
committed
Implement hash-based routing (#505)
This commit provides the basic implementation for hash-based routing. It does not consider the balance factor yet. Co-authored-by: Clemens Hoffmann <[email protected]> Co-authored-by: Tamara Boehm <[email protected]> Co-authored-by: Soha Alboghdady <[email protected]>
1 parent 6dd6f71 commit 0ee521d

File tree

9 files changed

+1061
-4
lines changed

9 files changed

+1061
-4
lines changed

docs/03-how-to-add-new-route-option.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ applications:
2222
- route: example2.com
2323
options:
2424
loadbalancing: least-connection
25+
- route: example3.com
26+
options:
27+
loadbalancing: hash
28+
hash_header: tenant-id
29+
hash_balance: 1.25
2530
```
2631

2732
**NOTE**: In the implementation, the `options` property of a route represents per-route features.

src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ func (rt *roundTripper) RoundTrip(originalRequest *http.Request) (*http.Response
127127
stickyEndpointID, mustBeSticky := handlers.GetStickySession(request, rt.config.StickySessionCookieNames, rt.config.StickySessionsForAuthNegotiate)
128128
numberOfEndpoints := reqInfo.RoutePool.NumEndpoints()
129129
iter := reqInfo.RoutePool.Endpoints(rt.logger, stickyEndpointID, mustBeSticky, rt.config.LoadBalanceAZPreference, rt.config.Zone)
130+
if reqInfo.RoutePool.LoadBalancingAlgorithm == config.LOAD_BALANCE_HB {
131+
if reqInfo.RoutePool.HashRoutingProperties == nil {
132+
rt.logger.Error("hash-routing-properties-nil", slog.String("host", reqInfo.RoutePool.Host()))
133+
134+
} else {
135+
headerName := reqInfo.RoutePool.HashRoutingProperties.Header
136+
headerValue := request.Header.Get(headerName)
137+
if headerValue != "" {
138+
iter.(*route.HashBased).HeaderValue = headerValue
139+
} else {
140+
iter = reqInfo.RoutePool.FallBackToDefaultLoadBalancing(rt.config.LoadBalance, rt.logger, stickyEndpointID, mustBeSticky, rt.config.LoadBalanceAZPreference, rt.config.Zone)
141+
}
142+
}
143+
}
130144

131145
// The selectEndpointErr needs to be tracked separately. If we get an error
132146
// while selecting an endpoint we might just have run out of routes. In

src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"math/rand"
89
"net"
910
"net/http"
1011
"net/http/httptest"
@@ -1700,6 +1701,167 @@ var _ = Describe("ProxyRoundTripper", func() {
17001701
})
17011702
})
17021703

1704+
Context("when load-balancing strategy is set to hash-based routing", func() {
1705+
JustBeforeEach(func() {
1706+
for i := 1; i <= 3; i++ {
1707+
endpoint = route.NewEndpoint(&route.EndpointOpts{
1708+
AppId: fmt.Sprintf("appID%d", i),
1709+
Host: fmt.Sprintf("%d.%d.%d.%d", i, i, i, i),
1710+
Port: 9090,
1711+
PrivateInstanceId: fmt.Sprintf("instanceID%d", i),
1712+
PrivateInstanceIndex: fmt.Sprintf("%d", i),
1713+
AvailabilityZone: AZ,
1714+
LoadBalancingAlgorithm: config.LOAD_BALANCE_HB,
1715+
HashHeaderName: "X-Hash",
1716+
})
1717+
1718+
_ = routePool.Put(endpoint)
1719+
Expect(routePool.HashLookupTable).ToNot(BeNil())
1720+
1721+
}
1722+
})
1723+
1724+
It("routes requests with same hash header value to the same endpoint", func() {
1725+
req.Header.Set("X-Hash", "value")
1726+
reqInfo, err := handlers.ContextRequestInfo(req)
1727+
Expect(err).ToNot(HaveOccurred())
1728+
reqInfo.RoutePool = routePool
1729+
1730+
var selectedEndpoints []*route.Endpoint
1731+
1732+
// Make multiple requests with the same hash value
1733+
for i := 0; i < 5; i++ {
1734+
_, err = proxyRoundTripper.RoundTrip(req)
1735+
Expect(err).NotTo(HaveOccurred())
1736+
selectedEndpoints = append(selectedEndpoints, reqInfo.RouteEndpoint)
1737+
}
1738+
1739+
// All requests should go to the same endpoint
1740+
firstEndpoint := selectedEndpoints[0]
1741+
for _, ep := range selectedEndpoints[1:] {
1742+
Expect(ep.PrivateInstanceId).To(Equal(firstEndpoint.PrivateInstanceId))
1743+
}
1744+
})
1745+
1746+
It("routes requests with different hash header values to potentially different endpoints", func() {
1747+
reqInfo, err := handlers.ContextRequestInfo(req)
1748+
Expect(err).ToNot(HaveOccurred())
1749+
reqInfo.RoutePool = routePool
1750+
1751+
endpointDistribution := make(map[string]int)
1752+
1753+
// Make requests with different hash values
1754+
for i := 0; i < 10; i++ {
1755+
req.Header.Set("X-Hash", fmt.Sprintf("value-%d", i))
1756+
_, err = proxyRoundTripper.RoundTrip(req)
1757+
Expect(err).NotTo(HaveOccurred())
1758+
endpointDistribution[reqInfo.RouteEndpoint.PrivateInstanceId]++
1759+
}
1760+
1761+
// Should distribute across multiple endpoints (not all to one)
1762+
Expect(len(endpointDistribution)).To(BeNumerically(">", 1))
1763+
})
1764+
1765+
It("falls back to default load balancing algorithm when hash header is missing", func() {
1766+
reqInfo, err := handlers.ContextRequestInfo(req)
1767+
Expect(err).ToNot(HaveOccurred())
1768+
1769+
reqInfo.RoutePool = routePool
1770+
1771+
_, err = proxyRoundTripper.RoundTrip(req)
1772+
Expect(err).NotTo(HaveOccurred())
1773+
1774+
infoLogs := logger.Lines(zap.InfoLevel)
1775+
count := 0
1776+
for i := 0; i < len(infoLogs); i++ {
1777+
if strings.Contains(infoLogs[i], "hash-based-routing-header-not-found") {
1778+
count++
1779+
}
1780+
}
1781+
Expect(count).To(Equal(1))
1782+
// Verify it still selects an endpoint
1783+
Expect(reqInfo.RouteEndpoint).ToNot(BeNil())
1784+
})
1785+
1786+
Context("when sticky session cookies (JSESSIONID and VCAP_ID) are on the request", func() {
1787+
var (
1788+
sessionCookie *http.Cookie
1789+
cookies []*http.Cookie
1790+
)
1791+
1792+
JustBeforeEach(func() {
1793+
sessionCookie = &http.Cookie{
1794+
Name: StickyCookieKey, //JSESSIONID
1795+
}
1796+
transport.RoundTripStub = func(req *http.Request) (*http.Response, error) {
1797+
resp := &http.Response{StatusCode: http.StatusTeapot, Header: make(map[string][]string)}
1798+
//Attach the same JSESSIONID on to the response if it exists on the request
1799+
1800+
if len(req.Cookies()) > 0 {
1801+
for _, cookie := range req.Cookies() {
1802+
if cookie.Name == StickyCookieKey {
1803+
resp.Header.Add(round_tripper.CookieHeader, cookie.String())
1804+
return resp, nil
1805+
}
1806+
}
1807+
}
1808+
1809+
sessionCookie.Value, _ = uuid.GenerateUUID()
1810+
resp.Header.Add(round_tripper.CookieHeader, sessionCookie.String())
1811+
return resp, nil
1812+
}
1813+
resp, err := proxyRoundTripper.RoundTrip(req)
1814+
Expect(err).ToNot(HaveOccurred())
1815+
1816+
cookies = resp.Cookies()
1817+
Expect(cookies).To(HaveLen(2))
1818+
1819+
})
1820+
1821+
Context("when there is a JSESSIONID and __VCAP_ID__ set on the request", func() {
1822+
It("will always route to the instance specified with the __VCAP_ID__ cookie", func() {
1823+
1824+
// Generate 20 random values for the hash header, so chance that all go to instanceID1
1825+
// by accident is 0.33^20
1826+
for i := 0; i < 20; i++ {
1827+
randomStr := make([]byte, 8)
1828+
for j := range randomStr {
1829+
randomStr[j] = byte('a' + rand.Intn(26))
1830+
}
1831+
1832+
req.Header.Set("X-Hash", string(randomStr))
1833+
reqInfo, err := handlers.ContextRequestInfo(req)
1834+
req.AddCookie(&http.Cookie{Name: round_tripper.VcapCookieId, Value: "instanceID1"})
1835+
req.AddCookie(&http.Cookie{Name: StickyCookieKey, Value: "abc"})
1836+
1837+
Expect(err).ToNot(HaveOccurred())
1838+
reqInfo.RoutePool = routePool
1839+
1840+
resp, err := proxyRoundTripper.RoundTrip(req)
1841+
Expect(err).ToNot(HaveOccurred())
1842+
1843+
new_cookies := resp.Cookies()
1844+
Expect(new_cookies).To(HaveLen(2))
1845+
1846+
for _, cookie := range new_cookies {
1847+
Expect(cookie.Name).To(SatisfyAny(
1848+
Equal(StickyCookieKey),
1849+
Equal(round_tripper.VcapCookieId),
1850+
))
1851+
if cookie.Name == StickyCookieKey {
1852+
Expect(cookie.Value).To(Equal("abc"))
1853+
} else {
1854+
Expect(cookie.Value).To(Equal("instanceID1"))
1855+
}
1856+
}
1857+
1858+
}
1859+
1860+
})
1861+
})
1862+
})
1863+
})
1864+
17031865
Context("when endpoint timeout is not 0", func() {
17041866
var reqCh chan *http.Request
17051867
BeforeEach(func() {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package route
2+
3+
import (
4+
"context"
5+
"errors"
6+
"log/slog"
7+
"sync"
8+
9+
log "code.cloudfoundry.org/gorouter/logger"
10+
)
11+
12+
// HashBased load balancing algorithm distributes requests based on a hash of a specific header value.
13+
// The sticky session cookie has precedence over hash-based routing and the request should be routed to the instance stored in the cookie.
14+
// If requests do not contain the hash-related header set configured for the hash-based route option, use the default load-balancing algorithm.
15+
type HashBased struct {
16+
lock *sync.Mutex
17+
18+
logger *slog.Logger
19+
pool *EndpointPool
20+
lastEndpoint *Endpoint
21+
22+
stickyEndpointID string
23+
mustBeSticky bool
24+
25+
HeaderValue string
26+
}
27+
28+
// NewHashBased initializes an endpoint iterator that selects endpoints based on a hash of a header value.
29+
// The global properties locallyOptimistic and localAvailabilityZone will be ignored when using Hash-Based Routing.
30+
func NewHashBased(logger *slog.Logger, p *EndpointPool, initial string, mustBeSticky bool, locallyOptimistic bool, localAvailabilityZone string) EndpointIterator {
31+
return &HashBased{
32+
logger: logger,
33+
pool: p,
34+
lock: &sync.Mutex{},
35+
stickyEndpointID: initial,
36+
mustBeSticky: mustBeSticky,
37+
}
38+
}
39+
40+
// Next selects the next endpoint based on the hash of the header value.
41+
// If a sticky session endpoint is available and not overloaded, it will be returned.
42+
// If the request must be sticky and the sticky endpoint is unavailable or overloaded, nil will be returned.
43+
// If no sticky session is present, the endpoint will be selected based on the hash of the header value.
44+
// It returns the same endpoint for the same header value consistently.
45+
// If the hash lookup fails or the endpoint is not found, nil will be returned.
46+
func (h *HashBased) Next(attempt int) *Endpoint {
47+
h.lock.Lock()
48+
defer h.lock.Unlock()
49+
50+
e := h.findEndpointIfStickySession()
51+
if e == nil && h.mustBeSticky {
52+
return nil
53+
}
54+
55+
if e != nil {
56+
h.lastEndpoint = e
57+
return e
58+
}
59+
60+
if h.pool.HashLookupTable == nil {
61+
h.logger.Error("hash-based-routing-failed", slog.String("host", h.pool.host), log.ErrAttr(errors.New("Lookup table is empty")))
62+
return nil
63+
}
64+
65+
id, err := h.pool.HashLookupTable.Get(h.HeaderValue)
66+
67+
if err != nil {
68+
h.logger.Error(
69+
"hash-based-routing-failed",
70+
slog.String("host", h.pool.host),
71+
log.ErrAttr(err),
72+
)
73+
return nil
74+
}
75+
76+
h.logger.Debug(
77+
"hash-based-routing",
78+
slog.String("hash header value", h.HeaderValue),
79+
slog.String("endpoint-id", id),
80+
)
81+
82+
endpointElem := h.pool.findById(id)
83+
if endpointElem == nil {
84+
h.logger.Error("hash-based-routing-failed", slog.String("host", h.pool.host), log.ErrAttr(errors.New("Endpoint not found in pool")), slog.String("endpoint-id", id))
85+
return nil
86+
}
87+
88+
return endpointElem.endpoint
89+
}
90+
91+
// findEndpointIfStickySession checks if there is a sticky session endpoint and returns it if available.
92+
// If the sticky session endpoint is overloaded, returns nil.
93+
func (h *HashBased) findEndpointIfStickySession() *Endpoint {
94+
var e *endpointElem
95+
if h.stickyEndpointID != "" {
96+
e = h.pool.findById(h.stickyEndpointID)
97+
if e != nil && e.isOverloaded() {
98+
if h.mustBeSticky {
99+
if h.logger.Enabled(context.Background(), slog.LevelDebug) {
100+
h.logger.Debug("endpoint-overloaded-but-request-must-be-sticky", e.endpoint.ToLogData()...)
101+
}
102+
return nil
103+
}
104+
e = nil
105+
}
106+
107+
if e == nil && h.mustBeSticky {
108+
h.logger.Debug("endpoint-missing-but-request-must-be-sticky", slog.String("requested-endpoint", h.stickyEndpointID))
109+
return nil
110+
}
111+
112+
if !h.mustBeSticky {
113+
h.logger.Debug("endpoint-missing-choosing-alternate", slog.String("requested-endpoint", h.stickyEndpointID))
114+
h.stickyEndpointID = ""
115+
}
116+
}
117+
118+
if e != nil {
119+
e.RLock()
120+
defer e.RUnlock()
121+
return e.endpoint
122+
}
123+
return nil
124+
}
125+
126+
// EndpointFailed notifies the endpoint pool that the last selected endpoint has failed.
127+
func (h *HashBased) EndpointFailed(err error) {
128+
if h.lastEndpoint != nil {
129+
h.pool.EndpointFailed(h.lastEndpoint, err)
130+
}
131+
}
132+
133+
// PreRequest increments the in-flight request count for the selected endpoint from current Gorouter.
134+
func (h *HashBased) PreRequest(e *Endpoint) {
135+
e.Stats.NumberConnections.Increment()
136+
}
137+
138+
// PostRequest decrements the in-flight request count for the selected endpoint from current Gorouter.
139+
func (h *HashBased) PostRequest(e *Endpoint) {
140+
e.Stats.NumberConnections.Decrement()
141+
}

0 commit comments

Comments
 (0)