Skip to content
Open
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
4 changes: 4 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ surfpool-install-minimal = "install --path crates/cli --locked --force"

# regenerate every table in crates/types/src/startup-lifecycle.md
surfpool-update-startup-spec = "test -p surfpool-types regenerate_the_startup_spec_tables -- --ignored --nocapture"
# regenerate the tables in crates/core/src/surfnet/slot-lifecycle.md
surfpool-update-slot-spec = "test -p surfpool-core --lib regenerate_the_slot_spec_tables -- --ignored --nocapture"
# re-render the spec's mermaid diagrams to crates/types/src/diagrams/ (needs mmdc)
surfpool-render-startup-diagrams = "test -p surfpool-types render_the_startup_diagrams -- --ignored --nocapture"
# re-render the slot spec's mermaid diagrams to crates/core/src/surfnet/diagrams/ (needs mmdc)
surfpool-render-slot-diagrams = "test -p surfpool-core --lib render_the_slot_diagrams -- --ignored --nocapture"
45 changes: 45 additions & 0 deletions crates/core/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use std::{env, fs, path::Path};

/// Produces the rustdoc variant of `slot-lifecycle.md`: each mermaid
/// region is replaced by its pre-rendered SVG from
/// `src/surfnet/diagrams/`, so rustdoc shows the drawing while the
/// source file keeps the editable fence, which GitHub and editors
/// render natively. The test `the_diagrams_match_their_renderings`
/// holds the SVGs to their sources; this script only splices.
fn main() {
println!("cargo:rerun-if-changed=src/surfnet/slot-lifecycle.md");
println!("cargo:rerun-if-changed=src/surfnet/diagrams");

let source = fs::read_to_string("src/surfnet/slot-lifecycle.md")
.expect("slot-lifecycle.md should exist");

let mut output = String::new();
let mut rest = source.as_str();
loop {
let Some(start) = rest.find("<!-- BEGIN MERMAID: ") else {
output.push_str(rest);
break;
};
let name_start = start + "<!-- BEGIN MERMAID: ".len();
let name_end = rest[name_start..]
.find(" -->")
.expect("a mermaid marker name")
+ name_start;
let name = &rest[name_start..name_end];
let end_marker = format!("<!-- END MERMAID: {name} -->");
let end = rest
.find(&end_marker)
.unwrap_or_else(|| panic!("no closing marker for mermaid region {name}"))
+ end_marker.len();

output.push_str(&rest[..start]);
let svg_path = format!("src/surfnet/diagrams/{name}.svg");
let svg = fs::read_to_string(&svg_path)
.unwrap_or_else(|error| panic!("could not read {svg_path}: {error}"));
output.push_str(&svg);
rest = &rest[end..];
}

let out = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("slot-lifecycle.rustdoc.md");
fs::write(out, output).expect("write the rustdoc variant");
}
39 changes: 10 additions & 29 deletions crates/core/src/runloops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ pub async fn start_local_surfnet_runloop(

let (plugin_commands_tx, plugin_commands_rx) = unbounded::<PluginCommand>();

// Startup before traffic: plugins observe startup completion and the open slot
// before the RPC listeners bind, so external traffic can't emit block data for
// a slot that a plugin is not tracking yet.
let _ = svm_locker.with_svm_reader(|svm| svm.geyser_events_tx.send(GeyserEvent::EndOfStartup));
svm_locker.with_svm_writer(|svm_writer| svm_writer.announce_open_slot());

let (_rpc_handle, _ws_handle, shutdown_rpc_servers) = start_rpc_servers_runloop(
&config,
&simnet_commands_tx,
Expand Down Expand Up @@ -331,9 +337,6 @@ pub async fn start_local_surfnet_runloop(
}
simnet_events_tx_cc.core_started(initial_transaction_count);

// Notify geyser plugins that startup is complete
let _ = svm_locker.with_svm_reader(|svm| svm.geyser_events_tx.send(GeyserEvent::EndOfStartup));

start_block_production_runloop(
clock_event_rx,
clock_command_tx,
Expand Down Expand Up @@ -466,14 +469,7 @@ pub async fn start_block_production_runloop(
}

svm_locker.with_svm_writer(|svm_writer| {
svm_writer.inner.set_sysvar(&clock);
svm_writer.updated_at = clock.unix_timestamp as u64 * 1_000;
svm_writer.latest_epoch_info.absolute_slot = clock.slot;
svm_writer.latest_epoch_info.epoch = clock.epoch;
svm_writer.latest_epoch_info.slot_index = clock.slot;
svm_writer.latest_epoch_info.epoch = clock.epoch;
svm_writer.latest_epoch_info.absolute_slot = clock.slot + clock.epoch * svm_writer.latest_epoch_info.slots_in_epoch;
svm_writer.simnet_events_tx.system_clock_updated(clock);
svm_writer.warp_clock(clock);
});
}
SimnetCommand::UpdateInternalClockWithConfirmation(_, clock, response_tx) => {
Expand All @@ -484,17 +480,8 @@ pub async fn start_block_production_runloop(
));
}

let epoch_info = svm_locker.with_svm_writer(|svm_writer| {
svm_writer.inner.set_sysvar(&clock);
svm_writer.updated_at = clock.unix_timestamp as u64 * 1_000;
svm_writer.latest_epoch_info.absolute_slot = clock.slot;
svm_writer.latest_epoch_info.epoch = clock.epoch;
svm_writer.latest_epoch_info.slot_index = clock.slot;
svm_writer.latest_epoch_info.epoch = clock.epoch;
svm_writer.latest_epoch_info.absolute_slot = clock.slot + clock.epoch * svm_writer.latest_epoch_info.slots_in_epoch;
svm_writer.simnet_events_tx.system_clock_updated(clock);
svm_writer.latest_epoch_info.clone()
});
let epoch_info = svm_locker
.with_svm_writer(|svm_writer| svm_writer.warp_clock(clock));

// Send confirmation back
let _ = response_tx.send(epoch_info);
Expand Down Expand Up @@ -824,14 +811,8 @@ fn start_geyser_runloop(
}
}
Ok(GeyserEvent::UpdateSlotStatus { slot, parent, status }) => {
let slot_status = match status {
crate::surfnet::GeyserSlotStatus::Processed => SlotStatus::Processed,
crate::surfnet::GeyserSlotStatus::Confirmed => SlotStatus::Confirmed,
crate::surfnet::GeyserSlotStatus::Rooted => SlotStatus::Rooted,
};

for plugin in managed_plugins.iter().map(|p| &*p.plugin) {
if let Err(e) = plugin.update_slot_status(slot, parent, &slot_status) {
if let Err(e) = plugin.update_slot_status(slot, parent, &status) {
simnet_events_tx.error(format!("Failed to update slot status in Geyser plugin: {:?}", e));
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/surfnet/diagrams/machine-edges.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 3 additions & 13 deletions crates/core/src/surfnet/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{collections::HashMap, fmt::Display, sync::Arc};

use agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus;
use crossbeam_channel::{Receiver, Sender};
use jsonrpc_core::Result as RpcError;
use locker::SurfnetSvmLocker;
Expand Down Expand Up @@ -32,6 +33,7 @@ use crate::{
pub mod locker;
pub mod noop_program;
pub mod remote;
pub mod slot_lifecycle;
pub mod surfnet_lite_svm;
pub mod svm;

Expand All @@ -40,18 +42,6 @@ pub const SLOTS_PER_EPOCH: u64 = 432000;

pub type AccountFactory = Box<dyn Fn(SurfnetSvmLocker) -> GetAccountResult + Send + Sync>;

/// Slot status for geyser plugin notifications.
/// Mirrors `agave_geyser_plugin_interface::geyser_plugin_interface::SlotStatus`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeyserSlotStatus {
/// Slot is being processed
Processed,
/// Slot has been rooted (finalized)
Rooted,
/// Slot has been confirmed
Confirmed,
}

/// Block metadata for geyser plugin notifications.
#[derive(Debug, Clone)]
pub struct GeyserBlockMetadata {
Expand Down Expand Up @@ -90,7 +80,7 @@ pub enum GeyserEvent {
UpdateSlotStatus {
slot: Slot,
parent: Option<Slot>,
status: GeyserSlotStatus,
status: SlotStatus,
},
/// Notify plugins of block metadata.
NotifyBlockMetadata(GeyserBlockMetadata),
Expand Down
119 changes: 119 additions & 0 deletions crates/core/src/surfnet/slot-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
A slot is announced (`CreatedBank`) before any of its block data is
emitted, advances through `Processed`, `Confirmed`, and `Rooted` in that
order and at most once each, or dies (`Dead`): a clock warp abandons the
open slot it leaves behind, and a backward warp kills every slot the new
timeline rewrites. Block production, the startup task, the warp
handlers, and a network reset all drive this one transition relation
instead of each emitting statuses by hand.

## The per-slot table

Rows are the recorded stage of one slot, columns the per-slot events; a
cell says what is emitted and where the slot goes. This table is
generated from the spec's `PER_SLOT` constant
(`slot_lifecycle/spec.rs`), which the exhaustive sweeps hold the
machine to, so what you read here is what runs.

<!-- BEGIN GENERATED: per-slot-table -->
| State | announce | produce | confirm | root |
|---|---|---|---|---|
| `(absent)` | emits CreatedBank; -> Announced | emits CreatedBank, Processed; -> Processed | ignored | ignored |
| `Announced` | ignored | emits Processed; -> Processed | ignored | ignored |
| `Processed` | ignored | ignored | emits Confirmed; -> Confirmed | ignored |
| `Confirmed` | ignored | ignored | ignored | emits Rooted; -> forgotten |
<!-- END GENERATED: per-slot-table -->

## The machine, as edges

<!-- BEGIN GENERATED: diagram -->
<!-- BEGIN MERMAID: machine-edges -->
```mermaid
stateDiagram-v2
[*] --> Announced : announce (CreatedBank)
[*] --> Processed : produce (CreatedBank, Processed)
Announced --> Processed : produce (Processed)
Processed --> Confirmed : confirm (Confirmed)
Confirmed --> [*] : root (Rooted)
Announced --> [*] : warp away (Dead)
state "any stage" as any_stage
any_stage --> [*] : warp back (Dead, every rewritten slot)
```
<!-- END MERMAID: machine-edges -->
<!-- END GENERATED: diagram -->

## Warp, rooting, and clear

Warps, rooting, and a reset are set-level operations, deliberately
kept out of the table:

- A forward warp from the open slot `f` to `t` kills the abandoned
slot (`f` was announced and never produced, so it is emitted `Dead`
and forgotten) and announces `t` through the table's own announce
cell, so a warp landing on a slot already on record announces
nothing.
- A backward warp is a reorg: every slot at or past `t` dies (`Dead`,
in slot order), whatever its stage, and `t` is then re-announced.
The new timeline replays the killed slots, so their statuses appear
again; at-most-once holds per bank, and a reorg makes a new bank.
- A backward warp may land at or below the root line (time travel to
the current epoch does exactly this). Rooted slots left the registry
when they rooted, so they die without a `Dead`; the landing emits a
`CreatedBank` at or below anything a consumer saw `Rooted`, with no
recorded parent when the registry holds nothing below it. That
announce is the discontinuity signal: a consumer treats a
`CreatedBank` for a slot it saw rooted as a timeline replacement,
dropping its state for every slot at or above it.
- Rooting is a threshold, not a single slot: when finality reaches
slot `r`, every confirmed slot at or below `r` roots, in slot order,
each through the table's root cell. The registry decides which slots
are due from its own record, so a history with gaps (a warp) roots
exactly what it confirmed.
- A reset forgets every slot; the caller announces the new open slot.

## What the table cannot hold

Two obligations order this machine's emissions against other streams,
and live in code order rather than in cells:

- Data before confirmation: `confirm_current_block` emits a slot's block
data (`BlockMeta`, `Entry`) before driving `produce` and `confirm`,
so a consumer that flushes on `Confirmed` never loses data.
- Startup before traffic: the startup task announces the open slot and
sends `EndOfStartup` before the RPC listeners bind, so nothing
external can emit block data for a slot a plugin is not tracking.

Interleavings (who runs between which writer sections) are out of
scope for this document; the sweeps in
`slot_lifecycle/reachability_tests.rs` cover every reachable state and
event of the sequential machine.

## Limits

Two histories fall outside the registry's record:

- A restart in persistent mode starts an empty registry. Slots
confirmed by an earlier process get no further statuses in either
stream: nothing roots them, and nothing replays them.
- A network reset erases the world: every live slot is forgotten with
no terminal status, and the new genesis is announced. This is the
one path that drops a live slot without a `Rooted` or a `Dead`; a
warp, by contrast, kills what it abandons.

Warps split the guarantees in two. Within a bank, the per-slot
guarantees are unconditional: announced before data, data before
confirmation, statuses in order and at most once. Across timelines,
rooted-is-final and slot monotonicity hold only until the operator
warps across them: time travel is a cheatcode, and the operator who
calls it suspends exactly those two guarantees, at one announced
boundary.

## Maintenance

State a rule change in `PER_SLOT` (or the warp/clear arms) first, then
change the machine; the sweep names the first disagreement. Then run
`cargo surfpool-update-slot-spec` to regenerate the blocks above, and
review that diff as the observable change. When the diagram changed,
also run `cargo surfpool-render-slot-diagrams` (needs `mmdc`) to
re-render the SVG that cargo doc splices in place of the fence; a
stale render fails `the_diagrams_match_their_renderings`. The prose
here is authored: revise it when a rule changes meaning.
Loading
Loading