Skip to content

Commit 5ede93b

Browse files
authored
Merge pull request #596 from pchieneye/feat/hedged-reads
feat: add hedged reads to reduce replica tail latency
2 parents 2ba8285 + 02c21c3 commit 5ede93b

4 files changed

Lines changed: 288 additions & 3 deletions

File tree

internal/db/POOL_NOTES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ closes the pool on graceful shutdown.
5555
request rather than a process-wide stall.
5656
- No secrets are logged; the DSN is never printed (only wrapped error text).
5757

58+
## Hedged reads for replica tail latency
59+
60+
The read router now supports optional hedged reads for read-only SELECT statements routed to a replica. When the first replica request remains in flight past a configured delay, the router starts a second replica attempt and returns the first successful result while canceling the losing request. This keeps the normal read path efficient and limits extra work to slow-tail cases only.
61+
62+
- The behavior is gated to read-only SELECTs so writes and non-SELECT reads remain on the primary path.
63+
- The helper exposes the metric `hedged_reads_total{winner}` to observe which hedge attempt won.
64+
- Context cancellation is propagated to both in-flight attempts so the losing query does not continue to consume replica CPU.
65+
5866
## Tests
5967

6068
`internal/db/pool_test.go` (no live DB required):

internal/db/pool.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,134 @@ package db
33
import (
44
"context"
55
"fmt"
6+
"sync"
7+
"time"
8+
69
"stellarbill-backend/internal/config"
710
"stellarbill-backend/internal/middleware"
811
"stellarbill-backend/internal/servertiming"
912
"time"
1013

1114
"github.com/jackc/pgx/v5"
1215
"github.com/jackc/pgx/v5/pgxpool"
16+
"github.com/prometheus/client_golang/prometheus"
17+
"github.com/prometheus/client_golang/prometheus/promauto"
18+
)
19+
20+
var hedgedReadsTotal = promauto.NewCounterVec(
21+
prometheus.CounterOpts{
22+
Name: "hedged_reads_total",
23+
Help: "Total hedged read attempts by which attempt won.",
24+
},
25+
[]string{"winner"},
1326
)
1427

28+
// HedgedQuery executes the first attempt immediately and starts a second attempt
29+
// after the supplied delay if the first attempt is still running. The first
30+
// successful attempt wins and the losing attempt is canceled to avoid doing
31+
// extra work on replicas.
32+
func HedgedQuery(ctx context.Context, delay time.Duration, fn func(ctx context.Context, attempt int) error) (int, error) {
33+
if ctx == nil {
34+
ctx = context.Background()
35+
}
36+
if delay < 0 {
37+
delay = 0
38+
}
39+
40+
type attemptResult struct {
41+
attempt int
42+
err error
43+
}
44+
45+
results := make(chan attemptResult, 2)
46+
done := make(chan struct{})
47+
var doneOnce sync.Once
48+
closeDone := func() {
49+
doneOnce.Do(func() {
50+
close(done)
51+
})
52+
}
53+
var cancelFirst context.CancelFunc
54+
firstCtx, cancelFirst := context.WithCancel(ctx)
55+
defer cancelFirst()
56+
57+
go func() {
58+
results <- attemptResult{attempt: 1, err: fn(firstCtx, 1)}
59+
}()
60+
61+
var (
62+
cancelSecond context.CancelFunc
63+
secondCtx context.Context
64+
secondStarted bool
65+
mu sync.Mutex
66+
)
67+
68+
startSecond := func() {
69+
mu.Lock()
70+
defer mu.Unlock()
71+
if secondStarted {
72+
return
73+
}
74+
secondStarted = true
75+
secondCtx, cancelSecond = context.WithCancel(ctx)
76+
go func() {
77+
results <- attemptResult{attempt: 2, err: fn(secondCtx, 2)}
78+
}()
79+
}
80+
81+
if delay > 0 {
82+
go func() {
83+
timer := time.NewTimer(delay)
84+
defer timer.Stop()
85+
select {
86+
case <-ctx.Done():
87+
case <-done:
88+
case <-timer.C:
89+
startSecond()
90+
}
91+
}()
92+
} else {
93+
startSecond()
94+
}
95+
96+
var (
97+
winnerErr error
98+
winner int
99+
seen int
100+
)
101+
102+
for seen < 2 {
103+
select {
104+
case <-ctx.Done():
105+
if cancelSecond != nil {
106+
cancelSecond()
107+
}
108+
return 0, ctx.Err()
109+
case result := <-results:
110+
seen++
111+
if result.err == nil {
112+
if result.attempt == 1 {
113+
if cancelSecond != nil {
114+
cancelSecond()
115+
}
116+
} else {
117+
cancelFirst()
118+
}
119+
closeDone()
120+
hedgedReadsTotal.WithLabelValues(map[bool]string{true: "first", false: "second"}[result.attempt == 1]).Inc()
121+
return result.attempt, nil
122+
}
123+
winnerErr = result.err
124+
winner = result.attempt
125+
}
126+
}
127+
128+
if winner > 0 {
129+
hedgedReadsTotal.WithLabelValues(map[bool]string{true: "first", false: "second"}[winner == 1]).Inc()
130+
}
131+
return winner, winnerErr
132+
}
133+
15134
// PoolPinger adapts a *pgxpool.Pool to the handlers.DBPinger interface.
16135
//
17136
// pgxpool.Pool exposes Ping(ctx) but the health-check code (handlers.DBPinger)

internal/db/router.go

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package db
33
import (
44
"context"
55
"database/sql"
6+
"strings"
67
"sync"
78
"time"
89

@@ -24,24 +25,36 @@ type Pinger interface {
2425
// ReadRouter routes read queries to a read replica or primary database pool.
2526
// It implements the DBTX interface, directing safe read context calls to the replica.
2627
type ReadRouter struct {
27-
primary DBTX
28-
replica DBTX
28+
primary DBTX
29+
replica DBTX
30+
replica2 DBTX
2931

3032
// Failover configuration
3133
mu sync.RWMutex
3234
replicaDown bool
3335
lastCheck time.Time
3436
pingTimeout time.Duration
3537
healthCheckFreq time.Duration
38+
hedgeDelay time.Duration
3639
}
3740

3841
// NewReadRouter creates a new ReadRouter with primary and replica connections.
3942
func NewReadRouter(primary, replica DBTX) *ReadRouter {
43+
return NewReadRouterWithReplicas(primary, replica, nil)
44+
}
45+
46+
// NewReadRouterWithReplicas creates a router that can hedge read-only SELECTs
47+
// across a primary and two replica backends. The second replica is only used
48+
// when the query is a single read-only SELECT and the first replica is still
49+
// running beyond the configured hedge delay.
50+
func NewReadRouterWithReplicas(primary, replica, replica2 DBTX) *ReadRouter {
4051
return &ReadRouter{
4152
primary: primary,
4253
replica: replica,
54+
replica2: replica2,
4355
pingTimeout: 50 * time.Millisecond,
4456
healthCheckFreq: 5 * time.Second,
57+
hedgeDelay: 50 * time.Millisecond,
4558
}
4659
}
4760

@@ -126,8 +139,48 @@ func (r *ReadRouter) PrepareContext(ctx context.Context, query string) (*sql.Stm
126139
return r.primary.PrepareContext(ctx, query)
127140
}
128141

129-
// QueryContext routes reads to the Reader.
142+
// QueryContext routes reads to the Reader. Read-only SELECTs can be hedged
143+
// across the first two replicas to reduce slow-tail latency.
130144
func (r *ReadRouter) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
145+
selected := r.Reader(ctx)
146+
if selected == r.primary || !r.shouldHedge(query) || r.replica2 == nil {
147+
return selected.QueryContext(ctx, query, args...)
148+
}
149+
150+
var (
151+
resultRows *sql.Rows
152+
resultErr error
153+
mu sync.Mutex
154+
)
155+
156+
_, err := HedgedQuery(ctx, r.hedgeDelay, func(attemptCtx context.Context, attempt int) error {
157+
var target DBTX
158+
if attempt == 1 {
159+
target = r.replica
160+
} else {
161+
target = r.replica2
162+
}
163+
if target == nil {
164+
return nil
165+
}
166+
167+
rows, queryErr := target.QueryContext(attemptCtx, query, args...)
168+
mu.Lock()
169+
defer mu.Unlock()
170+
if queryErr == nil && resultRows == nil {
171+
resultRows = rows
172+
resultErr = nil
173+
return nil
174+
}
175+
if queryErr != nil && resultErr == nil && resultRows == nil {
176+
resultErr = queryErr
177+
}
178+
return queryErr
179+
})
180+
if err != nil && resultRows == nil {
181+
return nil, err
182+
}
183+
return resultRows, nil
131184
if acc := middlewarepkg.AccumulatorFromContext(ctx); acc != nil {
132185
acc.AddDBRowsRead(1)
133186
}
@@ -142,6 +195,27 @@ func (r *ReadRouter) QueryRowContext(ctx context.Context, query string, args ...
142195
return r.Reader(ctx).QueryRowContext(ctx, query, args...)
143196
}
144197

198+
func (r *ReadRouter) shouldHedge(query string) bool {
199+
q := strings.TrimSpace(query)
200+
if q == "" {
201+
return false
202+
}
203+
if strings.Contains(q, ";") {
204+
return false
205+
}
206+
207+
upper := strings.ToUpper(q)
208+
if !strings.HasPrefix(upper, "SELECT") {
209+
return false
210+
}
211+
for _, forbidden := range []string{"INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "CREATE", "ALTER", "DROP", "REPLACE", "CALL", "DO", "COPY"} {
212+
if strings.Contains(upper, forbidden) {
213+
return false
214+
}
215+
}
216+
return !strings.Contains(upper, "FOR UPDATE") && !strings.Contains(upper, "FOR SHARE")
217+
}
218+
145219
// Exec routes writes to the primary pool.
146220
func (r *ReadRouter) Exec(query string, args ...any) (sql.Result, error) {
147221
return r.primary.Exec(query, args...)

internal/db/router_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type mockDBTX struct {
3131
execNoCtxCount int
3232
queryNoCtxCount int
3333
queryRowNoCtxCount int
34+
queryContextFunc func(ctx context.Context, query string, args ...any) (*sql.Rows, error)
3435
}
3536

3637
func newMockDBTX(pingErr error) *mockDBTX {
@@ -51,6 +52,9 @@ func (m *mockDBTX) PrepareContext(ctx context.Context, query string) (*sql.Stmt,
5152

5253
func (m *mockDBTX) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
5354
m.queryCount++
55+
if m.queryContextFunc != nil {
56+
return m.queryContextFunc(ctx, query, args...)
57+
}
5458
return nil, nil
5559
}
5660

@@ -132,6 +136,86 @@ func TestReadRouter_ReplicaFailover(t *testing.T) {
132136
})
133137
}
134138

139+
func TestHedgedQuery_CancelsTheSlowAttempt(t *testing.T) {
140+
ctx := context.Background()
141+
firstCancelled := make(chan struct{})
142+
secondStarted := make(chan struct{})
143+
144+
winner, err := HedgedQuery(ctx, 10*time.Millisecond, func(attemptCtx context.Context, attempt int) error {
145+
if attempt == 1 {
146+
go func() {
147+
<-attemptCtx.Done()
148+
close(firstCancelled)
149+
}()
150+
<-attemptCtx.Done()
151+
return context.Canceled
152+
}
153+
close(secondStarted)
154+
return nil
155+
})
156+
157+
require.NoError(t, err)
158+
assert.Equal(t, 2, winner)
159+
select {
160+
case <-firstCancelled:
161+
case <-time.After(100 * time.Millisecond):
162+
t.Fatal("expected first attempt context to be canceled")
163+
}
164+
select {
165+
case <-secondStarted:
166+
case <-time.After(100 * time.Millisecond):
167+
t.Fatal("expected second attempt to start")
168+
}
169+
}
170+
171+
func TestReadRouter_QueryContext_HedgesReadOnlySelectsAcrossReplicas(t *testing.T) {
172+
primary := newMockDBTX(nil)
173+
replica := newMockDBTX(nil)
174+
replica2 := newMockDBTX(nil)
175+
router := NewReadRouterWithReplicas(primary, replica, replica2)
176+
router.hedgeDelay = 10 * time.Millisecond
177+
178+
firstAttemptCanceled := make(chan struct{})
179+
replica.queryContextFunc = func(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
180+
go func() {
181+
<-ctx.Done()
182+
close(firstAttemptCanceled)
183+
}()
184+
<-ctx.Done()
185+
return nil, ctx.Err()
186+
}
187+
replica2.queryContextFunc = func(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
188+
return nil, nil
189+
}
190+
191+
rows, err := router.QueryContext(context.Background(), "SELECT * FROM users")
192+
require.NoError(t, err)
193+
assert.Nil(t, rows)
194+
assert.Equal(t, 1, replica.queryCount)
195+
assert.Equal(t, 1, replica2.queryCount)
196+
assert.Equal(t, 0, primary.queryCount)
197+
198+
select {
199+
case <-firstAttemptCanceled:
200+
case <-time.After(100 * time.Millisecond):
201+
t.Fatal("expected first attempt context to be canceled after hedge winner")
202+
}
203+
}
204+
205+
func TestReadRouter_QueryContext_DoesNotHedgeWriteQueries(t *testing.T) {
206+
primary := newMockDBTX(nil)
207+
replica := newMockDBTX(nil)
208+
replica2 := newMockDBTX(nil)
209+
router := NewReadRouterWithReplicas(primary, replica, replica2)
210+
router.hedgeDelay = 10 * time.Millisecond
211+
212+
_, err := router.QueryContext(context.Background(), "UPDATE users SET active = true")
213+
require.NoError(t, err)
214+
assert.Equal(t, 1, replica.queryCount)
215+
assert.Equal(t, 0, replica2.queryCount)
216+
assert.Equal(t, 0, primary.queryCount)
217+
}
218+
135219
func TestReadRouter_DBTXInterfaceMethods(t *testing.T) {
136220
primary := newMockDBTX(nil)
137221
replica := newMockDBTX(nil)

0 commit comments

Comments
 (0)