Skip to content

Commit e66ba3a

Browse files
sean-ryanyuan
andauthored
test: release the transports and clients the tests construct (#1048)
* test: release the transports and clients the tests construct opensearchtransport.New starts two ticker goroutines on every call: the node-stats poller (NodeStatsInterval: 0 means auto-derive, not disabled) and the cluster-health refresh loop (healthCheckRate is derived from the server core count and is never zero). Close is the only thing that stops either one, so a test that constructs a transport or client and never releases it leaves two live tickers running for the remaining life of the test binary, where they perturb process-wide measurements. Every construction site now registers a release: t.Cleanup in tests, b.Cleanup in benchmarks, defer in examples (Example functions have no *testing.T). TestClassify_ZeroAlloc and TestNewRequestEventZeroAlloc use testing.AllocsPerRun, a process-wide allocation differential. Neither source file carried a build tag, so both compiled into every configuration including -tags integration,core, sharing a binary with the live-cluster tests whose transports poll node stats and cluster health in the background. That is what flaked TestClassify_ZeroAlloc in CI. New TestCloseReapsBackgroundPollers reads the goroutine dump to assert that New starts both pollers and that Close reaps them, so the leak cannot return silently. Verified to fail when Close is stubbed to skip its context cancellation. It carries the same !integration constraint, because the process-wide dump only settles in a binary where no other live transport is polling, and it omits t.Parallel for the same reason. Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com> Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> Co-authored-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
1 parent 8c768ac commit e66ba3a

39 files changed

Lines changed: 390 additions & 62 deletions

CHANGELOG.md

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

256256
### Fixed
257257

258+
- Fix tests, benchmarks, and examples that construct a transport or client and never release it, each leaking two ticker goroutines that outlive the test. `opensearchtransport.New` always starts the node-stats poller and the cluster-health refresh loop: `healthCheckRate` is derived from the server core count and is never zero, and `NodeStatsInterval: 0` means auto-derive rather than disabled. `Close` is the only thing that stops either one, so a leaked poller keeps ticking for the remaining life of the test binary, where it perturbs process-wide measurements. Every site now registers a release -- `t.Cleanup` in tests, `b.Cleanup` in benchmarks, `defer` in examples -- covering 153 sites in `opensearchtransport` and 39 more across `opensearch_integration_test.go`, `opensearch_benchmark_test.go`, `opensearch_example_test.go`, `opensearchapi`, `opensearchutil`, `osprom`, and `osotel`. Three kinds of site are deliberately left alone: the process-wide shared client from `opensearchapi/testutil.NewClient`, which the package owns rather than the caller; a `New` whose construction is expected to fail and returns nothing to close; and one bulk indexer case that forces `implicitClient` to true so the indexer owns the client and `BulkIndexer.Close` releases it. A client the caller supplies is not owned, as `TestBulkIndexerOwnClientFlag` asserts, so the cases that hand the indexer a client close it themselves. The two zero-allocation assertions (`TestClassify_ZeroAlloc` and `TestNewRequestEventZeroAlloc`) move into `//go:build !integration` files: `testing.AllocsPerRun` is a process-wide allocation differential and is only sound in a binary where nothing else allocates concurrently, and sharing a binary with the live-cluster tests is what flaked `TestClassify_ZeroAlloc` in CI. New `TestCloseReapsBackgroundPollers` reads the goroutine dump to assert both pollers start with `New` and are gone after `Close`, so the leak cannot return silently; it carries the same `!integration` constraint, because the dump only settles in a binary where no other live transport is polling
258259
- Fix `cmd/osgen` dropping every version annotation the spec writes beside a `$ref`. kin-openapi splits a `$ref`'s siblings across two places: standard fields such as `description` are overlaid onto the resolved schema, but `x-*` keys stay on the reference and never reach the resolved schema's extensions. The generator read only the latter, so 141 annotations were lost -- 135 `x-version-added`, 5 `x-version-removed`, and 1 `x-version-deprecated`. The visible half was missing documentation: `SearchResp.PhaseTook` carries `x-version-added: '2.12'` and emitted no availability note, and no generated file mentioned that version at all. The other half is a correctness problem, since the same values feed the version filter, so those fields were tested against an empty version and could not be excluded by `-min-version` or `-max-version`. A sibling annotation now wins over one on the referenced schema, because it describes the property carrying it rather than the shared type it points at: two properties may reference one schema and have been added in different versions. Regenerating adds 138 availability notes and changes no field
259260
- Fix collapsed types keeping their mangled generic-instantiation name instead of the readable alias the spec provides for them. When an `allOf` adds nothing to its base the two describe one Go type and the base's name was kept, so `AsAdjacencyMatrix()` returned `CommonAggregationsMultiBucketAggregateBaseAdjacencyMatrixBucket` even though the spec supplies `AdjacencyMatrixAggregate` as a bare `allOf: [$ref]` alias precisely to name that instantiation. A post-walk pass now renames the collapsed type to its alias and rewrites every reference, including types keyed beneath it (a nested `buckets` union is registered as `<parentKey>.buckets`, so it inherited the old prefix). The rename must run after the walk rather than during it: type references are plain Go type strings, and the spec chains these collapses (`RangeAggregate` -> `RangeAggregateBase` -> `MultiBucketAggregateBaseRangeBucket`), so a mid-walk rename leaves siblings that already resolved pointing at a name that no longer exists. Two guards keep it safe: a target several aliases share keeps its own name, since no one alias is the better choice (eight schemas from `AvgAggregate` to `WeightedAvgAggregate` collapse onto `SingleMetricAggregateBase`), and a target the spec references more heavily than its alias also keeps its name, so `SearchResult` is not retired in favor of `SearchResponse`. Restores `CommonAggregationsAdjacencyMatrixAggregate`, `CommonAggregationsDateHistogramAggregate`, `CommonAggregationsGeoHashGridAggregate` and their siblings, and drops type names over 60 characters from 74 to 16 -- the remainder being genuinely descriptive nested paths rather than erasure artifacts
260261
- Fix `cmd/osgen` deciding union branch reachability in the wrong pipeline phase, and stop emitting wrapper structs for schemas that merely rename another. Branch deduplication ran during the Parse phase, dropping any branch whose Go type duplicated an earlier one. Whether a duplicate is dead depends on the union's decode state, which is not assigned until the IR phase: a wire-decoded union walks its branches and stops at the first that decodes, so a same-type duplicate is unreachable, but a caller-keyed lazy union retains only raw bytes and lets the caller name the branch, so every `As<Branch>()` accessor is reachable even when several decode one Go type. Deduplication moves to `dropUnreachableBranches`, which runs once every union has reached its terminal state and skips the lazy ones. `SearchResultAggregationsValue` gains back the accessors the Parse-phase drop had been silently deleting (55 -> 62), including `AsSum`, `AsMin`, `AsMax`, `AsValueCount`, `AsWeightedAvg`, `AsSimpleValue`, and `AsMedianAbsoluteDeviation` alongside `AsAvg`. With reachability now judged correctly, `collapsesToBase` also accepts a bare `allOf: [$ref]` -- the spec's way of giving a generic instantiation a friendly name -- which removes 30 further wrapper structs whose only content was the embedded base (66 such wrappers at the start of this line of work, 2 remain). Breaking: the removed wrappers are no longer distinct types, so `CommonAggregationsAvgAggregate` and its siblings are now `CommonAggregationsSingleMetricAggregateBase`, and `New...FromAvg` and friends take that type; accessor and constructor names are unchanged

opensearch_benchmark_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func BenchmarkClientAPI(b *testing.B) {
127127
if err != nil {
128128
b.Fatalf("ERROR: %s", err)
129129
}
130+
b.Cleanup(func() { _ = client.Close() })
130131

131132
b.Run("InfoRequest{}.Do()", func(b *testing.B) {
132133
b.ResetTimer()

opensearch_example_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ func ExampleNewDefaultClient() {
5252
if err != nil {
5353
log.Fatalf("Error creating the client: %s\n", err)
5454
}
55+
defer func() { _ = client.Close() }()
5556

5657
_, err = client.Info(ctx, nil)
5758
if err != nil {
58-
log.Fatalf("Error getting the response: %s\n", err)
59+
log.Panicf("Error getting the response: %s\n", err)
5960
}
6061

6162
log.Print(client.Client.Transport.(*opensearchtransport.Transport).URLs())
@@ -82,6 +83,7 @@ func ExampleNewClient() {
8283
}
8384

8485
client, _ := opensearchapi.NewClient(cfg)
86+
defer func() { _ = client.Close() }()
8587
log.Print(client.Client.Transport.(*opensearchtransport.Transport).URLs())
8688
}
8789

@@ -99,5 +101,6 @@ func ExampleNewClient_logger() {
99101
},
100102
}
101103

102-
opensearchapi.NewClient(cfg)
104+
client, _ := opensearchapi.NewClient(cfg)
105+
defer func() { _ = client.Close() }()
103106
}

opensearch_integration_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ func TestClientTransport(t *testing.T) {
137137

138138
client, err := opensearchapi.NewClient(cfg)
139139
require.NoError(t, err)
140+
t.Cleanup(func() { _ = client.Close() })
140141

141142
_, err = client.Info(t.Context(), nil)
142143
require.Error(t, err)
@@ -187,6 +188,7 @@ func TestClientCustomTransport(t *testing.T) {
187188
}
188189
client, err = opensearchapi.NewClient(*cfg)
189190
require.NoError(t, err)
191+
t.Cleanup(func() { _ = client.Close() })
190192

191193
// Wait for cluster to be ready before running tests
192194
testutil.WaitForClusterReady(t, client)
@@ -220,6 +222,7 @@ func TestClientCustomTransport(t *testing.T) {
220222
Password: config.Client.Password,
221223
Context: t.Context(),
222224
})
225+
t.Cleanup(func() { _ = tp.Close() })
223226

224227
client := opensearchapi.Client{
225228
Client: &opensearch.Client{
@@ -349,6 +352,7 @@ func TestClientGetConfigIntegration(t *testing.T) {
349352
// Create a client with specific configuration
350353
osClient, err := opensearch.NewClient(cfg.Client)
351354
require.NoError(t, err)
355+
t.Cleanup(func() { _ = osClient.Close() })
352356

353357
// Retrieve the config
354358
retrievedConfig := osClient.GetConfig()
@@ -373,6 +377,7 @@ func TestClientGetConfigIntegration(t *testing.T) {
373377
// Verify we can create a new client with the retrieved config
374378
newClient, err := opensearch.NewClient(*config)
375379
require.NoError(t, err)
380+
t.Cleanup(func() { _ = newClient.Close() })
376381
require.NotNil(t, newClient)
377382

378383
// Verify the new client works by making a request
@@ -394,6 +399,7 @@ func TestNewFromClientIntegration(t *testing.T) {
394399
// Create an opensearchapi.Client from the shared config
395400
apiClient, err := opensearchapi.NewClient(opensearchapi.Config{Client: cfg.Client})
396401
require.NoError(t, err)
402+
t.Cleanup(func() { _ = apiClient.Close() })
397403
require.NotNil(t, apiClient)
398404

399405
// Verify the api client can make requests
@@ -410,9 +416,11 @@ func TestNewFromClientIntegration(t *testing.T) {
410416
// Create a base opensearch.Client and an api client from the same config
411417
osClient, err := opensearch.NewClient(cfg.Client)
412418
require.NoError(t, err)
419+
t.Cleanup(func() { _ = osClient.Close() })
413420

414421
apiClient, err := opensearchapi.NewClient(opensearchapi.Config{Client: cfg.Client})
415422
require.NoError(t, err)
423+
t.Cleanup(func() { _ = apiClient.Close() })
416424
require.NotNil(t, apiClient.Client.Transport)
417425

418426
// Verify both clients can make requests successfully
@@ -436,6 +444,7 @@ func TestNewFromClientIntegration(t *testing.T) {
436444
// Create an opensearchapi.Client from the shared config
437445
apiClient, err := opensearchapi.NewClient(opensearchapi.Config{Client: cfg.Client})
438446
require.NoError(t, err)
447+
t.Cleanup(func() { _ = apiClient.Close() })
439448

440449
// Retrieve config through the api client's wrapped opensearch client
441450
retrievedConfig := apiClient.Client.GetConfig()
@@ -453,6 +462,7 @@ func TestNewFromClientIntegration(t *testing.T) {
453462
// Create an opensearchapi.Client from the shared config
454463
apiClient, err := opensearchapi.NewClient(opensearchapi.Config{Client: cfg.Client})
455464
require.NoError(t, err)
465+
t.Cleanup(func() { _ = apiClient.Close() })
456466

457467
// Test a few sub-clients to ensure they're properly initialized
458468
// Cat client

opensearchapi/api_router_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func TestNewClient_RouterInjection(t *testing.T) {
6161
t.Parallel()
6262
c, err := opensearchapi.NewClient(tt.cfg)
6363
require.NoError(t, err)
64+
t.Cleanup(func() { _ = c.Close() })
6465
require.NotNil(t, c)
6566
require.NotNil(t, c.Client)
6667
})
@@ -110,6 +111,7 @@ func TestNewClient_RouterEnvOptOut(t *testing.T) {
110111
c, err := opensearchapi.NewClient(cfg)
111112
if tt.wantErrNil {
112113
require.NoError(t, err)
114+
t.Cleanup(func() { _ = c.Close() })
113115
require.NotNil(t, c)
114116
} else {
115117
require.Error(t, err)
@@ -195,6 +197,7 @@ func TestNewClient_RouterTruthyEnablesDiscovery(t *testing.T) {
195197
c, err := opensearchapi.NewClient(cfg)
196198
if tt.wantClientBuilds {
197199
require.NoError(t, err)
200+
t.Cleanup(func() { _ = c.Close() })
198201
require.NotNil(t, c)
199202
} else {
200203
require.Error(t, err)
@@ -210,5 +213,6 @@ func TestNewDefaultClient(t *testing.T) {
210213
t.Parallel()
211214
c, err := opensearchapi.NewDefaultClient()
212215
require.NoError(t, err)
216+
t.Cleanup(func() { _ = c.Close() })
213217
require.NotNil(t, c)
214218
}

opensearchapi/rest_status_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ func TestRestStatusClientDecode(t *testing.T) {
174174

175175
client, err := opensearch.NewClient(opensearch.Config{Addresses: []string{ts.URL}})
176176
require.NoError(t, err)
177+
t.Cleanup(func() { _ = client.Close() })
177178

178179
var body struct {
179180
Status *opensearchapi.RestStatus `json:"status"`

opensearchtransport/address_resolver_internal_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ func TestAddressResolver(t *testing.T) {
213213
MaxAddressResolvers: 1, // serial for deterministic behavior
214214
})
215215
require.NoError(t, err)
216+
t.Cleanup(func() { _ = tp.Close() })
216217

217218
nodes, err := tp.getNodesInfo(t.Context())
218219
if tt.wantErr {
@@ -271,6 +272,7 @@ func TestAddressResolver(t *testing.T) {
271272
HealthCheck: NoOpHealthCheck,
272273
})
273274
require.NoError(t, err)
275+
t.Cleanup(func() { _ = tp.Close() })
274276

275277
nodes, err := tp.getNodesInfo(t.Context())
276278
require.NoError(t, err)
@@ -297,6 +299,7 @@ func TestAddressResolver(t *testing.T) {
297299
MaxAddressResolvers: 1,
298300
})
299301
require.NoError(t, err)
302+
t.Cleanup(func() { _ = tp.Close() })
300303

301304
_, err = tp.getNodesInfo(t.Context())
302305
require.NoError(t, err)
@@ -347,6 +350,7 @@ func TestAddressResolver(t *testing.T) {
347350
MaxAddressResolvers: -1,
348351
})
349352
require.NoError(t, err)
353+
t.Cleanup(func() { _ = tp.Close() })
350354

351355
_, err = tp.getNodesInfo(t.Context())
352356
require.NoError(t, err)
@@ -379,6 +383,7 @@ func TestAddressResolver(t *testing.T) {
379383
MaxAddressResolvers: 1,
380384
})
381385
require.NoError(t, err)
386+
t.Cleanup(func() { _ = tp.Close() })
382387

383388
_, err = tp.getNodesInfo(t.Context())
384389
require.NoError(t, err)
@@ -438,6 +443,7 @@ func TestAddressResolver(t *testing.T) {
438443
MaxAddressResolvers: tt.maxResolvers,
439444
})
440445
require.NoError(t, err)
446+
t.Cleanup(func() { _ = tp.Close() })
441447

442448
nodes, err := tp.getNodesInfo(ctx)
443449
if tt.wantErr != nil {
@@ -470,6 +476,7 @@ func TestAddressResolver(t *testing.T) {
470476
MaxAddressResolvers: -1,
471477
})
472478
require.NoError(t, err)
479+
t.Cleanup(func() { _ = tp.Close() })
473480

474481
nodes, err := tp.getNodesInfo(ctx)
475482

@@ -492,6 +499,7 @@ func TestAddressResolver(t *testing.T) {
492499
},
493500
})
494501
require.NoError(t, err)
502+
t.Cleanup(func() { _ = tp.Close() })
495503

496504
err = tp.DiscoverNodes(t.Context())
497505
require.NoError(t, err)
@@ -629,6 +637,7 @@ func TestDiscoverNodes_PartialCancelDoesNotEvict(t *testing.T) {
629637

630638
tp, err := New(tt.configure(t, cancel))
631639
require.NoError(t, err)
640+
t.Cleanup(func() { _ = tp.Close() })
632641

633642
// Seed the pool with a clean discovery cycle.
634643
require.NoError(t, tp.DiscoverNodes(t.Context()))
@@ -704,6 +713,7 @@ func TestAddressResolverRunner(t *testing.T) {
704713
},
705714
})
706715
require.NoError(t, err)
716+
t.Cleanup(func() { _ = tp.Close() })
707717

708718
_, err = tp.getNodesInfo(t.Context())
709719
require.NoError(t, err)
@@ -736,6 +746,7 @@ func TestAddressResolverRunner(t *testing.T) {
736746
},
737747
})
738748
require.NoError(t, err)
749+
t.Cleanup(func() { _ = tp.Close() })
739750

740751
nodes, err := tp.getNodesInfo(t.Context())
741752
require.NoError(t, err)
@@ -771,6 +782,7 @@ func TestAddressResolverRunner(t *testing.T) {
771782
},
772783
})
773784
require.NoError(t, err)
785+
t.Cleanup(func() { _ = tp.Close() })
774786

775787
_, err = tp.getNodesInfo(t.Context())
776788
require.NoError(t, err)
@@ -845,6 +857,7 @@ func TestAddressResolverRunner(t *testing.T) {
845857
AddressResolverRunner: tt.runner,
846858
})
847859
require.NoError(t, err)
860+
t.Cleanup(func() { _ = tp.Close() })
848861

849862
nodes, err := tp.getNodesInfo(ctx)
850863
if tt.wantErr != nil {
@@ -869,6 +882,7 @@ func TestAddressResolverRunner(t *testing.T) {
869882
},
870883
})
871884
require.NoError(t, err)
885+
t.Cleanup(func() { _ = tp.Close() })
872886

873887
_, err = tp.getNodesInfo(t.Context())
874888
require.ErrorIs(t, err, runnerErr)
@@ -966,6 +980,7 @@ func TestAddressResolverRunner(t *testing.T) {
966980
},
967981
})
968982
require.NoError(t, err)
983+
t.Cleanup(func() { _ = tp.Close() })
969984

