Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions benchkit/benchmark/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,11 @@ func TestQuorumCallHonorsCallTimeout(t *testing.T) {
registerReplyDroppingPeer(servers[0], 1<<30) // drops every reply for the test's duration
go func() { _ = servers[0].ListenAndServe() }()

cfg, err := gorums.NewConfig(gorums.WithNodeList([]string{servers[0].Addr()}), gorumstest.InsecureDialOptions(t))
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList([]string{servers[0].Addr()}), gorumstest.InsecureDialOptions(t))
if err != nil {
t.Fatalf("NewConfig: %v", err)
}
t.Cleanup(gorumstest.Closer(t, cfg))
t.Cleanup(closeFn)

run := benchDescs[0].build(cfg, nil) // "QuorumCall"
opts := benchkit.Options{
Expand Down Expand Up @@ -292,11 +292,11 @@ func TestRunAsyncQCBenchmarkCountsErrorsWithoutAborting(t *testing.T) {
registerFailingQuorumCallPeer(servers[0], "quorum call failed")
go func() { _ = servers[0].ListenAndServe() }()

cfg, err := gorums.NewConfig(gorums.WithNodeList([]string{servers[0].Addr()}), gorumstest.InsecureDialOptions(t))
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList([]string{servers[0].Addr()}), gorumstest.InsecureDialOptions(t))
if err != nil {
t.Fatalf("NewConfig: %v", err)
}
t.Cleanup(gorumstest.Closer(t, cfg))
t.Cleanup(closeFn)

opts := benchkit.Options{Workers: 1, Duration: 30 * time.Millisecond, MaxAsync: 10, QuorumSize: 1}
result, err := runAsyncQCBenchmark(opts, cfg,
Expand Down
4 changes: 2 additions & 2 deletions benchkit/benchmark/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ func setupCoordinator(opts *benchkit.Options, remotes []string, configSize int,
opts.NumNodes = configSize
}

cfg, err := gorums.NewConfig(gorums.WithNodeList(remotes[:opts.NumNodes]), dialOpts...)
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList(remotes[:opts.NumNodes]), dialOpts...)
if err != nil {
return target, nil, fmt.Errorf("configuration setup: %w", err)
}

target.Config = cfg
return target, func() { _ = cfg.Close() }, nil
return target, closeFn, nil
}
28 changes: 25 additions & 3 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,36 @@ type Config = conn.Config
// Use [Config.Context] to create a ConfigContext from an existing context.
type ConfigContext = conn.ConfigContext

// NewConfig returns a new [Config] based on the provided nodes and dial options.
// NewConfig returns a new [Config] based on the provided nodes and dial
// options, and a function that closes the configuration's connection pool.
//
// The returned configuration and every configuration derived from it, for
// example with [Config.Extend], [Config.Remove], or [Config.Sort], share one
// connection pool. Only the returned close function closes that pool, and it
// closes every node in it, including nodes that [Config.Extend] added later.
// After the close function returns:
// - calls to the pool's nodes fail with an Unavailable "node closed" error;
// - [Config.Extend] on any configuration in the pool returns an error.
//
// One exception: a node that runs in-process, or that reuses a server's
// inbound stream, owns no connection, so calls to it still work.
//
// The close function is idempotent and safe for concurrent use; every call
// returns after the nodes are closed. It returns no error, because closing a
// connection has no failure that a caller can act on.
//
// On error, NewConfig returns a nil configuration and a nil close function.
//
// Example:
//
// cfg, err := NewConfig(
// cfg, closeFn, err := NewConfig(
// gorums.WithNodeList([]string{"localhost:8080", "localhost:8081", "localhost:8082"}),
// gorums.WithGRPCDialOptions(grpc.WithTransportCredentials(insecure.NewCredentials())),
// )
func NewConfig(nodes NodeSource, opts ...DialOption) (Config, error) {
// if err != nil {
// return err
// }
// defer closeFn()
func NewConfig(nodes NodeSource, opts ...DialOption) (Config, func(), error) {
return conn.NewConfig(nodes, opts...)
}
151 changes: 86 additions & 65 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ func TestNewConfig(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := gorums.NewConfig(tt.nodes, gorumstest.InsecureDialOptions(t))
cfg, closeFn, err := gorums.NewConfig(tt.nodes, gorumstest.InsecureDialOptions(t))
if err == nil {
t.Cleanup(gorumstest.Closer(t, cfg))
t.Cleanup(closeFn)
}
if tt.wantErr != "" {
if err == nil {
Expand All @@ -108,6 +108,9 @@ func TestNewConfig(t *testing.T) {
if err.Error() != tt.wantErr {
t.Errorf("Error = %q, want %q", err.Error(), tt.wantErr)
}
if cfg != nil || closeFn != nil {
t.Errorf("NewConfig() on error = %v, %p; want nil, nil", cfg.NodeIDs(), closeFn)
}
return
}
if err != nil {
Expand Down Expand Up @@ -154,13 +157,13 @@ func TestNewConfigWithBackChannel(t *testing.T) {
if srv != nil {
t.Cleanup(srv.Stop)
}
cfg, err := gorums.NewConfig(
cfg, closeFn, err := gorums.NewConfig(
Comment thread
meling marked this conversation as resolved.
gorums.WithNodeList(nodeList),
gorums.WithBackChannel(srv),
gorumstest.InsecureDialOptions(t),
)
if err == nil {
t.Cleanup(gorumstest.Closer(t, cfg))
t.Cleanup(closeFn)
}
if tt.wantErr != "" {
if err == nil {
Expand All @@ -169,6 +172,9 @@ func TestNewConfigWithBackChannel(t *testing.T) {
if err.Error() != tt.wantErr {
t.Errorf("Error = %q, want %q", err.Error(), tt.wantErr)
}
if cfg != nil || closeFn != nil {
t.Errorf("NewConfig() on error = %v, %p; want nil, nil", cfg.NodeIDs(), closeFn)
}
return
}
if err != nil {
Expand Down Expand Up @@ -237,12 +243,6 @@ func TestEmptyConfiguration(t *testing.T) {
}
})

t.Run("CloseNil", func(t *testing.T) {
if err := empty.Close(); err != nil {
t.Fatalf("empty.Close() error = %v, want nil", err)
}
})

t.Run("ContainsFalse", func(t *testing.T) {
if empty.Contains(1) {
t.Fatal("empty.Contains(1) = true, want false")
Expand Down Expand Up @@ -379,11 +379,11 @@ func TestConfigExtend(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, err := gorums.NewConfig(tt.initialNodes, gorumstest.InsecureDialOptions(t))
c, closeFn, err := gorums.NewConfig(tt.initialNodes, gorumstest.InsecureDialOptions(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(gorumstest.Closer(t, c))
t.Cleanup(closeFn)

c2, err := c.Extend(tt.extendNodes)
if tt.wantErr != "" {
Expand All @@ -409,11 +409,11 @@ func TestConfigExtendConcurrent(t *testing.T) {
addrs := gorumstest.Servers(t, 6, func(_ int) gorumstest.ServerIface { return gorums.NewServer() })

// Create base configuration so that concurrent Extend operations share the same node registry.
cfg, err := gorums.NewConfig(gorums.WithNodeList(addrs[0:1]), gorumstest.DialOptions(t))
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList(addrs[0:1]), gorumstest.DialOptions(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(gorumstest.Closer(t, cfg))
t.Cleanup(closeFn)

// Create multiple node maps to extend with, each containing a unique new node.
// These maps will be used concurrently to verify that Extend can safely mutate
Expand Down Expand Up @@ -448,49 +448,64 @@ func TestConfigExtendConcurrent(t *testing.T) {
}
}

// TestConfigClose verifies that Close on any configuration derived from one
// NewConfig call closes every node in that pool, is idempotent, and leaves no
// live node behind when Extend runs after or concurrently with Close.
// TestConfigClose verifies that the close function returned by NewConfig
// closes every node in the pool, including nodes that Extend added, that it is
// idempotent and safe for concurrent use, and that it leaves no live node
// behind when Extend runs after or concurrently with it.
func TestConfigClose(t *testing.T) {
t.Run("SubConfigClosesPool", func(t *testing.T) {
cfg := gorumstest.Config(t, 3, nil)
outside := gorumstest.PeerNode(t, cfg, 3)
call := func() error {
ctx := gorumstest.Context(t, 5*time.Second)
_, err := gorumsimpl.RemoteCall[*pb.StringValue, *pb.StringValue](outside.Context(ctx), pb.String("x"), mock.TestMethod)
return err
}
if err := call(); err != nil {
t.Fatalf("call before Close: %v", err)
}

sub := cfg.Remove(outside.ID())
if err := sub.Close(); err != nil {
t.Fatalf("sub.Close() = %v, want nil", err)
}
err := call()
call := func(t *testing.T, node *gorums.Node) error {
t.Helper()
ctx := gorumstest.Context(t, 5*time.Second)
_, err := gorumsimpl.RemoteCall[*pb.StringValue, *pb.StringValue](node.Context(ctx), pb.String("x"), mock.TestMethod)
return err
}
wantClosed := func(t *testing.T, node *gorums.Node) {
t.Helper()
err := call(t, node)
if status.Code(err) != codes.Unavailable || !strings.Contains(err.Error(), "node closed") {
t.Errorf("call on node %d outside the closed sub-configuration: err = %v, want Unavailable node closed", outside.ID(), err)
t.Errorf("call on node %d after close: err = %v, want Unavailable node closed", node.ID(), err)
}
if err := sub.Close(); err != nil {
t.Errorf("second sub.Close() = %v, want nil", err)
}

t.Run("ClosesExtendedNodes", func(t *testing.T) {
addrs := gorumstest.Servers(t, 2, nil)
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList(addrs[:1]), gorumstest.DialOptions(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(closeFn)
ext, err := cfg.Extend(gorums.WithNodeList(addrs[1:]))
if err != nil {
t.Fatal(err)
}
for _, node := range ext {
if err := call(t, node); err != nil {
t.Fatalf("call on node %d before close: %v", node.ID(), err)
}
}
if err := cfg.Close(); err != nil {
t.Errorf("cfg.Close() after sub.Close() = %v, want nil", err)
closeFn()
for _, node := range ext {
wantClosed(t, node)
}
})

t.Run("EmptySubConfigKeepsPool", func(t *testing.T) {
cfg := gorumstest.Config(t, 2, nil)
node := gorumstest.PeerNode(t, cfg, 1)
empty := cfg.Remove(cfg.NodeIDs()...)
if err := empty.Close(); err != nil {
t.Fatalf("empty.Close() = %v, want nil", err)
t.Run("Idempotent", func(t *testing.T) {
addrs := gorumstest.Servers(t, 1, nil)
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList(addrs), gorumstest.DialOptions(t))
if err != nil {
t.Fatal(err)
}
ctx := gorumstest.Context(t, 5*time.Second)
if _, err := gorumsimpl.RemoteCall[*pb.StringValue, *pb.StringValue](node.Context(ctx), pb.String("x"), mock.TestMethod); err != nil {
t.Errorf("call after closing an empty sub-configuration: %v, want nil", err)
t.Cleanup(closeFn)
node := cfg.Nodes()[0]
var wg sync.WaitGroup
for range 3 {
wg.Go(func() {
closeFn()
// Every call returns only after the nodes are closed.
wantClosed(t, node)
})
}
wg.Wait()
})

t.Run("ExtendAfterClose", func(t *testing.T) {
Expand All @@ -505,42 +520,48 @@ func TestConfigClose(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := gorums.NewConfig(gorums.WithNodeList(addrs[:1]), gorumstest.DialOptions(t))
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList(addrs[:1]), gorumstest.DialOptions(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(gorumstest.Closer(t, cfg))
if err := cfg.Close(); err != nil {
t.Fatalf("cfg.Close() = %v, want nil", err)
}
closeFn()

ext, err := cfg.Extend(tt.nodes)
if err == nil {
t.Cleanup(gorumstest.Closer(t, ext))
t.Fatalf("cfg.Extend() after Close = %v, nil; want error", ext.NodeIDs())
t.Fatalf("cfg.Extend() after close = %v, nil; want error", ext.NodeIDs())
}
if ext != nil {
t.Errorf("cfg.Extend() after Close = %v, want nil configuration", ext.NodeIDs())
t.Errorf("cfg.Extend() after close = %v, want nil configuration", ext.NodeIDs())
}
})
}
})

t.Run("NewConfigError", func(t *testing.T) {
addrs := gorumstest.Servers(t, 1, nil)
// NewConfig builds node 1 before it rejects the duplicate address, so
// it must close node 1 itself; goleak checks that at cleanup.
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList([]string{addrs[0], addrs[0]}), gorumstest.DialOptions(t))
if err == nil {
closeFn()
t.Fatalf("NewConfig() with a duplicate address = %v, nil; want error", cfg.NodeIDs())
}
if cfg != nil || closeFn != nil {
t.Errorf("NewConfig() on error = %v, %p; want nil, nil", cfg.NodeIDs(), closeFn)
}
})

t.Run("ConcurrentExtend", func(t *testing.T) {
addrs := gorumstest.Servers(t, 2, nil)
for range 10 {
cfg, err := gorums.NewConfig(gorums.WithNodeList(addrs[:1]), gorumstest.DialOptions(t))
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodeList(addrs[:1]), gorumstest.DialOptions(t))
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
wg.Go(closeFn)
wg.Go(func() {
if err := cfg.Close(); err != nil {
t.Errorf("cfg.Close() = %v, want nil", err)
}
})
wg.Go(func() {
// Extend either joins the pool before Close takes its snapshot
// Extend either joins the pool before close takes its snapshot
// and is closed with it, or it fails. Neither outcome may leave
// a live node behind; goleak checks that at cleanup.
_, _ = cfg.Extend(gorums.WithNodeList(addrs[1:]))
Expand Down Expand Up @@ -713,11 +734,11 @@ func TestConfigImmutability(t *testing.T) {
}

func TestConfigWithoutErrors(t *testing.T) {
cfg, err := gorums.NewConfig(gorums.WithNodes(nodeMap), gorumstest.InsecureDialOptions(t))
cfg, closeFn, err := gorums.NewConfig(gorums.WithNodes(nodeMap), gorumstest.InsecureDialOptions(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(gorumstest.Closer(t, cfg))
t.Cleanup(closeFn)

timeoutErr := errors.New("timeout")
connRefusedErr := errors.New("connection refused")
Expand Down
3 changes: 2 additions & 1 deletion doc/dev-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ classDiagram

A `Config` is a slice of `*Node`, and each `Node` has a fixed `stream.Transport`.
An `outboundManager` creates and owns the nodes of the configurations built by `NewConfig` and `Config.Extend`.
`Config.Close` on any of these configurations closes the manager and all its nodes.
`NewConfig` returns the manager's `Close` method as the close function; `Config` has no `Close` method, so only the creator of a pool can close it.
The manager's `Close` closes all its nodes and logs any node's close error, since a caller cannot act on it.
`Config.Extend` fails on a closed manager before it builds any node.
A closed manager also rejects new nodes under the same lock that guards its node list, so a node that races with `Close` cannot outlive it.
A server's `InboundManager` owns the nodes for its known peers and for the peer-capable clients that connect to it, and maintains the connected-peer and connected-client configurations.
Expand Down
Loading
Loading