Files
seaweedfs/weed/mount/peer_announcer.go
T
Chris Lu 73f10fa528 peer chunk sharing 6/8: announce queue + batched flush (#9135)
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.
2026-04-18 21:42:36 -07:00

375 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mount
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/mount_peer_pb"
)
// PeerAnnouncer batches and flushes ChunkAnnounce RPCs to the
// HRW-assigned owner mounts for each cached fid this mount holds.
//
// Shape:
// * EnqueueAnnounce(fid) is a non-blocking push into an in-memory set.
// * A background ticker flushes every announceInterval. Each flush
// drains the set, adds fids due for TTL renewal, groups by owner
// mount via HRW, and sends one ChunkAnnounce RPC per distinct owner.
// * A successful send records announced_at[fid]. A rejected (not-owner)
// fid is requeued so it will be retried after the seed view refreshes.
//
// See design-weed-mount-peer-chunk-sharing.md §4.4.
type PeerAnnouncer struct {
selfAddr string
selfDataCenter string
selfRack string
ownerFor func(fid string) string
dialPeer MountPeerDialer
localDir *PeerDirectory // populated directly for fids whose HRW owner == self
isCached func(fid string) bool // residency check; set nil to disable
announceInterval time.Duration
announceTTL time.Duration
mu sync.Mutex
pending map[string]struct{}
announcedAt map[string]announceRecord
stopCh chan struct{}
stopped atomic.Bool
runWg sync.WaitGroup // tracks the run goroutine so Stop can wait
// flushCancelMu guards flushCancel, which is the cancel fn for the
// currently in-flight flush (nil when idle). Stop takes the lock,
// cancels the flush to unblock its RPCs fast, then waits on runWg.
flushCancelMu sync.Mutex
flushCancel context.CancelFunc
flushes atomic.Int64
sentFids atomic.Int64
rejectedFids atomic.Int64
flushErrs atomic.Int64
clock func() time.Time
}
// MountPeerDialer opens a MountPeer gRPC client to a given peer. Tests
// inject a fake; production uses a real gRPC dial backed by a short
// connection cache.
type MountPeerDialer func(ctx context.Context, peerAddr string) (mount_peer_pb.MountPeerClient, func(), error)
// announceRecord is what we remember about a successful announce: who
// we told (the HRW owner at the time) and when. On each flush we
// recompute the HRW owner and, if it moved — typically because a new
// mount joined the seed list and the ring rebalanced — re-announce
// against the new owner. Without this, seed-view divergence during
// startup silently ages out fids on an owner that the reader never
// looks up.
type announceRecord struct {
owner string
at time.Time
}
// NewPeerAnnouncer constructs an announcer. Caller must call Start.
// selfDataCenter and selfRack are the locality labels attached to every
// ChunkAnnounce so the receiving directory records holders with DC+rack
// and the fetcher can later re-rank by locality.
//
// localDir is optional: when non-nil, any fid whose HRW owner resolves
// to self is written directly into localDir instead of sending a
// self→self RPC (which the receiver would reject anyway). This is the
// only way a fid the writer itself owns becomes visible to peers'
// ChunkLookup calls.
func NewPeerAnnouncer(selfAddr, selfDataCenter, selfRack string, ownerFor func(fid string) string, dial MountPeerDialer, localDir *PeerDirectory) *PeerAnnouncer {
return &PeerAnnouncer{
selfAddr: selfAddr,
selfDataCenter: selfDataCenter,
selfRack: selfRack,
ownerFor: ownerFor,
dialPeer: dial,
localDir: localDir,
announceInterval: 15 * time.Second,
announceTTL: 300 * time.Second,
pending: map[string]struct{}{},
announcedAt: map[string]announceRecord{},
stopCh: make(chan struct{}),
clock: time.Now,
}
}
// EnqueueAnnounce marks a fid as needing announcement. Non-blocking;
// dedupes within a flush window.
func (a *PeerAnnouncer) EnqueueAnnounce(fid string) {
a.mu.Lock()
a.pending[fid] = struct{}{}
a.mu.Unlock()
}
// SetCachePresence wires an optional residency check. When set, the
// announcer consults it right before dispatching a ChunkAnnounce RPC or
// writing a self-owned entry: if the cache no longer has the chunk
// (e.g. LRU-evicted under memory pressure between SetChunk and the
// next flush tick) we drop the announce rather than advertise bytes we
// can't serve back. nil disables the check.
func (a *PeerAnnouncer) SetCachePresence(isCached func(fid string) bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.isCached = isCached
}
// Start launches the background flush loop.
func (a *PeerAnnouncer) Start() {
a.runWg.Add(1)
go func() {
defer a.runWg.Done()
a.run()
}()
}
// Stop halts the flush loop and waits for the current flush (if any) to
// finish. Safe to call multiple times. Callers that tear down shared
// dependencies (conn pool, local directory) can rely on no more
// sendTo/localDir.Announce goroutines being live after Stop returns.
func (a *PeerAnnouncer) Stop() {
if a.stopped.Swap(true) {
return
}
close(a.stopCh)
// Cancel the in-flight flush's context so its RPCs return quickly
// instead of running out the 10 s timeout.
a.flushCancelMu.Lock()
if a.flushCancel != nil {
a.flushCancel()
}
a.flushCancelMu.Unlock()
a.runWg.Wait()
}
// Stats exposes counters for observability.
func (a *PeerAnnouncer) Stats() (flushes, sentFids, rejectedFids, flushErrs int64) {
return a.flushes.Load(), a.sentFids.Load(), a.rejectedFids.Load(), a.flushErrs.Load()
}
// announceFlushTimeout caps how long a single flush cycle can run. A slow
// or unresponsive owner peer would otherwise block a flush goroutine
// indefinitely, and since flushes fan out per owner in parallel a
// pileup would bleed into subsequent ticks and grow unbounded. The
// bound is set a bit under one flush interval so the next tick's flush
// doesn't chase the previous one's tail.
const announceFlushTimeout = 10 * time.Second
func (a *PeerAnnouncer) run() {
t := time.NewTicker(a.announceInterval)
defer t.Stop()
for {
select {
case <-a.stopCh:
return
case <-t.C:
ctx, cancel := context.WithTimeout(context.Background(), announceFlushTimeout)
a.flushCancelMu.Lock()
a.flushCancel = cancel
a.flushCancelMu.Unlock()
a.flushOnce(ctx)
a.flushCancelMu.Lock()
a.flushCancel = nil
a.flushCancelMu.Unlock()
cancel()
}
}
}
// flushOnce is exported via a wrapper for tests.
func (a *PeerAnnouncer) FlushForTest(ctx context.Context) {
a.flushOnce(ctx)
}
func (a *PeerAnnouncer) flushOnce(ctx context.Context) {
a.flushes.Add(1)
now := a.clock()
a.mu.Lock()
// Swap pending in O(1) instead of copying.
toAnnounce := a.pending
a.pending = map[string]struct{}{}
// Add renewals: (1) fids we announced long enough ago that they'll
// expire on the owner side before our next flush tick; (2) fids
// whose HRW owner has changed since we last announced them —
// usually because a new mount joined the seed list and the ring
// rebalanced. Also prune announcedAt entries that are so old
// (> 2× TTL) that re-announcing them would be pointless.
renewThreshold := now.Add(-a.announceTTL + 2*a.announceInterval)
staleCutoff := now.Add(-2 * a.announceTTL)
for fid, rec := range a.announcedAt {
switch {
case rec.at.Before(staleCutoff):
delete(a.announcedAt, fid)
case rec.at.Before(renewThreshold):
toAnnounce[fid] = struct{}{}
default:
// Same fid, fresh timestamp — but owner may have moved.
// Force a re-announce if HRW says a different mount is
// the owner now.
if a.ownerFor != nil {
if cur := a.ownerFor(fid); cur != "" && cur != rec.owner {
toAnnounce[fid] = struct{}{}
}
}
}
}
a.mu.Unlock()
if len(toAnnounce) == 0 {
return
}
// Drop fids the cache no longer has. The write path hooks into
// SetChunk + EnqueueAnnounce, but the chunk can be LRU-evicted in
// the window between that and the next 15 s flush. Advertising a
// chunk we can't actually serve just sends remote fetchers to our
// FetchChunk, which then returns NOT_FOUND — a wasted round trip.
// Also drop the announcedAt record so we don't keep renewing
// evicted fids forever.
a.mu.Lock()
present := a.isCached
a.mu.Unlock()
if present != nil {
for fid := range toAnnounce {
if !present(fid) {
delete(toAnnounce, fid)
a.mu.Lock()
delete(a.announcedAt, fid)
a.mu.Unlock()
}
}
if len(toAnnounce) == 0 {
return
}
}
// Classify each fid. Three buckets:
// byOwner — HRW owner is a remote mount; send a ChunkAnnounce RPC.
// selfOwned — HRW owner is us; write directly into our local
// directory (no RPC — we ARE the directory for this
// fid). Without this, fids we wrote AND HRW-own stay
// invisible to peer ChunkLookup forever.
// deferred — HRW owner unknown (empty seed view). Re-queue for
// the next flush once listOnce has populated seeds.
byOwner := map[string][]string{}
var selfOwned []string
var deferred []string
for fid := range toAnnounce {
owner := ""
if a.ownerFor != nil {
owner = a.ownerFor(fid)
}
switch owner {
case "":
deferred = append(deferred, fid)
case a.selfAddr:
selfOwned = append(selfOwned, fid)
default:
byOwner[owner] = append(byOwner[owner], fid)
}
}
if len(selfOwned) > 0 && a.localDir != nil {
a.localDir.Announce(a.selfAddr, a.selfDataCenter, a.selfRack, selfOwned, a.announceTTL, nil)
a.mu.Lock()
for _, fid := range selfOwned {
a.announcedAt[fid] = announceRecord{owner: a.selfAddr, at: now}
a.sentFids.Add(1)
}
a.mu.Unlock()
} else if len(selfOwned) > 0 {
// No local directory wired (e.g. unit tests with nil). Re-queue
// so a future flush with a possibly-different HRW owner can
// send them.
deferred = append(deferred, selfOwned...)
}
if len(deferred) > 0 {
a.mu.Lock()
for _, fid := range deferred {
a.pending[fid] = struct{}{}
}
a.mu.Unlock()
}
// Diagnostic V(2): shape of the current flush. Helps post-mortem
// debugging when an announce pipeline silently stops making progress
// (e.g. seed view stuck at self-only, owner unreachable, etc).
if len(byOwner) > 0 || len(selfOwned) > 0 || len(deferred) > 0 {
glog.V(2).Infof("peer-announce flush: toAnnounce=%d byOwner=%d selfOwned=%d deferred=%d",
len(toAnnounce), len(byOwner), len(selfOwned), len(deferred))
}
// Fan out one RPC per owner in parallel. A slow or unreachable
// owner blocks only its own goroutine; other owners' announces
// proceed immediately.
var wg sync.WaitGroup
for owner, fids := range byOwner {
wg.Add(1)
go func(owner string, fids []string) {
defer wg.Done()
a.sendTo(ctx, owner, fids, now)
}(owner, fids)
}
wg.Wait()
}
func (a *PeerAnnouncer) sendTo(ctx context.Context, owner string, fids []string, now time.Time) {
client, closeFn, err := a.dialPeer(ctx, owner)
if err != nil {
a.flushErrs.Add(1)
// Requeue these fids: a future flush will retry.
a.requeue(fids)
glog.V(2).Infof("peer-announce dial %s: %v", owner, err)
return
}
defer closeFn()
resp, err := client.ChunkAnnounce(ctx, &mount_peer_pb.ChunkAnnounceRequest{
FileIds: fids,
PeerAddr: a.selfAddr,
DataCenter: a.selfDataCenter,
Rack: a.selfRack,
TtlSeconds: int32(a.announceTTL / time.Second),
})
if err != nil {
a.flushErrs.Add(1)
a.requeue(fids)
glog.V(2).Infof("peer-announce %s: %v", owner, err)
return
}
rejected := map[string]struct{}{}
for _, f := range resp.RejectedFileIds {
rejected[f] = struct{}{}
}
a.mu.Lock()
for _, fid := range fids {
if _, skip := rejected[fid]; skip {
// Owner has changed or doesn't yet agree; retry later.
a.pending[fid] = struct{}{}
a.rejectedFids.Add(1)
continue
}
a.announcedAt[fid] = announceRecord{owner: owner, at: now}
a.sentFids.Add(1)
}
a.mu.Unlock()
}
func (a *PeerAnnouncer) requeue(fids []string) {
a.mu.Lock()
for _, fid := range fids {
a.pending[fid] = struct{}{}
}
a.mu.Unlock()
}