Skip to content

Commit 410b8c6

Browse files
committed
fix(opensearchtransport): keep dedicated cluster managers in the connection inventory
A dedicated cluster manager (cluster_manager role with no work roles) was filtered out of the allConns pool during discovery and promotion, while the router received the unfiltered added/removed diffs. Because the node was absent from allConns, the discovery reuse lookup (findConnectionByURL) never matched it, so a new *Connection was created for it on every discovery cycle, and the removed diff (computed against allConns) never contained it, so the prior cycle's connection was never evicted. The round-robin fallback pool accumulated these connections without bound, and its checkDead health checks repopulated a per-connection poolRegistry sync.Map each cycle, growing the heap without limit. The rate scaled with discovery frequency. Make allConns the full connection inventory: it holds every discovered node regardless of role, so discovery reuses and evicts connections symmetrically. Keep dedicated cluster managers out of request routing at the point of selection instead: - RoundRobinPolicy, the only policy that admits nodes irrespective of role, skips dedicated cluster managers in its DiscoveryUpdate add path unless IncludeDedicatedClusterManagers is set. The setting flows via policyConfig. - The allConns pool carries excludeDCM (set when IncludeDedicatedClusterManagers is false) so multiServerPool.Next() skips dedicated cluster managers during selection, including the no-router fallback path. A pool of only dedicated cluster managers exhausts its attempts and reports ErrNoConnections. Discovery still bootstraps against a dedicated cluster manager seed via the seed-fallback pool, which is unaffected. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent ac45b8e commit 410b8c6

12 files changed

Lines changed: 394 additions & 303 deletions

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
44

