Skip to main content

kevy_persist/
aof_rewrite.rs

1//! The concurrent (non-blocking) rewrite family of [`Aof`] — begin /
2//! tee handoff / finish / abort — split from `aof.rs` at the 500-LOC
3//! line when the two-phase handoff grew it past the cap. The two-phase
4//! protocol's driver lives in kevy-rt (`advance_rewrite_handoff`); this
5//! file owns the Aof-side state transitions.
6
7use std::fs::OpenOptions;
8use std::io::{self, BufWriter, Write};
9use std::path::{Path, PathBuf};
10use std::time::Instant;
11
12use crate::aof::{AOF_BUF_CAP, Aof};
13use crate::dump_store_to_buf;
14
15/// Handoff between the two halves of a non-blocking rewrite: the serialized
16/// keyspace image (produced under the store lock) and the temp path to spill
17/// it to (off-lock). See [`Aof::begin_concurrent_rewrite`].
18///
19/// ```
20/// use kevy_persist::{Aof, Fsync};
21///
22/// let path = std::env::temp_dir().join(format!("plan-doc-{}.aof", std::process::id()));
23/// let mut aof = Aof::open(&path, Fsync::No)?;
24/// let plan = aof.begin_concurrent_rewrite(&kevy_store::Store::new())?; // under the lock
25/// std::fs::write(&plan.tmp, &plan.body)?; // the slow part, off the lock
26/// aof.finish_concurrent_rewrite(&plan.tmp, plan.keys)?; // under the lock again
27/// assert!(!aof.is_rewriting() && !plan.tmp.exists());
28/// # drop(aof);
29/// # std::fs::remove_file(&path)?;
30/// # Ok::<(), std::io::Error>(())
31/// ```
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33#[non_exhaustive]
34pub struct RewritePlan {
35    /// The compacted AOF image (magic + one command stream per key).
36    ///
37    /// ```
38    /// use kevy_persist::{AOF2_MAGIC, Aof, Fsync};
39    /// use kevy_store::{SetCondition, Store};
40    ///
41    /// let path = std::env::temp_dir().join(format!("plan-body-doc-{}.aof", std::process::id()));
42    /// let mut store = Store::new();
43    /// store.set(b"k", b"v".to_vec(), None, SetCondition::Always);
44    /// let mut aof = Aof::open(&path, Fsync::No)?;
45    /// let plan = aof.begin_concurrent_rewrite(&store)?;
46    /// assert!(plan.body.starts_with(AOF2_MAGIC));
47    /// aof.abort_concurrent_rewrite();
48    /// # drop(aof);
49    /// # std::fs::remove_file(&path)?;
50    /// # Ok::<(), std::io::Error>(())
51    /// ```
52    pub body: Vec<u8>,
53    /// Same-directory temp file to spill `body` to before the final swap.
54    ///
55    /// ```
56    /// use kevy_persist::{Aof, Fsync};
57    ///
58    /// let path = std::env::temp_dir().join(format!("plan-tmp-doc-{}.aof", std::process::id()));
59    /// let mut aof = Aof::open(&path, Fsync::No)?;
60    /// let plan = aof.begin_concurrent_rewrite(&kevy_store::Store::new())?;
61    /// assert_eq!(plan.tmp.parent(), path.parent()); // rename stays atomic
62    /// aof.abort_concurrent_rewrite();
63    /// # drop(aof);
64    /// # std::fs::remove_file(&path)?;
65    /// # Ok::<(), std::io::Error>(())
66    /// ```
67    pub tmp: PathBuf,
68    /// Keys captured in `body` (for the resulting [`RewriteStats`]).
69    ///
70    /// ```
71    /// use kevy_persist::{Aof, Argv, Fsync};
72    /// use kevy_store::{SetCondition, Store};
73    ///
74    /// let path = std::env::temp_dir().join(format!("plan-keys-doc-{}.aof", std::process::id()));
75    /// let mut store = Store::new();
76    /// store.set(b"a", b"1".to_vec(), None, SetCondition::Always);
77    /// store.set(b"b", b"2".to_vec(), None, SetCondition::Always);
78    /// let mut aof = Aof::open(&path, Fsync::No)?;
79    /// let plan = aof.begin_concurrent_rewrite(&store)?;
80    /// assert_eq!(plan.keys, 2);
81    /// // spill off-lock; writes landing meanwhile are teed
82    /// std::fs::write(&plan.tmp, &plan.body)?;
83    /// aof.append(&Argv::from(vec![b"SET".to_vec(), b"c".to_vec(), b"3".to_vec()]))?;
84    /// let stats = aof.finish_concurrent_rewrite(&plan.tmp, plan.keys)?;
85    /// assert_eq!(stats.keys, 2);
86    /// let replayed = kevy_persist::replay_aof_quiet(&path, Default::default(), |_| {})?;
87    /// assert_eq!(replayed.commands, 3);
88    /// # drop(aof);
89    /// # std::fs::remove_file(&path)?;
90    /// # Ok::<(), std::io::Error>(())
91    /// ```
92    pub keys: u64,
93}
94
95/// Result of an [`Aof::rewrite_from`] call. Surfaced by `BGREWRITEAOF` /
96/// `INFO persistence`. The default is the empty rewrite (no keys, no
97/// bytes), the starting point for summing several shards' stats.
98///
99/// ```
100/// use kevy_persist::{Aof, Fsync, RewriteStats};
101///
102/// let dir = kevy_tmpdir::unique_dir("stats-doc");
103/// let mut total = RewriteStats::default();
104/// for shard in 0..2 {
105///     let mut aof = Aof::open(&kevy_persist::layout::aof_path(&dir, shard), Fsync::No)?;
106///     let stats = aof.rewrite_from(&kevy_store::Store::new())?;
107///     (total.keys, total.bytes) = (total.keys + stats.keys, total.bytes + stats.bytes);
108/// }
109/// assert_eq!(total.keys, 0);
110/// // an empty image is its header alone
111/// let empty = kevy_persist::estimate_rewrite_size(&kevy_store::Store::new());
112/// assert_eq!(total.bytes, 2 * empty);
113/// # std::fs::remove_dir_all(&dir)?;
114/// # Ok::<(), std::io::Error>(())
115/// ```
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
117#[non_exhaustive]
118pub struct RewriteStats {
119    /// Keys dumped into the new AOF.
120    ///
121    /// ```
122    /// use kevy_persist::{Aof, Argv, Fsync};
123    /// use kevy_store::{SetCondition, Store};
124    ///
125    /// let path = std::env::temp_dir().join(format!("stats-keys-doc-{}.aof", std::process::id()));
126    /// let mut aof = Aof::open(&path, Fsync::No)?;
127    /// let mut store = Store::new();
128    /// for _ in 0..3 {
129    ///     // three writes to one key compact to one key
130    ///     store.set(b"k", b"v".to_vec(), None, SetCondition::Always);
131    ///     aof.append(&Argv::from(vec![b"SET".to_vec(), b"k".to_vec(), b"v".to_vec()]))?;
132    /// }
133    /// assert_eq!(aof.rewrite_from(&store)?.keys, 1);
134    /// # drop(aof);
135    /// # std::fs::remove_file(&path)?;
136    /// # Ok::<(), std::io::Error>(())
137    /// ```
138    pub keys: u64,
139    /// New AOF size in bytes.
140    ///
141    /// ```
142    /// use kevy_persist::{Aof, Fsync};
143    ///
144    /// let path = std::env::temp_dir().join(format!("stats-bytes-doc-{}.aof", std::process::id()));
145    /// let mut aof = Aof::open(&path, Fsync::No)?;
146    /// let stats = aof.rewrite_from(&kevy_store::Store::new())?;
147    /// assert_eq!(stats.bytes, std::fs::metadata(&path)?.len());
148    /// assert_eq!(aof.size_bytes(), stats.bytes);
149    /// # drop(aof);
150    /// # std::fs::remove_file(&path)?;
151    /// # Ok::<(), std::io::Error>(())
152    /// ```
153    pub bytes: u64,
154}
155
156impl Aof {
157    /// Phase 1 of a **non-blocking** rewrite (Background auto-rewrite). Must be
158    /// called under the store lock: it serializes the keyspace into an
159    /// in-memory image and starts teeing subsequent `append`s into a diff
160    /// buffer — both atomic w.r.t. other writes. The caller then spills
161    /// `plan.body` to `plan.tmp` **with the lock released** (the slow disk
162    /// write), and finally calls [`Self::finish_concurrent_rewrite`] under the
163    /// lock again. Writes that land during the off-lock spill are captured by
164    /// the tee and appended after the snapshot, so nothing is lost.
165    pub fn begin_concurrent_rewrite<S: crate::SnapshotSource>(
166        &mut self,
167        store: &S,
168    ) -> io::Result<RewritePlan> {
169        self.flush_queued()?;
170        let (body, keys) = dump_store_to_buf(store, crate::AofFormat::V2);
171        self.rewrite_tee = Some(Vec::new());
172        Ok(RewritePlan { body, tmp: crate::aof_util::rewrite_tmp_path(&self.path), keys })
173    }
174
175    /// Phase 2: the `plan.body` is already on disk at `tmp` (spilled off-lock).
176    /// Append the diff buffer (writes since `begin`), fsync, atomically swap
177    /// over the live AOF, and reopen the append handle against it. Call under
178    /// the store lock. `keys` is `plan.keys`.
179    pub fn finish_concurrent_rewrite(&mut self, tmp: &Path, keys: u64) -> io::Result<RewriteStats> {
180        let tee = self.rewrite_tee.take().unwrap_or_default();
181        // Embedded/synchronous path: the caller's own thread is not a
182        // reactor, so dropping the spent buffers inline here is fine.
183        self.finish_concurrent_rewrite_with(tmp, keys, tee).map(|(stats, _bufs)| stats)
184    }
185
186    /// Take the accumulated diff buffer for an off-thread append and
187    /// immediately start a fresh generation — the two-phase rewrite's
188    /// handoff step (the worker appends+fsyncs THIS tee while new writes
189    /// keep teeing into the fresh one). `None` when no rewrite is live.
190    /// `is_rewriting` stays true throughout. The fresh generation reuses
191    /// the recycled spare buffer when one is stashed (warm pages, no
192    /// fresh mmap — see `stash_tee_spare`).
193    pub fn take_tee_for_handoff(&mut self) -> Option<Vec<u8>> {
194        let spare = self.tee_spare.take().unwrap_or_default();
195        match self.rewrite_tee.as_mut() {
196            Some(t) => Some(std::mem::replace(t, spare)),
197            None => {
198                self.tee_spare = Some(spare);
199                None
200            }
201        }
202    }
203
204    /// Live diff-buffer size, `None` when no rewrite is running — the
205    /// tick's overrun check reads this to defer a diverging rewrite
206    /// while the tee is still growing (before the gigabytes, not after).
207    #[must_use]
208    pub fn tee_len(&self) -> Option<usize> {
209        self.rewrite_tee.as_ref().map(Vec::len)
210    }
211
212    /// Return an appended generation's buffer (cleared) for the next
213    /// generation to grow into. Ping-pong: at most one generation is
214    /// ever out with the worker, so the slot is normally empty; a
215    /// surprise second return is kept only if larger. The LOSING buffer
216    /// is returned to the caller instead of dropped here — both
217    /// ping-pong buffers carry the FIRST generation's capacity forever
218    /// (a clear never shrinks), so "the smaller one" can still be
219    /// gigabytes, and a GB munmap on the reactor was the finish-tick
220    /// stall the diag timers caught at 288 ms. The caller ships it to
221    /// the worker like every other GB-scale free.
222    #[must_use]
223    pub fn stash_tee_spare(&mut self, mut buf: Vec<u8>) -> Option<Vec<u8>> {
224        buf.clear();
225        match &self.tee_spare {
226            Some(held) if held.capacity() >= buf.capacity() => Some(buf),
227            _ => self.tee_spare.replace(buf),
228        }
229    }
230
231    /// Drain every retained tee buffer (the live diff and the recycled
232    /// spare) for an off-thread drop at rewrite teardown — freeing a
233    /// GB-scale buffer is a munmap that must not run on the reactor.
234    /// Call BEFORE `abort_concurrent_rewrite` (which drops inline).
235    pub fn take_tee_teardown(&mut self) -> Vec<Vec<u8>> {
236        let mut bufs = Vec::new();
237        if let Some(t) = self.rewrite_tee.take() {
238            bufs.push(t);
239        }
240        if let Some(s) = self.tee_spare.take() {
241            bufs.push(s);
242        }
243        bufs.retain(|b| b.capacity() > 0);
244        bufs
245    }
246
247    /// Phase-final of the two-phase rewrite: `last_tee` is the (small)
248    /// final diff generation; everything earlier was appended+fsynced
249    /// to `tmp` off-thread. The synchronous cost here is bounded by the
250    /// handoff window's writes, not the rewrite window's — `sync_all`
251    /// pays for dirty pages, and the worker already flushed the image
252    /// and the earlier tee generations.
253    pub fn finish_concurrent_rewrite_with(
254        &mut self,
255        tmp: &Path,
256        keys: u64,
257        tee: Vec<u8>,
258    ) -> io::Result<(RewriteStats, Vec<Vec<u8>>)> {
259        // Both the live tee and the passed generation can carry the
260        // FIRST generation's GB-scale capacity (clears never shrink);
261        // hand them back to the caller instead of dropping here — on
262        // the reactor a GB munmap is the finish-tick stall the diag
263        // timers measured at 288 ms.
264        let mut spent: Vec<Vec<u8>> = self.rewrite_tee.take().into_iter().collect();
265        {
266            let mut f = OpenOptions::new().append(true).open(tmp)?;
267            f.write_all(&tee)?;
268            f.sync_all()?;
269        }
270        spent.push(tee);
271        // the old file's mapping must not outlive its name
272        self.unmap()?;
273        std::fs::rename(tmp, &self.path)?;
274        let f = OpenOptions::new().append(true).open(&self.path)?;
275        let bytes = f.metadata().map_or(0, |m| m.len());
276        self.file = BufWriter::with_capacity(AOF_BUF_CAP, f);
277        self.format = crate::AofFormat::V2; // the rewrite output always is
278        self.size_bytes = bytes;
279        self.queued_offset = bytes;
280        self.size_at_last_rewrite = bytes;
281        self.last_rewrite_at = Instant::now();
282        self.dirty = false;
283        self.rewrites_total = self.rewrites_total.saturating_add(1);
284        self.after_file_change()?;
285        Ok((RewriteStats { keys, bytes }, spent))
286    }
287
288    /// The graveyard name for the NEXT swap (queued mode only): the
289    /// pre-swap log keeps a link so `rename(2)` never frees a multi-GB
290    /// inode's extents inside the syscall.
291    #[must_use]
292    pub fn swap_trash_name(&self) -> Option<PathBuf> {
293        self.queue.as_ref()?;
294        let mut name = self
295            .path
296            .file_name()
297            .map(|n| n.to_os_string())
298            .unwrap_or_else(|| std::ffi::OsString::from("aof"));
299        name.push(format!(".trash{}", self.rewrites_total));
300        Some(self.path.with_file_name(name))
301    }
302
303    /// The live log's path — the off-thread swap job's rename target.
304    #[must_use]
305    pub fn live_path(&self) -> PathBuf {
306        self.path.clone()
307    }
308
309    /// Enter the swap-hold window: the worker is about to hardlink +
310    /// rename over the live path, so the offload driver must stop
311    /// draining the append queue (bytes keep accumulating in it —
312    /// bounded by the hold's duration) and must not fsync the old fd.
313    pub fn begin_swap_hold(&mut self) {
314        self.swap_hold = true;
315    }
316
317    /// Whether the swap-hold window is open (the driver's queue gate).
318    #[must_use]
319    pub fn swap_holding(&self) -> bool {
320        self.swap_hold
321    }
322
323    /// The reactor's half of an off-thread swap: the worker already
324    /// renamed the image over the live path (journal work, done off
325    /// this thread); reopen the append handle against it, reset every
326    /// anchor, release the hold. Opening an EXISTING file writes no
327    /// journal metadata — the reactor's synchronous cost is µs.
328    pub fn swap_finalize_reopen(
329        &mut self,
330        keys: u64,
331        trash: Option<PathBuf>,
332    ) -> io::Result<RewriteStats> {
333        self.swap_hold = false;
334        // Deliberately NOT clearing `rewrite_tee` here: the buffer can
335        // carry a GB-scale capacity (clears never shrink it), and `=
336        // None` was an inline munmap on the reactor. The caller drains
337        // it via `take_tee_teardown` immediately after this returns and
338        // ships it to the worker; `is_rewriting` stays true for those
339        // few statements on the same single-threaded reactor, which
340        // nothing observes in between.
341        self.swap_trash = trash;
342        let f = OpenOptions::new().append(true).open(&self.path)?;
343        let bytes = f.metadata().map_or(0, |m| m.len());
344        self.file = BufWriter::with_capacity(AOF_BUF_CAP, f);
345        self.format = crate::AofFormat::V2;
346        self.size_bytes = bytes;
347        self.queued_offset = bytes;
348        self.size_at_last_rewrite = bytes;
349        self.last_rewrite_at = Instant::now();
350        self.dirty = false;
351        self.rewrites_total = self.rewrites_total.saturating_add(1);
352        self.after_file_change()?;
353        Ok(RewriteStats { keys, bytes })
354    }
355
356    /// Abort an off-thread swap that failed before the rename landed:
357    /// the live path still names the old log — resume appends against
358    /// it unchanged.
359    pub fn abort_swap_hold(&mut self) {
360        self.swap_hold = false;
361    }
362
363    /// The graveyard hardlink from the last swap, for the caller's
364    /// off-thread unlink. `None` outside queued mode (the epoll path
365    /// keeps the classic inline drop — pre-existing behavior).
366    pub fn take_swap_trash(&mut self) -> Option<PathBuf> {
367        self.swap_trash.take()
368    }
369
370    /// Abandon an in-flight non-blocking rewrite (e.g. the off-lock spill
371    /// failed): drop the diff buffer and resume normal appends. The live AOF
372    /// is untouched, so no data is at risk; the caller deletes the temp file.
373    pub fn abort_concurrent_rewrite(&mut self) {
374        self.rewrite_tee = None;
375    }
376
377    /// A rewrite was aborted because ingest outran the disk (the tee
378    /// generations stopped shrinking — see the two-phase driver in
379    /// kevy-rt). Re-anchor the auto-rewrite growth rule at the CURRENT
380    /// size, as if a rewrite had landed here: retrying immediately would
381    /// diverge again identically, so the next attempt waits for another
382    /// full growth factor (or an explicit BGREWRITEAOF). Degradation,
383    /// not a stall: under sustained overload the log grows and the
384    /// reactor stays responsive.
385    pub fn anchor_rewrite_deferred(&mut self) {
386        self.size_at_last_rewrite = self.size_bytes;
387        self.last_rewrite_at = Instant::now();
388    }
389
390    /// Phase 1 of a **COW** rewrite: flush pending appends and start teeing
391    /// subsequent ones into the diff buffer. O(1) — the keyspace itself is
392    /// already frozen in the caller's `SnapshotView`. Returns the temp path
393    /// the background serializer must write (via [`crate::dump_aof`]),
394    /// after which [`Self::finish_concurrent_rewrite`] (same thread as the
395    /// appends) swaps it in, or [`Self::abort_concurrent_rewrite`] backs out.
396    ///
397    /// **Atomicity contract**: the `collect_snapshot` and this call must
398    /// happen with no `append` between them (same critical section / same
399    /// thread). A write squeezing in between would either miss the new AOF
400    /// (tee started late) or replay twice (tee started early) — and
401    /// commands like LPUSH are not idempotent.
402    pub fn begin_view_rewrite(&mut self) -> io::Result<std::path::PathBuf> {
403        self.flush_queued()?;
404        self.file.flush()?;
405        self.rewrite_tee = Some(Vec::new());
406        Ok(crate::aof_util::rewrite_tmp_path(&self.path))
407    }
408}