Skip to content

test: release the transports and clients the tests construct - #1048

Merged
ryanyuan merged 8 commits into
opensearch-project:mainfrom
sean-:fix-test-transport-leaks
Aug 7, 2026
Merged

test: release the transports and clients the tests construct#1048
ryanyuan merged 8 commits into
opensearch-project:mainfrom
sean-:fix-test-transport-leaks

Conversation

@sean-

@sean- sean- commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

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 and never releases it
leaves live tickers running for the remaining life of the test binary,
where they perturb process-wide measurements such as the package's
zero-allocation assertions.

Register t.Cleanup(func() { _ = tp.Close() }) at all 151 construction
sites. Sites that already closed their transport are left alone, as is the
one site where New is expected to fail and returns a nil transport
(TestNewCancelsDNSResolverOnError in dnscache_internal_test.go).

Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
…ation

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.

Move each test, unchanged, into a //go:build !integration file so the
differential only ever runs where nothing else allocates concurrently.
The two tests need separate files because their sources sit in different
packages: classify_extra_test.go is opensearchtransport_test and
observer_response_internal_test.go is opensearchtransport. The remaining
tests in both source files stay untagged; only the differentials need the
constraint.

Add TestCloseReapsBackgroundPollers as the durable guard: it asserts via
the goroutine dump that New starts the node-stats and cluster-health
pollers and that Close reaps both. Verified to fail when Close is stubbed
to skip its context cancellation.

Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
Same goroutine contract as the opensearchtransport sweep: a transport
starts a node-stats poller and a cluster-health refresh loop that only
Close stops, so a client left unreleased leaks two tickers for the rest of
the test binary's life. opensearch.Client.Close and
opensearchapi.Client.Close are the release points.

Register a release at all 26 remaining hand-written sites: t.Cleanup in
tests, b.Cleanup in benchmarks, defer in examples (Example functions have
no *testing.T). ExampleNewClient_logger discarded its client entirely; it
now binds and releases it.

Three kinds of site are deliberately untouched:

  - opensearchapi/testutil.NewClient returns a sync.Once-shared client the
    package owns; closing it from one test would break every later test.
  - TestClientCustomTransport already reclaims its NewDefaultClient
    transport, and BenchmarkClient already closes per iteration.
  - Nothing relies on BulkIndexer.Close: all three opensearchutil sites
    pass a client in, so implicitClient is false and the test owns it. In
    the integration test the client release is registered before the
    index-delete cleanup so LIFO ordering leaves the client alive for the
    delete.

Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
Thirteen cases in bulk_indexer_internal_test.go build an opensearchapi
client and hand it to BulkIndexerConfig.Client. A supplied client is not
owned by the indexer, so implicitClient stays false and BulkIndexer.Close
leaves it open, exactly as TestBulkIndexerOwnClientFlag asserts. Each one
left its transport polling node stats and cluster health for the rest of
the test binary.

Register the same t.Cleanup close used elsewhere on this branch. The one
remaining unclosed client in the file is deliberate: that case forces
implicitClient to true so the indexer owns it, then asserts the close
happened.

Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
…it binary

TestCloseReapsBackgroundPollers asserts the poller frames are gone from
the process-wide runtime.Stack dump after Close. That only holds where no
other live transport is polling, the same constraint that already keeps
the zero-allocation assertions out of the live-cluster binaries, so this
file now carries !integration too.

The doc comment states the constraint once and covers both the build tag
and the absence of t.Parallel.

Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
The counts predated the bulk indexer fixups: 153 sites in
opensearchtransport and 39 elsewhere. The exclusion also described the
wrong case. A client the caller supplies is never owned by the indexer,
so the surviving unclosed site is the one that forces implicitClient to
true and asserts the indexer closed it. Note that the poller guard
carries !integration for the same reason the allocation assertions do.

Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
@sean-
sean- requested review from Jakob3xD and ryanyuan as code owners August 7, 2026 05:18
@sean- sean- added bug Something isn't working v5 opensearch-go/v5 labels Aug 7, 2026
gocritic's exitAfterDefer failed the lint job on the two examples this
branch gave a `defer func() { _ = client.Close() }()`. log.Fatalf calls
os.Exit, which does not unwind the stack, so every Fatalf placed after the
defer would have skipped the close the branch added -- the leak the branch
set out to fix, still leaking on the error path.

Switch those calls to log.Panicf, which runs deferred functions on its way
out. Examples have no *testing.T, so require.NoError and t.Cleanup are not
available here; panicking is what the neighbouring osprom and osotel
examples already do.

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.31%. Comparing base (8c768ac) to head (a8e3f06).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1048      +/-   ##
==========================================
- Coverage   61.35%   61.31%   -0.04%     
==========================================
  Files         666      666              
  Lines       59841    59841              
==========================================
- Hits        36713    36693      -20     
- Misses      21226    21242      +16     
- Partials     1902     1906       +4     
Flag Coverage Δ
integration 33.15% <ø> (-0.01%) ⬇️
unit 60.13% <ø> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ryanyuan
ryanyuan merged commit e66ba3a into opensearch-project:main Aug 7, 2026
50 checks passed
@sean-
sean- deleted the fix-test-transport-leaks branch August 7, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working v5 opensearch-go/v5

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants