1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00
Files
go-redis/osscluster_latency_tolerance_test.go
Arthur Yazhevich 6aedc4ce78 feat(cluster): add RouteByLatencyTolerance to spread reads across equally-close nodes (#3973)
* 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>
2026-08-27 17:33:45 +03:00

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])
}
}