snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
package exclusive_locks
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
)
const (
RenewInterval = 4 * time.Second
SafeRenewInterval = 3 * time.Second
InitLockInterval = 1 * time.Second
// bounds each lease and renew RPC attempt so an unresponsive master
// cannot hold l.mu (and block ReleaseLock) indefinitely
rpcTimeout = 3 * time.Second
)
type ExclusiveLocker struct {
token int64
lockTsNs int64
isLocked atomic.Bool
masterClient *wdclient.MasterClient
lockName string
message string
clientName string
// serializes renew and release RPCs: a renewal in flight during a release
// would re-create the lock on the master and leave it held until expiry
mu sync.Mutex
// Each lock has and only has one goroutine
renewGoroutineRunning atomic.Bool
}
func NewExclusiveLocker(masterClient *wdclient.MasterClient, lockName string) *ExclusiveLocker {
return &ExclusiveLocker{
masterClient: masterClient,
lockName: lockName,
}
}
func (l *ExclusiveLocker) IsLocked() bool {
return l.isLocked.Load()
}
func (l *ExclusiveLocker) GetToken() (token int64, lockTsNs int64) {
for time.Unix(0, atomic.LoadInt64(&l.lockTsNs)).Add(SafeRenewInterval).Before(time.Now()) {
// wait until now is within the safe lock period, no immediate renewal to change the token
time.Sleep(100 * time.Millisecond)
}
return atomic.LoadInt64(&l.token), atomic.LoadInt64(&l.lockTsNs)
}
func (l *ExclusiveLocker) RequestLock(clientName string) {
if l.isLocked.Load() {
return
}
// retry to get the lease
for {
if err := l.masterClient.WithClient(context.Background(), false, func(client master_pb.SeaweedClient) error {
attemptCtx, cancel := context.WithTimeout(context.Background(), rpcTimeout)
defer cancel()
resp, err := client.LeaseAdminToken(attemptCtx, &master_pb.LeaseAdminTokenRequest{
PreviousToken: atomic.LoadInt64(&l.token),
PreviousLockTime: atomic.LoadInt64(&l.lockTsNs),
LockName: l.lockName,
ClientName: clientName,
})
if err == nil {
atomic.StoreInt64(&l.token, resp.Token)
atomic.StoreInt64(&l.lockTsNs, resp.LockTsNs)
}
return err
}); err != nil {
glog.V(2).Infof("Failed to acquire lock %s: %v", l.lockName, err)
time.Sleep(InitLockInterval)
} else {
break
}
}
l.mu.Lock()
l.clientName = clientName
l.isLocked.Store(true)
l.mu.Unlock()
glog.V(1).Infof("Acquired lock %s", l.lockName)
// Each lock has and only has one goroutine
if l.renewGoroutineRunning.CompareAndSwap(false, true) {
// start a goroutine to renew the lease
go func() {
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
for {
if err := l.renewLease(ctx2); err != nil {
glog.Warningf("Failed to renew lock %s: %v", l.lockName, err)
// clear the running flag before isLocked, so a RequestLock that
// reacquires (once isLocked is false) starts a replacement renewer
l.renewGoroutineRunning.Store(false)
l.isLocked.Store(false)
return
}
time.Sleep(RenewInterval)
}
}()
}
}
func (l *ExclusiveLocker) renewLease(ctx context.Context) error {
l.mu.Lock()
defer l.mu.Unlock()
if !l.isLocked.Load() {
return nil
}
return l.masterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
attemptCtx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := client.LeaseAdminToken(attemptCtx, &master_pb.LeaseAdminTokenRequest{
PreviousToken: atomic.LoadInt64(&l.token),
PreviousLockTime: atomic.LoadInt64(&l.lockTsNs),
LockName: l.lockName,
ClientName: l.clientName,
Message: l.message,
})
if err == nil {
atomic.StoreInt64(&l.token, resp.Token)
atomic.StoreInt64(&l.lockTsNs, resp.LockTsNs)
glog.V(2).Infof("Renewed lock %s: ts %d token %d", l.lockName, l.lockTsNs, l.token)
}
return err
})
}
func (l *ExclusiveLocker) ReleaseLock() {
l.mu.Lock()
defer l.mu.Unlock()
l.isLocked.Store(false)
l.clientName = ""
prevToken := atomic.LoadInt64(&l.token)
prevLockTsNs := atomic.LoadInt64(&l.lockTsNs)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// single unbounded attempt: a release cut short by a deadline leaves the
// lock held until it expires, turning a slow unlock into a ghost lock
l.masterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
client.ReleaseAdminToken(ctx, &master_pb.ReleaseAdminTokenRequest{
PreviousToken: prevToken,
PreviousLockTime: prevLockTsNs,
LockName: l.lockName,
})
return nil
})
// compare on clear: a RequestLock racing a slow release must not have its
// fresh token zeroed
atomic.CompareAndSwapInt64(&l.token, prevToken, 0)
atomic.CompareAndSwapInt64(&l.lockTsNs, prevLockTsNs, 0)
}
func (l *ExclusiveLocker) SetMessage(message string) {
l.message = message
}

View File

@@ -0,0 +1,743 @@
package wdclient
import (
"context"
"fmt"
"math/rand"
"strings"
"sync"
"sync/atomic"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// UrlPreference controls which URL to use for volume access
type UrlPreference string
const (
PreferUrl UrlPreference = "url" // Use private URL (default)
PreferPublicUrl UrlPreference = "publicUrl" // Use public URL
)
// filerHealth tracks the health status of a filer
type filerHealth struct {
lastFailureTimeNs int64 // atomic: last failure time in Unix nanoseconds
failureCount int32 // atomic: consecutive failures
}
// FilerClient provides volume location services by querying a filer
// It uses the shared vidMap cache for efficient lookups
// Supports multiple filer addresses with automatic failover for high availability
// Tracks filer health to avoid repeatedly trying known-unhealthy filers
// Can discover additional filers from master server when configured with filer group
type FilerClient struct {
*vidMapClient
filerAddresses []pb.ServerAddress
filerAddressesMu sync.RWMutex // Protects filerAddresses and filerHealth
filerIndex int32 // atomic: current filer index for round-robin
filerHealth []*filerHealth // health status per filer (same order as filerAddresses)
grpcDialOption grpc.DialOption
urlPreference UrlPreference
grpcTimeout time.Duration
cacheSize int // Number of historical vidMap snapshots to keep
clientId int32 // Unique client identifier for gRPC metadata
failureThreshold int32 // Circuit breaker: consecutive failures before circuit opens
resetTimeout time.Duration // Circuit breaker: time before re-checking unhealthy filer
maxRetries int // Retry: maximum retry attempts for transient failures
initialRetryWait time.Duration // Retry: initial wait time before first retry
retryBackoffFactor float64 // Retry: backoff multiplier for wait time
// Filer discovery fields
masterClient *MasterClient // Optional: for discovering filers in the same group
filerGroup string // Optional: filer group for discovery
discoveryInterval time.Duration // How often to refresh filer list from master
stopDiscovery chan struct{} // Signal to stop discovery goroutine
closeDiscoveryOnce sync.Once // Ensures discovery channel is closed at most once
}
// filerVolumeProvider implements VolumeLocationProvider by querying filer
// Supports multiple filer addresses with automatic failover
type filerVolumeProvider struct {
filerClient *FilerClient
}
// FilerClientOption holds optional configuration for FilerClient
type FilerClientOption struct {
GrpcTimeout time.Duration
UrlPreference UrlPreference
CacheSize int // Number of historical vidMap snapshots (0 = use default)
FailureThreshold int32 // Circuit breaker: consecutive failures before skipping filer (0 = use default of 3)
ResetTimeout time.Duration // Circuit breaker: time before re-checking unhealthy filer (0 = use default of 30s)
MaxRetries int // Retry: maximum retry attempts for transient failures (0 = use default of 3)
InitialRetryWait time.Duration // Retry: initial wait time before first retry (0 = use default of 1s)
RetryBackoffFactor float64 // Retry: backoff multiplier for wait time (0 = use default of 1.5)
// Filer discovery options
MasterClient *MasterClient // Optional: enables filer discovery from master
FilerGroup string // Optional: filer group name for discovery (required if MasterClient is set)
DiscoveryInterval time.Duration // Optional: how often to refresh filer list (0 = use default of 5 minutes)
}
// NewFilerClient creates a new client that queries filer(s) for volume locations
// Supports multiple filer addresses for high availability with automatic failover
// Uses sensible defaults: 5-second gRPC timeout, PreferUrl, DefaultVidMapCacheSize
func NewFilerClient(filerAddresses []pb.ServerAddress, grpcDialOption grpc.DialOption, dataCenter string, opts ...*FilerClientOption) *FilerClient {
if len(filerAddresses) == 0 {
glog.Fatal("NewFilerClient requires at least one filer address")
}
// Apply defaults
grpcTimeout := 5 * time.Second
urlPref := PreferUrl
cacheSize := DefaultVidMapCacheSize
failureThreshold := int32(3) // Default: 3 consecutive failures before circuit opens
resetTimeout := 30 * time.Second // Default: 30 seconds before re-checking unhealthy filer
maxRetries := 3 // Default: 3 retry attempts for transient failures
initialRetryWait := time.Second // Default: 1 second initial retry wait
retryBackoffFactor := 1.5 // Default: 1.5x backoff multiplier
var masterClient *MasterClient
var filerGroup string
discoveryInterval := 5 * time.Minute // Default: refresh every 5 minutes
// Override with provided options
if len(opts) > 0 && opts[0] != nil {
opt := opts[0]
if opt.GrpcTimeout > 0 {
grpcTimeout = opt.GrpcTimeout
}
if opt.UrlPreference != "" {
urlPref = opt.UrlPreference
}
if opt.CacheSize > 0 {
cacheSize = opt.CacheSize
}
if opt.FailureThreshold > 0 {
failureThreshold = opt.FailureThreshold
}
if opt.ResetTimeout > 0 {
resetTimeout = opt.ResetTimeout
}
if opt.MaxRetries > 0 {
maxRetries = opt.MaxRetries
}
if opt.InitialRetryWait > 0 {
initialRetryWait = opt.InitialRetryWait
}
if opt.RetryBackoffFactor > 0 {
retryBackoffFactor = opt.RetryBackoffFactor
}
if opt.MasterClient != nil {
masterClient = opt.MasterClient
filerGroup = opt.FilerGroup
if opt.DiscoveryInterval > 0 {
discoveryInterval = opt.DiscoveryInterval
}
}
}
// Initialize health tracking for each filer
health := make([]*filerHealth, len(filerAddresses))
for i := range health {
health[i] = &filerHealth{}
}
fc := &FilerClient{
filerAddresses: filerAddresses,
filerIndex: 0,
filerHealth: health,
grpcDialOption: grpcDialOption,
urlPreference: urlPref,
grpcTimeout: grpcTimeout,
cacheSize: cacheSize,
clientId: rand.Int31(), // Random client ID for gRPC metadata tracking
failureThreshold: failureThreshold,
resetTimeout: resetTimeout,
maxRetries: maxRetries,
initialRetryWait: initialRetryWait,
retryBackoffFactor: retryBackoffFactor,
masterClient: masterClient,
filerGroup: filerGroup,
discoveryInterval: discoveryInterval,
}
// Start filer discovery if master client is configured
// Empty filerGroup is valid (represents default group)
if masterClient != nil {
fc.stopDiscovery = make(chan struct{})
go fc.discoverFilers()
glog.V(0).Infof("FilerClient: started filer discovery for group '%s' (refresh interval: %v)", filerGroup, discoveryInterval)
}
// Create provider that references this FilerClient for failover support
provider := &filerVolumeProvider{
filerClient: fc,
}
fc.vidMapClient = newVidMapClient(provider, dataCenter, cacheSize)
return fc
}
// GetCurrentFiler returns the currently active filer address
// This is the filer that was last successfully used or the one indicated by round-robin
// Returns empty string if no filers are configured
func (fc *FilerClient) GetCurrentFiler() pb.ServerAddress {
fc.filerAddressesMu.RLock()
defer fc.filerAddressesMu.RUnlock()
if len(fc.filerAddresses) == 0 {
return ""
}
// Get current index (atomically updated on successful operations)
index := atomic.LoadInt32(&fc.filerIndex)
if index >= int32(len(fc.filerAddresses)) {
index = 0
}
return fc.filerAddresses[index]
}
// GetAllFilers returns a snapshot of all filer addresses
// Returns a copy to avoid concurrent modification issues
func (fc *FilerClient) GetAllFilers() []pb.ServerAddress {
fc.filerAddressesMu.RLock()
defer fc.filerAddressesMu.RUnlock()
// Return a copy to avoid concurrent modification
filers := make([]pb.ServerAddress, len(fc.filerAddresses))
copy(filers, fc.filerAddresses)
return filers
}
// SetCurrentFiler updates the current filer index to the specified address
// This is useful after successful failover to prefer the healthy filer for future requests
func (fc *FilerClient) SetCurrentFiler(addr pb.ServerAddress) {
fc.filerAddressesMu.RLock()
defer fc.filerAddressesMu.RUnlock()
// Find the index of the specified filer address
for i, filer := range fc.filerAddresses {
if filer == addr {
atomic.StoreInt32(&fc.filerIndex, int32(i))
return
}
}
// If address not found, leave index unchanged
}
// ShouldSkipUnhealthyFiler checks if a filer address should be skipped based on health tracking
// Returns true if the filer has exceeded failure threshold and reset timeout hasn't elapsed
func (fc *FilerClient) ShouldSkipUnhealthyFiler(addr pb.ServerAddress) bool {
fc.filerAddressesMu.RLock()
defer fc.filerAddressesMu.RUnlock()
// Find the health for this filer address
for i, filer := range fc.filerAddresses {
if filer == addr {
if i < len(fc.filerHealth) {
return fc.shouldSkipUnhealthyFilerWithHealth(fc.filerHealth[i])
}
return false
}
}
// If address not found, don't skip it
return false
}
// RecordFilerSuccess resets failure tracking for a successful filer
func (fc *FilerClient) RecordFilerSuccess(addr pb.ServerAddress) {
fc.filerAddressesMu.RLock()
defer fc.filerAddressesMu.RUnlock()
// Find the health for this filer address
for i, filer := range fc.filerAddresses {
if filer == addr {
if i < len(fc.filerHealth) {
fc.recordFilerSuccessWithHealth(fc.filerHealth[i])
}
return
}
}
}
// RecordFilerFailure increments failure count for an unhealthy filer
func (fc *FilerClient) RecordFilerFailure(addr pb.ServerAddress) {
fc.filerAddressesMu.RLock()
defer fc.filerAddressesMu.RUnlock()
// Find the health for this filer address
for i, filer := range fc.filerAddresses {
if filer == addr {
if i < len(fc.filerHealth) {
fc.recordFilerFailureWithHealth(fc.filerHealth[i])
}
return
}
}
}
// Close stops the filer discovery goroutine if running
// Safe to call multiple times (idempotent)
func (fc *FilerClient) Close() {
if fc.stopDiscovery != nil {
fc.closeDiscoveryOnce.Do(func() {
close(fc.stopDiscovery)
})
}
}
// discoverFilers periodically queries the master to discover filers in the same group
// and updates the filer list. This runs in a background goroutine.
func (fc *FilerClient) discoverFilers() {
defer func() {
if r := recover(); r != nil {
glog.Errorf("FilerClient: panic in filer discovery goroutine for group '%s': %v", fc.filerGroup, r)
}
}()
// Do an initial discovery
fc.refreshFilerList()
ticker := time.NewTicker(fc.discoveryInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fc.refreshFilerList()
case <-fc.stopDiscovery:
glog.V(0).Infof("FilerClient: stopping filer discovery for group '%s'", fc.filerGroup)
return
}
}
}
// refreshFilerList queries the master for the current list of filers and updates the local list
func (fc *FilerClient) refreshFilerList() {
if fc.masterClient == nil {
return
}
// Get current master address
currentMaster := fc.masterClient.GetMaster(context.Background())
if currentMaster == "" {
glog.V(1).Infof("FilerClient: no master available for filer discovery")
return
}
// Query master for filers in our group
updates := cluster.ListExistingPeerUpdates(currentMaster, fc.grpcDialOption, fc.filerGroup, cluster.FilerType)
if len(updates) == 0 {
glog.V(2).Infof("FilerClient: no filers found in group '%s'", fc.filerGroup)
return
}
// Build new filer address set
discoveredFilers := make(map[pb.ServerAddress]struct{}, len(updates))
for _, update := range updates {
if update.Address != "" {
discoveredFilers[pb.ServerAddress(update.Address)] = struct{}{}
}
}
// Ignore snapshots whose addresses are all empty; reconciling against an
// empty set would wipe the in-memory list.
if len(discoveredFilers) == 0 {
glog.V(1).Infof("FilerClient: discovery snapshot for group '%s' had no usable addresses, keeping existing list", fc.filerGroup)
return
}
fc.applyDiscoveredFilers(discoveredFilers)
}
// applyDiscoveredFilers treats the master snapshot as authoritative: survivors
// keep their health counters, new addresses get fresh health, addresses missing
// from the snapshot are pruned so replaced pods (e.g. rolled K8s filer pods
// with new IPs) don't linger and get retried after the circuit-breaker reset.
func (fc *FilerClient) applyDiscoveredFilers(discoveredFilers map[pb.ServerAddress]struct{}) {
fc.filerAddressesMu.Lock()
defer fc.filerAddressesMu.Unlock()
existingFilers := make(map[pb.ServerAddress]struct{}, len(fc.filerAddresses))
for _, f := range fc.filerAddresses {
existingFilers[f] = struct{}{}
}
var newFilers []pb.ServerAddress
for addr := range discoveredFilers {
if _, found := existingFilers[addr]; !found {
newFilers = append(newFilers, addr)
}
}
var removedFilers []pb.ServerAddress
for _, f := range fc.filerAddresses {
if _, found := discoveredFilers[f]; !found {
removedFilers = append(removedFilers, f)
}
}
if len(newFilers) == 0 && len(removedFilers) == 0 {
return
}
// Remember the active filer so the round-robin pointer can follow it across the rebuild.
currentIndex := atomic.LoadInt32(&fc.filerIndex)
var currentFiler pb.ServerAddress
if currentIndex >= 0 && currentIndex < int32(len(fc.filerAddresses)) {
currentFiler = fc.filerAddresses[currentIndex]
}
newAddresses := make([]pb.ServerAddress, 0, len(fc.filerAddresses)-len(removedFilers)+len(newFilers))
newHealth := make([]*filerHealth, 0, cap(newAddresses))
for i, f := range fc.filerAddresses {
if _, found := discoveredFilers[f]; found {
newAddresses = append(newAddresses, f)
newHealth = append(newHealth, fc.filerHealth[i])
}
}
for _, f := range newFilers {
newAddresses = append(newAddresses, f)
newHealth = append(newHealth, &filerHealth{})
}
fc.filerAddresses = newAddresses
fc.filerHealth = newHealth
var newIndex int32
if currentFiler != "" {
for i, f := range newAddresses {
if f == currentFiler {
newIndex = int32(i)
break
}
}
}
atomic.StoreInt32(&fc.filerIndex, newIndex)
if len(removedFilers) > 0 {
glog.V(0).Infof("FilerClient: removed %d filer(s) no longer in group '%s': %v", len(removedFilers), fc.filerGroup, removedFilers)
}
if len(newFilers) > 0 {
glog.V(0).Infof("FilerClient: discovered %d new filer(s) in group '%s': %v", len(newFilers), fc.filerGroup, newFilers)
}
}
// GetLookupFileIdFunction returns a lookup function with URL preference handling
func (fc *FilerClient) GetLookupFileIdFunction() LookupFileIdFunctionType {
if fc.urlPreference == PreferUrl {
// Use the default implementation from vidMapClient
return fc.vidMapClient.GetLookupFileIdFunction()
}
// Custom implementation that prefers PublicUrl
return func(ctx context.Context, fileId string) (fullUrls []string, err error) {
// Parse file ID to extract volume ID
parts := strings.Split(fileId, ",")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid fileId format: %s", fileId)
}
volumeIdStr := parts[0]
// First try the cache using LookupVolumeIdsWithFallback
vidLocations, err := fc.LookupVolumeIdsWithFallback(ctx, []string{volumeIdStr})
// Check for partial results first (important for multi-volume batched lookups)
locations, found := vidLocations[volumeIdStr]
if !found || len(locations) == 0 {
// Volume not found - return specific error with context from lookup if available
if err != nil {
return nil, fmt.Errorf("volume %s not found for fileId %s: %w", volumeIdStr, fileId, err)
}
return nil, fmt.Errorf("volume %s not found for fileId %s", volumeIdStr, fileId)
}
// Volume found successfully - ignore any errors about other volumes
// (not relevant for single-volume lookup, but defensive for future batching)
// Build URLs with publicUrl preference, and also prefer same DC
var sameDcUrls, otherDcUrls []string
localUrls := make(map[string]bool)
dataCenter := fc.GetDataCenter()
for _, loc := range locations {
url := loc.PublicUrl
if url == "" {
url = loc.Url
}
httpUrl := "http://" + url + "/" + fileId
glog.V(4).Infof("lookup %s => %s, data in remote storage tier: %v", fileId, url, loc.DataInRemote)
if !loc.DataInRemote {
localUrls[httpUrl] = true
}
if dataCenter != "" && dataCenter == loc.DataCenter {
sameDcUrls = append(sameDcUrls, httpUrl)
} else {
otherDcUrls = append(otherDcUrls, httpUrl)
}
}
// Shuffle to distribute load across volume servers
rand.Shuffle(len(sameDcUrls), func(i, j int) { sameDcUrls[i], sameDcUrls[j] = sameDcUrls[j], sameDcUrls[i] })
rand.Shuffle(len(otherDcUrls), func(i, j int) { otherDcUrls[i], otherDcUrls[j] = otherDcUrls[j], otherDcUrls[i] })
// Local replicas go first inside each data center, but never ahead of
// the data-center preference itself. Mirrors
// vidMap.LookupVolumeServerUrl so all client lookup paths agree.
if len(localUrls) > 0 {
sameDcUrls = util.ReorderToFront(localUrls, sameDcUrls)
otherDcUrls = util.ReorderToFront(localUrls, otherDcUrls)
}
fullUrls = append(sameDcUrls, otherDcUrls...)
return fullUrls, nil
}
}
// isRetryableGrpcError checks if a gRPC error is transient and should be retried
//
// Note on codes.Aborted: While Aborted can indicate application-level conflicts
// (e.g., transaction failures), in the context of volume location lookups (which
// are simple read-only operations with no transactions), Aborted is more likely
// to indicate transient server issues during restart/recovery. We include it here
// for volume lookups but log it for visibility in case misclassification occurs.
func isRetryableGrpcError(err error) bool {
if err == nil {
return false
}
// Check gRPC status code
st, ok := status.FromError(err)
if ok {
switch st.Code() {
case codes.Unavailable: // Server unavailable (temporary)
return true
case codes.DeadlineExceeded: // Request timeout
return true
case codes.ResourceExhausted: // Rate limited or overloaded
return true
case codes.Aborted:
// Aborted during read-only volume lookups is likely transient
// (e.g., filer restarting), but log for visibility
glog.V(1).Infof("Treating Aborted as retryable for volume lookup: %v", err)
return true
}
}
// Fallback for non-gRPC errors (e.g. network errors). "connection" and
// "timeout" are deliberately broader than the shared classifier: a volume
// lookup is a cheap read-only call, so leaning towards a retry is fine.
errStr := strings.ToLower(err.Error())
return util.IsTransientError(err) ||
strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "timeout")
}
// jitter returns a duration in the range [d/2, d) using equal jitter.
// This prevents thundering herds when many clients retry simultaneously
// after a transient failure (e.g., network partition healing).
func jitter(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
half := d / 2
if half <= 0 {
return d
}
return half + time.Duration(rand.Int63n(int64(half)))
}
// shouldSkipUnhealthyFiler checks if we should skip a filer based on recent failures
// Circuit breaker pattern: skip filers with multiple recent consecutive failures
// shouldSkipUnhealthyFilerWithHealth checks if a filer should be skipped based on health
// Uses atomic operations only - safe to call without locks
func (fc *FilerClient) shouldSkipUnhealthyFilerWithHealth(health *filerHealth) bool {
failureCount := atomic.LoadInt32(&health.failureCount)
// Check if failure count exceeds threshold
if failureCount < fc.failureThreshold {
return false
}
// Re-check unhealthy filers after reset timeout
lastFailureNs := atomic.LoadInt64(&health.lastFailureTimeNs)
if lastFailureNs == 0 {
return false // Never failed, shouldn't skip
}
lastFailureTime := time.Unix(0, lastFailureNs)
if time.Since(lastFailureTime) > fc.resetTimeout {
return false // Time to re-check
}
return true // Skip this unhealthy filer
}
// recordFilerSuccessWithHealth resets failure tracking for a successful filer
func (fc *FilerClient) recordFilerSuccessWithHealth(health *filerHealth) {
atomic.StoreInt32(&health.failureCount, 0)
}
// recordFilerFailureWithHealth increments failure count for an unhealthy filer
func (fc *FilerClient) recordFilerFailureWithHealth(health *filerHealth) {
atomic.AddInt32(&health.failureCount, 1)
atomic.StoreInt64(&health.lastFailureTimeNs, time.Now().UnixNano())
}
// LookupVolumeIds queries the filer for volume locations with automatic failover
// Tries all configured filer addresses until one succeeds (high availability)
// Retries transient gRPC errors (Unavailable, DeadlineExceeded, etc.) with exponential backoff
// Note: Unlike master's VolumeIdLocation, filer's Locations message doesn't currently have
// an Error field. This implementation handles the current structure while being prepared
// for future error reporting enhancements.
func (p *filerVolumeProvider) LookupVolumeIds(ctx context.Context, volumeIds []string) (map[string][]Location, error) {
fc := p.filerClient
result := make(map[string][]Location)
// Retry transient failures with configurable backoff
var lastErr error
waitTime := fc.initialRetryWait
maxRetries := fc.maxRetries
for retry := 0; retry < maxRetries; retry++ {
// Try all filer addresses with round-robin starting from current index
// Skip known-unhealthy filers (circuit breaker pattern)
i := atomic.LoadInt32(&fc.filerIndex)
// Get filer count with read lock
fc.filerAddressesMu.RLock()
n := int32(len(fc.filerAddresses))
fc.filerAddressesMu.RUnlock()
for x := int32(0); x < n; x++ {
// Get current filer address and health with read lock
fc.filerAddressesMu.RLock()
if len(fc.filerAddresses) == 0 {
fc.filerAddressesMu.RUnlock()
lastErr = fmt.Errorf("no filers available")
break
}
if i >= int32(len(fc.filerAddresses)) {
// Filer list changed, reset index
i = 0
}
// Get health pointer while holding lock
health := fc.filerHealth[i]
filerAddress := fc.filerAddresses[i]
fc.filerAddressesMu.RUnlock()
// Circuit breaker: skip unhealthy filers (no lock needed - uses atomics)
if fc.shouldSkipUnhealthyFilerWithHealth(health) {
glog.V(2).Infof("FilerClient: skipping unhealthy filer %s (consecutive failures: %d)",
filerAddress, atomic.LoadInt32(&health.failureCount))
i++
if i >= n {
i = 0
}
continue
}
// Use anonymous function to ensure defer cancel() is called per iteration, not accumulated
err := func() error {
// Create a fresh timeout context for each filer attempt
// This ensures each retry gets the full grpcTimeout, not a diminishing deadline
timeoutCtx, cancel := context.WithTimeout(ctx, fc.grpcTimeout)
defer cancel() // Always clean up context, even on panic or early return
return pb.WithGrpcFilerClient(false, fc.clientId, filerAddress, fc.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupVolume(timeoutCtx, &filer_pb.LookupVolumeRequest{
VolumeIds: volumeIds,
})
if err != nil {
return fmt.Errorf("filer.LookupVolume failed: %w", err)
}
// Process each volume in the response
for vid, locs := range resp.LocationsMap {
// Convert locations from protobuf to internal format
var locations []Location
for _, loc := range locs.Locations {
locations = append(locations, Location{
Url: loc.Url,
PublicUrl: loc.PublicUrl,
DataCenter: loc.DataCenter,
GrpcPort: int(loc.GrpcPort),
})
}
// Only add to result if we have locations
// Empty locations with no gRPC error means "not found" (volume doesn't exist)
if len(locations) > 0 {
result[vid] = locations
glog.V(4).Infof("FilerClient: volume %s found with %d location(s)", vid, len(locations))
} else {
glog.V(2).Infof("FilerClient: volume %s not found (no locations in response)", vid)
}
}
// Check for volumes that weren't in the response at all
// This could indicate a problem with the filer
for _, vid := range volumeIds {
if _, found := resp.LocationsMap[vid]; !found {
glog.V(1).Infof("FilerClient: volume %s missing from filer response", vid)
}
}
return nil
})
}()
if err != nil {
glog.V(1).Infof("FilerClient: filer %s lookup failed (attempt %d/%d, retry %d/%d): %v", filerAddress, x+1, n, retry+1, maxRetries, err)
fc.recordFilerFailureWithHealth(health)
lastErr = err
i++
if i >= n {
i = 0
}
continue
}
// Success - update the preferred filer index and reset health tracking
atomic.StoreInt32(&fc.filerIndex, i)
fc.recordFilerSuccessWithHealth(health)
glog.V(3).Infof("FilerClient: looked up %d volumes on %s, found %d", len(volumeIds), filerAddress, len(result))
return result, nil
}
// All filers failed on this attempt
// Check if the error is retryable (transient gRPC error)
if !isRetryableGrpcError(lastErr) {
// Non-retryable error (e.g., NotFound, PermissionDenied) - fail immediately
return nil, fmt.Errorf("all %d filer(s) failed with non-retryable error: %w", n, lastErr)
}
// Transient error - retry if we have attempts left
if retry < maxRetries-1 {
jitteredWait := jitter(waitTime)
glog.V(1).Infof("FilerClient: all %d filer(s) failed with retryable error (attempt %d/%d), retrying in %v: %v",
n, retry+1, maxRetries, jitteredWait, lastErr)
timer := time.NewTimer(jitteredWait)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
waitTime = time.Duration(float64(waitTime) * fc.retryBackoffFactor)
}
}
// All retries exhausted
fc.filerAddressesMu.RLock()
totalFilers := len(fc.filerAddresses)
fc.filerAddressesMu.RUnlock()
return nil, fmt.Errorf("all %d filer(s) failed after %d attempts, last error: %w", totalFilers, maxRetries, lastErr)
}

View File

