mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-21 22:56:55 +00:00
* mount: batched announcer + pooled peer conns for mount-to-mount RPCs * peer_announcer.go: non-blocking EnqueueAnnounce + ticker flush that groups fids by HRW owner, fans out one ChunkAnnounce per owner in parallel. announcedAt is pruned at 2× TTL so it stays bounded. * peer_dialer.go: PeerConnPool caches one grpc.ClientConn per peer address; the announcer and (next PR) the fetcher share it so steady-state owner RPCs skip the handshake cost entirely. Bounded at 4096 cached entries; shutdown conns are transparently replaced. * WFS starts both alongside the gRPC server; stops them on unmount. * mount: wire tryPeerRead via FetchChunk streaming gRPC Replaces the HTTP GET byte-transfer path with a gRPC server-stream FetchChunk call. Same fall-through semantics: any failure drops through to entryChunkGroup.ReadDataAt, so reads never slow below status quo. * peer_fetcher.go: tryPeerRead resolves the offset to a leaf chunk (flattening manifests), asks the HRW owner for holders via ChunkLookup, then opens FetchChunk on each holder in LRU order (PR #5) until one succeeds. Assembled bytes are verified against FileChunk.ETag end-to-end — the peer is still treated as untrusted. Reuses the shared PeerConnPool from PR #6 for all outbound gRPC. * peer_grpc.go: expose SelfAddr() so the fetcher can avoid dialing itself on a self-owned fid. * filehandle_read.go: tryPeerRead slot between tryRDMARead and entryChunkGroup.ReadDataAt. Gated by option.PeerEnabled and the presence of peerGrpcServer (the single identity test). Read ordering with the feature enabled is now: local cache -> RDMA sidecar -> peer mount (gRPC stream) -> volume server One port, one identity, one connection pool — no more HTTP bytecast. * test(fuse_p2p): end-to-end CI test for peer chunk sharing Adds a FUSE-backed integration test that proves mount B can satisfy a read from mount A's chunk cache instead of the volume tier. Layout (modelled on test/fuse_dlm): test/fuse_p2p/framework_test.go — cluster harness (1 master, 1 volume, 1 filer, N mounts, all with -peer.enable) test/fuse_p2p/peer_chunk_sharing_test.go — writer-reader scenario The test (TestPeerChunkSharing_ReadersPullFromPeerCache): 1. Starts 3 mounts. Three is the sweet spot: with 2 mounts, HRW owner of a chunk is self ~50 % of the time (peer path short-circuits); with 3+ it drops to ≤ 1/3, so a multi-chunk file almost certainly exercises the remote-owner fan-out. 2. Mount 0 writes a ~8 MiB file, then reads it back through its own FUSE to warm its chunk cache. 3. Waits for seed convergence (one full MountList refresh) plus an announcer flush cycle, so chunk-holder entries have reached each HRW owner. 4. Mount 1 reads the same file. 5. Verifies byte-for-byte equality AND greps mount 1's log for "peer read successful" — content matching alone is not proof (the volume fallback would also succeed), so the log marker is what distinguishes p2p from fallback. Workflow .github/workflows/fuse-p2p-integration.yml triggers on any change to mount/filer peer code, the p2p protos, or the test itself. Failure artifacts (server + mount logs) are uploaded for 3 days. Mounts run with -v=4 so the tryPeerRead success/failure glog messages land in the log file the test greps.
148 lines
5.7 KiB
Go
148 lines
5.7 KiB
Go
package fuse_p2p
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/md5"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// peerReadSuccessMarker is the log line tryPeerRead emits when a peer
|
|
// fetch succeeded. The test greps non-writer mount logs for it to
|
|
// prove the p2p path fired. At glog verbosity 4 (framework sets -v=4).
|
|
const peerReadSuccessMarker = "peer read successful"
|
|
|
|
// seedConvergenceTimeout bounds how long the test waits for:
|
|
// - the filer registry to list all mounts,
|
|
// - every mount's seed view (via MountList poll) to include all peers,
|
|
// - the first announcer flush cycle to publish chunk holders.
|
|
//
|
|
// With defaults the mount polls MountList every 30 s and flushes
|
|
// ChunkAnnounce every 15 s. Allowing 45 s absorbs one MountList refresh
|
|
// plus one announce cycle plus some slop for CI variance.
|
|
const seedConvergenceTimeout = 90 * time.Second
|
|
|
|
// TestPeerChunkSharing_ReadersPullFromPeerCache is the headline p2p
|
|
// integration test. It proves at least one non-writer mount can satisfy
|
|
// a read from the writer's chunk cache instead of the volume tier.
|
|
//
|
|
// 1. Bring up 1 master/volume/filer + 3 mounts, all with -peer.enable.
|
|
// 2. Mount 0 writes a ~8 MiB file and reads it back so chunks land in
|
|
// its local cache and the announcer publishes them.
|
|
// 3. Wait for seed convergence + at least one announcer flush cycle.
|
|
// 4. BOTH mount 1 and mount 2 read the file.
|
|
//
|
|
// Why both readers: with 3 mounts, HRW picks one owner for the chunk.
|
|
// If that owner is mount 1, only mount 2's read will hit the peer
|
|
// path (mount 1's tryPeerRead bails on owner==self). If that owner is
|
|
// mount 2, only mount 1's read will. If that owner is mount 0 (the
|
|
// writer), both can. So by reading from both, we deterministically
|
|
// guarantee at least one non-writer mount exercises the peer path.
|
|
//
|
|
// Once the peer fetch populates its local cache, subsequent reads
|
|
// short-circuit on IsInCache — so we only get one real shot per mount
|
|
// per chunk. That's fine: one success is all the test needs.
|
|
func TestPeerChunkSharing_ReadersPullFromPeerCache(t *testing.T) {
|
|
c := startP2PTestCluster(t, 3)
|
|
|
|
// ~8 MiB, pseudo-random so compression doesn't collapse it to one block.
|
|
payload := make([]byte, 8*1024*1024)
|
|
rng := rand.New(rand.NewPCG(1, 2))
|
|
for i := range payload {
|
|
payload[i] = byte(rng.Uint32())
|
|
}
|
|
|
|
const relPath = "p2p-test.bin"
|
|
writer := c.MountDir(0)
|
|
require.NoError(t, os.WriteFile(filepath.Join(writer, relPath), payload, 0644))
|
|
|
|
// Warm mount 0's chunk cache by reading back through its own FUSE.
|
|
// Without this the chunks are on the volume server but not yet
|
|
// in anyone's peer-servable cache.
|
|
readBack, err := os.ReadFile(filepath.Join(writer, relPath))
|
|
require.NoError(t, err)
|
|
require.True(t, bytes.Equal(readBack, payload), "write-then-read on writer mount should match")
|
|
|
|
waitForSeedConvergence(t, c, seedConvergenceTimeout)
|
|
|
|
// Give the announcer several flush windows to push chunk-holder
|
|
// entries to the HRW owners. First flush may see the writer's seed
|
|
// view incomplete (only self) and defer all fids; subsequent
|
|
// flushes re-check against a refreshed seed view. announce interval
|
|
// is 15 s, so 45 s covers three attempts.
|
|
time.Sleep(45 * time.Second)
|
|
|
|
// Read from both non-writer mounts. For any HRW outcome on any
|
|
// chunk, at least one of these reads will NOT have the reader as
|
|
// the HRW owner, so its tryPeerRead will proceed to ChunkLookup +
|
|
// FetchChunk.
|
|
for _, idx := range []int{1, 2} {
|
|
got, err := os.ReadFile(filepath.Join(c.MountDir(idx), relPath))
|
|
require.NoError(t, err, "read from mount %d must succeed\n--- mount%d ---\n%s",
|
|
idx, idx, tailLines(c.MountLog(idx), 80))
|
|
require.Equal(t, md5.Sum(payload), md5.Sum(got),
|
|
"mount %d returned mismatched bytes (len got=%d want=%d)", idx, len(got), len(payload))
|
|
}
|
|
|
|
// Content matches alone doesn't prove p2p — the volume fallback
|
|
// would also satisfy the reads. Require at least one non-writer
|
|
// mount's log to contain the peer-read success marker.
|
|
var sawPeerRead bool
|
|
for _, idx := range []int{1, 2} {
|
|
if strings.Contains(c.MountLog(idx), peerReadSuccessMarker) {
|
|
sawPeerRead = true
|
|
break
|
|
}
|
|
}
|
|
if !sawPeerRead {
|
|
t.Fatalf("no non-writer mount logged %q — peer read path never fired.\n"+
|
|
"--- mount0 (writer) tail ---\n%s\n--- mount1 tail ---\n%s\n--- mount2 tail ---\n%s",
|
|
peerReadSuccessMarker,
|
|
tailLines(c.MountLog(0), 60), tailLines(c.MountLog(1), 60), tailLines(c.MountLog(2), 60))
|
|
}
|
|
}
|
|
|
|
// waitForSeedConvergence polls each mount's log looking for any sign
|
|
// that MountList returned a peer list containing the other mounts.
|
|
func waitForSeedConvergence(t *testing.T, c *p2pTestCluster, timeout time.Duration) {
|
|
t.Helper()
|
|
// The first MountRegister happens synchronously during mount startup,
|
|
// and the first MountList is pulled right after. 30 s is the refresh
|
|
// interval; waiting one full cycle here guarantees every mount has
|
|
// at minimum observed the others in its seed view.
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
ready := true
|
|
for i := range c.mountCmds {
|
|
if !strings.Contains(c.MountLog(i), "peer-grpc listening on") {
|
|
ready = false
|
|
break
|
|
}
|
|
}
|
|
if ready {
|
|
time.Sleep(30 * time.Second)
|
|
return
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
t.Fatalf("peer-grpc servers did not come up within %s", timeout)
|
|
}
|
|
|
|
// tailLines returns the last n newline-delimited lines of s, or the
|
|
// whole thing if shorter. Keeps test failures readable.
|
|
func tailLines(s string, n int) string {
|
|
lines := strings.Split(s, "\n")
|
|
if len(lines) <= n {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("... (%d earlier lines omitted) ...\n%s",
|
|
len(lines)-n, strings.Join(lines[len(lines)-n:], "\n"))
|
|
}
|