Rust API
Modules
The Rust execution events API is split across two library packages:
-
monad-event-ring - this package provides the core event ring functionality. Recall that event rings are a generic broadcast utility based on shared memory communication, and are agnostic about what kind of event data they contain. Consequently, this package does not include the definitions of the execution event types (nor any other event types)
-
monad-exec-events - the execution event data types are defined in this library, along with some helpful utilities for writing real-time data applications
These libraries fit together in a more structured way than they do in C.
In the C API, the event ring API works with unstructured data, e.g., event
numerical codes are uint16_t
values and event payloads are raw byte arrays.
The reader performs unchecked type casts to reinterpret the meaning of those
bytes. There are some
safety mechanisms
to check if an event ring file appears to contain the right kind of data,
but the content types are not strongly represented in the type system.
In the Rust API, the event ring is not just "generic" in the general sense of the word; it is a literal generic type:
struct EventRing<D: EventDecoder>
Event rings are explicitly parameterized by a "decoder". The decoder knows how to interpret the raw bytes for a particular event content type, e.g., execution events.
Core concepts
Event enumeration type
Consider how decoding works in the C API: it's typically a "giant switch statement" pattern, where we examine the numerical code of an event and reinterpret the raw bytes as the appropriate payload type via an unchecked type cast:
const void *payload = monad_event_ring_payload_peek(&exec_ring, &event);
switch (event.event_type) {case BLOCK_START: handle_block_start((const struct monad_exec_block_start *)payload); break;
case BLOCK_END: handle_block_end((const struct monad_exec_block_end *)payload); break;
// ... more event types handled here}
The Rust way of expressing this is to use an enum
type: the different kinds
of event payloads become the enum's variants, and the switch
logic is
replaced by a more-powerful match
.
In Rust, decoding produces a value of enumeration type ExecEvent
, which is
defined like this:
#[derive(Clone, Debug)]pub enum ExecEvent { BlockStart(monad_exec_block_start), BlockReject(monad_exec_block_reject), BlockEnd(monad_exec_block_end), // more variants follow
Notice that each variant of ExecEvent
holds a value whose type name resembles
the C event payload structures. For example, struct monad_exec_block_start
is
the event payload structure definition in the C API. It's recorded when a new
block starts, and is defined in the file exec_event_ctypes.h
.
The use of these exact same C structure names -- including the monad_exec
prefix and lower-case, snake-case spelling -- is designed to alert you to the
fact that the payload types have exactly the same in-memory representation
as their C API counterparts. They are generated by
bindgen and are layout-compatible
(via a #[repr(C)]
attribute) with the C types of the same names.
Event rings and the 'ring
reference lifetime
EventRing
is an RAII-handle type: when you create an EventRing
instance,
new shared memory mapping are added to your process for that event ring file.
Likewise, when EventRing::drop
is called, those shared memory mappings are
removed. Any pointers or references pointing into shared memory would need to
be invalidated at that point.
We rely on Rust's builtin reference lifetime analysis framework to express
this. References to data that lives in event ring shared memory always carries
a reference lifetime called 'ring
. This lifetime corresponds to the lifetime
of the EventRing
object itself. Since an EventRing
pins the shared memory
mappings in place by being alive, the true meaning of 'ring
can usually be
thought of as the "shared memory lifetime", which is the same.
Zero-copy APIs and the "event reference" enumeration type
In a previous section, we discussed the decoded execution event type,
enum ExecEvent
. There is a second type with a similar design called
enum ExecEventRef<'ring>
; it is used for the zero copy API.
To compare the two, here is the ExecEvent
type:
#[derive(Clone, Debug)]pub enum ExecEvent { BlockStart(monad_exec_block_start), BlockReject(monad_exec_block_reject), BlockEnd(monad_exec_block_end), // more variants follow
And here is the ExecEventRef<'ring>
type:
#[derive(Clone, Debug)]pub enum ExecEventRef<'ring> { BlockStart(&'ring monad_exec_block_start), BlockReject(&'ring monad_exec_block_reject), BlockEnd(&'ring monad_exec_block_end), // more variants follow
The former contains copies of event payloads, whereas the latter directly
references the bytes living in the shared memory payload buffer. By working
with ExecEventRef<'ring>
, you avoid avoid copying a potentially large amount
of data, e.g., especially large EVM logs or call frames. This is valuable
if you are filtering out most events anyway.
The "event reference" enum type offers better performance, but it comes with two drawbacks:
-
Because it has a reference lifetime as a generic parameter, it can be more difficult to work with (i.e., more running afoul of the borrow checker)
-
Data that lives directly in the payload buffer can be overwritten at any time, so you shouldn't rely on it still being there long after you first look at it
Copying vs. zero-copy payload APIs
The copy vs. zero-copy decision only applies to event payloads; event descriptors are small, and are always copied. There are two ways to read an event's payload once you have its descriptor:
-
Copying style
EventDescriptor::try_read
- this will return anEventPayloadResult
enum type, which either contains the "success" variant (EventPayloadResult::Ready
) or the "failure" variant (EventPayloadResult::Expired
); the former contains aExecEvent
payload value, and the latter indicates that the payload was lost -
Zero-copy style
EventDescriptor::try_filter_map
- you pass a non-capturing closure to this method, and it is called back with anExecEventRef<'ring>
reference pointing to the event payload in shared memory; since your closure can't capture anything, the only way for you to react to the event payload is to return some valuev
of typeT
;EventDescriptor::try_filter_map
itself returns anOption<T>
, which is used in the following way:-
If the payload has expired prior to calling your closure, then your closure is never called, and the
try_filter_map
returnsOption::None
-
Otherwise your closure is run and its return value
v: T
is moved into thetry_filter_map
function -
If the payload is still valid after your closure has run, then the value is transferred to the caller by returning
Option::Some(v)
, otherwiseOption::None
is returned
-
Why non-capturing closures?
The pattern of zero-copy APIs generally works like this:
-
Create a reference to the data in the event ring payload buffer (
e: &'ring E
) and check for expiration; if not expired ... -
... compute something based on the event payload value, i.e., compute
let v = f(&e)
-
Once
f
finishes, check again if the payload expired; if it is expired now, then it may have become expired sometime during the computation ofv = f(&e)
; the only safe thing we can do is discard the computed valuev
, since we have no way of knowing exactly when the expiration happened
If you were permitted to capture variables in the zero-copy closure, you could "smuggle out" computations out-of-band from the library's payload expiration detection checks. That is, if the library later detects that the payload was overwritten sometime during when your closure was running, it would have no guaranteed way to "poison" your smuggled out value. It could only advise you not to trust it, but that is error prone.
Idiomatic Rust tends to follow a "correct by default" style, and guards against these kinds of unsafe patterns. In the zero-copy API, you can communicate only through return values since you cannot capture anything. This way, the library can decide not to propagate the return value back to you at all, if it later discovers that the payload it gave you as input has expired.
Important types in the Rust API
There are six core types in the API:
-
Event ring
EventRing<D: EventDecoder>
- given a path to an event ring file, you create one of these to gain access to the shared memory segments of the event ring in that file; you typically use the type aliasExecEventRing
, which is syntactic sugar forEventRing<ExecEventDecoder>
-
Event reader
EventReader<'ring, D: EventDecoder>
- this is the iterator-like type that is used to read events; it's called a "reader" rather than an "iterator" because Iterator already has a specific meaning in Rust; the event reader has a more complex return type than a Rust iterator because it has a "polling" style: its equivalent ofnext()
-- callednext_descriptor()
-- can return an event descriptor, report a gap, or indicate that no new event is ready yet -
Event descriptor
EventDescriptor<'ring, D: EventDecoder>
- the event reader produces one of these if the next event is read successfully; recall that the event descriptor contains the common fields of the event, and stores the necessary data to read the event payload and check if it's expired; in the Rust API, reading payloads is done using methods defined on the event descriptor -
Event decoder
trait EventDecoder
- you don't use this directly, but a type that implements this trait --ExecEventDecoder
in the case of an execution event ring -- contains all the logic for how to decode event payloads -
Event enumeration types (associated types
EventDecoder::Event
andEventDecoder::EventRef
) - these give the "copy" and "zero-copy" decoded forms of events; in the case of theExecEventDecoder
,ExecEvent
is the "copy" type andExecEventRef<'ring>
is the zero-copy (shared-memory reference) type -
Execution event payload types (
monad_exec_block_start
, and others) - these are bindgen-generated,#[repr(C)]
event payload types that match their C API counterparts
Block-level utilities
ExecutedBlockBuilder
Execution events are granular: most actions taken by the EVM will publish a
single event describing that one action, e.g., every EVM log emitted is
published as as a separate ExecEvent::TxnLog
event. The events are streamed
to consumers almost as soon as they are available, so the real-time data of a
block comes in "a piece at a time."
A utility called the ExecutedBlockBuilder
will aggregate these events back
into a single, block-oriented update, if the user prefers working with complete
blocks. The data types in the block representation are also
alloy_primitives types
which are more ergonomic to work with in Rust.
CommitStateBlockBuilder
As explained in the section on speculative real-time data, the EVM publishes execution events as soon as it is able to, which means it is usually publishing data about blocks that are speculatively executed. We do not know if these blocks will be appended to the blockchain or not, since the consensus decision is occurring in parallel with (and will finish later than) the block's execution.
CommitStateBlockBuilder
builds on the ExecutedBlockBuilder
by also
tracking the commit state of the block as it moves through the consensus
life cycle. The block update itself is passed around via an
Arc<ExecutedBlock>
, so that it is cheap to copy references to it. As the
block commit state changes, you receive updates describing the new state,
along with another reference to the Arc<ExecutedBlock>
itself.
The speculative real-time data guide often points out that block abandonment
is not explicitly communicated by the event system (e.g.,
here and
here).
The CommitStateBlockBuilder
however, does report explicit abandonment of
failed proposals, because it is a higher level, user-friendly utility.