Skip to content

feat(storage): Add PrefixDB to pkg internal/storage#4562

Merged
alesforz merged 29 commits intofeature/remove-cometbftdbfrom
alesforz/import-prefixdb
Dec 6, 2024
Merged

feat(storage): Add PrefixDB to pkg internal/storage#4562
alesforz merged 29 commits intofeature/remove-cometbftdbfrom
alesforz/import-prefixdb

Conversation

@alesforz
Copy link
Collaborator

@alesforz alesforz commented Nov 29, 2024

Partially addresses #4486.

Context

CometBFT will stop importing cometbft-db as a dependency to support multiple database backends. Instead, it will use pebble by default.

Changes

This PR adds type PrefixDB to internal/storage from cometbft-db.
PrefixDB is a database wrapper that implements theDB, Batch, and Iterator interfaces.

Note: this PR makes some changes to the implementation of PrefixDB. Namely:

  • PrefixDB no longer uses a mutex, because we think that it is sufficient to rely on the wrapped database's concurrency control mechanism. That is, concurrent calls to PrefixDB's methods will already "lock" on the calls to the wrapped DB methods.
  • NewPrefixDB now returns an error if the given prefix is empty. We think allowing the creation of a PrefixDB with no prefix was an oversight in the design.
  • prefixDBBatch methods now want a pointer receiver.
  • PrefixDB.Compact now prepends the prefix to start and end keys defining the iterator's range. The previous implementation did not do so, and this was likely an oversight. We believed this behavior contradicted the definition of a PrefixDB as a logical database that prepends the prefix to all keys before operating on the underlying database.

PR checklist

  • Tests written/updated
  • [ ] Changelog entry added in .changelog (we use unclog to manage our changelog)
  • Updated relevant documentation (docs/ or spec/) and code comments

Alessandro added 2 commits November 27, 2024 10:51
- `NewMemDB` to create an in-memory instance of pebble (for testing).
- `PrefixIterator` to create an iterator over keys that begin with a given prefix.
- `incrementBigEndian` to increase the value of a byte slice (read as a big-endian
   unsigned integer) by one.

if !source.Valid() || !bytes.HasPrefix(source.Key(), prefix) {
return itInvalid, nil
}
Copy link
Collaborator Author

@alesforz alesforz Nov 29, 2024

Choose a reason for hiding this comment

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

Why do we create this itInvalid object a few lines above? Since it's an invalid iterator, any method call on it will cause a panic, thus making it useless. Why not return an error here instead?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should return an error

Copy link
Collaborator Author

@alesforz alesforz Dec 6, 2024

Choose a reason for hiding this comment

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

After some testing, I now understand why itInvalid is created.
The current design allows the creation of an Iterator with the key range [nil:nil], which we interpret as "from the first to the last key in the database." However, there is a corner case: creating an Iterator with the range [nil:some_key] when there is only one key in the database (or only one key with the given prefix, i.e., prefix+some_key). Because the iterator's upper bound is exclusive, this results in an empty key range. There might be other corner cases that I haven't found, though.

In the case of Pebble (and possibly other databases), such a scenario leads to an invalid iterator state. According to Pebble's documentation, this situation produces an iterator that "has a non-exhausted internalIterator, but has reached a limit without any key for the caller." In other words, a call to Valid() will fail, and therefore the creation of the Iterator itself will fail.

I think cometbft’s code depends on newPrefixDBIterator returning an invalid iterator rather than an error. For example, in the evidence package, returning an invalid iterator that returns false on the first call to iterator.Valid() allows the related functions to proceed normally. They end up creating an empty Evidence slice, which cometbft interprets as "no evidence to broadcast." If we returned an error instead, these functions would fail, breaking the process.

This design is not ideal, but changing it would likely require revisiting a large portion of cometbft’s codebase.

@alesforz alesforz self-assigned this Nov 29, 2024
@alesforz alesforz added storage P:tech-debt Priority: Technical debt that needs to be paid off to enable us to move faster, reliably labels Nov 29, 2024
return
}

if bytes.Equal(it.source.Key(), it.prefix) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Is the purpose of these if statements to double-check that the current key of the wrapped Iterator in prefixDBIterator is correct?

We already call Iterator.Valid on the wrapped iterator to check the validity of the key. Assuming a prefixDBIterator is correctly initialized with the key range [prefix|start_key : prefix|end_key], in what scenarios are these additional checks needed?

For example, consider our implementation of the Pebble iterator pebbleDBIterator. Its Valid() method ensures that the iterator's current key is between start and end. Now, suppose we create a prefixDBIterator wrapping a pebbleDBIterator with prefix foo, start key a, and end key f. The key range of this iterator will be [fooa : fooe] (since the end is exclusive, foof won't be included). If I understand correctly, a call to pebbleDBIterator.Valid() should be sufficient to verify that the current key is valid, as any key outside [fooa : fooe] will be considered invalid.

Therefore, it seems that checking the prefix again in the prefixDBIterator methods might be unnecessary.

Returning to my initial question: Are we performing these additional checks to guard against third-party Iterator implementations that might not strictly enforce key validity and could return keys we should consider invalid? If not, what am I missing?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agree that it's not needed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

After some thinking, we have decided to leave these checks in place. It is very likely that the reason they are there is to guard against third-party Iterator implementations that might not strictly enforce key validity and could return keys we should consider invalid. Changing this requires some in-depth testing to make sure we aren't screwing up validation checks on the keys. We, unfortunately, don't have enough time to do it at the moment.

@alesforz alesforz marked this pull request as ready for review November 29, 2024 14:50
@alesforz alesforz requested a review from a team as a code owner November 29, 2024 14:50
@alesforz alesforz requested a review from a team November 29, 2024 14:50
@alesforz
Copy link
Collaborator Author

alesforz commented Nov 29, 2024

There might be a subtle bug in the validity check of prefixDBIterator. The construct we suggest to use the iterator:

for ; itr.Valid(); itr.Next() {
	key := itr.Key()
	value := itr.Value()
        // do something...
}

panics unexpectedly.
I'm investigating, but I think it's because when the iterator reaches the last key, here we check the iterator's validity, and it returns true (note that this function calls the wrapped iterator's Valid method under the hood). Then, on the next line, we call Next on the wrapped iterator. Next, calls Valid again (which returns true again) then moves the iterator to the next key.

After moving the iterator to the next key (which is beyond the last key of the iterator's range) we call Valid again, but this time it returns false and sets the wrapped iterator's isInvalid field to true. Now, when we call Key on what we believe is a valid iterator on the next line, it panics because the iterator is now invalid. Note that all of this happens within the wrapped iterator! The prefixDBIterator is unaware that the wrapped iterator has become invalid at this point.

Therefore, I think we need to make sure that prefixDBIterator marks itself as invalid as soon as the wrapped iterator becomes invalid. Otherwise, as in this bug, prefixDBIterator will continue iterating without realizing that the wrapped iterator is now invalid, leading to a panic.

In general, I think we should revisit the implementation of prefixDBIterator because its methods repeatedly call the Valid method of the wrapped iterator (four times for every call to Next, i.e., for every key the prefixDBIterator processes), which I think is unnecessary.

Alessandro added 3 commits December 2, 2024 11:29
The Iterator would panic after reaching the last key of its range.
The bug was caused by a naive refactoring of the `Valid` and `Next` methods
of `PrefixDB`.
@alesforz
Copy link
Collaborator Author

alesforz commented Dec 2, 2024

There might be a subtle bug in the validity check of prefixDBIterator. The construct we suggest to use the iterator panics unexpectedly...

Found and solved the problem. It was caused by a refactoring of the if statements.
Still, I think this point:

In general, I think we should revisit the implementation of prefixDBIterator because its methods repeatedly call the Valid method of the wrapped iterator (four times for every call to Next, i.e., for every key the prefixDBIterator processes), which I think is unnecessary.

is something we should consider.

@alesforz
Copy link
Collaborator Author

alesforz commented Dec 3, 2024

Are there any particular reasons why for prefixDBBatch we use a value receiver instead of a pointer, as we do for pebbleDBBatch?

@alesforz alesforz mentioned this pull request Dec 3, 2024
9 tasks
This will maintain compatibility with cometbft-db.
Copy link
Collaborator

@melekes melekes left a comment

Choose a reason for hiding this comment

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

great work @alesforz 👍


if !source.Valid() || !bytes.HasPrefix(source.Key(), prefix) {
return itInvalid, nil
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should return an error

return
}

if bytes.Equal(it.source.Key(), it.prefix) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Agree that it's not needed.

if err != nil {
return nil, fmt.Errorf("prefixed DB namespace get: %w", err)
}
return value, nil
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should rethink the policy of ignoring the case when the key is not found. We'll discuss synchronously, but as we are now using only one DB we can check whether this still makes sense.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We are already ignoring that case. If pebble returns a pebble.ErrNotFound error, we ignore it and return an empty slice. CometBFT's code always checks if a len(returnedSlice) == 0 to see if the key was found.

Alessandro Sforzin added 8 commits December 5, 2024 17:02
It is sufficient to rely on the wrapped database's concurrency control mechanism.
That is, concurrent calls to PrefixDB's methods will already "lock" on the calls
to the wrapped DB methods.
Additionally, pebble has conconcurrency control mechanisms of its own.
…end`.

The previous code did not do so, and that was an oversight.
…s invalid.

Previously, it returne an invalid `prefixDBIterator`, that is, an iterator that
would panic on the next method call, thus making it useless.
@alesforz
Copy link
Collaborator Author

alesforz commented Dec 6, 2024

Are there any particular reasons why for prefixDBBatch we use a value receiver instead of a pointer, as we do for pebbleDBBatch?

Apparently an oversight, changed the receiver to a pointer.

@alesforz alesforz merged commit 287d09a into feature/remove-cometbftdb Dec 6, 2024
@alesforz alesforz deleted the alesforz/import-prefixdb branch December 6, 2024 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P:tech-debt Priority: Technical debt that needs to be paid off to enable us to move faster, reliably storage

Projects

No open projects
Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants