@@ -32,6 +32,7 @@ type FanOutHandler struct {
3232 comparator comparator.ResponsesComparator
3333 instrumentCompares bool
3434 enableRace bool
35+ raceTolerance time.Duration
3536}
3637
3738// FanOutHandlerConfig holds configuration for creating a FanOutHandler.
@@ -45,6 +46,7 @@ type FanOutHandlerConfig struct {
4546 Comparator comparator.ResponsesComparator
4647 InstrumentCompares bool
4748 EnableRace bool
49+ RaceTolerance time.Duration
4850}
4951
5052// NewFanOutHandler creates a new FanOutHandler.
@@ -59,6 +61,7 @@ func NewFanOutHandler(cfg FanOutHandlerConfig) *FanOutHandler {
5961 comparator : cfg .Comparator ,
6062 instrumentCompares : cfg .InstrumentCompares ,
6163 enableRace : cfg .EnableRace ,
64+ raceTolerance : cfg .RaceTolerance ,
6265 }
6366}
6467
@@ -108,69 +111,47 @@ func (h *FanOutHandler) Do(ctx context.Context, req queryrangebase.Request) (que
108111 if err != nil {
109112 return nil , fmt .Errorf ("failed to extract tenant IDs: %w" , err )
110113 }
111- shouldSample := false
112- if h .goldfishManager != nil {
113- for _ , tenant := range tenants {
114- if h .goldfishManager .ShouldSample (tenant ) {
115- shouldSample = true
116- level .Debug (h .logger ).Log (
117- "msg" , "Goldfish sampling decision" ,
118- "tenant" , tenant ,
119- "sampled" , shouldSample ,
120- "path" , httpReq .URL .Path )
121- break
122- }
123- }
124- }
125-
126- results := make (chan * backendResult , len (h .backends ))
127-
128- for i , backend := range h .backends {
129- go func (_ int , b * ProxyBackend ) {
130- result := h .executeBackendRequest (ctx , httpReq , body , b , req )
131-
132- // ensure a valid status code is set in case of error
133- if result .err != nil && result .backendResp .status == 0 {
134- result .backendResp .status = statusCodeFromError (result .err )
135- }
136-
137- results <- result
138-
139- // Record metrics
140- h .recordMetrics (result , httpReq .Method , issuer )
141- }(i , backend )
142- }
143-
114+ shouldSample := h .shouldSample (tenants , httpReq )
115+ results := h .makeBackendRequests (ctx , httpReq , body , req , issuer )
144116 collected := make ([]* backendResult , 0 , len (h .backends ))
145117
146118 for i := 0 ; i < len (h .backends ); i ++ {
147119 result := <- results
148120 collected = append (collected , result )
149121
150122 // Race mode: return first successful response from ANY backend
151- // TODO: move race logic to a separate function, and catch the condition in Do and fan out to two different functions
152- // rather than have it all nested in one.
153123 if h .enableRace {
154- // Check if this is the first successful result (race winner)
155124 if result .err == nil && result .backendResp .succeeded () {
156- // Record race win metric
157- h .metrics .raceWins .WithLabelValues (
158- result .backend .name ,
159- h .routeName ,
160- ).Inc ()
161-
162- // Spawn goroutine to collect remaining responses
125+ winner := result
163126 remaining := len (h .backends ) - i - 1
164- go func () {
165- h .collectRemainingAndCompare (remaining , httpReq , results , collected , shouldSample )
166- }()
167127
168- return result .response , nil
128+ // If the preferred (v1) backend wins, then apply the handicap to give v2 a
129+ // chance to "win" by finishing within the race tolerance of v1.
130+ if result .backend .preferred && h .raceTolerance > 0 {
131+ select {
132+ case r2 := <- results :
133+ collected = append (collected , r2 )
134+ if r2 .err == nil && r2 .backendResp .succeeded () {
135+ winner = r2
136+ remaining = len (h .backends ) - i - 2
137+ }
138+ case <- time .After (h .raceTolerance ):
139+ // tolerance expired, fall back to original winner
140+ }
141+ }
142+
143+ return h .finishRace (
144+ winner ,
145+ remaining ,
146+ httpReq ,
147+ results ,
148+ collected ,
149+ shouldSample )
169150 }
170151 } else {
171- // Non-race mode: existing logic (wait for preferred)
152+ // Non-race mode: legacy logic (wait for preferred)
172153 if result .backend .preferred {
173- // when the preferred backend fails (5xx or request error) we return any successful backend response
154+ // when the preferred backend fails return any successful response
174155 if ! result .backendResp .succeeded () {
175156 continue
176157 }
@@ -212,6 +193,20 @@ func (h *FanOutHandler) Do(ctx context.Context, req queryrangebase.Request) (que
212193 return nil , fmt .Errorf ("all backends failed" )
213194}
214195
196+ // finishRace records the race winner and spawns a goroutine to collect remaining results.
197+ func (h * FanOutHandler ) finishRace (winner * backendResult , remaining int , httpReq * http.Request , results <- chan * backendResult , collected []* backendResult , shouldSample bool ) (queryrangebase.Response , error ) {
198+ h .metrics .raceWins .WithLabelValues (
199+ winner .backend .name ,
200+ h .routeName ,
201+ ).Inc ()
202+
203+ go func () {
204+ h .collectRemainingAndCompare (remaining , httpReq , results , collected , shouldSample )
205+ }()
206+
207+ return winner .response , nil
208+ }
209+
215210// collectRemainingAndCompare collects remaining backend results, performs comparisons,
216211// and processes goldfish sampling. Should be called asynchronously to not block preferred response from returning.
217212func (h * FanOutHandler ) collectRemainingAndCompare (remaining int , httpReq * http.Request , results <- chan * backendResult , collected []* backendResult , shouldSample bool ) {
@@ -430,6 +425,55 @@ func (h *FanOutHandler) WithComparator(comparator comparator.ResponsesComparator
430425 return h
431426}
432427
428+ // shouldSample determines if a query should be sampled for goldfish comparison.
429+ func (h * FanOutHandler ) shouldSample (tenants []string , httpReq * http.Request ) bool {
430+ if h .goldfishManager == nil {
431+ return false
432+ }
433+
434+ for _ , tenant := range tenants {
435+ if h .goldfishManager .ShouldSample (tenant ) {
436+ level .Debug (h .logger ).Log (
437+ "msg" , "Goldfish sampling decision" ,
438+ "tenant" , tenant ,
439+ "sampled" , true ,
440+ "path" , httpReq .URL .Path )
441+ return true
442+ }
443+ }
444+
445+ return false
446+ }
447+
448+ // makeBackendRequests initiates backend requests and returns a channel for receiving results.
449+ func (h * FanOutHandler ) makeBackendRequests (
450+ ctx context.Context ,
451+ httpReq * http.Request ,
452+ body []byte ,
453+ req queryrangebase.Request ,
454+ issuer string ,
455+ ) chan * backendResult {
456+ results := make (chan * backendResult , len (h .backends ))
457+
458+ for i , backend := range h .backends {
459+ go func (_ int , b * ProxyBackend ) {
460+ result := h .executeBackendRequest (ctx , httpReq , body , b , req )
461+
462+ // ensure a valid status code is set in case of error
463+ if result .err != nil && result .backendResp .status == 0 {
464+ result .backendResp .status = statusCodeFromError (result .err )
465+ }
466+
467+ results <- result
468+
469+ // Record metrics
470+ h .recordMetrics (result , httpReq .Method , issuer )
471+ }(i , backend )
472+ }
473+
474+ return results
475+ }
476+
433477func extractOriginalHeaders (ctx context.Context ) http.Header {
434478 if headers , ok := ctx .Value (originalHTTPHeadersKey ).(http.Header ); ok {
435479 return headers
0 commit comments