970985
nodes, err := tp.getNodesInfo(t.Context())
971986
require.NoError(t, err)
@@ -1005,6 +1020,7 @@ func TestAddressResolverRunner(t *testing.T) {
10051020
},
10061021
})
10071022
require.NoError(t, err)
1023+
t.Cleanup(func() { _ = tp.Close() })
10081024

10091025
err = tp.DiscoverNodes(t.Context())
10101026
require.NoError(t, err)
@@ -1147,6 +1163,7 @@ func TestAddressResolverRunnerProtocol(t *testing.T) {
11471163
AddressResolverRunner: p.runner,
11481164
})
11491165
require.NoError(t, err)
1166+
t.Cleanup(func() { _ = tp.Close() })
11501167
tp.observer.Store(&iface)
11511168

11521169
nodes, err := tp.getNodesInfo(t.Context())

opensearchtransport/classify_extra_test.go

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,37 +16,6 @@ import (
1616
"github.com/opensearch-project/opensearch-go/v5/opensearchtransport"
1717
)
1818

19-
// TestClassify_ZeroAlloc guards the zero-allocation claim documented in
20-
// CHANGELOG: OperationClassifier.Classify must not allocate on the hot
21-
// path (it lives inside RoundTrip and runs once per request). A
22-
// regression here means a per-request heap object that compounds across
23-
// the cluster's RPS.
24-
func TestClassify_ZeroAlloc(t *testing.T) {
25-
c := opensearchtransport.NewOperationClassifier()
26-
// Warm any one-time setup the classifier may do.
27-
_ = c.Classify(http.MethodGet, "/events/_search")
28-
29-
tests := []struct {
30-
name string
31-
method string
32-
path string
33-
}{
34-
{"search hot path", http.MethodPost, "/events/_search"},
35-
{"bulk hot path", http.MethodPost, "/_bulk"},
36-
{"doc get hot path", http.MethodGet, "/events/_doc/abc-123"},
37-
{"unknown path falls through to OpOther", http.MethodGet, "/_unknown/endpoint"},
38-
}
39-
40-
for _, tt := range tests {
41-
t.Run(tt.name, func(t *testing.T) {
42-
allocs := testing.AllocsPerRun(200, func() {
43-
_ = c.Classify(tt.method, tt.path)
44-
})
45-
require.Zero(t, allocs, "Classify(%q, %q) must be zero-alloc, got %g", tt.method, tt.path, allocs)
46-
})
47-
}
48-
}
49-
5019
// TestClassify_PathEdgeCases covers path-shape variants that callers
5120
// pass through Classify directly: trailing slashes, query strings, mixed
5221
// case methods. The classifier must be tolerant of common HTTP-layer

0 commit comments

Comments
 (0)