Skip to content

Commit ca4569f

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 d006abf commit ca4569f

10 files changed

Lines changed: 440 additions & 336 deletions

opensearchtransport/discovery.go

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -984,27 +984,16 @@ func (c *Transport) createOrUpdateMultiNodePoolWithLock(readyConnections, deadCo
984984
return c.promoteConnectionPoolWithLock(readyConnections, deadConnections)
985985
}
986986

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

992-
for _, conn := range readyConnections {
993-
if !c.includeDedicatedClusterManagers && conn.Roles.isDedicatedClusterManager() {
994-
if dl := loadDebugLogger(); dl != nil {
995-
dl.Logf("Excluding dedicated cluster manager %q from connection pool\n", conn.Name)
996-
}
997-
continue
998-
}
999-
allReadyConns = append(allReadyConns, conn)
1000-
}
1001-
1002-
for _, conn := range deadConnections {
1003-
if !c.includeDedicatedClusterManagers && conn.Roles.isDedicatedClusterManager() {
1004-
continue
1005-
}
1006-
allDeadConns = append(allDeadConns, conn)
1007-
}
995+
allReadyConns = append(allReadyConns, readyConnections...)
996+
allDeadConns = append(allDeadConns, deadConnections...)
1008997

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

opensearchtransport/discovery_integration_internal_test.go

Lines changed: 119 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -335,44 +335,48 @@ func TestShouldSkipDedicatedClusterManagers(t *testing.T) {
335335
// TestDiscoverNodesWithNewRoleValidation verifies the enhanced discovery behavior
336336
func TestDiscoverNodesWithNewRoleValidation(t *testing.T) {
337337
tests := []struct {
338-
name string
339-
nodes map[string][]string // nodeName -> roles
340-
expectedNodes []string // nodes that should be included
341-
expectedSkipped []string // nodes that should be skipped
338+
name string
339+
nodes map[string][]string // nodeName -> roles
340+
// expectedInInventory lists nodes that must appear in the allConns pool,
341+
// which holds every discovered node regardless of role.
342+
expectedInInventory []string
343+
// expectedNotRoutable lists dedicated cluster managers that must not be
344+
// selected for request routing.
345+
expectedNotRoutable []string
342346
}{
343347
{
344348
"mixed node types with validation",
345349
map[string][]string{
346-
"cm-only": {RoleClusterManager}, // should be skipped
347-
"master-only": {RoleMaster}, // should be skipped
348-
"data-node": {RoleData}, // should be included
349-
"mixed-good": {RoleClusterManager, RoleData}, // should be included
350-
"search-only": {RoleSearch}, // should be included
350+
"cm-only": {RoleClusterManager}, // dedicated CM: not routable
351+
"master-only": {RoleMaster}, // dedicated CM: not routable
352+
"data-node": {RoleData}, // routable
353+
"mixed-good": {RoleClusterManager, RoleData}, // routable
354+
"search-only": {RoleSearch}, // routable
351355
},
352-
[]string{"data-node", "mixed-good", "search-only"},
356+
[]string{"cm-only", "master-only", "data-node", "mixed-good", "search-only"},
353357
[]string{"cm-only", "master-only"},
354358
},
355359
{
356360
"OpenSearch 3.X compliant setup",
357361
map[string][]string{
358-
"dedicated-cm": {RoleClusterManager}, // should be skipped
359-
"data-hot": {RoleData, RoleIngest}, // should be included
360-
"data-warm": {RoleWarm, RoleData}, // should be included
361-
"search-node": {RoleSearch}, // should be included
362-
"coordinating": {RoleCoordinatingOnly}, // should be included
362+
"dedicated-cm": {RoleClusterManager}, // dedicated CM: not routable
363+
"data-hot": {RoleData, RoleIngest}, // routable
364+
"data-warm": {RoleWarm, RoleData}, // routable
365+
"search-node": {RoleSearch}, // routable
366+
"coordinating": {RoleCoordinatingOnly}, // routable
363367
},
364-
[]string{"data-hot", "data-warm", "search-node", "coordinating"},
368+
[]string{"dedicated-cm", "data-hot", "data-warm", "search-node", "coordinating"},
365369
[]string{"dedicated-cm"},
366370
},
367371
{
368372
"cluster manager and remote cluster client filtering",
369373
map[string][]string{
370-
"cm-rcc": {RoleClusterManager, RoleRemoteClusterClient}, // should be skipped
371-
"cm-data": {RoleClusterManager, RoleData}, // should be included
372-
"rcc-only": {RoleRemoteClusterClient}, // should be included
373-
"data-node": {RoleData}, // should be included
374+
"cm-rcc": {RoleClusterManager, RoleRemoteClusterClient}, // dedicated CM: not routable
375+
"cm-data": {RoleClusterManager, RoleData}, // routable
376+
"rcc-only": {RoleRemoteClusterClient}, // routable
377+
"data-node": {RoleData}, // routable
374378
},
375-
[]string{"cm-data", "rcc-only", "data-node"},
379+
[]string{"cm-rcc", "cm-data", "rcc-only", "data-node"},
376380
[]string{"cm-rcc"},
377381
},
378382
}
@@ -398,29 +402,43 @@ func TestDiscoverNodesWithNewRoleValidation(t *testing.T) {
398402
require.Truef(t, ok, "Expected multiServerPool but got %T with URLs: %v",
399403
c.mu.connectionPool, c.mu.connectionPool.URLs())
400404

401-
// Check that expected nodes are included (in either ready or dead lists,
402-
// since newly discovered nodes start in dead state pending health checks)
403-
actualNodes := make(map[string]struct{})
405+
// allConns holds every discovered node regardless of role (in either the
406+
// ready or dead list, since newly discovered nodes start dead pending
407+
// health checks). Dedicated cluster managers stay in the inventory so
408+
// discovery can reuse and evict them symmetrically; they are excluded at
409+
// selection time instead.
410+
pool.mu.RLock()
411+
inventory := make(map[string]struct{}, len(pool.mu.ready)+len(pool.mu.dead))
404412
for _, conn := range pool.mu.ready {
405-
actualNodes[conn.Name] = struct{}{}
413+
inventory[conn.Name] = struct{}{}
406414
}
407415
for _, conn := range pool.mu.dead {
408-
actualNodes[conn.Name] = struct{}{}
416+
inventory[conn.Name] = struct{}{}
409417
}
418+
pool.mu.RUnlock()
410419

411-
require.Len(t, actualNodes, len(tt.expectedNodes),
412-
"Expected %d nodes but got %d: %v", len(tt.expectedNodes), len(actualNodes), actualNodes)
420+
require.Len(t, inventory, len(tt.expectedInInventory),
421+
"Expected %d nodes in inventory but got %d: %v",
422+
len(tt.expectedInInventory), len(inventory), inventory)
413423

414-
for _, expectedNode := range tt.expectedNodes {
415-
_, ok := actualNodes[expectedNode]
424+
for _, expectedNode := range tt.expectedInInventory {
425+
_, ok := inventory[expectedNode]
416426
require.True(t, ok,
417-
"Expected node %q to be included but it wasn't", expectedNode)
427+
"Expected node %q in the connection inventory but it wasn't", expectedNode)
418428
}
419429

420-
for _, skippedNode := range tt.expectedSkipped {
421-
_, ok := actualNodes[skippedNode]
422-
require.False(t, ok,
423-
"Expected node %q to be skipped but it was included", skippedNode)
430+
// Dedicated cluster managers stay in the inventory but must not be handed
431+
// out for routing. With no router, routing uses the inventory pool's
432+
// Next(), which skips them.
433+
for _, dcm := range tt.expectedNotRoutable {
434+
for i := 0; i < len(tt.nodes)*4; i++ {
435+
conn, nextErr := pool.Next()
436+
if nextErr != nil {
437+
break
438+
}
439+
require.NotEqual(t, dcm, conn.Name,
440+
"Dedicated cluster manager %q must not be selected for routing", dcm)
441+
}
424442
}
425443
})
426444
}
@@ -432,39 +450,43 @@ func TestIncludeDedicatedClusterManagersConfiguration(t *testing.T) {
432450
name string
433451
includeDedicatedClusterManagers bool
434452
nodes map[string][]string // nodeName -> roles
435-
expectedIncluded []string // nodes that should be included
436-
expectedExcluded []string // nodes that should be excluded
453+
// expectedInInventory lists nodes that must appear in the allConns pool,
454+
// which holds every discovered node regardless of role.
455+
expectedInInventory []string
456+
// expectedNotRoutable lists dedicated cluster managers that must not be
457+
// selected for request routing.
458+
expectedNotRoutable []string
437459
}{
438460
{
439-
name: "IncludeDedicatedClusterManagers enabled - includes all nodes",
461+
name: "IncludeDedicatedClusterManagers enabled - all nodes routable",
440462
includeDedicatedClusterManagers: true,
441463
nodes: map[string][]string{
442464
"cm-only": {RoleClusterManager},
443465
"data-node": {RoleData},
444466
},
445-
expectedIncluded: []string{"cm-only", "data-node"},
446-
expectedExcluded: []string{},
467+
expectedInInventory: []string{"cm-only", "data-node"},
468+
expectedNotRoutable: []string{},
447469
},
448470
{
449-
name: "IncludeDedicatedClusterManagers disabled (default) - excludes dedicated CM nodes",
471+
name: "IncludeDedicatedClusterManagers disabled (default) - dedicated CM in inventory but not routable",
450472
includeDedicatedClusterManagers: false,
451473
nodes: map[string][]string{
452474
"cm-only": {RoleClusterManager},
453475
"data-node": {RoleData},
454476
"dummy": {RoleData}, // Add second node to avoid single connection pool
455477
},
456-
expectedIncluded: []string{"data-node", "dummy"},
457-
expectedExcluded: []string{"cm-only"},
478+
expectedInInventory: []string{"cm-only", "data-node", "dummy"},
479+
expectedNotRoutable: []string{"cm-only"},
458480
},
459481
{
460-
name: "Mixed roles with CM always included regardless of setting",
482+
name: "Mixed roles with CM always routable regardless of setting",
461483
includeDedicatedClusterManagers: false,
462484
nodes: map[string][]string{
463485
"cm-data": {RoleClusterManager, RoleData},
464486
"dummy": {RoleData}, // Add second node to avoid single connection pool
465487
},
466-
expectedIncluded: []string{"cm-data", "dummy"},
467-
expectedExcluded: []string{},
488+
expectedInInventory: []string{"cm-data", "dummy"},
489+
expectedNotRoutable: []string{},
468490
},
469491
}
470492

@@ -481,41 +503,60 @@ func TestIncludeDedicatedClusterManagersConfiguration(t *testing.T) {
481503
})
482504
require.NoError(t, err)
483505

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{}{}
506+
// Run discovery repeatedly. The inventory must converge to exactly one
507+
// connection per node and stay there: an unbounded pool that re-created
508+
// connections each cycle (the dedicated-cluster-manager leak) would grow
509+
// with every iteration. Asserting the exact length on every cycle is the
510+
// regression guard.
511+
const cycles = 5
512+
var pool *multiServerPool
513+
for cycle := 1; cycle <= cycles; cycle++ {
514+
require.NoErrorf(t, c.DiscoverNodes(t.Context()), "discovery cycle %d", cycle)
515+
516+
var ok bool
517+
pool, ok = c.mu.connectionPool.(*multiServerPool)
518+
require.Truef(t, ok, "Expected multiServerPool but got %T with URLs: %v",
519+
c.mu.connectionPool, c.mu.connectionPool.URLs())
520+
521+
pool.mu.RLock()
522+
readyLen := len(pool.mu.ready)
523+
deadLen := len(pool.mu.dead)
524+
membersLen := len(pool.mu.members)
525+
inventory := make(map[string]struct{}, readyLen+deadLen)
526+
for _, conn := range pool.mu.ready {
527+
inventory[conn.Name] = struct{}{}
528+
}
529+
for _, conn := range pool.mu.dead {
530+
inventory[conn.Name] = struct{}{}
531+
}
532+
pool.mu.RUnlock()
533+
534+
require.Equalf(t, len(tt.nodes), readyLen+deadLen,
535+
"cycle %d: inventory connection count (ready=%d dead=%d)", cycle, readyLen, deadLen)
536+
require.Equalf(t, readyLen+deadLen, membersLen,
537+
"cycle %d: members map must match ready+dead", cycle)
538+
require.Lenf(t, inventory, len(tt.nodes),
539+
"cycle %d: one connection per node (no duplicates)", cycle)
540+
for _, expectedNode := range tt.expectedInInventory {
541+
_, present := inventory[expectedNode]
542+
require.Truef(t, present,
543+
"cycle %d: expected node %q in the connection inventory", cycle, expectedNode)
544+
}
501545
}
502546

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)
507-
}
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)
547+
// Dedicated cluster managers stay in the inventory but must not be
548+
// handed out for routing. With no router, routing uses the inventory
549+
// pool's Next(), which skips them.
550+
for _, dcm := range tt.expectedNotRoutable {
551+
for i := 0; i < len(tt.nodes)*4; i++ {
552+
conn, nextErr := pool.Next()
553+
if nextErr != nil {
554+
break
555+
}
556+
require.NotEqual(t, dcm, conn.Name,
557+
"Dedicated cluster manager %q must not be selected for routing", dcm)
558+
}
513559
}
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))
519560
})
520561
}
521562
}

0 commit comments

Comments
 (0)