Skip to content

Commit 4a14759

Browse files
committed
feat: singleflight to deduplicate GET requests
Resolves NEX-2296. Add singleflight to deduplicate concurrent GET/HEAD/OPTIONS/TRACE requests. Deduplicate concurrent requests to shared list endpoints using singleflight. Many resources, such as databases, do not have individual endpoints and instead share a single "list" endpoint (ServiceDatabaseList). This often results in multiple simultaneous requests for the same data, which can cause redundant API calls and unnecessary load. Currently, the client uses only GET for reading, but additional methods may be supported in the future.
1 parent 4f50c18 commit 4a14759

6 files changed

Lines changed: 81 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ go get github.com/aiven/go-client-codegen
2121
| `AIVEN_USER_AGENT` | `string` | User Agent |
2222
| `AIVEN_DEBUG` | `bool` | Debug Output Flag (stderr) |
2323

24+
See all configuration options in [`client.go`](client.go).
25+
2426
#### Via Constructor Options
2527

2628
```go

client.go

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"time"
1717

1818
"golang.org/x/exp/slices"
19+
"golang.org/x/sync/singleflight"
1920

2021
"github.com/hashicorp/go-multierror"
2122
"github.com/hashicorp/go-retryablehttp"
@@ -105,15 +106,17 @@ func NewClient(opts ...Option) (Client, error) {
105106
// Default values: 26 seconds
106107
// Changed values: 67 seconds
107108
type aivenClient struct {
108-
Host string `envconfig:"AIVEN_WEB_URL" default:"https://api.aiven.io"`
109-
UserAgent string `envconfig:"AIVEN_USER_AGENT" default:"aiven-go-client/v3"`
110-
Token string `envconfig:"AIVEN_TOKEN"`
111-
Debug bool `envconfig:"AIVEN_DEBUG"`
112-
RetryMax int `envconfig:"AIVEN_CLIENT_RETRY_MAX" default:"6"`
113-
RetryWaitMin time.Duration `envconfig:"AIVEN_CLIENT_RETRY_WAIT_MIN" default:"2s"`
114-
RetryWaitMax time.Duration `envconfig:"AIVEN_CLIENT_RETRY_WAIT_MAX" default:"15s"`
115-
logger zerolog.Logger
116-
doer Doer
109+
Host string `envconfig:"AIVEN_WEB_URL" default:"https://api.aiven.io"`
110+
UserAgent string `envconfig:"AIVEN_USER_AGENT" default:"aiven-go-client/v3"`
111+
Token string `envconfig:"AIVEN_TOKEN"`
112+
Debug bool `envconfig:"AIVEN_DEBUG"`
113+
RetryMax int `envconfig:"AIVEN_CLIENT_RETRY_MAX" default:"6"`
114+
RetryWaitMin time.Duration `envconfig:"AIVEN_CLIENT_RETRY_WAIT_MIN" default:"2s"`
115+
RetryWaitMax time.Duration `envconfig:"AIVEN_CLIENT_RETRY_WAIT_MAX" default:"15s"`
116+
EnableSingleFlight bool `envconfig:"AIVEN_CLIENT_ENABLE_SINGLE_FLIGHT" default:"true"`
117+
logger zerolog.Logger
118+
doer Doer
119+
singleflight singleflight.Group
117120
}
118121

119122
// OperationIDKey is the key used to store the operation ID in the context.
@@ -180,6 +183,26 @@ func (d *aivenClient) do(ctx context.Context, operationID, method, path string,
180183
req.Header.Set("Authorization", "aivenv1 "+d.Token)
181184
req.URL.RawQuery = fmtQuery(operationID, query...)
182185

186+
// Deduplicate concurrent requests to shared list endpoints using singleflight.
187+
// Many resources, such as databases,
188+
// do not have individual endpoints and instead share a single "list" endpoint (e.g., ServiceDatabaseList).
189+
// This often results in multiple simultaneous requests for the same data,
190+
// which can cause redundant API calls and unnecessary load.
191+
// Currently, the client uses only GET for reading,
192+
// but additional methods may be supported in the future.
193+
if d.EnableSingleFlight {
194+
switch method {
195+
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
196+
v, err, _ := d.singleflight.Do(req.URL.String(), func() (any, error) {
197+
return d.doer.Do(req)
198+
})
199+
if err != nil {
200+
return nil, err
201+
}
202+
return v.(*http.Response), err
203+
}
204+
}
205+
183206
return d.doer.Do(req)
184207
}
185208

client_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http/httptest"
99
"os"
1010
"strings"
11+
"sync"
1112
"sync/atomic"
1213
"testing"
1314
"time"
@@ -299,3 +300,39 @@ func TestServiceIntegrationEndpointGet(t *testing.T) {
299300
// All calls are received
300301
assert.EqualValues(t, 1, callCount)
301302
}
303+
304+
func TestSingleFlightDeduplication(t *testing.T) {
305+
var callCount int64
306+
307+
mux := http.NewServeMux()
308+
mux.HandleFunc(
309+
"/v1/project/test-project/service",
310+
func(w http.ResponseWriter, _ *http.Request) {
311+
w.Header().Set("Content-Type", "application/json")
312+
w.WriteHeader(http.StatusOK)
313+
_, err := w.Write([]byte(`{"services": []}`))
314+
assert.NoError(t, err)
315+
atomic.AddInt64(&callCount, 1)
316+
},
317+
)
318+
319+
server := httptest.NewServer(mux)
320+
defer server.Close()
321+
322+
c, err := NewClient(TokenOpt("token"), HostOpt(server.URL), UserAgentOpt("unit-test"))
323+
require.NotNil(t, c)
324+
require.NoError(t, err)
325+
326+
ctx := context.Background()
327+
328+
var wg sync.WaitGroup
329+
for range 100 {
330+
wg.Go(func() {
331+
_, _ = c.ServiceList(ctx, "test-project")
332+
})
333+
}
334+
wg.Wait()
335+
336+
// We expect less than 10 calls to the API because of the single flight deduplication.
337+
assert.Less(t, callCount, int64(10))
338+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,6 @@ require (
2323
github.com/mattn/go-isatty v0.0.20 // indirect
2424
github.com/pmezard/go-difflib v1.0.0 // indirect
2525
github.com/stretchr/objx v0.5.2 // indirect
26+
golang.org/x/sync v0.19.0 // indirect
2627
golang.org/x/sys v0.21.0 // indirect
2728
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
4141
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
4242
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
4343
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
44+
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
45+
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
4446
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
4547
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
4648
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

option.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,10 @@ func RetryWaitMaxOpt(retryWaitMax time.Duration) Option {
6262
d.RetryWaitMax = retryWaitMax
6363
}
6464
}
65+
66+
// EnableSingleFlightOpt enables singleflight for deduplicating concurrent identical requests
67+
func EnableSingleFlightOpt(enableSingleFlight bool) Option {
68+
return func(d *aivenClient) {
69+
d.EnableSingleFlight = enableSingleFlight
70+
}
71+
}

0 commit comments

Comments
 (0)