Skip to content

Commit abf8db6

Browse files
authored
feat(client): add Close() and close bulk indexer's implicit client (#932)
Backport #926 to v4 without the default-client cache. Add Close() to opensearch.Client and opensearchapi.Client so callers can release the transport's background goroutines and idle connections. NewBulkIndexer now closes the client it creates when the caller supplies none, on all Close paths including a cancelled context. Its flusher stops via context cancellation instead of a done channel, so Close no longer risks a deadlock when the flusher has already exited. The process-wide client cache and its metrics-aggregation behavior are left out, so this stays a non-breaking maintenance change. Refs: #893 Fixes: #928 Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
1 parent 2cc5d9c commit abf8db6

7 files changed

Lines changed: 201 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
66

77
### Added
88

9+
- Add `Close()` to `opensearch.Client` and `opensearchapi.Client` to release background goroutines (node discovery, health/stats pollers, DNS refresh) and idle connections; `opensearchutil.NewBulkIndexer` now closes the client it implicitly creates. Backport of #926 without the default-client cache ([#928](https://github.com/opensearch-project/opensearch-go/issues/928), [#893](https://github.com/opensearch-project/opensearch-go/issues/893))
910
- Add `cmd/osgen` code generator for typed path builders and API consumer files from the OpenAPI spec
1011
- v5preview/opensearchapi: `NewClient` and `NewDefaultClient` now inject `opensearchtransport.NewDefaultRouter` when `config.Client.Router` is nil, opting every v5preview client into intelligent request routing by default. The `OPENSEARCH_GO_ROUTER` env var preserves its v4 semantics end-to-end: `=true`/`=1` enables auto-discovery (via `DiscoverNodesOnStart`); `=false`/`=0` suppresses both Router injection and auto-discovery; unset injects the Router without auto-discovery. v4's `opensearchapi.NewClient` is unchanged. ([#816](https://github.com/opensearch-project/opensearch-go/issues/816))
1112
- Add `envvars.Falsy(name)` helper that distinguishes "explicitly opted out" from "unset" (Truthy collapses both into false). Used by v5preview's router injection rule.

opensearch.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,17 @@ func getAddressFromEnvironment() []string {
370370
return addrsFromEnvironment(envOpenSearchURL)
371371
}
372372

373+
// Close releases the client's background resources by closing the underlying
374+
// transport if it implements io.Closer -- the built-in *opensearchtransport.Client
375+
// does, canceling pollers and closing idle connections -- and is a no-op for a
376+
// custom Interface that does not. Safe on a zero value and idempotent.
377+
func (c *Client) Close() error {
378+
if closer, ok := c.Transport.(io.Closer); ok {
379+
return closer.Close()
380+
}
381+
return nil
382+
}
383+
373384
// ParseVersion returns an int64 representation of version.
374385
func ParseVersion(version string) (int64, int64, int64, error) {
375386
reVersion := regexp.MustCompile(`^([0-9]+)\.([0-9]+)\.([0-9]+)`)

opensearch_internal_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,3 +566,40 @@ func TestClientGetConfig(t *testing.T) {
566566
require.Equal(t, expectedConfig.EnableRetryOnTimeout, config.EnableRetryOnTimeout)
567567
})
568568
}
569+
570+
func TestClientClose(t *testing.T) {
571+
t.Run("falls back to transport io.Closer", func(t *testing.T) {
572+
tc := &stubTransportCloser{}
573+
c := &Client{Transport: tc}
574+
require.NoError(t, c.Close())
575+
require.Equal(t, 1, tc.closed)
576+
})
577+
578+
t.Run("no-op when transport lacks Close", func(t *testing.T) {
579+
c := &Client{Transport: stubPerformOnly{}}
580+
require.NoError(t, c.Close())
581+
})
582+
583+
t.Run("idempotent", func(t *testing.T) {
584+
tc := &stubTransportCloser{}
585+
c := &Client{Transport: tc}
586+
require.NoError(t, c.Close())
587+
require.NoError(t, c.Close())
588+
require.Equal(t, 2, tc.closed)
589+
})
590+
}
591+
592+
// stubTransportCloser implements opensearchtransport.Interface + io.Closer.
593+
type stubTransportCloser struct{ closed int }
594+
595+
//nolint:nilnil // stub: Perform is never called, only Close is exercised
596+
func (s *stubTransportCloser) Perform(*http.Request) (*http.Response, error) { return nil, nil }
597+
598+
//nolint:unparam // Close must return error to satisfy io.Closer; stub never fails
599+
func (s *stubTransportCloser) Close() error { s.closed++; return nil }
600+
601+
// stubPerformOnly implements only opensearchtransport.Interface.
602+
type stubPerformOnly struct{}
603+
604+
//nolint:nilnil // stub: Perform is never called, exists only to satisfy Interface
605+
func (stubPerformOnly) Perform(*http.Request) (*http.Response, error) { return nil, nil }

opensearchapi/opensearchapi.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,15 @@ func NewDefaultClient() (*Client, error) {
159159
return clientInit(rootClient, resolveErrorMask(Config{})), nil
160160
}
161161

162+
// Close releases the client's background resources by closing the underlying
163+
// opensearch.Client. Safe on a zero value and idempotent.
164+
func (c *Client) Close() error {
165+
if c.Client != nil {
166+
return c.Client.Close()
167+
}
168+
return nil
169+
}
170+
162171
// NewFromClient creates an opensearchapi client from an existing opensearch.Client.
163172
// In v4 this preserves the legacy "mask everything" default; use NewClient
164173
// with Config to enable partial-failure errors.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
//
3+
// The OpenSearch Contributors require contributions made to
4+
// this file be licensed under the Apache-2.0 license or a
5+
// compatible open source license.
6+
7+
package opensearchapi
8+
9+
import (
10+
"net/http"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/opensearch-project/opensearch-go/v4"
16+
)
17+
18+
func TestClientClose(t *testing.T) {
19+
t.Run("delegates to embedded opensearch.Client", func(t *testing.T) {
20+
tc := &stubTransportCloser{}
21+
c := &Client{Client: &opensearch.Client{Transport: tc}}
22+
require.NoError(t, c.Close())
23+
require.Equal(t, 1, tc.closed)
24+
})
25+
26+
t.Run("no-op when embedded client is nil", func(t *testing.T) {
27+
c := &Client{}
28+
require.NoError(t, c.Close())
29+
})
30+
}
31+
32+
// stubTransportCloser implements opensearchtransport.Interface + io.Closer.
33+
type stubTransportCloser struct{ closed int }
34+
35+
//nolint:nilnil // stub: Perform is never called, only Close is exercised
36+
func (s *stubTransportCloser) Perform(*http.Request) (*http.Response, error) { return nil, nil }
37+
38+
//nolint:unparam // Close must return error to satisfy io.Closer; stub never fails
39+
func (s *stubTransportCloser) Close() error { s.closed++; return nil }

opensearchutil/bulk_indexer.go

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,25 @@ type bulkIndexer struct {
169169
queue chan BulkIndexerItem
170170
workers []*worker
171171
ticker *time.Ticker
172-
done chan bool
173-
stats *bulkIndexerStats
172+
// stopFlush cancels the flusher goroutine; flusherDone is closed when that
173+
// goroutine returns. Close cancels via stopFlush (non-blocking and
174+
// idempotent, so Close never deadlocks even when the flusher already
175+
// returned via the construction context) and then waits on flusherDone, so
176+
// the periodic flush has fully stopped before Close runs its final drain and
177+
// the deferred implicit-client Close.
178+
stopFlush context.CancelFunc
179+
flusherDone chan struct{}
180+
stats *bulkIndexerStats
174181

175182
metaPool sync.Pool
176183
metaPoolMaxBytes int
177184

185+
// implicitClient is true when NewBulkIndexer implicitly created the client
186+
// (cfg.Client was nil). Close then closes it to release its background
187+
// goroutines and connection pool; a caller-supplied client is left for its
188+
// owner to close.
189+
implicitClient bool
190+
178191
config BulkIndexerConfig
179192
}
180193

