featurebase/querycontext/query_context_test.go
Seebs e3d137f29c initial implementation of QueryContext design
This is living in a subdirectory for now so we can have better
turnaround time on tests and not have to build everything else
along with it.

This covers the logic that we can have *without* actually using
databases or the filesystem in any way, just to provide a framework
that lets us validate the logic handling overlapping queries.

The overall purpose of this is to prevent deadlocks, by ensuring
that database locks are only taken when we have already proven
that they are available. In short, the QueryContext preregisters
its "scope" -- the set of things it may want to lock. The operation
of creating the QueryContext can block, but it blocks with no
database locks held. Once it is unblocked, the scope it has reported
is now considered unavailable, and no other QueryContext using any
overlapping scope can complete creation until this QueryContext
completes. While it's running, the QueryContext can't request write
access to anything outside its scope. Thus, once created, a
QueryContext can always proceed, without being blocked, until it's
done.

Note that this does not fully address multi-node behaviors;
once you have a QueryContext blocking things, you need to not
make queries to other nodes that could be blocked in turn by those
nodes. In short, no write queries to other nodes while holding a
write-type QueryContext on the local node, because if two nodes
do that to each other at once, they can both be blocked.

We believe RBF is currently designed such that read-only accesses
don't block progress on writes, so non-write access doesn't
create problems.

We also have some code to allow us to create dot-format output
from the components of this system, which is mostly intended to
be a debugging tool.
2022-10-28 14:01:58 -05:00

390 lines
11 KiB
Go

// Copyright 2022 Molecula Corp (DBA FeatureBase). All rights reserved.
package querycontext
import (
"context"
"errors"
"math/rand"
"runtime"
"testing"
"time"
"golang.org/x/sync/errgroup"
)
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, err := newRbfTxStore("foo", nil)
if err != nil {
t.Fatalf("creating Tx store: %v", err)
}
q, err := txs.NewQueryContext(ctx)
if err != nil {
t.Fatalf("creating read context: %v", err)
}
err = txs.Close()
if err == nil {
t.Fatalf("shouldn't close a database with unused QueryContext live")
}
_, err = q.NewRead("i", "f", "v", 0)
if err != nil {
t.Fatalf("creating read query: %v", err)
}
err = txs.Close()
if err == nil {
t.Fatalf("shouldn't close a database while still open")
}
q.Release()
err = txs.Close()
if err != nil {
t.Fatalf("closing database: %v", err)
}
}
// writeReq represents a possible write request.
type writeReq struct {
index IndexName
field FieldName
shard ShardID
}
// 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 TxStore, writeList []writeReq) error {
if len(writeList) == 0 {
return nil
}
writes := txs.Scope()
prevIndex := writeList[0].index
prevField := writeList[0].field
shards := []ShardID{writeList[0].shard}
add := func(index IndexName, field FieldName, shards ...ShardID) {
if field == "" {
if len(shards) == 1 && shards[0] == 0 {
writes.AddIndex(index)
} else {
writes.AddIndexShards(index, shards...)
}
} else {
if len(shards) == 1 && shards[0] == 0 {
writes.AddField(index, field)
} else {
writes.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 _, write := range writeList[1:] {
if write.index == prevIndex && write.field == prevField {
shards = append(shards, write.shard)
} else {
add(prevIndex, prevField, shards...)
shards = append(shards[:0], write.shard)
prevIndex = write.index
prevField = write.field
}
}
add(prevIndex, prevField, shards...)
qcx, err := txs.NewWriteQueryContext(context.Background(), writes)
defer qcx.Release()
if err != nil {
return err
}
for _, i := range rand.Perm(len(writeList)) {
write := writeList[i]
_, err := qcx.NewWrite(write.index, write.field, "v", write.shard)
if err != nil {
return err
}
// try reading something that could be outside our scope, because that
// should still be allowed
_, err = qcx.NewRead(write.index, write.field, "v", write.shard+5)
if err != nil {
return err
}
}
// 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 TxStore, 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]
_, err := qcx.NewWrite(write.index, write.field, "v", write.shard)
if err != nil {
qcx.Release()
return nil, err
}
}
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, err := newRbfTxStore("foo", splitter)
if err != nil {
t.Fatalf("creating Tx store: %v", err)
}
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)
}
err = txs.Close()
if err != nil {
t.Fatalf("closing tx store: %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, err := newRbfTxStore("foo", write.splitter)
if err != nil {
t.Fatalf("creating Tx store: %v", err)
}
// 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([]func() error, len(write.reqs))
for i := range write.reqs {
i := i
req := write.reqs[i]
eg.Go(func() (err error) {
closeFuncs[i], err = delayWriteRequest(t, txs, req, doneCh)
return err
})
}
errCh := make(chan error)
go func() {
errCh <- eg.Wait()
}()
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(50 * time.Millisecond):
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
err = txs.Close()
if err == nil {
t.Fatalf("close completed while transactions were pending")
}
close(doneCh)
for _, fn := range closeFuncs {
if err := fn(); err != nil {
t.Fatalf("closing transaction: %v", err)
}
}
err = txs.Close()
if err != nil {
t.Fatalf("closing tx store: %v", err)
}
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}},
{{"b", "f", 0}},
{{"a", "f", 1}},
},
},
{
// we should be able to do two fields in i, but we wouldn't in j.
splitter: NewFlexibleKeySplitter("i"),
reqs: [][]writeReq{
{{"i", "f", 0}},
{{"i", "g", 0}},
{{"j", "f", 0}},
},
},
{
// this should fail
splitter: NewFlexibleKeySplitter("i"),
reqs: [][]writeReq{
{{"i", "f", 0}},
{{"i", "g", 0}},
{{"j", "f", 0}},
{{"j", "g", 0}},
},
shouldError: true,
},
}
for i, testCase := range overlapping {
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 writeRequestsFromBytes(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)
sub = append(sub, writeReq{index, field, shard})
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 := writeRequestsFromBytes(data)
testSomeWriteRequests(t, writeRequests)
})
}