@@ -0,0 +1,102 @@
package wdclient
import (
"sync/atomic"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
func newTestFilerClient(addrs ...pb.ServerAddress) *FilerClient {
health := make([]*filerHealth, len(addrs))
for i := range health {
health[i] = &filerHealth{}
}
return &FilerClient{
filerAddresses: addrs,
filerHealth: health,
}
}
func filerAddressList(fc *FilerClient) []pb.ServerAddress {
out := make([]pb.ServerAddress, len(fc.filerAddresses))
copy(out, fc.filerAddresses)
return out
}
func TestApplyDiscoveredFilersPrunesStaleAddress(t *testing.T) {
a := pb.ServerAddress("10.0.0.1:18888")
b := pb.ServerAddress("10.0.0.2:18888") // gets replaced by c
c := pb.ServerAddress("10.0.0.3:18888")
fc := newTestFilerClient(a, b)
// Give b a non-trivial failure count so we can confirm it leaves with its health.
atomic.StoreInt32(&fc.filerHealth[1].failureCount, 7)
// Give a a known failure count so we can confirm survivor health is preserved.
atomic.StoreInt32(&fc.filerHealth[0].failureCount, 2)
atomic.StoreInt32(&fc.filerIndex, 1) // active filer is b
fc.applyDiscoveredFilers(map[pb.ServerAddress]struct{}{
a: {},
c: {},
})
got := filerAddressList(fc)
if len(got) != 2 || got[0] != a || got[1] != c {
t.Fatalf("expected [%s %s], got %v", a, c, got)
}
if len(fc.filerHealth) != 2 {
t.Fatalf("expected 2 health entries, got %d", len(fc.filerHealth))
}
if got := atomic.LoadInt32(&fc.filerHealth[0].failureCount); got != 2 {
t.Errorf("survivor health was reset: want 2, got %d", got)
}
if got := atomic.LoadInt32(&fc.filerHealth[1].failureCount); got != 0 {
t.Errorf("newly added filer should start with fresh health, got failureCount=%d", got)
}
// Active filer (b) disappeared; index must reset rather than dangle.
if idx := atomic.LoadInt32(&fc.filerIndex); idx != 0 {
t.Errorf("expected filerIndex reset to 0 after active filer removed, got %d", idx)
}
}
func TestApplyDiscoveredFilersKeepsIndexOnSurvivor(t *testing.T) {
a := pb.ServerAddress("10.0.0.1:18888") // gets replaced
b := pb.ServerAddress("10.0.0.2:18888") // active, survives
c := pb.ServerAddress("10.0.0.3:18888") // new
fc := newTestFilerClient(a, b)
atomic.StoreInt32(&fc.filerIndex, 1) // active filer is b
fc.applyDiscoveredFilers(map[pb.ServerAddress]struct{}{
b: {},
c: {},
})
got := filerAddressList(fc)
if len(got) != 2 || got[0] != b || got[1] != c {
t.Fatalf("expected [%s %s], got %v", b, c, got)
}
// b moved to index 0 after a was pruned; index should follow.
if idx := atomic.LoadInt32(&fc.filerIndex); idx != 0 {
t.Errorf("expected filerIndex to follow surviving active filer to position 0, got %d", idx)
}
}
func TestApplyDiscoveredFilersNoChangeIsNoop(t *testing.T) {
a := pb.ServerAddress("10.0.0.1:18888")
b := pb.ServerAddress("10.0.0.2:18888")
fc := newTestFilerClient(a, b)
originalHealthA := fc.filerHealth[0]
originalHealthB := fc.filerHealth[1]
fc.applyDiscoveredFilers(map[pb.ServerAddress]struct{}{
a: {},
b: {},
})
if fc.filerHealth[0] != originalHealthA || fc.filerHealth[1] != originalHealthB {
t.Errorf("no-op refresh should not reallocate health entries")
}
}

View File

@@ -0,0 +1,65 @@
package wdclient
import (
"testing"
"time"
)
func TestJitterBounds(t *testing.T) {
cases := []time.Duration{
1 * time.Millisecond,
100 * time.Millisecond,
1 * time.Second,
5 * time.Second,
}
for _, d := range cases {
for i := 0; i < 100; i++ {
j := jitter(d)
if j < d/2 {
t.Errorf("jitter(%v) = %v, below lower bound %v", d, j, d/2)
}
if j >= d {
t.Errorf("jitter(%v) = %v, at or above upper bound %v", d, j, d)
}
}
}
}
func TestJitterZeroAndNegative(t *testing.T) {
if j := jitter(0); j != 0 {
t.Errorf("jitter(0) = %v, want 0", j)
}
if j := jitter(-1 * time.Second); j != 0 {
t.Errorf("jitter(-1s) = %v, want 0", j)
}
}
func TestJitterTinyDuration(t *testing.T) {
// When d < 2, half == 0 and rand.Int63n(0) panics.
// We should return d unmodified in that case.
j := jitter(1 * time.Nanosecond)
if j != 1*time.Nanosecond {
t.Errorf("jitter(1ns) = %v, want 1ns", j)
}
}
func TestJitterDistribution(t *testing.T) {
const iterations = 10000
const base = 100 * time.Millisecond
var sum time.Duration
for i := 0; i < iterations; i++ {
j := jitter(base)
sum += j
}
avg := sum / iterations
// Equal jitter average should be around 75% of base (midpoint of [50%, 100%))
expected := base * 3 / 4
tolerance := base / 10 // ±10%
if avg < expected-tolerance || avg > expected+tolerance {
t.Errorf("average jitter %v deviated from expected %v", avg, expected)
}
}

View File

@@ -0,0 +1,621 @@
package wdclient
import (
"context"
"errors"
"fmt"
"math/rand"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/version"
)
// masterVolumeProvider implements VolumeLocationProvider by querying master
// This is rarely called since master pushes updates proactively via KeepConnected stream
type masterVolumeProvider struct {
masterClient *MasterClient
}
func isCanceledErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
if statusErr, ok := status.FromError(err); ok {
switch statusErr.Code() {
case codes.Canceled, codes.DeadlineExceeded:
return true
}
}
return false
}
// LookupVolumeIds queries the master for volume locations (fallback when cache misses).
// Returns partial results with aggregated errors for volumes that failed.
// Retries on codes.Unavailable (e.g. master warming up after restart) with backoff.
func (p *masterVolumeProvider) LookupVolumeIds(ctx context.Context, volumeIds []string) (map[string][]Location, error) {
var result map[string][]Location
var lookupErrors []error
glog.V(2).Infof("Looking up %d volumes from master: %v", len(volumeIds), volumeIds)
retryErr := util.RetryWithBackoff(ctx, "lookup", 30*time.Second,
func(err error) bool {
st, ok := status.FromError(err)
return ok && st.Code() == codes.Unavailable
},
func() error {
result = make(map[string][]Location)
lookupErrors = nil
// Per-attempt timeout bounds both master resolution and the RPC
// so a single attempt cannot consume the entire retry budget.
timeoutCtx, cancel := context.WithTimeout(ctx, p.masterClient.grpcTimeout)
defer cancel()
master := p.masterClient.GetMaster(timeoutCtx)
if master == "" {
if ctx.Err() != nil {
return ctx.Err()
}
return status.Errorf(codes.Unavailable, "no master available")
}
return pb.WithMasterClient(timeoutCtx, false, master, p.masterClient.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
resp, err := client.LookupVolume(timeoutCtx, &master_pb.LookupVolumeRequest{
VolumeOrFileIds: volumeIds,
})
if err != nil {
return err
}
for _, vidLoc := range resp.VolumeIdLocations {
// Preserve per-volume errors from master response
// These could indicate misconfiguration, volume deletion, etc.
if vidLoc.Error != "" {
lookupErrors = append(lookupErrors, fmt.Errorf("volume %s: %s", vidLoc.VolumeOrFileId, vidLoc.Error))
glog.V(1).Infof("volume %s lookup error from master: %s", vidLoc.VolumeOrFileId, vidLoc.Error)
continue
}
// Parse volume ID from response
parts := strings.Split(vidLoc.VolumeOrFileId, ",")
vidOnly := parts[0]
vid, err := strconv.ParseUint(vidOnly, 10, 32)
if err != nil {
lookupErrors = append(lookupErrors, fmt.Errorf("volume %s: invalid volume ID format: %w", vidLoc.VolumeOrFileId, err))
glog.Warningf("Failed to parse volume id '%s' from master response '%s': %v", vidOnly, vidLoc.VolumeOrFileId, err)
continue
}
var locations []Location
for _, masterLoc := range vidLoc.Locations {
loc := Location{
Url: masterLoc.Url,
PublicUrl: masterLoc.PublicUrl,
GrpcPort: int(masterLoc.GrpcPort),
DataCenter: masterLoc.DataCenter,
DataInRemote: masterLoc.DataInRemote,
ReadOnly: masterLoc.ReadOnly,
ReadOnlyCanDelete: masterLoc.ReadOnlyCanDelete,
}
// Update cache with the location
p.masterClient.addLocation(uint32(vid), loc)
locations = append(locations, loc)
}
if len(locations) > 0 {
result[vidOnly] = locations
}
}
return nil
})
})
if retryErr != nil {
return nil, retryErr
}
// Return partial results with detailed errors
// Callers should check both result map and error
if len(lookupErrors) > 0 {
glog.V(2).Infof("MasterClient: looked up %d volumes, found %d, %d errors", len(volumeIds), len(result), len(lookupErrors))
return result, fmt.Errorf("master volume lookup errors: %w", errors.Join(lookupErrors...))
}
glog.V(3).Infof("MasterClient: looked up %d volumes, found %d", len(volumeIds), len(result))
return result, nil
}
// MasterClient connects to master servers and maintains volume location cache
// It receives real-time updates via KeepConnected streaming and uses vidMapClient for caching
type MasterClient struct {
*vidMapClient // Embedded cache with shared logic
FilerGroup string
clientType string
clientHost pb.ServerAddress
rack string
currentMaster pb.ServerAddress
currentMasterLock sync.RWMutex
masters pb.ServerDiscovery
grpcDialOption grpc.DialOption
grpcTimeout time.Duration // Timeout for gRPC calls to master
OnPeerUpdate func(update *master_pb.ClusterNodeUpdate, startFrom time.Time)
OnPeerUpdateLock sync.RWMutex
OnLockRingUpdate func(update *master_pb.LockRingUpdate)
OnLockRingUpdateLock sync.RWMutex
}
func NewMasterClient(grpcDialOption grpc.DialOption, filerGroup string, clientType string, clientHost pb.ServerAddress, clientDataCenter string, rack string, masters pb.ServerDiscovery) *MasterClient {
mc := &MasterClient{
FilerGroup: filerGroup,
clientType: clientType,
clientHost: clientHost,
rack: rack,
masters: masters,
grpcDialOption: grpcDialOption,
grpcTimeout: 5 * time.Second, // Default: 5 seconds for gRPC calls to master
}
// Create provider that references this MasterClient
provider := &masterVolumeProvider{masterClient: mc}
// Initialize embedded vidMapClient with the provider and default cache size
mc.vidMapClient = newVidMapClient(provider, clientDataCenter, DefaultVidMapCacheSize)
return mc
}
func (mc *MasterClient) SetOnPeerUpdateFn(onPeerUpdate func(update *master_pb.ClusterNodeUpdate, startFrom time.Time)) {
mc.OnPeerUpdateLock.Lock()
mc.OnPeerUpdate = onPeerUpdate
mc.OnPeerUpdateLock.Unlock()
}
func (mc *MasterClient) SetOnLockRingUpdateFn(fn func(update *master_pb.LockRingUpdate)) {
mc.OnLockRingUpdateLock.Lock()
mc.OnLockRingUpdate = fn
mc.OnLockRingUpdateLock.Unlock()
}
func (mc *MasterClient) tryAllMasters(ctx context.Context) {
var nextHintedLeader pb.ServerAddress
failedMasters := make(map[pb.ServerAddress]struct{})
mc.masters.RefreshBySrvIfAvailable()
for _, master := range mc.masters.GetInstances() {
if _, failed := failedMasters[master]; failed {
continue
}
nextHintedLeader = mc.tryConnectToMaster(ctx, master)
for nextHintedLeader != "" {
if _, failed := failedMasters[nextHintedLeader]; failed {
break // don't follow redirect to a known-unreachable master
}
select {
case <-ctx.Done():
glog.V(0).Infof("Connection attempt to all masters stopped: %v", ctx.Err())
return
default:
target := nextHintedLeader
nextHintedLeader = mc.tryConnectToMaster(ctx, target)
if nextHintedLeader == "" {
// connection to target failed; remember it so we skip
// stale redirects pointing back to it this cycle
failedMasters[target] = struct{}{}
}
}
}
mc.setCurrentMaster("")
}
}
func (mc *MasterClient) tryConnectToMaster(ctx context.Context, master pb.ServerAddress) (nextHintedLeader pb.ServerAddress) {
glog.V(1).Infof("%s.%s masterClient Connecting to master %v", mc.FilerGroup, mc.clientType, master)
stats.MasterClientConnectCounter.WithLabelValues("total").Inc()
connectStartTime := time.Now()
gprcErr := pb.WithMasterClient(context.Background(), true, master, mc.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
stream, err := client.KeepConnected(ctx)
if err != nil {
glog.V(1).Infof("%s.%s masterClient failed to keep connected to %s: %v", mc.FilerGroup, mc.clientType, master, err)
stats.MasterClientConnectCounter.WithLabelValues(stats.FailedToKeepConnected).Inc()
return err
}
glog.V(1).Infof("%s.%s masterClient gRPC stream established to %s in %v", mc.FilerGroup, mc.clientType, master, time.Since(connectStartTime))
if err = stream.Send(&master_pb.KeepConnectedRequest{
FilerGroup: mc.FilerGroup,
DataCenter: mc.GetDataCenter(),
Rack: mc.rack,
ClientType: mc.clientType,
ClientAddress: string(mc.clientHost),
Version: version.Version(),
}); err != nil {
glog.V(0).Infof("%s.%s masterClient failed to send to %s: %v", mc.FilerGroup, mc.clientType, master, err)
stats.MasterClientConnectCounter.WithLabelValues(stats.FailedToSend).Inc()
return err
}
glog.V(1).Infof("%s.%s masterClient Connected to %v", mc.FilerGroup, mc.clientType, master)
resp, err := stream.Recv()
if err != nil {
canceled := isCanceledErr(err) || ctx.Err() != nil
if canceled {
glog.V(1).Infof("%s.%s masterClient stream closed from %s: %v", mc.FilerGroup, mc.clientType, master, err)
} else {
glog.V(0).Infof("%s.%s masterClient failed to receive from %s: %v", mc.FilerGroup, mc.clientType, master, err)
stats.MasterClientConnectCounter.WithLabelValues(stats.FailedToReceive).Inc()
}
return err
}
// check if it is the leader to determine whether to reset the vidMap
if resp.VolumeLocation != nil {
if resp.VolumeLocation.Leader != "" && !master.Equals(pb.ServerAddress(resp.VolumeLocation.Leader)) {
glog.V(1).Infof("master %v redirected to leader %v", master, resp.VolumeLocation.Leader)
nextHintedLeader = pb.ServerAddress(resp.VolumeLocation.Leader)
stats.MasterClientConnectCounter.WithLabelValues(stats.RedirectedToLeader).Inc()
return nil
}
mc.resetVidMap()
mc.updateVidMap(resp)
} else {
// First message from master is not VolumeLocation (e.g., ClusterNodeUpdate)
// Still need to reset cache to ensure we don't use stale data from previous master
mc.resetVidMap()
}
mc.setCurrentMaster(master)
for {
resp, err := stream.Recv()
if err != nil {
canceled := isCanceledErr(err) || ctx.Err() != nil
if canceled {
glog.V(1).Infof("%s.%s masterClient stream closed from %s: %v", mc.FilerGroup, mc.clientType, master, err)
} else {
glog.V(0).Infof("%s.%s masterClient failed to receive from %s: %v", mc.FilerGroup, mc.clientType, master, err)
stats.MasterClientConnectCounter.WithLabelValues(stats.FailedToReceive).Inc()
}
return err
}
if resp.VolumeLocation != nil {
// Check for leader change during the stream
// If master announces a new leader, reconnect to it
if currentMaster := mc.GetMaster(ctx); resp.VolumeLocation.Leader != "" && !currentMaster.Equals(pb.ServerAddress(resp.VolumeLocation.Leader)) {
glog.V(1).Infof("currentMaster %v redirected to leader %v", currentMaster, resp.VolumeLocation.Leader)
nextHintedLeader = pb.ServerAddress(resp.VolumeLocation.Leader)
stats.MasterClientConnectCounter.WithLabelValues(stats.RedirectedToLeader).Inc()
return nil
}
mc.updateVidMap(resp)
}
if resp.ClusterNodeUpdate != nil {
update := resp.ClusterNodeUpdate
mc.OnPeerUpdateLock.RLock()
if mc.OnPeerUpdate != nil {
if update.FilerGroup == mc.FilerGroup {
if update.IsAdd {
glog.V(0).Infof("+ %s@%s noticed %s.%s %s\n", mc.clientType, mc.clientHost, update.FilerGroup, update.NodeType, update.Address)
} else {
glog.V(0).Infof("- %s@%s noticed %s.%s %s\n", mc.clientType, mc.clientHost, update.FilerGroup, update.NodeType, update.Address)
}
stats.MasterClientConnectCounter.WithLabelValues(stats.OnPeerUpdate).Inc()
mc.OnPeerUpdate(update, time.Now())
}
}
mc.OnPeerUpdateLock.RUnlock()
}
if resp.LockRingUpdate != nil {
update := resp.LockRingUpdate
mc.OnLockRingUpdateLock.RLock()
if mc.OnLockRingUpdate != nil {
if update.FilerGroup == mc.FilerGroup {
glog.V(0).Infof("LockRing: %s@%s received ring update v%d: %v", mc.clientType, mc.clientHost, update.Version, update.Servers)
mc.OnLockRingUpdate(update)
}
}
mc.OnLockRingUpdateLock.RUnlock()
}
if err := ctx.Err(); err != nil {
if isCanceledErr(err) {
glog.V(1).Infof("Connection attempt to master stopped: %v", err)
} else {
glog.V(0).Infof("Connection attempt to master stopped: %v", err)
}
return err
}
}
})
if gprcErr != nil {
if isCanceledErr(gprcErr) || ctx.Err() != nil {
glog.V(1).Infof("%s.%s masterClient connection closed to %v: %v", mc.FilerGroup, mc.clientType, master, gprcErr)
return nextHintedLeader
}
stats.MasterClientConnectCounter.WithLabelValues(stats.Failed).Inc()
glog.V(1).Infof("%s.%s masterClient failed to connect with master %v: %v", mc.FilerGroup, mc.clientType, master, gprcErr)
}
return nextHintedLeader
}
// addedVids indexes added ids that are also being removed. A volume moved
// between a server's disks is reported both ways in one message, and the server
// still has it -- acting on the removal would drop a good location, whichever
// order the two lists happen to be applied in. Empty unless both lists are
// non-empty, so the full id list a client gets on connect costs nothing.
func addedVids(added, removed []uint32) map[uint32]struct{} {
if len(added) == 0 || len(removed) == 0 {
return nil
}
index := make(map[uint32]struct{}, len(added))
for _, vid := range added {
index[vid] = struct{}{}
}
return index
}
// updateVidMap applies a KeepConnectedResponse volume-location message to the
// local vidMap. NewVids adds the entries; RemoteVids names the subset of them
// backed by remote storage, which is added with DataInRemote so read paths can
// prefer the cheap local replica. DeletedVids drops the named entry unless the
// same message also added it back (volume moved between this server's disks).
// EC vid changes go through the parallel addEcLocation / deleteEcLocation pair.
func (mc *MasterClient) updateVidMap(resp *master_pb.KeepConnectedResponse) {
if resp.VolumeLocation.IsEmptyUrl() {
glog.V(0).Infof("updateVidMap ignore short heartbeat: %+v", resp)
return
}
// process new volume location
loc := Location{
Url: resp.VolumeLocation.Url,
PublicUrl: resp.VolumeLocation.PublicUrl,
DataCenter: resp.VolumeLocation.DataCenter,
GrpcPort: int(resp.VolumeLocation.GrpcPort),
}
stillOnServer := addedVids(resp.VolumeLocation.NewVids, resp.VolumeLocation.DeletedVids)
// RemoteVids repeats ids NewVids already carries, so the tier is settled
// before anything is written rather than adding each one twice.
var remoteVids map[uint32]struct{}
if len(resp.VolumeLocation.RemoteVids) > 0 {
remoteVids = make(map[uint32]struct{}, len(resp.VolumeLocation.RemoteVids))
for _, vid := range resp.VolumeLocation.RemoteVids {
remoteVids[vid] = struct{}{}
}
}
var readOnlyVids map[uint32]struct{}
if len(resp.VolumeLocation.ReadOnlyVids) > 0 {
readOnlyVids = make(map[uint32]struct{}, len(resp.VolumeLocation.ReadOnlyVids))
for _, vid := range resp.VolumeLocation.ReadOnlyVids {
readOnlyVids[vid] = struct{}{}
}
}
var readOnlyCanDeleteVids map[uint32]struct{}
if len(resp.VolumeLocation.ReadOnlyCanDeleteVids) > 0 {
readOnlyCanDeleteVids = make(map[uint32]struct{}, len(resp.VolumeLocation.ReadOnlyCanDeleteVids))
for _, vid := range resp.VolumeLocation.ReadOnlyCanDeleteVids {
readOnlyCanDeleteVids[vid] = struct{}{}
}
}
for _, newVid := range resp.VolumeLocation.NewVids {
if _, isRemote := remoteVids[newVid]; isRemote {
continue
}
newLoc := loc
if _, isReadOnly := readOnlyVids[newVid]; isReadOnly {
newLoc.ReadOnly = true
}
if _, canDelete := readOnlyCanDeleteVids[newVid]; canDelete {
newLoc.ReadOnlyCanDelete = true
}
glog.V(2).Infof("%s.%s: %s masterClient adds volume %d", mc.FilerGroup, mc.clientType, newLoc.Url, newVid)
mc.addLocation(newVid, newLoc)
}
for _, remoteVid := range resp.VolumeLocation.RemoteVids {
remoteLoc := loc
remoteLoc.DataInRemote = true
if _, isReadOnly := readOnlyVids[remoteVid]; isReadOnly {
remoteLoc.ReadOnly = true
}
if _, canDelete := readOnlyCanDeleteVids[remoteVid]; canDelete {
remoteLoc.ReadOnlyCanDelete = true
}
glog.V(2).Infof("%s.%s: %s masterClient adds remote volume %d", mc.FilerGroup, mc.clientType, remoteLoc.Url, remoteVid)
mc.addLocation(remoteVid, remoteLoc)
}
for _, deletedVid := range resp.VolumeLocation.DeletedVids {
if _, moved := stillOnServer[deletedVid]; moved {
continue
}
glog.V(2).Infof("%s.%s: %s masterClient removes volume %d", mc.FilerGroup, mc.clientType, loc.Url, deletedVid)
mc.deleteLocation(deletedVid, loc)
}
stillOnServerEc := addedVids(resp.VolumeLocation.NewEcVids, resp.VolumeLocation.DeletedEcVids)
for _, newEcVid := range resp.VolumeLocation.NewEcVids {
glog.V(2).Infof("%s.%s: %s masterClient adds ec volume %d", mc.FilerGroup, mc.clientType, loc.Url, newEcVid)
mc.addEcLocation(newEcVid, loc)
}
for _, deletedEcVid := range resp.VolumeLocation.DeletedEcVids {
if _, moved := stillOnServerEc[deletedEcVid]; moved {
continue
}
glog.V(2).Infof("%s.%s: %s masterClient removes ec volume %d", mc.FilerGroup, mc.clientType, loc.Url, deletedEcVid)
mc.deleteEcLocation(deletedEcVid, loc)
}
glog.V(1).Infof("updateVidMap(%s) %s.%s: %s volume add local: %d, remote: %d, del: %d, add ec: %d del ec: %d",
resp.VolumeLocation.DataCenter, mc.FilerGroup, mc.clientType, loc.Url,
len(resp.VolumeLocation.NewVids)-len(resp.VolumeLocation.RemoteVids),
len(resp.VolumeLocation.RemoteVids),
len(resp.VolumeLocation.DeletedVids), len(resp.VolumeLocation.NewEcVids),
len(resp.VolumeLocation.DeletedEcVids))
}
func (mc *MasterClient) WithClient(ctx context.Context, streamingMode bool, fn func(client master_pb.SeaweedClient) error) error {
getMasterF := func() pb.ServerAddress {
return mc.GetMaster(ctx)
}
return mc.WithClientCustomGetMaster(ctx, getMasterF, streamingMode, fn)
}
// WithClientCustomGetMaster bounds the wait for a master leader by ctx, so a
// caller with a deadline is not parked for the length of an election. The dial
// still gets context.Background(): fn brings its own RPC context, so nothing
// here can attribute a cancellation to the shared connection.
func (mc *MasterClient) WithClientCustomGetMaster(ctx context.Context, getMasterF func() pb.ServerAddress, streamingMode bool, fn func(client master_pb.SeaweedClient) error) error {
return util.RetryWithBackoff(ctx, "master grpc", util.RetryWaitTime, util.IsTransientError, func() error {
master := getMasterF()
if err := ctx.Err(); err != nil {
return err
}
return pb.WithMasterClient(context.Background(), streamingMode, master, mc.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
return fn(client)
})
})
}
func (mc *MasterClient) getCurrentMaster() pb.ServerAddress {
mc.currentMasterLock.RLock()
defer mc.currentMasterLock.RUnlock()
return mc.currentMaster
}
func (mc *MasterClient) setCurrentMaster(master pb.ServerAddress) {
mc.currentMasterLock.Lock()
mc.currentMaster = master
mc.currentMasterLock.Unlock()
}
// GetMaster returns the current master address, blocking until connected.
//
// IMPORTANT: This method blocks until KeepConnectedToMaster successfully establishes
// a connection to a master server. If KeepConnectedToMaster hasn't been started in a
// background goroutine, this will block indefinitely (or until ctx is canceled).
//
// Typical initialization pattern:
//
// mc := wdclient.NewMasterClient(...)
// go mc.KeepConnectedToMaster(ctx) // Start connection management
// // ... later ...
// master := mc.GetMaster(ctx) // Will block until connected
//
// If called before KeepConnectedToMaster establishes a connection, this may cause
// unexpected timeouts in LookupVolumeIds and other operations that depend on it.
func (mc *MasterClient) GetMaster(ctx context.Context) pb.ServerAddress {
mc.WaitUntilConnected(ctx)
return mc.getCurrentMaster()
}
// GetMasters returns all configured master addresses, blocking until connected.
// See GetMaster() for important initialization contract details.
func (mc *MasterClient) GetMasters(ctx context.Context) []pb.ServerAddress {
mc.WaitUntilConnected(ctx)
return mc.masters.GetInstances()
}
// ListMasterSet returns a set of configured master addresses keyed by their
// canonical http form. Unlike GetMasters this does not wait for a connection,
// so it is safe to call from admission paths that must stay non-blocking.
func (mc *MasterClient) ListMasterSet() map[string]struct{} {
addrs := mc.masters.GetInstances()
set := make(map[string]struct{}, len(addrs))
for _, a := range addrs {
set[a.ToHttpAddress()] = struct{}{}
}
return set
}
// WaitUntilConnected blocks until a master connection is established or ctx is canceled.
// This does NOT initiate connections - it only waits for KeepConnectedToMaster to succeed.
func (mc *MasterClient) WaitUntilConnected(ctx context.Context) {
attempts := 0
for {
select {
case <-ctx.Done():
return
default:
currentMaster := mc.getCurrentMaster()
if currentMaster != "" {
return
}
attempts++
if attempts%100 == 0 { // Log every 100 attempts (roughly every 20 seconds)
glog.V(0).Infof("%s.%s WaitUntilConnected still waiting for master connection (attempt %d)...", mc.FilerGroup, mc.clientType, attempts)
}
// Use select with time.After to respect context cancellation during sleep
sleepDuration := time.Duration(rand.Int31n(200)) * time.Millisecond
select {
case <-ctx.Done():
return
case <-time.After(sleepDuration):
// continue to next iteration
}
}
}
}
func (mc *MasterClient) KeepConnectedToMaster(ctx context.Context) {
glog.V(1).Infof("%s.%s masterClient bootstraps with masters %v", mc.FilerGroup, mc.clientType, mc.masters)
reconnectCount := 0
for {
select {
case <-ctx.Done():
if isCanceledErr(ctx.Err()) {
glog.V(1).Infof("Connection to masters stopped: %v", ctx.Err())
} else {
glog.V(0).Infof("Connection to masters stopped: %v", ctx.Err())
}
return
default:
reconnectStart := time.Now()
if reconnectCount > 0 {
glog.V(0).Infof("%s.%s masterClient reconnection attempt #%d", mc.FilerGroup, mc.clientType, reconnectCount)
}
mc.tryAllMasters(ctx)
reconnectCount++
glog.V(1).Infof("%s.%s masterClient connection cycle completed in %v, sleeping before retry",
mc.FilerGroup, mc.clientType, time.Since(reconnectStart))
time.Sleep(time.Second)
}
}
}
func (mc *MasterClient) FindLeaderFromOtherPeers(myMasterAddress pb.ServerAddress) (leader string) {
for _, master := range mc.masters.GetInstances() {
if master == myMasterAddress {
continue
}
if grpcErr := pb.WithMasterClient(context.Background(), false, master, mc.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond)
defer cancel()
resp, err := client.GetMasterConfiguration(ctx, &master_pb.GetMasterConfigurationRequest{})
if err != nil {
return err
}
leader = resp.Leader
return nil
}); grpcErr != nil {
glog.V(0).Infof("connect to %s: %v", master, grpcErr)
}
if leader != "" {
glog.V(0).Infof("existing leader is %s", leader)
return
}
}
glog.V(0).Infof("No existing leader found!")
return
}

View File

@@ -0,0 +1,93 @@
package wdclient
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
func moveClient() *MasterClient {
return &MasterClient{vidMapClient: newVidMapClient(nil, "", 0)}
}
func moveResponse(newVids, deletedVids []uint32) *master_pb.KeepConnectedResponse {
return &master_pb.KeepConnectedResponse{VolumeLocation: &master_pb.VolumeLocation{
Url: "server:8080", PublicUrl: "server:8080", NewVids: newVids, DeletedVids: deletedVids,
}}
}
// A volume moved between a server's disks arrives added and removed at once,
// and the server still has it.
func TestMovedVolumeKeepsItsLocation(t *testing.T) {
mc := moveClient()
mc.updateVidMap(moveResponse([]uint32{1}, nil))
mc.updateVidMap(moveResponse([]uint32{1}, []uint32{1}))
locations, found := mc.GetLocations(1)
if !found || len(locations) != 1 {
t.Errorf("a volume that moved between its server's disks lost its location: found=%v %v", found, locations)
}
}
func TestRemovedVolumeLosesItsLocation(t *testing.T) {
mc := moveClient()
mc.updateVidMap(moveResponse([]uint32{1}, nil))
mc.updateVidMap(moveResponse(nil, []uint32{1}))
if locations, found := mc.GetLocations(1); found && len(locations) > 0 {
t.Errorf("a volume that left the server kept its location: %v", locations)
}
}
func TestReadOnlyVolumeUpdatesItsLocation(t *testing.T) {
mc := moveClient()
mc.updateVidMap(moveResponse([]uint32{1}, nil))
mc.updateVidMap(&master_pb.KeepConnectedResponse{VolumeLocation: &master_pb.VolumeLocation{
Url: "server:8080", PublicUrl: "server:8080", NewVids: []uint32{1}, ReadOnlyVids: []uint32{1},
}})
locations, found := mc.GetLocations(1)
if !found || len(locations) != 1 || !locations[0].ReadOnly {
t.Fatalf("read-only update = %v, want one read-only location", locations)
}
}
// Removals of other volumes in the same message must still apply.
func TestRemovalsAlongsideAMoveStillApply(t *testing.T) {
mc := moveClient()
mc.updateVidMap(moveResponse([]uint32{1, 2}, nil))
mc.updateVidMap(moveResponse([]uint32{1}, []uint32{1, 2}))
if locations, found := mc.GetLocations(1); !found || len(locations) != 1 {
t.Errorf("the moved volume lost its location: found=%v %v", found, locations)
}
if locations, found := mc.GetLocations(2); found && len(locations) > 0 {
t.Errorf("a volume that left the server kept its location: %v", locations)
}
}
func moveEcResponse(newEcVids, deletedEcVids []uint32) *master_pb.KeepConnectedResponse {
return &master_pb.KeepConnectedResponse{VolumeLocation: &master_pb.VolumeLocation{
Url: "server:8080", PublicUrl: "server:8080", NewEcVids: newEcVids, DeletedEcVids: deletedEcVids,
}}
}
func TestEcVolumeMovedKeepsItsLocation(t *testing.T) {
mc := moveClient()
mc.updateVidMap(moveEcResponse([]uint32{7}, nil))
mc.updateVidMap(moveEcResponse([]uint32{7}, []uint32{7}))
if locations, found := mc.GetLocations(7); !found || len(locations) != 1 {
t.Errorf("an ec volume reported both ways lost its location: found=%v %v", found, locations)
}
}
func TestEcVolumeRemovedLosesItsLocation(t *testing.T) {
mc := moveClient()
mc.updateVidMap(moveEcResponse([]uint32{7}, nil))
mc.updateVidMap(moveEcResponse(nil, []uint32{7}))
if locations, found := mc.GetLocations(7); found && len(locations) > 0 {
t.Errorf("an ec volume that left the server kept its location: %v", locations)
}
}

View File

@@ -0,0 +1,119 @@
package wdclient
import (
"context"
"errors"
"fmt"
"net"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
// fakeLookupServer returns Unavailable for the first N calls, then succeeds.
type fakeLookupServer struct {
master_pb.UnimplementedSeaweedServer
unavailableCount int32
callCount atomic.Int32
}
func (s *fakeLookupServer) LookupVolume(_ context.Context, req *master_pb.LookupVolumeRequest) (*master_pb.LookupVolumeResponse, error) {
n := s.callCount.Add(1)
if n <= s.unavailableCount {
return nil, status.Errorf(codes.Unavailable, "master is warming up")
}
resp := &master_pb.LookupVolumeResponse{}
for _, vid := range req.VolumeOrFileIds {
resp.VolumeIdLocations = append(resp.VolumeIdLocations, &master_pb.LookupVolumeResponse_VolumeIdLocation{
VolumeOrFileId: vid,
Locations: []*master_pb.Location{
{Url: "127.0.0.1:8080", PublicUrl: "127.0.0.1:8080"},
},
})
}
return resp, nil
}
func startFakeMasterServer(t *testing.T, srv master_pb.SeaweedServer) pb.ServerAddress {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
grpcServer := grpc.NewServer()
master_pb.RegisterSeaweedServer(grpcServer, srv)
go func() { _ = grpcServer.Serve(lis) }()
t.Cleanup(grpcServer.GracefulStop)
_, port, _ := net.SplitHostPort(lis.Addr().String())
return pb.ServerAddress(fmt.Sprintf("127.0.0.1:0.%s", port))
}
func TestLookupVolumeIdsRetriesOnUnavailable(t *testing.T) {
srv := &fakeLookupServer{unavailableCount: 3}
addr := startFakeMasterServer(t, srv)
mc := NewMasterClient(
grpc.WithTransportCredentials(insecure.NewCredentials()),
"", "test", "", "", "",
pb.ServerDiscovery{},
)
mc.setCurrentMaster(addr)
mc.grpcTimeout = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
provider := &masterVolumeProvider{masterClient: mc}
result, err := provider.LookupVolumeIds(ctx, []string{"1"})
if err != nil {
t.Fatalf("expected success after retries, got error: %v", err)
}
if _, ok := result["1"]; !ok {
t.Error("expected volume 1 in result")
}
if calls := srv.callCount.Load(); calls != 4 {
t.Errorf("expected 4 calls (3 unavailable + 1 success), got %d", calls)
}
}
func TestLookupVolumeIdsStopsOnContextCancel(t *testing.T) {
srv := &fakeLookupServer{unavailableCount: 1000}
addr := startFakeMasterServer(t, srv)
mc := NewMasterClient(
grpc.WithTransportCredentials(insecure.NewCredentials()),
"", "test", "", "", "",
pb.ServerDiscovery{},
)
mc.setCurrentMaster(addr)
mc.grpcTimeout = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
provider := &masterVolumeProvider{masterClient: mc}
start := time.Now()
_, err := provider.LookupVolumeIds(ctx, []string{"1"})
elapsed := time.Since(start)
// Verify the error is from context deadline
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected context.DeadlineExceeded, got: %v", err)
}
// Verify the loop actually retried (not just an immediate failure)
if calls := srv.callCount.Load(); calls <= 1 {
t.Errorf("expected multiple retry attempts, got %d calls", calls)
}
if elapsed > 5*time.Second {
t.Errorf("took %v, expected to stop near context deadline of 2s", elapsed)
}
}

View File

