Skip to content

Assembling the Runtime: Parallel Execution, Commit, and Rollback

The scheduler gave us the one thing parallel execution needs: a way to split a stream of transactions into conflict-free batches, where nothing inside a batch touches the same writable account. That was the hard, chain-specific insight. What is left is mechanical — and this page is where the mechanics turn into a runtime you can actually run.

We are going to build the Runtime: a HashMap account database, a registry of programs by id, and two execution paths — an obvious run_sequential baseline and a run_parallel path that hands each thread a disjoint mutable working set through rayon. The payoff is a single claim we will hold ourselves to for the rest of the part: the parallel path must produce the identical final state as the sequential one, or it is worthless. Everything below exists to make that claim true, and cheap.

State lives outside code — that was the lesson of Accounts and Stateless Programs. So the runtime is, at its core, two maps: one from account address to account data, and one from program id to the code that owns it.

use std::collections::HashMap;
pub struct Runtime {
pub accounts: HashMap<Pubkey, Account>,
programs: HashMap<Pubkey, Box<dyn Program>>,
/// Hash rounds to spin per transaction, standing in for the real per-tx CPU
/// work (signature verification + program execution). `0` in fast tests;
/// raise it to *see* parallel speedup on a wall clock.
pub compute_per_tx: u64,
}

The account map is the global state machine — the whole point of the book, reduced to a HashMap<Pubkey, Account>. The program map is the runtime’s dispatch table: a transaction names a program id, and the runtime looks up the Box<dyn Program> registered under it. Programs are stateless, so one boxed instance serves every transaction that calls it.

Construction registers the two built-in programs and gives us the helpers a caller needs to set up a scenario and inspect the result:

impl Runtime {
pub fn new() -> Self {
let mut programs: HashMap<Pubkey, Box<dyn Program>> = HashMap::new();
programs.insert(TransferProgram::id(), Box::new(TransferProgram));
programs.insert(CounterProgram::id(), Box::new(CounterProgram));
Runtime { accounts: HashMap::new(), programs, compute_per_tx: 0 }
}
/// Register an additional program under its id.
pub fn register(&mut self, id: Pubkey, program: Box<dyn Program>) {
self.programs.insert(id, program);
}
/// Create or overwrite an account.
pub fn fund(&mut self, key: Pubkey, account: Account) {
self.accounts.insert(key, account);
}
/// The lamport balance of an account (0 if it does not exist).
pub fn balance(&self, key: &Pubkey) -> u64 {
self.accounts.get(key).map(|a| a.lamports).unwrap_or(0)
}
}

fund seeds the world (rt.fund(alice, Account::wallet(100))); balance reads it back after execution. register is how a real deployment adds programs — in solmini it lets a test drop in its own Program. Nothing here is clever. The interesting part is how a transaction gets to touch this database without letting two threads collide.

The working-set pattern: clone, run, commit

Section titled “The working-set pattern: clone, run, commit”

Here is the idea the whole runtime hinges on. A transaction never runs against the live database. Instead the runtime pulls out an owned clone of exactly the accounts the transaction declared, hands the program that private copy to scribble on, and only afterward — and only if the program succeeded — writes the writable accounts back.

DB (live) working set (owned clone) DB (after commit)
───────── ───────────────────────── ─────────────────
alice: 100 ──► [alice: 100, bob: 0] alice: 70
bob: 0 program mutates the clone ──► bob: 30
alice -= 30, bob += 30 (only writables written back)

Three functions carry this pattern:

/// Pull a transaction's declared accounts out of the DB as an owned working set
/// (missing accounts default to empty). This *clone* is the tx's private sandbox.
fn working_set(&self, tx: &Transaction) -> Vec<Account> {
tx.accounts
.iter()
.map(|m| self.accounts.get(&m.key).cloned().unwrap_or_default())
.collect()
}
/// Run one tx against an already-gathered working set. An *associated* function
/// (no `&self`) so a worker thread can call it with just a `&programs` share,
/// not a borrow of the whole runtime.
fn run_tx(
programs: &HashMap<Pubkey, Box<dyn Program>>,
compute_per_tx: u64,
tx: &Transaction,
accounts: &mut [Account],
) -> Result<()> {
simulate_compute(tx, compute_per_tx);
match programs.get(&tx.program) {
Some(p) => p.process(accounts, &tx.data),
None => Err(SolError::UnknownProgram(tx.program.short())),
}
}
/// Commit a transaction's *writable* accounts back into the DB.
fn commit(&mut self, tx: &Transaction, working: &[Account]) {
for (meta, acct) in tx.accounts.iter().zip(working) {
if meta.is_writable {
self.accounts.insert(meta.key, acct.clone());
}
}
}

