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

refactor: queue-based block production + better separation of node's mutable state #517

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

itegulov
Copy link
Contributor

@itegulov itegulov commented Jan 8, 2025

What 💻

Closes #501

Apologies for an insane diff once again - one change required another so I ended up refactoring more stuff than originally intended. Vast part of the diff is just moving from std::sync::RwLock to tokio::sync::RwLock (was needed due to lifetime shenanigans) and hence adding async/await in a lot of places that did not require it before. I will try to give a brief overview of functional changes below:

  • Moved (most of) mutable node state into a separate module inner. Essentially trying to restrict writeable access to as little entrypoints as I could (see inner rustdoc for more info)
  • Introduced a new layer purely for blockchain state: inner/blockchain.rs that has reader/writer structs. BlockchainWriter is held exclusively by InMemoryNodeInner and is inaccessible from outside inner module. All API endpoints were refactored to rely on BlockchainReader for their queries, thus removing the need to lock the entirety of InMemoryNodeInner.
  • Refactored time module into TimeWriter and TimeReader. Former is owned by InMemoryNodeInner and is inaccessible from outside of inner module. Latter can still be used to read current time in API endpoints.
  • BlockProducer was renamed to NodeExecutor and is the sole place that can seal blocks. Works via mpsc command queue that ensures no contentions between time lock and block production. Note: some more improvements can still be made to relax its lock holding but this at least removes the requirement to hold time lock for the entire block production time. Can also seal multiple blocks "atomically" (term used pretty loosely here but hopefully conveys the meaning).
  • BlockSealer was adapted accordingly and is now a separate background process that pushes commands to BlockProducer

Why ✋

Hopefully better code quality + less lock contention across the board

# Conflicts:
#	crates/cli/src/cli.rs
#	crates/cli/src/main.rs
#	crates/core/src/node/eth.rs
#	crates/core/src/node/in_memory.rs
#	crates/core/src/node/in_memory_ext.rs
#	crates/core/src/node/zks.rs
#	crates/core/src/system_contracts.rs
@itegulov itegulov requested a review from a team as a code owner January 8, 2025 08:27
Copy link
Member

@popzxc popzxc left a comment

Choose a reason for hiding this comment

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

Overall I like what I see, but tbh it feels much more complicated and low-level than it intuitively has to be.

Left a few preliminary comments.

Comment on lines +19 to +20
let chain_id = tokio::runtime::Handle::current()
.block_on(async { self.node.get_chain_id().await.map_err(RpcError::from) })?;
Copy link
Member

Choose a reason for hiding this comment

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

Why not make this method async as well?

@@ -19,7 +19,7 @@ struct Bytecode {

// Loads a list of bytecodes and addresses from the directory and then inserts them directly
// into the Node's storage.
pub fn override_bytecodes(node: &InMemoryNode, bytecodes_dir: String) -> eyre::Result<()> {
pub async fn override_bytecodes(node: &InMemoryNode, bytecodes_dir: String) -> eyre::Result<()> {
for entry in fs::read_dir(bytecodes_dir)? {
Copy link
Member

Choose a reason for hiding this comment

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

Given that you made this function async, probably it makes sense to replace calls with non-blocking alternatives.

None => Ok(None),
}
) -> anyhow::Result<Option<api::TransactionReceipt>> {
// TODO: Call fork if not found
Copy link
Member

Choose a reason for hiding this comment

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

Should we create a task for that?

Comment on lines +555 to +564
.inner
.read()
.await
.fork_storage
.inner
.read()
.expect("failed reading fork storage")
.fork
.as_ref()
.and_then(|fork| {
Copy link
Member

Choose a reason for hiding this comment

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

Nit: here and in other places the composition looks super awkward. A good candidate for the structure being re-thought. Not suggesting to do in this PR obv.

Comment on lines +182 to +185
/// A single-instance writer to blockchain state that is only available to [`super::InMemoryNodeInner`].
pub(super) struct BlockchainWriter {
pub(super) inner: Arc<RwLock<Blockchain>>,
}
Copy link
Member

Choose a reason for hiding this comment

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

Can't we express BlockchainReader and BlockchainWriter as two traits? Actually, probably BlockchainWriter is not even needed -- you can expose only Box<dyn BlockchainReader> outside of the crate, and rework Blockchain as pub(crate) struct Blockchain { inner: Arc<RwLock<Blockchain>> }.

With that, hopefully, you will be able to expose all the required mutability methods on the structure itself so that you don't have to leak guards in the interface.

use tokio::sync::RwLock;

impl InMemoryNodeInner {
// TODO: Bake in Arc<RwLock<_>> into the struct itself
Copy link
Member

Choose a reason for hiding this comment

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

Most of structures already have rwlocks inside, no? This indeed feels a bit clunky

Comment on lines +196 to +199
impl Future for NodeExecutor {
type Output = ();

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe it's a stupid question, but why can't we just do a run method that would do something like this?
It feels a bit overengineered.

while let Some(command) = this.command_receiver.next().await {
  ...
}

}

#[derive(Debug)]
pub enum Command {
Copy link
Member

Choose a reason for hiding this comment

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

Does it have to be public?

}
}

impl Future for BlockSealer {
Copy link
Member

Choose a reason for hiding this comment

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

Similarly -- cannot we express this as a simple loop?

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.

refactor: make BlockProducer an actor with sole right to mine blocks
2 participants