Skip to main content

kevy_persist/
aof.rs

1//! Append-only command log. Split out from `lib.rs` to keep that file
2//! under the 500-LOC house rule; the snapshot writer/reader stays there.
3
4use std::fs::{File, OpenOptions};
5use std::io::{self, BufWriter, Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9use kevy_resp::ArgvView;
10use kevy_store::Store;
11
12use crate::{estimate_multibulk_bytes, write_multibulk};
13
14/// 9-byte file-format header written at the start of every kevy-managed
15/// AOF. `replay_aof` strips it before parsing RESP, so
16/// non-kevy bytes accidentally written into the AOF path (e.g. a deploy
17/// pipeline redirecting shell stderr into the file) get the same loud
18/// rejection as any other corrupt frame. Legacy AOFs (no magic) still
19/// replay — the parser only consumes the magic if it sees it.
20///
21/// Public so host-mediated AOF sinks (a browser pump appending kevy
22/// frames to its own storage, for example) can stamp files that stay
23/// byte-compatible with kevy-written logs.
24pub const AOF_MAGIC: &[u8; 9] = b"KEVYAOF1\n";
25
26/// AOF write buffer capacity. `BufWriter`'s default is 8 KiB — a single
27/// 4 KiB value fills it in two writes, so the append path spends ~half
28/// its time in the `write` syscall (perf-measured: SET 4 KiB, 52% in
29/// `write`/`ksys_write`, on both tmpfs and ext4). MMKV's mmap append
30/// pays no syscall at all; a larger buffer amortises the write across
31/// many appends the same way, without changing durability — `EverySec`
32/// still flushes + fsyncs once a second, so the crash window is
33/// unchanged (≤ 1 s) regardless of buffer size. 256 KiB holds ~64 4 KiB
34/// appends per syscall; per-shard cost is one such buffer.
35pub(crate) const AOF_BUF_CAP: usize = 256 * 1024;
36
37/// When to fsync the AOF to disk.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Fsync {
40    /// fsync after every write — safest, slowest.
41    Always,
42    /// fsync at most once per second (call [`Aof::maybe_sync`] periodically).
43    EverySec,
44    /// Never fsync explicitly; leave it to the OS.
45    No,
46}
47
48/// An append-only command log. Each write command is appended as a RESP
49/// multi-bulk frame; [`crate::replay_aof`] re-applies them on startup.
50///
51/// Durability model (paired with snapshots): a snapshot taken at T0 plus
52/// the AOF of writes in (T0, now] reconstructs the current state. `SAVE`
53/// writes the snapshot then [`Aof::truncate`]s the log, so replay never
54/// double-applies.
55///
56/// Sizes (`size_bytes`, `size_at_last_rewrite`) drive auto-trigger of
57/// [`Aof::rewrite_from`] (BGREWRITEAOF) via the
58/// `auto_aof_rewrite_percentage` + `auto_aof_rewrite_min_size` knobs in
59/// `kevy_config`.
60#[derive(Debug)]
61pub struct Aof {
62    pub(crate) file: BufWriter<File>,
63    /// A begin marker has been written and its commit marker has not.
64    pub(crate) in_txn: bool,
65    pub(crate) path: PathBuf,
66    pub(crate) fsync: Fsync,
67    pub(crate) dirty: bool,
68    pub(crate) last_sync: Instant,
69    /// Estimated bytes currently in the AOF file (existing + appended since
70    /// open). Maintained without fstat() syscalls per append.
71    pub(crate) size_bytes: u64,
72    /// File size right after the most recent [`Self::rewrite_from`] (or
73    /// `Self::open` if never rewritten). Anchor for `auto_aof_rewrite_*`.
74    pub(crate) size_at_last_rewrite: u64,
75    /// Total rewrites successfully completed since open. Surfaced via INFO.
76    pub(crate) rewrites_total: u64,
77    /// Group-commit window: while `true`, an `Fsync::Always` `append` only
78    /// buffers (sets `dirty`) instead of fsyncing per command. The caller
79    /// brackets a batch of writes with [`Self::begin_group`] /
80    /// [`Self::end_group`] and `end_group` does the single fsync **before**
81    /// the batch's replies are sent — preserving "durable before reply"
82    /// while amortizing the per-command `flush()+sync_data()` syscalls.
83    /// Only the multi-command reactor entry points (pipelined socket reads,
84    /// cross-shard request batches) open a group; every other path keeps
85    /// the per-command fsync, so the default is always the safe one.
86    pub(crate) deferred: bool,
87    /// Non-blocking rewrite "diff buffer". While `Some`, every `append` also
88    /// tees its RESP frame here, so writes that land *during* an off-lock
89    /// rewrite are captured and replayed after the compacted snapshot. See
90    /// [`Self::begin_concurrent_rewrite`].
91    pub(crate) rewrite_tee: Option<Vec<u8>>,
92    /// Recycled tee buffer (two-phase rewrite): the worker returns each
93    /// appended generation's buffer CLEARED, and the next generation
94    /// grows into its warm pages instead of faulting a fresh range —
95    /// the reactor-vs-worker mmap churn convicted in the S5-E finding.
96    pub(crate) tee_spare: Option<Vec<u8>>,
97    /// The pre-swap log's graveyard hardlink, awaiting an off-thread
98    /// unlink (see `swap_image` — rename must not drop a multi-GB
99    /// inode's last link on the reactor).
100    pub(crate) swap_trash: Option<PathBuf>,
101    /// Off-thread swap window open: the driver holds queue drains and
102    /// fsyncs while the worker renames over the live path.
103    pub(crate) swap_hold: bool,
104    /// Where `open` quarantined a dropped tail, if it had to repair one —
105    /// surfaced so the store's open report can name the file.
106    open_quarantine: Option<PathBuf>,
107    /// When the last rewrite (or the open, if none yet) finished — the
108    /// anchor for [`RewritePolicy::interval_secs`].
109    pub(crate) last_rewrite_at: Instant,
110    /// The on-disk encoding this file speaks. New files and every rewrite
111    /// output are V2 (checksummed record envelopes); a pre-existing V1
112    /// file keeps appending V1 until its first rewrite upgrades it —
113    /// mixing formats within one file would corrupt it.
114    pub(crate) format: crate::AofFormat,
115    /// Reusable payload buffer for V2 envelope encoding (and the tee,
116    /// which is always V2 because the rewrite output it lands in is).
117    scratch: Vec<u8>,
118    /// `Some` = queued-append mode (RFC v3-aof-offload S1): encoded
119    /// record bytes accumulate here instead of hitting `file`, and the
120    /// DRIVER (the io_uring reactor) drains them via
121    /// [`Aof::take_pending`] as async write SQEs at
122    /// [`Aof::append_offset`]. `None` = every append writes
123    /// synchronously — today's behavior, and the epoll / test default.
124    ///
125    /// Contract for the driver: before any structural file operation
126    /// (truncate, rewrite finish, fsync-policy upgrade to Always) the
127    /// driver must have COMPLETED its in-flight writes; bytes still
128    /// queued HERE are flushed synchronously by those entry points as
129    /// an honest fallback, but bytes already taken are invisible to
130    /// this struct and only the driver can order them.
131    pub(crate) queue: Option<Vec<u8>>,
132    /// File offset where the NEXT taken chunk lands (queued mode):
133    /// advances as chunks are taken, so concurrent chunks carry
134    /// non-overlapping explicit offsets in their SQEs.
135    pub(crate) queued_offset: u64,
136    /// Monotone count of records ever pushed into `queue`. The uring
137    /// driver's Always reply-gate (S2) compares this watermark against
138    /// the fsync-proven durable watermark; unlike file offsets it never
139    /// resets across a rewrite swap, so held replies cannot wedge.
140    pub(crate) queued_seq: u64,
141}
142
143/// Handoff between the two halves of a non-blocking rewrite: the serialized
144/// keyspace image (produced under the store lock) and the temp path to spill
145/// it to (off-lock). See [`Aof::begin_concurrent_rewrite`].
146#[derive(Debug)]
147pub struct RewritePlan {
148    /// The compacted AOF image (magic + one command stream per key).
149    pub body: Vec<u8>,
150    /// Same-directory temp file to spill `body` to before the final swap.
151    pub tmp: PathBuf,
152    /// Keys captured in `body` (for the resulting [`RewriteStats`]).
153    pub keys: u64,
154}
155
156/// Result of an [`Aof::rewrite_from`] call. Surfaced by `BGREWRITEAOF` /
157/// `INFO persistence`.
158#[derive(Debug, Clone, Copy)]
159pub struct RewriteStats {
160    /// Keys dumped into the new AOF.
161    pub keys: u64,
162    /// New AOF size in bytes.
163    pub bytes: u64,
164}
165
166impl Aof {
167    /// The on-disk record format this file currently speaks.
168    ///
169    /// A `V1` answer means a 3.x binary can still open this file — the
170    /// downgrade window `UPGRADING.md` describes is a *state*, and this
171    /// is where an embedder reads it instead of telling their users
172    /// "assume it closed" (an embedder's dogfood ask: their `doctor`
173    /// command wanted to say "you can still swap the binary back" and
174    /// could not, because this was `pub(crate)`).
175    #[must_use]
176    pub fn format(&self) -> crate::AofFormat {
177        self.format
178    }
179
180    /// Open (creating if needed) `path` for appending. New files get the
181    /// 9-byte `AOF_MAGIC` header so replays can identify the file as
182    /// kevy-managed. Pre-existing files (legacy bare-RESP or already-
183    /// magic'd) are left untouched.
184    pub fn open(path: &Path, fsync: Fsync) -> io::Result<Self> {
185        Self::open_with_repair(path, fsync, false)
186    }
187
188    /// [`Self::open`] with the repair policy explicit: under `resync`,
189    /// interior corrupt regions are left in place (the resync replay hops
190    /// them deterministically each boot until a rewrite compacts them
191    /// away) and only the bytes after the LAST recoverable record are
192    /// quarantined + truncated — so a mid-file corruption no longer costs
193    /// the good tail behind it.
194    pub fn open_with_repair(path: &Path, fsync: Fsync, resync: bool) -> io::Result<Self> {
195        let mut file = OpenOptions::new().create(true).append(true).open(path)?;
196        let mut size = file.metadata().map_or(0, |m| m.len());
197        let mut quarantined = None;
198        let mut format = crate::AofFormat::V2;
199        if size == 0 {
200            // Fresh file: stamp the (v2) magic header so the replayer can
201            // distinguish kevy-written AOFs from accidental writes.
202            file.write_all(crate::record::AOF2_MAGIC)?;
203            file.sync_data()?;
204            size = crate::record::AOF2_MAGIC.len() as u64;
205        } else {
206            // Existing file: keep appending in ITS format. V1 (magic'd or
207            // legacy bare-RESP) upgrades to V2 at the next rewrite.
208            format = crate::replay::sniff_format(path)?;
209            quarantined = crate::aof_util::repair_tail(path, &mut file, &mut size, resync)?;
210        }
211        Ok(Aof {
212            in_txn: false,
213            file: BufWriter::with_capacity(AOF_BUF_CAP, file),
214            path: path.to_path_buf(),
215            fsync,
216            dirty: false,
217            last_sync: Instant::now(),
218            size_bytes: size,
219            size_at_last_rewrite: size,
220            rewrites_total: 0,
221            deferred: false,
222            rewrite_tee: None,
223            tee_spare: None,
224            swap_trash: None,
225            swap_hold: false,
226            open_quarantine: quarantined,
227            last_rewrite_at: Instant::now(),
228            format,
229            scratch: Vec::new(),
230            queue: None,
231            queued_offset: size,
232            queued_seq: 0,
233        })
234    }
235
236    /// The quarantine file `open` wrote while repairing a dropped tail, if
237    /// any. `None` after a clean open.
238    #[inline]
239    pub fn open_quarantine(&self) -> Option<&Path> {
240        self.open_quarantine.as_deref()
241    }
242
243    /// When the last rewrite (or the open) finished — the staleness anchor
244    /// [`crate::RewritePolicy`] measures from.
245    #[inline]
246    pub(crate) fn last_rewrite_at(&self) -> Instant {
247        self.last_rewrite_at
248    }
249
250    /// The fsync policy this AOF was opened with (or last switched to).
251    /// Mostly for tests / INFO output; the hot path doesn't read this.
252    #[inline]
253    pub fn fsync_policy(&self) -> Fsync {
254        self.fsync
255    }
256
257    /// Switch the fsync policy at runtime (called by `CONFIG SET
258    /// appendfsync`). When tightening to `Always`, also flushes + fsyncs
259    /// any bytes still in the BufWriter so the new "every write is on
260    /// disk before reply" contract is honoured starting on the next
261    /// append, not after the dirty backlog clears.
262    pub fn set_fsync(&mut self, fsync: Fsync) -> io::Result<()> {
263        let upgrading_to_always =
264            matches!(fsync, Fsync::Always) && !matches!(self.fsync, Fsync::Always);
265        self.fsync = fsync;
266        if upgrading_to_always {
267            self.flush_queued()?;
268        }
269        if upgrading_to_always && self.dirty {
270            self.file.flush()?;
271            self.file.get_ref().sync_data()?;
272            self.dirty = false;
273            self.last_sync = Instant::now();
274        }
275        Ok(())
276    }
277
278    /// Write the encoded scratch frame straight to the file (the
279    /// non-queued path): V2 = length + CRC header then payload, V1 = bare.
280    fn write_scratch_to_file(&mut self) -> io::Result<()> {
281        match self.format {
282            crate::AofFormat::V2 => {
283                self.file.write_all(&(self.scratch.len() as u32).to_le_bytes())?;
284                self.file.write_all(&crate::crc32c::crc32c(&self.scratch).to_le_bytes())?;
285                self.file.write_all(&self.scratch)
286            }
287            crate::AofFormat::V1 => self.file.write_all(&self.scratch),
288        }
289    }
290
291    /// Append one command, applying the fsync policy. V2 files get the
292    /// checksummed record envelope; a V1 file keeps its bare-RESP form
293    /// until a rewrite upgrades it.
294    pub fn append<A: ArgvView + ?Sized>(&mut self, args: &A) -> io::Result<()> {
295        // One multibulk encode either way: V2 wraps the scratch bytes in an
296        // envelope, V1 writes them bare. The tee is ALWAYS V2 — its bytes
297        // land in the rewrite output, which is V2 by contract.
298        self.scratch.clear();
299        write_multibulk(&mut self.scratch, args)?;
300        if let Some(q) = &mut self.queue {
301            // Queued mode: the same bytes, into the driver's chunk.
302            match self.format {
303                crate::AofFormat::V2 => {
304                    q.extend_from_slice(&(self.scratch.len() as u32).to_le_bytes());
305                    q.extend_from_slice(&crate::crc32c::crc32c(&self.scratch).to_le_bytes());
306                    q.extend_from_slice(&self.scratch);
307                }
308                crate::AofFormat::V1 => q.extend_from_slice(&self.scratch),
309            }
310            self.queued_seq += 1;
311        } else {
312            self.write_scratch_to_file()?;
313        }
314        if let Some(tee) = &mut self.rewrite_tee {
315            crate::record::write_record(tee, &self.scratch)?;
316        }
317        let overhead = match self.format {
318            crate::AofFormat::V2 => crate::record::RECORD_HEADER as u64,
319            crate::AofFormat::V1 => 0,
320        };
321        self.size_bytes =
322            self.size_bytes.saturating_add(estimate_multibulk_bytes(args)).saturating_add(overhead);
323        match self.fsync {
324            // Inside a group-commit window, defer the fsync to `end_group`
325            // (one per batch, still before the batch's replies). Outside
326            // one, fsync per command — the safe default for every path.
327            // Queued appends live in the driver's chunk, not the file: a
328            // sync here would durabilize nothing. The ring fsync owns
329            // durability there; mark dirty so the driver can see it.
330            Fsync::Always if self.deferred || self.queue.is_some() => self.dirty = true,
331            Fsync::Always => {
332                self.file.flush()?;
333                self.file.get_ref().sync_data()?;
334            }
335            Fsync::EverySec | Fsync::No => self.dirty = true,
336        }
337        Ok(())
338    }
339
340    /// Empty the log (after a snapshot has captured the full state). The
341    /// post-truncate file keeps the `AOF_MAGIC` header so replays of
342    /// the freshly-trimmed log still identify as kevy-managed.
343    pub fn truncate(&mut self) -> io::Result<()> {
344        self.flush_queued()?;
345        self.file.flush()?;
346        let f = self.file.get_mut();
347        f.set_len(0)?;
348        f.seek(SeekFrom::Start(0))?; // harmless under O_APPEND; keeps len/pos coherent
349        f.write_all(crate::record::AOF2_MAGIC)?;
350        f.sync_all()?;
351        self.dirty = false;
352        self.format = crate::AofFormat::V2; // an empty log restarts in v2
353        self.size_bytes = crate::record::AOF2_MAGIC.len() as u64;
354        self.queued_offset = self.size_bytes;
355        self.size_at_last_rewrite = crate::record::AOF2_MAGIC.len() as u64;
356        self.last_rewrite_at = Instant::now();
357        Ok(())
358    }
359
360    /// Estimated current AOF size in bytes (file content as of last append).
361    #[inline]
362    pub fn size_bytes(&self) -> u64 {
363        self.size_bytes
364    }
365
366    /// AOF size at the most recent rewrite (or open). Auto-trigger compares
367    /// `(size_bytes - size_at_last_rewrite) * 100 / size_at_last_rewrite` to
368    /// the `auto_aof_rewrite_percentage` knob.
369    #[inline]
370    pub fn size_at_last_rewrite(&self) -> u64 {
371        self.size_at_last_rewrite
372    }
373
374    /// Re-anchor the growth-rule baseline to `bytes` — the live image's
375    /// estimated rewrite size ([`crate::estimate_rewrite_size`]), called
376    /// by open paths after replay. `open` alone can only baseline at the
377    /// file's current size, which for a short-lived process re-opening
378    /// the same directory resets the growth ratio every run and lets the
379    /// log grow without bound; anchoring to the live estimate keeps the
380    /// +pct% rule meaning "the log is pct% history" across processes.
381    /// Ignored while a rewrite is in flight (its completion sets the
382    /// true post-rewrite size).
383    pub fn anchor_rewrite_baseline(&mut self, bytes: u64) {
384        if !self.is_rewriting() {
385            self.size_at_last_rewrite = bytes.max(crate::record::AOF2_MAGIC.len() as u64);
386        }
387    }
388
389    /// Successful rewrite count since `Self::open`. Surfaced in INFO.
390    #[inline]
391    pub fn rewrites_total(&self) -> u64 {
392        self.rewrites_total
393    }
394
395    /// BGREWRITEAOF: rebuild a compact AOF from `store`'s current state and
396    /// atomically swap it in.
397    ///
398    /// **Synchronous** — the calling shard blocks for the rewrite's
399    /// duration. Each shard owns its own AOF, so the shards' rewrites
400    /// proceed independently; per-shard blocking matches Redis's `BGSAVE`
401    /// cost in a typical single-key-per-shard workload. Concurrent
402    /// (rewrite-during-writes) incrementalisation is deliberately not
403    /// attempted here.
404    ///
405    /// Writes to a `<path>.rewrite` temp file with fsync, then `rename(2)`s
406    /// it over the live AOF. The append handle is reopened against the new
407    /// file before this call returns, so subsequent `append` calls land in
408    /// the rewritten log.
409    pub fn rewrite_from(&mut self, store: &Store) -> io::Result<RewriteStats> {
410        // Flush any pending writes to the OLD file first so the snapshot
411        // accounts for everything the caller intended to durabilise.
412        self.flush_queued()?;
413        self.file.flush()?;
414
415        let tmp = crate::aof_util::rewrite_tmp_path(&self.path);
416        let (keys, bytes) = crate::dump_aof(&tmp, store)?;
417
418        // Atomic replacement. After this, the OLD file descriptor in
419        // `self.file` is open against an unlinked inode; new writes would
420        // go nowhere visible. Reopen against the new path.
421        std::fs::rename(&tmp, &self.path)?;
422        let f = OpenOptions::new().append(true).open(&self.path)?;
423        self.file = BufWriter::with_capacity(AOF_BUF_CAP, f);
424        self.format = crate::AofFormat::V2; // the rewrite output always is
425        self.size_bytes = bytes;
426        self.queued_offset = bytes;
427        self.size_at_last_rewrite = bytes;
428        self.last_rewrite_at = Instant::now();
429        self.dirty = false;
430        self.rewrites_total = self.rewrites_total.saturating_add(1);
431        Ok(RewriteStats { keys, bytes })
432    }
433
434    /// Is a non-blocking rewrite mid-flight (between
435    /// [`Self::begin_concurrent_rewrite`] and `finish`/`abort`)? While true,
436    /// don't start another rewrite — `append` is teeing into the diff buffer.
437    #[inline]
438    pub fn is_rewriting(&self) -> bool {
439        self.rewrite_tee.is_some()
440    }
441}