@@ -11,6 +11,7 @@ package opensearchtransport_test
1111import (
1212 "context"
1313 "encoding/json"
14+ "fmt"
1415 "net/http"
1516 "sync"
1617 "testing"
@@ -166,18 +167,70 @@ func drainWarmup(transport *opensearchtransport.Transport) {
166167 }
167168}
168169
170+ // poolCounts breaks a Metrics snapshot down by the states that matter for cap
171+ // enforcement. ActiveListCap bounds only the fully-warmed active connections;
172+ // warming connections are allowed in the active partition on top of the cap
173+ // (see enforceActiveCapWithLock). Callers therefore have to compare against
174+ // warmedActive rather than the raw active count.
175+ type poolCounts struct {
176+ warmedActive int // active and finished warmup -- the population the cap bounds
177+ warmingActive int // active but still warming up -- exempt from the cap
178+ standby int
179+ dead int
180+ }
181+
182+ func (p poolCounts ) active () int { return p .warmedActive + p .warmingActive }
183+
184+ func (p poolCounts ) String () string {
185+ return fmt .Sprintf ("active=%d (warmed=%d, warming=%d), standby=%d, dead=%d" ,
186+ p .active (), p .warmedActive , p .warmingActive , p .standby , p .dead )
187+ }
188+
189+ // countPool classifies every connection in a Metrics snapshot. It reads the
190+ // per-connection state flags instead of deriving active from
191+ // LiveConnections-StandbyConnections, because that subtraction cannot
192+ // distinguish warmed from warming active connections.
193+ func countPool (m opensearchtransport.Metrics ) poolCounts {
194+ var p poolCounts
195+ for _ , raw := range m .Connections {
196+ cm , ok := raw .(opensearchtransport.ConnectionMetric )
197+ if ! ok {
198+ continue
199+ }
200+ switch {
201+ case cm .IsDead :
202+ p .dead ++
203+ case cm .IsStandby :
204+ p .standby ++
205+ case cm .IsWarmingUp :
206+ p .warmingActive ++
207+ default :
208+ p .warmedActive ++
209+ }
210+ }
211+ return p
212+ }
213+
169214// discoverWithStandby runs DiscoverNodes then pumps requests to complete
170- // warmup so that deferred cap enforcement can fire. Retries until the expected
171- // number of standby connections is reached or the deadline expires.
215+ // warmup so that deferred cap enforcement can fire. Retries until cap
216+ // enforcement has settled (warmed-active connections down to the cap, with the
217+ // remaining nodes parked in standby or warming) or the deadline expires.
172218// Returns the final metrics. Handles transient discovery failures (e.g., EOF)
173219// by retrying. Each cycle's DiscoverNodes + drainWarmup provides natural
174220// backoff without explicit sleeps.
221+ //
222+ // The pool can legitimately settle with more than one active connection: a
223+ // request that finds no usable active connection promotes a standby through the
224+ // duress path in Next(), which intentionally ignores ActiveListCap, and the
225+ // following rotation re-promotes with warmup. Waiting on standby >= 2 therefore
226+ // deadlocks whenever that happens, so we wait on the invariant the pool
227+ // actually maintains: warmedActive <= cap.
175228func discoverWithStandby (t * testing.T , transport * opensearchtransport.Transport ) opensearchtransport.Metrics {
176229 t .Helper ()
177230
178231 var m opensearchtransport.Metrics
179232 var lastErr error
180- var lastActive , lastStandby , lastDead int
233+ var last poolCounts
181234
182235 // On slower clusters (e.g. v2.0.1 with security plugin), health checks
183236 // during discovery can take several seconds per cycle. Allow up to 30s
@@ -211,15 +264,16 @@ func discoverWithStandby(t *testing.T, transport *opensearchtransport.Transport)
211264 m , err = transport .Metrics ()
212265 require .NoError (t , err )
213266
214- active := m . LiveConnections - m . StandbyConnections
267+ counts := countPool ( m )
215268 // Only log when state changes to reduce CI noise.
216- if active != lastActive || m .StandbyConnections != lastStandby || m .DeadConnections != lastDead {
217- t .Logf ("Discovery attempt %d: active=%d, standby=%d, dead=%d" ,
218- attempt , active , m .StandbyConnections , m .DeadConnections )
219- lastActive , lastStandby , lastDead = active , m .StandbyConnections , m .DeadConnections
269+ if counts != last {
270+ t .Logf ("Discovery attempt %d: %s" , attempt , counts )
271+ last = counts
220272 }
221273
222- if m .StandbyConnections >= 2 {
274+ // Settled once the cap is satisfied and the nodes it displaced are
275+ // accounted for outside the warmed-active set.
276+ if counts .warmedActive <= 1 && counts .standby + counts .warmingActive >= 2 {
223277 return m
224278 }
225279
@@ -236,8 +290,7 @@ func discoverWithStandby(t *testing.T, transport *opensearchtransport.Transport)
236290 require .NoError (t , lastErr , "all discovery attempts failed" )
237291 }
238292 require .FailNowf (t , "discoverWithStandby timed out" ,
239- "pool did not reach 2 standby after %d attempts (active=%d, standby=%d, dead=%d)" ,
240- attempt , lastActive , lastStandby , lastDead )
293+ "cap enforcement did not settle after %d attempts (%s)" , attempt , last )
241294 return m // unreachable
242295}
243296
@@ -273,12 +326,19 @@ func TestStandbyRotation(t *testing.T) {
273326 // Discovery also enforces the active cap and runs rotation.
274327 m := discoverWithStandby (t , transport )
275328
276- // With 3 discovered nodes and cap=1, we expect 1 active + 2 standby.
277- activeCount := m .LiveConnections - m .StandbyConnections
278- require .Equal (t , 1 , activeCount , "expected 1 active connection (ActiveListCap=1)" )
279- require .Equal (t , 2 , m .StandbyConnections , "expected 2 standby connections" )
280-
281- // Verify per-connection metrics show standby flags
329+ // With 3 discovered nodes and cap=1, at most one connection may be
330+ // active-and-warmed; the rest are standby, warming, or dead. The active
331+ // connection can itself still be warming (warmed=0), which is a valid
332+ // settled state, so the cap is an upper bound rather than equality.
333+ counts := countPool (m )
334+ require .LessOrEqual (t , counts .warmedActive , 1 ,
335+ "fully-warmed active connections must not exceed ActiveListCap=1, got %s" , counts )
336+ require .GreaterOrEqual (t , counts .active (), 1 ,
337+ "expected at least one active connection, got %s" , counts )
338+ require .GreaterOrEqual (t , counts .standby + counts .warmingActive , 2 ,
339+ "expected the 2 capped-out nodes outside the warmed-active set, got %s" , counts )
340+
341+ // Verify per-connection metrics agree with the standby total.
282342 standbyCount := 0
283343 for _ , conn := range m .Connections {
284344 cm , ok := conn .(opensearchtransport.ConnectionMetric )
@@ -289,9 +349,13 @@ func TestStandbyRotation(t *testing.T) {
289349 standbyCount ++
290350 }
291351 }
292- require .Equal (t , 2 , standbyCount , "expected 2 connections marked as standby in per-connection metrics" )
352+ require .Equal (t , counts .standby , standbyCount ,
353+ "per-connection standby flags should match the classified standby count" )
293354
294- // Perform requests -- only the active connection should serve them
355+ // Perform requests -- only active connections should serve them. A
356+ // connection still warming is exempt from the cap and can legitimately
357+ // take traffic, so the bound is the active partition size (re-read after
358+ // the requests, since warmup completion can shift it), not a single node.
295359 nodesSeen := make (map [string ]bool )
296360 for range 6 {
297361 req , err := http .NewRequest (http .MethodGet , "/" , nil )
@@ -309,9 +373,13 @@ func TestStandbyRotation(t *testing.T) {
309373 nodesSeen [info .Name ] = true
310374 }
311375
312- // With cap=1, all requests should hit the same single active node
313- require .Len (t , nodesSeen , 1 , "expected all requests to hit the same active node, saw: %v" , nodesSeen )
314- t .Logf ("Active node: %v" , nodesSeen )
376+ after , err := transport .Metrics ()
377+ require .NoError (t , err )
378+ afterCounts := countPool (after )
379+ require .LessOrEqual (t , len (nodesSeen ), max (counts .active (), afterCounts .active ()),
380+ "requests reached more nodes than were ever active: saw %v (before: %s, after: %s)" ,
381+ nodesSeen , counts , afterCounts )
382+ t .Logf ("Nodes serving traffic: %v (pool: %s)" , nodesSeen , afterCounts )
315383 })
316384
317385 t .Run ("Rotation swaps standby into active" , func (t * testing.T ) {
@@ -327,7 +395,9 @@ func TestStandbyRotation(t *testing.T) {
327395 // Cap enforcement during this phase fires OnStandbyDemote for the 2
328396 // connections demoted to standby, but no OnStandbyPromote events yet.
329397 m0 := discoverWithStandby (t , transport )
330- require .Equal (t , 2 , m0 .StandbyConnections , "need 2 standby to test rotation" )
398+ counts0 := countPool (m0 )
399+ require .GreaterOrEqual (t , counts0 .standby + counts0 .warmingActive , 2 ,
400+ "need 2 non-warmed-active connections to test rotation, got %s" , counts0 )
331401
332402 // Record which node is currently active
333403 req , err := http .NewRequest (http .MethodGet , "/" , nil )
@@ -396,11 +466,26 @@ func TestStandbyRotation(t *testing.T) {
396466 // Verify pool state from the demotion event snapshot. The observer captures
397467 // counts at the exact moment enforceActiveCapWithLock runs (pool lock held).
398468 // A transient request failure during drainWarmup can move a connection to
399- // dead, reducing the standby count below 2. The invariant that matters is
400- // that cap enforcement set activeCount to the cap (1).
469+ // dead, reducing the standby count below 2.
470+ //
471+ // ActiveCount in the event counts every connection carrying lcActive,
472+ // including ones still warming up, and warming connections sit in the
473+ // active partition on top of the cap. So the event can legitimately report
474+ // active=2 with cap=1. What the event does prove is that enforcement ran
475+ // and parked a connection in standby.
401476 t .Logf ("Demotion snapshot: active=%d, standby=%d, dead=%d" ,
402477 obs .lastDemotionActiveCount (), obs .lastDemotionStandbyCount (), obs .lastDemotionDeadCount ())
403- require .Equal (t , 1 , obs .lastDemotionActiveCount (), "active count should equal cap after enforcement" )
478+ require .GreaterOrEqual (t , obs .lastDemotionActiveCount (), 1 ,
479+ "cap enforcement should leave at least one active connection" )
480+ require .GreaterOrEqual (t , obs .lastDemotionStandbyCount (), 1 ,
481+ "cap enforcement should have moved at least one connection to standby" )
482+
483+ // The cap invariant itself is only checkable against fully-warmed
484+ // connections, which the event does not distinguish. Assert it on a
485+ // settled metrics snapshot instead.
486+ settled := countPool (discoverWithStandby (t , transport ))
487+ require .LessOrEqual (t , settled .warmedActive , 1 ,
488+ "fully-warmed active connections must not exceed ActiveListCap=1, got %s" , settled )
404489
405490 // Verify the active connection works for real requests (was warmed up before serving).
406491 // Use GET / instead of /_cluster/health because the cluster health endpoint
0 commit comments