Skip to content

Commit 71ca7c2

Browse files
committed
fix(opensearchtransport): don't let unverified discovered nodes mask seed fallback
Node discovery inserts freshly discovered connections into a policy pool's dead list before they are ever health-checked (createConnection sets lcDead|lcNeedsWarmup|lcNeedsHardware). Each policy cached its "enabled" bit as len(ready)+len(dead) > 0, so a policy holding only never-verified discovered connections still reported enabled. Route then handed the request to the pool, which served the never-verified connection as a zombie (tryZombieWithLock) rather than returning ErrNoConnections -- so the request never cascaded to the user-supplied seed-URL fallback (which only triggers on ErrNoConnections). When discovered publish_addresses are unroutable from the client (NAT'd or misconfigured clusters, as seen in CI against a k8s stack cluster), this hijacks the request stream onto dead pod IPs and every request fails with "no route to host", even though the seed URL is reachable. Compute the enabled bit from availability instead of presence: a connection is available for routing if it is a user-supplied seed (assumed reachable) or a discovered connection confirmed reachable (lcNeedsHardware cleared). A dead, never-verified discovered connection no longer counts, so the policy reports not-enabled, Route returns ErrNoConnections, and the seed fallback serves the request until a discovered node health-checks clean and takes over. - Add Connection.seed (immutable, set at the two seed construction sites) and Connection.availableForRouting(). - Add multiServerPool.hasAvailableConnsWithLock(); use it for the enabled bit in the round-robin, role, and coordinator policies, and apply the same per-connection check in the index/doc router's flat active list. Genuinely-once-healthy dead connections (OnFailure re-sets lcNeedsHardware, but they are re-verified on the next health check) and steady-state zombie behavior are unchanged; seeds are always available so the fallback path is preserved.
1 parent 9cb06c1 commit 71ca7c2

10 files changed

Lines changed: 544 additions & 14 deletions

opensearchtransport/connection.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,16 @@ type Connection struct {
233233
lastAt time.Time
234234
}
235235
}
236+
237+
// seed marks a connection built from a user-supplied Config.URLs address
238+
// (as opposed to one learned from node discovery). Immutable after
239+
// construction; set only at the seed-connection construction sites in
240+
// opensearchtransport.New. Seed connections are assumed reachable and are
241+
// always available for routing (see availableForRouting) so they can serve
242+
// as the last-resort fallback when no discovered node is verified. The zero
243+
// value (false) means "not a seed", so any connection not explicitly minted
244+
// as a seed is treated as needing verification.
245+
seed bool
236246
}
237247

238248
// timeToNano converts a time.Time to its Unix-nanosecond representation. The
@@ -338,6 +348,26 @@ func (c *Connection) markAsHealthyWithLock() {
338348
c.failures.Store(0)
339349
}
340350

351+
// availableForRouting reports whether this connection may be routed to when a
352+
// policy decides if it has anything worth serving. A seed connection (one built
353+
// from a user-supplied Config.URLs address) is always available: the user
354+
// asserted it, and it is the last-resort fallback target. Any other connection
355+
// -- discovered, or a zero-value/test connection -- is available only once
356+
// confirmed reachable, keyed off the lcNeedsHardware lifecycle bit. That bit is
357+
// set at discovery (createConnection) and on every failure (OnFailure), and
358+
// cleared only by a successful hardware/health check. A discovered connection
359+
// still carrying lcNeedsHardware has never been proven reachable (a node whose
360+
// publish_address may be unroutable, e.g. a NAT'd or misconfigured cluster);
361+
// counting it as available would let it mask the seed-URL fallback. Because the
362+
// bit is unset on a zero-value connection, connections not minted by discovery
363+
// default to available. Lock-free (single atomic load).
364+
func (c *Connection) availableForRouting() bool {
365+
if c.seed {
366+
return true // user-supplied seed: assumed reachable
367+
}
368+
return !c.loadConnState().lifecycle().has(lcNeedsHardware)
369+
}
370+
341371
// RTTMedian returns the median health-check round-trip time for this connection.
342372
// Returns -1 if no RTT data is available (the connection has not completed
343373
// enough health checks for the ring buffer median to drop below the unknown

opensearchtransport/opensearchtransport.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,7 @@ func New(cfg Config) (*Transport, error) {
830830

831831
conns := make([]*Connection, len(cfg.URLs))
832832
for idx, u := range cfg.URLs {
833-
conn := &Connection{URL: u, URLString: u.String()}
833+
conn := &Connection{URL: u, URLString: u.String(), seed: true}
834834
conn.estLoad.clock = realClock{}
835835
conn.weight.Store(1)
836836
conns[idx] = conn
@@ -982,7 +982,7 @@ func New(cfg Config) (*Transport, error) {
982982
if !client.seedFallbackDisabled && len(cfg.URLs) > 0 {
983983
seedConns := make([]*Connection, len(cfg.URLs))
984984
for i, u := range cfg.URLs {
985-
conn := &Connection{URL: u, URLString: u.String()}
985+
conn := &Connection{URL: u, URLString: u.String(), seed: true}
986986
conn.estLoad.clock = realClock{}
987987
conn.weight.Store(1)
988988
conn.mu.Lock()

opensearchtransport/policy_cluster_coordinator.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,10 @@ func (p *CoordinatorPolicy) DiscoveryUpdate(added, removed, unchanged []*Connect
216216
}
217217
}
218218

219-
// Update cached state
220-
hasCoords := len(p.pool.mu.ready) > 0 || len(p.pool.mu.dead) > 0
221-
psSetEnabled(&p.policyState, hasCoords)
219+
// Update cached state. Dead-but-never-verified connections do not count as
220+
// available so the request cascades to the seed fallback rather than being
221+
// served as a zombie.
222+
psSetEnabled(&p.policyState, p.pool.hasAvailableConnsWithLock())
222223

223224
return nil
224225
}

opensearchtransport/policy_index_router.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,18 @@ func routerDiscoveryUpdate(
359359
*activeConns = all
360360
mu.Unlock()
361361

362-
psSetEnabled(policyState, len(all) > 0)
362+
// A router policy is enabled only when it has a connection available for
363+
// routing -- a user-supplied seed, or a discovered connection confirmed
364+
// reachable. A never-verified discovered connection does not count, so the
365+
// request cascades to the seed fallback rather than being served as a zombie.
366+
hasAvailable := false
367+
for _, c := range all {
368+
if c.availableForRouting() {
369+
hasAvailable = true
370+
break
371+
}
372+
}
373+
psSetEnabled(policyState, hasAvailable)
363374

364375
return nil
365376
}

opensearchtransport/policy_role.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,10 @@ func (p *RolePolicy) discoveryUpdateAdd(added []*Connection) {
253253
p.pool.appendToDeadWithLock(conn)
254254
}
255255

256-
// Update hasMatching state while holding the lock
257-
hasConnections := len(p.pool.mu.ready) > 0 || len(p.pool.mu.dead) > 0
258-
psSetEnabled(&p.policyState, hasConnections)
256+
// Update hasMatching state while holding the lock. Dead-but-never
257+
// -verified connections do not count as serviceable so the request
258+
// cascades to the seed fallback rather than being served as a zombie.
259+
psSetEnabled(&p.policyState, p.pool.hasAvailableConnsWithLock())
259260

260261
p.pool.Unlock()
261262
}
@@ -346,9 +347,10 @@ func (p *RolePolicy) discoveryUpdateRemove(removed []*Connection) {
346347
gap := activeCountBefore - p.pool.mu.activeCount
347348
p.pool.promoteStandbyGracefullyWithLock(p.pool.poolCtx(), gap)
348349

349-
// Update hasMatching state while holding the lock
350-
hasConnections := len(p.pool.mu.ready) > 0 || len(p.pool.mu.dead) > 0
351-
psSetEnabled(&p.policyState, hasConnections)
350+
// Update hasMatching state while holding the lock. Dead-but-never-verified
351+
// connections do not count as available so the request cascades to the seed
352+
// fallback rather than being served as a zombie.
353+
psSetEnabled(&p.policyState, p.pool.hasAvailableConnsWithLock())
352354
p.pool.Unlock()
353355
}
354356

opensearchtransport/policy_roundrobin.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,12 @@ func (p *RoundRobinPolicy) DiscoveryUpdate(added, removed, unchanged []*Connecti
196196
}
197197
}
198198

199-
// Update cached enabled state
200-
psSetEnabled(&p.policyState, len(p.pool.mu.ready)+len(p.pool.mu.dead) > 0)
199+
// Update cached enabled state. A policy is enabled only when it has a
200+
// connection worth routing to -- a ready connection, or a dead connection
201+
// that has been verified at least once. Dead-but-never-verified connections
202+
// (freshly discovered, possibly unroutable) do not count, so the request
203+
// cascades to the seed fallback instead of being served as a zombie.
204+
psSetEnabled(&p.policyState, p.pool.hasAvailableConnsWithLock())
201205

202206
return nil
203207
}

