-
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
State Rewind #451
Comments
Blockchain database growth is the one of major problems in space that yet needs to be solved. This is extreme example where history and every change set is saved in eth This is data footprint of eth prunned node where historic blocks are all saved but history change is saved for (i think OE is like last ~100 block while geth has like ~1000 or something, it is configurable not sure what is set in etherscan): https://etherscan.io/chartsync/chaindefault Current state of art database model in ETH is erigons where they managed to shrink 10tb of history model to 1-2tb: https://github.com/ledgerwatch/erigon/blob/devel/docs/programmers_guide/db_walkthrough.MD and eth harddrives are all NVMe SSD as it is needed to keep up with speed of read/writes when execution blocks. This is probably going to be mitigated a little bit as we don't have one state root but on other hand our transactions are a lot bigger than ethereum's. Having write/delete log will do the job of having ability to rewind and do reorg on blocks but we will not have ability to execute contract that are set in past and get debugger logs for example. Freezing db is new thing here for me, if reorg (re organization of blocks) happens this need to be atomic as until reorg is confirmed we still need to take new block and after it is confirmed our newest best block become invalid and everything will need to pause and revert back to block before invalid one. There is no need to include new blocks to invalid chain head as it has invalid state and our new valid state and new block head is in past. |
Are there any storage concerns to keep mind now when writing this? I know keeping state as minimal is necessary for decentralization, but not sure if there is anything specific I should do with this? |
This can be deferred to a follow-up task, but we should have the ability to configure state diff pruning based on the ivg challenge window (e.g. 1-2 weeks), as we don't need to keep these diffs for proving past that point.
It's likely there are other efficient ways to accomplish this, I'm open to alternate suggestions. Given our current structure, the in-memory overlay will pass through reads to the underlying datastore when retrieving any keys it hasn't encountered from a diff yet. If the underlying datastore gets updated (for any valid reason), this could cause issues for the overlay as it may return an unexpected value if a key-value was changed in the underlying datastore that wasn't part of any diffs. Here's a diagram example:
Because there could be a very large amount of potential keys, we would passthrough to the underlying db for anything that isn't cached in-memory from the diff. However, in this case the rewind view would return 3 instead of 1 for Another approach could be to suffix all keys by block number, and whenever retrieving older values, we leverage the lexicographic ordering of keys in rocksdb to fetch the latest key before the block number we care about. The benefit is then the rewind op would be O(1) reads from our perspective, only touch the keys it needs to, and we don't have to perform as much blob based serialization of diffs. While it would duplicate every key value in the same table, I don't think it would be much different than storing diff blobs. |
Very good diagram! I was gunning this use case, when we want to revert blocks because a block in history got reverted by IGV and in that case Is this a question about having the ability to re-execute past block to generate IGV if needed? In this case I am not sure if this is needed in side-chain as we will not vote on blocks we dont want to commit to contract, fuel consensus gives us proof the block is okay, and in case of manual override we just need to review old state and first use case comes in play. "but we will not have ability to execute contract that are set in past and get debugger logs for example." rewind as batches does not allow us to this as you pointed new block can override the db state. rockdb way of snapshot I feel this could be a trap for us as the whole DB is going to be snapshotted and it would be better to go for a universal path forward, but it can probably work. How most eth clients do this and have the ability to execute old blocks, is they cross-index the data by block change so you can easily jump to past and have the ability to append new block without disrupting past index. In our case, UTXO are not a problem but contract storage that is changeable and harder to track. I will need some time to extract and spec out how it is done exactly, but before that I will need to finish relayer relayed stuff. |
I was planning on building out a branch with state rewind as initially described by @Voxelot using a new DB column to store changes from a previous db snapshot (which would be configurable based on last snapshot so only 7 extra days of data is stored), but it sounds like you have a different design in mind @rakita you want some more time to spec out? Do you want me to continue with the initial design or would you prefer I tackle some other issues while you work out a different design? |
The initial design will need to be replaced in future. And we don't have an instant benefit to rush it now, so it is better to do it right from the start. It is best to wait to spec this out, i will need to dig around how it is done in OE, and if we go by erigon design that would possibly mean better data layout (smaller db size) but it needs a rework on how contract storage is saved. |
One interesting data point about rocksdb snapshots: Every standard iterator in rocksdb uses a snapshot on creation by default, so it's something we already use in many places today implicitly. This is to ensure the iterator results are deterministic and don't change as new data comes in (which is similar to our concern here). https://github.com/facebook/rocksdb/wiki/Iterator#consistent-view Appending a block height suffix on all of our keys would provide us with an simple data model to jump around in time. It would also simplify other issues such as storing state diffs of imported unfinalized blocks, as we could easily verify a sequence of unfinalized blocks that build on each other without complicated diff management. The performance downside of this approach that may outweigh this data model, is that every |
I didnt know about this beforehand, but it lead me to search why it is in that way, and i finaly got it why erigon didn't use rocksdb/levelsdb. Good initial read on Serializability: https://vladmihalcea.com/serializability/
Snapshot isolation that is needed for iteration and when we want to read data, rocksdb seems like it did good job: https://cockroachlabs.com/blog/cockroachdb-on-rocksd/#rocksdb-snapshots Multiversion concurrency control (MVCC) seems better in general as it gives us better snapshot isolation. On Tree indexing from same link:
B-tree would be preferable for us as we can expect a lot more read's than writes. And for this task: Here is how to get that with storage indexing: |
The TikV docs describe their motivations for using RocksDB delve into the LSM vs Btree issue a bit more in depth: https://tikv.github.io/deep-dive-tikv/key-value-engine/B-Tree-vs-Log-Structured-Merge-Tree.html Tl;dr they concluded it's easier to speed up reads via traditional methods like caching and additional indexing layers on LSM, than it is to improve the write performance of a btree on disk. Interestingly, TikV also achieved MVCC in RocksDB by using "Prefix Bloom Filters" and suffixing each key with a timestamp. Similar to how I suggested suffixing each key with a block height earlier. They also made some low-level RocksDB customizations to do cleanup on old key versions (which we'd like to do after the 2 week fraud proving window). As far as serializability, RocksDB guarantees that once data is committed to the WAL, it will be available and consistent. The DB comparison from Erigion saying RocksDB is brittle/unstable due to a power loss sounds a little bit like baloney to me. Jepsen found issues like this while verifying tendermint on LevelDB, but assuming you wait for While it's very possible something like LMDB/MDBX could be faster, or at least Erigon/MDBX has proven it's faster than Geth using LevelDB, there are still a lot of unknowns given our unique setup. For example, MDBX heavily uses mmap'ing which could have other negative consequences in highly concurrent environments. RocksDB has the most features and support and should be more than sufficient to deliver a quality product (at the very least we shouldn't be any slower or have any more issues than Substrate, Near or Solana which all use RocksDB). It's not worth the risk to consider switching DB's at this time until we have substantial motivations to beyond MDBX-sponsored rumors and benchmark theatrics. Currently, we don't make use of the built-in RocksDB transaction API. However, RocksDB has a lot of robust and powerful APIs such as snapshot and AtomicBatchWrite which makes it possible to implement your own custom transactional behaviors (optimistic vs pessimistic) at the application level. Most databases don't provide this kind of flexibility. This allows us to fine-tune our locking behavior based on the context, e.g. we could even do some hybrid scheme of globally pessimistic vs locally optimistic at the contract level. Back to our original discussion around modeling. The indexing for diff blobs was a missing piece in the original proposal here, and definitely seems like a good idea. Although, it might be worth looking more into the suffix approach that TikV used for MVCC, as it could be radically more space and query time efficient for time traversal than storing and indexing diff blobs. |
Oh TikV link seems like great info, thank you for that, will read it later as i feel like I am still missing some knowladge on databases. I think MDBX has ACIS on file system level, while rocksdb does that in memory or with WAL, so in that side MDBX seems better. As it is written on github it allow's it: I think choosing RocksDB is a right call, as it is used by a lot of projects as primary database storage. I just wanted to share info about alternatives as it was interesting to me to investigate (scratch a surface) a litle bit. I created issue #496 that contains all needed changes to support new path that we are taking, as it requires more changes to current columns, and the diff is now specified in more detail. |
Closes #1549 ## Overview The change extracts the off-chain-related logic from the executor and moves it to the GraphQL off-chain worker. It creates two new concepts - Off-chain and On-chain databases where the GraphQL worker has exclusive ownership of the database and may modify it without intersecting with the On-chain database. ## Challenges caused by the change Delegating updating of the state to something other than `BlockImporter` causes several new problems: - The commitment to the on-chain and off-chain databases is done in different places. The off-chain database may be out of sync with the on-chain database due to race conditions. - The result of the block execution(receipts, statuses) is not stored anywhere and may be lost due to emergency shutdown. We don't want to duplicate on-chain data inside of the off-chain database, so the GraphQL service works with two sources of data, which leads to two problems: - The off-chain database may be out of sync with the on-chain database due to race conditions causing failing requests. - The view of the databases during the GraphQL request may change, causing invalid responses with a mix of old and new data. We had this problem before, but now it is more critical. ## Solutions to the challenges ### Out of sync The change applies two steps to solve this issue. The main one is a new trait for the database: ```rust /// Provides a view of the storage at the given height. /// It guarantees to be atomic, meaning the view is immutable to outside modifications. pub trait AtomicView<View>: Send + Sync { /// Returns the view of the storage at the given `height`. fn view_at(&self, height: BlockHeight) -> StorageResult<View>; /// Returns the view of the storage for the latest block height. fn latest_view(&self) -> View; } ``` Another one to await on the `BlockCommiter` side finishing processing the `ImportResult` by all listeners. The goal of the trait is to provide an immutable read-only view of the database at a specific time. However, this trait has not yet been implemented properly during this PR and will be implemented in the following PRs. The `view_at` requires functionality from #451. We already can implement the `latest_view` method via [`RocksDB::Transaction`](https://github.com/facebook/rocksdb/wiki/Transactions#reading-from-a-transaction), but it is better to do it after merging #1576. Waiting on the `BlockImporter` side is a temporary solution to not escalate the problem. But maybe we can keep it later to guarantee the consistent state of the blockchain. ### Losing result of execution The `AtomicView` trait also solves the issue of losing the state of the execution because it is possible to get a view of the database at a specific block height and execute the block again receiving the same execution result. Waiting inside the `BlockImporter` guarantees that we will not lose more than one `ImportResult`. ### Inconsistent database view within GraphQL requests The GraphQL now has `ReadDatabase`: ```rust pub type OnChainView = Arc<dyn OnChainDatabase>; pub type OffChainView = Arc<dyn OffChainDatabase>; pub struct ReadDatabase { on_chain: Box<dyn AtomicView<OnChainView>>, off_chain: Box<dyn AtomicView<OffChainView>>, } ``` It implements the `view` method that returns the `ReadView` type. The `ReadView` implements all required methods by using internal on-chain view and off-chain view. The `AtomicView` allows us to get the `last_view` of the off-chain database and get the `view_at(off_chain_view.last_height())` of the on-chain database creating a consistent view for both databases at a specific height. The change also adds a `ViewExtension` to the GraphQL that creates a `ReadView` for each request. ```rust /// The extension that adds the `ReadView` to the request context. /// It guarantees that the request works with the one view of the database, /// and external database modification cannot affect the result. struct ViewExtension; #[async_trait::async_trait] impl Extension for ViewExtension { async fn prepare_request( &self, ctx: &ExtensionContext<'_>, request: Request, next: NextPrepareRequest<'_>, ) -> ServerResult<Request> { let database: &ReadDatabase = ctx.data_unchecked(); let view = database.view(); let request = request.data(view); next.run(ctx, request).await } } ``` ## Implementation details - The `ExecutionResult` now also has receipts for the transaction along with its status. The off-chain worker will insert them later into the database, while the `dry_run` can fetch them immediately. - All API requests now work with the `ReadView` instead of the `Database` type. The `ReadDatabase` is only used in one place in the `ViewExtension`. - The `BlockImpoerter::comit_result` now is `async` and awaits for the previous block to be processed by all listeners. The execution of the `execute_and_commit` now runs `verify_and_execute_block` in the spawned task in the `tokio_rayon`. ## Follow up - #1580 - #1581 - #1582 - #1583 - #1584
Is this still relevant? |
Yeah, we want this feature before mainnet. It would be nice to have it during testnet |
It is preparation for #451. Most of the services don't need historical functionality; only the executor uses the view of the storage at a specific height. The change extracts this functionality into its own `HistoricalView` trait. During the implementation of the state rewind, I realized that the historical view has some limitations(like we are not able to iterate). So because of that, the historical view uses another type that only implements the `KeyValueInspect` trait. ## Checklist - [x] Breaking changes are clearly marked as such in the PR description and changelog ### Before requesting review - [x] I have reviewed the code myself
Closes #451 ## Overview Added support for the state rewind feature. The feature allows the execution of the blocks in the past and the same execution results to be received. Together with forkless upgrades, execution of any block from the past is possible if historical data exist for the target block height. The default size of historical/rewind window is 7 days. Also added support for rollback command when state rewind feature is enabled. The command allows the rollback of the state of the blockchain several blocks behind until the end of the historical window. ## Implementation details The change adds a new `HistoricalRocksDB` type that is the wrapper around regular `RocksDB`. This type has inside another RocksDB instance that is used to duplicate all tables plus has one more column to store the reverse modifications at each block height. The reverse modification is the opposite to the operation that was done during transition from block height X to X + 1. The screenshot below should describe the idea: <img width="723" alt="image" src="https://github.com/FuelLabs/fuel-core/assets/18346821/c4becce0-1669-4938-8dd7-87d274efa224"> The key of duplicated tables is extended with block height, and the value is the reverse operation to reach the state of entry at the previous height. Having the history of reverse operations, we can iterate back from the latest version of the entry to the previous one. Using the main property of the RocksDB(sorting keys by default), lookup operations are fast and we don't need to iterate all modifications. It is just enough to find the nearest reverse operation to the target height. ## Checklist - [x] New behavior is reflected in tests ### Before requesting review - [x] I have reviewed the code myself - [x] I have created follow-up issues caused by this PR and linked them here - #1997 - #1995 - #1993
Closes FuelLabs/fuel-core#1549 ## Overview The change extracts the off-chain-related logic from the executor and moves it to the GraphQL off-chain worker. It creates two new concepts - Off-chain and On-chain databases where the GraphQL worker has exclusive ownership of the database and may modify it without intersecting with the On-chain database. ## Challenges caused by the change Delegating updating of the state to something other than `BlockImporter` causes several new problems: - The commitment to the on-chain and off-chain databases is done in different places. The off-chain database may be out of sync with the on-chain database due to race conditions. - The result of the block execution(receipts, statuses) is not stored anywhere and may be lost due to emergency shutdown. We don't want to duplicate on-chain data inside of the off-chain database, so the GraphQL service works with two sources of data, which leads to two problems: - The off-chain database may be out of sync with the on-chain database due to race conditions causing failing requests. - The view of the databases during the GraphQL request may change, causing invalid responses with a mix of old and new data. We had this problem before, but now it is more critical. ## Solutions to the challenges ### Out of sync The change applies two steps to solve this issue. The main one is a new trait for the database: ```rust /// Provides a view of the storage at the given height. /// It guarantees to be atomic, meaning the view is immutable to outside modifications. pub trait AtomicView<View>: Send + Sync { /// Returns the view of the storage at the given `height`. fn view_at(&self, height: BlockHeight) -> StorageResult<View>; /// Returns the view of the storage for the latest block height. fn latest_view(&self) -> View; } ``` Another one to await on the `BlockCommiter` side finishing processing the `ImportResult` by all listeners. The goal of the trait is to provide an immutable read-only view of the database at a specific time. However, this trait has not yet been implemented properly during this PR and will be implemented in the following PRs. The `view_at` requires functionality from FuelLabs/fuel-core#451. We already can implement the `latest_view` method via [`RocksDB::Transaction`](https://github.com/facebook/rocksdb/wiki/Transactions#reading-from-a-transaction), but it is better to do it after merging FuelLabs/fuel-core#1576. Waiting on the `BlockImporter` side is a temporary solution to not escalate the problem. But maybe we can keep it later to guarantee the consistent state of the blockchain. ### Losing result of execution The `AtomicView` trait also solves the issue of losing the state of the execution because it is possible to get a view of the database at a specific block height and execute the block again receiving the same execution result. Waiting inside the `BlockImporter` guarantees that we will not lose more than one `ImportResult`. ### Inconsistent database view within GraphQL requests The GraphQL now has `ReadDatabase`: ```rust pub type OnChainView = Arc<dyn OnChainDatabase>; pub type OffChainView = Arc<dyn OffChainDatabase>; pub struct ReadDatabase { on_chain: Box<dyn AtomicView<OnChainView>>, off_chain: Box<dyn AtomicView<OffChainView>>, } ``` It implements the `view` method that returns the `ReadView` type. The `ReadView` implements all required methods by using internal on-chain view and off-chain view. The `AtomicView` allows us to get the `last_view` of the off-chain database and get the `view_at(off_chain_view.last_height())` of the on-chain database creating a consistent view for both databases at a specific height. The change also adds a `ViewExtension` to the GraphQL that creates a `ReadView` for each request. ```rust /// The extension that adds the `ReadView` to the request context. /// It guarantees that the request works with the one view of the database, /// and external database modification cannot affect the result. struct ViewExtension; #[async_trait::async_trait] impl Extension for ViewExtension { async fn prepare_request( &self, ctx: &ExtensionContext<'_>, request: Request, next: NextPrepareRequest<'_>, ) -> ServerResult<Request> { let database: &ReadDatabase = ctx.data_unchecked(); let view = database.view(); let request = request.data(view); next.run(ctx, request).await } } ``` ## Implementation details - The `ExecutionResult` now also has receipts for the transaction along with its status. The off-chain worker will insert them later into the database, while the `dry_run` can fetch them immediately. - All API requests now work with the `ReadView` instead of the `Database` type. The `ReadDatabase` is only used in one place in the `ViewExtension`. - The `BlockImpoerter::comit_result` now is `async` and awaits for the previous block to be processed by all listeners. The execution of the `execute_and_commit` now runs `verify_and_execute_block` in the spawned task in the `tokio_rayon`. ## Follow up - FuelLabs/fuel-core#1580 - FuelLabs/fuel-core#1581 - FuelLabs/fuel-core#1582 - FuelLabs/fuel-core#1583 - FuelLabs/fuel-core#1584
The database in fuel-core needs the ability to rewind state to a previous point in time. This will allow nodes to contest invalid state transitions on L1 via IVG fraud-proofs.
Each atomic batch-write to the database is composed of either insert
(column id: u32, key: Vec<u8>, value: Vec<u8>)
or delete operations(column id: u32, key: Vec<u8>)
. By keeping a history of these batches, we'll be able to create an in-memory overlay over the database where batches are used to rewind from the current state tip. Both insert and remove operations will now also need to include the previous value associated with the key in order to be rewound:(column id: u32, key: Vec<u8>, value: Vec<u8>, prev_value: Vec<u8>)
&(column id: u32, key: Vec<u8>, prev_value: Vec<u8>)
Add a new column to the database that contains a serialized blob of these operations, bundled together by block height:
Vec<WriteOperation>
In order to prevent ongoing state changes (e.g. new blocks committed during an IVG session) from altering the rewind overlay, we'll also need the ability to snapshot the current database state at the start of the rewind operation. This should be fairly efficient as RocksDB can make the snapshot from symlinks as long as the snapshot is on the same filesystem as the database: http://rocksdb.org/blog/2015/11/10/use-checkpoints-for-efficient-snapshots.html
The in-memory database will also need to support state rewind, but it could just clone all the data for snapshotting since it doesn't need to be very efficient and we don't expect the in-memory db to achieve significant usage outside of short-lived integ tests.
The text was updated successfully, but these errors were encountered: