mirror of
https://github.com/redis/go-redis.git
synced 2026-10-22 19:49:00 +03:00
* feat(cluster): add RouteByLatencyTolerance to spread reads across equally-close nodes RouteByLatency picks the strict minimum, so when several nodes are effectively equidistant every client converges on the same one and the rest take no read traffic. Latency is the mean of ten pings of a sub-millisecond operation, refreshed at most every 10s, so a difference well inside the measurement noise can decide the whole read load for the next interval. Observed on a sentinel-backed failover cluster client spread over three availability zones: GET rates across a five-replica set ran 97.7, 71.4, 7.6, 4.3 and 0.17 per second - a 590x spread - while per-node CPU stayed flat, because the split follows the ranking rather than any real difference in capacity. Zone locality worked; the distribution within a zone did not. RouteByLatencyTolerance widens the selection from "the fastest node" to "any node within this much of the fastest", round-robining between them with the existing ShardPicker. Nodes an availability zone away sit far outside any sensible tolerance, so locality is preserved. Zero, the default, keeps the current strict-minimum behaviour, and the option has no effect unless RouteByLatency is set. Also plumbed through FailoverOptions so sentinel users can reach it. Also fixes a latent bug in the same function: closestNonFailingNode was only assigned while scanning for a new minimum, so a healthy node that was not the outright fastest could be skipped entirely, and selection fell through to the all-nodes-are-failing path even though a healthy node existed. Healthy and overall minima are now tracked separately, covered by TestSlotClosestNodePrefersSlowerHealthyNode. * fix(cluster): sample latency once, and expose tolerance through the remaining option paths Addresses the review on #3973. The tolerance filter read Latency() a second time, after the pass that computed the minimum. The background probe updates latency concurrently, so the fastest node could move above minLatency+tolerance between the two reads, leaving the candidate set empty. slotClosestNode then returned (nil, nil) and callers dereferenced node.Client. Latency and health are now sampled once per node and the filter uses the captured values, so the candidate set always contains at least the node that established the minimum. The comparison is also written as a subtraction rather than minLatency+tolerance, which would overflow for a very large tolerance. TestSlotClosestNodeToleranceNeverReturnsNilWithHealthyNode churns latencies from a second goroutine while selecting, and fails on the previous two-pass implementation. The option is now also reachable from the three configuration paths that mirror RouteByLatency: route_by_latency_tolerance in ParseClusterURL and ParseFailoverURL - both parsers reject unknown keys, so the URL form previously failed outright - and UniversalOptions, which feeds both Cluster() and Failover(). * perf(cluster): skip tolerance sampling when the option is unset Addresses the review on #3973. The candidate slice was built on every command regardless of RouteByLatencyTolerance, then discarded by the tolerance <= 0 branch, so existing RouteByLatency users paid for work whose result was thrown away. Collection is now gated on tolerance > 0, and the slice is pre-sized when it is used, so the tolerance path does not grow it either. Latency is still read once per node in the same pass, so the guarantee from the previous commit holds: the candidate set cannot disagree with the minimum it is compared against. TestSlotClosestNodeToleranceDisabledDoesNotAllocate pins this with testing.AllocsPerRun. Without the gate it reports 3 allocations per call; with it, zero. * fix(cluster): keep tolerance rotation per slot Addresses the review on #3973. The tolerance path rotated through candidates with the single ClusterOptions.ShardPicker counter, which is shared by every slot. On a real cluster that counter is advanced by the other slots between two visits to the same one, so a regular interleaving leaves each slot landing on the same index every time - two slots with two candidates each alternate to 0,0,0... and 1,1,1..., and one replica per shard takes all of that shard's reads. The balancing this option exists to provide is then absent exactly where there is more than one slot to balance. Rotation now lives on clusterSlot, so each slot advances its own cursor. The state is rebuilt on every cluster-state reload, which is fine: it only decides which of several equally-close replicas answers next. slotEntry is factored out of slotNodes to reach the slot rather than just its nodes. TestSlotClosestNodeToleranceRotatesPerSlot alternates two slots and asserts both candidates are used in each. With a shared counter it reports 100/0 for the first slot. * fix(cluster): reduce the tolerance rotation cursor in the unsigned domain The per-slot rotation cursor is a uint32 and wraps. Converting it to int before the modulo is only safe while int is 64 bits: on a 32-bit build a cursor past 2^31 becomes negative, Go's % keeps the dividend's sign, and indexing candidates panics. Reproduced rather than reasoned about. With the cursor primed to math.MaxInt32, GOARCH=386 gives: panic: runtime error: index out of range [-1] osscluster.go:1068 Indexing with the uint32 directly keeps the reduction unsigned, and the same test passes on 386 and amd64. Only reachable once a slot has more than one in-band candidate and has served ~2^31 reads, so it needs both the tolerance option and a very hot slot -- but it is a hard panic in the read path, not degraded routing. * test(cluster): add the 6-node integration coverage for RouteByLatencyTolerance .claude/specs/cluster-routing.md:147-153 requires cluster-routing changes to add a test that spins the 6-node clusterScenario, uses keys verified to land on different shards, and exercises both the happy path and the redirect path. The tolerance option only had in-memory selector tests, so nothing checked it against real CLUSTER SLOTS and MOVED behaviour. Adds a Describe block mirroring the RouteByLatency one with a 1s tolerance, wide enough that every healthy node for a slot is in band and the per-slot rotation is what is under test. It calls assertClusterClient(), which already covers GET/SET/DEL, redirects for both via SwapNodes, and key distribution. The shard keys are discovered rather than hardcoded. keyN does not spread across the three masters -- key2 (slot 4998) and key3 (slot 935) both hash into 0-5460 -- and the first version of this test failed on exactly that. Hardcoding luckier keys would work today and silently stop covering three shards if the slot ranges ever moved, so the test walks candidates until it has one key per master and asserts it found three. Verified against the real cluster from docker-compose --profile all: 45 specs pass across both RouteByLatency blocks. * refactor(cluster): split the tolerance path out of slotClosestNode Addresses @ndyakov's review. slotClosestNode goes back to exactly what is on master - same signature, same body, byte-identical - and the tolerance logic moves to a new slotNodeWithinLatency(slot, tolerance). slotReadOnlyNode dispatches between them on RouteByLatencyTolerance > 0. This is better than the tolerance parameter it replaces: with the option unset, existing latency routing now runs literally the same code as before rather than the same code plus a branch, so there is nothing to argue about on the hot path. Also renames clusterSlot.rotation to nodesByLatencyRotation, so the field says what it is for. One consequence worth flagging: the earlier version of this PR also fixed a latent bug in slotClosestNode, where closestNonFailingNode is only updated inside the 'is this the new minimum' branch, so a healthy-but-slower node never wins over a faster failing one. Restoring the function byte-for-byte puts that bug back. It is pre-existing and unrelated to tolerance, so it does not belong in this PR - the test that covered it now points at slotNodeWithinLatency, which retains the fix. Happy to raise it separately. * fix(cluster): skip loading nodes in the tolerance band Codex caught a real gap. slotSlaveNode and slotShardPickerSlaveNode both filter on !Failing() && !Loading(); slotClosestNode only checks Failing(), and slotNodeWithinLatency inherited that. It matters more with a tolerance than without. Under a strict minimum a loading replica has to be the fastest node to be chosen at all; within a band it only has to be close, so widening the candidate set widens the window for picking a node that answers LOADING. And a replica is loading precisely after the full resync that a too-small replication backlog causes, so this is not a rare state. Filtered at the candidate stage rather than in the sampling pass, because Loading() costs a PING with a 100ms timeout whenever the node is not already known loaded - doing it per node per command would be a real cost, whereas doing it for the handful inside the band is bounded. If every candidate is loading the strict-minimum healthy node is returned, so the filter can never empty the set or return nil. Test helper now marks nodes loaded, which is what steady state looks like and keeps the suite off a 100ms dial per candidate. Added TestSlotNodeWithinLatencyKeepsUnconfirmedNode to pin the other direction: loaded=0 means 'not yet confirmed', not 'loading', and such a node must stay selectable - a connection error is not a LOADING reply. * fix(cluster): fall back outside the band when it is entirely loading Both Codex and Cursor Bugbot caught the same real bug in the loading filter I added, and they are right. When every node inside the tolerance band is loading, the fallback returned closestHealthyNode - but that node is the band minimum, so it is in the band and was just observed loading. The caller then gets -LOADING even though a ready node exists outside the band. With MaxRedirects=0 the request fails outright; with the default budget it can still exhaust retries when the band holds more loading nodes than there are retries. Now the exceptional path widens the search: it scans the healthy nodes outside the band and returns the closest ready one. In a multi-AZ layout that is typically the master, still serving while the local replicas reload together after a resync - which is exactly the shape this option is aimed at, and exactly what happens after the full resync a small replication backlog causes. The extra Loading() calls stay off the hot path. This branch is only reached when the whole band is loading, and the latency comparison is evaluated before Loading() so most candidates are skipped anyway. If nothing is ready anywhere it still returns closestHealthyNode, so the caller always gets a node to retry against - the behaviour this path had before the loading filter existed. * refactor(cluster): rename the rotation field to latencyBandNodeCursor Picking one and stopping, per @ndyakov. nodesByLatencyRotation was misleading in the way he first flagged: it reads as nodes ordered by latency, with the cursor walking them fastest-first. That is not what happens. Latency decides only which nodes are in the tolerance band; inside it they are treated as equally close and picked in turn. Swapping Rotation for Cursor fixes the 'what is this' half and leaves the misleading half in place, which is why nodesByLatencyCursor and nodeByLatencyCursor were not quite it either. latencyBandNodeCursor reads as: a cursor, over nodes, in the latency band. 'band' is already the vocabulary in the surrounding comments, the PR description and the review thread, so it is continuity rather than a fourth coinage. The field comment now says explicitly that latency governs membership and not order, so the next reader does not have to infer it. * fix(cluster): keep strict selection until a latency probe lands newClusterNodeWithNodeAddress stores math.MaxUint32 as each node's initial latency and measures asynchronously, so for the ~150-250ms until the first of ten probes completes every node reports the same sentinel value. Every difference is then zero, so any positive tolerance admitted the whole slot and round-robined startup reads across distant zones - the opposite of what RouteByLatency is for, and worst for short-lived clients that spend a large share of their traffic in that window. Track whether any healthy node carries a measurement and fall back to the strict pick until one does. A mix needs no special case: an unmeasured node's sentinel latency is ~4295s, far outside the band of any measured node. updateLatency stores the value and the timestamp together, so a node with a latency but no timestamp does not occur in production; the test helper now sets both, and the two new tests cover the all-unmeasured and mixed cases. * fix(cluster): derive unmeasured state from the sampled latency The previous guard read LastLatencyMeasurement() separately from the latency it was guarding. updateLatency publishes the two as independent stores - latency first, timestamp second - so a probe landing between the two reads made the set look measured while every sampled latency was still the initial sentinel. That is precisely the case the guard exists to catch, so it had a scheduling-dependent hole. Compare the already-sampled latency against the sentinel instead, which is consistent by construction and needs no second read. Equality against a named constant rather than a >= threshold, because maximumNodeLatency (1 minute, every ping failed) is a real measurement and must keep taking the band path. newClusterNodeWithNodeAddress now stores that same constant so the two cannot drift. The test helper's timestamp is reverted, being unread again. --------- Co-authored-by: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com>
415 lines
14 KiB
Go
415 lines
14 KiB
Go
package redis
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func toleranceTestNode(t *testing.T, latency time.Duration, failing bool) *clusterNode {
|
|
t.Helper()
|
|
|
|
n := &clusterNode{Client: NewClient(&Options{Addr: "127.0.0.1:0"})}
|
|
t.Cleanup(func() { _ = n.Client.Close() })
|
|
|
|
// Steady state: the node has answered a PING at least once, so Loading() takes its
|
|
// cached path instead of dialling a dead address for 100ms per candidate.
|
|
n.loaded.Store(1)
|
|
n.latency.Store(uint32(latency / time.Microsecond))
|
|
if failing {
|
|
n.MarkAsFailing()
|
|
}
|
|
|
|
return n
|
|
}
|
|
|
|
func toleranceTestState(nodes ...*clusterNode) *clusterState {
|
|
return &clusterState{slots: []*clusterSlot{{start: 0, end: 16383, nodes: nodes}}}
|
|
}
|
|
|
|
func TestRouteByLatencyToleranceZeroKeepsStrictMinimum(t *testing.T) {
|
|
fastest := toleranceTestNode(t, 100*time.Microsecond, false)
|
|
state := toleranceTestState(
|
|
fastest,
|
|
toleranceTestNode(t, 120*time.Microsecond, false),
|
|
toleranceTestNode(t, 900*time.Microsecond, false),
|
|
)
|
|
|
|
for i := 0; i < 20; i++ {
|
|
got, err := state.slotClosestNode(0)
|
|
if err != nil {
|
|
t.Fatalf("slotClosestNode: %v", err)
|
|
}
|
|
if got != fastest {
|
|
t.Fatalf("iteration %d: expected the fastest node with zero tolerance", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSlotNodeWithinLatencySpreadsAcrossCloseNodes(t *testing.T) {
|
|
a := toleranceTestNode(t, 100*time.Microsecond, false)
|
|
b := toleranceTestNode(t, 120*time.Microsecond, false)
|
|
far := toleranceTestNode(t, 900*time.Microsecond, false)
|
|
state := toleranceTestState(a, b, far)
|
|
|
|
counts := map[*clusterNode]int{}
|
|
|
|
for i := 0; i < 100; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, 50*time.Microsecond)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
counts[got]++
|
|
}
|
|
|
|
if counts[far] != 0 {
|
|
t.Fatalf("node outside the tolerance received %d requests", counts[far])
|
|
}
|
|
if counts[a] == 0 || counts[b] == 0 {
|
|
t.Fatalf("expected both close nodes to be used, got a=%d b=%d", counts[a], counts[b])
|
|
}
|
|
if diff := counts[a] - counts[b]; diff > 1 || diff < -1 {
|
|
t.Fatalf("expected an even split across close nodes, got a=%d b=%d", counts[a], counts[b])
|
|
}
|
|
}
|
|
|
|
func TestSlotNodeWithinLatencySkipsFailingNodes(t *testing.T) {
|
|
healthy := toleranceTestNode(t, 120*time.Microsecond, false)
|
|
state := toleranceTestState(toleranceTestNode(t, 100*time.Microsecond, true), healthy)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, 50*time.Microsecond)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got != healthy {
|
|
t.Fatal("a failing node was selected")
|
|
}
|
|
}
|
|
}
|
|
|
|
// The fastest node being unhealthy must not stop a slower healthy one from being chosen.
|
|
// Previously the healthy candidate was only recorded while scanning for a new minimum, so
|
|
// a healthy-but-slower node could be skipped and selection fell through to the
|
|
// all-nodes-failing path.
|
|
// Exercises slotNodeWithinLatency rather than slotClosestNode: preferring a slower healthy
|
|
// node over a faster failing one is behaviour of the new path. slotClosestNode is left as it
|
|
// was on master, latent bug included - see the PR discussion.
|
|
func TestSlotNodeWithinLatencyPrefersSlowerHealthyNode(t *testing.T) {
|
|
healthy := toleranceTestNode(t, 800*time.Microsecond, false)
|
|
state := toleranceTestState(toleranceTestNode(t, 100*time.Microsecond, true), healthy)
|
|
|
|
got, err := state.slotNodeWithinLatency(0, 0)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got != healthy {
|
|
t.Fatal("expected the slower healthy node rather than the failing fastest one")
|
|
}
|
|
}
|
|
|
|
func TestSlotNodeWithinLatencyNeverReturnsNilWithHealthyNode(t *testing.T) {
|
|
nodes := []*clusterNode{
|
|
toleranceTestNode(t, 100*time.Microsecond, false),
|
|
toleranceTestNode(t, 120*time.Microsecond, false),
|
|
toleranceTestNode(t, 900*time.Microsecond, false),
|
|
}
|
|
state := toleranceTestState(nodes...)
|
|
|
|
stop := make(chan struct{})
|
|
defer close(stop)
|
|
|
|
// Churn the latencies while selection runs, including pushing the fastest node above
|
|
// the band it established.
|
|
go func() {
|
|
for i := 0; ; i++ {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
nodes[i%len(nodes)].latency.Store(uint32(100 + (i*37)%5000))
|
|
}
|
|
}()
|
|
|
|
for i := 0; i < 5000; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, 50*time.Microsecond)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("returned nil despite healthy nodes being available")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSlotNodeWithinLatencyHandlesHugeTolerance(t *testing.T) {
|
|
a := toleranceTestNode(t, 100*time.Microsecond, false)
|
|
state := toleranceTestState(a, toleranceTestNode(t, 900*time.Microsecond, false))
|
|
|
|
// A tolerance near the maximum must not overflow into a negative comparison.
|
|
got, err := state.slotNodeWithinLatency(0, time.Duration(math.MaxInt64))
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("returned nil for a very large tolerance")
|
|
}
|
|
}
|
|
|
|
func TestRouteByLatencyToleranceFromClusterURL(t *testing.T) {
|
|
o, err := ParseClusterURL("redis://localhost:6379?route_by_latency=true&route_by_latency_tolerance=250us")
|
|
if err != nil {
|
|
t.Fatalf("ParseClusterURL: %v", err)
|
|
}
|
|
if !o.RouteByLatency {
|
|
t.Fatal("RouteByLatency not parsed")
|
|
}
|
|
if o.RouteByLatencyTolerance != 250*time.Microsecond {
|
|
t.Fatalf("tolerance = %v, want 250us", o.RouteByLatencyTolerance)
|
|
}
|
|
}
|
|
|
|
func TestRouteByLatencyToleranceFromFailoverURL(t *testing.T) {
|
|
o, err := ParseFailoverURL("redis://localhost:26379?master_name=mymaster&route_by_latency=true&route_by_latency_tolerance=1ms")
|
|
if err != nil {
|
|
t.Fatalf("ParseFailoverURL: %v", err)
|
|
}
|
|
if o.RouteByLatencyTolerance != time.Millisecond {
|
|
t.Fatalf("tolerance = %v, want 1ms", o.RouteByLatencyTolerance)
|
|
}
|
|
}
|
|
|
|
func TestRouteByLatencyToleranceFromUniversalOptions(t *testing.T) {
|
|
o := &UniversalOptions{
|
|
Addrs: []string{"localhost:6379"},
|
|
RouteByLatency: true,
|
|
RouteByLatencyTolerance: 300 * time.Microsecond,
|
|
}
|
|
|
|
if got := o.Cluster().RouteByLatencyTolerance; got != 300*time.Microsecond {
|
|
t.Fatalf("Cluster() tolerance = %v, want 300us", got)
|
|
}
|
|
|
|
o.MasterName = "mymaster"
|
|
if got := o.Failover().RouteByLatencyTolerance; got != 300*time.Microsecond {
|
|
t.Fatalf("Failover() tolerance = %v, want 300us", got)
|
|
}
|
|
}
|
|
|
|
// With the option unset, selection must stay on the pre-existing hot path: no candidate
|
|
// slice, no allocation per command.
|
|
func TestSlotClosestNodeStillDoesNotAllocate(t *testing.T) {
|
|
state := toleranceTestState(
|
|
toleranceTestNode(t, 100*time.Microsecond, false),
|
|
toleranceTestNode(t, 120*time.Microsecond, false),
|
|
toleranceTestNode(t, 900*time.Microsecond, false),
|
|
)
|
|
|
|
allocs := testing.AllocsPerRun(200, func() {
|
|
if _, err := state.slotClosestNode(0); err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
})
|
|
|
|
if allocs != 0 {
|
|
t.Fatalf("zero tolerance allocated %.0f times per call, want 0", allocs)
|
|
}
|
|
}
|
|
|
|
// Rotation state is per slot. A counter shared across slots is advanced by the other slots
|
|
// between two visits to this one, so with a regular interleaving each slot keeps landing on
|
|
// the same candidate index and one replica per shard takes all of that shard's reads.
|
|
func TestSlotNodeWithinLatencyRotatesPerSlot(t *testing.T) {
|
|
nodesFor := func() []*clusterNode {
|
|
return []*clusterNode{
|
|
toleranceTestNode(t, 100*time.Microsecond, false),
|
|
toleranceTestNode(t, 120*time.Microsecond, false),
|
|
}
|
|
}
|
|
a, b := nodesFor(), nodesFor()
|
|
state := &clusterState{slots: []*clusterSlot{
|
|
{start: 0, end: 100, nodes: a},
|
|
{start: 101, end: 200, nodes: b},
|
|
}}
|
|
|
|
counts := map[*clusterNode]int{}
|
|
for i := 0; i < 100; i++ {
|
|
for _, slot := range []int{50, 150} { // alternate slots, the interleaving that breaks a shared counter
|
|
got, err := state.slotNodeWithinLatency(slot, 50*time.Microsecond)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
counts[got]++
|
|
}
|
|
}
|
|
|
|
for name, nodes := range map[string][]*clusterNode{"slot 0-100": a, "slot 101-200": b} {
|
|
if counts[nodes[0]] == 0 || counts[nodes[1]] == 0 {
|
|
t.Fatalf("%s did not rotate: %d / %d", name, counts[nodes[0]], counts[nodes[1]])
|
|
}
|
|
}
|
|
}
|
|
|
|
// The rotation cursor is a uint32 and wraps. Reducing it to an index must stay in the unsigned
|
|
// domain: on 32-bit builds int is 32 bits, so converting a cursor past 2^31 before the modulo
|
|
// yields a negative index and panics. Run with GOARCH=386 to exercise that.
|
|
func TestSlotNodeWithinLatencyRotationPastIntMax(t *testing.T) {
|
|
first := toleranceTestNode(t, 100*time.Microsecond, false)
|
|
second := toleranceTestNode(t, 110*time.Microsecond, false)
|
|
state := toleranceTestState(first, second)
|
|
state.slots[0].latencyBandNodeCursor.Store(math.MaxInt32)
|
|
|
|
seen := map[*clusterNode]int{}
|
|
for i := 0; i < 8; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, 50*time.Microsecond)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got != first && got != second {
|
|
t.Fatalf("unexpected node %p", got)
|
|
}
|
|
seen[got]++
|
|
}
|
|
|
|
if len(seen) != 2 {
|
|
t.Fatalf("rotation stalled across the uint32 boundary: %v", seen)
|
|
}
|
|
}
|
|
|
|
// A node that has not yet been confirmed loaded must still be selectable: Loading() only
|
|
// reports true for an actual LOADING reply, and a connection error is not one. Guards
|
|
// against the tolerance filter over-excluding and shrinking the rotation to nothing.
|
|
func TestSlotNodeWithinLatencyKeepsUnconfirmedNode(t *testing.T) {
|
|
fast := toleranceTestNode(t, 100*time.Microsecond, false)
|
|
unconfirmed := toleranceTestNode(t, 110*time.Microsecond, false)
|
|
unconfirmed.loaded.Store(0)
|
|
|
|
state := toleranceTestState(fast, unconfirmed)
|
|
|
|
seen := map[*clusterNode]bool{}
|
|
for i := 0; i < 8; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, 50*time.Microsecond)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("slotNodeWithinLatency returned nil")
|
|
}
|
|
seen[got] = true
|
|
}
|
|
|
|
if !seen[unconfirmed] {
|
|
t.Fatal("node with loaded=0 was excluded from the rotation; a connection error is not a LOADING reply")
|
|
}
|
|
}
|
|
|
|
// Before the background probes finish, every node still carries the math.MaxUint32 sentinel
|
|
// from newClusterNodeWithNodeAddress. All differences are then zero, so a positive tolerance
|
|
// would admit the entire slot and round-robin startup reads across distant zones. Until one
|
|
// measurement lands, the tolerance path must behave exactly like the strict one.
|
|
func TestSlotNodeWithinLatencyKeepsStrictOrderBeforeFirstProbe(t *testing.T) {
|
|
unprobed := func() *clusterNode {
|
|
n := &clusterNode{Client: NewClient(&Options{Addr: "127.0.0.1:0"})}
|
|
t.Cleanup(func() { _ = n.Client.Close() })
|
|
n.loaded.Store(1)
|
|
n.latency.Store(unmeasuredNodeLatencyMicros) // as newClusterNodeWithNodeAddress does
|
|
return n
|
|
}
|
|
|
|
first, second, third := unprobed(), unprobed(), unprobed()
|
|
state := toleranceTestState(first, second, third)
|
|
|
|
for i := 0; i < 50; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, time.Second)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got != first {
|
|
t.Fatalf("iteration %d: rotated across unmeasured nodes; want the strict pick", i)
|
|
}
|
|
}
|
|
|
|
strict, err := state.slotClosestNode(0)
|
|
if err != nil {
|
|
t.Fatalf("slotClosestNode: %v", err)
|
|
}
|
|
if strict != first {
|
|
t.Fatalf("slotClosestNode disagrees with the tolerance path before the first probe")
|
|
}
|
|
}
|
|
|
|
// Once any healthy node has a real measurement the band is meaningful again, and an
|
|
// unmeasured node must stay out of it - its sentinel latency is ~4295s, far beyond any
|
|
// sane tolerance.
|
|
func TestSlotNodeWithinLatencyExcludesUnmeasuredOnceProbed(t *testing.T) {
|
|
measured := toleranceTestNode(t, 100*time.Microsecond, false)
|
|
alsoMeasured := toleranceTestNode(t, 130*time.Microsecond, false)
|
|
|
|
unprobed := &clusterNode{Client: NewClient(&Options{Addr: "127.0.0.1:0"})}
|
|
t.Cleanup(func() { _ = unprobed.Client.Close() })
|
|
unprobed.loaded.Store(1)
|
|
unprobed.latency.Store(unmeasuredNodeLatencyMicros)
|
|
|
|
state := toleranceTestState(measured, alsoMeasured, unprobed)
|
|
|
|
for i := 0; i < 60; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, 50*time.Microsecond)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got == unprobed {
|
|
t.Fatalf("iteration %d: unmeasured node entered the tolerance band", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateLatency publishes the latency and its timestamp as two separate stores, so a probe can
|
|
// land between selection sampling a node's latency and reading its measurement timestamp. That
|
|
// interleaving is observable as "sentinel latency, timestamp set" - and must still count as
|
|
// unmeasured, or the band widens to the whole slot while every sampled latency is a sentinel.
|
|
func TestSlotNodeWithinLatencyIgnoresTimestampWithoutLatency(t *testing.T) {
|
|
interleaved := func() *clusterNode {
|
|
n := &clusterNode{Client: NewClient(&Options{Addr: "127.0.0.1:0"})}
|
|
t.Cleanup(func() { _ = n.Client.Close() })
|
|
n.loaded.Store(1)
|
|
n.latency.Store(unmeasuredNodeLatencyMicros)
|
|
// The half-published state: timestamp visible, latency not yet stored.
|
|
n.SetLastLatencyMeasurement(time.Now())
|
|
return n
|
|
}
|
|
|
|
first, second, third := interleaved(), interleaved(), interleaved()
|
|
state := toleranceTestState(first, second, third)
|
|
|
|
for i := 0; i < 50; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, time.Second)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
if got != first {
|
|
t.Fatalf("iteration %d: a set timestamp widened the band while latencies were sentinels", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// maximumNodeLatency (all pings failed) must not be mistaken for the unmeasured sentinel: such a
|
|
// node has a real measurement, so the band applies normally rather than collapsing to strict.
|
|
func TestSlotNodeWithinLatencyTreatsFailedPingsAsMeasured(t *testing.T) {
|
|
a := toleranceTestNode(t, maximumNodeLatency, false)
|
|
b := toleranceTestNode(t, maximumNodeLatency, false)
|
|
state := toleranceTestState(a, b)
|
|
|
|
counts := map[*clusterNode]int{}
|
|
for i := 0; i < 80; i++ {
|
|
got, err := state.slotNodeWithinLatency(0, time.Millisecond)
|
|
if err != nil {
|
|
t.Fatalf("slotNodeWithinLatency: %v", err)
|
|
}
|
|
counts[got]++
|
|
}
|
|
if counts[a] == 0 || counts[b] == 0 {
|
|
t.Fatalf("expected rotation across both measured nodes, got a=%d b=%d", counts[a], counts[b])
|
|
}
|
|
}
|