Notice what commit does not do: it never writes back a read-only account. The is_writable flag from the account meta — the same flag the scheduler used to decide conflicts — is reused here to decide what lands in the database. Read-only accounts are handed to the program so it can look at them, then discarded unchanged.

Rollback is free — you just don’t commit

Section titled “Rollback is free — you just don’t commit”

The most elegant thing about this pattern is that failure handling costs zero extra code. A transaction mutates its own clone. If the program returns Err, the runtime skips commit, and the clone goes out of scope and is dropped. The live database was never touched. The transaction rolled back — not because we wrote an undo log, but because we never wrote anything in the first place.

let mut working = self.working_set(tx);
let r = Self::run_tx(&self.programs, self.compute_per_tx, tx, &mut working);
if r.is_ok() {
self.commit(tx, &working); // success: writable accounts land in the DB
}
// failure: `working` is dropped here; the DB is exactly as it was
results.push(r);

That block is run_sequential, run over every transaction in order. This is the “obviously correct” baseline: one transaction at a time, no concurrency, nothing subtle. It is our oracle. Whatever the parallel path does, its final database must match this one.

run_parallel: rayon over disjoint working sets

Section titled “run_parallel: rayon over disjoint working sets”

Now the reason we did all of that. Sequential execution wastes every core but one. To use the whole machine we schedule into batches and run each batch in parallel — but a batch runs in parallel safely only because of the working-set pattern.

pub fn run_parallel(&mut self, txs: &[Transaction]) -> Vec<Result<()>> {
let batches = schedule(txs);
let mut results: Vec<Result<()>> = (0..txs.len()).map(|_| Ok(())).collect();
for batch in &batches {
// 1. Gather every tx's working set (sequential reads of the DB).
let mut work: Vec<Vec<Account>> =
batch.iter().map(|&i| self.working_set(&txs[i])).collect();
let programs = &self.programs;
let compute = self.compute_per_tx;
// 2. PARALLEL: rayon hands out one disjoint `&mut Vec<Account>` per
// element. The batch is conflict-free, so no two threads can ever
// reach the same writable account.
let batch_results: Vec<Result<()>> = batch
.par_iter()
.zip(work.par_iter_mut())
.map(|(&i, accts)| Self::run_tx(programs, compute, &txs[i], accts))
.collect();
// 3. COMMIT (sequential): each writable key in a conflict-free batch is
// touched by exactly one tx, so write-back never clobbers.
for (slot, &i) in batch.iter().enumerate() {
if batch_results[slot].is_ok() {
self.commit(&txs[i], &work[slot]);
}
}
for (slot, r) in batch_results.into_iter().enumerate() {
results[batch[slot]] = r;
}
}
results
}

Read it as three phases per batch:

  1. Gather — build one working set per transaction. Each transaction owns its own Vec<Account>; the vectors are separate allocations. This phase reads the database sequentially, which is fine — reads are cheap and never race.
  2. Execute in parallelwork.par_iter_mut() is the whole trick. rayon splits the vector of working sets and gives each worker thread a distinct &mut Vec<Account>. Because the batch is conflict-free, two working sets may share a read-only account (each has its own clone of it) but can never share a writable one. Each thread runs its transaction against its own sandbox.
  3. Commit sequentially — write the successful transactions’ writable accounts back into the database, one at a time. We do this on a single thread so two write-backs can never interleave — though in a conflict-free batch each writable key is owned by exactly one transaction, so there is nothing to interleave anyway.
batch = [t0(writes A), t1(writes B), t3(writes C)] ← conflict-free
gather: work = [ [A], [B], [C] ] three separate allocations
execute: thread0→&mut[A] thread1→&mut[B] thread2→&mut[C] (par_iter_mut)
commit: A ⇐ work[0]; B ⇐ work[1]; C ⇐ work[2] (sequential write-back)

Under the hood — why the data race is unrepresentable

Section titled “Under the hood — why the data race is unrepresentable”

There are two independent guardrails here, and they enforce the same invariant at two different levels. That redundancy is the point.

The scheduler’s guarantee is a runtime property: no batch contains two transactions that share a writable account. We proved that when we built schedule — a later writer always lands in a strictly higher batch than the writers before it.

The compiler’s rule is shared XOR mutable, checked at compile time: you may have many &T or exactly one &mut T, never both. rayon’s par_iter_mut is typed to produce disjoint &mut references — one per element — and the type system forbids any thread from obtaining a second mutable reference to the same slot. This is the borrow checker’s “aliasing XOR mutation” rule, lifted from a single variable to a whole account.

So the data race that “just run these on threads” invites in C is not merely avoided here — it is unrepresentable. To write it you would have to hand two threads a &mut to the same account, and there is no line of safe Rust that produces such a pair. The scheduler makes the collision impossible in the problem domain; the compiler makes it impossible in the code. Belt and suspenders, and both are free.

This is why solmini reaches for rayon and never for a Mutex. A lock is what you use when you can’t prove non-interference and must serialize access at runtime. We proved it at schedule time, so there is nothing to lock.

Proving it: parallel must equal sequential

Section titled “Proving it: parallel must equal sequential”

A parallel result you can’t trust is worse than a slow one. So the correctness test is blunt: run the same transactions through both paths on two identically-funded runtimes and assert the final databases are identical.

let mut seq_rt = build(); // fund N payers, N payees
seq_rt.run_sequential(&txs);
let mut par_rt = build(); // same funding, fresh runtime
par_rt.run_parallel(&txs);
// Every payee ends with the same balance under both paths.
let same = (0..N).all(|i| {
let payee = Pubkey::from_seed(&format!("payee{i}"));
seq_rt.balance(&payee) == par_rt.balance(&payee)
});
assert!(same);

If any scheduling or commit bug existed, it would surface here as a divergence: a transaction that observed a stale account, a write-back that clobbered another, an ordering that flipped. The sequential path is the oracle; the parallel path either matches it exactly or is rejected.

Correctness proven, we make the payoff visible. In fast tests compute_per_tx is 0, so run_tx is nearly instant and the scheduling overhead would dominate — you would see no speedup, because there is no work to parallelize. The demo raises compute_per_tx so each transaction burns a fixed number of hashes, standing in for the real per-transaction cost: signature verification plus program execution.

const N: usize = 1_200;
const COMPUTE: u64 = 1_000; // per-tx hash spin ≈ sig-verify + exec
// N fully independent transfers across 2N distinct accounts → ONE batch.
let txs: Vec<Transaction> = (0..N)
.map(|i| Transaction::transfer(
Pubkey::from_seed(&format!("payer{i}")),
Pubkey::from_seed(&format!("payee{i}")),
1,
))
.collect();

Because all N transfers touch distinct accounts, the scheduler drops them into a single parallel batch. Time both paths and the wall clock tells the story:

1200 independent transfers scheduled into 1 batch(es)
sequential: 1200 × COMPUTE hashes, one core
parallel: 1200 × COMPUTE hashes, spread across all cores
speedup: ~Nx, bounded by core count (varies by machine; not a benchmark)

The speedup is bounded above by your core count — this is Amdahl’s law made concrete: the parallel fraction here is the per-transaction compute, and the serial fraction is the gather-and-commit around it. As of 2024 a laptop with 8 cores will show a several-fold speedup on this workload; the exact figure depends entirely on the machine, which is why the demo prints it as an observation, not a benchmark. The shape is the lesson: the same transactions, the same final state, a fraction of the wall-clock time.

Flip the workload to N transfers that all drain one hot account and the scheduler produces N single-transaction batches — the parallel path degrades to the sequential one, and rightly so. That contention ceiling is a story for the Sealevel part; here it is enough to see that safe parallelism is real when the work is independent, and honestly absent when it isn’t.

  • Why does it exist? The Runtime exists to turn the scheduler’s promise into throughput: it is the machine that owns the account database, dispatches to stateless programs, and runs conflict-free batches across every core instead of leaving them idle.
  • What problem does it solve? It closes the gap between “these transactions don’t conflict” and “so run them on separate threads without corrupting the ledger or losing rollback semantics” — via the clone-then-commit working set, which gives isolation and free rollback in one move.
  • What are the trade-offs? Each transaction clones its declared accounts (bounded, but not free) and commit is serialized (a small serial tail on every batch). In exchange you get parallel execution with no locks and a rollback that costs nothing. On highly-contended workloads the batches shrink to one transaction and the speedup vanishes — the runtime is only as parallel as the traffic allows.
  • When should I avoid it? When the workload is dominated by a few hot accounts (an order book, a single global counter), or when transactions are so cheap that scheduling and cloning overhead swamp the work saved. Then a simpler sequential runtime, or per-account state redesign, wins.
  • What breaks if I remove it? Remove the working-set clone and transactions mutate the live database directly: rollback needs an undo log, and parallel threads race on shared accounts. Remove the sequential baseline and you lose the oracle that proves the parallel path correct — every scheduling bug becomes a silent state corruption instead of a failed assertion.
  1. A transaction runs against its working set and the program returns Err. Walk through what happens to the live account database, and explain why no rollback code is needed.
  2. In commit, why does the loop check meta.is_writable before writing an account back? What would go wrong if it wrote every account in the working set?
  3. run_parallel uses par_iter_mut() over the working sets but keeps commit on a single thread. Give the reason each of those two choices is safe.
  4. There are two separate guarantees that stop two threads from touching the same writable account — one from the scheduler, one from the compiler. Name each and explain how they enforce the same invariant.
  5. The speedup demo sets compute_per_tx to a non-zero value and schedules N independent transfers into one batch. Why is a non-zero compute_per_tx necessary to see a speedup, and what happens to the batch count if all N transfers instead share one account?
Show answers
  1. The database is left exactly as it was. The transaction only ever mutated its own owned clone (its working set); on Err, the runtime skips commit, the clone goes out of scope and is dropped, and nothing was ever written to the live HashMap. Rollback is the absence of a commit, so no undo log or explicit revert is needed — the isolation of the clone provides it for free.
  2. commit writes back only the accounts the transaction declared writable, because read-only accounts were handed to the program only for inspection and must not change. Writing every account back would persist any accidental mutation to a read-only account and, worse, would let a transaction silently overwrite state it never had permission to modify — breaking the same is_writable contract the scheduler relied on to decide conflicts.
  3. par_iter_mut() is safe because the batch is conflict-free: rayon hands each thread a disjoint &mut working set, and no two working sets in a conflict-free batch share a writable account, so no two threads can reach the same one. Sequential commit is safe because write-backs happen one at a time on a single thread, so they cannot interleave — and in a conflict-free batch each writable key is owned by exactly one transaction anyway, so there is nothing to clobber.
  4. The scheduler’s guarantee (runtime level): schedule never places two transactions that share a writable account in the same batch, because a later writer is always pushed to a strictly higher batch. The compiler’s rule (compile time): shared XOR mutablepar_iter_mut produces disjoint &mut references and the borrow checker forbids two mutable references to the same slot. Both enforce the invariant “at most one writer per account at a time” — one in the problem domain, one in the type system — making the data race unrepresentable.
  5. With compute_per_tx == 0, run_tx does almost no work, so scheduling and cloning overhead dominate and there is nothing to parallelize — the parallel path can’t beat sequential because the work itself is negligible. A non-zero value makes each transaction spend real CPU (standing in for sig-verify + execution), so spreading it across cores produces a visible wall-clock win. If all N transfers share one account they all conflict, and the scheduler produces N single-transaction batches — the parallel path degrades to sequential and the speedup disappears.