Skip to content
Select theme

Persistence with a snapshot

Step 6 of 10. Step 5 ended at the tag mini-ch05 with a deadline on every key: EXPIRE and TTL, lazy expiry on the read path, and a sweeper reclaiming the rest once a second. This step starts from that tag and ends at mini-ch06. A SAVE command writes the whole store to disk, the next boot reads the file back before it opens its port, and deadlines survive the trip — a key with 100 seconds left keeps shrinking through a restart instead of coming back fresh. The server can die now, and the keys do not.

Step 5’s data model said it outright: the deadline is an Instant, and an Instant is process-relative. It counts from an arbitrary point chosen when the machine booted, so its raw value means nothing to the next process — write one to disk and the file holds a number no reader can place on any clock. Storing “seconds left” instead does not survive contact with the real server either: 100 seconds of remaining TTL written at save time would come back as 100, and the real server comes back with less.

What the real server actually does, captured on redis-server 7.2.4 before any of the Rust below was written:

Event Real server
SAVE +OK\r\n, and the reply waits for the dump to finish
BGSAVE +Background saving started\r\n, and the reply returns at once
TTL 100, SAVE, 3 seconds of downtime, restart TTL answers 97
restart with a saved database Done loading RDB, keys loaded: 1, keys expired: 0.
corrupt dump file at startup # Short read or OOM loading DB. Unrecoverable error, aborting now. — the process exits

The 97 is the whole design in one line. The file carries a moment on the shared timeline, not a remaining duration: the real server’s RDB stores deadlines as milliseconds since the Unix epoch, and downtime comes out of every TTL because the shared clock did not stop.

The clone copies that. The live Entry keeps its monotonic Instant — step 5’s reason, that an adjusted system clock cannot move a key’s expiry, still holds for everything the running server does. The conversion happens only at the edge:

  • on the way out, the remaining time is measured on the monotonic clock and pinned to the wall clock: now_ms + (deadline - now), in milliseconds since the epoch;
  • on the way in, the reverse: Instant::now() + (stored_ms - now_ms).

Both directions take the same (now, now_ms) pair, one from each clock, captured at the same instant — that pairing is what keeps the two scales consistent.

Length-prefixed throughout, like the RESP frames of step 2:

mini.rdb
MRDB 4 bytes magic
01 1 byte format version
xx xx xx xx 4 bytes key count, u32 little-endian
then, per key:
xx xx xx xx 4 bytes key length, u32 LE
.... N bytes key
00 1 byte value kind: 0 = string, 1 = list
kind 0: 4 bytes length, then the bytes
kind 1: 4 bytes item count, then per item 4 bytes length + bytes
00|01 1 byte deadline flag: 0 = none, 1 = deadline follows
xx × 8 8 bytes deadline: milliseconds since the Unix epoch, u64 LE

The magic and the version byte exist for the file’s reader, not its writer. The writer is this binary and knows the format; the reader might be a future one — or the file might never have been a snapshot at all. Both are refused before a single record is parsed.

The whole round trip, run over three keys — one string with no deadline, one list, one string with 100 seconds left — and then over a file with one byte missing:

3 key(s), 86 bytes
loaded back after 40 seconds of downtime:
name -1
queue -1
session 60
truncated: unexpected end of snapshot

Three things worth pausing on. The session row is the point of the whole step: 100 seconds saved, 40 seconds of downtime, 60 left — the same answer the real server gave above, scaled. The truncation is caught by the bounds checks in get_byte and friends, not by trusting the header’s count: every read either fits in the buffer or ends the load with an error. And the name and queue rows answer -1, not 0, because a snapshot with no deadline is a key with no deadline — the Option from step 5 survives the file.

One new variant, at the end of the enum:

src/main.rs — the new variant
enum Command {
Ping,
Echo(Vec<u8>),
Set(Vec<u8>, Vec<u8>),
Get(Vec<u8>),
Del(Vec<Vec<u8>>),
LPush(Vec<u8>, Vec<Vec<u8>>),
LRange(Vec<u8>, i64, i64),
Expire(Vec<u8>, i64),
Ttl(Vec<u8>),
Save,
}

And because SAVE takes nothing, its arm can only match the empty argument list:

src/main.rs — parsing
// No arguments, ever: the real server takes none and dumps
// the whole keyspace. Captured on redis-server 7.2.4 —
// `SAVE` with an argument answers the wrong-arity error.
b"SAVE" => match args {
[] => Ok(Command::Save),
_ => Err(wrong_args(name)),
},

The command arm. SAVE is synchronous on purpose, like the real server’s: the dump happens inside the command and every other client waits for it:

src/main.rs — the command
Command::Save => {
// Synchronous on purpose, like the real server's SAVE: the
// dump happens inside the command, and every other client
// waits for it. Captured on redis-server 7.2.4 — `SAVE`
// answers +OK, `BGSAVE` answers
// `Background saving started` and returns at once.
match save_snapshot(&db, SNAPSHOT_PATH) {
Ok(count) => {
println!("saved {count} key(s) to {SNAPSHOT_PATH}");
Frame::Simple("OK".to_string())
}
Err(err) => Frame::Error(format!("ERR {err}")),
}
}

