mirror of
https://github.com/zeromicro/go-zero.git
synced 2026-05-13 18:00:00 +08:00
feat: consistent hash balancer support (#5246)
Co-authored-by: 周曙光 <zsg@zhoushuguangdeMacBook-Pro.local>
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
package zrpc
|
package zrpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/conf"
|
"github.com/zeromicro/go-zero/core/conf"
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
"github.com/zeromicro/go-zero/zrpc/internal"
|
"github.com/zeromicro/go-zero/zrpc/internal"
|
||||||
"github.com/zeromicro/go-zero/zrpc/internal/auth"
|
"github.com/zeromicro/go-zero/zrpc/internal/auth"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc/internal/balancer/consistenthash"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc/internal/balancer/p2c"
|
||||||
"github.com/zeromicro/go-zero/zrpc/internal/clientinterceptors"
|
"github.com/zeromicro/go-zero/zrpc/internal/clientinterceptors"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/keepalive"
|
"google.golang.org/grpc/keepalive"
|
||||||
@@ -67,6 +71,9 @@ func NewClient(c RpcClientConf, options ...ClientOption) (Client, error) {
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svcCfg := makeLBServiceConfig(c.BalancerName)
|
||||||
|
opts = append(opts, WithDialOption(grpc.WithDefaultServiceConfig(svcCfg)))
|
||||||
|
|
||||||
opts = append(opts, options...)
|
opts = append(opts, options...)
|
||||||
|
|
||||||
target, err := c.BuildTarget()
|
target, err := c.BuildTarget()
|
||||||
@@ -111,7 +118,20 @@ func SetClientSlowThreshold(threshold time.Duration) {
|
|||||||
clientinterceptors.SetSlowThreshold(threshold)
|
clientinterceptors.SetSlowThreshold(threshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHashKey sets the hash key into context.
|
||||||
|
func SetHashKey(ctx context.Context, key string) context.Context {
|
||||||
|
return consistenthash.SetHashKey(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
// WithCallTimeout return a call option with given timeout to make a method call.
|
// WithCallTimeout return a call option with given timeout to make a method call.
|
||||||
func WithCallTimeout(timeout time.Duration) grpc.CallOption {
|
func WithCallTimeout(timeout time.Duration) grpc.CallOption {
|
||||||
return clientinterceptors.WithCallTimeout(timeout)
|
return clientinterceptors.WithCallTimeout(timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeLBServiceConfig(balancerName string) string {
|
||||||
|
if len(balancerName) == 0 {
|
||||||
|
balancerName = p2c.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`{"loadBalancingPolicy":"%s"}`, balancerName)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"github.com/zeromicro/go-zero/core/discov"
|
"github.com/zeromicro/go-zero/core/discov"
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
"github.com/zeromicro/go-zero/internal/mock"
|
"github.com/zeromicro/go-zero/internal/mock"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc/internal/balancer/consistenthash"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc/internal/balancer/p2c"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
@@ -245,3 +247,42 @@ func TestNewClientWithTarget(t *testing.T) {
|
|||||||
|
|
||||||
assert.NotNil(t, err)
|
assert.NotNil(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMakeLBServiceConfig(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty name uses default p2c",
|
||||||
|
input: "",
|
||||||
|
expected: fmt.Sprintf(`{"loadBalancingPolicy":"%s"}`, p2c.Name),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom balancer name",
|
||||||
|
input: "consistent_hash",
|
||||||
|
expected: `{"loadBalancingPolicy":"consistent_hash"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := makeLBServiceConfig(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("expected %q, got %q", tt.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHashKey(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
key := "abc123"
|
||||||
|
|
||||||
|
ctx = SetHashKey(ctx, key)
|
||||||
|
got := consistenthash.GetHashKey(ctx)
|
||||||
|
assert.Equal(t, key, got)
|
||||||
|
|
||||||
|
assert.Empty(t, consistenthash.GetHashKey(context.Background()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type (
|
|||||||
Timeout int64 `json:",default=2000"`
|
Timeout int64 `json:",default=2000"`
|
||||||
KeepaliveTime time.Duration `json:",optional"`
|
KeepaliveTime time.Duration `json:",optional"`
|
||||||
Middlewares ClientMiddlewaresConf
|
Middlewares ClientMiddlewaresConf
|
||||||
|
BalancerName string `json:",default=p2c_ewma"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// A RpcServerConf is a rpc server config.
|
// A RpcServerConf is a rpc server config.
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
zconf "github.com/zeromicro/go-zero/core/conf"
|
||||||
"github.com/zeromicro/go-zero/core/discov"
|
"github.com/zeromicro/go-zero/core/discov"
|
||||||
"github.com/zeromicro/go-zero/core/service"
|
"github.com/zeromicro/go-zero/core/service"
|
||||||
"github.com/zeromicro/go-zero/core/stores/redis"
|
"github.com/zeromicro/go-zero/core/stores/redis"
|
||||||
|
"github.com/zeromicro/go-zero/zrpc/internal/balancer/p2c"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRpcClientConf(t *testing.T) {
|
func TestRpcClientConf(t *testing.T) {
|
||||||
@@ -39,6 +41,13 @@ func TestRpcClientConf(t *testing.T) {
|
|||||||
_, err := conf.BuildTarget()
|
_, err := conf.BuildTarget()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("default balancer name", func(t *testing.T) {
|
||||||
|
var conf RpcClientConf
|
||||||
|
err := zconf.FillDefault(&conf)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, p2c.Name, conf.BalancerName)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRpcServerConf(t *testing.T) {
|
func TestRpcServerConf(t *testing.T) {
|
||||||
|
|||||||
97
zrpc/internal/balancer/consistenthash/consistenthash.go
Normal file
97
zrpc/internal/balancer/consistenthash/consistenthash.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package consistenthash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/hash"
|
||||||
|
"google.golang.org/grpc/balancer"
|
||||||
|
"google.golang.org/grpc/balancer/base"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Name = "consistent_hash"
|
||||||
|
|
||||||
|
defaultReplicaCount = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
var emptyPickResult balancer.PickResult
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
balancer.Register(newBuilder())
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
// hashKey is the key type for consistent hash in context.
|
||||||
|
hashKey struct{}
|
||||||
|
// pickerBuilder is a builder for picker.
|
||||||
|
pickerBuilder struct{}
|
||||||
|
// picker is a picker that uses consistent hash to pick a sub connection.
|
||||||
|
picker struct {
|
||||||
|
hashRing *hash.ConsistentHash
|
||||||
|
conns map[string]balancer.SubConn
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *pickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
|
||||||
|
readySCs := info.ReadySCs
|
||||||
|
if len(readySCs) == 0 {
|
||||||
|
return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
conns := make(map[string]balancer.SubConn, len(readySCs))
|
||||||
|
hashRing := hash.NewCustomConsistentHash(defaultReplicaCount, hash.Hash)
|
||||||
|
for conn, connInfo := range readySCs {
|
||||||
|
addr := connInfo.Address.Addr
|
||||||
|
conns[addr] = conn
|
||||||
|
hashRing.Add(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &picker{
|
||||||
|
hashRing: hashRing,
|
||||||
|
conns: conns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBuilder() balancer.Builder {
|
||||||
|
return base.NewBalancerBuilder(Name, &pickerBuilder{}, base.Config{HealthCheck: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *picker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
|
||||||
|
hashKey := GetHashKey(info.Ctx)
|
||||||
|
if len(hashKey) == 0 {
|
||||||
|
return emptyPickResult, status.Error(codes.InvalidArgument,
|
||||||
|
"[consistent_hash] missing hash key in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
if addrAny, ok := p.hashRing.Get(hashKey); ok {
|
||||||
|
addr, ok := addrAny.(string)
|
||||||
|
if !ok {
|
||||||
|
return emptyPickResult, status.Error(codes.Internal,
|
||||||
|
"[consistent_hash] invalid addr type in consistent hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
subConn, ok := p.conns[addr]
|
||||||
|
if !ok {
|
||||||
|
return emptyPickResult, status.Errorf(codes.Internal,
|
||||||
|
"[consistent_hash] no subConn for addr: %s", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return balancer.PickResult{SubConn: subConn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyPickResult, status.Errorf(codes.Unavailable,
|
||||||
|
"[consistent_hash] no matching conn for hashKey: %s", hashKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHashKey sets the hash key into context.
|
||||||
|
func SetHashKey(ctx context.Context, key string) context.Context {
|
||||||
|
return context.WithValue(ctx, hashKey{}, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHashKey gets the hash key from context.
|
||||||
|
func GetHashKey(ctx context.Context) string {
|
||||||
|
v, _ := ctx.Value(hashKey{}).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
175
zrpc/internal/balancer/consistenthash/consistenthash_test.go
Normal file
175
zrpc/internal/balancer/consistenthash/consistenthash_test.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package consistenthash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/zeromicro/go-zero/core/hash"
|
||||||
|
"google.golang.org/grpc/balancer"
|
||||||
|
"google.golang.org/grpc/balancer/base"
|
||||||
|
"google.golang.org/grpc/resolver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeSubConn struct{ id int }
|
||||||
|
|
||||||
|
func (f *fakeSubConn) Connect() {}
|
||||||
|
func (f *fakeSubConn) UpdateAddresses(_ []resolver.Address) {}
|
||||||
|
func (f *fakeSubConn) Shutdown() {}
|
||||||
|
func (f *fakeSubConn) GetOrBuildProducer(b balancer.ProducerBuilder) (balancer.Producer, func()) {
|
||||||
|
return nil, func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickerBuilder_EmptyReadySCs(t *testing.T) {
|
||||||
|
b := &pickerBuilder{}
|
||||||
|
p := b.Build(base.PickerBuildInfo{ReadySCs: map[balancer.SubConn]base.SubConnInfo{}})
|
||||||
|
|
||||||
|
_, err := p.Pick(balancer.PickInfo{})
|
||||||
|
assert.Equal(t, balancer.ErrNoSubConnAvailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickerBuilder_BuildAndRing(t *testing.T) {
|
||||||
|
subConn1 := &fakeSubConn{id: 1}
|
||||||
|
subConn2 := &fakeSubConn{id: 2}
|
||||||
|
addr1 := "127.0.0.1:8080"
|
||||||
|
addr2 := "127.0.0.1:8081"
|
||||||
|
|
||||||
|
b := &pickerBuilder{}
|
||||||
|
info := base.PickerBuildInfo{
|
||||||
|
ReadySCs: map[balancer.SubConn]base.SubConnInfo{
|
||||||
|
subConn1: {Address: resolver.Address{Addr: addr1}},
|
||||||
|
subConn2: {Address: resolver.Address{Addr: addr2}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p := b.Build(info).(*picker)
|
||||||
|
assert.NotNil(t, p.hashRing)
|
||||||
|
assert.Len(t, p.conns, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPicker_HashConsistency(t *testing.T) {
|
||||||
|
subConn1 := &fakeSubConn{id: 1}
|
||||||
|
subConn2 := &fakeSubConn{id: 2}
|
||||||
|
|
||||||
|
pb := &pickerBuilder{}
|
||||||
|
info := base.PickerBuildInfo{
|
||||||
|
ReadySCs: map[balancer.SubConn]base.SubConnInfo{
|
||||||
|
subConn1: {Address: resolver.Address{Addr: "127.0.0.1:8080"}},
|
||||||
|
subConn2: {Address: resolver.Address{Addr: "127.0.0.1:8081"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p := pb.Build(info).(*picker)
|
||||||
|
ctx := SetHashKey(context.Background(), "user_123")
|
||||||
|
res1, err := p.Pick(balancer.PickInfo{Ctx: ctx})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, res1.SubConn)
|
||||||
|
|
||||||
|
// Multiple requests with the same key remain consistent
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
resN, err := p.Pick(balancer.PickInfo{Ctx: ctx})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, res1.SubConn, resN.SubConn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPicker_MissingKey(t *testing.T) {
|
||||||
|
subConn := &fakeSubConn{id: 1}
|
||||||
|
|
||||||
|
pb := &pickerBuilder{}
|
||||||
|
info := base.PickerBuildInfo{
|
||||||
|
ReadySCs: map[balancer.SubConn]base.SubConnInfo{
|
||||||
|
subConn: {Address: resolver.Address{Addr: "127.0.0.1:8080"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p := pb.Build(info).(*picker)
|
||||||
|
|
||||||
|
// No hash key in context
|
||||||
|
_, err := p.Pick(balancer.PickInfo{Ctx: context.Background()})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "[consistent_hash] missing hash key in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPicker_NoMatchingConn(t *testing.T) {
|
||||||
|
emptyRing := newCustomRingForTest()
|
||||||
|
p := &picker{
|
||||||
|
hashRing: emptyRing,
|
||||||
|
conns: map[string]balancer.SubConn{},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.Pick(balancer.PickInfo{Ctx: SetHashKey(context.Background(), "someone")})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "[consistent_hash] no matching conn for hashKey: someone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPicker_InvalidAddrType(t *testing.T) {
|
||||||
|
ring := newCustomRingForTest()
|
||||||
|
ring.Add(12345)
|
||||||
|
|
||||||
|
subConn := &fakeSubConn{id: 1}
|
||||||
|
p := &picker{
|
||||||
|
hashRing: ring,
|
||||||
|
conns: map[string]balancer.SubConn{
|
||||||
|
"12345": subConn,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.Pick(balancer.PickInfo{Ctx: SetHashKey(context.Background(), "anykey")})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "[consistent_hash] invalid addr type in consistent hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPicker_NoSubConnForAddr(t *testing.T) {
|
||||||
|
ring := newCustomRingForTest()
|
||||||
|
ring.Add("ghost:9999")
|
||||||
|
|
||||||
|
exist := &fakeSubConn{id: 1}
|
||||||
|
p := &picker{
|
||||||
|
hashRing: ring,
|
||||||
|
conns: map[string]balancer.SubConn{
|
||||||
|
"real:8080": exist,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.Pick(balancer.PickInfo{Ctx: SetHashKey(context.Background(), "anykey")})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "[consistent_hash] no subConn for addr: ghost:9999")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAndGetHashKey(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
key := "abc123"
|
||||||
|
|
||||||
|
ctx = SetHashKey(ctx, key)
|
||||||
|
got := GetHashKey(ctx)
|
||||||
|
assert.Equal(t, key, got)
|
||||||
|
|
||||||
|
assert.Empty(t, GetHashKey(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkPicker_HashConsistency(b *testing.B) {
|
||||||
|
subConn1 := &fakeSubConn{id: 1}
|
||||||
|
subConn2 := &fakeSubConn{id: 2}
|
||||||
|
|
||||||
|
pb := &pickerBuilder{}
|
||||||
|
info := base.PickerBuildInfo{
|
||||||
|
ReadySCs: map[balancer.SubConn]base.SubConnInfo{
|
||||||
|
subConn1: {Address: resolver.Address{Addr: "127.0.0.1:8080"}},
|
||||||
|
subConn2: {Address: resolver.Address{Addr: "127.0.0.1:8081"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p := pb.Build(info).(*picker)
|
||||||
|
|
||||||
|
ctx := SetHashKey(context.Background(), "hot_user_123")
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
res, err := p.Pick(balancer.PickInfo{Ctx: ctx})
|
||||||
|
if err != nil || res.SubConn == nil {
|
||||||
|
b.Fatalf("unexpected result: res=%v err=%v", res.SubConn, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCustomRingForTest() *hash.ConsistentHash {
|
||||||
|
return hash.NewCustomConsistentHash(defaultReplicaCount, hash.Hash)
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/zrpc/internal/balancer/p2c"
|
|
||||||
"github.com/zeromicro/go-zero/zrpc/internal/clientinterceptors"
|
"github.com/zeromicro/go-zero/zrpc/internal/clientinterceptors"
|
||||||
"github.com/zeromicro/go-zero/zrpc/resolver"
|
"github.com/zeromicro/go-zero/zrpc/resolver"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -53,9 +52,6 @@ func NewClient(target string, middlewares ClientMiddlewaresConf, opts ...ClientO
|
|||||||
middlewares: middlewares,
|
middlewares: middlewares,
|
||||||
}
|
}
|
||||||
|
|
||||||
svcCfg := fmt.Sprintf(`{"loadBalancingPolicy":"%s"}`, p2c.Name)
|
|
||||||
balancerOpt := WithDialOption(grpc.WithDefaultServiceConfig(svcCfg))
|
|
||||||
opts = append([]ClientOption{balancerOpt}, opts...)
|
|
||||||
if err := cli.dial(target, opts...); err != nil {
|
if err := cli.dial(target, opts...); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user