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