mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
* Enable linter: stylecheck This enabled the stylecheck linter, but excludes some staticchecks for now. The following are ignored because they will take a bit of time to address, but the intention is to address them and remove them from the exclusion list. ST1000: at least one file in a package should have a package comment ST1003: golang naming standards ST1008: error should be returned as the last argument ST1016: methods on the same type should have the same receiver name ST1020: comment on exported function * Address ST1015 For some reason this failed in CI but not locally. I can't figure out why that check isn't happening locally. This just moves the switch statements around so that the `default` is the first (or last) item. * Adjust error string in test to match case-adjusted error * Remove TestCloseTimeout
455 lines
14 KiB
Go
455 lines
14 KiB
Go
// Copyright 2022 Molecula Corp. (DBA FeatureBase).
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
package test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
gohttp "net/http"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
pilosa "github.com/featurebasedb/featurebase/v3"
|
|
"github.com/featurebasedb/featurebase/v3/disco"
|
|
"github.com/featurebasedb/featurebase/v3/encoding/proto"
|
|
"github.com/featurebasedb/featurebase/v3/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(tb DirCleaner, opts ...server.CommandOption) *Command {
|
|
path := tb.TempDir()
|
|
|
|
// 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}
|
|
output := io.Discard
|
|
if testing.Verbose() {
|
|
output = os.Stderr
|
|
}
|
|
m.Command = server.NewCommand(output, 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
|
|
|
|
return m
|
|
}
|
|
|
|
// NewCommandNode returns a new instance of Command with clustering enabled.
|
|
func NewCommandNode(tb DirCleaner, 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 MustRunUnsharedCluster(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
|
|
output := io.Discard
|
|
if testing.Verbose() {
|
|
output = os.Stderr
|
|
}
|
|
m.Command = server.NewCommand(output, 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 := io.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 := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return &httpResponse{Response: resp, Body: string(buf)}
|
|
}
|
|
|
|
// Do executes http.Do() with an http.NewRequest().
|
|
func DoProto(t testing.TB, method, urlStr string, body []byte) *gohttp.Response {
|
|
t.Helper()
|
|
req, err := gohttp.NewRequest(
|
|
method,
|
|
urlStr,
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-protobuf")
|
|
req.Header.Set("Accept", "application/x-protobuf")
|
|
|
|
// 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)
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
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
|
|
}
|