@@ -0,0 +1,186 @@
package wdclient
import (
"context"
"errors"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// TestWaitUntilConnectedWithoutKeepConnected verifies that WaitUntilConnected
// respects context cancellation when KeepConnectedToMaster is not running.
// This tests the fix for https://github.com/seaweedfs/seaweedfs/issues/7721
func TestWaitUntilConnectedWithoutKeepConnected(t *testing.T) {
mc := NewMasterClient(grpc.EmptyDialOption{}, "test-group", "test-client", "", "", "", pb.ServerDiscovery{})
// Without KeepConnectedToMaster running, WaitUntilConnected should
// respect context cancellation and not block forever
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
mc.WaitUntilConnected(ctx)
elapsed := time.Since(start)
// Should have returned due to context timeout, not blocked forever
if elapsed > 200*time.Millisecond {
t.Errorf("WaitUntilConnected blocked for %v, expected to return on context timeout", elapsed)
}
// GetMaster should return empty since we never connected
master := mc.getCurrentMaster()
if master != "" {
t.Errorf("Expected empty master, got %s", master)
}
}
// TestWaitUntilConnectedReturnsImmediatelyWhenConnected verifies that
// WaitUntilConnected returns immediately when a master is already set.
func TestWaitUntilConnectedReturnsImmediatelyWhenConnected(t *testing.T) {
mc := NewMasterClient(grpc.EmptyDialOption{}, "test-group", "test-client", "", "", "", pb.ServerDiscovery{})
// Simulate that KeepConnectedToMaster has already established a connection
mc.setCurrentMaster("localhost:9333")
ctx := context.Background()
start := time.Now()
mc.WaitUntilConnected(ctx)
elapsed := time.Since(start)
// Should return almost immediately (< 10ms)
if elapsed > 10*time.Millisecond {
t.Errorf("WaitUntilConnected took %v when master was already set, expected immediate return", elapsed)
}
// Verify master is returned
master := mc.getCurrentMaster()
if master != "localhost:9333" {
t.Errorf("Expected master localhost:9333, got %s", master)
}
}
// TestGetMasterRespectsContextCancellation verifies that GetMaster
// respects context cancellation and doesn't block forever.
func TestGetMasterRespectsContextCancellation(t *testing.T) {
mc := NewMasterClient(grpc.EmptyDialOption{}, "test-group", "test-client", "", "", "", pb.ServerDiscovery{})
// GetMaster calls WaitUntilConnected internally
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
master := mc.GetMaster(ctx)
elapsed := time.Since(start)
// Should return on context timeout
if elapsed > 200*time.Millisecond {
t.Errorf("GetMaster blocked for %v, expected to return on context timeout", elapsed)
}
// Master should be empty since we never connected
if master != "" {
t.Errorf("Expected empty master when not connected, got %s", master)
}
}
// TestMasterClientFilerGroupLogging verifies the FilerGroup is properly set
// and would be logged correctly (regression test for issue #7721 log message format)
func TestMasterClientFilerGroupLogging(t *testing.T) {
filerGroup := "filer_1"
clientType := "s3"
mc := NewMasterClient(grpc.EmptyDialOption{}, filerGroup, clientType, "", "", "", pb.ServerDiscovery{})
if mc.FilerGroup != filerGroup {
t.Errorf("Expected FilerGroup %s, got %s", filerGroup, mc.FilerGroup)
}
if mc.clientType != clientType {
t.Errorf("Expected clientType %s, got %s", clientType, mc.clientType)
}
}
// TestWithClientStopsWaitingOnCanceledContext verifies that WithClient hands the
// caller's context to the wait for a master leader, so a caller that has already
// given up is not parked until an election finishes.
func TestWithClientStopsWaitingOnCanceledContext(t *testing.T) {
mc := NewMasterClient(grpc.EmptyDialOption{}, "test-group", "test-client", "", "", "", pb.ServerDiscovery{})
ctx, cancel := context.WithCancel(context.Background())
cancel()
called := false
start := time.Now()
err := mc.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
called = true
return nil
})
elapsed := time.Since(start)
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
if called {
t.Error("callback ran without a master leader")
}
if elapsed > time.Second {
t.Errorf("WithClient blocked for %v with an already canceled context", elapsed)
}
}
// TestWithClientStopsWaitingOnDeadline verifies the same bound applies to a
// deadline the caller set rather than an outright cancellation.
func TestWithClientStopsWaitingOnDeadline(t *testing.T) {
mc := NewMasterClient(grpc.EmptyDialOption{}, "test-group", "test-client", "", "", "", pb.ServerDiscovery{})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
err := mc.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
return nil
})
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("expected context.DeadlineExceeded, got %v", err)
}
if elapsed > time.Second {
t.Errorf("WithClient blocked for %v past a 100ms deadline", elapsed)
}
}
// TestWithClientStopsBackoffOnCancel verifies that a cancellation arriving while
// the retry is backing off cuts the backoff short rather than sleeping it out.
func TestWithClientStopsBackoffOnCancel(t *testing.T) {
mc := NewMasterClient(grpc.WithTransportCredentials(insecure.NewCredentials()), "test-group", "test-client", "", "", "", pb.ServerDiscovery{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
getMasterF := func() pb.ServerAddress { return "localhost:19333" }
attempts := 0
start := time.Now()
err := mc.WithClientCustomGetMaster(ctx, getMasterF, false, func(client master_pb.SeaweedClient) error {
attempts++
cancel()
return errors.New("connection reset by peer")
})
elapsed := time.Since(start)
if attempts != 1 {
t.Errorf("expected 1 attempt, got %d", attempts)
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
if elapsed > time.Second {
t.Errorf("WithClientCustomGetMaster slept %v after the context was canceled", elapsed)
}
}

431
weed/wdclient/vid_map.go Normal file
View File

@@ -0,0 +1,431 @@
package wdclient
import (
"context"
"errors"
"fmt"
"math/rand"
"strconv"
"strings"
"sync"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
type HasLookupFileIdFunction interface {
GetLookupFileIdFunction() LookupFileIdFunctionType
}
type LookupFileIdFunctionType func(ctx context.Context, fileId string) (targetUrls []string, err error)
type Location struct {
Url string `json:"url,omitempty"`
PublicUrl string `json:"publicUrl,omitempty"`
DataCenter string `json:"dataCenter,omitempty"`
GrpcPort int `json:"grpcPort,omitempty"`
DataInRemote bool `json:"dataInRemote,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
ReadOnlyCanDelete bool `json:"readOnlyCanDelete,omitempty"`
}
func (l Location) ServerAddress() pb.ServerAddress {
return pb.NewServerAddressWithGrpcPort(l.Url, l.GrpcPort)
}
// locationsEntry is what a volume id maps to: the locations themselves plus the
// generation they were learned in. An entry is immutable once stored; every
// update installs a new one, so locations handed to a reader are never
// rewritten underneath it.
type locationsEntry struct {
locations []Location
generation uint64
}
type vidMap struct {
sync.RWMutex
vid2Locations map[uint32]*locationsEntry
ecVid2Locations map[uint32]*locationsEntry
// serverRefCount tracks how many vid locations (regular + EC) currently
// reference each volume server address. Maintaining it incrementally lets
// hasVolumeServer answer in O(1) instead of walking every volume entry.
// Keys are the canonical http form of pb.ServerAddress, so callers that
// pass either "host:port" or "host:port.grpc" find the same entry.
serverRefCount map[string]int
DataCenter string
// generation counts resets. Each entry remembers the generation it was
// learned in, so history expires per volume rather than by keeping
// snapshot copies of the whole map.
generation uint64
// retainGenerations is how many resets an entry survives without being
// refreshed before reset drops it.
retainGenerations uint64
}
func newVidMap(dataCenter string, retainGenerations int) *vidMap {
if retainGenerations <= 0 {
retainGenerations = DefaultVidMapCacheSize
}
return &vidMap{
vid2Locations: make(map[uint32]*locationsEntry),
ecVid2Locations: make(map[uint32]*locationsEntry),
serverRefCount: make(map[string]int),
DataCenter: dataCenter,
retainGenerations: uint64(retainGenerations),
}
}
// locationServerKey returns the index key used by serverRefCount for a
// Location. The key normalises away the optional grpc-port suffix so the
// counter stays consistent with hasVolumeServer's lookup.
func locationServerKey(loc Location) string {
return loc.ServerAddress().ToHttpAddress()
}
func (vc *vidMap) isSameDataCenter(loc *Location) bool {
if vc.DataCenter == "" || loc.DataCenter == "" || vc.DataCenter != loc.DataCenter {
return false
}
return true
}
// LookupVolumeServerUrl returns the cached volume-server URLs for vid in
// preference order: same-DC local, same-DC remote-tier, then the other data
// centers on the same footing. Within each group the order is randomized so
// load spreads across equivalent servers.
func (vc *vidMap) LookupVolumeServerUrl(vid string) (serverUrls []string, err error) {
id, err := strconv.Atoi(vid)
if err != nil {
glog.V(1).Infof("Unknown volume id %s", vid)
return nil, err
}
locations, found := vc.GetLocations(uint32(id))
if !found {
return nil, fmt.Errorf("volume %d not found", id)
}
var sameDcServers, otherDcServers []string
localUrls := make(map[string]bool)
for _, loc := range locations {
glog.V(4).Infof("lookup %s => %s, data in remote storage tier: %v", vid, loc.Url, loc.DataInRemote)
if !loc.DataInRemote {
localUrls[loc.Url] = true
}
if vc.isSameDataCenter(&loc) {
sameDcServers = append(sameDcServers, loc.Url)
} else {
otherDcServers = append(otherDcServers, loc.Url)
}
}
rand.Shuffle(len(sameDcServers), func(i, j int) {
sameDcServers[i], sameDcServers[j] = sameDcServers[j], sameDcServers[i]
})
rand.Shuffle(len(otherDcServers), func(i, j int) {
otherDcServers[i], otherDcServers[j] = otherDcServers[j], otherDcServers[i]
})
// Local replicas go first inside each data center, but never ahead of the
// data-center preference itself: a remote tier is often in the same region
// as the local replicas, so crossing a DC boundary to avoid it can cost
// more than the remote read it saves.
if len(localUrls) > 0 {
sameDcServers = util.ReorderToFront(localUrls, sameDcServers)
otherDcServers = util.ReorderToFront(localUrls, otherDcServers)
}
serverUrls = append(sameDcServers, otherDcServers...)
return
}
// LookupFileId resolves a "<vid>,<cookie>" file id to a list of HTTP read
// URLs using the same DC-then-local ordering as LookupVolumeServerUrl.
func (vc *vidMap) LookupFileId(ctx context.Context, fileId string) (fullUrls []string, err error) {
parts := strings.Split(fileId, ",")
if len(parts) != 2 {
return nil, errors.New("Invalid fileId " + fileId)
}
serverUrls, lookupError := vc.LookupVolumeServerUrl(parts[0])
if lookupError != nil {
return nil, lookupError
}
for _, serverUrl := range serverUrls {
fullUrls = append(fullUrls, "http://"+serverUrl+"/"+fileId)
}
return
}
// GetVidLocations returns the cached Location entries for vid as a string,
// for callers that need richer per-server fields than raw URLs (e.g.
// DataInRemote, PublicUrl).
func (vc *vidMap) GetVidLocations(vid string) (locations []Location, err error) {
id, err := strconv.Atoi(vid)
if err != nil {
glog.V(1).Infof("Unknown volume id %s", vid)
return nil, fmt.Errorf("Unknown volume id %s", vid)
}
foundLocations, found := vc.GetLocations(uint32(id))
if found {
return foundLocations, nil
}
return nil, fmt.Errorf("volume id %s not found", vid)
}
// GetLocations returns the cached Location entries for vid as a uint32.
// When both regular and EC entries are present, whichever was learned last
// wins so a volume that switched between regular and EC encoding stops
// answering from the stale copy. Returns found=false when nothing remains,
// including when only an older-generation entry would otherwise apply.
func (vc *vidMap) GetLocations(vid uint32) (locations []Location, found bool) {
vc.RLock()
defer vc.RUnlock()
regular, hasRegular := lookupEntry(vc.vid2Locations, vid)
ec, hasEc := lookupEntry(vc.ecVid2Locations, vid)
switch {
case hasRegular && hasEc:
// Whichever was learned last wins: once a volume is EC encoded, the
// regular copies a previous generation knew must stop answering for
// it, and a decoded volume must stop answering with its shards. A tie
// means one generation reported both, where the regular copies serve.
if ec.generation > regular.generation {
return ec.locations, true
}
return regular.locations, true
case hasRegular:
return regular.locations, true
case hasEc:
return ec.locations, true
}
// Nothing older to fall back to: a volume's history lives in its own entry,
// so a volume whose locations are all gone (a pod restarting, say) is a
// miss rather than a reason to serve what it used to have.
return nil, false
}
// lookupEntry returns vid's entry when it still holds locations. Callers must
// hold the lock.
func lookupEntry(vid2Locations map[uint32]*locationsEntry, vid uint32) (*locationsEntry, bool) {
entry, found := vid2Locations[vid]
if !found || len(entry.locations) == 0 {
return nil, false
}
return entry, true
}
func (vc *vidMap) GetLocationsClone(vid uint32) (locations []Location, found bool) {
locations, found = vc.GetLocations(vid)
if found {
// clone the locations in case the volume locations are changed below
existingLocations := make([]Location, len(locations))
copy(existingLocations, locations)
return existingLocations, found
}
return nil, false
}
// hasVolumeServer reports whether any tracked volume (regular or EC) is hosted
// on addr, including volumes still held from earlier generations. Used to gate
// admission of operations targeting a volume server.
func (vc *vidMap) hasVolumeServer(addr pb.ServerAddress) bool {
key := addr.ToHttpAddress()
if key == "" {
return false
}
vc.RLock()
defer vc.RUnlock()
return vc.serverRefCount[key] > 0
}
func (vc *vidMap) addLocation(vid uint32, location Location) {
vc.Lock()
defer vc.Unlock()
glog.V(4).Infof("+ volume id %d: %+v", vid, location)
vc.addLocationToMap(vc.vid2Locations, vid, location)
}
func (vc *vidMap) addEcLocation(vid uint32, location Location) {
vc.Lock()
defer vc.Unlock()
glog.V(4).Infof("+ ec volume id %d: %+v", vid, location)
vc.addLocationToMap(vc.ecVid2Locations, vid, location)
}
// addLocationToMap records location for vid. The first write of a generation
// replaces what an earlier one held instead of merging with it: after a reset
// the new master is the authority, so a volume that moved must not keep
// answering with the server it moved off. Callers must hold the write lock.
//
// If the URL is already present and the remote/local classification matches,
// the entry is left untouched (same replica, same view). When the
// classification flips -- e.g. a volume tiered to remote storage, or a
// remote-backed replica restored locally -- the entry is rebuilt so
// subsequent lookups pick up the new DataInRemote. The server reference key
// only depends on the URL/grpc port, so it stays stable across the flip and
// the refcount does not need to move.
func (vc *vidMap) addLocationToMap(vid2Locations map[uint32]*locationsEntry, vid uint32, location Location) {
entry, found := vid2Locations[vid]
if !found || entry.generation != vc.generation {
if found {
vc.releaseEntry(entry)
}
vid2Locations[vid] = &locationsEntry{
locations: []Location{location},
generation: vc.generation,
}
vc.incrementServerRef(locationServerKey(location))
return
}
for i, loc := range entry.locations {
if loc.Url == location.Url {
if loc.DataInRemote == location.DataInRemote && loc.ReadOnly == location.ReadOnly && loc.ReadOnlyCanDelete == location.ReadOnlyCanDelete {
return
}
// A reader holds the slice GetLocations handed it after the lock
// was dropped, so the replacement is copied rather than written
// into the array underneath it.
updated := make([]Location, len(entry.locations))
copy(updated, entry.locations)
updated[i] = location
entry.locations = updated
return
}
}
locations := make([]Location, 0, len(entry.locations)+1)
locations = append(locations, entry.locations...)
locations = append(locations, location)
vid2Locations[vid] = &locationsEntry{locations: locations, generation: entry.generation}
vc.incrementServerRef(locationServerKey(location))
}
func (vc *vidMap) deleteLocation(vid uint32, location Location) {
vc.Lock()
defer vc.Unlock()
glog.V(4).Infof("- volume id %d: %+v", vid, location)
vc.deleteLocationFromMap(vc.vid2Locations, vid, location)
}
func (vc *vidMap) deleteEcLocation(vid uint32, location Location) {
vc.Lock()
defer vc.Unlock()
glog.V(4).Infof("- ec volume id %d: %+v", vid, location)
vc.deleteLocationFromMap(vc.ecVid2Locations, vid, location)
}
// deleteLocationFromMap drops one location from vid's entry, and the entry
// itself once its last location is gone. The generation is untouched: a delete
// only speaks about the location it names, it does not make the rest of the
// entry any fresher. Callers must hold the write lock.
func (vc *vidMap) deleteLocationFromMap(vid2Locations map[uint32]*locationsEntry, vid uint32, location Location) {
entry, found := vid2Locations[vid]
if !found {
return
}
for i, loc := range entry.locations {
if loc.Url != location.Url {
continue
}
vc.decrementServerRef(locationServerKey(loc))
if len(entry.locations) == 1 {
delete(vid2Locations, vid)
return
}
remaining := make([]Location, 0, len(entry.locations)-1)
remaining = append(remaining, entry.locations[:i]...)
remaining = append(remaining, entry.locations[i+1:]...)
vid2Locations[vid] = &locationsEntry{locations: remaining, generation: entry.generation}
return
}
}
func (vc *vidMap) deleteVid(vid uint32) {
vc.Lock()
defer vc.Unlock()
if entry, found := vc.vid2Locations[vid]; found {
vc.releaseEntry(entry)
delete(vc.vid2Locations, vid)
}
if entry, found := vc.ecVid2Locations[vid]; found {
vc.releaseEntry(entry)
delete(vc.ecVid2Locations, vid)
}
}
// reset starts a new generation, as when the master changes and everything it
// told us has to be relearned. Entries stay readable while they are relearned
// and are dropped once they fall out of the retained window.
func (vc *vidMap) reset() {
vc.Lock()
defer vc.Unlock()
vc.generation++
if vc.generation <= vc.retainGenerations {
return
}
oldest := vc.generation - vc.retainGenerations
vc.expire(vc.vid2Locations, oldest)
vc.expire(vc.ecVid2Locations, oldest)
}
// expire drops entries last refreshed before oldest. Callers must hold the
// write lock.
func (vc *vidMap) expire(vid2Locations map[uint32]*locationsEntry, oldest uint64) {
for vid, entry := range vid2Locations {
if entry.generation >= oldest {
continue
}
vc.releaseEntry(entry)
delete(vid2Locations, vid)
}
}
// releaseEntry drops the server references an entry holds. Callers must hold
// the write lock.
func (vc *vidMap) releaseEntry(entry *locationsEntry) {
for _, loc := range entry.locations {
vc.decrementServerRef(locationServerKey(loc))
}
}
// incrementServerRef increases the refcount for key. Empty keys are skipped
// so a zero-value Location (which serialises to "") does not leak a permanent
// bucket that hasVolumeServer and decrementServerRef both ignore. Callers
// must hold vc's write lock.
func (vc *vidMap) incrementServerRef(key string) {
if key == "" {
return
}
vc.serverRefCount[key]++
}
// decrementServerRef decreases the refcount for key and removes the entry
// once it falls to zero. Callers must hold vc's write lock.
func (vc *vidMap) decrementServerRef(key string) {
if key == "" {
return
}
if n, ok := vc.serverRefCount[key]; ok {
if n <= 1 {
delete(vc.serverRefCount, key)
} else {
vc.serverRefCount[key] = n - 1
}
}
}

View File

@@ -0,0 +1,330 @@
package wdclient
import (
"runtime"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
func urlsOf(locations []Location) []string {
urls := make([]string, 0, len(locations))
for _, loc := range locations {
urls = append(urls, loc.Url)
}
return urls
}
// A volume that moved while we were talking to the previous master must answer
// with where it is now, not with both servers: the first write of a generation
// replaces the entry instead of merging into it.
func TestAddLocationReplacesEarlierGeneration(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(3)
movedFrom := Location{Url: "10.0.0.1:8080"}
movedTo := Location{Url: "10.0.0.2:8080"}
vm.addLocation(vid, movedFrom)
vm.reset()
vm.addLocation(vid, movedTo)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != movedTo.Url {
t.Fatalf("expected only %s after the move, got %v", movedTo.Url, urlsOf(locs))
}
if vm.hasVolumeServer(pb.ServerAddress(movedFrom.Url)) {
t.Errorf("server %s should no longer be referenced after the volume moved", movedFrom.Url)
}
if !vm.hasVolumeServer(pb.ServerAddress(movedTo.Url)) {
t.Errorf("server %s should be referenced after the volume moved", movedTo.Url)
}
}
// Replicas reported within one generation accumulate, and a repeated report of
// the same server is not counted twice.
func TestAddLocationMergesWithinGeneration(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(4)
first := Location{Url: "10.0.0.1:8080"}
second := Location{Url: "10.0.0.2:8080"}
vm.addLocation(vid, first)
vm.addLocation(vid, second)
vm.addLocation(vid, first)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 2 {
t.Fatalf("expected both replicas, got %v", urlsOf(locs))
}
// One delete must be enough to evict the server: the duplicate report of
// `first` must not have taken a second reference.
vm.deleteLocation(vid, first)
if vm.hasVolumeServer(pb.ServerAddress(first.Url)) {
t.Errorf("server %s should be evicted after a single delete", first.Url)
}
}
// An entry survives exactly retainGenerations resets without being relearned.
func TestResetRetainsUntilWindowExpires(t *testing.T) {
const retain = 2
vm := newVidMap("", retain)
vid := uint32(5)
location := Location{Url: "10.0.0.1:8080"}
vm.addLocation(vid, location)
for i := 0; i < retain; i++ {
vm.reset()
if _, found := vm.GetLocations(vid); !found {
t.Fatalf("location should still be retained after %d reset(s)", i+1)
}
}
vm.reset()
if _, found := vm.GetLocations(vid); found {
t.Errorf("location should be dropped after %d resets", retain+1)
}
if vm.hasVolumeServer(pb.ServerAddress(location.Url)) {
t.Errorf("expiry should release the server reference for %s", location.Url)
}
if len(vm.serverRefCount) != 0 {
t.Errorf("expiry left %d dangling server refcounts", len(vm.serverRefCount))
}
}
// Relearning an entry restarts its retention window.
func TestResetWindowRestartsOnRelearn(t *testing.T) {
const retain = 2
vm := newVidMap("", retain)
vid := uint32(6)
location := Location{Url: "10.0.0.1:8080"}
for i := 0; i < retain+3; i++ {
vm.addLocation(vid, location)
vm.reset()
}
if _, found := vm.GetLocations(vid); !found {
t.Error("a location relearned every generation must never expire")
}
}
// A delete names one location; it must not make the rest of a stale entry look
// freshly learned and so outlive its retention window.
func TestDeleteLocationDoesNotRefreshGeneration(t *testing.T) {
const retain = 2
vm := newVidMap("", retain)
vid := uint32(7)
first := Location{Url: "10.0.0.1:8080"}
second := Location{Url: "10.0.0.2:8080"}
vm.addLocation(vid, first)
vm.addLocation(vid, second)
for i := 0; i < retain; i++ {
vm.reset()
}
vm.deleteLocation(vid, first)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != second.Url {
t.Fatalf("expected %s to remain, got found=%v %v", second.Url, found, urlsOf(locs))
}
vm.reset()
if _, found := vm.GetLocations(vid); found {
t.Error("the surviving location was learned in the expired generation and should be dropped")
}
}
// Encoding a volume must stop the regular copies a previous generation knew
// from answering for it, and decoding it must stop its shards from answering.
func TestNewestGenerationWinsAcrossEcTransition(t *testing.T) {
regular := Location{Url: "10.0.0.1:8080"}
ecShard := Location{Url: "10.0.0.2:8080"}
t.Run("encoded", func(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vm.addLocation(1, regular)
vm.reset()
vm.addEcLocation(1, ecShard)
locs, found := vm.GetLocations(1)
if !found || len(locs) != 1 || locs[0].Url != ecShard.Url {
t.Fatalf("expected the freshly learned EC shard, got found=%v %v", found, urlsOf(locs))
}
})
t.Run("decoded", func(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vm.addEcLocation(1, ecShard)
vm.reset()
vm.addLocation(1, regular)
locs, found := vm.GetLocations(1)
if !found || len(locs) != 1 || locs[0].Url != regular.Url {
t.Fatalf("expected the freshly learned regular copy, got found=%v %v", found, urlsOf(locs))
}
})
t.Run("same generation prefers regular", func(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vm.addEcLocation(1, ecShard)
vm.addLocation(1, regular)
locs, found := vm.GetLocations(1)
if !found || len(locs) != 1 || locs[0].Url != regular.Url {
t.Fatalf("expected the regular copy, got found=%v %v", found, urlsOf(locs))
}
})
}
// Losing the last location drops the entry: a client that never resets should
// not accumulate one empty entry per volume it has ever seen deleted.
func TestDeleteLastLocationDropsEntry(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(12)
location := Location{Url: "10.0.0.1:8080"}
vm.addLocation(vid, location)
vm.deleteLocation(vid, location)
if _, found := vm.GetLocations(vid); found {
t.Error("a volume with no locations left must not resolve")
}
if len(vm.vid2Locations) != 0 {
t.Errorf("expected the emptied entry to be dropped, got %v", vm.vid2Locations)
}
if len(vm.serverRefCount) != 0 {
t.Errorf("server refcounts leaked: %v", vm.serverRefCount)
}
}
// EC locations follow the same rules as regular ones, and back a volume whose
// regular locations are gone.
func TestEcLocationsFollowGenerationRules(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(8)
regular := Location{Url: "10.0.0.1:8080"}
ecShard := Location{Url: "10.0.0.2:8080"}
movedEcShard := Location{Url: "10.0.0.3:8080"}
vm.addLocation(vid, regular)
vm.addEcLocation(vid, ecShard)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != regular.Url {
t.Fatalf("regular locations should win while they exist, got %v", urlsOf(locs))
}
// Regular copy goes away: the EC shards still serve the volume.
vm.deleteLocation(vid, regular)
locs, found = vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != ecShard.Url {
t.Fatalf("expected EC shard location, got found=%v %v", found, urlsOf(locs))
}
// EC shards move under a new master: no merging with the old report.
vm.reset()
vm.addEcLocation(vid, movedEcShard)
locs, found = vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != movedEcShard.Url {
t.Fatalf("expected only the moved EC shard, got found=%v %v", found, urlsOf(locs))
}
}
// Locations handed to a caller are never rewritten underneath it.
func TestGetLocationsResultIsStable(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(9)
first := Location{Url: "10.0.0.1:8080"}
second := Location{Url: "10.0.0.2:8080"}
vm.addLocation(vid, first)
vm.addLocation(vid, second)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 2 {
t.Fatalf("expected both replicas, got %v", urlsOf(locs))
}
snapshot := append([]Location(nil), locs...)
vm.deleteLocation(vid, first)
vm.addLocation(vid, Location{Url: "10.0.0.3:8080"})
for i := range snapshot {
if locs[i] != snapshot[i] {
t.Errorf("location %d changed under the caller: %v became %v", i, snapshot[i], locs[i])
}
}
}
func TestNewVidMapDefaultsRetention(t *testing.T) {
for _, retain := range []int{0, -1} {
if got := newVidMap("", retain).retainGenerations; got != DefaultVidMapCacheSize {
t.Errorf("newVidMap(%d) retention = %d, want %d", retain, got, DefaultVidMapCacheSize)
}
}
}
// Readers must never see a spurious miss for a volume that stays live, and the
// bookkeeping must survive concurrent writers. Run with -race.
func TestConcurrentResetAndUpdates(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
live := Location{Url: "10.0.0.1:8080"}
churn := Location{Url: "10.0.0.2:8080"}
const liveVid, churnVid = 1, 2
vm.addLocation(liveVid, live)
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
if _, found := vm.GetLocations(liveVid); !found {
t.Error("a volume that is relearned every generation must always resolve")
return
}
vm.hasVolumeServer(pb.ServerAddress(live.Url))
vm.GetLocationsClone(churnVid)
runtime.Gosched()
}
}
}()
}
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
vm.addEcLocation(churnVid, churn)
vm.deleteEcLocation(churnVid, churn)
vm.deleteVid(churnVid)
}
}()
}
for i := 0; i < 300; i++ {
vm.reset()
vm.addLocation(liveVid, live)
}
close(stop)
wg.Wait()
if _, found := vm.GetLocations(liveVid); !found {
t.Fatal("live volume lost after the churn")
}
vm.deleteVid(liveVid)
if len(vm.serverRefCount) != 0 {
t.Errorf("server refcounts leaked: %v", vm.serverRefCount)
}
}

View File

@@ -0,0 +1,122 @@
package wdclient
import (
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
// When the master reports a tier transition (local ↔ remote) for an existing
// replica on the same server URL, the cached DataInRemote must update. Otherwise
// reads keep preferring a remote-backed replica or skip a newly-restored local
// one. The server refcount must stay stable because the server key only depends
// on the URL/grpc port.
func TestAddLocationUpdatesDataInRemoteOnTransition(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(11)
server := Location{Url: "10.0.0.1:8080", DataCenter: "dc1", GrpcPort: 18080}
// First seen as local.
vm.addLocation(vid, server)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].DataInRemote {
t.Fatalf("expected single local replica, got %+v", locs)
}
// Same URL flips to remote.
tiered := server
tiered.DataInRemote = true
vm.addLocation(vid, tiered)
locs, found = vm.GetLocations(vid)
if !found {
t.Fatalf("tier transition dropped the replica")
}
if len(locs) != 1 {
t.Fatalf("tier transition should replace in place, got %d entries: %+v", len(locs), locs)
}
if !locs[0].DataInRemote {
t.Errorf("DataInRemote did not flip to remote on tier-out: %+v", locs[0])
}
if locs[0].Url != server.Url || locs[0].DataCenter != server.DataCenter {
t.Errorf("replaced entry lost non-DataInRemote fields: %+v", locs[0])
}
if !vm.hasVolumeServer(pb.ServerAddress(server.Url)) {
t.Errorf("server ref should remain stable across DataInRemote flip")
}
// Restored locally: same URL, DataInRemote back to false.
restored := server
vm.addLocation(vid, restored)
locs, found = vm.GetLocations(vid)
if !found {
t.Fatalf("restore transition dropped the replica")
}
if len(locs) != 1 {
t.Fatalf("restore should replace in place, got %d entries: %+v", len(locs), locs)
}
if locs[0].DataInRemote {
t.Errorf("DataInRemote did not flip back to local on restore: %+v", locs[0])
}
}
// LookupVolumeServerUrl must reflect the latest DataInRemote so the local-first
// ordering picks up newly-restored local replicas on the next read.
func TestLookupVolumeServerUrlReflectsRemoteTransition(t *testing.T) {
vm := newVidMap("dc1", DefaultVidMapCacheSize)
vid := uint32(12)
remote := Location{Url: "10.0.0.1:8080", DataCenter: "dc1", DataInRemote: true}
vm.addLocation(vid, remote)
urls, err := vm.LookupVolumeServerUrl("12")
if err != nil {
t.Fatalf("lookup failed: %v", err)
}
if len(urls) != 1 || urls[0] != "10.0.0.1:8080" {
t.Fatalf("expected only the remote replica, got %v", urls)
}
// Tier restored: same URL, DataInRemote=false.
local := Location{Url: "10.0.0.1:8080", DataCenter: "dc1"}
vm.addLocation(vid, local)
urls, err = vm.LookupVolumeServerUrl("12")
if err != nil {
t.Fatalf("lookup after restore failed: %v", err)
}
if len(urls) != 1 || urls[0] != "10.0.0.1:8080" {
t.Fatalf("expected only the restored replica, got %v", urls)
}
}
// A tier flip must not write into the slice a concurrent lookup is still
// walking: GetLocations hands out the entry's own slice and the caller reads
// it after the lock is dropped.
func TestTierFlipDoesNotRaceWithLookup(t *testing.T) {
vm := newVidMap("dc1", DefaultVidMapCacheSize)
vid := uint32(13)
vm.addLocation(vid, Location{Url: "10.0.0.1:8080", DataCenter: "dc1"})
vm.addLocation(vid, Location{Url: "10.0.0.2:8080", DataCenter: "dc1"})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 2000; i++ {
vm.addLocation(vid, Location{Url: "10.0.0.1:8080", DataCenter: "dc1", DataInRemote: i%2 == 0})
}
}()
go func() {
defer wg.Done()
for i := 0; i < 2000; i++ {
if _, err := vm.LookupVolumeServerUrl("13"); err != nil {
t.Errorf("lookup failed mid-flip: %v", err)
return
}
}
}()
wg.Wait()
}

View File

@@ -0,0 +1,166 @@
package wdclient
import (
"context"
"strconv"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
"google.golang.org/grpc"
)
func TestLookupFileId(t *testing.T) {
mc := NewMasterClient(grpc.EmptyDialOption{}, "", "", "", "", "", pb.ServerDiscovery{})
length := 5
//Construct a cache linked list of length 5
for i := 0; i < length; i++ {
mc.addLocation(uint32(i), Location{Url: strconv.FormatInt(int64(i), 10)})
mc.resetVidMap()
}
for i := 0; i < length; i++ {
locations, found := mc.GetLocations(uint32(i))
if !found || len(locations) != 1 || locations[0].Url != strconv.FormatInt(int64(i), 10) {
t.Fatalf("urls of vid=%d is not valid.", i)
}
}
//When continue to add nodes to the linked list, the previous node will be deleted, and the cache of the response will be gone.
for i := length; i < length+5; i++ {
mc.addLocation(uint32(i), Location{Url: strconv.FormatInt(int64(i), 10)})
mc.resetVidMap()
}
for i := 0; i < length; i++ {
locations, found := mc.GetLocations(uint32(i))
if found {
t.Fatalf("urls of vid[%d] should not exists, but found: %v", i, locations)
}
}
//The delete operation will be applied to all cache nodes
_, found := mc.GetLocations(uint32(length))
if !found {
t.Fatalf("urls of vid[%d] not found", length)
}
//If the locations of the current node exist, return directly
newUrl := "abc"
mc.addLocation(uint32(length), Location{Url: newUrl})
locations, found := mc.GetLocations(uint32(length))
if !found || locations[0].Url != newUrl {
t.Fatalf("urls of vid[%d] not found", length)
}
//After delete `abc`, cache nodes are searched
deleteLoc := Location{Url: newUrl}
mc.deleteLocation(uint32(length), deleteLoc)
locations, found = mc.GetLocations(uint32(length))
if found && locations[0].Url != strconv.FormatInt(int64(length), 10) {
t.Fatalf("urls of vid[%d] not expected", length)
}
//lock: concurrent test
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
for i := 0; i < 20; i++ {
_, _ = mc.GetLocations(uint32(i))
}
}
}()
}
for i := 0; i < 100; i++ {
mc.addLocation(uint32(i), Location{})
}
wg.Wait()
}
func TestConcurrentGetLocations(t *testing.T) {
mc := NewMasterClient(grpc.EmptyDialOption{}, "", "", "", "", "", pb.ServerDiscovery{})
location := Location{Url: "TestDataRacing"}
mc.addLocation(1, location)
ctx, cancel := context.WithCancel(context.Background())
wg := sync.WaitGroup{}
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
_, found := mc.GetLocations(1)
if !found {
cancel()
t.Error("vid map invalid due to data racing. ")
return
}
}
}
}()
}
//Simulate vidmap reset with cache when leader changes
for i := 0; i < 100; i++ {
mc.resetVidMap()
mc.addLocation(1, location)
time.Sleep(1 * time.Microsecond)
}
cancel()
wg.Wait()
}
func TestHasVolumeServer(t *testing.T) {
mc := NewMasterClient(grpc.EmptyDialOption{}, "", "", "", "", "", pb.ServerDiscovery{})
regular := Location{Url: "10.0.0.1:8080", GrpcPort: 18080}
ecOnly := Location{Url: "10.0.0.2:8080", GrpcPort: 18080}
mc.addLocation(7, regular)
mc.addEcLocation(9, ecOnly)
addr := func(u string) pb.ServerAddress { return pb.ServerAddress(u) }
if !mc.HasVolumeServer(addr("10.0.0.1:8080")) {
t.Fatalf("regular volume server must be known by http address")
}
if !mc.HasVolumeServer(addr("10.0.0.1:8080.18080")) {
t.Fatalf("regular volume server must be known by grpc-suffix address")
}
if !mc.HasVolumeServer(addr("10.0.0.2:8080")) {
t.Fatalf("ec-only volume server must be known")
}
if mc.HasVolumeServer(addr("127.0.0.1:1")) {
t.Fatalf("unknown address must not be known")
}
// Adding the same location twice must not double-count:
// deleting once should evict the server.
mc.addLocation(7, regular)
mc.deleteLocation(7, regular)
if mc.HasVolumeServer(addr("10.0.0.1:8080")) {
t.Fatalf("server should be evicted after deleteLocation")
}
// Removing the EC entry must also drop the index entry.
mc.deleteEcLocation(9, ecOnly)
if mc.HasVolumeServer(addr("10.0.0.2:8080")) {
t.Fatalf("server should be evicted after deleteEcLocation")
}
// deleteVid removes every reference held by that vid in one call.
mc.addLocation(11, regular)
mc.addEcLocation(11, regular)
mc.InvalidateCache("11,abc")
if mc.HasVolumeServer(addr("10.0.0.1:8080")) {
t.Fatalf("server should be evicted after InvalidateCache")
}
}

View File

@@ -0,0 +1,49 @@
package wdclient
import (
"context"
"reflect"
"testing"
)
type authoritativeLookupProvider struct {
calls int
result map[string][]Location
err error
}
func (p *authoritativeLookupProvider) LookupVolumeIds(_ context.Context, volumeIDs []string) (map[string][]Location, error) {
p.calls++
return p.result, p.err
}
func TestAuthoritativeVolumeLookupDoesNotTrustCachedLocations(t *testing.T) {
provider := &authoritativeLookupProvider{result: map[string][]Location{}}
client := newVidMapClient(provider, "", DefaultVidMapCacheSize)
client.addLocation(17, Location{Url: "retired-volume:8080"})
locations, err := client.LookupVolumeIdsAuthoritative(context.Background(), []string{"17"})
if err != nil {
t.Fatalf("authoritative lookup: %v", err)
}
if provider.calls != 1 {
t.Fatalf("provider calls = %d, want 1", provider.calls)
}
if len(locations) != 0 {
t.Fatalf("authoritative lookup returned stale cache: %#v", locations)
}
}
func TestAuthoritativeVolumeLookupReturnsProviderResult(t *testing.T) {
want := map[string][]Location{"17": {{Url: "current-volume:8080"}}}
provider := &authoritativeLookupProvider{result: want}
client := newVidMapClient(provider, "", DefaultVidMapCacheSize)
got, err := client.LookupVolumeIdsAuthoritative(context.Background(), []string{"17"})
if err != nil {
t.Fatalf("authoritative lookup: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("locations = %#v, want %#v", got, want)
}
}

View File

@@ -0,0 +1,338 @@
package wdclient
import (
"context"
"errors"
"fmt"
"math/rand"
"sort"
"strconv"
"strings"
"golang.org/x/sync/singleflight"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// VolumeLocationProvider is the interface for looking up volume locations
// This allows different implementations (master subscription, filer queries, etc.)
type VolumeLocationProvider interface {
// LookupVolumeIds looks up volume locations for the given volume IDs
// Returns a map of volume ID to locations
LookupVolumeIds(ctx context.Context, volumeIds []string) (map[string][]Location, error)
}
// vidMapClient provides volume location caching with pluggable lookup
// It wraps the battle-tested vidMap with customizable volume lookup strategies
type vidMapClient struct {
vidMap *vidMap
provider VolumeLocationProvider
vidLookupGroup singleflight.Group
}
const (
// DefaultVidMapCacheSize is the default number of resets a volume location
// survives without being relearned. This provides cache history when
// volumes move between servers.
DefaultVidMapCacheSize = 5
)
// newVidMapClient creates a new client with the given provider and data center
func newVidMapClient(provider VolumeLocationProvider, dataCenter string, cacheSize int) *vidMapClient {
return &vidMapClient{
vidMap: newVidMap(dataCenter, cacheSize),
provider: provider,
}
}
// GetLookupFileIdFunction returns a function that can be used to lookup file IDs
func (vc *vidMapClient) GetLookupFileIdFunction() LookupFileIdFunctionType {
return vc.LookupFileIdWithFallback
}
// LookupFileIdWithFallback resolves a "<vid>,<cookie>" file id to a list of
// HTTP read URLs, using the cached vidMap when populated and falling back to
// the provider for a fresh lookup on miss. URLs are returned in preference
// order: same-DC local, same-DC remote-tier, then the other data centers on
// the same footing -- mirroring the cached vidMap path so both routes agree.
// Concurrent misses for the same vid are coalesced via the singleflight group
// on LookupVolumeIdsWithFallback.
func (vc *vidMapClient) LookupFileIdWithFallback(ctx context.Context, fileId string) (fullUrls []string, err error) {
// Try cache first
dataCenter := vc.vidMap.DataCenter
fullUrls, err = vc.vidMap.LookupFileId(ctx, fileId)
// Cache hit - return immediately
if err == nil && len(fullUrls) > 0 {
return
}
// Cache miss - extract volume ID from file ID (format: "volumeId,needle_id_cookie")
if fileId == "" {
return nil, fmt.Errorf("empty fileId")
}
parts := strings.Split(fileId, ",")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid fileId %s", fileId)
}
volumeId := parts[0]
// Use shared lookup logic with batching and singleflight
vidLocations, err := vc.LookupVolumeIdsWithFallback(ctx, []string{volumeId})
// Check for partial results first (important for multi-volume batched lookups)
locations, found := vidLocations[volumeId]
if !found || len(locations) == 0 {
// Volume not found - return specific error with context from lookup if available
if err != nil {
return nil, fmt.Errorf("volume %s not found for fileId %s: %w", volumeId, fileId, err)
}
return nil, fmt.Errorf("volume %s not found for fileId %s", volumeId, fileId)
}
// Volume found successfully - ignore any errors about other volumes
// (not relevant for single-volume lookup, but defensive for future batching)
// Build HTTP URLs from locations, preferring same data center
var sameDcUrls, otherDcUrls []string
localUrls := make(map[string]bool)
for _, loc := range locations {
httpUrl := "http://" + loc.Url + "/" + fileId
glog.V(4).Infof("lookup %s => %s, data in remote storage tier: %v", fileId, loc.Url, loc.DataInRemote)
if !loc.DataInRemote {
localUrls[httpUrl] = true
}
if dataCenter != "" && dataCenter == loc.DataCenter {
sameDcUrls = append(sameDcUrls, httpUrl)
} else {
otherDcUrls = append(otherDcUrls, httpUrl)
}
}
// Shuffle to distribute load across volume servers
rand.Shuffle(len(sameDcUrls), func(i, j int) { sameDcUrls[i], sameDcUrls[j] = sameDcUrls[j], sameDcUrls[i] })
rand.Shuffle(len(otherDcUrls), func(i, j int) { otherDcUrls[i], otherDcUrls[j] = otherDcUrls[j], otherDcUrls[i] })
// Local replicas go first inside each data center, but never ahead of the
// data-center preference itself. Mirrors vidMap.LookupVolumeServerUrl so
// all client lookup paths agree.
if len(localUrls) > 0 {
sameDcUrls = util.ReorderToFront(localUrls, sameDcUrls)
otherDcUrls = util.ReorderToFront(localUrls, otherDcUrls)
}
fullUrls = append(sameDcUrls, otherDcUrls...)
return fullUrls, nil
}
// LookupVolumeIdsWithFallback looks up volume locations, querying provider if not in cache.
// Uses singleflight to coalesce concurrent requests for the same batch of volumes.
//
// IMPORTANT: This function may return PARTIAL results with a non-nil error.
// The result map contains successfully looked up volumes, while the error aggregates
// failures for volumes that couldn't be found or had lookup errors.
//
// Callers MUST check both the result map AND the error:
// - result != nil && err == nil: All volumes found successfully
// - result != nil && err != nil: Some volumes found, some failed (check both)
// - result == nil && err != nil: Complete failure (connection error, etc.)
//
// Example usage:
//
// locs, err := mc.LookupVolumeIdsWithFallback(ctx, []string{"1", "2", "999"})
// if len(locs) > 0 {
// // Process successfully found volumes
// }
// if err != nil {
// // Log/handle failed volumes
// }
func (vc *vidMapClient) LookupVolumeIdsWithFallback(ctx context.Context, volumeIds []string) (map[string][]Location, error) {
result := make(map[string][]Location)
var needsLookup []string
var lookupErrors []error
// Check cache first and parse volume IDs once
vidStringToUint := make(map[string]uint32, len(volumeIds))
for _, vidString := range volumeIds {
vid, err := strconv.ParseUint(vidString, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid volume id %s: %v", vidString, err)
}
vidStringToUint[vidString] = uint32(vid)
locations, found := vc.vidMap.GetLocations(uint32(vid))
if found && len(locations) > 0 {
result[vidString] = locations
} else {
needsLookup = append(needsLookup, vidString)
}
}
if len(needsLookup) == 0 {
return result, nil
}
// Batch query all missing volumes using singleflight on the batch key
// Sort for stable key to coalesce identical batches
sort.Strings(needsLookup)
batchKey := strings.Join(needsLookup, ",")
sfResult, err, _ := vc.vidLookupGroup.Do(batchKey, func() (interface{}, error) {
// Double-check cache for volumes that might have been populated while waiting
stillNeedLookup := make([]string, 0, len(needsLookup))
batchResult := make(map[string][]Location)
for _, vidString := range needsLookup {
vid := vidStringToUint[vidString] // Use pre-parsed value
if locations, found := vc.vidMap.GetLocations(vid); found && len(locations) > 0 {
batchResult[vidString] = locations
} else {
stillNeedLookup = append(stillNeedLookup, vidString)
}
}
if len(stillNeedLookup) == 0 {
return batchResult, nil
}
// Query provider with batched volume IDs
glog.V(2).Infof("Looking up %d volumes from provider: %v", len(stillNeedLookup), stillNeedLookup)
providerResults, err := vc.provider.LookupVolumeIds(ctx, stillNeedLookup)
if err != nil {
return batchResult, fmt.Errorf("provider lookup failed: %w", err)
}
// Update cache with results
for vidString, locations := range providerResults {
vid, err := strconv.ParseUint(vidString, 10, 32)
if err != nil {
glog.Warningf("Failed to parse volume id '%s': %v", vidString, err)
continue
}
for _, loc := range locations {
vc.addLocation(uint32(vid), loc)
}
if len(locations) > 0 {
batchResult[vidString] = locations
}
}
return batchResult, nil
})
if err != nil {
lookupErrors = append(lookupErrors, err)
}
// Merge singleflight batch results
if batchLocations, ok := sfResult.(map[string][]Location); ok {
for vid, locs := range batchLocations {
result[vid] = locs
}
}
// Check for volumes that still weren't found
for _, vidString := range needsLookup {
if _, found := result[vidString]; !found {
lookupErrors = append(lookupErrors, fmt.Errorf("volume %s not found", vidString))
}
}
// Return aggregated errors
return result, errors.Join(lookupErrors...)
}
// LookupVolumeIdsAuthoritative bypasses the asynchronously maintained vid map.
// Use it when a stale positive result is unsafe, such as before serving cache
// payload from a Volume that the master may have retired. The provider still
// batches all requested IDs into one lookup, returns a nil map when it got no
// answer, and reports the volumes the master does not serve as errors.
func (vc *vidMapClient) LookupVolumeIdsAuthoritative(ctx context.Context, volumeIds []string) (map[string][]Location, error) {
return vc.provider.LookupVolumeIds(ctx, volumeIds)
}
// Public methods for external access
//
// The vidMap itself is never replaced, so these all read the one map under its
// own lock. Resets bump its generation instead of swapping in a fresh instance.
// GetLocations safely retrieves volume locations
func (vc *vidMapClient) GetLocations(vid uint32) (locations []Location, found bool) {
return vc.vidMap.GetLocations(vid)
}
// GetLocationsClone safely retrieves a clone of volume locations
func (vc *vidMapClient) GetLocationsClone(vid uint32) (locations []Location, found bool) {
return vc.vidMap.GetLocationsClone(vid)
}
// GetVidLocations safely retrieves volume locations by string ID
func (vc *vidMapClient) GetVidLocations(vid string) (locations []Location, err error) {
return vc.vidMap.GetVidLocations(vid)
}
// LookupFileId safely looks up URLs for a file ID
func (vc *vidMapClient) LookupFileId(ctx context.Context, fileId string) (fullUrls []string, err error) {
return vc.vidMap.LookupFileId(ctx, fileId)
}
// LookupVolumeServerUrl safely looks up volume server URLs
func (vc *vidMapClient) LookupVolumeServerUrl(vid string) (serverUrls []string, err error) {
return vc.vidMap.LookupVolumeServerUrl(vid)
}
// HasVolumeServer reports whether addr is currently a known volume server
// (hosts at least one volume or EC shard) in the cached vid map. Used by
// admission paths that must only contact peers learned from the master.
func (vc *vidMapClient) HasVolumeServer(addr pb.ServerAddress) bool {
return vc.vidMap.hasVolumeServer(addr)
}
// GetDataCenter safely retrieves the data center
func (vc *vidMapClient) GetDataCenter() string {
return vc.vidMap.DataCenter
}
// Thread-safe helpers for vidMap operations
// addLocation adds a volume location
func (vc *vidMapClient) addLocation(vid uint32, location Location) {
vc.vidMap.addLocation(vid, location)
}
// deleteLocation removes a volume location
func (vc *vidMapClient) deleteLocation(vid uint32, location Location) {
vc.vidMap.deleteLocation(vid, location)
}
// addEcLocation adds an EC volume location
func (vc *vidMapClient) addEcLocation(vid uint32, location Location) {
vc.vidMap.addEcLocation(vid, location)
}
// deleteEcLocation removes an EC volume location
func (vc *vidMapClient) deleteEcLocation(vid uint32, location Location) {
vc.vidMap.deleteEcLocation(vid, location)
}
// resetVidMap starts a new generation, as when the master changes: what the
// previous one told us stays readable until it is relearned or expires.
func (vc *vidMapClient) resetVidMap() {
vc.vidMap.reset()
}
// InvalidateCache removes all cached locations for a volume ID
func (vc *vidMapClient) InvalidateCache(fileId string) {
parts := strings.Split(fileId, ",")
vidString := parts[0]
vid, err := strconv.ParseUint(vidString, 10, 32)
if err != nil {
return
}
vc.vidMap.deleteVid(uint32(vid))
}

View File

@@ -0,0 +1,113 @@
package wdclient
import (
"context"
"strings"
"testing"
)
type testLocationProvider struct {
locations map[string][]Location
}
func (p *testLocationProvider) LookupVolumeIds(ctx context.Context, volumeIds []string) (map[string][]Location, error) {
result := make(map[string][]Location)
for _, vid := range volumeIds {
if locs, found := p.locations[vid]; found {
result[vid] = locs
}
}
return result, nil
}
// TestLookupFileIdWithFallbackLocalFirst ensures volumes whose data is still
// local are tried before remote-tier replicas, on the provider (cache miss) path.
func TestLookupFileIdWithFallbackLocalFirst(t *testing.T) {
vc := newVidMapClient(&testLocationProvider{
locations: map[string][]Location{
"5": {
{Url: "10.0.0.1:8080", DataInRemote: true},
{Url: "10.0.0.2:8080", DataInRemote: false},
},
},
}, "", 5)
urls, err := vc.LookupFileIdWithFallback(context.Background(), "5,abcdef0123456789")
if err != nil {
t.Fatalf("lookup failed: %v", err)
}
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %v", urls)
}
hasLocal, hasRemote := false, false
for _, u := range urls {
if strings.Contains(u, "10.0.0.2:8080") {
hasLocal = true
}
if strings.Contains(u, "10.0.0.1:8080") {
hasRemote = true
}
}
if !hasLocal {
t.Errorf("local replica missing from result: %v", urls)
}
if !hasRemote {
t.Errorf("remote replica missing from result: %v", urls)
}
if !strings.Contains(urls[0], "10.0.0.2:8080") {
t.Errorf("expected local replica first, got %v", urls)
}
}
// TestLookupFileIdWithFallbackAllRemote keeps shuffled order when every replica is remote.
func TestLookupFileIdWithFallbackAllRemote(t *testing.T) {
vc := newVidMapClient(&testLocationProvider{
locations: map[string][]Location{
"6": {
{Url: "10.0.0.1:8080", DataInRemote: true},
{Url: "10.0.0.2:8080", DataInRemote: true},
},
},
}, "", 5)
urls, err := vc.LookupFileIdWithFallback(context.Background(), "6,abcdef0123456789")
if err != nil {
t.Fatalf("lookup failed: %v", err)
}
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %v", urls)
}
}
// TestLookupFileIdWithFallbackKeepsDataCenterFirst verifies that the local-first
// ordering applies inside each data center and does not override the data-center
// preference: a same-DC remote replica still beats an other-DC local one, because
// the remote tier is usually nearer than another data center.
func TestLookupFileIdWithFallbackKeepsDataCenterFirst(t *testing.T) {
vc := newVidMapClient(&testLocationProvider{
locations: map[string][]Location{
"7": {
{Url: "10.0.0.1:8080", DataCenter: "dc1", DataInRemote: true},
{Url: "10.0.0.2:8080", DataCenter: "dc1", DataInRemote: false},
{Url: "10.0.0.3:8080", DataCenter: "dc2", DataInRemote: false},
{Url: "10.0.0.4:8080", DataCenter: "dc2", DataInRemote: true},
},
},
}, "dc1", 5)
urls, err := vc.LookupFileIdWithFallback(context.Background(), "7,abcdef0123456789")
if err != nil {
t.Fatalf("lookup failed: %v", err)
}
if len(urls) != 4 {
t.Fatalf("expected 4 urls, got %v", urls)
}
// dc1 local, dc1 remote, dc2 local, dc2 remote.
want := []string{"10.0.0.2:8080", "10.0.0.1:8080", "10.0.0.3:8080", "10.0.0.4:8080"}
for i, host := range want {
if !strings.Contains(urls[i], host) {
t.Fatalf("position %d should be %s, got %v", i, host, urls)
}
}
}

View File

@@ -0,0 +1,172 @@
package wdclient
import (
"testing"
)
// TestInvalidateCacheValidFileId tests cache invalidation with a valid file ID
func TestInvalidateCacheValidFileId(t *testing.T) {
// Create a simple vidMapClient (can use nil provider for this test)
vc := &vidMapClient{
vidMap: newVidMap("", DefaultVidMapCacheSize),
}
// Add some locations to the cache
vid := uint32(456)
vc.addLocation(vid, Location{Url: "http://server1:8080"})
if _, found := vc.GetLocations(vid); !found {
t.Fatal("Location should exist before invalidation")
}
// Call InvalidateCache with a properly formatted file ID
fileId := "456,abcdef123456"
vc.InvalidateCache(fileId)
// Verify the locations were removed
if _, found := vc.GetLocations(vid); found {
t.Errorf("Expected locations for vid %d to be removed after InvalidateCache", vid)
}
}
// TestInvalidateCacheInvalidFileId tests cache invalidation with invalid file IDs
func TestInvalidateCacheInvalidFileId(t *testing.T) {
testCases := []struct {
name string
fileId string
}{
{"empty file ID", ""},
{"no comma separator", "12345"},
{"non-numeric vid", "abc,defg"},
{"negative vid", "-1,abcd"},
{"oversized vid", "999999999999999999999,abcd"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
vc := &vidMapClient{
vidMap: newVidMap("", DefaultVidMapCacheSize),
}
// Add a location to ensure the cache isn't empty
vc.addLocation(1, Location{Url: "http://server:8080"})
// This should not panic or cause errors
vc.InvalidateCache(tc.fileId)
// Verify the existing location is still there (not affected)
if _, found := vc.GetLocations(1); !found {
t.Errorf("InvalidateCache with invalid fileId '%s' should not affect other entries", tc.fileId)
}
})
}
}
// TestInvalidateCacheWithHistory tests that invalidation also drops locations
// that were learned before the last reset and are still being retained.
func TestInvalidateCacheWithHistory(t *testing.T) {
vid := uint32(789)
vc := &vidMapClient{
vidMap: newVidMap("", DefaultVidMapCacheSize),
}
// Learned from an earlier master, then kept across a reset
vc.addLocation(vid, Location{Url: "http://server1:8080"})
vc.resetVidMap()
if _, found := vc.GetLocations(vid); !found {
t.Fatal("Retained location should still be readable after a reset")
}
vc.InvalidateCache("789,xyz123")
if _, found := vc.GetLocations(vid); found {
t.Error("Expected retained location to be dropped by InvalidateCache")
}
}
// TestDeleteVidDropsRetainedGenerations tests that deleteVid removes a volume
// no matter which generation it was last refreshed in.
func TestDeleteVidDropsRetainedGenerations(t *testing.T) {
vid := uint32(999)
vm := newVidMap("", DefaultVidMapCacheSize)
vm.addLocation(vid, Location{Url: "http://server1:8080"})
vm.addEcLocation(vid, Location{Url: "http://server2:8080"})
vm.reset()
vm.reset()
if _, found := vm.GetLocations(vid); !found {
t.Fatal("Volume should still be readable before deletion")
}
vm.deleteVid(vid)
if _, found := vm.GetLocations(vid); found {
t.Error("Expected volume to be gone after deleteVid")
}
if vm.hasVolumeServer("http://server1:8080") || vm.hasVolumeServer("http://server2:8080") {
t.Error("Expected deleteVid to release the server references it held")
}
}
// TestGetLocationsEmptyArrayNoFallback tests that a volume known to have no
// locations reports a miss instead of serving what an earlier generation held.
// Covers the bug where a volume pod restarts, the vid map holds an empty array,
// and lookups fall back to the stale locations from before the restart.
func TestGetLocationsEmptyArrayNoFallback(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(10)
oldLocation := Location{Url: "10.131.1.28:8081"}
newLocation := Location{Url: "10.131.1.65:8081"}
// Volume initially has its old location, which is then retained across a reset
vm.addLocation(vid, oldLocation)
vm.reset()
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != oldLocation.Url {
t.Fatalf("Expected to find old location, got found=%v locs=%v", found, locs)
}
// Volume server restarts and the old location is deleted
vm.deleteLocation(vid, oldLocation)
locs, found = vm.GetLocations(vid)
if found {
t.Errorf("Expected found=false for empty location array, got found=true with locs=%v", locs)
}
if locs != nil {
t.Errorf("Expected nil locations for empty array, got %v (should not serve stale history!)", locs)
}
// When the new location is added, it should be returned (not the stale one)
vm.addLocation(vid, newLocation)
locs, found = vm.GetLocations(vid)
if !found || len(locs) != 1 {
t.Fatalf("Expected to find new location, got found=%v locs=%v", found, locs)
}
if locs[0].Url != newLocation.Url {
t.Errorf("Expected new location %s, got %s (got stale history!)", newLocation.Url, locs[0].Url)
}
}
// TestGetLocationsRetainedAcrossReset tests that a volume the new master has
// not mentioned yet is still served from what the previous one told us.
func TestGetLocationsRetainedAcrossReset(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(99)
retained := Location{Url: "cache-server:8081"}
vm.addLocation(vid, retained)
vm.reset()
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 {
t.Fatalf("Expected to find retained location after reset, got found=%v locs=%v", found, locs)
}
if locs[0].Url != retained.Url {
t.Errorf("Expected retained location %s, got %s", retained.Url, locs[0].Url)
}
}