Skip to content

Commit 7fcee4a

Browse files
authored
cluster/grpc: add clustered gRPC client and example (#133)
* etcd: add WaitFor() to reliably wait for a state of a KV * cluster: Register/Deregister blocks, memory rework. Ensure Register()/Deregister blocks until local observation, and rework the memory package to be more robust (a bunch of test issues with certain patterns of use). * cluster/registry: add clustered node registry * cluster/grpc: add clustered gRPC client and example. Adds infrastructure for 'smart clients' that can route requests based on a context key either consistently or round robin, along with server side hooks for validation/invalidation. An end to end example exists under the example/ directory, which can be used as a jumping off point for integration for other services. This setup currently requires the use of explicit forwarding clients, but server interceptors/gateways can be built using this infrastructure. To do so would just be writing an interceptor/forwarder that uses a ClientConnInterface using the balancer config.
1 parent f062a84 commit 7fcee4a

16 files changed

Lines changed: 1434 additions & 186 deletions

File tree

pkg/cluster/cluster.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,15 @@ type Membership interface {
7575

7676
// Register registers the membership with the cluster.
7777
//
78-
// There is no guarantee for when the member will
79-
// propagate to the rest of the members.
78+
// Register will block until the local process observes the
79+
// registration, but there is no guarantee for when other members
80+
// will observe the change.
8081
Register(ctx context.Context) error
8182

82-
// Deregister deregisters the membership with the cluster.
83+
// Deregister de-registers the membership with the cluster.
8384
//
84-
// There is no guarantee for when the deregistration will
85-
// propagate to the rest of the members.
85+
// Deregister will block until the local process observes the
86+
// de-registration, but there is no guarantee for when other members
87+
// will observer the change.
8688
Deregister(ctx context.Context) error
8789
}

pkg/cluster/etcd/etcd.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ func (c *Cluster) watchMembers() {
137137
for _, ch := range c.membershipWatchers {
138138
select {
139139
case ch <- slices.Clone(members):
140-
default:
141140
}
142141
}
143142
c.mu.Unlock()
@@ -204,7 +203,7 @@ func (m *Membership) SetData(data string) error {
204203
return nil
205204
}
206205

207-
func (m *Membership) Register(_ context.Context) error {
206+
func (m *Membership) Register(ctx context.Context) error {
208207
m.mu.Lock()
209208
defer m.mu.Unlock()
210209

@@ -223,19 +222,24 @@ func (m *Membership) Register(_ context.Context) error {
223222
string(b),
224223
m.ttl,
225224
)
226-
return err
225+
if err != nil {
226+
return err
227+
}
228+
229+
return etcd.WaitFor(ctx, m.client, m.pl.Key(), false)
227230
}
228231

229-
func (m *Membership) Deregister(_ context.Context) error {
232+
func (m *Membership) Deregister(ctx context.Context) error {
230233
m.mu.Lock()
231234
defer m.mu.Unlock()
232235

233236
if m.pl == nil {
234237
return nil
235238
}
236239

240+
key := m.pl.Key()
237241
m.pl.Close()
238242
m.pl = nil
239243

240-
return nil
244+
return etcd.WaitFor(ctx, m.client, key, false)
241245
}

pkg/cluster/grpc/balancer.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package grpc
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
7+
"github.com/sirupsen/logrus"
8+
"go.uber.org/zap"
9+
"google.golang.org/grpc/balancer"
10+
"google.golang.org/grpc/connectivity"
11+
"google.golang.org/grpc/resolver"
12+
13+
"github.com/code-payments/code-server/pkg/cluster/registry"
14+
"github.com/code-payments/code-server/pkg/cluster/ring"
15+
)
16+
17+
const (
18+
BalancerName = "cluster"
19+
BalancerConfig = `{"loadBalancingConfig": [{"cluster": {}}]}`
20+
)
21+
22+
func RegisterLoadBalancer(registry registry.Reader) {
23+
balancer.Register(&balancerBuilder{
24+
log: logrus.WithField("type", "cluster/grpc/balancer"),
25+
reader: registry,
26+
})
27+
}
28+
29+
type balancerBuilder struct {
30+
log *logrus.Entry
31+
reader registry.Reader
32+
}
33+
34+
func (b *balancerBuilder) Name() string {
35+
return BalancerName
36+
}
37+
38+
func (b *balancerBuilder) Build(cc balancer.ClientConn, _ balancer.BuildOptions) balancer.Balancer {
39+
return &sbBalancer{
40+
log: b.log,
41+
reader: b.reader,
42+
cc: cc,
43+
44+
subConns: make(map[string]balancer.SubConn),
45+
scStates: make(map[balancer.SubConn]balancer.SubConnState),
46+
scNodes: make(map[balancer.SubConn]*registry.NodeDescriptor),
47+
}
48+
}
49+
50+
type sbBalancer struct {
51+
log *logrus.Entry
52+
reader registry.Reader
53+
cc balancer.ClientConn
54+
55+
picker balancer.Picker
56+
57+
mu sync.RWMutex
58+
subConns map[string]balancer.SubConn
59+
scStates map[balancer.SubConn]balancer.SubConnState
60+
scNodes map[balancer.SubConn]*registry.NodeDescriptor
61+
}
62+
63+
func (b *sbBalancer) UpdateClientConnState(state balancer.ClientConnState) error {
64+
tokenRing := state.ResolverState.Attributes.Value(ringAttributeKey).(*ring.HashRing)
65+
if tokenRing == nil {
66+
return fmt.Errorf("missing ring: balancer must be used with resolver")
67+
}
68+
69+
b.mu.Lock()
70+
defer b.mu.Unlock()
71+
72+
addressSet := make(map[string]struct{})
73+
74+
for _, addr := range state.ResolverState.Addresses {
75+
node, ok := addr.Attributes.Value(contextKeyNode).(*registry.NodeDescriptor)
76+
if !ok {
77+
b.log.Warn("NodeDescriptor not present in metadata, ignoring", zap.String("address", addr.Addr))
78+
continue
79+
}
80+
81+
addressSet[addr.Addr] = struct{}{}
82+
83+
// If we already have a SubConn for the address, then we're simply updating
84+
// the NodeDescriptor state. The picker will deal with any routing level updates.
85+
if sc, ok := b.subConns[addr.Addr]; ok {
86+
b.scNodes[sc] = node
87+
continue
88+
}
89+
90+
// Set up a new SubConn for the newly observed Node.
91+
sc, err := b.cc.NewSubConn([]resolver.Address{addr}, balancer.NewSubConnOptions{})
92+
if err != nil {
93+
b.log.Warn("Failed to create new SubConn", zap.Error(err), zap.String("address", addr.Addr))
94+
continue
95+
}
96+
97+
b.log.Debug("Created new SubConn", zap.String("address", addr.Addr))
98+
99+
b.subConns[addr.Addr] = sc
100+
b.scStates[sc] = balancer.SubConnState{ConnectivityState: connectivity.Idle}
101+
b.scNodes[sc] = node
102+
103+
// We want to immediately start the connection flow to help reduce 'cold starts' on actual requests.
104+
//
105+
// Note: In a multi-service world, we would only want to do this on services that we've actually communicated
106+
// with in the past to reduce the number of overall connections. However, cluster sizes should be relatively
107+
// small and homogeneous.
108+
sc.Connect()
109+
}
110+
111+
// Prune any SubConn's for nodes that are no longer resolved.
112+
for addr, sc := range b.subConns {
113+
if _, ok := addressSet[addr]; !ok {
114+
// Note: The reason we're deleting from subConns and _not_ scStates/scNodes is
115+
// that they're required to correctly transition the state into Shutdown.
116+
//
117+
// See UpdateSubConnState() below for their transition.
118+
delete(b.subConns, addr)
119+
120+
sc.Shutdown()
121+
}
122+
}
123+
124+
b.regeneratePicker(tokenRing)
125+
126+
// We just report the Balancer as ready (which in turn indicates that the ClientConn is ready)
127+
// as we don't do any connection warming or probing.
128+
//
129+
// In theory, this should only affect Dial() when `WithBlock()` is used. This behaviour
130+
// effectively makes the `WithBlock()` a no-op.
131+
b.cc.UpdateState(balancer.State{Picker: b.picker, ConnectivityState: connectivity.Ready})
132+
return nil
133+
}
134+
135+
func (b *sbBalancer) UpdateSubConnState(sc balancer.SubConn, newState balancer.SubConnState) {
136+
b.mu.Lock()
137+
defer b.mu.Unlock()
138+
139+
oldState, ok := b.scStates[sc]
140+
if !ok {
141+
b.log.
142+
WithField("new_state", newState.ConnectivityState.String()).
143+
Warn("State change for unknown SubConn")
144+
145+
return
146+
}
147+
b.scStates[sc] = newState
148+
149+
node, ok := b.scNodes[sc]
150+
if !ok {
151+
b.log.Warn("NodeDescriptor doesn't exist for SubConn")
152+
return
153+
}
154+
155+
address := fmt.Sprintf("%s:%d", node.Address.IP, node.Address.Port)
156+
switch newState.ConnectivityState {
157+
case connectivity.Idle:
158+
b.log.WithField("address", address).Debug("SubConn changed to idle; reconnecting")
159+
sc.Connect()
160+
case connectivity.Shutdown:
161+
b.log.
162+
WithField("address", address).
163+
WithError(newState.ConnectionError).
164+
Debug("SubConn shutting down")
165+
166+
delete(b.scStates, sc)
167+
delete(b.scNodes, sc)
168+
}
169+
170+
// There's a slightly sinister case where the Picker may block if the selected
171+
// SubConn is non-ready. In this case, it will wait until UpdateState() is called
172+
// again (by us, the LoadBalancer).
173+
//
174+
// To help avoid/unblock this case, we poke the picker (via UpdateState) every time
175+
// we see a state transition.
176+
//
177+
// This behavior is acceptable in small homogeneous clusters, but maybe revised in
178+
// larger deploys (by service).
179+
if (oldState.ConnectivityState == connectivity.Ready) != (newState.ConnectivityState == connectivity.Ready) {
180+
b.cc.UpdateState(balancer.State{Picker: b.picker, ConnectivityState: connectivity.Ready})
181+
}
182+
}
183+
184+
func (b *sbBalancer) ResolverError(err error) {
185+
b.log.WithError(err).Error("Unexpected resolver error")
186+
}
187+
188+
func (b *sbBalancer) Close() {
189+
}
190+
191+
func (b *sbBalancer) regeneratePicker(tokenRing *ring.HashRing) {
192+
b.log.WithField("nodes", len(b.scNodes)).Debug("Regenerating picker")
193+
194+
nodes := make([]*registry.NodeDescriptor, 0, len(b.scNodes))
195+
for _, node := range b.scNodes {
196+
nodes = append(nodes, node)
197+
}
198+
199+
b.picker = newPicker(nodes, b.scNodes, tokenRing)
200+
}

pkg/cluster/grpc/context.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package grpc
2+
3+
import (
4+
"context"
5+
)
6+
7+
type contextKey int
8+
9+
const (
10+
contextKeyRouting contextKey = iota
11+
contextKeyNode
12+
)
13+
14+
func WithRoutingKey(parent context.Context, val []byte) context.Context {
15+
return context.WithValue(parent, contextKeyRouting, val)
16+
}
17+
18+
func RoutingKey(ctx context.Context) (val []byte, ok bool) {
19+
val, ok = ctx.Value(contextKeyRouting).([]byte)
20+
return
21+
}

0 commit comments

Comments
 (0)