Skip to content

Commit e70b2bd

Browse files
ryanyuansean-
andauthored
test(opensearchtransport): assert the warmed-active cap invariant in TestStandbyRotation (#1031)
TestStandbyRotation asserted that ActiveListCap=1 leaves exactly 1 active and 2 standby connections. That is not the invariant the pool maintains, so the test failed ~5 of 6 runs against a local 3-node cluster while passing in CI. ActiveListCap bounds only fully-warmed connections: enforceActiveCapWithLock computes newActiveCount as warmCount + activeListCap (pool_standby.go:195), so connections still warming sit in the active partition on top of the cap. Every DiscoverNodes triggers rotateStandby, which promotes a standby with warmup, so a pool at cap=1 routinely reports active=2 as warmed=1 plus warming=1. Locally that state persists. A request that finds no usable active connection takes the duress path in tryStandbyWithLock (pool_standby.go:249), which pulls in a standby and leaves the pool with one fewer node parked in standby. Cap enforcement brings warmed-active back to 1, but the next rotation promotes another connection with warmup, so standby oscillates between 1 and 2 rather than settling at 2. discoverWithStandby waited for StandbyConnections >= 2, which could be missed indefinitely, so it exhausted its 30s context and returned partial metrics instead of reaching its own require.FailNowf. The reported failure therefore surfaced at the caller's assertion rather than in the helper. CI is fast enough never to enter the duress path. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> Co-authored-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 18203d2 commit e70b2bd

1 file changed

Lines changed: 111 additions & 26 deletions

File tree

opensearchtransport/standby_rotation_integration_test.go

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ package opensearchtransport_test
1111
import (
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.
175228
func 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

Comments
 (0)