mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-09-06 08:35:55 +00:00
Some cluster tests failed sporadically. In order to fix them, I introduced some debugging-related functionality, which revealed several new bugs that were actually existing bugs we just happened not to hit in testing. This combines various fixes. We start with "make the nodes used in testing have distinct names based on the test case name", which lets us discover that we are leaking clusters, which continue to sit around talking with each other. That in turn causes significantly higher load on access to ephemeral ports, which causes sporadic failures when we shut a node down and try to restart it, but something else has gotten assigned its ephemeral port number since then. Part of the fix is to try to rebind on port 0 if an attempt to bind to a specified port over 32k fails. This is a guess; the actual ephemeral port range could be 16k+, 32k+, or 48k+, or just about anything else really, but it seems reasonable in practice. There were bugs in the oft-repeated loops to await the cluster achieving a given state, and it could hang forever if it didn't, so we add a timeout and a standard function on the test.Cluster type to handle that. Note that the timeout seems irrelevant; in every case I've tried, a timeout of 0 is fine because the node start doesn't complete until the cluster state has changed. Add a method to test.Command to run a query, expecting a specific result. Also clean up some of the formatting and generation of queries, and allow parameterized (badly) queries. This lets us fix a subtle bug, which is that test cases were depending on assumptions about shardwidths. Also improve the diagnostic output from some of these functions so test failures are more comprehensible. But actually that dependency on shardwidths was ALSO revealing a genuine underlying bug, which is that a node resize did not correctly propagate the schema to a new node if there was no data present on shards that node would own. We now also have a test case that hits that (or would, if we hadn't fixed it). Add comments explaining the server options parameters for MustNewCluster and MustRunCluster. Also, we implement the ReadFrom and WriteTo behaviors for InMemTranslateStore, without which some of the cluster resize tests fail. Props to the comment for specifically stating that they wouldn't work if that happened, which probably saved me several hours of debugging. The implementations may not be robust, but InMemTranslateStore is intended to be used only in lightweight and transient testing.
360 lines
11 KiB
Go
360 lines
11 KiB
Go
// Copyright 2017 Pilosa Corp.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io/ioutil"
|
|
gohttp "net/http"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pilosa/pilosa/v2"
|
|
"github.com/pilosa/pilosa/v2/encoding/proto"
|
|
"github.com/pilosa/pilosa/v2/http"
|
|
"github.com/pilosa/pilosa/v2/server"
|
|
)
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////
|
|
// Command represents a test wrapper for server.Command.
|
|
type Command struct {
|
|
*server.Command
|
|
|
|
commandOptions []server.CommandOption
|
|
}
|
|
|
|
func OptAllowedOrigins(origins []string) server.CommandOption {
|
|
return func(m *server.Command) error {
|
|
m.Config.Handler.AllowedOrigins = origins
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// newCommand returns a new instance of Main with a temporary data directory and random port.
|
|
func newCommand(opts ...server.CommandOption) *Command {
|
|
path, err := ioutil.TempDir("", "pilosa-")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Set aggressive close timeout by default to avoid hanging tests. This was
|
|
// a problem with PDK tests which used go-pilosa as well. We put it at the
|
|
// beginning of the option slice so that it can be overridden by user-passed
|
|
// options.
|
|
// Also set TranslateFile MapSize to a smaller number so memory allocation
|
|
// does not fail on 32-bit systems.
|
|
opts = append([]server.CommandOption{
|
|
server.OptCommandCloseTimeout(time.Millisecond * 2),
|
|
}, opts...)
|
|
m := &Command{commandOptions: opts}
|
|
m.Command = server.NewCommand(bytes.NewReader(nil), ioutil.Discard, ioutil.Discard, opts...)
|
|
m.Config.DataDir = path
|
|
defaultConf := server.NewConfig()
|
|
if m.Config.Bind == defaultConf.Bind {
|
|
m.Config.Bind = "http://localhost:0"
|
|
}
|
|
if m.Config.BindGRPC == defaultConf.BindGRPC {
|
|
m.Config.BindGRPC = "http://localhost:0"
|
|
}
|
|
m.Config.Translation.MapSize = 140000
|
|
m.Config.WorkerPoolSize = 2
|
|
|
|
if testing.Verbose() {
|
|
m.Command.Stdout = os.Stdout
|
|
m.Command.Stderr = os.Stderr
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// NewCommandNode returns a new instance of Command with clustering enabled.
|
|
func NewCommandNode(isCoordinator bool, opts ...server.CommandOption) *Command {
|
|
// We want tests to default to using the in-memory translate store, so we
|
|
// prepend opts with that functional option. If a different translate store
|
|
// has been specified, it will override this one.
|
|
opts = prependTestServerOpts(opts)
|
|
m := newCommand(opts...)
|
|
m.Config.Cluster.Disabled = false
|
|
m.Config.Cluster.Coordinator = isCoordinator
|
|
return m
|
|
}
|
|
|
|
// RunCommand returns a new, running Main. Panic on error.
|
|
func RunCommand(t *testing.T) *Command {
|
|
t.Helper()
|
|
m := newCommand(server.OptCommandServerOptions(pilosa.OptServerOpenTranslateStore(pilosa.OpenInMemTranslateStore)))
|
|
m.Config.Metric.Diagnostics = false // Disable diagnostics.
|
|
m.Config.Gossip.Port = "0"
|
|
if err := m.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// GossipAddress returns the address on which gossip is listening after a Main
|
|
// has been setup. Useful to pass as a seed to other nodes when creating and
|
|
// testing clusters.
|
|
func (m *Command) GossipAddress() string {
|
|
return m.GossipTransport().URI.String()
|
|
}
|
|
|
|
// Close closes the program and removes the underlying data directory.
|
|
func (m *Command) Close() error {
|
|
defer os.RemoveAll(m.Config.DataDir)
|
|
return m.Command.Close()
|
|
}
|
|
|
|
// Reopen closes the program and reopens it.
|
|
func (m *Command) Reopen() error {
|
|
if err := m.Command.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create new main with the same config.
|
|
config := m.Command.Config
|
|
m.Command = server.NewCommand(bytes.NewReader(nil), ioutil.Discard, ioutil.Discard, m.commandOptions...)
|
|
m.Command.Config = config
|
|
|
|
// Run new program.
|
|
return m.Start()
|
|
}
|
|
|
|
// SoftOpen is like Reopen, but doesn't close the program first.
|
|
// This is useful in the case where a test needs to decouple the
|
|
// close from the re-open (for example, there may need to be
|
|
// actions which take place between those two steps).
|
|
func (m *Command) SoftOpen() error {
|
|
// Create new main with the same config.
|
|
config := m.Command.Config
|
|
m.Command = server.NewCommand(bytes.NewReader(nil), ioutil.Discard, ioutil.Discard, m.commandOptions...)
|
|
m.Command.Config = config
|
|
|
|
// Run new program.
|
|
return m.Start()
|
|
}
|
|
|
|
// MustCreateIndex uses this command's API to create an index and fails the test
|
|
// if there is an error.
|
|
func (m *Command) MustCreateIndex(tb testing.TB, name string, opts pilosa.IndexOptions) *pilosa.Index {
|
|
tb.Helper()
|
|
idx, err := m.API.CreateIndex(context.Background(), name, opts)
|
|
if err != nil {
|
|
tb.Fatalf("creating index: %v with options: %v, err: %v", name, opts, err)
|
|
}
|
|
return idx
|
|
}
|
|
|
|
// MustCreateField uses this command's API to create the field. The index must
|
|
// already exist - it fails the test if there is an error.
|
|
func (m *Command) MustCreateField(tb testing.TB, index, field string, opts ...pilosa.FieldOption) *pilosa.Field {
|
|
tb.Helper()
|
|
f, err := m.API.CreateField(context.Background(), index, field, opts...)
|
|
if err != nil {
|
|
tb.Fatalf("creating field: %s in index: %s err: %v", field, index, err)
|
|
}
|
|
return f
|
|
}
|
|
|
|
// QueryAPI uses this command's API to execute the given query request, failing
|
|
// if Query returns a non-nil error, otherwise returning the QueryResponse.
|
|
func (m *Command) QueryAPI(tb testing.TB, req *pilosa.QueryRequest) pilosa.QueryResponse {
|
|
tb.Helper()
|
|
resp, err := m.API.Query(context.Background(), req)
|
|
if err != nil {
|
|
tb.Fatalf("making query: %v, err: %v", req, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// MustRecalculateCaches calls RecalculateCaches on the command's API, and fails
|
|
// if there is an error.
|
|
func (m *Command) MustRecalculateCaches(tb testing.TB) {
|
|
err := m.API.RecalculateCaches(context.Background())
|
|
if err != nil {
|
|
tb.Fatalf("recalcluating caches: %v", err)
|
|
}
|
|
}
|
|
|
|
// URL returns the base URL string for accessing the running program.
|
|
func (m *Command) URL() string { return m.API.Node().URI.String() }
|
|
|
|
// ID returns the node ID used by the running program.
|
|
func (m *Command) ID() string { return m.API.Node().ID }
|
|
|
|
// Client returns a client to connect to the program.
|
|
func (m *Command) Client() *http.InternalClient {
|
|
return m.Server.InternalClient().(*http.InternalClient)
|
|
}
|
|
|
|
// Query executes a query against the program through the HTTP API.
|
|
func (m *Command) Query(t *testing.T, index, rawQuery, query string) (string, error) {
|
|
resp := Do(t, "POST", fmt.Sprintf("%s/index/%s/query?%s", m.URL(), index, rawQuery), query)
|
|
if resp.StatusCode != gohttp.StatusOK {
|
|
return "", fmt.Errorf("invalid status: %d, body=%s", resp.StatusCode, resp.Body)
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
|
|
// Queryf is like Query, but with a format string.
|
|
func (m *Command) Queryf(t *testing.T, index, rawQuery, query string, params ...interface{}) (string, error) {
|
|
query = fmt.Sprintf(query, params...)
|
|
resp := Do(t, "POST", fmt.Sprintf("%s/index/%s/query?%s", m.URL(), index, rawQuery), query)
|
|
if resp.StatusCode != gohttp.StatusOK {
|
|
return "", fmt.Errorf("invalid status: %d, body=%s", resp.StatusCode, resp.Body)
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
|
|
// QueryExpect executes a query against the program through the HTTP API, and
|
|
// confirms that it got an expected response.
|
|
func (m *Command) QueryExpect(t *testing.T, index, rawQuery, query string, expected string) {
|
|
resp := Do(t, "POST", fmt.Sprintf("%s/index/%s/query?%s", m.URL(), index, rawQuery), query)
|
|
if resp.StatusCode != gohttp.StatusOK {
|
|
t.Fatalf("invalid status from %s: %d, body=%q", m.ID(), resp.StatusCode, resp.Body)
|
|
}
|
|
last := len(resp.Body) - 1
|
|
// Trim trailing newline so we don't need it to be present in the expected data.
|
|
if last >= 0 && resp.Body[last] == '\n' {
|
|
resp.Body = resp.Body[:last]
|
|
}
|
|
|
|
if resp.Body != expected {
|
|
t.Fatalf("node %s, query %q: expected response %s, got %s", m.ID(), query, expected, resp.Body)
|
|
}
|
|
}
|
|
|
|
func (m *Command) QueryProtobuf(indexName string, query string) (*pilosa.QueryResponse, error) {
|
|
var ser proto.Serializer
|
|
queryReq := &pilosa.QueryRequest{
|
|
Index: indexName,
|
|
Query: query,
|
|
}
|
|
body, err := ser.Marshal(queryReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := gohttp.NewRequest(
|
|
"POST",
|
|
fmt.Sprintf("%s/index/%s/query", m.URL(), indexName),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-protobuf")
|
|
req.Header.Set("Accept", "application/x-protobuf")
|
|
|
|
resp, err := gohttp.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
buf, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := &pilosa.QueryResponse{}
|
|
err = ser.Unmarshal(buf, response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// RecalculateCaches is deprecated. Use MustRecalculateCaches.
|
|
func (m *Command) RecalculateCaches(t *testing.T) error {
|
|
resp := Do(t, "POST", fmt.Sprintf("%s/recalculate-caches", m.URL()), "")
|
|
if resp.StatusCode != 204 {
|
|
return fmt.Errorf("invalid status: %d, body=%s", resp.StatusCode, resp.Body)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Do executes http.Do() with an http.NewRequest().
|
|
func Do(t *testing.T, method, urlStr string, body string) *httpResponse {
|
|
t.Helper()
|
|
req, err := gohttp.NewRequest(
|
|
method,
|
|
urlStr,
|
|
strings.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := gohttp.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
buf, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return &httpResponse{Response: resp, Body: string(buf)}
|
|
}
|
|
|
|
func CheckGroupBy(t *testing.T, expected, results []pilosa.GroupCount) {
|
|
t.Helper()
|
|
if len(results) != len(expected) {
|
|
t.Fatalf("number of groupings mismatch:\n got:%+v\nwant:%+v\n", results, expected)
|
|
}
|
|
for i, result := range results {
|
|
if !reflect.DeepEqual(expected[i], result) {
|
|
t.Fatalf("unexpected result at %d: \n got:%+v\nwant:%+v\n", i, result, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// httpResponse is a wrapper for http.Response that holds the Body as a string.
|
|
type httpResponse struct {
|
|
*gohttp.Response
|
|
Body string
|
|
}
|
|
|
|
// RetryUntil repeatedly executes fn until it returns nil or timeout occurs.
|
|
func RetryUntil(timeout time.Duration, fn func() error) (err error) {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
if err = fn(); err == nil {
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case <-timer.C:
|
|
return err
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|