kevy_embedded/store_persist.rs
1//! Durability methods on [`Store`] — `BGREWRITEAOF` and `SAVE` — plus
2//! their per-shard helpers. Extracted from `store.rs` to keep that file
3//! under the 500-LOC project ceiling. Behaviour is unchanged from the
4//! pre-split layout; this module hosts long-running disk paths
5//! separately from the hot lock/dispatch surface in `store.rs`.
6
7use std::io;
8use std::sync::RwLock;
9use std::time::Instant;
10
11use kevy_persist::RewriteStats;
12
13use crate::metric::KevyMetric;
14use crate::store::{Inner, Store, lock_write};
15
16impl Store {
17 /// Durability barrier (v2.1): flush + `fdatasync` every shard's
18 /// AOF now, regardless of the configured `appendfsync` policy. On
19 /// `Ok(())`, every write acknowledged before this call is on
20 /// stable storage. The `EverySec` serving-store idiom:
21 ///
22 /// ```ignore
23 /// store.atomic(|c| { /* critical write */ Ok(()) })?;
24 /// store.fsync_aof()?; // durable-on-ack for THIS block only
25 /// ```
26 ///
27 /// Cost: one `fdatasync` per dirty shard; a no-op on clean shards.
28 /// Under `appendfsync = always` it is a no-op (already durable).
29 pub fn fsync_aof(&self) -> io::Result<()> {
30 for shard in self.shards.iter() {
31 let mut g = lock_write(shard);
32 if let Some(aof) = &mut g.aof {
33 aof.sync_now()?;
34 }
35 }
36 Ok(())
37 }
38
39 /// `BGREWRITEAOF`: rebuild every shard's AOF from current state.
40 /// Synchronous (despite the RESP name). Returns the summed stats
41 /// (`None` if persistence is off / no shard rewrote). Shards mid
42 /// rewrite are skipped. Emits [`KevyMetric::Rewrite`] per shard.
43 pub fn rewrite_aof(&self) -> io::Result<Option<RewriteStats>> {
44 let mut agg: Option<RewriteStats> = None;
45 for shard in self.shards.iter() {
46 let start = Instant::now();
47 // Phase 1 (locked): freeze the COW view + start the tee —
48 // O(n)-shallow, no serialization under the lock.
49 let (view, tmp, before_bytes) = {
50 let mut g = lock_write(shard);
51 let Inner { store, aof, .. } = &mut *g;
52 let Some(aof) = aof else { continue };
53 if aof.is_rewriting() {
54 continue;
55 }
56 let before = aof.size_bytes();
57 let view = store.collect_snapshot();
58 (view, aof.begin_view_rewrite()?, before)
59 };
60 // Phase 2 (unlocked): serialize + fsync the compacted log.
61 let keys = match kevy_persist::dump_aof(&tmp, &view) {
62 Ok((keys, _)) => keys,
63 Err(e) => {
64 let mut g = lock_write(shard);
65 if let Some(aof) = &mut g.aof {
66 aof.abort_concurrent_rewrite();
67 }
68 let _ = std::fs::remove_file(&tmp);
69 return Err(e);
70 }
71 };
72 // Phase 3 (locked): append the tee'd diff and swap.
73 let mut g = lock_write(shard);
74 let Some(aof) = &mut g.aof else { continue };
75 let stats = match aof.finish_concurrent_rewrite(&tmp, keys) {
76 Ok(s) => s,
77 Err(e) => {
78 aof.abort_concurrent_rewrite();
79 let _ = std::fs::remove_file(&tmp);
80 return Err(e);
81 }
82 };
83 if let Some(sink) = &self.config.metric_sink {
84 sink.emit(KevyMetric::Rewrite {
85 keys: stats.keys,
86 before_bytes,
87 after_bytes: stats.bytes,
88 elapsed_ms: start.elapsed().as_millis() as u64,
89 });
90 }
91 let acc = agg.get_or_insert(RewriteStats { keys: 0, bytes: 0 });
92 acc.keys += stats.keys;
93 acc.bytes += stats.bytes;
94 }
95 Ok(agg)
96 }
97
98 /// Snapshot every shard to its `dump-{i}.rdb` (single shard: the configured
99 /// name), atomically. `Ok(false)` when persistence is disabled.
100 pub fn save_snapshot(&self) -> io::Result<bool> {
101 let Some(dir) = self.config.data_dir.as_ref() else {
102 return Ok(false);
103 };
104 let n = self.shards.len();
105 for (i, shard) in self.shards.iter().enumerate() {
106 let name = if n == 1 {
107 self.config.snapshot_filename.clone()
108 } else {
109 kevy_persist::layout::snapshot_file(i)
110 };
111 save_shard_snapshot(shard, &dir.join(name))?;
112 }
113 Ok(true)
114 }
115}
116
117/// Save one shard's snapshot with the snapshot+log contract intact:
118/// after a successful save the AOF holds **only post-collect writes**,
119/// so a restart replays them over the snapshot without double-applying
120/// history (non-idempotent commands like RPUSH duplicated before this).
121///
122/// Phase 1 (write lock): freeze the COW view + start the AOF tee — no
123/// write may land between the two (the tee atomicity contract). Phase 2
124/// (unlocked): serialize the view to the snapshot's durable tmp.
125/// Phase 3 (write lock): commit — snapshot rename and tee'd AOF reset
126/// adjacent, so the snapshot/log commit window stays microseconds.
127pub(crate) fn save_shard_snapshot(
128 shard: &RwLock<Inner>,
129 path: &std::path::Path,
130) -> io::Result<()> {
131 let (view, reset_tmp) = freeze_for_save(shard)?;
132 let tmp = match kevy_persist::write_snapshot_tmp(&view, path) {
133 Ok(t) => t,
134 Err(e) => {
135 if reset_tmp.is_some()
136 && let Some(aof) = &mut lock_write(shard).aof
137 {
138 aof.abort_concurrent_rewrite();
139 }
140 return Err(e);
141 }
142 };
143 let mut g = lock_write(shard);
144 std::fs::rename(&tmp, path)?;
145 if let (Some(reset), Some(aof)) = (reset_tmp, &mut g.aof) {
146 let swap = kevy_persist::write_aof_base(&reset)
147 .and_then(|()| aof.finish_concurrent_rewrite(&reset, 0));
148 if let Err(e) = swap {
149 aof.abort_concurrent_rewrite();
150 let _ = std::fs::remove_file(&reset);
151 return Err(e);
152 }
153 }
154 Ok(())
155}
156
157/// Phase-1 helper: collect the view and start the tee under one write
158/// lock. A racing background auto-rewrite owns the tee; it runs its
159/// slow half off-lock and finishes in milliseconds, so wait it out
160/// (bounded) rather than saving a snapshot whose log would double-
161/// apply on replay.
162fn freeze_for_save(
163 shard: &RwLock<Inner>,
164) -> io::Result<(kevy_store::SnapshotView, Option<std::path::PathBuf>)> {
165 for _ in 0..2000 {
166 {
167 let mut g = lock_write(shard);
168 let Inner { store, aof, .. } = &mut *g;
169 match aof {
170 Some(a) if a.is_rewriting() => {} // racing rewrite — retry
171 Some(a) => {
172 let view = store.collect_snapshot();
173 return Ok((view, Some(a.begin_view_rewrite()?)));
174 }
175 None => return Ok((store.collect_snapshot(), None)),
176 }
177 }
178 std::thread::sleep(std::time::Duration::from_millis(5));
179 }
180 Err(io::Error::new(
181 io::ErrorKind::TimedOut,
182 "kevy-embedded: AOF rewrite still in flight after 10s; snapshot aborted",
183 ))
184}