mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* 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
260 lines
8.2 KiB
Go
260 lines
8.2 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAppendQueryParameter(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
rawURL string
|
|
key string
|
|
value string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "without existing query",
|
|
rawURL: "http://example.com/3,abc",
|
|
key: "readDeleted",
|
|
value: "true",
|
|
expected: "http://example.com/3,abc?readDeleted=true",
|
|
},
|
|
{
|
|
name: "with existing query",
|
|
rawURL: "http://example.com/?proxyChunkId=3,abc",
|
|
key: "readDeleted",
|
|
value: "true",
|
|
expected: "http://example.com/?proxyChunkId=3,abc&readDeleted=true",
|
|
},
|
|
{
|
|
name: "with trailing question mark",
|
|
rawURL: "http://example.com/?",
|
|
key: "readDeleted",
|
|
value: "true",
|
|
expected: "http://example.com/?readDeleted=true",
|
|
},
|
|
{
|
|
name: "with trailing ampersand",
|
|
rawURL: "http://example.com/?proxyChunkId=3,abc&",
|
|
key: "readDeleted",
|
|
value: "true",
|
|
expected: "http://example.com/?proxyChunkId=3,abc&readDeleted=true",
|
|
},
|
|
{
|
|
name: "encodes values",
|
|
rawURL: "http://example.com/data",
|
|
key: "note",
|
|
value: "space value",
|
|
expected: "http://example.com/data?note=space+value",
|
|
},
|
|
{
|
|
name: "preserves fragment",
|
|
rawURL: "http://example.com/data#frag",
|
|
key: "readDeleted",
|
|
value: "true",
|
|
expected: "http://example.com/data?readDeleted=true#frag",
|
|
},
|
|
{
|
|
name: "blank url",
|
|
rawURL: "",
|
|
key: "readDeleted",
|
|
value: "true",
|
|
expected: "?readDeleted=true",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
actual := AppendQueryParameter(tc.rawURL, tc.key, tc.value)
|
|
if actual != tc.expected {
|
|
t.Fatalf("expected %q, got %q", tc.expected, actual)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestReadUrlAsStreamReturnsGzipReaderError(t *testing.T) {
|
|
InitGlobalHttpClient()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("not gzip"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err := ReadUrlAsStream(context.Background(), server.URL, "", nil, false, true, 0, 0, func(data []byte) {})
|
|
if err == nil {
|
|
t.Fatal("ReadUrlAsStream returned nil error for invalid gzip response")
|
|
}
|
|
}
|
|
|
|
func TestDeleteReturnsInvalidRequestErrorBeforeAddingAuth(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("Delete panicked before returning the request error: %v", r)
|
|
}
|
|
}()
|
|
|
|
if err := Delete("http://[::1", "jwt"); err == nil {
|
|
t.Fatal("expected invalid request error")
|
|
}
|
|
}
|
|
|
|
func TestDeleteTreatsNoContentAsSuccess(t *testing.T) {
|
|
InitGlobalHttpClient()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
t.Errorf("expected DELETE, got %s", r.Method)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
if err := Delete(server.URL, ""); err != nil {
|
|
t.Fatalf("expected 204 DELETE to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteProxiedReturnsInvalidRequestErrorBeforeAddingAuth(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("DeleteProxied panicked before returning the request error: %v", r)
|
|
}
|
|
}()
|
|
|
|
if _, _, err := DeleteProxied("http://[::1", "jwt"); err == nil {
|
|
t.Fatal("expected invalid request error")
|
|
}
|
|
}
|
|
|
|
// TestRetriedFetchChunkDataRetriesFreshUrlsImmediately covers the case the
|
|
// refresh hook exists for: every location the caller knew about is gone, and
|
|
// the data is live somewhere the caller has not heard of yet. The read must
|
|
// land on the fresh location without first sitting through the backoff ladder.
|
|
func TestRetriedFetchChunkDataRetriesFreshUrlsImmediately(t *testing.T) {
|
|
payload := []byte("chunk contents")
|
|
live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write(payload)
|
|
}))
|
|
defer live.Close()
|
|
|
|
// A port nothing listens on: the address is well formed, the dial fails.
|
|
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
deadURL := dead.URL
|
|
dead.Close()
|
|
|
|
refreshed := 0
|
|
buffer := make([]byte, len(payload))
|
|
start := time.Now()
|
|
n, err := RetriedFetchChunkData(context.Background(), buffer, []string{deadURL + "/3,abc"}, nil, false, true, 0, "3,abc",
|
|
func() []string {
|
|
refreshed++
|
|
return []string{live.URL + "/3,abc"}
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
if err != nil {
|
|
t.Fatalf("fetch with a refreshed location: %v", err)
|
|
}
|
|
if n != len(payload) || string(buffer[:n]) != string(payload) {
|
|
t.Fatalf("got %q, want %q", buffer[:n], payload)
|
|
}
|
|
if refreshed != 1 {
|
|
t.Fatalf("refresh called %d times, want exactly 1", refreshed)
|
|
}
|
|
// The first backoff is a full second; landing well under it is the point.
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Fatalf("took %v, expected the retry to skip the backoff", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestRetriedFetchChunkDataKeepsBackoffWhenLocationsAreUnchanged makes sure a
|
|
// refresh that returns the same list is treated as "the locations were never
|
|
// the problem" rather than as a reason to spin.
|
|
func TestRetriedFetchChunkDataKeepsBackoffWhenLocationsAreUnchanged(t *testing.T) {
|
|
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
deadURL := dead.URL
|
|
dead.Close()
|
|
|
|
urls := []string{deadURL + "/3,abc"}
|
|
refreshed := 0
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
buffer := make([]byte, 4)
|
|
_, err := RetriedFetchChunkData(ctx, buffer, urls, nil, false, true, 0, "3,abc", func() []string {
|
|
refreshed++
|
|
return urls
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected the fetch to fail against a dead location")
|
|
}
|
|
if refreshed != 1 {
|
|
t.Fatalf("refresh called %d times, want exactly 1", refreshed)
|
|
}
|
|
}
|
|
|
|
// TestRetriedFetchChunkDataRefreshesLocationsAfterPartialFailure covers a read
|
|
// that fails on a cached dead replica and succeeds on the next one: the read
|
|
// itself is fine, but the list it came from is stale and must be refreshed so
|
|
// later reads do not start with the dead replica again. Both the direct and
|
|
// the streaming read paths take this branch.
|
|
func TestRetriedFetchChunkDataRefreshesLocationsAfterPartialFailure(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()
|
|
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
deadURL := dead.URL
|
|
dead.Close()
|
|
|
|
for _, isFullChunk := range []bool{true, false} {
|
|
forgetUnreachable(t)
|
|
refreshed := 0
|
|
refresh := func() []string {
|
|
refreshed++
|
|
return []string{live.URL + "/3,abc"}
|
|
}
|
|
buffer := make([]byte, len(payload))
|
|
n, err := RetriedFetchChunkData(context.Background(), buffer, []string{deadURL + "/3,abc", live.URL + "/3,abc"}, nil, false, isFullChunk, 0, "3,abc", refresh)
|
|
if err != nil || string(buffer[:n]) != string(payload) {
|
|
t.Fatalf("fullChunk=%v: got %q, %v; want %q", isFullChunk, buffer[:n], err, payload)
|
|
}
|
|
if refreshed != 1 {
|
|
t.Fatalf("fullChunk=%v: refresh called %d times after a partial failure, want exactly 1", isFullChunk, refreshed)
|
|
}
|
|
|
|
// a read answered by the first location leaves the list alone
|
|
refreshed = 0
|
|
n, err = RetriedFetchChunkData(context.Background(), buffer, []string{live.URL + "/3,abc", deadURL + "/3,abc"}, nil, false, isFullChunk, 0, "3,abc", refresh)
|
|
if err != nil || string(buffer[:n]) != string(payload) {
|
|
t.Fatalf("fullChunk=%v: got %q, %v; want %q", isFullChunk, buffer[:n], err, payload)
|
|
}
|
|
if refreshed != 0 {
|
|
t.Fatalf("fullChunk=%v: refresh called %d times without a failure, want none", isFullChunk, refreshed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSameUrlsIgnoresOrder(t *testing.T) {
|
|
a := []string{"http://a:8080/3,x", "http://b:8080/3,x"}
|
|
if !SameUrls(a, []string{a[1], a[0]}) {
|
|
t.Fatal("a reshuffle of the same locations should not count as fresh")
|
|
}
|
|
if SameUrls(a, []string{a[0], "http://c:8080/3,x"}) {
|
|
t.Fatal("a different location should count as fresh")
|
|
}
|
|
if SameUrls(a, a[:1]) {
|
|
t.Fatal("a shorter list should count as fresh")
|
|
}
|
|
}
|