Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Event Timelines

The previous chapter introduced StateHandle with publish/get semantics. Workloads publish a snapshot, invariants read it. But snapshots only show the current value. The history is gone. If a balance was 100, then 50, then 100 again, a snapshot-based invariant sees 100 and declares everything fine. The dip to 50 is invisible.

Temporal properties need the full sequence. Monotonicity (a term number never decreased), causal ordering (the lock was acquired before the write), conservation over time (total money stayed constant through a sequence of transfers). These require an append-only log, not a mutable register.

That is what event timelines provide.

Emitting Events

Inside a workload or process, call ctx.emit() with a timeline name and an event value:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
struct TransferEvent {
    from: String,
    to: String,
    amount: u64,
}

// Inside a workload's run() method
ctx.emit("transfers", TransferEvent {
    from: "alice".into(),
    to: "bob".into(),
    amount: 50,
});
}

Each entry is automatically wrapped in a TimelineEntry<T>:

#![allow(unused)]
fn main() {
pub struct TimelineEntry<T> {
    pub event: T,       // your payload
    pub time_ms: u64,   // simulation time (auto-captured from ctx.time().now())
    pub source: String, // emitter IP (auto-captured from ctx.my_ip())
    pub seq: u64,       // global monotonic sequence number
}
}

The seq field deserves attention. It is a single counter shared across all timelines in the StateHandle. If timeline A gets seq 0 and timeline B gets seq 1, you know A’s event was emitted first, even if both have the same time_ms. This gives you a total ordering over all events in the simulation.

Reading Timelines

Workloads read timelines through ctx.timeline(). Invariants read them through state.timeline(). Both return an Option<Timeline<T>> that is None if no events have been emitted to that key yet.

#![allow(unused)]
fn main() {
impl Invariant for TransferOrderInvariant {
    fn name(&self) -> &str { "transfer_ordering" }

    fn check(&self, state: &StateHandle, _sim_time_ms: u64) {
        let Some(tl) = state.timeline::<TransferEvent>("transfers") else {
            return; // no transfers yet
        };

        // Check monotonicity: transfer times never go backwards
        let entries = tl.all(); // zero-copy borrow
        for window in entries.windows(2) {
            assert_always!(
                window[1].time_ms >= window[0].time_ms,
                format!("transfer time went backwards: {} -> {}",
                    window[0].time_ms, window[1].time_ms)
            );
        }
    }
}
}

Timeline<T> has four read methods:

MethodReturnsUse case
all()Ref<Vec<TimelineEntry<T>>>Full scan, zero-copy
since(index)Vec<TimelineEntry<T>>Incremental processing from a cursor
last()Option<TimelineEntry<T>>Most recent event
len()usizeCount check

For invariants that run after every event, since() avoids rescanning the entire history. Store the cursor between calls and only process new entries.

The Fault Timeline

Every fault the simulator injects, from network partitions to storage corruption to process kills, is automatically emitted to a well-known timeline called "sim:faults". No workload code needed.

#![allow(unused)]
fn main() {
use moonpool_sim::{SimFaultEvent, SIM_FAULT_TIMELINE};

fn check(&self, state: &StateHandle, _sim_time_ms: u64) {
    let Some(faults) = state.timeline::<SimFaultEvent>(SIM_FAULT_TIMELINE) else {
        return;
    };

    // Count how many times each process was killed
    let kill_count = faults.all().iter()
        .filter(|e| matches!(&e.event, SimFaultEvent::ProcessForceKill { .. }))
        .count();

    assert_always!(
        kill_count <= 10,
        format!("too many kills in one iteration: {}", kill_count)
    );
}
}

SimFaultEvent covers 17 fault variants across three categories:

  • Process lifecycle: ProcessGracefulShutdown, ProcessForceKill, ProcessRestart
  • Network: PartitionCreated, PartitionHealed, ConnectionCut, CutRestored, HalfOpenError, SendPartitionCreated, RecvPartitionCreated, RandomClose, PeerCrash, BitFlip
  • Storage: StorageReadFault, StorageWriteFault, StorageSyncFault, StorageCrash, StorageWipe

The real power is correlation. When an application-level invariant fires, cross-reference the fault timeline to understand what the infrastructure was doing at that moment. A conservation law violation at t=5000 that coincides with a ProcessForceKill at t=4980 tells a very different story than one with no faults nearby.

Snapshots vs Timelines

Use both. They solve different problems.

publish()/get() stores the latest snapshot. Good for properties about the current state: “the sum of all balances equals total deposits minus total withdrawals.” The conservation law invariant from the previous chapter is a snapshot invariant.

emit()/timeline() stores append-only history. Good for properties about how the state changed over time: “no message was received before it was sent,” “the leader term never decreased,” “every debit has a matching credit.”

A well-designed simulation typically publishes a reference model for snapshot invariants and emits events for temporal invariants. The fault timeline adds infrastructure context for free.