Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: Spec on current cachekv implementation #13977

Merged
merged 10 commits into from
Dec 28, 2022
146 changes: 146 additions & 0 deletions store/cachekv/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# CacheKVStore specification

A `CacheKVStore` is cache wrapper for a `KVStore`. It extends the operations of the `KVStore` to work with a cache, allowing for reduced I/O operations and more efficient disposing of changes (e.g. after processing a failed transaction).

dangush marked this conversation as resolved.
Show resolved Hide resolved
## Types and Structs

---

```go
type Store struct {
mtx sync.Mutex
cache map[string]*cValue
deleted map[string]struct{}
unsortedCache map[string]struct{}
sortedCache *dbm.MemDB // always ascending sorted
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just pointing out that #13881 replaces this with a BTree.

parent types.KVStore
}
```

The Store struct wraps the underlying `KVStore` (referred to as `parent`) with additional data structures for implementing the cache. Mutex is needed as IAVL trees (the `KVStore` in application) are not safe for concurrent use.

### `cache`

The main mapping of key-value pairs stored in cache. This map contains both keys that are cached from read operations as well as ‘dirty’ keys which map to a value that is different than what is in the underlying `KVStore`.
dangush marked this conversation as resolved.
Show resolved Hide resolved

Values that are mapped to in the `cache` map are wrapped in a `cValue` struct, which contains the value and a boolean flag (`dirty`) representing whether the value differs from the `parent`.

```go
type cValue struct {
value []byte
dirty bool
}
```

### `deleted`

Key-value pairs that are to be deleted from the `parent`are stored in the `deleted` map. Keys are mapped to an empty struct to implement a set.

### `unsortedCache`

Similar to`deleted`, this is a set of keys that are dirty and will need to be updated in the `KVStore` upon a write. Keys are mapped to an empty struct to implement a set.

### `sortedCache`

A database that will be populated by the keys in `unsortedCache` during iteration over the cache. Keys are always inserted in sorted order.
dangush marked this conversation as resolved.
Show resolved Hide resolved

## CRUD Operations and Writing

---

The `Set`, `Get`, and `Delete` functions all reference `setCacheValue()` , which is the only entrypoint to mutating the `cache` map.

`setCacheValue()` is defined as follows:

```go
func (store *Store) setCacheValue(key, value []byte, deleted bool, dirty bool) {
keyStr := conv.UnsafeBytesToStr(key)
store.cache[keyStr] = &cValue{
value: value,
dirty: dirty,
}
dangush marked this conversation as resolved.
Show resolved Hide resolved
if deleted {
store.deleted[keyStr] = struct{}{}
} else {
delete(store.deleted, keyStr)
}
if dirty {
store.unsortedCache[keyStr] = struct{}{}
}
}
```

`setCacheValue()` inserts a key-value pair into the `cache` map. Two boolean parameters, `deleted` and `dirty`, are passed in to flag whether the inserted key should also be inserted into the `deleted` and `dirty` sets.

### `Get`

`Get` first attempts to return the value from the `cache` map. If the key does not exist in `cache`, gets the value from `store.parent` instead and calls `setCacheValue()` with `deleted=false` and `dirty=false`.

### `Set`

Calls `setCacheValue()` with `deleted=false` and `dirty=true`.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a short description, similar to the one for Delete below?


### `Delete`

A value being deleted from the `KVStore` is represented with a `nil` value in the `cache` map, and an insertion of the key into the `deleted` map.

Calls `setCacheValue` with `deleted=true` and `dirty=true`.

### `Write`

Values in the `cache` are written to `parent` in ascending order of their keys.

A slice of all dirty keys in the `cache` is made, then sorted in increasing order. These keys are then iterated over to update `parent`.

If a key is marked for deletion (checked with `isDeleted()`), then the `parent.Delete` is called. Otherwise, `parent.Set` is called to update the underlying `KVStore` with the value in cache.

## Iteration

---

Efficient iteration over keys in the `KVStore` is important for efficiently generating Merkle range proofs.

To iterate over the `CacheKVStore`, `iterate()` must iterate over a merged set of keys from the parent `KVStore` and cache.

`[cacheMergeIterator](https://github.com/cosmos/cosmos-sdk/blob/d8391cb6796d770b02448bee70b865d824e43449/store/cachekv/mergeiterator.go)` implements functions to provide a single iterator with an input of iterators over`parent` and `cache`. This iterator iterates over keys from both iterators in a shared lexicological order, and overrides the value provided by the parent iterator if the same key is dirty or deleted in the cache.

### Implementation Overview

The iterators over the `parent` and `cache` are generated and passed into `cacheMergeIterator`, which is the iterator that is returned. Implementation of the `parent` iterator is up to the underlying `KVStore` . The rest of the implementation details here will cover the generation of the `cache` iterator.

Generating the cache iterator can be decomposed into four parts:
dangush marked this conversation as resolved.
Show resolved Hide resolved

1. Finding all keys that exist in the range we are iterating over
2. Sorting this list of keys
3. Inserting these keys into the `sortedCache` and removing them from the `unsortedCache`
4. Returning an iterator over `sortedCache` with the desired range

Currently, the implementation for the first two parts is split into two cases, depending on the size of the unsorted cache. The two cases are as follows.

If the size of the `unsortedCache` is less than `minSortSize` (currently 1024), a linear time approach is taken to search over keys.

```go
n := len(store.unsortedCache)
unsorted := make([]*kv.Pair, 0)

if n < minSortSize {
for key := range store.unsortedCache {
if dbm.IsKeyInDomain(conv.UnsafeStrToBytes(key), start, end) {
cacheValue := store.cache[key]
unsorted = append(unsorted, &kv.Pair{Key: []byte(key), Value: cacheValue.value})
}
}
store.clearUnsortedCacheSubset(unsorted, stateUnsorted)
return
}
```

Here, we iterate through all the keys in the `unsortedCache`, collecting them in a slice called `unsorted`.

At this point, part 3. is achieved in`clearUnsortedCacheSubset()`. This function iterates through `unsorted`, removing each key from `unsortedCache` . Afterwards, the`unsorted` slice is sorted. Lastly, it iterates through the key-pairs in the now sorted slice, setting any key meant to be deleted to map to an arbitrary value (`[]byte{}`).

In the case that the size of `unsortedCache` is larger than `minSortSize` , a linear time approach to finding keys within the desired range is too slow to use. Instead, a slice of all keys in `unsortedCache` is sorted, and binary search is used to find the beginning and ending indices of the desired range. This produces an already-sorted slice that is passed into the same `clearUnsortedCacheSubset()` function. An iota identifier (`sortedState`) is used to skip the sorting step in the function.

Finally, part 4. is achieved with `memIterator`, which implements an iterator over the `sortedCache` items.

As of [PR#12885](https://github.com/cosmos/cosmos-sdk/pull/12885), an optimization to the binary search case mitigates the overhead of sorting the entirety of the key set in `unsortedCache`. To avoid wasting the compute spent sorting, we should ensure that a reasonable amount of values are removed from `unsortedCache`. If the length of the range for iteration is less than `minSortedCache`, we widen the range of values for removal from `unsortedCache` to be up to `minSortedCache` in length. This amortizes the cost of processing elements across multiple calls.