Skip to content

refactor: frame AOF and snapshot records with a checked length - #450

Merged
kacy merged 3 commits into
mainfrom
refactor/persistence-format
Nov 12, 2026
Merged

kacy merged 3 commits into
mainfrom
refactor/persistence-format

Conversation

@kacy

@kacy kacy commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Theme 6 from the persistence audit, plus the torn-tail and snapshot TTL bugs it makes fixable.

New file layout (version 5). The AOF and snapshots share a header and a frame format, in the new frame module:

header: [magic: 4B][version: 1B][flags: 1B][salt: 16B, only when encrypted]
frame:  [len: 4B][!len: 4B][body][crc32 of body: 4B]

Bugs this fixes

  • Torn tail: plaintext AOF records had no length, so a damaged length field read like a record cut short at the end of the file. Recovery then truncated every valid record after it. Now the inverted copy of the length tells the two apart. A torn last frame is dropped. A damaged length anywhere else stops the start with an error and leaves the file untouched.
  • Zeroed tail: a tail of zeros, which some filesystems leave after a crash, is now treated as torn. Before, it failed with "unknown record tag 0".
  • Snapshot TTLs: snapshots stored the TTL left, and recovery subtracted the time since the file's mtime. A snapshot restored from a backup therefore brought back keys that had expired. Entries now store the absolute unix-ms expiry (0 for none), as AOF records already did. The replica full sync uses the same encoding.

Simplifications

  • A Codec enum (plain or encrypted) replaces the #[cfg(feature = "encryption")] branches in the readers and writers. frame::Key is uninhabited without the feature, so the internal paths take Option<&Key> in every build. That also removed the key alias and cfg branches in recovery.
  • Old layouts are no longer read: v1 untagged snapshots, v2 plaintext, the v3 master-key cipher and the v4 envelope. Without known users there is nothing to migrate. An old file is refused at startup with "unsupported format version", and the writer marks it for rewrite.
  • Snapshots end with an empty frame, the entry count and a CRC over the entry bodies, so the writer no longer seeks back to patch the count. The same encoder writes files and the in-memory replica snapshot.
  • AofRecord::from_bytes and snapshot entries reject trailing bytes. CrcReader is gone.

Not done: the RecoveryOutcome enum from theme 6. After #423, RecoveryResult has two flags, and both lead to the same action.

Tests

  • Frame tests: a round trip, every cut of the last frame, a zeroed tail, a damaged length, zeros followed by data, a body that fails its CRC, and encrypted bodies with the right key, no key and the wrong key.
  • Recovery tests: a zeroed tail is cut off and later writes survive; a damaged length mid-file fails recovery and leaves the file untouched; keys that expired since the snapshot are left out.
  • Manual check with an encryption build: SET with TTLs, then BGSAVE, more writes and a restart. Every key comes back, the TTL key keeps its remaining time, a key that expired while the server was down stays gone, and starting without the key fails instead of starting empty.

Performance: AOF writes with everysec are unchanged within noise: 851k–863k against 854k–875k SET/s without pipelining, and 2.53M–2.67M against 2.55M–2.59M with a pipeline of 16.

@kacy
kacy merged commit 0bd46f6 into main Nov 12, 2026
12 checks passed
@kacy
kacy deleted the refactor/persistence-format branch November 12, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant