Skip to content

v0.5.0 — Snapshots + Log Compaction

Pre-release
Pre-release

Choose a tag to compare

@jamesgober jamesgober released this 08 Jun 08:43
· 9 commits to main since this release

raft-io v0.5.0 — Snapshots + Log Compaction

Bounded log growth and fast catch-up. v0.5.0 adds snapshots: a node can
capture its state machine, compact the log behind it, and ship that snapshot to a
follower too far behind to replicate entry by entry. With this the protocol is
feature-complete except for membership changes (v0.6). A separate framing
feature adds pack-io wire encoding for messages. A property test that takes
snapshots throughout an adversarial, partitioned schedule found — and the fix
closed — a real post-compaction edge case before this tag shipped.

What is raft-io?

A from-scratch implementation of the Raft consensus algorithm, built as a clean,
embeddable library rather than a framework. The protocol core is a deterministic,
sans-I/O state machine: you feed a node events (logical ticks, inbound messages,
client proposals, snapshots) and it returns actions (send these messages, apply
this command, take or restore a snapshot). Time, networking, and storage are
injected through trait seams, which is what makes the core provable and
reproducible from a seed. It is the consensus layer above wal-db and the
coordination substrate for Hive DB clustering.

What's new in 0.5.0

Snapshots and log compaction

The log now has a compaction boundary. After a snapshot through index I, entries
up to I are dropped and (I, term_at(I)) becomes the log's new base —
term_at(I) still answers (so consistency checks at the boundary work) while
reads below it return None. The RaftLog trait gains snapshot,
apply_snapshot, and snapshot_index; both MemoryLog and WalLog implement
them, and WalLog persists a snapshot record and physically compacts the WAL file.

A snapshot-policy hint

Snapshotting is driven by the node but performed by the application — the state
machine is yours, so only you can serialize it. Set a threshold:

use raft_io::RaftConfig;

// Ask for a snapshot once 1024 entries pile up beyond the last one.
let cfg = RaftConfig::new(1, [2, 3]).with_snapshot_threshold(1024);
# assert_eq!(cfg.snapshot_threshold(), 1024);

When the applied log grows past the threshold, the node emits
Action::Snapshot { index, term }. The application serializes its state through
index and hands it back as Event::Snapshot { index, data }, and the node
compacts. 0 (the default) disables the hint, so snapshots are strictly opt-in.

InstallSnapshot — catching up a far-behind follower

When a follower needs an entry the leader has already compacted away, the leader
sends an InstallSnapshot (new Message variant) instead of an AppendEntries.
The follower installs it — the node emits Action::RestoreSnapshot { index, term, data } for the application to reset its state machine — and then resumes normal
tail replication. The whole flow, end to end:

use raft_io::{Action, Event, RaftConfig, RaftNode};

let mut node = RaftNode::new(RaftConfig::single(1).with_snapshot_threshold(4));
// … drive the node; on Action::Snapshot { index, .. } reply with the state …
# let index = 1;
let _ = node.step(Event::Snapshot { index, data: b"serialized state".to_vec() });

framing feature — typed wire encoding

The protocol stays transport-agnostic, but a transport needs some codec. Behind
the framing feature, framing::encode / framing::decode serialize a Message
with pack-io; the message types derive pack_io::Serialize / Deserialize.

# #[cfg(feature = "framing")] {
use raft_io::{framing, Message, RequestVote};

let msg = Message::RequestVote(RequestVote {
    term: 4, candidate: 2, last_log_index: 9, last_log_term: 3,
});
let bytes = framing::encode(&msg).unwrap();
assert_eq!(framing::decode(&bytes).unwrap(), msg);
# }

A decode failure is Error::Encoding, which a transport should treat like a
dropped message rather than a crash.

Testing found a real bug

tests/snapshot.rs includes a property test that takes snapshots throughout an
adversarial schedule of ticks, proposals, reordered deliveries, and partitions,
asserting no committed entry ever diverges. It surfaced a case where a stale,
reordered AppendEntries whose prev_log_index fell below a follower's
freshly-compacted boundary slipped through the head-of-log shortcut and triggered
a non-contiguous append. The follower's consistency check now accounts for the
snapshot boundary — it acknowledges that it already holds everything through the
boundary and lets the leader resend the tail. This is exactly why the adversarial
suite exists.

Alongside it: a deterministic test that a partitioned follower catches up via
snapshot then tail, a check that compaction never exceeds the applied index, and
framing round-trip tests for every message variant.

New example

  • snapshot_catchup — a node is isolated while the cluster commits and
    compacts a long run, then rejoins and is caught up by a snapshot.

Breaking changes

Pre-1.0, additive in spirit but two surface changes to note:

  • The RaftLog trait gains three methods. snapshot_index and snapshot have
    defaults; apply_snapshot defaults to an error, so a custom backend compiles
    but must implement it to support snapshots.
  • Message gains InstallSnapshot / InstallSnapshotReply variants (it is
    #[non_exhaustive], so matches already carry a wildcard arm).

Verification

Run on Windows x86_64, Rust stable; the same commands pass on Linux (WSL2
Ubuntu) and via the CI matrix:

cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo test --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo deny check
cargo audit
cargo build --examples --all-features
cargo bench

All green. Test counts at this tag:

  • --all-features: 80 unit + 13 integration / property tests
    (safety.rs, replication.rs, recovery.rs, snapshot.rs) + 46 doctests.

Property suites were additionally run at elevated case counts
(PROPTEST_CASES=1500) with no failures. loom is still not exercised: the core
is a single-threaded, owned state machine with no lock-free or shared-state path.

What's next

  • v0.6.0 — Membership changes + feature freeze. Single-server add/remove with
    joint-consensus-safe sequencing, leadership transfer, and the feature freeze.

Installation

[dependencies]
raft-io = "0.5"

# Optional features:
raft-io = { version = "0.5", features = ["persistence"] } # durable wal-db-backed log
raft-io = { version = "0.5", features = ["framing"] }     # pack-io wire framing

MSRV: Rust 1.85 (edition 2024).

Documentation


Full diff: v0.4.0...v0.5.0.
Changelog: CHANGELOG.md.