The lock is held only while the entries are read into the buffer; the file write happens after it drops, so the critical section stays a couple of map operations long. And a missing file at startup is Ok(None), not an error:

src/main.rs — the file
/// Where the snapshot lives, relative to the working directory. The real
/// server's default is `dump.rdb`; the clone keeps its own name so it
/// cannot clobber a real server's snapshot when both run in the same
/// directory.
const SNAPSHOT_PATH: &str = "mini.rdb";
/// `SAVE`, end to end. The entries are read under the lock and the file
/// is written after it drops, so the critical section stays a couple of
/// map operations long.
fn save_snapshot(db: &Db, path: &str) -> Result<usize, String> {
let (bytes, count) = {
let store = db.lock().unwrap();
snapshot(&store, Instant::now(), unix_ms())
};
std::fs::write(path, &bytes).map_err(|err| err.to_string())?;
Ok(count)
}
/// The startup half. `Ok(None)` is no snapshot yet — the normal state
/// of a server's first boot, not an error.
fn load_snapshot(path: &str) -> Result<Option<HashMap<Vec<u8>, Entry>>, String> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.to_string()),
};
restore(&bytes, Instant::now(), unix_ms()).map(Some)
}

And main, which loads before the port opens — the real server also loads before it accepts. A missing file is the normal case for a first boot and costs nothing; a corrupt one costs a warning, not a crash:

src/main.rs — main
let db = new_db();
// The startup half of persistence: whatever the last run saved comes
// back, deadlines re-anchored to this boot's clocks, and the port
// only opens after — the real server also loads before it accepts.
// A corrupt file costs a warning, not a crash. The real server
// refuses to start instead ("Short read or OOM loading DB.
// Unrecoverable error, aborting now.", captured on 7.2.4); the
// divergence is deliberate, because a dev server whose snapshot is
// lost should still come up.
match load_snapshot(SNAPSHOT_PATH) {
Ok(Some(entries)) => {
let count = entries.len();
db.lock().unwrap().extend(entries);
println!("loaded {count} key(s) from {SNAPSHOT_PATH}");
}
Ok(None) => {}
Err(err) => eprintln!("ignoring snapshot: {err}"),
}

That last branch is a divergence from the real server, stated rather than hidden: a corrupt dump makes redis-server 7.2.4 refuse to start — a production server with data worth keeping should not silently come up empty — while the clone prints ignoring snapshot: … and serves. A dev server that lost its snapshot should still come up.

The binary hardcodes 127.0.0.1:6379, which the real redis-server owns, so the same binary was rebuilt on a scratch port and run from an empty directory, with redis-cli pointed at it:

$ redis-cli -p 7781 set session token
OK
$ redis-cli -p 7781 lpush queue a b
(integer) 2
$ redis-cli -p 7781 expire session 100
(integer) 1
$ redis-cli -p 7781 ttl session
(integer) 100
$ redis-cli -p 7781 save
OK

The server’s own stdout shows the dump:

listening on 127.0.0.1:7781
saved 2 key(s) to mini.rdb

The file is 64 bytes — nine of header, thirty for session, twenty-five for queue. Then the process is killed — the clone has no SHUTDOWN command — and started again from the same directory:

loaded 2 key(s) from mini.rdb
listening on 127.0.0.1:7781
$ redis-cli -p 7781 get session
"token"
$ redis-cli -p 7781 ttl session
(integer) 98
$ redis-cli -p 7781 lrange queue 0 -1
1) "b"
2) "a"

Two seconds of downtime came out of the deadline, exactly as the real server’s did. The list came back whole, including its order: LPUSH put b at the head before the save, and it is still at the head after the load.

Sixty-two tests, up from fifty: the step-5 set carried over unchanged, plus twelve for the snapshot. The round trip is pinned at three levels — values, deadlines, and the file itself: every value kind survives snapshot then restore, a 100-second deadline comes back as 100 and as 60 after forty simulated seconds of downtime, a key expired while the server was down is dropped at load, an already-expired key is not written, truncated bytes and a wrong magic and an unsupported version are all errors, and save_snapshot/load_snapshot round-trip through a real file in the system temp directory. SAVE itself is tested through execute — it answers +OK, writes the file, and refuses an argument.

$ cargo test
Compiling mini-redis v0.1.0 (/Users/rango/www/hackingwithrust/code/mini-redis)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.75s
Running unittests src/main.rs (/Volumes/RustBuild/tracks/debug/deps/mini_redis-be061b86284cc959)
running 62 tests
test tests::a_frame_in_two_pieces_is_incomplete_not_invalid ... ok
test tests::a_snapshot_round_trips_every_value_kind ... ok
test tests::a_key_expired_while_down_is_dropped_at_load ... ok
test tests::a_deadline_survives_the_round_trip ... ok
test tests::a_non_positive_ttl_on_a_missing_key_answers_zero ... ok
test tests::a_key_with_no_deadline_answers_minus_one ... ok
test tests::a_snapshot_with_the_wrong_magic_is_an_error ... ok
test tests::a_non_positive_ttl_deletes_the_key ... ok
test tests::a_deadline_that_has_arrived_expires_the_key_on_read ... ok
test tests::an_empty_array_is_not_a_command ... ok
test tests::a_truncated_snapshot_is_an_error ... ok
test tests::an_expire_time_past_the_cutoff_is_refused ... ok
test tests::an_expired_key_is_not_written ... ok
test tests::an_unknown_command_lists_its_arguments ... ok
test tests::an_unknown_command_caps_its_argument_list_at_128_bytes ... ok
test tests::an_unsupported_version_is_an_error ... ok
test tests::bytes_that_are_not_resp_are_invalid ... ok
test tests::del_counts_only_the_keys_that_existed ... ok
test tests::downtime_shrinks_the_ttl_the_way_it_does_on_the_real_server ... ok
test tests::echo_round_trips_through_the_codec ... ok
test tests::echo_with_the_wrong_number_of_arguments_is_refused ... ok
test tests::error_messages_quote_the_command_name_as_it_was_sent ... ok
test tests::a_buffered_second_frame_needs_no_read ... ok
test tests::every_argument_must_be_a_bulk_string ... ok
test tests::every_frame_type_round_trips ... ok
test tests::expire_on_a_missing_key_answers_zero ... ok
test tests::expire_reads_its_argument_the_way_the_real_server_does ... ok
test tests::expire_replaces_an_earlier_deadline ... ok
test tests::expire_then_ttl_reports_the_seconds_left ... ok
test tests::expire_with_the_wrong_number_of_arguments_is_refused ... ok
test tests::expire_works_on_a_list_key ... ok
test tests::get_on_a_list_is_a_wrongtype_error ... ok
test tests::get_on_a_missing_key_answers_null ... ok
test tests::a_protocol_error_ends_the_connection_without_a_reply ... ok
test tests::load_snapshot_answers_none_when_the_file_is_missing ... ok
test tests::lpush_needs_at_least_one_value ... ok
test tests::lpush_on_a_string_key_is_a_wrongtype_error ... ok
test tests::lpush_on_an_expired_key_starts_a_new_list ... ok
test tests::a_half_sent_command_does_not_block_another_connection ... ok
test tests::lpush_prepends_each_value_in_order ... ok
test tests::lrange_on_a_missing_key_answers_an_empty_array ... ok
test tests::lrange_on_a_string_key_is_a_wrongtype_error ... ok
test tests::lrange_reads_a_slice_the_way_the_real_server_clamps_it ... ok
test tests::one_decode_consumes_exactly_one_frame ... ok
test tests::lrange_with_a_non_integer_index_is_refused ... ok
test tests::ping_with_an_argument_echoes_it ... ok
test tests::save_with_an_argument_is_refused ... ok
test tests::set_clears_the_deadline ... ok
test tests::set_replaces_a_list_without_an_error ... ok
test tests::set_then_get_round_trips ... ok
test tests::the_exact_bytes_the_real_redis_cli_sends_for_ping ... ok
test tests::ping_over_a_real_socket ... ok
test tests::the_sweeper_removes_only_the_keys_whose_deadline_has_passed ... ok
test tests::pipelined_commands_are_all_answered ... ok
test tests::ttl_on_a_missing_key_answers_minus_two ... ok
test tests::save_answers_ok_and_writes_the_file ... ok
test tests::save_then_load_round_trips_through_a_file ... ok
test tests::ttl_rounds_to_the_nearest_second ... ok
test tests::ttl_with_the_wrong_number_of_arguments_is_refused ... ok
test tests::two_connections_share_one_store ... ok
test tests::the_sweeper_task_reclaims_an_expired_key_on_its_own ... ok
test tests::a_frame_split_across_two_reads_is_reassembled ... ok
test result: ok. 62 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s

The order the tests print in varies between runs; the set does not. The Running line names this machine’s shared build cache; elsewhere it reads target/debug/deps/mini_redis-….

  • BGSAVE. The real server forks a child process and answers at once; the clone has one process and no fork, so SAVE blocks — the same trade the real server’s own SAVE makes.
  • Auto-save points. A real server configured with save 900 1 dumps on its own after enough changes; the clone saves only when told.
  • Durability. std::fs::write hands the bytes to the operating system and returns; nothing forces them through to the disk. A power cut between the two can lose the file’s tail. The real server has the same shape, with the fsync policy configurable.
  • The RDB format itself. Compression, checksums, auxiliary fields — all reduced here to a magic string, a version byte, and length prefixes.
  • A matching corrupt-file startup. The clone warns and starts empty; the real server refuses to start. Deliberate, and stated above.

Step 7, Pub/sub messaging, adds subscribers: SUBSCRIBE and PUBLISH, channels, and fan-out to every connection listening. It is not written yet, so it gets no link — the track overview carries the full list as articles land.