OpenRaft Storage Best Practices: A Complete Implementation Guide

OpenRaft storage implementations require three core traits—RaftLogReader, RaftLogStorage, and RaftStateMachine—with strict durability guarantees, atomic appends, and proper snapshot handling to ensure consensus safety.

The databendlabs/openraft repository provides a flexible, trait-based storage abstraction that separates the Raft consensus protocol from underlying persistence mechanisms. Implementing OpenRaft storage correctly is critical for maintaining safety properties and achieving high performance in distributed systems.

Understanding OpenRaft Storage Architecture

OpenRaft delegates all persistence concerns to user-provided storage implementations through a well-defined trait hierarchy. Understanding these boundaries is essential before writing production storage code.

The Three Core Storage Traits

OpenRaft storage implementations must provide three distinct capabilities:

  1. RaftLogReader – Provides read access to log entries and the current vote. Implemented by types that can iterate over the replicated log.

  2. RaftLogStorage – Handles durable persistence of log entries, votes, snapshots, and log truncation. This trait requires append operations to be atomic and durable before invoking the IOFlushed callback.

  3. RaftStateMachine – Applies committed commands to the application state, builds and installs snapshots, and exposes the current applied state. In OpenRaft, the state machine type also implements RaftSnapshotBuilder.

Type Configuration and Generic Implementations

All storage implementations must be generic over the application's TypeConfig. The declare_raft_types! macro in openraft/src/type_config.rs defines the request/response types, node ID, and leader ID implementation. Storage code must use these associated types rather than concrete types to remain compatible with the generic Raft core.

Implementing the Log Store

The log store is the most performance-critical component, handling every write to the replicated log. Implementation mistakes here directly impact cluster availability and durability.

Core Requirements for Log Storage

Every OpenRaft log store must satisfy these invariants:

  • Atomic Append: The append method in RaftLogStorage must write all entries and invoke the IOFlushed callback only after the write is truly durable (fsync or WAL flush).
  • Consistent Reads: try_get_log_entries and limited_get_log_entries must return entries in strict index order without gaps.
  • Metadata Persistence: Vote and last_purged_log_id must survive restarts, typically stored as separate key/value pairs.
  • Non-blocking Operations: All methods are async; heavy IO should use C::spawn_blocking to avoid blocking the async runtime.

In-Memory Log Store Example

The reference in-memory implementation in stores/memstore/src/lib.rs demonstrates the trait contract:

impl RaftLogStorage<TypeConfig> for Arc<MemLogStore> {
    type LogReader = Self;

    async fn append<I>(&mut self, entries: I, callback: IOFlushed<TypeConfig>) -> Result<(), io::Error>
    where
        I: IntoIterator<Item = Entry<TypeConfig>> + OptionalSend,
    {
        // Serialize each entry as JSON and store in a BTreeMap.
        let mut log = self.log.write().await;
        for entry in entries {
            let s = serde_json::to_string(&entry)
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
            log.insert(entry.index(), s);
        }
        // Notify the Raft core that the write is durable.
        callback.io_completed(Ok(()));
        Ok(())
    }

    // … other required methods (get_log_state, purge, truncate_after, etc.) …
}

Key implementation details from stores/memstore/src/lib.rs lines 55-74:

  • The log map uses RwLock to allow concurrent readers while serializing writes.
  • JSON serialization ensures compatibility with the state machine snapshot format.
  • The IOFlushed callback signals durability immediately for in-memory storage.

Production-Grade RocksDB Implementation

For production deployments, the RocksDB example in examples/rocksstore/src/log_store.rs provides a durable, high-performance implementation:

async fn append<I>(&mut self, entries: I, callback: IOFlushed<C>) -> Result<(), io::Error>
where
    I: IntoIterator<Item = EntryOf<C>> + Send,
{
    for entry in entries {
        let id = id_to_bin(entry.index());
        self.db.put_cf(
            self.cf_logs(),
            id,
            serde_json::to_vec(&entry).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
        )?;
    }

    // Flush WAL in a background thread; the callback will be invoked when the OS flush finishes.
    let db = self.db.clone();
    std::thread::spawn(move || {
        let res = db.flush_wal(true).map_err(io::Error::other);
        callback.io_completed(res);
    });
    Ok(())
}

