mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
This is a unification of a number of bug fixes, feature additions, and so on. Features include: * Dropping the "New" from NewWrite/NewRead. * IndexName->keys.Index, etc. * Add a new "Flush" operation which is necessary to get the intended behavior of Delete, which allows us to commit/flush changes without letting go of a write lock. * Some additional wrapping and locking in rbfTxWrappers to support that. rbfQueryRead/Write now forward their calls to the parent rbfTxWrappers, so it can lock around the reference to its underlying tx, so the flush operation can replace that tx safely. * AddIndexShards now treats no shards as "all shards", to simplify call sites. * Added parameters to NewRBFTxStore to let it interact with executor's logger and worker pool. * Internally, support explicit closes of parts of the database which can also check for errors and fail if it's in use. * Add ability to request a map of fields and views in use for a given index/shard pair. This is probably deprecated but we need it for the way backup/restore work. * Add ability to request a complete map of the database showing which shards exist for which index/field/view tuples. This is backwards from how we store things on disk, but we need it to allow creating the right in-memory data structures on database open. * Add "Backend()" method to let us distinguish backends in case we some day have them again. * Support deleting indexes, fields, or fragments. * Support Backup (returning a ReadCloser that dumps the RBF file, implicitly merging any current WAL) and Restore (create a new RBF file). * Change directory structure and fragment keys to match existing databases, so we should in theory be able to open an existing data directory. * Fragment delete probably doesn't lock correctly and this should be reviewed. * Export the DOT-format Dump so we can hook it up to a debug endpoint. This wants to be explored more; ideally the front-end UI should be able to display this. * Create a NopTxStore which can be used like a TxStore but everything that can error errors out. This is then used to let a holder that hasn't had a txstore initialized work anyway. There's at least a couple of open issues that need to be revisited here.
545 lines
15 KiB
Go
545 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"
|
|
|
|
"github.com/molecula/featurebase/v3/keys"
|
|
rbfcfg "github.com/molecula/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, nil, nil, 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, nil, 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.Write("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[keys.Shard]struct{}{}
|
|
for i := 0; i < 20; i++ {
|
|
sl := &shardList{}
|
|
seen := make(map[keys.Shard]struct{})
|
|
for j := 0; j < 20; j++ {
|
|
add := keys.Shard(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 := keys.Shard(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.Read("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 keys.Index
|
|
field keys.Field
|
|
shard keys.Shard
|
|
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 := []keys.Shard{requests[0].shard}
|
|
add := func(index keys.Index, field keys.Field, shards ...keys.Shard) {
|
|
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.Read(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.Write(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.Write(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.Write(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[keys.Index]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 := keys.Index(rune(idx) + 'a')
|
|
total += int(idx)
|
|
fld := (data[0] / 5) % 3
|
|
field := keys.Field(rune(fld) + 'a')
|
|
// generate some index-only requests
|
|
if fld == 0 {
|
|
field = ""
|
|
}
|
|
shard := keys.Shard(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)
|
|
})
|
|
}
|
|
|
|
func TestNopTxStore(t *testing.T) {
|
|
NopTxStore.Scope()
|
|
}
|