opensearchtransport/pool_multi_server.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,33 @@ func (cp *multiServerPool) countByLifecycleWithLock() lifecycleCounts {
283283
return counts
284284
}
285285

286+
// hasAvailableConnsWithLock reports whether the pool holds any connection that
287+
// is available for routing (see Connection.availableForRouting): any ready
288+
// connection, or any dead connection that is either a user-supplied seed or a
289+
// discovered connection already confirmed reachable.
290+
//
291+
// A dead, never-verified discovered connection does not count. Counting it as
292+
// available is what lets an unreachable discovered node mask the seed-URL
293+
// fallback: the policy reports "enabled", Route hands the request to the pool,
294+
// and the pool serves the never-verified connection as a zombie instead of
295+
// returning ErrNoConnections. Excluding it here makes the policy report
296+
// "not enabled" so the request cascades to the seed fallback until a discovered
297+
// connection actually proves reachable.
298+
//
299+
// CALLER RESPONSIBILITIES:
300+
// - Caller must hold the pool lock (read or write).
301+
func (cp *multiServerPool) hasAvailableConnsWithLock() bool {
302+
if len(cp.mu.ready) > 0 {
303+
return true
304+
}
305+
for _, c := range cp.mu.dead {
306+
if c.availableForRouting() {
307+
return true
308+
}
309+
}
310+
return false
311+
}
312+
286313
// recalculateWarmupParams recalculates activeListCap (when auto-scaling) and sets
287314
// warmupRounds/warmupSkipCount based on effective pool size.
288315
//
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
//
3+
// The OpenSearch Contributors require contributions made to
4+
// this file be licensed under the Apache-2.0 license or a
5+
// compatible open source license.
6+
7+
package opensearchtransport
8+
9+
import (
10+
"net/url"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// TestAvailableForRouting covers the per-connection predicate that decides
17+
// whether a connection may satisfy a policy's "has anything to serve" check.
18+
// Seeds are always available; discovered connections are available only once
19+
// they have been verified (lcNeedsHardware cleared).
20+
func TestAvailableForRouting(t *testing.T) {
21+
newConn := func(seed bool, lc connLifecycle) *Connection {
22+
c := &Connection{URL: &url.URL{Scheme: "http", Host: "n:9200"}, seed: seed}
23+
c.state.Store(int64(newConnState(lc)))
24+
return c
25+
}
26+
27+
tests := []struct {
28+
name string
29+
conn *Connection
30+
want bool
31+
}{
32+
{
33+
name: "seed dead and never verified is still available",
34+
conn: newConn(true, lcDead|lcNeedsWarmup|lcNeedsHardware),
35+
want: true,
36+
},
37+
{
38+
name: "seed active is available",
39+
conn: newConn(true, lcActive),
40+
want: true,
41+
},
42+
{
43+
name: "discovered never verified is not available",
44+
conn: newConn(false, lcDead|lcNeedsWarmup|lcNeedsHardware),
45+
want: false,
46+
},
47+
{
48+
name: "discovered verified (hardware cleared) is available",
49+
conn: newConn(false, lcDead|lcNeedsWarmup),
50+
want: true,
51+
},
52+
{
53+
name: "zero-value (non-seed, no hardware bit) defaults to available",
54+
conn: &Connection{URL: &url.URL{Scheme: "http", Host: "n:9200"}},
55+
want: true,
56+
},
57+
}
58+
59+
for _, tt := range tests {
60+
t.Run(tt.name, func(t *testing.T) {
61+
require.Equal(t, tt.want, tt.conn.availableForRouting())
62+
})
63+
}
64+
}
65+
66+
// TestHasAvailableConnsWithLock covers the pool-level predicate that policies
67+
// use to set their enabled bit. A pool whose only dead connections are
68+
// never-verified discovered nodes must report "no available connections" so the
69+
// request cascades to the seed fallback.
70+
func TestHasAvailableConnsWithLock(t *testing.T) {
71+
seedURL := &url.URL{Scheme: "http", Host: "seed:9200"}
72+
discURL := &url.URL{Scheme: "http", Host: "10.0.0.1:9200"}
73+
74+
deadDiscovered := func() *Connection {
75+
c := &Connection{URL: discURL, URLString: discURL.String()}
76+
c.state.Store(int64(newConnState(lcDead | lcNeedsWarmup | lcNeedsHardware)))
77+
return c
78+
}
79+
deadSeed := func() *Connection {
80+
c := &Connection{URL: seedURL, URLString: seedURL.String(), seed: true}
81+
c.state.Store(int64(newConnState(lcDead | lcNeedsWarmup | lcNeedsHardware)))
82+
return c
83+
}
84+
verifiedDiscovered := func() *Connection {
85+
c := &Connection{URL: discURL, URLString: discURL.String()}
86+
c.state.Store(int64(newConnState(lcDead | lcNeedsWarmup))) // hardware cleared
87+
return c
88+
}
89+
90+
t.Run("only never-verified discovered dead conns -> not available", func(t *testing.T) {
91+
cp := &multiServerPool{name: "test"}
92+
cp.mu.dead = []*Connection{deadDiscovered(), deadDiscovered()}
93+
require.False(t, cp.hasAvailableConnsWithLock())
94+
})
95+
96+
t.Run("a dead seed keeps the pool available", func(t *testing.T) {
97+
cp := &multiServerPool{name: "test"}
98+
cp.mu.dead = []*Connection{deadDiscovered(), deadSeed()}
99+
require.True(t, cp.hasAvailableConnsWithLock())
100+
})
101+
102+
t.Run("a verified discovered dead conn keeps the pool available", func(t *testing.T) {
103+
cp := &multiServerPool{name: "test"}
104+
cp.mu.dead = []*Connection{deadDiscovered(), verifiedDiscovered()}
105+
require.True(t, cp.hasAvailableConnsWithLock())
106+
})
107+
108+
t.Run("any ready conn keeps the pool available", func(t *testing.T) {
109+
cp := &multiServerPool{name: "test"}
110+
ready := &Connection{URL: discURL, URLString: discURL.String()}
111+
ready.state.Store(int64(newConnState(lcActive)))
112+
cp.mu.ready = []*Connection{ready}
113+
require.True(t, cp.hasAvailableConnsWithLock())
114+
})
115+
116+
t.Run("empty pool is not available", func(t *testing.T) {
117+
cp := &multiServerPool{name: "test"}
118+
require.False(t, cp.hasAvailableConnsWithLock())
119+
})
120+
}

0 commit comments

Comments
 (0)