@@ -192,12 +205,14 @@ type bulkIndexerStats struct {
192205

193206
// NewBulkIndexer creates a new bulk indexer.
194207
func NewBulkIndexer(cfg BulkIndexerConfig) (BulkIndexer, error) {
208+
implicitClient := false
195209
if cfg.Client == nil {
196210
var err error
197211
cfg.Client, err = opensearchapi.NewDefaultClient()
198212
if err != nil {
199213
return nil, err
200214
}
215+
implicitClient = true
201216
}
202217

203218
// Initialize context if not provided
@@ -223,9 +238,9 @@ func NewBulkIndexer(cfg BulkIndexerConfig) (BulkIndexer, error) {
223238

224239
bi := bulkIndexer{
225240
config: cfg,
226-
done: make(chan bool),
227241
stats: &bulkIndexerStats{},
228242
metaPoolMaxBytes: cfg.MetaBufferPoolMaxBytes,
243+
implicitClient: implicitClient,
229244
metaPool: sync.Pool{
230245
New: func() any {
231246
//nolint:mnd // 512B matches the original per-worker aux preallocation.
@@ -258,11 +273,27 @@ func (bi *bulkIndexer) Add(ctx context.Context, item BulkIndexerItem) error {
258273
}
259274

260275
// Close stops the periodic flush, closes the indexer queue channel,
261-
// notifies the done channel and calls flush on all writers.
276+
// stops the flusher goroutine and calls flush on all writers.
262277
func (bi *bulkIndexer) Close(ctx context.Context) error {
263278
bi.ticker.Stop()
264279
close(bi.queue)
265-
bi.done <- true
280+
// Stop the periodic flusher and wait for it to return before the final
281+
// drain below, so no auto-flush races the drain. stopFlush is non-blocking
282+
// and idempotent; flusherDone is already closed if the flusher exited via
283+
// the construction context, so this never blocks Close indefinitely.
284+
bi.stopFlush()
285+
<-bi.flusherDone
286+
287+
// Close the implicitly-created client on every exit path (including the
288+
// ctx-cancelled early return below), or its background goroutines and
289+
// connection pool would leak.
290+
if bi.implicitClient {
291+
defer func() {
292+
if err := bi.config.Client.Close(); err != nil && bi.config.OnError != nil {
293+
bi.config.OnError(ctx, err)
294+
}
295+
}()
296+
}
266297

267298
select {
268299
case <-ctx.Done():
@@ -324,13 +355,19 @@ func (bi *bulkIndexer) init(ctx context.Context) {
324355

325356
bi.ticker = time.NewTicker(bi.config.FlushInterval)
326357

358+
// The flusher stops on either the caller's construction context or Close's
359+
// stopFlush cancel, whichever fires first. Deriving flushCtx from ctx folds
360+
// both signals into one channel. Workers keep the original ctx so Close can
361+
// still drive its final drain flush after stopping the periodic flusher.
362+
flushCtx, stopFlush := context.WithCancel(ctx)
363+
bi.stopFlush = stopFlush
364+
bi.flusherDone = make(chan struct{})
365+
327366
go func() {
367+
defer close(bi.flusherDone)
328368
for {
329369
select {
330-
case <-bi.done:
331-
return
332-
case <-ctx.Done():
333-
// Context cancelled, stop flusher
370+
case <-flushCtx.Done():
334371
return
335372
case <-bi.ticker.C:
336373
if bi.config.DebugLogger != nil {

opensearchutil/bulk_indexer_internal_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,3 +872,61 @@ func int64Pointer(i int64) *int64 {
872872
func intPointer(i int) *int {
873873
return &i
874874
}
875+
876+
func TestBulkIndexerImplicitClientClose(t *testing.T) {
877+
t.Run("closes implicitly created client", func(t *testing.T) {
878+
tc := &stubTransportCloser{}
879+
bi := &bulkIndexer{
880+
config: BulkIndexerConfig{Client: &opensearchapi.Client{Client: &opensearch.Client{Transport: tc}}, FlushInterval: time.Hour},
881+
implicitClient: true,
882+
stats: &bulkIndexerStats{},
883+
}
884+
bi.init(t.Context())
885+
require.NoError(t, bi.Close(t.Context()))
886+
require.Equal(t, 1, tc.closed)
887+
})
888+
889+
t.Run("does not close caller-supplied client", func(t *testing.T) {
890+
tc := &stubTransportCloser{}
891+
bi := &bulkIndexer{
892+
config: BulkIndexerConfig{Client: &opensearchapi.Client{Client: &opensearch.Client{Transport: tc}}, FlushInterval: time.Hour},
893+
implicitClient: false,
894+
stats: &bulkIndexerStats{},
895+
}
896+
bi.init(t.Context())
897+
require.NoError(t, bi.Close(t.Context()))
898+
require.Equal(t, 0, tc.closed)
899+
})
900+
901+
t.Run("closes implicit client without deadlock under cancelled context", func(t *testing.T) {
902+
tc := &stubTransportCloser{}
903+
ctx, cancel := context.WithCancel(t.Context())
904+
bi := &bulkIndexer{
905+
config: BulkIndexerConfig{Client: &opensearchapi.Client{Client: &opensearch.Client{Transport: tc}}, FlushInterval: time.Hour},
906+
implicitClient: true,
907+
stats: &bulkIndexerStats{},
908+
}
909+
bi.init(ctx)
910+
cancel()
911+
done := make(chan error, 1)
912+
go func() { done <- bi.Close(ctx) }()
913+
select {
914+
case <-done:
915+
// Close returns ctx.Err() on cancelled ctx; the deferred implicit
916+
// client Close still runs. Both outcomes acceptable; the point is
917+
// no deadlock and the client is closed.
918+
case <-time.After(5 * time.Second):
919+
t.Fatal("Close deadlocked under cancelled context")
920+
}
921+
require.Equal(t, 1, tc.closed)
922+
})
923+
}
924+
925+
// stubTransportCloser implements opensearchtransport.Interface + io.Closer.
926+
type stubTransportCloser struct{ closed int }
927+
928+
//nolint:nilnil // stub: Perform is never called, only Close is exercised
929+
func (s *stubTransportCloser) Perform(*http.Request) (*http.Response, error) { return nil, nil }
930+
931+
//nolint:unparam // Close must return error to satisfy io.Closer; stub never fails
932+
func (s *stubTransportCloser) Close() error { s.closed++; return nil }

0 commit comments

Comments
 (0)