From b66d23ee5468ca45674f970ee13fd8d9bfc0e1e3 Mon Sep 17 00:00:00 2001 From: Uday Patil Date: Tue, 26 Sep 2023 15:55:13 -0400 Subject: [PATCH] Multiversion Item Implementation and Tests (#318) ## Describe your changes and provide context Add multiversion store data structures file, and implement the multiversioned item ## Testing performed to validate your change Added unit tests to verify behavior --- store/multiversion/data_structures.go | 160 +++++++++++++++++ store/multiversion/data_structures_test.go | 192 +++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 store/multiversion/data_structures.go create mode 100644 store/multiversion/data_structures_test.go diff --git a/store/multiversion/data_structures.go b/store/multiversion/data_structures.go new file mode 100644 index 000000000..a382a6f0a --- /dev/null +++ b/store/multiversion/data_structures.go @@ -0,0 +1,160 @@ +package multiversion + +import ( + "sync" + + "github.com/cosmos/cosmos-sdk/store/types" + "github.com/google/btree" +) + +const ( + // The approximate number of items and children per B-tree node. Tuned with benchmarks. + multiVersionBTreeDegree = 2 // should be equivalent to a binary search tree TODO: benchmark this +) + +type MultiVersionValue interface { + GetLatest() (value MultiVersionValueItem, found bool) + GetLatestBeforeIndex(index int) (value MultiVersionValueItem, found bool) + Set(index int, value []byte) + SetEstimate(index int) + Delete(index int) +} + +type MultiVersionValueItem interface { + IsDeleted() bool + IsEstimate() bool + Value() []byte + Index() int +} + +type multiVersionItem struct { + valueTree *btree.BTree // contains versions values written to this key + mtx sync.RWMutex // manages read + write accesses +} + +var _ MultiVersionValue = (*multiVersionItem)(nil) + +func NewMultiVersionItem() *multiVersionItem { + return &multiVersionItem{ + valueTree: btree.New(multiVersionBTreeDegree), + } +} + +// GetLatest returns the latest written value to the btree, and returns a boolean indicating whether it was found. +// +// A `nil` value along with `found=true` indicates a deletion that has occurred and the underlying parent store doesn't need to be hit. +func (item *multiVersionItem) GetLatest() (MultiVersionValueItem, bool) { + item.mtx.RLock() + defer item.mtx.RUnlock() + + bTreeItem := item.valueTree.Max() + if bTreeItem == nil { + return nil, false + } + valueItem := bTreeItem.(*valueItem) + return valueItem, true +} + +// GetLatest returns the latest written value to the btree prior to the index passed in, and returns a boolean indicating whether it was found. +// +// A `nil` value along with `found=true` indicates a deletion that has occurred and the underlying parent store doesn't need to be hit. +func (item *multiVersionItem) GetLatestBeforeIndex(index int) (MultiVersionValueItem, bool) { + item.mtx.RLock() + defer item.mtx.RUnlock() + + // we want to find the value at the index that is LESS than the current index + pivot := NewDeletedItem(index - 1) + + var vItem *valueItem + var found bool + // start from pivot which contains our current index, and return on first item we hit. + // This will ensure we get the latest indexed value relative to our current index + item.valueTree.DescendLessOrEqual(pivot, func(bTreeItem btree.Item) bool { + vItem = bTreeItem.(*valueItem) + found = true + return false + }) + return vItem, found +} + +func (item *multiVersionItem) Set(index int, value []byte) { + types.AssertValidValue(value) + item.mtx.Lock() + defer item.mtx.Unlock() + + valueItem := NewValueItem(index, value) + item.valueTree.ReplaceOrInsert(valueItem) +} + +func (item *multiVersionItem) Delete(index int) { + item.mtx.Lock() + defer item.mtx.Unlock() + + deletedItem := NewDeletedItem(index) + item.valueTree.ReplaceOrInsert(deletedItem) +} + +func (item *multiVersionItem) SetEstimate(index int) { + item.mtx.Lock() + defer item.mtx.Unlock() + + estimateItem := NewEstimateItem(index) + item.valueTree.ReplaceOrInsert(estimateItem) +} + +type valueItem struct { + index int + value []byte + estimate bool +} + +var _ MultiVersionValueItem = (*valueItem)(nil) + +// Index implements MultiVersionValueItem. +func (v *valueItem) Index() int { + return v.index +} + +// IsDeleted implements MultiVersionValueItem. +func (v *valueItem) IsDeleted() bool { + return v.value == nil && !v.estimate +} + +// IsEstimate implements MultiVersionValueItem. +func (v *valueItem) IsEstimate() bool { + return v.estimate +} + +// Value implements MultiVersionValueItem. +func (v *valueItem) Value() []byte { + return v.value +} + +// implement Less for btree.Item for valueItem +func (i *valueItem) Less(other btree.Item) bool { + return i.index < other.(*valueItem).index +} + +func NewValueItem(index int, value []byte) *valueItem { + return &valueItem{ + index: index, + value: value, + estimate: false, + } +} + +func NewEstimateItem(index int) *valueItem { + return &valueItem{ + index: index, + value: nil, + estimate: true, + } +} + +func NewDeletedItem(index int) *valueItem { + return &valueItem{ + index: index, + value: nil, + estimate: false, + } +} diff --git a/store/multiversion/data_structures_test.go b/store/multiversion/data_structures_test.go new file mode 100644 index 000000000..92975462d --- /dev/null +++ b/store/multiversion/data_structures_test.go @@ -0,0 +1,192 @@ +package multiversion_test + +import ( + "testing" + + mv "github.com/cosmos/cosmos-sdk/store/multiversion" + "github.com/stretchr/testify/require" +) + +func TestMultiversionItemGetLatest(t *testing.T) { + mvItem := mv.NewMultiVersionItem() + // We have no value, should get found == false and a nil value + value, found := mvItem.GetLatest() + require.False(t, found) + require.Nil(t, value) + + // assert that we find a value after it's set + one := []byte("one") + mvItem.Set(1, one) + value, found = mvItem.GetLatest() + require.True(t, found) + require.Equal(t, one, value.Value()) + + // assert that we STILL get the "one" value since it is the latest + zero := []byte("zero") + mvItem.Set(0, zero) + value, found = mvItem.GetLatest() + require.True(t, found) + require.Equal(t, one, value.Value()) + + // we should see a deletion as the latest now, aka nil value and found == true + mvItem.Delete(2) + value, found = mvItem.GetLatest() + require.True(t, found) + require.True(t, value.IsDeleted()) + require.Nil(t, value.Value()) + + // Overwrite the deleted value with some data + two := []byte("two") + mvItem.Set(2, two) + value, found = mvItem.GetLatest() + require.True(t, found) + require.Equal(t, two, value.Value()) +} + +func TestMultiversionItemGetByIndex(t *testing.T) { + mvItem := mv.NewMultiVersionItem() + // We have no value, should get found == false and a nil value + value, found := mvItem.GetLatestBeforeIndex(9) + require.False(t, found) + require.Nil(t, value) + + // assert that we find a value after it's set + one := []byte("one") + mvItem.Set(1, one) + // should not be found because we specifically search "LESS THAN" + value, found = mvItem.GetLatestBeforeIndex(1) + require.False(t, found) + require.Nil(t, value) + // querying from "two" should be found + value, found = mvItem.GetLatestBeforeIndex(2) + require.True(t, found) + require.Equal(t, one, value.Value()) + + // verify that querying for an earlier index returns nil + value, found = mvItem.GetLatestBeforeIndex(0) + require.False(t, found) + require.Nil(t, value) + + // assert that we STILL get the "one" value when querying with a later index + zero := []byte("zero") + mvItem.Set(0, zero) + // verify that querying for zero should ALWAYS return nil + value, found = mvItem.GetLatestBeforeIndex(0) + require.False(t, found) + require.Nil(t, value) + + value, found = mvItem.GetLatestBeforeIndex(2) + require.True(t, found) + require.Equal(t, one, value.Value()) + // verify we get zero when querying with index 1 + value, found = mvItem.GetLatestBeforeIndex(1) + require.True(t, found) + require.Equal(t, zero, value.Value()) + + // we should see a deletion as the latest now, aka nil value and found == true, but index 4 still returns `one` + mvItem.Delete(4) + value, found = mvItem.GetLatestBeforeIndex(4) + require.True(t, found) + require.Equal(t, one, value.Value()) + // should get deletion item for a later index + value, found = mvItem.GetLatestBeforeIndex(5) + require.True(t, found) + require.True(t, value.IsDeleted()) + + // verify that we still read the proper underlying item for an older index + value, found = mvItem.GetLatestBeforeIndex(3) + require.True(t, found) + require.Equal(t, one, value.Value()) + + // Overwrite the deleted value with some data and verify we read it properly + four := []byte("four") + mvItem.Set(4, four) + // also reads the four + value, found = mvItem.GetLatestBeforeIndex(6) + require.True(t, found) + require.Equal(t, four, value.Value()) + // still reads the `one` + value, found = mvItem.GetLatestBeforeIndex(4) + require.True(t, found) + require.Equal(t, one, value.Value()) +} + +func TestMultiversionItemEstimate(t *testing.T) { + mvItem := mv.NewMultiVersionItem() + // We have no value, should get found == false and a nil value + value, found := mvItem.GetLatestBeforeIndex(9) + require.False(t, found) + require.Nil(t, value) + + // assert that we find a value after it's set + one := []byte("one") + mvItem.Set(1, one) + // should not be found because we specifically search "LESS THAN" + value, found = mvItem.GetLatestBeforeIndex(1) + require.False(t, found) + require.Nil(t, value) + // querying from "two" should be found + value, found = mvItem.GetLatestBeforeIndex(2) + require.True(t, found) + require.False(t, value.IsEstimate()) + require.Equal(t, one, value.Value()) + // set as estimate + mvItem.SetEstimate(1) + // should not be found because we specifically search "LESS THAN" + value, found = mvItem.GetLatestBeforeIndex(1) + require.False(t, found) + require.Nil(t, value) + // querying from "two" should be found as ESTIMATE + value, found = mvItem.GetLatestBeforeIndex(2) + require.True(t, found) + require.True(t, value.IsEstimate()) + + // verify that querying for an earlier index returns nil + value, found = mvItem.GetLatestBeforeIndex(0) + require.False(t, found) + require.Nil(t, value) + + // assert that we STILL get the "one" value when querying with a later index + zero := []byte("zero") + mvItem.Set(0, zero) + // verify that querying for zero should ALWAYS return nil + value, found = mvItem.GetLatestBeforeIndex(0) + require.False(t, found) + require.Nil(t, value) + + value, found = mvItem.GetLatestBeforeIndex(2) + require.True(t, found) + require.True(t, value.IsEstimate()) + // verify we get zero when querying with index 1 + value, found = mvItem.GetLatestBeforeIndex(1) + require.True(t, found) + require.Equal(t, zero, value.Value()) + // reset one to no longer be an estiamte + mvItem.Set(1, one) + // we should see a deletion as the latest now, aka nil value and found == true, but index 4 still returns `one` + mvItem.Delete(4) + value, found = mvItem.GetLatestBeforeIndex(4) + require.True(t, found) + require.Equal(t, one, value.Value()) + // should get deletion item for a later index + value, found = mvItem.GetLatestBeforeIndex(5) + require.True(t, found) + require.True(t, value.IsDeleted()) + + // verify that we still read the proper underlying item for an older index + value, found = mvItem.GetLatestBeforeIndex(3) + require.True(t, found) + require.Equal(t, one, value.Value()) + + // Overwrite the deleted value with an estimate and verify we read it properly + mvItem.SetEstimate(4) + // also reads the four + value, found = mvItem.GetLatestBeforeIndex(6) + require.True(t, found) + require.True(t, value.IsEstimate()) + require.False(t, value.IsDeleted()) + // still reads the `one` + value, found = mvItem.GetLatestBeforeIndex(4) + require.True(t, found) + require.Equal(t, one, value.Value()) +}