mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 02:44:59 +00:00
At 50ms, we see sporadic failures in CI. So much for "this should
only need a couple milliseconds". Bumped timeout to avoid that.
The challenge here is that we have some tests which *want* to hit
the timeout to confirm that we aren't allowing things we shouldn't.
But we don't want the test to hang forever. But we want to be sure
it is actually stuck and not just being slow...
(cherry picked from commit f1a68f8a82)
540 lines
15 KiB
Go
540 lines
15 KiB
Go
// Copyright 2022 Molecula Corp (DBA FeatureBase). All rights reserved.
|
|
package querycontext
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
rbfcfg "github.com/featurebasedb/featurebase/v3/rbf/cfg"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
var rbfTestConfig = func() *rbfcfg.Config {
|
|
cfg := rbfcfg.NewDefaultConfig()
|
|
cfg.FsyncEnabled = false
|
|
cfg.MaxSize = (1 << 28)
|
|
cfg.MaxWALSize = (1 << 28)
|
|
return cfg
|
|
}()
|
|
|
|
// testTxStore creates a testTxStoreWrapper with the provided path (relative to a TempDir)
|
|
// and KeySplitter, or fails the test. It also registers a cleanup function
|
|
// which closes the TxStore, and fails the test if that close doesn't succeed,
|
|
// for instance if the test leaves a QueryContext open. Neat, huh!
|
|
func testTxStore(tb testing.TB, path string, ks KeySplitter) *testTxStoreWrapper {
|
|
dir := filepath.Join(tb.TempDir(), path)
|
|
txs, err := NewRBFTxStore(dir, rbfTestConfig, ks)
|
|
if err != nil {
|
|
tb.Fatalf("opening TxStore: %v", err)
|
|
}
|
|
tb.Cleanup(func() {
|
|
err := txs.Close()
|
|
if err != nil {
|
|
tb.Errorf("closing TxStore: %v", err)
|
|
}
|
|
})
|
|
return newTestTxStoreWrapper(tb, txs)
|
|
}
|
|
|
|
func TestTxStoreClose(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "foo")
|
|
ctx := context.Background()
|
|
txs, err := NewRBFTxStore(dir, rbfTestConfig, nil)
|
|
if err != nil {
|
|
t.Fatalf("opening TxStore: %v", err)
|
|
}
|
|
qcx, err := txs.NewQueryContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("creating initial query context: %v", err)
|
|
}
|
|
_, err = qcx.NewWrite("i", "f", "v", 0)
|
|
if err == nil {
|
|
t.Fatalf("should get error requesting a write from a read qcx")
|
|
}
|
|
err = txs.Close() // should fail because of the qcx...
|
|
if err == nil {
|
|
t.Fatalf("should have failed to close TxStore because of open query")
|
|
}
|
|
qcx.Release()
|
|
err = txs.Close()
|
|
if err != nil {
|
|
t.Fatalf("should have closed TxStore successfully")
|
|
}
|
|
qcx, err = txs.NewQueryContext(ctx)
|
|
if err == nil {
|
|
qcx.Release()
|
|
t.Fatalf("should have failed to open query context on closed TxStore")
|
|
}
|
|
qcx, err = txs.NewWriteQueryContext(ctx, txs.Scope())
|
|
if err == nil {
|
|
qcx.Release()
|
|
t.Fatalf("should have failed to open query context on closed TxStore")
|
|
}
|
|
err = txs.Close()
|
|
if err == nil {
|
|
t.Fatalf("should get error on double-close")
|
|
}
|
|
}
|
|
|
|
func TestShardList(t *testing.T) {
|
|
rng := rand.New(rand.NewSource(3))
|
|
prev := &shardList{}
|
|
prevSeen := map[ShardID]struct{}{}
|
|
for i := 0; i < 20; i++ {
|
|
sl := &shardList{}
|
|
seen := make(map[ShardID]struct{})
|
|
for j := 0; j < 20; j++ {
|
|
add := ShardID(rng.Intn(20))
|
|
sl.Add(add)
|
|
seen[add] = struct{}{}
|
|
}
|
|
// verify that we have the same set of things in the list directly
|
|
if len(sl.any) != len(seen) {
|
|
t.Fatalf("expected %d entries, got %d: %v vs %d", len(seen), len(sl.any), seen, sl.any)
|
|
}
|
|
for _, v := range sl.any {
|
|
if _, ok := seen[v]; !ok {
|
|
t.Fatalf("found %d in shard list, not seen", v)
|
|
}
|
|
}
|
|
// verify that we neither allow things not included, nor disallow things included
|
|
for j := ShardID(0); j < 20; j++ {
|
|
_, ok1 := seen[j]
|
|
ok2 := sl.Allowed(j)
|
|
if ok1 != ok2 {
|
|
t.Fatalf("seen %t vs allowed %t mismatch for %d", ok1, ok2, j)
|
|
}
|
|
}
|
|
// verify that overlap between our two lists matches overlap between the current "seen"
|
|
// and previous "seen"
|
|
overlap := prev.Overlap(*sl)
|
|
var computedOverlap bool
|
|
for i := range seen {
|
|
if _, ok := prevSeen[i]; ok {
|
|
computedOverlap = true
|
|
break
|
|
}
|
|
}
|
|
if overlap != computedOverlap {
|
|
t.Fatalf("shardlists %#v and %#v: Overlap() %t, computed overlap %t", prev, sl, overlap, computedOverlap)
|
|
}
|
|
// and stash these for next time
|
|
prev = sl
|
|
prevSeen = seen
|
|
}
|
|
}
|
|
|
|
func TestTxStore(t *testing.T) {
|
|
ctx := context.Background()
|
|
txs := testTxStore(t, "foo", nil)
|
|
q, _ := txs.NewQueryContext(ctx)
|
|
_, _ = q.NewRead("i", "f", "v", 0)
|
|
txs.expect(1)
|
|
err := txs.Close()
|
|
if err == nil {
|
|
t.Fatalf("shouldn't close a database while still open")
|
|
}
|
|
q.Release()
|
|
q.expect(1)
|
|
err = q.Commit()
|
|
if err == nil {
|
|
t.Fatalf("shouldn't be able to commit after releasing")
|
|
}
|
|
q, _ = txs.NewWriteQueryContext(ctx, txs.Scope())
|
|
q.Error("oops")
|
|
q.expect(1)
|
|
err = q.Commit()
|
|
if err == nil {
|
|
t.Fatalf("shouldn't have been able to commit with error)")
|
|
}
|
|
nctx, cancel := context.WithCancel(ctx)
|
|
q, _ = txs.NewWriteQueryContext(nctx, txs.Scope())
|
|
cancel()
|
|
q.expect(1)
|
|
err = q.Commit()
|
|
if err == nil {
|
|
t.Fatalf("shouldn't have been able to commit after context cancelled")
|
|
}
|
|
}
|
|
|
|
// writeReq represents a possible write request. some of them will
|
|
// actually just read, and don't need to be within write scope.
|
|
type writeReq struct {
|
|
index IndexName
|
|
field FieldName
|
|
shard ShardID
|
|
justRead bool
|
|
}
|
|
|
|
// satisfyWriteRequest tries to process the writes in a list of
|
|
// writeReqs, by requesting them in order, and returns an error
|
|
// if it encounters any errors.
|
|
func satisfyWriteRequest(t *testing.T, txs *testTxStoreWrapper, requests []writeReq) error {
|
|
if len(requests) == 0 {
|
|
return nil
|
|
}
|
|
scope := txs.Scope()
|
|
prevIndex := requests[0].index
|
|
prevField := requests[0].field
|
|
shards := []ShardID{requests[0].shard}
|
|
add := func(index IndexName, field FieldName, shards ...ShardID) {
|
|
if field == "" {
|
|
if len(shards) == 1 && shards[0] == 0 {
|
|
scope.AddIndex(index)
|
|
} else {
|
|
scope.AddIndexShards(index, shards...)
|
|
}
|
|
} else {
|
|
if len(shards) == 1 && shards[0] == 0 {
|
|
scope.AddField(index, field)
|
|
} else {
|
|
scope.AddFieldShards(index, field, shards...)
|
|
}
|
|
}
|
|
}
|
|
// batch shards together. this is mostly irrelevant, but if we happened to get
|
|
// shard lists in an unsorted order, and didn't handle that correctly, this would
|
|
// catch that.
|
|
for _, req := range requests[1:] {
|
|
// don't add scope for a request that isn't a write
|
|
if req.justRead {
|
|
continue
|
|
}
|
|
if req.index == prevIndex && req.field == prevField {
|
|
shards = append(shards, req.shard)
|
|
} else {
|
|
add(prevIndex, prevField, shards...)
|
|
shards = append(shards[:0], req.shard)
|
|
prevIndex = req.index
|
|
prevField = req.field
|
|
}
|
|
}
|
|
add(prevIndex, prevField, shards...)
|
|
qcx, err := txs.NewWriteQueryContext(context.Background(), scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer qcx.Release()
|
|
for _, i := range rand.Perm(len(requests)) {
|
|
req := requests[i]
|
|
if req.justRead {
|
|
qr, err := qcx.NewRead(req.index, req.field, "v", req.shard)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = qr.Contains(1)
|
|
if !scope.Allowed(req.index, req.field, "v", req.shard) {
|
|
qcx.expect(1)
|
|
_, err := qcx.NewWrite(req.index, req.field, "v", req.shard)
|
|
if err == nil {
|
|
qcx.Error("write was allowed when it should have been out of scope")
|
|
}
|
|
}
|
|
} else {
|
|
qw, err := qcx.NewWrite(req.index, req.field, "v", req.shard)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = qw.Add(1)
|
|
}
|
|
}
|
|
if t.Failed() {
|
|
qcx.Error("an error occurred during read or write ops")
|
|
}
|
|
// When testing this, we're spawning a number of satisfyWriteRequest tasks
|
|
// at once. We want to be checking the overlap case more than we care about
|
|
// the no-overlap case, but we don't want to actually spend *time* sleeping.
|
|
// Gosched() hints that we should let one of the other goroutines start
|
|
// trying to do things, improving the chances of an overlap.
|
|
runtime.Gosched()
|
|
return qcx.Commit()
|
|
}
|
|
|
|
// delayWriteRequest is like satisfyWriteRequest, but doesn't commit
|
|
// the write request until the provided channel is closed, allowing us
|
|
// to verify that new requests can still happen before this one is closed.
|
|
func delayWriteRequest(t *testing.T, txs *testTxStoreWrapper, writeList []writeReq, ch chan struct{}) (func() error, error) {
|
|
writes := txs.Scope()
|
|
for _, write := range writeList {
|
|
writes.AddFieldShards(write.index, write.field, write.shard)
|
|
}
|
|
qcx, err := txs.NewWriteQueryContext(context.Background(), writes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, i := range rand.Perm(len(writeList)) {
|
|
write := writeList[i]
|
|
qw, err := qcx.NewWrite(write.index, write.field, "v", write.shard)
|
|
if err != nil {
|
|
qcx.Release()
|
|
return nil, err
|
|
}
|
|
qw.Add(1)
|
|
}
|
|
return func() error {
|
|
<-ch
|
|
return qcx.Commit()
|
|
}, nil
|
|
}
|
|
|
|
func testSomeWriteRequests(t *testing.T, writeRequests [][]writeReq) {
|
|
var splitter KeySplitter
|
|
// try some of these with a splitIndexes splitter, some with an indexShard splitter,
|
|
// so they both get tested
|
|
if len(writeRequests)%3 == 1 {
|
|
splitter = &flexibleKeySplitter{splitIndexes: map[IndexName]struct{}{"a": {}}}
|
|
} else {
|
|
splitter = &indexShardKeySplitter{}
|
|
}
|
|
txs := testTxStore(t, "foo", splitter)
|
|
eg := errgroup.Group{}
|
|
for i := range writeRequests {
|
|
req := writeRequests[i]
|
|
eg.Go(func() error {
|
|
return satisfyWriteRequest(t, txs, req)
|
|
})
|
|
}
|
|
err := eg.Wait()
|
|
if err != nil {
|
|
t.Logf("write requests (%d):", len(writeRequests))
|
|
for _, req := range writeRequests {
|
|
t.Logf("> %v", req)
|
|
}
|
|
t.Fatalf("running reqs: %v", err)
|
|
}
|
|
}
|
|
|
|
// overlappingWriteReqs represents a test case for allowing write requests to
|
|
// coexist.
|
|
type overlappingWriteReqs struct {
|
|
splitter KeySplitter
|
|
reqs [][]writeReq
|
|
shouldError bool
|
|
}
|
|
|
|
// testOverlappingWriteRequests takes multiple batches of writeReqs, creates
|
|
// a QueryScope from each batch, and tries to open queries with those writes
|
|
// simultaneously. If the writeReq batches overlap, this will time out.
|
|
//
|
|
// the timeout is returned as an error, other failures cause the test to fail.
|
|
func testOverlappingWriteRequests(t *testing.T, write overlappingWriteReqs) error {
|
|
txs := testTxStore(t, "foo", write.splitter)
|
|
// we spawn goroutines to open each of these, but wait until doneCh
|
|
// closes before closing any of them, so they're all open at once.
|
|
doneCh := make(chan struct{})
|
|
eg := errgroup.Group{}
|
|
closeFuncs := make(chan func() error, len(write.reqs))
|
|
for i := range write.reqs {
|
|
i := i
|
|
req := write.reqs[i]
|
|
eg.Go(func() (err error) {
|
|
fn, err := delayWriteRequest(t, txs, req, doneCh)
|
|
if err == nil {
|
|
closeFuncs <- fn
|
|
}
|
|
return err
|
|
})
|
|
}
|
|
errCh := make(chan error)
|
|
go func() {
|
|
errCh <- eg.Wait()
|
|
close(errCh)
|
|
}()
|
|
defer func() {
|
|
close(doneCh)
|
|
// we have an obligation to close every QueryContext before we close
|
|
// the TxStore. but we couldn't open them all! So. We check whether
|
|
// we actually got a response from errCh. If that times out, some of
|
|
// the delayWriteRequest calls aren't done yet, which means they haven't
|
|
// updated their entry in closeFuncs. So, we close all the existing
|
|
// QueryContexts, and try again. At least something ought to succeed,
|
|
// but it's possible not everything will, so we keep trying until we
|
|
// actually got them all done, call the remaining closeFuncs, and
|
|
// exit.
|
|
pending := true
|
|
expected := len(write.reqs)
|
|
for pending {
|
|
// wait up to 50ms for errCh to provide a result; if it doesn't,
|
|
// we need to try again
|
|
select {
|
|
case <-errCh:
|
|
pending = false
|
|
case <-time.After(50 * time.Millisecond):
|
|
pending = true
|
|
}
|
|
// run any pending close funcs. If errCh is closed, we should
|
|
// have all of our closeFuncs. If it isn't, we might only have
|
|
// some, because others haven't been created yet. So we grab
|
|
// everything currently available.
|
|
gotClose := true
|
|
for gotClose {
|
|
select {
|
|
case fn := <-closeFuncs:
|
|
if fn != nil {
|
|
if err := fn(); err != nil {
|
|
t.Errorf("closing transaction: %v", err)
|
|
}
|
|
}
|
|
expected--
|
|
if expected == 0 {
|
|
pending = false
|
|
}
|
|
default:
|
|
gotClose = false
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
t.Logf("write requests (%d):", len(write.reqs))
|
|
for _, req := range write.reqs {
|
|
t.Logf("> %v", req)
|
|
}
|
|
t.Fatalf("running reqs: %v", err)
|
|
}
|
|
case <-time.After(250 * time.Millisecond):
|
|
if !write.shouldError {
|
|
// Only log this if we didn't expect an error.
|
|
t.Logf("write requests (%d):", len(write.reqs))
|
|
for _, req := range write.reqs {
|
|
t.Logf("> %v", req)
|
|
}
|
|
}
|
|
return errors.New("timed out trying to create write requests")
|
|
}
|
|
// all the writes should still be pending
|
|
txs.expect(1)
|
|
err := txs.Close()
|
|
if err == nil {
|
|
t.Fatalf("close completed while transactions were pending")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// This test is here to make sure that the overlap-checking isn't
|
|
// insanely aggressive; in theory, these three requests can all coexist at once.
|
|
func TestOverlappingWriteRequests(t *testing.T) {
|
|
overlapping := []overlappingWriteReqs{
|
|
{
|
|
splitter: nil,
|
|
reqs: [][]writeReq{
|
|
{{"a", "f", 0, false}},
|
|
{{"b", "f", 0, false}},
|
|
{{"a", "f", 1, false}},
|
|
},
|
|
},
|
|
{
|
|
// we should be able to do two fields in i, but we wouldn't in j.
|
|
splitter: NewFlexibleKeySplitter("i"),
|
|
reqs: [][]writeReq{
|
|
{{"i", "f", 0, false}},
|
|
{{"i", "g", 0, false}},
|
|
{{"j", "f", 0, false}},
|
|
},
|
|
},
|
|
{
|
|
// this should fail
|
|
splitter: NewFlexibleKeySplitter("i"),
|
|
reqs: [][]writeReq{
|
|
{{"i", "f", 0, false}},
|
|
{{"i", "g", 0, false}},
|
|
{{"j", "f", 0, false}},
|
|
{{"j", "g", 0, false}},
|
|
},
|
|
shouldError: true,
|
|
},
|
|
{
|
|
// this should fail more than once
|
|
splitter: NewFlexibleKeySplitter("i"),
|
|
reqs: [][]writeReq{
|
|
{{"i", "f", 0, false}},
|
|
{{"i", "g", 0, false}},
|
|
{{"j", "f", 0, false}},
|
|
{{"j", "g", 0, false}},
|
|
{{"i", "f", 0, false}},
|
|
{{"i", "g", 0, false}},
|
|
{{"i", "f", 0, false}},
|
|
},
|
|
shouldError: true,
|
|
},
|
|
}
|
|
for i, testCase := range overlapping {
|
|
// run these in separate test cases, because testTxStore is closing in
|
|
// t.Cleanup, and we want them wrapped up as we go.
|
|
t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) {
|
|
err := testOverlappingWriteRequests(t, testCase)
|
|
if testCase.shouldError {
|
|
if err == nil {
|
|
t.Fatalf("expected error for case %d, but didn't get it", i)
|
|
}
|
|
} else {
|
|
if (err != nil) != testCase.shouldError {
|
|
t.Fatalf("case %d: unexpected error %v", i, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func buildRequestsFromBytes(data []byte) [][]writeReq {
|
|
// To fuzz these, we'll use []byte. each pair of bytes is an index/shard pair, and
|
|
// we break the list into batches of up to 50 such that the sum of their values caps
|
|
// at 256.
|
|
var out [][]writeReq
|
|
var sub []writeReq
|
|
total := 0
|
|
for len(data) >= 2 {
|
|
idx := data[0] % 5
|
|
index := IndexName(rune(idx) + 'a')
|
|
total += int(idx)
|
|
fld := (data[0] / 5) % 3
|
|
field := FieldName(rune(fld) + 'a')
|
|
// generate some index-only requests
|
|
if fld == 0 {
|
|
field = ""
|
|
}
|
|
shard := ShardID(data[1] % 8)
|
|
total += int(shard)
|
|
// some requests are just reads. reads won't be added to our scope, so if there's
|
|
// no corresponding write, they'll be outside of scope, so we're verifying that we
|
|
// can read outside of scope.
|
|
justRead := ((data[1] >> 4) % 4) != 0
|
|
sub = append(sub, writeReq{index, field, shard, justRead})
|
|
if total > 10 {
|
|
out = append(out, sub)
|
|
sub = []writeReq{}
|
|
total = 0
|
|
}
|
|
data = data[2:]
|
|
}
|
|
if len(sub) > 0 {
|
|
out = append(out, sub)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func FuzzWriteRequests(f *testing.F) {
|
|
rng := rand.New(rand.NewSource(3))
|
|
// Add a bit of random data so that we get more coverage even when not fuzzing
|
|
for i := 0; i < 20; i++ {
|
|
seed := make([]byte, rand.Intn(20)+20)
|
|
_, _ = rng.Read(seed)
|
|
f.Add(seed)
|
|
}
|
|
for _, seed := range [][]byte{{0, 4, 0, 5, 1, 4, 1, 5}, {4, 0, 3, 1, 3, 2, 3, 3, 2, 6, 3, 8, 3, 9}, {2, 4, 2, 5, 2, 6, 2, 4, 2, 5}, {0, 2, 0, 3, 0, 4, 0, 5, 0, 1}, {0, 1, 2, 3, 4, 5}, {0, 0, 0, 1, 0, 2, 0, 3, 0, 4}} {
|
|
f.Add(seed)
|
|
}
|
|
f.Fuzz(func(t *testing.T, data []byte) {
|
|
writeRequests := buildRequestsFromBytes(data)
|
|
testSomeWriteRequests(t, writeRequests)
|
|
})
|
|
}
|