mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-09-07 17:15:56 +00:00
When we make dummy test servers, we should make them using sockets for etcd rather than TCP ports so we don't run into problems like the test always failing if anything else is on that port already, which it can totally legitimately be. For instance, if you ran an existing featurebase server, and then tried "go test" in the server directory, this would fail.
426 lines
13 KiB
Go
426 lines
13 KiB
Go
// Copyright 2021 Molecula Corp. All rights reserved.
|
|
package test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io/ioutil"
|
|
gohttp "net/http"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
pilosa "github.com/molecula/featurebase/v3"
|
|
"github.com/molecula/featurebase/v3/disco"
|
|
"github.com/molecula/featurebase/v3/encoding/proto"
|
|
"github.com/molecula/featurebase/v3/server"
|
|
"github.com/molecula/featurebase/v3/testhook"
|
|
)
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////
|
|
// 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(tb testing.TB, opts ...server.CommandOption) *Command {
|
|
path, err := testhook.TempDir(tb, "pilosa-command-")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Set aggressive close timeout by default to avoid hanging tests. This was
|
|
// a problem with PDK tests which used pilosa/client as well. We put it at the
|
|
// beginning of the option slice so that it can be overridden by user-passed
|
|
// options.
|
|
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...)
|
|
// pick etcd ports using a socket rather than a real port
|
|
err = GetPortsGenConfigs(tb, []*Command{m})
|
|
if err != nil {
|
|
tb.Fatalf("generating config: %v", err)
|
|
}
|
|
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(tb testing.TB, 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(tb, opts...)
|
|
return m
|
|
}
|
|
|
|
// RunCommand returns a new, running Main. Panic on error.
|
|
func RunCommand(t *testing.T) *Command {
|
|
t.Helper()
|
|
|
|
// prefer MustRunCluster since it sets up for using etcd using
|
|
// the GenDisCoConfig(size) option.
|
|
return MustRunCluster(t, 1).GetNode(0)
|
|
}
|
|
|
|
// Close closes the program and removes the underlying data directory.
|
|
func (m *Command) Close() error {
|
|
// leave the removing part to the test logic. Some tests are closing and opening again the command
|
|
// 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()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// IsPrimary returns true if this is the primary.
|
|
func (m *Command) IsPrimary() bool {
|
|
coord := m.API.PrimaryNode()
|
|
if coord == nil {
|
|
return false
|
|
}
|
|
return coord.ID == m.API.Node().ID
|
|
}
|
|
|
|
// Client returns a client to connect to the program.
|
|
func (m *Command) Client() *pilosa.InternalClient {
|
|
return m.Server.InternalClient()
|
|
}
|
|
|
|
// Query executes a query against the program through the HTTP API.
|
|
func (m *Command) Query(t testing.TB, 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.TB, 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")
|
|
|
|
// set a timeout instead of allowing gohttp.Defaultclient to
|
|
// potentially hang forever.
|
|
hc := &gohttp.Client{
|
|
Timeout: time.Second * 30,
|
|
}
|
|
resp, err := hc.Do(req)
|
|
|
|
if err != nil {
|
|
fmt.Printf(" hc.Do() err = '%v'\n", err)
|
|
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 {
|
|
// have to check each field Row individually because FieldOptions is getting set
|
|
for j := range expected[i].Group {
|
|
// Field:"ppa", RowID:0x3, RowKey:"", Value:(*int64)(nil), FieldOptions:
|
|
if !reflect.DeepEqual(expected[i].Group[j].Field, result.Group[j].Field) {
|
|
t.Fatalf("unexpected result at %d: \n got:%+v\nwant:%+v\n", i, result, expected[i])
|
|
}
|
|
if !reflect.DeepEqual(expected[i].Group[j].RowKey, result.Group[j].RowKey) {
|
|
t.Fatalf("unexpected result at %d: \n got:%+v\nwant:%+v\n", i, result, expected[i])
|
|
}
|
|
if !reflect.DeepEqual(expected[i].Group[j].Value, result.Group[j].Value) {
|
|
t.Fatalf("unexpected result at %d: \n got:%+v\nwant:%+v\n", i, result, expected[i])
|
|
}
|
|
}
|
|
|
|
if !reflect.DeepEqual(expected[i].Count, result.Count) {
|
|
t.Fatalf("unexpected result at %d: \n got:%+v\nwant:%+v\n", i, result, expected[i])
|
|
}
|
|
if !reflect.DeepEqual(expected[i].Agg, result.Agg) {
|
|
t.Fatalf("unexpected result at %d: \n got:%+v\nwant:%+v\n", i, result, expected[i])
|
|
}
|
|
if !reflect.DeepEqual(expected[i].DecimalAgg, result.DecimalAgg) {
|
|
t.Fatalf("unexpected result at %d: \n got:%+v\nwant:%+v\n", i, result, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// CheckGroupByOnKey is like CheckGroupBy, but it doen't enforce a match on the GroupBy.Group.RowID value.
|
|
// In cases where the Group has a RowKey, then the value of RowID is not consistently assigned. Instead,
|
|
// it depends on the order of key translation IDs based on shard allocation to the
|
|
func CheckGroupByOnKey(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 {
|
|
exp := expected[i]
|
|
if len(exp.Group) != len(result.Group) {
|
|
t.Fatalf("number of groups within GroupCount mismatch:\n got:%+v\nwant:%+v\n", result, exp)
|
|
}
|
|
if exp.Count != result.Count {
|
|
t.Fatalf("GroupCount count mismatch:\n got:%+v\nwant:%+v\n", result, exp)
|
|
}
|
|
if exp.Agg != result.Agg {
|
|
t.Fatalf("GroupCount aggregate mismatch:\n got:%+v\nwant:%+v\n", result, exp)
|
|
}
|
|
for j, grp := range result.Group {
|
|
if grp.Field != exp.Group[j].Field || grp.RowKey != exp.Group[j].RowKey {
|
|
t.Fatalf("GroupCount group value mismatch:\n got:%+v\nwant:%+v\n", result, exp)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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:
|
|
}
|
|
}
|
|
}
|
|
|
|
// AwaitState waits for the whole cluster to reach a specified state.
|
|
func (m *Command) AwaitState(expectedState disco.ClusterState, timeout time.Duration) (err error) {
|
|
startTime := time.Now()
|
|
var elapsed time.Duration
|
|
for elapsed = 0; elapsed <= timeout; elapsed = time.Since(startTime) {
|
|
// Counterintuitive: We're returning if the err *is* nil,
|
|
// meaning we've reached the expected state.
|
|
if err = m.exceptionalState(expectedState); err == nil {
|
|
return err
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
return fmt.Errorf("waited %v for command to reach state %q: %v",
|
|
elapsed, expectedState, err)
|
|
}
|
|
|
|
// AssertState waits for the whole cluster to reach a specified state, or
|
|
// fails the calling test if it can't.
|
|
func (m *Command) AssertState(t testing.TB, expectedState disco.ClusterState, timeout time.Duration) {
|
|
startTime := time.Now()
|
|
var elapsed time.Duration
|
|
var err error
|
|
for elapsed = 0; elapsed <= timeout; elapsed = time.Since(startTime) {
|
|
// Counterintuitive: We're returning if the err *is* nil,
|
|
// meaning we've reached the expected state.
|
|
if err = m.exceptionalState(expectedState); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
t.Fatalf("waited %v for command to reach state %q: %v", elapsed, expectedState, err)
|
|
}
|
|
|
|
// exceptionalState returns an error if the node is not in the expected state.
|
|
func (m *Command) exceptionalState(expectedState disco.ClusterState) error {
|
|
state, err := m.API.State()
|
|
if err != nil || state != expectedState {
|
|
return fmt.Errorf("node %q: state %s: err %v", m.ID(), state, err)
|
|
}
|
|
return nil
|
|
}
|