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

docs(book): tracking state in ExExes #8804

Merged
merged 21 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions book/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,6 @@
- [Execution Extensions](./developers/exex/exex.md)
- [How do ExExes work?](./developers/exex/how-it-works.md)
- [Hello World](./developers/exex/hello-world.md)
- [Tracking State](./developers/exex/tracking-state.md)
- [Remote](./developers/exex/remote.md)
- [Contribute](./developers/contribute.md)
1 change: 1 addition & 0 deletions book/developers/exex/exex.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ and run it on the Holesky testnet.

1. [How do ExExes work?](./how-it-works.md)
1. [Hello World](./hello-world.md)
1. [Tracking State](./tracking-state.md)
4 changes: 4 additions & 0 deletions book/developers/exex/hello-world.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,7 @@ and it's safe to prune the associated data.
</div>

What we've arrived at is the [minimal ExEx example](https://github.com/paradigmxyz/reth/blob/b8cd7be6c92a71aea5341cdeba685f124c6de540/examples/exex/minimal/src/main.rs) that we provide in the Reth repository.

## What's next?

Let's do something a bit more interesting, and see how you can [keep track of some state](./tracking-state.md) inside your ExEx.
193 changes: 193 additions & 0 deletions book/developers/exex/tracking-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Tracking State

In this chapter, we'll learn how to keep track of some state inside our ExEx.

Let's continue with our Hello World example from the [previous chapter](./hello-world.md).

### Turning ExEx into a struct

First, we need to turn our ExEx into a stateful struct.

