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}