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

Versioned Storage for Modifications History #2233

Open
wants to merge 26 commits into
base: master
Choose a base branch
from

Conversation

acerone85
Copy link
Contributor

@acerone85 acerone85 commented Sep 20, 2024

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

  • Breaking changes are clearly marked as such in the PR description and changelog
  • New behavior is reflected in tests
  • The specification matches the implemented behavior (link update PR if changes are needed)

Before requesting review

  • I have reviewed the code myself
  • I have created follow-up issues caused by this PR and linked them here

After merging, notify other teams

[Add or remove entries as needed]

@acerone85 acerone85 marked this pull request as draft September 23, 2024 08:57
@acerone85 acerone85 self-assigned this Sep 23, 2024
@acerone85 acerone85 force-pushed the feature/version-modifications-history branch 3 times, most recently from ee8edeb to ffae32a Compare September 26, 2024 08:24
@acerone85 acerone85 changed the title WIP - Versioned Storage for Modifications History Versioned Storage for Modifications History Sep 26, 2024
@acerone85 acerone85 marked this pull request as ready for review September 26, 2024 12:32
@acerone85 acerone85 requested a review from a team September 26, 2024 21:50
crates/fuel-core/src/state/historical_rocksdb.rs Outdated Show resolved Hide resolved
crates/fuel-core/src/state/historical_rocksdb.rs Outdated Show resolved Hide resolved
crates/fuel-core/src/state/historical_rocksdb.rs Outdated Show resolved Hide resolved
@@ -300,12 +355,95 @@ where

Ok(())
}

/// Migrates a ModificationHistory key-value pair from V1 to V2.
/// The migration fails if other
Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed: 87ace1b. Although I'll wait also for @xgreenx to check if my comment is correct, because from the code it does not look like we use the ConflictPolicy::Fail flag when writing directly to rocksDB.

crates/fuel-core/src/state/historical_rocksdb.rs Outdated Show resolved Hide resolved
// `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>(
Copy link
Contributor

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>>,

Copy link
Contributor Author

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.

Copy link
Contributor

@rafal-ch rafal-ch Oct 2, 2024

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.

Copy link
Contributor Author

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

@acerone85 acerone85 force-pushed the feature/version-modifications-history branch from 910186c to fe3e453 Compare September 30, 2024 15:55
Copy link
Collaborator

@xgreenx xgreenx left a 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,
Copy link
Collaborator

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).

Copy link
Contributor Author

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,
Copy link
Collaborator

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

Copy link
Contributor Author

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.

@acerone85 acerone85 force-pushed the feature/version-modifications-history branch 3 times, most recently from f787cf6 to 308c34a Compare October 3, 2024 15:57
@acerone85 acerone85 force-pushed the feature/version-modifications-history branch from 308c34a to 3c9eff4 Compare October 3, 2024 16:45
@acerone85
Copy link
Contributor Author

It also would be nice to have a tests that inserts 1000 blocks, and tries to rollback them by calling HistoricalRocksDB::rollback_last_block

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",
Copy link
Contributor

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.
Copy link
Contributor

Choose a reason for hiding this comment

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

A nit:

Suggested change
// 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

crates/fuel-core/src/state/historical_rocksdb.rs Outdated Show resolved Hide resolved
crates/fuel-core/src/state/historical_rocksdb.rs Outdated Show resolved Hide resolved
type Blueprint = Plain<Primitive<8>, Postcard>;
type Column = Column<Description>;

// We store key-value pairs using the same column family as for V1
Copy link
Contributor

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.

Copy link
Contributor Author

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

Copy link
Contributor

@AurelienFT AurelienFT left a 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

@acerone85 acerone85 force-pushed the feature/version-modifications-history branch from d552e7c to 9f050e5 Compare October 29, 2024 16:49
CHANGELOG.md Outdated
Comment on lines 104 to 105
### Changed

Copy link
Collaborator

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.
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 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.

Copy link
Contributor Author

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.

Copy link
Collaborator

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)

Copy link
Contributor Author

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

Comment on lines 384 to 385
let (cumulative_changes, maybe_cumulative_changes_guard) =
self.take_migration_changes();
Copy link
Collaborator

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.

Copy link
Contributor Author

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.

Copy link
Collaborator

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).

Copy link
Contributor Author

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.

Copy link
Contributor Author

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<()> {
Copy link
Collaborator

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).

Copy link
Contributor Author

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.

Copy link
Collaborator

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

@acerone85 acerone85 force-pushed the feature/version-modifications-history branch from 5e36072 to df975db Compare October 30, 2024 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants