@@ -335,44 +335,48 @@ func TestShouldSkipDedicatedClusterManagers(t *testing.T) {
335335// TestDiscoverNodesWithNewRoleValidation verifies the enhanced discovery behavior
336336func 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