5-
## [4.7.2]
5+
## [4.7.3]
6+
7+
### Fixed
8+
9+
- Fix an unbounded connection/heap leak in node discovery when the cluster has a dedicated cluster manager (`cluster_manager` role with no work roles). The node was filtered out of the `allConns` inventory while the router received the unfiltered added/removed diffs, so `findConnectionByURL` never matched it: a new `*Connection` was created every discovery cycle and the stale one was never evicted, accumulating without bound in the round-robin fallback pool whose `checkDead` health checks repopulated a per-connection `poolRegistry` `sync.Map` each cycle (leak rate scaled with discovery frequency). `allConns` is now the full connection inventory so discovery reuses and evicts symmetrically, and dedicated cluster managers are excluded at request-routing selection instead: `RoundRobinPolicy` skips them in its `DiscoveryUpdate` add path and `multiServerPool.Next()` skips them during selection (including the no-router fallback), both gated on `IncludeDedicatedClusterManagers`. Discovery still bootstraps against a dedicated cluster manager seed via the seed-fallback pool ([#1003](https://github.com/opensearch-project/opensearch-go/pull/1003))
610

711
## [4.7.0]
812

@@ -656,6 +660,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
656660
- Bumps `github.com/stretchr/testify` from 1.8.0 to 1.8.1
657661
- Bumps `github.com/aws/aws-sdk-go` from 1.44.45 to 1.44.132
658662

663+
[4.7.3]: https://github.com/opensearch-project/opensearch-go/compare/v4.7.2...v4.7.3
659664
[4.7.2]: https://github.com/opensearch-project/opensearch-go/compare/v4.7.1...v4.7.2
660665
[4.7.0]: https://github.com/opensearch-project/opensearch-go/compare/v4.6.0...v4.7.0
661666
[4.6.0]: https://github.com/opensearch-project/opensearch-go/compare/v4.5.0...v4.6.0

internal/version/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@
2727
package version
2828

2929
// Client returns the client version as a string.
30-
const Client = "4.7.2"
30+
const Client = "4.7.3"

opensearchtransport/discovery.go

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -967,27 +967,16 @@ func (c *Client) createOrUpdateMultiNodePoolWithLock(readyConnections, deadConne
967967
return c.promoteConnectionPoolWithLock(readyConnections, deadConnections)
968968
}
969969

970-
// Update existing multiServerPool or create new one
971-
// Apply client-level filtering for dedicated cluster managers
970+
// allConns is the bookkeeping inventory of every discovered connection,
971+
// including dedicated cluster managers. Discovery reuses connections by
972+
// scanning allConns (findConnectionByURL) and evicts them via the removed
973+
// diff computed against allConns. Query traffic is kept off dedicated
974+
// cluster managers at the routing-policy layer (see RoundRobinPolicy).
972975
allReadyConns := make([]*Connection, 0, len(readyConnections))
973976
allDeadConns := make([]*Connection, 0, len(deadConnections))
974977

975-
for _, conn := range readyConnections {
976-
if !c.includeDedicatedClusterManagers && conn.Roles.isDedicatedClusterManager() {
977-
if dl := loadDebugLogger(); dl != nil {
978-
dl.Logf("Excluding dedicated cluster manager %q from connection pool\n", conn.Name)
979-
}
980-
continue
981-
}
982-
allReadyConns = append(allReadyConns, conn)
983-
}
984-
985-
for _, conn := range deadConnections {
986-
if !c.includeDedicatedClusterManagers && conn.Roles.isDedicatedClusterManager() {
987-
continue
988-
}
989-
allDeadConns = append(allDeadConns, conn)
990-
}
978+
allReadyConns = append(allReadyConns, readyConnections...)
979+
allDeadConns = append(allDeadConns, deadConnections...)
991980

992981
// Shuffle connections for load distribution unless disabled
993982
if !c.skipConnectionShuffle && len(allReadyConns) > 1 {

opensearchtransport/discovery_integration_internal_test.go

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -432,39 +432,43 @@ func TestIncludeDedicatedClusterManagersConfiguration(t *testing.T) {
432432
name string
433433
includeDedicatedClusterManagers bool
434434
nodes map[string][]string // nodeName -> roles
435-
expectedIncluded []string // nodes that should be included
436-
expectedExcluded []string // nodes that should be excluded
435+
// expectedInInventory lists nodes that must appear in the allConns pool,
436+
// which holds every discovered node regardless of role.
437+
expectedInInventory []string
438+
// expectedNotRoutable lists dedicated cluster managers that must not be
439+
// selected for request routing.
440+
expectedNotRoutable []string
437441
}{
438442
{
439-
name: "IncludeDedicatedClusterManagers enabled - includes all nodes",
443+
name: "IncludeDedicatedClusterManagers enabled - all nodes routable",
440444
includeDedicatedClusterManagers: true,
441445
nodes: map[string][]string{
442446
"cm-only": {RoleClusterManager},
443447
"data-node": {RoleData},
444448
},
445-
expectedIncluded: []string{"cm-only", "data-node"},
446-
expectedExcluded: []string{},
449+
expectedInInventory: []string{"cm-only", "data-node"},
450+
expectedNotRoutable: []string{},
447451
},
448452
{
449-
name: "IncludeDedicatedClusterManagers disabled (default) - excludes dedicated CM nodes",
453+
name: "IncludeDedicatedClusterManagers disabled (default) - dedicated CM in inventory but not routable",
450454
includeDedicatedClusterManagers: false,
451455
nodes: map[string][]string{
452456
"cm-only": {RoleClusterManager},
453457
"data-node": {RoleData},
454458
"dummy": {RoleData}, // Add second node to avoid single connection pool
455459
},
456-
expectedIncluded: []string{"data-node", "dummy"},
457-
expectedExcluded: []string{"cm-only"},
460+
expectedInInventory: []string{"cm-only", "data-node", "dummy"},
461+
expectedNotRoutable: []string{"cm-only"},
458462
},
459463
{
460-
name: "Mixed roles with CM always included regardless of setting",
464+
name: "Mixed roles with CM always routable regardless of setting",
461465
includeDedicatedClusterManagers: false,
462466
nodes: map[string][]string{
463467
"cm-data": {RoleClusterManager, RoleData},
464468
"dummy": {RoleData}, // Add second node to avoid single connection pool
465469
},
466-
expectedIncluded: []string{"cm-data", "dummy"},
467-
expectedExcluded: []string{},
470+
expectedInInventory: []string{"cm-data", "dummy"},
471+
expectedNotRoutable: []string{},
468472
},
469473
}
470474

@@ -481,41 +485,60 @@ func TestIncludeDedicatedClusterManagersConfiguration(t *testing.T) {
481485
})
482486
require.NoError(t, err)
483487

484-
// Perform discovery
485-
err = c.DiscoverNodes(t.Context())
486-
require.NoError(t, err)
487-
488-
// Verify results
489-
pool, ok := c.mu.connectionPool.(*multiServerPool)
490-
require.Truef(t, ok, "Expected multiServerPool but got %T with URLs: %v",
491-
c.mu.connectionPool, c.mu.connectionPool.URLs())
492-
493-
// Check included nodes (in either ready or dead lists,
494-
// since newly discovered nodes start in dead state pending health checks)
495-
actualNodes := make(map[string]struct{})
496-
for _, conn := range pool.mu.ready {
497-
actualNodes[conn.Name] = struct{}{}
498-
}
499-
for _, conn := range pool.mu.dead {
500-
actualNodes[conn.Name] = struct{}{}
488+
// Run discovery repeatedly. The inventory must converge to exactly one
489+
// connection per node and stay there: an unbounded pool that re-created
490+
// connections each cycle (the dedicated-cluster-manager leak) would grow
491+
// with every iteration. Asserting the exact length on every cycle is the
492+
// regression guard.
493+
const cycles = 5
494+
var pool *multiServerPool
495+
for cycle := 1; cycle <= cycles; cycle++ {
496+
require.NoErrorf(t, c.DiscoverNodes(t.Context()), "discovery cycle %d", cycle)
497+
498+
var ok bool
499+
pool, ok = c.mu.connectionPool.(*multiServerPool)
500+
require.Truef(t, ok, "Expected multiServerPool but got %T with URLs: %v",
501+
c.mu.connectionPool, c.mu.connectionPool.URLs())
502+
503+
pool.mu.RLock()
504+
readyLen := len(pool.mu.ready)
505+
deadLen := len(pool.mu.dead)
506+
membersLen := len(pool.mu.members)
507+
inventory := make(map[string]struct{}, readyLen+deadLen)
508+
for _, conn := range pool.mu.ready {
509+
inventory[conn.Name] = struct{}{}
510+
}
511+
for _, conn := range pool.mu.dead {
512+
inventory[conn.Name] = struct{}{}
513+
}
514+
pool.mu.RUnlock()
515+
516+
require.Equalf(t, len(tt.nodes), readyLen+deadLen,
517+
"cycle %d: inventory connection count (ready=%d dead=%d)", cycle, readyLen, deadLen)
518+
require.Equalf(t, readyLen+deadLen, membersLen,
519+
"cycle %d: members map must match ready+dead", cycle)
520+
require.Lenf(t, inventory, len(tt.nodes),
521+
"cycle %d: one connection per node (no duplicates)", cycle)
522+
for _, expectedNode := range tt.expectedInInventory {
523+
_, present := inventory[expectedNode]
524+
require.Truef(t, present,
525+
"cycle %d: expected node %q in the connection inventory", cycle, expectedNode)
526+
}
501527
}
502528

503-
for _, expectedNode := range tt.expectedIncluded {
504-
_, ok := actualNodes[expectedNode]
505-
require.True(t, ok,
506-
"Expected node %q to be included but it wasn't", expectedNode)
529+
// Dedicated cluster managers stay in the inventory but must not be
530+
// handed out for routing. With no router, routing uses the inventory
531+
// pool's Next(), which skips them.
532+
for _, dcm := range tt.expectedNotRoutable {
533+
for i := 0; i < len(tt.nodes)*4; i++ {
534+
conn, nextErr := pool.Next()
535+
if nextErr != nil {
536+
break
537+
}
538+
require.NotEqual(t, dcm, conn.Name,
539+
"Dedicated cluster manager %q must not be selected for routing", dcm)
540+
}
507541
}
508-
509-
for _, excludedNode := range tt.expectedExcluded {
510-
_, ok := actualNodes[excludedNode]
511-
require.False(t, ok,
512-
"Expected node %q to be excluded but it was included", excludedNode)
513-
}
514-
515-
// Verify total count
516-
expectedTotal := len(tt.expectedIncluded)
517-
require.Len(t, actualNodes, expectedTotal,
518-
"Expected %d nodes but got %d", expectedTotal, len(actualNodes))
519542
})
520543
}
521544
}

0 commit comments

Comments
 (0)