v0.5.0 — Snapshots + Log Compaction
Pre-releaseraft-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
RaftLogtrait gains three methods.snapshot_indexandsnapshothave
defaults;apply_snapshotdefaults to an error, so a custom backend compiles
but must implement it to support snapshots. MessagegainsInstallSnapshot/InstallSnapshotReplyvariants (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 benchAll 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 framingMSRV: Rust 1.85 (edition 2024).
Documentation
Full diff: v0.4.0...v0.5.0.
Changelog: CHANGELOG.md.