Critical patterns from examples/rocksstore/src/log_store.rs lines 81-122:

  • Separate column families (meta and logs) isolate metadata from log data.
  • Entries are serialized to JSON vectors for storage.
  • The WAL flush occurs in a dedicated thread to prevent blocking the async runtime, with IOFlushed invoked only after flush_wal(true) completes.

Building the State Machine and Snapshot System

The state machine represents the application-specific logic that processes committed Raft commands. Unlike the log store, which is generic to Raft, the state machine implements your business logic while conforming to OpenRaft's trait boundaries.

Command Application Logic

The apply method in RaftStateMachine receives a stream of committed entries and must apply them to the application state. From stores/memstore/src/lib.rs lines 100-130:

async fn apply<Strm>(&mut self, mut entries: Strm) -> Result<(), io::Error>
where
    Strm: Stream<Item = Result<EntryResponder<TypeConfig>, io::Error>> + Unpin + OptionalSend,
{
    let mut sm = self.sm.write().await;
    while let Some((entry, responder)) = entries.try_next().await? {
        sm.last_applied_log = Some(entry.log_id);
        let response = match entry.payload {
            EntryPayload::Blank => ClientResponse(None),
            EntryPayload::Normal(ref data) => {
                let previous = sm.client_status.insert(data.client.clone(), data.status.clone());
                ClientResponse(previous)
            }
            EntryPayload::Membership(ref mem) => {
                sm.last_membership = StoredMembership::new(Some(entry.log_id), mem.clone());
                ClientResponse(None)
            }
        };
        if let Some(responder) = responder {
            responder.send(response);
        }
    }
    Ok(())
}

Key implementation requirements:

  • The state machine uses RwLock to allow concurrent snapshot reads during application.
  • Each entry updates last_applied_log to track progress.
  • Membership changes are stored in last_membership to enable cluster topology recovery from snapshots.
  • The responder channel sends results back to the client once the entry is applied.

Snapshot Building Best Practices

Snapshots enable log compaction and efficient node recovery. The RaftSnapshotBuilder trait (implemented by the state machine type) creates point-in-time snapshots. From stores/memstore/src/lib.rs lines 109-135:

async fn build_snapshot(&mut self) -> Result<Snapshot<TypeConfig>, io::Error> {
    // Optional delay for testing.
    if let Some(d) = self.block.get_blocking(&BlockOperation::DelayBuildingSnapshot) {
        TypeConfig::sleep(d).await;
    }

    // Serialize the whole machine.
    let sm = self.sm.read().await;
    let data = serde_json::to_vec(&*sm)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;

    let last_applied_log = sm.last_applied_log;
    let last_membership = sm.last_membership.clone();

    // Create meta information.
    let meta = SnapshotMeta {
        last_log_id: last_applied_log,
        last_membership,
        snapshot_id: format!("snap-{}", uuid::Uuid::new_v4()),
    };

    // Store the snapshot for later retrieval.
    let snapshot = MemStoreSnapshot { meta: meta.clone(), data: data.clone() };
    *self.current_snapshot.write().await = Some(snapshot);

    Ok(Snapshot { meta, snapshot: Cursor::new(data) })
}

Critical snapshot patterns:

  • Consistency: Acquire the state machine read lock before serializing to ensure a consistent point-in-time view.
  • Metadata: Include last_log_id and last_membership in SnapshotMeta so Raft can verify snapshot validity and recover cluster membership.
  • Unique IDs: Generate unique snapshot IDs (e.g., using UUID) to prevent collision during concurrent operations.
  • Format: Use binary serialization (JSON, protobuf, or bincode) rather than text formats for production deployments.

Snapshot Installation and Recovery

When a lagging node needs to catch up or a new node joins, OpenRaft installs snapshots via the install_snapshot method. From stores/memstore/src/lib.rs:

async fn install_snapshot(
    &mut self,
    meta: &SnapshotMeta<TypeConfig>,
    snapshot: SnapshotDataOf<TypeConfig>,
) -> Result<(), io::Error> {
    let new_snapshot = MemStoreSnapshot {
        meta: meta.clone(),
        data: snapshot.into_inner(),
    };
    // Deserialize into the real machine.
    let new_sm: MemStoreStateMachine = serde_json::from_slice(&new_snapshot.data)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
    *self.sm.write().await = new_sm;
    *self.current_snapshot.write().await = Some(new_snapshot);
    Ok(())
}

