-
Notifications
You must be signed in to change notification settings - Fork 2.8k
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
Versioned Storage for Modifications History #2233
base: master
Are you sure you want to change the base?
Conversation
ee8edeb
to
ffae32a
Compare
@@ -300,12 +355,95 @@ where | |||
|
|||
Ok(()) | |||
} | |||
|
|||
/// Migrates a ModificationHistory key-value pair from V1 to V2. | |||
/// The migration fails if other |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like an incomplete comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// `ModificationsHistoryV1` and return it if no value for `ModificationsHistoryV2` | ||
// was found. This is necessary to avoid scenarios where it is possible to | ||
// roll back twice to the same block height | ||
fn multiversion_take<Description>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
multiversion_take()
and multiversion_replace()
are very similar, please consider some deduplication. We should be able to provide, let's say, multiversion_op()
which also takes a generic F
argument.
Then, on the callsite we can use something like this:
let old_changes = multiversion_op(
storage_transaction,
height_u64,
self.migration_in_progress,
|storage| storage.replace(&height_u64, &reverse_changes),
)?;
or
let last_changes = multiversion_op(
&mut storage_transaction,
height_to_rollback,
self.migration_in_progress,
|storage| storage.take(&height_to_rollback),
)?
.ok_or(not_found!(ModificationsHistoryV1<Description>))?;
However, due to the complex types, the definition of F
may become a mess, so I'm leaving it up to you if this is worth changing.
F: FnOnce(
StorageMut<
'_,
StructuredStorage<
InMemoryTransaction<&rocks_db::RocksDb<Historical<Description>>>,
>,
ModificationsHistoryV2<Description>,
>,
) -> StorageResult<Option<Changes>>,
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tried, the signature itself is not a problem, but I spent quite a bit of time fighitng with lifetimes and eventually gave up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please see here how I imagined it :)
#2277
But again, up to you. Feel free to close the PR and leave it implemented with two separate functions if you find it more convenient.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, I had a look :)
I changed this PR quite a lot, so still fighting with typechecking when trying to rebase.
But I definitely like it how you have done it
crates/fuel-core/src/state/historical_rocksdb/modifications_history.rs
Outdated
Show resolved
Hide resolved
910186c
to
fe3e453
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It also would be nice to have a tests that inserts 1000 blocks, and tries to rollback them by calling HistoricalRocksDB::rollback_last_block
) -> StorageResult<()> { | ||
let mut migration_transaction = StorageTransaction::transaction( | ||
&self.db, | ||
ConflictPolicy::Fail, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Conflict policy works only when you try to commit one in-memory transaction into another. It doesn't work with the Rocksdb.
The migration process should accumulate changes in the memory and dump them inside of the commit_changes
function(when upper services like block importer or relayer call this function).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. In the latest version of the MR I switched to having cumulative changes in the rocksDB state, and I handle race conditions by locking on them for the whole duration of the migration/rollback/commit_changes function. Unfortunately I cannot use more fine-grained guards because of potential consistency issues (explanation in the comments).
@@ -94,6 +103,7 @@ where | |||
Ok(Self { | |||
state_rewind_policy, | |||
db, | |||
modifications_history_migration_in_progress: true, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be nice to add logic that checks do we have any entries in the V1. If not, set this to false
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the latest version of the PR does not have this check anymore, and instead we check directly if we are able to fetch at least one change for the v1 history.
f787cf6
to
308c34a
Compare
308c34a
to
3c9eff4
Compare
Done. There is a new test that checks that commits are rollback consistently to the latest height, when we use V2. 0d2c490 |
Cargo.lock
Outdated
@@ -735,7 +735,7 @@ dependencies = [ | |||
"percent-encoding", | |||
"pin-project-lite", | |||
"tracing", | |||
"uuid 1.10.0", | |||
"uuid 1.11.0", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I learned here that we don't usually update dependencies in an arbitrary PR.
// This is to avoid a scenario where the migration changes overwrites changes to the modification history | ||
// coming from other transactions. | ||
// If we were to release the lock as soon as we take the cumulative changes, and then acquiring it again when | ||
// writing the updatet set of changes, then we could have the following schedule of events. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A nit:
// writing the updatet set of changes, then we could have the following schedule of events. | |
// writing the updated set of changes, then we could have the following schedule of events. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
type Blueprint = Plain<Primitive<8>, Postcard>; | ||
type Column = Column<Description>; | ||
|
||
// We store key-value pairs using the same column family as for V1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit confused here, since I see that the column is different, not the same.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, stale comment. Removed in f162887
crates/fuel-core/src/state/historical_rocksdb/modifications_history.rs
Outdated
Show resolved
Hide resolved
crates/fuel-core/src/state/historical_rocksdb/modifications_history.rs
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with some comments of rafal and added some
d552e7c
to
9f050e5
Compare
CHANGELOG.md
Outdated
### Changed | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you return it back, please?=)
@@ -441,15 +717,58 @@ where | |||
height: Option<Description::Height>, | |||
changes: Changes, | |||
) -> StorageResult<()> { | |||
let mut storage_transaction = | |||
StorageTransaction::transaction(&self.db, ConflictPolicy::Overwrite, changes); | |||
// cumulative_changes_lock_guard is defined to be Some only when the migration is in progress. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the current solution is overly complicated. We shouldn't be afraid of losing the migration state. The migration process just should re-migrate lost migration later.
In this case, this function will be simple and only insert migration into final changes.
I think just returning Option<Changes>
should be enough without lock.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For clarity: do you mean the function take_migration_changes
should return only Option<Changes>
?
If we do not care about keeping the migration changes I can remove keeping the lock guard here.
I also don't need to readd the migration changes later on.
Note that this works under the assumption that commit_changes
always commit changes at a height
that is not present already in the underlying db, so I want to validate this first. If this is not the case, then there is the risk of having a stale version from a migration overwriting the newer version that has been written into the DB by commit_changes
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do you mean the function take_migration_changes should return only Option?
Yes, it seems should be enough.
Note that this works under the assumption that commit_changes always commit changes at a height that is not present already in the underlying db, so I want to validate this first. If this is not the case, then there is the risk of having a stale version from a migration overwriting the newer version that has been written into the DB by commit_changes.
Why we can't apply changes from the store_modifications_history
on top of the changes from migration? In this case we will not lose changes from the store_modifications_history
.
If you worry that later migration will override modification history, then it shouldn't happen because you migrate only v1 entries, while store_modifications_history
uses v2. Only in the case of rollback can you face the situation where migration with old modifications can override new ones. But as I mentioned in another comment, we can handle this situation just during the migration process by tracking what is the maximum height of the migration(and decreasing this height during the rollback event)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why we can't apply changes from the store_modifications_history on top of the changes from migration? In this case we will not lose changes from the store_modifications_history.
The scenario I was concerned about goes as follows (As I mention this can happen only if we commit twice at the same height, which does not seem something that can happen without rollbacking first)
- You start the function to commit at a height for which there is a V1 key present already, with a different set of changes. This function takes the migration changes to commit, and find that they are empty
- You migrate the same key from V1 to V2. The changes (which are different from the ones from V1) are persisted in memory
- The commit transaction commits into rocksDB. Later on, a second commit transaction at a different height gets the stale migration changes that have been persisted in memory.
So if the following above cannot happen then I am convinced that it should be fine to not keep the lock for long
let (cumulative_changes, maybe_cumulative_changes_guard) = | ||
self.take_migration_changes(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you elaborate more on why we need to change how rollback_block_to
should work?
How migration is going shouldn't affect the work of the rollback_block_to
method. Migration may have a state higher than the chain's height (after rollback), and it can be a problem. But we can handle this case in the commit_changes
function and remove modifications that are higher than blockchain height.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I follow only partially here. I was under the impression that rollback_block_to
reverts all the changes from the current height to the one specified in the function, but this does not seem to be the case. This leaves me with the question: why is it okay to revert only the changes at the block height specified in input? Do we use this function only to rollback to the last block (which seems to be the case from the codebase)?
Regarding your question, my flow of thought is as follows: Migration changes are piggybacked into transactions whenever they are committed to the rocksDB instance. We do this in two places: rollback_block_to
and <HistoricalRocksDB as TransactableStorage>::commit_changes
. In the first case we compute the last_changes by taking into account the migration changes from the in memory transaction, so even if the key for that height is being migrated from V1 to V2, the migrated changes will be captured in last_changes
and removed. Please let me know if i am missing something.
If the cumulative changes were not flushed here, they would be kept in memory and flush when we later commit a block. Now we could have a scenario where we rollbacked a block several time, but we did not commit the migration changes for blocks at a higher height.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This leaves me with the question: why is it okay to revert only the changes at the block height specified in input? Do we use this function only to rollback to the last block (which seems to be the case from the codebase)?
It is a temporary workaround because rollback_last_block
doesn't work as expected.
Migration changes are piggybacked into transactions whenever they are committed to the rocksDB instance.
For us it will be enough to commit changes from the migration only in commit_changes
.
If the cumulative changes were not flushed here, they would be kept in memory and flush when we later commit a block. Now we could have a scenario where we rollbacked a block several time, but we did not commit the migration changes for blocks at a higher height.
I don't see any problems in keeping them in the memory until we start importing new blocks(it is okay if we need to rollback a lot of blocks. Usually it is how we use rollback_block_to
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see any problems in keeping them in the memory until we start importing new blocks(it is okay if we need to rollback a lot of blocks. Usually it is how we use rollback_block_to).
My main concern in this case is a scenario that would go as follows (note that unless I have misunderstood the rollback and commit functions, this can happen):
- Say the max height of the modification history is 100. We rollback the last block 50 times, using a strategy where we do not commit the migration changes in rocksDB.
- During this time the migration process for all blocks completes and the changes are persisted in memory,
- The next time the changes for a block height are committed, all the migration changes (including those at height > 50) will be committed, leaving the DB in an inconsistent state.
You mentioned to keep track of the maximum height of the migration, but if rollbacks are not frequent a simpler alternative would be to wipe all migration changes before committing a transaction. Though this would require the lock to be kept while the commit to rocksDB happens to avoid data races.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Or we can simply wipe all migration changes higher than the block height to be committed, when committing)
/// are simply recorded, and will be flushed to the database when it | ||
/// commits or rollbacks to a new height. | ||
#[cfg(test)] | ||
fn migrate_modifications_history_at_height(&self, height: u64) -> StorageResult<()> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Side question: Why do we want to implement a function in this PR? It can be part of the migration PR.
I think the migration process should work with the iterator of the historical modification table instead of fetching each modification height by height. In this case, you can easily fetch butch of modifications up to some limit, migrate them, and store them in one Changes
type inside of the migration_changes
by just locking it once. Then the migration process can wait until we take changes from migration_changes
during commit_changes
, and continue after.
With this solution, you only lock the migration_changes
changes once for very fast operation. Plus, the iteration over the storage will be much faster(and it will work even if we do rollback or alter the history somehow because while you are holding an iterator over RocksDB, it will not lose the data).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. I can make the change, although probably it makes sense to do it in the migration PR.
Btw in this PR that function is only for testing purposes, so if that's okay I would keep it and change the migration process using the rocksDB iterator in the follow-up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems this function is only used inside of the migrate_modifications_history_works
tests that is dedicated to this function=D I think it is better to test in the PR where we really plan to have final migration
5e36072
to
df975db
Compare
Linked Issues/PRs
Related to #2095
Description
Adds a ModificationsHistoryV2 column in the Historical RocksDB, with keys encoded in big endian
Adds a flag to the historical RocksDB that keeps track whether the migration is still in progress
Change HistoricalRocksDB to alway insert historical changes from V2 table. When the migration is in progress, delete key-value pairs from both V2 and V2, read key-value pairs from V2 falling back to V1 if no value in V2 is found
#TODO (either this or next MR):
Checklist
Before requesting review
After merging, notify other teams
[Add or remove entries as needed]