// 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) }) }