Files
seaweedfs/weed/util/http/reachability_test.go
T
Chris Lu fe98520358 read: try a replica that stopped answering last, and relearn its volume's locations (#11130)
* http: try a volume server that failed to answer last

A cached location list is shuffled on every read, so once a replica dies
half the reads keep dialing it first and pay a connect failure or timeout
before the healthy replica answers. Remember, per host, when a request got
no answer at all and order such hosts last for the next half minute. Once
that passes, one read probes the host in its usual place while the others
keep it last until the probe settles, so a black-holed server costs one
stalled read per interval instead of one per read.

Nothing is ever skipped: a host that failed is still tried when the others
fail too. Any response, including an error status, counts as reachable.

Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs

* filer: refresh a chunk's locations after one of them fails

A mount's location cache is only relearned when every cached location
fails. When one replica dies and the other still answers, every read
succeeds and the dead replica stays in the cache, and in the shuffled
order it keeps being dialed first long after the master has dropped it.

When a read fails on one location and a later one answers, call the
refresh hook so the cached entry is dropped and looked up again. The read
that already paid for the failure returns its data; the reads after it
start from the locations the master knows now.

Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs

* http: claim the probe for every expired host, and try it first

The claim was only checked for the first url, so with two replicas whose
marks expired together the second was probed by every read at once. Claim
each expired host on its own and put the reads that won a claim ahead of
the reachable hosts, so a probe is always a real attempt and a lost claim
always means the host is tried last.

Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs

* filer: refresh a chunk's locations in the streaming read path too

The streaming loop had no refresh hook, so a manifest or streamed chunk
that failed on one cached location and was served by another kept the
stale entry until every location failed. Give it the same hook as the
buffered loop, built by one refreshUrls function shared by the reader
cache and the stream callers.

Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs

* http: probe at most one expired host per read

Claiming every expired host in one ordering left all but the first claim
without an attempt, since a read stops at its first answer, and a host that
had come back waited another interval for nothing. Claim only the first
expired host a read sees and leave the rest last and unclaimed, so each
following read probes one of them.

Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs

* test: start the live server before releasing the dead server's port

Closing the dead server first let the live server come up on the same
port, in which case the dead location answers and the partial failure
under test never happens.

Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs
2026-09-03 11:51:48 -07:00

113 lines
3.2 KiB
Go

package http
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func forgetUnreachable(t *testing.T) {
t.Helper()
forget := func() {
unreachable.Range(func(host, _ any) bool {
unreachable.Delete(host)
return true
})
}
forget()
t.Cleanup(forget)
}
func assertOrder(t *testing.T, got []string, want ...string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
}
}
}
func TestReachableFirstTriesUnansweringHostLast(t *testing.T) {
forgetUnreachable(t)
urls := []string{"http://a:8080/3,x", "http://b:8080/3,x", "http://c:8080/3,x"}
assertOrder(t, ReachableFirst(urls), urls...)
recordUnreachable("a:8080")
assertOrder(t, ReachableFirst(urls), urls[1], urls[2], urls[0])
recordReachable("a:8080")
assertOrder(t, ReachableFirst(urls), urls...)
}
func TestReachableFirstProbesOnceAfterRetryInterval(t *testing.T) {
forgetUnreachable(t)
urls := []string{"http://b:8080/3,x", "http://a:8080/3,x"}
unreachable.Store("a:8080", time.Now().Add(-unreachableRetryInterval))
// the first read to come by probes a, the next keeps it last until that settles
assertOrder(t, ReachableFirst(urls), urls[1], urls[0])
assertOrder(t, ReachableFirst(urls), urls...)
recordReachable("a:8080")
assertOrder(t, ReachableFirst(urls), urls...)
}
func TestReachableFirstProbesOneExpiredHostPerRead(t *testing.T) {
forgetUnreachable(t)
urls := []string{"http://a:8080/3,x", "http://b:8080/3,x", "http://c:8080/3,x"}
expired := time.Now().Add(-unreachableRetryInterval)
unreachable.Store("a:8080", expired)
unreachable.Store("c:8080", expired)
assertOrder(t, ReachableFirst(urls), urls[0], urls[1], urls[2])
assertOrder(t, ReachableFirst(urls), urls[2], urls[1], urls[0])
assertOrder(t, ReachableFirst(urls), urls[1], urls[0], urls[2])
}
// hangupServer accepts the connection and drops it without answering, the way
// a replica behind a broken network does, and counts how often that happened.
func hangupServer(t *testing.T) (*httptest.Server, *int32) {
t.Helper()
var hangups int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hangups, 1)
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
conn.Close()
}))
t.Cleanup(srv.Close)
return srv, &hangups
}
func TestRetriedFetchChunkDataTriesUnansweringServerLast(t *testing.T) {
forgetUnreachable(t)
payload := []byte("chunk contents")
live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(payload)
}))
defer live.Close()
hangup, hangups := hangupServer(t)
urls := []string{hangup.URL + "/3,abc", live.URL + "/3,abc"}
for i := 0; i < 3; i++ {
buffer := make([]byte, len(payload))
n, err := RetriedFetchChunkData(context.Background(), buffer, urls, nil, false, true, 0, "3,abc", nil)
if err != nil {
t.Fatalf("read %d: %v", i, err)
}
if string(buffer[:n]) != string(payload) {
t.Fatalf("read %d got %q, want %q", i, buffer[:n], payload)
}
}
if got := atomic.LoadInt32(hangups); got != 1 {
t.Fatalf("the server that hung up was tried %d times, want once", got)
}
}