Before, we had just an async function, but now we'll need to implement
the [`Future`](https://doc.rust-lang.org/std/future/trait.Future.html) trait manually.

<div class="warning">

Having a stateful async function is also possible, but it makes testing harder,
because you can't access variables inside the function to assert the state of your ExEx.

</div>

```rust,norun,noplayground,ignore
use std::{
future::Future,
pin::Pin,
task::{ready, Context, Poll},
};
use reth::api::FullNodeComponents;
use reth_exex::{ExExContext, ExExEvent, ExExNotification};
use reth_node_ethereum::EthereumNode;
use reth_tracing::tracing::info;
struct MyExEx<Node: FullNodeComponents> {
ctx: ExExContext<Node>,
}
impl<Node: FullNodeComponents> Future for MyExEx<Node> {
type Output = eyre::Result<()>;
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.

We should also explain how you'd call out to other functions?

Prob we'd save most of the logic in async functions on the exex that the future loop calls?

let this = self.get_mut();
while let Some(notification) = ready!(this.ctx.notifications.poll_recv(cx)) {
match &notification {
ExExNotification::ChainCommitted { new } => {
info!(committed_chain = ?new.range(), "Received commit");
}
ExExNotification::ChainReorged { old, new } => {
info!(from_chain = ?old.range(), to_chain = ?new.range(), "Received reorg");
}
ExExNotification::ChainReverted { old } => {
info!(reverted_chain = ?old.range(), "Received revert");
}
};
if let Some(committed_chain) = notification.committed_chain() {
this.ctx
.events
.send(ExExEvent::FinishedHeight(committed_chain.tip().number))?;
}
}
Poll::Ready(Ok(()))
}
}
fn main() -> eyre::Result<()> {
reth::cli::Cli::parse_args().run(|builder, _| async move {
let handle = builder
.node(EthereumNode::default())
.install_exex("my-exex", |ctx| async move { Ok(MyExEx { ctx }) })
.launch()
.await?;
handle.wait_for_node_exit().await
})
}
```

For those who are not familiar with how async Rust works on a lower level, that may seem scary,
but let's unpack what's going on here:

1. Our ExEx is now a `struct` that contains the context and implements the `Future` trait. It's now pollable (hence `await`-able).
1. We can't use `self` directly inside our `poll` method, and instead need to acquire a mutable reference to the data inside of the `Pin`.
Read more about pinning in [the book](https://rust-lang.github.io/async-book/04_pinning/01_chapter.html).
1. We also can't use `await` directly inside `poll`, and instead need to poll futures manually.
We wrap the call to `poll_recv(cx)` into a [`ready!`](https://doc.rust-lang.org/std/task/macro.ready.html) macro,
so that if the channel of notifications has no value ready, we will instantly return `Poll::Pending` from our Future.
1. We initialize and return the `MyExEx` struct directly in the `install_exex` method, because it's a Future.

With all that done, we're now free to add more fields to our `MyExEx` struct, and track some state in them.

### Adding state

Our ExEx will count the number of transactions in each block and log it to the console.

```rust,norun,noplayground,ignore
use std::{
future::Future,
pin::Pin,
task::{ready, Context, Poll},
};
use reth::{api::FullNodeComponents, primitives::BlockNumber};
use reth_exex::{ExExContext, ExExEvent};
use reth_node_ethereum::EthereumNode;
use reth_tracing::tracing::info;
struct MyExEx<Node: FullNodeComponents> {
ctx: ExExContext<Node>,
/// First block that was committed since the start of the ExEx.
first_block: Option<BlockNumber>,
/// Total number of transactions committed.
transactions: u64,
}
impl<Node: FullNodeComponents> MyExEx<Node> {
fn new(ctx: ExExContext<Node>) -> Self {
Self {
ctx,
first_block: None,
transactions: 0,
}
}
}
impl<Node: FullNodeComponents> Future for MyExEx<Node> {
type Output = eyre::Result<()>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
while let Some(notification) = ready!(this.ctx.notifications.poll_recv(cx)) {
if let Some(reverted_chain) = notification.reverted_chain() {
this.transactions = this.transactions.saturating_sub(
reverted_chain
.blocks_iter()
.map(|b| b.body.len() as u64)
.sum(),
);
}
if let Some(committed_chain) = notification.committed_chain() {
this.first_block.get_or_insert(committed_chain.first().number);
this.transactions += committed_chain
.blocks_iter()
.map(|b| b.body.len() as u64)
.sum::<u64>();
this.ctx
.events
.send(ExExEvent::FinishedHeight(committed_chain.tip().number))?;
}
if let Some(first_block) = this.first_block {
info!(%first_block, transactions = %this.transactions, "Total number of transactions");
}
}
Poll::Ready(Ok(()))
}
}
fn main() -> eyre::Result<()> {
reth::cli::Cli::parse_args().run(|builder, _| async move {
let handle = builder
.node(EthereumNode::default())
.install_exex("my-exex", |ctx| async move { Ok(MyExEx::new(ctx)) })
.launch()
.await?;
handle.wait_for_node_exit().await
})
}
```
Comment on lines +97 to +176
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't like that we basically repeat the whole snippet twice. How can we do it better?

Copy link
Member

Choose a reason for hiding this comment

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

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 don't fully repeat the snippet but rather change some parts of it. I meant how we can do better at showing what changed in the code without repeating it all over again, but we can solve it later.


As you can see, we added two fields to our ExEx struct:
- `first_block` to keep track of the first block that was committed since the start of the ExEx.
- `transactions` to keep track of the total number of transactions committed, accounting for reorgs and reverts.

We also changed our `match` block to two `if` clauses:
- First one checks if there's a reverted chain using `notification.reverted_chain()`. If there is:
- We subtract the number of transactions in the reverted chain from the total number of transactions.
- It's important to do the `saturating_sub` here, because if we just started our node and
instantly received a reorg, our `transactions` field will still be zero.
- Second one checks if there's a committed chain using `notification.committed_chain()`. If there is:
- We update the `first_block` field to the first block of the committed chain.
- We add the number of transactions in the committed chain to the total number of transactions.
- We send a `FinishedHeight` event back to the main node.

Finally, on every notification, we log the total number of transactions and
the first block that was committed since the start of the ExEx.
Loading