Installation requirements:

  • Validation: Verify the snapshot format before replacing the state machine to prevent corruption.
  • Atomic Replacement: Update both the state machine and the cached snapshot atomically (under the write lock) to ensure consistency.
  • Error Handling: Return io::Error with InvalidData kind for deserialization failures to signal unrecoverable snapshot corruption.

Testing and Fault Injection

Robust OpenRaft storage implementations must handle adverse conditions including disk failures, network partitions, and slow IO. The reference MemLogStore in stores/memstore/src/lib.rs provides a BlockConfig mechanism for deterministic fault injection.

// In a test:
let block = BlockConfig::default();
block.set_blocking(BlockOperation::DelayBuildingSnapshot, Duration::from_millis(100));

Available fault injection points include:

  • DelayBuildingSnapshot: Simulates slow snapshot serialization to test Raft's timeout handling.
  • return_empty_limited_get: Forces limited_get_log_entries to return empty vectors, exercising the Raft core's retry logic.
  • enable_saving_committed: Toggles whether the log store remembers the last committed index, testing state machine recovery scenarios.

These mechanisms are extensively exercised in the repository's test suite (tests/tests/*) to verify correctness under failure conditions.

Performance Optimization Strategies

Optimizing OpenRaft storage requires balancing durability guarantees with throughput. The following strategies derive from production implementations in the codebase:

Situation Recommendation Implementation Details
High write throughput Batch entries in append and flush once per batch In examples/rocksstore/src/log_store.rs, collect entries into a vector before calling flush_wal(true)
Large state machines Use binary serialization formats (protobuf, bincode) instead of JSON The MemStore uses JSON for clarity, but production systems should replace serde_json::to_vec with binary codecs
Frequent compaction Promptly purge old logs and update last_purged_log_id Call purge when the state machine advances; store last_purged_log_id in the metadata column family
Multi-threaded workloads Use Arc<RwLock<…>> for the log map Allows concurrent limited_get_log_entries calls while serializing appends
Testing latency Inject delays via BlockConfig Verify Raft's retry logic handles slow IO gracefully

Summary

Implementing OpenRaft storage correctly requires careful attention to trait contracts and durability guarantees. Key takeaways include:

  • Implement both RaftLogReader and RaftLogStorage for the log store, and RaftStateMachine for the application state.
  • Persist vote and last_purged_log_id as separate metadata keys to survive restarts.
  • Ensure append invokes the IOFlushed callback only after writes are truly durable (post-fsync or WAL flush).
  • Use RwLock or equivalent to allow concurrent log reads while serializing writes.
  • Provide a snapshot builder that captures consistent state machine snapshots with proper SnapshotMeta including last_log_id and last_membership.
  • Implement install_snapshot to atomically replace state machine state and validate snapshot format integrity.
  • Use BlockConfig during testing to simulate slow IO, empty reads, and forced failures.

Frequently Asked Questions

What are the three main traits required for OpenRaft storage?

OpenRaft storage implementations must provide RaftLogReader for reading log entries and votes, RaftLogStorage for persisting log entries and metadata, and RaftStateMachine for applying commands and managing snapshots. These traits are defined in openraft/src/storage/mod.rs and separate concerns between the consensus engine and persistence layer.

How does OpenRaft ensure log entry durability?

Durability is enforced through the IOFlushed callback mechanism. When RaftLogStorage::append is called, the implementation must write entries to durable storage (such as RocksDB with WAL enabled or files with fsync) and only invoke callback.io_completed(Ok(())) after the operating system confirms the write is persistent. This ensures the Raft core never acknowledges a log entry until it is safely stored.

What is the purpose of the IOFlushed callback in OpenRaft storage?

The IOFlushed callback bridges asynchronous storage operations and the Raft core's durability requirements. It signals that previously appended entries have reached non-volatile storage and the Raft state machine can safely advance. Implementations must call io_completed with Ok(()) only after fsync or WAL flush completes, or with an error if the write fails, allowing Raft to handle the failure appropriately.

How should snapshots be handled in OpenRaft storage implementations?

Snapshots require implementing RaftSnapshotBuilder (typically on the state machine type) and the install_snapshot method. The builder must serialize the entire state machine state while holding a read lock to ensure consistency, producing a SnapshotMeta containing last_log_id and last_membership. The install_snapshot method must atomically replace the current state machine with the deserialized snapshot data, validating the format before updating internal state. Both operations should use Arc<RwLock<...>> for safe concurrent access during snapshotting.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →